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:
The plank's top is green (+y) and the covers are blue-violet, shading toward pink as they turn toward +x: correct. Each face is one flat color, as box faces should be.
Black or gray patches mean zero-length or unnormalized normals: an attribute not bound (its default is (0, 0, 0, 1)) or a missing normalize().
Colors that change with the camera mean the normals are in view space while the light is in world space.
Wrong hues after scaling mean the model matrix was used instead of the normal matrix (Normal Matrices for Lighting).
A face darker than its neighbors, inverted, means a flipped normal or reversed winding (Winding Order).
Keep the switch in development builds; tools such as Spector.js 1,619 (Spector.js) show the same data per draw call.
<!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>