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.
The cache
Section titled “The cache”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.
Why it matters
Section titled “Why it matters”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.
Update: TextureRegion support has landed
Section titled “Update: TextureRegion support has landed”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.