Skip to content
DasPeTe Tutorials

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

Part 1: Vite, Babylon.js and a Density Field

Set up a Vite project with TypeScript and Babylon.js 9, and describe a shape the way smooth voxel terrain does: as a number at every point of a grid, above 0 inside and below 0 outside.

Quest objectives

  • Know what smooth voxel terrain is, and what you build in this series
  • Create a Vite project with TypeScript and Babylon.js 9
  • Describe a shape as a density: a number at every point, its sign telling solid from air
  • Combine shapes with min and max
  • Sample the density on a grid, and show it the way a block game would

In a block game, the world is a grid of cubes, and each cell is either full or empty. Smooth voxel terrain keeps the grid but stores a number at each of its points: how far the point is inside the ground, or outside of it. Where the numbers change from positive to negative, there is a surface, and because the numbers say where between two points it lies, the surface can be smooth. Slopes come out as slopes, not as stairs.

A heightmap, the other common way to store terrain, has one height per spot on the map. It cannot hold a cave, an overhang or a tunnel. A grid of numbers can hold all of them, and changing the numbers digs and builds: the ground can be shaped anywhere, in any direction.

In this series you build such a terrain in the browser: generated from noise, endless, cut into chunks that are meshed in the background, textured, with caves that get darker the further in you go, ground you can dig away and build up, a player who walks on it, and a view to the horizon.

The series

  1. Vite, Babylon.js and a density field (this part)

  2. Marching cubes with a generated table

  3. Surface nets

  4. Terrain from noise: hills, overhangs and caves

  5. Chunks and seams

  6. Meshing in Web Workers

  7. Streaming an endless world

  8. Triplanar textures

  9. Digging and building

  10. Walking on the terrain

  11. Cave light

  12. A far view to the horizon

Parts 2 and 3 build two different ways to turn the numbers into a mesh, marching cubes and surface nets. From then on, every demo has both, and the M key switches between them, so you can compare them on the same terrain.

Every part ends with a demo you can try right on the page. It is built from exactly the code the part shows, which ran with Babylon.js 9.33, simplex-noise 4.0, Vite 8.3 and TypeScript 7 while the series was written.

What you need

Node.js 20.19 or newer, which Vite 8 requires, a terminal and a current browser with WebGL 2. You should be at home in TypeScript. Babylon.js is explained as far as the series uses it; if you want a gentler start with it, the first part of the first-person controller series sets up a similar project.

Create the project

Vite's project generator sets up a TypeScript project without a framework. When it offers to install and start right away, answer 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

Then install the template's packages, and Babylon.js on top. The version is pinned to the one the series was written with; a newer 9.x should work too.

~
$ 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

The template comes with a small counter demo. Remove it, and the public folder with its icons, because the page gets new files:

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

The page

The page is a canvas for Babylon.js to draw into, and a line of help. Replace index.html with this:

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>

The canvas fills the window, and the help sits in a corner:

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;
}

A density field

A shape in smooth voxel terrain is a function that takes a point and returns a number, its density. Above 0 the point is solid, below 0 it is air, and the surface lies where the density is exactly 0. A ball of radius 10 around the origin has the density 10 - distance: 10 at its center, 0 on its surface, -5 at five meters outside of it.

When the density is the distance to the surface, as it is here, the function is a signed distance field. It doesn't have to be exact: what matters is the sign, and that the numbers change smoothly near the surface. A sample of 0.25 next to one of -0.75 says that the surface lies a quarter of the way from the first to the second. The next part builds the smooth surface from exactly that.

The terrain is sampled at the corners of a grid of cells, one meter apart. A chunk of the terrain is 32 cells along each side, and so 33 samples: each side has one corner more than it has cells. Create src/grid.ts:

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;
  }
}
  • The samples sit in one flat Float32Array, x first, then y, then z. index() turns three coordinates into the place in it.

  • fill() goes up each column before it moves on to the next one, from y to y with x and z the same. It makes no difference yet, but the terrain in part 4 works out a column's height once and uses it for every sample above.

  • origin is where the grid's first corner is in the world. Later chunks sample the world at their own place with it.

Shapes from shapes

Two densities combine with nothing but min and max. A point is solid in either of two shapes when the larger of their densities is above 0, so max is the union. It is solid in both when the smaller one is, so min is the intersection. And it is in the first shape but not in the second when it is in the first and in the opposite of the second, whose density is the negated one. That is the difference, and it is exactly what digging will be in part 9.

Create src/shapes.ts with a test shape: a ball resting on a slab, and a tunnel through the ball.

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);
};

The slab is the intersection of six half-spaces, one for each face: below 6.5 m, above 1.5 m, and between 2.5 m and 29.5 m on x and on z. Its edges are at half meters on purpose. A sample of exactly 0 lies right on the surface, and the meshes in the next parts would get triangles of no size there. Edges between the samples avoid that.

The block game view

Before the smooth surface, the grid the way a block game shows it: a cube at every solid sample. Only cubes next to air can be seen, so the others are left out. Create src/blocks.ts:

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;
}

Thousands of cubes as thousands of meshes would be slow. Thin instances draw one mesh many times in one draw call: the cube, plus a list of matrices, one per copy. Here each matrix only moves the cube to its sample. The cubes are a little smaller than a meter, so that the gaps show the grid.

The scene

Replace src/main.ts. It creates the engine and the scene, two lights, the grid and its blocks, and a camera that orbits the grid:

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 is imported from @babylonjs/core/pure, a path whose imports do nothing by themselves, so the bundle only holds what the code uses. The parts of Babylon.js that hook into others are switched on with Register…() calls: the standard material, and the thin instances. The first-person controller series explains this in more detail.

  • The last argument of new Engine() renders at the screen's real resolution, which keeps edges sharp on high-density displays.

  • The hemispheric light is the sky, cool from above and dark violet from below. The directional light is a warm, low sun. The series keeps this light, and from part 8 on the terrain's own shader does the same.

  • The ArcRotateCamera circles the middle of the grid: drag to turn it, scroll to come closer.

Try it

Start the dev server and open the address it prints:

~/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

The ball and the slab stand on the screen in blocks, with the tunnel cut through the ball. Turn the camera and look at the ball's top: it is a staircase. Each cube only knows whether its sample is solid, the sign of the density. The numbers themselves would say that the surface runs between the samples, at slants and curves, and this view throws that away. The next part puts it to use.

The project at the end of part 1: the samples of the test shape as blocks. Drag to turn, scroll to zoom.
Open in a new tab

In the next part the samples become a smooth surface with marching cubes, from a table the code works out by itself.

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.