Fog and Lighting

Fog and Lighting in a Single Fragment Shader

GLH.shelfFS, printed at the start of this section, is the whole pipeline in one fragment shader, in a fixed order that matters:

  1. Renormalize the interpolated normal and compute the view vector per fragment.

  2. For each light, compute the falloff once and reuse it for both terms; add diffuse light, and a Blinn-Phong highlight only on the lit side.

  3. Combine: surface color times diffuse light, plus specular times gloss. Highlights are not multiplied by the surface color, so a blue globe still shows a white glint.

  4. Apply fog last, to the lit color including the highlight, so glints fade into the distance too.

  5. Output the object's alpha unchanged, for blending.

Lighting before fog is the one order that works: fogging first and lighting afterward would brighten the fog itself. Its cost grows with lights times fragments; with many lights, skip those whose fade is 0 or move to deferred shading (Deferred Rendering). All uniforms start at 0, so a page using it sets at least uAmbient, uEye, the lights, uShininess and uFogColor; a uFogDensity of 0 turns fog off.

One fragment shader built up stage by stage: diffuse, plus Blinn-Phong highlights, plus fog, and fog applied too earlyHTMLLive
<!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="300"></canvas>
<div class="names"><div>1. diffuse</div><div>2. + specular</div><div>3. + fog, last</div><div>wrong: fog, then light</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, (a + 1) / 2, (b + 1) / 2);
  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; layout(location = 2) in vec2 aUV;
uniform mat4 uViewProjection, uModel; out vec3 vPosition, vNormal; out vec2 vUV;
void main() { vec4 w = uModel * vec4(aPosition, 1.0); vPosition = w.xyz; vNormal = aNormal; vUV = aUV;
  gl_Position = uViewProjection * w; }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision highp float;
struct Light { vec3 position, color; };
uniform Light uLights[2];
uniform vec3 uColor, uAmbient, uEye, uFogColor;
uniform float uShininess, uFogDensity; uniform int uStage;
in vec3 vPosition, vNormal; in vec2 vUV; out vec4 outColor;
void main() {
  vec3 n = normalize(vNormal), v = normalize(uEye - vPosition);   // 1. per-fragment vectors
  float fog = exp(-pow(uFogDensity * distance(uEye, vPosition), 2.0));
  vec3 surface = uStage == 3 ? mix(uFogColor, uColor, fog) : uColor;   // the wrong order fogs first
  vec3 diffuse = uAmbient, specular = vec3(0.0);
  for (int i = 0; i < 2; i++) {                                    // 2. each light, falloff reused
    vec3 toLight = uLights[i].position - vPosition;
    float d = length(toLight); vec3 l = toLight / d;
    float fade = clamp(1.0 - pow(d / 7.0, 4.0), 0.0, 1.0) / (d * d);
    float lambert = max(dot(n, l), 0.0);
    diffuse += uLights[i].color * lambert * fade;
    if (lambert > 0.0) specular += uLights[i].color * pow(max(dot(n, normalize(l + v)), 0.0), uShininess) * fade;
  }
  float band = step(abs(vUV.y - 0.78), 0.05);
  vec3 color = surface * diffuse;                                  // 3. combine
  if (uStage >= 1) color += specular * (0.3 + band);               //    highlights not tinted by the surface
  if (uStage == 2) color = mix(uFogColor, color, fog);             // 4. fog last
  outColor = vec4(color, 1.0);                                     // 5. alpha unchanged
}`]]) {
  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, 32, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 32, 12); gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 32, 24); gl.enableVertexAttribArray(2);
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 items = [{ color: [0.72, 0.66, 0.58], model: m4.model(0, -0.05, -5, 5, 0.1, 16) }];
for (let k = 0; k < 12; k++) items.push({ color: palette[k % 6], model: m4.model(-0.9 + (k % 2) * 1.8, 0.6, 0.5 - Math.floor(k / 2) * 1.6, 0.9, 1.2, 0.25) });
const eye = [0, 1.1, 3], fogColor = [0.93, 0.91, 0.87];
gl.uniform3fv(u('uEye'), eye); gl.uniform3fv(u('uFogColor'), fogColor);
gl.uniform3f(u('uAmbient'), 0.25, 0.26, 0.3);
gl.uniform1f(u('uShininess'), 50); gl.uniform1f(u('uFogDensity'), 0.2);
[[[0, 1.4, 2], [3, 2.8, 2.4]], [[0, 1.4, -4], [2.5, 2.4, 2]]].forEach(([p, c], i) => {
  gl.uniform3fv(u(`uLights[${i}].position`), p); gl.uniform3fv(u(`uLights[${i}].color`), c);
});
gl.uniformMatrix4fv(u('uViewProjection'), false, m4.multiply(m4.perspective(40, 300 / 300, 0.1, 40), m4.lookAt(eye, [0, 0.6, -3])));
for (let stage = 0; stage < 4; stage++) {
  gl.viewport(stage * 300, 0, 300, 300); gl.scissor(stage * 300 + 2, 0, 296, 300);
  gl.clearColor(...fogColor, 1);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  gl.uniform1i(u('uStage'), stage);
  for (const { color, model } of items) {
    gl.uniformMatrix4fv(u('uModel'), false, model); gl.uniform3fv(u('uColor'), color);
    gl.drawArrays(gl.TRIANGLES, 0, 36);
  }
}
</script>