Was ist ein datenorientiertes Game Engine-Design?

Sie haben vielleicht von datenorientiertem Game-Engine-Design gehört, einem relativ neuen Konzept, das eine andere Denkweise als das eher objektorientierte Design bietet. In diesem Artikel werde ich erklären, worum es bei DOD geht und warum einige Entwickler von Game-Engines der Ansicht sind, dass dies das Ticket für spektakuläre Leistungssteigerungen sein könnte.

Ein bisschen Geschichte

In den frühen Jahren der Spieleentwicklung wurden Spiele und ihre Engines in Sprachen der alten Schule wie z. B. C geschrieben. Sie waren ein Nischenprodukt, und das Herauspressen jeden letzten Taktzyklus aus langsamer Hardware war zu dieser Zeit die höchste Priorität. In den meisten Fällen hackte nur eine bescheidene Anzahl von Leuten den Code eines einzelnen Titels, und sie kannten die gesamte Codebasis auswendig. Die Tools, die sie verwendeten, hatten ihnen gute Dienste geleistet, und C bot die Leistungsvorteile, die es ihnen ermöglichten, das Beste aus der CPU herauszuholen. Da diese Spiele immer noch von der CPU gebunden waren, zogen sie ihre eigenen Frame-Puffer an. Dies war ein sehr wichtiger Punkt.

Mit dem Aufkommen von GPUs, die die Zahlen mit Dreiecken, Texeln, Pixeln usw. bearbeiten, sind wir weniger auf die CPU angewiesen. Gleichzeitig ist die Spieleindustrie stetig gewachsen: Immer mehr Menschen möchten mehr und mehr Spiele spielen, was wiederum dazu führt, dass immer mehr Teams zusammenkommen, um sie zu entwickeln. 

Das Mooresche Gesetz zeigt, dass das Wachstum der Hardware exponentiell und nicht zeitlich linear ist: Dies bedeutet, dass sich die Anzahl der Transistoren, die wir auf einer einzigen Platine montieren können, alle paar Jahre nicht um einen konstanten Betrag ändert, sondern verdoppelt!

Größere Teams benötigten eine bessere Zusammenarbeit. Es dauerte nicht lange, bis die Game-Engines mit ihrem komplexen Level, ihrer KI, ihrer Keulung und ihrer Rendering-Logik die Codierer disziplinierter waren und ihre bevorzugte Waffe waren Objektorientiertes Design.

Wie Paul Graham einmal sagte: 

In großen Unternehmen wird Software in der Regel von großen (und häufig wechselnden) Teams mittelmäßiger Programmierer geschrieben. Die objektorientierte Programmierung legt diesen Programmierern eine Disziplin auf, die verhindert, dass einer von ihnen zu viel Schaden anrichtet.

Unabhängig davon, ob es uns gefällt oder nicht, dies muss zu einem gewissen Grad zutreffen. Größere Unternehmen haben begonnen, größere und bessere Spiele zu implementieren. Als sich die Standardisierung der Tools herausstellte, wurden die Hacker, die an Spielen arbeiteten, zu Teilen, die einfacher ausgetauscht werden konnten. Die Tugend eines bestimmten Hackers wurde immer weniger wichtig.

Probleme mit objektorientiertem Design

Während objektorientiertes Design ein schönes Konzept ist, das Entwicklern bei großen Projekten wie Spielen hilft, mehrere Abstraktionsebenen zu erstellen und alle Benutzer auf ihrer Zielebene arbeiten zu lassen, ohne sich um die Implementierungsdetails der darunter liegenden kümmern zu müssen, ist dies unumgänglich Gib uns etwas Kopfschmerzen.

Wir sehen eine Explosion von parallelen Programmiercodierern, die alle verfügbaren Prozessorkerne für atemberaubende Rechengeschwindigkeiten nutzen. Gleichzeitig wird die Spiellandschaft immer komplexer, und wenn wir mit diesem Trend Schritt halten wollen und trotzdem die Frames liefern wollen - pro Sekunde, die unsere Spieler erwarten, müssen wir es auch tun. Durch die Nutzung aller zur Verfügung stehenden Geschwindigkeit können wir Türen für völlig neue Möglichkeiten öffnen: Zum Beispiel kann die CPU-Zeit dazu verwendet werden, die Gesamtzahl der an die GPU gesendeten Daten zu reduzieren.

Bei der objektorientierten Programmierung behalten Sie den Zustand innerhalb eines Objekts bei, sodass Sie Konzepte wie Synchronisationsprimitive einführen müssen, wenn Sie von mehreren Threads aus daran arbeiten möchten. Für jeden virtuellen Funktionsaufruf, den Sie vornehmen, gibt es eine neue Umleitungsebene. Und die Speicherzugriffsmuster werden durch objektorientiert geschriebenen Code erzeugt können Aber schrecklich, in der Tat hat Mike Acton (Insomniac Games, Ex-Rockstar Games) eine Reihe von Folien, die ein Beispiel beiläufig erklären. 

Robert Harper, Professor an der Carnegie Mellon University, formulierte es ähnlich: 

Die objektorientierte Programmierung ist von Natur aus […] sowohl antimodular als auch antiparallel und daher für ein modernes CS-Curriculum nicht geeignet.

Über OOP wie dieses zu sprechen ist schwierig, da OOP ein riesiges Spektrum an Eigenschaften umfasst und nicht jeder einverstanden ist, was OOP bedeutet. In diesem Sinne spreche ich meistens von OOP, wie es von C ++ implementiert wird, weil dies derzeit die Sprache ist, die die Game Engine-Welt stark beherrscht.

Wir wissen also, dass Spiele parallel werden müssen, weil Es gibt immer mehr Arbeit, die die CPU leisten kann (muss, aber nicht muss), und Ausgabezyklen, die warten, bis die GPU die Verarbeitung abgeschlossen hat, sind einfach verschwenderisch. Wir wissen auch, dass wir bei herkömmlichen OO-Design-Ansätzen teure Sperrenkonflikte einführen müssen, und gleichzeitig die Cache-Lokalität verletzen oder unnötige Verzweigungen (die sehr teuer sein können) in den unerwartetsten Umständen verursachen können.

Wenn wir nicht mehrere Kerne nutzen, verwenden wir die gleiche Menge an CPU-Ressourcen, auch wenn die Hardware willkürlich besser wird (mehr Kerne hat). Gleichzeitig können wir die GPU an ihre Grenzen stoßen, weil sie konstruktionsbedingt parallel ist und jede Menge Arbeit gleichzeitig übernehmen kann. Dies kann unsere Mission beeinträchtigen, den Spielern die beste Erfahrung mit ihrer Hardware zu bieten, da wir sie offensichtlich nicht voll ausschöpfen.

Dies wirft die Frage auf: Sollten wir unsere Paradigmen insgesamt überdenken??

Geben Sie Folgendes ein: Datenorientiertes Design

Einige Befürworter dieser Methodik haben namens es ist datenorientiertes Design, aber die Wahrheit ist, dass das allgemeine Konzept schon lange bekannt ist. Die Grundvoraussetzung ist einfach: Erstellen Sie Ihren Code um die Datenstrukturen und beschreiben Sie, was Sie hinsichtlich der Manipulation dieser Strukturen erreichen möchten

Wir haben diese Art von Vortrag schon einmal gehört: Linus Torvalds, der Schöpfer von Linux und Git, sagte in einem Git-Mailinglisten-Post, dass er ein großer Befürworter des "Entwerfens des Codes um die Daten ist und nicht umgekehrt" und schreibt dies als einen der Gründe für den Erfolg von Git an. Er behauptet sogar, der Unterschied zwischen einem guten und einem schlechten Programmierer bestehe darin, ob sie sich um Datenstrukturen oder den Code selbst kümmere.

Die Aufgabe mag auf den ersten Blick nicht eingängig erscheinen, da Sie Ihr Denkmodell auf den Kopf stellen müssen. Stellen Sie sich das so vor: Ein Spiel erfasst während des Laufens alle Eingaben des Benutzers und alle leistungslastigen Teile davon (diejenigen, bei denen es sinnvoll wäre, den Standard aufzuheben) alles ist ein objekt Philosophie) verlassen sich nicht auf externe Faktoren wie Netzwerk oder IPC. Für alles, was Sie wissen, verbraucht ein Spiel Benutzerereignisse (Maus bewegt, Joystick-Taste gedrückt usw.) und den aktuellen Spielzustand und wandelt diese in einen neuen Satz von Daten um, z. B. Stapel, die an die GPU gesendet werden. PCM-Beispiele, die an die Audiokarte gesendet werden, und einen neuen Spielstatus.

Dieses "Data Churning" kann in viele weitere Unterprozesse unterteilt werden. Ein Animationssystem übernimmt die nächsten Keyframe-Daten und den aktuellen Status und erzeugt einen neuen Status. Ein Partikelsystem nimmt seinen aktuellen Zustand (Partikelpositionen, Geschwindigkeiten usw.) und einen zeitlichen Fortschritt an und erzeugt einen neuen Zustand. Ein Culling-Algorithmus benötigt einen Satz von möglichen Render-Elementen und erzeugt einen kleineren Satz von Render-Werten. Fast alles in einer Spiel-Engine kann man sich so vorstellen, als würde man einen Datenblock manipulieren, um einen weiteren Datenblock zu erzeugen.

Prozessoren lieben die Lokalisierung der Referenz und die Verwendung des Cache. Daher neigen wir beim datenorientierten Design dazu, wo immer es möglich ist, alles in großen, homogenen Arrays zu organisieren und, wo immer möglich, gute, Cache-kohärente Brute-Force-Algorithmen anstelle eines potentiell schickeren (der über ein Z-System verfügt) auszuführen Bessere Big O-Kosten, jedoch nicht die Architekturbeschränkungen der Hardware, mit der sie arbeitet.. 

Bei einer Ausführung pro Bild (oder mehrmals pro Bild) führt dies möglicherweise zu enormen Leistungsvorteilen. Zum Beispiel berichten die Leute bei Scalyr, dass sie Protokolldateien mit einer Geschwindigkeit von 20 GB / s durchsuchen, wobei sie einen sorgfältig ausgearbeiteten, aber naiv klingenden linearen Brute-Force-Scan verwenden. 

Wenn wir Objekte verarbeiten, müssen wir sie als "Black Boxes" betrachten und ihre Methoden aufrufen, die wiederum auf die Daten zugreifen und uns erhalten, was wir wollen (oder die gewünschten Änderungen vornehmen). Dies ist ideal für Wartungsarbeiten, aber nicht zu wissen, wie unsere Daten angeordnet sind, kann die Leistung beeinträchtigen.

Beispiele

Beim datenorientierten Design müssen wir uns alle mit Daten beschäftigen. Lassen Sie uns also etwas anders machen, als wir es normalerweise tun. Betrachten Sie diesen Code:

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

Obwohl es vielfach vereinfacht ist, wird dieses Muster häufig in objektorientierten Game-Engines gesehen. Aber warten Sie, wenn viele Render-Elemente nicht wirklich sichtbar sind, stoßen wir auf eine Reihe von Verzweigungsvorhersagen, die dazu führen, dass der Prozessor einige Anweisungen in den Papierkorb bringt, die er in der Hoffnung ausgeführt hat, dass ein bestimmter Zweig genommen wurde. 

Für kleine Szenen ist dies offensichtlich kein Problem. Aber wie oft machen Sie diese bestimmte Sache, nicht nur beim Rendern in der Warteschlange, sondern beim Durchlaufen von Szenenlichtern, Splittern von Schattenkarten, Zonen oder dergleichen? Wie wäre es mit AI- oder Animations-Updates? Multiplizieren Sie alles, was Sie in der gesamten Szene tun, sehen Sie, wie viele Taktzyklen Sie verbrauchen, berechnen Sie, wie viel Zeit Ihrem Prozessor zur Verfügung steht, um alle GPU-Stapel für einen stabilen 120FPS-Rhythmus bereitzustellen, und Sie sehen diese Dinge können auf einen beträchtlichen Betrag skalieren. 

Es wäre komisch, wenn beispielsweise ein Hacker, der an einer Web-App arbeitet, sogar solche winzigen Mikrooptimierungen in Betracht zieht, aber wir wissen, dass Spiele Echtzeitsysteme sind, in denen die Ressourceneinschränkungen unglaublich eng sind, so dass diese Überlegung für uns nicht falsch ist.

Um dies zu vermeiden, sollten wir auf eine andere Art und Weise darüber nachdenken: Was wäre, wenn wir die Liste der sichtbaren Render-Elemente in der Engine behalten würden? Sicher, wir würden die ordentliche Syntax von opfern myRenerable-> hide () und verstoßen gegen einige OOP-Prinzipien, aber wir könnten dies dann tun:

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

Hurra! Keine falschen Vorhersagen und Annahmen mVisibleRenderables ist ein schönes std :: vector (was ein zusammenhängendes Array ist), hätten wir dies genauso gut wie ein schnelles umschreiben können Memcpy Aufruf (mit ein paar zusätzlichen Updates unserer Datenstrukturen, wahrscheinlich).

Nun können Sie mich auf die schiere Käserei dieser Codebeispiele aufmerksam machen, und Sie werden Recht haben: dies ist vereinfacht viel. Aber um ehrlich zu sein, ich habe noch nicht einmal die Oberfläche gekratzt. Das Nachdenken über Datenstrukturen und deren Beziehungen eröffnet uns eine Vielzahl von Möglichkeiten, an die wir noch nicht gedacht haben. Lassen Sie uns als Nächstes einige davon betrachten.

Parallelisierung und Vektorisierung

Wenn wir über einfache, gut definierte Funktionen verfügen, die große Datenblöcke als Basisbausteine ​​für unsere Verarbeitung verwenden, können Sie leicht vier, acht oder 16 Arbeitsthreads erzeugen und jedem von ihnen ein Stück Daten geben, um die gesamte CPU zu behalten Kerne beschäftigt. Keine Mutexe, Atomics oder Sperrenkonflikte, und sobald Sie die Daten benötigen, müssen Sie nur noch an allen Threads teilnehmen und warten, bis sie fertig sind. Wenn Sie Daten parallel sortieren müssen (eine sehr häufige Aufgabe bei der Vorbereitung von Daten, die an die GPU gesendet werden sollen), müssen Sie dies aus einer anderen Perspektive betrachten. Diese Folien können hilfreich sein.

Als zusätzlicher Bonus können Sie innerhalb eines Threads SIMD-Vektoranweisungen (wie SSE / SSE2 / SSE3) verwenden, um eine zusätzliche Geschwindigkeitssteigerung zu erzielen. Manchmal können Sie dies nur erreichen, indem Sie Ihre Daten auf eine andere Art und Weise ablegen, z. B. indem Sie Vektor-Arrays in einer so genannten „Structure of Arrays“ (SoA) -Anordnung (z. B. XXX… JJJ… ZZZ… ) und nicht das herkömmliche Array von Strukturen (AoS), das wäre XYZXYZXYZ… ). Ich kratze hier kaum die Oberfläche. Weitere Informationen finden Sie im Lesen Sie weiter Abschnitt unten.

Wenn unsere Algorithmen die Daten direkt verarbeiten, wird es schwierig, sie zu parallelisieren, und wir können auch einige Geschwindigkeitsnachteile vermeiden.

Unit-Tests, die Sie nicht wussten, waren möglich

Mit einfachen Funktionen ohne externe Effekte können sie leicht getestet werden. Dies kann besonders gut in Form von Regressionstests für Algorithmen sein, die Sie leicht ein- und auswechseln möchten. 

Sie können zum Beispiel eine Testsuite für das Verhalten eines Ausschlussalgorithmus erstellen, eine orchestrierte Umgebung einrichten und die Leistung genau messen. Wenn Sie einen neuen Culling-Algorithmus entwickeln, führen Sie denselben Test erneut ohne Änderungen durch. Sie messen die Leistung und die Korrektheit, sodass Sie eine Beurteilung an Ihren Fingerspitzen haben. 

Je mehr Sie sich mit den datenorientierten Designansätzen beschäftigen, desto einfacher wird es, Aspekte Ihrer Spiel-Engine zu testen.

Klassen und Objekte mit monolithischen Daten kombinieren

Datenorientiertes Design ist keineswegs gegen objektorientierte Programmierung, nur einige seiner Ideen. Als Ergebnis können Sie es ganz ordentlich verwenden Ideen vom datenorientierten Design und erhalten Sie trotzdem die meisten Abstraktionen und Denkmodelle, die Sie gewohnt sind. 

Schauen Sie sich zum Beispiel die Arbeit an OGRE Version 2.0 an: Matias Goldberg, der Vordenker hinter diesem Vorhaben, hat sich entschieden, Daten in großen, homogenen Arrays zu speichern und Funktionen zu haben, die ganze Arrays durchlaufen und nicht nur an einem Datum arbeiten , um Ogre zu beschleunigen. Laut einer Benchmark (die er zugegebenermaßen sehr unfair ist, kann der gemessene Leistungsvorteil nicht sein) nur deshalb funktioniert es jetzt dreimal schneller. Nicht nur das, er behielt eine Menge der alten, bekannten Klassenabstraktionen bei, so dass die API weit davon entfernt war, sie komplett neu zu schreiben.

Ist es praktisch??

Es gibt viele Beweise dafür, dass Spiel-Engines auf diese Weise entwickelt werden können und werden.

Der Entwicklungsblog von Molecule Engine hat eine Serie mit dem Namen Abenteuer im datenorientierten Design,und enthält viele nützliche Hinweise, wo DOD mit großartigen Ergebnissen eingesetzt wurde.

DICE scheint an datenorientiertem Design interessiert zu sein, da sie es im Abbruchsystem der Frostbite Engine eingesetzt haben (und auch bedeutende Beschleunigungen erhalten haben!). Einige andere Folien von ihnen beinhalten auch ein datenorientiertes Design im KI-Subsystem, das einen Blick wert ist.

Abgesehen davon scheinen Entwickler wie der zuvor erwähnte Mike Acton das Konzept zu begrüßen. Es gibt ein paar Benchmarks, die beweisen, dass es eine Menge an Leistung bringt, aber ich habe seit geraumer Zeit nicht viel Aktivität im Bereich des datenorientierten Designs gesehen. Es könnte natürlich nur eine Modeerscheinung sein, aber seine Haupträume scheinen sehr logisch. In diesem Geschäft (und auch in anderen Softwareentwicklungsgeschäften) gibt es sicherlich eine gewisse Trägheit, so dass dies die Einführung einer solchen Philosophie in großem Umfang verhindern kann. Oder vielleicht ist es keine so gute Idee, wie es scheint. Was denkst du? Kommentare sind sehr willkommen!

Lesen Sie weiter

  1. Datenorientiertes Design (oder warum Sie sich mit OOP in den Fuß schießen könnten)
  2. Einführung in datenorientiertes Design [DICE] 
  3. Eine ziemlich nette Diskussion über Stack Overflow 
  4. Ein Online-Buch von Richard Fabian, das viele Konzepte erläutert 
  5. Ein Benchmark, der die andere Seite der Geschichte zeigt, ein scheinbar kontraintuitives Ergebnis 
  6. Mike Actons Testbericht zu OgreNode.cpp enthüllt einige häufige Fallstricke bei der Entwicklung der OOP-Game-Engine