zurück zum Artikel

Moderne Spieleprogrammierung mit dem Entity Component System und der Engine Bevy

Gerhard Völkl

(Bild: Shutterstock)

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

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:

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:

Carter Anderson kündigte seine Stelle bei Microsoft als Senior Software Engineer, nachdem er jahrelang mit unterschiedlichen Game-Engines gearbeitet hatte, und rief 2020 seine eigene Game-Engine ins Leben. Bevy ist ECS-basiert [1] und setzt auf die Programmiersprache Rust. Anderson fand genügend finanzielle Unterstützer und Leute, die an der freien Software mitarbeiten. Ende Juli 2022 ist Version 0.8 von Bevy erschienen.

Anderson und die Community haben sich für Bevy klare Ziele gesetzt:

Bevy setzt das ECS-Muster einfach mit den in Rust enthaltenen Datentypen um:

Die Programmiersprache Rust, die aktuell in Version 1.63 verfügbar ist [2], lässt sich einfach installieren. Bei macOS oder Linux genügt der Befehl

curl --proto '=https' --tlsv1.2 -sSf \
https://sh.rustup.rs | sh

Für Windows ist auf der Rust-Site ein Installer verfügbar [3].

Mit der Sprache kommen der Compiler und alle für die Entwicklung mit Rust benötigten Werkzeuge. Eine ausführliche Beschreibung der Installation findet man im frei verfügbaren Rust-Einsteiger-Buch [4].

Mit dem Tool cargo kann man ein neues Projekt erstellen.

cargo new rust-bevyastro

Daraufhin findet sich im Verzeichnis rust-bevyastro die Manifest-Datei Cargo.toml, die einer Make-Datei in C/C++ und den Gradle- oder den Maven-Skripten in Java entspricht. Um Bevy zu nutzen ist ein zusätzlicher Eintrag in der Datei erforderlich:

[dependencies]
bevy = "0.8"

Das eigentliche Programm entsteht im Unterverzeichnis /src in der Datei main.rs. Dort kommt in der ersten Zeile ein Verweis auf Bevy hinzu.

use bevy::prelude::*;

Der Befehl

cargo run

erstellt das minimalistische Programm und startet es.

Für viele Entwicklungsumgebungen wie Visual Studio Code oder IntelliJ IDEA existieren kostenlose Plug-ins zum Arbeiten mit Rust.

Komponenten sind die Daten, aus denen eine Entität zusammengesetzt ist. Das Raumschiff benötigt die aktuelle Geschwindigkeit.

#[derive(Component)]
struct Speed{
  value:f32
}

Eine Komponente ist in Bevy eine Struktur mit dem Trait Component. Traits lassen sich mit Interfaces in anderen Programmiersprachen vergleichen. Der Compiler erzeugt den Trait automatisch.

#[derive(Component)]
struct TurnSpeed{
  value:f32
}
#[derive(Component)]
struct Ship;

Das Raumschiff hat zusätzlich die aktuelle Drehbewegung (TurnSpeed). Darüber hinaus gibt es eine weitere Komponente Ship, die keine Daten besitzt. Sie dient dazu, die Entität als Raumschiff zu kennzeichnen (Marker Component), um es von den Asteroiden, die fast die gleichen Komponenten haben, bei der Verarbeitung zu unterscheiden.

Alle Entitäten sind in Bevy in einem Container mit dem Namen World abgelegt. Im Interesse einer einfachen Parallelverarbeitung greift ein Spiel darauf nicht direkt zu. Es verwendet Command-Buffer, die sich darum kümmern, dass die Erzeugung der Entitäten die parallele Verarbeitung nicht stört.

Folgender Befehl erzeugt die Entität Raumschiff mit allen Komponenten:

commands.spawn_bundle((
  Transform::from_translation(ship_position),
  GlobalTransform::identity(),
))
  .with_children(|parent| {
    parent.spawn_scene(
      asset_server.load("models/ship.gltf#Scene0"));
})
.insert(Ship)
.insert(TurnSpeed{value:0.0})
.insert(Shake{value:false, default_time:2.0, time:0.0})
.insert(Speed{value:0.0});

Die Variable commands enthält einen Command-Buffer. Dessen Methode spawn_bundle erzeugt die beiden in Bevy definierten Komponenten Transform und GlobalTransform. Ein Bundle ist eine Sammlung von Komponenten – in diesem Fall zwei.

Die Komponente Transform besteht aus drei Attributen: Translation (Position), Rotation (Drehung) und Scale (Skalierung). Damit beschreibt sie die aktuelle Position des Raumschiffs. Um in Spielszenen Zusammenhänge herzustellen, können Entitäten Kinder-Entitäten haben. Die Komponente GlobalTransformation berücksichtigt die Transformationen der Eltern-Entität, falls es eine gibt. Gibt es keine, hat sie den gleichen Wert wie Transform.

Der Asset-Server ist in Bevy für alle Inhalte jenseits des Codes wie Bilder, 3D-Modelle und Musik zuständig. Dessen Methode load lädt das Drahtgittermodel des Raumschiffs, das als GLTF-Datei vorliegt. Der Befehl commands.insert_bundle fügt es dem Raumschiff mit der neuen Komponente SceneBundle hinzu.

Durch die Methode insert erhält das Raumschiff die einzelnen Komponenten Ship, Turnspeed und Speed.

Für häufiger benötigte Entitäten, wie bei SceneBundle gesehen, bietet Bevy fertige Bundles mit passenden Komponenten. Das gilt ebenso für Lichtquellen oder Kameras.

Das Bundle Camera3dBundle steht für eine Kamera, bei der man lediglich die aktuelle Position und Blickrichtung festlegt:

commands.spawn_bundle( Camera3dBundle{
  transform: Transform::from_xyz(0.0,20.0,0.5)
             .looking_at(Vec3::new(0.,0.,0.), Vec3::Y),
  ..Default::default()
});

Damit Bevy die Entitäten erzeugt, benötigt es ein System, eine Funktion.

fn setup(
    mut commands: Commands,
    asset_server: Res<AssetServer>
){
   commands.spawn_bundle(PerspectiveCameraBundle{ ... });
...
}

In der Funktion setup müssen die benötigten Parameter stehen. Bevy kümmert sich darum, dass sie beim Ausführen die richtigen Werte enthalten. Das mut vor dem Parameter commands für den Command-Buffer signalisiert dem Rust-Compiler, dass der Parameter veränderbar (mutable) ist. In Rust sind alle Variablen nur zum Lesen bestimmt, wenn sie nicht explizit mit mut gekennzeichnet sind.

Die Funktion setup enthält die weiter oben gezeigten Befehle zum Erzeugen der Entitäten. Ein weiteres System moving kümmert sich um die Bewegung des Raumschiffs, der Asteroiden und der Laser.

fn moving(
  time:Res<Time>,
  mut query: Query<(&mut Transform, &mut Speed)>,
){
  for (mut transform, speed) in query.iter_mut() {
    if speed.value != 0.0 {
      let translation_change = 
         transform.forward() * speed.value 
         * time.delta_seconds();
      transform.translation -= translation_change;
      ...
    }
  }
}

Die Ressource Time als Parameter liefert mit der Methode delta_seconds die Zeit seit dem letzten Zeichnen eines Bilds. Das ist ein Faktor dafür, wie weit sich die Entität bewegt hat. Ressourcen sind in Bevy zentrale Werte, auf die alle Systeme zugreifen dürfen. Neben den vordefinierten können Entwicklerinnen und Entwickler eigene erstellen.

query, der zweite Parameter des Systems moving, definiert die Auswahl der Entitäten, die die Funktion bearbeiten möchte:

mut query: Query<(&mut Transform, &mut Speed)>

Es handelt sich um die Entitäten, die eine Komponente Transform und Speed besitzen:

for (mut transform, speed) in query.iter_mut() {...}

Die Methode query.it_mut() liefert die einzelnen ausgewählten Entitäten, die die Funktion in einer For-Schleife abarbeitet. Diese Auswahl und eine nachfolgende Schleife gibt es bei den meisten Systemen, da sie dafür zuständig sind, Veränderungen an bestimmten Entitäten durchzuführen.

Bevy verarbeitet Eingaben über Tastatur, Maus, Touchscreen und Gamepad. Grundsätzlich gibt es zwei Wege, an die Eingabewerte zu kommen: über eine globale Ressource mit den Werten oder über Input-Events.

Das System input_ship holt sich die aktuellen Tastatureingaben und verändert dementsprechend die Geschwindigkeit (Speed) oder die Drehgeschwindigkeit (TurnSpeed) des Raumschiffs.

fn input_ship(
  keyboard_input:Res<Input<KeyCode>>,
  mut query: Query<(&mut TurnSpeed,
                    &mut Speed, 
                    &Transform), With<Ship>>)
{
  let (mut turnspeed,mut speed,transform) = 
    query.single_mut();
  turnspeed.value = 
    if keyboard_input.pressed(KeyCode::Left) {
      TURN_SPEED
    } else if keyboard_input.pressed(KeyCode::Right) {
     -TURN_SPEED
    } else {
      0.0
    };
...
}

Durch die Ressource keyboard_input erhält das System Zugriff auf die aktuellen Tastatureingaben. Beispielsweise liefert die Methode

keyboard_input.pressed(KeyCode::Left)

true zurück, wenn gerade die Pfeiltaste nach links gedrückt ist. Dementsprechend setzt das System die Drehgeschwindigkeit.

Das einfache Raumschiffspiel hat einige weitere Systeme für die Kollisionserkennung und die Anzeige des aktuellen Punktestands. Die Systeme sind die Bausteine, aus denen das Spiel besteht.

Die Applikation entsteht in der Funktion main, die Rust immer als erstes ausführt.

fn main() {
  App::new()
    //add config resources
    //bevy itself
    //systems
    .run();
}

Die Funktion new erzeugt die Anwendung. Darauf folgen die Definitionen der Ressourcen und der Systeme, bis schließlich die Funktion run die Applikation startet:

App::new().insert_resource(WindowDescriptor{
  title: "bevyastro".to_string(),
  width: 800.0,
  height: 600.0,
  ..Default::default()
})
.insert_resource(CountLaser{value:0})

Einige Ressourcen definiert Bevy standardmäßig, darunter den WindowDescriptor, der beschreibt, wie das Fenster für das Spiel aussehen soll. Daneben gibt es spielspezifische Ressourcen wie CountLaser, die mitzählt, wie viele Laser das Schiff gerade abgefeuert hat.

Vor den Systemen des Spiels stehen die Systeme, aus denen Bevy besteht. Plug-ins fassen Systeme zusammen, um nicht jedes separat angeben zu müssen:

  .add_plugins(DefaultPlugins)

DefaultPlugins enthält die gängigen Systeme der Engine. Alternativ lassen sich gezielt diejenigen Systeme auswählen, die für ein bestimmtes Spiel erforderlich sind.

Durch die Funktion add_startup_system läuft das System setup genau einmal vor allen anderen Systemen. Anschließend folgen die Systeme, die immer wieder vor jedem Bild laufen:

  .add_system(input_ship)
  .add_system(turn)
  ...
  .add_system(collision_laser)
  .run();

Ein Spiel in Bevy lässt sich ohne Änderungen als Web-Applikation nach WebAssembly (wasm) kompilieren. Mehr dazu findet sich im Bevy Cheatbook [5].

Eine in vielen Browsern lauffähige Web-Version des Beispiels ist online verfügbar [6].

Vor ein paar Jahren hat sich der Game-Engine-Hersteller Unity entschlossen, mit seiner Initiative Data-Oriented Tech Stack (DOTS) ebenfalls in den datenorientierten Ansatz einzusteigen. Im März 2022 ist Version 0.5 erschienen [7]. DOTS koexistiert im klassischen Unity mit seinen MonoBehaviour-Klassen in C#.

DOTS besteht aus drei Teilen:

Unity versucht, den datenorientierten Ansatz in der Objektorientierung von C# unterzubringen. Daher ist die zentrale Anlaufstelle des ECS-Frameworks zum Erzeugen neuer Entitäten die Klasse EntityManager:

using UnityEngine;
using Unity.Entities;

private EntityManager entityManager;

entityManager = 
  World.DefaultGameObjectInjectionWorld.EntityManager;

Entity entity = entityManager.CreateEntity();

Die durch die Methode CreateEntity erzeugte Entität hat keine Komponenten. Letztere lassen sich mit einer Vorlage in Form der Klasse EntityArchetype erstellen.

EntityArchetype archetype = entityManager.CreateArchetype(
  typeof(Translation),
  typeof(Rotation),
  typeof(RenderMesh),
  typeof(RenderBounds),
  typeof(LocalToWorld)
);

Entity entity = entityManager.CreateEntity(archetype);

Danach kann man den erzeugten Komponenten konkrete Daten zuweisen:

entityManager.AddComponentData(entity,new Translation{
  Value = new float3(-3f, 0.5f, 5f)
});

Wer eigene Komponenten definieren möchte, muss ein Struct erstellen, das das Interface IComponentData verwendet.

using Unity.Entities;
public struct SpeedComponent :  IComponentData
{
   public float speed;
}

In DOTS sind Systeme Klassen, die von ComponentSystem abgeleitet sind.

using Unity.Entities;
using Unity.Transforms;
using Unity.Mathematics;

public class MoveSystem : ComponentSystem
{
  protected override void OnUpdate()
  {
    Entities.WithAll<Move>()
      .ForEach((ref Translation trans, 
                ref Rotation rot, 
                ref SpeedComponent speedComponent) =>
    {
      trans.Value += speedComponent.speed 
                  * Time.DeltaTime 
                  * math.forward(rot.Value);
    });
  }
}

Das System MoveSystem holt sich die Komponenten Translation, Rotation und SpeedComponent und berechnet daraus die aktuelle Position, die es in die Komponente Translation zurückschreibt.

Das neue System ist nach dem Speichern der zugehörigen Datei aktiv, und Unity führt es beim nächsten Spielestart aus.

Für ECS gibt es in Unity viele hilfreiche Dialoge und Debugger. Entities kann man automatisch aus vorhandenen Klassen durch Markieren des Felds ConvertToEntity im GameObjects-Dialog erzeugen.

Ein Blick auf die Performance lohnt sich erst zur ersten Hauptversion von DOTS mit allen Bestandteilen.

Der Hersteller der Game-Engine Unreal arbeitet mit dem Entity Fragment Processor [8] ebenfalls an einer Umsetzung des ECS-Musters. Er verwendet zwar teilweise andere Begriffe, arbeitet aber letztlich nach demselben Prinzip.

Im Bereich Data Science, in dem die Daten immer mehr im Mittelpunkt stehen, ist es eine spannende Herangehensweise, die Prinzipien der datenorientierten Programmierung auf Bereiche außerhalb der Spieleentwicklung zu übertragen. Einen Ansatz dazu zeigt Yehonathan Sharvit in seinem Buch Data-Oriented Programming [9] [1].

Das Entity Component System vereinfacht das Erstellen komplexer Spiele enorm. Wer das Prinzip begriffen hat, schafft schnell den Einstieg in umfangreiche Spiele-Engines wie Bevy oder Unity DOTS.

Bevy und die Programmiersprache Rust sind ideale Partner, da die Entwicklung auf einer hohen Abstraktionsebene abläuft, ohne auf Hardwarenähe und hohe Performance zu verzichten.

Gerhard Völkl
ist Fachjournalist für Softwareentwicklung, Data Science und Computergrafik.

Der Autor bedankt sich bei Kenney [10] für die kostenfreien Game-Assets und bei Rust-Ninja-Sabi [11] für das Beispielprogramm.

  1. Yehonathan Sharvit; Data-Oriented Programming; Manning 2022

(rme [12])


URL dieses Artikels:
https://www.heise.de/-7257412

Links in diesem Artikel:
[1] https://bevyengine.org/
[2] https://www.heise.de/news/Programmiersprache-Rust-1-63-bekommt-Threads-die-sich-Daten-ausleihen-duerfen-7218192.html
[3] https://www.rust-lang.org/tools/install
[4] https://doc.rust-lang.org/book/
[5] https://bevy-cheatbook.github.io/introduction.html
[6] https://rust-ninja-sabi.github.io/rust-web-bevyastro/
[7] https://unity.com/de/dots
[8] https://docs.unrealengine.com/5.0/en-US/overview-of-mass-entity-in-unreal-engine/
[9] https://www.manning.com/books/data-oriented-programming
[10] https://www.kenney.nl/
[11] https://github.com/Rust-Ninja-Sabi
[12] mailto:rme@ix.de