All examples
Shadows
Compare shadows on moving shapes and a row of distant pillars. Adjust the shadow settings to see the balance between detail, softness and visible artefacts. Changing a setting rebuilds the shadows; changing the technique reloads the example.
Off airLivePaused
Shadows
Lighting · 3D

WebGPU: checking…See browser support
Try this
- Open Bias, pull Depth bias to zero, and watch the sphere and the box stripe themselves with their own shadow.
- Raise the depth bias until the shadows detach from the shapes that threw them.
- Switch the technique to CSM, then lower Shadow distance until the far pillars lose their shadows.
Show source code
Source
import { Camera, Light } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { degreesPerSecond, metres, percent } from "../_kit/format.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 { BIAS_TRACKS, CASTER_SPIN, createCasters, exponential, FILL, FOCUS, labelFor, MAP_SIZES, metresOrDefault, SHADOWS, SUN, TECHNIQUES, Turntable,} from "./scene.ts";import type { PanelGroup } from "../_kit/panel.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` is read * once, 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 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 the scene's materials are built for one technique; the select reloads the frame with * `?technique=`. The cascade count and the shadow distance belong to CSM alone, so they are set, * and shown, only when the page loaded with it. */bootExample({ title: "Shadows", settings: { rendering: { clearColor: { r: 0.043, g: 0.055, b: 0.078, a: 1 }, features: { shadows: true }, }, }, 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); const isCascaded = technique === "csm"; sun.shadows.technique = technique; sun.shadows.mapSize = SHADOWS.mapSize; sun.shadows.bias = SHADOWS.bias[technique]; sun.shadows.normalBias = SHADOWS.normalBias; sun.shadows.darkness = SHADOWS.darkness; if (isCascaded) { 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); }; // Lite clamps the count to four. Turn the distance down and the far pillars lose their shadows. const cascades: PanelGroup = { label: "Cascades", controls: [ 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") }, ), ], }; 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); }, }), // Lite's `darkness` is 0 for a black shadow and 1 for none, so the slider shows its // complement under a name that grows the way the shadow does. slider( "Shadow strength", { min: 0, max: 1, step: 0.02, format: percent }, { value: 1 - SHADOWS.darkness, change: (value: number): void => { writes("darkness")(1 - value); }, }, ), ], }, ...(isCascaded ? [cascades] : []), { 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. // The three techniques read the bias in different units, so each gets its own track. slider( "Depth bias", { min: 0, ...BIAS_TRACKS[technique], format: exponential }, { value: SHADOWS.bias[technique], 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: "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, format: metres }, { 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)), ], }, ], }); },});/** * Rebuild the shadow generator to apply settings other than the live `shadows.enabled` flag. * Technique changes require a page reload because scene material layouts depend on the technique; * a new generator alone would bind an incompatible texture. Select it through `?technique=`. */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) { return; } // Switching casting back on builds a fresh generator, so it counts as a rebuild. if (casting && !light.shadows.enabled) { this.#rebuilds += 1; } 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. * * @remarks * `bias` is per technique, because the three read it in different units and a figure tuned for * one stripes another; each was found by sweeping it on a real GPU and taking the smallest that * opens with no acne on the turntable's casters, so the acne the Bias sliders show is one the * visitor makes. * * - **PCF** compares depths directly and has the normal offset as well, so 5e-5 is enough. * - **CSM** has no normal offset and offsets each cascade by half the bias of its own depth range, * so it needs eighty times as much — and `maxDistance` is 60 metres rather than Lite's default * (the camera's far plane, 400) because four cascades spread over 400 metres stripe every caster * at any bias that leaves the shadows attached. Sixty still reaches past the farthest pillar. * - **ESM** compares exponentials of depth, so its bias is a different kind of number again, and a * trade rather than a cure: below about 0.02 every caster shadows its own lit side grey, and * above 0.03 the shadows on the floor fade. That fade is ESM's light bleeding, and it is why PCF * is the default. * * `darkness` is Lite's field, and it is backwards from its name: 0 is a black shadow and 1 is no * shadow at all. The panel shows it the other way up, as a strength. */export const SHADOWS = { technique: "pcf", mapSize: 1024, bias: { pcf: 0.00005, esm: 0.025, csm: 0.004 }, normalBias: 0.02, darkness: 0.12, cascades: 4, maxDistance: 60,} as const;/** * The Depth bias slider's track for each technique: from zero, where every technique stripes, to * where its shadows have plainly let go of the casters (PCF, CSM) or faded out (ESM), in steps * that land on the opening figure in {@link SHADOWS}. Measured on a real GPU, like the figures. */export const BIAS_TRACKS = { pcf: { max: 0.0003, step: 0.00001 }, esm: { max: 0.06, step: 0.001 }, csm: { max: 0.08, step: 0.0005 },} 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 };/** 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 far sideways a pillar stands for each metre it recedes. */const PILLAR_DRIFT = 0.42;/** 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 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 map stripes across a curved * surface when the depth bias is pulled to 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> { // The fade starts past the farthest the orbit can pull back, so the farthest pillar is always // clear of it, and the grid grows until its edge is never in sight. await createGridGround(app, { fade: true }); 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. They recede to the left, away // from the parameter panel. 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 * PILLAR_DRIFT, y: PILLAR_HEIGHT / 2, z: distance }); } return turntable;}