- […] Co do CPU, to używam póki co tylko dwóch wątków roboczych, jeden na
Update()
i jeden na Render()
(+1 wątek główny dla okna, inputów i komunikatów) więc maksymalnie mogę docisnąć do 100%, ale tylko 2 rdzenie.
Teoretycznie możesz używać wszystkich rdzeni, bo możesz odpalić pulę wątków w Update()
i pulę w Render()
. Istotne jest jednak to, aby główny Update()
mógł robić swoje i nie wtrącać się rendererowi (i vice versa). A że te mechanizmy zdają się być niezależne — bo korzystasz z wielostopniowego buforowania — to IMO już teraz ten kod jest zdolny do takich rzeczy.
- Synchronizację robię przez potrójne bufforowanie, ale jeszcze z jednym dodatkowym buforem awaryjnym. W skrócie coś takiego:
[…]
Remedium póki co znalazłem takie, że wprowadziłem sobie awaryjny bufor B3, który może zostać tymczasowo użyty w czasie opóźnień Rendera()
. Nie jest to rozwiązanie idealne, ale na lepsze na razie nie wpadłem ;/.
Nie myślałeś o trzymaniu tych buforów w FIFO (kolejce)? Liczba buforów mogła by się dostosowywać dynamicznie, więc Update()
nigdy nie musiałby czekać na Render()
(ani odwrotnie). Jeśli zrobić kolejkę na liście jednokierunkowej, to obie metody mogłyby korzystać z takiej kolejki bez żadnej synchronizacji. Jedyne co potrzeba to dwa wskaźniki — jeden na głowę listy, drugi na ogon. Update()
modyfikowałby głowę, a Render()
czytał ogon (albo na odwrót).
- Czyli jednak przekonałeś się do mojego podejścia ze stałym interwałem odświeżania logiki i interpolowaniem klatki z dwóch stanów świata? :D.
Stałą liczbę odświeżeń na sekundę stosowałem od zawsze, np. w Deep Platformerze czy Fairtrisie, więc to dla mnie nie jest żadna nowość. A nawet inaczej — nigdy nie napisałem gry, która by korzystała ze zmiennej delty. :D
Z interpolowanego renderowania nie chciałem korzystać, bo wprowadza dużo komplikacji w budowie silnika, ale ma tę zaletę, że pozwala uniknąć artefaktów związanych z lagami i obsługą VSynca — dlatego chcąc nie chcąc muszę z niej skorzystać, aby zachować bardzo dobrą płynność obrazu. Szczególnie, że to będzie gra pikselartowa, a w takich bardzo trudno o płynność.
Dodatkową zaletą interpolowanego renderowania jest to, że będę mógł zapewnić renderowanie większej liczby klatek niż aktualizacji świata — jeśli gracz np. ma monitor 144Hz to czemu gra miała by renderować tylko 60fps? I znów, w pikselartowej grze może to mieć małe znaczenie, ale jeśli choćby w małym stopniu poprawi jakość animacji, to warto z tego skorzystać (nie kosztuje mnie to praktycznie nic — jednen warunek w pętli więcej i FPS-y odblokowane).
- Robisz całość w jednym wątku?
Główna pętla działa w ramach głównego wątku, bo — z tego co mi wiadomo — SDL na razie nie wspiera wielowątkowego renderowania. To powoduje, że wszelkie operacje na teksturach oraz SDL_RenderPresent
muszą być wykonywane w tym wątku, który obsługuje kolejkę zdarzeń (czyli w głównym wątku gry). W skrócie wygląda to tak:
repeat
// pobranie czasu i ticków TSC.
// obliczenie ile ticków TSC upłynęło.
// jeśli już czas na kolejną aktualizację logiki:
if TimeToUpdate then
// aktualizacja logiki w jednym lub wielu krokach, zalezy ile czasu minęło.
// jeśli już czas na renderowanie lub FPS'y nie są limitowane:
if TimeToRender or RenderUnlimited then
// wyrenderowanie klatki i wyświetlenie jej na ekranie.
// jeśli już czas na aktualizację liczników zużycia (raz lub wiele razy na sekundę):
if TimeToRefresh then
// zaktualizowanie liczników zużycia CPU przez "update" i "render".
// jeśli już czas na aktualizację liczników frame rate'u (zawsze raz na sekundę):
if SecondChanged then
// zaktualizowanie liczników frame rate'u (dwóch: updateów i renderów na sekundę).
// jeśli FPS-y są limitowane, trzeba oddać moc CPU, ograniczając jego zużycie do minimum:
if not RenderUnlimited then
// jeśli do kolejnego kroku jest więcej niż milisekunda:
// zamroź pętlę na milisekundę — wewnetrznie za pomocą "Sleep(1)".
// jeśli jest mniej niż milisekunda:
// odczekaj za pomocą króciutkiego spinlocku ("QPC" + assemblerowy "pause").
until not Spinning;
Aktualizacja logiki będzie działać w ramach głównego wątku, bo niewiele będzie miała do roboty. Jeśli pojawi się lag, to wykona kilka kroków aktualizacji logiki. W każdym kroku aktualizacji, najpierw obliczany jest czas rzeczywisty i on jest używany do przetwarzania zdarzeń. Jeśli np. pojawi się lag na 10 klatek, to aktualizacja logiki wykonana zostanie w 10 krokach, a każdy krok przetworzy swoją paczuszkę zdarzeń. Dzięki temu lagi ani nie będą wpływać na responsywność sterowania, ani inputy nie będą się gubić — nawet jeśli lag będzie gigantyczny. Dodałem też zabezpieczenie przed "spiralą śmierci" — jeśli pomiędzy klatkami minęło więcej niż pół sekundy, to modyfikuję time stampy tak, aby logika zaktualizowała się tylko raz (standardowa technika).
W kolejnym kroku wykonywane jest renderowanie i ono będzie wielowątkowe — tyle wątków ile rdzeni logicznych. Co prawda SDL nie wspiera tego, ale w moim silniku zamierzam zrobić software'owy raytracing, który będzie w całości wykonany na CPU. Najpierw zgram bufor tekstury do zwykłej tablicy bajtów, potem raytracer zaktualizuje tę tablicę, a po wszystkim przerzuci tę tablicę do GPU, aktualizując teksturę. Postprocessing będzie wykonany w ramach głównego wątku, bo już będzie operował bezpośrednio na teksturach. Buforów do renderowania gry będzie mnóstwo — jeden dla raytracera i kilka dodatkowych do zmontowania finalnej klatki.
Czas pomiędzy kolejnymi krokami wyznaczany jest precyzyjnie, za pomocą ticków TSC (u mnie ma rozdzielczość 10MHz), natomiast renderowanie przyspawałem do liczby kroków, bo zależy mi wyłącznie na trzech częstotliwościach renderowania:
- 60fps — domyślnie, zgodnie z liczbą wykonywanych aktualizacji logiki na sekundę,
- 30fps — dla wolniejszych komputerów,
- 15fps — renderowanie 30 pól na sekundę, korzystając z interlacingu poziomego/diagonalnego, dla bardzo wolnych/starych komputerów,
- nielimitowana — dla wolnych i szybkich komputerów, wsparcia VSynca i wysokich częstotliwości odświeżania monitora.
Wszystko będzie konfigurowalne w opcjach gry, więc każdy będzie mógł wybrać co tylko chce. Do wygładzenia animacji będzie można też włączyć ”motion blur” i ustawić jego siłę, co przyda się szczególnie przy obniżonych częstotliwościach renderowania.
A jeżeli Update()
lub Render()
się opóźniają, to nie rozregulowuje ci się cały loop? Bo jednak w razie opóźnienia jeden musi czekać na drugiego i tym samym opóźnienie jeszcze bardziej narasta.
Nie, bo logika w razie lagów aktualizowana jest w wielu krokach, w ten sposób doganiając czas rzeczywisty, a przetwarzanie zdarzeń w paczkach odpowiednich czasowo dla każdego kroku, zapobiega utracie kontroli nad sterowaniem, a więc poczuciu braku responsywności.
Może właśnie tu ci ginęła ta klatka raz na jakiś czas, bo opóźnienia się po prostu zbytnio kumulowały?
Klatka gubiła się regularnie co ~10 sekund wyłącznie ze względu na VSync, co dało się bardzo łatwo wybadać. ;)
Pętla zawsze aktualizuje logikę 60 razy na sekundę, odświeżanie w laptopie mam 60Hz. Niby jedno równe drugiemu, więc powinno być idealnie równo, ale częstotliwość odświeżania nie jest równiutko 60Hz — zawsze jest jakaś ”dupka” po przecinku. Poza tym VSync sam z siebie wprowadza opóźnienie.
Przy powyższej konfiguracji i aktywnym VSyncu, renderowanych jest średnio 59.940
klatek, ale ta wartość waha się od 59.880
do 60.000
. Lekkie opóźnienie spowodowane VSyncem kumuluje się dotąd, aż przewyższy czas równy odstępowi pomiędzy krokami — i wtedy logika aktualizuje się dwa razy, aby dogonić czas rzeczywisty, co jest widoczne jako delikatny skok mojego ruchomego kwadracika (jedna klatka jest pomijana podczas renderowania). Interpolowanie renderowania zapobiega temu artefaktowi, bo cały czas używa akumulatora ticków do wstępnej interpolacji ruchomego kwadracika, więc jest on na ekranie widoczny tam gdzie ma być, pomimo tego, że logika jeszcze go tam nie przesunęła.
Jeśli natomiast wyłączę VSync, to liczba wyrenderowanych klatek zawsze jest równa 60.000
, ale ma to jeden minus. W trybie okienkowym, płótno okna tak czy siak odświeżane jest przez system całościowo (rozrywanie ekranu nie wystepuje), więc animacje przez krótki czas są płynne, a następnie klatkują, potem znów są płynne i znów klatkują (i tak w kółko). Natomiast w ekskluzywnym trybie wideo nic nie klatkuje, ale widać efekt rozrywania ekranu, bo tym się właśnie kończy nieużywanie VSynca. :]
- Masz prawdziwy
VSync
(komunikujący się z GPU) czy własny "czekacz"?
Tak, mam prawdziwy VSync. I dobrze, że nie jest on zaimplementowany w formie spinlocka.
Jakich bibliotek / API używasz do renderowania?
Całą grę oparłem na bibliotece SDL2, bo jest niskopoziomowa, lekka i cholernie szybka, więc mogę robić co tylko chcę. Używam jej do renderowania (Direct3D domyślnie, a jest też wsparcie OpenGL, Vulkan itd.), ale też do obsługi okien, kontrolerów, miksera audio i masy innych rzeczy. No i jest multiplatformowa, dzięki czemu w ogóle nie muszę używać systemowego API.
- "Wstępna interpolacja"? Co masz na myśli?
Wstępna, bo nie musi ona być częścią renderera — dodatkową interpolację może przeprowadzić aktualizator logiki. Istotne jest jednak to, aby ta dodatkowa interpolacja tworzyła kopię danych obiektów, które będą używane przez renderer, a po wyrenderowaniu te kopie muszą być usunięte. I najpewniej tak zrobię — renderer zajmie się wyłącznie renderowaniem, a za dodatkowe przesuwanie obiektów odpowiedzialne będą inne mechanizmy.