A camera is easiest to describe by three vectors: where it is (eye), what it looks at (target) and which way is roughly up. From them, lookAt derives the camera's axes: z points from the target back to the eye (the camera looks down -z), x is perpendicular to up and z, and y is perpendicular to both. Those axes form the rows of the view matrix's rotation, and the last column moves the eye to the origin:
// Section 3.6: the view matrix of a camera at eye, looking at target, with up roughly upward
GLH.m4.lookAt = function (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])); // the camera looks down its -z axis
const x = unit(cross(up, z)), y = cross(z, x);
return new Float32Array([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]);
};Applied to the eye, the result gives the origin; applied to the target, a point straight ahead on -z at the target's distance. up need only be roughly upward, but not parallel to the view direction, or the matrix fills with NaN; looking straight down, use (0, 0, -1). Every matrix in this section matches gl-matrix 3.4.4 5,695 's output exactly.
<!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="280"></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],
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];
},
// The view matrix of a camera at eye, looking at target, with up roughly upward.
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])); // the camera looks down its -z axis
const 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;
},
};
function cube() { // unit cube: position and normal per vertex
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 uViewProjection, uModel; out float vLight;
void main() {
vLight = 0.5 + 0.5 * max(dot(normalize(mat3(uModel) * aNormal), normalize(vec3(0.3, 0.9, 0.5))), 0.0);
gl_Position = uViewProjection * uModel * 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);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
gl.attachShader(program, s);
}
gl.linkProgram(program);
gl.useProgram(program);
const uVP = gl.getUniformLocation(program, 'uViewProjection');
const uModel = gl.getUniformLocation(program, 'uModel');
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]];
const scene = [{ color: [0.55, 0.39, 0.25], model: m4.multiply(m4.translation(0, -0.05, 0), m4.scaling(2.4, 0.1, 0.7)) },
...colors.map((color, i) => ({ color,
model: m4.multiply(m4.translation(-0.9 + i * 0.45, 0.5 + (i % 2) * 0.08, 0), m4.scaling(0.3, 1 + (i % 2) * 0.16, 0.55)) }))];
const projection = m4.perspective(40, 600 / 280, 0.1, 50);
const target = [0, 0.5, 0]; // the middle book
function frame(ms) {
const a = ms * 0.0005;
const eye = [Math.sin(a) * 4, 1.4 + Math.sin(a * 0.7) * 0.8, Math.cos(a) * 4];
const view = m4.lookAt(eye, target);
gl.uniformMatrix4fv(uVP, false, m4.multiply(projection, view));
gl.clearColor(0.97, 0.96, 0.93, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
for (const { color, model } of scene) {
gl.uniformMatrix4fv(uModel, false, model);
gl.uniform3fv(uColor, color);
gl.drawArrays(gl.TRIANGLES, 0, 36);
}
const f = (v) => v.map(n => n.toFixed(2).padStart(5)).join(', ');
document.getElementById('out').textContent =
`eye (${f(eye)}) target (${f(target)})\n` +
`camera x axis (row 0): ${f([view[0], view[4], view[8]])}\n` +
`camera y axis (row 1): ${f([view[1], view[5], view[9]])}\n` +
`camera z axis (row 2): ${f([view[2], view[6], view[10]])} points from target to eye`;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>