Zum Inhalt springen
DasPeTe Tutorials

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

  1. Babylon.js, Havok und Vite (dieser Teil)

  2. Mausblick mit Pointer Lock

  3. Gehen mit einem Character-Controller

  4. Schrägen, Treppen und Stufen

  5. Rennen und Springen

  6. Ducken

  7. Leitern und ein Zustandsautomat

  8. Auf Kanten klettern

  9. An Kanten hängen

  10. Wände hochklettern

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

~/first-person-controller
$ rm -r src/counter.ts src/assets public

Die Seite

Die Seite ist ein Canvas, in das Babylon.js zeichnet. Ersetze index.html durch das hier:

index.html
<!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:

src/style.css
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:

src/physics.ts
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.

src/level.ts
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: ein PhysicsAggregate mit 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. faceUV wiederholt 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:

src/main.ts
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.

  • await ganz oben in einem Modul funktioniert, weil die Einstellungen der Vorlage für TypeScript und Vite aktuelle Browser voraussetzen.

  • Die FreeCamera mit attachControl() 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

~/first-person-controller
$ 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:

Der Testparcours: Treppe und Plattform, drei Rampen, ein Bordstein und Kisten zum Draufspringen.
Das Projekt am Ende von Teil 1. Ziehe mit der Maus, um dich umzusehen, und bewege dich mit den Pfeiltasten.
In neuem Tab öffnen

Im nächsten Teil übernimmt die Maus die Kamera, so wie in jedem First-Person-Spiel.

Kommentare

Die Kommentare sind GitHub Discussions: Zum Schreiben mit GitHub anmelden.

Die Kommentare kommen über giscus von GitHub. Beim Laden gehen deine IP-Adresse an giscus.app und GitHub.