All examples

Shadows

Lighting3D
  • Mouse
  • Touch
  • Gamepad

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

A sphere, a torus, a box and a capsule on a grey grid floor at night, each with a soft shadow stretching to the right, and a line of five pillars receding to the left into the dark with shadows of their own.
Open standalone

WebGPU: checking…

Try this

  1. Open Bias, pull Depth bias to zero, and watch the sphere and the box stripe themselves with their own shadow.
  2. Raise the depth bias until the shadows detach from the shapes that threw them.
  3. Switch the technique to CSM, then lower Shadow distance until the far pillars lose their shadows.
Show source code
main.ts
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.ts
/** * 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;    }  }}
scene.ts
/** * 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;}
Uses
Light.shadowsfeatures.shadowsLight.isCastingShadowsScript.defineMeshAsset
Assets
Everything in this example is created in code.