Wczoraj pisałem wpis na blogu o prototypie głównej pętli gry, którą opracowałem na potrzeby swojego silnika, jendak usunąłem ten wpis, dlatego że istniał w nim co najmniej jeden problem, w wyniku którego późniejsze obliczenia — głównie zużycia CPU — stawały się nieprawidłowe. Lepiej nie rozpowszechniać błędnych kodów.
Dziś podszedłem do tematu jeszcze raz, ale tym razem zmieniając wymagania — o ile zmienna delta nie jest jakoś szczególnie problematyczna (szczególnie, jeśli jest dzielona na małe fragmenty w przypadku lagów), to lepiej jednak mieć stałą deltę. Być może przyda się to w przyszłości, a nawet jeśli nie, to przynajmniej znacznie uprości aktualizowanie logiki, co jest pożądane.
Pętla przede wszystkim powinna działać w taki sposób, aby liczba aktualizacji logiki w każdej sekundzie była stała, dostarczając deltę o stałej wartości do funkcji aktualizującej. Liczba aktualizacji musi posiadać wartość stanowiącą kompromis — nie może być mała, aby delta nie była zbyt duża, ani też duża, bo na potrzeby jednego renderowania, logika będzie aktualizowana po kilka razy (strata mocy CPU). Odpowiednią liczbą aktualizacji wydaje się poczciwe 60
.
Po drugie, liczba generowanych klatek powinna być limitowana, aby nie żreć CPU bez opamiętania i aby nie renderować po kilka razy tej samej klatki (skoro nic się nie zmieniło, to szkoda mocy na renderowanie tego samego). Jeśli gra zużywa mniej mocy niż oferuje CPU, to pozostały czas powinien być przespany. Im mniej roboty z generowaniem klatek, tym więcej czasu wątek gry powinien być zamrożony.
Po trzecie, liczba renderowanych klatek powinna być niezależna od liczby aktualizacji. Stała liczba aktualizacji logiki nie może być w żaden sposób powiązana z renderowaniem — te dwa mechanizmy powinny być zupełnie niezależne. Dla przykładu, logika renderowana jest 60x/s
, natomiast renderowanie 50x/s
. Gracz musi mieć możliwość obniżenia limitu klatek na sekundę — np. zamiast renderowania 60x/s
, może ustawić 30x/s
. Poza tym, w razie gdyby pecet był zbyt słaby, klatki powinny być renderowane rzadziej, a jeśli nastąpi lag, to wyrenderowana powinna być tylko jedna klatka — ta ostatnia, po wielokrotnym zaktualizowaniu logiki, aby nadgonić czas rzeczywisty.
Do zliczania tych wszystkich czasów, potrzebna jest spora kupka zmiennych i kilka stałych:
const
LOOP_FIXED_UPDATES = 60; // Liczba aktualizacji logiki na sekundę.
LOOP_FIXED_DELTA = 1.0 / GAME_LOOP_FIXED_UPDATES; // Stała delta przypadająca na klatkę (tu: 1/60).
LOOP_FRAMERATE_DEFAULT = 50; // Liczba klatek renderowanych na sekundę.
LOOP_REFRESHRATE_DEFAULT = 4; // Liczba aktualizacji dodatkowych liczników na sekundę.
var
Spinning: Bool; // Flaga podtrzymująca pętlę przy życiu.
FreqRendering: Int32; // Liczba renderowanych klatek na sekundę.
TicksPerSecond: UInt64; // Liczba ticków na sekundę.
TicksPerUpdate: UInt64; // Liczba ticków pomiędzy aktualizacjami logiki.
TicksPerRender: UInt64; // Liczba ticków pomiędzy renderowaniami klatek.
TicksPerRefresh: UInt64; // Liczba ticków pomiędzy odświeżeniami liczników.
TicksCurrent: UInt64; // Liczba ticków przed rozpoczęciem generowania klatki.
TicksLastUpdate: UInt64; // Liczba ticków podczas poprzedniej aktualizacji logiki.
TicksLastRender: UInt64; // Liczba ticków podczas poprzedniego renderowania klatki.
TicksLastRefresh: UInt64; // Liczba ticków podczas poprzedniego odświeżenia liczników.
TicksAccuUpdate: UInt64; // Akumulator zliczający ticki na potrzeby aktualizacji logiki.
TicksAccuRender: UInt64; // Akumulator zliczający ticki na potrzeby renderowania klatek.
TicksAccuRefresh: UInt64; // Akumulator zliczający ticki na potrzeby odświeżania liczników.
Wspomniane wyżej „odświeżanie liczników” dotyczy dodatkowych liczników, np. mierzących framerate, obciążenie CPU i inne rzeczy potrzebne do debugowania (to jest opcjonalne).
Zanim główna pętla wystartuje, trzeba zainicjalizować zmienne. Najlepiej w miejscu deklaracji wszystki wyzerować (co mam u siebie, ale nie w powyższym snippecie), jednak wiele z nich musi być ustawionych w runtime.
// Ustawienie częstotliwości renderowania i odświeżania liczników.
FreqRendering := LOOP_FRAMERATE_DEFAULT; // 60
FreqRefreshing := LOOP_REFRESHRATE_DEFAULT; // 4
// Obliczenie liczby ticków przypadających na poszczególne czynności (logika, renderowanie, odświeżanie).
TicksPerSecond := SDL_GetPerformanceFrequency();
TicksPerUpdate := Round(TicksPerSecond * LOOP_FIXED_DELTA);
TicksPerRender := Round(TicksPerSecond / FreqRendering);
TicksPerRefresh := Round(TicksPerSecond / FreqRefreshing);
// Inicjalizacja liczników ticków dla poprzednio wykonanych poszczególnych czynności.
// Dzięki odejmowaniu ticków, pierwsza iteracja pętli wymusi wykonanie zadanych trzech czynności.
TicksCurrent := SDL_GetPerformanceCounter();
TicksLastUpdate := TicksCurrent - TicksPerUpdate;
TicksLastRender := TicksCurrent - TicksPerRender;
TicksLastRefresh := TicksCurrent - TicksPerRefresh;
Skoro wszystko jest zainicjalizowane, pętla startuje i obraca się dotąd, aż flaga podtrzymująca życie pętli zostanie zgaszona.
repeat
// Pobranie liczby ticków przed wygenerowaniem klatki.
TicksCurrent := SDL_GetPerformanceCounter();
// Aktualizacja akumulatorów, ale tylko wtedy, gdy upłynęło wystarczająco dużo czasu.
if TicksCurrent > TicksLastUpdate then TicksAccuUpdate += TicksCurrent - TicksLastUpdate;
if TicksCurrent > TicksLastRender then TicksAccuRender += TicksCurrent - TicksLastRender;
if TicksCurrent > TicksLastRefresh then TicksAccuRefresh += TicksCurrent - TicksLastRefresh;
// Sprawdzenie, czy nadszedł czas wykonania co najmniej jednej czynności.
if (TicksAccuUpdate > TicksPerUpdate) or (TicksAccuRender > TicksPerRender) or (TicksAccuRefresh > TicksPerRefresh) then
begin
// Sprawdzenie, czy nadszedł czas aktualizacji logiki (możliwa wielokrokowa aktualizacja).
while TicksAccuUpdate > TicksPerUpdate do
begin
// Przetworzenie zdarzeń i aktualizacja logiki o stały krok.
UpdateEvents();
UpdateLogic(LOOP_FIXED_DELTA);
// Zapamiętanie czasu najświeższej aktualizacji i dekrementacja akumulatora.
TicksLastUpdate += TicksPerUpdate;
TicksAccuUpdate -= TicksPerUpdate;
// Inkrementacja ogólnego licznika zaktualizowanych klatek.
NumFramesUpdated += 1;
end;
// Sprawdzenie, czy nadszedł czas renderowania.
// Nieważne ile czasu minęło od poprzedniego renderowania, malujemy tylko najświeższą klatkę.
if TicksAccuRender > TicksPerRender then
begin
// Wyrenderowanie klatki i wyrzucenie obrazu na ekran.
Game_RenderFrame();
Game_RenderPresent();
// Zapamiętanie czasu najświeższego renderowania i nadgonienie czasu.
while TicksLastRender < TicksCurrent do TicksLastRender += TicksPerRender;
while TicksAccuRender > TicksPerRender do TicksAccuRender -= TicksPerRender;
// Inkrementacja ogólnego licznika wyrenderowanych klatek.
NumFramesRendered += 1;
end;
// Sprawdzenie, czy nadszedł czas aktualizacji ogólnych liczników.
if TicksAccuRefresh > TicksPerRefresh then
begin
// Tutaj kod aktualizujący ogólne liczniki.
// Zapamiętanie czasu najświeższego odświeżenia liczników i nadgonienie czasu.
while TicksLastRefresh < TicksCurrent do TicksLastRefresh += TicksPerRefresh;
while TicksAccuRefresh > TicksPerRefresh do TicksAccuRefresh -= TicksPerRefresh;
// Inkrementacja ogólnego licznika odświeżeń liczników (opcjonalnie).
NumFramesRefreshed += 1;
end;
end
else
// Jest za wcześnie na wykonanie którejkolwiek czynności, więc czekamy milisekundę.
SDL_Delay(1);
until not Spinning;
Najpierw pobiera się bieżącą liczbę ticków, a następnie aktualizuje akumulatory, jeśli upłynęło wystarczająco dużo czasu. Jeśli nie upłynęło, to liczniki zapamiętujące poprzednie wykonania będą miały wartość większą od czasu obecnego, a więc wynikiem odejmowania byłaby wartość ujemna, co w przypadku używania typów UInt64
(bez znaku) skończyłoby się błędem „integer underflow”.
Następnie wykonywany jest główny warunek, który sprawdza, czy nadszedł czas wykonania którejkolwiek czynności (albo kilku). Jeśli tak, to najpierw aktualizuje się logikę — w pętli, bo mógł pojawić się lag, przez co konieczne jest nadgonienie czasu. Następnie renderuje się klatkę i wywala obraz na ekran. Renderowana jest tylko jedna klatka, nieważne czy był lag czy nie — tylko najświeższa. Jeśli trzeba, aktualizuje się ogólne liczniki — też tylko raz, nieważne czy lag był czy nie. Głównie chodzi o licznik fps-ów, aby gracz widział czy mu CPU wyrabia, czy jednak gra gubi klatki.
Stałe zostały ustawione tak, aby logika była aktualizowana 60x/s
, klatki były renderowane 50x/s
, a stan liczników aktualizowany 4x/s
. Trzy czynności, każda wykonywana w różnych (niezależnych) odstępach czasu. Dopisałem trochę kodu aby pomierzyć i wyświetlić stan liczników co sekundę — niżej przykład:
Iterations: 610 | Updated: 60 | Rendered: 50 | Refreshed: 4
Iterations: 604 | Updated: 60 | Rendered: 50 | Refreshed: 4
Iterations: 611 | Updated: 60 | Rendered: 50 | Refreshed: 4
Iterations: 607 | Updated: 60 | Rendered: 50 | Refreshed: 4
Pierwsza kolumna pokazuje liczbę iteracji pętli repeat until
, druga wyświetla NumFramesUpdated
, trzecia NumFramesRendered
, ostatnia NumFramesRefreshed
. Wszystko się zgadza, wedle założeń. Teraz wprowadźmy niewielki lag (symuluję łapiąc za belkę tytułową okna):
Iterations: 618 | Updated: 60 | Rendered: 50 | Refreshed: 4
Iterations: 327 | Updated: 33 | Rendered: 27 | Refreshed: 2 // lag
Iterations: 567 | Updated: 87 | Rendered: 47 | Refreshed: 5 // lag
Iterations: 614 | Updated: 60 | Rendered: 50 | Refreshed: 4
Jak widać, lag obniża liczbę renderowanych klatek (renderowanie nieaktualnych klatek jest pomijane), ale liczba aktualizowanych klatek zgadza się — w zalagowanej sekundzie aktualizacji jest mniej (tu: 33
), a w następnej sekundzie wykonywane są brakujące aktualizacje (tu: 87
), czyli wyrównanie do czasu rzeczywistego. Teraz potężny lag, taki na kilka sekund:
Iterations: 611 | Updated: 60 | Rendered: 50 | Refreshed: 4
Iterations: 144 | Updated: 15 | Rendered: 11 | Refreshed: 1 // lag
Iterations: 552 | Updated: 165 | Rendered: 46 | Refreshed: 4 // lag
Iterations: 611 | Updated: 60 | Rendered: 50 | Refreshed: 4
Znów następuje nadgonienie, tym razem w zalagowanej sekundzie aktualizacji jest 15
, potem kilka sekund przerwy, a następnie duże nadgonienie — tutaj aż 165
klatek. Dlaczego tyle? Bo 15 + 165 = 180
, a to dzieli się przez 60
bez reszty (czyli przez zadaną, stałą liczbę aktualizacji na sekundę).
Podana wyżej implementacja głównej pętli wygląda na poprawną. Na razie do testów mam tylko konsolę i logowanie w niej danych — póki nie mam jeszcze silnika, trudno powiedzieć jaki będzie efekt widoczny na ekranie. Choć obecnie nie mam podstaw by sądzić, że klatki na ekranie nie będą wyświetlane w równych odstępach (przy stabilnym działaniu).
Napisałbym dużo więcej na temat tej pętli, ale… limit znaków wpisów na mikroblogach. :D
, jendak usunąłem ten wpis, dlatego że istniał w nim co najmniej jeden problem, w wyniku którego późniejsze obliczenia — głównie zużycia CPU — stawały się nieprawidłowe. Lepiej nie rozpowszechniać błędnych kodów.
Mi się wydaje, że trochę szkoda, a przynajmniej mógłbyś do tego wpisu dodać jakieś omówienie, co powodowało, że te obliczenia stawały się nieprawidłowe. Dla innych na przyszłość byłoby to przydatne na swój sposób na zasadzie "Nie idźcie tą drogą, ja tam byłem, jest źle".
Co w przypadku ekranow LCD z czestotliwoscia odswiezania nie bedaca podzielna przez liczbe 60? Np. 144 Hz? Wedlug mnie takie ograniczanie sie zmniejsza tylko liczbe potencjalnie kompatybilnych konfiguracji sprzetowych. Skoro ktos ma monitor 144 Hz, to dlaczego ma tylko widziec 60 odswiezen stanu rozgrywki na sekunde?
@BartoSAS: poprzedni przykład może i by był przydatny po jego naprawieniu, jednak używanie zmiennej delty od dawna nie jest proponowane, dlatego że symulacja przestaje być deterministyczna. Mój kod co prawda limitował frame rate oraz przerabiał deltę na w miarę bezpieczny zakres wartości (np. od 0.75
do 1.5
jako mnożnik 1/fps
), no ale to tylko małe usprawnienie, a nie rozwiązanie problemu.
@winuser: mój obecny kod — czyli ten widoczny w tym wpisie — pozwala ustawić dowolny framerate, również większy niż liczba aktualizacji logiki na sekundę. Update logiki i renderowanie działają sekwencyjnie, ale zupełnie niezależnie, dlatego też oba te mechanizmy mogą posiadać dowolną częstotliwość. Możliwe jest więc aktualizowanie logiki zawsze o stałą liczbę kroków (np. 60
) oraz renderować obraz z częstotliwością 144
klatek na sekundę. Niżej output konsoli dla takiej konfiguracji:
Iterations: 696 | Updated: 60 | Rendered: 144
Iterations: 689 | Updated: 60 | Rendered: 144
Iterations: 688 | Updated: 60 | Rendered: 144
Iterations: 583 | Updated: 51 | Rendered: 122 // symulacja mikro-laga
Iterations: 642 | Updated: 69 | Rendered: 134 // symulacja mikro-laga
Iterations: 689 | Updated: 60 | Rendered: 144
Iterations: 695 | Updated: 60 | Rendered: 144
Iterations: 57 | Updated: 5 | Rendered: 12 // symulacja dużego laga
Iterations: 14 | Updated: 115 | Rendered: 3 // symulacja dużego laga
Iterations: 694 | Updated: 60 | Rendered: 144
Iterations: 690 | Updated: 60 | Rendered: 142
Iterations: 697 | Updated: 60 | Rendered: 144
Pamiętaj jednak, że jeśli logika gry aktualizowana jest np. w 60
krokach, to nie ma żadnego sensu renderować 144
klatki w każdej sekundzie, bo więcej niż połowa klatek będzie takich samych (dokładnie 84
duplikaty w każdej sekundzie). Aby każda renderowana klatka była inna, należy dokonać interpolacji podczas renderowania — czyli głównych aktualizacji logiki zawsze przeprowadzać 60
, a następnie obliczyć zinterpolowaną deltę (od najświeższego update'u logiki do rozpoczęcia renderowania) i wykorzystać ją podczas renderowania. Teraz wyobraź sobie, jak cholernie skomplikowany byłoby kod takiego renderowania i jak duże zużycie mocy CPU by powodowało. Tu już nie chodzi o samo renderowanie, a o to, że końcowy SDL_Delay(1)
nie wykona się praktycznie nigdy, więc pętla gry będzie notorycznie wykorzystywać spinlock (busy waiting).
Moja gra będzie w stylu retro (à la SNES), a więc nie ma sensu, aby tak bardzo komplikować silnik, skoro interpolowane renderowanie klatek w ilości większej niż liczba aktualizacji logiki, nie da mi żadnych benefitów. Po drugie, pojedyncza aktualizacja logiki będzie trwać wielokrotnie krócej niż wyrenderowanie klatki, dlatego muszę zadbać o to, aby główna pętla gry potrafiła renderować tyle samo lub mniej klatek niż aktualizacji logiki. Framerate będzie można ustawić dowolnie od 30
do 60
(w debugu od 1
do 60
), a dodatkowo będzie można włączyć ”renderowanie połowiczne” (przeplot horyzontalny lub diaginalny), aby zwiększyć jego wydajność o 40-50% (na potrzeby słabszych/starszych pecetów).
O to mi wlasnie chodzi, bo rozumiem, ze mozesz sobie zakodowac do 60 Hz dla grafiki (albo inny dowolnie wybrany w runtime), jednak constraint na 60 Hz dla logiki to druga strona tego samego medalu, tzn ograniczasz plynnosc rozgrywki w imie w zasadzie nie wiem czego? Rozumiem, ze moze chodzic o pewne ulatwienie na dalszym etapie implementacji, zawsze wiesz ile jeden "tick" silnika logiki gry trwa, jednak mozna to przeciez zakodowac w sposob elastyczny, aby nie ograniczac ani logiki ani grafiki.
@winuser: stała liczba aktualizacji logiki na sekundę nie jest ograniczeniem, a rozwiązaniem wielu problemów. ;)
Rozumiem, ze moze chodzic o pewne ulatwienie na dalszym etapie implementacji, zawsze wiesz ile jeden "tick" silnika logiki gry trwa […]
Każdy krok aktualizacji wykorzystuje deltę o stałej wartości (np. 1/60
dla 60
update'ów logiki), dzięki czemu uwalnia się fizykę gry od błędów spowodowanych przez zbyt małą lub za dużą deltę, a po drugie, cała symulacja staje się w pełni deterministyczna. Ten determinizm symulacji jest czymś, do czego wszyscy deweloperzy dążą — można bardzo łatwo (i co najważniejsze, bezpiecznie) przeprowadzać symulację gry na potrzeby sieciowego multiplayera, ale można też bardzo łatwo tworzyć powtórki całej rozgrywki, bo wystarczy rejestrować input dla każdej aktualizacji logiki i potem go klatka po klatce odtwarzać (zobacz sobie jak to robi np. Trackmania i jej system replay'ów).
[…] jednak mozna to przeciez zakodowac w sposob elastyczny, aby nie ograniczac ani logiki ani grafiki.
Tym właśnie jest interpolowane renderowanie klatek, o którym pisałem w poprzednim komentarzu. Logika nadal aktualizowana jest stałą liczbę razy na sekundę, ale podczas renderowania, dodatkowo przesuwa się obiekty, a więc każda klatka na ekranie może wyglądać nieco inaczej. Jednak ta interpolacja musi być zaimplementowana w rendererze, nie w głównej pętli — w niej należy tylko obliczyć deltę pomiędzy ostatnią aktualizacją a czasem rozpoczęcia świężego renderowania, a następnie przekazać ją funkcji renderującej.
Determinizm mozna osiagnac poprzez wyciagniecie seeda + czestotliwosc odswiezania w tym wypadku. Jednak rozklad randomowych framedropow na pewno nie jest deterministyczny ani staly, dlatego mowienie o determinizmie w takim przypadku jest i tak uproszczeniem.
@winuser: nie da się zaprogramować w pełni deterministecznej symulacji ze zmienną deltą. Stała delta jest jedynym rozwiązaniem tego problemu, dlatego też współczesne duże silniki taką zapewniają (np. Unity i jego FixedUpdate
). Nie musisz mi wierzyć — poczytaj na ten temat artykuły, traktujące o wzorcach używanych w gamedevie.
W kodzie, który podałem we wpisie, logika aktualizowana jest w stałych interwałach, czyli jest to odpowiednik FixedUpdate
z Unity. Sam nie potrzebuję, ale jeśli bym chciał, to mogę sobie dodać również odpowiednik zwykłego, nieregularnego Update
— wystarczy taką funkcję wywołać w każdej iteracji pętli repeat until
, czyli tuż przed głównym warunkiem (tym testującym trzy akumulatory).
Kilka rzeczy, o których chciałem napisać, ale brakło mi miejsca we wpisie.
Czym są ticki?
We wpisie oraz w kodzie używam pojęcia ticków. One nie mają nic wspólnego z funkcją
SDL_GetTicks
, bo to są ticki sprzętowego zegara wysokiej rozdzielczości (u mnie to 10MHz), a nie ticki zegara systemowego (którego rozdzielczość to zaledwie 1kHz). Co prawda funkcjaSDL_GetTicks
zwraca czas w milisekundach, z faktycznie milisekundowymi interwałami, ale mimo wszystko wolę operować na licznikach dających znacznie większą precyzję.Nieprecyzyjna metoda pomiarowa.
Do mierzenia tego ile razy dana czynność została wykonana w każdej sekundzie (aktualizacja logiki, renderowanie, odświeżanie liczników), zaimplementowałem w formie testowania czy zmieniła się sekunda w zegarze systemowym. Wołam
SDL_GetTicks
, dzielę przez1000
i sprawdzam czy obecna sekunda jest różna od poprzedniej zapamiętanej. Jeśli tak, wyświetlam stan liczników w konsoli (w formacie jaki widoczny jest we wpisie), a następnie resetuję liczniki. Przez większą część czasu liczniki mają prawidłowe wartości, ale raz na jakiś czas zdarza się, że np. przy60fps
pokazuje w jednej sekundzie59fps
, w kolejnej61fps
, a w kolejnych już normalnie60fps
. Precyzja pomiarów nie jest zbyt wysoka, ale wystarczająca na potrzeby testów.Wielokrokowe przetwarzanie zdarzeń.
We wpisie jej nie ma, bo brakło mi miejsca. Kod pętli widoczny we wpisie zawsze od razu przetwarza wszystkie zdarzenia z kolejki, bez względu na to czy był lag czy nie. To nie jest dobre, bo jeśli zdarzy się lag i w trakcie gdy wątek wisi wciśniemy kilka razy np. przycisk na gamepadzie, to później podczas pierwszej aktualizacji logiki, wszystkie zdarzenia dotyczące tych wciśnięć zostaną przetworzone od razu (cała kolejka). W takim przypadku gra w ogóle nie zauważy tych wszystkich wciśnięć, bo weźmie pod uwagę jedynie stan przycisku sprzed laga oraz ten po jego ustąpieniu.
Aby wykluczyć ten problem, należy obliczyć liczbę wymaganych aktualizacji logiki, następnie policzyć liczbę milisekund, jaka upłynęła od ostatniej aktualizacji, następnie podzielić tę liczbę milisekund przez liczbę wymaganych aktualizacji. W ten sposób otrzymuje się milisekundowy krok, który należy wykorzystać podczas przetwarzania zdarzeń. Wtedy w pętli najpierw oblicza się timestamp danej aktualizacji, następnie wyciąga się z kolejki SDL-a zdarzenia aż do tego timestampu, a resztę pozostawia się kolejnym krokom aktualizacji logiki.
Dzięki temu wszystkiemu, gra nie pogubi akcji gracza, które wykonał w trakcie lagu. Ma to szczególne znaczenie, jeśli zdarzenia dotyczące inputu nie są zamieniane na akcje, które są przechowywane w osobnej kolejce. U mnie czegoś takiego nie będzie, bo to dość komplikuje obsługę inputu.