Taos Engine · Modules

Modules

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; 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.

TypeScript
import {
  TerrainSystem,
  TerrainQuality,
  loadHeightmapTexture,
  DEFAULT_HEIGHT_SCALE,   // 130
  WORLD_EXTENT,           // 8192
} from 'taos/terrain/index.js';

TerrainSystem is not a Component or GameObject. 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, drive update(camera) each frame, and render it through three render-graph passes (below).


Creating the system#

TypeScript
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:

OptionDefaultNotes
heightScale130World units at normalized height 1.0; mutable per-frame.
qualityMediumMesh + VT density preset (see below).
worldOrigincentered[x, z] of the world rect's min corner.
worldExtent8192Square side. Shrink for a finite slab (island).
pagesPerSide32VT pages tiling the world.
numSlots16 (or pagesPerSide² when bounded)Resident atlas layers.
boundedfalseAll 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:

TypeScript
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.

TypeScript
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.

origin must match your world geometry's coordinate space. If the terrain rect spans (-512, -512) to (512, 512), pass origin: [-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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

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:

TypeScript
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#

TypeScript
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#


Gotchas#

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#