Skip to content

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 renderer
return 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 returning
let 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 pixels
let 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 through
let 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't
const 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 straight
const png = await target.toPNG(); // unpremultiplied by default
// safe to hand straight to PNG encoders / image tooling as-is

The 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.

  • Custom fragment returns a color: multiply RGB by alpha once, at the return, not before.
  • Sampling texAt/a loaded Texture: it’s already premultiplied — don’t multiply by alpha again.
  • Crossing into/out of the renderer (insomni-node readback, raw pixel buffers before loadTexture): straight alpha on the outside, premultiplied on the inside.