Moderne Spieleprogrammierung mit dem Entity Component System und der Engine Bevy

Seite 2: Das Spiele-Framework Bevy

Inhaltsverzeichnis

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 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:

  • umfassend: Alles zu bieten, um komplette 2D- und 3D-Spiele zu erstellen,
  • einfach: Möglichst einsteigerfreundlich, aber gleichzeitig flexibel für Fortgeschrittene,
  • datenzentriert: datenorientierte Architektur mit dem ECS-Muster,
  • modular: Nimm, was du brauchst, und ersetze, was du nicht magst, sowie
  • performant: Die Anwendungslogik soll schnell und möglichst parallel laufen.

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

  • die Komponente ist eine normale Rust-Struktur (struct),
  • die Entität ist ein Datentyp, der aus einer eindeutigen Integer-Zahl besteht und
  • das System ist eine Rust-Funktion.

Die Programmiersprache Rust, die aktuell in Version 1.63 verfügbar ist, 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.

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.

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.

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