Datenorientiertes Design (oder warum Sie sich mit OOP wahrscheinlich in den Fuß schießen)

Bild

Stellen Sie sich dieses Bild vor: Das Ende des Entwicklungszyklus rückt näher, Ihr Spiel schleicht sich kaum ein, aber im Profiler finden Sie keine offensichtlichen Problembereiche. Wer ist schuld? Arbeitsspeichermuster mit wahlfreiem Zugriff und anhaltende Cache-Fehler. Wenn Sie versuchen, die Leistung zu verbessern, versuchen Sie, Teile des Codes zu parallelisieren, aber es lohnt sich, heldenhaft zu arbeiten, und am Ende ist die Beschleunigung aufgrund der hinzuzufügenden Synchronisation kaum spürbar. Darüber hinaus ist der Code so kompliziert, dass das Beheben von Fehlern noch mehr Probleme verursacht und der Gedanke, neue Funktionen hinzuzufügen, sofort verworfen wird. Kommt Ihnen das bekannt vor?

Eine solche Entwicklung von Ereignissen beschreibt ziemlich genau fast jedes Spiel, an dessen Entwicklung ich in den letzten zehn Jahren teilgenommen habe. Die Gründe liegen nicht in Programmiersprachen oder Entwicklungswerkzeugen oder sogar in mangelnder Disziplin. Nach meiner Erfahrung sollte weitgehend die objektorientierte Programmierung (OOP) und ihre umgebende Kultur verantwortlich gemacht werden. OOP hilft vielleicht nicht, stört aber Ihre Projekte!

Es geht nur um Daten


OOP ist so weit in die bestehende Kultur der Videospielentwicklung eingedrungen, dass man sich kaum etwas anderes als Objekte vorstellen kann, wenn man an ein Spiel denkt. Seit vielen Jahren schaffen wir Klassen für Autos, Spieler und Staatsmaschinen. Was sind die Alternativen? Prozedurale Programmierung? Funktionssprachen? Exotische Programmiersprachen?

Datenorientiertes Design ist eine weitere Möglichkeit, Software zu entwickeln, mit der all diese Probleme gelöst werden können. Das Hauptelement der prozeduralen Programmierung sind Prozeduraufrufe, und OOP befasst sich hauptsächlich mit Objekten. Beachten Sie, dass in beiden Fällen der Code in die Mitte gestellt wird: In einem Fall handelt es sich um normale Prozeduren (oder Funktionen), in dem anderen um gruppierten Code, der einem bestimmten internen Status zugeordnet ist. Datenorientiertes Design verlagert den Fokus der Aufmerksamkeit von Objekten auf die Daten selbst: die Art der Daten, ihre Position im Speicher, die Methoden zum Lesen und Verarbeiten im Spiel.

Das Programmieren per Definition ist eine Methode zum Konvertieren von Daten: das Erstellen einer Folge von Maschinenanweisungen, die den Prozess der Verarbeitung von Eingabedaten und der Erstellung von Ausgabedaten beschreiben. Ein Spiel ist nichts anderes als ein interaktives Programm. Wäre es nicht logischer, sich hauptsächlich auf Daten zu konzentrieren und nicht auf den Code, der sie verarbeitet?

Um Sie nicht zu verwirren, erkläre ich sofort: Datenorientiertes Design bedeutet nicht, dass das Programm datengesteuert ist. Ein datengesteuertes Spiel ist normalerweise ein Spiel, dessen Funktionalität weitgehend außerhalb des Codes liegt. Es ermöglicht Daten, das Spielverhalten zu bestimmen. Dieses Konzept ist unabhängig vom datenorientierten Design und kann in jeder Programmiermethode verwendet werden.

Perfekte Daten


Anruffolge mit objektorientiertem Ansatz

Abbildung 1a. Aufrufsequenz mit objektorientiertem Ansatz

Wenn wir das Programm in Bezug auf Daten betrachten, wie sehen dann die idealen Daten aus? Dies hängt von den Daten selbst und ihrer Verwendung ab. Im Allgemeinen liegen ideale Daten in einem Format vor, das mit minimalem Aufwand verwendet werden kann. Im besten Fall stimmt das Format vollständig mit dem erwarteten Ausgabeergebnis überein, dh die Verarbeitung besteht nur aus dem Kopieren der Daten. Sehr oft sieht ein ideales Datenschema aus wie große Blöcke benachbarter homogener Daten, die nacheinander verarbeitet werden können. Wie auch immer, das Ziel ist es, die Anzahl der Transformationen zu minimieren. Wenn möglich, "backen" Sie die Daten in diesem idealen Format im Voraus, während Sie Spielressourcen erstellen.

Da beim datenorientierten Design die Daten an erster Stelle stehen, können wir die Architektur eines gesamten Programms um ein ideales Datenformat herum erstellen. Es wird uns nicht immer gelingen, es vollständig zu perfektionieren (so wie der Code selten OOP aus einem Lehrbuch ähnelt), aber dies ist unser Hauptziel, an das wir uns immer erinnern. Wenn wir dies erreichen, lösen sich die meisten der am Anfang des Artikels erwähnten Probleme einfach auf (mehr dazu im nächsten Abschnitt).

Wenn wir an Objekte denken, erinnern wir uns sofort an die Bäume - Vererbungsbäume, Nestbäume oder Nachrichtenbäume - und unsere Daten sind natürlich auf diese Weise geordnet. Wenn wir eine Operation an einem Objekt ausführen, führt dies normalerweise dazu, dass das Objekt wiederum auf andere Objekte im Baum zugreift. Wenn Sie über mehrere Objekte iterieren und dieselbe Operation ausführen, werden für jedes Objekt nachgeschaltete, völlig unterschiedliche Operationen generiert (siehe Abbildung 1a).

Anruffolge mit datenorientiertem Ansatz

Abbildung 1b. Anruffolge in datenorientierter Technik

Um das beste Datenspeicherungsschema zu erhalten, kann es nützlich sein, jedes Objekt in verschiedene Komponenten aufzuteilen und Komponenten desselben Typs im Speicher zu gruppieren, unabhängig davon, von welchem ​​Objekt wir sie genommen haben. Eine solche Reihenfolge führt zur Erstellung großer Blöcke homogener Daten, sodass wir die Daten nacheinander verarbeiten können (siehe Abbildung 1b). Der Hauptgrund für die Leistungsfähigkeit des datenorientierten Entwurfskonzepts liegt darin, dass es sehr gut mit großen Gruppen von Objekten funktioniert. OOP funktioniert per Definition mit einem einzelnen Objekt. Erinnern Sie sich an das letzte Spiel, an dem Sie gearbeitet haben: Wie oft gab es im Code Stellen, an denen Sie nur mit einem Element arbeiten mussten? Ein Feind? Ein Fahrzeug? Ein Weg Knoten finden? Eine Kugel? Ein Stück? Niemals! Wo es einen gibt, gibt es noch mehrere. OOP ignoriert dies und arbeitet mit jedem Objekt einzeln. Daher können wir die Arbeit für uns und die Ausrüstung vereinfachen, indem wir die Daten so anordnen, dass viele Elemente desselben Typs verarbeitet werden müssen.

Kommt Ihnen dieser Ansatz seltsam vor? Aber weißt du was? Höchstwahrscheinlich verwenden Sie es bereits in einigen Teilen des Codes: nämlich im Partikelsystem! Datenorientiertes Design verwandelt die gesamte Codebasis in ein riesiges Partikelsystem. Es ist möglich, dass diese Methode den Spieleentwicklern vertrauter erschien, sie müsste als partikelgesteuerte Programmierung bezeichnet werden.

Vorteile von datenorientiertem Design


Wenn wir zunächst über Daten nachdenken und auf dieser Grundlage die Architektur des Programms erstellen, ergeben sich daraus viele Vorteile.

Parallelität


Heutzutage ist es unmöglich, die Tatsache loszuwerden, dass wir mit mehreren Kernen arbeiten müssen. Diejenigen, die versucht haben, den OOP-Code zu parallelisieren, können bestätigen, wie komplex, fehleranfällig und möglicherweise nicht besonders effizient die Aufgabe ist. Oft müssen Sie viele Synchronisationsprimitive hinzufügen, um den gleichzeitigen Zugriff auf Daten von mehreren Threads zu vermeiden. In der Regel sind viele Threads lange Zeit inaktiv und warten darauf, dass andere Threads nicht mehr funktionieren. Infolgedessen sind Produktivitätssteigerungen ziemlich mittelmäßig.

Wenn wir datenorientiertes Design anwenden, wird die Parallelisierung viel einfacher: Wir haben Eingabedaten, eine kleine Funktion, die sie verarbeitet und Daten ausgibt. Ähnliches kann leicht in mehrere Streams mit minimaler Synchronisation zwischen ihnen unterteilt werden. Sie können sogar noch einen Schritt weiter gehen und diesen Code auf Prozessoren mit lokalem Speicher ausführen (z. B. in SPUs von Cell-Prozessoren), ohne Vorgänge zu ändern.

Cache-Nutzung


Neben der Verwendung von Multi-Core ist die Implementierung eines Datenzugriffs, der für das Caching geeignet ist, eine der wichtigsten Möglichkeiten, um auf modernen Geräten mit Deep-Instruction-Pipelines und langsamen Speichersystemen mit mehreren Cache-Ebenen eine hohe Leistung zu erzielen. Das datenorientierte Design ermöglicht eine sehr effiziente Verwendung des Befehls-Cache, da ständig derselbe Code darin ausgeführt wird. Wenn wir die Daten in großen benachbarten Blöcken anordnen, können wir die Daten außerdem nacheinander verarbeiten, wodurch eine nahezu perfekte Nutzung des Datencaches und eine hervorragende Leistung erzielt werden.

Optimierungsoption


Wenn wir an Objekte oder Funktionen denken, konzentrieren wir uns normalerweise auf die Optimierung auf der Ebene einer Funktion oder sogar eines Algorithmus: Wir versuchen, die Reihenfolge der Funktionsaufrufe zu ändern, die Sortiermethode zu ändern oder sogar einen Teil des C-Codes in Assemblersprache neu zu schreiben.

Solche Optimierungen sind sicherlich nützlich, aber wenn Sie zuerst über die Daten nachdenken, können wir einen Schritt zurücktreten und ehrgeizigere und wichtigere Optimierungen erstellen. Vergessen Sie nicht, dass sich das Spiel nur mit der Konvertierung bestimmter Daten (Ressourcen, Benutzereingaben, Status) in andere Daten (Grafikbefehle, neue Spielzustände) befasst. In Anbetracht dieses Datenstroms können wir fundiertere Entscheidungen auf höherer Ebene treffen, basierend darauf, wie Daten konvertiert und angewendet werden. Solche Optimierungen bei traditionelleren OOP-Techniken können äußerst komplex und zeitaufwändig sein.

Modularität


Alle oben genannten Vorteile des datenorientierten Designs standen im Zusammenhang mit der Leistung: Cache-Nutzung, Optimierung und Parallelisierung. Es besteht kein Zweifel, dass für uns Spielprogrammierer die Leistung äußerst wichtig ist. Oft gibt es einen Konflikt zwischen Techniken, die die Produktivität steigern, und Techniken, die die Lesbarkeit des Codes und die einfache Entwicklung fördern. Wenn wir beispielsweise einen Teil des Codes in Assemblersprache umschreiben, verbessern wir die Leistung. Dies führt jedoch normalerweise zu einer Verringerung der Lesbarkeit und erschwert die Unterstützung des Codes.

Glücklicherweise fördert datenorientiertes Design sowohl die Produktivität als auch die einfache Entwicklung. Wenn Sie Code speziell für die Datenkonvertierung schreiben, erhalten Sie kleine Funktionen mit einer sehr geringen Anzahl von Abhängigkeiten zu anderen Teilen des Codes. Die Codebasis bleibt sehr "flach", mit vielen "Blatt" -Funktionen, die keine großen Abhängigkeiten aufweisen. Dieser Grad an Modularität und das Fehlen von Abhängigkeiten vereinfachen das Verständnis, Ersetzen und Aktualisieren von Code erheblich.

Testen


Der letzte große Vorteil des datenorientierten Designs ist die einfache Prüfung. Viele Menschen wissen, dass das Schreiben von Komponententests zum Testen der Interaktion von Objekten keine triviale Aufgabe ist. Sie müssen Layouts und Testelemente indirekt erstellen. Ehrlich gesagt ist das ziemlich schmerzhaft. Andererseits ist es absolut einfach, Unit-Tests direkt mit Daten zu schreiben: Wir erstellen einige eingehende Daten, rufen die Funktion auf, die sie konvertiert, und prüfen, ob die Ausgabe mit den erwarteten Daten übereinstimmt. Und das ist alles. Tatsächlich ist dies ein großer Vorteil, der das Testen von Codes erheblich vereinfacht, sei es die testgetriebene Entwicklung oder das Schreiben von Komponententests nach dem Code.

Nachteile des datenorientierten Designs


Datenorientiertes Design ist keine "Silberkugel", die alle Probleme bei der Spieleentwicklung löst. Es hilft wirklich beim Schreiben von Hochleistungscode und beim Erstellen von Programmen, die besser lesbar und einfacher zu warten sind, aber an sich einige Nachteile haben.

Das Hauptproblem beim datenorientierten Design: Es unterscheidet sich von dem, was die meisten Programmierer gelernt und gewohnt sind. Es erfordert, unser mentales Modell des Programms um neunzig Grad zu drehen und die Sichtweise darauf zu ändern. Damit dieser Ansatz zur zweiten Natur wird, ist Übung erforderlich.

Aufgrund der unterschiedlichen Ansätze kann es außerdem zu Schwierigkeiten bei der Interaktion mit vorhandenem Code kommen, der in einem prozeduralen oder OOP-Stil geschrieben wurde. Es ist schwierig, eine Funktion separat zu schreiben, aber sobald Sie ein datenorientiertes Design auf ein gesamtes Subsystem anwenden können, können Sie viele Vorteile erzielen.

Verwenden von datenorientiertem Design


Genug Theorie und Rezensionen. Wie beginne ich mit der Implementierung der datenorientierten Entwurfsmethode? Wählen Sie zunächst einen bestimmten Bereich Ihres Codes aus: Navigation, Animationen, Kollisionen oder etwas anderes. Wenn sich der Hauptteil der Spiel-Engine später auf Daten konzentriert, können Sie den Datenfluss entlang des gesamten Pfads vom Anfang des Frames bis zum Ende optimieren.

Als nächstes müssen die vom System benötigten Eingabedaten und die Art der Daten, die es generieren soll, klar identifiziert werden. Möglicherweise denken Sie vorerst in der OOP-Terminologie, nur um die Daten zu identifizieren. Bei einem Animationssystem sind beispielsweise Skelette, Grundposen, Animationsdaten und der aktuelle Status Teil der Eingabedaten. Das Ergebnis ist kein „animierter Animationscode“, sondern Daten, die von den aktuell wiedergegebenen Animationen generiert werden. In diesem Fall besteht die Ausgabe aus einem neuen Satz von Posen und einem aktualisierten Status.

Es ist wichtig, einen Schritt zurückzutreten und eingehende Daten anhand ihrer Verwendung zu klassifizieren. Sind sie schreibgeschützt, schreibgeschützt oder schreibgeschützt? Eine solche Klassifizierung hilft bei Entscheidungen darüber, wo Daten gespeichert und wann sie verarbeitet werden sollen, da Abhängigkeiten von anderen Teilen des Programms bestehen.

In diesem Stadium müssen Sie aufhören, über die für einen Vorgang erforderlichen Daten nachzudenken, und darüber nachdenken, sie auf Dutzende oder Hunderte von Elementen anzuwenden. Wir haben nicht mehr ein Skelett, eine Grundhaltung und einen aktuellen Zustand: Wir haben einen Block von jedem dieser Typen mit vielen Instanzen in jedem der Blöcke.

Überlegen Sie genau, wie die Daten im Transformationsprozess von der Eingabe zur Ausgabe verwendet werden. Möglicherweise stellen Sie fest, dass Sie zum Übertragen von Daten ein bestimmtes Feld in der Struktur scannen und dann die Ergebnisse verwenden müssen, um einen weiteren Durchgang durchzuführen. In diesem Fall ist es möglicherweise logischer, dieses Quellfeld in einen separaten Speicherblock aufzuteilen, der separat verarbeitet werden kann, um den Cache besser zu nutzen und den Code für eine mögliche Parallelisierung vorzubereiten. Oder Sie müssen möglicherweise einen Teil des Codes vektorisieren, wenn Sie Daten von verschiedenen Orten empfangen müssen, damit diese in ein Vektorregister gestellt werden. In diesem Fall werden die Daten nebeneinander gespeichert, so dass Vektoroperationen ohne unnötige Konvertierungen direkt angewendet werden können.

Sie sollten jetzt ein sehr gutes Verständnis Ihrer Daten haben. Das Schreiben von Code zum Konvertieren wird viel einfacher. Es wird so sein, als würde man Code durch Ausfüllen von Leerzeichen erstellen. Sie werden angenehm überrascht sein, dass der Code im Vergleich zum gleichen OOP-Code viel einfacher und kompakter war als ursprünglich angenommen.

Die meisten Beiträge in meinem Blog haben Sie auf diese Art von Design vorbereitet. Jetzt müssen wir vorsichtig sein, wie die Daten angeordnet sind, die Daten im Eingabeformat backen, damit sie effizient verwendet werden können, und Verknüpfungen ohne Zeiger zwischen den Datenblöcken verwenden, damit sie leicht verschoben werden können.

Ist noch Platz für die Verwendung von OOP?


Bedeutet dies, dass OOP nutzlos ist und niemals beim Erstellen von Programmen verwendet werden sollte? Das kann ich nicht sagen. Das Denken im Kontext von Objekten ist nicht schädlich, wenn es sich nur um eine Instanz jedes Objekts handelt (z. B. ein Grafikgerät, einen Protokollmanager usw.). In diesem Fall kann der Code jedoch auf der Grundlage einfacherer C-Funktionen und statischer Funktionen implementiert werden Daten auf Dateiebene. Und selbst in dieser Situation ist es immer noch wichtig, dass Objekte mit Schwerpunkt auf Datentransformation entworfen werden.

Eine andere Situation, in der ich immer noch OOP verwende, sind GUI-Systeme. Vielleicht liegt dies daran, dass wir hier mit einem System arbeiten, das bereits objektorientiert entworfen wurde, oder vielleicht daran, dass Leistung und Komplexität keine kritischen Faktoren für den GUI-Code sind. Wie dem auch sei, ich bevorzuge GUI-APIs, die die Vererbung nur wenig nutzen und die Verschachtelung maximieren (gute Beispiele hierfür sind Cocoa und CocoaTouch). Es ist wahrscheinlich, dass Sie für Spiele gut aussehende GUI-Systeme mit Datenorientierung schreiben können, aber bisher habe ich solche nicht gesehen.

Am Ende hindert Sie nichts daran, ein mentales Bild zu erstellen, das auf Objekten basiert, wenn Sie das Spiel lieber auf diese Weise betrachten. Es ist nur so, dass die Essenz des Feindes nicht einen physischen Platz im Gedächtnis einnimmt, sondern in kleinere Unterkomponenten unterteilt wird, von denen jede Teil einer großen Datentabelle ähnlicher Komponenten ist.

Datenorientiertes Design ist ein bisschen weit von herkömmlichen Programmiermethoden entfernt. Wenn Sie jedoch immer über Daten und die erforderlichen Transformationsmethoden nachdenken, erhalten Sie große Vorteile in Bezug auf Produktivität und einfache Entwicklung.

Source: https://habr.com/ru/post/de472052/


All Articles