Terrain

Terrain is a landscape ground: a heightmap grid drawn in chunks with up to four blended texture layers (grass, rock, sand…), editable at runtime, with a heightfield collider to walk and drive on. For a flat floor or a level mesh use a Mesh or a model with a mesh collider (physics.md); grass and trees on the ground are foliage.

  • make a landscape from a heightmap or a height function
  • texture the ground: grass on flat ground, rock on steep slopes
  • get the ground height under a point to place a tree or a character
  • raise, lower, flatten or dig the ground, or cut holes in it, at runtime
  • make the ground follow a road or a building's foundation
  • make the player walk and collide on the terrain
  • save and load a terrain file, or put a terrain in a scene file

Platforms: iOS, Android, desktop, the web, the web player, the le.codes viewer app, headless tests. On the web (Web Lite) the chunks are plain meshes (normal maps read flat, no baked light); the heightfield collider needs the physics engine — check Physics.supported.

At a glance

TypeScript
const scene = new Scene()
scene.add(Light.sun())
const t = Terrain.create({
  size: 257, cellSize: 2,                                      // 512 m square, samples 2 m apart
  heights: (x, z) => 20 * Math.sin(x / 60) * Math.cos(z / 80),   // metres, LOCAL x/z
  layers: [
    { albedo: asset('./assets/terrain/grass.ktx2'), tiling: 6, normalScale: 1 },
    { albedo: asset('./assets/terrain/rock.ktx2'), tiling: 4, normalScale: 1, triplanar: true },
  ],
  normals: [asset('./assets/terrain/normals01.ktx2')],
})
t.paintAuto({ base: 0, slope: { layer: 1, from: 30, to: 45 } })  // rock on the cliffs
t.commit()
t.node.aspect(Shape, { heightfield: true }).aspect(Physics, { motion: 'static' })
scene.add(t.node)
scene.open()
t.ready.then(() => console.log(`ground at the centre: ${t.heightAt(256, 256).toFixed(2)} m`))

Create a terrain from heights — Terrain.create / Terrain.load

One Terrain per node (terrain.node). A level's ground is usually a terrain: block in a scene file, produced by the asset pipeline (below), then queried and edited from code.

TypeScript
Terrain.create(opts?: TerrainOptions): Terrain                 // synchronous; `ready` = textures loaded
Terrain.load(url, opts?): Promise<Terrain>                     // a `.terrain` file (heights / holes / control / layers)
Terrain.of(node): Terrain | null                               // the Terrain a node carries (a scene-file terrain node)

type TerrainOptions = {
  size?: number                 // samples per side, default 129 (257 / 513 / 1025 are the usual sizes)
  sizeX?, sizeZ?: number
  cellSize?: number             // metres between samples, default 1
  chunk?: number                // cells per chunk side, ≤ 255, default 64
  heights?: Float32Array | ((x: number, z: number) => number)   // metres; row-major z rows, or f(local x, z)
  holes?: Uint8Array            // a byte per sample, 1 = hole
  control?: Uint8Array          // RGBA layer weights per sample (normalised in the shader); default: all layer 0
  layers?: TerrainLayer[]       // ≤ 4
  normals?: [pack01?, pack23?]  // the two normal-map PACKS (below)
  tint?: ColorInput             // multiplier on every layer's albedo
  material?: Material           // a custom terrain shader with the same uniforms (default Material.terrain())
  name?: string
}

The terrain's origin is its sample (0, 0); local x/z run along the grid in metres (extent = [(sizeX − 1) · cellSize, (sizeZ − 1) · cellSize]), heights on +Y. Place it by moving and rotating terrain.node (keep its scale 1) — every query below has a local and a world form. t.ready resolves once every layer texture has loaded (the terrain draws white until then; a texture that fails is warned about and skipped). Terrain.load takes the same options minus the grid (size, heights, holes, control come from the file); textures are not in the file, so pass layers / normals — without layers the file's layer scalars apply.

Note

heights, holes, control are the live arrays. Editing them directly is allowed, but nothing reaches the engine until commit() (the edit methods track the dirty rectangle for you).

Ground textures: grass, rock, sand layers — the splat material

TypeScript
type TerrainLayer = {
  albedo?: string | Texture     // sRGB; unset = white
  tiling?: number               // metres per texture repeat, default 8
  roughness?: number            // a FACTOR on the albedo's alpha (its roughness map), default 1
  normalScale?: number          // 0 = the layer's slot of the normal pack is not read (default); 1 with a normal map
  triplanar?: boolean           // sample in world space (cliff rock: no stretching on slopes); its normal map is skipped
}

The material blends the four layers by the control map's RGBA weights (one texel per height sample). There is no metallic: ground is a dielectric.

Roughness is per pixel and lives in the albedo's alpha. A layer's roughness at a texel is roughness × albedo.a. An opaque albedo reads alpha 1, so the factor alone stands; an albedo whose alpha carries a roughness map makes a wet patch or a trodden path shine inside one layer. That is the one free channel the sampler budget leaves (nine samplers at feature level 1), so there is no separate roughness texture — lecodes assets terrain-pack --roughness (or a Unity *_AlbedoSmoothness image, inverted) packs the map into the alpha for you.

Note

a roughness of 0 is a mirror. When a layer looks like wet glass in the sun, check the factor first: it multiplies whatever the alpha holds, and an authoring tool that means "smoothness 1" has written the opposite of what this field means.

Normal maps ship packed two per texture — normals[0] holds layer 0's XY in RG and layer 1's in BA, normals[1] likewise for layers 2 and 3; Z is reconstructed. An unset pack decodes to a flat normal, and a layer with normalScale 0 never reads it. lecodes assets terrain-pack writes normals01.ktx2 / normals23.ktx2 next to the layer albedos.

TypeScript
t.layers                        // the live TerrainLayer[]; edit, then
t.applyLayers(): Promise<void>  // re-bind the textures / factors
t.material                      // the splat Material (`Material.terrain()`), for `set()`

Ground height, normal and surface under a point — heightAt / raycast / weightsAt

TypeScript
t.heightAt(x, z): number                       // LOCAL metres, on the drawn triangles; NaN off the grid / in a hole
t.worldHeightAt(x, z): number                  // WORLD x/z → world y; NaN likewise
t.normalAt(x, z): Vec3                         // the drawn triangle's normal (local)
t.sampleNormal(ix, iz): Vec3                   // the smooth normal at a sample (indices)
t.weightsAt(x, z): [r, g, b, a]                // the four layer weights, normalised (sum 1)
t.layerAt(x, z): number                        // the dominant layer index
t.raycast(origin, dir, maxDistance?): TerrainHit | null        // local
t.raycastWorld(origin, dir, maxDistance?): TerrainHit | null   // world;  { point, normal, distance }
t.toLocal(p) / t.toWorld(p): Vec3
t.extent: [width, depth]                       // metres
t.heightRange: [min, max]

The queries run in JS on the same triangle split every host draws and collides, so a game reads the ground identically everywhere. Holes are respected: a ray through a hole misses and heightAt there is NaN — nothing is drawn or collided. weightsAt reads the control map bilinearly ([1, 0, 0, 0] where it is empty, the edge off the grid): what the ground is under a point, for footprints in sand and none on cobble, or a footstep sound per layer.

TypeScript
// stand a tree on the ground at a world x/z
const plant = (t: Terrain, tree: Node, x: number, z: number) => {
  const y = t.worldHeightAt(x, z)
  if (Number.isNaN(y)) return            // off the grid or in a hole
  tree.position = new Vec3(x, y, z)
}

Sculpt the ground: raise, lower, flatten, holes — sculpt / commit()

Every edit method marks a dirty rectangle; commit() pushes the batch — chunk meshes, the control texture and, when the node carries a heightfield Shape, the collider — once.

TypeScript
t.setHeights(x0, z0, w, h, src): this          // a block of samples (sample indices)
t.shape(region, (h, w, x, z) => number, falloff?): this   // h = current height, w = 0…1 weight inside the region
t.flatten(region, height, { falloff? }): this
t.raise(region, delta, { falloff? }): this
t.sculpt({ x, z, radius, mode?, amount?, hardness?, target? }): this   // one brush dab: raise | lower | smooth | flatten
t.hole(region, on = true): this                // no triangles, no collision, no navmesh there
t.commit(): this

type TerrainRegion =                           // LOCAL metres
  | { circle: { x, z, r } }
  | { rect: { x, z, w, h } }
  | { polygon: [x, z][] }
  | { node: Node }                             // the XZ footprint of a node's triangles
TypeScript
// a crater where a shell lands: dig the ground, scorch it with layer 2, update meshes + collider
const crater = (t: Terrain, hit: Vec3) => {
  const p = t.toLocal(hit)
  t.sculpt({ x: p.x, z: p.z, radius: 4, mode: 'lower', amount: 1.5 })
    .paintLayer({ circle: { x: p.x, z: p.z, r: 5 } }, 2, 0.8)
    .commit()
}

Roads and foundations: the ground follows a mesh — conform / conformPath

The ground hugs another node, a geometry or a path:

TypeScript
t.conform(target: Node | Geometry | Float32Array, { offset?, falloff?, mode? }): this
t.conformPath(points: Vec3[], { width, uvScale?, offset?, falloff?, mode? }): this
Terrain.ribbon(points: Vec3[], { width, uvScale? }): Geometry     // the road mesh itself, to draw

offset (default 0.05 m) keeps the bilinear ground strictly under a curved road between samples; falloff (default 4 m; 0 = hard edge) blends the surrounding ground back; mode both cuts and fills, lower only cuts, raise only fills.

TypeScript
// a road over the hills: the points take the ground's height, the ground is cut and filled under it
const buildRoad = (t: Terrain) => {
  const path = [new Vec3(20, 0, 20), new Vec3(80, 0, 60), new Vec3(160, 0, 70)]   // terrain-local metres
  for (const p of path) p.y = t.heightAt(p.x, p.z) + 0.1
  t.conformPath(path, { width: 6, falloff: 6 }).commit()
  const road = Mesh.from(Terrain.ribbon(path, { width: 6 }), { material: Material.lit({ color: '#555' }) })
  t.node.add(road)   // the ribbon is in the terrain's local space
}

Paint layers onto the ground — paint / paintLayer / paintAuto

The control map holds each sample's layer weights:

TypeScript
t.paint(x0, z0, w, h, rgba): this              // a block of RGBA weights (sample indices)
t.paintLayer(region, layer, strength = 1, falloff = 2): this
t.paintAuto({ base?, slope?: { layer, from, to }, height?: { layer, from, to }, low?: { layer, from, to } }): this

paintAuto is the CPU pass a procedural level starts from: slope in degrees (rock on cliffs), height in metres (a snow line), low below a height (valley floors).

Undo terrain edits — snapshot / restore

TypeScript
t.snapshot(): TerrainSnapshot                  // heights / holes / control, copied
t.restore(snapshot): this

Walk and collide on the terrain, shadows, baked light — Shape { heightfield }

TypeScript
t.castShadows = true                           // default true
t.receiveShadows = true                        // default true
t.node.aspect(Shape, { heightfield: true }).aspect(Physics, { motion: 'static' })   // Jolt HeightField (see physics.md)

The collider is updated for the dirty rectangle on every commit() (rebuilt whole when the heights leave the range it was built for). Under a lightmap a scene-file terrain is the natural receiver: a static terrain: node is built with the baked splat material (Material.terrainLightmap()) and takes one rect of the atlas on its UV1; plants take their baked light from it (foliage.md).

Host-gated

a host without native terrain draws the same chunks as child meshes (a warning says so; normal maps read flat there), and one that cannot upload a control map shows every layer equally (warned once). Queries, edits and the .terrain file work the same everywhere.

Save and load a terrain: the .terrain file and the scene block {#the-terrain-file-and-the-scene-block}

TypeScript
t.serialize(): ArrayBuffer                     // heights (16-bit over their range) / holes / control / layer scalars
Terrain.parse(buf): { sizeX, sizeZ, cellSize, chunk, heights, holes, control, layers }

In a scene file a node is a terrain by its terrain: block — from a file or a fresh grid, with the layers, stamps and holes applied at load, after every node of the file exists:

TypeScript
ground: {
  terrain: {
    data: asset('./assets/map/terrain/map.terrain'),
    layers: [{ albedo: asset('./assets/map/terrain/soil.ktx2'), tiling: 2, roughness: 1, normalScale: 2 }, /* … */],
    normals: [asset('./assets/map/terrain/normals01.ktx2'), asset('./assets/map/terrain/normals23.ktx2')],
    stamps: [{ node: 'road', offset: 0.05, falloff: 6 }],     // Terrain.conform against a node of this file
    holes: [{ circle: { x: 120, z: 80, r: 4 } }],
  },
  aspects: [use(Shape, { heightfield: true }), use(Physics, { motion: 'static' })],
}

Import a terrain: lecodes assets terrain-pack, Unity terrains

  • lecodes assets terrain-pack <layer|dir> … — up to four layers (an albedo, its normal guessed from the name or --normals, an optional --roughness map or a Unity *_AlbedoSmoothness alpha) → <name>.ktx2 per layer with the roughness in the alpha + normals01/23.ktx2, and prints the layers fragment.
  • lecodes assets scene <map.unity> --project … — a Unity terrain arrives as .terrain + packed layers (its splat layers merged down to four by coverage). An HDRP layer keeps its smoothness in the mask map's alpha with m_Smoothness as a multiplier on it; the importer packs 1 − smoothness into the albedo alpha and sets the factor to 1. A layer without a mask map takes 1 − m_Smoothness as its factor.

Pitfalls

  • A layer's roughness is a factor, not the value: roughness: 0 ➜ roughness: 1 with the map in the albedo's alpha (or the intended constant when the albedo is opaque).
  • Edits without commit() change the arrays only ➜ t.raise(…).flatten(…).commit().
  • heights is row-major with z rows: heights[z * sizeX + x].

See also

  • scene-files.md — the terrain: block among the other node sources
  • physics.md — Shape { heightfield }, raycasts against the ground
  • lightmap.md — the terrain as the bake's receiver
  • foliage.md — plants take the ground's baked light under them
  • material.md — Material.terrain() / terrainLightmap()