ignifx
All examples

Heightmap terrain

Terrain3D

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

An island seen from the air across dark blue water: a pale sand beach round its edge, rolling green grassland inland, and grey rocky ridges with pale snow on the highest crests, fading into a blue haze at the far shore.
Open standalone

` devtools

WebGPU: checking…

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

main.ts
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)),            ),          ],        },      ],    });  },});
fly.ts
/** * 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);}
lod-overlay.ts
/** * 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;}
../assets/terrain/island.terrain.json
{  "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 }}

Uses:TerrainTerrainAssetTerrainLodSystemInstancedMeshRendererEnvironment.fog

Assets:everything in this example is created in code.