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.
@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.
| parameter | default | meaning |
|---|---|---|
width, depth | 320, 320 | region size in voxels |
height | 176 | grid height; the ground stays well below it so trees fit |
baseY | 18 | mean ground level |
relief | 11 | hill amplitude: ground ranges about baseY plus or minus this |
featureSize | 110 | typical hill size, in voxels |
detail | 1.5 | small bumps, in voxels |
waterLevel | 13 | absolute Y of the water surface; columns below it flood |
river.enabled | true | carve a river |
river.width, river.depth, river.banks | 16, 5, 18 | channel width, depth below the water at its centre, and the width of the sloping banks either side |
river.meander | 380 | distance over which the river swings from one side to the other |
river.widthVariation, river.widthScale | 0.6, 120 | how 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 |
rockSlope | 3 | height step to a neighbour at which ground turns to rock |
sandBand | 2 | voxels above the water that are sand, near water |
dryAbove | 5 | ground this far above baseY turns to dry grass |
colors | SUMMER | the 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)intocells(x + y * 8 + z * 64), mapping role r tobase + r - 1, and returns whether it wrote anything.topcan override a column's top voxel with an absolute palette slot (a path, a yard), 0 to keep it: a functiontop(x, z), or the same as data, aUint8Arrayof slots indexedx + z * width(TopOverride), which crosses to a worker.- Queries for placement rules:
heightAt,waterAt,waterDepth,topRole(the role of a column's top voxel) androleAt(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 withwaterthe 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.
| what | on the main thread | on the worker |
|---|---|---|
| the height map, in bands | terrainSize(params), then stitch the bands into one Int16Array | terrainHeightsBand(params, seed, z0, z1): the rows z0 <= z < z1, unedited |
| a coarse terrain | terrainInit(terrain): complete params, seed and a copy of the heights, levelling included | generateTerrain(init.params, init.seed, { heights: init.heights }): adopts the heights, samples nothing |
| the settlement's surface | topOverrideData(terrain, top): the top function evaluated once per column, as a Uint8Array | pass it to fillBrick as top |
| a fine terrain | fineTerrainInit(fine, top?): the coarse terrain, k and the options, top as data | fineTerrainFrom(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'sheightsand throws aRangeErrorwhen its length does not match the params.terrainInitcopies 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
generateTerrainsamples 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' functionFor 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.pngThe terrain is not covered by the contract: its checks are its
own verify.
See the API reference for every type.