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

WebGPU: checking…See browser support
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
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)), ], }, ], }); },});/** * 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;}