Smooth Voxel Terrain in Babylon.js · Part 6 of 12 in the series
Part 6: Meshing in Web Workers
Move sampling and meshing off the page into a few Web Workers, hand the finished arrays back without copying them, and keep the page responsive while the chunks are built.

Quest objectives
- Know what a Web Worker can do, and what can be sent to it
- Write a worker that builds a chunk, and bundle it with Vite
- Hand typed arrays back without copying them
- Run a few workers, each with one chunk at a time
- Throw away results that a newer build has made out of date
In part 5 the page froze for seconds while it built the chunks. JavaScript on a page runs on one thread, and that thread also draws the frames and reads the keys: as long as it builds chunks, nothing else happens. Web Workers are JavaScript on threads of their own. The page gives a worker a job, the worker works on it while the page goes on drawing, and sends the result back.
What a worker can do
A worker runs its own module, with its own copies of everything it imports. It has no access to the page: no DOM, and no Babylon.js scene. That's fine here, because building a chunk is plain arithmetic: the terrain, the grid and the meshers. The Babylon.js mesh is made on the page, from the arrays the worker sends back.
The worker and the page talk with messages. A message is copied, with the structured clone algorithm: objects, arrays, numbers, strings and typed arrays can be sent, functions can't. That is why the meshers have names since part 3: the page sends 'surface-nets', and the worker looks the function up.
Every worker makes its own terrain from its own copy of terrain.ts. Thanks to the seed from part 4, they all make the same one.
The worker
Create 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 });
};The worker waits for a message, builds the chunk with the buildChunk() of part 5, and posts the mesh back, with the time it took.
The last argument of postMessage() lists transferables. A chunk's arrays add up to a hundred kilobytes or more, and copying them costs time on both sides. Transferring an ArrayBuffer moves it instead: the page gets the memory as it is, and the worker can't use it any more. The worker made the arrays for sending, so that's what it wants.
A few workers
One worker would keep the page free, but it would build one chunk after another. Several workers build several chunks at once, one per processor core. Create 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.hardwareConcurrencyis the number of logical cores. One stays for the page, and six workers are plenty for this terrain.new Worker(new URL('./chunkWorker.ts', import.meta.url), { type: 'module' })is the form Vite recognizes: it bundles the worker file with everything it imports as a script of its own, in development and in the build.type: 'module'lets the worker useimport, which all current browsers support.Each worker builds one chunk at a time.
run()takes a free worker and returns a promise of the result, and the worker is free again once the result is in.
The pool has no queue of its own. The page asks how many workers are free and gives them jobs right then. That sounds like a detail, but it lets the page choose the next chunk at the last moment, when the camera may have moved on. The next part builds on that.
Building in the background
Replace src/main.ts. The area stays the same, but instead of building it in one loop, every frame hands the next chunks to the free workers:
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());todoholds the chunks still to build.buildArea()fills it again, for the start and after M.show()replaces a chunk's mesh as soon as its result is in. During a switch of the mesher, the chunks keep their old mesh until the new one arrives, so the terrain changes chunk by chunk instead of disappearing.buildcounts the builds of the whole area. A result that comes back from an older build, because M was pressed in between, is thrown away.The HUD shows how far the build is, the number of workers, the average time a worker spent on a chunk, and the frame rate.
Try it
The terrain grows in chunk by chunk, and the camera flies while it does. Press M: the chunks change to the other mesher one after another, and the frame rate stays up. The milliseconds per chunk tell you what a chunk costs a worker.
In the next part the area ends: chunks load around the camera wherever it flies, and the world has no edge.