ignifx
All examples

Custom shader

Shaders3D

  • Mouse
  • Touch
  • Gamepad

There is no material JSON here and no `uniforms: [...]` array. Each `.wgsl` file declares itself with `// @ignifx` comment pragmas — the attributes it reads, the engine values it wants, its own uniforms with a type, a default, a range and a tooltip, and the blend and cull state it draws with — so the file stays valid WGSL for every other tool and `@ignifx/vite-plugin` parses and checks it at build time: an undeclared name, a hand-written binding or a `textureSample` in a vertex path is a build failure with the line it is on. What a custom material gives up is everything the engine's PBR material does for free: Babylon Lite hands it transforms, the camera position and the screen size, and no lights, no shadows and no probe. Toon and dissolve therefore light themselves from ignifx's own `mainLightDirection`.

A blue-grey sphere on a near-black floor with several rounded holes eaten through it, each hole ringed by a band of glowing orange where the surface is burning away, against a faint pool of light behind it.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Drag Progress on the dissolve: the sphere burns away and the surviving edge glows.
  • Switch to Hologram and watch the scan lines crawl — that is `shaderUniforms.time`, which Lite does not have.
  • Pull Bands on the toon shader down to one. Cel shading is `floor(lambert * n) / n` and nothing else.
Show source code

Source

main.ts
import {  Camera,  createMaterialAsset,  Environment,  MeshAsset,  MeshRenderer,  SHADER_ASSET_TYPE,  shaderMaterialDefinition,} from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { readout, select, slider } from "../_kit/panel.ts";import { createBackdrop, createStudioFloor, createStudioRig } from "../_kit/stage.ts";import { BACKDROP_DIAMETER, CLEAR_COLOR, FLOOR_SIZE, SHADERS, SHOT, START_SHADER, SUBJECT } from "./shot.ts";import type { ShaderRow } from "./shot.ts";import type { PanelGroup } from "../_kit/panel.ts";import type { AssetHandle, MaterialAsset, ShaderAsset } from "ignifx";/** * Four hand-written WGSL materials on one sphere. * * ## The file is the declaration * * There is no material JSON describing these shaders and no `uniforms: [...]` array in this file. * Each `.wgsl` declares itself with `// @ignifx` comment pragmas — the vertex attributes it reads, * the engine values it wants (`world`, `viewProjection`, `cameraPosition` from Babylon Lite; * `time`, `mainLightDirection`, `mainLightColor`, `ambientColor` from ignifx), its own uniforms with * a type, a default, a range, a step and a tooltip, and the blend and cull state it draws with. The * file stays valid WGSL for every other tool, and **`@ignifx/vite-plugin` parses and checks it at * build time**: a name the file reads but never declares, a binding written by hand, a * `textureSample` in a vertex path, a missing entry point — each is a build failure with the line it * is on, rather than a black sphere and a browser diagnostic about generated code nobody wrote. * * So loading one is `assets.load(address, { type: SHADER_ASSET_TYPE })`, building a material from * it is `shaderMaterialDefinition({ shader })`, and setting a value is `setUniform(name, value)` — * checked against the declaration, so a typo throws at the call site instead of drawing nothing. * * ## What you give up * * Everything the engine's PBR material does for free. Babylon Lite hands a custom material its * transforms, the camera position, the screen size and nothing else — no lights, **no shadows**, no * image-based lighting, no fog. `toon` and `dissolve` light themselves from ignifx's own * `mainLightDirection`, which is one directional light and no shadow map. When a look only needs to * change the engine's shading rather than replace it, the answer is a surface shader * (`/examples/surface-shaders/`), which keeps all of it. * * `shot.ts` beside this file holds the stage and the panel's uniform table. *//** * Builds the value formatter a row asks for. * * @param places - How many decimals to show. * @returns The formatter. */function decimals(places: number): (value: number) => string {  return (value: number): string => value.toFixed(places);}bootExample({  title: "Custom shader",  settings: {    rendering: {      clearColor: CLEAR_COLOR,      msaaSamples: 4,      features: { shadows: true },    },    time: { fixedDeltaTime: 1 / 60 },  },  async setup({ app, panel }) {    const eye = app.world.createEntity("Main Camera");    eye.addComponent(Camera, { near: 0.05, far: 200, fov: SHOT.fov });    attachOrbit(app, eye, {      yaw: SHOT.yaw,      pitch: SHOT.pitch,      distance: SHOT.distance,      target: SHOT.target,      minDistance: 1.4,      maxDistance: 9,    });    createStudioRig(app, { focus: SHOT.target, keyIntensity: 2.4, rimIntensity: 0.9 });    createStudioFloor(app, { size: FLOOR_SIZE, color: CLEAR_COLOR });    await createBackdrop(app, { diameter: BACKDROP_DIAMETER });    const sky = app.world.createEntity("Environment").addComponent(Environment, { clearColor: CLEAR_COLOR });    sky.imageProcessing.toneMapping = "aces";    sky.imageProcessing.exposure = SHOT.exposure;    // Every shader is loaded and every material built **before** `app.start()`: a load that    // completes before the loop runs settles at once, and a material is *values applied to a    // shader*, so the shader has to be loaded before the material can be built from it (IGX-0501    // otherwise). Four materials rather than one, so the select is an assignment and not a rebuild.    const shaders = SHADERS.map((row: ShaderRow) =>      app.assets.load<ShaderAsset>(row.address, { type: SHADER_ASSET_TYPE }),    );    await Promise.all(shaders.map((handle) => handle.promise));    const materials = new Map<string, AssetHandle<MaterialAsset>>();    for (let index = 0; index < SHADERS.length; index += 1) {      const row = SHADERS[index];      const shader = shaders[index];      if (row === undefined || shader === undefined) {        continue;      }      // The file's declared defaults apply on their own; `values` here only names the ones this      // example wants to open on something other than the file's default.      const values: Record<string, number> = {};      for (const uniform of row.uniforms) {        values[uniform.name] = uniform.value;      }      materials.set(row.label, createMaterialAsset(app, shaderMaterialDefinition({ shader, values }), []));    }    const subject = app.world.createEntity("Subject");    subject.transform.localPosition.set(0, SUBJECT.height, 0);    const renderer = subject.addComponent(MeshRenderer, {      mesh: MeshAsset.sphere(app, { diameter: SUBJECT.diameter, segments: SUBJECT.segments }),      materials: [materials.get(START_SHADER) ?? null],      // A custom shader material casts no shadow worth having: Lite gives it no shadow bindings, so      // the caster pass would draw the mesh's silhouette with this shader's own colour.      castShadows: false,      receiveShadows: false,    });    // One group per shader, so every slider is bound to the material it belongs to whether or not    // that material is the one on the mesh. `setUniform` re-uploads the material's own uniform    // block and recompiles nothing, which is what makes a slider cheap enough to drag.    const groups: PanelGroup[] = SHADERS.map((row: ShaderRow) => ({      label: row.label,      collapsed: row.label !== START_SHADER,      controls: row.uniforms.map((uniform) => {        const material = materials.get(row.label);        return slider(          uniform.label,          { min: uniform.min, max: uniform.max, step: uniform.step, format: decimals(uniform.places) },          {            value: uniform.value,            change: (value: number): void => {              material?.value.setUniform(uniform.name, value);            },          },        );      }),    }));    panel({      title: "Custom shader",      groups: [        {          label: "Shader",          controls: [            select(              "Material",              SHADERS.map((row: ShaderRow) => row.label),              {                value: START_SHADER,                change: (label: string): void => {                  // Swapping a material is one assignment. The two transparent shaders declare                  // `depthWrite off` in their own files, so nothing here has to know which is which.                  renderer.materials = [materials.get(label) ?? null];                },              },            ),            readout("Draw calls", (): string => String(app.renderer.drawCalls)),          ],        },        ...groups,      ],    });  },});
shot.ts
/** * The stage the four shaders are shown on, and the table that says which uniforms each of them puts * on the panel. * * @remarks * A separate file for the reason `pbr-model/shot.ts` is: none of it is a lesson about custom * shaders. `main.ts` is then the four loads, the four materials, the select that swaps one onto the * mesh, and the sliders that write the files' own declared uniforms — which is all a reader came for. * * ## Why the uniform table is written out here * * A `.wgsl` file's `// @ignifx uniform` lines already carry a range, a step and a tooltip, and * `ShaderAsset.declaration` hands them back at runtime — so a real inspector builds its rows from * the file and never repeats itself. The kit's panel takes a `label` and a `format` this * declaration has no field for, so the example writes the four rows it wants out longhand and keeps * the bounds the same as the file's. Reading the bounds off `shader.value.declaration.uniforms` * instead is three lines and is what an editor would do; it is not shown here because the point of * the example is the shader, not the panel. */import type { ColorLike } from "ignifx";/** The near-black the frame is cleared to; the additive shaders need somewhere dark to burn. */export const CLEAR_COLOR: ColorLike = { r: 0.012, g: 0.015, b: 0.023, a: 1 };/** The opening shot: the pose every capture is taken from. */export const SHOT = {  fov: 32,  yaw: 24,  pitch: 12,  distance: 3.1,  target: { x: 0, y: 0.62, z: 0 },  exposure: 1,} as const;/** The subject: one sphere, high enough off the floor to catch its own shadow. */export const SUBJECT = { diameter: 1.05, segments: 48, height: 0.62 } as const;/** How wide the plain floor is, in metres. */export const FLOOR_SIZE = 26;/** How wide the backdrop sphere is, in metres. */export const BACKDROP_DIAMETER = 18;/** One panel row: a uniform the shader file declares, and how the panel should draw it. */export interface UniformRow {  /** The visible label. */  readonly label: string;  /** The uniform's name, exactly as the `.wgsl` file declared it. */  readonly name: string;  /** The lowest value; the same bound the file's `range(…)` states. */  readonly min: number;  /** The highest value. */  readonly max: number;  /** The increment; the same one the file's `step(…)` states. */  readonly step: number;  /** The value the material opens on; the file's own default unless the example overrides it. */  readonly value: number;  /** How many decimals the value cell shows. */  readonly places: number;}/** One shader on the select: the file to load, and the rows its uniforms get. */export interface ShaderRow {  /** The label on the select and on the panel group. */  readonly label: string;  /** The asset address, under `website/examples/assets/`. */  readonly address: string;  /** The sliders the panel offers for it. */  readonly uniforms: readonly UniformRow[];}/** The four shaders, in select order. The opening frame is {@link START_SHADER}. */export const SHADERS: readonly ShaderRow[] = [  {    label: "Dissolve",    address: "shaders/custom-shader/dissolve.wgsl",    uniforms: [      { label: "Progress", name: "progress", min: 0, max: 1, step: 0.01, value: 0.35, places: 2 },      { label: "Edge width", name: "edgeWidth", min: 0.01, max: 0.3, step: 0.01, value: 0.09, places: 2 },    ],  },  {    label: "Toon",    address: "shaders/custom-shader/toon.wgsl",    uniforms: [      { label: "Bands", name: "bands", min: 1, max: 8, step: 1, value: 3, places: 0 },      { label: "Outline", name: "outline", min: 0, max: 0.8, step: 0.02, value: 0.28, places: 2 },    ],  },  {    label: "Hologram",    address: "shaders/custom-shader/hologram.wgsl",    uniforms: [      { label: "Scan lines", name: "scanFrequency", min: 10, max: 240, step: 5, value: 90, places: 0 },      { label: "Scan speed", name: "scanSpeed", min: 0, max: 3, step: 0.05, value: 0.6, places: 2 },      { label: "Glow", name: "glow", min: 0.2, max: 4, step: 0.1, value: 1.4, places: 1 },    ],  },  {    label: "Force field",    address: "shaders/custom-shader/forcefield.wgsl",    uniforms: [      { label: "Cells", name: "cells", min: 3, max: 40, step: 1, value: 14, places: 0 },      { label: "Pulse", name: "pulse", min: 0, max: 2, step: 0.05, value: 0.5, places: 2 },    ],  },];/** Which of {@link SHADERS} the example opens on, and therefore what the poster shows. */export const START_SHADER = "Dissolve";
../assets/shaders/custom-shader/dissolve.wgsl
// Dissolve: discard where a noise field falls under `progress`, and burn the surviving edge.
// The field is three multiplied sine waves rather than a sampled texture, so the file needs no
// asset of its own — and a `discard` is the one thing a PBR material cannot be talked into doing.
// @ignifx shader
// @ignifx attributes position, normal
// @ignifx system world, worldViewProjection, mainLightDirection, mainLightColor, ambientColor
// @ignifx uniform baseColor: vec3<f32> = color(0.42, 0.55, 0.72)
// @ignifx uniform edgeColor: vec3<f32> = color(1.0, 0.48, 0.12)
// @ignifx uniform progress: f32 = 0.35 range(0, 1) step(0.01) tooltip("How much of the surface has burned away.")
// @ignifx uniform edgeWidth: f32 = 0.09 range(0.01, 0.3) step(0.01) tooltip("How wide the burning edge is.")
// @ignifx blend opaque cull back

struct VertexOutput {
  @builtin(position) position: vec4<f32>,
  @location(0) normal: vec3<f32>,
  @location(1) localPosition: vec3<f32>,
}

fn field(p: vec3<f32>) -> f32 {
  let waves = sin(p.x * 7.3 + p.y * 3.1) * sin(p.y * 8.7 + p.z * 2.3) * sin(p.z * 6.1 + p.x * 4.7);
  return 0.5 + 0.5 * waves;
}

@vertex fn mainVertex(input: VertexInput) -> VertexOutput {
  var out: VertexOutput;
  out.position = shaderSystem.worldViewProjection * vec4<f32>(input.position, 1.0);
  out.normal = normalize((shaderSystem.world * vec4<f32>(input.normal, 0.0)).xyz);
  out.localPosition = input.position;
  return out;
}

@fragment fn mainFragment(input: VertexOutput) -> @location(0) vec4<f32> {
  let noise = field(input.localPosition);
  if (noise < shaderUniforms.progress) { discard; }
  let lambert = clamp(dot(normalize(input.normal), -normalize(shaderUniforms.mainLightDirection)), 0.0, 1.0);
  let lit = shaderUniforms.baseColor * (shaderUniforms.ambientColor + shaderUniforms.mainLightColor * lambert);
  // The band just above the cut glows: `edgeColor` is used as an emitter, so it survives the lambert.
  let heat = 1.0 - smoothstep(shaderUniforms.progress, shaderUniforms.progress + shaderUniforms.edgeWidth, noise);
  return vec4<f32>(mix(lit, shaderUniforms.edgeColor * 2.0, heat), 1.0);
}
../assets/shaders/custom-shader/toon.wgsl
// Cel shading: the light term quantised into bands, plus a dark outline from the view angle.
// `mainLightDirection` and `ambientColor` are ignifx's, not Babylon Lite's, so they are read from
// `shaderUniforms` beside this file's own values.
// @ignifx shader
// @ignifx attributes position, normal
// @ignifx system world, worldViewProjection, cameraPosition, mainLightDirection, mainLightColor, ambientColor
// @ignifx uniform baseColor: vec3<f32> = color(0.86, 0.31, 0.24)
// @ignifx uniform bands: f32 = 3 range(1, 8) step(1) tooltip("How many steps the light is quantised into.")
// @ignifx uniform outline: f32 = 0.28 range(0, 0.8) step(0.02) tooltip("How wide the silhouette rim is.")
// @ignifx blend opaque cull back

struct VertexOutput {
  @builtin(position) position: vec4<f32>,
  @location(0) normal: vec3<f32>,
  @location(1) worldPosition: vec3<f32>,
}

@vertex fn mainVertex(input: VertexInput) -> VertexOutput {
  var out: VertexOutput;
  out.position = shaderSystem.worldViewProjection * vec4<f32>(input.position, 1.0);
  out.normal = normalize((shaderSystem.world * vec4<f32>(input.normal, 0.0)).xyz);
  out.worldPosition = (shaderSystem.world * vec4<f32>(input.position, 1.0)).xyz;
  return out;
}

@fragment fn mainFragment(input: VertexOutput) -> @location(0) vec4<f32> {
  let normal = normalize(input.normal);
  let toLight = -normalize(shaderUniforms.mainLightDirection);
  let steps = max(shaderUniforms.bands, 1.0);
  // `floor(x * n) / n` is the whole of cel shading: the smooth lambert ramp becomes n flat plateaus.
  let lambert = clamp(dot(normal, toLight), 0.0, 1.0);
  let stepped = floor(lambert * steps) / steps;
  let lit = shaderUniforms.baseColor * (shaderUniforms.ambientColor + shaderUniforms.mainLightColor * stepped);
  let toEye = normalize(shaderSystem.cameraPosition - input.worldPosition);
  // The silhouette, not a post-process edge filter: a fragment whose normal turns away from the eye
  // is on the rim of the shape, whatever is behind it.
  let rim = 1.0 - smoothstep(shaderUniforms.outline * 0.5, shaderUniforms.outline, abs(dot(normal, toEye)));
  return vec4<f32>(mix(lit, vec3<f32>(0.02, 0.02, 0.04), rim), 1.0);
}
../assets/shaders/custom-shader/hologram.wgsl
// Hologram: horizontal scan lines that crawl with `time`, a fresnel edge, and additive blending so
// the floor shows through. `time` is ignifx's clock, not Babylon Lite's — Lite ships none — and it
// freezes under `app.pause()` and under the `?static=1` capture flag.
// @ignifx shader
// @ignifx attributes position, normal
// @ignifx system world, worldViewProjection, cameraPosition, time
// @ignifx uniform tint: vec3<f32> = color(0.29, 0.82, 1.0)
// @ignifx uniform scanFrequency: f32 = 90 range(10, 240) step(5) tooltip("Scan lines per metre of height.")
// @ignifx uniform scanSpeed: f32 = 0.6 range(0, 3) step(0.05) tooltip("How fast the lines crawl, metres a second.")
// @ignifx uniform glow: f32 = 1.4 range(0.2, 4) step(0.1) tooltip("Overall brightness of the projection.")
// @ignifx blend additive cull none depthWrite off

struct VertexOutput {
  @builtin(position) position: vec4<f32>,
  @location(0) normal: vec3<f32>,
  @location(1) worldPosition: vec3<f32>,
}

@vertex fn mainVertex(input: VertexInput) -> VertexOutput {
  var out: VertexOutput;
  out.position = shaderSystem.worldViewProjection * vec4<f32>(input.position, 1.0);
  out.normal = normalize((shaderSystem.world * vec4<f32>(input.normal, 0.0)).xyz);
  out.worldPosition = (shaderSystem.world * vec4<f32>(input.position, 1.0)).xyz;
  return out;
}

@fragment fn mainFragment(input: VertexOutput) -> @location(0) vec4<f32> {
  let scan = input.worldPosition.y * shaderUniforms.scanFrequency - shaderUniforms.time * shaderUniforms.scanSpeed * shaderUniforms.scanFrequency;
  let lines = 0.35 + 0.65 * pow(0.5 + 0.5 * sin(scan), 4.0);
  let toEye = normalize(shaderSystem.cameraPosition - input.worldPosition);
  // A grazing fragment is where a real projection is brightest, so the fresnel term is added, not
  // multiplied: the silhouette burns and the front face stays readable.
  let fresnel = pow(1.0 - clamp(abs(dot(normalize(input.normal), toEye)), 0.0, 1.0), 2.5);
  let strength = (lines * 0.6 + fresnel * 1.2) * shaderUniforms.glow;
  // `blend additive` ignores the alpha channel, so the colour alone carries the brightness.
  return vec4<f32>(shaderUniforms.tint * strength, 1.0);
}
../assets/shaders/custom-shader/forcefield.wgsl
// Force field: a fresnel shell with a hex lattice on it, drawn premultiplied so the shape reads as
// glass rather than as a flat additive glow. `blend premultiplied` is the mode Babylon Lite cannot
// name — its own `"alpha"` multiplies by alpha a second time — so ignifx supplies the blend state.
// @ignifx shader
// @ignifx attributes position, normal, uv
// @ignifx system world, worldViewProjection, cameraPosition, time
// @ignifx uniform tint: vec3<f32> = color(0.35, 0.66, 1.0)
// @ignifx uniform cells: f32 = 14 range(3, 40) step(1) tooltip("How many lattice cells fit across the shell.")
// @ignifx uniform pulse: f32 = 0.5 range(0, 2) step(0.05) tooltip("How hard the lattice breathes.")
// @ignifx blend premultiplied cull none depthWrite off

struct VertexOutput {
  @builtin(position) position: vec4<f32>,
  @location(0) normal: vec3<f32>,
  @location(1) worldPosition: vec3<f32>,
  @location(2) uv: vec2<f32>,
}

@vertex fn mainVertex(input: VertexInput) -> VertexOutput {
  var out: VertexOutput;
  out.position = shaderSystem.worldViewProjection * vec4<f32>(input.position, 1.0);
  out.normal = normalize((shaderSystem.world * vec4<f32>(input.normal, 0.0)).xyz);
  out.worldPosition = (shaderSystem.world * vec4<f32>(input.position, 1.0)).xyz;
  out.uv = input.uv;
  return out;
}

@fragment fn mainFragment(input: VertexOutput) -> @location(0) vec4<f32> {
  let grid = abs(fract(input.uv * shaderUniforms.cells) - vec2<f32>(0.5, 0.5));
  let lattice = 1.0 - smoothstep(0.34, 0.46, min(grid.x, grid.y));
  let toEye = normalize(shaderSystem.cameraPosition - input.worldPosition);
  let fresnel = pow(1.0 - clamp(abs(dot(normalize(input.normal), toEye)), 0.0, 1.0), 2.0);
  let breathe = 1.0 + shaderUniforms.pulse * sin(shaderUniforms.time * 2.4);
  let alpha = clamp((fresnel * 0.85 + lattice * 0.35) * breathe, 0.0, 1.0);
  // Premultiplied: the colour is already multiplied by the coverage it is written with, which is
  // what lets the lattice and the rim share one draw without double-darkening the overlap.
  return vec4<f32>(shaderUniforms.tint * alpha * 1.6, alpha);
}

Uses:shaderMaterialDefinitionMaterialAsset.setUniformSHADER_ASSET_TYPEMeshRendererEnvironment

Assets:everything in this example is created in code.