Jak zaprojektować metody render() i update() w dwóch wątkach?

1

Założenia są takie:

  1. Rander i Update działają w osobnych wątkach tworzonych przy starcie programu i zabijanych przy jego zakończeniu.
  2. Update wykonywany jest ze stałą częstotliwością, np. 30 razy na sekundę (fixed step).
  3. Render działa pod kontrolą v-synca, a więc ilość klatek na sekundę może być zmienna, inna dla każdego użytkownika.
  4. Konsekwencją powyższego jest to, że klatek na sekundę zawsze jest więcej niż odświeżeń symulacji (logiki gry)
  5. Render działa z opóźnieniem jednego cyklu, interpolując wartości pomiędzy dwoma ostatnimi stanami świata (update'ami) przechowywanymi w buforze.

Mając powyższe na uwadze, nie jestem pewien jak powinienem zaprojektować to buforowanie świata. Załóżmy, że mam poruszający się kwadrat, który chcę narysować:

//Bazowy obiekt renderowany. Każdy renderowany obiekt w grze jest jego pochodną.

class Object
{
	virtual void Render() = 0;
};

//Klasa kwadratu

class Square : public Object
{
private:
	int X, Y;
	int Size;
	int Weight;
public:
	void Render() override
	{

	}
};

Z powyższych wartości, pod kątem renderowania znaczenie mają tylko X i Y (pozycja kwadratu) oraz Size (długość boku), przy czym ta ostatnia wartość jest niezmienna, więc nie musi być buforowana. Weight jest niegraficzne (nie ma graficznej reprezentacji w świecie gry), więc dla rendera jest pomijalne.

Jak przechowywać odpowiednie wartości? Czy powinienem po prostu wprowadzić podział na pola buforowane i niebuforowane, rozróżniając je już na etapie projektowania obiektu? Np.:

class Square : public Object
{
private:
	int X[3], Y[3];
	int Size;
	int Weight;
};

Dobrze kombinuję, czy to zły pomysł?

0

AFAIR w niektórych językach (większości?) niesynchronizowany dostęp do obiektu jest UB, zatem pierwszym pytaniem powinno być: o jakim języku mowa?

0

C++, a kwestię dostępu chcę rozwiązać przy pomocy Mutexów. Coś w stylu:

  1. Update zapisuje stan świata do Buffer[0] - Render śpi.
  2. Update zapisuje stan świata do Buffer[1] - Render rysuje klatkę interpolując między Buffer[0] i Buffer[1], blokując do nich dostęp.
  3. Update zapisuje stan świata do Buffer[2] - Render rysuje klatkę interpolując między Buffer[1] i Buffer[2], zwalniając uprzednio dostęp do Buffer[0] i blokując Buffer[2].
  4. Update próbuje zapisać stan świata do Buffer[0] (teoretycznie powinien być już dostępny), a jeżeli nie może (co świadczy o opóźnieniu rendera), zapisuje do Buffer[2] (nadpisując go).
    itd.
0
  1. Czy czytałeś coś na ten temat? Np. takie coś znalazłem: https://vkguide.dev/docs/extra-chapter/multithreading/
  2. Czy robisz coś na tyle wymagającego obliczeniowo, żeby Twoja implementacja wielowątkowości była niezbędna? Warto sobie na tym głowę łamać, zamiast skorzystać z obecnie dostępnych rozbudowanych rozwiązań?
  3. Moim zdaniem podział na rendering/update ma mało sensu. Hardware'owy rendering raczej polega na wysyłaniu rozkazów do karty graficznej, a ona już sobie sama te ciężkie operacje rozbija na wątki. Jeśli nie wysyłasz do karty graficznej nie wiadomo ilu poleceń, to tutaj nakład obliczeń jest pomijalnie mały i nie wymaga zastosowania specjalnego wątku.
1

@Spine:

  1. Czytałem całkiem sporo (tego z linka nie), np. tu: http://nepos.games/nebuchadnezzar/blog/4. O ile jednak znalazłem dużo artykułów opisujących ogólną strukturę takiego game loopa (opisałem w pierwszym poście), to nigdzie nie było konkretnych wskazówek co do implementacji, np. kwestii buforowania.
  2. (i 3). Tutaj wielowątkowość jest podyktowana chęcią oderwania odświeżania logiki gry od wyświetlanych klatek na sekundę. Wcześniej robiłem to w jednym wątku i bawiłem się ze skalowaniem przez DeltaTime, ale to rozwiązanie jest zawodne (choć niezaprzeczalnie eleganckie i wygodne), bo niedeterministyczne i tym samym nieprzewidywalne, zwłaszcza w kwestii fizyki czy kolizji. Wolę więc mieć update co stały interwał (np. 30 razy na sekundę), niezależnie od ilości klatek czy opóźnień na renderze. Przy takim założeniu, Render i Update po prostu muszą działać na osobnych wątkach. Także dlatego, że v-sync na Direct2D, którego na ten moment używam, działa w taki sposób, że rysuje klatkę i po zakończeniu usypia cały wątek. Ten zostaje wybudzony wraz z kolejnym odświeżeniem monitora (działa to pod kontrolą GPU, które jak rozumiem, wysyła do wątku stosowny Notify Event).
1
Crow napisał(a):
  1. (i 3). Tutaj wielowątkowość jest podyktowana chęcią oderwania odświeżania logiki gry od wyświetlanych klatek na sekundę.

Do tego jest przewidziana Delta, nie wątki. Jeśli zamiast delty używasz wątków, to bardzo mocno komplikujesz sobie robotę i zamiast rozwiązać problem, tworzysz masę nowych, znacznie trudniejszych do rozwiązania.

Wcześniej robiłem to w jednym wątku i bawiłem się ze skalowaniem przez DeltaTime, ale to rozwiązanie jest zawodne (choć niezaprzeczalnie eleganckie i wygodne), bo niedeterministyczne i tym samym nieprzewidywalne, zwłaszcza w kwestii fizyki czy kolizji.

Delta nie jest zawodna, jeśli jest poprawnie wykorzystywana. Bez problemu można użyć dowolnego framerate'u, dowolnego odświeżania ekranu i dowolnej technologii zapobiegającej rozrywaniu ekranu. Poprawność obliczeń zapewnia podstawowa delta, a kompensację lagów przeprowadza się dzieląc dużą deltę na kilka części i kilka razy aktualizując logikę.

Kiedyś już podawałem Ci pseudokod ilustrujący to w jaki sposób wykorzystać deltę przy zmiennym klatkażu oraz jak uodpornić logikę na lagi. Ale chyba pisanie o tym nie ma sensu, bo jak widać niczego nie wyjaśnia i lepiej by było po prostu zrobić mały projekt implementujący taką mechanikę i go dać do zabawy. Niczego nie obiecuję, ale jak znajdę czas to taki zrobię.

Natomiast jeśli chodzi o opóźnienia renderowania, to mam wrażenie, że jesteś zafiksowany na tym punkcie. Nie da się zrobić gry, której klatki będą wyświetlane bez opóźnienia — to niemożliwe. Zawsze najpierw opóźnienie powoduje wyrenderowanie klatki (które trwa trochę milisekund), następnie kolejne opóźnienie powoduje oczekiwanie na wysłanie obrazu do monitora, a na koniec kolejne opóźnienie, związane ze zmianą kolorów fizycznych pikseli w matrycy monitora.

Jednak nieważne czy użyjesz V-Synca czy nie, obraz i tak zostanie wyświetlony z opóźnieniem, a jedyną jego zaletą jest brak rozrywania ekranu i ew. duplikacja wyświetlanych klatek przy podwójnym/potrójnym buforowaniu.


Jeśli miałbym coś doradzić to zostaw wątki i zrób sobie malutką grę, w której pobaw się deltą i zrób aktualizację logiki w taki sposób, aby odświeżanie ekranu, framerate gry oraz lagi nie miały żadnego znaczenia. Do testowania lagów możesz wykorzysać ”wbudowany ficzer” — łapiąc myszą za górną belkę okna, główny wątek jest zamrażany, czym możesz testować lagi różnej długości.

0

@furious programming: Zgadzam się, że z Deltą można całkiem sensownie pracować i przy odrobinie pomysłowości obejść różne problemy (np. zrobić kilka mniejszych update'ów, gdy za bardzo urośnie i dzięki temu zabezpieczyć się przed np. niewyłapaniem jakiejś kolizji). Co nie zmienia jednak faktu, że jest taka droga na skróty, z której współczesne gry w większości zrezygnowały. Opowiadał o tym nawet w jednym wywiadzie John Carmack (ten od id Software), który też zastosował Deltę pisząc pierwszego Quak'e, ale potem tego pożałował i już kolejne jego silniki (od tego napędzającego Quake'a 2 poczynając) korzystały z symulacji deterministycznej. Wiem, że jest to trudniejsze w implementacji (bo wielowątkowość), ale ma swoje korzyści. Przede wszystkim pozwala na przewidywanie przyszłych cykli (np. wartości, które nie mogą być modyfikowane przez gracza - inputy nie mają na nie wpływu), a to z kolei pozwala na myślenie w "cyklach" (tickach), a nie w czasie rzeczywistym. Delta pozwala ci przewidywać np. gdzie obiekt będzie za 1 sekundę, ale już nie to, gdzie będzie przy następnym odświeżeniu (w następnym cyklu), bo Delta jest nieprzewidywalna.

A co do opóźnienia klatek, to nie, akurat tutaj zupełnie nie o to chodzi (nawet nie brałem tego pod uwagę). Moim celem jest tylko i wyłącznie determinizm.

Malutkie gierki z Deltą też już robiłem (Teris, Pac-Man, nawet bieda-klon Wolfensteina 3D) ale teraz, przed przejściem do większego projektu, chciałbym unowocześnić technologię :).

0
Crow napisał(a):

@furious programming: Zgadzam się, że z Deltą można całkiem sensownie pracować i przy odrobinie pomysłowości obejść różne problemy (np. zrobić kilka mniejszych update'ów, gdy za bardzo urośnie i dzięki temu zabezpieczyć się przed np. niewyłapaniem jakiejś kolizji).

Wyłapywanie kolizji między dwoma ruchomymi obiektami może być problematyczne.

Ale kolizję ruchomego obiektu z np. terenem można wykryć przy Delcie dowolnej wielkości. Zanim obiekt wykona przesunięcie, rzutujemy jego kształt kolizji w danym kierunku.

3
Crow napisał(a):

Co nie zmienia jednak faktu, że jest taka droga na skróty, z której współczesne gry w większości zrezygnowały.

To nieprawda, a nawet dwie nieprawdy. Po pierwsze, delta to jedyny istniejący czynnik umożliwiający określenie ile czasu upłynęło od poprzedniej aktualizacji, wyrażony w postaci liczby, na podstawie której można poprawnie przeprowadzić interpolacje. Nie ma innego sposobu na współczesnych komputerach i konsolach. Poza tym nie da się manipulować czasem (bez utraty zadanego klatkażu) inaczej niż w oparciu o deltę — np. w celu spowolnienia gry (efekt slow motion).

Po drugie, delta używana jest w każdej współczesnej grze, od mniej wymagających po AAA — jedyne co rozróżnia projekty mniej skomplikowane od tych najbardziej skomplikowanych to sposób obsługi logiki i renderowania obrazu. Mniej wymagające gry nie potrzebują wielowątkowości, bardzo duże gry potrzebują, bo generowanie klatki w sposób liniowy tak by obniżyło framerate, że obraz by ”żabkował”.

Opowiadał o tym nawet w jednym wywiadzie John Carmack (ten od id Software), który też zastosował Deltę pisząc pierwszego Quak'e, ale potem tego pożałował i już kolejne jego silniki (od tego napędzającego Quake'a 2 poczynając) korzystały z symulacji deterministycznej.

Podajesz tezę (wywód), ale nie podajesz argumentów — to dowód anegdotyczny.

Wiem, że jest to trudniejsze w implementacji (bo wielowątkowość), ale ma swoje korzyści.

Tak, jednak rozwiązanie dobiera się na podstawie wymagań projektowych, a nie pogłosek z Internetu. Skorzystaj z wielowątkowości tylko jeśli Twój silnik nie jest w stanie w sposób liniowy generować 30 klatek na sekundę (bo tyle sobie założyłeś). Jeśli jest w stanie to pisz program dalej, zamiast poświęcać czas na coś, czego i tak żaden gracz nie zauważy, a co opóźni ”premierę” gry.

Przede wszystkim pozwala na przewidywanie przyszłych cykli (np. wartości, które nie mogą być modyfikowane przez gracza - inputy nie mają na nie wpływu), a to z kolei pozwala na myślenie w "cyklach" (tickach), a nie w czasie rzeczywistym.

O jakim przewidywaniu mowa? Silnik ma zaktualizować logikę, aby ta nie była w tyle, czyli aby była zgodna z chwilą obecną. I po to dostajesz do tego celu deltę, aby popchnąć logikę do przodu i wyrównać ją z czasem obecnym. To nie ma nic wspólnego z przyszłością, bo ta nigdy nie jest znana.

Delta pozwala ci przewidywać np. gdzie obiekt będzie za 1 sekundę, ale już nie to, gdzie będzie przy następnym odświeżeniu (w następnym cyklu), bo Delta jest nieprzewidywalna.

Absolutnie nie. Delta jest liczbową reprezentacją czasu który upłynął od poprzedniej aktualizacji, a więc dotyczy wyłącznie przeszłości, w celu pchnięcia logiki do teraźniejszości — do tego została wymyślona. A skoro dotyczy odcinka czasu od przeszłości do teraźniejszości, to nie ma możliwości wykorzystania jej do przewidywania przyszłości.

Dopóki tego nie zrozumiesz, będziesz miał wykrzywiony obraz działania silników gier i nie będziesz w stanie racjonalnie podejść do tego tematu. To tak jakbyś podczas wprowadzania tekstu przez użytkownika, chciał na podstawie komunikatów o wciśniętych klawiszach przewidzieć co użytkownik wpisze za chwilę. Te komunikaty dotyczą wyłącznie przeszłości, nie da się na ich podstawie określić przyszłości, bez interpolacji czy zgadywania.

A co do opóźnienia klatek, to nie, akurat tutaj zupełnie nie o to chodzi (nawet nie brałem tego pod uwagę). Moim celem jest tylko i wyłącznie determinizm.

Determinizm skończył się w momencie gdy zaczęto produkować komputery z procesorami o różnej prędkości taktowania. Od tamtej chwili determinizm został całkowicie zastąpiony interpolacjami i tak już na zawsze zostanie.


Spine napisał(a):

Wyłapywanie kolizji między dwoma ruchomymi obiektami może być problematyczne.

Słowo klucz — może. I dlatego deltę dzieli się na małe kawałki w przypadku lagów. Poprawność wykrywania kolizji spoczywa głównie na obliczeniach, a delta — jeśli zbyt duża — dzielona jest na mniejsze części, aby przy dużysz prędkościach wszystko działało jak należy.

Jeśli obiekty przenikają przez siebie zamiast ze sobą kolidować, to winne są albo obliczenia, albo brana jest pod uwagę zbyt duża delta. W obu tych przypadkach winny jest programista, a nie delta sama z siebie.

1

@furious programming: Mam wrażenie, że przespałeś ostatnie 20 lat rozwoju game-devu... Ja przez ostatnie kilka miesięcy naczytałem się o tym sporo i wiem, że gry już od wielu lat nie używają delty w sposób, o którym piszesz. Dzisiaj dominującym podejściem jest projektowanie odświeżania symulacji w sposób deterministyczny, która sprowadza się do aktualizowania zmiennych o konkretne wartości, co konkretny interwał. Delty, jako swego rodzaju mnożnika symbolizującego upływ czasu, się w tych zastosowaniach nie używa. Upływ czasu rzeczywistego nadal się oczywiście monitoruje, ale nie do obliczeń w zakresie logiki, tylko żeby np. móc reagować na opóźnienia symulacji (frame skipping).

Do czego zatem współcześnie używa się delty? Do renderowania, bo ono, w przeciwieństwie do odświeżania logiki, działa w czasie rzeczywistym, więc interpolując lub ekstrapolując między stanami logiki, trzeba brać pod uwagę upływ czasu rzeczywistego.

Wyobraź sobie sytuację, że masz 2 obiekty, które poruszają się z różną prędkość, zgodnie z zadanym wektorem (każdy z własnym). Obiekty znajdują się na kursie kolizyjnym i w którymś momencie się zderzą. Czy przy odświeżaniu logiki opartym o deltę jesteś w stanie przewidzieć kiedy dokładnie nastąpi ich kolizja? Nie, możesz to tylko szacować, a wynik za każdym razem będzie inny. Natomiast przy podejściu deterministycznym jesteś w stanie podać dokładny moment i to od razu, jeżeli tylko znasz dane wejściowe (niezależnie od mocy sprzętu), a wynik za każdym razem będzie taki sam.

Dlatego właśnie nikt przy zdrowych zmysłach nie odświeża dziś fizyki gry przy pomocy delty, tylko stosuje stałe przesunięcia (fixed step).

Tutaj jest to dobrze wyjaśnione:
https://gafferongames.com/post/fix_your_timestep/
https://dewitters.com/dewitters-gameloop/
https://gameprogrammingpatterns.com/game-loop.html

Ale wróćmy proszę do tematu, bo nie tego dotyczyło moje pytanie.

2
Crow napisał(a):

@furious programming: Mam wrażenie, że przespałeś ostatnie 20 lat rozwoju game-devu... Ja przez ostatnie kilka miesięcy naczytałem się o tym sporo i wiem, że gry już od wielu lat nie używają delty w sposób, o którym piszesz. Dzisiaj dominującym podejściem jest projektowanie odświeżania symulacji w sposób deterministyczny, która sprowadza się do aktualizowania zmiennych o konkretne wartości, co konkretny interwał. Delty, jako swego rodzaju mnożnika symbolizującego upływ czasu, się tu nie używa.

[…]

Dlatego właśnie nikt przy zdrowych zmysłach nie odświeża dziś fizyki przy pomocy delty, stosując właśnie stałe przesunięcia (fixed step). Tutaj jest to dobrze wyjaśnione:
https://gafferongames.com/post/fix_your_timestep/

Spojrzałem i wszędzie widzę deltę — w każdym przykładzie. Wartość delty — czy to stała czy zmienna — nie stanowi o prawdziwości jej użycia, tak samo jak to czy nazwiemy ją deltą, tickami czy jeszcze czymś innym. Poza tym to że w kilku artykułach przedstawia się inne podejście, nie oznacza, że cały świat z niego korzysta i wykorzystywany jest w każdej grze, od momentu pojawienia się danego artykułu. Tym bardziej, że „fixed time stamp” to praktycznie to samo co dzielenie delty na mniejsze kawałki, o czym pisałem wcześniej, a renderowanie klatek bez aktualizacji logiki (w trakcie laga) jest po prostu marnowaniem mocy obliczeniowej (po co renderować po raz kolejny to samo?).

Popatrz na to z innej strony — masz możliwość wykonania silnika na milion sposobów, możesz korzystać z czego chcesz i jak chcesz. Jednak zanim wybierzesz jakieś rozwiązanie, najpierw należy określić jak silnik ma działać, aby wykonywał swoją robotę poprawnie i wydajnie. To specyfika silnika jest bodźcem do dokonania wyboru, nie artykuły opisujące najróżniejsze sposoby.

Pisałem niedawno swojego Tetrisa. Mogłem pójść w kierunku, w którym sam zmierzasz, czyli skorzystać z wątków, V-Synca itd. Ale wiedząc w jaki sposób ma ten silnik działać, sam strzeliłbym sobie w stopę, marnując czas na implementację czegoś, co nie da absolutnie żadnych plusów — jedynie bardzo mocno skomplikuje kod (i zwiększy prawdopodobieństwo zaistnienia błędów) i wymusi poświęcenie masy czasu. Mój silnik miał działać tak jak założyłem, te założenia spełniał stały framerate i brak delty, dlatego z niego skorzystałem — najprostszy sposób spełniający wymagania i najszybszy w implementacji.

To samo Tobie sugeruję — określ wymagania silnika i dobierz najprostsze rozwiązanie spełniające wymagania. Jeśli bardzo chcesz skorzystać ze sposobu gdzieś opisanego, to śmiało — Twój projekt, robisz co chcesz i jak chcesz.



Jeśli chodzi o pierwotne pytanie — jeżeli w jakikolwiek sposób przeprowadzasz różne czynności niezależnie od siebie (np. aktualizację logiki i renderowanie), to musisz te kilka czynności synchronizować. I to jest bardzo skomplikowane.

Pierwsze co przychodzi na myśl to zablokowanie odczytu rendererowi, na czas aktualizowania logiki. Wtedy modyfikuje się dane „świata”, a renderer (jeśli musi) czeka aż ten proces się zakończy. Nie może być tak, że jeden wątek modyfikuje dane, a renderer maluje klatkę na ich podstawie — się to wykrzaczy (UB). Teoretycznie można by zakładać lock na dane potrzebne rendererowi tylko podczas ich modyfikacji, ale to jeszcze bardziej komplikuje całość.

Dwie podpowiedzi. Po pierwsze, nie używaj mutekstów, bo to armaty na muchę — te działają w obrębie całego systemu, a Ty potrzebujesz synchronizować wątki tego samego procesu. Po drugie, obiekty (jak wspomniany kwadracik) nie powinny malować same siebie. Od renderowania jest renderer, który bierze dane obiektu (współrzędne, rozmiar, teksturę) i renderuje ten obiekt na jakimś buforze.

0

@furious programming:

  1. Przeanalizuj proszę ten kod i zobacz do czego ta delta jest używana (interpolacja renderu). Tutaj moja implementacja przykładu z artykułu (tak jak tam, działająca jednowątkowo):
void Loop()
{
	const int UPS = 3; //Ilość odświeżeń logiki na sekundę
	const int FPS = 5; //Ilość klatek na sekundę
	
	std::chrono::nanoseconds UT = std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::seconds(1)) / UPS; //Interwał czasowy 
//dla odświeżania (w nanosekundach)
	std::chrono::nanoseconds FT = std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::seconds(1)) / FPS; //Interwał czasowy 
//dla klatek (w nanosekundach)
	std::chrono::nanoseconds TotalU = std::chrono::nanoseconds(0); //Licznik czasu dla odświeżania
	std::chrono::nanoseconds TotalF = std::chrono::nanoseconds(0); //Licznik czasu dla klatek

	std::chrono::high_resolution_clock::time_point Before, After;
	std::chrono::nanoseconds Delta;
	double Alpha = 0.0f;

	Before = std::chrono::high_resolution_clock::now();
	int X[2] = { 10, 10 }; //Przykładowa wartość z buforem przechowującym stan obecny i poprzedni
	const int Velocity = 50; //Wartość dodawana do X przy każdym odświeżeniu logiki
	float RenderX; //Wartość gotowa do "wyrenderowania"


	while (true)
	{
		//Liczymy deltę (rozumianą jako czas od ostatniego update'u)

		After = std::chrono::high_resolution_clock::now();
		Delta = (After - Before);
		Before = After;

		//Kumulujemy czas na obu licznikach

		TotalU += Delta;
		TotalF += Delta;

		//Jeżeli czasu nazbierało się tyle, że trzeba zrobić kolejny update... to robimy kolejny update (a jeżeli jest opóźnienie, to więcej
		//niż 1)

		while (TotalU >= UT)
		{
			X[0] = X[1];
			X[1] += Velocity; //Aktualizujemy X, żadnej delty, przyrost jest STAŁY przy każdym odświeżeniu logiki, niezależnie od upływu czasu!
			std::cout << "Update: " << X[0] << " --> " << X[1] << std::endl;
			TotalU -= UT; //Odejmujemy czas od licznika, a resztkę (opóźnienie, bo to nigdy nie będzie idealnie dokładne, wykorzystujemy do 
			//interpolowania przy renderowaniu)
		}

		Alpha = (double)TotalU.count() / (double)UT.count(); //Tutaj wykorzystujemy opóźnienie

		//Sprawdzamy czy nadszedł czas na narysowanie kolejnej klatki

		if (TotalF >= FT)
		{
			RenderX = (float)((1.0f - Alpha) * X[0]) + (float)(Alpha * X[1]); //Wykorzystujemy Alphę do obliczenia wartości, 
			//która ma zostać "wyrenderowana"
			std::cout << "Rendering: " << RenderX << " Alpha = " << Alpha << std::endl;
			TotalF -= FT; //Odejmujemy czas od licznika
		}
	}
}
  1. Mam główne założenie silnika, jest nim determinizm.
  2. Kwestie odpowiedniej synchronizacji to jeszcze inna zagwozdka. Głowię się nad nią już od jakiegoś czasu, ale doszedłem do wniosku, że najpierw muszę przyjąć jakiś model buforowania danych, a potem będę się zastanawiać jak je zabezpieczyć. Przechowywać te dane w polach obiektu? Tworzyć kopie całych obiektów?
  3. Doszedłem do wniosku, że sposób renderowania musi być wbudowany w obiekt, bo przecież różne byty mogą używać różnych rutyn renderowania. Inaczej trzeba będzie podejść do płaskiego obrazka, a inaczej do bryły 3D. To jeszcze nie jest ostateczne, póki co, taki mam pomysł.
2
Crow napisał(a):
  1. Przeanalizuj proszę ten kod i zobacz do czego ta delta jest używana. Tutaj moja implementacja prototypu tego mechanizmu (tak jak w przykładach, działająca jednowątkowo):

Jestem zaziębiony, głowa mi pęka, ale zapoznałem się z tym kodem i ogólnie kumam co masz na myśli. Jeśli dobrze rozumiem (choć C++ to nie jest moja mocna strona), to efekt działania tego kodu jest dokładnie taki, jaki opisywałem, jeśli chodzi o kompensowanie laga dzieleniem delty na mniejsze części i aktualizowaniem logiki kilka razy.

Różnica jednak polega na tym, że Twój silnik zawsze wykonuje logikę dwa razy na każdą klatkę — raz aby zaktualizować dane o stałe wartości, a potem drugi raz, na potrzeby renderowania. Z tego też wnioskuję, że wydajność takiego tworu jest średnio dwa razy mniejsza niż podejścia przeze mnie proponowanego. W skrócie — podejście absolutnie przekombinowane i nieefektywne.

Poza tym nie dasz razy w tym silniku zaimplementować efektu slow motion, bo silnik nie potrafi manipulować czasem (nie potrafi np. przesuwać obiektów o mniejsze kawałki, bo używa stałych wartości prędkości). A to spowoduje, że im bardziej spowolnisz czas gry, tym bardziej logika ucieknie w przyszłość względem renderera.

  1. Mam główne założenie silnika, jest nim determinizm.

Silnik ma realizować przede wszystkim założenia dotyczące specyfiki gry, a nie za wszelką cenę implementować dany sposób ich realizacji. Masz dobrać sposób do wymagań, a Ty wybierasz wymagania w taki sposób, aby je dopasować do determinizmu. Dla mnie to ogromny błąd.

  1. Kwestie odpowiedniej synchronizacji to jeszcze inna zagwozdka. Głowię się nad nią już od jakiegoś czasu, ale doszedłem do wniosku, że najpierw muszę przyjąć jakiś model buforowania danych, a potem będę się zastanawiać jak je zabezpieczyć. Przechowywać te dane w polach obiektu? Tworzyć kopie całych obiektów?

Czyli nie dość, że dwa razy wykonujesz logikę na daną klatkę, to jeszcze chcesz dwa razy więcej pamięci zużywać? Pytanie zasadnicze — do czego jest Ci potrzebe prowadzenie historii zmian danych obiektów?

  1. Doszedłem do wniosku, że sposób renderowania musi być wbudowany w obiekt, bo przecież różne byty mogą używać różnych rutyn renderowania. Inaczej trzeba będzie podejść do płaskiego obrazka, a inaczej do bryły 3D. To jeszcze nie jest ostateczne, póki co, taki mam pomysł.

Wg mnie jest to podejście złe, w dodatku niezgodne z przyjętymi standardami wytwarzania kodu — łamiesz zasadę pojedynczej odpowiedzialności, nadając martwemu obiektowi cech, których posiadać nie powinien (obiekt nie powinien posiadać dostępu do bufora klatki, a także nie powinien przeprowadzać renderowania, bo nie do tego służy).

To renderer powinien być odpowiedzialny za generowanie klatki — i tylko on. Jeśli korzystasz ze świata trójwymiarowego, w którym mogą się znajdować bryły oraz sprajty, to renderer powinien być w stanie namalować jedno i drugie, używając tych samych metod. Tym bardziej, że z punktu widzenia silnika, sprajt to nic innego jak płaska i oteksturowana bryła, mająca dwie strony.

Można to w uproszczeniu ująć tak, że sprajt to jedna powierzchnia, a obiekt trójwymiarowy to zestaw powierzchni. A skoro jedno i drugie to piwierzchnie, to nie ma powodu, aby rozróżniać wymiarowość obiektów i dzielić renderowanie na ich podstawie.

0

@furious programming:

Różnica jednak polega na tym, że Twój silnik zawsze wykonuje logikę dwa razy na każdą klatkę — raz aby zaktualizować dane o stałe wartości, a potem drugi raz, > na potrzeby renderowania

No właśnie nie do końca :).

Schemat wygląda trochę inaczej (nie w tym przykładzie, w silniku i z użyciem wątków, przykład jest jednowątkowy i przez to bezsensowny). Przyjmijmy, że jest 30 odświeżeń logiki na sekundę i 60 klatek na sekundę (30/60). To oznacza, że na każde jedno odświeżenie zostaną narysowane 2 klatki. No ale to bez sensu, bo po co rysować 2 identyczne klatki, skoro stan logiki się nie zmienił? No i tu wchodzi interpolacja (lub ekstrapolacja, ale to zbyt kosztowne obliczeniowo). Robisz więc tak, że Render jest zawsze o 1 cykl do tyłu, przez co zawsze ma do dyspozycji nie 1 tylko 2 stany świata, a więc każda klatka może powstać na bazie obu (z użyciem interpolacji i delty).

  1. Update zapisuje stan logiki do B1 | Render jeszcze nic nie robi.
  2. Update zapisuje nowy stan logiki do B2 | Render w tym momencie podejmuje pracę i rysuje pierwszą klatkę na bazie B1 i B2.
  3. Update nic nie robi (czas jeszcze nie nadszedł) | Render rysuje kolejną klatkę na bazie B1 i B2, ale trochę inną, bo upłynęło trochę czasu (właśnie tutaj znaczenie ma delta).
  4. Update zapisuje nowy stan logiki do B3 | Render (niezależnie śledząc upływ czasu) też już wie, że pora na nowy update, więc przeskakuje na B3, a zwalnia B1 i rysuje klatkę pomiędzy B2 i B3.
    itd.

Teraz bardziej zrozumiałe? Wszystkie obliczenia logiki wykonywane są tylko raz. I tak, wiem, Render też musi obliczyć interpolowaną wartość, ale to akurat nie jest bardzo złożone obliczeniowo, cały "heavy lifting" robi Update. Poza tym taki jest obecnie standard (używany np. w Unreal Engine, Unity i CryEngine). Ostatni silnik, o którym mi wiadomo, że odświeża logikę na delcie, jest Creation Engine od Bethesdy (stąd były te śmieszne bugi w Falloucie 76, gdy ludzie odblokowali sobie FPSy z narzuconych 60 ;d).

Jedyną niedogodnością jest konieczność buforowania niektórych danych i właśnie nie wiem jaki model tutaj zastosować.

Wracaj do zdrowia :).

1
Crow napisał(a):

Przyjmijmy, że jest 30 odświeżeń logiki na sekundę i 60 klatek na sekundę (30/60). To oznacza, że na każde jedno odświeżenie zostaną narysowane 2 klatki. No ale to bez sensu, bo po co rysować 2 identyczne klatki, skoro stan logiki się nie zmienił?

Jeśli założeniem mojego silnika miałoby być 30fps, to bym po prostu renderował tylko 30 klatek na sekundę w jakiś klasyczny sposób, nie bawiąc się w wątki. Chyba że liniowy proces aktualizacji logiki + renderowanie klatki trwałby sumarycznie więcej niż ~32ms, to wtedy wątki są jedynym rozwiązaniem pozwalającym utrzymać zadany framerate.

No i tu wchodzi interpolacja (lub ekstrapolacja, ale to zbyt kosztowne obliczeniowo). Robisz więc tak, że Render jest zawsze o 1 cykl do tyłu, przez co zawsze ma do dyspozycji nie 1 tylko 2 stany świata, a więc każda klatka może powstać na bazie obu (z użyciem interpolacji i delty).

No i tu wchodzi proces kosztowny pamięciowo. Teraz pytanie — czy szybciej jest wykonywać tradycyjną interpolację podczas aktualizowania logiki, czy trzymać dwa stany świata i wykonywać interpolację po stronie renderera? Jedno i drugie potrzebuje mocy obliczeniowej, a bez testów można jedynie zgadywać.

Poza tym nie rozumiem gdzie jest zysk. Aktualizacja logiki musi być wykonana, co zajmuje trochę czasu. Renderowanie też zabiera czas i nie może być przeprowadzone (a na pewno nie w całości) w trakcie aktualizacji danych obiektów, bo na obrazie klatki — w najlepszym wypadku — wyjdą dziwactwa, niezgodne z rzeczywistością.

  1. Update zapisuje stan logiki do B1 | Render jeszcze nic nie robi.
  2. Update zapisuje nowy stan logiki do B2 | Render w tym momencie podejmuje pracę i rysuje pierwszą klatkę na bazie B1 i B2.
  3. Update nic nie robi (czas jeszcze nie nadszedł) | Render rysuje kolejną klatkę na bazie B1 i B2, ale trochę inną, bo upłynęło trochę czasu (właśnie tutaj znaczenie ma delta).
  4. Update zapisuje nowy stan logiki do B3 | Render (niezależnie śledząc upływ czasu) też już wie, że pora na nowy update, więc przeskakuje na B3, a zwalnia B1 i rysuje klatkę pomiędzy B2 i B3.

Wygląda mi to na klasyczne potrójne buforowanie, tyle że buforowanie nie renderowania, a aktualizacji logiki, co wydaje mi się bardzo dziwne. Gdzie jest zysk? A jeśli gdzieś jest, to na czym on polega?

Z tego co widzę, Unity nie ma wydzielonego renderowania do osobnego wątku:

screenshot-20211113235705.png
https://docs.unity3d.com/Manual/ExecutionOrder.html

Renderowanie — jeśli dobrze czytam ten schemat — wykonywane jest po wielowątkowo aktualizowanej logice. Czyli wychodzi na to, że wątki są użyte jedynie do aktualiazcji maszyny/logiki, a następnie w sposób liniowy renderowana jest klatka. No a na koniec leci jakiś Pause, zdefiniowany przez użytkownika.

Czyli aktualizowanie logiki nie potrzebuje synchronizacji, bo renderer zawsze czeka, aż ta się skończy.

0

Zysk jest taki, że:

  1. Logika jest odświeżana deterministycznie (co jest w zasadzie jedyną akceptowalną opcją w silnikach fizycznych).
  2. Update i Render mają własne wątki, przez co nie muszą dzielić czasu klatki.
  3. Oba mogą działać asynchronicznie (tak jak w przykładzie, Update może następować 30 razy na sekundę i wtedy ma dla siebie pełne 33 milisekundy), a Render 60 albo 1500 razy na sekundę i cały czas ma dla siebie.

Przy robieniu tego w jednym wątku i z deltą, oba muszą działać synchronicznie, muszą się dzielić czasem i nie można używać v-sync'a. Poza tym całość jest niedeterministyczna.

0
Crow napisał(a):
  1. Logika jest odświeżana deterministycznie (co jest w zasadzie jedyną akceptowalną opcją w silnikach fizycznych).

Tyle że to żaden zysk. Bo nawet jeśli wziąć pod uwagę to, że aktualizacja pozycji obiektów o z góry znaną wartość jest szybsza od aktualizacji o zadaną wartość i dodatkowo pomnożoną o deltę, to nijak ma się to do zastosowań praktycznych, czyli gdy wartość przesunięcia obiektu jest obliczana (bo obiekt rozpędza się lub zwalnia). Tym bardziej, że chcąc zaimplementować jakikolwiek mechanizm manipulowania czasem (przyspieszanie upływu czasu lub slow motion), prędkość musi być dodatkowo pomnożona przez mnożnik upływu czasu (czyli przez liczbę z zakresu (0,n>).

Bo sam powiedz — jak w takim silniku zaimplementować slow motion?

Dla mnie ten argument nie jest nawet argumentem — to robienie czegoś na sztukę, bez jakiegokolwiek sensu. „Chcę mieć determinizm to go mam”, a kod zamiast maksymalnie uproszczony, to dodatkowo skomplikowany (i dużo większe zużycie pamięci).

  1. Update i Render mają własne wątki, przez co nie muszą dzielić czasu klatki.

Muszą, bo renderer nie może generować klatki, gdy dane obiektów są przez logikę modyfikowane. To powoduje dodatkowe skomplikowanie, związane z zakładaniem blokad na modyfikowane zasoby.

  1. Oba mogą działać asynchronicznie (tak jak w przykładzie, Update może następować 30 razy na sekundę i wtedy każdy ma dla siebie pełne 33 milisekundy), a Render 60 albo 1500 razy na sekundę i cały czas ma dla siebie.

To też jest problem, bo nie dość że wymaga zsynchronizowania obu mechanizmów (aby nie operować na właśnie modyfikowanych danych), to w dodatku wymaga takiej synchronizacji, której efektem będzie przedstawienie maksymalnie aktualnego stanu świata, bez dodatkowych opóźnień czy pokazywania przyszłości.

Trudno mi cokolwiek wywnioskować, bez implementacji takiego silnika. Teoria to jedno, a w praktyce może wyjść zupełnie inaczej. No ale nie mam testowego projektu. Widzę pewne zalety tego rozwiązania, jednak ja to widzę tak, że potrzeba implementacji silnika w taki sposób będzie potrzebna wyłącznie w przypadku bardzo wymagających gier (w których bardziej klasyczne podejście nie da wysokiej wydajności). I to też tylko wtedy, gdy faktycznie „podwójne” obliczenia związane z logiką faktycznie są szybsze od tradycyjnego podejścia.

Nie rozumiem też zalety renderowania większej liczby klatek niż aktualizacji logiki, powiązanej ze wspomnianym interpolowaniem po stronie renderera. Dwa razy robi się to samo, zamiast raz, nawet gdy to nie jest konieczne.

Jedynym przypadkiem, w którym zastosowałbym takie podejście, to przypadek, w którym update logiki jest wąskim gardłem — aktualizacja danych trwa tak długo, że musi być wykonywana rzadko, a żeby utrzymać wysoki framerate, renderer musi interpolować stan silnika. No ale znów — czasochłonne aktualizowanie logiki to nie jest domena większości gier.

Przy robieniu tego w jednym wątku i z deltą oba muszą działać synchronicznie, muszą się dzielić czasem i nie można używać v-sync'a. Poza tym całość jest niedeterministyczna.

Brzmi to tak, jakby determinizm był dla Ciebie religią, a nie rozwiązaniem faktycznie istniejącego problemu. ;)

0

@furious programming: Prototyp będzie (i nawet go tu wrzucę), tylko muszę najpierw wykoncypować model bufora dla świata :).

Wydaje mi się, że skoro nie każdy obiekt musi być renderowany (bo może być niegraficzny albo znajdować się poza kamerą), to najlepiej nie zapisywać stanu całego świata, tylko poszczególne pola, poszczególnych obiektów (tylko te przydatne dla rendera). Tyle, że taki model utrudni projektowanie obiektów, bo będzie wymagać implementacji bufora do każdego pola obiektu z osobna. A może zamiast tego zrobić to w formie jakiejś kolejki? Typu ThingsToRender i wrzucać tam wszystkie dane, na bazie których render będzie w stanie narysować obiekt? Kolejka sama w sobie jest trochę wolna, ale przynajmniej daje elastyczność.

0

Jeśli o mnie chodzi, to implementacja takiego modelu silnika nie ma absolutnie żadnego sensu, jeśli ma on być bazą do tworzenia jakiejś ”normalnej” gry. Za dużo tu kombinacji w celu osiągnięcia relatywnie prostych efektów. Ale rób jak chcesz — może wyjdzie z tego coś fajnego, może nauczysz się wielu nowych rzeczy. ;)

Crow napisał(a):

Wydaje mi się, że skoro nie każdy obiekt musi być renderowany (bo może być niegraficzny albo znajdować się poza kamerą), to najlepiej nie zapisywać stanu całego świata, tylko poszczególne pola, poszczególnych obiektów (tylko te przydatne dla rendera). Tyle, że taki model utrudni projektowanie obiektów, bo będzie wymagać implementacji bufora do każdego pola obiektu z osobna.

Wiesz, jak robiłem Richtrisa, to potrzebowałem przechowywać historię zmian danych wszystkich widocznych w wizualizacji obiektów, czyli m.in. zawartości pola gry, wszystkich liczników i mierników, stanu przycisków kontrolera itd. Poprzedni stan obiektu był potrzebny do określenia tego, czy jego stan zmienił się po aktualizacji (jeśli poprzednia wartość jest taka sama jak nowa, to nie zmienił się). Na podstawie informacji o tym czy stan obiektu zmienił się, renderer przemalowywał dany obiekt w buforze lub nie.

Wynikiem takiego działania był niesamowite przyspieszenie procesu renderowania klatek. W Tetrisie, większość danych widocznych na ekranie zmienia się sporadycznie (zawartość pola gry zmienia się co kilka klatek, a liczników co kilkadziesiąt/kilkaset), więc nie ma sensu renderować każdej klatki od nowa. Dlatego tylny bufor (w ramach jednej sceny) nigdy nie jest czyszczony — jeśli któryś licznik zmienił stan, to jest zamalowywany w buforze kawałkiem tła, a następnie renderowana jest nowa wartość. Natomiast bufor wyrzucany jest na ekran w każdej klatce.

Mógłbym proces renderowania zoptymalizować jeszcze bardziej. Wystarczyłoby sprawdzić, czy cokolwiek co jest widoczne na ekranie zmieniło stan i jeśli nie, to w ogóle pominąć zarówno aktualizowanie tylnego bufora, jak i aktualizację płótna okna. Ale to wielkiego zysku nie da (nawet zważywszy na to, że renderowanie mam czysto software'owe), więc tego nie implementowałem, coby nie komplikować kodu logiki.

Dlaczego o tym wspominam — jeśli potrzebujesz przechowywać poprzedni stan obiektu, to możesz zrobić podobnie jak ja w ww projekcie. Jeśli dane pole w klasie obiektu ma być zapamiętywane w ramach historii, to zamiast tworzyć kopię całego obiektu, zadeklaruj takie pole jako ”dwu-wartość”. U siebie wykorzystałem klasę generyczną, która sama zapamiętuje dwie wartości (nową oraz poprzednią), a także przewiduje trochę dodatkowych właściwości. Wygląda tak:

unit Richtris.Classes;

{$MODE OBJFPC}{$LONGSTRINGS ON}

interface

type
  generic TState<T> = class(TObject)
  protected
    FPrevious: T;
    FCurrent: T;
    FDefault: T;
  protected
    FChanged: Boolean;
  protected
    procedure SetCurrent(AState: T); virtual;
    procedure SetDefault(ADefault: T); virtual;
  public
    constructor Create(ADefault: T);
  public
    procedure Reset(); virtual;
  public
    procedure Validate();
    procedure Invalidate();
  public
    property Previous: T read FPrevious;
    property Current: T read FCurrent write SetCurrent;
    property Default: T read FDefault write SetDefault;
  public
    property Changed: Boolean read FChanged;
  end;

implementation

constructor TState.Create(ADefault: T);
begin
  FDefault := ADefault;
  Reset();
end;

procedure TState.SetCurrent(AState: T);
begin
  FPrevious := FCurrent;
  FCurrent := AState;

  FChanged := FPrevious <> FCurrent;
end;

procedure TState.SetDefault(ADefault: T);
begin
  FDefault := ADefault;
  Reset();
end;

procedure TState.Reset();
begin
  FPrevious := FDefault;
  FCurrent := FDefault;

  FChanged := False;
end;

procedure TState.Validate();
begin
  SetCurrent(FCurrent);
end;

procedure TState.Invalidate();
begin
  FChanged := True;
end;

end.

Nową wartość wpisuje się do Current — mutator SetCurrent przepisuje Current do Previous, nową wartość wpisuje do Current, a następnie aktualizuje flagę zmiany. I to wszystko. W swoim projekcie mam koło stu pojedynczych danych (liczb, stringów i wartości logicznych), których stan poprzedni potrzebuję przechowywać, więc każda informacja jest zadeklarowana jako instancja powyższej klasy. Narzut pamięciowy jest znikomy — cały program zajmuje 133MB, po załadowaniu wszystkiego do RAM-u (głównie setek bitmap, bo one stanowią 95% objętości danych programu).

Do czego zmierzam — u mnie mogę sobie pozwolić na reprezentowanie danych prostych w formie małych klas, automatycznie zapamiętujących poprzednie wartości. W Twoim przypadku, gdy mowa o silniku 3D, który może operować na dziesiątkach tysięcy obiektów (a pewnie i milionach), tworzenie historii danych obiektów będzie stanowiło gigantyczny narzut pamięciowy i niemały narzut obliczeniowy. Dlatego zastanów się, czy aby na pewno chcesz w ten sposób ten silnik implementować.

A może zamiast tego zrobić to w formie jakiejś kolejki? Typu ThingsToRender i wrzucać tam wszystkie dane, na bazie których render będzie w stanie narysować obiekt? Kolejka sama w sobie jest trochę wolna, ale przynajmniej daje elastyczność.

A może drzewo zwane Octree?

0

@Crow: i jak tam, jest postęp w projekcie?

1
furious programming napisał(a):

@Crow: i jak tam, jest postęp w projekcie?

Rozwija się :). Po drodze zaliczył reset (trochę zmieniłem filozofię projektową), teraz kończę implementację prostego ECS'a. Bo całość ostatecznie skonstruowałem tak, jak chciałem, tzn. wątek renderujący działa niezależnie od update'ów świata. Przez to jednak nie mogę łączyć odświeżania i rysowania świata w jednej funkcji, np.:

Pseudokod:

void OnUpdate() override
{
  if (Key == VK_RIGHT) Player.Position.X += 10;
  Render.Draw(Player);
}

while (Alive)
{
  //jakiś kod
  OnUpdate();
  //jakiś kod
}

Zamiast tego robię po prostu update świata, a render rysuje go do buffora we własnym tempie, co zadany interwał. Konsekwencją jest jednak to, że render musi mieć dostęp do predefiniowanego świata, np. w formie listy. Mógłbym w tym celu posłużyć się np. taką konstrukcją:

Pseudokod

struct GraphicObject
{
  uint32_t ID;
  int32_t X;
  int32_t Y;
  uint32_t Width;
  uint32_t Height;
  uint32_t Color;
  Bitmap BMP;
  virtual void Render()=0;
};

struct Player : public GraphicObject
{
  void Render() override
  {
    //Kod rysujący gracza w bufforze klatki
  }
};

No i potem konstruować kolejne elementy przez polimorfizm i przechowywać je na odgórne utworzonej liście wskaźników do klasy abstrakcyjnej

GraphicObject** World = nullptr; 

Na krótką metę pewnie by się sprawdziło, bo obiekt w silniku 2D nie potrzebuje wielu danych i z użyciem powyższego GraphicObject dałoby się zmajstrować niemal wszystko, choćby i tak:

struct PlayerRender : public GraphicObject
{
  void Render() override
  {
    //Kod rysujący gracza w bufforze klatki
  }
};

struct Player : public PlayerRender
{
  float Speed;
  std::wstring Name;
  uint16_t Armor;
  //Tu mogłaby się znajdować część obiektu, która nie zostaje uwzględniona przy rysowaniu.
}

No ale to raczej mało elastyczne podejście i liczę na to, że ECS sprawdzi się lepiej :).

A jak twoja gra? :d.

0
Crow napisał(a):

Bo całość ostatecznie skonstruowałem tak, jak chciałem, tzn. wątek renderujący działa niezależnie od update'ów świata.

Czyli silnik jest w stanie używać maksymalną moc CPU i GPU jednocześnie, tak jak w dużych produkcjach? Wiele razy widziałem gameplay'e z jakichś dużych tytułów i testów jakichś konsol (np. tej nowej od Valve), które miały wyświetlone różne liczniki i widziałem, że CPU i GPU działały niemal na 100%. Czyli siłą rzeczy silnik musi działać w taki sposób, aby renderer działał zupełnie niezależnie od wątku logiki (czyli tak jak sam robisz, jeśli dobrze rozumiem).

Przez to jednak nie mogę łączyć odświeżania i rysowania świata w jednej funkcji, np.:

[…]

Zamiast tego robię po prostu update świata, a render rysuje go do buffora we własnym tempie, co zadany interwał. Konsekwencją jest jednak to, że render musi mieć dostęp do predefiniowanego świata, np. w formie listy.

Jak to cholerstwo chcesz synchronizować między wątkami? Duplikując dane obiektów, tak aby logika mogła zmieniać jedną paczkę, a renderer operować na drugiej, niezmiennej w czasie renderowania? I drugie pytanie — do renderowania używasz CPU czy GPU? Z tego co pamiętam, kiedyś pisałeś o czysto software'owym rendererze (podawałeś nawet zrzuty z jakimiś drewnianymi skrzynkami), ale nie wiem czy zmieniłeś koncepcję czy nie.

Na krótką metę pewnie by się sprawdziło, bo obiekt w silniku 2D nie potrzebuje wielu danych […]

2D? To nie robisz silnika 3D?

A jak twoja gra? :d.

Mam 20 tysięcy linijek kodu i rotujący w oknie kwadracik — jest sukces. ;)

screenshot-20221111120824.png

A tak na poważnie — w ostatnich dniach pracowałem nad główną pętlą gry, szukając jakiejś ciekawej i funkcjonalnej implementacji. No i przypomniałem sobie nasze dyskusje (dwa Twoje wątki tutaj), w których rozmawialiśmy na temat wielowątkowości, stałej liczby kroków i interpolowanego renderowania. Zaimplementowałem pętlę tak jak chciałem, czyli wykonującej stałą liczbę kroków na sekundę i renderującą określoną liczbę klatek, ale w ramach jednego wątku. Wszystko działało cacy, ale miałem problem z ustabilizowaniem jej działania, bo co jakiś czas obraz zaczynał solidnie żabkować. Kilka sekund działało płynnie, a potem przez kilka sekund gubiło się co drugie renderowanie (i tak w kółko).

Zmieniłem koncepcję i uprościłem działanie pętli, oczywiście nadal obstając przy stałej liczbie kroków, bo to jedyne sensowne rozwiązanie. Co prawda działa stabilnie, ale przy włączonym VSyncu (60fps i 60Hz odświeżania), czas gry potrafi coraz bardziej odstawać od czasu rzeczywistego, co powoduje, że w regularnych odstępach (mniej więcej co 10 sekund) czas gry jest do tyłu o jedną klatkę, więc w kolejnej iteracji pętli logika aktualizowana jest dwa razy, co gubi jedną klatkę do wyrenderowania i — jeśli się dobrze wpatrzeć w kwadracik — widać leciutki skok. Jeśli VSync jest wyłączony, to czas gry jest zgodny z czasem rzeczywistym, ale pojawia się rozrywanie ekranu — normalne przy braku VSynca.

Zaimplementowałem więc interpolowane renderowanie i problem zniknął — działa to wprost rewelacyjnie. :D

Początkowo byłem sceptyczny co do takiego renderowania, bo renderer siłą rzeczy musi duplikować kod logiki, ale ma ogromny plus, jakim jest wygładzanie animacji ruchomych obiektów. Samo wygładzanie nie jest mi jakoś szczególnie potrzebne, bo w pikselartowej grze (o bardzo niskiej rozdzielczości), animacje i tak są klatkujące, ale interpolacja rozwiązuje problem opóźnionego czasu gry i okazjonalnego gubienia klatki. Nie chciałem jej stosować, bo to skomplikuje silnik, ale ostatecznie skorzystam z niej, choćby nie wiem co. Tym bardziej, że logika nie będzie miała zbyt wiele roboty (aktualizacja nie powinna trwać dłużej niż milisekundę), więc to znikomy narzut dla renderera.

W moim przypdaku, pętla będzie działać tak jak w Unity, czyli najpierw logika (jeden wątek, w Unity może ich być wiele), potem renderowanie (wielowątkowe). Aby renderer mógl przeprowadzić wstępną interpolację, najpierw poprzesuwa sobie obiekty (dodatkowa interpolacja), potem wyrenderuje z nich klatkę. Szukam teraz sposobu na to, aby móc przechowywać wszystkie obiekty mapy w octree, ale jednocześnie aby renderer mógł je przesuwać, wyrenderować, a potem przywrócić, tak aby logika w kolejnej klatce nie uwzględniała interpolacji renderera.

Na razie muszę dokończyć komentowanie kodu i zabieram się za właściwy silnik.

0

@furious programming:

Dużo kwestii poruszyłeś, więc po kolei :).

  1. jeżeli chodzi o GPU, to tak, w teorii mój silnik potrafi docisnąć do 100% zużycia, ale to bardziej w syntetycznych testach obciążeniowych. Tak normalnie (np. w Tetrisie), to zjada z 15-20% w 1080p 75 FPS (takie mam odświeżanie monitora) i 30 UPS'ach logiki. 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. W przyszłości może pomyślę o jakiejś bardziej zaawansowanej wielowątkowości (np. w formie thread pool). Przy czym Render() nie działa zupełnie niezależnie, bo jak wspomniałem, napędza go wątek na CPU (inaczej się tego chyba nie da zrobić).

  2. Synchronizację robię przez potrójne bufforowanie, ale jeszcze z jednym dodatkowym buforem awaryjnym. W skrócie coś takiego:

  • Update() aktualizuje stan świata i zapisuje go do bufora (B0), następnie czeka przez zadany interwał (używam stałych interwałów, tak jak kiedyś pisałem).
  • Update() ponownie aktualizuje stan świata, zapisuje go do bufora (B1) i znowu czeka.
  • Mając 2 stany świata w buforze, Render() zaczyna robotę (zawsze jest o 1 stan świata do tyłu, ale to jest w praktyce niezauważalne).
  • Jeżeli nadejdzie pora na kolejne odświeżenie świata, to Update() nie czeka aż Render() skończy, tylko łapie za B2 i tam spokojnie majstruje kolejny stan świata.

W teorii, do następnego interwału Render() powinien już skończyć i zwolnić B0 (najstarszy bufor), żeby Update() mógł go ponownie przejąć i rozpocząć cały cykl od początku, B0 -> B1 -> B2 -> B0 -> B1 itd. Problem w tym, że łatwo może dojść do sytuacji, w której B2 jest już pełny, a B0 jeszcze nie jest wolny (bo Render() złapał zadyszkę). Wtedy Update() mógłby po prostu zacząć nadpisywać B2, ale to by oznaczało utratę jednego stanu świata (nie zostałby narysowany), a poza tym prowadziłoby to do kolejnych opóźnień na renderze, który tym razem musiałby czekać na Update(). 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 ;/.

  1. Póki co wróciłem do 2D, bo implementuję na nowo cały GameLoop(). Jak skończę, rozbudowa do 3D będzie łatwa :).

  2. Do renderowania używam na ten moment metody hybrydowej. Do testów i debugowania stosuję GDI / GDIPlus. Oba natywnie wykorzystują GPU, więc wszystkie wbudowane funkcje pokroju DrawRect() czy FillPolygon() są renderowane przy pomocy GPU. Jednak te API nie korzystają z shader'ów, więc bardziej skomplikowane zabawy z pikselami (np. teksturowanie) muszę robić na CPU, po prostu manipulując tablicą bitów bezpośrednio w buforze klatki. Docelowo natomiast silnik będzie korzystać z Direct2D, który ma o wiele więcej wbudowanych funkcji i obsługę shader'ów, więc na upartego da się nawet napisać całkiem wydajne renderowanie oteksturowanych brył 3D na GPU (wiem, bo sprawdzałem ;d). No a w odległej przyszłości pewnie spróbuję zaimplementować Direct3D.

  3. 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.

  4. Robisz całość w jednym wątku? Czyli jakoś tak?

while(Alive)
{
  //Sprawdź upływ czasu dla Update()
  if (CzasSwiata) Update();

  //Sprawdź upływ czasu dla Render()
  if (CzasKlatki) Render();
}

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. Może właśnie tu ci ginęła ta klatka raz na jakiś czas, bo opóźnienia się po prostu zbytnio kumulowały?

  1. Masz prawdziwy VSync (komunikujący się z GPU) czy własny "czekacz"? Jakich bibliotek / API używasz do renderowania?

  2. "Wstępna interpolacja"? Co masz na myśli? Jeżeli masz np. X1 = 120 i X2 = 160 (dwa ostatnie stany zmiennej), to co tu jeszcze można wstępnie obliczać? :).

0
Crow napisał(a):
  1. […] 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.

  1. 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).

  1. 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).

  1. 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. :]

  1. 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.

  1. "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.

0

@furious programming:

Myślałem o kolejkowaniu buforów w sposób dynamiczny, ale u mnie bufor to po prostu kopia bieżącego świata, czyli wszystkich obiektów danego poziomu. Coś takiego:

class GraphicObject //Klasa abstrakcyjna
{
  virtual void Render()=0;
}

//Normalnie poniższe jest oczywiście obudowane w klasę:

GraphicObject** World;
World[0] = new OBJPlayer;
World[1] = new OBJRock;
World[2] = new OBJTree;
//każdy z powyższych dziedziczy po GraphicObject

//A potem, tworząc grę, robię po prostu:

World.Initialize(10); //Wielkość świata ustalona na 10 obiektów, więc alokuje pamięć na 40 obiektów, bo 4 bufory

OBJTree* Pine;
World.RegisterObject(&Pine);
Pine->Size = 10.0f;
Pine->Color = 0xFF76AC54;
//itd.

//Potem Renderek leci sobie w pętli i po prostu odpala FinalWorld[I]->Render();. FinalWorld to kolejna, 
//lokalna dla Rendera, pre-alokowana instancja świata, którą Render zapełnia na potrzeby każdej rysowanej 
//przez siebie klatki (interpolując z dwóch buforów). Czyli naraz istnieje w sumie 5 alokacji świata.

Gdybym natomiast chciał alokować i zwalniać kopie świata w locie (kilkadziesiąt razy na sekundę kopiując wszystkie obiekty, razem z listą), to zakładam, że byłoby to pieruńsko wolne. Zamiast tego wolę przygotować wszystkie alokacje już wcześniej i trzymać w pamięci tak długo, jak długo istnieje dany świat lub level.

Niestety, taki model ma swoje minusy (oprócz dużej pamięciożerności). Np. to, że świat musi mieć odgórnie zaplanowaną wielkość, bo późniejsze re-alokacje rozwaliłyby mi wskaźniki do obiektów, a poza tym byłby pewnie strasznie powolne. No ale to chyba i tak jest branżowy standard, bo pisząc swój ECS (żeby zastąpić powyższy system obiektów), we wszystkich implementacjach widziałem odgórnie narzuconą ilość obiektów i wstępną alokację pamięci, więc tak się chyba robi.

A tak swoją drogą, jak ty przechowujesz i zarządzasz obiektami świata?

Inny minus jest dużo poważniejszy i dotyczy samej interpolacji świata. W moim modelu interpolacja jest "głupia", czyli bierze po prostu jakieś OldValue i NewValue, a potem (w zależności od upływu czasu) wylicza właściwą liczbę z zakresu pomiędzy nimi. Ale popatrz teraz na poniższy, szkaradny obrazek poglądowy :):

Interpolation.png

Wyobraź sobie, że czerwona i niebieska kreska to portale, które teleportują ludzika. W pierwszym stanie świata ludzik jest przed portalem wejścia, w drugim już za portalem wyjścia. I teraz chcemy z tych dwóch stanów świata stworzyć 10 interpolowanych stanów pośrednich (klatek). Tutaj nie wystarczy jednak interpolować pomiędzy Player.X1 i Player.X2, tylko raczej pomiędzy Player.X1 i RedPortal.X, a potem BluePortal.X i Player.X2 (diagram nr 3). Dlatego rozważam przejście na model eventowy. Czyli zamiast po prostu kopiować świat, kolejkowałbym listę zmian, zaszytą pod manipulacją obiektów. Coś takiego:

Player.SetXPosition(100, true);

//Co przekładałoby się na coś takiego w kolejce eventów Renderera:

//Pseudokod eventu
{
  OldValue = 20;
  NewValue = 100;
  Interpolation = true;
}

I dla opisania teleportu z powyższego obrazka, wyglądałoby to jakoś tak (wszystko to jeden update świata):

SetPlayerXPosition(RedPortal.X, true); //interpolowane przejście ludzika ze swojej obecnej pozycji do pozycji czerwonego portalu
SetPlayerXPosition(BluePortal.X, false); //natychmiastowy przeskok z jednego portalu do drugiego, brak interpolacji
SetPlayerXPosition(BluePortal.X + 20, true); //interpolowane wyjście ludzika z pozycji niebieskiego portalu, do pozycji końcowej

Co przekładałoby się na 3 eventy,

{
  OldValue = 20; //Pozycja gracza
  NewValue = 40; //Pozycja czerwonego portalu
  Interpolation = true;
}

{
  OldValue = 40; //Pozycja czerwonego portalu
  NewValue = 340; //Pozycja niebieskiego portalu
  Interpolation = false;
}

{
  OldValue = 300; //Pozycja niebieskiego portalu
  NewValue = 320; //Pozycja końcowa gracza
  Interpolation = true;
}

Renderer musiałby to jakoś uwzględnić przy rysowaniu kolejnych 10 klatek. I liczenie musiałoby wyglądać chyba tak, że interpolacja co do zasady następuje z pozycji początkowej do końcowej (z 20 do 320), ale jeżeli InterValue >= 40, to wtedy InterValue = 300 + Distance. Ty jakoś to rozkminiałeś, czy olałeś problem? :).

0

A co do reszty:

  1. Z tym dzieleniem update'ów na kroki to nie głupi pomysł, ale moim zdaniem nadal istnieje ryzyko, że będziesz miał opóźnienia. Bo weź pod uwagę taki scenariusz:
//Wewnątrz pętli:
1. Nadszedł czas na odświeżenie logiki świata.
2. Update robi odświeżenie świata (tylko jedno).
3. Update czegoś tam nie mógł przemielić, więc znacznie przekroczył limit czasu dla jednego odświeżenia (np. 28 ms zamiast 16 ms).
4. Update skończył, więc Render zaczyna robić swoje, chociaż już jest spóźniony.
5. W tym czasie Update stoi i czeka na kolejny cykl pętli, chociaż już powinien zacząć kolejne odświeżenie świata 
//(interwał przeleciał np. w połowie roboty Rendera).
itd.

Możesz ciąć proces na mniejsze kawałki, ale nie mniejsze niż jedno odświeżenie i jedna klatka, więc chyba i tak czasem zaliczysz opóźnienia?

  1. Co do odświeżania płótna przez system, to nie próbowałeś np. nadpisać WM_PAINT i WM_BACKGROUND, żeby okienko olewało wezwania do odmalowywania?

  2. Skoro przesuwasz obiekty zanim zrobi to logika, to chyba stosujesz formę ekstrapolacji? Akurat przy deterministycznym podejściu jest to możliwe, ale istnieje ryzyko, że dana zmiana z punktu widzenia logiki w ogóle nie nastąpi (bo np. kolizja z innym obiektem), więc przewidywanie może być błędne :).

0

Ogólnie rzecz ujmując, logika zawsze aktualizowana jest 60 razy na sekundę. Nieważne czy wystąpią opóźnienia spowodowane VSyncem, czy lekkim lagiem — logika, jeśli jest opóźniona, dąży do tego, aby zrównać się z czasem rzeczywistym. Wyjątkiem są gigantyczne lagi — wtedy logika pomija aktualizację w wielu krokach. Zwykle jest to zabezpieczenie przed ”spiralą śmierci”, ale służy mi dodatkowo za wstrzymanie symulacji podczas rozciągania czy przesuwania okna.

Logika może być aktualizowana mniejszą lub większą liczbę razy na sekundę — 60ups to wartość, którą sam używam, bo jest zgodna z typowym odświeżaniem monitorów, czyli 60Hz. Widywałem też pętle, które wykonują 100ups, ale może być również 30ups. Im większa liczba aktualizacji na sekundę, tym lepsza responsywność, bo wciskane przyciski szybciej są zamieniane na akcje w grze — dlatego nie chcę mniejszej częstotliwości niż 60ups, a większa w moim przypadku jest zbędna (nie da żadnych benefitów, a tylko dodatkowo zeżre CPU).

Crow napisał(a):

A tak swoją drogą, jak ty przechowujesz i zarządzasz obiektami świata?

Na razie nijak, bo silnik zacznę implementować w najbliższych dniach. ;)

Na pewno będę implementował octree, w którym wszystkie obiekty będą przechowywane. Aby nie trzeba było przeszukiwać drzewa, obiekty ruchome (w tym aktorzy) będą się znajdować w dedykowanej liście (albo kilku), a żeby szybko je przesuwać w drzewie, każdy obiekt będzie trzymał referencję węzła drzewa, w którym się znajduje.

Nie wiem jeszcze z czego dokładnie skorzystam do interpolacji renderera, ale tak czy siak muszę przechowywać pozycję obiektów podwójnie — jedna pozycja będzie tą właściwą, potrzebną do aktualizacji logiki, a druga będzie dodatkową, wyłącznie na potrzeby renderera. Być może będę trzymał też kopie innych informacji — np. progresu animacji, tak aby renderer mógł wykorzystywać sprajty, których czas jeszcze nie nadszedł (logika jeszcze nie posunęła animacji tak daleko), ale które względem delty renderera już powinny być widoczne na ekranie.

Tym tematem będę się zajmował za kilka tygodni, a może i jeszcze później. W każdym razie tę dodatkową interpolację (tylko na potrzeby renderowania) trzeba zaimplementować tak, aby trwała jak najkrócej. I jedyne co będzie rendererowi potrzebne to dodatkowo poprzesuwać obiekty i posunąć do przodu animacje — czyli aby renderer malował obiekty w aktualnych ich miejscach i korzystał z aktualnych sprajtów (tych z przyszłości, względem logiki). Pozostałe funkcje dotyczące obiektów, takie jak ich tworzenie i usuwanie z silnika oraz wszystkie inne rzeczy nie są do renderowania potrzebne, więc szkoda mocy obliczeniowej na duplikowanie logiki.

Renderer musiałby to jakoś uwzględnić przy rysowaniu kolejnych 10 klatek. I liczenie musiałoby wyglądać chyba tak, że interpolacja co do zasady następuje z pozycji początkowej do końcowej (z 20 do 320), ale jeżeli InterValue >= 40, to wtedy InterValue = 300 + Distance. Ty jakoś to rozkminiałeś, czy olałeś problem? :).

Tym się nie muszę przejmować, bo logika zawsze jest aktualizowana 60 razy na sekundę (podczas lagów nadgania aktualizując wielokrotnie), natomiast renderer zawsze dostaje deltę mniejszą niż jeden krok, więc nigdy nie będzie musiał wyprzedzać logiki o klatkę i więcej. Czyli inaczej pisząc, logika względem renderera maksymalnie może być opóźniona o jedną klatkę.

W razie gdyby mi pętla posysała przy powolnym renderowaniu, co nieco sobie dopiszę do swojej pętli. Np. jeśli w końcówka akumulatora z czasem wyznaczającym kolejny krok będzie większa niż pół czasu kroku, to wykonam cały krok (wyprzedzając czas rzeczywisty) — w ten sposób ograniczając opóźnienie logiki względem renderera. Ale to się okaże w dalekiej przyszłości, bo muszę wiedzieć ile będzie trwało renderowanie i jak będzie wyglądać responsywność sterowania. Teoria to zdecydowanie za mało.

Crow napisał(a):
  1. Z tym dzieleniem update'ów na kroki to nie głupi pomysł, ale moim zdaniem nadal istnieje ryzyko, że będziesz miał opóźnienia.

Wykonywanie logiki wielokrotnie, jest standardową techniką doganiania czasu rzeczywistego. Musi tak być, aby kompensować lagi, a jeśli tego nie robić, to gra będzie się zachowywać tak jak te stare, na konsolę NES. A tam, jeśli logika nie wyrabiała, to symulacja zwalniała o połowę (z 60fps do 30fps). Gdyby logika posysała jeszcze bardziej i zjadała nie dwie, a trzy klatki, to symulacja zwalniałaby trzykrotnie, czyli do 20fps.

Tak działały moje poprzednie gry, czyli Deep Platformer i Fairtris, bo one imitowały NES-a, ale moja obecna gra ma tego nie robić, dlatego lagi zjadam aktualizowaniem logiki w wielu krokach.

Możesz ciąć proces na mniejsze kawałki, ale nie mniejsze niż jedno odświeżenie i jedna klatka, więc chyba i tak czasem zaliczysz opóźnienia?

Dokładnie. Jednak opóźnienie nigdy nie przekracza jednej klatki względem czasu rzeczywistego. Tego typu opóźnień nie da się uniknąć, bo zarówno aktualizacja logiki, jak i renderowanie, trwają kilka lub kilkanaście sekund — niemożliwe jest więc przedstawienie na ekranie stanu świata idealnie zgodnego z czasem rzeczywistym.

Choć zawsze można mierzyć czas aktualizacji logiki i renderowania oraz na podstawie tych informacji wyprzedzać czas rzeczywisty prognozując przyszłość, tak aby kompensować czas potrzebny na wyrenderowanie obrazu. Niestety na razie nie mam jak sprawdzić tego jak coś takiego wypadnie w praktyce, więc na razie to tylko teoria — swoją drogą warta uwagi, bo faktycznie można by drastycznie zmniejszyć opóźnienia.

  1. Co do odświeżania płótna przez system, to nie próbowałeś np. nadpisać WM_PAINT i WM_BACKGROUND, żeby okienko olewało wezwania do odmalowywania?

Nie używam systemowego API, bo chcę mieć kod przenośny. Renderowanie obrazu i odświeżanie okien jest w całości zaimplementowane po stronie SDL-a, więc na nim w całości polegam.

  1. Skoro przesuwasz obiekty zanim zrobi to logika, to chyba stosujesz formę ekstrapolacji?

W sumie to tak, nie stosuję interpolacji, a ekstrapolację — choć to zależy od punktu widzenia. W każdym razie, bierze się pozostały czas, który siedzi w akumulatorze, zawsze mniejszy niż czas pomiędzy dwoma krokami (czyli mniejszy niż 1/60 sekundy), przerabia na deltę i daje rendererowi, aby na jej podstawie dodatkowo przesunął obiekty.

Rozwiązania są trzy:

  1. Logika nigdy nie wyprzedza czasu rzeczywistego, a pozostały czas renderer wykorzystuje do dodatkowego przesunięcia obiektów, przewidując przyszłość (obecnie taki sposób mam zaimplementowany). Taka prognoza renderera dotyczy wyłącznie pozycji obiektów (ew. dodatkowo postępu ich animacji) i nigdy nie wyprzedza czasu rzeczywistego — to nie renderer jest w przyszłości, a logika jest opóźnina.

  2. Logika może wyprzedzić czas o ~pół klatki, a renderer musi zastosować interpolację i jakoś wyliczyć stan pośredni, na podstawie różnicy czasu rzeczywistego i tego z przyszłości, który zastosowała logika. Zdaje się, że Ty z tego korzystasz.

  3. Interpolacja i ekstrapolacja, dobierana w zależności od tego czy logika jest opóźniona względem czasu rzeczywistego czy go wyprzedza.

Akurat przy deterministycznym podejściu jest to możliwe, ale istnieje ryzyko, że dana zmiana z punktu widzenia logiki w ogóle nie nastąpi (bo np. kolizja z innym obiektem), więc przewidywanie może być błędne :).

No i c**j. :]

Dlatego właśnie renderer będzie przeprowadzał ekstrapolację związaną tylko z pozycją i ew. postępem animacji — nic więcej. Nawet jeśli w kolejnym kroku dany obiekt wcale nie znajdzie się w miejscu, w którym namalował go renderer, albo animacja nie dojdzie do tego momentu, to nic się nie stanie, bo nie powinno to być zauważalne, szczególnie przy wysokim frame rate.

Oczywiście to na razie wstępna implementacja — wszystko się okaże w przyszłości, kiedy już będę mógł odpalić silnik i się nim pobawić. W dowolnym momencie będę mógł zmienić zachowanie pętli, nie wpływając na inne podsystemy gry.

0

@furious programming:

Tym się nie muszę przejmować, bo logika zawsze jest aktualizowana 60 razy na sekundę (podczas lagów nadgania aktualizując wielokrotnie), natomiast renderer zawsze dostaje deltę mniejszą niż jeden krok, więc nigdy nie będzie musiał wyprzedzać logiki o klatkę i więcej. Czyli inaczej pisząc, logika względem renderera maksymalnie może być opóźniona o jedną klatkę.

Eee, to na pewno odpowiedź na moje pytanie? :D.

Mi chodziło o sytuację, w której pomiędzy jednym a drugim update'm zachodzi jakaś aktualizacja danych, ale częściowo musi być interpolowana, a częściowo nie (patrz: obrazek z poprzedniego posta).

Update 1: Value = 20
Update 2: Value = 320

Ale nie możesz po prostu interpolować z 20 do 320, tylko musisz rozbić to na 3 osobne ruchy:

20 -> 40 (interpolacja) 
40 -> 300 (brak interpolacji) 
300 -> 320 (interpolacja) 

Teraz pytanie, jak to przekazać Rendererowi? Musisz mieć jakieś rozwiązanie tej kwestii, bo to jedna z kluczowych rzeczy, bez grafika nie będzie wyświetlana poprawnie. Renderer po prostu musi rozróżniać zmiany fazowe (interpolowane) od natychmiastowych (nieinterpolowanych).

W jednoczesnym aktualizowaniu logiki i bufora klatki z użyciem delty, wyglądałoby to tak:

while (Alive)
{
  Player.X += (30 * Delta);
  if (Player.X >= 40) Player = 300 + (Player.X - 40); 
  //Jeżeli pozycja gracza po zmianie jest większa lub równa pozycji czerwonego portalu (40), przemieść go
  //natychmiastowo do pozycji niebieskiego portalu (300) i to względem niego oblicz nową pozycję gracza.
  Player.Draw();
}

Natomiast przy modelu rozdzielonym, to Renderer musi ogarniać stany pośrednie, tylko jak mu to zakomunikować? Jak jeden update może zawierać w sobie informacje o kilku sposobach renderowania tego samego obiektu, w zależności od klatki pośredniej?

1
Crow napisał(a):

Mi chodziło o sytuację, w której pomiędzy jednym a drugim update'm zachodzi jakaś aktualizacja danych, ale częściowo musi być interpolowana, a częściowo nie (patrz: obrazek z poprzedniego posta).

@Crow: opisałem w tej odpowiedzi to jak działa mój silnik — w dwóch zdaniach. Logika jest aktualizowana w każdym kroku (60fps) i jest jedynym kodem, który modyfikuje dane świata i posuwa symulację do przodu. Renderer wykonuje jedynie interpolację, znając stan obecny (logika go produkuje), stan końcowy (kolejnego kroku w przyszłości) i deltę (pozostałość czasu mniejszego od czasu pomiędzy krokami). I tak interpolacja renderera dotyczy wyłącznie pozycji obiektów plus ew. postępu ich animacji — nic więcej.

Mój silnik działa tak jak ten z Unity, czyli logika i renderowanie wykonywane są sekwencyjnie — jedno po drugim, zawsze (podawałem wcześniej schemat, jakby co). Nawet jeśli rozbić aktualizację logiki oraz renderowanie na wiele wątków, to nadal nie zmienia to faktu, że jedno następuje po drugim. Dlatego działania tych mechanizmów nie muszę synchronizować — i nie chcę, bo muszę ograniczyć ficzery do tych, które faktycznie są potrzebne. W przeciwnym razie, tworzenie gry zajmie 30 lat, a nie 3.

Teraz pytanie, jak to przekazać Rendererowi? Musisz mieć jakieś rozwiązanie tej kwestii, bo to jedna z kluczowych rzeczy, bez grafika nie będzie wyświetlana poprawnie. Renderer po prostu musi rozróżniać zmiany fazowe (interpolowane) od natychmiastowych (nieinterpolowanych).

W moim przypadku, taka konieczność po prostu nie istnieje, więc nie mam się nad czym głowić i czym przejmować. Aktualny stan symulacji zapewniają wywołania Update, a renderer nie zajmuje się kolizjami, tworzeniem i usuwaniem obiektów itp. itd. — jedynie dodatkowo je przesuwa, aby upłynnić ich ruch na ekranie.

Logika w moim silniku nigdy nie wyprzedza czasu rzeczywistego. Renderer może wyprzedzać logikę maksymalnie o jednen krok, czyli o 1/60 sekundy. Takie opóźnienie jest jak najbardziej akceptowalne — gracz nie ma szans go zauważyć/wyczuć. Testowałem to przy różnych frame rate'ach i obciążeniach — responsywność jest bardzo dobra. No i nie potrzebuję przechowywać kopii obiektów świata, a jedynie krótką listę tych, które dodatkowo renderer przesunął, tak aby po wyrenderowaniu je cofnąć na swoje miejsce (znane logice).

Ty wybrałeś rozwiązanie znacznie trudniejsze, bo nie dość że musisz walczyć z przyszłością, to w dodaktu musisz synchronizować działania wykonywane równolegle w dwóch wątkach. Łatwiej drogi nie wybrałeś.

Natomiast przy modelu rozdzielonym, to Renderer musi ogarniać stany pośrednie, tylko jak mu to zakomunikować? Jak jeden update może zawierać w sobie informacje o kilku sposobach renderowania tego samego obiektu, w zależności od klatki pośredniej?

Najlepiej by było gdyby renderer nie musiał zajmować się tym czym logika — po prostu.

Jednym z rozwiązań, jakie rozważałem, było aktualizowanie logiki z wyprzedzeniem czasu rzeczywistego. Jeśli dupka z czasu, który upłynął od ostatniej aktualizacji, jest większa niż 0.5 czasu pomiędzy krokami aktualizacji, to logika może wykonać dodatkowy krok, wyprzedzając czas rzeczywisty. Wtedy oblicza się deltę dla renderera, która w przypadku wyprzedzenia czasu rzeczywistego wychodzi ujemna, więc renderer zamiast przesuwać obiekty do przodu (zgodnie z wektorami), cofałby je.

Efekt na ekranie byłby identyczny jak to co chcesz uzyskać, ale renderer mógłby się zająć wyłącznie przesuwaniem obiektów, a nie wszystkim tym, czym normalnie zajmuje się Update. Ale szybko zrezygnowałem z tego podejścia, bo znów — wprowadza spore komplikacje dotyczące interpolacji renderowania, w tym przede wszystkim interpolowanie obiektów, które się dopiero co pojawiły lub zostąły usunięte ze świata.

0

@furious programming:

Logika jest aktualizowana w każdym kroku (60fps) i jest jedynym kodem, który modyfikuje dane świata i posuwa symulację do przodu

To tak jak u mnie.

Renderer wykonuje jedynie interpolację, znając stan obecny (logika go produkuje), stan końcowy (kolejnego kroku w przyszłości) i deltę (pozostałość czasu mniejszego od czasu pomiędzy krokami).

U mnie tak samo.

Mój silnik działa tak jak ten z Unity, czyli logika i renderowanie wykonywane są sekwencyjnie — jedno po drugim, zawsze (podawałem wcześniej schemat, jakby co). Nawet jeśli rozbić aktualizację logiki oraz renderowanie na wiele wątków, to nadal nie zmienia to faktu, że jedno następuje po drugim.

Znowu, tak jak u mnie, tylko że wątki nie muszą na siebie czekać w razie opóźnień (tzn. logika nigdy nie czeka, Renderer owszem, powtarzając tę samą klatkę).

Dlatego działania tych mechanizmów nie muszę synchronizować

Ja muszę, ale tylko w takim zakresie, żeby wątki mogły się komunikować (zawsze w jedną stronę tylko Update wysyła dane do Renderera).

W moim przypadku, taka konieczność po prostu nie istnieje

No tego właśnie nie ogarniam. No bo w uproszczeniu masz coś takiego, prawda?

while (Alive)
{
  //Jakis kod
  UpdateLogiki(OstatniStanSwiata); //dajmy na to, sztywne 30 razy na sekundę
  Render(PrzedostatniStanSwiata, OstatniStanSwiata); //dajmy na to 60 razy na sekundę, bo taki V-sync
  //Jakis kod
}

Czyli jeżeli PrzedostatniStanSwiata wynosi jakieś tam abstrakcyjne 20, a OstatniStanSiwta wynosi 50, to Renderer będzie interpolował od 20 do 50 i musi z tego tytułu narysować 2 klatki (bo 30 UPS / 60 FPS), tak? Dobrze to rozumiem? Bo tak rozumiem twój opis Renderer wykonuje jedynie interpolację, znając stan obecny (logika go produkuje), stan końcowy (kolejnego kroku w przyszłości). Być może myślimy o tym samym, ale w różny sposób, dlatego staram się to pojąć z twojej perspektywy.

Ty wybrałeś rozwiązanie znacznie trudniejsze, bo nie dość że musisz walczyć z przyszłością

Z przyszłością? Znowu, może ja inaczej na to patrzę, ale nie dostrzegam tu elementu przewidywania przyszłości, wręcz przeciwnie, raczej nadganianie z przeszłości (bo Renderer zawsze jest opóźniony o jeden cykl logiki i próbuje za nią nadążyć):

//UPDATE THREAD

//Stan 1
Player.X = 100;

//Stan 2
Player.X = 300; //To już się wydarzyło, w tym momencie gracz już znajduje się w punkcie 300, tylko na ekranie jeszcze tego nie widać.

//RENDER THREAD

Render(Stan1, Stan2);

//Dopiero teraz gracz widzi, że jego postać znajduje się w punkcie najpierw 200, a w kolejnej klatce w 300, czyli docelowej,
//zgodnej ze stanem świata.

Render interpoluje ze Stanu 1 do Stanu 2, czyli w zasadzie przenosi na ekran to, co już miało miejsce (z punktu widzenia logiki gry już się wydarzyło, tylko gracz jeszcze tego nie zobaczył). Render musi jedynie narysować wynik tego, co zmajstrowała logika, tworząc przy tym kilka stanów pośrednich. Nie mają one jednak wpływu na logikę jako taką, Renderer nie testuje żadnych kolizji, fizyki itd., niczego nie zmienia w logice (nawet nie ma do niej dostępu), tylko operuje na stanach świata, które mu dano. W tym przykładzie ma jakieś tam liczby 100 i 300, wylicza wartość pośrednią między nimi, równą 200 i tam właśnie rysuje postać gracza, choć de facto nigdy go tam nie było, bo logika przeniosła go od razu ze 100 do 300. Nie zgaduje, ani nie przewiduje, obrazuje tylko to, co już się wydarzyło, dorabiając na potrzeby klatki stany pośrednie.

Najlepiej by było gdyby renderer nie musiał zajmować się tym czym logika — po prostu.

Jak wyżej, nie robi tego, wszystkie obliczenia wykonuje na własne potrzeby, bo musi tworzyć klatki pośrednie. Twój przecież też tak robi, prawda?

Wybacz, że męczę, ale staram się zrozumieć twój sposób myślenia :).

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