Zum Inhalt springen
DasPeTe Tutorials

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

Teil 1: Vite, Babylon.js und ein Dichtefeld

Richte ein Vite-Projekt mit TypeScript und Babylon.js 9 ein und beschreibe eine Form so, wie es glattes Voxel-Terrain tut: als Zahl an jedem Punkt eines Gitters, über 0 innen und unter 0 außen.

Quest-Ziele

  • Wissen, was glattes Voxel-Terrain ist und was du in dieser Serie baust
  • Ein Vite-Projekt mit TypeScript und Babylon.js 9 anlegen
  • Eine Form als Dichte beschreiben: eine Zahl an jedem Punkt, deren Vorzeichen fest und Luft unterscheidet
  • Formen mit min und max kombinieren
  • Die Dichte auf einem Gitter abtasten und so zeigen, wie es ein Blockspiel täte

In einem Blockspiel ist die Welt ein Gitter aus Würfeln, und jede Zelle ist voll oder leer. Glattes Voxel-Terrain behält das Gitter, speichert aber an jedem seiner Punkte eine Zahl: wie weit der Punkt im Boden liegt oder außerhalb davon. Wo die Zahlen von positiv zu negativ wechseln, liegt eine Oberfläche, und weil die Zahlen sagen, wo zwischen zwei Punkten sie liegt, kann die Oberfläche glatt sein. Hänge werden zu Hängen, nicht zu Treppen.

Eine Heightmap, die andere übliche Art, Terrain zu speichern, hat eine Höhe pro Stelle der Karte. Eine Höhle, einen Überhang oder einen Tunnel kann sie nicht fassen. Ein Gitter aus Zahlen fasst all das, und wer die Zahlen ändert, gräbt und baut: Der Boden lässt sich überall formen, in jede Richtung.

In dieser Serie baust du so ein Terrain im Browser: aus Rauschen erzeugt, endlos, in Chunks zerlegt, die im Hintergrund vernetzt werden, texturiert, mit Höhlen, die dunkler werden, je weiter du hineingehst, mit Boden, den du weggraben und aufbauen kannst, einer Spielfigur, die darauf geht, und einer Sicht bis zum Horizont.

Die Serie

  1. Vite, Babylon.js und ein Dichtefeld (dieser Teil)

  2. Marching Cubes mit einer erzeugten Tabelle

  3. Surface Nets

  4. Terrain aus Rauschen: Hügel, Überhänge und Höhlen

  5. Chunks und Nähte

  6. Meshing in Web Workern

  7. Eine endlose Welt streamen

  8. Triplanare Texturen

  9. Graben und Bauen

  10. Auf dem Terrain gehen

  11. Höhlenlicht

  12. Fernsicht bis zum Horizont

Die Teile 2 und 3 bauen zwei verschiedene Wege, aus den Zahlen ein Mesh zu machen, Marching Cubes und Surface Nets. Ab dann hat jede Demo beide, und die Taste M schaltet zwischen ihnen um, sodass du sie auf demselben Terrain vergleichen kannst.

Jeder Teil endet mit einer Demo, die du direkt auf der Seite ausprobieren kannst. Sie ist aus genau dem Code gebaut, den der Teil zeigt, und der lief mit Babylon.js 9.33, simplex-noise 4.0, Vite 8.3 und TypeScript 7, als die Serie entstand.

Was du brauchst

Node.js 20.19 oder neuer, das Vite 8 voraussetzt, ein Terminal und einen aktuellen Browser mit WebGL 2. In TypeScript solltest du dich zu Hause fühlen. Babylon.js wird so weit erklärt, wie die Serie es nutzt; wenn du einen sanfteren Einstieg willst, richtet der erste Teil der Serie zum First-Person-Controller ein ähnliches Projekt ein.

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:

~
$ npm create vite@latest voxel-terrain -- --template vanilla-ts◇  Install with npm and start now?│  No│◇  Scaffolding project in /home/robin/voxel-terrain...│└  Done. Now run:  cd voxel-terrain  npm install  npm run dev

Dann installierst du die Pakete der Vorlage und Babylon.js dazu. Die Version ist auf die festgelegt, mit der die Serie geschrieben wurde; ein neueres 9.x sollte auch gehen.

~
$ cd voxel-terrain$ npm installadded 17 packages, and audited 18 packages in 5s8 packages are looking for funding  run `npm fund` for detailsfound 0 vulnerabilities$ npm install @babylonjs/core@9.33.2added 1 package, and audited 19 packages in 2s8 packages are looking for funding  run `npm fund` for detailsfound 0 vulnerabilities$ npm lsvoxel-terrain@0.0.0 /home/robin/voxel-terrain├── @babylonjs/core@9.33.2├── typescript@7.0.2└── vite@8.3.8

Die Vorlage bringt eine kleine Zähler-Demo mit. Entferne sie und den Ordner public mit seinen Icons, denn die Seite bekommt neue Dateien:

~/voxel-terrain
$ rm -r src/counter.ts src/assets public

Die Seite

Die Seite ist eine Leinwand, auf die Babylon.js zeichnet, und eine Zeile Hilfe. 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>Voxel terrain</title>
  </head>
  <body>
    <canvas id="game"></canvas>
    <div id="help">Drag to turn, scroll to zoom</div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Die Leinwand füllt das Fenster, und die Hilfe sitzt in einer Ecke:

src/style.css
html,
body {
  margin: 0;
  height: 100%;
  overflow: hidden;
  background: #1d1233;
  color: #fff7ed;
  font-family: system-ui, sans-serif;
}

#game {
  display: block;
  width: 100%;
  height: 100%;
  outline: none;
}

#help {
  position: fixed;
  left: 12px;
  bottom: 12px;
  padding: 6px 10px;
  border-radius: 6px;
  background: rgb(29 18 51 / 0.7);
  font-size: 13px;
  pointer-events: none;
}

Ein Dichtefeld

Eine Form ist bei glattem Voxel-Terrain eine Funktion, die einen Punkt nimmt und eine Zahl liefert, seine Dichte. Über 0 ist der Punkt fest, unter 0 ist er Luft, und die Oberfläche liegt dort, wo die Dichte genau 0 ist. Eine Kugel mit Radius 10 um den Ursprung hat die Dichte 10 - Abstand: 10 in ihrer Mitte, 0 an ihrer Oberfläche, -5 fünf Meter außerhalb.

Ist die Dichte der Abstand zur Oberfläche, wie hier, ist die Funktion ein Signed Distance Field, ein vorzeichenbehaftetes Abstandsfeld. Exakt muss es nicht sein: Es zählt das Vorzeichen, und dass sich die Zahlen nahe der Oberfläche gleichmäßig ändern. Ein Wert von 0,25 neben einem von -0,75 sagt, dass die Oberfläche ein Viertel des Wegs vom ersten zum zweiten liegt. Der nächste Teil baut aus genau dem die glatte Oberfläche.

Abgetastet wird das Terrain an den Ecken eines Gitters aus Zellen, einen Meter voneinander entfernt. Ein Chunk des Terrains ist 32 Zellen lang, breit und hoch, also 33 Werte je Seite: Jede Seite hat eine Ecke mehr als Zellen. Lege src/grid.ts an:

src/grid.ts
/** Cells along each side of a chunk. A side has one sample more than it has cells. */
export const CHUNK_SIZE = 32;

/** The density of the world at a point: above 0 is solid, below 0 is air. */
export type Density = (x: number, y: number, z: number) => number;

/** The density sampled at the corners of a cube of CHUNK_SIZE³ cells, one meter apart. */
export class DensityGrid {
  /** Samples along each side. */
  readonly side = CHUNK_SIZE + 1;
  readonly values = new Float32Array(this.side ** 3);

  index(x: number, y: number, z: number): number {
    return x + this.side * (y + this.side * z);
  }

  get(x: number, y: number, z: number): number {
    return this.values[this.index(x, y, z)];
  }

  /** Samples `density` at every corner. The corner (0, 0, 0) is at `origin` in the world. */
  fill(density: Density, origin = { x: 0, y: 0, z: 0 }): this {
    for (let z = 0; z < this.side; z += 1) {
      for (let x = 0; x < this.side; x += 1) {
        for (let y = 0; y < this.side; y += 1) {
          this.values[this.index(x, y, z)] = density(origin.x + x, origin.y + y, origin.z + z);
        }
      }
    }
    return this;
  }
}
  • Die Werte liegen in einem flachen Float32Array, zuerst x, dann y, dann z. index() macht aus drei Koordinaten die Stelle darin.

  • fill() geht jede Säule nach oben durch, bevor es zur nächsten geht, von y zu y bei gleichem x und z. Noch macht das keinen Unterschied, aber das Terrain in Teil 4 rechnet die Höhe einer Säule einmal aus und nutzt sie für jeden Wert darüber.

  • origin ist die Stelle der ersten Gitterecke in der Welt. Spätere Chunks tasten damit die Welt an ihrem eigenen Platz ab.

Formen aus Formen

Zwei Dichten lassen sich mit nichts als min und max verbinden. Ein Punkt ist in einer von zwei Formen fest, wenn die größere ihrer Dichten über 0 liegt, also ist max die Vereinigung. In beiden ist er fest, wenn es die kleinere ist, also ist min der Schnitt. Und in der ersten Form, aber nicht in der zweiten, liegt er, wenn er in der ersten liegt und im Gegenteil der zweiten, dessen Dichte die negierte ist. Das ist die Differenz, und genau das wird Graben in Teil 9 sein.

Lege src/shapes.ts mit einer Testform an: eine Kugel, die auf einer Platte liegt, und ein Tunnel durch die Kugel.

src/shapes.ts
import type { Density } from './grid.ts';

/** Solid where either shape is solid. */
const union = (a: number, b: number) => Math.max(a, b);
/** Solid where both shapes are solid. */
const intersect = (a: number, b: number) => Math.min(a, b);
/** Solid where `a` is solid and `b` is not. */
const subtract = (a: number, b: number) => Math.min(a, -b);

/** A ball resting on a slab, with a tunnel through both. */
export const testShape: Density = (x, y, z) => {
  // A slab from 1.5 m to 6.5 m up and 27 m wide: inside all six of its faces at once.
  const slab = intersect(
    intersect(6.5 - y, y - 1.5),
    intersect(intersect(x - 2.5, 29.5 - x), intersect(z - 2.5, 29.5 - z)),
  );
  // Solid within 9.5 m of the center: 9.5 minus the distance to it.
  const ball = 9.5 - Math.hypot(x - 16, y - 15, z - 16);
  // A cylinder along x, 3.5 m thick, 13 m up.
  const tunnel = 3.5 - Math.hypot(y - 13, z - 16);
  return subtract(union(slab, ball), tunnel);
};

Die Platte ist der Schnitt von sechs Halbräumen, einem für jede Seite: unter 6,5 m, über 1,5 m und zwischen 2,5 m und 29,5 m auf x und auf z. Ihre Kanten liegen mit Absicht auf halben Metern. Ein Wert von genau 0 liegt direkt auf der Oberfläche, und die Meshes der nächsten Teile bekämen dort Dreiecke ohne Fläche. Kanten zwischen den Werten vermeiden das.

Die Blockspiel-Ansicht

Vor der glatten Oberfläche das Gitter so, wie es ein Blockspiel zeigt: ein Würfel an jedem festen Wert. Sichtbar sind nur Würfel neben Luft, die anderen bleiben weg. Lege src/blocks.ts an:

src/blocks.ts
import { Color3, CreateBox, Matrix, StandardMaterial, type Mesh, type Scene } from '@babylonjs/core/pure';
import { CHUNK_SIZE, type DensityGrid } from './grid.ts';

/** One cube for every solid sample next to air, the way a block game would show the grid. */
export function showBlocks(scene: Scene, grid: DensityGrid): Mesh {
  const matrices: number[] = [];
  const last = CHUNK_SIZE;
  for (let z = 0; z <= last; z += 1) {
    for (let y = 0; y <= last; y += 1) {
      for (let x = 0; x <= last; x += 1) {
        if (grid.get(x, y, z) <= 0) continue;
        // Solid samples with solid all around can't be seen.
        const hidden =
          x > 0 && x < last && y > 0 && y < last && z > 0 && z < last &&
          grid.get(x - 1, y, z) > 0 && grid.get(x + 1, y, z) > 0 &&
          grid.get(x, y - 1, z) > 0 && grid.get(x, y + 1, z) > 0 &&
          grid.get(x, y, z - 1) > 0 && grid.get(x, y, z + 1) > 0;
        if (!hidden) matrices.push(...Matrix.Translation(x, y, z).asArray());
      }
    }
  }

  const cube = CreateBox('blocks', { size: 0.96 }, scene);
  const material = new StandardMaterial('blocks', scene);
  material.diffuseColor = Color3.FromHexString('#d9a066');
  material.specularColor = Color3.Black();
  cube.material = material;
  cube.thinInstanceSetBuffer('matrix', new Float32Array(matrices), 16);
  return cube;
}

Tausende Würfel als Tausende Meshes wären langsam. Thin Instances zeichnen ein Mesh viele Male in einem Draw Call: den Würfel und dazu eine Liste von Matrizen, eine pro Kopie. Hier schiebt jede Matrix den Würfel nur an seinen Wert. Die Würfel sind etwas kleiner als ein Meter, damit die Fugen das Gitter zeigen.

Die Szene

Ersetze src/main.ts. Es legt die Engine und die Szene an, zwei Lichter, das Gitter mit seinen Blöcken und eine Kamera, die um das Gitter kreist:

src/main.ts
import {
  ArcRotateCamera,
  Color3,
  Color4,
  DirectionalLight,
  Engine,
  HemisphericLight,
  RegisterStandardEngineExtensions,
  RegisterStandardMaterial,
  RegisterThinInstanceMesh,
  Scene,
  Vector3,
} from '@babylonjs/core/pure';
import { showBlocks } from './blocks.ts';
import { CHUNK_SIZE, DensityGrid } from './grid.ts';
import { testShape } from './shapes.ts';
import './style.css';

RegisterStandardEngineExtensions();
RegisterStandardMaterial();
RegisterThinInstanceMesh();

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

const engine = new Engine(canvas, true, {}, true);
const scene = new Scene(engine);
scene.clearColor = Color4.FromHexString('#2a1150ff');

// A cool light from the sky and a warm, low sun.
const sky = new HemisphericLight('sky', new Vector3(0, 1, 0), scene);
sky.intensity = 0.6;
sky.diffuse = Color3.FromHexString('#c4b5fd');
sky.groundColor = Color3.FromHexString('#3b1676');
const sun = new DirectionalLight('sun', new Vector3(0.5, -0.65, -0.55), scene);
sun.intensity = 1.1;
sun.diffuse = Color3.FromHexString('#ffd8a8');

const grid = new DensityGrid().fill(testShape);
showBlocks(scene, grid);

// Orbit around the middle of the grid.
const middle = new Vector3(CHUNK_SIZE / 2, CHUNK_SIZE / 3, CHUNK_SIZE / 2);
const camera = new ArcRotateCamera('camera', 2.2, 1.1, 60, middle, scene);
camera.attachControl();
camera.lowerRadiusLimit = 10;
camera.upperRadiusLimit = 120;
camera.wheelPrecision = 5;

engine.runRenderLoop(() => scene.render());
window.addEventListener('resize', () => engine.resize());
  • Babylon.js 9 kommt aus @babylonjs/core/pure, einem Pfad, dessen Importe von sich aus nichts tun, sodass im Bundle nur landet, was der Code nutzt. Die Teile von Babylon.js, die sich in andere einklinken, werden mit Register…()-Aufrufen eingeschaltet: das Standardmaterial und die Thin Instances. Die Serie zum First-Person-Controller erklärt das genauer.

  • Das letzte Argument von new Engine() rendert in der echten Auflösung des Bildschirms, damit Kanten auf Displays mit hoher Pixeldichte scharf bleiben.

  • Das hemisphärische Licht ist der Himmel, kühl von oben und dunkelviolett von unten. Das gerichtete Licht ist eine warme, tief stehende Sonne. Die Serie behält dieses Licht, und ab Teil 8 macht der eigene Shader des Terrains dasselbe.

  • Die ArcRotateCamera kreist um die Mitte des Gitters: Ziehen dreht sie, Scrollen bringt sie näher.

Ausprobieren

Starte den Dev-Server und öffne die Adresse, die er ausgibt:

~/voxel-terrain
$ npm run dev> voxel-terrain@0.0.0 dev> vite  VITE v8.3.8  ready in 112 ms  ➜  Local:   http://localhost:5173/  ➜  Network: use --host to expose

Kugel und Platte stehen in Blöcken auf dem Bildschirm, mit dem Tunnel durch die Kugel. Dreh die Kamera und schau dir die Oberseite der Kugel an: Sie ist eine Treppe. Jeder Würfel weiß nur, ob sein Wert fest ist, also das Vorzeichen der Dichte. Die Zahlen selbst würden sagen, dass die Oberfläche zwischen den Werten verläuft, schräg und gekrümmt, und diese Ansicht wirft das weg. Der nächste Teil nutzt es.

Das Projekt am Ende von Teil 1: die Werte der Testform als Blöcke. Ziehen dreht, Scrollen zoomt.
In neuem Tab öffnen

Im nächsten Teil werden die Werte mit Marching Cubes zu einer glatten Oberfläche, aus einer Tabelle, die der Code selbst ausrechnet.

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.