Come non perdersi nel Game Loop

Nei dispositivi veloci il passo di integrazione è molto piccolo. “Ottimo”, direte voi, “questo significa che la nostra simulazione sarà più precisa e robusta.”. Sbagliato. I problemi su macchine veloci sono di altra natura ma non meno pericolosi.

In teoria passi di integrazione piccolissimi non fanno altro che aumentare la precisione della simulazione. In pratica questo non accade poiché le operazioni in virgola mobile sui computer soffrono di errori di rappresentazione. Se per esempio digitate su una shell python la semplice operazione 0.1+0.2 il risultato non è l’aspettato 0.3 bensì il numero 0.30000000000000004 e la situazione peggiora drasticamente più i numeri si avvicinano allo zero. Capirete che questi errori di rappresentazioni ripetuti 60/100 volte in un secondo possono falsare significativamente una simulazione.

Se per voi questo non è sufficiente pensate ad implementare un gioco online. Poiché l’entità degli errori di simulazione dipendono dalla macchina sulla quale viene eseguito il calcolo è molto probabile che la stessa scena, sottoposta agli stessi input abbia esiti completamente diversi su PC diversi. In un gioco online questo è semplicemente inaccettabile. Allo stesso modo non è accettabile per quei giochi che permettono di salvare i “replay” di una sessione di gioco.

Insomma. L’update variabile non solo non ci aiuta ma ci crea anche più problemi del caso semplice. Per questo ed altri motivi è meglio scartarla.

Constant FPS

A questo punto dobbiamo inventarci qualcosa di più elaborato. Il passo consiste nello scegliere un valore desiderato di FPS in modo che ogni macchina si comporti allo stesso modo. Per fare questo usiamo il seguente algoritmo.

FRAMES_PER_SECOND = 25
SKIP_TICKS = 1000 / FRAMES_PER_SECOND

next_step_time = GetTicks()
sleep_time = 0;

running = True;

while running  :
    update()
    rendering()

    next_step_time += SKIP_TICKS
    sleep_time = next_step_time - GetTicks()
    if sleep_time >= 0  :
        Sleep( sleep_time )
    else :
        # Ops. Siamo indietro...

Il funzionamento dell’algoritmo è piuttosto semplice. Esegui un iterazione e se hai tempo che ti avanza allora aspetta. Se per esempio impostiamo 10 FPS abbiamo la necessità che ogni iterazione impieghi 1/10 di secondo. Se il tempo con cui viene eseguito l’update e il rendering del gioco è minore di 1/10 di secondo allora l’algoritmo resta in attesa in modo tale che il tempo di ogni iterazione resti costantemente a 0.1 secondi.

Ma cosa succede se il tempo di update e rendering è maggiore di 0.1 secondi? Questo è il caso di dispositivi poco prestanti. In queste condizioni semplicemente il gioco rallenta. Poiché Update e Rendering sono sequenziali il rallentamento non è solo visivo ma totale: i comandi vengono ricevuti ed elaborati in ritardo e la reattività del gioco crolla fino ad essere ingiocabile. Per ovviare a questo dobbiamo abbassare il numero degli FPS.

Abbassare il valore di FPS però fa sì che in macchine prestanti vengano sprecate sempre più risorse. Perché limitare a 10 FPS un dispositivo che ne supporterebbe senza problemi almeno 50? È uno spreco prestazionale che molto spesso è inaccettabile. Tuttavia rimane una soluzione migliore delle precedenti due.

UPD Costante con MaxSkipFrame

Siamo quindi arrivati al primo dei game loop che garantisce un’ottimo compromesso fra prestazioni e scalabilità. Questo game loop può essere utilizzato senza vergognarsi in molti giochi.

FRAMES_PER_SECOND = 50
SKIP_TICKS = 1000 / FRAMES_PER_SECOND
MAX_FRAMESKIP = 10

next_step_time = GetTicks()
loop = 0;

running = True;

while running  :
    loop = 0

    while (GetTicks() > next_step_time) and (loop < MAX_FRAMESKIP) :
        update()
        next_step_time += SKIP_TICKS
        loop += 1

    rendering()

Questo nuovo ciclo offre un ottima esperienza di gioco. Inoltre è semplice il che è sempre un buon vantaggio. Per capire bene il suo funzionamento facciamo un esempio. Immaginiamo di volere 50 FPS e 10 frame skippabili (per usare un orrendo inglesismo) e all’inizio del programma viene inizializzata next_step_time con 5000 millisecondi.

Appena entriamo nel loop resettiamo la variabile loop che conterrà il numero di volte che è stato eseguito il ciclo interno (e quindi il numero di frame saltati). Dopodiché valutiamo la condizione del ciclo interno: alla prima esecuzione sarà sicuramente vera quindi entriamo ed eseguiamo l’update, aggiungiamo SKIP_TICKS a next_skip_time (che adesso assume il valore 5000 + 1000/50 = 5020).

A questo punto ci sono due casi: se l’update ha impiegato meno di 20 millisecondi (in generale update+rendering) la prossima chiamata di GetTicks() sarà minore di next_skip_time. Questo significa che abbiamo eseguito l’update “nei tempi stabiliti” e quindi usciamo dal ciclo per effettuare il rendering. Se invece l’update ha impiegato più di 20 millisecondi significa che non abbiamo tempo per fare il rendering quindi lo saltiamo a piè pari e continuiamo con la successiva fase di update, e così via fino a quando non recuperiamo il tempo perduto o, nel caso peggiore, raggiungiamo il massimo di frame saltabili.

Questo loop quindi ci permette di oscillare dinamicamente fra un massimo di 50 FPS fino ad un minimo di 5 FPS (50/10).

Schema di un MaxSkipFrame Game Loop

Schema del loop MaxSkipFrame. Come potete vedere, se Update + Rendering impiegano più del dovuto l’algoritmo salta il frame successivo per recuperare il tempo perduto.

Il problema è che anche in questo modo, sebbene non ci siano limiti teorici per gli FPS, è chiaro che una volta superato il limite imposto (nel nostro esempio 50) l’algoritmo disegnerà due o più frame identici. Questo impone quindi un limite effettivo di 50FPS. Ancora una volta per i PC velocissimi stiamo sprecando risorse (sebbene sia più semplice ottimizzare meglio le risorse per una gamma più vasta di dispositivi).

Esiste una soluzione? Si. È un po’ complessa ma può migliorare la resa grafica per dispositivi in grado di generare un grande numero di FPS. Andiamo a vedere quale.

Comments are closed.