Heightmap terrain
- Keyboard
- Mouse
- Touch
- Gamepad
`island.terrain.json` names one `.r16` file — raw little-endian `uint16`, no header — and four ground layers, and one RGBA image says which layer wins where: its red, green, blue and alpha channels are the weights of sand, grass, rock and snow. Sixteen bits is not a detail; eight would put a 31 cm stair on every slope of an 80 m range. The ground is sixty-four chunks, each built at four levels with a downward skirt on every edge, and `TerrainLodSystem` picks one level per chunk in `PreRender` and then tests each chunk's box against the camera's frustum, because Babylon Lite does not cull plain meshes. All four layers are blended by one PBR material carrying a generated surface shader, so the ground keeps the engine's own lighting, fog and tone mapping.

WebGPU: checking…See browser support
Try this
- Turn LOD markers on and fly forward: each marker changes colour as its chunk drops a level.
- Pull LOD bias down to 0.25. The chunk count is unchanged and the draw calls are unchanged — the triangles are not.
- Turn frustum culling off and watch Chunks drawn jump to all sixty-four.
Show source code
Source
import { Camera, Environment, Light, MeshAsset, MeshRenderer, pbrMaterialDefinition, createMaterialAsset, Terrain, terrain,} from "ignifx";import { bootExample } from "../_kit/boot.ts";import { readout, slider, toggle } from "../_kit/panel.ts";import { attachFly } from "./fly.ts";import { createLodOverlay } from "./lod-overlay.ts";import type { AssetHandle, TerrainAsset } from "ignifx";/** * A 512-metre island from a 16-bit heightmap, four blended ground layers, and the level-of-detail * machinery that makes it affordable. * * ## What the document carries * * `island.terrain.json` names one `.r16` file — raw little-endian `uint16`, no header — and four * layers, and `island_splat.png` says which layer wins where: one RGBA image whose red, green, * blue and alpha channels are the weights of sand, grass, rock and snow. Sixteen bits is not a * detail: eight would put a 31 cm stair on every slope of an 80 m range, which no lighting hides. * * ## What the engine does with it * * The ground is `chunksPerSide²` chunks, each built at four levels of detail with a downward skirt * on every edge, and exactly one level of each is visible. `TerrainLodSystem` runs once in * `PreRender`: it picks each chunk's level from the camera's distance to the chunk's world box — * with a ten per cent hysteresis band, so a camera parked on a threshold does not flip every frame * — and then tests that box against the camera's six frustum planes, because Babylon Lite does not * cull plain meshes and a terrain would otherwise draw the half behind you. * * All four layers are blended by **one** PBR material carrying the generated `terrainSplat` surface * shader, so the ground keeps the engine's own lighting, fog and tone mapping. That is also why * there is no wireframe switch here: every chunk shares that one material. `lodOf(chunkX, chunkZ)` * is the public answer, and `lod-overlay.ts` draws it as a marker over each chunk. *//** The address of the island document; every file it needs is named relative to it. */const ISLAND_ADDRESS = "terrain/island.terrain.json";/** The sky the island stands against. */const SKY = { r: 0.55, g: 0.68, b: 0.82, a: 1 } as const;/** The fog colour: brighter than {@link SKY}, because fog is composited before tone mapping. */const HAZE = { r: 0.6, g: 0.72, b: 0.86, a: 1 } as const;/** Where the camera opens, in metres: south-west of the island, above the water. */const SHOT = { x: -170, y: 66, z: -212, yaw: 39, pitch: -13 } as const;/** Where the water plane sits, in metres. The document's heights run from `0` to `80`. */const SEA_LEVEL = 3;/** * Writes a metre count. * * @param value - The value, in metres. * @returns The text for the value cell. */function metres(value: number): string { return `${value.toFixed(0)} m`;}bootExample({ title: "Terrain", // `terrain()` declares the `materialPlugins` rendering feature the splat surface shader needs. extensions: [terrain()], settings: { rendering: { clearColor: SKY, msaaSamples: 4, features: { shadows: false } }, time: { fixedDeltaTime: 1 / 60 }, }, async setup({ app, panel }) { // Awaited before `app.start()`: the document, the `.r16`, the four layer images and the control // map all arrive together, and a load finished before the loop runs settles at once. const island: AssetHandle<TerrainAsset> = await app.assets.loadAsync<TerrainAsset>(ISLAND_ADDRESS); const sun = app.world.createEntity("Sun"); sun.transform.lookAt({ x: 0.55, y: -0.72, z: 0.42 }); sun.addComponent(Light, { type: "directional", intensity: 3.4, color: { r: 1, g: 0.97, b: 0.9, a: 1 } }); app.world.createEntity("Sky light").addComponent(Light, { type: "hemispheric", intensity: 0.85, color: SKY, groundColor: { r: 0.3, g: 0.29, b: 0.25, a: 1 }, }); const sky = app.world.createEntity("Environment").addComponent(Environment, { clearColor: SKY }); sky.imageProcessing.toneMapping = "aces"; // Linear fog over the last third of the view, which is what hides a chunk changing level far // away and what stops the island ending at a hard edge against the sky. sky.fog.mode = "linear"; // Brighter than the clear colour: the fog colour is composited before tone mapping, so a haze // that matches the sky on paper reads darker than it on screen. sky.fog.color = HAZE; sky.fog.start = 450; sky.fog.end = 1700; const ground = app.world.createEntity("Island").addComponent(Terrain, { definition: island }); const water = app.world.createEntity("Sea", { position: { x: 0, y: SEA_LEVEL, z: 0 } }); water.addComponent(MeshRenderer, { mesh: MeshAsset.ground(app, { width: 1600, height: 1600 }), materials: [ createMaterialAsset( app, pbrMaterialDefinition({ name: "terrain/sea", baseColor: { r: 0.09, g: 0.2, b: 0.3, a: 1 }, roughness: 0.22, metallic: 0, }), [], ), ], castShadows: false, }); const eye = app.world.createEntity("Main Camera", { position: { x: SHOT.x, y: SHOT.y, z: SHOT.z } }); eye.addComponent(Camera, { near: 1, far: 1600, fov: 58 }); const fly = attachFly(app, eye, { yaw: SHOT.yaw, pitch: SHOT.pitch }); fly.ground = ground; const middleChunk = Math.floor(island.value.definition.chunksPerSide / 2); // Built before `app.start()`: an `InstancedMeshRenderer`'s capacity sizes a buffer Babylon Lite // fixes when the scene is registered. const overlay = createLodOverlay(app, ground, island.value); panel({ title: "Terrain", groups: [ { label: "Level of detail", controls: [ toggle("LOD markers", { value: false, change: (on: boolean): void => { overlay.setVisible(on); }, }), slider( "LOD bias", { min: 0.25, max: 4, step: 0.05, format: (value): string => `${value.toFixed(2)}x` }, { value: ground.lodBias, change: (value: number): void => { ground.lodBias = value; }, }, ), toggle("Frustum culling", { value: ground.frustumCulling, change: (on: boolean): void => { ground.frustumCulling = on; }, }), ], }, { label: "What it costs", controls: [ readout("Chunks drawn", (): string => `${String(ground.visibleChunks)} of ${String(ground.chunkCount)}`), // `Terrain.drawCalls` and not `app.renderer.drawCalls`: a chunk is a renderable and not // a component, and Lite's own counter includes bindings the terrain never issued. readout("Terrain draws", (): string => String(ground.drawCalls)), readout("Middle chunk LOD", (): string => String(ground.lodOf(middleChunk, middleChunk))), ], }, { label: "Camera", collapsed: true, controls: [ slider( "Speed", { min: 6, max: 140, step: 2, format: (value): string => `${value.toFixed(0)} m/s` }, { value: fly.speed, change: (value: number): void => { fly.speed = value; }, }, ), readout("Altitude", (): string => metres(eye.transform.position.y)), readout("Ground below", (): string => metres(ground.heightAt(eye.transform.position.x, eye.transform.position.z)), ), ], }, ], }); },});/** * The fly camera the island is toured with: one action map and one `Script`, on the same * `@ignifx/input` actions a game would use rather than DOM listeners. * * It keeps itself above the ground by asking the terrain — `heightAt` is a bilinear read of the * height field and needs no collider, no physics extension and no raycast — and it clamps itself to * the field's own extent, so the camera cannot wander off into empty space. */import { clamp, defineInputActions, degToRad, f32, Script, Vec3 } from "ignifx";import type { App, ComponentInit, Entity, InputAction, InputActionsDefinition, MutableVec3, ScriptCallbacks, Terrain,} from "ignifx";/** The action map the fly camera reads. Its own map, so an example's gameplay map is untouched. */export const FLY_ACTION_MAP = "TerrainFly";/** The actions the fly camera binds, as a document `app.input.loadActions` takes. */export const FLY_ACTIONS: InputActionsDefinition = defineInputActions({ maps: [ { name: FLY_ACTION_MAP, actions: [ { name: "flyMove", type: "vector2", bindings: [ { composite: "2DVector", up: "<Keyboard>/w", down: "<Keyboard>/s", left: "<Keyboard>/a", right: "<Keyboard>/d", }, { composite: "2DVector", up: "<Keyboard>/arrowUp", down: "<Keyboard>/arrowDown", left: "<Keyboard>/arrowLeft", right: "<Keyboard>/arrowRight", }, { path: "<Gamepad>/leftStick", processors: ["deadzone(0.2)"] }, { path: "<Gamepad>/dpad" }, { path: "<Virtual>/joystick", processors: ["deadzone(0.15)"] }, ], }, { name: "flyDrag", bindings: [{ path: "<Pointer>/press" }] }, { name: "flyLook", type: "vector2", bindings: [{ path: "<Pointer>/delta" }] }, { name: "flyStick", type: "vector2", bindings: [{ path: "<Gamepad>/rightStick", processors: ["deadzone(0.2)"] }], }, { name: "flyRise", bindings: [{ path: "<Keyboard>/e" }, { path: "<Gamepad>/buttonSouth" }] }, { name: "flyDive", bindings: [{ path: "<Keyboard>/q" }, { path: "<Gamepad>/buttonEast" }] }, { name: "flyBoost", bindings: [{ path: "<Keyboard>/shiftLeft" }, { path: "<Gamepad>/leftStickPress" }] }, ], }, ],});/** * Flies its entity over a terrain. * * @example * ```ts * const camera = attachFly(app, eye, { yaw: 40, pitch: -14 }); * camera.ground = island; * ``` */export class FlyCamera extends Script.define({ yaw: f32(0, { tooltip: "Heading about world Y, in degrees." }), pitch: f32(-12, { min: -89, max: 89, tooltip: "Elevation above the horizon, in degrees." }), speed: f32(34, { min: 1, tooltip: "Travel speed, in metres per second." }), boost: f32(3.5, { min: 1, tooltip: "How much faster Shift is." }), clearance: f32(6, { min: 0, tooltip: "The least distance kept above the ground, in metres." }), lookDegreesPerScreen: f32(200, { min: 1, tooltip: "Degrees per drag across the whole canvas." }), stickDegreesPerSecond: f32(120, { min: 0 }), minPitch: f32(-85, { min: -89, max: 89 }), maxPitch: f32(70, { min: -89, max: 89 }), }) implements ScriptCallbacks{ /** The namespaced registration id. */ static typeId = "terrain-example/FlyCamera"; /** * The ground the camera stays above, or `null` to fly freely. * * @remarks * Assigned in code rather than declared as a schema field, because an example's terrain is built * in `main.ts` and there is no scene file here for a reference to live in. */ ground: Terrain | null = null; /** Where the camera looks, rebuilt each frame; a field so no frame allocates (standards §7). */ readonly #target = new Vec3(); /** * Reads the frame's input, moves, and points the camera. * * @remarks * Travel runs on the scaled delta, so `?static=1` — which stops the clock before `app.start()` — * freezes the shot at the authored pose. The look runs on the unscaled delta, matching the kit's * orbit camera, so a paused example can still be looked around. * * @param dt - Seconds since the previous frame, scaled by `time.timeScale`. */ update(dt: number): void { this.#look(this.app.time.unscaledDeltaTime); this.pitch = clamp(this.pitch, this.minPitch, this.maxPitch); const yaw = degToRad(this.yaw); const pitch = degToRad(this.pitch); const cosPitch = Math.cos(pitch); // ignifx is left-handed with +Z forward, so a yaw of zero looks down +Z. const forwardX = Math.sin(yaw) * cosPitch; const forwardY = Math.sin(pitch); const forwardZ = Math.cos(yaw) * cosPitch; const move = this.#action("flyMove"); const rise = (this.#pressed("flyRise") ? 1 : 0) - (this.#pressed("flyDive") ? 1 : 0); const step = this.speed * (this.#pressed("flyBoost") ? this.boost : 1) * dt; const forward = move?.vector.y ?? 0; const strafe = move?.vector.x ?? 0; const position = this.transform.localPosition; position.set( position.x + (forwardX * forward + Math.cos(yaw) * strafe) * step, position.y + (forwardY * forward + rise) * step, position.z + (forwardZ * forward - Math.sin(yaw) * strafe) * step, ); this.#keepAboveGround(position); this.#target.set(position.x + forwardX, position.y + forwardY, position.z + forwardZ); this.transform.lookAt(this.#target); } /** * Turns a drag and the right stick into yaw and pitch. * * @param unscaled - The unscaled frame delta, in seconds. */ #look(unscaled: number): void { const stick = this.#action("flyStick"); if (stick !== null) { const rate = this.stickDegreesPerSecond * unscaled; this.yaw += stick.vector.x * rate; this.pitch += stick.vector.y * rate; } const delta = this.#action("flyLook"); if (delta === null || !this.#pressed("flyDrag")) { return; } // `<Pointer>/delta` is in CSS pixels, so the divisor is the canvas's CSS height and a drag // turns the camera by the same amount at any device pixel ratio (`08-input.md` §5). const perPixel = this.lookDegreesPerScreen / this.#screenHeight; this.yaw += delta.vector.x * perPixel; this.pitch -= delta.vector.y * perPixel; } /** * Holds the camera inside the terrain's extent and above its surface. * * @param position - The camera's local position, written in place. */ #keepAboveGround(position: MutableVec3): void { const ground = this.ground; if (ground === null || !ground.isLoaded) { return; } const size = ground.size; const origin = ground.transform.position; const halfWidth = size.width / 2; const halfDepth = size.depth / 2; const x = clamp(position.x, origin.x - halfWidth, origin.x + halfWidth); const z = clamp(position.z, origin.z - halfDepth, origin.z + halfDepth); position.set(x, Math.max(position.y, ground.heightAt(x, z) + this.clearance), z); } /** * One action of the camera's own map. * * @param name - The action name. * @returns The action, or `null` when the map is not loaded. */ #action(name: string): InputAction | null { return this.app.input.actions.find(name); } /** * Whether a button action is held this frame. * * @param name - The action name. * @returns `true` while it is pressed. */ #pressed(name: string): boolean { return this.#action(name)?.isPressed ?? false; } /** * The canvas's height in CSS pixels, which the drag delta is normalised by. * * @returns The height, or `1` when there is no surface, so a division is always safe. */ get #screenHeight(): number { const surface = this.app.renderer.surface; if (surface === null) { return 1; } const layoutHeight = "clientHeight" in surface ? surface.clientHeight : 0; return layoutHeight > 0 ? layoutHeight : Math.max(1, surface.height); }}/** * Registers the fly camera, loads its actions, and attaches it to an entity. * * @param app - The running app; needs the `input()` extension. * @param entity - The camera's entity. * @param init - Field overrides, usually `yaw`, `pitch` and `speed`. * @returns The attached component. */export function attachFly(app: App, entity: Entity, init?: ComponentInit<FlyCamera>): FlyCamera { app.registerComponents([FlyCamera]); app.input.loadActions(FLY_ACTIONS); return entity.addComponent(FlyCamera, init);}/** * The level-of-detail readout you can see: one marker hovering over each chunk, coloured by the * level that chunk is currently drawing. * * A terrain draws through **one** PBR material shared by every chunk, so there is no per-chunk * colour to tint and no wireframe switch to flip — `Terrain.lodOf(chunkX, chunkZ)` is the whole of * the public answer. Markers are the honest way to show it: one `InstancedMeshRenderer` per level, * four draw calls, and the matrices rewritten only when a chunk changes level. */import { createMaterialAsset, InstancedMeshRenderer, MeshAsset, pbrMaterialDefinition, Script } from "ignifx";import type { App, ColorLike, ScriptCallbacks, Terrain, TerrainAsset } from "ignifx";/** One colour per level, coarsening from green to red. A terrain may declare at most this many. */const LEVEL_COLORS: readonly ColorLike[] = [ { r: 0.29, g: 0.78, b: 0.42, a: 1 }, { r: 0.95, g: 0.83, b: 0.28, a: 1 }, { r: 0.96, g: 0.55, b: 0.19, a: 1 }, { r: 0.9, g: 0.31, b: 0.31, a: 1 }, { r: 0.72, g: 0.36, b: 0.85, a: 1 }, { r: 0.4, g: 0.62, b: 0.95, a: 1 },];/** Floats in one column-major 4x4 matrix. */const MATRIX_FLOATS = 16;/** The marker cube's edge, in metres. Big enough to read from the far side of a 512 m island. */const MARKER_SIZE = 7;/** How far above the ground under a chunk's centre a marker floats, in metres. */const MARKER_LIFT = 16;/** The markers over one terrain. */export interface LodOverlay { /** * Shows or hides the markers. * * @param visible - Whether to draw them. */ setVisible(visible: boolean): void; /** Re-reads every chunk's level and moves the markers. Cheap: one pass over the chunk grid. */ refresh(): void;}/** * Moves the markers every frame, because `TerrainLodSystem` may have changed a level. * * @remarks * A `Script` rather than a system: a system registry is an extension's business, and an example * that needs one frame callback writes the component every ignifx game already writes. */class LodMarkerRefresh extends Script implements ScriptCallbacks { /** The namespaced registration id. */ static typeId = "terrain-example/LodMarkerRefresh"; /** What to refresh. Assigned in code: an overlay is a closure, not something a scene can carry. */ overlay: LodOverlay | null = null; /** Re-reads the levels. Cheap enough to do unconditionally; the overlay skips itself when hidden. */ update(): void { this.overlay?.refresh(); }}/** * Builds the markers for a loaded terrain. * * @remarks * Call it **before** `app.start()`: an `InstancedMeshRenderer`'s `capacity` sizes an instance * buffer Babylon Lite fixes when the scene is registered, so a renderer created later would have * nowhere to put its matrices. The asset is passed in rather than read off the component, because * `Terrain.asset` is `null` until `TerrainLodSystem` has built the chunks in the first `PreRender`. * * @param app - The running app. * @param ground - The terrain to describe. * @param asset - Its loaded document, for the chunk grid. * @returns The overlay, hidden until {@link LodOverlay.setVisible} is called. */export function createLodOverlay(app: App, ground: Terrain, asset: TerrainAsset): LodOverlay { const chunks = asset.definition.chunksPerSide; const chunkSize = asset.definition.chunks.size; const levels = Math.min(LEVEL_COLORS.length, asset.definition.chunks.lodLevels); const spacing = (asset.definition.size.width / (asset.field.resolution - 1)) * chunkSize; const cube = MeshAsset.box(app, { size: MARKER_SIZE }); const renderers: InstancedMeshRenderer[] = []; const slabs: Float32Array[] = []; for (let level = 0; level < levels; level += 1) { const material = createMaterialAsset( app, pbrMaterialDefinition({ name: `terrain/lod-${String(level)}`, baseColor: { r: 0.06, g: 0.06, b: 0.07, a: 1 }, // Emissive, not lit: a debug marker has to read the same on the shadowed side of a hill. emissive: LEVEL_COLORS[level] ?? { r: 1, g: 1, b: 1, a: 1 }, roughness: 1, metallic: 0, }), [], ); const entity = app.world.createEntity(`LOD ${String(level)} markers`); renderers.push( entity.addComponent(InstancedMeshRenderer, { mesh: cube.retain(), materials: [material], capacity: chunks * chunks, gpuCulling: false, castShadows: false, receiveShadows: false, }), ); slabs.push(new Float32Array(chunks * chunks * MATRIX_FLOATS)); } cube.release(); app.registerComponents([LodMarkerRefresh]); const refresher = app.world.createEntity("LOD marker refresh").addComponent(LodMarkerRefresh); let visible = false; const counts = new Int32Array(levels); /** * Writes one marker's world matrix into a level's slab. * * @param slab - The level's matrices. * @param index - Which instance to write. * @param x - The marker's world X. * @param y - The marker's world Y. * @param z - The marker's world Z. */ function writeMarker(slab: Float32Array, index: number, x: number, y: number, z: number): void { const at = index * MATRIX_FLOATS; slab.fill(0, at, at + MATRIX_FLOATS); slab[at] = 1; slab[at + 5] = 1; slab[at + 10] = 1; slab[at + 12] = x; slab[at + 13] = y; slab[at + 14] = z; slab[at + 15] = 1; } const overlay: LodOverlay = { setVisible(next: boolean): void { visible = next; if (!next) { for (let level = 0; level < renderers.length; level += 1) { renderers[level]?.setCount(0); } return; } this.refresh(); }, refresh(): void { if (!visible) { return; } counts.fill(0); const origin = ground.transform.position; const half = (chunks * spacing) / 2; for (let chunkZ = 0; chunkZ < chunks; chunkZ += 1) { for (let chunkX = 0; chunkX < chunks; chunkX += 1) { const level = Math.min(levels - 1, Math.max(0, ground.lodOf(chunkX, chunkZ))); const slab = slabs[level]; if (slab === undefined) { continue; } const x = origin.x - half + (chunkX + 0.5) * spacing; const z = origin.z - half + (chunkZ + 0.5) * spacing; const index = counts[level] ?? 0; writeMarker(slab, index, x, ground.heightAt(x, z) + MARKER_LIFT, z); counts[level] = index + 1; } } for (let level = 0; level < renderers.length; level += 1) { const slab = slabs[level]; if (slab !== undefined) { renderers[level]?.setMatrices(slab, counts[level] ?? 0); } } }, }; refresher.overlay = overlay; return overlay;}{ "format": "ignifx.terrain", "formatVersion": 1, "name": "island", "size": { "width": 512, "depth": 512, "height": 80 }, "resolution": 513, "heightmap": { "source": "island.r16" }, "chunks": { "size": 64, "lodLevels": 4, "lodDistance": 128, "skirtDepth": 2 }, "layers": [ { "name": "sand", "albedo": "sand_albedo.png", "tiling": 14 }, { "name": "grass", "albedo": "grass_albedo.png", "tiling": 12 }, { "name": "rock", "albedo": "rock_albedo.png", "tiling": 20, "triplanar": true }, { "name": "snow", "albedo": "snow_albedo.png", "tiling": 16 } ], "splat": { "control": ["island_splat.png"] }, "material": { "roughness": 0.94, "metallic": 0 }}