Zum Inhalt springen
DasPeTe Tutorials

Glattes Voxel-Terrain in Babylon.js · Teil 10 von 12 der Serie

Teil 10: Auf dem Terrain gehen

Geh auf dem Terrain ohne Physik-Engine: Die Spielfigur sind zwei Kugeln, die das Dichtefeld selbst fragen, wie weit der Boden ist, auf Hängen stehen, ohne zu rutschen, an Wänden entlanggleiten, springen und in die Löcher fallen, die du gräbst.

Quest-Ziele

  • Das Dichtefeld zu jedem Punkt befragen, mit oder ohne geladenen Chunk
  • Den Abstand zur Oberfläche aus der Dichte und ihrem Gradienten schätzen
  • Einen Körper aus dem Boden drücken: auf Boden senkrecht nach oben, an Wänden entlang der Normale
  • Gehen, springen und fallen, mit einem Tempo, das sich weich ändert
  • Zwischen Gehen und Fliegen umschalten

Seit Teil 9 kannst du graben, aber nur im Flug. Dieser Teil stellt dich auf die Füße. Die Serie zum First-Person-Controller nutzte den Character-Controller von Havok, der mit den Meshes eines Levels kollidiert. Hier ändert sich der Boden mit jedem Edit, und seine Meshes werden in Workern neu gebaut, einen Moment später. Es geht direkter: Frag die Dichte selbst.

Das Feld

Die Dichte an einem beliebigen Punkt kommt aus den acht Werten um ihn herum, gemischt danach, wie nah der Punkt jedem ist: die trilineare Mischung, die die Surface Nets für ihre Normalen nutzten. Ihr Gradient kommt aus denselben Werten. Gelesen werden die Werte mit edits.sample(), geändert oder erzeugt, also spürt die Spielfigur genau den Boden, den die Meshes zeigen, Löcher eingeschlossen. Und nichts muss geladen sein: Die Funktion antwortet überall, auch wo es noch keinen Chunk gibt. Lege src/field.ts an:

src/field.ts
import type { EditStore } from './edits.ts';

/** The density at a point and its gradient. */
export interface Probe {
  density: number;
  gradient: [number, number, number];
}

/**
 * The terrain's density anywhere, as the meshes see it: blended from the eight samples
 * around the point, edited or generated. It needs no chunk to be loaded.
 */
export class Field {
  private readonly edits: EditStore;

  constructor(edits: EditStore) {
    this.edits = edits;
  }

  probe(x: number, y: number, z: number): Probe {
    const x0 = Math.floor(x);
    const y0 = Math.floor(y);
    const z0 = Math.floor(z);
    const fx = x - x0;
    const fy = y - y0;
    const fz = z - z0;
    const d = (i: number, j: number, k: number) => this.edits.sample(x0 + i, y0 + j, z0 + k);
    const [d000, d100, d010, d110] = [d(0, 0, 0), d(1, 0, 0), d(0, 1, 0), d(1, 1, 0)];
    const [d001, d101, d011, d111] = [d(0, 0, 1), d(1, 0, 1), d(0, 1, 1), d(1, 1, 1)];
    const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
    // The same trilinear blend and gradient as the surface nets' normals.
    const density = lerp(
      lerp(lerp(d000, d100, fx), lerp(d010, d110, fx), fy),
      lerp(lerp(d001, d101, fx), lerp(d011, d111, fx), fy),
      fz,
    );
    const gradient: [number, number, number] = [
      lerp(lerp(d100 - d000, d110 - d010, fy), lerp(d101 - d001, d111 - d011, fy), fz),
      lerp(lerp(d010 - d000, d110 - d100, fx), lerp(d011 - d001, d111 - d101, fx), fz),
      lerp(lerp(d001 - d000, d101 - d100, fx), lerp(d011 - d010, d111 - d110, fx), fy),
    ];
    return { density, gradient };
  }
}

Eine Abfrage liest acht Werte, jeder ein schnelles Nachschlagen oder ein Aufruf der Terrainfunktion. Das kostet Mikrosekunden, und die Spielfigur braucht ein paar Dutzend Abfragen pro Frame.

Wie weit ist der Boden?

Die Dichte sagt, ob ein Punkt im Boden liegt. Die Spielfigur braucht mehr: wie weit er von der Oberfläche entfernt ist. Nahe der Oberfläche ändert sich die Dichte etwa um die Länge ihres Gradienten pro Meter, also ist die Dichte geteilt durch die Länge des Gradienten ungefähr der Abstand zur Oberfläche. Für ein echtes Abstandsfeld wäre das genau; für dieses Terrain ist es nah genug, weil die Spielfigur nur einen Schritt von der Oberfläche entfernt fragt.

Zwei Kugeln

Der Körper sind zwei Kugeln mit 40 cm Radius übereinander: eine an den Füßen, eine am Kopf, zusammen 1,8 m groß. In jedem Frame bewegt sich die Spielfigur und prüft dann jede Kugel: Ist ihre Mitte der Oberfläche näher als ihr Radius, steckt sie im Boden und muss heraus. Wohin, hängt von der Oberfläche ab:

  • Boden, eine Oberfläche, die nicht steiler ist als 50°: Die Spielfigur geht senkrecht nach oben, um die Tiefe geteilt durch das y der Normale. Entlang der Normale zu drücken, schöbe auf einem Hang seitlich, in jedem Frame ein wenig, und die Spielfigur rutschte jeden Hügel hinab. Senkrecht nach oben steht sie still.

  • Eine Wand oder eine Decke: Die Spielfigur geht entlang der Normale hinaus und verliert den Teil der Geschwindigkeit, der in die Oberfläche zeigt. Das ist Entlanggleiten an der Wand statt Klebenbleiben. Ein Hang steiler als 50° ist auch eine Wand, und die Spielfigur rutscht ihn hinab.

Drei Durchgänge über beide Kugeln klären Ecken, wo das Herausdrücken aus einer Oberfläche in eine andere hineindrückt.

Die Spielfigur

Lege src/player.ts an:

src/player.ts
import { Vector3 } from '@babylonjs/core/pure';
import type { Field } from './field.ts';
import type { Input } from './input.ts';

/** The body: two balls of this radius, one at the feet and one at the head. */
const RADIUS = 0.4;
const HEIGHT = 1.8;
/** Where the eyes are, above the feet. */
export const EYES = 1.65;
const WALK_SPEED = 5;
const RUN_SPEED = 9;
const JUMP_SPEED = 7;
const GRAVITY = 20;
/** How fast the speed changes, in m/s², on the ground and in the air. */
const GROUND_ACCELERATION = 40;
const AIR_ACCELERATION = 8;
/** Ground whose normal points up more than this is ground to stand on: up to 50°. */
const WALKABLE = Math.cos((50 * Math.PI) / 180);
/** How far the feet follow the ground down a slope or a step. */
const STEP_DOWN = 0.4;
/** The longest move in one step, so that a fast fall can't pass through a thin floor. */
const LONGEST_STEP = RADIUS / 2;

/** A body that walks on the terrain's density, with no physics engine. */
export class Player {
  /** Where the feet are. */
  readonly position: Vector3;
  readonly velocity = new Vector3();
  grounded = false;
  private readonly field: Field;

  constructor(field: Field, feet: Vector3) {
    this.field = field;
    this.position = feet.clone();
  }

  /** Moves the player by one frame; `yaw` is where the camera looks, around the vertical. */
  update(dt: number, input: Input, yaw: number): void {
    // The speed the keys ask for, along the ground, as the camera faces.
    const forward = input.axis('KeyS', 'KeyW');
    const right = input.axis('KeyA', 'KeyD');
    const length = Math.hypot(forward, right) || 1;
    const speed = input.pressed('ShiftLeft') ? RUN_SPEED : WALK_SPEED;
    const wishX = ((Math.sin(yaw) * forward + Math.cos(yaw) * right) / length) * speed;
    const wishZ = ((Math.cos(yaw) * forward - Math.sin(yaw) * right) / length) * speed;
    const step = (this.grounded ? GROUND_ACCELERATION : AIR_ACCELERATION) * dt;
    this.velocity.x = approach(this.velocity.x, wishX, step);
    this.velocity.z = approach(this.velocity.z, wishZ, step);

    if (this.grounded && input.pressed('Space')) {
      this.velocity.y = JUMP_SPEED;
      this.grounded = false;
    }
    if (!this.grounded) this.velocity.y -= GRAVITY * dt;

    const wasGrounded = this.grounded;
    const steps = Math.ceil((this.velocity.length() * dt) / LONGEST_STEP) || 1;
    for (let i = 0; i < steps; i += 1) {
      this.position.addInPlace(this.velocity.scale(dt / steps));
      this.collide();
    }
    // Walking down a slope, the ground drops away below the feet: follow it.
    if (wasGrounded && !this.grounded && this.velocity.y <= 0) {
      this.position.y -= STEP_DOWN;
      this.collide();
      if (!this.grounded) this.position.y += STEP_DOWN;
    }
  }

  /** Pushes both balls out of the ground. */
  private collide(): void {
    this.grounded = false;
    for (let pass = 0; pass < 3; pass += 1) {
      for (const height of [RADIUS, HEIGHT - RADIUS]) {
        const { density, gradient } = this.field.probe(
          this.position.x,
          this.position.y + height,
          this.position.z,
        );
        const steepness = Math.hypot(...gradient);
        if (steepness === 0) continue;
        // How far the ball's center is from the surface: the density over how fast it
        // changes. Positive in the air.
        const distance = -density / steepness;
        if (distance >= RADIUS) continue;
        const depth = RADIUS - distance;
        // The surface's normal points out of the ground, against the gradient.
        const normal = new Vector3(-gradient[0], -gradient[1], -gradient[2]).scaleInPlace(1 / steepness);
        if (normal.y > WALKABLE) {
          // Ground: straight up, so that the player doesn't slide down a slope.
          this.position.y += depth / normal.y;
          this.velocity.y = Math.max(this.velocity.y, 0);
          this.grounded = true;
        } else {
          // A wall or a ceiling: back out along the normal, and stop moving into it.
          this.position.addInPlace(normal.scale(depth));
          const into = Vector3.Dot(this.velocity, normal);
          if (into < 0) this.velocity.subtractInPlace(normal.scale(into));
        }
      }
    }
  }
}

/** Moves `value` towards `target` by at most `step`. */
function approach(value: number, target: number, step: number): number {
  return value < target ? Math.min(value + step, target) : Math.max(value - step, target);
}
  • update() macht aus den Tasten die gewünschte Geschwindigkeit, entlang des Bodens, wie die Kamera schaut. approach() ändert die Geschwindigkeit pro Frame höchstens um die Beschleunigung darauf zu: schnell am Boden, langsam in der Luft, wo du nur ein wenig lenken kannst.

  • Die Schwerkraft zieht nur, solange die Spielfigur in der Luft ist. Am Boden drückte sie die Füße in jedem Frame in den Hang, nur damit die Kollision sie zurückdrückt.

  • Eine Bewegung wird in Schritte von höchstens 20 cm geteilt, einer halben Kugel. Ein Fall ist schnell, und eine große Bewegung pro Frame könnte eine Kugel durch einen dünnen Boden tragen, bevor die Kollision sie bemerkt.

  • Einen Hang hinab fällt der Boden unter den Füßen weg, und die Spielfigur hüpfte in kleinen Stürzen hinunter. War sie am Boden und ist es nicht mehr, versucht sie es 40 cm tiefer: Ist dort Boden, bleibt sie auf ihm.

Gemessen bei 60 Bildern pro Sekunde geht die Spielfigur in ihren ersten zwei Sekunden 9,7 m, fast die vollen 5 m/s; springt 1,17 m hoch; steht fünf Sekunden auf einem 36°-Hang, ohne sich einen Millimeter zu bewegen; und wenn unter ihren Füßen eine Grube gegraben wird, fällt sie 4 m und landet auf ihrem Grund.

Tasten

Die Spielfigur und das Fliegen brauchen beide die Tasten, also bekommen sie eine eigene Klasse. Lege src/input.ts an:

src/input.ts
/** Which keys are held down, by their place on the keyboard (KeyW is W on any layout). */
export class Input {
  private readonly down = new Set<string>();

  constructor() {
    window.addEventListener('keydown', (event) => this.down.add(event.code));
    window.addEventListener('keyup', (event) => this.down.delete(event.code));
    // Keys let go in another window never send their keyup.
    window.addEventListener('blur', () => this.down.clear());
  }

  pressed(code: string): boolean {
    return this.down.has(code);
  }

  /** -1, 0 or 1 from two opposite keys. */
  axis(negative: string, positive: string): number {
    return (this.pressed(positive) ? 1 : 0) - (this.pressed(negative) ? 1 : 0);
  }
}

event.code benennt die Taste nach ihrer Stelle auf der Tastatur: Auf einer französischen Tastatur ist die Taste dort, wo bei anderen W liegt, trotzdem KeyW.

Gehen und Fliegen

Die Kamera von Babylon.js behält nur ihren Mausblick; bewegt wird sie vom Code. Ersetze src/main.ts:

src/main.ts
import {
  Engine,
  RegisterEnginesExtensionsEngineRawTexture,
  RegisterRay,
  RegisterShaderMaterial,
  RegisterStandardEngineExtensions,
  RegisterStandardMaterial,
  Scene,
  UniversalCamera,
  Vector3,
} from '@babylonjs/core/pure';
import { Field } from './field.ts';
import { CHUNK_SIZE } from './grid.ts';
import { Input } from './input.ts';
import { createTerrainMaterial, SKY } from './material.ts';
import { MESHERS, otherMesher } from './meshers.ts';
import { EYES, Player } from './player.ts';
import { surfaceHeight } from './terrain.ts';
import { DigTool } from './tool.ts';
import { ChunkWorkers } from './workers.ts';
import { VIEW_RADIUS, World } from './world.ts';
import './style.css';

RegisterStandardEngineExtensions();
// For the texture array of the terrain.
RegisterEnginesExtensionsEngineRawTexture();
RegisterShaderMaterial();
// For the tool: the marker's material, and the ray that finds the ground.
RegisterStandardMaterial();
RegisterRay();

const canvas = document.querySelector<HTMLCanvasElement>('#game')!;
const hud = document.querySelector<HTMLElement>('#hud')!;

const engine = new Engine(canvas, true, {}, true);
const scene = new Scene(engine);
scene.clearColor = SKY.toColor4();

// Fog hides the edge of the loaded chunks: it ends a little before the view radius.
const viewDistance = VIEW_RADIUS * CHUNK_SIZE;
const material = createTerrainMaterial(scene, viewDistance * 0.5, viewDistance * 0.95);

const world = new World(scene, material, new ChunkWorkers());

// The camera only looks around: the player and the flying below move it.
const feet = new Vector3(0, surfaceHeight(0, 40) + 1, 40);
const camera = new UniversalCamera('camera', feet.add(new Vector3(0, EYES, 0)), scene);
camera.setTarget(new Vector3(0, surfaceHeight(0, -200) + 20, -200));
camera.inputs.removeByType('FreeCameraKeyboardMoveInput');
camera.minZ = 0.1;
camera.maxZ = viewDistance * 1.2;
camera.attachControl();

const input = new Input();
const player = new Player(new Field(world.edits), feet);
let mode: 'walk' | 'fly' = 'walk';

/** Flies the camera: WASD along the view, Q and E down and up, Shift faster. */
function fly(dt: number): void {
  const speed = input.pressed('ShiftLeft') ? 60 : 15;
  const forward = camera.getDirection(Vector3.Forward()).scale(input.axis('KeyS', 'KeyW'));
  const right = camera.getDirection(Vector3.Right()).scale(input.axis('KeyA', 'KeyD'));
  camera.position.addInPlace(forward.add(right).scale(speed * dt));
  camera.position.y += input.axis('KeyQ', 'KeyE') * speed * dt;
}

const tool = new DigTool(scene, camera, world);
// The mouse wheel sizes the tool instead of moving the camera.
camera.inputs.removeByType('FreeCameraMouseWheelInput');

// A click captures the mouse, so that it turns the camera without a button held down.
// While it is captured, the left button digs and the right one builds.
canvas.addEventListener('pointerdown', (event) => {
  if (document.pointerLockElement !== canvas) {
    canvas.requestPointerLock();
    return;
  }
  if (event.button === 0) tool.start('dig');
  if (event.button === 2) tool.start('build');
});
window.addEventListener('pointerup', () => tool.stop());
canvas.addEventListener('contextmenu', (event) => event.preventDefault());
canvas.addEventListener('wheel', (event) => tool.resize(-Math.sign(event.deltaY)), { passive: true });

window.addEventListener('keydown', (event) => {
  if (event.code === 'KeyL') material.wireframe = !material.wireframe;
  if (event.code === 'KeyM') world.setMesher(otherMesher(world.mesher));
  if (event.code === 'KeyF') {
    mode = mode === 'walk' ? 'fly' : 'walk';
    // Landing where the camera is: the feet go below the eyes.
    if (mode === 'walk') {
      player.position.copyFrom(camera.position).y -= EYES;
      player.velocity.setAll(0);
    }
  }
});

scene.onBeforeRenderObservable.add(() => {
  // After a stall, such as a tab in the background, one huge step would be no good.
  const dt = Math.min(engine.getDeltaTime() / 1000, 0.05);
  if (mode === 'walk') {
    player.update(dt, input, camera.rotation.y);
    camera.position.copyFrom(player.position).y += EYES;
  } else {
    fly(dt);
  }
  world.update(camera.position);
  tool.update(performance.now());
  const { loaded, waiting } = world.counts;
  const { x, y, z } = camera.position;
  hud.textContent =
    `${MESHERS[world.mesher].label}: ${loaded} chunks, ${waiting} waiting\n` +
    `${mode === 'walk' ? (player.grounded ? 'Walking' : 'In the air') : 'Flying'}\n` +
    `x ${x.toFixed(0)}, y ${y.toFixed(0)}, z ${z.toFixed(0)}\n` +
    `Tool ${tool.radius.toFixed(1)} m\n` +
    `${engine.getFps().toFixed(0)} fps`;
});

engine.runRenderLoop(() => scene.render());
window.addEventListener('resize', () => engine.resize());
  • removeByType('FreeCameraKeyboardMoveInput') nimmt der Kamera ihre eigenen Tasten. fly() macht, was sie taten, mit den Tasten aus Input: entlang des Blicks mit W und S, seitwärts mit A und D, runter und hoch mit Q und E.

  • Beim Gehen sitzt die Kamera in den Augen der Spielfigur, 1,65 m über den Füßen, und die Spielfigur geht dorthin, wohin die Kamera schaut: camera.rotation.y ist ihre Drehung um die Senkrechte.

  • F schaltet zwischen beidem um. Landen setzt die Füße der Spielfigur unter die Kamera, wo sie auch ist, und lässt sie fallen.

  • Die Framezeit ist auf 50 ms begrenzt. Nach einer Pause, etwa in einem Hintergrund-Tab, trüge ein riesiger Schritt die Spielfigur weit durch die Welt.

In index.html nennt die Hilfe die neuen Tasten:

index.html
<div id="help">
  Click to look around · WASD walk, Shift run, Space jump · F fly (Q/E down/up) · left
  dig, right build, wheel size · M mesher
</div>

Ausprobieren

Du beginnst auf den Füßen und schaust nach Norden, auf einen Hang mit Höhleneingängen. Klick, um die Maus einzufangen, und geh mit W, A, S und D, renn mit Shift, spring mit Leertaste. Geh den Hang hinauf und bleib stehen: Du rutschst nicht. Geh in einen Höhleneingang. Grab ein Loch zu deinen Füßen und fall hinein, oder grab dir eine Treppe nach unten. Drück F, um zum Umschauen hochzufliegen, und wieder F, um zu landen, wo du bist.

Das Projekt am Ende von Teil 10: Klick, dann geh mit WASD, Shift rennt, Leertaste springt, F fliegt. Die linke Taste gräbt, die rechte baut.
In neuem Tab öffnen

Die Höhlen sind so hell wie die offenen Felder. Im nächsten Teil bleibt das Licht des Himmels draußen und verblasst, je weiter du in eine Höhle gehst.

Für Ducken, Leitern, Klettern und eine Kamera, die mit deinen Schritten schwingt, geht die Serie zum First-Person-Controller viel weiter.

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.