Pomiar czasu w programowaniu gier

English English | Polski Polski

Ten artykuł został oryginalnie opublikowany w numerze 9/2013 (16) magazynu Programista.

W grach i niektórych innych programach zachodzi potrzeba mierzenia upływu czasu, a ten podlega ograniczeniom (np. zakresu, precyzji) zależnie od metody, jaką go mierzymy, i formatu, w jakim go zapisujemy. Dlatego ważne jest, by zdawać sobie sprawę z tych ograniczeń i odpowiednio do danego zastosowania wybrać funkcję odczytującą aktualny czas, typ zmiennej do jej przechowywania, jednostkę oraz punkt zerowy, a także w świadomy sposób posługiwać się nim, wykonując tylko operacje logicznie prawidłowe i nie powodujące zbyt dużej utraty precyzji.

Wstęp

Pisząc grę lub inny program czasu rzeczywistego, potrzebujemy mierzyć upływ czasu. W przeciwieństwie do systemów biznesowych, tu nie chodzi jednak o kalendarzową datę i aktualną godzinę, ale o upływ czasu w sekundach, milisekundach lub nawet w jeszcze mniejszych jednostkach – im dokładniej, tym lepiej. To może się wydawać tematem prostym i faktycznie taki jest w porównaniu z innymi zagadnieniami spotykanymi w tej dziedzinie – jak programowanie grafiki 3D, symulacji fizycznych czy sztucznej inteligencji. Jednakże istnieje tu wiele możliwych rozwiązań, a na programistę czeka nieco pułapek. Dlatego warto mieć pewne podstawy wiedzy i poznać dobre praktyki, aby unikać możliwych do popełnienia błędów.

Podczas pisania gry, mając w pamięci komputera pewne obiekty, zazwyczaj aktualizujemy ich pozycję w każdej klatce, biorąc pod uwagę ich prędkość oraz różnicę czasu od klatki poprzedniej, zgodnie z wyrażeniem: x = x + v * dt. To jest wzór, który każdy z nas kojarzy ze szkoły – „droga równa się prędkość razy czas”. Równocześnie to jest najprostsza metoda całkowania numerycznego – tzw. metoda Eulera. Warto i tym się zainteresować, ponieważ istnieją też lepsze metody. Tym niemniej programiści dawnych gier pod DOS nieraz nie używali nawet tego sposobu, w efekcie czego ich gry działały na nowszych komputerach z większą prędkością – nieraz do tego stopnia, że nie nadawały się do grania.

Dzięki zastosowaniu tego wzoru prędkość przesuwania się obiektów czynimy niezależną od prędkości, z jaką działa gra, czyli od liczby klatek na sekundę. Istnieje inne rozwiązanie: wymuszenie stałej liczby FPS, ale ma ono, obok pewnych zalet, także wiele wad. Poza tym dzięki mnożeniu prędkości przez deltę czasu możemy wyrazić prędkość w bardziej naturalnych jednostkach, np. metrach na sekundę, a nie metrach czy pikselach na klatkę. To jednak – zakładam – powinno być Ci już znane, podobnie jak zasada, by w każdej klatce pobierać bieżący czas, liczyć różnicę od czasu klatki poprzedniej i tę różnicę wykorzystywać wszędzie w kodzie aktualizującym stan gry. Tymczasem tutaj skupmy się na samym pomiarze czasu.

Artykuły dotyczące pomiaru czasu w programowaniu gier omawiają zwykle budowę pętli gry i inne dodatkowe tematy, jak np. kodowanie timerów wykonujących jakąś akcję co określony interwał. My tutaj skupimy się na nieco niższym poziomie. Po krótkim wstępie teoretycznym, w kolejnych rozdziałach dowiemy się, w jakiego typu zmiennych najkorzystniej jest przechowywać czas, jakimi funkcjami najlepiej jest pobierać jego bieżącą wartość, następnie zobaczymy przykład implementacji typu przechowującego czas, by wreszcie omówić sposoby jego prawidłowego przetwarzania, w tym obliczania FPS (liczby klatek na sekundę).

Jak reprezentować czas?

Decydując się na jakąś reprezentację czasu w kodzie, musimy podjąć szereg ważnych decyzji. Pierwszą z nich jest jednostka, w jakiej będzie wyrażony czas. Mogą to być np. sekundy, milisekundy albo jakieś cykle, których liczba przypadająca na jedną sekundę jest zapamiętana w osobnej zmiennej.

Czas może być bezwzględny lub względny. Tak naprawdę każdy czas jest „względny” – wyrażony względem pewnego umownego czasu 0, tak jak w geometrii mamy początek układu współrzędnych, czyli punkt (0, 0, 0). Dla dat w naszym kalendarzu gregoriańskim jest to umowny rok 1 n.e., dla godziny w ciągu doby jest to północ, a dla czasu uniksowego jest to początek dnia 1 stycznia 1970, czyli tzw. „epoki uniksowej”. Dla czasu mierzonego w cyklach procesora może to być natomiast moment ostatniego uruchomienia komputera. Wybór tego punktu tak naprawdę nie ma znaczenia, o ile stosujemy konsekwentnie ten sam punkt oraz o ile w wykorzystywanym przez nas typie zmiennych nie przekraczamy zakresu ani nie tracimy precyzji z powodu stosowania zbyt dużych wartości (będzie o nich mowa poniżej).

Czasem bezwzględnym można natomiast nazwać czas względem ustalonego punktu 0 dla odróżnienia go od interwału – odcinka czasu, który nie posiada takiego punktu odniesienia i wyraża czas trwania jakiejś operacji wynoszący przykładowo 3 sekundy i 875 ms. Odpowiednikiem w geometrii jest rozróżnienie między pojęciem punktu a wektora. Zależnie od implementacji, możemy napisać oddzielne klasy do reprezentowania czasu bezwzględnego i interwału, tak jak to robi .NET Framework, definiując typy DateTime i TimeSpan. Wówczas, dzięki przeciążeniu operatorów, możemy pozwolić wyłącznie na operacje mające logiczny sens, tj.:

  • czas + interwał = czas (przesunięty)
  • czas - interwał = czas (przesunięty)
  • czas - czas = interwał (różnica czasu)
  • interwał + interwał = interwał (suma)
  • interwał - interwał = interwał (różnica)
  • - interwał = interwał (odwrócony)
  • interwał * skalar = interwał (przeskalowany)
  • interwał / skalar = interwał (przeskalowany)

Wszelkie inne operacje są niedozwolone. Na przykład operacja czas + czas albo czas * skalar nie ma żadnej logicznej interpretacji. Można jednak potraktować to rozróżnienie między czasem a interwałem jako czysto koncepcyjne i używać w kodzie tego samego typu do reprezentowania obydwu, tak jak w geometrii zwykle mamy jeden typ nazwany vector3 albo float3 do reprezentowania zarówno punktu, jak i wektora w 3D. Właśnie to podejście zastosujemy w przykładowym kodzie poniżej, definiując pojedynczy typ GameTime.

Jak przechowywać czas?

Następną kwestią jest ustalenie typu zmiennej, w której będziemy przechowywali czas. Z teoretycznego punktu widzenia mamy tu dwa zjawiska, na które musimy uważać: zakres i precyzję. Zakres oznacza maksymalną wartość, jaką może przechowywać zmienna danego typu. W przypadku liczb całkowitych wynika ona wprost z szerokości tego typu w bitach.

Tabela 1 prezentuje typy całkowitoliczbowe dostępne w C++ wraz z WinAPI i ich zakresy. Oczywiście, typy wbudowane C++ nie mają szerokości ściśle określonej przez standard. Tu podana jest ich interpretacja w Visual C++. Typy zaczynające się od __ (dwa znaki podkreślenia) to niestandardowe rozszerzenie kompilatora. Typy o szerokości określonej liczbą bitów pochodzą z nagłówka <cstdint> / <stdint.h>. Te pisane dużymi literami są natomiast dostępne po włączeniu <Windows.h>.

Tabela 1. Cechy typów całkowitoliczbowych w C++

TypSzerokośćZakres wartości
signed char, __int8
int8_t
CHAR, INT8
8 b = 1 B-128 … 127
unsigned char, unsigned __int8
uint8_t
UCHAR, UINT8, BYTE
8 b = 1 B0 … 255
short, __int16
int16_t
SHORT, INT16
16 b = 2 B-32 768 … 32 767
unsigned short, unsigned __int16
uint16_t
USHORT, UINT16, WORD
16 b = 2 B0 … 65 535
int, __int32, long
int32_t
INT, INT32, LONG, LONG32
32 b = 4 B-2 147 483 648 … 2 147 483 647
unsigned int, unsigned __int32, unsigned long
uint32_t
UINT, UINT32, ULONG, ULONG32, DWORD
32 b = 4 B0 … 4 294 967 295
long long, __int64
int64_t
INT64, LONG64
64 b = 8 B-9 223 372 036 854 775 808 …
9 223 372 036 854 775 807
unsigned long long, unsigned __int64
uint64_t
UINT64, ULONG64, QWORD
64 b = 8 B0 … 18 446 744 073 709 551 615

Liczby całkowite, ponieważ nie posiadają części ułamkowej, reprezentują czas w takiej precyzji, w jakiej jednostce są wyrażone. Jeśli więc jako jednostkę ustalimy milisekundy, to czas w takiej zmiennej będzie przechowywany z dokładnością do jednej milisekundy. Jeśli natomiast chodzi o zakres, liczba 32-bitowa z maksymalną wartością ponad 4 mld mogłaby wydawać się wystarczająca. Jednak, jak łatwo policzyć, wybierając jako jednostki milisekundy, spowodujemy przepełnienie zakresu już po upływie 49 dni i 17 godzin. Liczba 64-bitowa natomiast ma zakres wystarczający do praktycznie każdego zastosowania. Choćby wyrażała czas w pojedynczych cyklach procesora o częstotliwości 3 GHz (jednostka to 0,333 ns), to jej zakresu wystarczy na 195 lat.

Liczby zmiennoprzecinkowe natomiast, z powodu przechowywania oddzielnie mantysy i oddzielnie wykładnika, potrafią reprezentować zarówno bardzo małe, jak i bardzo duże liczby. Zakres nie jest więc tu problemem. W zamian jednak posiadają określoną precyzję, wyrażoną w cyfrach znaczących. Można dla nich określić dużo więcej właściwości. Niektóre z nich pokazuje Tabela 2.

Tabela 2. Cechy typów zmiennoprzecinkowych

Typfloatdouble
Bity = znak +wykładnik + mantysa32 = 1 + 8 + 2364 = 1 + 11 + 52
Zakres wykładnika−126 … 127−1022 … 1023
Znaczących cyfr dziesiętnych≈ 7,225 (6 … 9)≈ 15,955 (15 … 17)
Najmniejsza wartość zdenormalizowana2−149 ≈ 1,4 × 10−452−1022-52 ≈ 5 × 10−324
Najmniejsza wartość znormalizowana2−126 ≈ 1,18 × 10−382−1022 ≈ 2,225 × 10−308
Następna wartość po 11 + 2−23 ≈ 1.00000011 + 2−52 ≈ 1.0000000000000002
Liczby całkowite reprezentowane dokładnie0 … 224 = 16 777 2160 … 253 = 9 007 199 254 740 992
Wartość maksymalna(2−2−23) × 2127 ≈ 3,4 × 1038(1 + (1 − 2−52)) × 21023 ≈ 1,79769 × 10308

Dokładne omówienie działania liczb zmiennoprzecinkowych i wynikających z tego ograniczeń mogłoby być tematem oddzielnego artykułu (zobacz mój artykuł: Tajemnice liczb zmiennoprzecinkowych). Tutaj ograniczmy się do ustalenia, jaki one mają wpływ na przechowywanie z ich użyciem czasu. Przede wszystkim, z powodu przesuwającego się przecinka, precyzja wyrażona w jednostkach jest tym mniejsza, im większa jest wartość. Ponieważ typ float jest 32-bitowy – tak jak int – ma on tyle samo możliwych wartości, więc nie da się za pomocą typu float zapisać dokładnie każdej liczby całkowitej z zakresu int.

Oto przykład pokazujący problem precyzji: Załóżmy, że czas wyrażony w sekundach przechowujemy w zmiennej typu float. Jeśli wartość wynosi ok. 1 s, to precyzja – różnica między sąsiednimi wartościami – wynosi ok. 119 ns. Tyle na pewno wystarczy do precyzyjnych obliczeń w symulacji gry. Natomiast kiedy wartość wzrasta do 10 000 s, co odpowiada ok. 3 godzinom, precyzja wynosi niecałą 1 ms. Kiedy wartość czasu wynosi 100 000, czyli ok. 1 dnia, precyzja to już tylko 7,81 ms. [1] Warto dodać, że precyzja degraduje się w analogiczny sposób niezależnie od tego, jaką jednostkę wybierzemy – czy będą to milisekundy, czy sekundy.

Problem utraty precyzji występuje w przypadku tych wielkości, których wartość jest duża, czyli przede wszystkim dla czasu bezwzględnego. Jest tym większy, im dalej znajduje się „czas 0” od chwili bieżącej. Dlatego zmienne typu float nie są najlepszym sposobem zapisywania takich wielkości, a jeśli już, to warto zadbać o stosowanie jak najmniejszych wartości – np. posługując się czasem względem momentu uruchomienia gry, a nie jakiegoś momentu z przeszłości określonego przez system operacyjny. Można to zrealizować, odejmując czas bieżący od czasu pobranego w chwili uruchomienia gry.

Ważne jest przy tym, aby to odjęcie następowało z użyciem bardziej precyzyjnej reprezentacji i dopiero różnica – której wartość jest już mała – została zamieniona na typ float. W przeciwnym wypadku możemy mieć do czynienia ze zjawiskiem zwanym catastrophic cancellation. Można go zilustrować równie dobrze na liczbach dziesiętnych, ponieważ zasada jest taka sama, jak w reprezentacji binarnej. Załóżmy, że czas mamy wyrażony w sekundach i zapisany z dokładnością do 4 cyfr po przecinku, przy czym przecinek jest zawsze przesunięty tak, by przed nim znajdowała się jedna niezerowa cyfra. Czas poprzedniej klatki wynosi dokładnie 10 s, czyli w naszym zapisie 1,0000 × 101 s. Jeśli do następnej klatki upłynęło 4,3210 ms, to jej czas bezwzględny w naszym zapisie trzeba podać jako 1,0004 × 101 s. Odjęcie czasu nowego od poprzedniego da w wyniku 0,0004 × 101 s, a po znormalizowaniu: 4,0000 × 10-3 s, ponieważ dalsze cyfry zostały utracone z powodu dużej wartości czasu bezględnego. Widzimy więc, że choć zastosowany tutaj zapis posiada precyzję 5 znaczących cyfr dziesiętnych, to po odjęciu zbliżonych do siebie wartości otrzymaliśmy liczbę o precyzji tylko 1 cyfry. Podobnie w drugą stronę – dodanie do czasu bezwzględnego 1,0000 × 101 s delty 4,3210 ms da w wyniku 1,0004 × 101 s, czyli wykorzystana zostanie tylko pierwsza cyfra, bo przy wartościach rzędu 10 s krok między sąsiednimi wartościami w naszym zapisie wynosi 1 ms. Natomiast sama delta czasu od poprzedniej klatki jest wartością na tyle małą, że można ją swobodnie zapisywać w tak reprezentowanej zmiennej bez większych obaw o problemy z precyzją.

Teoretycznie taka utrata precyzji wraz ze zwiększaniem się wartości nie musi być problemem. Jeśli mierzymy czas od chwili startu gry, a grę można przejść w ciągu godziny, to wartości do 3600 sekund wciąż zapewniają dokładność poniżej 1 ms, co powinno być wystarczające. Co innego jednak, jeśli piszemy program taki jak serwer gry MMO, który może działać bez restartu nawet miesiącami. Poza tym nikomu nie życzymy przecież tropienia błędów, które objawiają się dopiero po wielu godzinach czy dniach od uruchomienia gry czy programu.

Mogłoby się wydawać, że rozwiązaniem problemu precyzji jest użycie typu double. On faktycznie ma precyzję wystarczającą do praktycznie każdego zastosowania. Jednak wiąże się z nim kilka dodatkowych problemów. Po pierwsze, obliczenia na tym typie wcale nie muszą zachodzić w jego pełnej precyzji, zależnie od ustawień FPU dla bieżącego wątku. Ustawienia te można zmieniać funkcją _controlfp. Zmienia je automatycznie DirectX 9, chyba że podamy podczas tworzenia obiektu urządzenia flagę D3DCREATE_FPU_PRESERVE. Poza tym używając typu double, wcale nie pozbywamy się problemów związanych z precyzją i wynikających ze zmiennego przecinka, a jedynie je zmniejszamy. Ponadto operacje na tym typie działają wolniej, niż na typie float. [2]

Dlatego do przechowywania czasu bezwzględnego lepiej jest używać typu całkowitoliczbowego 64-bitowego, a typu float używać jedynie do przechowywania delty czasu dla obliczeń symulacji w danej klatce.

Jak pobierać czas?

Przejdźmy teraz do omówienia funkcji służących do pobierania aktualnego czasu. Dostępne funkcje zależą oczywiście od języka programowania i systemu, jakiego używamy, a w każdym z nich jakieś przeznaczone do tego API jest dostępne. Funkcji do tego celu dostarcza zarówno biblioteka standardowa języka, jak i API systemu operacyjnego. Warto poznać różnice między nimi. Tu skupimy się na funkcjach dostępnych na platformie PC x86.

Podstawową cechą każdej funkcji odczytującej czas jest typ danych, jaki ona zwraca, a co za tym idzie – zakres i precyzja. Warto przy tym podkreślić, że dokładność pomiaru czasu może być niższa, niż precyzja jego zapisu. Najbardziej radykalnym przykładem tej różnicy byłoby, gdyby ktoś popatrzył na pozycję słońca na niebie, po czym orzekł, że w tej chwili jest godzina 12:16. Choć czas został wyrażony z dokładnością co do minuty, to sam pomiar tą metodą może być obarczony błędem rzędu całych godzin. Równie dobrze można by w tym przypadku powiedzieć, że jest „około południa”. Podobnie w programowaniu funkcja zwracająca czas w milisekundach może tak naprawdę raportować wyniki z dokładnością mniejszą, np. „przeskakując” co kilkadziesiąt milisekund.

Dodatkową cechą funkcji pobierającej czas jest monotoniczność – założenie, że każde następne wywołanie zwróci wartość nie mniejszą, niż poprzednie. Założenie to nie jest spełnione m.in. przez funkcje, które zwracają „czas zegarowy” (ang. wall-clock time), czyli aktualną datę i godzinę. Pobierana wartość podlega bowiem nieoczekiwanym zmianom, np. podczas przestawiania zegara na czas letni/zimowy albo po aktualizacji czasu przez Internet za pomocą protokołu NTP.

Z podobnym problemem nieoczekiwanych „przeskoków” czasu (przynajmniej w przód) możemy mieć do czynienia zawsze, jeśli gra podczas działania zostaje wstrzymana, np. poprzez uśpienie całego systemu albo przez debugger. Wówczas może się okazać, że czas, jaki upłynął od poprzedniej klatki, wynosi wiele minut lub nawet godzin. Dlatego warto ograniczyć obliczoną deltę czasu z jednej strony do co najmniej 0, a z drugiej do jakiejś rozsądnej wartości maksymalnej, np. 1/10 sekundy.

Mamy dostępne funkcje zwracające czas zegarowy, ale istnieją również takie, które pobierają wartość licznika cykli procesora lub innego podobnego licznika opartego na sprzęcie. Te metody są zwykle bardzo precyzyjne oraz nie podlegają korekcji zegara systemowego, natomiast podczas studiowania ich opisu warto zwrócić uwagę na dodatkowe niebezpieczeństwa. Po pierwsze, współczesne procesory potrafią zmieniać swoje taktowanie w czasie pracy, przez co liczba cykli na sekundę może być zmienna, a to spowoduje zmiany tempa upływu czasu w grze. Po drugie, jeśliby każdy procesor lub rdzeń w komputerze był wyposażony we własny licznik, zwracana wartość może być zależna od rdzenia, na którym wykonują się instrukcje bieżącego wątku.

Oto ważniejsze metody pobierania czasu dostępne na platformie PC:

  • Funkcja time z biblioteki standardowej C, z nagłówka <time.h>: Zwraca datę i czas typu time_t, w sekundach, od początku epoki uniksowej. Precyzja jej wyniku oczywiście nie jest wystarczająca do pomiaru czasu w grach.
  • Funkcja gettimeofday z nagłówka <sys/time.h> dostępnego na platformach uniksowych: Zwraca datę i czas typu timeval, który jest strukturą złożoną z time_t i liczby mikrosekund. Posiada dokładność do pojedynczych mikrosekund, więc jest dobrym kandydatem do wykorzystania w grach. Niestety również podlega zmianom czasu systemowego.
  • Funkcja clock_gettime z nagłówka <time.h>, dostępna na platformach uniksowych: Potrafi pobierać wartość różnych zegarów, zależnie od podanego parametru: CLOCK_REALTIME (czas systemowy podobnie jak gettimeofday), CLOCK_MONOTONIC (licznik procesora) lub CLOCK_MONOTONIC_RAW. Zwraca wartość typu timespec złożonego z time_t i liczby nanosekund. Posiada bardzo dużą dokładność, wynoszącą przykładowo na Xeon 3,5 GHz ok. 0,000035 ms [3].
  • Funkcja GetSystemTimeAsFileTime dostępna w WinAPI, w nagłówku <Windows.h>: Zwraca bieżącą datę i czas jako liczbę 64-bitową wyrażoną w wielokrotnościach 100 nanosekund od 1 stycznia 1601. Ma dość dobrą dokładność – ok. 0,5 ms [3]. Niestety podlega zmianom zegara systemowego.
  • Funkcja GetTickCount również dostępna w WinAPI: Zwraca liczbę milisekund od startu systemu jako wartość typu DWORD. Liczba ta przepełnia się, kiedy system działa ponad 49 dni i 17 godzin (choć Windows rzadko przecież działa tyle czasu bez restartu :) Od Windows Vista dostępna jest wersja 64-bitowa: GetTickCount64. Obie jednak, choć zwracają wynik w milisekundach, mają dokładność tylko ok. 10-20 ms – raczej zbyt mało, aby precyzyjnie mierzyć czas na potrzeby gier.
  • Funkcja timeGetTime z WinAPI, z grupy Multimedia (wymaga zlinkowania Winmm.lib): Zwraca liczbę milisekund od startu systemu jako wartość typu DWORD, tak jak powyższa. Ma jednak większą dokładność, rzędu ok. 1-5 ms. Dodatkowo dokładność można zwiększyć, wywołując wcześniej funkcję timeBeginPeriod. To może być dobre rozwiązanie do zastosowania w grach.
  • QueryPerformanceCounter z WinAPI: Zwraca 64-bitową liczbę cykli, których ilość przypadającą na jedną sekundę można pobrać funkcją QueryPerformanceFrequency (wystarczy to zrobić jednokrotnie na początku programu). Ma bardzo dużą dokładność. Przykładowo, w moim systemie Windows 7 64-bit z procesorem AMD Phenom II X4 3,4 GHz jest to 3 335 439 cykli na sekundę, czyli precyzja wynosi poniżej 0,3 mikrosekundy. Zwracany przez nią licznik nie powinien podlegać wahaniom podczas przełączania wątku między rdzeniami procesora albo zmiany częstotliwości taktowania procesora (choć znane są związane z tym błędy na niektórych CPU i chipsetach). Właśnie tę funkcję zastosujemy w kodzie w następnym rozdziale.
  • Instrukcja asemblerowa rdtsc bezpośrednio odczytująca licznik procesora, tzw. Time Stamp Counter. Jest najbardziej niskopoziomowa, ma ogromną precyzję, ale zależnie od platformy może dawać inną wartość w zależności od tego, na którym rdzeniu się wykona, albo może zmieniać tempo zliczania, więc trudno polegać na jej wyniku, chcąc mierzyć upływ czasu.

Implementacja GameTime

Przejdźmy wreszcie do kodu. Listing 1 pokazuje implementację typu GameTime, który reprezentuje czas w grze. Jest to struktura, której od lat używam w swoich prywatnych projektach. Przed jej napisaniem podjęte zostały następujące decyzje projektowe:

  • Do pobierania czasu używana jest funkcja QueryPerformanceCounter z WinAPI.
  • Czas przechowywany jest w takim formacie i w takich jednostkach, jakie zwraca ta funkcja, czyli jako liczba 64-bitowa wyrażona w cyklach.
  • Pojedynczy typ reprezentuje zarówno czas bezwzględny, jak i interwał. Z tego powodu dozwolone jest na tym typie wiele operacji (poprzez przeciążone operatory) i do używającego go programisty należy decyzja, które z nich mają sens w danym kontekście.
  • Implementacja w umiarkowany sposób używa enkapsulacji – nie pozwala na swobodne mieszanie typu GameTime ze zwykłą liczbą int64_t, ale przechowywana wartość jest dostępna jako pole publiczne.
  • Jako punkt zero używany jest moment uruchomienia programu, a konkretnie wywołania GameTime::Init.

Listing 1. Typ GameTime

#include <Windows.h>
#include <cassert>
#include <cstdint>

struct GameTime
{
public:
    static const GameTime ZERO;
    static const GameTime MIN_VALUE;
    static const GameTime MAX_VALUE;
    
    static void Init();
    static GameTime GetCurrent();
    static GameTime FromMilliseconds(int64_t ms);
    static GameTime FromSeconds(float s);

    int64_t Value;

    GameTime() { }
    explicit GameTime(int64_t v) : Value(v) { }

    GameTime operator+() const { return *this; }
    GameTime operator-() const { return GameTime(-Value); }

    GameTime operator+(GameTime rhs) const { return GameTime(Value + rhs.Value); }
    GameTime operator-(GameTime rhs) const { return GameTime(Value - rhs.Value); }
    GameTime& operator+=(GameTime rhs) { Value += rhs.Value; return *this; }
    GameTime& operator-=(GameTime rhs) { Value -= rhs.Value; return *this; }

    friend GameTime operator*(GameTime lhs, int64_t rhs) { return GameTime(lhs.Value * rhs); }
    friend GameTime operator*(int64_t lhs, GameTime rhs) { return GameTime(lhs * rhs.Value); }
    GameTime operator/(int64_t rhs) const { return GameTime(Value / rhs); }
    GameTime operator%(int64_t rhs) const { return GameTime(Value % rhs); }
    GameTime& operator*=(int64_t rhs) { Value *= rhs; return *this; }
    GameTime& operator/=(int64_t rhs) { Value /= rhs; return *this; }
    GameTime& operator%=(int64_t rhs) { Value %= rhs; return *this; }

    int64_t operator/(GameTime rhs) const { return Value / rhs.Value; }
    GameTime operator%(GameTime rhs) const { return GameTime(Value % rhs.Value); }
    GameTime& operator%=(GameTime rhs) { Value %= rhs.Value; return *this; }

    bool operator==(GameTime rhs) const { return Value == rhs.Value; }
    bool operator!=(GameTime rhs) const { return Value != rhs.Value; }
    bool operator<(GameTime rhs) const { return Value < rhs.Value; }
    bool operator>(GameTime rhs) const { return Value > rhs.Value; }
    bool operator<=(GameTime rhs) const { return Value <= rhs.Value; }
    bool operator>=(GameTime rhs) const { return Value >= rhs.Value; }

    int64_t ToMilliseconds() const;
    float ToSeconds() const;

private:
    static bool m_Initialized;
    static int64_t m_Freq;
    static int64_t m_StartValue;
};

const GameTime GameTime::ZERO = GameTime(0);
const GameTime GameTime::MIN_VALUE = GameTime(INT64_MIN);
const GameTime GameTime::MAX_VALUE = GameTime(INT64_MAX);

bool GameTime::m_Initialized = false;
int64_t GameTime::m_Freq = 0;
int64_t GameTime::m_StartValue = 0;

void GameTime::Init()
{
    assert(!m_Initialized);
    QueryPerformanceFrequency((LARGE_INTEGER*)&m_Freq);
    QueryPerformanceCounter((LARGE_INTEGER*)&m_StartValue);
    m_Initialized = true;
}

GameTime GameTime::GetCurrent()
{
    assert(m_Initialized);
    int64_t v;
    QueryPerformanceCounter((LARGE_INTEGER*)&v);
    return GameTime(v - GameTime::m_StartValue);
}

GameTime GameTime::FromMilliseconds(int64_t ms)
{
    assert(m_Initialized);
    return GameTime((ms * m_Freq + 500) / 1000);
}

GameTime GameTime::FromSeconds(float s)
{
    assert(m_Initialized);
    return GameTime((int64_t)(s * (float)m_Freq + .5f));
}

int64_t GameTime::ToMilliseconds() const
{
    return (Value * 1000 + m_Freq / 2) / m_Freq;
}

float GameTime::ToSeconds() const
{
    return (float)Value / (float)m_Freq;
}

Wartość zerowa, a także najmniejsza i największa reprezentowalna (która może być używana np. jako wartość specjalna oznaczająca zmienną niezainicjalizowaną) dostępne są jako statyczne stałe składowe ZERO, MIN_VALUE, MAX_VALUE.

Przed używaniem obiektów tego typu w jakikolwiek sposób, najlepiej na samym początku programu, należy jednokrotnie wywołać statyczną metodę Init. Inicjalizuje ona prywatne pola klasy wartościami pobranymi z systemu: m_StartValue to początkowa wartość czasu traktowana dalej jako punkt 0, a m_Freq to liczba cykli na sekundę.

Statyczna metoda GetCurrent pobiera z systemu i zwraca aktualny czas. Używa w tym celu funkcji QueryPerformanceCounter, a od wyniku odejmuje czas początkowy m_StartValue, by otrzymana wartość wyrażona była względem momentu wywołania metody Init. Nie jest ona natomiast przez nic mnożona ani dzielona, więc pozostaje zapisana w takich samych jednostkach, w jakich zwraca ją ta funkcja.

Liczne definicje przeciążonych operatorów definiują różne operacje matematyczne, jakie można wykonywać na tym typie – dodawanie, odejmowanie, mnożenie, dzielenie i reszta z dzielenia (modulo), a także porównania. Odbywają się one za pomocą prostej arytmetyki całkowitoliczbowej na przechowywanej wewnątrz wartości typu int64_t.

Metody ToSeconds i FromSeconds służą do – odpowiednio – zamiany GameTime na liczbę typu float wyrażoną w sekundach i odwrotnie – utworzenie obiektu GameTime na podstawie takiej liczby. Taka zamiana cykli na (z) sekundy polega na podzieleniu (pomnożeniu) wartości wejściowej przez m_Freq – liczbę cykli na sekundę.

Dodatkowe metody ToMilliseconds i FromMilliseconds mogą się przydać do zamiany GameTime na (z) liczbę milisekund zapisaną jako int64_t. Warto je wykorzystywać tam, gdzie chcemy mieć (mamy) liczbę milisekund czy sekund oraz nie interesuje nas część ułamkowa tej wartości, ponieważ te operacje odbywają się w całości na liczbach całkowitych i nie wymagają konwersji do (z) liczby zmiennoprzecinkowej, która jest operacją stosunkowo powolną.

Przyjrzyjmy się teraz trzem prostym przykładom użycia typu GameTime. Listing 2 pokazuje najprostszą możliwą implementację pętli gry realizującej pomiar czasu. W każdym przebiegu pętli (w każdej klatce) pobierany jest bieżący czas, a różnica tego czasu od czasu pobranego w klatce poprzedniej to delta – krok czasowy wykorzystywany do symulacji. O ile czas bezwzględny pozostaje zapisany w zmiennej typu GameTime, o tyle delta zostaje zamieniona na typ float. Do funkcji Update przekazane zostają wszystkie trzy zmienne. Wewnątrz tej funkcji, wszędzie tam, gdzie chodzi o czas (np. do obliczania upływu czasu animacji, czasu życia cząsteczek i innych obiektów od chwili ich utworzenia), warto posługiwać się typem GameTime i wykorzystywać parametry currTime i dt. Natomiast do obliczeń angażujących inne wielkości typu float – np. pozycja obiektu w metrach, prędkość w metrach na sekundę – można użyć wartości przekazanej jako dt_f.

Listing 2. Prosta pętla gry

GameTime::Init();
GameTime prevTime = GameTime::GetCurrent();
while(!exitProgram)
{
    GameTime currTime = GameTime::GetCurrent();
    GameTime dt = currTime - prevTime;
    float dt_f = dt.ToSeconds();

    Update(currTime, dt, dt_f);

    prevTime = currTime;
}

Drugi przykład – ten na Listingu 3 – pokazuje, jak można użyć typu GameTime do mierzenia czasu trwania jakiejś operacji. Aktualny czas pobierany jest przed i po wywołaniu długotrwałej funkcji. Następnie liczona jest ich różnica i – podobnie, jak w poprzednim przykładzie – dopiero taka różnica, czyli interwał czasu, zamieniana jest na liczbę typu float celem jej wyświetlenia w jednostkach przystępnych dla użytkownika, tj. w sekundach.

Listing 3. Pomiar czasu trwania operacji

GameTime begTime = GameTime::GetCurrent();
LongOperation();
GameTime endTime = GameTime::GetCurrent();
GameTime duration = endTime - begTime;
printf("Operation took %g s\n", duration.ToSeconds());

Listing 4 pokazuje, jak można zaimplementować wykonywanie pewnej operacji (tutaj wywołanie UpdateAI, czyli obliczenia sztucznej inteligencji) co określony czas (przykładowo co 1 sekundę), a nie w każdej klatce. Najwygodniej jest w tym celu zapamiętać czas bezwzględny, po osiągnięciu którego operacja ma zostać wykonana ponownie. Używamy w tym celu zmiennej m_NextAIUpdateTime. Kiedy w danej klatce czas bieżący currTime jest większy lub równy zapamiętanej tam wartości, wówczas należy wywołać UpdateAI, po czym m_NextAIUpdateTime jest zwiększany o interwał równy 1000 ms, czyli 1 s. Zwróćmy przy tym uwagę, że ten czas jest zwiększany od swojej poprzedniej wartości, a nie przypisany na currTime + GameTime::FromMilliseconds(1000). To mała, ale istotna różnica. Wewnątrz tego warunku czas bieżący prawie nigdy nie będzie równy m_NextAIUpdateTime – zwykle będzie nieco większy. Gdyby do obliczenia m_NextAIUpdateTime wykorzystany został parametr currTime, ta różnica zostałaby pominięta i w efekcie UpdateAI byłoby wywoływane średnio nieco rzadziej, niż co 1 s.

Listing 4. Wykonywanie operacji co określony czas

GameTime m_NextAIUpdateTime;
//(...)

int main()
{
    GameTime::Init();
    m_NextAIUpdateTime = GameTime::GetCurrent();
    // Game loop...
}

void Update(GameTime currTime, GameTime dt, float dt_f)
{
    //(...)
    if(currTime >= m_NextAIUpdateTime)
    {
        UpdateAI();
        m_NextAIUpdateTime += GameTime::FromMilliseconds(1000);
    }
}

Wykorzystanie pokazanego tu kodu na wyższym poziomie mogłoby polegać na napisaniu klasy timera, która przechowywałaby w swoich polach bieżący czas, w metodach wywoływanych w każdej klatce obliczałaby deltę czasu, pozwalałaby na jego zatrzymywanie (pauzę) czy nawet skalowanie prędkości upływu czasu. Takich timerów warto mieć więcej (każdy ze swoim stanem, a być może też z innym punktem początkowym), aby można było np. zatrzymać czy spowolnić aktualizację logiki gry, podczas kiedy animacja elementów HUD i ruch kamery powinny działać w niezmieniony sposób. To jednak wykracza już poza ramy tego artykułu.

Zalety i wady FPS

Popularną jednostką związaną z pomiarem czasu w grach, znaną zarówno programistom, jak i graczom, jest FPS (ang. Frames Per Second) – liczba klatek na sekundę. Czasami bywa ona używana błędnie, dlatego warto dobrze zrozumieć jej istotę.

Jak mierzyć FPS? Najbardziej oczywiste rozwiązanie to policzenie klatek, jakie zostały przetworzone w pewnym czasie i podzielić tę liczbę przez ten czas. Jeśli jest to liczba wszystkich klatek od uruchomienia programu, otrzymujemy średni FPS z całego czasu działania programu. Jeśli natomiast resetujemy licznik co jakiś czas – np. co 1 s – wówczas otrzymujemy średni FPS z ostatniej sekundy. Przykład pokazujący, jak to robić, zawiera Listing 5, bazujący na kodzie z [4]. Zmienna m_FPS jest raz na nieco ponad 1 s aktualizowana do nowej wartości oznaczającej średnią liczbę klatek na sekundę w czasie od poprzedniego pomiaru. Do tego pomiaru wykorzystywane są dwie zmienne. m_FPSTime odmierza czas od pomiaru poprzedniego, a m_FPSFrames – liczbę klatek, jakie zostały w tym czasie przetworzone. Obliczenie FPS polega na podzieleniu tej drugiej zmiennej przez tą pierwszą.

Listing 5. Pomiar średniego FPS z ostatniej sekundy

GameTime m_FPSTime;
unsigned m_FPSFrames;
float m_FPS;

void InitFPS()
{
    m_FPSTime = GameTime::ZERO;
    m_FPSFrames = 0;
    m_FPS = 0.f;
}

void UpdateFPS(GameTime dt)
{
    m_FPSTime += dt;
    m_FPSFrames += 1;
    if(m_FPSTime >=
        GameTime::FromMilliseconds(1000))
    {
        m_FPS = (float)m_FPSFrames / m_FPSTime.ToSeconds();
        m_FPSTime = GameTime::ZERO;
        m_FPSFrames = 0;
    }
}

Jeszcze innym rozwiązaniem jest obliczenie FPS jako odwrotności czasu klatki, czyli m_FPS = 1.f / dt.ToSeconds(). Jest to wartość FPS chwilowa. Ponieważ ona zmieniałaby się co klatkę i mogłaby – tak jak czas trwania klatki – podlegać znacznym wahaniom, ta metoda nie jest zbyt użyteczna (choć można by tak obliczony chwilowy FPS uśredniać z kilku poprzednich klatek). Jej poprawność pokazuje jednak, z czym mamy tu faktycznie do czynienia. FPS to tak naprawdę odwrotność (aktualnego lub średniego) czasu trwania klatki, a jego jednostka – klatki na sekundę, czyli 1/s – to nic innego jak Hz (częstotliwość). Ponieważ występuje tu dzielenie, jest to miara nieliniowa. Dlatego nie nadaje się do obliczeń wyrażających wydajność aplikacji.

Czas trwania klatki, mówiąc w uproszczeniu, zależy proporcjonalnie od złożoności sceny (lub jej widocznej części), jaką należy przeliczyć i wyrenderować, a w szczególności od liczby obiektów. Załóżmy, że w pewnej grze pusta scena renderuje się z częstotliwością 200 FPS, czyli jedna klatka trwa 5 ms. Dodanie każdego następnego potwora do sceny wydłuża czas klatki o 3,5 ms. Wówczas scena z jednym stworem działa ze 118 FPS, z 2 – 83 FPS, a z 10 – 25 FPS. Zależność między liczbą stworów na tej scenie a liczbą FPS pokazuje Rysunek 1. Jak widać, liczba FPS zmniejsza się nieliniowo wraz ze wzrostem liczby obiektów. Wykres ma kształt funkcji 1/x.

Zależność FPS od liczby obiektów
Rysunek 1. Zależność FPS od liczby obiektów

Oto inny przykład: Pewien programista pisał grę, która działała z prędkością 110 FPS. Gra innego programisty miała tylko 20 FPS. Pierwszy spędził tydzień na optymalizowaniu kodu, po czym stwierdził, że teraz jego gra osiąga 151 FPS. Ucieszył się, chwaląc się wszystkim dookoła, że jego optymalizacja przyniosła zysk aż 41 FPS. Tymczasem drugi, po tygodniu ciężkiej pracy, zaobserwował wzrost do 21 FPS, czyli „tylko o marny 1 FPS”. Czy pierwszy programista faktycznie miał powód do radości, a drugi do smutku?

W rzeczywistości każdy z nich przyspieszył grę o 2,5 ms na klatkę! Tyle tylko, że klatka w grze pierwszego programisty trwała przed optymalizacją 9 ms, a drugiego – 50 ms. Każdy z nich wykonał więc podobną pracę. Praca drugiego – tego smutnego – była nawet więcej warta, ponieważ jego gra, działając z prędkością 20 FPS, z pewnością bardziej potrzebowała optymalizacji.

Morał z tej historii jest następujący: Liczby FPS nie można używać do ilościowego określania wydajności i jej przyrostu, ponieważ jest to miara nieliniowa – odwrotność czasu, a to czas jest właściwą jednostką miary wydajności. Błędu tego nie unikają nawet niektórzy naukowcy w swoich publikacjach. Warto zapamiętać, aby nigdy nie mówić „zmiana o X FPS”. Podobnie przy bardziej szczegółowym analizowaniu wydajności gry należy zawsze określać, ile milisekund w czasie jednej klatki zajmują obliczenia fizyki, AI i inne elementy składające się na jej całkowity czas.

Nie oznacza to jednak, że nie wolno porównywać FPS mnożnikowo. Stwierdzenia „FPS wzrosło dwukrotnie” albo „wzrosło o 20%” są jednoznaczne niezależnie od wartości początkowej: pierwsze oznacza skrócenie czasu klatki o połowę, drugie zwiększenie FPS do 1,2 wartości początkowej, a więc skrócenie czasu klatki o około 16,7%.

Czy wobec tego podawanie wartości FPS w ogóle ma sens i może się do czegokolwiek przydać? Tak, ponieważ to częstotliwość jest wielkością, która decyduje o tym, czy gra działa płynnie. Człowiek postrzega ciąg klatek obrazu jako płynną animację począwszy od ok. 25-30 FPS, a najlepiej 60 FPS (różnica między 30 a 60 FPS jest wbrew pozorom bardzo widoczna). Dlatego liczba FPS, jaką osiąga dana gra (średnio albo w przypadku szczególnie skomplikowanej sceny) jest dobrym kryterium oceny, czy działa wystarczająco szybko, aby wyświetlany obraz wyglądał płynnie.

Inne zagadnienia

Na koniec warto wspomnieć o kilku dodatkowych zagadnieniach, które wiążą się z omówionym tutaj tematem. Delta czasu, o jaką przesuwamy do przodu symulację w danej klatce, to w rzeczywistości oszacowanie czasu, jaki ta klatka potrwa. Wykorzystanie w tym celu czasu trwania klatki poprzedniej to tylko najprostsza metoda tego oszacowania i ma ona pewne wady. Zakłada bowiem, że kolejne klatki zabierają podobny czas. Jeśli natomiast w poprzedniej klatce zdarzyło się coś, co spowodowało jej nadzwyczajne wydłużenie (np. włączyły się obliczenia AI wykonywane tylko raz na kilka klatek albo antywirus w tle postanowił zaktualizować swoje bazy), wówczas nowa klatka przesunie symulację o dłuższy czas, co będzie widoczne jako „przycinanie się” gry (animacja nie będzie płynna nawet, jeśli średnia liczba FPS jest wysoka). Można sobie wyobrazić nawet taką sytuację, w której z powodu zwiększonej delty czasu nowa klatka również potrwa dłużej (np. wykonają się dwa kroki symulacji fizycznej zamiast jednego), co wpłynie na klatkę następną itd., powodując dłuższe „zamulenie” gry. Dlatego jako deltę czasu dla nowej klatki warto brać średnią z czasu trwania kilku klatek poprzednich. To zmniejszy wpływ chwilowych wahań czasu trwania klatki (tzw. spikes).

Pamiętajmy też, że liczbę klatek na sekundę może ograniczać API graficzne (OpenGL lub DirectX), blokując funkcję kończącą klatkę na dłuższy czas, kiedy włączona jest tzw. pionowa synchronizacja (V-sync). Wówczas funkcja ta synchronizuje się z częstotliwością odświeżania obrazu na monitorze, która wynosi zwykle 60 Hz.

Bardziej zaawansowanym sposobem obsługi upływu czasu w grze jest stały krok czasowy (ang. fixed timestep). W tej metodzie funkcja Update aktualizująca stan gry dostaje zawsze taką samą wartość delty czasu. Podczas każdej klatki wykonujemy 0, 1 lub więcej wywołań Update. Metoda ta zapewnia większą stabilność obliczeń (co jest szczególnie ważne w symulacji fizyki) i pozwala na stworzenie kodu w pełni deterministycznego, ale jest trudniejsza w implementacji. Po szczegóły odsyłam do [5].

Częstotliwość aktualizacji gry to jedna wielkość, a inną jest opóźnienie (ang. latency), które w grach można zdefiniować np. jako czas upływający od naciśnięcia przez gracza przycisku na kontrolerze do momentu wyświetlenia jego efektu na ekranie. Oczywiście im ten czas jest mniejszy, tym lepiej. Na opóźnienie składa się jednak wiele czynników, a czas trwania klatki to tylko jeden z nich. Inne to m.in. z jednej strony czas, po jakim informacja od kontrolera zostanie odczytana przez kod gry, a z drugiej – czas od aktualizacji stanu gry, poprzez wysłanie zmienionego stanu do renderowania, przetworzenie komend graficznych przez sterownik i kartę graficzną, do pokazania na ekranie nowej klatki. Te ostatnie etapy wykonują się asynchronicznie względem kodu gry.

Podsumowanie

Gry, jak również programy symulacyjne i programy czasu rzeczywistego, potrzebują w precyzyjny sposób mierzyć upływ czasu. Czas, jak każda wielkość fizyczna, podlega ograniczeniom (np. zakresu, precyzji) zależnie od metody, jaką go mierzymy, i formatu, w jakim go zapisujemy. Dlatego ważne jest, by zdawać sobie sprawę z tych ograniczeń i odpowiednio do danego zastosowania wybrać funkcję odczytującą aktualny czas, typ zmiennej do jej przechowywania, jednostkę oraz punkt zerowy, a także w świadomy sposób posługiwać się poszczególnymi wielkościami (czas bezwzględny, interwał, FPS), wykonując na nich tylko operacje logicznie prawidłowe i nie powodujące zbyt dużej utraty precyzji.

Adam Sawicki
Październik 2013

Comments

[Download] [Dropbox] [pub] [Mirror] [Privacy policy]
Copyright © 2004-2026