voxolith

Terrain

@voxolith/gen-terrain: hills, a meandering river and lakes as an editable heightfield, filled a brick at a time, with a finer refinement that streams, and a data form that rebuilds it on workers.

A shaded map of a generated region: hills, a river meandering through its valley, and lakes

@voxolith/gen-terrain makes the ground a world stands on: rolling hills from layered noise, a river that meanders along a noise contour and carves its own valley with sloping banks, widening into deep pools and pinching into shallow narrows along its course, lakes wherever the ground dips below the water level, and a surface chosen per column: grass (drier up high), rock on steep slopes, sand on the banks, gravel and mud on the beds. Water fills every flooded column with the water material the renderer animates.

It describes a region rather than a model, so it is not an EntityGenerator. It returns a heightfield and answers everything else on demand, which is what a chunked world wants:

import { generateTerrain } from "@voxolith/gen-terrain";
import { makeChunkedWorld, PaletteAllocator } from "@voxolith/engine";

const terrain = generateTerrain({ width: 1280, depth: 1280, height: 192 }, seed);
const palette = new PaletteAllocator();
const { base } = palette.allocate(terrain.roles, "terrain");

const world = makeChunkedWorld({
  target: renderer,
  size: { x: 1280, y: 192, z: 1280 },
  chunk: 128,
  seed,
  generate(ctx) {
    ctx.edit({ ...ctx.box, y1: terrain.maxY() }, (cells, ox, oy, oz) => terrain.fillBrick(cells, ox, oy, oz, base));
  },
});

See placement and worlds for chunked worlds and streaming.

Generating

generateTerrain(params?, seed?) takes overrides of DEFAULT_TERRAIN (river merges field by field) and a seed rather than an rng. Everything is a pure function of params, seed and position.

parameterdefaultmeaning
width, depth320, 320region size in voxels
height176grid height; the ground stays well below it so trees fit
baseY18mean ground level
relief11hill amplitude: ground ranges about baseY plus or minus this
featureSize110typical hill size, in voxels
detail1.5small bumps, in voxels
waterLevel13absolute Y of the water surface; columns below it flood
river.enabledtruecarve a river
river.width, river.depth, river.banks16, 5, 18channel width, depth below the water at its centre, and the width of the sloping banks either side
river.meander380distance over which the river swings from one side to the other
river.widthVariation, river.widthScale0.6, 120how much the width varies along the river, 0 (constant) to 1 (from a trickle to about twice as wide, deeper where wide), and over what distance
rockSlope3height step to a neighbour at which ground turns to rock
sandBand2voxels above the water that are sand, near water
dryAbove5ground this far above baseY turns to dry grass
colorsSUMMERthe default colour of each role

Like the entity generators, the terrain works in 10 voxels per metre.

The region

The returned Terrain:

  • heights: the ground height per column (x + z * width), sampled once. It is yours to edit: a settlement levels pads into it before the world is built, and everything below reads the edited map.
  • fillBrick(cells, ox, oy, oz, base, top?): writes one 8³ brick at (ox, oy, oz) into cells (x + y * 8 + z * 64), mapping role r to base + r - 1, and returns whether it wrote anything. top can override a column's top voxel with an absolute palette slot (a path, a yard), 0 to keep it: a function top(x, z), or the same as data, a Uint8Array of slots indexed x + z * width (TopOverride), which crosses to a worker.
  • Queries for placement rules: heightAt, waterAt, waterDepth, topRole (the role of a column's top voxel) and roleAt(x, y, z); maxY() is the highest Y anything is written at.
  • pick(origin, dir, { water?, maxT? }): the first point where a ray meets the ground, or with water the water surface, for putting a cursor or a lamp on the ground.

terrainHeight(params, seed) is the height function on its own, pure in (x, z) and exactly what generateTerrain samples before any editing, for a world that streams columns without ever sampling the whole map. It takes complete params: completeTerrainParams(params) fills the defaults exactly as generateTerrain merges them, and terrainSize(params) gives the map size it rounds to.

The roles are grass, light grass, dry grass, soil, rock, dark rock, sand, gravel, mud and water. As with entities, voxel values are role indices, so the ground restyles (a season, a biome) by palette. The WATER role carries the water material hint, so an app with water on screen renders continuously.

Settlement layout (house sites, levelled pads, paths) is app code on top of this; the examples keep theirs in examples/src/world/layout.ts, shared by the world and valley pages.

Finer terrain

refineTerrain(terrain, k, opts?) is the same terrain k times finer, answered per brick like the coarse one, so a valley of 1 cm voxels can stream. A region cannot be refined like a model (it is too big to exist at once), so it wraps the coarse terrain. The coarse heightfield stays the source of truth: levelled pads stay flat, and the river, lakes, banks and slopes stay exactly where they were. What the finer grid adds:

  • a smooth surface, bicubic (Catmull-Rom) through the coarse column tops, so terraces of 10 cm steps become slopes, with a little micro relief;
  • organic boundaries between surface roles and paths, looked up a little off each column by noise;
  • grass blades one voxel across and a few to fifteen tall, in clumps with bare patches, on grassy ground (grass, the blades per grassy column, default 0.22; bladeHeight, default 1.4k and at least 4, so blades at k = 2, 20 voxels per metre, still vary in height);
  • pebbles on beds, banks and paths;
  • only a skin of ground below the surface (skin, default 6 fine voxels), since it is seen from above: a 128 m valley at 1 cm voxels costs its surface, not its volume.

Edit a box per 8x8 brick column, sized by columnSpan(ox, oz): each column is computed once for its whole stack of bricks, about 40 ms per 256² chunk. This is how the valley example streams its ground:

import { columnBoxes } from "@voxolith/engine";
import { refineTerrain } from "@voxolith/gen-terrain";

const ground = refineTerrain(terrain, 10, { seed });

// in the chunked world's generate(ctx): one box per brick column, as tall as it needs
const boxes = columnBoxes(ctx.box, (ox, oz) => ground.columnSpan(ox, oz));
renderer.editMany(boxes, (cells, ox, oy, oz) => ground.fillBrick(cells, ox, oy, oz, base, top));

The valley example fills these boxes on workers instead (on workers, below).

On a FineTerrain, top is looked up on coarse columns (the settlement's paths and yards) and applies to the top two fine voxels, with no blades. It can be passed per call or once in the options, refineTerrain(terrain, k, { top }), which fillBrick then uses when a call passes none. heightAt, waterAt, waterTop, maxY, spanOf and pick work in fine voxels. Its GPU cost for a whole valley grows fast with k: the valley example measures about 19 MB at 20 voxels per metre, 126 MB at 50 and 560 MB at 100, which is why it defaults to 50 on a desktop and 20 on a phone.

On workers

Sampling a height map and filling bricks are pure CPU work, and at fine scales they are most of a streamed world's cost on the thread that draws: the valley's 1280 × 1280 height map takes about 1.5 s in the browser, and a 256² chunk of fine ground 20 to 40 ms in fillBrick. Everything a terrain is made of can travel as plain data, so a worker can rebuild the same terrain and fill its bricks, byte for byte what the main thread would write.

whaton the main threadon the worker
the height map, in bandsterrainSize(params), then stitch the bands into one Int16ArrayterrainHeightsBand(params, seed, z0, z1): the rows z0 <= z < z1, unedited
a coarse terrainterrainInit(terrain): complete params, seed and a copy of the heights, levelling includedgenerateTerrain(init.params, init.seed, { heights: init.heights }): adopts the heights, samples nothing
the settlement's surfacetopOverrideData(terrain, top): the top function evaluated once per column, as a Uint8Arraypass it to fillBrick as top
a fine terrainfineTerrainInit(fine, top?): the coarse terrain, k and the options, top as datafineTerrainFrom(init): the fine terrain, whose fillBrick applies the carried top
  • Heights are adopted, not copied. generateTerrain(params, seed, { heights }) (TerrainBuild) uses the array as the terrain's heights and throws a RangeError when its length does not match the params. terrainInit copies them, so the init can be transferred while the terrain stays usable.
  • Level first. An init holds the heights as they are when it is made. Level the pads and settle the paths before, and make a new one after any later edit.
  • Bands are independent. Each is exactly what generateTerrain samples there, so several workers can each sample one; heights.set(band, z0 * width) stitches them.
import { generateTerrain, terrainInit, terrainSize, terrainHeightsBand, topOverrideData } from "@voxolith/gen-terrain";

// The height map in two bands, each of which could come from a worker of its own.
const { width, depth } = terrainSize(params);
const heights = new Int16Array(width * depth);
const mid = depth >> 1;
heights.set(terrainHeightsBand(params, seed, 0, mid), 0);
heights.set(terrainHeightsBand(params, seed, mid, depth), mid * width);
const t = generateTerrain(params, seed, { heights }); // byte for byte generateTerrain(params, seed)

// Level the pads, then send the terrain and the paths as data.
level(t.heights);
const init = terrainInit(t);
const top = topOverrideData(t, paths);
worker.postMessage({ init, top }, [init.heights.buffer, top.buffer]);

// in the worker:
// const terrain = generateTerrain(init.params, init.seed, { heights: init.heights });
// terrain.fillBrick(cells, ox, oy, oz, base, top); // the same bricks as with the paths' function

For a streamed ground the engine does the moving: makeChunkedWorld({ fill }) with a makeChunkFillPool whose init is { fine: fineTerrainInit(ground), base }, and a worker file that calls serveChunks with fineTerrainFrom. The manual's ground on workers has the whole wiring. The examples sample the valley's height map in up to six bands on workers (about 0.45 s, measured) and fill its ground on up to six more, and the world page fills its coarse ground from terrainInit plus the paths as topOverrideData.

Development

bun run --cwd generators/terrain verify    # shape, river continuity, fillBrick vs roleAt, determinism, picking
bun run --cwd generators/terrain preview   # a shaded map into previews/map.png

The terrain is not covered by the contract: its checks are its own verify.

See the API reference for every type.

On this page