Seine Geschwindigkeit und Hardwarenähe prädestinieren Rust für 3D-Anwendungen und Spiele. Dabei sticht die Bibliothek Bevy hervor, die neben einer hardwareunabhängigen Grafik-API eine komplette Game-Engine für 2D/3D mitbringt.
Als mein erster eigener Rechner vor mir stand, ein C64, wollte ich damit nicht irgendwelche schnöden Berechnungen für Mathe programmieren oder bloß mit einem Englisch-Vokabeltrainer lernen. Nein, ich wollte die Computerspiele nachprogrammieren, die ich in der Spielhalle gesehen hatte. Damals waren das die bunten Klötzchengrafiken von Pac-Man und Space Invaders.
Heute unterscheiden sich computergenerierte Bilder, etwa in Spielen wie Assassin’s Creed von Ubisoft, kaum mehr von denen aus Filmen. Meine Begeisterung für Computergrafik hält dennoch bis heute an. Zum Jubiläum dieser Rust-Kolumne soll es in der vorliegenden 10. Episode deswegen um 3D-Anwendungen und Spiele gehen. Eines der gezeigten Beispiele ist der Space-Shooter Planet Rust, auf den das Aufmacherbild des Artikels einen Vorgeschmack liefert.
Computergrafik ist zwar grundsätzlich ohne spezielle Hardware möglich, aber heutzutage nicht mehr zu empfehlen. Damit Programme möglichst unabhängig von den unterschiedlichen Grafikchips (GPUs) laufen, haben sich die Hersteller allgemeingültige Programmierschnittstellen wie Vulkan [1], DirectX [2] oder WebGPU [3] einfallen lassen. Die APIs direkt zu verwenden, erfordert einiges an Wissen, sie sind alles andere als selbsterklärend. Im Gegensatz dazu arbeiten Rust-Bibliotheken wie Bevy unabhängig von konkreten Grafik-APIs. Sie kooperieren zwar mit ihnen, in einem selbst erstellten Programm sehen Sie davon jedoch nichts.
Im Jahr 2020 kam Carter Anderson, damals Senior Software Engineer bei Microsoft, auf die Idee, mit Rust seine eigene Grafik- und Spielebibliothek zu programmieren. Zu diesem Zeitpunkt hatte Anderson schon mit vielerlei 3D-Engines gearbeitet. Er kündigte seinen Arbeitsplatz und suchte sich sowohl finanzielle Unterstützung als auch genügend Mitstreiter, um die frei verfügbare Bibliothek Bevy [4] ins Leben zu rufen.
Alle am Projekt Beteiligten setzen sich ehrgeizige Ziele. Als primäre Prämisse gilt, dass die Bibliothek tatsächlich frei sein soll. Sie muss also stets Open-Source-Kriterien genügen, es darf keinerlei kostenpflichtige Lizenzen geben. Daneben soll Bevy alles mitbringen, was man für 2D- und 3D-Anwendungen braucht. Das Projekt möchte sich einerseits an Einsteiger richten und andererseits Fortgeschrittenen größtmögliche Flexibilität bieten.
Außerdem folgen die Entwickler einer datenorientierten Architektur nach dem Muster Entity-Component-System. Großen Wert legen sie darüber hinaus auf Modularität, gemäß dem Motto: “Verwende nur das, was du willst, und tausche das aus, was du nicht magst.” Schließlich soll die resultierende Anwendung auch zügig laufen, falls möglich parallel.
Entity-Component-System
Beim Programmiermuster Entity-Component-System (ECS) ist buchstäblich der Name Programm: Es unterteilt sich in Entitäten, Komponenten und Systeme. Hinter einer Entity verbirgt sich ein Objekt, eine Person oder etwas, das im Programm vorkommt. Eine Entität besteht lediglich aus Daten und kann beliebig viele Komponenten besitzen. Hinsichtlich unseres Space Shooters könnte eine Komponente des Raumschiffs die Laser-Bewaffnung sein. Sie speichert, über wie viele Laser-Torpedos das Raumschiff aktuell verfügt.
Analog zu den Entitäten setzen sich Komponenten ebenfalls nur aus Daten zusammen. Die Komponente Laser-Bewaffnung könnte jede Raumschiff-Entität besitzen, egal, ob es sich dabei um die Angreifer oder das Raumschiff des Spielers handelt. Das Raumschiff des Spielers hat zusätzlich die Komponente Steuerung-per-Tastatur. Jede Komponente lässt sich ohne Einschränkungen oder Regeln jeder Entität zuordnen.
Hinter einem System, dem dritten Teil von ECS, steckt grundsätzlich nichts anderes als eine Funktion. Sie verändert die Entitäten und Komponenten. Ein System Move sucht sich beispielsweise die Raumschiff-Entitäten heraus und variiert deren Position im Raum entsprechend der Vorgaben im Spiel.
Ein wesentliches Prinzip von ECS liegt in der konsequenten Trennung von Daten (Entitäten, Komponenten) und Funktionen (System). Es handelt sich also um einen Gegenentwurf zur klassischen objektorientierten Herangehensweise. Bei Letzterer gibt es Klassen, die wieder aus anderen Klassen bestehen. Jede Klasse verfügt über ihre eigenen Funktionen (Methoden). Bei sehr vielen im Detail unterschiedlichen Objekten fällt es schwer, die Abhängigkeiten zwischen einzelnen Klassen sowie die geerbten Methoden zu erkennen.
Der Ansatz der datenorientierten Programmierung (DOP), zu der ECS gehört, spielt seine Vorteile aus, wenn es gilt, zahlreiche unterschiedliche Daten zu verarbeiten (transformieren). Zusätzlich eröffnet ECS mehr Möglichkeiten für automatische Optimierungen und Parallelisierung.
Mithilfe der Bibliothek Bevy gestaltet sich das Umsetzen des Programmiermusters in Rust einfach und intuitiv [5]. Eine Komponente entspricht einer Standard-Rust-Struktur (»struct«), eine Entität dagegen einem Datentyp, der aus einer eindeutigen ID (Integer-Zahl) besteht. Ein System ist eine Rust-Funktion.
Das klingt im ersten Moment ein wenig abstrakt, doch das Umsetzen in einer 3D-Anwendung oder einem Spiel fällt relativ unkompliziert aus. Außerdem unterstützt die Herangehensweise Entwickler dabei, in immer größer werdenden Programmen den Überblick zu behalten. Immerhin sorgt ECS für eine eindeutige Struktur.
3D-Programm mit Bevy
Wir fangen klein an: Die erste 3D-Anwendung umfasst einen sich drehenden Würfel und eine Kugel, die Sie mit den Pfeiltasten nach oben oder unten verschieben (Abbildung 1). Das wirkt nicht besonders aufregend, enthält aber dieselben Grundelemente wie das Spiel Planet Rust.
Um mit Bevy zu starten, fügen Sie der Datei »cargo.toml« den Eintrag »[dependencies] bevy = “0.14”« hinzu. Das Programm »main.rs« in Listing 1 nimmt in der ersten Zeile Bezug darauf.
Listing 1
main.rs
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.insert_resource(ClearColor(Color::Srgba(WHITE_SMOKE)))
.add_systems(Startup, setup)
.add_systems(Update, rotate_cube)
.add_systems(Update, move_ball)
.run();
}
In einem Programm mit Bevy setzt sich die Funktion »main()« aus der Definition der Anwendung und deren Bestandteilen zusammen. Nach dem Erzeugen der App fügen Sie die benötigten Systeme (Funktionen) und andere Bestandteile der Anwendung hinzu. Die Vorgehensweise heißt Builder-Pattern und kommt in Rust häufig zum Einsatz.
Durch die Methode »add_plugins()« in der Zeile 4 erhält die Anwendung ein zusätzliches Plugin. Ein Plugin entspricht im Wesentlichen einer Sammlung von Systemen. Damit Sie nicht jedes System einzeln benennen müssen, gibt es ein ganzes Paket davon. Das Plugin »DefaultPlugins« enthält die Grundfunktionalität von Bevy, beispielsweise das Einlesen der Anwendereingaben und die Kommunikation mit der 3D-Schnittstelle. Deswegen sollten Sie es immer zuerst einbinden. Benötigen Sie einige Systeme von Bevy nicht oder möchten einzelne durch eigene Varianten ersetzen, wählen Sie nicht das gesamte Paket aus, sondern exakt die gewünschten Systeme.
Bevy hält einen globalen Bereich vor, in dem sogenannte Ressourcen liegen. Auf sie können Sie in jeder Funktion zugreifen und sie verändern. Der Zugriff auf eine Bevy-Ressource erfolgt über deren Typ. Daher ist es wichtig, dass jede Ressource einen anderen Typ hat. Der Datentyp »ClearColor« aus unserem Beispiel (Zeile 5) steht für die Hintergrundfarbe. Diese Ressource bringt Bevy bereits mit und reagiert auf deren Wert.
Die Methode »add_systems()« fügt der App eines oder mehrere Systeme hinzu. Der erste Parameter gibt an, wann Bevy es ausführt. Beim Wert »Startup« (Zeile 6) startet die Funktion einmal zu Beginn der Anwendung und dann nie mehr. Das passt zu unserer Funktion »setup«, die alle Elemente der 3D-Anwendung wie Würfel, Kamera, Licht und so weiter erzeugt. Das fällt nur einmal beim Start des Programms an
Die Funktion »rotate_cube« (Zeile 7) sorgt dafür, dass der Würfel sich bei jedem Bildaufbau ein Stückchen weiterdreht. Durch den Parameter »Update« weiß Bevy, dass es die Funktion möglichst häufig aufrufen soll. In Bevy gibt es viele Möglichkeiten, um zu definieren, wann und unter welchen Voraussetzungen die Bibliothek eine Funktion aufrufen soll. So könnten Sie beispielsweise festlegen, dass eine Funktion alle zwei Sekunden aufgerufen wird oder erst die eine und danach eine andere Funktion ausgeführt werden soll.
Da es bei 3D-Anwendungen ums Timing geht, spielt der Zeitpunkt der Ausführung von Funktionen eine wichtige Rolle. Der Vorteil bei Bevy: Sie definieren lediglich, was wann passieren soll – den Rest übernimmt die Bibliothek. Haben Sie der Applikation alle Bestandteile hinzugefügt, startet in der letzten Zeile von Listing 1 die Methode »run()« die Anwendung.
3D-Welt erzeugen
Was braucht man alles für die 3D-Beispiel-Anwendung? Einen Würfel und eine Kugel, allgemeiner: 3D-Figuren. Aktuell ist es immer noch zappenduster. Genau, etwas Licht wäre nicht schlecht! Es ist immer noch nichts zu sehen. Was Sie noch benötigen, ist eine virtuelle Kamera, um zu definieren, was Bevy auf dem Bildschirm zeigen soll. Die Funktion »setup« (Listing 2) erstellt sämtliche Entitäten der Anwendung. Die Werte für deren Aufruf übergibt Bevy automatisch.
Listing 2
Funktion setup()
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
// Kamera
commands.spawn(Camera3dBundle {
transform: Transform::from_xyz(-2.0, 2.5, 5.0).looking_at(Vec3::ZERO, Vec3::Y),
..default()
});
// Licht
commands.spawn(PointLightBundle {
point_light: PointLight {
shadows_enabled: true,
..default()
},
transform: Transform::from_xyz(3.0, 8.0, 5.0),
..default()
});
// Grundplatte
commands.spawn(PbrBundle {
mesh: meshes.add(Mesh::from(Cuboid::new(5.0,0.1,5.0))),
material: materials.add(Color::Srgba(LIGHT_GRAY)),
transform: Transform::from_xyz(1.0, -0.5, -1.0),
..default()
});
// blauer Würfel
commands.spawn((
PbrBundle {
mesh: meshes.add(Mesh::from(Cuboid::new(1.0, 1.0, 1.0))),
material: materials.add(Color::Srgba(BLUE)),
transform: Transform::from_xyz(0.0, 0.5, 0.0),
..default()
},
Cube,
));
// grüne Kugel
commands.spawn(PbrBundle {
mesh: meshes.add(Mesh::from(Sphere{
radius: 0.5
})),
material: materials.add(Color::Srgba(LIMEGREEN)),
transform: Transform::from_xyz(1.5, 0.5, 0.0),
..default()
}).insert(Ball{});
}
Mithilfe des ersten Parameters »commands« teilen Sie mit, welche Veränderungen Sie in der 3D-Welt vornehmen möchten. Bevy setzt sie um, sobald das technisch möglich ist. Dabei achtet die Bibliothek darauf, dass parallele Prozesse nicht ins Stocken geraten, weil beispielsweise der Programmierer gleichzeitig irgendwelche Entitäten erzeugt oder ändert. Der Einsatz von »Commands« macht die Abarbeitung sehr sicher und automatisierte Parallelisierungen überhaupt erst möglich.
Die Methode »spawn()« weist »commands« an, eine neue Entität zu erzeugen. Dabei handelt es sich eigentlich nur um eine ID – wichtig ist, welche Komponenten die Entität besitzt. Für eine Entität, die einer virtuellen Kamera entspricht, sind das einige. Damit Sie sich die Mühe sparen können, jedes Mal wieder die passenden Komponenten zusammenzusuchen, gibt es in Bevy sogenannte Bundles. Sie versammeln die passenden Komponenten für bestimmte Aufgaben.
Das Bundle »Camera3dBundle« (Listing 2, Zeile 7 bis 10) enthält die Komponente »Transform«. Sie bestimmt, wo sich die Kamera befindet und in welche Richtung sie zeigt. Im Beispiel legt die Methode »from_xyz()« die Kameraposition (Translation) fest. Die Methode »looking_at()« definiert die Ausrichtung der Kamera, also im Endeffekt die Drehung (Rotation).
Darüber hinaus bringt die Komponente »Transform« die Möglichkeit der Skalierung (Scale) mit. Das erweist sich bei 3D-Figuren als nützlich, aber nicht bei einer virtuellen Kamera, da sie kein Volumen hat. Mit der Komponente »Transform«, bestehend aus Translation, Rotation und Scale, lässt sich die aktuelle Position beschreiben und auf einfache Weise verändern.
Wo ist oben?
Aus der Schule kennen Sie das 2D-Koordinatensystem. Die X-Achse zeigt nach oben und unten, die Y-Achse nach rechts und links. Im dreidimensionalen Raum kommt mit der Z-Achse eine weitere hinzu. Dafür hat sich keine so eindeutige Zuordnung herauskristallisiert, unterschiedliche 3D-Schnittstellen verwenden unterschiedliche Definitionen.
Bevy gehört zur Fraktion der Rechtshänder-Koordinatensysteme. Nehmen Sie einfach ihre rechte Hand und zeigen Sie mit dem Daumen nach rechts. Er steht für die X-Achse. Rechts befinden sich die positiven X-Werte, links die negativen. Anschließend weisen Sie mit dem Zeigefinger nach oben, sodass er im rechten Winkel zum Daumen steht. Er deutet die Ausrichtung der der Y-Achse an. Nach oben werden die Werte größer, nach unten kleiner. Zu guter Letzt zeigen Sie mit dem Mittelfinger auf sich selbst. Damit steht er orthogonal zu Zeigefinger und Daumen und verkörpert die Z-Achse. Auf Sie zu wachsen die Werte an, nach hinten fallen Sie ab.
Damit haben Sie den Einstieg in das 3D-Koordinatensystem von Bevy gemeistert. Wenn Sie irgendwann einmal einen Grafikprogrammierer sehen, der auf seine Finger starrt, geht es nicht darum, ob er sie heute schon gewaschen hat: Sie wissen jetzt, dass er darüber nachdenkt, wo im 3D-Raum sich was befindet.
Blauer Würfel
Selbst eine Komponente zu erstellen fällt leicht, da es sich um eine Rust-Struktur handelt. Die Komponente »struct Cube« bezeichnet eine 3D-Figur und verrät, dass es sich dabei um einen Würfel handelt. Die Struktur könnte beliebige Attribute besitzen, doch in unserem Fall braucht sie keine. Um die Struktur zu einer vollwertigen Komponente zu machen, fügen Sie die Direktive »derive(Component)« hinzu, den Rest erledigt der Compiler:
#[derive(Component)] struct Cube;
Unser Beispielprogramm erstellt über das Bundle »PbrBundle« (Physically Based Rendering Bundle), das die Komponenten für 3D-Figuren mitbringt, einen blauen Würfel (Listing 3, zweite Zeile). Dazu passt es die Komponenten für das Volumen der Figur (Mesh, Geometriedefinition), das Material und »Transform« an. Einfache Figuren erzeugen Sie direkt mit Bevy. Für komplexere Varianten gibt es die Möglichkeit, sie mit entsprechender 3D-Software zu generieren und in Bevy zu laden.
Listing 3
PbrBundle
commands.spawn((
PbrBundle {
mesh: meshes.add(Mesh::from(Cuboid::new(1.0, 1.0, 1.0))),
material: materials.add(Color::Srgba(BLUE)),
transform: Transform::from_xyz(0.0, 0.5, 0.0),
..default()
},
Cube,
));
In der dritten Zeile des Listings steht »mesh« für das Maschengitter der Punkte und Flächen, aus denen die Figur besteht. »Cuboid« (Quader) erstellt einen Klotz mit den Seitenlängen von jeweils »1.0«, sprich: einen Würfel. Ihn ordnet das Programm nicht direkt der Komponente »Mesh« zu, sondern lädt das Mesh zunächst in eine Liste von Meshes, die sich in den Ressourcen befindet (»meshes.add«). Diese Liste fungiert als zweiter Parameter der Funktion »setup« (Listing 2, dritte Zeile).
Meshes, Materialdefinitionen, Bilder, Musik und so weiter heißen in 3D-Anwendungen Assets. Für jeden Typ dieser Assets hält Bevy eine Liste in den Ressourcen bereit, sämtliche Assets sind somit zentral gespeichert. Die Methode »meshes.add()« fügt dieser Liste nicht nur das neue Mesh hinzu, sondern liefert einen Verweis darauf – einen Handle – an die Komponente zurück. Auf diese Weise stellt Bevy den Bezug zwischen den Mesh-Daten und der Komponente her. Der Vorteil dieser Herangehensweise zeigt sich, wenn Sie nicht nur einen Würfel brauchen, sondern 5000. Sie erzeugen dann einmal die Mesh-Daten und hinterlegen 5000-mal denselben Verweis, also den gleichen Handle.
Um den Würfel zu vervollständigen, bekommt er noch ein Material mit blauer Farbe. Das Programm erzeugt dieses Material und legt es ebenfalls in einer Asset-Liste ab. Am Ende fügt es noch unsere selbst definierte Komponente »Cube« hinzu. Das ist notwendig, damit wir später den Würfel von den anderen Entitäten unterscheiden können.
Würfel drehen
Wie in der App-Definition in Listing 1 festgelegt, ruft Bevy bei jedem Bildaufbau die Funktion »rotate_cube()« (Listing 4) auf. Der erste Parameter der Funktion, »query« (Zeile 2) steht für eine Auswahl von Entitäten, mit denen die Funktion etwas machen soll.
Listing 4
Funktion rotate_cube()
fn rotate_cube(
mut query: Query<&mut Transform, With<Cube>>,
time: Res<Time>
) {
for mut transform in &mut query {
transform.rotate_y(0.5 * time.delta_seconds());
}
}
Eine Query besteht bei Bevy aus zwei Teilen. Der erste beschreibt das gewünschte Ergebnis, in unseren Fall die Komponente »Transform« der Entität. Der zweite Teil filtert die entsprechenden Entitäten heraus. Die Aussage »With<Cube>« bedeutet: Liefere mir jede Entität zurück, die eine Komponente »Cube« besitzt.
Die For-Schleife in Zeile 5 führt die Query aus und dreht den Würfel ein Stück weiter. In Zeile 6 rotiert die Methode »transform.rotate_y« die Figur um die Y-Achse. Der Wert dafür hängt von der Zeit ab. Je mehr Zeit seit dem letzten Aufruf vergangen ist, desto größer muss der Drehwinkel ausfallen, damit es nach einer gleichmäßigen Bewegung aussieht.
An dieser Stelle kommt »time« ins Spiel, der zweite Parameter der Funktion »rotate_cube()«. Dabei handelt es sich um einen Zugriff auf die Zeitkomponente von Bevy. Die Methode »delta_seconds()« (Zeile 6) liefert die Zeit in Sekunden seit dem letzten Aufruf zurück.
Input
Eingabegeräte wie Tastatur, Maus und Joystick entsprechen in Bevy Ressourcen (Listing 5). Ein Programm kann auf deren aktuellen Zustand genauso zugreifen wie bei der Ressource »time«.
Die Tastatur ist eine Ressource des Datentyps »ButtonInput«. Sie liefert »KeyCode« zurück (Zeile 3), dessen Wert etwas darüber aussagt, welche Taste der Anwender gedrückt hat. Drückt der Spieler gerade auf die Pfeil-nach-oben-Taste, liefert die Methode »pressed« (Zeile 10) den Wert »true« (»1«) zurück. Dann verschiebt die Funktion den Ball nach oben.
Listing 5
Funktion move_ball()
const SPEED:f32 = 1.0;
fn move_ball(
keyboard_input:Res<ButtonInput<KeyCode>>,
mut query: Query<(&mut Transform),With<Ball>>,
time: Res<Time>
){
for mut transform in &mut query {
let vertical: f32 = if keyboard_input.pressed(KeyCode::ArrowDown) {
-1.
} else if keyboard_input.pressed(KeyCode::ArrowUp) {
1.
} else {
0.0
};
transform.translation.y = transform.translation.y + vertical * time.delta_seconds()
* SPEED;
}
}
Quellcode
Den Quellcode zu Planet Rust finden Sie auf Github [6]. Dort steht außerdem ein einfaches 3D-Beispiel [7] zur Verfügung.
Ausblick
Damit kennen Sie die wesentlichen Teile, aus denen 3D-Anwendungen und Spiele wie Planet Rust bestehen. Noch fehlt jedoch eine Physik-Bibliothek, die sich um realistische Bewegungen und Zusammenstöße kümmert. Dazu lesen Sie mehr im nächsten Teil dieser Artikelreihe. (csi)
Infos
- Vulkan: https://www.vulkan.org
- DirectX Developer Blog: https://devblogs.microsoft.com/directx/
- WebGPU: https://www.w3.org/TR/webgpu/
- Bevy: https://github.com/bevyengine/bevy
- Bevy Cheatbook: https://bevy-cheatbook.github.io.
- Quellcode zu Planet Rust: https://github.com/Rust-Ninja-Sabi/planet-rust
- 3D-Beispiel: https://github.com/Rust-Ninja-Sabi/rust-bevy-first






