Skip to content

Writing shader effects

createTextureEffect(renderer, options) shades a textured quad with a WGSL fragment you supply, and returns a CustomDrawable you drop into render([...]). For the mental model and where it fits, start with Custom shaders; this page is the reference.

OptionTypeDefaultNotes
textureTextureThe image to shade (from loadTexture). Bound even for full-view effects that ignore it.
fragmentstringWGSL fragment body — statements that return a premultiplied vec4f.
fragmentModulestringModule-scope WGSL defining fn effect(uv: vec2f) -> vec4f. Exclusive with fragment.
rect{x,y,width,height}Placement box in space units (top-left + size).
paddingnumber0Extra room, per side, so effects can draw outside the image (outline, glow).
space"ui" | "world""ui"ui = pixels; world = through the live camera.
uniformsRecord<string, number>{}Named f32 uniforms, read as u.<name>. Values here are the initial values.
filter"linear" | "nearest""linear"Sampler filter. nearest for crisp pixel-art.
placement"underlay" | "overlay""overlay"Render before all scene layers or in the existing late phase.

The returned TextureEffect adds three methods to the drawable:

  • set(values) — update time and/or any named uniform: effect.set({ time, intensity }). Keys not present are unchanged; an unknown key throws.
  • setRect(rect) — move/resize the quad (call from your resize handler).
  • destroy() — release the uniform buffer + bind groups. Does not destroy the texture (caller-owned).

Your fragment string is spliced into @fragment fn fs(...) -> @location(0) vec4f { … }. In scope:

SymbolTypeMeaning
uvvec2f0..1 across the image. Runs negative / >1 across the padding band.
texAt(p)(vec2f) -> vec4fSample the texture, returning transparent outside 0..1. Prefer this.
tex / samptexture / samplerThe raw texture and sampler, if you need textureSample directly.
u.timef32Whatever you last passed to set({ time }). 0 until you set it.
u.texelvec2f(1/width, 1/height) — one-pixel step, for neighbour taps.
u.<name>f32Each key of uniforms.

You must return a premultiplied-alpha vec4f (opaque colours are just vec4f(rgb, 1.0)). discard is allowed.

For helpers, use fragmentModule instead. It is inserted at module scope and must define exactly fn effect(uv: vec2f) -> vec4f; it may reference tex, samp, texAt, and u. Names owned by Insomni (Camera, Effect, VsOut, camera, u, tex, samp, texAt, vs, fs) are reserved. Invalid or duplicate declarations fail through normal WebGPU shader diagnostics.

Sample the loaded texture and transform it. Place the quad at the sprite’s box, animate by pushing time each frame:

const shine = createTextureEffect(renderer, {
texture,
filter: "nearest",
rect: { x: 0, y: 0, width: 128, height: 128 },
uniforms: { speed: 0.25, intensity: 0.9 },
fragment: /* wgsl */ `
let base = texAt(uv);
if (base.a < 0.01) { discard; }
let band = abs(fract(uv.x * 0.5 + uv.y * 0.5 - u.time * u.speed) - 0.5);
let sweep = smoothstep(0.07, 0.0, band) * u.intensity;
return base + vec4f(vec3f(sweep), 0.0) * base.a;
`,
});
// per frame:
shine.set({ time: now / 1000 });
renderer.render([shine]);
Custom Shaders — Image Filters Three common per-element filters — pixelate, cel-shade, and chromatic aberration — each one small WGSL fragment over the same loaded PNG.

Neighbour taps via u.texel are the basis for most image filters — pixelate (snap uv to a grid), cel-shade (posterise + test surrounding alpha for a silhouette ink), chromatic aberration (sample channels at an x-offset). The demo above is all three.

By default the drawn quad is exactly rect, so an effect can’t paint past the image. Set padding to grow the quad by N space-units per side. The texture stays at rect; uv runs negative / >1 across the band, and texAt returns transparent there — so a ring-sampled outline extends cleanly outside the sprite instead of smearing its edge pixels:

const outline = createTextureEffect(renderer, {
texture,
padding: 24, // room for the outline
uniforms: { radius: 4 },
fragment: /* wgsl */ `
let base = texAt(uv);
if (base.a > 0.01) { return base; }
let r = u.texel * u.radius;
var a = 0.0;
for (var i = 0; i < 8; i = i + 1) {
let ang = f32(i) * 0.785398;
a = max(a, texAt(uv + vec2f(cos(ang), sin(ang)) * r).a);
}
return vec4f(1.0, 1.0, 1.0, 1.0) * smoothstep(0.01, 0.6, a);
`,
});

Nothing forces the fragment to read the texture. Bind a 1×1 blank, size the rect to the viewport, and generate every pixel from uv + uniforms:

// a texture must be bound, but a full-view procedural shader never samples it
const blank = await loadTexture(renderer, new ImageData(1, 1));
const plasma = createTextureEffect(renderer, {
texture: blank,
rect: { x: 0, y: 0, width: 0, height: 0 }, // sized in resize()
placement: "underlay",
fragment: /* wgsl */ `
let p = uv * 3.0;
let t = u.time * 0.4;
var v = sin(p.x + t) + sin((p.y + t) * 1.3) + sin((p.x + p.y + t) * 0.7);
let col = vec3f(0.5) + 0.5 * cos(vec3f(v) * 1.4 + vec3f(0.0, 2.0, 4.0));
return vec4f(col, 1.0);
`,
});
// resize(w, h): plasma.setRect({ x: 0, y: 0, width: w / dpr, height: h / dpr });
Custom Shaders — Full-view Plasma A single full-viewport WGSL fragment that generates an animated plasma from `uv` + a `time` uniform — no geometry, no texture.

Named uniforms are the channel for any per-frame value — cursor, camera, audio. Add them to uniforms, then set() from your event handler. Here the cursor, normalised to 0..1 viewport space, drives ripples and a glass lens:

const water = createTextureEffect(renderer, {
texture: blank,
rect: { x: 0, y: 0, width: 0, height: 0 },
placement: "underlay",
uniforms: { mx: 0.5, my: 0.5 },
fragment: /* wgsl */ `
let d = distance(uv, vec2f(u.mx, u.my));
let ripple = sin(d * 42.0 - u.time * 4.0) * exp(-d * 4.0);
let col = mix(vec3f(0.02, 0.20, 0.42), vec3f(0.34, 0.78, 0.98), 0.5 + 0.4 * ripple);
return vec4f(col, 1.0);
`,
});
canvas.addEventListener("pointermove", (e) => {
const r = canvas.getBoundingClientRect();
water.set({
mx: (e.clientX - r.left) / r.width,
my: (e.clientY - r.top) / r.height,
});
});
Custom Shaders — Cursor Ripples A full-view water shader driven by two extra uniforms — the cursor position — updated from a pointer listener so ripples follow the mouse.

The liquid-glass demo extends the same pattern into a refracting capsule lens over a real baked backdrop — cylindrical/spherical distortion, chromatic fringing, and a rim highlight, all keyed off the cursor uniforms.

  • Premultiplied alpha. The blend state is premultiplied. Return vec4f(rgb, 1.0) for opaque, or premultiply your colour by its alpha (vec4f(rgb, a) * a shapes).
  • Sampling in a data-dependent branch. A plain textureSample is illegal after a uv-dependent (non-uniform) branch — it needs implicit derivatives. texAt uses textureSampleLevel(..., 0.0) internally, so it’s always safe; prefer it.
  • Helpers require fragmentModule. fragment is only an fs body; module- scope helper functions belong in fragmentModule alongside effect.
  • normalize at the origin returns NaN. Guard with select(vec2f(0.0), rel / d, d > 1e-4).

fragmentModule accepts helper-only declaration output emitted by TypeGPU. Resolve once with strict names; the literal wrapper keeps Insomni’s required effect name stable:

import tgpu from "typegpu";
const fragmentModule = tgpu.resolve({
names: "strict",
template: `
fn effect(uv: vec2f) -> vec4f {
return effectImpl(uv);
}
`,
externals: { effectImpl },
});
createTextureEffect(renderer, { texture, rect, fragmentModule });

TypeGPU resources and bind-group layouts are outside this API, as are WGSL extension directives. Use only constants and helper functions that rely on Insomni’s existing tex, samp, texAt, and u declarations.

Do not splice or brace-balance resolved modules into fragment. Both shader forms use the full generated WGSL as the pipeline-cache key and have identical per-frame cost; placement does not create pipeline variants.