Ein First-Person-Controller in Babylon.js · Teil 2 von 11 der Serie
Teil 2: Mausblick mit Pointer Lock
Fange die Maus mit der Pointer Lock API ein, mach aus ihrer Bewegung die Winkel des Blicks und setze sie auf eine eigene Kamera, ohne Verzögerung und ohne dass sich das Bild überschlägt.

Quest-Ziele
- Die Maus mit der Pointer Lock API einfangen
- Nach roher Mausbewegung fragen und ohne sie auskommen, wo es keine gibt
- Mausbewegung in Gierwinkel und Neigung umrechnen
- Die Neigung anhalten, bevor sich das Bild überschlägt
- Die Winkel auf eine eigene Kamera von Babylon.js setzen
In Teil 1 bist du mit der Steuerung der FreeCamera von Babylon.js über den Parcours geflogen. Sie bewegt die Kamera selbst, was zu einem Betrachter passt, aber ein Spiel bewegt einen Körper, und die Kamera folgt ihm. Außerdem bleibt die Maus am Rand des Bildschirms stehen. Dieser Teil ersetzt die Steuerung durch einen eigenen Mausblick, so wie ihn ein First-Person-Spiel macht.
Einstellungen
Die Zahlen, die bestimmen, wie sich das Spiel anfühlt, kommen in eine Datei, damit du sie an einer Stelle abstimmen kannst. Fürs Erste sind das die der Kamera:
export const CAMERA = {
/** Radians per pixel of mouse movement. */
sensitivity: 0.002,
/** Looking straight up or down flips the view, so stop just short of it. */
maxPitch: (89 * Math.PI) / 180,
/** Vertical field of view: about 100 degrees across on a 16:9 screen. */
fov: (70 * Math.PI) / 180,
};Die Empfindlichkeit ist in Radiant pro Pixel Mausbewegung angegeben: Bei 0,002 drehen etwa 1.600 Pixel den Blick um einen halben Kreis.
Die Neigung hört bei 89 Grad auf. Bei genau 90 schaut der Blick senkrecht nach oben oder unten, und welche Richtung „vorne“ ist, ist nicht mehr festgelegt, also kann sich das Bild überschlagen.
Das Sichtfeld von Babylon.js ist vertikal. 70 Grad nach oben und unten sind auf einem 16:9-Bildschirm etwa 100 Grad in der Breite, ein üblicher Wert für First-Person-Spiele.
Die Maus einfangen
Die Pointer Lock API versteckt den Mauszeiger und hält ihn fest, und die Seite bekommt statt seiner Position, wie weit sich die Maus bewegt hat. Am Bildschirmrand hört das nie auf. Eine Seite darf nur als Antwort auf einen Klick oder Tastendruck danach fragen, und mit Esc bekommen die Spielenden die Maus zurück.
Die Eingabe bekommt eine eigene Klasse, die die Mausbewegung zwischen zwei Frames sammelt:
export class Input {
/** True while the mouse is captured. The mouse only counts then. */
locked = false;
onLockChange: (locked: boolean) => void = () => {};
private readonly canvas: HTMLCanvasElement;
private mouseX = 0;
private mouseY = 0;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
document.addEventListener('pointerlockchange', () => {
this.locked = document.pointerLockElement === canvas;
this.onLockChange(this.locked);
});
document.addEventListener('mousemove', (event) => {
if (!this.locked) return;
this.mouseX += event.movementX;
this.mouseY += event.movementY;
});
}
/** Captures the mouse. Browsers only allow it in answer to a click or a key press. */
async lock(): Promise<void> {
try {
// Raw mouse movement, without the acceleration of the operating system.
await this.canvas.requestPointerLock({ unadjustedMovement: true });
} catch {
// Not every platform has raw movement (Linux does not), so ask again without it.
await this.canvas.requestPointerLock().catch(() => {});
}
}
/** The mouse movement since the last call, in pixels. */
takeMouse(): { x: number; y: number } {
const movement = { x: this.mouseX, y: this.mouseY };
this.mouseX = 0;
this.mouseY = 0;
return movement;
}
}Jedes mousemove-Event addiert sein movementX und movementY, und takeMouse() gibt die Summe einmal pro Frame heraus und fängt wieder bei null an. Die Bewegung zählt nur, solange die Maus eingefangen ist, und onLockChange sagt der Seite, wenn sich das ändert.
unadjustedMovement: true fragt nach roher Bewegung, ohne die Beschleunigung, die das Betriebssystem auf den Mauszeiger legt. Mit Beschleunigung dreht dieselbe Strecke auf dem Tisch den Blick weiter, wenn die Maus schnell bewegt wird, und das macht das Zielen schwerer. Nicht jede Plattform hat rohe Bewegung: Chromium unter Linux lehnt die Anfrage mit einem NotSupportedError ab. Deshalb fragt lock() ein zweites Mal ohne sie.
Gierwinkel und Neigung
Der Blick besteht aus zwei Winkeln. Der Gierwinkel (yaw) dreht nach links und rechts um die senkrechte Achse, die Neigung (pitch) schaut nach oben und unten. Die Maus nach rechts dreht nach rechts, und die Maus zu dir hin schaut nach unten, wie in den meisten Spielen:
import type { Input } from './input.ts';
import { CAMERA } from './settings.ts';
/** Where the player looks: yaw turns around the vertical axis, pitch looks up and down. */
export class Look {
yaw = 0;
pitch = 0;
update(input: Input): void {
const mouse = input.takeMouse();
this.yaw = (this.yaw + mouse.x * CAMERA.sensitivity) % (Math.PI * 2);
this.pitch = Math.min(Math.max(this.pitch + mouse.y * CAMERA.sensitivity, -CAMERA.maxPitch), CAMERA.maxPitch);
}
}Der Gierwinkel bleibt innerhalb einer Umdrehung, damit er in einer langen Sitzung nicht ohne Ende wächst, und die Neigung wird begrenzt. Babylon.js nutzt ein linkshändiges Koordinatensystem: x zeigt nach rechts, y nach oben und z nach vorne, und ein Gierwinkel von 0 schaut entlang +z.
Die Kamera
Die Kamera ist eine schlichte FreeCamera ohne attachControl(), damit nichts außer deinem Code sie bewegt:
import { FreeCamera, Vector3, type Scene } from '@babylonjs/core/pure';
import type { Look } from './look.ts';
import { CAMERA } from './settings.ts';
/** The player's eyes: the camera, turned where they look. */
export class PlayerCamera {
readonly camera: FreeCamera;
constructor(scene: Scene) {
this.camera = new FreeCamera('eyes', Vector3.Zero(), scene);
// A near plane close to the eye lets the player hug a wall without seeing through it.
this.camera.minZ = 0.05;
this.camera.fov = CAMERA.fov;
}
update(look: Look): void {
this.camera.rotation.set(look.pitch, look.yaw, 0);
}
}Die Drehung der Kamera wird in jedem Frame aus den Winkeln gesetzt: x ist die Neigung, y der Gierwinkel und z die Rollbewegung, die bis zum letzten Teil 0 bleibt. Babylon.js wendet sie in der Reihenfolge Gieren, Neigen, Rollen an, deshalb dreht sich der Blick immer um die senkrechte Achse der Welt, egal wie weit nach oben oder unten man schaut.
minZ ist die vordere Schnittebene: Was näher an der Kamera ist, wird abgeschnitten. Babylon.js nimmt standardmäßig 1 m, und damit würde man durch eine Wand schauen, lange bevor man sie berührt.
Die Seite
Die Seite bekommt ein Fadenkreuz und ein Overlay, das um den Klick bittet:
<!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>
<div id="crosshair"></div>
<div id="overlay">
<strong>Click to look around</strong>
<span>Move the mouse to look · Esc lets go</span>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>Ihre Styles kommen ans Ende von src/style.css:
#crosshair {
position: fixed;
left: 50%;
top: 50%;
width: 4px;
height: 4px;
margin: -2px 0 0 -2px;
border-radius: 50%;
background: #f5f3ff;
pointer-events: none;
}
#overlay {
position: fixed;
inset: 0;
display: grid;
place-content: center;
gap: 8px;
text-align: center;
background: rgb(19 14 44 / 0.75);
cursor: pointer;
}
#overlay.hidden {
display: none;
}
#overlay strong {
font-size: 28px;
}Zum Schluss tauscht src/main.ts die Flugkamera gegen die neuen Teile. Ein Klick auf das Overlay fängt die Maus ein, und solange sie eingefangen ist, ist das Overlay versteckt. Vor jedem Frame nimmt der Blick die Mausbewegung, und die Kamera dreht sich:
import {
Color3,
Color4,
DirectionalLight,
Engine,
HemisphericLight,
RegisterStandardEngineExtensions,
RegisterStandardMaterial,
Scene,
Vector3,
} from '@babylonjs/core/pure';
import { PlayerCamera } from './camera.ts';
import { Input } from './input.ts';
import { buildLevel } from './level.ts';
import { Look } from './look.ts';
import { enablePhysics } from './physics.ts';
import './style.css';
RegisterStandardEngineExtensions();
RegisterStandardMaterial();
const canvas = document.querySelector<HTMLCanvasElement>('#game')!;
const overlay = document.querySelector<HTMLElement>('#overlay')!;
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);
const input = new Input(canvas);
const look = new Look();
const view = new PlayerCamera(scene);
// At the start, at the eye height of a standing player. In the next part it follows one.
view.camera.position.set(0, 1.75, 0);
overlay.addEventListener('click', () => input.lock());
input.onLockChange = (locked) => overlay.classList.toggle('hidden', locked);
scene.onBeforeRenderObservable.add(() => {
look.update(input);
view.update(look);
});
engine.runRenderLoop(() => scene.render());
window.addEventListener('resize', () => engine.resize());Die Kamera steht dort, wo im nächsten Teil die Augen der Spielfigur sein werden: 1,65 m über den Füßen, die 10 cm über dem Boden beginnen.
Ausprobieren
Klicke in die Seite und bewege die Maus. Der Blick dreht sich ohne Mauszeiger, einmal ganz herum, und hält kurz vor senkrecht oben und senkrecht unten an. Esc gibt die Maus zurück und holt das Overlay wieder.
Im nächsten Teil bekommt die Kamera einen Körper: eine Kapsel, die auf dem Character-Controller von Babylon.js geht.