The terrain module renders large, detailed heightmap terrain with CDLOD
(continuous distance-dependent level of detail) and virtual-texture page
streaming. Heights come from procedural noise or an image you supply; up to
8 PBR layers are blended either procedurally (height + slope) or from
authored splatmaps (painted RGBA weight textures). A finite world can be
fully resident (a single island), or an effectively endless one can stream
pages around the camera. Authored heightmaps can keep a CPU mirror for
heightAt(x, z) queries (character grounding, foliage seating, colliders).
The public module is src/terrain/, centered on
TerrainSystem.
import {
TerrainSystem,
TerrainQuality,
loadHeightmapTexture,
DEFAULT_HEIGHT_SCALE, // 130
WORLD_EXTENT, // 8192
} from 'taos/terrain/index.js';
TerrainSystemis not aComponentorGameObject. It's a standalone GPU state manager (it owns the VT atlas, the LOD compute pipeline, and the camera/params/patch buffers). You create it, feed it a heightmap and layer textures, driveupdate(camera)each frame, and render it through three render-graph passes (below).
Creating the system#
const terrain = TerrainSystem.create(ctx, {
quality: TerrainQuality.Medium, // Low | Medium (default) | High
heightScale: 200, // world units at normalized height 1.0
worldOrigin: [-512, -512], // XZ of the rect's min corner (default centered)
worldExtent: 1024, // square world side (default 8192)
pagesPerSide: 8, // virtual pages per side (default 32 → 1024 pages)
bounded: true, // all pages resident, no streaming
// numSlots: 16, // resident atlas layers; defaults to pagesPerSide² when bounded
});TerrainSystemOptions (source) in full:
| Option | Default | Notes |
|---|---|---|
heightScale | 130 | World units at normalized height 1.0; mutable per-frame. |
quality | Medium | Mesh + VT density preset (see below). |
worldOrigin | centered | [x, z] of the world rect's min corner. |
worldExtent | 8192 | Square side. Shrink for a finite slab (island). |
pagesPerSide | 32 | VT pages tiling the world. |
numSlots | 16 (or pagesPerSide² when bounded) | Resident atlas layers. |
bounded | false | All pages resident once; requires numSlots ≥ pagesPerSide². |
Quality presets (TerrainQuality): Low (17 verts/patch, 128 texels/page),
Medium (33 / 256 — the default), High (65 / 512). Switch live with
terrain.setQuality(q) — it rebuilds the atlas and buffers and refills around
the camera over the next frames.
Height sources#
Procedural (default)#
Out of the box TerrainSystem generates heights from noise. Tune it with:
terrain.setSeed(12345); // u32 world seed
terrain.setFeatureFlags(/*erosion*/ true, /*plateaus+dunes*/ false);Image heightmap#
Load an image into an r16float texture (the R channel is read as normalized
height), then bind it. The loader denoises and blurs to kill JPG spikes.
const hm = await loadHeightmapTexture(device, '/terrain/heightmap.jpg', {
resolution: 2048, // output texture size (default 256)
blurRadius: 2, // box-blur radius in decode texels (default 2; 0 = off)
blurPasses: 2, // repeat to approximate Gaussian (default 2)
});
terrain.setHeightmap(hm.texture, {
origin: [-512, -512], // world XZ of the heightmap's (0,0) texel
extent: [1024, 1024], // world span the heightmap covers
heightScale: 200, // override the system's heightScale for this map
floorHeight: 0, // world height outside the rect (sea level)
});setHeightmap rebakes resident pages. clearHeightmap() reverts to procedural.
There's also heightmapDataToTexture(device, data, res) to upload a normalized
Float32Array height field directly.
originmust match your world geometry's coordinate space. If the terrain rect spans(-512, -512)to(512, 512), passorigin: [-512, -512]. A mismatch shifts the terrain and shows seams.
Raw 16-bit heightmaps + CPU height queries#
For authored terrain (e.g. the output of tools/terrain/stitch_terrain.mjs),
loadHeightmapRaw16 loads a little-endian uint16 field losslessly, and passing
its CPU data into setHeightmap enables height queries:
const hm = await loadHeightmapRaw16(device, '/terrain/height.r16');
terrain.setHeightmap(hm.texture, {
origin: [0, 0], extent: [4000, 4000], heightScale: 800,
data: { values: hm.data, width: hm.resolution, height: hm.resolution },
});
terrain.heightAt(x, z); // world-space height (floorHeight outside the rect)
terrain.normalAt(x, z); // central-difference surface normal
terrain.heightField; // the raw HeightField (bilinear, normalized)PBR layer textures#
Up to 8 layers (layerCount in the create options, default 4), each with
albedo + optional normal/roughness/displacement:
const terrain = TerrainSystem.create(ctx, { layerCount: 7, /* … */ });
await terrain.loadLayerTextures({
albedo: [rock02Diff, rockDiff, rockFaceDiff, snowDiff],
normal: [rock02Nor, rockNor, rockFaceNor, snowNor],
roughness: [rock02Rgh, rockRgh, rockFaceRgh, snowRgh],
displacement: [rock02Dsp, rockDsp, rockFaceDsp, snowDsp],
});
terrain.setLayerStyle(2, { repeat: 25, tint: [1, 1, 1], metallic: 0 });With no splatmap bound, weights are procedural: lower elevations get layer 0, higher get layer 3; steep slopes expose bare rock (layers 4+ stay unused).
Authored splatmaps#
setSplatmap switches blending to painted RGBA weight maps — a's channels
weight layers 0-3, b's weight layers 4-7:
const splatA = await loadSplatmapTexture(device, '/terrain/splat_a.png');
const splatB = await loadSplatmapTexture(device, '/terrain/splat_b.png');
terrain.setSplatmap(splatA, { origin: [0, 0], extent: [4000, 4000] }, splatB);
// terrain.clearSplatmap() returns to the procedural rule.Weights are renormalized per-fragment; unpainted texels fall back to layer 0. See samples/lakes_and_rivers.ts — a 4 km² 16-tile landscape imported via tools/terrain/ with 7 painted layers — for the full authored-terrain wiring.
Rendering it: passes + feature wrappers#
Terrain has three dedicated render-graph passes, exported from the engine's render graph:
TerrainLodPass— compute pass; selects which patches to draw at which LOD this frame and fills the indirect-draw args.TerrainShadowPass— appends terrain depth to each shadow cascade.TerrainGeometryPass— fills the deferred G-Buffer (depth, albedo, normal) with the splatted terrain.
To run them through Engine.addFeature(...), each is wrapped in a tiny
RenderFeature. Those wrappers are sample-local, not part of the engine —
the canonical copy is
samples/terrain/terrain_features.ts
(TerrainLodFeature, TerrainShadowFeature, TerrainGeometryFeature). Copy
that file into your project, or write equivalents; they're a few lines each and
just call pass.addToGraph(...) and thread the patch / indirect / gbuffer
handles through frame.
The per-frame terrain.update(camera, heightScale?) call (page streaming, LOD
buffer upload, indirect-arg reset) lives in TerrainLodFeature.update(), so once
the features are registered you don't call it yourself.
Wiring (from samples/bounded_heightmap.ts)#
Feature registration order matters — LOD compute first, then shadow, then geometry, then lighting:
engine.addFeature(new AtmosphereFeature());
engine.addFeature(new TerrainLodFeature(terrain, HEIGHT_SCALE)); // 1. select patches
engine.addFeature(new ShadowFeature({ light: keyLight, shadowFar: 700, /* ... */ }));
engine.addFeature(new TerrainShadowFeature(terrain)); // 2. terrain into cascades
engine.addFeature(new TerrainGeometryFeature(terrain)); // 3. terrain into G-Buffer
engine.addFeature(new AOFeature({ method: 'gtao', radius: 1.4, bias: 0.1, strength: 1.6 }));
engine.addFeature(new DayNightDeferredLightingFeature({ keyLight, sunDirection: () => sunDir, ibl }));
engine.addFeature(new TerrainTonemapFeature());Make sure engine.camera is set before the first frame — the LOD feature reads
it to stream pages and compute LOD.
Tuning and debug#
terrain.heightScale = 180; // change vertical scale live
terrain.wireframe = true; // switch to the line-list index buffer
terrain.residentCount; // resident pages (HUD)
terrain.setQuality(TerrainQuality.High); // rebuilds the atlas; one frame of progressive bakeSamples#
- samples/bounded_heightmap.ts — finite, fully-resident island driven by an image heightmap. The clearest end-to-end reference.
- samples/heightmap_terrain.ts — heightmap terrain variant.
- samples/terrain/ (
mountain_fox) — the big showcase: streaming procedural terrain, day/night, grass, rain/snow, a fox controller. Its terrain_features.ts is the feature-wrapper reference.
Gotchas#
- Heightmap
origin/extentmust match world geometry, or terrain shifts and seams appear. - Bounded worlds need
numSlots ≥ pagesPerSide²(the system throws at create time otherwise). Either raisenumSlotsor lowerpagesPerSide. - Feature order is a contract:
TerrainLodFeature→ShadowFeature→TerrainShadowFeature→TerrainGeometryFeature→ lighting. The features throw with a clear message if a dependency is missing. - Textures are bound, not copied. You own the heightmap and layer textures;
keep them alive for the terrain's lifetime (or until
clearHeightmap()/destroy()).destroy()frees the atlas/buffers but not your heightmap. setQuality()isn't cheap — it rebuilds the atlas and resets residency. Fine from a UI handler, but expect one frame of progressive rebaking.
Editor component#
The editor-addable Terrain component (data-only; rendered by the
discovery-driven features registerTerrainFeatures wires into both editor
engines) carries the same authored data as the runtime API: a heightmap ref
(.r16 or grayscale image), two splatmap refs, a layers[] list (texture
refs + repeat/tint/metallic), floorHeight, and collision +
collisionResolution — refs are project-asset guids resolved through the
host's Content pipeline. With collision on, a static physics heightfield is
built from the CPU height mirror automatically (the physics module loads on
demand). The layer list has no inspector UI yet; author it in the scene doc or
via the editor MCP tools.
See also#
- Quick-Start Guide — engine, features, and the render graph
- Heightmap Terrain — companion book — the theory
- Crafty Chapter 2 — Block / Voxel Terrain — the other terrain system (voxels)