mat4 covers everything MVP Matrices built by hand: create, multiply, invert and transpose; fromTranslation, fromRotation and fromScaling for single transformations; lookAt, perspective, ortho and frustum; and mat3.normalFromMat4 for the normal matrix. translate, rotate, rotateY and scale post-multiply: mat4.rotateY(m, m, a) sets m = m * Ry(a), so calls read in the same order as a GLH.m4.chain(). perspective maps depth to -1..1 as WebGL expects; perspectiveZO maps it to 0..1 for WebGPU (WebGPU). The listing times 200,000 book model matrices both ways:
const { mat4 } = glMatrix, m4 = GLH.m4, N = 200000;
const at = [0.83, 0, 0], size = [0.42, 0.98, 0.12], model = mat4.create();
let hand, t0 = performance.now();
for (let i = 0; i < N; i++) {
hand = m4.chain(m4.translation(...at), m4.rotation(i % 60, 0, 1, 0), m4.scaling(...size));
}
const t1 = performance.now();
for (let i = 0; i < N; i++) { // one output array, reused
mat4.rotateY(model, mat4.fromTranslation(model, at), (i % 60) * Math.PI / 180);
mat4.scale(model, model, size);
}
const t2 = performance.now();
console.log(`GLH.m4 ${(t1 - t0).toFixed(0)} ms, gl-matrix ${(t2 - t1).toFixed(0)} ms,`,
`ratio ${((t1 - t0) / (t2 - t1)).toFixed(1)}, same result: ${mat4.equals(hand, model)}`);GLH.m4 229 ms, gl-matrix 25 ms, ratio 9.2, same result: true
Three runs on this shared machine gave ratios of 7.0 to 9.2, from allocation and from chain()'s general 64-step products, where rotateY touches only the eight elements it changes. equals() compares within a relative tolerance (EPSILON); exactEquals() demands identical floats. In a render loop, allocate outputs once and recompute the view-projection only when the camera moves.
<!doctype html>
<style>
body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
canvas { display: block; width: 100%; max-width: 600px; }
pre { max-width: 600px; margin: 4px 0 0; padding: 6px 8px; background: #fff; border: 1px solid #ddd; }
</style>
<script src="https://cdn.jsdelivr.net/npm/gl-matrix@3.4.4/gl-matrix-min.js"></script>
<canvas id="view" width="600" height="230"></canvas>
<pre id="out">timing...</pre>
<script>
const { mat4, mat3 } = glMatrix;
// Hand-rolled helpers that allocate a new array on every call.
const m4 = {
translation: (x, y, z) => new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1]),
scaling: (x, y, z) => new Float32Array([x, 0, 0, 0, 0, y, 0, 0, 0, 0, z, 0, 0, 0, 0, 1]),
rotationY(deg) { const r = deg * Math.PI / 180, c = Math.cos(r), s = Math.sin(r);
return new Float32Array([c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1]); },
multiply(a, b) {
const out = new Float32Array(16);
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;
},
};
// Time 100,000 book model matrices both ways.
const N = 100000, at = [0.83, 0, 0], size = [0.42, 0.98, 0.12], model = mat4.create();
let hand, t0 = performance.now();
for (let i = 0; i < N; i++) hand = m4.multiply(m4.multiply(m4.translation(...at), m4.rotationY(i % 60)), m4.scaling(...size));
const t1 = performance.now();
for (let i = 0; i < N; i++) { // one output array, reused
mat4.rotateY(model, mat4.fromTranslation(model, at), (i % 60) * Math.PI / 180);
mat4.scale(model, model, size); // post-multiplies: reads like T * Ry * S
}
const t2 = performance.now();
document.getElementById('out').textContent =
`hand-rolled ${(t1 - t0).toFixed(1)} ms, gl-matrix ${(t2 - t1).toFixed(1)} ms ` +
`(ratio ${((t1 - t0) / (t2 - t1)).toFixed(1)}), mat4.equals: ${mat4.equals(hand, model)}`;
// The scene: 60 books fanned in a circle, all built in the same two reused arrays.
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 cube = [];
for (const [n, u, v] of faces) for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]])
cube.push(...[0, 1, 2].map(i => (n[i] + a * u[i] + b * v[i]) / 2), ...n);
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; layout(location = 1) in vec3 aNormal;
uniform mat4 uMVP; uniform mat3 uNormal; out float vLight;
void main() { vLight = 0.4 + 0.6 * max(dot(normalize(uNormal * aNormal), normalize(vec3(0.3, 1.0, 0.5))), 0.0);
gl_Position = uMVP * vec4(aPosition, 1.0); }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; uniform vec3 uColor; in float vLight; out vec4 c; void main() { c = vec4(uColor * vLight, 1.0); }`]]) {
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(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(cube), 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.enableVertexAttribArray(1);
gl.enable(gl.DEPTH_TEST);
const u = (n) => gl.getUniformLocation(program, n);
const viewProjection = mat4.perspective(mat4.create(), 0.7, 600 / 230, 0.1, 30);
mat4.multiply(viewProjection, viewProjection, mat4.lookAt(mat4.create(), [0, 2.2, 4.4], [0, 0, 0], [0, 1, 0]));
const mvp = mat4.create(), normal = mat3.create();
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]];
function frame(ms) {
gl.clearColor(0.97, 0.96, 0.93, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
for (let i = 0; i < 60; i++) {
mat4.fromYRotation(model, i * Math.PI / 30 + ms * 0.0003);
mat4.translate(model, model, [1.8, 0, 0]);
mat4.scale(model, model, size);
mat3.normalFromMat4(normal, model);
gl.uniformMatrix4fv(u('uMVP'), false, mat4.multiply(mvp, viewProjection, model));
gl.uniformMatrix3fv(u('uNormal'), false, normal);
gl.uniform3fv(u('uColor'), palette[i % 6]);
gl.drawArrays(gl.TRIANGLES, 0, 36);
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>