Blending depends on order, while the depth buffer keeps only the nearest surface. The standard recipe, followed by the right pane of Enabling Alpha Blending's figure:
With the depth test on, draw every opaque object (front to back is fastest).
Sort the transparent objects by distance from the camera, farthest first; re-sort when the camera moves.
Draw them with blending and depthMask(false): hidden by opaque surfaces, they no longer hide each other.
Restore depthMask(true), or the next frame's clear() leaves the depth buffer untouched.
That listing measures from the eye to each object's origin, read with mat4.getTranslation(). Sorting whole objects fails when they intersect or surround one another; split them, or use order-independent transparency (weighted blended OIT, on float render targets). Additive glows need no sort: addition is commutative.
<!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="600" height="260"></canvas>
<div class="names"><div>fixed order, depth writes on</div><div id="order">sorted far to near, 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.5 + 0.5 * 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 vec4 uColor; in float vLight; out vec4 c;
void main() { c = vec4(uColor.rgb * vLight, uColor.a); }`]]) {
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 book = { color: [0.12, 0.37, 0.55, 1], model: m4.box(0, 0, 0, 0.5, 1.2, 0.8) };
const floor = { color: [0.8, 0.76, 0.68, 1], model: m4.box(0, -0.65, 0, 4, 0.1, 4) };
const panes = [[1, 0.3, 0.2], [0.95, 0.75, 0.1], [0.2, 0.7, 0.3], [0.5, 0.3, 0.9]].map((rgb, i) => {
const position = [Math.cos(i * Math.PI / 2) * 1.1, 0, Math.sin(i * Math.PI / 2) * 1.1];
const flat = i % 2 === 0; // panes on x face along x, panes on z along z
return { name: ['red', 'gold', 'green', 'violet'][i], color: [...rgb, 0.45], position,
model: m4.box(...position, flat ? 0.03 : 1.7, 1.0, flat ? 1.7 : 0.03) };
});
function draw(list) {
for (const { color, model } of list) {
gl.uniformMatrix4fv(u('uModel'), false, model);
gl.uniform4fv(u('uColor'), color);
gl.drawArrays(gl.TRIANGLES, 0, 36);
}
}
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.enable(gl.SCISSOR_TEST);
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
function frame(ms) {
const a = ms * 0.0005 - 0.5, eye = [Math.sin(a) * 4, 1.8, Math.cos(a) * 4];
gl.uniformMatrix4fv(u('uViewProjection'), false, m4.multiply(m4.perspective(40, 300 / 260, 0.1, 20), m4.lookAt(eye, [0, 0, 0])));
const distance = (item) => Math.hypot(...item.position.map((c, i) => c - eye[i]));
const sorted = [...panes].sort((p, q) => distance(q) - distance(p)); // re-sort when the camera moves
for (let side = 0; side < 2; side++) {
gl.viewport(side * 300, 0, 300, 260); gl.scissor(side * 300 + 2, 0, 296, 260);
gl.clearColor(0.93, 0.91, 0.87, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.disable(gl.BLEND);
draw([floor, book]); // 1. opaque objects first
gl.enable(gl.BLEND);
if (side === 0) draw(panes); // fixed order, each pane writes depth
else { gl.depthMask(false); draw(sorted); gl.depthMask(true); } // 2-4. sorted, no depth writes
}
document.getElementById('order').textContent = 'sorted, depthMask(false): ' + sorted.map(p => p.name).join(' > ');
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>