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.

This example is deliberately heavy: expect a lower frame rate on a phone or an older laptop.

Off airLivePaused

Heightmap terrain

Terrain · 3D

An island seen from the air across dark blue water: a pale sand beach round its edge, green grassland inland, and a grey rocky peak with snow on its crest, while the sea behind it fades into a pale blue sky with no line at the horizon.
Open standalone

WebGPU: checking…

Try this

  1. Turn LOD markers on and fly forward: each marker changes colour as its chunk drops a level.
  2. Pull LOD bias down to 0.25. The chunk count is unchanged and the draw calls are unchanged — the triangles are not.
  3. Turn frustum culling off and watch Chunks drawn jump to all sixty-four.
Show source code
main.ts
import { Camera, Terrain, terrain, VirtualJoystick } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { wholeMetres } from "../_kit/format.ts";import { fadeToHorizon } from "../_kit/horizon.ts";import { bind, readout, slider, toggle } from "../_kit/panel.ts";import { createSky } from "../_kit/sky.ts";import { createApron } from "../_kit/terrain.ts";import { hasTouchScreen } from "../_kit/touch.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, in sRGB. */const SKY = { r: 0.59, g: 0.78, b: 1, a: 1 } as const;/** Where the camera opens, in metres: south-west of the island, above the water. */const SHOT = { x: -196, y: 76, z: -250, yaw: 47, pitch: -13 } as const;/** Where the water plane sits, in metres. The document's heights run from `0` to `80`. */const SEA_LEVEL = 3;/** The fog, in metres from the camera: it hides a far chunk changing level, and the sea's edge. */const FOG = { start: 300, end: 1100 } as const;/** How far the fly camera can stray from the middle along X or Z: the island's half-width. */const CAMERA_REACH = 256;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);    createSky(app, {      sky: SKY,      ground: { r: 0.3, g: 0.29, b: 0.25, a: 1 },      sunPosition: { x: -55, y: 72, z: -42 },      sunIntensity: 3.4,      skyIntensity: 0.85,    });    // The sea reaches past the fog's end from anywhere the camera can fly, so its edge is never seen.    const fade = fadeToHorizon(app, { ...FOG, groundSize: 2 * (FOG.end + CAMERA_REACH) + 100 });    const ground = app.world.createEntity("Island").addComponent(Terrain, { definition: island });    createApron(app, {      size: fade.groundSize,      y: SEA_LEVEL,      color: { r: 0.09, g: 0.2, b: 0.3, a: 1 },      roughness: 0.22,    });    const eye = app.world.createEntity("Main Camera", { position: { x: SHOT.x, y: SHOT.y, z: SHOT.z } });    // The far plane sits past the fog's end, so nothing is clipped before it has faded into the sky.    eye.addComponent(Camera, { near: 1, far: FOG.end + 300, fov: 58 });    const fly = attachFly(app, eye, { yaw: SHOT.yaw, pitch: SHOT.pitch });    fly.ground = ground;    if (hasTouchScreen()) {      // Writes `<Virtual>/joystick`, which `flyMove` already binds; dragging the frame still looks.      const bottom = "calc(1.5rem + var(--ignifx-safe-bottom, 0px))";      new VirtualJoystick(app, { control: "joystick", ariaLabel: "Fly", style: { left: "1.5rem", bottom } });    }    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", bind(overlay, "visible")),            slider(              "LOD bias",              { min: 0.25, max: 4, step: 0.05, format: (value): string => `${value.toFixed(2)}x` },              bind(ground, "lodBias"),            ),            toggle("Frustum culling", bind(ground, "frustumCulling")),          ],        },        {          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` },              bind(fly, "speed"),            ),            readout("Altitude", (): string => wholeMetres(eye.transform.position.y)),            readout("Ground below", (): string =>              wholeMetres(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. * * On a portrait canvas it widens its lens the way the kit's orbit camera does (`_kit/orbit.ts`, * "Portrait frames"): the vertical field of view grows until the horizontal one is what a square * frame would show, so a phone held upright sees the island the author framed rather than a slice * of it. The camera does not move, so the fog and the sea's edge stay where the example put them. */import { bool, Camera, clamp, defineInputActions, degToRad, f32, radToDeg, 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" },            // What the on-screen stick in `main.ts` writes on a touch screen.            { 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 }),    fitPortrait: bool(true, { tooltip: "Widen the lens on a portrait canvas so the shot keeps its width." }),  })  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();  /** The entity's lens, found once in `awake`; `null` when the entity has none. */  #camera: Camera | null = null;  /** The field of view the example gave the lens, in degrees; `null` until first read. */  #authoredFov: number | null = null;  /** The field of view this script last wrote, so a change made by anyone else is noticed. */  #writtenFov: number | null = null;  /** The map's actions, found once in `awake`; `null` until then. */  #move: InputAction | null = null;  #drag: InputAction | null = null;  #look: InputAction | null = null;  #stick: InputAction | null = null;  #rise: InputAction | null = null;  #dive: InputAction | null = null;  #boost: InputAction | null = null;  /** Finds the actions by name once, rather than every frame (coding standards §7). */  awake(): void {    this.#camera = this.entity.getComponent(Camera);    const actions = this.app.input.actions;    this.#move = actions.find("flyMove");    this.#drag = actions.find("flyDrag");    this.#look = actions.find("flyLook");    this.#stick = actions.find("flyStick");    this.#rise = actions.find("flyRise");    this.#dive = actions.find("flyDive");    this.#boost = actions.find("flyBoost");  }  /**   * 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.#turn(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 rise = (isHeld(this.#rise) ? 1 : 0) - (isHeld(this.#dive) ? 1 : 0);    const step = this.speed * (isHeld(this.#boost) ? this.boost : 1) * dt;    const forward = this.#move?.vector.y ?? 0;    const strafe = this.#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);    this.#fitLens();  }  /**   * Widens the lens on a portrait canvas, or gives back the authored one on a landscape canvas.   *   * @remarks   * The same rule as the kit's orbit camera: `tan(vfov / 2)` grows by `height / width`, so the   * horizontal field of view is the authored vertical one. A `fov` that differs from the one last   * written was set by someone else and becomes the authored figure, and nothing is written while   * the figure already matches, so a landscape frame never touches the `Camera`.   */  #fitLens(): void {    const camera = this.#camera;    if (camera === null || camera.projection === "orthographic") {      return;    }    if (this.#authoredFov === null || camera.fov !== this.#writtenFov) {      this.#authoredFov = camera.fov;    }    const authored = this.#authoredFov;    const scale = this.#portraitScale;    // The authored figure exactly when nothing is widened: a round trip through `atan` would leave    // a landscape camera a rounding error away from what the example set.    const fov = scale === 1 ? authored : radToDeg(2 * Math.atan(Math.tan(degToRad(authored) / 2) * scale));    if (camera.fov !== fov) {      camera.fov = fov;    }    this.#writtenFov = camera.fov;  }  /**   * How much the lens widens, as a factor on `tan(vfov / 2)`.   *   * @returns `height / width` on a portrait canvas when {@link FlyCamera.fitPortrait} is on, else `1`.   */  get #portraitScale(): number {    const surface = this.app.renderer.surface;    if (!this.fitPortrait || surface === null || surface.width <= 0 || surface.height <= 0) {      return 1;    }    return Math.max(1, surface.height / surface.width);  }  /**   * Turns a drag and the right stick into yaw and pitch.   *   * @param unscaled - The unscaled frame delta, in seconds.   */  #turn(unscaled: number): void {    const stick = this.#stick;    if (stick !== null) {      const rate = this.stickDegreesPerSecond * unscaled;      this.yaw += stick.vector.x * rate;      this.pitch += stick.vector.y * rate;    }    const delta = this.#look;    if (delta === null || !isHeld(this.#drag)) {      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);  }  /**   * 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);  }}/** * Whether a button action is held this frame. * * @param action - The action, or `null` when its map is not loaded. * @returns `true` while it is pressed. */function isHeld(action: InputAction | null): boolean {  return action?.isPressed ?? false;}/** * 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 in a frame where some chunk changed 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 {  /** Whether the markers are drawn. */  visible: boolean;  /** Re-reads every chunk's level and rewrites the markers if any changed. */  refresh(): void;}/** * Refreshes 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;  /** Checks the levels; the overlay does nothing while hidden or unchanged. */  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. * * @param app - The running app. * @param ground - The terrain to describe. * @param asset - Its loaded document, for the chunk grid and the heights under each marker. * @returns The overlay, hidden until {@link LodOverlay.visible} is set. */export function createLodOverlay(app: App, ground: Terrain, asset: TerrainAsset): LodOverlay {  const chunks = asset.definition.chunksPerSide;  const levels = Math.min(LEVEL_COLORS.length, asset.definition.chunks.lodLevels);  const spacing = asset.definition.size.width / chunks;  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,        materials: [material],        capacity: chunks * chunks,        gpuCulling: false,        castShadows: false,        receiveShadows: false,      }),    );    slabs.push(new Float32Array(chunks * chunks * MATRIX_FLOATS));  }  // The ground never moves or changes shape here, so each marker's position is worked out once.  const origin = ground.transform.position;  const half = asset.definition.size.width / 2;  const places = new Float32Array(chunks * chunks * 3);  for (let chunk = 0; chunk < chunks * chunks; chunk += 1) {    const x = origin.x - half + ((chunk % chunks) + 0.5) * spacing;    const z = origin.z - half + (Math.floor(chunk / chunks) + 0.5) * spacing;    places.set([x, asset.field.heightAt(x - origin.x, z - origin.z) + origin.y + MARKER_LIFT, z], chunk * 3);  }  const shown = new Int8Array(chunks * chunks).fill(-1);  const counts = new Int32Array(levels);  let visible = false;  const overlay: LodOverlay = {    get visible(): boolean {      return visible;    },    set visible(next: boolean) {      visible = next;      shown.fill(-1);      for (const renderer of renderers) {        renderer.setCount(0);      }      overlay.refresh();    },    refresh(): void {      if (!visible) {        return;      }      let changed = false;      for (let chunk = 0; chunk < shown.length; chunk += 1) {        const level = Math.min(levels - 1, Math.max(0, ground.lodOf(chunk % chunks, Math.floor(chunk / chunks))));        changed ||= shown[chunk] !== level;        shown[chunk] = level;      }      if (!changed) {        return;      }      counts.fill(0);      for (let chunk = 0; chunk < shown.length; chunk += 1) {        const level = shown[chunk] ?? 0;        const index = counts[level] ?? 0;        writeMarker(slabs[level], index, places, chunk);        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);        }      }    },  };  app.registerComponents([LodMarkerRefresh]);  app.world.createEntity("LOD marker refresh").addComponent(LodMarkerRefresh).overlay = overlay;  return overlay;}/** * Writes one marker's world matrix, a pure translation, into a level's slab. * * @param slab - The level's matrices. * @param index - Which instance to write. * @param places - Every marker's position, three floats each. * @param chunk - Whose position to use. */function writeMarker(slab: Float32Array | undefined, index: number, places: Float32Array, chunk: number): void {  if (slab === undefined) {    return;  }  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] = places[chunk * 3] ?? 0;  slab[at + 13] = places[chunk * 3 + 1] ?? 0;  slab[at + 14] = places[chunk * 3 + 2] ?? 0;  slab[at + 15] = 1;}
../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
TerrainTerrainAssetTerrainLodSystemTerrain.lodOfInstancedMeshRenderer
Assets
Everything in this example is created in code.