Premultiplied alpha — the 3 standard mistakes
insomni’s blend state — sprites, shapes, custom shaders,
texture effects, and the OIT resolve pass
— is premultiplied alpha, all the way through. Every kind’s SDF fragment
emits premultiplied RGBA, and the OIT A-buffer’s over-composite assumes it
(see assemble.ts’s emphasis-apply comment: “outColor is already
premultiplied … every kind emits premultiplied RGBA”). The moment you write
your own fragment shader — a createTextureEffect or
a custom shader — you inherit that contract, and it’s
the single most common thing to get wrong first. The docs already say “return
a premultiplied vec4f” in a few places; this page is the loud version, with
the three mistakes shown before/after.
If you’ve never touched premultiplied alpha: in straight alpha, RGB is the
color independent of coverage — vec4f(1, 0, 0, 0.5) is “fully red, 50%
covered.” In premultiplied alpha, RGB is already scaled by coverage —
the same half-covered red is vec4f(0.5, 0, 0, 0.5). Premultiplied composites
correctly under over-blending and additive blending without a second divide;
straight alpha doesn’t, which is why the renderer standardizes on it.
Mistake 1 — returning straight alpha from a fragment
Section titled “Mistake 1 — returning straight alpha from a fragment”The most common one: color math feels natural in straight alpha (“this pixel is red, and 50% transparent”), so a fresh custom shader returns exactly that.
// BEFORE — straight alpha, wrong for this rendererreturn vec4f(1.0, 0.0, 0.0, 0.5);Composited with premultiplied over, this reads as more red than intended
and produces dark fringes at edges — the classic symptom, because the
blend hardware never divides RGB back down before compositing.
// AFTER — premultiply RGB by alpha before returninglet a = 0.5;return vec4f(vec3f(1.0, 0.0, 0.0) * a, a);If you’re porting a fragment that computes a straight-alpha color and alpha
separately, the fix is always vec4f(rgb * a, a) at the return — never
vec4f(rgb, a).
Mistake 2 — double-premultiplying (or double-darkening a glow)
Section titled “Mistake 2 — double-premultiplying (or double-darkening a glow)”The opposite error: a shader is already premultiplied correctly, but a
downstream step (a texture sample, a manual * alpha, or copying a snippet
that itself premultiplies) multiplies by alpha again.
// BEFORE — texAt() already returns premultiplied texel data;// multiplying by alpha a second time double-darkens translucent glow pixelslet texel = texAt(uv);return vec4f(texel.rgb * texel.a, texel.a);This shows up as glow/bloom halos that look muddier and darker than the source art the more transparent they are — the “double-darkened glow” failure mode. The fix is to premultiply once, at the one place a straight value is produced (a literal color, a straight-alpha texture load, an additive tint) — not again on the way out:
// AFTER — texel is already premultiplied; pass it throughlet texel = texAt(uv);return texel;The rule of thumb: trace each RGBA value back to where it was born. If it
came from texAt/a loaded Texture (loaded via insomni’s texture loader,
which premultiplies at upload time — premultipliedAlpha: true in
copyExternalImageToTexture) or another kind’s outColor, it is already
premultiplied — don’t multiply it by alpha again. Only multiply when you’re
converting a genuinely straight-alpha value (a literal, a uniform tint) into
the pipeline.
Mistake 3 — forgetting a data boundary that expects straight alpha
Section titled “Mistake 3 — forgetting a data boundary that expects straight alpha”The contract flips at the edges of the renderer. Node headless readback
(insomni-node’s toPNG()) returns unpremultiplied pixels by default —
matching what a PNG file and most image tooling expect — so if you’re feeding
GPU-side premultiplied data into CPU code that assumes straight alpha (or
vice versa: uploading a straight-alpha buffer as if it were premultiplied),
you’ll get one of two visible bugs: colors that look premultiplied-darkened
in an exported PNG, or (uploading raw straight-alpha pixels without going
through loadTexture) a texture that renders too bright/fringed on-screen.
// BEFORE — assumes toPNG() gives premultiplied bytes; it doesn'tconst png = await target.toPNG();// treating png's RGB as if still scaled by alpha here is wrong// AFTER — toPNG() already un-premultiplies for you; treat its RGB as straightconst png = await target.toPNG(); // unpremultiplied by default// safe to hand straight to PNG encoders / image tooling as-isThe general fix is the same shape every time: know which side of a boundary
you’re on. Inside the renderer (fragment shaders, outColor, the OIT
A-buffer, loaded Textures) everything is premultiplied. Outside it (PNG
bytes from insomni-node, straight-alpha source art before loadTexture
converts it) everything is straight. Converting at the wrong boundary — or
not converting at all — is what causes both directions of this bug.
Quick checklist
Section titled “Quick checklist”- Custom fragment
returns a color: multiply RGB by alpha once, at thereturn, not before. - Sampling
texAt/a loadedTexture: it’s already premultiplied — don’t multiply by alpha again. - Crossing into/out of the renderer (
insomni-nodereadback, raw pixel buffers beforeloadTexture): straight alpha on the outside, premultiplied on the inside.