Dokładna pętla gry w C++

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

Wstęp

Pętla gry (ang. game loop) to zagadnienie, które pomimo nazwy ma zastosowanie nie tylko w grach. Głównie chodzi tu o pętlę, która powinna wykonywać się dokładnie co zadany czas. W grach zwykle chodzi o renderowanie odpowiedniej ilości klatek na sekundę. Ja jednak ostatnio miałem potrzebę napisania w C++ pętli działającej na osobnym wątku, która będzie się wykonywała z dokładnie zadaną częstotliwością. Pewnym utrudnieniem było to, że zadanie wykonywane w pętli czasami może trwać dłużej, a czasami znacznie krócej niż zadany okres. Mimo że zadanie z początku wydawało się banalne, dopiero za trzecim podejściem uzyskałem dokładnie to, co chciałem. W kolejnych krokach pokażę Ci, jak doszedłem do rozwiązania.

TL; DR

Aby pętla średnio wywoływała się z zadaną częstotliwością, musisz wziąć pod uwagę, że polecenia typu sleep, sleep_for, sleep_until, i im podobne nie są dokładne i czasem mogą zająć więcej czasu niż zadany. Żeby było dokładnie, trzeba ten czas mierzyć.

Podejście pierwsze, najprostsze

Moim pierwszym podejściem była absolutnie najprostsza pętla ze sleep-em na końcu. Coś takiego:

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

		doLongLastingTask();

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

Dość szybko zauważyłem, że pętla wykonuje się rzadziej w jednostce czasu, niż powinna. Równie szybko doszedłem do wniosku, że jeśli zdarza się, że zadanie wykonuje się czasami dłużej niż targetPerdiodUs, błąd się nawarstwia. Nie można przecież poczekać ujemnej ilości czasu…

Podejście drugie, z akumulatorem błędu

W tym momencie doszedłem do wniosku, że spróbuję dodać zmienną, która będzie rodzajem akumulatora. Dokładniej mówiąc, będzie przechowywała różnicę w czasie wykonania pętli względem idealnego. Czyli mówiąc prościej, będzie pokazywała, o ile pętla jest do przodu lub do tyłu. Tym razem otrzymałem taki kod:

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::duration_cast< std::chrono::microseconds >( endTime - startTime );
		timeToWaitUs += targetPeriodUs - loopTimeUs.count();
		if ( timeToWaitUs > 0 ) {
			std::this_thread::sleep_for( std::chrono::microseconds( timeToWaitUs ) );
			timeToWaitUs = 0;
		}
	}
}

Jak widzisz, dodałem zmienną timeToWaitUs, która przechowuje wspomnianą różnicę w mikrosekundach. Idea była taka, że jeśli zadanie doLongLastingTask() powinno wykonywać się co 100ms, a będzie trwało kolejno 180ms, 90ms i 20ms, program:

  • Po pierwszej iteracji będzie musiał poczekać -80ms. Nie jest to możliwe, a więc pozostawi tą wartość w akumulatorze i przystąpi do kolejnej iteracji.
  • Po drugiej iteracji będzie musiał poczekać 10ms, jednak w akumulatorze jest jeszcze -80ms. Razem daje nam to -70ms, czyli ponownie program od razu wykona kolejną iterację.
  • Po trzeciej iteracji będzie musiał poczekać 80ms. W akumulatorze jest -70ms, więc poczeka 10ms i przystąpi do kolejnej pętli.

Mam nadzieję, że widzisz już, jak to w założeniu powinno działać. Jednak w praktyce nadal program wykonywał mniej iteracji, niż powinien…

Podejście trzecie, w pełni działające

To był moment, w którym zacząłem dokładnie logować czas wykonania poszczególnych linii. Okazało się, że sleep_for niekoniecznie czeka zadaną ilość czasu. Czasem nieco dłużej. Ogólnie byłem tego świadomy, nie sądziłem jednak, że różnice są aż tak wyraźne. Wyposażony w nowe spostrzeżenie wykonałem kolejną wersję pętli:

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::duration_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::duration_cast< std::chrono::microseconds >(
					std::chrono::steady_clock::now() - sleepStartTime ).count();
		}
	}
}

Ta pętla jest niemal identyczna do poprzedniej. Jedyne usprawnienie, jakie wprowadziłem, to pomiar rzeczywistego czasu wykonania funkcji sleep_for. Kolejne odpalenie programu, i… działa! Tym razem pętla działała tak, jak chciałem, czyli w dłuższym czasie wykonywała się średnio co targetPeriodUs mikrosekund.

Podsumowanie

Liczę, że te trzy proste przykłady pokazały, że nawet w bardzo prostym kodzie mogą czaić się pułapki. Dodatkowo, jeśli staniesz kiedyś przed zadaniem wymagającym dokładniej pętli gry, teraz już wiesz, jak do tego podejść. 😉 Zastosowań jest wiele, ale może to być na przykład kodowanie audio lub wideo na żywo, w prostej grze, czy pobieraniu danych z zewnętrznego urządzenia z zadanym interwałem.

Jeśli masz ochotę poczytać więcej na ten temat w zastosowaniu stricte growym, w Internecie jest sporo informacji, na przykład tutaj.

Jak zwykle zapraszam do kontaktu, i do następnego!