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:
Keep the buffer's alpha at 1 with blendFuncSeparate(..., gl.ONE, gl.ONE_MINUS_SRC_ALPHA), or create the context with { alpha: false } when the page never needs to show through.
For a see-through canvas, write premultiplied colors (c.rgb *= c.a) and clear to (0, 0, 0, 0).
Textures are premultiplied only with UNPACK_PREMULTIPLY_ALPHA_WEBGL set (Textures); mixed conventions give dark or bright fringes.
Cut-outs such as leaves suit discard (Control Flow) better than blending: they need no sorting.
<!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>