<-- Chapter 17 | Back to Index | Chapter 19 -->
At the moment, our code simply runs as fast as it can: processing frames in a loop with no break. There are several advantages to rendering in this manner: it can reduce latency, be used for benchmarking and can scale smoothly with hardware performance. On the other hand, there are several disadvantages to this technique:
The next step, of course, is working out which framerate we'll want to run at. Note that this doesn't have to be the same across every machine: we can adjust the desired frame time based on the computer we're on: if the machine isn't fast enough to run at 60 Hz, we could drop to 30 Hz. The important thing is knowing when you're going to display the next frame.
One useful piece of information to have when deciding on a framerate is the refresh rate of the display. SDL gives us the refresh rate in the SDL_DisplayMode structure. This is because the refresh rate is not just a property of the monitor, but of the monitor and display mode. The same monitor may have a different refresh rate at different resolutions: perhaps it can do 1920x1080 at 60Hz, but 3840x2160 at only 30Hz. We therefore need to decide what mode we're going to run at. Because we're running in a window, we'll be using the desktop mode. If we were running in fullscreen mode, we'd probably loop over all supported modes and pick the best one.
Fortunately, SDL provides the SDL_GetDesktopDisplayMode() function. SDL_GetDesktopDisplayMode() is very simple, it accepts the display index, so that multi-monitor systems can be supported, and a pointer to a SDL_DisplayMode struct, which it will fill out. To find out which display index we want, we can use the SDL_GetWindowDisplayIndex() function:
internal int SDLGetWindowRefreshRate(SDL_Window *Window) { SDL_DisplayMode Mode; int DisplayIndex = SDL_GetWindowDisplayIndex(Window); // If we can't find the refresh rate, we'll return this: int DefaultRefreshRate = 60; if (SDL_GetDesktopDisplayMode(DisplayIndex, &Mode) != 0) { return DefaultRefreshRate; } if (Mode.refresh_rate == 0) { return DefaultRefreshRate; } return Mode.refresh_rate; }
You may have noticed that we check if refresh_rate is zero: it's entirely possible that we can't get the refreh rate. This may be because the user has a variable refresh rate display (GSync/FreeSync and friends) or, more likely, the driver is somehow broken. (I once broke refresh rate detection in SDL, so I'm now particularly wary both of this and of coding in airport lounges.) There are a couple of options we have when we can't determine the refresh rate: try to measure it or give up and guess. While it's usually possible to measure it, this will break on some variable refresh rate monitors, and requires rendering several frames, synchronising with the vertical blank, so we'll just blindly assume it's 60 Hz. This is the correct value more often than not, anyway.
Check it out! SDL has several other functions for getting SDL_DisplayMode structs. SDL_GetWindowDisplayMode() gives us the mode our window will use if it is set to fullscreen. SDL_GetCurrentDisplayMode() gets us the current display mode: the desktop mode or a mode we've set. SDL_GetClosestDisplayMode() will return the mode which is closest to our ideal mode. Finally, we can use SDL_GetDisplayMode() to enumerate all of the supported display modes for a monitor. |
We might not always want the refresh rate to be our actual update rate, though. In particular, our code might not be fast enough to process 60 frames per second, so we could select 30 Hz. Indeed, this is what we will do for the moment, as it's possible our software renderer will get a bit too slow.
Once we've got our target framerate, we're going to need to make our code stick to that timing. We'll start by computing some useful values:
int GameUpdateHz = 30; real32 TargetSecondsPerFrame = 1.0f / (real32)GameUpdateHz;
In order to fit our game to those times, we're going to need to measure how much time has elapsed since we drew the last frame. We can do that with our trusty SDL_GetPerformanceCounter() function. We'll want to measure the time at the end of each frame (in a LastCounter variable) and loop until the current time is TargetSecondsPerFrame seconds after LastCounter. To make this easier, we'll first define a helper function to make calculating elapsed times easier:
internal real32 SDLGetSecondsElapsed(uint64 OldCounter, uint64 CurrentCounter) { return ((real32)(CurrentCounter - OldCounter) / (real32)(SDL_GetPerformanceFrequency())); }
We can now simply loop, checking the time, until we hit the TargetSecondsPerFrame:
while (SDLGetSecondsElapsed(LastCounter, SDL_GetPerformanceCounter()) < TargetSecondsPerFrame) { // We're waiting. }
This has a couple of problems, one we've seen in the Windows version, and one we haven't. Firstly, it's just chewing up CPU cycles while waiting, checking the time constantly. It'd be nice for us to reliquish the CPU while we're waiting. This is a job for the SDL_Delay() function. SDL_Delay() is very simple: it takes a number of milliseconds and tells the operating system to wake us up again when that much time has passed.
while (SDLGetSecondsElapsed(LastCounter, SDL_GetPerformanceCounter()) < TargetSecondsPerFrame) { SDL_Delay((TargetSecondsPerFrame - SDL_GetSecondsElapsed(LastFrame, SDL_GetPerformanceCounter())) * 1000); }
Check it out! SDL_Delay(), of course, is using some Linux-specific calls under the hood. sleep() is not very useful, as it accepts its time in seconds, so can't be used to sleep for less than a second. usleep is better — it accepts a time in microseconds — and is very easy to use. It has however, been deprecated by the nanosleep() which, as you might expect, takes the time in nanoseconds. nanosleep() actually accepts a struct timespec which contains the number of seconds and nanoseconds, as well as accepting a pointer to a remainder timespec which it will fill out if the sleep is interrupted by a signal. |
The one thing we have to be careful about is that SDL_Delay() may take a little bit longer than the specified time before it returns. On Windows, we used the timeBeginPeriod() function to increase the resolution Sleep() used. There is no equivalent function on Linux, though it doesn't really matter: Linux is much better at hitting its time targets. Since Linux 2.5.21, the Linux kernel's sleep functions can use the hardware's high-resolution timers. In my testing, SDL_Delay() is accurate to within a millisecond unless the system is under heavy load, and is often less that 100µs out (particularly on "low-latency" kernels). We'll still want to give ourselves some slack, so let's try to ensure that we have an extra millisecond.
if (SDLGetSecondsElapsed(LastCounter, SDL_GetPerformanceCounter()) < TargetSecondsPerFrame) { uint32 TimeToSleep = ((TargetSecondsPerFrame - SDLGetSecondsElapsed(LastCounter, SDL_GetPerformanceCounter())) * 1000) - 1; SDL_Delay(TimeToSleep); Assert(SDLGetSecondsElapsed(LastCounter, SDL_GetPerformanceCounter()) < TargetSecondsPerFrame) while (SDLGetSecondsElapsed(LastCounter, SDL_GetPerformanceCounter()) < TargetSecondsPerFrame) { // Waiting... } }
The other issue we have is a little bit more complicated...
Many SDL rendering backends (including the OpenGL one, which is the default) allow us to enable something called VSync. VSync basically requests that, when we call SDL_RenderPresent(), instead of updating the screen and returning immediately, it will wait for the next vertical blanking period and update the screen then.
The vertical blanking period, or VBlank is the time the monitor spends in between scanning out frames. If we update the screen while it's being scanned out, we can get tearing, where part of the screen is updated and part of it is not. By updating during the VBlank, we know that no tearing will occur. By waiting for the VBlank before returning from SDL_RenderPresent(), we're actually automatically limiting our framerate to the refresh rate.
To enable VSync in our window, we need to pass the SDL_RENDERER_PRESENTVSYNC flag to our SDL_CreateRenderer() function call. Note that, on some systems, VSync will be always enabled (or always disabled), so you have to be prepared for it to be either on or off.
If we just run our code, we now notice two horrifying things. Firstly, our timer is showing us that we're actually taking around 34ms to render each frame, not the 33.33ms we wanted. This is because our SDL_RenderPresent() function doesn't happen instantly, especially if VSync is enabled. We therefore need to update our LastCounter variable before the SDL_RenderPresent() call: otherwise we're not taking into account the time SDL_RenderPresent() takes.
The second problem is the horribly crackly sound. This one is easy to fix, at the expense of latency. We'll look into audio latency in more detail in the next chapter, so for now let's just fix the horrible stuttery sound. The reason the sound suddenly is running slowly is that, while before we were updating our sound buffer 60 or more times a second, we're now only updating it 30 times a second. We'll therefore just need to increase the amount of sound we're buffering ahead. The good news is that this is easy: we should just double the value of SoundOutput.LatencySampleCount. We'll still have the same number of frames of latency then; they'll just be longer frames. A good value is SoundOutput.SamplesPerSecond / 15, which equates to two frames worth of latency. It works well enough on my system!
Congratulations: we've now got a fixed framerate and therefore know exactly when our next frame is going to be displayed. Tomorrow we'll be trying to reduce our sound latency.
If you've bought Handmade Hero, the source for the Linux version can be downloaded here. Note that you will require the official source code for handmade.h and handmade.cpp.