Blending, off by default, mixes each new fragment (the source) with the color already in the buffer (the destination): result = source x source factor + destination x destination factor. For ordinary transparency, gl.enable(gl.BLEND) and blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA). The figure adds a glass door (25% opaque) and shows an out-of-stock book as a ghost (40%), drawn first on the left and last on the right:
<canvas id="c" width="1240" height="300" style="width:620px;height:150px"></canvas>
<script src="https://cdn.jsdelivr.net/npm/gl-matrix@3.4.4/gl-matrix-min.js"></script>
<script src="webgl-helper.js"></script>
<script>
const gl = GLH.context('c'), { attributes, uniforms } = GLH.tint(gl);
const { mat4, vec3 } = glMatrix, shelf = GLH.shelf(gl, attributes), eye = [0.9, 0.9, 2.9];
const glass = { color: [0.72, 0.86, 0.95], alpha: 0.25, model: mat4.create() };
mat4.translate(glass.model, glass.model, [0, 0.03, 0.45]);
mat4.scale(glass.model, glass.model, [3.5, 1.1, 0.02]); // a thin pane in front
const ghost = Object.assign(shelf.items[4], { alpha: 0.4 }); // out of stock
const opaque = shelf.items.filter((item) => item !== ghost);
const distance = (item) => vec3.distance(eye, mat4.getTranslation(vec3.create(), item.model));
const farToNear = [glass, ghost].sort((a, b) => distance(b) - distance(a));
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); // one layer of glass, not two
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
GLH.panes(gl, 2, (pane, aspect) => {
uniforms.uViewProjection(shelf.camera(aspect, eye));
gl.enable(gl.BLEND);
if (pane === 0) shelf.draw(uniforms, [glass, ghost]); // wrong: before the opaque books
gl.disable(gl.BLEND);
shelf.draw(uniforms, opaque);
if (pane === 1) { // right: last, sorted, no depth writes
gl.enable(gl.BLEND); gl.depthMask(false);
shelf.draw(uniforms, farToNear);
gl.depthMask(true);
}
});
window.__done = true;
</script>
On the left the glass wrote its depth first, so every book behind it failed the depth test; on the right each layer shows through the next. Premultiplied Alpha explains the blendFuncSeparate() call.
<!doctype html>
<style>
body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
canvas { display: block; width: 100%; max-width: 600px; }
.names { display: flex; max-width: 600px; text-align: center; font-family: monospace; }
.names div { flex: 1; padding: 4px 2px; }
</style>
<canvas id="c" width="1200" height="320"></canvas>
<div class="names"><div>transparent drawn first (wrong)</div><div>opaque first, then sorted, depthMask(false)</div></div>
<script>
const m4 = {
perspective(fovy, aspect, near, far) {
const f = 1 / Math.tan(fovy * Math.PI / 360), d = near - far;
return [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) / d, -1, 0, 0, 2 * far * near / d, 0];
},
lookAt(eye, target, up = [0, 1, 0]) {
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const unit = (v) => v.map((c) => c / Math.hypot(...v));
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const z = unit(eye.map((c, i) => c - target[i])), x = unit(cross(up, z)), y = cross(z, x);
return [x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1];
},
multiply(a, b) {
const out = new Array(16).fill(0);
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) out[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
return out;
},
box: (x, y, z, sx, sy, sz) => [sx, 0, 0, 0, 0, sy, 0, 0, 0, 0, sz, 0, x, y, z, 1],
};
function cube() {
const faces = [[[0,0,1],[1,0,0],[0,1,0]], [[0,0,-1],[-1,0,0],[0,1,0]], [[1,0,0],[0,0,-1],[0,1,0]],
[[-1,0,0],[0,0,1],[0,1,0]], [[0,1,0],[1,0,0],[0,0,-1]], [[0,-1,0],[1,0,0],[0,0,1]]];
const out = [];
for (const [n, u, v] of faces) for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]])
out.push(...[0, 1, 2].map(i => (n[i] + a * u[i] + b * v[i]) / 2), ...n);
return new Float32Array(out);
}
const gl = document.getElementById('c').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal;
uniform mat4 uViewProjection, uModel; out float vLight;
void main() {
vLight = 0.35 + 0.65 * max(dot(normalize(mat3(uModel) * aNormal), normalize(vec3(0.4, 0.8, 0.6))), 0.0);
gl_Position = uViewProjection * uModel * vec4(aPosition, 1.0);
}`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; uniform vec3 uColor; uniform float uAlpha; in float vLight; out vec4 c;
void main() { c = vec4(uColor * vLight, uAlpha); }`]]) {
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
gl.attachShader(program, s);
}
gl.linkProgram(program); gl.useProgram(program);
const u = (n) => gl.getUniformLocation(program, n);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, cube(), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12); gl.enableVertexAttribArray(1);
const colors = [[0.12, 0.37, 0.55], [0.36, 0.25, 0.6], [0.88, 0.6, 0.06], [0.25, 0.49, 0.23], [0.71, 0.27, 0.18], [0.16, 0.62, 0.56]];
const items = colors.map((color, i) => ({ color, alpha: 1, position: [-1.25 + i * 0.5, 0.5, 0],
model: m4.box(-1.25 + i * 0.5, 0.5 + (i % 3) * 0.05, 0, 0.4, 1 + (i % 3) * 0.1, 0.6) }));
items.push({ color: [0.54, 0.38, 0.25], alpha: 1, position: [0, 0, 0], model: m4.box(0, -0.05, 0, 3.4, 0.1, 0.9) });
const glass = { color: [0.72, 0.86, 0.95], alpha: 0.25, position: [0, 0.6, 0.6], model: m4.box(0, 0.6, 0.6, 3.5, 1.3, 0.02) };
const ghost = items[4]; ghost.alpha = 0.4; // out of stock
const opaque = items.filter((item) => item !== ghost);
const eye = [0.9, 1.2, 3.4];
const distance = (item) => Math.hypot(...item.position.map((c, i) => c - eye[i]));
const farToNear = [glass, ghost].sort((a, b) => distance(b) - distance(a));
function draw(list) {
for (const { color, alpha, model } of list) {
gl.uniformMatrix4fv(u('uModel'), false, model);
gl.uniform3fv(u('uColor'), color); gl.uniform1f(u('uAlpha'), alpha);
gl.drawArrays(gl.TRIANGLES, 0, 36);
}
}
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); // one layer of glass, not two
gl.enable(gl.SCISSOR_TEST);
// Colour: ordinary transparency. Alpha: keep the canvas opaque (see Premultiplied Alpha).
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.uniformMatrix4fv(u('uViewProjection'), false, m4.multiply(m4.perspective(40, 600 / 320, 0.1, 20), m4.lookAt(eye, [0, 0.45, 0])));
for (let pane = 0; pane < 2; pane++) {
gl.viewport(pane * 600, 0, 600, 320); gl.scissor(pane * 600 + 3, 0, 594, 320);
gl.clearColor(0.93, 0.91, 0.87, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.enable(gl.BLEND);
if (pane === 0) draw([glass, ghost]); // wrong: they write depth before the books exist
gl.disable(gl.BLEND);
draw(opaque);
if (pane === 1) { // right: last, far to near, no depth writes
gl.enable(gl.BLEND); gl.depthMask(false);
draw(farToNear);
gl.depthMask(true);
}
}
</script>