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:
// 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));
};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.
<!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>