Taos Engine ▦ Taos: API Documentation

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

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#

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.

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.

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#


Gotchas#

  • Heightmap origin/extent must match world geometry, or terrain shifts and seams appear.
  • Bounded worlds need numSlots ≥ pagesPerSide² (the system throws at create time otherwise). Either raise numSlots or lower pagesPerSide.
  • Feature order is a contract: TerrainLodFeatureShadowFeatureTerrainShadowFeatureTerrainGeometryFeature → 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#