Terrain (Heightmap)
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; PBR layer textures (rock, snow, …) are splatted by height and slope. A finite world can be fully resident (a single island), or an effectively endless one can stream pages around the camera.
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.
PBR layer textures#
Four layers, each with albedo + optional normal/roughness/displacement, splatted by height and slope:
await terrain.loadLayerTextures({
albedo: [rock02Diff, rockDiff, rockFaceDiff, snowDiff],
normal: [rock02Nor, rockNor, rockFaceNor, snowNor],
roughness: [rock02Rgh, rockRgh, rockFaceRgh, snowRgh],
displacement: [rock02Dsp, rockDsp, rockFaceDsp, snowDsp],
});
Lower elevations get layer 0, higher get layer 3; steep slopes expose bare rock.
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 bake
Samples#
- 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.
See also#
- Quick-Start Guide — engine, features, and the render graph
- Chapter 24 — Heightmap Terrain — the theory
- Chapter 17 — Block / Voxel Terrain — the other terrain system (voxels)