All examples

Light probes

Lighting & GI3D
  • Mouse
  • Touch
  • Gamepad

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

A room with a red left wall, a green right wall and a white back wall, seen through its open side: a white ball on the pale floor sits between them under a lamp.
Open standalone

WebGPU: checking…

Try this

  1. 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.
  2. Paint the left wall blue: the ball keeps its red tint until you press Rebake, because the probes were baked with red.
  3. Rebake at 32 rays, then at 1024, and compare the time the Grid row reports.
Show source code
main.ts
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)),          ],        },      ],    });  },});
scene.ts
/** * 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 };}
tools/bake-room.ts
// 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();
Uses
LightProbeVolumeLightProbeAssetbakeLightProbescreateLightProbeAssetsampleLightProbesMeshAsset.fromDataEnvironmentScript
Assets
Studio environment — CC-BY 4.0, Babylon.js contributors