Accurate game loop in C++

Napisano dnia 5.07.2022 r. o godzinie 12:00
Autor: Piotr Sperka

Introduction

The game loop, despite the name, applies not only to games. Basically, it is a loop that should be executed exactly given number of times per, for example, second. In game engines, it is usually about rendering the right amount of frames per second. However, I recently had a need to write a loop in C ++ running on a separate thread, which will execute at exactly the specified frequency. The difficulty was that the task running inside the loop could sometimes take longer, and sometimes much shorter, than the set period. Even though the task seemed trivial at first, it wasn’t until the third try that I got exactly what I wanted. In this short article, I will show you how I came to the solution.

TL; DR

In order for the loop to meet the desired frequency, you need to take into account that commands such as sleep, sleep_for, sleep_until, and the like are not accurate and can sometimes sleep for longer time that set in the parameter. In conclusion, to be accurate, you have to measure how long the sleep exactly takes.

First, the simplest approach

My first approach was the absolute simplest loop, with sleep at the end. Something like that:

void loop() {
	while ( active ) {
		auto startTime = std::chrono::steady_clock::now();

doLongLastingTask();

auto endTime = std::chrono::steady_clock::now();
		auto loopTimeUs = std::chrono::d uration_cast< std::chrono::microseconds >( endTime - startTime );
		std::this_thread::sleep_for( std::chrono::microseconds( targetPeriodUs - loopTimeUs.count() ) );
	}
}

I noticed quite quickly that the loop was iterating less frequently than it should have been. Just as quickly, I came to the conclusion that if a task sometimes takes longer than targetPerdiodUs, the error builds up. You can’t wait a negative amount of time after all…

A second approach, with an error accumulator

At this point I figured I would try to add a variable which will be a type of accumulator. More specifically, it will store the loop execution time difference from the ideal one. So simply put, it will show how much the loop is ahead of time or lagging. This time I wrote a code like this:

void loop() {
	int64_t timeToWaitUs = 0;

while ( active ) {
		auto startTime = std::chrono::steady_clock::now();

doLongLastingTask();

auto endTime = std::chrono::steady_clock::now();
		auto loopTimeUs = std::chrono::d uration_cast< std::chrono::microseconds >( endTime - startTime );
		timeToWaitUs += targetPeriodUs - loopTimeUs.count();
		if ( timeToWaitUs > 0 ) {
			std::this_thread::sleep_for( std::chrono::microseconds( timeToWaitUs ) );
			timeToWaitUs = 0;
		}
	}
}

As you can see, I’ve added the timeToWaitUs variable, which stores said difference in microseconds. The idea was that if the toLongLastingTask() method should be performed every 100ms, and it will last, let’s say, 180ms, 90ms and 20ms respectively, the program:

  • After the first iteration, it will have to wait -80ms. This is not possible, so it will leave this value in the accumulator and proceed to the next iteration.
  • After the second iteration, it will have to wait 10ms, however, there is still -80ms in the accumulator. Together it gives us -70ms, so again, the program will immediately perform another iteration.
  • After the third iteration, it will have to wait 80ms. There is -70ms in the accumulator, so it will wait 10ms and proceed to the next loop.

I hope you can already see how it should work. However, in practice, the program still iterated less than it should…

A third approach, that works as it should

This was the moment when I started to accurately log the execution time of each line of code. It turned out that sleep_for does not necessarily wait a set amount of time. Sometimes a little longer. I was generally aware of this issue, but I didn’t think that the differences were so noticeable. Equipped with a new insight, I made another version of the loop:

void loop() {
	int64_t timeToWaitUs = 0;

while ( active ) {
		auto startTime = std::chrono::steady_clock::now();

doLongLastingTask();

auto endTime = std::chrono::steady_clock::now();
		auto loopTimeUs = std::chrono::d uration_cast< std::chrono::microseconds >( endTime - startTime );
		timeToWaitUs += targetPeriodUs - loopTimeUs.count();
		if ( timeToWaitUs > 0 ) {
			auto sleepStartTime = std::chrono::steady_clock::now();
			std::this_thread::sleep_for( std::chrono::microseconds( timeToWaitUs ) );
			timeToWaitUs -= std::chrono::d uration_cast< std::chrono::microseconds >(
					std::chrono::steady_clock::now() - sleepStartTime ).count();
		}
	}
}

This loop is almost identical to the previous one. The only improvement I’ve made is measuring the actual execution time of sleep_for. Another launch of the program, and… it works! This time the loop worked as I wanted. In a longer period of time, it was executed on average exactly every targetPeriodUs microseconds.

Summary

I hope that these three simple examples have shown that even a very simple code can have pitfalls. Plus, if you ever face a task that requires a precise game loop, now you know how to approach it. Game loops have many applications, not necessary in games. It can be live audio or video encoding, or collecting data from an external device at a given interval.

If you want to read more about game loops in a purely gaming application, there is a lot of information on the Internet, for example here.

As usual, feel free to contact me, and see you next time! 😉