The three matrices multiply into one, MVP = projection * view * model, applied right to left like any chain. Multiplying once per object on the CPU and uploading one uMVP saves two matrix products per vertex (Virtual Shelf does this). Shaders that light in world or eye space (Directional and Point Lighting) take uModel and uViewProjection instead. The product is the same either way:
const [p, v, m] = [GLH.m4.perspective(45, 2, 0.1, 100), GLH.m4.lookAt([0, 1, 4], [0, 0, 0]),
GLH.m4.rotation(30, 0, 1, 0)];
const corner = [0.4, 0.5, 0], round = (clip) => clip.map((c) => c.toFixed(2)).join(', ');
console.log(round(GLH.m4.apply(GLH.m4.chain(p, v, m), corner)), '|',
round(GLH.m4.apply(p, GLH.m4.apply(v, GLH.m4.apply(m, corner)))));Output
0.42, 1.29, 4.00, 4.20 | 0.42, 1.29, 4.00, 4.20
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>
<canvas id="view" width="600" height="260"></canvas>
<pre id="out"></pre>
<script>
const m4 = {
translation: (x, y, z) => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1],
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];
},
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 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;
},
chain: (...ms) => ms.reduce((a, b) => m4.multiply(a, b)),
apply: (m, [x, y, z, w = 1]) => [0, 1, 2, 3].map((r) => m[r] * x + m[4 + r] * y + m[8 + r] * z + m[12 + r] * w),
};
function cube() {
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 out = [];
for (const [n, u, v] of faces)
for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]])
out.push(...[0, 1, 2].map(i => (n[i] + a * u[i] + b * v[i]) / 2), ...n);
return new Float32Array(out);
}
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; // one matrix per object: no matrix products per vertex
out vec3 vNormal;
void main() { vNormal = aNormal; gl_Position = uMVP * vec4(aPosition, 1.0); }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; uniform vec3 uColor; in vec3 vNormal; out vec4 c;
void main() { // shade by the cube face (object space), enough to read the shape
float shade = 0.65 + 0.35 * dot(abs(vNormal), vec3(0.6, 1.0, 0.8)) - 0.25 * max(-vNormal.y, 0.0);
c = vec4(uColor * shade, 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 uMVP = gl.getUniformLocation(program, 'uMVP');
const uColor = gl.getUniformLocation(program, 'uColor');
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, 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 colors = [[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]];
// The camera does not move, so the view-projection is computed once.
const viewProjection = m4.multiply(m4.perspective(40, 600 / 260, 0.1, 50), m4.lookAt([0, 1.5, 6], [0, 0, 0]));
function frame(ms) {
gl.clearColor(0.97, 0.96, 0.93, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
colors.forEach((color, i) => {
const model = m4.chain(m4.translation(-2.5 + i, 0, 0), m4.rotation(ms * 0.05 + i * 30, 0, 1, 0),
m4.rotation(15, 1, 0, 0), m4.scaling(0.6, 1, 0.18));
gl.uniformMatrix4fv(uMVP, false, m4.multiply(viewProjection, model)); // MVP = P * V * M
gl.uniform3fv(uColor, color);
gl.drawArrays(gl.TRIANGLES, 0, 36);
});
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// The page's check: one combined matrix gives the same clip position as three in turn.
const [p, v, m] = [m4.perspective(45, 2, 0.1, 100), m4.lookAt([0, 1, 4], [0, 0, 0]), m4.rotation(30, 0, 1, 0)];
const corner = [0.4, 0.5, 0], round = (clip) => clip.map((c) => c.toFixed(2)).join(', ');
document.getElementById('out').textContent =
`(P * V * M) * corner = ${round(m4.apply(m4.chain(p, v, m), corner))}\n` +
`P * (V * (M * corner)) = ${round(m4.apply(p, m4.apply(v, m4.apply(m, corner))))}`;
</script>