Smooth Voxel Terrain in Babylon.js · Part 7 of 12 in the series
Part 7: Streaming an Endless World
Load the chunks within a radius of the camera, nearest first, drop the ones it leaves behind, skip the sky above the highest ground, and spread the work of showing meshes over the frames.

Quest objectives
- Keep the chunks around the camera loaded, wherever it goes
- Build the nearest chunks first, and start a new one as soon as a worker is free
- Know how high the ground can be without sampling it, and never build the air above it
- Rebuild a chunk while it keeps showing its old mesh
- Hand meshes to the graphics card within a time budget per frame
The area of part 6 was 256 m wide, and then the world ended. The terrain function goes on forever; only the chunks stop. This part loads chunks where the camera is: every chunk within 8 chunks, about 256 m, is built, the nearest first, and chunks the camera leaves behind are dropped. The world gets no edge, and the memory stays the same wherever you fly.
The world
One class keeps track of the chunks: which are loaded, which are being built, which have a mesh to show and which can go. Create src/world.ts:
import type { Material, Mesh, Scene, Vector3 } 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 type { MesherName } from './meshers.ts';
import { highestGround } from './terrain.ts';
import type { ChunkWorkers } from './workers.ts';
/** How far around the camera chunks are loaded, in chunks. */
export const VIEW_RADIUS = 8;
/** The lowest layer of chunks: below it the world is solid. */
const LOWEST_LAYER = -2;
/** Milliseconds per frame for handing new meshes to the graphics card. */
const SHOW_BUDGET = 4;
interface Chunk {
position: ChunkPosition;
mesh: Mesh | null;
/** Counts up whenever the chunk has to be built again. */
version: number;
/** The version the chunk's mesh shows; -1 before it has one. */
shown: number;
/** The version a worker builds, or that waits to be shown; -1 when there is none. */
pending: number;
}
interface Finished {
chunk: Chunk;
version: number;
data: MeshData | null;
}
/** The chunks around the camera: which to load, build, show and drop. */
export class World {
mesher: MesherName = 'marching-cubes';
private readonly chunks = new Map<string, Chunk>();
/** The highest layer of chunks that can hold ground, per column. */
private readonly tops = new Map<string, number>();
private readonly finished: Finished[] = [];
private focus = { x: 0, y: 0, z: 0 };
private readonly scene: Scene;
private readonly material: Material;
private readonly workers: ChunkWorkers;
constructor(scene: Scene, material: Material, workers: ChunkWorkers) {
this.scene = scene;
this.material = material;
this.workers = workers;
}
/** Switches the mesher. Every chunk is built again and keeps its mesh until then. */
setMesher(mesher: MesherName): void {
this.mesher = mesher;
for (const chunk of this.chunks.values()) chunk.version += 1;
}
/** Once a frame: follows the camera at `focus`. */
update(focus: Vector3): void {
this.focus = { x: focus.x, y: focus.y, z: focus.z };
this.dropFar();
this.startJobs();
this.showFinished();
}
/** How many chunks are loaded, and how many of them wait for a mesh. */
get counts(): { loaded: number; waiting: number } {
let waiting = 0;
for (const chunk of this.chunks.values()) if (chunk.shown !== chunk.version) waiting += 1;
return { loaded: this.chunks.size, waiting };
}
/** The column of chunks the camera is in. */
private get center(): { x: number; z: number } {
return { x: Math.floor(this.focus.x / CHUNK_SIZE), z: Math.floor(this.focus.z / CHUNK_SIZE) };
}
private dropFar(): void {
const center = this.center;
// A little beyond the view radius, so that chunks at the edge are not dropped and
// loaded again while the camera moves back and forth.
const limit = (VIEW_RADIUS + 1.5) ** 2;
for (const [key, chunk] of this.chunks) {
if ((chunk.position.x - center.x) ** 2 + (chunk.position.z - center.z) ** 2 <= limit) continue;
chunk.mesh?.dispose();
this.chunks.delete(key);
}
for (const key of this.tops.keys()) {
const [x, z] = key.split(',').map(Number);
if ((x - center.x) ** 2 + (z - center.z) ** 2 > limit) this.tops.delete(key);
}
}
/** Gives every free worker the nearest chunk that needs building. */
private startJobs(): void {
if (this.workers.free === 0) return;
const center = this.center;
const wanted: { position: ChunkPosition; distance: number }[] = [];
for (let dz = -VIEW_RADIUS; dz <= VIEW_RADIUS; dz += 1) {
for (let dx = -VIEW_RADIUS; dx <= VIEW_RADIUS; dx += 1) {
if (dx * dx + dz * dz > VIEW_RADIUS * VIEW_RADIUS) continue;
const x = center.x + dx;
const z = center.z + dz;
for (let y = LOWEST_LAYER; y <= this.top(x, z); y += 1) {
const chunk = this.chunks.get(chunkKey({ x, y, z }));
if (chunk && (chunk.pending >= 0 || chunk.shown === chunk.version)) continue;
const distance = Math.hypot(
(x + 0.5) * CHUNK_SIZE - this.focus.x,
(y + 0.5) * CHUNK_SIZE - this.focus.y,
(z + 0.5) * CHUNK_SIZE - this.focus.z,
);
wanted.push({ position: { x, y, z }, distance });
}
}
}
wanted.sort((a, b) => a.distance - b.distance);
for (const { position } of wanted.slice(0, this.workers.free)) {
const key = chunkKey(position);
let chunk = this.chunks.get(key);
if (!chunk) {
chunk = { position, mesh: null, version: 0, shown: -1, pending: -1 };
this.chunks.set(key, chunk);
}
const job = chunk;
const version = chunk.version;
job.pending = version;
this.workers.run({ chunk: position, mesher: this.mesher }).then((result) => {
this.finished.push({ chunk: job, version, data: result.mesh });
// The worker is free again: no need to wait for the next frame.
this.startJobs();
});
}
}
/** The highest layer of chunks in a column that can hold ground. */
private top(x: number, z: number): number {
const key = `${x},${z}`;
let top = this.tops.get(key);
if (top === undefined) {
top = Math.floor(highestGround(x * CHUNK_SIZE, z * CHUNK_SIZE, CHUNK_SIZE) / CHUNK_SIZE);
this.tops.set(key, top);
}
return top;
}
/** Shows the meshes the workers made, as many as fit into the frame's budget. */
private showFinished(): void {
const started = performance.now();
while (this.finished.length > 0 && performance.now() - started < SHOW_BUDGET) {
const { chunk, version, data } = this.finished.shift()!;
chunk.pending = -1;
// Dropped while it was being built: the chunk is gone.
if (this.chunks.get(chunkKey(chunk.position)) !== chunk) continue;
chunk.mesh?.dispose();
chunk.mesh = data ? this.createChunkMesh(chunk.position, data) : null;
chunk.shown = version;
}
}
private createChunkMesh(position: ChunkPosition, data: MeshData): Mesh {
const mesh = createMesh(`chunk ${chunkKey(position)}`, data, this.scene);
mesh.position.set(position.x * CHUNK_SIZE, position.y * CHUNK_SIZE, position.z * CHUNK_SIZE);
mesh.material = this.material;
// The chunk never moves: Babylon.js can stop working out its world matrix.
mesh.freezeWorldMatrix();
return mesh;
}
}Every frame, update() takes three steps: drop the chunks that are too far, start building the ones that are missing, and show the meshes that are ready.
Versions
A chunk has three numbers, which together say what it needs:
versioncounts up whenever the chunk has to be built again. A switch of the mesher counts up every chunk; part 9's digging will count up the chunks it changes.shownis the version its mesh shows. When it equalsversion, the chunk is up to date.pendingis the version a worker is building, or that is built and waits to be shown.
A chunk is built when it is out of date and nothing is pending for it. Until the new mesh arrives, the old one stays: switching the mesher never leaves a hole. And pending lasts until the mesh is shown, not just until the worker is done. Otherwise a chunk whose mesh waits in the queue would look out of date, be handed to the next free worker, and be built again and again.
Nearest first
startJobs() lists every chunk that wants building: the columns of chunks within the view radius around the camera's column, and in each, the layers from 64 m below 0 up to the highest one that can hold ground. It sorts them by distance from the camera, and the free workers take the nearest ones. Ground near you appears first, and if you fly on, the list is made anew and the chunks ahead come first.
When a worker is done, startJobs() runs again right away instead of waiting for the next frame. A frame takes 16 ms, and many chunks take less, so a worker would otherwise wait for half its time.
The air above the ground
How high does a column of chunks go? High enough for the tallest mountain, it would be mostly air, because most ground is much lower, and every chunk above the ground is air. Building those would cost a worker a full sample of the chunk, only to find nothing. The terrain can say how high its ground can reach without sampling a whole chunk: the height function is 2D and cheap. Add to src/terrain.ts:
/**
* A height that no ground in the square from (x, z) to (x + size, z + size) rises above:
* the highest of 5 × 5 heights in it, plus the overhangs and some room for a peak
* between the samples.
*/
export function highestGround(x: number, z: number, size: number): number {
let highest = -Infinity;
for (let i = 0; i <= 4; i += 1) {
for (let j = 0; j <= 4; j += 1) {
highest = Math.max(highest, surfaceHeight(x + (i * size) / 4, z + (j * size) / 4));
}
}
return highest + MOST_BEND + 16;
}The heights at 25 points of the chunk's square, the highest of them, plus the most the overhangs push up, plus room for a peak that lies between the points. The world works this out once per column in top() and keeps it. The 16 m of room are generous on purpose: a missed bit of ground would be a hole in a mountain top. Over 1,640 columns, sampled meter by meter, no ground rose above it.
Chunks deep down are built: they may hold caves. The world stops at 64 m below 0, which the terrain never reaches with its surface.
Dropping chunks
dropFar() drops chunks further than 9.5 chunks away, a little more than the 8 they are loaded within. If both were the same, a chunk right at the edge would be dropped and loaded again every time the camera moved back and forth across a chunk border.
A budget for showing
The workers made the arrays, but turning them into a Babylon.js mesh happens on the page: the arrays go to the graphics card as buffers. That takes a moment per chunk, and when a dozen arrive in the same frame, the frame is late and the camera stutters. showFinished() shows meshes for 4 ms per frame at most and leaves the rest for the next one.
Fog
The loaded chunks end in a ragged circle 256 m away. Fog hides it: from half that distance on, the terrain fades into the color of the sky, and just before the edge it is gone. Replace src/main.ts:
import {
Color3,
Color4,
DirectionalLight,
Engine,
HemisphericLight,
RegisterStandardEngineExtensions,
RegisterStandardMaterial,
Scene,
StandardMaterial,
UniversalCamera,
Vector3,
} from '@babylonjs/core/pure';
import { CHUNK_SIZE } from './grid.ts';
import { MESHERS, otherMesher } from './meshers.ts';
import { surfaceHeight } from './terrain.ts';
import { ChunkWorkers } from './workers.ts';
import { VIEW_RADIUS, World } from './world.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');
// Fog hides the edge of the loaded chunks: it ends a little before the view radius.
const viewDistance = VIEW_RADIUS * CHUNK_SIZE;
scene.fogMode = Scene.FOGMODE_LINEAR;
scene.fogColor = Color3.FromHexString('#2a1150');
scene.fogStart = viewDistance * 0.5;
scene.fogEnd = viewDistance * 0.95;
// 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 material = new StandardMaterial('ground', scene);
material.diffuseColor = Color3.FromHexString('#d9a066');
material.specularColor = Color3.Black();
const world = new World(scene, material, new ChunkWorkers());
// A flying camera: WASD to move, Q and E for down and up, drag the mouse to look.
const start = new Vector3(0, surfaceHeight(0, 40) + 25, 40);
const camera = new UniversalCamera('camera', start, scene);
camera.setTarget(new Vector3(0, surfaceHeight(0, -200) + 20, -200));
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.maxZ = viewDistance * 1.2;
camera.attachControl();
window.addEventListener('keydown', (event) => {
if (event.code === 'KeyL') material.wireframe = !material.wireframe;
if (event.code === 'KeyM') world.setMesher(otherMesher(world.mesher));
// Shift flies faster.
if (event.key === 'Shift') camera.speed = 4;
});
window.addEventListener('keyup', (event) => {
if (event.key === 'Shift') camera.speed = 1;
});
scene.onBeforeRenderObservable.add(() => {
world.update(camera.position);
const { loaded, waiting } = world.counts;
const { x, y, z } = camera.position;
hud.textContent =
`${MESHERS[world.mesher].label}: ${loaded} chunks, ${waiting} waiting\n` +
`x ${x.toFixed(0)}, y ${y.toFixed(0)}, z ${z.toFixed(0)}\n` +
`${engine.getFps().toFixed(0)} fps`;
});
engine.runRenderLoop(() => scene.render());
window.addEventListener('resize', () => engine.resize());Babylon.js's scene fog is linear here, from
fogStarttofogEnd, and the standard material applies it. Its color is the clear color, so that the terrain fades into the sky.The camera starts a little above the ground and looks north, at the hills and the mountains behind them. Shift makes it four times as fast.
The HUD shows the loaded chunks, how many wait for a mesh, and where the camera is.
In index.html, the help gets Shift faster and loses C chunks, which this part drops.
Try it
The ground near the camera appears first, then the rest fills in around it. Hold W and Shift and fly in any direction: the chunks in front of you appear before you get there, and the waiting count in the HUD rises and falls. Turn around after a while: the ground behind you has been dropped and is built anew. The loaded count stays about the same, however far you fly.
The terrain is still the color of clay. In the next part it gets grass, soil, rock and snow, painted in code and projected from three sides.