All examples
Light probes
A LightProbeVolume lights PBR materials from a baked .probes.json grid instead of the Environment's diffuse light: each pixel blends the eight probes around it, so a ball rolling from a red wall to a green one picks up each wall's colour on the side that faces it. The page opens on a grid baked from this room ahead of time; Rebake runs bakeLightProbes in the page, one bounce on the CPU, and swaps the result in with createLightProbeAsset.
Off airLivePaused
Light probes
Lighting & GI · 3D

WebGPU: checking…See browser support
Try this
- Turn the volume off and on as the ball passes the red wall: the red tint on the side facing the wall is the baked bounce.
- Paint the left wall blue: the ball keeps its red tint until you press Rebake, because the probes were baked with red.
- Rebake at 32 rays, then at 1024, and compare the time the Grid row reports.
Show source code
Source
import { bakeLightProbes, Camera, createLightProbeAsset, Environment, LightProbeVolume, MeshRenderer, sampleLightProbes,} from "ignifx";import { bootExample } from "../_kit/boot.ts";import { twoPlaces } from "../_kit/format.ts";import { attachOrbit } from "../_kit/orbit.ts";import { bind, button, readout, select, slider, toggle } from "../_kit/panel.ts";import { loadEnvironment } from "../_kit/stage.ts";import { BAKE_RAYS, createRoom, FOCUS, Glider, GRID, LEFT_WALL_COLOUR_NAMES, SHOT } from "./scene.ts";import type { LeftWallColour } from "./scene.ts";import type { LightProbeAsset, LightProbeData } from "ignifx";/** * Light probes: a grid of points that each store the light arriving from every direction, baked * once, so a moving object is lit by the room it is in. * * A `LightProbeVolume` takes a `.probes.json` grid (`LightProbeAsset`) and lights every PBR * material with it instead of the `Environment`'s diffuse light: each pixel blends the eight probes * around it. Watch the white ball as it rolls: next to the red wall its red-facing side turns pink, * next to the green wall its other side turns green. Nothing renders that bounce per frame — it was * baked into the 108 probes. Reflections still come from the `Environment` (or a `ReflectionProbe`), * and the volume needs a loaded `Environment`. * * The page opens on `light-probes-room.probes.json`, which `tools/bake-room.ts` baked from this * same room headless. **Rebake** runs `bakeLightProbes` here in the page on the CPU — one bounce of * the lamp off every mesh built with `MeshAsset.fromData` (the walls; the ball is a primitive, so * it is skipped, as a moving object should be) — and swaps the result in through * `createLightProbeAsset`. Repaint the left wall and the probes keep the old colour until you * rebake: that is what "baked" means. The bake yields after every probe, so the frame keeps * drawing, and an `AbortSignal` stops it between probes. * * The bake also adds the lamp's own light as seen from each probe, and the lamp still lights the * room in real time, so the probes count its direct light a second time. Intensity scales the * probes' light, which is how a scene tunes that against its real-time lights today. *//** Ray counts the panel offers; more is smoother and slower. */const RAYS = ["32", "128", "256", "1024"] as const;/** The committed grid's address. */const PROBES_ADDRESS = "lighting/light-probes-room.probes.json";/** The exposure the room is graded at. */const EXPOSURE = 1;/** * The light a probe grid sends at a point from one side, as three short numbers. * * @param data - The grid. * @param at - The point. * @param at.x - X. * @param at.y - Y. * @param at.z - Z. * @param towardX - `-1` for the side facing the left wall, `1` for the right. * @returns `"r g b"`, linear. */function sideLight(data: LightProbeData, at: { x: number; y: number; z: number }, towardX: number): string { const [r, g, b] = sampleLightProbes(data, at.x, at.y, at.z, { x: towardX, y: 0, z: 0 }); return `${r.toFixed(2)} ${g.toFixed(2)} ${b.toFixed(2)}`;}bootExample({ title: "Light probes", settings: { rendering: { clearColor: { r: 0.04, g: 0.045, b: 0.055, a: 1 } }, }, async setup({ app, panel }) { app.registerComponents([Glider]); const eye = app.world.createEntity("Main Camera"); eye.addComponent(Camera, { near: 0.05, far: 100, fov: SHOT.fov }); attachOrbit(app, eye, { yaw: SHOT.yaw, pitch: SHOT.pitch, distance: SHOT.distance, target: FOCUS, minDistance: 2, maxDistance: 12, }); // The volume needs a loaded `Environment`; its diffuse light is what the probes replace. The // skybox is off, so outside the room's open side the frame shows the clear colour. const studio = loadEnvironment(app, "studio"); const baked = app.assets.loadAsync<LightProbeAsset>(PROBES_ADDRESS); await studio.promise; const sky = app.world .createEntity("Environment") .addComponent(Environment, { environment: studio, skybox: { enabled: false, size: 20 } }); sky.imageProcessing.exposure = EXPOSURE; sky.imageProcessing.toneMapping = "agx"; const room = createRoom(app); const volume = app.world.createEntity("Room probes").addComponent(LightProbeVolume, { probes: await baked }); let wall: LeftWallColour = "red"; let bakedWall: LeftWallColour = "red"; let rays: number = BAKE_RAYS; let status = `the committed file (red wall, ${String(BAKE_RAYS)} rays)`; let running: AbortController | null = null; /** Bakes the room as it stands now and swaps the grid in; a second press restarts it. */ const rebake = async (): Promise<void> => { running?.abort(); const controller = new AbortController(); running = controller; const paintedWith = wall; const started = performance.now(); try { const result = await bakeLightProbes(app.world, { ...GRID, rays, signal: controller.signal, onProgress: (done: number, total: number): void => { status = `baking ${String(done)} / ${String(total)} probes`; }, }); // Let go of the grid being replaced (the committed file or an earlier bake): this page held // it since it loaded or made it, and nothing else does once the volume points elsewhere. const previous = volume.probes; volume.probes = createLightProbeAsset(app, result.data); previous?.release(); bakedWall = paintedWith; const seconds = ((performance.now() - started) / 1000).toFixed(1); status = `${paintedWith} wall, ${String(rays)} rays, ${seconds} s; ` + `${String(result.triangles)} triangles, ${String(result.skipped)} skipped`; } catch (error: unknown) { if (!controller.signal.aborted) { throw error; } if (running === controller) { status = "cancelled; the previous grid stays"; } } finally { if (running === controller) { running = null; } } }; /** The grid the volume lights with now, or `null` while there is none. */ const grid = (): LightProbeData | null => { const handle = volume.probes; return handle !== null && handle.state === "loaded" ? handle.value.data : null; }; panel({ title: "Light probes", groups: [ { label: "Volume", controls: [ toggle("Enabled", bind(volume, "enabled")), slider("Intensity", { min: 0, max: 2, step: 0.05, format: twoPlaces }, bind(volume, "intensity")), readout("Grid", (): string => status), ], }, { label: "Bake", controls: [ select("Left wall", LEFT_WALL_COLOUR_NAMES, { value: wall, change: (name: string): void => { wall = name as LeftWallColour; room.leftWall.requireComponent(MeshRenderer).materials = [room.leftWallPaint[wall]]; }, read: (): string => wall, }), select("Rays", RAYS, { value: String(rays), change: (count: string): void => { rays = Number(count); }, read: (): string => String(rays), }), button("Rebake", (): void => { void rebake(); }), button("Cancel", (): void => { running?.abort(); }), readout("Baked with", (): string => (bakedWall === wall ? `${bakedWall} wall` : `${bakedWall} wall: rebake`)), ], }, { label: "Ball", controls: [ slider("Speed", { min: 0, max: 1, step: 0.05, format: twoPlaces }, bind(room.glider, "speed")), readout("Light from left", (): string => { const data = grid(); return data === null ? "no grid" : sideLight(data, room.glider.transform.position, -1); }), readout("Light from right", (): string => { const data = grid(); return data === null ? "no grid" : sideLight(data, room.glider.transform.position, 1); }), ], }, { label: "Frame", collapsed: true, controls: [ slider("Exposure", { min: 0.3, max: 3, step: 0.05, format: twoPlaces }, bind(sky.imageProcessing, "exposure")), readout("Draw calls", (): string => String(app.renderer.drawCalls)), ], }, ], }); },});/** * The room `light-probes` bakes: a red wall, a green wall, a white back wall, floor and ceiling, a * lamp under the ceiling, and a white ball that rolls from one coloured wall to the other. * * @remarks * Shared by the page and by `tools/bake-room.ts`, which bakes the committed `.probes.json` from the * same `createRoom` headless, so the file the page opens with is exactly what its Rebake button * produces. * * `bakeLightProbes` traces only meshes whose triangles are on the CPU, which is a mesh built with * `MeshAsset.fromData`; a primitive (`MeshAsset.box`, `MeshAsset.sphere`) or a glTF model keeps its * triangles on the GPU side and is skipped. So the walls here are one `fromData` box, scaled, and * the ball is a primitive sphere on purpose: a moving object should not be in its own bake. */import { f32, Light, MeshAsset, Script } from "ignifx";import { paint, place } from "../_kit/props.ts";import type { Point3 } from "../_kit/stage.ts";import type { App, AssetHandle, Entity, MaterialAsset, ScriptCallbacks } from "ignifx";/** The room's inner width (X), height (Y) and depth (Z), in metres. The open side faces -Z. */export const ROOM = { x: 6, y: 3, z: 6 } as const;/** How thick the walls are, in metres. */const WALL = 0.2;/** The point the camera orbits. */export const FOCUS: Point3 = { x: 0, y: 0.9, z: 0.4 };/** The opening shot: outside the open side, looking in and a little down. */export const SHOT = { fov: 46, yaw: 10, pitch: 12, distance: 7.4 } as const;/** * The probe grid: six by three by six probes, one metre apart, filling the room half a metre in * from the walls, floor and ceiling. 108 probes. */export const GRID = { origin: { x: -2.5, y: 0.5, z: -2.5 }, cellSize: { x: 1, y: 1, z: 1 }, counts: { x: 6, y: 3, z: 6 },} as const;/** Rays per probe for the committed bake; the panel offers others. */export const BAKE_RAYS = 256;/** The colours the left wall can be painted, sRGB. The committed bake is `red`. */export const LEFT_WALL_COLOURS = { red: { r: 0.85, g: 0.12, b: 0.08, a: 1 }, blue: { r: 0.1, g: 0.25, b: 0.9, a: 1 }, yellow: { r: 0.95, g: 0.75, b: 0.08, a: 1 },} as const;/** A left wall colour's name. */export type LeftWallColour = keyof typeof LEFT_WALL_COLOURS;/** The names, in panel order. */export const LEFT_WALL_COLOUR_NAMES: readonly LeftWallColour[] = Object.freeze(["red", "blue", "yellow"]);/** The ball's path: along X between the two coloured walls, in front of the back wall. */const PATH = { reach: 2.1, y: 0.55, z: 0.6 } as const;/** Rolls the ball from the red wall to the green one and back, so it passes through the probes. */export class Glider extends Script.define({ speed: f32(0.35, { min: 0, tooltip: "Round trips per ten seconds." }), }) implements ScriptCallbacks{ /** The namespaced registration id. */ static typeId = "light-probes/Glider"; /** Where along the round trip the ball is, in radians. Starts near the red wall. */ #phase = Math.PI * 1.35; /** * Moves the ball. `dt` is scaled, so `?static=1` holds it still. * * @param dt - Seconds since the previous frame. */ update(dt: number): void { this.#phase += this.speed * dt * ((Math.PI * 2) / 10); this.transform.localPosition.set(Math.sin(this.#phase) * PATH.reach, PATH.y, PATH.z); }}/** * A one-metre box centred on its origin, built with `MeshAsset.fromData` so the bake can trace it. * * @param app - The app the asset belongs to. * @returns The mesh. */function tracedBox(app: App): AssetHandle<MeshAsset> { const positions: number[] = []; const normals: number[] = []; const indices: number[] = []; // One face per axis direction: its normal `n` and two axes spanning it with `u × v = n`. Lite's // front face has the normal opposite the right-handed cross of its first two edges (the flag in // `vertex-animation/meshes.ts` and `MeshAsset.fromData`'s own example agree), so each quad is // indexed 0-2-1, 0-3-2. const faces: readonly (readonly [Point3, Point3, Point3])[] = [ [{ x: 1, y: 0, z: 0 }, { x: 0, y: 0, z: -1 }, { x: 0, y: 1, z: 0 }], [{ x: -1, y: 0, z: 0 }, { x: 0, y: 0, z: 1 }, { x: 0, y: 1, z: 0 }], [{ x: 0, y: 1, z: 0 }, { x: 1, y: 0, z: 0 }, { x: 0, y: 0, z: -1 }], [{ x: 0, y: -1, z: 0 }, { x: 1, y: 0, z: 0 }, { x: 0, y: 0, z: 1 }], [{ x: 0, y: 0, z: 1 }, { x: 1, y: 0, z: 0 }, { x: 0, y: 1, z: 0 }], [{ x: 0, y: 0, z: -1 }, { x: -1, y: 0, z: 0 }, { x: 0, y: 1, z: 0 }], ]; for (const [n, u, v] of faces) { const base = positions.length / 3; for (const [su, sv] of [ [-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5], ] as const) { positions.push( n.x * 0.5 + u.x * su + v.x * sv, n.y * 0.5 + u.y * su + v.y * sv, n.z * 0.5 + u.z * su + v.z * sv, ); normals.push(n.x, n.y, n.z); } indices.push(base, base + 2, base + 1, base, base + 3, base + 2); } return MeshAsset.fromData(app, "light-probes/box", { positions: Float32Array.from(positions), normals: Float32Array.from(normals), indices: Uint32Array.from(indices), });}/** What {@link createRoom} hands back. */export interface Room { /** The left wall, whose material the panel swaps. */ readonly leftWall: Entity; /** The left wall's paint per colour, made once. */ readonly leftWallPaint: Readonly<Record<LeftWallColour, AssetHandle<MaterialAsset>>>; /** The ball's script, for the panel. */ readonly glider: Glider;}/** * Builds the room, the lamp and the ball. * * @remarks * `app.registerComponents([Glider])` is the caller's job. * * @param app - The app the entities and the assets belong to. * @returns The left wall, its paints and the ball's script. */export function createRoom(app: App): Room { const box = tracedBox(app); const half = { x: ROOM.x / 2, z: ROOM.z / 2 }; const white = paint(app, "probes/white", { r: 0.86, g: 0.85, b: 0.82, a: 1 }, { roughness: 0.9 }); place(app, "Floor", box, paint(app, "probes/floor", { r: 0.72, g: 0.7, b: 0.66, a: 1 }, { roughness: 0.9 }), { at: { x: 0, y: -WALL / 2, z: 0 }, scale: { x: ROOM.x + WALL * 2, y: WALL, z: ROOM.z + WALL * 2 }, }); place(app, "Ceiling", box, white, { at: { x: 0, y: ROOM.y + WALL / 2, z: 0 }, scale: { x: ROOM.x + WALL * 2, y: WALL, z: ROOM.z + WALL * 2 }, }); place(app, "Back wall", box, white, { at: { x: 0, y: ROOM.y / 2, z: half.z + WALL / 2 }, scale: { x: ROOM.x + WALL * 2, y: ROOM.y, z: WALL }, }); const leftWallPaint = { red: paint(app, "probes/red", LEFT_WALL_COLOURS.red, { roughness: 0.9 }), blue: paint(app, "probes/blue", LEFT_WALL_COLOURS.blue, { roughness: 0.9 }), yellow: paint(app, "probes/yellow", LEFT_WALL_COLOURS.yellow, { roughness: 0.9 }), }; const leftWall = place(app, "Left wall", box, leftWallPaint.red, { at: { x: -half.x - WALL / 2, y: ROOM.y / 2, z: 0 }, scale: { x: WALL, y: ROOM.y, z: ROOM.z + WALL * 2 }, }); place(app, "Right wall", box, paint(app, "probes/green", { r: 0.12, g: 0.7, b: 0.18, a: 1 }, { roughness: 0.9 }), { at: { x: half.x + WALL / 2, y: ROOM.y / 2, z: 0 }, scale: { x: WALL, y: ROOM.y, z: ROOM.z + WALL * 2 }, }); // The ball: a primitive, so the bake skips it — it moves, and the probes light it. const ball = place( app, "Ball", MeshAsset.sphere(app, { diameter: 1, segments: 48 }), paint(app, "probes/ball", { r: 0.92, g: 0.92, b: 0.92, a: 1 }, { roughness: 0.85 }), { at: { x: -PATH.reach, y: PATH.y, z: PATH.z }, scale: { x: 1.1, y: 1.1, z: 1.1 } }, ); const glider = ball.addComponent(Glider); // One lamp under the ceiling. It lights the room in real time, and the bake bounces it off the // walls into the probes. const lamp = app.world.createEntity("Lamp", { position: { x: 0, y: ROOM.y - 0.35, z: 0.2 } }); lamp.addComponent(Light, { type: "point", intensity: 10, range: 14, color: { r: 1, g: 0.96, b: 0.9, a: 1 } }); return { leftWall, leftWallPaint, glider };}// Bakes `examples/assets/lighting/light-probes-room.probes.json`, the grid `light-probes` opens// with. Run it from the repository root://// node examples/light-probes/tools/bake-room.ts//// It builds the page's own room (`../scene.ts`) in a headless app and runs `bakeLightProbes` over// it with the page's grid and ray count: the bake is CPU-only and deterministic, so the file is what// the page's Rebake button produces with the red wall and 256 rays, rounded to five decimals.import { writeFile, mkdir } from "node:fs/promises";import path from "node:path";import { bakeLightProbes, createApp, writeLightProbeFile } from "ignifx";import { BAKE_RAYS, createRoom, Glider, GRID } from "../scene.ts";/** Where the file lands. */const OUT = path.resolve(import.meta.dirname, "..", "..", "assets", "lighting", "light-probes-room.probes.json");/** Five decimals: well under what a half-float texel keeps, and a file a fifth the size. */const DECIMALS = 1e5;const app = await createApp({ headless: true, logLevel: "error" });app.registerComponents([Glider]);createRoom(app);const started = performance.now();const result = await bakeLightProbes(app.world, { ...GRID, rays: BAKE_RAYS });const file = writeLightProbeFile({ ...result.data, coefficients: result.data.coefficients.map((value) => Math.round(value * DECIMALS) / DECIMALS),});await mkdir(path.dirname(OUT), { recursive: true });await writeFile(OUT, `${JSON.stringify(file)}\n`);console.log( `${path.relative(process.cwd(), OUT)}: ${String(result.data.coefficients.length / 12)} probes, ` + `${String(result.triangles)} triangles, ${String(result.skipped)} skipped, ` + `${(performance.now() - started).toFixed(0)} ms`,);app.dispose();