ignifx
All examples

Instancing

Rendering3D

  • Mouse
  • Touch
  • Gamepad

A ring of 20,000 rocks drawn by a single `InstancedMeshRenderer`. `setMatrices` hands Babylon Lite a reference to the example's own `Float32Array` — sixteen floats per instance, column-major — and Lite never copies it, so a foliage scatterer or a particle system can write into its own memory and call `markDirty()` with nothing allocating in between. `setCount` draws fewer of them without re-uploading anything. The two settings Lite fixes when the scene is registered, GPU culling and the LOD partner, are what the toggles on the right rebuild the renderer for: change one on a live component and it logs `IGX-0717` and writes the applied value back, so what the component reports is always what is being drawn.

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

` devtools

WebGPU: checking…

Try this

  • Pull Drawn down to two thousand and back up. The draw-call count never moves.
  • Turn the LOD partner off and orbit out: the far side of the belt is suddenly the full-detail mesh.
  • Turn GPU culling off. The renderer is rebuilt, because Lite bakes that flag into the renderable.
Show source code

Source

main.ts
import {  Camera,  createMaterialAsset,  Environment,  InstancedMeshRenderer,  MeshAsset,  pbrMaterialDefinition,} from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { readout, slider, toggle } from "../_kit/panel.ts";import { createLightRig } from "../_kit/stage.ts";import { BELT, 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 * * `setMatrices` hands Babylon Lite a **reference** to this file's `Float32Array` — Lite never * copies it. That is the whole point: a foliage scatterer or a particle system writes into its own * memory and tells the renderer the range moved, and nothing allocates in between. The price is * that the array has to stay alive and stay at least `count * 16` floats long. Sixteen floats per * instance, column-major, the layout `Transform.worldMatrix` already uses. * * Drawing fewer of them is `setCount`, which changes the number without re-uploading anything; * moving them is a write into the slab followed by `markDirty()`. * * ## Three settings Lite fixes when the scene is registered * * `capacity` sizes the instance buffer once, and `gpuCulling` and the LOD partner's identity only * reach the GPU through the renderable `app.start()` compiles. Change one afterwards and the * component **refuses it**: it logs `IGX-0717` once and writes the applied value back onto the * field, so what the component reports is always what is being drawn. The GPU-culling and LOD * toggles below therefore build a **new** `InstancedMeshRenderer` rather than editing the live one, * which is exactly what a graphics-settings screen has to do. `distance` and `band` are not in that * set — Lite re-applies those live, so the LOD slider is an ordinary assignment. * * `belt.ts` beside this file lays the ring out and writes the matrices. *//** * 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`;}/** * Writes an instance count with thousands separators. * * @param value - The count. * @returns The text for the value cell. */function thousands(value: number): string {  return Math.round(value).toLocaleString("en-GB");}bootExample({  title: "Instancing",  settings: {    rendering: { clearColor: CLEAR_COLOR, msaaSamples: 4, features: { shadows: false } },    time: { fixedDeltaTime: 1 / 60 },  },  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,    });    const sky = app.world.createEntity("Environment").addComponent(Environment, { clearColor: CLEAR_COLOR });    sky.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,      }),      [],    );    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 renderer = build();    /**     * Creates the renderer the two baked settings currently describe, replacing any earlier one.     *     * @returns The live component.     */    function build(): InstancedMeshRenderer {      belt.getComponent(InstancedMeshRenderer)?.destroy();      const created = belt.addComponent(InstancedMeshRenderer, {        mesh: near.retain(),        materials: [rock.retain()],        capacity: CAPACITY,        gpuCulling,        castShadows: false,        receiveShadows: false,        lod: useLod ? { mesh: far.retain(), distance: LOD.distance, 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)),            readout("Draw calls", (): string => String(app.renderer.drawCalls)),          ],        },        {          label: "Culling and detail",          controls: [            toggle("GPU culling", {              value: gpuCulling,              change: (on: boolean): void => {                gpuCulling = on;                renderer = build();              },            }),            toggle("LOD partner", {              value: useLod,              change: (on: boolean): void => {                useLod = on;                renderer = build();              },            }),            slider(              "LOD distance",              { min: 8, max: 160, step: 2, format: metres },              {                value: LOD.distance,                change: (value: number): void => {                  // Live: Lite's own declaration says the pairing's distance and band "may be re-set                  // by calling again with the same pair", so this is reconciled like any other field.                  const lod = renderer.lod;                  if (lod !== null) {                    lod.distance = value;                  }                },              },            ),            readout("Belt radius", (): string => metres(BELT.radius)),          ],        },      ],    });  },});
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: 104,  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 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;    // The square root is what spreads the rocks evenly through the annulus: without it they pile up    // against the inner edge, because a ring's area grows with its radius.    const radius = BELT.radius + (Math.sqrt(random()) - 0.5) * 2 * BELT.spread;    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:InstancedMeshRenderersetMatricessetCountMeshAsset.sphereapp.renderer.drawCalls

Assets:everything in this example is created in code.