Ein First-Person-Controller in Babylon.js · Teil 1 von 11 der Serie
Teil 1: Babylon.js, Havok und Vite
Richte ein Vite-Projekt mit TypeScript, Babylon.js 9 und der Physik-Engine Havok ein und baue den Testparcours, durch den die Spielfigur in dieser Serie geht, springt und klettert.

Quest-Ziele
- Wissen, was du in dieser Serie baust
- Ein Vite-Projekt mit TypeScript anlegen
- Babylon.js 9 so importieren, dass nur landet, was du nutzt
- Die Physik-Engine Havok laden, die als WebAssembly läuft
- Einen Testparcours aus Kisten mit statischen Physikkörpern bauen
Ein First-Person-Controller ist der Code zwischen den Händen der Spielenden und der Spielwelt: Er macht aus Tasten und Mausbewegungen einen Körper, der geht, fällt, gegen Wände stößt und klettert, und er setzt die Kamera in den Kopf dieses Körpers. Er bestimmt mehr als alles andere, wie sich ein First-Person-Spiel anfühlt, deshalb nimmt sich diese Serie Zeit für ihn.
Am Ende geht und rennt der Controller, nimmt Schrägen und Treppen, springt mit etwas Nachsicht, duckt sich unter niedrigen Decken und klettert auf vier Arten: Leitern hoch, auf Kanten, an Kanten entlang, an denen er hängt, und überall an kletterbaren Wänden. Die Kamera wippt mit den Schritten und federt bei der Landung.
Die Werkzeuge
Babylon.js ist eine 3D-Engine für das Web. Sie zeichnet mit WebGL oder WebGPU und bringt eine Physik-Anbindung mit.
Havok ist eine Physik-Engine aus der Spielebranche, kompiliert zu WebAssembly. Babylon.js liefert dafür einen Character-Controller mit, auf dem diese Serie aufbaut.
Vite liefert das Projekt aus, während du daran arbeitest, und bündelt es für das Web. TypeScript prüft den Code.
Die Serie
Babylon.js, Havok und Vite (dieser Teil)
Mausblick mit Pointer Lock
Gehen mit einem Character-Controller
Schrägen, Treppen und Stufen
Rennen und Springen
Ducken
Leitern und ein Zustandsautomat
Auf Kanten klettern
An Kanten hängen
Wände hochklettern
Kameragefühl
Jeder Teil endet mit einer Demo, die du direkt auf der Seite spielen kannst. Sie ist aus genau dem Code gebaut, den der Teil zeigt, und der lief beim Schreiben der Serie mit Babylon.js 9.33, Havok 1.3, Vite 8.3 und TypeScript 7.
Was du brauchst
Node.js 20.19 oder neuer, das Vite 8 voraussetzt, ein Terminal und einen aktuellen Browser. Tastatur und Maus helfen: Der Controller ist für sie gemacht.
Das Projekt anlegen
Der Projektgenerator von Vite richtet ein TypeScript-Projekt ohne Framework ein. Wenn er anbietet, gleich zu installieren und zu starten, antworte mit No, denn zuerst kommt Babylon.js dazu:
$ npm create vite@latest first-person-controller -- --template vanilla-ts◇ Install with npm and start now?│ No│◇ Scaffolding project in /home/robin/first-person-controller...│└ Done. Now run: cd first-person-controller npm install npm run dev
Dann installierst du die Pakete der Vorlage und Babylon.js und Havok dazu:
$ cd first-person-controller$ npm installadded 17 packages, and audited 18 packages in 6sfound 0 vulnerabilities$ npm install @babylonjs/core @babylonjs/havokadded 3 packages, and audited 21 packages in 2sfound 0 vulnerabilities$ npm lsfirst-person-controller@0.0.0 /home/robin/first-person-controller├── @babylonjs/core@9.33.2├── @babylonjs/havok@1.3.14├── typescript@7.0.2└── vite@8.3.5
Die Vorlage bringt eine kleine Zähler-Demo mit. Entferne sie und den Ordner public mit seinen Icons, denn die Seite bekommt neue Dateien:
$ rm -r src/counter.ts src/assets publicDie Seite
Die Seite ist ein Canvas, in das Babylon.js zeichnet. Ersetze index.html durch das hier:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>First-person controller</title>
</head>
<body>
<canvas id="game"></canvas>
<script type="module" src="/src/main.ts"></script>
</body>
</html>Das Canvas füllt das Fenster, und nichts scrollt:
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
background: #130e2c;
color: #f5f3ff;
font-family: system-ui, sans-serif;
}
#game {
display: block;
width: 100%;
height: 100%;
outline: none;
}Babylon.js, aber nur die Teile, die du nutzt
Babylon.js ist groß: @babylonjs/core hat mehr als 2.000 Quelldateien, von Partikeln bis WebXR. Ein Spiel nutzt einen Bruchteil davon, und der Bundler soll den Rest weglassen. Das heißt Tree Shaking, und es funktioniert nur, wenn das Importieren einer Datei von sich aus nichts tut.
Babylon.js 9 hat dafür einen eigenen Importpfad, @babylonjs/core/pure. Er exportiert jede Klasse und jede Funktion, und ihn zu importieren ändert nichts. Die Teile von Babylon.js, die anderen Klassen etwas hinzufügen, etwa scene.enablePhysics(), schaltest du stattdessen mit Register…()-Aufrufen ein, und nur die, die du aufrufst, landen im Bundle. Das fertige Spiel dieser Serie kommt auf etwa 1,2 MB JavaScript, 280 kB komprimiert, dazu die WebAssembly-Datei von Havok.
Physik
Die Physik bekommt eine eigene Datei, die im Lauf der Serie wächst. Fürs Erste lädt sie Havok und schaltet die Physik ein:
import {
HavokPlugin,
RegisterJoinedPhysicsEngineComponent,
Vector3,
type Scene,
} from '@babylonjs/core/pure';
import '@babylonjs/core/Physics/joinedPhysicsEngineComponent.types';
import HavokPhysics from '@babylonjs/havok';
RegisterJoinedPhysicsEngineComponent();
/** Loads Havok's WebAssembly module and turns physics on for the scene. */
export async function enablePhysics(scene: Scene): Promise<void> {
const havok = await HavokPhysics();
scene.enablePhysics(new Vector3(0, -9.81, 0), new HavokPlugin(true, havok));
}HavokPhysics() lädt und startet das WebAssembly-Modul, etwa 2 MB oder 670 kB komprimiert, deshalb wartet await darauf, bevor irgendetwas die Physik nutzt. RegisterJoinedPhysicsEngineComponent() gibt der Szene enablePhysics(), und der .types-Import erzählt TypeScript davon.
Die Schwerkraft der Welt zieht nur an dynamischen Körpern, die fallen und umkippen. Diese Serie hat keine: Das Level bewegt sich nicht, und die Spielfigur bekommt in Teil 3 eine eigene Schwerkraft.
Der Testparcours
Bevor ein Spiel seine Grafik bekommt, wird seine Bewegung auf einem Parcours aus schlichten Kisten abgestimmt: einem Greybox-Level. Dieses hier hat, was die ersten Teile brauchen, und spätere Teile ergänzen Stationen: einen Tunnel zum Ducken, eine Leiter, Kanten und eine Kletterwand.
import {
Color3,
CreateBox,
PhysicsAggregate,
PhysicsShapeType,
StandardMaterial,
Texture,
Vector3,
Vector4,
type Mesh,
type Scene,
} from '@babylonjs/core/pure';
export interface Level {
/** Where the player's feet start. */
spawn: Vector3;
}
const COLORS = {
floor: '#3a2a7a',
steps: '#3ae0f0',
jumps: '#a0f05a',
};
type ColorName = keyof typeof COLORS;
interface BlockOptions {
/** Width (x), height (y) and depth (z) in meters. */
size: [number, number, number];
/** Where the center of the block goes. */
at: [number, number, number];
color: ColorName;
/** Rotation around the x axis, in degrees. */
tilt?: number;
}
export function buildLevel(scene: Scene): Level {
const level: Level = { spawn: new Vector3(0, 0.1, 0) };
const build = new Builder(scene);
build.block('floor', { size: [80, 1, 80], at: [0, -0.5, 0], color: 'floor' });
// Ahead: two staircases up to one platform, with low steps on the left and tall ones on
// the right.
for (let i = 0; i < 10; i += 1) {
const height = 0.2 * (i + 1);
build.block(`step-${i}`, { size: [3, height, 0.3], at: [0, height / 2, 6.15 + 0.3 * i], color: 'steps' });
}
for (let i = 0; i < 4; i += 1) {
const height = 0.5 * (i + 1);
build.block(`tall-step-${i}`, { size: [2, height, 0.75], at: [2.5, height / 2, 6.375 + 0.75 * i], color: 'steps' });
}
build.block('stairs-top', { size: [5, 2, 3.3], at: [1, 1, 10.35], color: 'steps' });
// Left: ramps of 20, 40 and 70 degrees. The steepest is too steep to walk up.
[20, 40, 70].forEach((degrees, i) => {
const angle = (degrees * Math.PI) / 180;
build.ramp(`ramp-${degrees}`, { x: -6 - 3 * i, width: 2.4, z: 4, rise: 4.5 * Math.sin(angle), run: 4.5 * Math.cos(angle) });
});
// Right: a curb low enough to walk over, boxes to jump on, then gaps. The last gap needs
// a running jump.
build.block('curb', { size: [4, 0.15, 0.4], at: [7, 0.075, 3], color: 'jumps' });
build.block('jump-low', { size: [2, 0.6, 2], at: [7, 0.3, 6], color: 'jumps' });
build.block('jump-1', { size: [2, 1, 2], at: [7, 0.5, 9.5], color: 'jumps' });
build.block('jump-2', { size: [2, 1, 2], at: [11, 0.5, 9.5], color: 'jumps' });
build.block('jump-3', { size: [4, 1, 2], at: [19, 0.5, 9.5], color: 'jumps' });
return level;
}
/** Makes the blocks of the level: meshes with a grid, and static physics bodies. */
class Builder {
private readonly scene: Scene;
private readonly grid: Texture;
private readonly materials = new Map<ColorName, StandardMaterial>();
constructor(scene: Scene) {
this.scene = scene;
this.grid = gridTexture(scene);
}
block(name: string, options: BlockOptions): Mesh {
const [width, height, depth] = options.size;
// One grid square per meter on every face, whatever the size of the block.
const faceUV = [
new Vector4(0, 0, width, height),
new Vector4(0, 0, width, height),
new Vector4(0, 0, depth, height),
new Vector4(0, 0, depth, height),
new Vector4(0, 0, width, depth),
new Vector4(0, 0, width, depth),
];
const mesh = CreateBox(name, { width, height, depth, faceUV }, this.scene);
mesh.position.set(...options.at);
mesh.rotation.x = -((options.tilt ?? 0) * Math.PI) / 180;
mesh.material = this.material(options.color);
// Mass 0 makes a static body: it never moves, and costs almost nothing.
new PhysicsAggregate(mesh, PhysicsShapeType.BOX, { mass: 0 }, this.scene);
mesh.freezeWorldMatrix();
return mesh;
}
/**
* A slab that rises along +z from the floor at `z`, by `rise` over `run` meters. Its top
* surface starts at floor level, so there is no edge to trip over.
*/
ramp(name: string, options: { x: number; width: number; z: number; rise: number; run: number }): void {
const { x, width, z, rise, run } = options;
const angle = Math.atan2(rise, run);
const thickness = 0.2;
// The middle of the top surface, moved down to the middle of the slab.
const y = rise / 2 - (Math.cos(angle) * thickness) / 2;
const middle = z + run / 2 + (Math.sin(angle) * thickness) / 2;
this.block(name, {
size: [width, thickness, Math.hypot(rise, run)],
at: [x, y, middle],
tilt: (angle * 180) / Math.PI,
color: 'steps',
});
}
private material(color: ColorName): StandardMaterial {
let material = this.materials.get(color);
if (!material) {
material = new StandardMaterial(color, this.scene);
material.diffuseTexture = this.grid;
material.diffuseColor = Color3.FromHexString(COLORS[color]);
material.specularColor = Color3.Black();
this.materials.set(color, material);
}
return material;
}
}
/** A white square with a dark edge, drawn once. The material's color tints it. */
function gridTexture(scene: Scene): Texture {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 128;
const context = canvas.getContext('2d')!;
context.fillStyle = '#ffffff';
context.fillRect(0, 0, 128, 128);
context.strokeStyle = 'rgba(0, 0, 0, 0.35)';
context.lineWidth = 6;
context.strokeRect(0, 0, 128, 128);
const texture = new Texture(canvas.toDataURL(), scene);
texture.anisotropicFilteringLevel = 8;
return texture;
}Ein paar Dinge darin lohnen einen genaueren Blick:
block()erzeugt ein Kisten-Mesh und einen gleich großen statischen Physikkörper: einPhysicsAggregatemit Masse 0. Statische Körper bewegen sich nie und kosten fast nichts, und mit ihnen kollidiert der Character-Controller.Das Raster ist eine einzige Textur, die beim Start auf ein Canvas gezeichnet wird, deshalb braucht das Projekt keine Bilddateien.
faceUVwiederholt sie einmal pro Meter auf jeder Fläche, so zeigt eine 4 m breite Wand vier Kästchen, und das Raster dient als Lineal.ramp()kippt eine Platte so, dass ihre Oberseite genau auf dem Boden beginnt. Eine gedrehte Kiste verschiebt ihre Ecken, deshalb rückt der Mittelpunkt um die halbe Plattendicke zurück.freezeWorldMatrix()sagt Babylon.js, dass sich das Mesh nie bewegt, und es rechnet seine Lage nicht mehr in jedem Frame neu aus.
Der Parcours hat eine Treppe mit 20 cm hohen Stufen, daneben höhere 50-cm-Stufen, eine 2 m hohe Plattform, Rampen mit 20, 40 und 70 Grad, einen 15 cm hohen Bordstein und Kisten mit Lücken von 2 m und 5 m dazwischen.
Die Szene
Ersetze src/main.ts. Sie erzeugt die Engine und die Szene, zwei Lichter, die Physik und das Level und fürs Erste die Kamera von Babylon.js, um sich umzusehen:
import {
Color3,
Color4,
DirectionalLight,
Engine,
FreeCamera,
HemisphericLight,
RegisterStandardEngineExtensions,
RegisterStandardMaterial,
Scene,
Vector3,
} from '@babylonjs/core/pure';
import { buildLevel } from './level.ts';
import { enablePhysics } from './physics.ts';
import './style.css';
RegisterStandardEngineExtensions();
RegisterStandardMaterial();
const canvas = document.querySelector<HTMLCanvasElement>('#game')!;
const engine = new Engine(canvas, true, {}, true);
const scene = new Scene(engine);
scene.clearColor = Color4.FromHexString('#130e2cff');
// Light from the sky, tilted so that walls facing different ways get different shades,
// and a sun for contrast.
const sky = new HemisphericLight('sky', new Vector3(0.5, 1, 0), scene);
sky.intensity = 0.9;
sky.groundColor = Color3.FromHexString('#6a5cb0');
const sun = new DirectionalLight('sun', new Vector3(-0.5, -1, 0.7), scene);
sun.intensity = 0.4;
await enablePhysics(scene);
buildLevel(scene);
// For now Babylon's own camera, to look around: drag with the mouse, move with the arrow keys.
const camera = new FreeCamera('camera', new Vector3(0, 1.75, 0), scene);
camera.attachControl();
camera.minZ = 0.05;
camera.speed = 0.2;
engine.runRenderLoop(() => scene.render());
window.addEventListener('resize', () => engine.resize());Das letzte Argument von
new Engine()rendert in der echten Auflösung des Bildschirms, das hält das Raster auf hochauflösenden Displays scharf.Das hemisphärische Licht kommt vom Himmel. Seine Richtung ist etwas geneigt, damit Wände, die in verschiedene Richtungen zeigen, verschieden hell sind, und ein gerichtetes Licht sorgt für Kontrast wie eine tief stehende Sonne.
awaitganz oben in einem Modul funktioniert, weil die Einstellungen der Vorlage für TypeScript und Vite aktuelle Browser voraussetzen.Die
FreeCameramitattachControl()ist ein Platzhalter: Ziehe mit der Maus, um dich umzusehen, und bewege dich mit den Pfeiltasten. Der nächste Teil ersetzt sie durch eine eigene Kamera.
Starten
$ npm run dev> first-person-controller@0.0.0 dev> vite VITE v8.3.5 ready in 108 ms ➜ Local: http://localhost:5173/ ➜ Network: use --host to expose
Öffne localhost:5173 und fliege über den Parcours:
Im nächsten Teil übernimmt die Maus die Kamera, so wie in jedem First-Person-Spiel.
