Fog is applied in each object's own fragment shader, before blending, so transparent objects need care:
Keep alpha. Mix only the color with the fog, vec4(mix(uFogColor, color.rgb, fog), color.a), as GLH.shelfFS does. The glass door of Enabling Alpha Blending then fogs by its own distance, and the fogged books behind it show through correctly.
Premultiplied colors (Premultiplied Alpha) must mix with a premultiplied fog color, uFogColor * alpha, otherwise faint edges glow with fog color.
Additive effects such as glows and particles should fade toward black, color * fog, not toward the fog color, since adding fog color would brighten the fog behind them.
Fully fogged transparent objects are invisible anyway; skip drawing them to save the blending cost.
Fog also hides the far plane: with fog that reaches zero before far, objects never pop out at the clipping distance, the original reason games of the 1990s used it.
<!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; font-size: 10px; }
.names div { flex: 1; padding: 4px 2px; }
</style>
<canvas id="c" width="1200" height="380"></canvas>
<div class="names">
<div>wrong: mix(fog, vec4(rgb, a), f); glow + fog colour</div>
<div>right: vec4(mix(fog, rgb, f), a); glow * f</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;
},
model: (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 vec3 vPosition, vNormal, vLocal;
void main() { vec4 w = uModel * vec4(aPosition, 1.0); vPosition = w.xyz; vNormal = aNormal; vLocal = aPosition;
gl_Position = uViewProjection * w; }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision highp float;
in vec3 vPosition, vNormal, vLocal;
uniform vec4 uColor; uniform vec3 uEye, uFogColor; uniform bool uRight, uGlow;
out vec4 outColor;
void main() {
float fog = exp(-pow(0.16 * distance(uEye, vPosition), 2.0));
if (uGlow) { // additive glow: a soft disc
float g = max(0.0, 1.0 - length(vLocal.xy) * 2.0);
vec3 glow = uColor.rgb * g * g;
outColor = vec4(uRight ? glow * fog : glow + uFogColor * (1.0 - fog) * g, 1.0);
return;
}
vec3 lit = uColor.rgb * (0.5 + 0.5 * max(dot(normalize(vNormal), normalize(vec3(-0.4, 0.8, 0.5))), 0.0));
outColor = uRight ? vec4(mix(uFogColor, lit, fog), uColor.a) // fog the colour, keep alpha
: mix(vec4(uFogColor, 1.0), vec4(lit, uColor.a), fog); // alpha fogs toward 1
}`]]) {
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);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.enable(gl.SCISSOR_TEST);
const palette = [[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 opaque = [{ color: [0.72, 0.66, 0.58, 1], model: m4.model(0, -0.05, -6, 8, 0.1, 18) }];
for (let k = 0; k < 10; k++) opaque.push({ color: [...palette[k % 6], 1], model: m4.model(-1.2 + (k % 2) * 2.4, 0.7, 1 - k * 1.3, 0.6, 1.4, 0.9) });
// Glass doors at several depths (already far to near) and glowing lamps between the stacks.
const glass = [-9, -6, -3, 0].map(z => ({ color: [0.6, 0.8, 0.95, 0.3], model: m4.model(0, 0.8, z, 1.6, 1.6, 0.02) }));
const glows = [-10, -7, -4, -1].map((z, i) => ({ color: [[1, 0.6, 0.2], [0.3, 0.6, 1], [1, 0.4, 0.6], [0.5, 1, 0.4]][i], z }));
const eye = [0.4, 1.2, 3.4], fogColor = [0.93, 0.91, 0.87];
gl.uniform3fv(u('uEye'), eye); gl.uniform3fv(u('uFogColor'), fogColor);
gl.uniformMatrix4fv(u('uViewProjection'), false, m4.multiply(m4.perspective(45, 600 / 380, 0.1, 40), m4.lookAt(eye, [0, 0.8, -4])));
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);
}
}
function frame(ms) {
for (let pane = 0; pane < 2; pane++) {
gl.viewport(pane * 600, 0, 600, 380); gl.scissor(pane * 600 + 3, 0, 594, 380);
gl.clearColor(...fogColor, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.uniform1i(u('uRight'), pane); gl.uniform1i(u('uGlow'), 0);
gl.disable(gl.BLEND);
draw(opaque);
gl.enable(gl.BLEND); gl.depthMask(false);
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
draw(glass);
gl.blendFunc(gl.ONE, gl.ONE); // additive: no sorting needed
gl.uniform1i(u('uGlow'), 1);
glows.forEach(({ color, z }, i) => {
gl.uniformMatrix4fv(u('uModel'), false, m4.model(Math.sin(ms * 0.001 + i) * 0.5, 1.6, z, 1.2, 1.2, 0.01));
gl.uniform4f(u('uColor'), ...color, 1);
gl.drawArrays(gl.TRIANGLES, 0, 6); // just the front face of the cube
});
gl.depthMask(true);
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>