What "distance" means matters. The simplest code uses depth, gl_FragCoord.z or eye-space z, but depth measures along the view axis, so an object at the edge of the screen gets less fog than one at the same distance straight ahead, and fog shifts as the camera turns. Distance fog uses the true distance from the eye, distance(uEye, vPosition), computed per fragment: interpolating a per-vertex distance, as older tutorials do, underestimates it across large triangles. Height fog thickens toward the floor, like mist in a valley or dust low in a stockroom, by making the density depend on height:
float distanceFog = exp(-pow(uFogDensity * distance(uEye, vPosition), 2.0));
float height = clamp((uFogTop - vPosition.y) / uFogDepth, 0.0, 1.0); // 0 above, 1 deep
float heightFog = exp(-uHeightDensity * height * distance(uEye, vPosition));
float fog = distanceFog * heightFog; // both factors: 1 means clear
outColor = vec4(mix(uFogColor, color, fog), uAlpha);This is an approximation: exact height fog integrates the density along the whole ray from the eye, which has a closed form for exponential falloff with height and is what engines' "exponential height fog" computes. For a shelf-sized scene the per-fragment version above is indistinguishable and costs three more lines.
<!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="360"></canvas>
<div class="names"><div>eye-space depth</div><div>distance(uEye, vPosition)</div><div>distance × height fog</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 uProjection, uView, uModel; out vec3 vPosition, vNormal; out float vDepth;
void main() {
vec4 w = uModel * vec4(aPosition, 1.0);
vec4 eyeSpace = uView * w;
vPosition = w.xyz; vNormal = aNormal; vDepth = -eyeSpace.z; // along the view axis only
gl_Position = uProjection * eyeSpace;
}`], [gl.FRAGMENT_SHADER, `#version 300 es
precision highp float;
in vec3 vPosition, vNormal; in float vDepth;
uniform vec3 uColor, uEye, uFogColor; uniform int uMode;
uniform float uFogDensity, uFogTop, uFogDepth, uHeightDensity;
out vec4 outColor;
void main() {
vec3 color = uColor * (0.45 + 0.55 * max(dot(normalize(vNormal), normalize(vec3(-0.4, 0.8, 0.5))), 0.0));
float d = uMode == 0 ? vDepth : distance(uEye, vPosition);
float fog = exp(-pow(uFogDensity * d, 2.0));
if (uMode == 2) { // thicker below uFogTop
float height = clamp((uFogTop - vPosition.y) / uFogDepth, 0.0, 1.0); // 0 above, 1 deep
fog *= exp(-uHeightDensity * height * d);
}
outColor = vec4(mix(uFogColor, color, fog), 1.0);
}`]]) {
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);
// A ring of tall book stacks around the camera, on a floor.
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 items = [{ color: [0.72, 0.66, 0.58], model: m4.model(0, -0.05, 0, 30, 0.1, 30) }];
for (let k = 0; k < 18; k++) {
const a = k / 18 * Math.PI * 2, r = 5 + (k % 3);
items.push({ color: palette[k % 6], model: m4.model(Math.sin(a) * r, 1.2, -Math.cos(a) * r, 0.8, 2.4, 0.8) });
}
const eye = [0, 1.4, 0], fogColor = [0.93, 0.91, 0.87];
gl.uniform3fv(u('uEye'), eye);
gl.uniform3fv(u('uFogColor'), fogColor);
gl.uniform1f(u('uFogDensity'), 0.13);
gl.uniform1f(u('uFogTop'), 0.9); gl.uniform1f(u('uFogDepth'), 0.9); gl.uniform1f(u('uHeightDensity'), 0.5);
gl.uniformMatrix4fv(u('uProjection'), false, m4.perspective(75, 400 / 360, 0.1, 50)); // wide: edges show the depth error
function frame(ms) {
const yaw = Math.sin(ms * 0.0004) * 0.9; // the camera turns in place
gl.uniformMatrix4fv(u('uView'), false, m4.lookAt(eye, [Math.sin(yaw), 1.1, -Math.cos(yaw)]));
for (let mode = 0; mode < 3; mode++) {
gl.viewport(mode * 400, 0, 400, 360); gl.scissor(mode * 400 + 3, 0, 394, 360);
gl.clearColor(...fogColor, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.uniform1i(u('uMode'), mode);
for (const { color, model } of items) {
gl.uniformMatrix4fv(u('uModel'), false, model);
gl.uniform3fv(u('uColor'), color);
gl.drawArrays(gl.TRIANGLES, 0, 36);
}
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>