All examples

Instancing

Rendering3D
  • Mouse
  • Touch
  • Gamepad

A ring of 20,000 rocks drawn from one mesh in a single draw call. Each rock is a matrix in an array the example owns, and the renderer reads that array directly instead of copying it. Draw fewer rocks without uploading anything, move the distance where a simpler mesh takes over, or switch GPU culling and the low-detail mesh on and off, which builds a new renderer because both are fixed when one is created.

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

Off airLivePaused

Instancing

Rendering · 3D

A wide ring of thousands of small pale-grey rocks seen at a shallow angle against black, thousands deep, with an empty hole at its centre and the far side of the ring receding towards the top of the frame.
Open standalone

WebGPU: checking…

Try this

  1. Pull Drawn down to two thousand and back up. The draw-call count never moves.
  2. Turn the LOD partner off and orbit out: the far side of the belt is suddenly the full-detail mesh.
  3. Turn GPU culling off. The renderer is rebuilt, and the LOD partner goes off with it: its mesh is chosen in the culling pass.
Show source code
main.ts
import {  Camera,  createMaterialAsset,  Environment,  InstancedMeshRenderer,  MeshAsset,  pbrMaterialDefinition,} from "ignifx";import { bootExample } from "../_kit/boot.ts";import { thousands, wholeMetres } from "../_kit/format.ts";import { attachOrbit } from "../_kit/orbit.ts";import { readout, slider, toggle } from "../_kit/panel.ts";import { createLightRig } from "../_kit/stage.ts";import { CAPACITY, CLEAR_COLOR, layOutBelt, LOD, MESH, SHOT } from "./belt.ts";import type { AssetHandle, MaterialAsset, MeshAsset as MeshAssetType } from "ignifx";/** * Twenty thousand asteroids, one draw call. * * ## The slab is yours * * An `InstancedMeshRenderer` draws one mesh many times, each copy placed by its own matrix. The * matrices live in a `Float32Array` this file owns: sixteen floats per rock, column-major, the * layout `Transform.worldMatrix` already uses. `setMatrices` hands the renderer the array itself, * not a copy, so the array has to stay alive and stay at least `count * 16` floats long. Moving a * rock is a write into the array followed by `markDirty()`; drawing fewer is `setCount`, which * uploads nothing. * * ## Three settings fixed when the renderer is built * * `capacity`, `gpuCulling` and which mesh is the LOD partner are baked in when the renderer is first * drawn. Changing one later is refused with `IGX-0717`, and the field goes back to the value being * drawn. So the GPU-culling and LOD toggles below build a **new** renderer — which is what a * graphics-settings screen has to do too. The LOD `distance` and `band` are not in that set, so the * distance slider is an ordinary assignment. * * The two toggles are not independent. Lite picks each rock's mesh in the culling compute pass, so * a LOD partner on a renderer with `gpuCulling: false` is accepted and never used: every rock draws * the full-detail mesh. The panel keeps them honest — culling off takes the partner with it, and * the partner on brings culling back. * * `belt.ts` beside this file lays the ring out and writes the matrices. */bootExample({  title: "Instancing",  settings: {    rendering: { clearColor: CLEAR_COLOR, features: { shadows: false } },  },  setup({ app, panel, random, flags }) {    const eye = app.world.createEntity("Main Camera");    eye.addComponent(Camera, { near: 0.5, far: 600, fov: SHOT.fov });    attachOrbit(app, eye, {      yaw: SHOT.yaw,      pitch: SHOT.pitch,      distance: SHOT.distance,      target: SHOT.target,      minDistance: 6,      maxDistance: 320,    });    // One sun and a cool bounce: the rocks are lit, not shadowed. Shadows are off in `settings`    // because a 20,000-instance caster pass is a different lesson, and a directional shadow map    // fitted around a 110-metre belt would be a blur at any resolution worth paying for.    createLightRig(app, {      focus: SHOT.target,      keyPosition: { x: -60, y: 34, z: -40 },      keyIntensity: 5.2,      fillIntensity: 0.7,      fillColor: { r: 0.42, g: 0.55, b: 0.9, a: 1 },      rimIntensity: 1.6,      rimPosition: { x: 52, y: 12, z: 46 },      shadows: false,    });    app.world.createEntity("Environment").addComponent(Environment).imageProcessing.toneMapping = "aces";    const rock: AssetHandle<MaterialAsset> = createMaterialAsset(      app,      pbrMaterialDefinition({        name: "instancing/rock",        baseColor: { r: 0.55, g: 0.51, b: 0.46, a: 1 },        metallic: 0,        roughness: 0.95,      }),      [],    );    // This file holds the three handles for the page's life; each renderer only borrows them.    const near: AssetHandle<MeshAssetType> = MeshAsset.sphere(app, {      diameter: MESH.diameter,      segments: MESH.segments,    });    const far: AssetHandle<MeshAssetType> = MeshAsset.sphere(app, {      diameter: MESH.diameter,      segments: MESH.lodSegments,    });    // Allocated once, filled once, and handed to Lite by reference for the life of the page.    const slab = new Float32Array(CAPACITY * 16);    layOutBelt(slab, CAPACITY, random);    const belt = app.world.createEntity("Asteroid belt");    // The frame-time probe needs 420 rendered frames, which SwiftShader cannot rasterise at 20,000    // instances in its time limit. The engine's per-frame cost is the same for any count once the    // slab is static, so a benchmark draws a tenth of the belt.    let drawn = flags.isBench ? CAPACITY / 10 : CAPACITY;    let gpuCulling = true;    let useLod = true;    let lodDistance: number = LOD.distance;    let renderer = build(null);    /**     * Creates the renderer the current settings describe, replacing the one before it.     *     * @param previous - The renderer being replaced, or `null` for the first.     * @returns The live component.     */    function build(previous: InstancedMeshRenderer | null): InstancedMeshRenderer {      // `destroy` lands at the end of the frame, so the old belt draws nothing until then. Not      // `enabled = false`: that hides the entity's node, which the new renderer shares.      if (previous !== null) {        previous.setCount(0);        previous.destroy();      }      const created = belt.addComponent(InstancedMeshRenderer, {        mesh: near,        materials: [rock],        capacity: CAPACITY,        gpuCulling,        castShadows: false,        receiveShadows: false,        lod: useLod ? { mesh: far, distance: lodDistance, band: LOD.band } : null,      });      created.setMatrices(slab, drawn);      return created;    }    panel({      title: "Instancing",      groups: [        {          label: "Belt",          controls: [            slider(              "Drawn",              { min: 0, max: CAPACITY, step: 100, format: thousands },              {                value: drawn,                change: (value: number): void => {                  // No re-upload: the slab is unchanged and only the active range moves.                  drawn = Math.round(value);                  renderer.setCount(drawn);                },              },            ),            readout("Instances", (): string => thousands(renderer.count)),          ],        },        {          label: "Culling and detail",          controls: [            // Each toggle reads the other's variable back, so switching one moves the other with it.            toggle("GPU culling", {              value: gpuCulling,              change: (on: boolean): void => {                gpuCulling = on;                useLod = useLod && on;                renderer = build(renderer);              },              read: (): boolean => gpuCulling,            }),            toggle("LOD partner", {              value: useLod,              change: (on: boolean): void => {                useLod = on;                gpuCulling = gpuCulling || on;                renderer = build(renderer);              },              read: (): boolean => useLod,            }),            // Live on the renderer that is drawing, and remembered for the next one a toggle builds;            // it does nothing while the LOD partner is off.            slider(              "LOD distance",              { min: 8, max: 160, step: 2, format: wholeMetres },              {                value: lodDistance,                change: (value: number): void => {                  lodDistance = value;                  const lod = renderer.lod;                  if (lod !== null) {                    lod.distance = value;                  }                },                read: (): number => lodDistance,              },            ),          ],        },        {          label: "Frame",          collapsed: true,          controls: [readout("Draw calls", (): string => String(app.renderer.drawCalls))],        },      ],    });  },});
belt.ts
/** * The asteroid belt's geometry: where the 20,000 rocks sit, and the matrix slab that says so. * * @remarks * Split out of `main.ts` because none of it is a lesson about instancing — it is a seeded ring of * placements and the sixteen floats each one becomes. What *is* worth reading here is the layout * rule the slab obeys, because getting it wrong is the usual first failure: **sixteen floats per * instance, column-major**, the same order `Transform.worldMatrix` and Babylon Lite's `Mat4` already * use, with the translation in elements 12, 13 and 14. * * The slab is written once, in `setup`, and never again: nothing here animates. A belt that turned * would mutate this same array in place and call `markDirty()`, which is the point of the component * handing Lite the caller's memory rather than copying it. *//** How many floats one instance matrix occupies. */const FLOATS_PER_MATRIX = 16;/** The shot the example opens on. */export const SHOT = {  fov: 40,  yaw: 18,  pitch: 27,  distance: 122,  target: { x: 0, y: 0, z: 0 },} as const;/** The near-black of space the frame is cleared to. */export const CLEAR_COLOR = { r: 0.008, g: 0.009, b: 0.014, a: 1 } as const;/** How many rocks the slab holds, and the renderer's capacity. */export const CAPACITY = 20_000;/** The belt's shape, in metres. */export const BELT = {  /** The radius the ring is centred on. */  radius: 44,  /** How far in and out of that radius a rock may scatter. */  spread: 13,  /** How far above and below the plane a rock may scatter. */  thickness: 3.4,  /** The smallest rock's diameter. */  minSize: 0.22,  /** The largest rock's diameter. */  maxSize: 1.15,} as const;/** The belt's inner radius, squared, in square metres. */const INNER_SQUARED = (BELT.radius - BELT.spread) ** 2;/** The belt's outer radius, squared, in square metres. */const OUTER_SQUARED = (BELT.radius + BELT.spread) ** 2;/** The prototype mesh, and the coarse partner drawn past {@link LOD.distance}. */export const MESH = { segments: 10, lodSegments: 4, diameter: 1 } as const;/** Where an instance switches to the coarse mesh, and how wide the dither window is. */export const LOD = { distance: 46, band: 12 } as const;/** * Fills a matrix slab with a seeded ring of rocks. * * @remarks * The composition is a **ring**, not a disc, because a disc at this count reads as noise: a torus * of rocks has a near edge, a far edge and a hole, which is what makes 20,000 of something legible. * Each rock gets a scale, a yaw and a pitch, so the same low-poly sphere never appears twice at the * same orientation. * * @param slab - The array to fill; at least `count * 16` floats. * @param count - How many instances to write. * @param random - The kit's seeded generator, so the same seed lays out the same belt. */export function layOutBelt(slab: Float32Array, count: number, random: () => number): void {  for (let index = 0; index < count; index += 1) {    const angle = random() * Math.PI * 2;    // A ring's area grows with its radius, so a radius drawn uniformly would crowd the inner edge.    // Drawing the squared radius uniformly between the two edges spreads the rocks evenly by area.    const radius = Math.sqrt(INNER_SQUARED + random() * (OUTER_SQUARED - INNER_SQUARED));    const height = (random() - 0.5) * 2 * BELT.thickness;    const size = BELT.minSize + random() * (BELT.maxSize - BELT.minSize);    writeMatrix(      slab,      index,      Math.cos(angle) * radius,      height,      Math.sin(angle) * radius,      size,      random() * Math.PI * 2,      random() * Math.PI * 2,    );  }}/** * Writes one instance's sixteen column-major floats: a uniform scale, a yaw, a pitch, a position. * * @param slab - The array to write into. * @param index - Which instance. * @param x - Metres along X. * @param y - Metres along Y. * @param z - Metres along Z. * @param scale - The uniform scale. * @param yaw - Rotation about Y, in radians. * @param pitch - Rotation about X, in radians. */function writeMatrix(  slab: Float32Array,  index: number,  x: number,  y: number,  z: number,  scale: number,  yaw: number,  pitch: number,): void {  const base = index * FLOATS_PER_MATRIX;  const cy = Math.cos(yaw);  const sy = Math.sin(yaw);  const cp = Math.cos(pitch);  const sp = Math.sin(pitch);  // R = Ry(yaw) * Rx(pitch), scaled, written column by column.  slab[base] = cy * scale;  slab[base + 1] = 0;  slab[base + 2] = -sy * scale;  slab[base + 3] = 0;  slab[base + 4] = sy * sp * scale;  slab[base + 5] = cp * scale;  slab[base + 6] = cy * sp * scale;  slab[base + 7] = 0;  slab[base + 8] = sy * cp * scale;  slab[base + 9] = -sp * scale;  slab[base + 10] = cy * cp * scale;  slab[base + 11] = 0;  slab[base + 12] = x;  slab[base + 13] = y;  slab[base + 14] = z;  slab[base + 15] = 1;}
Uses
InstancedMeshRendererInstancedMeshRenderer.setMatricesInstancedMeshRenderer.setCountInstancedMeshRenderer.lodMeshAsset.sphereapp.renderer.drawCalls
Assets
Everything in this example is created in code.