A dozen matrix functions are enough to learn from, but they fail a growing scene in three ways:
Allocation. Every GLH.m4 call returns a new Float32Array: the shelf of Virtual Shelf creates 55 per frame, 3,300 a second at 60 frames per second, and the garbage collector eventually pauses a frame to reclaim them. gl-matrix 5,695 writes into an out array you allocate once.
Coverage. Real scenes need a general 4x4 inverse (for picking, Rotation and Picking), quaternions, decomposition and tolerant comparisons. GLH.m4 has none of them.
Conventions and tests. Degrees or radians, row- or column-major, which way a rotation turns: a wrong guess shows up only as an empty canvas. gl-matrix's choices match WebGL's, and a test suite guards them.
The listing counts the functions each library offers, with gl-matrix's UMD build loaded (Installing gl-matrix):
const count = (o) => Object.values(o).filter((f) => typeof f === 'function').length;
const modules = ['vec3', 'vec4', 'mat3', 'mat4', 'quat'];
console.log('GLH.m4:', count(GLH.m4), '| gl-matrix:',
modules.map((m) => `${m} ${count(glMatrix[m])}`).join(', '));GLH.m4: 11 | gl-matrix: vec3 49, vec4 41, mat3 32, mat4 52, quat 39
The counts include short aliases such as mat4.mul. Keep GLH.m4 for teaching; build scenes on the library.
<!doctype html>
<style>
body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
.wrap { position: relative; width: 100%; max-width: 600px; }
canvas { display: block; width: 100%; }
.lbl { position: absolute; left: 6px; transform: translateY(-50%); font-weight: 600; }
.val { position: absolute; transform: translateY(-50%); }
h4 { margin: 6px 0 2px; font-size: 12px; }
</style>
<script src="https://cdn.jsdelivr.net/npm/gl-matrix@3.4.4/gl-matrix-min.js"></script>
<div class="wrap"><canvas id="view" width="600" height="300"></canvas></div>
<script>
// The chapter's hand-rolled helper set: a dozen functions, each returning a NEW array.
const m4 = {
identity() {}, translation() {}, scaling() {}, rotation() {}, multiply() {}, chain() {},
apply() {}, lookAt() {}, ortho() {}, perspective() {}, normal() {},
};
const count = (o) => Object.values(o).filter((f) => typeof f === 'function').length;
const rows = [
['hand-rolled m4', count(m4), [0.55, 0.55, 0.55]],
...['vec3', 'vec4', 'mat3', 'mat4', 'quat'].map((m, i) =>
[`gl-matrix ${m}`, count(glMatrix[m]), [0.12 + i * 0.04, 0.37 + i * 0.05, 0.55]]),
['allocations per frame, m4', 55, [0.71, 0.27, 0.18]], // the shelf: one array per call
['allocations per frame, gl-matrix', 3, [0.25, 0.49, 0.23]], // outputs allocated once, reused
];
const gl = document.getElementById('view').getContext('webgl2');
const wrap = document.querySelector('.wrap');
const max = Math.max(...rows.map(r => r[1]));
const rowH = 300 / rows.length;
rows.forEach(([name, n], i) => {
const top = ((i + 0.5) * rowH / 300 * 100) + '%';
const px = 230 + (n / max) * 330;
wrap.insertAdjacentHTML('beforeend', `<div class="lbl" style="top:${top}">${name}</div>` +
`<div class="val" style="top:${top};left:${(px + 6) / 6}%">${n}</div>`);
});
// Bars as scissored clears, growing in over the first second.
gl.enable(gl.SCISSOR_TEST);
function frame(ms) {
const grow = Math.min(1, ms / 1000);
gl.scissor(0, 0, 600, 300);
gl.clearColor(0.97, 0.96, 0.93, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.scissor(0, 300 - 6 * rowH, 600, 1); // a divider between the two charts
gl.clearColor(0.7, 0.68, 0.64, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
rows.forEach(([, n, rgb], i) => {
const y = 300 - (i + 1) * rowH + 6; // scissor y counts from the bottom
gl.scissor(230, y, Math.max(2, (n / max) * 330 * grow), rowH - 12);
gl.clearColor(...rgb, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
});
if (grow < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>