Normal Matrices for Lighting

Lighting needs each surface's normal, perpendicular to it. The model matrix transforms normals correctly only for rotations and uniform scales; the general fix is the normal matrix, the inverse transpose of the model's upper-left 3x3, passed as a mat3 uniform:

webgl-helper.js, eleventh part: the normal matrixJavaScript
// Section 3.6: the normal matrix, the inverse transpose of m's upper-left 3x3 (a mat3)
GLH.m4.normal = function (m) {
  const [a, b, c, , d, e, f, , g, h, i] = m;          // columns (a b c), (d e f), (g h i)
  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];   // cofactors
  const det = a * n[0] + b * n[1] + c * n[2];
  return new Float32Array(n.map((v) => v / det));
};
A 45-degree surface stretched 2x along x: which normal stays perpendicular?JavaScript
const model = GLH.m4.scaling(2, 1, 1), nm = GLH.m4.normal(model);
const t = [2, -1, 0];                          // the surface direction (1, -1), scaled
const byModel = [0.707 * 2, 0.707, 0], byNormal = [0.707 * nm[0], 0.707 * nm[4], 0];
const dot = (a, b) => (a[0] * b[0] + a[1] * b[1]).toFixed(3);
console.log('model matrix:', dot(byModel, t), '| normal matrix:', dot(byNormal, t));
Output
model matrix: 2.121 | normal matrix: 0.000

Only the normal matrix keeps the normal perpendicular (dot product 0). Renormalize it in the shader. For rotations and uniform scales it equals the model's 3x3 up to scale, so engines often skip it there.

A sphere stretched 2x along x, lit with normals from the model matrix (left) and the normal matrix (right)HTMLLive
<!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="view" width="600" height="240"></canvas>
<div class="names"><div>mat3(uModel) * aNormal (wrong)</div><div>uNormalMatrix * aNormal (right)</div></div>
<script>
const m4 = {
  scaling: (x, y, z) => [x, 0, 0, 0, 0, y, 0, 0, 0, 0, z, 0, 0, 0, 0, 1],
  rotation(degrees, x, y, z) {
    const r = degrees * Math.PI / 180, c = Math.cos(r), s = Math.sin(r), t = 1 - c;
    return [t * x * x + c, t * x * y + s * z, t * x * z - s * y, 0, t * x * y - s * z, t * y * y + c, t * y * z + s * x, 0,
      t * x * z + s * y, t * y * z - s * x, t * z * z + c, 0, 0, 0, 0, 1];
  },
  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];
  },
  translation: (x, y, z) => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1],
  multiply(a, b) {
    const out = new Array(16).fill(0);
    for (let col = 0; col < 4; col++)
      for (let row = 0; row < 4; row++)
        for (let k = 0; k < 4; k++) out[col * 4 + row] += a[k * 4 + row] * b[col * 4 + k];
    return out;
  },
  // The normal matrix: the inverse transpose of m's upper-left 3x3 (a mat3).
  normal(m) {
    const [a, b, c, , d, e, f, , g, h, i] = m;          // columns (a b c), (d e f), (g h i)
    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];   // cofactors
    const det = a * n[0] + b * n[1] + c * n[2];
    return n.map((v) => v / det);
  },
};
// A UV sphere: position = normal for a unit sphere.
const sphere = [];
const ring = (i, j) => { const t = i / 24 * Math.PI, p = j / 32 * 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 < 32; j++)
  for (const [di, dj] of [[0, 0], [1, 0], [1, 1], [0, 0], [1, 1], [0, 1]]) sphere.push(...ring(i + di, j + dj));

const gl = document.getElementById('view').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec3 aPosition;
uniform mat4 uProjection, uModel; uniform mat3 uNormalMatrix; uniform bool uUseNormalMatrix;
out vec3 vNormal;
void main() {
  vec3 aNormal = aPosition;                               // unit sphere
  vNormal = uUseNormalMatrix ? uNormalMatrix * aNormal : mat3(uModel) * aNormal;
  gl_Position = uProjection * uModel * vec4(aPosition, 1.0);
}`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; in vec3 vNormal; out vec4 c;
void main() {
  vec3 n = normalize(vNormal);                            // renormalize after the transform
  vec3 light = normalize(vec3(-0.8, 0.5, 0.6));
  float diffuse = max(dot(n, light), 0.0);
  float spec = pow(max(dot(reflect(-light, n), vec3(0.0, 0.0, 1.0)), 0.0), 40.0);
  c = vec4(vec3(0.12, 0.37, 0.55) * (0.25 + 0.8 * diffuse) + spec * 0.6, 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);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sphere), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(0);
gl.enable(gl.DEPTH_TEST);
const u = (n) => gl.getUniformLocation(program, n);
gl.uniformMatrix4fv(u('uProjection'), false, m4.perspective(40, 300 / 240, 0.1, 20));

function frame(ms) {
  // Non-uniform scale: 2x along x, then a slow tilt so the error changes shape.
  const model = m4.multiply(m4.multiply(m4.translation(0, 0, -5.2), m4.rotation(25 * Math.sin(ms * 0.0007), 0, 0, 1)),
                            m4.scaling(2, 1, 1));
  gl.uniformMatrix4fv(u('uModel'), false, model);
  gl.uniformMatrix3fv(u('uNormalMatrix'), false, m4.normal(model));
  gl.clearColor(0.97, 0.96, 0.93, 1);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  [false, true].forEach((useNormalMatrix, i) => {
    gl.viewport(i * 300, 0, 300, 240);
    gl.uniform1i(u('uUseNormalMatrix'), useNormalMatrix);
    gl.drawArrays(gl.TRIANGLES, 0, sphere.length / 3);
  });
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>