Skip to content

Pipeline sharing for texture effects

createTextureEffect(renderer, options) (from insomni) lets you shade a textured quad with your own WGSL fragment. Every call site looks like it builds its own pipeline — it doesn’t. The renderer keys a per-camera-layout cache by `${format}\n${wgsl}`, so effects with byte-identical generated WGSL source share one GPURenderPipeline, no matter how many TextureEffect instances you create from them.

From packages/insomni/src/effects/texture-effect.ts:

// Keyed by the renderer's camera layout (so pipelines stay compatible with the
// exact `ctx.cameraBindGroup` they'll be drawn with), then by `format\nwgsl`.
const PIPELINE_CACHE = new WeakMap<GPUBindGroupLayout, Map<string, GPURenderPipeline>>();

Every createTextureEffect call assembles a full WGSL module (camera struct + your fragment/fragmentModule + the fixed vertex stage) via buildEffectWgsl, then looks that exact string up in PIPELINE_CACHE. First lookup compiles a shader module and builds a GPURenderPipeline; every subsequent effect with the same generated WGSL (and render target format) reuses that pipeline — no new shader compile, no new pipeline object. Bind group layouts are cached too (LAYOUT_CACHE, keyed per GPUDevice), so the only thing that’s ever per-instance is the small Effect uniform buffer and its bind group.

Shader module compilation and createRenderPipeline are the expensive parts of standing up a GPU effect — expensive enough that doing it once per frame, or once per on-screen instance, is a real cost. The pipeline-sharing contract is what makes “apply the same effect to a hundred sprites” cheap:

// 100 outline effects, but ONE pipeline — same fragment source every time.
const outlines = sprites.map((sprite) =>
createTextureEffect(renderer, {
texture: sprite.texture,
rect: sprite.rect,
padding: 4,
fragment: OUTLINE_FRAGMENT_WGSL, // identical string every call
uniforms: { strength: 1 },
}),
);

Each TextureEffect still owns its own uniform buffer and bind group (so effect.set(...) / effect.setRect(...) are independent per instance), but GPU pipeline construction happens exactly once for all 100.

The corollary: bake constants into WGSL and you pay for it N times

Section titled “The corollary: bake constants into WGSL and you pay for it N times”

The cache key is the generated string, not some semantic identity of the shader. If your WGSL source varies per instance — because you interpolated a color, a sheet-cell constant, or any other “constant” into the template string before calling createTextureEffect — every distinct value produces a distinct cache entry, i.e. its own shader compile and its own pipeline:

// BAD: bakes a per-instance color into source. N distinct colors = N pipelines.
fragment: `return vec4f(${r}, ${g}, ${b}, 1.0) * texAt(uv).a;`,
// GOOD: same source every time, color travels through a uniform.
fragment: `return vec4f(u.r, u.g, u.b, 1.0) * texAt(uv).a;`,
uniforms: { r, g, b },

The rule of thumb: parameterize via uniforms, not via string interpolation. Anything that changes per-instance or per-frame belongs in the Effect uniform buffer (effect.set({...})); anything that’s truly fixed for the life of the app (a #define-style constant, a code branch chosen at setup time) is fine to bake into the WGSL string, since it only costs one extra pipeline for the whole run, not one per instance.

createTextureEffect’s texture option now accepts a TextureRegion (a sub-rect of a shared atlas, same shape Layer.pushSprite takes) as well as a whole Texture, plus a matching effect.setRegion(region). That doesn’t change the pipeline-sharing story above — a TextureEffect per atlas region is still one createTextureEffect call per region, and they still share one GPURenderPipeline as long as their generated WGSL is byte-identical (the region sub-rect lives in the per-instance uniform buffer, not in the WGSL string, so it never busts the cache). What it does not yet do is instancing: N regions still means N TextureEffect instances and N draw calls, just against one shared pipeline — not the “N regions, one draw call” story that true instanced batching would give you. That’s still ahead.