Einfachste Implementierung des Entity Component-Systems

Hallo allerseits!

Der vierte Stream „C ++ Developer“ startet hier, einer der aktivsten Kurse in unserem Land, gemessen an den tatsĂ€chlichen Treffen, bei denen nicht nur „Kreuzfahrer“ mit Dima Shebordaev ins GesprĂ€ch kommen :) Im Allgemeinen ist der Kurs bereits gewachsen Als eine der grĂ¶ĂŸten in unserem Land ist es unverĂ€ndert geblieben, dass Dima offene Lektionen durchfĂŒhrt und wir vor Beginn des Kurses interessante Materialien auswĂ€hlen.

Lass uns gehen!

Eintrag


Das Entity-Component-System (ECS, „Entity-Component-System“) ist als architektonische Alternative, die das Prinzip der Komposition gegenĂŒber der Vererbung betont, auf dem Höhepunkt der PopularitĂ€t. In diesem Artikel werde ich nicht auf die Details des Konzepts eingehen, da zu diesem Thema bereits genĂŒgend Ressourcen vorhanden sind. Es gibt viele Möglichkeiten, ECS zu implementieren, und ich wĂ€hle meistens recht komplexe, die AnfĂ€nger verwirren und viel Zeit in Anspruch nehmen können.

In diesem Beitrag werde ich einen sehr einfachen Weg beschreiben, um ECS zu implementieren, dessen funktionale Version fast keinen Code erfordert, aber vollstÀndig dem Konzept folgt.



ECS


Apropos ECS: Menschen meinen oft verschiedene Dinge. Wenn ich ĂŒber ECS spreche, meine ich ein System, mit dem Sie EntitĂ€ten definieren können, die keine oder mehr reine Datenkomponenten enthalten. Diese Komponenten werden von reinen Logiksystemen selektiv verarbeitet. Beispielsweise sind Position, Geschwindigkeit, Trefferfeld und Zustand einer Komponente an die EntitĂ€t E gebunden. Sie speichern einfach Daten in sich. Beispielsweise kann eine Gesundheitskomponente zwei Ganzzahlen speichern: eine fĂŒr den aktuellen Zustand und eine fĂŒr das Maximum. Ein System kann ein System zur Wiederherstellung der Gesundheit sein, das alle Instanzen einer Gesundheitskomponente findet und alle 120 Frames um 1 erhöht.

Typische C ++ - Implementierung


Es gibt viele Bibliotheken, die ECS-Implementierungen anbieten. Normalerweise enthalten sie ein oder mehrere Elemente aus der Liste:

  • Vererbung der Basiskomponente / des GravitySystem : public ecs::System der GravitySystem : public ecs::System Klasse GravitySystem : public ecs::System ;
  • Aktive Nutzung von Vorlagen;
  • Sowohl das als auch ein anderes in einem CRTP- Look;
  • Die EntityManager Klasse, die die Erstellung / Speicherung von EntitĂ€ten implizit steuert.

Einige schnelle Google-Beispiele:


Alle diese Methoden haben das Recht auf Leben, aber sie haben einige Nachteile. Die Art und Weise, wie sie die Daten auf undurchsichtige Weise verarbeiten, bedeutet, dass es schwierig sein wird zu verstehen, was im Inneren vor sich geht und ob die Leistung nachgelassen hat. Dies bedeutet auch, dass Sie die gesamte Abstraktionsebene studieren und sicherstellen mĂŒssen, dass sie gut zu vorhandenem Code passt. Vergessen Sie nicht versteckte Fehler, die wahrscheinlich viel in der Menge an Code versteckt sind, die Sie debuggen mĂŒssen.

Ein vorlagenbasierter Ansatz kann die Kompilierungszeit und die HĂ€ufigkeit, mit der Sie den Build neu erstellen mĂŒssen, erheblich beeinflussen. WĂ€hrend vererbungsbasierte Konzepte die Leistung beeintrĂ€chtigen können.

Der Hauptgrund, warum ich diese AnsĂ€tze fĂŒr ĂŒbertrieben halte, ist, dass das Problem, das sie lösen, zu einfach ist. Letztendlich sind dies nur zusĂ€tzliche Datenkomponenten, die der EntitĂ€t und ihrer selektiven Verarbeitung zugeordnet sind. Im Folgenden werde ich einen sehr einfachen Weg zeigen, wie dies implementiert werden kann.

Mein einfacher Ansatz


Essenz

In einigen AnsĂ€tzen ist die EntitĂ€tsklasse definiert, in anderen arbeiten sie mit EntitĂ€ten als ID / Handle. Bei einem Komponentenansatz ist eine EntitĂ€t nichts anderes als die ihr zugeordneten Komponenten, und dafĂŒr wird keine Klasse benötigt. Eine EntitĂ€t wird explizit basierend auf ihren zugehörigen Komponenten existieren. Definieren Sie dazu:

 using EntityID = int64_t; //    , int64_t -   

EntitÀtskomponenten

Komponenten sind verschiedene Arten von Daten, die vorhandenen EntitĂ€ten zugeordnet sind. Wir können sagen, dass fĂŒr jede EntitĂ€t e null und leichter zugĂ€ngliche Komponententypen vorhanden sind. Im Wesentlichen handelt es sich hierbei um eine explodierte SchlĂŒssel-Wert-Beziehung, fĂŒr die es glĂŒcklicherweise Standardbibliothekstools in Form von Karten gibt.

Also definiere ich die Komponenten wie folgt:

 struct Position { float x; float y; }; struct Velocity { float x; float y; }; struct Health { int max; int current; }; template <typename Type> using ComponentMap = std::unordered_map<EntityID, Type>; using Positions = ComponentMap<Position>; using Velocities = ComponentMap<Velocity>; using Healths = ComponentMap<Health>; struct Components { Positions positions; Velocities velocities; Healths healths; }; 

Dies reicht aus, um EntitÀten durch Komponenten anzuzeigen, wie von ECS erwartet. Um beispielsweise eine EntitÀt mit einer Position und Gesundheit, jedoch ohne Geschwindigkeit, zu erstellen, benötigen Sie:

 //given a Components instance c EntityID newID = /*obtain new entity ID*/; c.positions[newID] = Position{0.0f, 0.0f}; c.healths[newID] = Health{100, 100}; 

Um eine EntitÀt mit einer bestimmten ID zu zerstören, .erase() wir sie einfach von jeder Karte.

Systeme

Die letzte Komponente, die wir brauchen, sind Systeme. Dies ist die Logik, die mit Komponenten arbeitet, um ein bestimmtes Verhalten zu erreichen. Da ich Dinge gerne vereinfache, benutze ich normale Funktionen. Das oben erwÀhnte Gesundheitsregenerationssystem kann einfach die nÀchste Funktion sein.

 void updateHealthRegeneration(int64_t currentFrame, Healths& healths) { if(currentFrame % 120 == 0) { for(auto& [id, health] : healths) { if(health.current < health.max) ++health.current; } } } 

Wir können den Aufruf dieser Funktion an einer geeigneten Stelle in der Hauptschleife platzieren und ihn in den Speicher der IntegritĂ€tskomponente ĂŒbertragen. Da das IntegritĂ€tsrepository nur DatensĂ€tze fĂŒr EntitĂ€ten mit IntegritĂ€tsdaten enthĂ€lt, kann es diese isoliert verarbeiten. Dies bedeutet auch, dass die Funktion nur die erforderlichen Daten aufnimmt und die irrelevanten nicht berĂŒhrt.

Was aber, wenn das System mit mehr als einer Komponente arbeitet? Sagen wir ein physisches System, das seine Position basierend auf der Geschwindigkeit Ă€ndert. Dazu mĂŒssen wir alle SchlĂŒssel aller beteiligten Komponententypen schneiden und ĂŒber ihre Werte iterieren. Zu diesem Zeitpunkt reicht die Standardbibliothek nicht mehr aus, aber das Schreiben von Helfern ist nicht so schwierig. Zum Beispiel:

 void updatePhysics(Positions& positions, const Velocities& velocities) { //   ,   N   //   ID,    . std::unordered_set<EntityID> targets = mapIntersection(positions, velocities); // target'     ,   //  ,       . for(EntityID id : targets) { Position& pos = positions.at(id); const Velocity& vel = velocities.at(id); pos.x += vel.x; pos.y += vel.y; } } 

Oder Sie können einen kompakteren Helfer schreiben, der einen effizienteren Zugriff ĂŒber Iteration anstelle der Suche ermöglicht.

 void updatePhysics(Positions& positions, const Velocities& velocities) { //   ,    //  .        //    . intersectionInvoke<Position, Velocity>(positions, velocities, [] (EntityID id, Position& pos, const Velocity& vel) { pos.x += vel.x; pos.y += vel.y; } ); } 

So haben wir uns mit der GrundfunktionalitÀt eines regulÀren ECS vertraut gemacht.

Die Vorteile


Dieser Ansatz ist sehr effektiv, da er von Grund auf neu erstellt wird, ohne die Abstraktion einzuschrĂ€nken. Sie mĂŒssen keine externen Bibliotheken integrieren oder die Codebasis an die vordefinierten Vorstellungen von EntitĂ€ten / Komponenten / Systemen anpassen.
Und da dieser Ansatz vollstĂ€ndig transparent ist, können Sie auf seiner Grundlage alle Dienstprogramme und Helfer erstellen. Diese Implementierung wĂ€chst mit den Anforderungen Ihres Projekts. FĂŒr einfache Prototypen oder Spiele fĂŒr Game Jam'ov verfĂŒgen Sie höchstwahrscheinlich ĂŒber genĂŒgend Funktionen, die oben beschrieben wurden.

Wenn Sie mit diesem gesamten ECS-Bereich noch nicht vertraut sind, hilft ein derart einfacher Ansatz, die Hauptideen zu verstehen.

EinschrÀnkungen


Wie bei jeder anderen Methode gibt es jedoch einige EinschrĂ€nkungen. Nach meiner Erfahrung ist es genau eine solche Implementierung mit unordered_map in einem nicht trivialen Spiel, die zu Leistungsproblemen fĂŒhrt.

Das Iterieren von SchlĂŒsselschnittpunkten auf mehreren Instanzen von unordered_map mit mehreren EntitĂ€ten lĂ€sst sich nicht gut skalieren, da Sie tatsĂ€chlich N*M Lookups durchfĂŒhren, wobei N die Anzahl ĂŒberlappender Komponenten, M die Anzahl ĂŒbereinstimmender EntitĂ€ten und unordered_map Caching nicht sehr gut ist. Dieses Problem kann behoben werden, indem anstelle von unordered_map ein SchlĂŒsselwertspeicher verwendet wird, der besser fĂŒr die Iteration geeignet ist.

Eine weitere EinschrĂ€nkung ist das Boilerplating. Je nachdem, was Sie tun, kann das Identifizieren neuer Komponenten mĂŒhsam werden. Möglicherweise mĂŒssen Sie eine AnkĂŒndigung nicht nur in der Komponentenstruktur, sondern auch in der Spawn-Funktion, der Serialisierung, dem Debugging-Dienstprogramm usw. hinzufĂŒgen. Ich bin selbst darauf gestoßen und habe das Problem durch Generieren von Code gelöst. Ich habe Komponenten in externen JSON-Dateien definiert und dann in der Erstellungsphase C ++ - Komponenten und Hilfsfunktionen generiert. Ich bin sicher, dass Sie andere Methoden finden können, die auf Vorlagen basieren, um alle Probleme mit Boilerplates zu beheben, auf die Sie stoßen.

DAS ENDE

Wenn Sie Fragen und Kommentare haben, können Sie diese hier lassen oder zu einer offenen Lektion mit Dima gehen , ihm zuhören und bereits herumfragen.

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


All Articles