Un motore per videogiochi (game engine) è composto da diversi componenti che forniscono varie funzionalità:

  • rendering della grafica 2D/3D
  • scene-graph: rappresentazione spaziale della scena grafica
  • gestione dell’audio e della musica
  • gestione delle leggi della fisica
  • gestione delle collisioni fra oggetti
  • scripting
  • animazione
  • intelligenza artificiale
  • gestione dei device I/O
  • networking
  • gestione delle risorse

Il motore grafico deve fornire i suoi servizi ad una pluralità di componenti applicativi. Durante il corso del gioco, ognuno dei vari componenti effettua delle operazioni di aggiornamento degli oggetti presenti sulla scena.
I videogiochi devono di visualizzare una successione di immagini fisse (frame) ad una velocità elevata, per dare l’illusione del movimento continuo. Il fattore tempo è essenziale; i due parametri principali da considerare sono i seguenti:

  • il tempo reale misurato dall’orologio della CPU
  • il tempo del gioco, che può coincidere o no con il tempo reale

La sigla FPS indica il numero dei frame visualizzati in un secondo (Frames Per Second). Il tempo del gioco (Game Speed) indica il numero degli aggiornamenti (Update) effettuati in un secondo.
Per uno studio approfondito dell’argomento sono utili i libri della bibliografia[1][2][3].


1) Il game loop

Il game loop è il nucleo centrale del codice che controlla il programma del videogioco. Si chiama loop poiché vengono eseguite iterazioni cicliche di aggiornamento con una data frequenza, fino a quando l’utente termina il gioco. Ogni iterazione prepara la visualizzazione di un frame. Durante ogni ciclo il game loop legge gli input esterni, aggiorna lo stato degli oggetti e prepara l’immagine della scena da visualizzare.
In genere la frequenza di aggiornamento del frame è di 30 o 60 frame al secondo (fps). In un gioco che gira a 30 fps il game loop esegue 30 iterazioni ogni secondo. Se fissiamo la frequenza a 30, allora il tempo per completare l’aggiornamento del frame non deve superare 33.3 millisecondi; se invece fissiamo gli fps a 60, dobbiamo finire l’aggiornamento in 16,6 millisecondi:

   1 secondo = 1000 millisecondi
1000/30 = ~33.3 millisecondi
1000/60 = ~16.6 millisecondi

Esistono diverse variazioni dell’algoritmo del game loop, dipendenti da vari fattori, in primo luogo la piattaforma hardware di destinazione. L’algoritmo tradizionale di base può essere rappresentato con il seguente diagramma a blocchi.

Diagramma a blocchi

2) Un generico game loop

La principale caratteristica del game loop è l’aggiornamento periodico dello stato del gioco e il rendering grafico 2D/3D della scena. Il pattern architetturale è il seguente:

void gameLoop() {
   start_game();
   while (true)
   {
      elaboraInput();
      aggiorna();
      generaOutput();
   }
   close_game();
}

2.1) Fase iniziale del gioco

Questa fase viene seguita una sola vola al momento del lancio di un gioco. Vengono eseguite le varie operazioni di inizializzazione richieste: caricamento di risorse (assets), preparazione dei dispositivi (devices) di input, impostazione iniziale degli oggetti (GameObjects) e loro proprietà, ecc.

2.2) Fase di elaborazione dell’input

La prima parte del game loop consiste nella elaborazione dei comandi di input, se presenti, del giocatore. Le tipologie di input possono essere suddivise in due categorie: digitali e analogiche. Esempi di input digitali sono il click del mouse o la pressione di un tasto della tastiera. Un classico input analogico è rappresentato dal movimento degli stick analogici di cui sono dotati i controller delle console casalinghe (Xbox, Playstation o Nintendo). I dispositivi mobili mettono a disposizione funzionalità di input diversi da tastiera, mouse o joystick. Il giocatore può interagire tramite il touch screen o anche tramite il multitouch. Altre possibilità di input sono fornite dall’accelerometro, dal giroscopio, dalle informazioni GPS, ecc.
L’elaborazione dell’input implica il riconoscimento dei vari tipi di dati immessi con i vari dispositivi: tastiera, mouse, joystick, etc. A questi deve essere aggiunta anche l’elaborazione di eventi temporali o di input generati da dispositivi esterni in rete.

2.3) Fase di aggiornamento

Questa fase consiste nell’aggiornamento di tutti gli oggetti attivi sulla scena (spesso si tratta di centinaia di oggetti).
In questa fase si entra all’interno del ciclo iterativo del game loop; le iterazioni verranno eseguite secondo una certa frequenza (30, 60 fps) fino alla fine del gioco. Lo scopo fondamentale di ogni ciclo di aggiornamento, è di aggiornare lo stato degli oggetti. Il programma deve intercettare gli eventi esterni e temporali che sono intervenuti nel periodo rispetto al frame precedente, e aggiornare tutti gli oggetti interessati.

Alcune azioni tipiche sono le seguenti:

  • aggiornamento degli oggetti e delle loro proprietà
  • controllo delle collisioni fra oggetti
  • calcolo dei percorsi ottimali fra due luoghi

L’elaborazione dell’input può comportare la gestione dei dati inviati tramite mouse, tastiera, joystick, ma anche di dati esterni nel caso di giochi multiplayer. Il game loop elabora l’input eventuale dell’utente, se presente, ma non rimane in attesa nel caso fosse assente, e prosegue con i cicli in continuazione.
La scena di un videogioco non è statica ma è dinamica. Anche quando l’utente non fornisce un input, la fisica degli attori presenti sulla scena e le animazioni producono in ogni caso dei cambiamenti, degli effetti visivi.

2.4) Fase di generazione dell’output

Lo scopo fondamentale di questa fase è la preparazione dell’immagine grafica 2D o 3D dei vari oggetti, da visualizzare sullo schermo. La presentazione della serie delle immagini grafiche (frame) con una velocità opportuna crea l’illusione del movimento. In genere i programmi applicativi si interfacciano con le funzioni della scheda grafica tramite delle API fornite da prodotti come OpenGL e DirectX. Le operazioni principali di questa fase sono le seguenti:

  • aggiornamento della telecamera
  • aggiornamento degli oggetti sulla scena
  • generazione del buffer con l’immagine della scena (rendering)
  • visualizzazione sullo schermo

Oltre alle immagine grafiche, è necessario generare altri tipi di dati di output: audio, musica, effetti sonori, dialoghi, ecc. Inoltre nei giochi multiplayer in rete devono essere preparati e inviati i dati via Internet.

2.5) Fase finale del gioco

Il gioco viene terminato quando il giocatore invia un comando di uscita dal gioco stesso; il game loop si interrompe e vengono eseguite funzioni finali, come la pulizia degli oggetti di memoria non più necessari.


3) Il problema del tempo

Il modello base sopra descritto presenta un limite fondamentale, in quanto non si ha alcun controllo sulla velocità del gioco, che sarà molto diversa a seconda della potenza del computer. Nel caso di scene che richiedono l’utilizzo di complessi algoritmi di intelligenza artificiale o di fisica del movimento, il gioco può risultare molto lento su computer poco potenti. Inoltre i giochi moderni devono poter essere eseguiti su varie piattaforme caratterizzate da potenza elaborativa diversa (Pc, Mac, Xbox, Playstation, smartphone Android o IOS, ecc.). Un obiettivo fondamentale da rispettare è quello di fare in modo che il gioco possa essere eseguito ad una velocità consistente sulle diverse piattaforme.
Una prima soluzione per questo problema è quella di introdurre una ulteriore fase di attesa, per cercare di uniformare i tempi nelle varie piattaforme.

3.1) Fase di attesa

Dopo aver completato la fase di generazione dell’output, il gioco resterà in attesa per un certo tempo, al fine di ottenere un processo temporale regolare, indipendente dal tempo impiegato nelle fasi precedenti del game loop. Senza questo controllo il gioco procederebbe con la massima velocità possibile permessa dalla piattaforma hardware e software di base. Per raggiungere questo obiettivo bisogna poter calcolare la differenza fra la frequenza desiderata del frame e il tempo richiesto dalla particolare CPU per completare la preparazione del frame. Naturalmente il tempo di attesa ha un valore molto piccolo dal punto di vista umano, tale da non essere percepibile dal giocatore.

void gameLoop() {
   start_game();
   while (true)
   {
      elaboraInput();
      aggiorna();
      generaOutput();
      attesa(delta);  
     // delta= tempoFissato - tempo utilizzato per il frame
   }
   close_game();
}

La funzione attesa (tempo) garantisce che la velocità del gioco non sia troppo grande. Tuttavia se il tempo di completamento del frame è maggiore del tempo fissato per il gioco allora l’algoritmo non è più valido. Per gestire questa situazione è necessario modificare ulteriormente l’algoritmo del game loop.

3.2) Utilizzo di un valore di FPS variabile

L’idea consiste nel rinunciare al valore fisso per la durata del frame, e impostare la durata del frame in base al tempo effettivo utilizzato per completare il frame precedente. Il valore della frequenza dei frame (fps) diventa variabile.

void gameLoop() {

   double tempoPrecedente = getCurrentTime();
  
   while (true)
   {
      double tempoCorrente = getCurrentTime();
      double elapsedTime = tempoCorrente - tempoPrecedente;
      elaboraInput();
      aggiorna(elapsedTime);
      generaOutput();
      tempoPrecedente = tempoCorrente;
   }
}  

Per ogni frame viene calcolato quanto tempo è passato dall’ultimo aggiornamento(elapsed time). Durante la fase di aggiornamento il motore grafico utilizzerà questa informazione per gestire la velocità di avanzamento del gioco.
Il framework Unity fornisce la funzione Time.deltaTime per calcolare il tempo in secondi speso per completare l’ultimo frame. Il calcolo del deltaTime utilizza l’orologio interno presente in ogni CPU; il motore grafico può accedere a questa informazione mediante delle specifiche API messe a disposizione dal sistema operativo. Il calcolo del deltaTime viene effettuato confrontando il tempo in cui è iniziata l’elaborazione del frame precedente con il tempo in cui è iniziata l’elaborazione del frame corrente.
La funzione Time.deltaTime permette di gestire il movimento degli oggetti non in base alla velocità del frame, ma in base al tempo della specifica piattaforma hardware.

3.3) Problemi con il tempo variabile

Nonostante i suoi vantaggi, l’utilizzo di un tempo variabile per la funzione di update, introduce un fattore negativo, in quanto rende il gioco non-deterministico e potenzialmente instabile.
Questo si verifica ad esempio nel caso di oggetti in movimento, che potrebbero assumere velocità troppo grandi, oppure di collisioni fra oggetti che potrebbero non essere intercettate. La simulazione dei fenomeni fisici richiede di utilizzare un intervallo costante.


4) Tempo di update fisso e rendering variabile

Per superare questi inconvenienti, si può scegliere una strategia intermedia, che effettua l’aggiornamento ad intervalli fissi, ma esegue la funzione di rendering grafico in modo flessibile.

Rendering variabile

Quindi l’aggiornamento viene fatto ad intervalli fissi di tempo, mentre il rendering è variabile. Un problema che si può presentare è quando effettuiamo il rendering in un punto intermedio fra due aggiornamenti. In questo caso il gioco visualizza la scena nell’intervallo fra due update. Questo sfasamento viene denominato LAG, e può dare luogo ad una visualizzazione distorta, non fluida ma che procede a scatti (stuttering). Per evitare queste situazioni è necessario effettuare il rendering tenendo conto di questo fenomeno.

void gameLoop() {

double tempoPrecedente = getCurrentTime();
double tempoAccumulato = 0.0;   // LAG
  while (true)
  {
    double tempoCorrente = getCurrentTime();
    double tempoElapsed = tempoCorrente - tempoPrecedente;
    tempoPrecedente = tempoCorrente;
    tempoAccumulato += tempoElapsed;  
    elaboraInput();

    while (tempoAccumulato >= intervalloAggiornamento)
    {
    aggiorna();
    tempoAccumulato -= intervalloAggiornamento;
    }
    effettuaRendering();
  }
}

La variabile intervalloAggiornamento viene impostata dall’esterno. In genere assume i valori 30 o 60 FPS, in funzione della piattaforma sulla quale viene eseguito il gioco.


5) Nuove architetture hardware

Negli ultimi anni i produttori di hardware hanno messo a disposizione nuove CPU multicore, che sono indispensabili per rispondere alla domanda sempre crescente di prestazioni in tutti i settori dell’informatica. La nuova tecnologia è disponibile anche nei dispositivi mobili.
Un singolo circuito integrato viene utilizzato per contenere più di un processore. Ognuno dei processori può eseguire un’applicazione separata in modo parallelo. L’obiettivo è creare un sistema in grado di completare diversi task nello stesso tempo. Anche le consolle per videogiochi sono dotate di processori multipli.
In questa situazione anche gli sviluppatori dei motori grafici e delle applicazioni si stanno adeguando per sfruttare al meglio le nuove potenzialità di parallelismo e concorrenza.
Naturalmente possono essere messe in concorrenza solo quelle parti del motore che non hanno legami di dipendenza fra loro. Alcune delle funzionalità del game engine che possono essere messe in parallelo sono:

  • gestione e caricamento delle risorse
  • elaborazione dei messaggi di input e di output (le interazioni con gli altri componenti possono essere racchiuse in buffers di input e output con cui le altri parti interagiscono)
  • operazioni grafiche primitive per preparare i componenti della scena
  • gestione degli effetti sonori inviati ai dispositivi hardware

6) Il game loop nel motore Unity

In questa sede accenniamo solo ad alcuni degli eventi principali del framework Unity.

6.1) Le funzioni Awake() e Start()

Queste due funzioni vengono chiamate automaticamente quando viene caricato uno script associato ad un oggetto. La funzione Awake() viene chiamata prima della funzione Start().

using UnityEngine;
using System.Collections;
public class AwakeAndStart : MonoBehaviour
{
    // La funzione Awake viene chiamata anche se il componente script non è abilitato; 
    void Awake ()
    {
        Debug.Log("Awake called.");
    }
    
    // La funzione Start viene chiamata dopo la funzione Awake e prima della Update;
    void Start ()
    {
        Debug.Log("Start called.");
    }
 
}

6.2) Funzioni Update() e FixedUpdate()

In genere gran parte degli aggiornamenti vengono eseguiti all’interno della funzione Update(). La funzione Update() viene chiamata ad ogni frame su tutti gli script attivi dei vari oggetti.
Questa funzione non viene chiamata ad intervalli regolari di tempo; l’intervallo fra due chiamate può dipendere dalla lunghezza della elaborazione del frame.
Mediante questa funzione ad esempio si aggiorna la posizione degli oggetti che si muovono (operando sulle componenti Transform e/o Rigidbody), in modo che vengano visualizzati nella nuova posizione dando l’illusione del movimento.
Nella funzione Update si eseguono anche controlli sull’input del giocatore.
In alcune situazioni c’è l’esigenza di effettuare gli aggiornamenti ad intervalli regolari. Per questo si utilizza la funzione FixedUpdate(). Questa funzione può essere chiamata un numero multiplo di volte per ogni frame, se la frequenza del frame è bassa, il contrario se la frequenza è alta.
La funzione FixedUpdate viene chiamata in genere prima di effettuare i calcoli relativi al motore fisico; viene utilizzata soprattutto per le operazioni che riguardano la fisica. Un esempio è costituito dal moto dei proiettili o dei missili: questi oggetti si muovono a grande velocità, ed è possibile che in un frame l’oggetto si trovi in una stanza e nel frame successivo nella stanza accanto, senza che venga rilevato il passaggio attraverso la parete, se non c’è un frame intermedio.

using UnityEngine;
using System.Collections;
public class UpdateAndFixedUpdate : MonoBehaviour
{
//Eseguita ad intervalli regolari (default: 0.02 sec)
    void FixedUpdate ()
    {
        Debug.Log("FixedUpdate tempo= " + Time.deltaTime);
    }
    
//eseguita ad intervalli non regolari  
    void Update ()
    {
        Debug.Log("Update tempo= " + Time.deltaTime);
    }
}

Per una esposizione completa degli eventi previsti nel framework Unity vedere EsecuzioneEventiUnity.


Conclusione

Il game loop è il cuore del programma che gestisce un videogioco. Inizialmente i giochi erano programmati con un singolo ciclo, come esposto all’inizio dell’articolo. Il paradigma del singolo ciclo verrà gradualmente sostituito con una nuova architettura parallela, che sfrutterà al meglio le nuove possibilità offerte dalle piattaforme hardware e permetterà di sviluppare giochi con crescente complessità.


Bibliografia

[1]Jason Gregory – Game Engine Architecture (CRC Press, 2014)

[2]Sanjay Madhav – Game Programming Algorithms and Techniques (Addison Wesley, 2014)

[3]Robert Nystrom – Game Programming Patterns (Genever-Benning, 2014)


0 commenti

Lascia un commento!