Normal Debugging

Debugging Lighting with a Normal Visualization Shader

When lighting looks wrong, first look at the normals themselves. Mapping each component from -1..1 to a color channel, outColor = vec4(normal * 0.5 + 0.5, 1.0), paints +x reddish, +y greenish and +z bluish, and the left pane of Lighting the Covers's figure shows the shelf this way through a uShowNormals switch. Reading it:

Keep the switch in development builds; tools such as Spector.js 1,619 (Spector.js) show the same data per draw call.

Normals painted as colours: correct, with the model matrix instead of the normal matrix, and with the attribute unboundHTMLLive
<!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>uNormalMatrix (correct)</div><div>mat3(uModel): hues drift on the squashed globe</div><div>normal attribute disabled: (0, 0, 0)</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, turn = 0) {        // T * Ry(turn) * S
    const c = Math.cos(turn), s = Math.sin(turn);
    return [c * sx, 0, -s * sx, 0, 0, sy, 0, 0, s * sz, 0, c * sz, 0, x, y, z, 1];
  },
  normal(m) {                                   // inverse transpose of the upper-left 3x3
    const [a, b, c, , d, e, f, , g, h, i] = m;
    const n = [e * i - f * h, f * g - d * i, d * h - e * g, c * h - b * i, a * i - c * g,
      b * g - a * h, b * f - c * e, c * d - a * f, a * e - b * d];
    const det = a * n[0] + b * n[1] + c * n[2];
    return n.map((v) => v / det);
  },
};
// A unit sphere (position = normal) followed by a unit cube, in one buffer: position + normal.
const data = [];
const ring = (i, j) => { const t = i / 24 * Math.PI, p = j / 36 * 2 * Math.PI;
  return [Math.sin(t) * Math.cos(p), Math.cos(t), -Math.sin(t) * Math.sin(p)]; };
for (let i = 0; i < 24; i++) for (let j = 0; j < 36; j++)
  for (const [di, dj] of [[0, 0], [1, 0], [0, 1], [0, 1], [1, 0], [1, 1]]) { const p = ring(i + di, j + dj); data.push(...p, ...p); }
const sphereCount = data.length / 6;
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]]];
for (const [n, u, v] of faces) for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]])
  data.push(...[0, 1, 2].map(i => (n[i] + a * u[i] + b * v[i]) / 2), ...n);

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; uniform mat3 uNormalMatrix; uniform bool uUseModel;
out vec3 vNormal;
void main() {
  vNormal = uUseModel ? mat3(uModel) * aNormal : uNormalMatrix * aNormal;
  gl_Position = uViewProjection * uModel * vec4(aPosition, 1.0);
}`], [gl.FRAGMENT_SHADER, `#version 300 es
precision highp float; in vec3 vNormal; out vec4 outColor;
void main() {
  float len = length(vNormal);
  // +x reddish, +y greenish, +z bluish; a zero-length normal shows as black.
  outColor = vec4(len > 0.0 ? (vNormal / len) * 0.5 + 0.5 : vec3(0.0), 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, new Float32Array(data), 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.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.enable(gl.SCISSOR_TEST);

// A globe bookend squashed to a third of its height, a book, and the plank.
const scene = [
  { model: m4.model(-0.55, 0.3, 0, 0.9, 0.3, 0.9, 0.4), first: 0, count: sphereCount },
  { model: m4.model(0.65, 0.5, 0, 0.42, 0.98, 0.12, -0.5), first: sphereCount, count: 36 },
  { model: m4.model(0, -0.03, 0, 2.6, 0.06, 0.8), first: sphereCount, count: 36 },
];
gl.uniformMatrix4fv(u('uViewProjection'), false, m4.multiply(m4.perspective(40, 400 / 360, 0.1, 20), m4.lookAt([0.6, 1.5, 3.0], [0, 0.35, 0])));
for (let pane = 0; pane < 3; pane++) {
  gl.viewport(pane * 400, 0, 400, 360); gl.scissor(pane * 400 + 3, 0, 394, 360);
  gl.clearColor(0.93, 0.91, 0.87, 1);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  if (pane === 2) { gl.disableVertexAttribArray(1); gl.vertexAttrib3f(1, 0, 0, 0); }   // the classic mistake
  else gl.enableVertexAttribArray(1);
  gl.uniform1i(u('uUseModel'), pane === 1);
  for (const { model, first, count } of scene) {
    gl.uniformMatrix4fv(u('uModel'), false, model);
    gl.uniformMatrix3fv(u('uNormalMatrix'), false, m4.normal(model));
    gl.drawArrays(gl.TRIANGLES, first, count);
  }
}
</script>