Skip to content
DasPeTe Tutorials

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

Part 4: Terrain From Noise: Hills, Overhangs and Caves

Generate an endless terrain from seeded simplex noise: hills and mountain ridges as a height, then overhangs and tunnel caves that only a density can hold. A block of it, cut out like a slice of cake, shows the inside.

Quest objectives

  • Make noise that is the same every time, from a seed
  • Layer noise into hills, and turn it into ridged mountains
  • Write a height as a density, and bend it into overhangs with 3D noise
  • Carve tunnel caves where two noises are both 0
  • Work out a column's height once, not for every sample in it

The meshers of part 2 and part 3 work for any density. Time to give them terrain: a function of x, y and z that describes ground, endless in every direction, without anyone placing a hill by hand. The tool for that is noise.

Simplex noise

Noise in this sense is a smooth random function: it returns values between -1 and 1 that change gently from point to point, with no pattern you'd notice. Ken Perlin invented the classic kind in 1983, and the faster simplex noise in 2001. The simplex-noise package has it for two, three and four dimensions, without any dependencies. Install it, and delete the test shape, which this part replaces:

~/voxel-terrain
$ npm install simplex-noise@4.0.3added 1 package, and audited 20 packages in 421ms8 packages are looking for funding  run `npm fund` for detailsfound 0 vulnerabilities$ rm src/shapes.ts

The same world every time

createNoise2D() shuffles a table with a random function before it starts, and the table decides what the noise looks like. With Math.random, every page load would make a different world, and from part 6 on, every Web Worker would make its own, which doesn't fit together at all. A seeded random function returns the same numbers for the same seed. Mulberry32 is one in five lines, and good enough for shuffling a table.

Layers of noise

Noise at one scale looks like soft bumps. Real ground has big shapes with smaller ones on them, and those have smaller ones still. Add several layers of noise, each twice as fine and half as strong as the one before, and it looks like that. Each layer is called an octave, and the sum has a fine name, fractal Brownian motion, but it is just this loop.

Hills and mountains

The height of the ground has three ingredients:

  • Hills: five layers of noise, the largest about 400 m across, up to about 40 m up and down. They roll gently and cover the whole world.

  • Where the mountains are: one slow layer, 1,500 m across, through a smoothstep that turns it into 0 in the lowlands and 1 in the mountains, with a soft border.

  • Ridges: noise is 0 along lines that wind across the map. One minus its size is highest along those lines, which makes ridges, and cubing it makes them steep, with wide valleys between them. The hypot with 0.15 rounds the very top: without it the crest would be a sharp crease. The ridges rise up to about 90 m above the hills, but only where the land is mountainous.

From height to density

A height becomes a density with one subtraction: height - y is positive below the ground and negative above it, and 0 exactly on it. That alone would be a heightmap in disguise. The next two steps use what a density can do and a heightmap can't.

Overhangs. 3D noise added to the density pushes the surface in and out, in every direction. Where it pushes sideways harder than the slope rises, the ground leans over and makes an overhang. On the plains it is only a meter, which keeps them smooth; in the mountains it is up to 10 m, which bends cliffs into shelves. The noise is stretched a little in height, so the shapes run more along the slope than up it.

Caves. One 3D noise is 0 on a wavy surface that runs through the world. Two different noises are both 0 where two such surfaces cross, and two surfaces cross in a line. Around those lines, where both noises are close to 0, run the caves: long, winding tunnels. Math.hypot(a, b) is how far a point is from the line in noise units, and times 50 it is roughly meters. Within 4 m of the surface, the tunnels get narrower, so that most of them stay underground and only some break through as cave entrances. Math.min() subtracts the caves from the ground, as in part 1.

Create src/terrain.ts:

src/terrain.ts
import { createNoise2D, createNoise3D } from 'simplex-noise';

/** Numbers that look random but are the same for the same seed (mulberry32). */
export function seededRandom(seed: number): () => number {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), seed | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const random = seededRandom(20261114);
const noise2 = createNoise2D(random);
const noise3 = createNoise3D(random);

/** Layers of noise, each twice as fine and half as strong as the one before. */
function layered2(x: number, z: number, layers: number): number {
  let sum = 0;
  let strength = 0.5;
  for (let layer = 0; layer < layers; layer += 1) {
    sum += noise2(x, z) * strength;
    x *= 2;
    z *= 2;
    strength /= 2;
  }
  return sum;
}

const smoothstep = (from: number, to: number, value: number) => {
  const t = Math.min(Math.max((value - from) / (to - from), 0), 1);
  return t * t * (3 - 2 * t);
};

/** How mountainous the land is at (x, z), from 0 to 1. */
function mountains(x: number, z: number): number {
  return smoothstep(-0.1, 0.4, noise2(x / 1500 + 40, z / 1500 - 70));
}

// The grid asks for a whole column before the next one, so the last column's
// numbers are kept.
let columnX = Number.NaN;
let columnZ = Number.NaN;
let columnHeight = 0;
let columnMountains = 0;

function column(x: number, z: number): void {
  if (x === columnX && z === columnZ) return;
  columnX = x;
  columnZ = z;
  columnMountains = mountains(x, z);
  // Rolling hills everywhere, and ridges where the land is mountainous: high where the
  // noise is near 0. The small number in the hypot rounds the crest off.
  const hills = layered2(x / 400, z / 400, 5) * 40;
  const ridge = Math.max(1 - Math.hypot(layered2(x / 500 - 20, z / 500 + 90, 4) * 1.6, 0.15), 0);
  columnHeight = 12 + hills + columnMountains * ridge ** 3 * 150;
}

/** The height of the ground at (x, z), before overhangs and caves. */
export function surfaceHeight(x: number, z: number): number {
  column(x, z);
  return columnHeight;
}

/** How far the overhangs push the ground out at most, in meters. */
const MOST_BEND = 10;

/** The density of the terrain: above 0 in rock and soil, below 0 in the air. */
export function density(x: number, y: number, z: number): number {
  column(x, z);
  // How far below the ground the point is: the same 0 as the height, but in 3D.
  let d = columnHeight - y;

  // Overhangs: 3D noise pushes the ground in and out, more in the mountains.
  const bend = 1 + (MOST_BEND - 1) * columnMountains;
  d += noise3(x / 50, y / 35, z / 50) * bend;

  // Caves: tunnels along the lines where two noises are both 0. Near the surface they
  // narrow, so that only some of them break through.
  const a = noise3(x / 100, y / 50, z / 100);
  const b = noise3(x / 100 + 43.7, y / 50 - 11.3, z / 100 + 7.9);
  const cave = (Math.hypot(a, b) - 0.09) * 50 + Math.max(4 - d, 0) * 0.6;
  return Math.min(d, cave);
}
  • The second noise for the caves is the same noise at another place: shifting the coordinates by a few odd numbers gives a pattern that has nothing to do with the first.

  • MOST_BEND is the most the overhangs push. Part 7 needs that number to know how high the ground can reach.

  • surfaceHeight() is the height before overhangs and caves. Part 8 uses it to tell how deep below the ground a point is.

A slice of terrain

A chunk is 32 m on each side, a small piece of the world. To see what the density holds inside, this part cuts that piece out like a slice of cake: the terrain, intersected with a box a little smaller than the chunk, so that the cut faces close it and show the caves and overhangs in section. Replace src/main.ts:

src/main.ts
import {
  ArcRotateCamera,
  Color3,
  Color4,
  DirectionalLight,
  Engine,
  HemisphericLight,
  RegisterStandardEngineExtensions,
  RegisterStandardMaterial,
  RegisterThinInstanceMesh,
  Scene,
  StandardMaterial,
  Vector3,
  type Mesh,
} from '@babylonjs/core/pure';
import { showBlocks } from './blocks.ts';
import { CHUNK_SIZE, DensityGrid } from './grid.ts';
import { createMesh } from './meshData.ts';
import { MESHERS, otherMesher, type MesherName } from './meshers.ts';
import { density, surfaceHeight } from './terrain.ts';
import './style.css';

RegisterStandardEngineExtensions();
RegisterStandardMaterial();
RegisterThinInstanceMesh();

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

const material = new StandardMaterial('ground', scene);
material.diffuseColor = Color3.FromHexString('#d9a066');
material.specularColor = Color3.Black();

/** Solid inside the box from 0.5 to CHUNK_SIZE - 0.5, so the cut faces close the block. */
function insideBlock(x: number, y: number, z: number): number {
  const far = CHUNK_SIZE - 0.5;
  return Math.min(x - 0.5, far - x, y - 0.5, far - y, z - 0.5, far - z);
}

// The part of the world shown: the low corner of the block. Here a shelf of rock hangs
// over a cave; the arrow keys move on.
const place = { x: 304, z: -64 };
const grid = new DensityGrid();
let mesher: MesherName = 'marching-cubes';
let mesh: Mesh | null = null;
let blocks: Mesh | null = null;
let sampleTime = 0;

/** Samples the world at `place`, cut to a block with the ground 20 m up. */
function sample(): void {
  const origin = {
    x: place.x,
    y: Math.round(surfaceHeight(place.x + CHUNK_SIZE / 2, place.z + CHUNK_SIZE / 2)) - 20,
    z: place.z,
  };
  const started = performance.now();
  grid.fill((x, y, z) => Math.min(density(x, y, z), insideBlock(x - origin.x, y - origin.y, z - origin.z)), origin);
  sampleTime = performance.now() - started;
  const wasShown = blocks?.isEnabled() ?? false;
  blocks?.dispose();
  blocks = showBlocks(scene, grid);
  blocks.setEnabled(wasShown);
  remesh();
}

/** Meshes the grid with the current mesher, in place of the last mesh. */
function remesh(): void {
  const started = performance.now();
  const data = MESHERS[mesher].mesh(grid);
  const time = performance.now() - started;
  mesh?.dispose();
  mesh = createMesh('terrain', data, scene);
  mesh.material = material;
  hud.textContent =
    `x ${place.x}, z ${place.z}, sampled in ${sampleTime.toFixed(1)} ms\n` +
    `${MESHERS[mesher].label} in ${time.toFixed(1)} ms\n` +
    `${data.indices.length / 3} triangles, ${data.positions.length / 3} vertices`;
}
sample();

// Steps of x and z for the arrow keys, as the camera looks at the block at the start:
// up is away from it, along -z.
const MOVES: Record<string, [number, number]> = {
  ArrowLeft: [1, 0],
  ArrowRight: [-1, 0],
  ArrowUp: [0, -1],
  ArrowDown: [0, 1],
};

window.addEventListener('keydown', (event) => {
  if (event.code === 'KeyB') blocks?.setEnabled(!blocks.isEnabled());
  if (event.code === 'KeyL') material.wireframe = !material.wireframe;
  if (event.code === 'KeyM') {
    mesher = otherMesher(mesher);
    remesh();
  }
  const move = MOVES[event.code];
  if (move) {
    event.preventDefault();
    place.x += move[0] * (CHUNK_SIZE / 2);
    place.z += move[1] * (CHUNK_SIZE / 2);
    sample();
  }
});

// Orbit around the middle of the block.
const middle = new Vector3(CHUNK_SIZE / 2, CHUNK_SIZE / 2, CHUNK_SIZE / 2);
const camera = new ArcRotateCamera('camera', 2.2, 1.1, 70, middle, scene);
camera.attachControl();
camera.lowerRadiusLimit = 10;
camera.upperRadiusLimit = 120;
camera.wheelPrecision = 5;

engine.runRenderLoop(() => scene.render());
window.addEventListener('resize', () => engine.resize());
  • insideBlock() is the box's density, the intersection of six half-spaces like the slab in part 1, half a meter inside the chunk on each side.

  • The block's height follows the ground: origin.y puts the surface in the middle of the block about 20 m up, wherever it is.

  • The arrow keys move the place in steps of 16 m, half a block, so you can walk the block across the world and see that it goes on. The HUD shows how long sampling and meshing take.

  • It starts at a spot where a shelf of rock hangs over a cave, found by trying.

In index.html, add arrows move to the help.

Try it

At the start, a shelf of rock reaches out over a hollow, and below it the cave runs into the block. A heightmap can't hold that: it has one height for each spot. Press the arrow keys and travel: plains, slopes, the steep sides of ridges, and now and then a tunnel cut open in the side of the block. Try M on real terrain, and B to see the samples as blocks again.

The project at the end of part 4: a block of terrain. The arrow keys move through the world, M switches the mesher, B shows blocks.
Open in a new tab

One block is small. In the next part the world becomes many chunks side by side, and their meshes have to meet without a seam.

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.