ignifx
GitHubnpm · soon
All examples

Shadows

Lighting3D

  • 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.

A sphere, a torus, a box and a capsule standing on a pale grid floor in daylight, each with a soft shadow stretching to the right, and a line of five pillars receding into the distance with shadows of their own.
Open standalone

` devtools

WebGPU: checking…

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

main.ts
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)),          ],        },      ],    });  },});
rebuild.ts
/** * 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;    }  }}
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. */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;}

Uses:Light.shadowsfeatures.shadowsLight.isCastingShadowsScript.defineMeshAsset

Assets:everything in this example is created in code.