All examples
Shadows
- Mouse
- Touch
- Gamepad
Four shapes turning over a floor that receives, and five pillars receding to forty metres so a cascade split and a shadow distance have somewhere to show. Shadows are two decisions in ignifx: the `rendering.features.shadows` opt-in, which is read once when the scene is registered, and each light's own `shadows` record. Only `enabled` in that record is live — everything else is read when the shadow generator is built — so the panel drops the generator and builds another, and counts them while it does.

WebGPU: checking…See browser support
Try this
- Pull the normal bias to zero and watch the sphere stripe itself with its own shadow.
- Raise the depth bias until the shadows detach from the shapes that threw them.
- Switch the technique to ESM for a softer edge, or to cascades, which reloads the frame.
Show source
Source
import { Camera, Light } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { bind, readout, select, slider, toggle } from "../_kit/panel.ts";import { loadWithTechnique, readTechnique, ShadowRebuild } from "./rebuild.ts";import { CASTER_SPIN, createCasters, degreesPerSecond, exponential, FILL, FOCUS, labelFor, MAP_SIZES, metresOrDefault, SHADOWS, SUN, TECHNIQUES, Turntable,} from "./scene.ts";/** * Every shadow control ignifx has, on one directional light over a floor that receives. * * Shadows are two decisions. The first is a project setting: `rendering.features.shadows` picks * `registerSceneWithShadowSupport` over `registerScene` when `app.start()` registers the scene, and * nothing can add a shadow pass afterwards — a light that asks later gets `IGX-0704`, and one that * sets `shadows.enabled` without the feature gets a logged warning and no shadow. The second is per * light: `shadows.enabled`, and the technique and its tuning in the same record. Only a directional * or a spot light can cast; a point or hemispheric light is refused with `IGX-0703`. * * **Only `enabled` is live.** The technique, the map size, the biases, the darkness, the cascade * count and the shadow distance are read once, when the generator is built, so changing one means * dropping that generator and building another — which is why a shadow-quality setting in a real * game belongs on a menu you leave, not on a slider you drag. `rebuild.ts` beside this file is that * rebuild, and the panel's "Generators built" readout counts them. The **technique** cannot be * changed even that way, because a renderable bakes the shadow bind group's layout; the select * reloads the frame with `?technique=`, and `rebuild.ts` records what happens if you do not. */bootExample({ title: "Shadows", settings: { rendering: { clearColor: { r: 0.043, g: 0.055, b: 0.078, a: 1 }, msaaSamples: 4, features: { shadows: true }, }, time: { fixedDeltaTime: 1 / 60 }, }, async setup({ app, panel }) { app.registerComponents([ShadowRebuild, Turntable]); const eye = app.world.createEntity("Main Camera"); eye.addComponent(Camera, { near: 0.1, far: 400, fov: 42 }); attachOrbit(app, eye, { yaw: 14, pitch: 20, distance: 14, target: FOCUS, minDistance: 5, maxDistance: 60 }); const casters = await createCasters(app); const turntable = casters.addComponent(Turntable, { speed: CASTER_SPIN }); // The one caster. A directional light shines along its entity's +Z, and Lite fits the shadow // frustum from that direction and this position — so `lookAt` aims both at once. const sunEntity = app.world.createEntity("Sun", { position: SUN.at }); sunEntity.transform.lookAt(FOCUS); const sun = sunEntity.addComponent(Light, { type: "directional", intensity: SUN.intensity, color: SUN.color }); sun.shadows.enabled = true; // Per page load, not per change: see `rebuild.ts`. const technique = readTechnique(SHADOWS.technique); sun.shadows.technique = technique; sun.shadows.mapSize = SHADOWS.mapSize; sun.shadows.bias = SHADOWS.bias; sun.shadows.normalBias = SHADOWS.normalBias; sun.shadows.darkness = SHADOWS.darkness; sun.shadows.cascades = SHADOWS.cascades; sun.shadows.maxDistance = SHADOWS.maxDistance; const rebuild = sunEntity.addComponent(ShadowRebuild); // Hemispheric, so the shadow side of a caster stays readable. It casts nothing, which is the // point: ambient light has no direction to cast from. app.world.createEntity("Sky").addComponent(Light, { type: "hemispheric", intensity: FILL.intensity }); /** Which numeric fields of the `shadows` record the panel writes. */ type ShadowNumber = "bias" | "cascades" | "darkness" | "mapSize" | "maxDistance" | "normalBias"; /** * Writes one numeric shadow field, then asks for the single rebuild that makes it land. * * @param key - The field. * @returns The `change` callback a slider or a select takes. */ const writes = (key: ShadowNumber): ((value: number) => void) => { return (value: number): void => { sun.shadows[key] = value; rebuild.request(); }; }; /** * Moves the light up and re-aims it, so every shadow in the scene lengthens or shortens. * * @param value - The light's height, in metres. */ const raise = (value: number): void => { sunEntity.transform.localPosition.set(SUN.at.x, value, SUN.at.z); sunEntity.transform.lookAt(FOCUS); }; panel({ title: "Shadows", groups: [ { label: "Shadow map", controls: [ toggle("Casts shadows", { value: true, change: (on: boolean): void => { rebuild.setCasting(on); }, }), // The one control that reloads the frame rather than changing something live. select("Technique", Object.keys(TECHNIQUES), { value: labelFor(technique), change: (label: string): void => { loadWithTechnique(TECHNIQUES[label] ?? SHADOWS.technique); }, }), select("Map size", Object.keys(MAP_SIZES), { value: String(SHADOWS.mapSize), change: (label: string): void => { writes("mapSize")(MAP_SIZES[label] ?? SHADOWS.mapSize); }, }), slider("Darkness", { min: 0, max: 1, step: 0.02 }, { value: SHADOWS.darkness, change: writes("darkness") }), ], }, { label: "Bias", collapsed: true, controls: [ // Depth bias fights acne — a surface shadowing itself — and too much of it detaches a // shadow from the object that threw it. That trade is what this pair of sliders is for. slider( "Depth bias", { min: 0, max: 0.001, step: 0.00002, format: exponential }, { value: SHADOWS.bias, change: writes("bias") }, ), // PCF only: ESM ignores it, and CSM has no normal offset at all. slider( "Normal bias", { min: 0, max: 0.1, step: 0.002, format: exponential }, { value: SHADOWS.normalBias, change: writes("normalBias") }, ), ], }, { label: "Cascades", collapsed: true, controls: [ // CSM only, and Lite clamps the count to four. On PCF and ESM these two do nothing, // which is why they are a group of their own rather than two more rows above. slider("Count", { min: 1, max: 4, step: 1 }, { value: SHADOWS.cascades, change: writes("cascades") }), slider( "Shadow distance", { min: 0, max: 120, step: 5, format: metresOrDefault }, { value: SHADOWS.maxDistance, change: writes("maxDistance") }, ), ], }, { label: "Scene", collapsed: true, controls: [ slider("Turn", { min: 0, max: 90, step: 2, format: degreesPerSecond }, bind(turntable, "speed")), slider("Light height", { min: 3, max: 14, step: 0.5 }, { value: SUN.at.y, change: raise }), ], }, { label: "Frame", collapsed: true, controls: [ readout("Casting", (): string => (sun.isCastingShadows ? "yes" : "no")), readout("Generators built", (): string => String(rebuild.rebuilds)), readout("Draw calls", (): string => String(app.renderer.drawCalls)), ], }, ], }); },});/** * What a shadow setting can and cannot change while a game is running, and the two-frame rebuild * that makes the changeable half land. * * @remarks * **Only `shadows.enabled` is live.** Everything else in a `Light`'s `shadows` record — the * technique, the map size, the depth and normal biases, the darkness, the cascade count and the * shadow distance — is read exactly once, when the component builds its generator: * `packages/core/src/render/gpu/light-shadows.ts` maps the record onto Babylon Lite's configuration * at that moment and never looks at it again, and `Light.sync` only compares whether casting is * wanted against whether a generator is attached. Writing `shadows.mapSize = 2048` on a light * that is already casting therefore changes nothing at all, silently. * * So a graphics menu that offers shadow quality has to drop the generator and build another, which * is what {@link ShadowRebuild} does. * * ## The technique is the exception, and it needs a reload * * A renderable bakes the shadow bind group's **layout** when the scene's material groups are built * — a single 2D depth texture for PCF, a float colour texture for ESM, a four-layer depth array for * CSM — and `rebuildSceneRenderables` does not re-pick it. So dropping the generator and building * one of a different kind binds the new texture to the old layout. Measured on 2026-09-08 in * Chromium: PCF to ESM binds an `RGBA16Float` map where the shader declares `Depth`, and PCF to CSM * binds a four-layer array view where it declares a single 2D view, after which the frame is black. * Each technique is correct when it is the one the scene started with, so the technique is chosen * per page load, through `?technique=`, and everything else is changed in place. */import { Light, Script, SHADOW_TECHNIQUES, u32 } from "ignifx";import type { ScriptCallbacks, ShadowTechniqueName } from "ignifx";/** The query parameter the technique is carried in, so a reload keeps it. */const TECHNIQUE_PARAM = "technique";/** * The shadow technique this page load was asked for. * * @param fallback - The technique to use when the query names none, or names one Lite has not got. * @returns The technique. * * @example * ```ts * light.shadows.technique = readTechnique("pcf"); * ``` */export function readTechnique(fallback: ShadowTechniqueName): ShadowTechniqueName { const asked = new URLSearchParams(window.location.search).get(TECHNIQUE_PARAM); return SHADOW_TECHNIQUES.find((name: ShadowTechniqueName) => name === asked) ?? fallback;}/** * Reloads the frame with a different shadow technique, which is the only way to change one. * * @remarks * The module comment says why. The rest of the query is kept, so a capture opened at * `?static=1&nopanel=1&seed=1` stays a capture. * * @param technique - The technique to load with. * * @example * ```ts * select("Technique", labels, { value: label, change: () => { loadWithTechnique("esm"); } }); * ``` */export function loadWithTechnique(technique: ShadowTechniqueName): void { const url = new URL(window.location.href); url.searchParams.set(TECHNIQUE_PARAM, technique); window.location.assign(url.toString());}/** * Re-attaches its light's shadow generator, which is the only way a changed shadow setting lands. * * @remarks * Put it on the light's own entity; it finds the `Light` itself. Every panel row that writes into * `shadows` calls {@link ShadowRebuild.request}, and the script then waits `quietFrames` frames * with no further request before it drops the generator — so dragging a slider costs one rebuild at * the end of the drag rather than one per pointer event, which matters when the map is 4096 texels * of depth texture. The rebuild itself takes two frames: `enabled = false` in the first, which is * what makes `Light.sync` release the generator, and `enabled = true` in the second, which builds a * new one from whatever the record now says. * * @example * ```ts * const rebuild = lightEntity.addComponent(ShadowRebuild); * light.shadows.mapSize = 2048; * rebuild.request(); * ``` */export class ShadowRebuild extends Script.define({ quietFrames: u32(8) }) implements ScriptCallbacks { /** The namespaced registration id. */ static typeId = "shadows/ShadowRebuild"; #light: Light | null = null; /** Frames left to wait before dropping the generator; `-1` when nothing is pending. */ #countdown = -1; /** Whether the next frame should build the new generator. */ #isReattaching = false; /** Whether the visitor wants shadows at all; a rebuild must not switch them back on. */ #isCasting = true; /** How many generators have been built since the page loaded. */ #rebuilds = 0; /** * How many shadow generators this light has built, which is the number the panel reports. * * @returns The count, starting at zero for the one the scene was authored with. */ get rebuilds(): number { return this.#rebuilds; } /** Finds the light this script rebuilds the generator of. */ awake(): void { this.#light = this.entity.getComponent(Light); } /** Asks for a rebuild once the panel has stopped changing things. */ request(): void { this.#countdown = this.quietFrames; } /** * Switches casting on or off, which is the one shadow field that *is* live. * * @param casting - Whether the light should cast. */ setCasting(casting: boolean): void { this.#isCasting = casting; this.#countdown = -1; this.#isReattaching = false; const light = this.#light; if (light !== null) { light.shadows.enabled = casting; } } /** Runs the two-frame rebuild, once the requests have gone quiet. */ update(): void { const light = this.#light; if (light === null || !this.#isCasting) { return; } if (this.#isReattaching) { this.#isReattaching = false; light.shadows.enabled = true; this.#rebuilds += 1; return; } if (this.#countdown < 0) { return; } this.#countdown -= 1; if (this.#countdown < 0) { light.shadows.enabled = false; this.#isReattaching = true; } }}/** * Everything in `shadows` that is not a shadow setting: the casters, the receding pillars, the * script that turns the rig, and the numbers the shot is composed with. * * @remarks * Split out for the reason `pbr-model/shot.ts` is: what a reader wants from `main.ts` is the shadow * record and what each of its fields does. The one part of this example that *is* a lesson about * the engine has its own file, `rebuild.ts`. */import { createMaterialAsset, f32, MeshAsset, MeshRenderer, pbrMaterialDefinition, Script } from "ignifx";import { createGridGround } from "../_kit/stage.ts";import type { App, AssetHandle, ColorLike, Entity, ScriptCallbacks } from "ignifx";/** Where the camera looks, between the orbiting casters and the receding pillars. */export const FOCUS = { x: 0.7, y: 1.1, z: 2.6 } as const;/** The one light that casts: a warm sun from the front upper left. */export const SUN = { color: { r: 1, g: 0.945, b: 0.839, a: 1 }, intensity: 2.6, at: { x: -5.5, y: 7, z: -2.5 },} as const;/** A soft, cool fill so the shadow side is readable without washing the shadow out. */export const FILL = { color: { r: 0.576, g: 0.706, b: 1, a: 1 }, intensity: 0.32 } as const;/** The shadow settings the example opens on. */export const SHADOWS = { technique: "pcf", mapSize: 1024, bias: 0.00005, normalBias: 0.02, darkness: 0.12, cascades: 4, maxDistance: 0,} as const;/** How fast the casters turn when the page loads, in degrees per second. */export const CASTER_SPIN = 22;/** The map sizes the panel offers, as the labels it shows them under. */export const MAP_SIZES: Readonly<Record<string, number>> = { "512": 512, "1024": 1024, "2048": 2048, "4096": 4096 };/** * The shadow techniques, by the label the panel shows; the values are `SHADOW_TECHNIQUES`. * * @remarks * PCF filters several taps of a depth map and gives a defined contact shadow; ESM stores an * exponential of depth and is blurred by construction, which is cheaper than widening a PCF kernel; * CSM splits the view into up to four depth slices so a distant shadow keeps its texels. A **spot** * light ignores all of this and always uses PCF, the only generator Lite gives it. */export const TECHNIQUES: Readonly<Record<string, "csm" | "esm" | "pcf">> = { PCF: "pcf", ESM: "esm", "CSM (cascades)": "csm",};/** * The label {@link TECHNIQUES} shows one technique under. * * @param technique - The engine's own name for it. * @returns The panel's label, or the first one when nothing matches. */export function labelFor(technique: string): string { return Object.keys(TECHNIQUES).find((label: string) => TECHNIQUES[label] === technique) ?? "PCF";}/** The colour the casters and pillars are drawn in. */const CASTER_COLOR: ColorLike = { r: 0.82, g: 0.83, b: 0.86, a: 1 };/** The ground plane's edge length, in metres. Long enough for a cascade split to be visible. */const GROUND_SIZE = 90;/** How far the casters orbit from the turntable's centre, in metres. */const ORBIT_RADIUS = 2.6;/** Where the pillars stand along +Z, in metres: near, then receding away from the camera. */const PILLAR_DISTANCES: readonly number[] = Object.freeze([7, 12, 19, 28, 40]);/** How tall a pillar is, in metres. */const PILLAR_HEIGHT = 2.4;/** A point in metres, as the kit's options and `createEntity` take one. */interface Point3 { /** Metres along X. */ readonly x: number; /** Metres along Y. */ readonly y: number; /** Metres along Z. */ readonly z: number;}/** * Writes a slider's value as a distance, and zero as what zero means. * * @param value - Metres. * @returns The text for the slider's value cell. */export function metresOrDefault(value: number): string { return value === 0 ? "Lite's default" : `${String(value)} m`;}/** * Writes a slider's value as a rate. * * @param value - Degrees per second. * @returns The text for the slider's value cell. */export function degreesPerSecond(value: number): string { return `${String(value)}°/s`;}/** * Writes a bias slider's value, which is small enough to need its exponent. * * @param value - The bias, in Lite's depth units. * @returns The text for the slider's value cell. */export function exponential(value: number): string { return value === 0 ? "0" : value.toExponential(1);}/** Turns its entity about world Y, in degrees per second. */export class Turntable extends Script.define({ speed: f32(0) }) implements ScriptCallbacks { /** The namespaced registration id. */ static typeId = "shadows/Turntable"; /** Reused so the per-frame path allocates nothing (coding standards §7). */ readonly #step = { x: 0, y: 0, z: 0 }; /** * Advances the rotation. * * @param dt - Seconds since the previous frame, already scaled by `time.timeScale`. Under * `?static=1` the scale is zero, so the casters hold the pose they were authored at. */ update(dt: number): void { this.#step.y = this.speed * dt; this.transform.rotate(this.#step); }}/** * Builds the floor, the turning casters and the receding pillars. * * @remarks * Two kinds of caster, for two kinds of question. The four shapes on the turntable are close to the * camera and always moving, which is where the technique, the map size and the biases show: a * sphere's contact shadow, a torus shadowing itself, and the acne a PCF map stripes across a curved * surface when `normalBias` is zero. The five pillars recede to forty metres, which is where the * cascade count and the shadow distance show — turn `maxDistance` down and the far pillars lose * their shadows one by one. * * @param app - The app the entities and assets belong to. * @returns The turntable entity, so the caller can attach {@link Turntable} to it. * * @example * ```ts * const casters = await createCasters(app); * casters.addComponent(Turntable, { speed: CASTER_SPIN }); * ``` */export async function createCasters(app: App): Promise<Entity> { await createGridGround(app, { size: GROUND_SIZE }); const chalk = createMaterialAsset( app, pbrMaterialDefinition({ name: "shadows/chalk", baseColor: CASTER_COLOR, metallic: 0, roughness: 0.62, environmentIntensity: 0, }), [], ); const place = (name: string, mesh: AssetHandle<MeshAsset>, at: Point3, parent?: Entity): Entity => { const position = { x: at.x, y: at.y, z: at.z }; const entity = parent === undefined ? app.world.createEntity(name, { position }) : app.world.createEntity(name, { parent, position }); entity.addComponent(MeshRenderer, { mesh, materials: [chalk], castShadows: true, receiveShadows: true }); return entity; }; const turntable = app.world.createEntity("Casters"); const r = ORBIT_RADIUS; // A sphere with few segments on purpose: shadow acne is a facet artefact, and sixteen segments is // where a 1024-texel map with no normal bias shows it plainly. place("Sphere", MeshAsset.sphere(app, { diameter: 1.5, segments: 16 }), { x: r, y: 0.75, z: 0 }, turntable); const torus = MeshAsset.torus(app, { diameter: 1.7, thickness: 0.38, tessellation: 28 }); place("Torus", torus, { x: 0, y: 1.5, z: r }, turntable); place("Box", MeshAsset.box(app, { size: 1.2 }), { x: -r, y: 0.6, z: 0 }, turntable); const capsule = MeshAsset.capsule(app, { height: 1.8, radius: 0.34, tessellation: 20 }); place("Capsule", capsule, { x: 0, y: 0.9, z: -r }, turntable); // The pillars are separate entities, not children of the turntable: what they are for is depth, // and a pillar that moved would take its shadow's distance with it. const pillar = MeshAsset.box(app, { width: 0.5, height: PILLAR_HEIGHT, depth: 0.5 }); for (const distance of PILLAR_DISTANCES) { place("Pillar", pillar, { x: distance * 0.42, y: PILLAR_HEIGHT / 2, z: distance }); } return turntable;}