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.
Options
Section titled “Options”| Option | Type | Default | Notes |
|---|---|---|---|
texture | Texture | — | The image to shade (from loadTexture). Bound even for full-view effects that ignore it. |
fragment | string | — | WGSL fragment body — statements that return a premultiplied vec4f. |
fragmentModule | string | — | Module-scope WGSL defining fn effect(uv: vec2f) -> vec4f. Exclusive with fragment. |
rect | {x,y,width,height} | — | Placement box in space units (top-left + size). |
padding | number | 0 | Extra room, per side, so effects can draw outside the image (outline, glow). |
space | "ui" | "world" | "ui" | ui = pixels; world = through the live camera. |
uniforms | Record<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)— updatetimeand/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 yourresizehandler).destroy()— release the uniform buffer + bind groups. Does not destroy the texture (caller-owned).
The fragment contract
Section titled “The fragment contract”Your fragment string is spliced into @fragment fn fs(...) -> @location(0) vec4f { … }.
In scope:
| Symbol | Type | Meaning |
|---|---|---|
uv | vec2f | 0..1 across the image. Runs negative / >1 across the padding band. |
texAt(p) | (vec2f) -> vec4f | Sample the texture, returning transparent outside 0..1. Prefer this. |
tex / samp | texture / sampler | The raw texture and sampler, if you need textureSample directly. |
u.time | f32 | Whatever you last passed to set({ time }). 0 until you set it. |
u.texel | vec2f | (1/width, 1/height) — one-pixel step, for neighbour taps. |
u.<name> | f32 | Each 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.
Per-element effects
Section titled “Per-element effects”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]);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.
Effects beyond the image bounds
Section titled “Effects beyond the image bounds”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); `,});Full-view effects
Section titled “Full-view effects”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 itconst 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 });Feeding live input
Section titled “Feeding live input”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, });});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.
Gotchas
Section titled “Gotchas”- Premultiplied alpha. The blend state is premultiplied. Return
vec4f(rgb, 1.0)for opaque, or premultiply your colour by its alpha (vec4f(rgb, a) * ashapes). - Sampling in a data-dependent branch. A plain
textureSampleis illegal after auv-dependent (non-uniform) branch — it needs implicit derivatives.texAtusestextureSampleLevel(..., 0.0)internally, so it’s always safe; prefer it. - Helpers require
fragmentModule.fragmentis only anfsbody; module- scope helper functions belong infragmentModulealongsideeffect. normalizeat the origin returns NaN. Guard withselect(vec2f(0.0), rel / d, d > 1e-4).
TypeGPU
Section titled “TypeGPU”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.
See also
Section titled “See also”- Custom shaders — concepts and where effects fit.
- Examples: Sprite effects · Image filters · Plasma · Cursor ripples · Liquid glass
- CustomDrawable — the raw hook for pipelines
createTextureEffectdoesn’t cover.