ignifx
All examples

Surface shaders

Shaders3D

  • Mouse
  • Touch
  • Gamepad

A surface shader is the other half of the custom-shader story — Unity's `surf()`, Godot's `fragment()`. Three named hooks are compiled into the engine's own PBR shader, so a custom look keeps direct lighting, shadows, image-based lighting, fog and tone mapping without the author writing a line of any of it. The two `surface` hooks here edit the base colour the lighting is then computed from, so the snow is shaded and shadowed like paint; the two `composite` hooks add to the lit result, which is why the rim light survives shadow and the hit flash reads even on a face the key light never reaches. The Corset beside the ship wears none of them: same probe, same lamp, same shadow map.

A dark metal spaceship floating above a studio floor and casting a shadow onto it, with pale snow settled along its upward-facing plating, a darker damp band beneath, and a cool blue rim along its silhouette; a small textured corset stands on the floor to its right, wearing none of it.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Press “Hit the ship”. The flash is one uniform driven to 1 and eased back — no material is rebuilt.
  • Turn Snow off and on. That one costs a pipeline rebuild, because it changes Lite's cache key.
  • Drag the water line up past the snow line and watch which hook wins: `surface` runs before lighting.
Show source code

Source

main.ts
import {  Camera,  createMaterialAsset,  Environment,  MODEL_ASSET_TYPE,  Model,  pbrMaterialDefinition,  SHADER_ASSET_TYPE,} from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { button, readout, slider, toggle } from "../_kit/panel.ts";import { createBackdrop, createStudioFloor, createStudioRig, loadEnvironment } from "../_kit/stage.ts";import { HitFlash } from "./flash.ts";import {  BACKDROP_DIAMETER,  CLEAR_COLOR,  CORSET,  FLASH_SECONDS,  FLOOR_SIZE,  HULL,  SHIP,  SHIP_MATERIAL,  SHOT,  SURFACES,} from "./shot.ts";import type { SurfaceRow } from "./shot.ts";import type { PanelControl, PanelGroup } from "../_kit/panel.ts";import type { ModelAsset, ShaderAsset, SurfaceShaderBinding, SurfaceShaderReference } from "ignifx";/** * Four custom looks on one hull, with the engine's own lighting untouched. * * ## What a surface shader is * * A `"shader"` material is *everything*: you own the vertex stage, the fragment stage and the * lighting, and Babylon Lite hands a custom material no light or shadow bindings at all * (`/examples/custom-shader/`). A **surface shader** is the other half — Unity's `surf()`, Godot's * `fragment()`. Three named hooks are compiled into the engine's own PBR shader, so a custom look * keeps direct lighting, shadows, image-based lighting, fog and tone mapping without the author * writing a line of any of it: * * | Hook        | Where it runs                        | What it can do                              | * | ----------- | ------------------------------------ | ------------------------------------------- | * | `displace`  | the vertex stage, inlined            | move the vertex — and nothing else, see below | * | `surface`   | before lighting                      | edit `baseColor`, `alpha`, `emissive`, `normal` | * | `composite` | after lighting                       | add to the lit colour                       | * * The two `surface` hooks here edit what the lighting is computed *from*, so snow is shaded by the * key light and shadowed by the shadow map like any other paint. The two `composite` hooks add to * the result, which is why the rim light survives shadow — a rim in shadow is exactly what a rim * light is for. The Corset on the right wears none of them: same probe, same lamp, same map. * * ## The three things to know * * 1. **`rendering.features.materialPlugins` has to be declared**, because Lite installs its plugin *    bridges once, before the scene is registered. Without it, attaching is `IGX-0716`. * 2. **The host has to be a PBR material.** Lite 1.27.0 bakes a *Standard* host's plugin signature *    from the meshes already in the scene, which is never in time, so a Standard host is refused. * 3. **`displace` cannot read a uniform, a texture or the clock** — Lite declares plugin uniforms *    fragment-visible only. `/examples/vertex-animation/` shows what that leaves and what a full *    shader material does instead. * * `shot.ts` holds the composition and the panel's table; `flash.ts` is the one moving part. */bootExample({  title: "Surface shaders",  settings: {    rendering: {      clearColor: CLEAR_COLOR,      msaaSamples: 4,      // `materialPlugins` is the opt-in a surface shader needs; `shadows` is what proves the hooks      // did not cost the host its shadow map.      features: { shadows: true, materialPlugins: true },    },    time: { fixedDeltaTime: 1 / 60 },  },  async setup({ app, panel }) {    const eye = app.world.createEntity("Main Camera");    eye.addComponent(Camera, { near: 0.03, far: 200, fov: SHOT.fov });    attachOrbit(app, eye, {      yaw: SHOT.yaw,      pitch: SHOT.pitch,      distance: SHOT.distance,      target: SHOT.target,      minDistance: 1,      maxDistance: 7,    });    createStudioRig(app, { focus: SHOT.target, shadowTechnique: "pcf", keyIntensity: 2.8, rimIntensity: 0.7 });    createStudioFloor(app, { size: FLOOR_SIZE, color: CLEAR_COLOR });    await createBackdrop(app, { diameter: BACKDROP_DIAMETER });    // Every load is awaited before `app.start()`: a completed load settles at once, and a material    // cannot be built from a shader that has not decoded (IGX-0501).    const shaders = SURFACES.map((row: SurfaceRow) =>      app.assets.load<ShaderAsset>(row.address, { type: SHADER_ASSET_TYPE }),    );    const ship = app.assets.load<ModelAsset>(SHIP.address, { type: MODEL_ASSET_TYPE });    const corset = app.assets.load<ModelAsset>(CORSET.address, { type: MODEL_ASSET_TYPE });    const environment = loadEnvironment(app, "studio");    await Promise.all([...shaders.map((handle) => handle.promise), ship.promise, corset.promise, environment.promise]);    const sky = app.world      .createEntity("Environment")      .addComponent(Environment, { environment, clearColor: CLEAR_COLOR });    sky.imageProcessing.toneMapping = "aces";    sky.imageProcessing.exposure = SHOT.exposure;    sky.blur = SHOT.blur;    // The `surfaces` list is part of the material declaration, exactly as it is in a    // `.material.json`: an address, the values to open on, and where in the order it sits. Lower    // `priority` runs first, so the two `surface` hooks are 100 and 200 and the two `composite`    // hooks 300 and 400.    const surfaces: SurfaceShaderReference[] = SURFACES.map((row: SurfaceRow, index: number) => ({      shader: row.address,      name: row.name,      values:        row.extra === undefined          ? { [row.amount]: row.value }          : { [row.amount]: row.value, [row.extra.name]: row.extra.value },      textures: {},      enabled: row.enabled,      priority: (index + 1) * 100,    }));    const hull = createMaterialAsset(      app,      pbrMaterialDefinition({ name: "surface-shaders/hull", ...HULL, surfaces }),      [],    );    // One binding per shader, in the order they were declared. `set` re-uploads the host's uniform    // block; `enabled` changes Lite's pipeline cache key and rebuilds — a settings operation.    const bindings: readonly SurfaceShaderBinding[] = hull.value.surfaces;    const decorated = app.world.createEntity("Ship");    decorated.transform.localPosition.set(SHIP.position.x, SHIP.position.y, SHIP.position.z);    decorated.transform.localEulerAngles = SHIP.pose;    // `materialOverrides` replaces one glTF material by the name it carries in the file, which is    // how a decorated material reaches a loaded model: `Model` exposes no `MaterialAsset` of its own.    decorated.addComponent(Model, { model: ship, materialOverrides: { [SHIP_MATERIAL]: hull }, castShadows: true });    const control = app.world.createEntity("Corset");    control.transform.localPosition.set(CORSET.position.x, CORSET.position.y, CORSET.position.z);    control.transform.localScale.set(CORSET.scale, CORSET.scale, CORSET.scale);    control.addComponent(Model, { model: corset, castShadows: true, receiveShadows: true });    app.registerComponents([HitFlash]);    const flash = decorated.addComponent(HitFlash, { seconds: FLASH_SECONDS });    flash.binding = bindings[SURFACES.length - 1] ?? null;    const groups: PanelGroup[] = SURFACES.map((row: SurfaceRow, index: number) => {      const binding = bindings[index];      const controls: PanelControl[] = [        toggle(`${row.label} (${row.hook})`, {          value: row.enabled,          change: (on: boolean): void => {            if (binding !== undefined) {              binding.enabled = on;            }          },        }),      ];      if (row.label !== "Hit flash") {        controls.push(          slider(            "Amount",            { min: 0, max: row.max, step: 0.01, format: (value: number): string => value.toFixed(2) },            {              value: row.value,              change: (value: number): void => binding?.set(row.amount, value),            },          ),        );      }      const extra = row.extra;      if (extra !== undefined) {        controls.push(          slider(            extra.label,            { min: extra.min, max: extra.max, step: extra.step, format: (value: number): string => value.toFixed(2) },            {              value: extra.value,              change: (value: number): void => binding?.set(extra.name, value),            },          ),        );      }      return { label: row.label, collapsed: index > 1, controls };    });    panel({      title: "Surface shaders",      groups: [        {          label: "Hull",          controls: [            button("Hit the ship", (): void => {              flash.hit();            }),            readout("Shaders attached", (): string => String(hull.value.surfaces.length)),            readout("Draw calls", (): string => String(app.renderer.drawCalls)),          ],        },        ...groups,      ],    });  },});
shot.ts
/** * The stage the surface shaders are shown on, and the table that drives the panel. * * @remarks * Split out of `main.ts` for the reason `pbr-model/shot.ts` is: the placements, the lamp * intensities and the panel's bounds are numbers that were found by looking at rendered candidates, * and none of them is a lesson about surface shaders. * * ## Why the ship wears the shaders and the corset does not * * A surface shader decorates a **`MaterialAsset`**, and a glTF model's materials are Babylon Lite's, * built inside the loader — `Model` reaches them only through `materialOverrides`, which *replaces* * one by name. So the ship's single material is replaced with the one built in `main.ts`, which * carries the four shaders; the Corset beside it keeps everything the file shipped. That is what * makes the frame a comparison: the same probe, the same key light, the same shadow map, one object * decorated and one not. */import type { ColorLike } from "ignifx";/** The near-black the frame is cleared to. */export const CLEAR_COLOR: ColorLike = { r: 0.01, g: 0.013, b: 0.02, a: 1 };/** The opening shot. */export const SHOT = {  fov: 34,  yaw: 28,  pitch: 11,  distance: 2.5,  target: { x: 0, y: 0.5, z: 0 },  exposure: 1.05,  /** How much the probe is blurred; a little turns the softboxes into reflections you can follow. */  blur: 0.25,} as const;/** The glTF material name the ship's single material answers to, from the file itself. */export const SHIP_MATERIAL = "tripo_material_fedcb3fd-2bd9-4616-af5b-e9682fee6ac0";/** Where the decorated ship sits: floating, nose out of the frame's left. */export const SHIP = {  address: "models/ignifx-ship.glb",  position: { x: -0.42, y: 0.66, z: 0 },  pose: { x: -10, y: 22, z: 6 },} as const;/** Where the undecorated Corset stands: on the floor, to the right, as the control. */export const CORSET = {  address: "models/corset.glb",  position: { x: 0.62, y: 0, z: 0.12 },  scale: 16,} as const;/** How wide the floor is, in metres. */export const FLOOR_SIZE = 16;/** How wide the backdrop sphere is, in metres. */export const BACKDROP_DIAMETER = 18;/** The hull the four shaders decorate: metal enough that the probe shows in it. */export const HULL = { baseColor: { r: 0.29, g: 0.24, b: 0.2, a: 1 }, metallic: 0.45, roughness: 0.4 } as const;/** How long the hit flash takes to fade back to nothing, in seconds. */export const FLASH_SECONDS = 0.32;/** One panel row: the surface shader it belongs to, and how the amount slider is drawn. */export interface SurfaceRow {  /** The label on the panel group. */  readonly label: string;  /** The `.surface.wgsl` address, under `website/examples/assets/`. */  readonly address: string;  /** The name the shader answers to on the material; the address's basename by default. */  readonly name: string;  /** Which hook it implements, for the panel's own caption. */  readonly hook: "surface" | "composite";  /** The uniform the amount slider writes. */  readonly amount: string;  /** The slider's highest value; the lowest is always zero. */  readonly max: number;  /** The value the material opens on. */  readonly value: number;  /** Whether the shader is on when the example opens. */  readonly enabled: boolean;  /** A second slider, when the shader has one worth showing. */  readonly extra?: {    /** The visible label. */    readonly label: string;    /** The uniform's name. */    readonly name: string;    /** The lowest value. */    readonly min: number;    /** The highest value. */    readonly max: number;    /** The increment. */    readonly step: number;    /** The value the material opens on. */    readonly value: number;  };}/** * The four shaders, in the order they are layered onto the material. * * @remarks * Order matters: the two `surface` hooks run first and edit the base colour the lighting is then * computed from, and the two `composite` hooks run last and add to the lit result. Snow before * wetness, so a wet band under a snow line still reads as wet rock rather than as grey snow. */export const SURFACES: readonly SurfaceRow[] = [  {    label: "Snow",    address: "shaders/surface-shaders/snow.surface.wgsl",    name: "snow",    hook: "surface",    amount: "amount",    max: 1,    value: 1,    enabled: true,    extra: { label: "Snow line", name: "height", min: 0.2, max: 1.4, step: 0.02, value: 0.62 },  },  {    label: "Wetness",    address: "shaders/surface-shaders/wetness.surface.wgsl",    name: "wetness",    hook: "surface",    amount: "amount",    max: 1,    value: 0.85,    enabled: true,    extra: { label: "Water line", name: "waterLine", min: 0.1, max: 1.2, step: 0.02, value: 0.6 },  },  {    label: "Rim light",    address: "shaders/surface-shaders/rim.surface.wgsl",    name: "rim",    hook: "composite",    amount: "strength",    max: 3,    value: 0.55,    enabled: true,    extra: { label: "Tightness", name: "power", min: 1, max: 8, step: 0.25, value: 4 },  },  {    label: "Hit flash",    address: "shaders/surface-shaders/hit-flash.surface.wgsl",    name: "hit-flash",    hook: "composite",    amount: "flash",    max: 1,    value: 0,    enabled: true,  },];
flash.ts
/** * The one moving part of this example: the script that decays the hit flash. * * @remarks * A game's damage response is exactly this — a uniform driven to 1 at the moment of the hit and * eased back to 0 over a fifth of a second — and it is a `Script` rather than a tween because * `app.tweens` moves *fields on an object*, and a surface shader's uniforms live inside the host * material's uniform block and are reached through {@link SurfaceShaderBinding.set}. * * Writing a value is the cheap path: it re-uploads the host material's uniform block and recompiles * nothing. Toggling `enabled` or binding a texture is the expensive one — both change Babylon * Lite's pipeline cache key — which is why the panel's per-shader toggles are settings and this is * a per-frame animation. * * `update` is not called while `app.pause()` holds and is called with `dt === 0` under `?static=1`, * so a capture always shows the flash at rest. */import { f32, Script } from "ignifx";import type { ScriptCallbacks, SurfaceShaderBinding } from "ignifx";/** Drives one surface shader's `flash` uniform from 1 back down to 0. */export class HitFlash  extends Script.define({    seconds: f32(0.32, { min: 0.05, tooltip: "How long the flash takes to fade out." }),  })  implements ScriptCallbacks{  /** The namespaced registration id. */  static typeId = "ignifx-example/HitFlash";  /** The shader whose `flash` uniform this drives. Assigned after `addComponent`. */  binding: SurfaceShaderBinding | null = null;  /** The uniform's name, as the `.surface.wgsl` file declares it. */  uniform = "flash";  /** How much of the flash is left, `1` at the moment of the hit. */  #level = 0;  /** Starts a flash, or restarts one already in flight. */  hit(): void {    this.#level = 1;    this.#write();  }  /**   * Eases the level back to zero.   *   * @param dt - Seconds since the previous frame, scaled by `time.timeScale`.   */  update(dt: number): void {    if (this.#level <= 0) {      return;    }    this.#level = Math.max(0, this.#level - dt / Math.max(this.seconds, 0.05));    this.#write();  }  /** Writes the current level onto the shader, squared so the tail is short and the head is bright. */  #write(): void {    this.binding?.set(this.uniform, this.#level * this.#level);  }}
../assets/shaders/surface-shaders/snow.surface.wgsl
// Snow: white where the surface faces up and the world height allows it, blended into whatever the
// host material's base colour already was. The host keeps its own direct lighting, shadows, IBL and
// tone mapping — that is the whole difference between a surface shader and a `"shader"` material.
// @ignifx surface
// @ignifx uniform amount: f32 = 0.85 range(0, 1) step(0.01) tooltip("How opaque the settled snow is.")
// @ignifx uniform snowColor: vec3<f32> = color(0.94, 0.96, 1.0)
// @ignifx uniform slope: f32 = 0.5 range(0, 1) step(0.01) tooltip("How steep a face still holds snow.")
// @ignifx uniform height: f32 = 0.15 range(-1, 2) step(0.05) tooltip("The world height the snow line sits at.")

fn surface(in: SurfaceInput, s: ptr<function, Surface>) {
  // `geometricNormal` is world-space, so `.y` is "how far up this face looks" with no extra maths.
  let facing = smoothstep(clamp(1.0 - surfaceUniforms.slope, 0.0, 0.99), 1.0, in.geometricNormal.y);
  let above = smoothstep(surfaceUniforms.height - 0.25, surfaceUniforms.height + 0.25, in.worldPosition.y);
  // Two multiplied sines break the snow line up, so it reads as drift rather than as a contour.
  let broken = 0.82 + 0.18 * sin(in.worldPosition.x * 3.5) * sin(in.worldPosition.z * 4.3);
  let mask = clamp(facing * above * broken, 0.0, 1.0) * surfaceUniforms.amount;
  (*s).baseColor = mix((*s).baseColor, surfaceUniforms.snowColor, mask);
}
../assets/shaders/surface-shaders/wetness.surface.wgsl
// Wetness: a darker, more saturated base colour in the low band of the model, which is what water
// actually does to a diffuse surface — it fills the pores and stops them scattering light back.
// @ignifx surface
// @ignifx uniform amount: f32 = 0.7 range(0, 1) step(0.01) tooltip("How wet the wet band is.")
// @ignifx uniform waterLine: f32 = 0.22 range(-0.5, 2) step(0.01) tooltip("World height the water reached.")
// @ignifx uniform fade: f32 = 0.18 range(0.01, 0.8) step(0.01) tooltip("How far the damp edge climbs.")

fn surface(in: SurfaceInput, s: ptr<function, Surface>) {
  let wet = 1.0 - smoothstep(surfaceUniforms.waterLine, surfaceUniforms.waterLine + surfaceUniforms.fade, in.worldPosition.y);
  let mask = clamp(wet, 0.0, 1.0) * surfaceUniforms.amount;
  // Darker and slightly richer, both from the same mask.
  (*s).baseColor = mix((*s).baseColor, (*s).baseColor * vec3<f32>(0.34, 0.36, 0.42), mask);
  // A real wet surface is also **smoother**, and `Surface` carries `roughness` — but Babylon Lite's
  // PBR template declares `roughness` as a `let`, so the write is accepted here and dropped on the
  // way out (`packages/core/src/render/surface-shader-compiler.ts`). Lowering roughness is the
  // first upstream ask; until it lands, wetness is an albedo effect and the sheen has to come from
  // the rim shader beside it.
}
../assets/shaders/surface-shaders/rim.surface.wgsl
// Rim light: added to the frame **after** the host has finished lighting it, which is what the
// `composite` hook is for. Doing it in `surface` would tint the base colour and then get multiplied
// by the lambert term, so a rim in shadow would vanish — the opposite of what a rim light is for.
// `composite` needs a PBR host: Babylon Lite's Standard template has no slot for it.
// @ignifx surface
// @ignifx uniform rimColor: vec3<f32> = color(0.55, 0.78, 1.0)
// @ignifx uniform strength: f32 = 0.9 range(0, 3) step(0.05) tooltip("How bright the edge burns.")
// @ignifx uniform power: f32 = 3 range(1, 8) step(0.25) tooltip("How tightly the rim hugs the silhouette.")

fn composite(in: SurfaceInput, color: vec3<f32>) -> vec3<f32> {
  let facing = clamp(abs(dot(normalize(in.geometricNormal), normalize(in.viewDirection))), 0.0, 1.0);
  let rim = pow(1.0 - facing, surfaceUniforms.power);
  return color + surfaceUniforms.rimColor * rim * surfaceUniforms.strength;
}
../assets/shaders/surface-shaders/hit-flash.surface.wgsl
// Hit flash: the damage response every action game ships. A game writes `flash` from a script — a
// tween down from 1 over about a fifth of a second — and the whole model whitens and fades back.
// It is a `composite` hook because a flash has to survive shadow: a fragment the key light never
// reaches still flashes, which is exactly what a player needs to read a hit.
// @ignifx surface
// @ignifx uniform flash: f32 = 0 range(0, 1) step(0.01) tooltip("1 at the moment of the hit, 0 at rest.")
// @ignifx uniform flashColor: vec3<f32> = color(1.0, 0.86, 0.72)

fn composite(in: SurfaceInput, color: vec3<f32>) -> vec3<f32> {
  let amount = clamp(surfaceUniforms.flash, 0.0, 1.0);
  // A grazing fragment flashes hardest, so the silhouette pops first — the shape of the hit reads
  // even at a frame or two of screen time.
  let facing = clamp(abs(dot(normalize(in.geometricNormal), normalize(in.viewDirection))), 0.0, 1.0);
  let edge = 1.0 + 1.6 * (1.0 - facing);
  return mix(color, surfaceUniforms.flashColor * edge, amount);
}

Uses:pbrMaterialDefinition surfacesSurfaceShaderBindingfeatures.materialPluginsModelScript

Assets:ignifx mascot ship — © 2026 Astrum Forge Studios Pty Ltd, Astrum Forge StudiosStudio environment — CC-BY 4.0, Babylon.js contributorsCorset — CC0 1.0, Microsoft