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
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.
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.
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
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.
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.
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
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.
// 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.
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// 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:
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 drawoffset (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.
// 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:
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 } }): thispaintAuto 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
t.snapshot(): TerrainSnapshot // heights / holes / control, copied
t.restore(snapshot): thisWalk and collide on the terrain, shadows, baked light — Shape { heightfield }
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).
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}
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:
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--roughnessmap or a Unity*_AlbedoSmoothnessalpha) →<name>.ktx2per layer with the roughness in the alpha +normals01/23.ktx2, and prints thelayersfragment.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 withm_Smoothnessas a multiplier on it; the importer packs1 − smoothnessinto the albedo alpha and sets the factor to 1. A layer without a mask map takes1 − m_Smoothnessas its factor.
Pitfalls
- A layer's
roughnessis a factor, not the value:roughness: 0➜roughness: 1with 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(). heightsis 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()