Premultiplied Alpha

Premultiplied Alpha and Common Pitfalls

A premultiplied color stores red, green and blue already multiplied by alpha: half-transparent orange is (0.4, 0.2, 0.1, 0.5). It blends with blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA), filters cleanly at edges, and is what the page expects: premultipliedAlpha defaults to true, so the canvas is composited as premultiplied. The first render of Enabling Alpha Blending's figure broke this: plain alpha blending left alpha below 1 and the canvas came out washed nearly white. The fixes, and related pitfalls:

Three canvases over a checkered page: washed-out plain alpha, alpha kept at 1, and premultiplied see-throughHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  .row { display: flex; gap: 8px; max-width: 600px; }
  .cell { flex: 1; text-align: center; }
  canvas { display: block; width: 100%; aspect-ratio: 1;
           background: repeating-conic-gradient(#d9d3c7 0 25%, #fff 0 50%) 0 0 / 24px 24px; }
  code { display: block; margin-top: 4px; font-size: 10px; }
</style>
<div class="row">
  <div class="cell"><canvas id="a" width="190" height="190"></canvas><code>blendFunc(SRC_ALPHA, 1-SRC_ALPHA)<br>alpha left below 1: washed out</code></div>
  <div class="cell"><canvas id="b" width="190" height="190"></canvas><code>blendFuncSeparate(..., ONE, 1-SRC_ALPHA)<br>alpha stays 1</code></div>
  <div class="cell"><canvas id="c" width="190" height="190"></canvas><code>clear to (0,0,0,0), rgb *= a<br>blendFunc(ONE, 1-SRC_ALPHA)</code></div>
</div>
<script>
// Each canvas has its own context; premultipliedAlpha defaults to true for all three.
function scene(id, setup) {
  const gl = document.getElementById(id).getContext('webgl2');
  const program = gl.createProgram();
  for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
uniform vec2 uCenter; uniform float uSize; out vec2 vLocal;
void main() {
  vLocal = vec2[6](vec2(-1), vec2(1, -1), vec2(1), vec2(-1), vec2(1), vec2(-1, 1))[gl_VertexID];
  gl_Position = vec4(uCenter + vLocal * uSize, 0.0, 1.0);
}`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; uniform vec4 uColor; uniform bool uPremultiply; in vec2 vLocal; out vec4 c;
void main() {
  if (length(vLocal) > 1.0) discard;
  c = uColor;
  if (uPremultiply) c.rgb *= c.a;             // premultiplied: colour already scaled by alpha
}`]]) {
    const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
    gl.attachShader(program, s);
  }
  gl.linkProgram(program); gl.useProgram(program);
  gl.bindVertexArray(gl.createVertexArray());
  const u = (n) => gl.getUniformLocation(program, n);
  const premultiply = setup(gl);
  gl.uniform1i(u('uPremultiply'), premultiply);
  gl.enable(gl.BLEND);
  [[[-0.3, 0.2], [0.12, 0.37, 0.55, 0.6]], [[0.3, 0.2], [0.88, 0.6, 0.06, 0.6]], [[0, -0.3], [0.71, 0.27, 0.18, 0.6]]]
    .forEach(([center, color]) => {
      gl.uniform2fv(u('uCenter'), center); gl.uniform1f(u('uSize'), 0.55); gl.uniform4fv(u('uColor'), color);
      gl.drawArrays(gl.TRIANGLES, 0, 6);
    });
}
scene('a', (gl) => {                          // opaque clear, but blending lowers alpha
  gl.clearColor(0.93, 0.91, 0.87, 1); gl.clear(gl.COLOR_BUFFER_BIT);
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
  return false;
});
scene('b', (gl) => {                          // colour as before, alpha accumulates toward 1
  gl.clearColor(0.93, 0.91, 0.87, 1); gl.clear(gl.COLOR_BUFFER_BIT);
  gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
  return false;
});
scene('c', (gl) => {                          // a deliberately see-through canvas
  gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
  gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
  return true;
});
</script>