Zum Inhalt springen
DasPeTe Tutorials

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

Teil 6: Meshing in Web Workern

Verlege Abtasten und Vernetzen von der Seite in ein paar Web Worker, gib die fertigen Arrays ohne Kopieren zurück und halte die Seite bedienbar, während die Chunks gebaut werden.

Quest-Ziele

  • Wissen, was ein Web Worker kann und was man ihm schicken kann
  • Einen Worker schreiben, der einen Chunk baut, und ihn mit Vite bündeln
  • Typisierte Arrays ohne Kopieren zurückgeben
  • Ein paar Worker laufen lassen, jeden mit einem Chunk zur Zeit
  • Ergebnisse verwerfen, die ein neuerer Bau überholt hat

In Teil 5 fror die Seite für Sekunden ein, während sie die Chunks baute. JavaScript läuft auf einer Seite in einem Thread, und dieser Thread zeichnet auch die Frames und liest die Tasten: Solange er Chunks baut, passiert sonst nichts. Web Worker sind JavaScript in eigenen Threads. Die Seite gibt einem Worker eine Aufgabe, der Worker arbeitet daran, während die Seite weiterzeichnet, und schickt das Ergebnis zurück.

Was ein Worker kann

Ein Worker führt sein eigenes Modul aus, mit eigenen Kopien von allem, was er importiert. Auf die Seite hat er keinen Zugriff: kein DOM und keine Szene von Babylon.js. Das passt hier, denn einen Chunk zu bauen ist reine Rechnerei: das Terrain, das Gitter und die Mesher. Das Mesh von Babylon.js entsteht auf der Seite, aus den Arrays, die der Worker zurückschickt.

Worker und Seite reden über Nachrichten. Eine Nachricht wird kopiert, mit dem Structured-Clone-Algorithmus: Objekte, Arrays, Zahlen, Strings und typisierte Arrays lassen sich schicken, Funktionen nicht. Deshalb haben die Mesher seit Teil 3 Namen: Die Seite schickt 'surface-nets', und der Worker schlägt die Funktion nach.

Jeder Worker macht sein eigenes Terrain aus seiner eigenen Kopie von terrain.ts. Dank des Seeds aus Teil 4 machen alle dasselbe.

Der Worker

Lege src/chunkWorker.ts an:

src/chunkWorker.ts
import { buildChunk, type ChunkPosition } from './chunks.ts';
import type { MeshData } from './meshData.ts';
import type { MesherName } from './meshers.ts';

export interface ChunkJob {
  chunk: ChunkPosition;
  mesher: MesherName;
}

export interface ChunkResult {
  /** null when the chunk holds no surface. */
  mesh: MeshData | null;
  /** Milliseconds the worker spent on the chunk. */
  time: number;
}

self.onmessage = (event: MessageEvent<ChunkJob>) => {
  const started = performance.now();
  const mesh = buildChunk(event.data.chunk, event.data.mesher);
  const result: ChunkResult = { mesh, time: performance.now() - started };
  // Hand the arrays' memory over to the page instead of copying it.
  const transfer = mesh ? [mesh.positions.buffer, mesh.normals.buffer, mesh.indices.buffer] : [];
  self.postMessage(result, { transfer });
};

Der Worker wartet auf eine Nachricht, baut den Chunk mit buildChunk() aus Teil 5 und schickt das Mesh zurück, mit der Zeit, die es gedauert hat.

Das letzte Argument von postMessage() listet Transferables. Die Arrays eines Chunks kommen auf hundert Kilobyte und mehr, und sie zu kopieren kostet auf beiden Seiten Zeit. Einen ArrayBuffer zu übertragen verschiebt ihn stattdessen: Die Seite bekommt den Speicher, wie er ist, und der Worker kann ihn nicht mehr nutzen. Der Worker hat die Arrays zum Verschicken gemacht, also ist genau das gewollt.

Ein paar Worker

Ein Worker hielte die Seite frei, aber er baute einen Chunk nach dem anderen. Mehrere Worker bauen mehrere Chunks zugleich, einen pro Prozessorkern. Lege src/workers.ts an:

src/workers.ts
import type { ChunkJob, ChunkResult } from './chunkWorker.ts';

/** A few workers that build chunks, each one chunk at a time. */
export class ChunkWorkers {
  private readonly idle: Worker[] = [];
  readonly count: number;

  constructor() {
    // One core for the page, the others for the workers, but not too many.
    this.count = Math.min(Math.max((navigator.hardwareConcurrency || 2) - 1, 1), 6);
    for (let i = 0; i < this.count; i += 1) {
      this.idle.push(new Worker(new URL('./chunkWorker.ts', import.meta.url), { type: 'module' }));
    }
  }

  /** How many workers wait for a job. */
  get free(): number {
    return this.idle.length;
  }

  /** Builds a chunk on a free worker; check `free` first. */
  run(job: ChunkJob): Promise<ChunkResult> {
    const worker = this.idle.pop();
    if (!worker) throw new Error('No free worker');
    return new Promise((resolve) => {
      worker.onmessage = (event: MessageEvent<ChunkResult>) => {
        this.idle.push(worker);
        resolve(event.data);
      };
      worker.postMessage(job);
    });
  }
}
  • navigator.hardwareConcurrency ist die Zahl der logischen Kerne. Einer bleibt für die Seite, und sechs Worker reichen für dieses Terrain gut.

  • new Worker(new URL('./chunkWorker.ts', import.meta.url), { type: 'module' }) ist die Form, die Vite erkennt: Es bündelt die Worker-Datei mit allem, was sie importiert, zu einem eigenen Skript, in der Entwicklung und im Build. type: 'module' erlaubt dem Worker import, was alle aktuellen Browser können.

  • Jeder Worker baut einen Chunk zur Zeit. run() nimmt einen freien Worker und liefert ein Promise auf das Ergebnis, und der Worker ist wieder frei, sobald das Ergebnis da ist.

Der Pool hat keine eigene Warteschlange. Die Seite fragt, wie viele Worker frei sind, und gibt ihnen genau dann Aufgaben. Das klingt nach einem Detail, aber so wählt die Seite den nächsten Chunk im letzten Moment, wenn die Kamera vielleicht schon weiter ist. Darauf baut der nächste Teil auf.

Bauen im Hintergrund

Ersetze src/main.ts. Das Gebiet bleibt dasselbe, aber statt es in einer Schleife zu bauen, gibt jeder Frame die nächsten Chunks an die freien Worker:

src/main.ts
import {
  Color3,
  Color4,
  DirectionalLight,
  Engine,
  HemisphericLight,
  RegisterStandardEngineExtensions,
  RegisterStandardMaterial,
  Scene,
  StandardMaterial,
  UniversalCamera,
  Vector3,
  type Mesh,
} from '@babylonjs/core/pure';
import { chunkKey, type ChunkPosition } from './chunks.ts';
import { CHUNK_SIZE } from './grid.ts';
import { createMesh, type MeshData } from './meshData.ts';
import { MESHERS, otherMesher, type MesherName } from './meshers.ts';
import { surfaceHeight } from './terrain.ts';
import { ChunkWorkers } from './workers.ts';
import './style.css';

RegisterStandardEngineExtensions();
RegisterStandardMaterial();

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 = 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');

/** Two materials, so that every other chunk can be told apart. */
const materials = ['#d9a066', '#a0d966'].map((color, i) => {
  const material = new StandardMaterial(`ground-${i}`, scene);
  material.diffuseColor = Color3.FromHexString(color);
  material.specularColor = Color3.Black();
  return material;
});
let tinted = false;

// The chunks around the origin: 8 × 8 columns, from 32 m down to 160 m up.
const area: ChunkPosition[] = [];
for (let z = -4; z < 4; z += 1) {
  for (let x = -4; x < 4; x += 1) {
    for (let y = -1; y < 5; y += 1) area.push({ x, y, z });
  }
}

let mesher: MesherName = 'marching-cubes';
const meshes = new Map<string, Mesh>();
const workers = new ChunkWorkers();

function materialFor(chunk: ChunkPosition): StandardMaterial {
  return materials[tinted ? (chunk.x + chunk.y + chunk.z) & 1 : 0];
}

/** Shows a chunk's mesh in place of the one it had. */
function show(chunk: ChunkPosition, data: MeshData | null): void {
  meshes.get(chunkKey(chunk))?.dispose();
  meshes.delete(chunkKey(chunk));
  if (!data) return;
  const mesh = createMesh(`chunk ${chunkKey(chunk)}`, data, scene);
  mesh.position.set(chunk.x * CHUNK_SIZE, chunk.y * CHUNK_SIZE, chunk.z * CHUNK_SIZE);
  mesh.material = materialFor(chunk);
  // The chunk never moves: Babylon.js can stop working out its world matrix.
  mesh.freezeWorldMatrix();
  mesh.metadata = chunk;
  meshes.set(chunkKey(chunk), mesh);
}

// The chunks still to build, and how the build of the whole area goes.
let todo: ChunkPosition[] = [];
let build = 0;
let built = 0;
let workTime = 0;
let started = 0;
let seconds = 0;

/** Starts building every chunk of the area again. */
function buildArea(): void {
  todo = [...area];
  build += 1;
  built = 0;
  workTime = 0;
  started = performance.now();
}
buildArea();

scene.onBeforeRenderObservable.add(() => {
  // Every free worker gets the next chunk.
  while (workers.free > 0 && todo.length > 0) {
    const chunk = todo.shift()!;
    const thisBuild = build;
    workers.run({ chunk, mesher }).then((result) => {
      // A switch of the mesher started a new build: this result is out of date.
      if (thisBuild !== build) return;
      show(chunk, result.mesh);
      built += 1;
      workTime += result.time;
      if (built === area.length) seconds = (performance.now() - started) / 1000;
    });
  }
  const done = built === area.length ? ` in ${seconds.toFixed(1)} s` : '';
  hud.textContent =
    `${MESHERS[mesher].label}: ${built} of ${area.length} chunks${done}\n` +
    `${workers.count} workers, ${(workTime / Math.max(built, 1)).toFixed(1)} ms per chunk\n` +
    `${engine.getFps().toFixed(0)} fps`;
});

window.addEventListener('keydown', (event) => {
  if (event.code === 'KeyL') for (const material of materials) material.wireframe = !material.wireframe;
  if (event.code === 'KeyC') {
    tinted = !tinted;
    for (const mesh of meshes.values()) mesh.material = materialFor(mesh.metadata);
  }
  if (event.code === 'KeyM') {
    mesher = otherMesher(mesher);
    buildArea();
  }
});

// A flying camera: WASD to move, Q and E for down and up, drag the mouse to look.
const start = new Vector3(40, surfaceHeight(40, 170) + 60, 170);
const camera = new UniversalCamera('camera', start, scene);
camera.setTarget(new Vector3(0, surfaceHeight(0, -60), -60));
camera.keysUp = [87];
camera.keysDown = [83];
camera.keysLeft = [65];
camera.keysRight = [68];
camera.keysDownward = [81];
camera.keysUpward = [69];
camera.speed = 1;
camera.minZ = 0.1;
camera.attachControl();

engine.runRenderLoop(() => scene.render());
window.addEventListener('resize', () => engine.resize());
  • todo hält die Chunks, die noch zu bauen sind. buildArea() füllt es neu, zum Start und nach M.

  • show() ersetzt das Mesh eines Chunks, sobald sein Ergebnis da ist. Beim Umschalten des Meshers behalten die Chunks ihr altes Mesh, bis das neue kommt, sodass sich das Terrain Chunk für Chunk ändert, statt zu verschwinden.

  • build zählt die Bauten des ganzen Gebiets. Ein Ergebnis, das aus einem älteren Bau zurückkommt, weil zwischendurch M gedrückt wurde, wird verworfen.

  • Das HUD zeigt, wie weit der Bau ist, die Zahl der Worker, die mittlere Zeit, die ein Worker für einen Chunk brauchte, und die Bildrate.

Ausprobieren

Das Terrain wächst Chunk für Chunk herein, und die Kamera fliegt dabei. Drück M: Die Chunks wechseln einer nach dem anderen zum anderen Mesher, und die Bildrate bleibt oben. Die Millisekunden pro Chunk sagen dir, was ein Chunk einen Worker kostet.

Das Projekt am Ende von Teil 6: dasselbe Gebiet, gebaut von Web Workern, während die Seite weiterläuft. WASD und Q/E fliegen, M schaltet den Mesher um.
In neuem Tab öffnen

Im nächsten Teil endet das Gebiet: Chunks laden rund um die Kamera, wohin sie auch fliegt, und die Welt hat keinen Rand.

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.