Možnosti stabilizace rychlosti běžícího programu

V tomto článku se zaměřím především na počítačové hry, programovací jazyky C/C++, knihovnu SDL a operační systémy Microsoft Windows a Linux, i když navrhovaná řešení jsou aplikovatelná i na další systémy. Inspirací mi byla i webová stránka Making the game world independent of CPU speed, která už ale bohužel neexistuje. Můj článek popisuje možnosti stabilizace rychlosti běžícího programu. Základní problém spočívá v tom, jak napsat zdrojový kód, aby výsledek běžel všude stejně rychle.

Pohyb v počítačové hře, který na obrazovce působí plynule, je ve skutečnosti rozfázován na tzv. snímky, podobně jako film v kině. Právě zkratka "FPS", která se v souvislosti s touto problematikou používá, pochází z anglického "frames per second", tedy "počet snímků za sekundu". Naším úkolem, tedy alespoň než dojdeme k odstavci 3, je vykreslování snímků rovnoměrně rozložit tak, aby jejich počet byl v každé další vteřině stejný.

Metoda 1: Na počátku byly počítače, které běžely všechny stejně rychle

V minulosti byla tato problematika relativně jednoduchá, protože program byl většinou napsán pro konkrétní model počítače (např. Sinclair ZX-Spectrum) a na všech počítačích běžel stejně rychle, neboť jednotlivé kusy byly identické. Programátorovi stačilo pouze odzkoušet program na svém počítači a optimalizovat ho tak, aby nezapisoval do obrazové paměti ve chvíli, kdy se překresluje obraz na televizi, resp. monitoru.

Metoda 2: Na konci každého snímku uděláme malou pauzu

Počítače v současnosti ale stejně rychlé nejsou, rozdíly spočívají především v různě rychlých mikroprocesorech, grafických kartách, příslušných sběrnicích a ve faktu, že na jednom procesoru může běžet víc úloh současně. Metoda 2 je založena na tom, že programátor si stanoví, kolik snímků za vteřinu má jeho program zobrazit. Převrácená hodnota tohoto čísla mu řekne, jak dlouho má být jednotlivý snímek zobrazen. Po každém zobrazení scény je tedy třeba udělat pauzu, která doplní čas do definované délky. I když mám vyzkoušeno, že i při deseti snímcích za sekundu je počítačová hra použitelná, aby pohyb působil na člověka plynule, potřebujeme více, dejme tomu 25 snímků za vteřinu. Doba, po kterou má být daný snímek na obrazovce, je tedy 1/25 vteřiny (40 milisekund). Je na programátorovi, aby spojením svého umu a dostatečně rychlých knihoven vytvořil program, který na počítačích současnosti vůbec zvládne spočítat a zobrazit scénu za tento (resp. kratší) čas. Pokud jsme snímek vykreslili za kratší čas, dejme tomu 38 ms, musíme provést 2 ms pauzu.

V multitaskingových systémech není vhodné dělat vlastní zacyklení, které zbytečně zdržuje systém, správným řešením je zavolat funkci, která náš program na kratičkou dobu "odstaví" a předá řízení jiným, současně běžícím úlohám (např. antivirus nebo další rezidentní programy).

Symbolický zápis našeho programu, vycházející ze syntaxe jazyka C, bude:

  dfps=25;                  // Požadovaný počet snímků za sekundu
  smyčka
  {
    a=systemovy_cas();
    spočtení scény;
    zobrazení scény;
    čtení vstupů;           // Např. klávesnice, myš...
    b=systemovy_cas();
    c=b-a;                  // Spočti, kolik času zabral tento snímek
    d=1/dfps-c;             // Spočti, jak dlouho je potřeba čekat
    pokud(d>0)
    {
      pauza(d);
    }
  }

Podmínka "pokud(d>0)" v našem zápise způsobí, že na pomalých počítačích, kde se scéna za daný čas ani nestihne vykreslit, poběží hra svou maximální (byť malou) rychlostí bez pauzy.

Přehlédnout nesmíme tyto čtyři okolnosti:
1. operační systém nevrátí našemu programu řízení přesně, ale s malým rozdílem
2. samo vykonání např. pauza(0) zabírá také nějaký čas
3. příkazy následující po načtení proměnné "b" taktéž zabírají čas
4. jádro operačního systému zřejmě nepracuje s jednotkou, kterou systému předáváme a od systému přebíráme (milisekunda nebo mikrosekunda), ale v určitých skocích, které se nazývají "clock tick".

V praxi proto doporučuji hodnotu v podmínce "pokud(d>0)" nahradit nějakým přiměřeně větším číslem, které určíme např. tak, že provedeme pauzu s délkou 1 a rozdílem aktuálních časů si zjistíme, jak dlouho pauza ve skutečnosti trvala. Testovat na pauze o délce 0 nedoporučuji, protože příslušná knihovna pravděpodobně okamžitě předá řízení zpátky a časovou ztrátu (v angličtině označovanou "overhead") tak nezjistíme.

Je potřeba smířit se s tím, že stabilizace rychlosti je nepřesná, na druhou stranu uživatel si ničeho nevšimne, pokud program jednu vteřinu zobrazí 25 snímků a další 23 nebo 27 snímků. Je pochopitelně možné program upravit i tak, aby sám uživatel mohl ovládat proměnnou dfps, tedy rychlost běhu programu.

Nyní buďme konkrétnější a podívejme se na tento zápis v jazyce C:
  #include <SDL/SDL.h>

  Uint32 time_a,
         time_b,
         time_c,
         time_d,
         overhead;

  dfps=25;
  time_a=SDL_GetTicks();
  SDL_Delay(1);
  time_b=SDL_GetTicks();
  overhead=time_b-time_a-1;       // Pauzu o délce 1, kterou jsme požadovali po systému, nesmíme zapomenut odečíst
  if(overhead<0)
  {
    overhead=0;
  }
  for(;;)
  {
    time_a=SDL_GetTicks();
    DrawScene();
    DisplayScene();
    ReadKeyboard();
    time_b=SDL_GetTicks();
    time_c=time_b-time_a;
    time_d=1000/dfps-time_c;
    if(time_d>overhead)
    {
      SDL_Delay(time_d);
    }
  }

Uvádím ho pro schválně, protože obsahuje vážnou chybu, která se relativně špatně odhaluje. Program se v průběhu hraní bude zasekávat. Víte proč?

Pokud definujeme proměnnou time_d takovýmto způsobem, nemůže obsahovat záporné hodnoty, takže se při první příležitosti, kdy se snímek nestihne vykreslit za 1/25 vteřiny (uživatel např. přesune okno s naší hrou), dostane do proměnné time_d namísto správné záporné hodnoty jakési obrovské kladné číslo, které po předání funkci SDL_Delay() způsobí pauzu téměř nekonečnou! Správná definice proměnných time_d i overhead zní:
  signed long time_d,
              overhead;
Pomocí symbolického zápisu si ukážeme, jak u běžícího programu zjistíme, kolik stihl vykreslit snímků za sekundu:
  snimek=0;                 // Prvotní nastavení proměnných
  fps=-1;                   // Počet snímků za poslední vteřinu
  a=systemovy_cas();        // Předpokládáme ve vteřinách
  smyčka
  {
    spočtení scény;
    zobrazení scény;
    čtení vstupů;           // Např. klávesnice, myš...
    pokud(fps==-1)
    {
      zobraz "FPS: nedostupné";
    }
    jinak
    {
      zobraz proměnnou fps;
    }
    snimek=snimek+1;        // Upozorňuji začínající programátory, že takto se zapisuje zvýšení proměnné o jedničku
    b=systemovy_cas();
    pokud(b-a>1)            // Je rozdíl časů větší než 1 vteřina?
    {
      fps=snimek;           // Ulož počet snímků za poslední vteřinu do proměnné "fps"
      a=b;                  // Poslední získaný čas se stává výchozím
      snimek=0;             // Snímky začni počítat od začátku
    }
  }

Všimněte si, že v první vteřině není výsledek ještě k dispozici, proměnná fps je naplněna hodnotou -1 a na obrazovce se objeví pouze slovo "nedostupné".

Metoda 3: Se změnou FPS vykreslujeme animovaný objekt po úměrně dlouhých skocích

Nevýhodou předchozí metody konstantního počtu snímků za vteřinu je, že když by ostatní procesy hru zpomalily natolik, že se tento požadovaný počet snímků nestihne vykreslit, zpomaluje se tím pádem i animace ve hře.

Následující příklad funguje tak, že ať je hodnota FPS malá nebo velká, animovaný pohyb se snaží působit pořád stejně rychle, z čehož vyplývá, že se změnou rychlosti běhu programu se musí ve stejném poměru měnit i vzdálenost (skok), s jakou se vykresluje animovaný objekt. V tomto případě se dá rychlost běhu regulovat pomocí PLUS a MINUS na keypadu, v praxi pak tato rychlost závisí na rychlosti vašeho CPU, na grafické kartě a vytíženosti systému, na kterém běží další programy.

S pomocí klávesy SHIFT se dá FPS zvyšovat a snižovat o deset jednotek.



Otestováno na OS Linux, ale mělo by bez úprav fungovat i pod MS Windows. Makefile:



Pozor na to, že před každým příkazem (g++, rm) musíte uvést znak tabulátor.

Obrázek strašidla ke stažení zde.
TrueType font ke stažení zde.

Dostupné funkce jazyka C určené k synchronizaci času

Přejděme ke konkrétním funkcím, které souvisejí s problematikou. Pokud chcete psát multiplatformní aplikaci, je třeba brát v úvahu zejména dostupnost funkcí v požadovaných systémech. Detaily jsem vypsal do přehledné tabulky na konci odstavce.

gettimeofday()

int gettimeofday(struct timeval *tv, struct timezone *tz)
Tato funkce je dostupná v Linuxu, ale v Microsoft Windows bohužel ne.

clock()

Funkce clock_t clock(void) pracuje podobně jako SDL_GetTicks(), tedy vrací jakýsi odhad času. Pokud vrácenou hodnotu chceme přepočítat na vteřiny, musíme jí vydělit konstantou CLOCKS_PER_SEC, která může být v různých operačních systémech různá. Pro Microsoft Windows je konkrétně 1000, jednotkou jsou tedy milisekundy. Pro Linux je 1000000 a jednotkou jsou mikrosekundy. Funkce je definovaná v hlavičkovém souboru "time.h".

sleep()

unsigned int sleep(unsigned int seconds)
Definovaná v "unistd.h". Na určenou dobu předá řízení operačnímu systému, čas se zadává v sekundách. Z toho vyplývá, že pro počítačové hry a animace nemá tato funkce valný význam, neboť nejmenší pauza, kterou je schopna provést, je jedna vteřina.
Funkce je dostupná pod Linuxem, v Microsoft Windows existuje obdoba, a to Sleep() a zastaralá funkce _sleep().
Pro kompilaci Sleep() je nejlepší includovat "windows.h". Čas akceptuje ale v milisekundách.

usleep()

int usleep(useconds_t usec)
Na určenou dobu předá řízení operačnímu systému, čas se zadává v mikrosekundách.
Definována v "unistd.h".
Tato funkce je dostupná v Linuxu, ale v Microsoft Windows bohužel ne.

SDL_GetTicks()

Uint32 SDL_GetTicks(void)
Vrací počet milisekund od inicializace knihovny SDL. Po zhruba 49 dnech proměnná přeteče a začíná znovu od nuly.
Definované v "SDL.h".
Funguje na všech platformách, kde běží SDL, tedy i pod Microsoft Windows a Linuxem.

SDL_Delay()

V rámci knihovny SDL je to void SDL_Delay(Uint32 ms) definovaný v "SDL.h". Na určenou dobu předá řízení operačnímu systému, čas se zadává v milisekundách.
Funguje na všech platformách, kde běží SDL, tedy i pod Microsoft Windows a Linuxem.

Celkový přehled

FunkceHlavičkový souborKnihovnaÚčelpod MS Windowspod Linuxem
gettimeofday()sys/time.hglibc (GNU)zjistí časneano
clock()sys/time.hstandardní knihovna Czjistí časanoano
sleep()unistd.hPOSIX Cpauzavarianty Sleep() a _sleep()ano
usleep()unistd.hPOSIX Cpauzaneano
SDL_GetTicks()v MS Windows i Linuxu SDL2/SDL.hSDL2zjistí časanoano
SDL_Delay()v MS Windows i Linuxu SDL2/SDL.hSDL2pauzaanoano


(c) 2011-2023 Daniel Bydžovský