Skip to content
DasPeTe Tutorials

Smooth Voxel Terrain in Babylon.js · Part 5 of 12 in the series

Part 5: Chunks and Seams

Cut the world into chunks of 32 m, mesh each one on its own, and make them meet without a seam, for both meshers, by giving every chunk the same samples at its borders.

Quest objectives

  • Address chunks by their place in a grid of chunks
  • Mesh each chunk on its own, with positions from its corner
  • Know why neighboring chunks meet without stitching, for both meshers
  • Skip chunks that hold no surface
  • Fly over the terrain with a camera of Babylon.js

The block of part 4 was one chunk. A world is many of them, side by side and stacked, and each one is sampled and meshed on its own. That keeps the work small: when something changes, only the chunks around it are built again, and later, chunks can be built in the background and loaded around the player as they move.

A grid of chunks

Chunks sit in a grid of their own. Chunk (1, 0, 2) is the chunk one step along x and two along z: its first sample is at x 32, y 0, z 64 in the world. Create src/chunks.ts:

src/chunks.ts
import { CHUNK_SIZE, DensityGrid } from './grid.ts';
import type { MeshData } from './meshData.ts';
import { MESHERS, type MesherName } from './meshers.ts';
import { density } from './terrain.ts';

/** A chunk's place in the grid of chunks: chunk (1, 0, 2) starts at x 32, y 0, z 64. */
export interface ChunkPosition {
  x: number;
  y: number;
  z: number;
}

export const chunkKey = ({ x, y, z }: ChunkPosition) => `${x},${y},${z}`;

/** Samples the terrain in a chunk and meshes it, or null when it holds no surface. */
export function buildChunk(chunk: ChunkPosition, mesher: MesherName): MeshData | null {
  const origin = { x: chunk.x * CHUNK_SIZE, y: chunk.y * CHUNK_SIZE, z: chunk.z * CHUNK_SIZE };
  const grid = new DensityGrid().fill(density, origin);
  if (!grid.hasSurface()) return null;
  return MESHERS[mesher].mesh(grid);
}

chunkKey() turns a position into a string, so that chunks can be kept in a Map. buildChunk() samples the terrain at the chunk's place and meshes it. The mesh's positions are measured from the chunk's corner, not from the world's origin, and the Babylon.js mesh is moved to the corner instead. Big numbers lose precision in a Float32Array, and the positions stay small this way however far the world goes.

Most chunks hold no surface at all: the ones high in the air are all air, deep ones without a cave are all rock. They don't need a mesh. Add a method to DensityGrid in src/grid.ts:

src/grid.ts
/** Whether the grid has solid and air samples, and so a surface between them. */
hasSurface(): boolean {
  const solid = this.values[0] > 0;
  return this.values.some((value) => value > 0 !== solid);
}

Why the seams close

Two chunks next to each other share a face, a wall of 33 × 33 samples. Both sample those points, each for its own grid, and both get exactly the same numbers, because the density is a function of the world position and nothing else. Everything follows from that:

  • Marching cubes puts its vertices on the edges of the grid. The edges in the shared face are worked out from the same two samples in both chunks, so the vertices land on the same spot: the meshes meet exactly, without any stitching.

  • The normals come from the gradient, which needs the samples on either side of the face. That is what the margin from part 2 is for: each chunk also samples a little of its neighbor, so the gradient at the face is the same in both, and the light doesn't jump at the seam.

  • Surface nets put their vertices inside the cells, so a quad at the border needs cells of both chunks. That is the rule from part 3: a chunk makes vertices in one cell more than it has, and takes only the edges it owns. The cells it shares with its neighbor come out the same in both, and each edge gets one quad, from one chunk.

All of this hinges on every chunk asking the same function. Part 9 lets the player change the terrain, and the changes will have to keep that promise.

The area

The blocks are not needed any more:

~/voxel-terrain
$ rm src/blocks.ts

The help names new keys. Replace index.html:

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="hud"></div>
    <div id="help">WASD fly, Q/E down/up, drag to look · M mesher · C chunks · L wireframe</div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Replace src/main.ts. It builds 8 × 8 columns of chunks around the origin, six chunks high, from 32 m below 0 to 160 m up, all at once, and flies a camera over them:

src/main.ts
import {
  Color3,
  Color4,
  DirectionalLight,
  Engine,
  HemisphericLight,
  RegisterStandardEngineExtensions,
  RegisterStandardMaterial,
  Scene,
  StandardMaterial,
  UniversalCamera,
  Vector3,
  type Mesh,
} from '@babylonjs/core/pure';
import { buildChunk, chunkKey, type ChunkPosition } from './chunks.ts';
import { CHUNK_SIZE } from './grid.ts';
import { createMesh } from './meshData.ts';
import { MESHERS, otherMesher, type MesherName } from './meshers.ts';
import { surfaceHeight } from './terrain.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>();

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

/** Builds every chunk of the area, one after the other. */
function buildArea(): void {
  for (const mesh of meshes.values()) mesh.dispose();
  meshes.clear();
  const started = performance.now();
  let triangles = 0;
  for (const chunk of area) {
    const data = buildChunk(chunk, mesher);
    if (!data) continue;
    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);
    triangles += data.indices.length / 3;
  }
  const time = performance.now() - started;
  hud.textContent =
    `${MESHERS[mesher].label}: ${area.length} chunks in ${(time / 1000).toFixed(1)} s\n` +
    `${meshes.size} with a surface, ${triangles} triangles`;
}
buildArea();

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());
  • freezeWorldMatrix() tells Babylon.js that the chunk never moves, so it stops working out its position every frame. With hundreds of chunks that adds up.

  • Two materials, one in the old color and one green, tint every other chunk when you press C: chunks whose x, y and z add up to an odd number get the green one.

  • The UniversalCamera flies: its keys are set to W, A, S, D to move and Q and E for down and up, and dragging the mouse turns it. The numbers are key codes: 87 is W.

Try it

After a pause, the terrain appears, about 250 m across, with its edges where the area ends. Of the 384 chunks, only about half have a surface. Fly around and look for the chunk borders: you won't find them. Press C, and a checkerboard appears: each square is a chunk, and the surface and its light run straight across the borders, with either mesher.

The project at the end of part 5: 384 chunks built at once. WASD and Q/E fly, drag to look, C tints every other chunk.
Open in a new tab

In the next part the work moves off the page into Web Workers, and the page stays responsive while the chunks are built.

Comments

The comments are GitHub Discussions: sign in with GitHub to write one.

The comments come from GitHub, through giscus. Loading them sends your IP address to giscus.app and GitHub.