Skip to content

Custom shaders

insomni draws everything through a fixed set of built-in “kinds” (rects, circles, strokes, sprites, glyphs…). Custom shaders are the escape hatch when you want a pixel program the built-ins don’t cover — an animated sprite effect, a generative background, an image filter, a refracting lens.

The public entry point is createTextureEffect: a textured quad, placed by the renderer’s camera, whose fragment stage you supply as a string of WGSL. It returns a CustomDrawable, so it drops straight into the same render([...]) call as any layer — no extra pipelines to manage, no core changes.

import { createTextureEffect, loadTexture } from "insomni";
const texture = await loadTexture(renderer, "/ship.png");
const effect = createTextureEffect(renderer, {
texture,
rect: { x: 40, y: 40, width: 128, height: 128 },
fragment: /* wgsl */ `
let c = texAt(uv);
if (c.a < 0.01) { discard; }
return c + vec4f(vec3f(sin(u.time) * 0.3), 0.0) * c.a;
`,
});
renderer.render([effect]);
Custom Shaders — Sprite Effects Two custom WGSL fragment shaders applied to one loaded PNG via createTextureEffect: an animated light sweep (left) and a static white outline (right).

createTextureEffect always shades a textured quad — but whether the fragment reads the texture is up to you. That single fact gives you two distinct uses:

ShapeTextureRectUsed for
Per-elementa loaded imagethe sprite’s placement boxsprite effects, image filters — outline, pixelate, cel-shade
Full-viewa 1×1 blank, ignoredthe whole viewportgenerative backgrounds, plasma/noise, glass lenses, water

A per-element effect samples the texture with texAt(uv) and transforms it. A full-view effect ignores the texture entirely and computes every pixel from uv

  • uniforms — the classic full-screen shader. The guide walks through both.

An effect is a CustomDrawable: the renderer hands it an active render pass and it records one 4-vertex draw in an explicit render phase —

  • Placement is "underlay" (before all scene layers) or "overlay" (the existing late phase and default). Array position orders effects only within a phase.
  • Space is "ui" (pixels) or "world" (through the live camera), same as a layer. Both are dpr- and resize-correct because the effect binds the renderer’s real camera bind group.
  • Blending is premultiplied alpha, matching sprites — your fragment must return a premultiplied vec4f (or discard).

To land a custom shader between a layer’s own primitives instead of a fixed phase, push it into the layer with layer.pushCustom — it z-interleaves at its submission point with the layer’s shapes, glyphs, and sprites.

It shades a quad from a texture you hand it plus uniforms. It is not a post-process over the already-rendered scene — there is no built-in “grab the frame and filter it” pass yet, so effects like a custom full-screen anti-alias or a scene-wide bloom aren’t expressible through createTextureEffect alone. Those need render-to-texture; if you need them, reach for a raw CustomDrawable with your own offscreen target.

The compiled pipeline and bind-group layouts are cached per device, keyed by the WGSL source. An outline shader applied to 100 sprites is one shader compile and one pipeline — each effect owns only a small uniform buffer and two bind groups. A static effect (no set() calls per frame) does zero per-frame CPU work, and damage-tracked rendering skips it entirely on idle frames. You pay for what animates, nothing more.

The fragment field is just WGSL text, and TypeGPU is a WGSL generator — so you can author an effect in TGSL and pass the resolved string. See Writing shader effects for the bridge.