Qual è il design del motore di gioco orientato ai dati?

Potresti aver sentito parlare di design di motori di gioco orientati ai dati, un concetto relativamente nuovo che propone una mentalità diversa rispetto al più tradizionale design orientato agli oggetti. In questo articolo, spiegherò che cos'è DOD e perché alcuni sviluppatori di motori di gioco sentono che potrebbe essere il biglietto per guadagni spettacolari in termini di prestazioni.

Un po 'di storia

Nei primi anni di sviluppo del gioco, i giochi e i loro motori erano scritti in lingue della vecchia scuola, come C. Erano un prodotto di nicchia, e l'ultimo ciclo di clock con hardware lento era, al momento, la massima priorità. Nella maggior parte dei casi, c'era solo un modesto numero di persone che hackeravano il codice di un singolo titolo e conoscevano l'intero codice base a memoria. Gli strumenti che stavano usando li stavano servendo bene, e C stava fornendo i benefici delle prestazioni che consentivano loro di spingere al massimo la CPU - e poiché questi giochi erano ancora vincolati dalla CPU, attingendo ai propri buffer di frame, questo era un punto molto importante.

Con l'avvento delle GPU che eseguono il lavoro di calcolo dei numeri su triangoli, texel, pixel e così via, siamo arrivati ​​a dipendere meno dalla CPU. Allo stesso tempo, l'industria del gioco ha visto una crescita costante: sempre più persone vogliono giocare sempre più giochi, e questo a sua volta ha portato a un numero sempre maggiore di team che si uniscono per svilupparli. 

La legge di Moore mostra che la crescita dell'hardware è esponenziale, non lineare rispetto al tempo: questo significa che ogni due anni, il numero di transistor che possiamo montare su una singola scheda non cambia di una quantità costante, ma raddoppia!

I team più grandi necessitavano di una migliore cooperazione. In poco tempo, i motori di gioco, con il loro livello complesso, l'intelligenza artificiale, l'abbattimento e la logica di rendering richiedevano che i programmatori fossero più disciplinati e la loro arma preferita era design orientato agli oggetti.

Come disse una volta Paul Graham: 

Nelle grandi aziende, il software tende ad essere scritto da grandi (e spesso mutevoli) team di programmatori mediocri. La programmazione orientata agli oggetti impone una disciplina a questi programmatori che impedisce a uno di loro di fare troppi danni.

Che ci piaccia o no, questo deve essere vero fino a un certo punto: aziende più grandi hanno iniziato a distribuire giochi più grandi e migliori, e quando è emersa la standardizzazione degli strumenti, gli hacker che lavorano sui giochi sono diventati parti che potrebbero essere sostituite più facilmente. La virtù di un particolare hacker è diventata sempre meno importante.

Problemi con il design orientato agli oggetti

Mentre il design orientato agli oggetti è un buon concetto che aiuta gli sviluppatori in grandi progetti, come i giochi, crea diversi livelli di astrazione e fa lavorare tutti sul loro livello di destinazione, senza doversi preoccupare dei dettagli di implementazione di quelli sottostanti, è destinato a Dacci alcuni mal di testa.

Vediamo un'esplosione di codificatori di programmazione paralleli che raccolgono tutti i core del processore disponibili per offrire velocità di calcolo incredibili, ma allo stesso tempo, lo scenario di gioco diventa sempre più complesso, e se vogliamo tenere il passo con questa tendenza e consegnare ancora i frame -per secondo i nostri giocatori si aspettano, anche noi dobbiamo farlo. Usando tutta la velocità che abbiamo a portata di mano, possiamo aprire le porte a possibilità completamente nuove: utilizzare il tempo della CPU per ridurre del tutto il numero di dati inviati alla GPU, ad esempio.

Nella programmazione orientata agli oggetti, si mantiene lo stato all'interno di un oggetto, che richiede l'introduzione di concetti come le primitive di sincronizzazione se si vuole lavorare su più thread. Hai un nuovo livello di riferimento per ogni chiamata di funzione virtuale che fai. E i modelli di accesso alla memoria generati dal codice scritto in modo orientato agli oggetti può essere pessimi - infatti, Mike Acton (Insomniac Games, ex-Rockstar Games) ha una grande serie di diapositive che spiega casualmente un esempio. 

Allo stesso modo, Robert Harper, professore alla Carnegie Mellon University, lo ha definito in questo modo: 

La programmazione orientata agli oggetti è [...] sia anti-modulare che anti-parallela per sua stessa natura, e quindi inadatta per un moderno curriculum CS.

Parlare di OOP in questo modo è complicato, perché l'OOP racchiude un enorme spettro di proprietà, e non tutti sono d'accordo sul significato di OOP. In questo senso, sto principalmente parlando di OOP come implementato da C ++, perché quello è attualmente il linguaggio che domina enormemente il mondo dei motori di gioco.

Quindi, sappiamo che i giochi devono essere paralleli perché c'è sempre più lavoro che la CPU può (ma non deve) fare, e i cicli di spesa in attesa che la GPU finisca di elaborare è solo uno spreco. Sappiamo anche che gli approcci di progettazione OO comuni ci impongono di introdurre costosi conflitti di blocco e, allo stesso tempo, possono violare la localizzazione della cache o causare inutili ramificazioni (che possono essere costose!) Nelle circostanze più impreviste.

Se non sfruttiamo più core, continuiamo a utilizzare la stessa quantità di risorse CPU anche se l'hardware diventa arbitrariamente migliore (ha più core). Allo stesso tempo, possiamo spingere GPU ai suoi limiti perché è, in base alla progettazione, parallela e in grado di assumere qualsiasi quantità di lavoro contemporaneamente. Questo può interferire con la nostra missione di fornire ai giocatori la migliore esperienza sul loro hardware, poiché chiaramente non lo stiamo sfruttando al massimo delle potenzialità.

Ciò solleva la domanda: dovremmo ripensare completamente i nostri paradigmi?

Invio: progettazione orientata ai dati

Alcuni sostenitori di questa metodologia hanno chiamato è un progetto orientato ai dati, ma la verità è che il concetto generale è stato conosciuto per molto più tempo. La sua premessa di base è semplice: costruisci il tuo codice intorno alle strutture dati e descrivi cosa vuoi ottenere in termini di manipolazioni di queste strutture

Abbiamo già sentito questo tipo di discorsi: Linus Torvalds, il creatore di Linux e Git, ha dichiarato in un post sulla mailing list di Git che è un grande sostenitore del "progettare il codice intorno ai dati, non il contrario", e attribuisce questo a uno dei motivi del successo di Git. Continua anche a sostenere che la differenza tra un buon programmatore e uno cattivo è se si preoccupa delle strutture dati o del codice stesso.

All'inizio il compito può sembrare controintuitivo, perché richiede di rovesciare il modello mentale. Ma pensalo in questo modo: un gioco, mentre è in esecuzione, acquisisce tutti gli input dell'utente e tutti i pezzi pesanti in termini di prestazioni (quelli in cui avrebbe senso abbandonare lo standard) tutto è un oggetto filosofia) non affidarsi a fattori esterni, come la rete o l'IPC. Per quel che ne sai, un gioco consuma eventi utente (il mouse viene spostato, il pulsante joystick premuto e così via) e lo stato attuale del gioco, e li trasforma in un nuovo set di dati, ad esempio i batch che vengono inviati alla GPU, Campioni PCM inviati alla scheda audio e un nuovo stato di gioco.

Questo "sfasamento dei dati" può essere suddiviso in più sottoprocessi. Un sistema di animazione prende i dati del fotogramma chiave successivo e lo stato corrente e produce un nuovo stato. Un sistema di particelle assume il suo stato attuale (posizioni delle particelle, velocità e così via) e un avanzamento temporale e produce un nuovo stato. Un algoritmo di cogli prende un insieme di oggetti di rendering candidati e produce un insieme più piccolo di oggetti di rendering. Quasi tutto in un motore di gioco può essere pensato come una manipolazione di una porzione di dati per produrre un'altra porzione di dati.

I processori amano la localizzazione di riferimento e l'utilizzo della cache. Quindi, nella progettazione orientata ai dati, tendiamo, ovunque possibile, a organizzare tutto in grandi matrici omogenee e, laddove possibile, eseguiamo algoritmi di forza bruta coerenti con la cache al posto di un algoritmo potenzialmente più elaborato (che ha un un costo maggiore di Big O, ma non riesce ad abbracciare i limiti di architettura dell'hardware su cui lavora). 

Quando viene eseguito per fotogramma (o più volte per fotogramma), ciò offre potenzialmente enormi ricompense nelle prestazioni. Ad esempio, la gente di Scalyr segnala la ricerca di file di log a 20 GB / sec utilizzando una scansione lineare a forza bruta dal suono accurato ma ingenuo. 

Quando elaboriamo gli oggetti, dobbiamo considerarli come "scatole nere" e chiamare i loro metodi, che a loro volta accedono ai dati e ci procurano ciò che vogliamo (o apportiamo le modifiche che vogliamo). Questo è ottimo per lavorare per la manutenibilità, ma non sapere come sono strutturati i nostri dati può essere dannoso per le prestazioni.

Esempi

La progettazione orientata ai dati ci fa riflettere sui dati, quindi facciamo qualcosa anche un po 'diverso da quello che facciamo di solito. Considera questo pezzo di codice:

void MyEngine :: queueRenderables () for (auto it = mRenderables.begin (); it! = mRenderables.end (); ++ it) if ((* it) -> isVisible ()) queueRenderable (* it ); 

Anche se semplificato molto, questo modello comune è quello che si vede spesso nei motori di gioco orientati agli oggetti. Ma aspettate - se un sacco di oggetti di rendering non sono effettivamente visibili, ci imbattiamo in molte errate previsioni di ramo che fanno sì che il processore rifiuti alcune istruzioni che aveva eseguito nella speranza che un particolare ramo sia stato preso. 

Per le scene piccole, questo ovviamente non è un problema. Ma quante volte si fa questa cosa particolare, non solo quando si mettono in coda i renderizzabili, ma quando si itera attraverso le luci di scena, la mappa delle ombre si divide, zone o simili? Che ne dici di AI o di aggiornamenti di animazione? Moltiplicate tutto ciò che fate durante la scena, osservate quanti cicli di clock espellete, calcolate quanto tempo il vostro processore è disponibile per fornire tutti i batch della GPU per un ritmo costante di 120FPS, e vedete che queste cose può scala ad una quantità considerevole. 

Sarebbe divertente se, ad esempio, un hacker che lavora su un'app Web considerasse tali micro-ottimizzazioni minuscole, ma sappiamo che i giochi sono sistemi in tempo reale in cui i vincoli di risorse sono incredibilmente stretti, quindi questa considerazione non è fuori luogo per noi.

Per evitare che ciò accada, pensiamoci in un altro modo: cosa succede se abbiamo mantenuto l'elenco dei renderable visibili nel motore? Certo, sacrificheremmo la sintassi ordinata di myRenerable-> hide () e violano alcuni principi OOP, ma potremmo quindi fare questo:

void MyEngine :: queueRenderables () for (auto it = mVisibleRenderables.begin (); it! = mVisibleRenderables.end (); ++ it) queueRenderable (* it); 

Evviva! Nessuna errata previsione dei rami e presunzione mVisibleRenderables è un bel std :: vector (che è un array contiguo), potremmo averlo riscritto come un digiuno memcpy chiamare (con alcuni aggiornamenti aggiuntivi alle nostre strutture dati, probabilmente).

Ora, puoi chiamarmi per pura gentilezza con questi esempi di codice e avrai perfettamente ragione: questo è semplificato Un sacco. Ma ad essere onesti, non ho ancora scalfito la superficie. Pensare alle strutture dati e alle loro relazioni ci apre a un sacco di possibilità a cui non avevamo mai pensato prima. Diamo un'occhiata ad alcuni di loro dopo.

Parallelizzazione e vettorizzazione

Se disponiamo di funzioni semplici e ben definite che operano su blocchi di dati di grandi dimensioni come blocchi di base per la nostra elaborazione, è facile generare quattro o otto o 16 thread di lavoro e fornire a ciascuno di essi una parte di dati per mantenere tutta la CPU core occupato. Nessun mutex, atomics o conflitto di blocco, e una volta che hai bisogno dei dati, devi solo unirti a tutti i thread e aspettare che finiscano. Se hai bisogno di ordinare i dati in parallelo (un compito molto frequente nella preparazione di materiale da inviare alla GPU), devi pensare a questo da una prospettiva diversa - queste diapositive potrebbero aiutare.

Come bonus aggiuntivo, all'interno di una discussione è possibile utilizzare le istruzioni vettoriali SIMD (come SSE / SSE2 / SSE3) per ottenere un ulteriore aumento di velocità. A volte, puoi farlo solo posizionando i tuoi dati in un modo diverso, ad esempio posizionando matrici vettoriali in una struttura di array (SoA) (come XXX ... YYY ... ZZZ ... ) piuttosto che la matrice convenzionale di strutture (AoS, sarebbe XYZXYZXYZ ... ). Qui sto a malapena a graffiare la superficie; puoi trovare maggiori informazioni nel Ulteriori letture sezione sottostante.

Quando i nostri algoritmi gestiscono direttamente i dati, diventa banale parallelizzarli e possiamo anche evitare alcuni inconvenienti di velocità.

Test delle unità che non sapevi fosse possibile

Avere funzioni semplici senza effetti esterni li rende facili da testare. Questo può essere particolarmente utile in una forma di test di regressione per gli algoritmi che si desidera scambiare e uscire facilmente. 

Ad esempio, è possibile creare una suite di test per il comportamento di un algoritmo di abbattimento, configurare un ambiente orchestrato e misurare esattamente il suo rendimento. Quando si escogita un nuovo algoritmo di abbattimento, si esegue nuovamente lo stesso test senza modifiche. Misurate le prestazioni e la correttezza, in modo da poter avere una valutazione a portata di mano. 

Man mano che ti avvicini maggiormente agli approcci di progettazione orientati ai dati, troverai più facile e più facile testare aspetti del tuo motore di gioco.

Combinazione di classi e oggetti con dati monolitici

La progettazione orientata ai dati non è affatto opposta alla programmazione orientata agli oggetti, solo alcune delle sue idee. Di conseguenza, puoi usare abbastanza ordinatamente idee dal design orientato ai dati e ottieni ancora la maggior parte delle astrazioni e dei modelli mentali a cui sei abituato. 

Dai un'occhiata, ad esempio, al lavoro su OGRE versione 2.0: Matias Goldberg, la mente dietro a questo sforzo, ha scelto di archiviare i dati in array grandi e omogenei e ha funzioni che iterano su interi array anziché lavorare su un solo dato , per accelerare l'Ogre. Secondo un punto di riferimento (che ammette è molto ingiusto, ma il vantaggio prestazionale misurato non può essere solo per questo motivo funziona ora tre volte più velocemente. Non solo: conservava molte delle vecchie e familiari astrazioni di classe, quindi l'API era lontana da una completa riscrittura.

È pratico??

Ci sono molte prove che i motori di gioco in questo modo possono e saranno sviluppati.

Il blog di sviluppo di Molecule Engine ha una serie chiamata Avventure nel design orientato ai dati,e contiene molti consigli utili su dove DOD è stato utilizzato con ottimi risultati.

DICE sembra essere interessato al design orientato ai dati, in quanto l'hanno utilizzato nel sistema di abbattimento di Frostbite Engine (e ottenuto anche significativi aumenti di velocità!). Alcune altre diapositive includono anche l'utilizzo di un design orientato ai dati nel sottosistema AI, che vale anche la pena di osservare.

Oltre a ciò, gli sviluppatori come Mike Acton di cui sopra sembrano abbracciare il concetto. Ci sono alcuni benchmark che dimostrano che guadagna molto in termini di prestazioni, ma non ho visto molte attività sul fronte del design orientato ai dati in un bel po 'di tempo. Potrebbe, naturalmente, essere solo una moda passeggera, ma le sue premesse principali sembrano molto logiche. In questo business c'è sicuramente molta inerzia (e qualsiasi altra attività di sviluppo di software, se è per questo), quindi questo potrebbe ostacolare l'adozione su larga scala di tale filosofia. O forse non è una grande idea come sembra. Cosa pensi? I commenti sono ben accetti!

Ulteriori letture

  1. Design orientato ai dati (o perché potresti spararti al piede con OOP)
  2. Introduzione al Data Oriented Design [DICE] 
  3. Una bella discussione su Stack Overflow 
  4. Un libro online di Richard Fabian che spiega molti dei concetti 
  5. Un punto di riferimento che mostra l'altro lato della storia, un risultato apparentemente contro-intuitivo 
  6. Revisione di Mike Acton su OgreNode.cpp, che rivela alcune trappole di sviluppo del motore di gioco OOP più comuni