Ein First-Person-Controller in Babylon.js · Teil 11 von 11 der Serie
Teil 11: Kameragefühl
Gib der Kamera die kleinen Bewegungen, die aus einem schwebenden Auge einen Körper machen: ein Wippen, das den Schritten folgt, ein Einfedern bei der Landung, ein Neigen bei Seitwärtsbewegungen und ein weiterer Blick beim Rennen.

Quest-Ziele
- Den Kopf mit den Schritten wippen lassen, nicht mit der Uhr
- Den Blick bei der Landung mit einer Feder einfedern lassen
- Sich ein wenig in Seitwärtsbewegungen neigen
- Den Blick beim Rennen weiten
- Jeden Effekt unabhängig von der Bildrate halten, und weg vom Zielen
Der Controller aus den vorigen Teilen bewegt sich gut, aber seine Kamera klebt an der Kapsel: Sie gleitet dahin wie ein Auge auf Schienen. Echte Köpfe wippen bei jedem Schritt, sinken ein wenig ein, wenn die Füße auf den Boden treffen, und neigen sich in eine Kurve. Spiele übernehmen ein wenig davon, und Spielende lesen das als Gewicht und Tempo, ohne es zu bemerken.
Das alles lebt in der Kamera. Die Spielfigur meldet nur Landungen, und nichts davon berührt die Winkel, die die Maus setzt, also verderben die Effekte nie das Zielen.
Die Spielfigur meldet Landungen
Die Kamera muss wissen, wie hart eine Landung war. Ergänze in der Klasse Player neben eyeHeight einen Callback:
/** Called on landing, with the speed the player came down at. */
onLand: (speed: number) => void = () => {};Wenn air() die Landung bemerkt, hat der Controller den Fall schon gestoppt: Die Geschwindigkeit, die er übrig lässt, zeigt am Boden entlang. Deshalb merkt sich die Spielfigur die Fallgeschwindigkeit des letzten Frames in der Luft. Das Feld kommt neben rising:
/** How fast the player was falling in the last frame in the air. */
private fallSpeed = 0;Und ersetze air():
private air(dt: number, support: CharacterSurfaceInfo): void {
if (this.jumpTimer > 0 && this.coyoteTimer > 0) {
this.jump();
return;
}
if (this.velocity.y <= 0) this.rising = false;
if (support.supportedState === CharacterSupportedState.SUPPORTED && !this.rising) {
// The controller has already stopped the fall, so use the speed from the frame before.
this.onLand(this.fallSpeed);
this.mode = 'walk';
this.walk(dt, support);
return;
}
if (this.tryLadder() || this.tryGrab()) return;
// Without input the player keeps their momentum. With it, they steer, but slowly.
if (this.input.axis('back', 'forward') || this.input.axis('left', 'right')) {
const target = this.wish(this.groundSpeed());
target.y = this.velocity.y;
approach(this.velocity, target, PLAYER.airAcceleration * dt);
}
this.velocity.y -= PLAYER.gravity * dt;
this.fallSpeed = Math.max(-this.velocity.y, 0);
}Für das Neigen braucht die Kamera auch die Geschwindigkeit der Spielfigur. Ergänze neben horizontalSpeed() einen Getter:
getVelocity(): Vector3 {
return this.controller.getVelocity();
}Die Kamera
Ersetze src/camera.ts. Sie hängt sich im Konstruktor an die Landungen und bekommt in update() die Framezeit, weil sich alles darin mit der Zeit bewegt:
import { FreeCamera, Vector3, type Scene } from '@babylonjs/core/pure';
import type { Look } from './look.ts';
import type { Player } from './player.ts';
import { CAMERA, PLAYER } from './settings.ts';
/** The player's eyes: the camera, put where the player is and turned where they look. */
export class PlayerCamera {
readonly camera: FreeCamera;
private bobPhase = 0;
private bobWeight = 0;
private dip = 0;
private dipSpeed = 0;
private roll = 0;
private nudge = 0;
constructor(scene: Scene, player: Player) {
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;
player.onLand = (speed) => {
if (speed > 2) this.dipSpeed -= speed * 0.4;
};
}
update(dt: number, player: Player, look: Look): void {
const speed = player.horizontalSpeed();
const walking = player.mode === 'walk';
// Head bob: one dip per step. It follows the distance walked, not the time, so the
// steps match the speed.
this.bobPhase += (speed * dt * Math.PI) / 0.8;
const weight = walking ? Math.min(speed / PLAYER.runSpeed, 1) : 0;
this.bobWeight += (weight - this.bobWeight) * ease(10, dt);
const bobUp = ((Math.cos(this.bobPhase * 2) - 1) / 2) * 0.06 * this.bobWeight;
const bobSide = Math.sin(this.bobPhase) * 0.03 * this.bobWeight;
// Landing: a spring that is pushed down and settles back, critically damped.
const stiffness = 150;
this.dipSpeed += (-stiffness * this.dip - 2 * Math.sqrt(stiffness) * this.dipSpeed) * dt;
this.dip += this.dipSpeed * dt;
// Lean a little into sideways movement, and look down a bit while climbing over a ledge.
const sideways = Vector3.Dot(player.getVelocity(), look.right()) / PLAYER.runSpeed;
this.roll += (-sideways * 0.03 - this.roll) * ease(8, dt);
this.nudge += ((player.mode === 'mantle' ? 0.15 : 0) - this.nudge) * ease(8, dt);
player.eyePosition(this.camera.position);
this.camera.position.addInPlace(look.right().scaleInPlace(bobSide));
this.camera.position.y += bobUp + this.dip;
this.camera.rotation.set(look.pitch + this.nudge, look.yaw, this.roll);
// A wider view when running makes the speed felt.
const fov = CAMERA.fov + (player.running && speed > PLAYER.walkSpeed ? 0.14 : 0);
this.camera.fov += (fov - this.camera.fov) * ease(6, dt);
}
}
/** The share of the way to close this frame, for smoothing that does not depend on the frame rate. */
function ease(rate: number, dt: number): number {
return 1 - Math.exp(-rate * dt);
}In src/main.ts bekommt die Kamera die Spielfigur, new PlayerCamera(scene, player), und die Framezeit: view.update(dt, player, look).
ease() ist die Glättung der Augenhöhe aus Teil 6 als Funktion: der Anteil des Wegs, der in diesem Frame zurückgelegt wird, pro Sekunde bei jeder Bildrate gleich. Jeder Effekt gleitet damit zu seinem Ziel, so springt keiner an oder aus.
Wippen
Das Wippen folgt der zurückgelegten Strecke, nicht der Zeit: Die Phase wächst mit Tempo × dt, so macht, wer schneller geht, schnellere Schritte, und wer stehen bleibt, keine. Jeder Schritt ist 0,8 m lang. Der Blick sinkt einmal pro Schritt ab, im Renntempo bis zu 6 cm, und schwingt einmal pro zwei Schritte 3 cm zur Seite, linker Fuß und rechter Fuß.
Das bobWeight blendet das Wippen mit dem Tempo ein und auf null, wenn die Spielfigur nicht geht: In der Luft, an einer Leiter oder an einer Wand gibt es keine Schritte.
Landen
Eine Landung stößt eine Feder an: Die Landegeschwindigkeit bestimmt, wie schnell sich der Blick nach unten bewegt, und die Feder zieht ihn zurück. Sie ist kritisch gedämpft, die Dämpfung 2 × √Steifigkeit, der schnellste Weg zurück, ohne über die Ruhelage hinauszuschwingen. Ein Sprung landet mit etwa 7 m/s, ein Fall von der 2-m-Plattform mit etwa 9 m/s, und der Blick federt dann etwa 7 cm ein. Landungen langsamer als 2 m/s, etwa von einer Stufe, bewegen nichts.
Neigen und Blickfeld
Das Neigen rollt die Kamera ein wenig in eine Seitwärtsbewegung: bis 0,03 Radiant, unter 2 Grad, im Renntempo. In Babylon.js kippt ein positives Rollen den Blick nach links, deshalb bekommt eine Bewegung nach rechts ein negatives.
Beim Klettern über eine Kante neigt sich der Blick um 0,15 Radiant nach unten, zur Oberseite, auf die die Spielfigur klettert.
Beim Rennen weitet sich das Sichtfeld um 0,14 Radiant, 8 Grad. Die Ränder des Bilds bewegen sich dann schneller, und das Tempo wird sichtbar.
Ausprobieren
Geh, und der Blick wippt sanft. Renne, und er wippt stärker, und das Sichtfeld wird weiter. Geh seitwärts, und er neigt sich. Spring oben an der Treppe von der Plattform, und er federt bei der Landung ein. Vergleiche mit der Demo aus Teil 10: Die Bewegung ist dieselbe, aber diese fühlt sich nach einem Körper an.
Was du gebaut hast
Elf Teile später geht und rennt der Controller, nimmt Schrägen, Treppen und Bordsteine, springt mit Coyote Time und Sprungpuffer, duckt sich, wo die Decke niedrig ist, und klettert auf vier Arten: Leitern hoch, auf Kanten, an Kanten entlang, an denen er hängt, und überall an kletterbaren Wänden. Jede davon ist ein Modus mit eigenem Code, und ein Modus, der nicht mehr passt, gibt an den nächsten ab.
Wie es weitergeht
Bewegte Plattformen. Der Controller von Babylon.js trägt die Spielfigur auf Körpern mit dem Bewegungstyp
ANIMATED.checkSupport()meldet deren Geschwindigkeit alsaverageSurfaceVelocity: Addiere sie inwalk()zur Zielgeschwindigkeit.Dinge zum Schieben. Dynamische Körper schiebt der Controller an, und
characterStrengthlegt fest, wie fest.Ein Gamepad. Die Gamepad API liefert Sticks als Zahlen von -1 bis 1. Der linke Stick passt in
axis(), und der rechte dreht den Blick mit einer Winkelgeschwindigkeit maldt.Schritte. Die Phase des Wippens weiß, wann ein Fuß aufsetzt: Spiel jedes Mal einen Klang, wenn das Absinken seinen tiefsten Punkt erreicht.
Ein echtes Level. Bau es in einem 3D-Programm, lade es als glTF und gib ihm statische Körper mit einfachen Kollisionsformen, wie die Kisten des Testparcours.
Viel Spaß damit, und zeig in den Kommentaren, was du baust.