Buforowanie stanów świata w wielowątkowym game loopie i śledzenie zmian obiektów.

0

Moje pytanie dotyczy kwestii dosyć precyzyjnej, ale żeby zrozumieć jego sens, wymagane jest trochę kontekstu.

  1. Silnik napisany jest w C++.
  2. Renderowanie i odświeżanie logiki gry odbywa się w osobnych wątkach.
  3. Odświeżanie odbywa się co stały, dowolny interwał (np. 30 razy na sekundę).
  4. Renderowanie działa niezależnie, z własnym interwałem (np. 60 klatek na sekundę), albo w ogóle bez odgórnego ograniczenia.
  5. Renderowanie jest opóźnione o jeden cykl względem odświeżania i używa interpolacji do rysowania klatek na bazie dwóch ostatnich stanów świata. Np.:
    Pozycja (stan 1) = 50, Pozycja (stan 2) = 70, więc kolejne klatki będą rysowane z wartościami np. 52, 57, 61, 65, 67 itd., 
    zgodnie z upływem czasu rzeczywistego pomiędzy klatkami.
    
  6. Interpolacja może zostać wyłączona dla dowolnego przekształcenia, gdy gradacja nie jest potrzebna, np. przy teleportacji obiektu.

Pseudokod wygląda tak:

void Update()
{
  //Obecna pozycja postaci gracza to { 100, 100 }.
  //Obecna saturacja postaci gracza to 1.0f.
  
  //Poniższe metody tworzą instrukcje dla renderera (dodając je do stosownej listy, indywidualnej dla każdego obiektu do wyrenderowania). 
  //Ostatni parametr wskazuje, czy zmiana ma być interpolowana. false oznacza jej brak i natychmiastowy przeskok (np. teleportację).
  
  /*1*/ Player.SetPosition(Player.Position.X + 60, Player.Position.Y, true)
  /*2*/ Player.SetSaturation(0.25f, false);
  /*3*/ Player.SetSaturation(0.80f, true);
  /*4*/ Player.SetPosition(21, 56, false)
  /*5*/ Player.SetPosition(Player.Position.X + 100, Player.Position.Y, true)
}

Zamysł jest taki, żeby stworzyć instrukcje dla renderera (event queue) odnośnie tego jak ma narysować obiekt na ekranie. Coś na wzór poniżej (pseudo kod):

[INDEX KOMENDY NA LIŚCIE] [KOD OPERACJI] [DANE WEJŚCIOWE] [INTRPOLACJA]

Dla płynnego (interpolowanego) przemieszczenia obiektu na ekranie (komenda nr 1 z kodu powyżej) wyglądałoby tak:

[0][1][{ 100, 100 }, { 160, 100 }][true] //Wykorzystuje starą i nową pozycję.

Dla przemieszczenia obiektu na ekranie poprzez teleportację (komenda nr 4 z kodu powyżej) wyglądałoby z kolei tak:

[0][1][{ 21, 56 }, { 21, 56 }][false] //Przy teleportacji (zmianie nieinterpolowanej) nowa wartość zawsze zastępuje oba parametry

Gdyby natomiast chęć wykonać wszystkie 5 komend kumulatywne, wyglądałoby to tak:

Krok 1 - dodaje do listy komendę przemieszczenia interpolowanego:
[0][1][{ 100, 100 }, { 160, 100 }][true]
Krok 2 - dodaje do listy komendę nieinterpolowanej (skokowej) zmiany saturacji:
[1][11][0.25f, 0.25f][false]
Krok 3 - modyfikuje komendę zmiany saturacji, dodając nową wartość końcową i interpolację:
[1][11][0.25f, 0.80f][true] //interpolacja będzie się więc odbywać w przedziale 0.25 - 0.80, a nie 1.0 - 0.80
Krok 4 - zmienia komendę przemieszczenia na teleportację (czyli de facto Krok 1 zostaje całkowicie zatarty)
[0][1][{ 21, 56 }, { 21, 56 }][false]
Krok 5 - modyfikuje komendę przemieszczenia, dodając nową wartość końcową i interpolację:
[0][1][{ 21, 56 }, { 121, 56 }][true]

Na finalnej liście, przekazywanej rendererowi będzie to więc wyglądać tak:

[0][1][{ 21, 56 }, { 121, 56 }][true]
[1][11][0.25f, 0.80f][true]

No i wreszcie do sedna, czyli czego dotyczy problem. Ano nie do końca mam pomysł na wydajne śledzenie tych komend na liście, czyli rozpoznawanie konieczności dodania nowej lub modyfikacji już istniejącej. Nowe komendy są dodawane dynamiczne, w zależności od zakresu modyfikacji obiektu (a więc nie mają stałych, przewidywalnych indexów), a każdorazowe przelatywanie listy w pętli w poszukiwaniu kodu operacji wydaje się średnim pomysłem. Na ten moment używam std::unordered_map, ale to też nie jest jakieś super wydaje rozwiązanie, bo mapa do specjalnie szybkich klas nie należy.

A może ktoś ma pomysł na jakieś prostsze albo bardziej wydajne rozwiązanie w zakresie przechowywania i przesyłania rendererowi informacji o stanie świata? Jestem otwarty na wszelkie sugestie.

1

W swoim silniku będę potrzebował wykorzystać taki sposób aktualizowania stanu silnika oraz jego renderowania. Jeden wątek aktualizujący logikę, czyli przesuwanie obiektów, wykrywanie kolizji i różne takie niezbyt skomplikowane duperele (po prostu główny update logiki), a kilka(naście) pobocznych wątków miało by zajmować się renderowaniem.

Ogólna zasada byłaby taka, że są dwa główne wątki — nazwijmy je logiki i renderowania. Ten dotyczący renderowania zarządzałby pulą wątków renderujących piksel po pikselu, wypełniając tylny bufor ramki. Aby móc wykorzystać jak najwięcej mocy, te dwa główne wątki muszą działać niezależnie. Ale że niemożliwe jest pogodzenie tych dwóch czynności (aktualizacji świata i renderowania) bez skomplikowanych mechanizmów synchronizacji, pozostają dwa rozwiązania.

Pierwszy to interpolacja ruchu obiektów przez wątek renderera. Jest to dość słabe rozwiązanie, bo o ile zapewnia niezależność renderowania, o tyle duplikuje to co robi wątek logiki, a więc marnuje się moc obliczeniową. Poza tym, przesuwanie obiektów to nie jest jedyne co taki renderer musiałby robić. Co z kolizjami? Renderer też ma sprawdzać kolizje podczas interpolowania? Co z tworzeniem i usuwaniem obiektów? Renderer również musiałby takie sytuacje obsługiwać. Koniec końców robiłby to samo co wątek logiki, więc takie podejście jest bezsensowne.


Drugi sposób jest znacznie lepszy, czyli tworzenie historii zmian. Wątek logiki aktualizuje stan świata, przesuwa, tworzy, usuwa i modyfikuje obiekty, ale wszystkie te zmiany zapisuje w liście zmian, a nie na mapie świata. Wątek renderera zawsze bazuje na niezmiennym stanie świata, więc niczego nie musi przewidywać i interpolować — zajmuje się tylko tym czy zajmować się powinien, czyli renderowaniem. Aby mieć świeże dane, wątek renderera najpierw aplikuje zmiany zapisane w historii, czyści historię i zabiera się za renderowanie. Wątek logiki czeka odpowiednią ilość czasu i jeśli historia jest pusta, to wie, że renderer sobie buduje ramkę, więc zabiera się za aktualizację kolejnej klatki — znów uzupełniając historię. Logika zawsze jest o jedną klatkę do przodu względem renderera — raczej nie wpłynie na responsywność (przy 30ups może).

Brzmi to na dość skomplikowane, ale w rzeczywistości potrzeba zsynchronizować tylko dwa wątki, a jedynym momentem kiedy synchronizacja nie jest możliwa, jest moment aplikowania zmian i czyszczenia listy historii — wtedy wątek logiki musi po prostu czekać. Natomiast wątek renderera też powinien czekać, jeśli historia jest pusta lub jeśli lista historii jest w trakcie uzupełniania. Przy czym renderer powinien nic nie robić i czekać, jeśli lista zmian jest pusta — skoro nie było zmian, to bufor klatki nie będzie wyglądał inaczej, więc renderowanie nowej klatki nie będzie miało żadnego sensu (straci się tylko moc obliczeniową). Czyli podsumowując, wątek renderera powinien klatkę wyrenderować tylko wtedy, kiedy wątek logiki coś doda do listy z historią zmian.

Trzeba też pamiętać o tym, że lista zmian musi uwzględniać wszelkie zmiany, które wpływają na to co widzimy na ekranie. Nie tylko ruch obiektów, ich pojawianie się i znikanie, ale też takie rzeczy jak np. zmiany oświetlenia, pozycji kamery itd.

Jeśli chodzi o listę zmian i jej specyfikę, to odradzam korzystania z jakichś tekstowych instrukcji — tworzenie i przetwarzanie tekstu jest marnowaniem mocy obliczeniowej i — przy tysiącach zmian do zaaplikowania — może negatywnie wpływać na wydajność. Znacznie lepiej by było, gdyby lista z historią zmian trzymała tokeny, gdzie każdy token zawiera kompletne i natywne informacje o potrzebnych do wykonania modyfikacjach. Jeśli dotyczy obiektu, to przyda się typ modyfikacji (przesunięcie, utworzenie, usunięcie obiektu itd.), wskaźnik na obiekt do zmodyfikowania (lub do dodania do mapy) plus opcjonalnie kilka innych informacji, najlepiej liczbowych (liczba lub enum). Podobnie z innymi zmianami. Chodzi o to, aby wątek renderera był w stanie w jak najkrótszym czasie wprowadzić zmiany, wyczyścić listę zmian i oddać ją wątkowi logiki (oraz zacząć renderowanie ramki).


Wg mnie sposób ten pozwoli wycisnąć z CPU ostatnie soki, zachować paralelizm aktualizacji i renderowania, a także maksymalnie uprościć i przyspieszyć wprowadzanie zmian w świecie gry (silniku). Niestety prócz teorii na razie nie mam nic do zaoferowania, ale będę taki mechanizm implementował, więc jeśli już będę miał działający prototyp to się jego kodem podzielę z Tobą. Niestety nie jestem w stanie określić tego kiedy zajmę się tym aspektem, więc niczego konkretnie nie obiecuję. Może jeszcze minąć kilka miesięcy zanim dotrę do tego etapu. ;)

1 użytkowników online, w tym zalogowanych: 0, gości: 1