Moderne Spieleprogrammierung mit dem Entity Component System und der Engine Bevy

Die Game-Engine Bevy verwendet die Programmiersprache Rust und das in der Spieleentwicklung zunehmend verbreitete Entity Component System.

In Pocket speichern vorlesen Druckansicht 4 Kommentare lesen

(Bild: Shutterstock)

Lesezeit: 15 Min.
Von
  • Gerhard Völkl
Inhaltsverzeichnis

Bei vielen professionellen Spieleprojekten löst das Entwurfsmuster Entity Component System (ECS) die klassische Spieleschleife ab, da es mehr Perfomance aus aktueller Hardware herausholt. Die bekannte Game-Engine Unity propagiert ebenfalls ECS in ihrer DOTS-Initiative. Die neue Game-Engine Bevy in der Programmiersprache Rust ist eine ideale Umsetzung von ECS. Um die wesentlichen Konzepte zu verstehen, sind keine Rust-Vorkenntnisse erforderlich.

Die Spieleschleife in Pseudo-Code für ein Weltraumspiel wie Asteroid, in dem ein Raumschiff per Laser die heranfliegenden Asteroiden zerstört, könnte folgendermaßen aussehen:

While true:
  time_diff := <Zeit, seit letztem Schleifendurchlauf>

  ship.read_input()
  ship.update(time_diff)

  for every asteroid:
    asteroid.update(time_diff)

  for every laser:
    laser.update(time_diff)
  collisions()

  ship.draw()
  for every asteroid: asteroid.draw()
  for every laser: laser.draw()

Die GameObjects repräsentieren die Objekte (ship, asteroid, laser), um die es in dem Spiel geht. Die zentrale Schleife der Game-Engine ruft möglichst häufig die Methode Update der GameObjects auf. Damit reagiert das Objekt auf seine Umgebung, berechnet seine neue Position oder verarbeitet Eingaben. In manchen Frameworks haben die GameObjects zusätzlich eine Draw-Methode, die sich um das Zeichnen bei jedem Schleifendurchlauf kümmert. Je öfter das passiert, desto mehr Bilder kommen pro Sekunde auf den Bildschirm, womit das Spiel flüssiger läuft.

Mit wachsender Komplexität müssen die Spieleschleife und die Update-Methoden der Objekte mehr Arbeit erledigen. Damit wird das Programm mit der Zeit unübersichtlicher.

Die klassische Herangehensweise der objektorientierten Programmierung für übersichtlicheren Code ist der Aufbau einer Vererbungshierarchie, bei der ein Objekt wie ship von allgemein gültigen Klassen abgeleitet ist. Viele GameObjects haben ähnliche Attribute und Methoden.

Alles, was sich bewegt, lässt sich von der Klasse Moveable ableiten, die sich um die Berechnung der Bewegung kümmert. Alles, was gezeichnet wird, erbt von der Klasse Renderable.

Das Raumschiff, das sich einerseits bewegt und das andererseits gezeichnet wird, müsste man von beiden Klassen ableiten. In Programmiersprachen ohne Mehrfachvererbung ist das nicht möglich. Ein Ausweg wären jedoch Interfaces in Programmiersprachen wie Java, die das Konzept bieten.

Der generelle Ausweg ist eine Hierarchie, in der oben die Klasse Renderable steht, von der Moveable abgeleitet ist, das wiederum Ship als Kindklasse hat. Soll später ein Raumschiff mit Tarnung hinzukommen, wird es schwierig: Es bewegt sich (Moveable), ist aber nicht sichtbar (Renderable). Die Vererbungshierarchie passt somit nicht mehr.

Viele, die mit objektorientierter Programmierung angefangen haben, sind über zu viele Vererbungen gestolpert und haben gelernt, lieber Klassen aus einzelnen Komponenten zusammenzusetzen.

Die Klasse Ship hätte die Komponenten Moveable und Renderable:

Class Ship:
  move = new Moveable()
  render = new Renderable()
  ...

Bei einem Raumschiff mit Tarnung genügt die Komponente Moveable.

Die Game-Engine Unity hat sich dieser komponentenbasierten Architektur verschrieben. Bei vielen Objekten ist es allerdings schwierig, die Performance zu optimieren, beispielsweise durch Parallelisierung von Prozessen oder bessere Speicherzugriffe. Eine Ursache dafür ist die Verteilung der Daten und der Funktionsweise auf unterschiedliche Klassen, die sich eventuell gegenseitig aufrufen.

Der Ansatz der datenorientierten Programmierung (DOP) versucht, Daten und Programm strikt voneinander zu trennen, um mehr Optionen für automatische Optimierungen zu schaffen. Ein Weg ist das Entwurfsmuster ECS.

Eine Komponente (Component) im Entwurfsmuster ECS enthält nur Daten. Bei Spielen sind es die Attribute, die ein Spielobjekt haben kann. Das könnte folgendermaßen aussehen:

  • Position(x,y) - PositionComponent
  • Geschwindigkeit (x,y) - SpeedComponent
  • Drahtgittermodell(mesh) - MeshComponent

Eine Komponente enthält keine Verarbeitungslogik.

Eine Entität (Entity) ist ein Spielobjekt wie das Raumschiff. Sie besteht aus beliebig vielen Komponenten und enthält keine Daten oder Programmlogik. Für die Implementierung einer Entität genügt eine eindeutige ID, die mit den Komponenten über eine interne Datenstruktur verknüpft ist.

Der dritte Baustein des ECS ist das System. Er enthält die komplette Verarbeitungslogik, aber keine eigenen Daten. Ein Spiel besteht aus beliebig vielen Systemen, die parallel in unterschiedlichen Threads laufen können.

Im ECS-Muster ersetzt ein Mechanismus, der möglichst häufig die benötigten Systeme parallel ausführt, die klassische Spieleschleife. Etwas allgemeingültiger formuliert: Ein System liest Komponenten und transformiert deren aktuellen Zustand in einen anderen.

Beispielsweise holt sich das System Movement alle Geschwindigkeitskomponenten (SpeedComponents) und Positions-Komponenten (PositionComponents), um daraus die neue Position zu berechnen.

Die Vorteile des Musters Entity Component System (ECS) gegenüber der Spieleschleife sind:

  • der datenorientierte Ansatz, bei dem die Daten die Abläufe steuern,
  • die saubere Architektur mit einer losen Kopplung statt verschachtelter Vererbung sowie
  • hohe Performance dank paralleler Verarbeitung und Caching.