60fps framelock done properly - without duplicated or skipped frames

Started by
11 comments, last by frob 4 years, 5 months ago

I am trying to lock my framerate at 60 for a game prototype I am working on* but it seems to be quite difficult to ensure that I always push out a new frame for every monitor vertical retrace, without duplicated or skipped frames.

This is my current approach, written in C++ using std::chrono:

constexpr auto frameTime = chrono::duration<int64_t, ratio<1, 60>>(1);
auto nextFrame = chrono::steady_clock::now() + frameTime; // schedule beginning of next frame
while (true)
{
  // process frame here

  this_thread::sleep_until(nextFrame); // wait until the next frame should start
  nextFrame += frameTime;
}

This schedules the time_point when the next frame should start processing, processes the current frame and then sleeps until the next frame is supposed to start. I have measured it and know that my frames do not take too long to process. I have also confirmed that steady_clock and high_resolution_clock are the same on my systems so it's not a clock precision problem.

I am moving a sprite 1px to the left/right every frame and it is immediately obvious when frames are duplicated or skipped. It works fine for a couple of seconds and then breaks down with multiple duplications and skips. That goes on for about a second, then it works well for a couple of seconds again. How long it works fine in between 'break downs' appears to depend on the machine.

There has to be some way to achieve a better stable framelock, right? What am I missing here?


* It's a 2D game with handdrawn animations so I think locking the framerate is reasonable.

Advertisement

A timer-based solution is never going to be exactly synchronized with the screen. sleep_until will never wake up at exactly the right moment in time. But if you're doing this:

while (true) {
  render_frame();
  present_to_screen();
  this_thread::sleep_until(nextFrame);
  nextFrame += frameTime;
}


...then you could try doing this instead:

while (true) {
  render_frame();
  this_thread::sleep_until(nextFrame);
  present_to_screen();
  nextFrame += frameTime;
}

Also, 60Hz is a pretty crappy screen refresh rate.

I think you're going about this a little backwards. Instead of thinking "I want to render at 60 frames per second, slow down until I reach it", say "I want to render as fast as I can, block only as long as required."

Games should generally render as fast as possible, limited only by refresh rates if players don't want to experience tearing, or as fast as possible without limits if they do want to experience tearing.

First, recognize that 60Hz is a legacy thing, based on TV standards. While many monitors are still 60Hz, games should recognize the actual screens being used. 72 Hz, 75 Hz, 90 Hz (especially in VR) and 120 Hz (especially in competitions) are all common. Less common timings exist, as do high refresh monitors like 144 Hz and 240Hz competitive screens. While older standards like VGA, DVI, and HDMI prefer fixed rates, new systems like GSync allow variable rates displaying whenever data is ready. In general, try to use the actual hardware's speed.

In many competitive environments screen tearing to display information early is often considered an advantage, with players turning off the fancy visual features and setting the graphics down to zero just so they can get a four or five millisecond advantage over their opponents. In highly competitive environments, the choice between displaying a photorealistic beautiful vehicle slowly versus a Tesla Truck model quickly, speed wins over beauty every time.

Said differently:

DO NOT call sleep. Call a blocking operation like d3d's Present() call. The operation will block until the display is ready, however long the hardware actually needs.

If a player has high end competitive hardware, let their hardware reach amazing speeds. If a player is using their grandma's old machine, let the hardware decide the speed.

Sleep in general is tricky to get right and not what programmers first expect. The details depend on the version of Windows you are using, and on system settings, but generally Window's sleep calls say wait around for at least this much time, more or less. The same is true (with different internal details) on other systems like Linux, which still have scheduler timings. While there are Real Time OS (RTOS) like the old Windows CE that did accurately respect sleep timings down to milliseconds accuracy, on desktop Windows the accuracy is far less, typically 10ms-15ms although it can be adjusted. And those are generally estimates, the system is free to block for much longer than that, whatever works for the scheduler.

Note that this is true even on Linux and Posix systems, functions like usleep() and nanosleep() are still entirely at the mercy of the scheduler. By default functions like nanosleep() and pselect() are scheduled at 500 microseconds in the best case. Even when you bump the priority and modify the OS scheduler to give your process a high priority, then manually tune the kernel, Linux won't go below about 10 microseconds per sleep. When you specify you want to sleep for 1 nanosecond, the call is to wait at least 1 nanosecond, not to wait exactly one nanosecond.

Block on the hardware, not a timer.

Blocking until the hardware is ready can also take a long time. While usually it means waiting only a few milliseconds until the transfer completes during vsync, or if vsync is disabled waiting a few microseconds for the operation to complete, sometimes it will take longer because the system is busy doing other tasks. Sometimes those other tasks are extreme, not just blocking for another process, but really big like being put into sleep mode or hibernation. While the OS will send a message in the message pump that the hardware is sleeping or hibernating, any thread that is blocked sees it as a really long blocking operation.

And to further complicate matters, games should generally run their simulations at a different rate than their display, preferably running with a fixed step, should run networking at a rate based on data availability, and should run audio at a rate based on audio processing. Asynchronous processing and multiple threads/fibers/processes are your friend if you want performance.


Thanks for the answers so far. I should have provided a bit more info to justify my choice to lock the framerate at 60 for this specific game prototype, but decided to keep the original post as short as possible.

In a nutshell: I am fully aware of the benefits of high refresh rate monitors and framerate independent game loops. But this specific project is a 2D retro platformer using hand drawn sprite sheets and a very low screen resolution. So at a high refresh rate, I'd simply be drawing the same exact frame multiple times since animation frames are not interpolated and sprites can not move less than one on-screen pixel.

I understand that sleep() functions are imperfect but try to mitigate that fact in my code by calculating the next time_point based on the old one, instead of using chrono::steady_clock::now() every frame. This should eliminate the clock drift that over-long sleeps create. And my goal is to only present a new frame in time for every 60Hz monitor retrace, so I have a duration of time to do it and it doesn't need to happen at one exact time_point so I don't think clock precision is the killer here.

I tried to present to the screen after the wait - but that unfortunately does not appear to help the issue I described.

Any further ideas? Or is it simply not possible to output a fresh frame just in time for every 60Hz retrace of my monitor?

Why not just use vsync? Turning vsync on in your windowing API takes care of locking the rendering loop at 60fps on a 60hz screen. No need to do anything else.

Vsync would be a perfect solution for my problem but it's more of a user setting, right? It can be overwritten and force disabled if the user wants to I believe? And I would have to somehow guarantee that it always limits the FPS to 60 and never anything above.

Vsync locks the framerate at the monitor's refresh rate, if 60hz 60fps, 144hz, 144fps.

This topic is closed to new replies.

Advertisement