Entity Component System
Hallo Leute,
ich habe mich in letzter Zeit, für eines meiner Projekte, sehr intensiv mit Entity Component Systemen beschäftigt. Es gibt zwar schon einige Artikel im Netz, die sich mit dem Thema beschäftigen, aber da wurde meist nur grob beschrieben was ein Entity Component System ist und wie es funktioniert. Eine umfassende Erläuterung wie man so etwas umsetzen kann habe ich bis jetzt leider nicht gefunden. Bei meiner Suche im Netz habe ich natürlich auch einige Implementierungen für die gängigsten Sprachen gefunden, aber leider nicht für die, die ich nutze (FPC). Wenn ihr also selbst ein Entity Component System implementieren möchtet, oder einfach nur wissen wollt wie man so etwas umsetzen kann, dann seid ihr hier genau richtig.
Was wird euch also in diesem Artikel erwarten? Ich werde – wie alle anderen natürlich auch – zuerst erklären was ein Entity Component System ist und welche Vor- oder Nachteile es mit sich bringt. Danach werde ich erläutern wie man ein solches System implementieren kann. Das wird hauptsächlich in Textform geschehen. Hier und da wird es für die bessere Übersichtlichkeit auch kleine Code-Ausschnitte geben. Ich werde dabei auf Probleme eingehen, auf die ich bei meiner Umsetzung gestoßen bin. Am Ende des Artikels solltet ihr genug Wissen haben um euer eigenes Entity Component System umsetzen zu können.
Bevor es los geht noch ein Hinweis: Was ich hier zusammengefasst habe sind lediglich meine persönlichen Erfahrungen und Ideen. Das bedeutet nicht, dass ihr das genauso machen müsst wie ich das hier beschrieben habe, oder dass das was hier steht das neue Wort Gottes ist. Ich möchte einfach mein Wissen mit euch teilen.
Inhalt
- Übersicht
- Umsetzung
- Component
- Entity
- Events
- Systeme
- Templates
- Zusammenfassung
Übersicht
Nehmen wir einmal an, ihr wollt ein cooles Space Game entwickeln und macht euch nun Gedanken was ihr alles in diesem Spiel umsetzen wollt. Neben schicken Raumschiffen und mächtigen Raumstationen gibt es unzählige Planeten die erforscht und besiedelt werden wollen. Also fangt ihr an euch ein entsprechendes Klassen Design auszudenken. Ihr verteilt die Eigenschaften der einzelnen Objekte so, dass sie gut in euer Design eingepasst werden können.
Jetzt habt ihr aber vergessen, dass ihr noch eine große Kampfstation implementieren wollt und findet keinen Platz in eurer Klassenhierarchie, an der die Kampfstation gut aufgehoben ist. Denn einerseits müsstet ihr von der Klasse SpaceStation und andererseits von der Klasse WarShip ableiten. Ihr habt also das klassische Diamond of Death Problem.
Wie könnt ihr dieses Problem nun lösen? Wenn ihr das Glück habt und eure Programmiersprache Mehrfachvererbung unterstützt, dann könnten ihr einfach von beiden Klassen ableiten. Aber selbst wenn eure Sprache dieses Feature unterstützt solltet ihr mir zustimmen, dass das keine schöne Lösung ist. Eine weitere Lösung wäre es, den relevanten Code entlang der Klassenhierarchie in eine der Basis-Klassen zu verlagern. Bei einem sehr großen Projekt würde das die Basis-Klassen aber unnötig verkomplizieren. Ihr könnte den benötigten Code auch einfach kopieren, dann besteht aber wiederrum die Gefahr, dass ihr einen eventuellen Bug mit kopiert. Der resultierende Code würde unmöglich zu warten sein.
Ein weiterer Nachteil des Objektorientierten Ansatzes ist, dass die Einarbeitung neuer Teammitglieder sehr lange dauert. Man muss meist einen großen Teil der Klassen überblicken können um sich ein Bild davon zu machen wie diese arbeiten. Wenn man da bei einer kleinen Änderung nicht alle Eventualitäten berücksichtigt funktioniert die Anwendung nicht mehr wie erwartet.
Das letzte Problem auf das ich hier eingehen möchte, ist die Aufrufreihenfolge von virtuellen Methoden. Was wäre, wenn unser SpaceObject eine virtuelle Methode Render hätte und unsere neu erzeugte Klasse WarStation möchte erst die Render Methode von SpaceObject aufrufen, dann eigenen Code ausführen und zum Schluss die Render Methode von SpaceStation? Das wäre in den meisten Programmiersprachen so nicht möglich.
Kurz gesagt: Ein Game Design, was auf Vererbung aufbaut ist schwer zu warten und kann auch nur bedingt weiterentwickelt werden. Neue große Features zu implementieren grenzt meist an ein komplettes Redesign der Anwendung.
Jetzt fragt ihr euch sicher: Wie sollte ich meine Daten und den Code sonst verwalten, wenn nicht mit einer Vererbungshierarchie? Die Antwort ist einfach: Aggregation! Wenn ihr die Spiel Objekte nicht mehr als Teil einer Hierarchie betrachtet, sondern als Aggregation verschiedener Komponenten, dann löst ihr damit alle Probleme die im letzten Abschnitt aufgedeckt wurden. So gibt es zum Beispiel in unserem Spiel eine Komponente Station die alle benötigten Informationen für eine Raumstation enthält und eine Komponente Combat die Informationen darüber hat wieviel Schaden eine Spiel Objekt verursachen kann. Wenn ihr nun eure Kampfstation implementieren wollt, legen ihr lediglich fest das WarStation eine Aggregation aus den benötigten Komponenten ist.
Die einzelnen Komponenten fasst ihr in einer Entity zusammen. Eine Entity kann mehrere Komponenten besitzen, wobei jeder Komponenten Typ maximal einmal vertreten sein kann. Wichtig bei diesem Ansatz ist, dass ihr jegliche Daten in Komponenten abbildet und keine Daten in der Entity selbst haltet. Nur so könnt ihr das volle Potential dieses Systems ausnutzen. Üblicherweise ist die Entity nur noch eine ID, die ihr nutzt um euch Zugriff auf die Komponenten der Entity zu verschaffen.
Wenn die Daten jetzt komplett in den Komponenten liegen, und die Entity nur eine ID ist, wo soll dann der eigentliche Code hin? Dafür werden die Systeme genutzt. Ein System ist ein Stück Code, der mit einigen ausgewählten Komponenten arbeitet. Zum Beispiel konntet ihr ein System schreiben, das die Position und die Velocity Komponente nutzt um zyklisch die Position einer Entity zu aktualisieren. Die komplette Spiel Logik wird in diesen Systemen abgebildet. Das Schöne an diesem Ansatz ist, dass man es ohne Probleme in mehreren Threads parallelisieren kann. Die einzigen Schnittstellen die synchronisiert werden müssen sind die Zugriffe auf die Komponenten (über die Entity ID) und die Verteilung der Events.
Unterstützt werden die Systeme von einem Event Manger, der Events generiert, sobald sich Daten in den Komponenten geändert haben oder Entities bzw. Komponenten erzeugt und gelöscht wurden. Diese Events könnt ihr als eine Art Methoden Aufruf in der Klassenhierarchie sehen. Wenn ein System festgestellt hat, das eine unserer WarShip Entities Schaden nehmen soll, dann generiert es ein TakeDamage Event. Der Event Manger verteilt das Event dann an alle anderen Systeme die sich für dieses Event interessieren. Zum Beispiel kann das Sound System einen Ton abspielen, das Partikel System kümmert sich um nette Explosions Effekte, das Kampf System verringert die Schild- und Hüllenpunkte des Schiffs und das Network System sorgt dafür, dass dem Server mitgeteilt wird, das ihr angegriffen habt. Ihr könnten sogar ein System schreiben, das alle Events speichert und dann als eine Art Replay wieder abspielt. Die Möglichkeiten sind vielfältig.
Umsetzung
Component
Wie oben bereits erläutert ist eine Komponente nichts weiter als Daten die von einem unserer Spiel Objekte benötigt werden. Die Schwierigkeit dabei ist festzulegen wie die Daten auf die Komponenten verteilt werden sollen. Ich hab hier am Anfang auch viele Fehler gemacht. Zuerst waren meine Komponenten zu klein bzw. hatten zu wenig Daten und das System wurde zu langsam und unübersichtlich. Dann waren sie zu groß und die Flexibilität des Sytems ging verloren.
Ich empfehle euch, setzt euch mit Zettel und Papier hin (oder von mir aus auch im Editor eurer Wahl) und schreibt auf, was für Objekte euer Spiel haben soll und welche Eigenschaften diese Objekte haben. Dann überlegt euch, welche Systeme bzw. was für Spiel Logiken ihr in eurem Spiel habt und schreibt euch auf welche Daten der Objekte diese Systeme nutzen müssen. Nun könnt ihr gucken, welche Objekte und Systeme die selben Daten benötigen. Diese fasst ihr dann in einer Komponente zusammen. Dabei sollte ihr darauf achten das die Komponente immer einen Bezug zu einem System und einem Objekt hat. Es bringt zum Beispiel wenig eine Color Komponente zur definieren, nur weil euer Schiff und eure Raumstation eine Farbe haben, diese dann aber nicht in einem seperatem System genutzt wird. Das macht euer Design nur unnötig kompliziert. Solche Eigenschaften könnt ihr in eine Komponente verlagern die das Schiff oder die Station ohnehin schon haben.
Faustregel: Verschiebe Eigenschaften nur in Komponenten wenn diese von verschiedenen Entities und gleichzeitig von verschiedenen Systemen genutzt werden!
Komponenten sind per Definition nur Daten, das heißt aber nicht dass das Objekt welches die Komponente implementiert kein Code enthalten darf. Ihr müsst nur gut aufpassen, das dieser Code keine Game Logik enthält. Ich habe in meinem System eine abstrakte Basis Klasse für alle meine Komponenten geschrieben. In dieser Basis Klasse habe ich unter anderem einige virtuelle Methoden definiert die es mir ermöglichen alle Eigenschaften der Komponente über einen Index oder einen Namen anzusprechen. Das macht das Speichern und Laden der Komponente einfacher.
TecsComponent = class protected function GetPropertyCount(): Integer; virtual; function GetPropertyName (const aIndex: Integer): String; virtual; function GetPropertyValue(const aIndex: Integer): Variant; virtual; procedure SetPropertyValue(const aIndex: Integer; aValue: Variant); virtual; public property PropertyCount: Integer read GetPropertyCount; property PropertyNames[const aIndex: Integer]: String read GetpropertyName; property Properties [const aIndex: Integer]: Variant read GetPropertyValue write SetPropertyValue; function IndexOfPropertyName(const aName: String): Integer; constructor Create; destructor Destroy; override; end; |
Ob ihr die Komponente als abstrakte Basisklasse oder als Interface implementiert ist egal. Wichtig ist, das die Komponenten eine Gemeinsamkeit haben, über die man sie ansprechen kann. In C++ könntet ihr das Ganze auch über Templates realisieren. Wie ihr es allerdings am Ende umsetzt müsst ihr leider selbst entscheiden.
Eine weitere Art von Code, den ich in den Komponenten noch zulassen würde, ist das absetzen von Events. Wenn ihr die Eigenschaft einer Komponente geändert habt, dann kann diese automatisch ein Event an den Event Manager absetzten, der andere Systeme darüber informiert. Hier ist wichtig dass ihr das nur für Eigenschaften implementiert die sich selten ändern. Ein Update Event für zum Beispiel die Position würde eine Flut von Events auslösen. Wenn ihr euch nicht sicher seid, wie oft eine Eigenschaft geändert wird, oder ob ihr ein Event dafür benötigt, dann lasst es einfach weg.
Eine spezielle Art von Komponenten, sind sogenannte Tag Komponenten. Tag Komponenten sind einfache Komponenten ohne Daten, die ihr dafür nutzen könnt eine Entity zu taggen. Zum Beispiel könntet ihr eine Tag Komponente mit dem Namen PlayerControlledTag implementieren. Diese Komponente benötigt keine Daten, sondern dient nur dazu das alle Entities die vom Spieler kontrolliert werden können von eurem Player System erkannt werden.
Entity
Ihr wisst jetzt bereits das eine Entity im weitesten Sinne nur eine Ansammlung von Komponenten ist, die über eine ID angesprochen werden und ein Objekt in eurer Spielwelt repräsentieren. Das klingt im Großen und Ganzen relativ einfach, aber es gibt trotzdem noch das Ein oder Andere das ihr dabei beachten müsst.
Entity IDs
Die Entity ID ist ein wichtiger Teil eures Entity Component Systems. Es muss sichergestellt sein, das man eine Entity eindeutig mit ihrer ID identifizieren kann. Die ID muss ohne Probleme über das Netzwerk übertragen werden können, in eine Datei gespeichert oder aus einer Datei geladen werden können. Und als wäre das nicht genug, muss das Ganze auch performant sein.
Auf der Suche nach einer Lösung für alle diese Probleme bin ich auf einen sehr interessanten Artikel gestoßen, der sich mit genau damit beschäftigt. Im Grunde ist es ein Handle Manager, der über ein Handle (oder eben unsere Entity ID) indiziert auf Daten zugreifen kann und trotzdem sicherstellt, das man die Handles auch serialisieren kann. Das funktioniert folgendermaßen. Das Handle besteht aus 64bit.
- 32bit Index – wird genutzt um die Daten im Array zu finden
- 16bit Counter – wird genutzt um den Index wieder zu verwenden und um sicher zu stellen das es sich aber nicht um dieselben Daten handelt
- 16bit Type – Typ der gespeicherten Daten (kann in unserem System genutzt werden um den Entities eine Art Priorität zu geben, wenn man das benötigt)
Im Array des Handle Managers werden dann nicht nur die eigentlichen Daten gespeichert, sondern auch ein paar Meta-Daten um das Array zu verwalten.
- Next Free Index – Index des nächsten freien Elements im Array
- Counter – der Counter Wert des aktuell gespeicherten Elements (zum Abgleich mit dem Counter Wert aus dem Handle)
- Active – dieser Eintrag ist belegt
- End Of List – dieser Eintrag ist der letzte Eintrag in der Liste
- Entry – die eigentlichen Daten dieses Eintrags
Mit diesen Meta Daten wird eine Linked List im Array aufgebaut. Dadurch ist gewährleistet das ihr eure Elemente mit einer Laufzeit von O(1) finden könnt und auch Elemente mit der Laufzeit von O(1) hinzufügen oder löschen könnt. Das sollte als grobe Orientierung ausreichend sein. Eine genaue Implementierung findet ihr im verlinkten Artikel.
Verwaltung der Entities
Das wichtigste in eurem Entity Component System ist der Zugriff auf eure Entities. Die Entity muss performant mit ihrer ID angesprochen werden können und es muss sichergestellt sein, das nur ein Thread gleichzeitig mit der Entity arbeitet, sprich die Entity muss synchronisiert werden. Es bietet sich an, dass ihr einen globalen Entity Manager schreibt, der die Entities verwaltet und auch automatisch für die Synchronisierung sorgt. Ob ihr dabei nur mit der Entity ID arbeitet, oder euch ein Entity Objekt erstellt ist euch überlassen.
Ich habe mich dagegen entschieden die Entity ausschließlich über ihre ID anzusprechen. Den Entity Manager in meinem System kann man nach einer Entity fragen wenn man ihre ID kennt. Wenn der Manager die Entity gefunden hat bekommt man ein Interface vom Manager über die man die Entity ansprechen kann.
IecsEntity = interface function GetID: TecsEntityID; function GetComponents: TecsComponents; procedure Kill; property ID: TecsEntityID read GetID; property Components: TecsComponents read GetComponents; end; |
Das Interface hat drei Methoden:
- GetID – Gibt die ID der Entity zurück
- GetComponents – Gibt das Component Objekt der Entity zurück
- Kill – Zerstört die Entity
Das Ganze hat folgenden Hintergrund. In FreePascal (und auch in Delphi) haben Interfaces einen automatischen Referenz Zähler. Das heißt wenn keine Variable mehr existiert, die das Interface hält, dann wird das dahinterliegende Objekt automatisch freigegeben. Die Mechanik habe ich mir zu nutzen gemacht. Der Entity Manager hat intern eine Klasse welche eine Entity repräsentiert. Unter anderem enthält diese Klasse die Komponenten der Entity. Wenn ich den Manager jetzt nach einer Entity frage, dann sucht er diese im Handle Manager, blockiert die Entity für weitere Zugriffe und gibt mit das Interface zurück. Jetzt kann ich mit der Entity arbeiten ohne das ich mir Sorgen machen muss, das ein anderer Thread Zugriff auf die Entity hat. Sobald ich das übergebene Interface nicht mehr nutzte wird der Zugriff auf die Entity wieder freigegeben. Das Interface darf natürlich nur in der Methode genutzt werden in der ich die Entity vom Manager angefragt habe, sonst würde die Entity für immer blockiert sein.
Events
Ein ordentliches Event System gehört in jedes gute Entity Component System. Events werden genutzt um Daten oder Eregnisse zwischen verschiedenen Prozessen auszutauschen. Die Systeme müssen dabei nicht auf dem selben Rechner laufen. In einer Client Server Umgebung ist es durchaus möglich das ein System auf dem Client ein Event erzeugt, welches dann von einem System auf dem Server verarbeitet wird. Es gibt zwei grundlegende Arten von Events.
- Change Event – eine Eigenschaft einer Komponente wurde geändert
- Execute Event – mit einer Entity soll etwas gemacht werden
Die erste Art von Events ist einfach nur eine Benachrichtigung darüber das sich etwas geändert hat. Diese Events werden im Normalfall nicht vom Empfänger quittiert. Die zweite Art von Events ist die interessantere und kann mit Methoden in einem Objekt Orientiertem System vergleichen werden. Diese Events sagen was mit einer Entity geschehen soll bzw. was schon geschehen ist. Zum Beispiel, wenn ihr eine Entity erstellen möchtet, dann macht ihr das nicht einfach, sondern ihr setzt ein EntityCreate Event ab. Dieses Event wird dann von einem entsprechenden System empfangen, die Entity wird erstellt und das Event wird mit einem EntityCreated Event quittiert.
Das Event kann von Auslöser zu seinem Zielsystem verschiedene Wege durchlaufen. Zum Beispiel könnte das Event vom Client zum Server übermittelt werden, der prüft dann, ob der Client überhaupt die Berechtigung besitzt diese Entity zu erstellen und führt die Operation dann gegebenfalls aus.
Events könnt ihr mit einfachen Callbacks implementieren. Daten die bei einem Event mitgegeben werden müssen habe ich in meinem System wieder über ein Interface gelöst (ihr erinnert euch, in FPC werden Interfaces automatisch freigegeben). Je nachdem was für ein Event ich in meinem System absetzen will gibt es eine entsprechende Implementierung mit unterschiedlichen Daten. Es empfiehlt sich für jedes Event eine Event ID zu implementieren um das Event eindeutig zu identifizieren. Ein Event, das Aufgrund eines anderen Events ausgelöst wurde, sollte die gleiche ID nutzen. So könnt ihr ohne Probleme nachvollziehen, ob das EntityKilled Event auch zu dem von euch ausgelösten EntityKill Event gehört. Solltet ihr ein Client Server System entwickeln, dann müsst ihr auch sicherstellen, dass die Event ID nicht nur auf eurem Rechner eindeutig ist, sondern auf allen Rechnern die mit dem System verbunden sind.
Systeme
Nun zur eigentlichen Game Logik, den Systemen. Systeme sind kleine separierte Aufgaben die euer Spiel ausführen muss. Die Systeme sollten so klein wie möglich sein, um den Aufwand des Systems gering zu halten. Auch bei den Systemen gibt es zwei unterschiedliche Arten.
- Event basierte Systeme
- zyklische Systeme
Event basierte Systeme reagieren auf Events vom Event Manager und führen daraufhin eine bestimmte Aufgabe aus. Die Systeme entscheiden dabei selbst auf welche Events sie reagieren müssen. Zyklische Systeme werden immer wiederkehrend in eurem Spiel ausgeführt, wie zum Beispiel der Render Loop. Implementiert man neue Systeme bleibt der Rest der Game Logik davon unbetroffen.
In Systemen die mit Entities arbeiten und dabei bestimmte Komponenten bei der Entity voraussetzen solltet ihr wenn möglich (und sofern es Sinn macht) einen Cache einbauen. Wenn eine Entity erzeugt oder gelöscht wird, oder eine Komponente zu einer Entity hinzugefügt oder entfernt wird, dann sollte dieser Cache aktualisiert werden. Im Cache sollten dann nur die Komponenten liegen, die die Anforderung eures Systems erfüllen. Das macht vor allem bei zyklischen System Sinn, da es hier sehr inperformant wäre in jedem Zyklus alle Entities auf ihre Komponenten zu prüfen. So muss das System nur die Entities verarbeiten von denen es bereits weiß, dass es die geforderten Komponenten bereitstellt.
Templates
Ein weiteres tolles Feature von Entity Components Systems ist, das man sich ohne Probleme Blueprints oder Templates für eine Entity erstellen kann. Dieses Template beinhaltet dann alle Komponenten und Werte die ein bestimmtes Objekt in eurer Spielwelt haben soll. Zum Beispiel könntet ihr ein Template für einen Asteroiden definieren. Wenn ihr dann ein neues Asteroid Objekt im Spiel benötigt ladet ihr einfach das Template und müsst nur noch wenige Eigenschaften der Entity selbst anpassen (wie z.B. die richtige Position).
Diese Templates lassen sich auch ohne Weiteres in eine Datei auslagern und dann ins Programm laden. Eine baumartige Dateistruktur wie zum Beispiel XML bietet sich dafür gut an.
<Blueprint Name="asteroid"> <Components> <Component Type="TPositionComponent"> <Properties> <Property Name="Position" Value="0,000; 0,000"/> <Property Name="Size" Value="100"/> </Properties> </Component> <Component Type="TAsteroidComponent"> <Properties> <Property Name="ResourceType" Value="Metal"/> </Properties> </Component> </Components> </Blueprint> |
Verschachtelte Templates sind auch denkbar. Als Basis Template legt man die Eigenschaften des Asteroiden fest. Als erweitertes Template könntet ihr einen bewohnbaren Asteroiden erstellen indem ihr einfach eine weitere Komponente hinzufügt. Die Möglichkeiten sind schier grenzenlos.
Zusammenfassung
Wir halten also fest. Die gute alte objektorientierte Herangehensweise bietet für komplexe Spiele nicht genügend Flexibilität. Ein Entity Component System hingegen bietet durch seine strikte Trennung von Daten und Logik genau das. Die Daten die im System gespeichert sind können ohne Probleme serialisiert und deserialisert werden. Egal ob in eine Datei oder über’s Netzwerk. Die Logik in den Systemen ist sehr einfach auf mehreren Threads parallelisierbar und somit sehr performant. Durch ein einfaches aber mächtiges Event System kann das Spiel einfach gesteuert werden, sei es über das Netztwerk, von einer KI oder vom Spieler selbst. Es muss nur das entsprechende Event abgesetzt werden. Außerdem kann durch das Aufzeichnen von Events ohne Probleme eine Replay Funktion eingebaut werden. Und ich bin sicher es gibt noch viel mehr Vorteile, an die ich jetzt gerade nicht denke.
Ich hoffe ihr konntet einiges aus diesem Artikel lernen. Über ein wenig Feedback wäre ich sehr dankbar. Vieleicht habt ihr ja auch schon euer eigenes Entity Component System entwickelt und seht einiges anders als ich, dann lasst es mich wissen. Ich lern auch gern noch dazu 😉