Strutture dati per interpolazione e threading?


20

Ultimamente ho avuto a che fare con alcuni problemi di jitter del frame rate con il mio gioco, e sembra che la soluzione migliore sarebbe quella suggerita da Glenn Fiedler (Gaffer su Games) nel classico Fix Your Timestep! articolo.

Ora, sto già utilizzando un time-step fisso per il mio aggiornamento. Il problema è che non sto facendo l'interpolazione suggerita per il rendering. Il risultato è che ottengo frame raddoppiati o saltati se la velocità di rendering non corrisponde alla frequenza di aggiornamento. Questi possono essere visivamente evidenti.

Quindi vorrei aggiungere l'interpolazione al mio gioco - e sono interessato a sapere come gli altri hanno strutturato i loro dati e il loro codice per supportare questo.

Ovviamente dovrò conservare (dove? / Come?) Due copie delle informazioni sullo stato del gioco relative al mio renderer, in modo che possano interpolare tra di loro.

Inoltre, questo sembra un buon posto per aggiungere thread. Immagino che un thread di aggiornamento possa funzionare su una terza copia dello stato del gioco, lasciando le altre due copie in sola lettura per il thread di rendering. (E 'questa una buona idea?)

Sembra che avere due o tre versioni dello stato del gioco potrebbe introdurre problemi di prestazioni e - cosa molto più importante - affidabilità e produttività degli sviluppatori, rispetto ad avere una sola versione. Quindi sono particolarmente interessato ai metodi per mitigare questi problemi.

Di particolare nota, penso, è il problema di come gestire l'aggiunta e la rimozione di oggetti dallo stato del gioco.

Infine, sembra che uno stato non sia direttamente necessario per il rendering, o sarebbe troppo difficile tracciare diverse versioni di (ad esempio: un motore fisico di terze parti che memorizza un singolo stato) - quindi sarei interessato a sapere come le persone hanno gestito quel tipo di dati all'interno di tale sistema.

Risposte:


4

Non tentare di replicare l'intero stato del gioco. Interpolare sarebbe un incubo. Basta isolare le parti che sono variabili e necessarie eseguendo il rendering (chiamiamolo "Stato visivo").

Per ogni classe di oggetti creare una classe di accompagnamento che sarà in grado di contenere l'oggetto Visual State. Questo oggetto verrà prodotto dalla simulazione e consumato dal rendering. L'interpolazione si collegherà facilmente tra. Se lo stato è immutabile e passato per valore, non avrai problemi di threading.

Il rendering di solito non ha bisogno di sapere nulla delle relazioni logiche tra gli oggetti, quindi la struttura utilizzata per il rendering sarà un vettore semplice o al massimo un albero semplice.

Esempio

Design tradizionale

class Actor
{
  Matrix4x3 position;
  float fuel;
  float armor;
  float stamina;
  float age;

  void Simulate(float deltaT)
  {
    age += deltaT;
    armor -= HitByAWeapon();
  }
}

Utilizzando lo stato visivo

class IVisualState
{
  public:
  virtual void Interpolate(const IVisualState &newVS, float f) {}
};
class Actor
{
  struct VisualState: public IVisualState
  {
    Matrix4x3 position;
    float fuel;
    float armor;
    float stamina;
    float age;

    virtual auto_ptr<IVisualState> Interpolate(const IVisualState &newVS, float f)
    {
      const VisualState &newState = static_cast<const VisualState &>(newVS);
      IVisualState *ret = new VisualState;
      ret->age = lerp(this->age,newState.age);
      // ... interpolate other properties as well, using any suitable interpolation method
      // liner, spline, slerp, whatever works best for the given property
      return ret;
    };
  };

  auto_ptr<VisualState> state_;

  void Simulate(float deltaT)
  {
    state_->age += deltaT;
    state_->armor -= HitByAWeapon();
  }
}

1
Il tuo esempio sarebbe più facile da leggere se non avessi usato "new" (una parola riservata in C ++) come nome di parametro.
Steve S,

3

La mia soluzione è molto meno elegante / complicata della maggior parte. Sto usando Box2D come mio motore fisico, quindi mantenere più di una copia dello stato del sistema non è gestibile (clonare il sistema fisico quindi provare a mantenerli sincronizzati, potrebbe esserci un modo migliore ma non sono riuscito a trovare uno).

Invece tengo un contatore corrente della generazione della fisica . Ogni aggiornamento aumenta la generazione della fisica, quando il sistema fisico raddoppia, anche il contatore della generazione si aggiorna.

Il sistema di rendering tiene traccia dell'ultima generazione di rendering e del delta da quella generazione. Quando si esegue il rendering di oggetti che desiderano interpolare la loro posizione, è possibile utilizzare questi valori insieme alla loro posizione e velocità per indovinare dove l'oggetto deve essere visualizzato.

Non ho affrontato cosa fare se il motore fisico era troppo veloce. Direi quasi che non dovresti interpolare per movimenti rapidi. Se hai fatto entrambe le cose, dovresti fare attenzione a non far saltare gli sprite indovinando troppo lentamente, quindi indovinando troppo velocemente.

Quando ho scritto le cose di interpolazione stavo eseguendo la grafica a 60Hz e la fisica a 30Hz. Si scopre che Box2D è molto più stabile quando viene eseguito a 120Hz. Per questo motivo il mio codice di interpolazione diventa molto scarso. Raddoppiando il target framerate la fisica con aggiornamenti medi due volte per frame. Con jitter che potrebbe essere anche 1 o 3 volte, ma quasi mai 0 o 4+. Un tasso di fisica più elevato risolve da solo il problema dell'interpolazione. Quando esegui sia la fisica che il framerate a 60Hz potresti ottenere 0-2 aggiornamenti per frame. La differenza visiva tra 0 e 2 è enorme rispetto a 1 e 3.


3
Ho trovato anche questo. Un loop di fisica a 120Hz con un aggiornamento del frame vicino a 60Hz rende l'interpolazione quasi senza valore. Sfortunatamente questo funziona solo per il set di giochi che possono permettersi un loop di fisica a 120Hz.

Ho appena provato a passare a un ciclo di aggiornamento a 120Hz. Questo sembra avere il doppio vantaggio di rendere la mia fisica più stabile e rendere il mio gioco fluido con frame rate non abbastanza di 60Hz. Il rovescio della medaglia è che interrompe tutta la mia fisica di gioco attentamente sintonizzata, quindi questa è sicuramente un'opzione che deve essere scelta all'inizio di un progetto.
Andrew Russell,

Inoltre: in realtà non capisco la tua spiegazione del tuo sistema di interpolazione. Sembra un po 'di estrapolazione, in realtà?
Andrew Russell,

Ottima scelta. In realtà ho descritto un sistema di estrapolazione. Data la posizione, la velocità e quanto tempo dall'ultimo aggiornamento della fisica, estrapolo dove sarebbe l'oggetto se il motore fisico non si fosse bloccato.
deft_code

2

Ho sentito questo approccio ai timestep suggerito abbastanza frequentemente, ma in 10 anni nei giochi, non ho mai lavorato su un progetto del mondo reale che si basava su un timestep e un'interpolazione fissi.

Sembra generalmente più sforzo di un sistema a timestep variabile (supponendo una gamma ragionevole di framerate, nel tipo di gamma 25Hz-100Hz).

Ho provato l'approccio con timestep fisso + interpolazione una volta per un prototipo molto piccolo - nessun threading, ma aggiornamento logico a timestep fisso e rendering il più veloce possibile quando non lo aggiornavo. Il mio approccio lì era di avere alcune classi come CInterpolatedVector e CInterpolatedMatrix - che memorizzavano i valori precedenti / correnti e utilizzava un accessor dal codice di rendering, per recuperare il valore per il tempo di rendering corrente (che sarebbe sempre tra il precedente e tempi attuali)

Ogni oggetto di gioco, alla fine del suo aggiornamento, imposterebbe il suo stato attuale su un insieme di questi vettori / matrici interpolabili. Questo genere di cose potrebbe essere esteso per supportare il threading, avresti bisogno di almeno 3 set di valori - uno che era in fase di aggiornamento e almeno 2 valori precedenti per interpolare tra ...

Si noti che alcuni valori non possono essere banalmente interpolati (ad esempio "frame di animazione sprite", "effetto speciale attivo"). Potresti essere in grado di saltare completamente l'interpolazione o potrebbe causare problemi, a seconda delle esigenze del tuo gioco.

IMHO, è meglio andare con timestep variabile - a meno che non si stia realizzando un RTS o un altro gioco in cui si abbia un numero enorme di oggetti e si debbano mantenere 2 simulazioni indipendenti sincronizzate per i giochi di rete (inviando solo ordini / comandi tramite rete, piuttosto che posizioni degli oggetti). In quella situazione, l'opzione a tempo fisso è l'unica opzione.


1
Sembra che almeno Quake 3 stesse usando questo approccio, con un "tick" predefinito di 20 fps (50 ms).
Suma,

Interessante. Suppongo che abbia dei vantaggi per i giochi multiplayer per PC altamente competitivi, per garantire che PC più veloci / framerate più alti non ottengano troppo vantaggio (controlli più reattivi o differenze piccole ma sfruttabili nel comportamento fisico / di collisione) ?
bluescrn,

1
In 10 anni non ti sei imbattuto in alcun gioco che non ha funzionato con la fisica e non con la simulazione e il renderer? Perché nel momento in cui lo fai, dovrai praticamente interpolare o accettare il jerkiness percepito nelle tue animazioni.
Kaj,

2

Ovviamente dovrò conservare (dove? / Come?) Due copie delle informazioni sullo stato del gioco relative al mio renderer, in modo che possano interpolare tra di loro.

Sì, per fortuna la chiave qui è "rilevante per il mio renderer". Questo potrebbe non essere altro che aggiungere una vecchia posizione e un timestamp per esso nel mix. Date 2 posizioni è possibile interpolare in una posizione tra loro e se si dispone di un sistema di animazione 3D in genere è possibile richiedere la posa in quel preciso momento comunque.

È davvero molto semplice: immagina che il tuo renderer debba essere in grado di renderizzare il tuo oggetto di gioco. Era solito chiedere all'oggetto come fosse, ma ora deve chiedergli come sarebbe stato in un determinato momento. Hai solo bisogno di memorizzare tutte le informazioni necessarie per rispondere a questa domanda.

Inoltre, questo sembra un buon posto per aggiungere thread. Immagino che un thread di aggiornamento possa funzionare su una terza copia dello stato del gioco, lasciando le altre due copie in sola lettura per il thread di rendering. (E 'questa una buona idea?)

A questo punto sembra solo una ricetta per aggiungere dolore. Non ho riflettuto su tutte le implicazioni, ma immagino che potresti guadagnare un po 'di throughput extra a costo di una latenza più elevata. Oh, e potresti ottenere alcuni benefici dalla possibilità di usare un altro core, ma non lo so.


1

Nota Non sto effettivamente esaminando l'interpolazione, quindi questa risposta non la affronta; Mi preoccupo solo di avere una copia dello stato del gioco per il thread di rendering e un'altra per il thread di aggiornamento. Quindi non posso commentare il problema dell'interpolazione, anche se è possibile modificare la seguente soluzione per interpolare.

Mi stavo chiedendo questo mentre stavo progettando e pensando a un motore multithread. Quindi ho posto una domanda su Stack Overflow, su come implementare una sorta di modello di progettazione "journaling" o "transazioni" . Ho avuto delle buone risposte e la risposta accettata mi ha fatto davvero pensare.

È difficile creare un oggetto immutabile, poiché anche tutti i suoi figli devono essere immutabili e devi stare molto attento che tutto sia veramente immutabile. Ma se stai davvero attento, potresti creare una superclasse GameStateche contiene tutti i dati (e i sottodati e così via) nel tuo gioco; la parte "Modello" dello stile organizzativo Model-View-Controller.

Quindi, come dice Jeffrey , le istanze dell'oggetto GameState sono veloci, efficienti in termini di memoria e thread-safe. Il grande svantaggio è che per cambiare qualcosa sul modello, è necessario ricreare il modello, quindi è necessario fare molta attenzione affinché il codice non si trasformi in un casino enorme. L'impostazione di una variabile all'interno dell'oggetto GameState su un nuovo valore è più complessa che var = val;in termini di righe di codice.

Ne sono terribilmente incuriosito. Non è necessario copiare l'intera struttura di dati in ogni frame; basta copiare un puntatore alla struttura immutabile. Questo di per sé è molto impressionante, non sei d'accordo?


È davvero una struttura interessante. Tuttavia non sono sicuro che funzionerebbe bene per un gioco, poiché il caso generale è un albero piuttosto piatto di oggetti che ciascuno cambia esattamente una volta per fotogramma. Anche perché l'allocazione dinamica della memoria è un grande no-no.
Andrew Russell,

L'allocazione dinamica in un caso come questo è molto semplice da eseguire in modo efficiente. È possibile utilizzare un buffer circolare, crescere da un lato, rilasare dal secondo.
Suma,

... che non sarebbe allocazione dinamica, solo uso dinamico della memoria preallocata;)
Kaj

1

Ho iniziato con tre copie dello stato del gioco di ciascun nodo nel mio grafico della scena. Uno viene scritto dal thread del grafico della scena, uno viene letto dal renderer e un terzo è disponibile per la lettura / scrittura non appena uno di questi deve essere scambiato. Funzionava bene, ma era troppo complicato.

Poi ho capito che dovevo solo mantenere tre stati di ciò che sarebbe stato reso. Il mio thread di aggiornamento ora riempie uno dei tre buffer molto più piccoli di "RenderCommands" e il Renderer legge dal buffer più recente su cui non è attualmente in corso la scrittura, il che impedisce ai thread di attendere l'uno sull'altro.

Nella mia configurazione, ogni RenderCommand ha la geometria / i materiali 3d, una matrice di trasformazione e un elenco di luci che lo influenzano (ancora eseguendo il rendering in avanti).

Il mio thread di rendering non deve più eseguire alcun calcolo di abbattimento o distanza della luce, e questo ha velocizzato notevolmente le cose su scene di grandi dimensioni.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.