View Matrix

The View Matrix and a Virtual Camera

WebGL has no camera. By convention, eye space puts a virtual camera at the origin looking down the negative z axis (not the negative y axis, as some tutorials say), with +y up and +x to the right. To view the world from elsewhere, you move the world the opposite way: the view matrix is the inverse of the camera's own model matrix. A camera 5 units back and 1 unit up is a translation by (0, 1, 5), so its view matrix translates by (0, -1, -5), putting the world origin at (0, -1, -5) in eye space: 5 units in front of the lens, since negative z means "in front".

Moving the camera right equals moving every object left, so one view matrix serves the whole scene while each object keeps its model matrix. For a camera that also turns, lookAt builds the inverse directly.

A camera sliding along x: its view (left) is the world moved the opposite way, seen from above (right)HTMLLive
<!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; font-family: monospace; }
  .names div { padding: 4px 2px; text-align: center; }
</style>
<canvas id="view" width="600" height="260"></canvas>
<div class="names"><div style="width:66%" id="cam"></div><div style="width:34%">top view: the camera is the red box</div></div>
<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];
  },
  ortho: (l, r, b, t, n, f) => [2 / (r - l), 0, 0, 0, 0, 2 / (t - b), 0, 0, 0, 0, -2 / (f - n), 0,
    -(r + l) / (r - l), -(t + b) / (t - b), -(f + n) / (f - n), 1],
  rotationX(deg) { const r = deg * Math.PI / 180, c = Math.cos(r), s = Math.sin(r);
    return [1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 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)),
};
// A unit cube: x y z, nx ny nz per vertex, 36 vertices.
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 uProjection, uView, uModel; out float vLight;
void main() {
  vLight = 0.55 + 0.45 * max(dot(normalize(mat3(uModel) * aNormal), normalize(vec3(0.4, 0.8, 0.6))), 0.0);
  gl_Position = uProjection * uView * 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 u = name => gl.getUniformLocation(program, name);
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);
gl.enable(gl.SCISSOR_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 books = colors.map((color, i) => ({ color,
  model: m4.chain(m4.translation(-1.6 + i * 0.8, 0.6, -i % 2 * 0.8), m4.scaling(0.5, 1.2, 0.2)) }));
const floor = { color: [0.8, 0.76, 0.68], model: m4.chain(m4.translation(0, -0.05, 0), m4.scaling(5, 0.1, 3)) };

function drawScene(projection, view, extra = []) {
  gl.uniformMatrix4fv(u('uProjection'), false, projection);
  gl.uniformMatrix4fv(u('uView'), false, view);
  for (const { color, model } of [floor, ...books, ...extra]) {
    gl.uniformMatrix4fv(u('uModel'), false, model);
    gl.uniform3fv(u('uColor'), color);
    gl.drawArrays(gl.TRIANGLES, 0, 36);
  }
}
function frame(ms) {
  const camX = 1.8 * Math.sin(ms * 0.0006);
  const cameraModel = m4.translation(camX, 1, 5);      // where the camera sits in the world
  const view = m4.translation(-camX, -1, -5);           // its inverse: move the world the other way
  gl.clearColor(0.97, 0.96, 0.93, 1);
  gl.viewport(0, 0, 396, 260); gl.scissor(0, 0, 396, 260);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  drawScene(m4.perspective(45, 396 / 260, 0.1, 50), view);

  gl.clearColor(0.9, 0.88, 0.83, 1);
  gl.viewport(400, 0, 200, 260); gl.scissor(400, 0, 200, 260);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  const top = m4.chain(m4.rotationX(90), m4.translation(0, -10, -1.5));   // look straight down
  drawScene(m4.ortho(-2.8, 2.8, -3.7, 3.7, 0.1, 30), top,
    [{ color: [0.85, 0.2, 0.15], model: m4.multiply(cameraModel, m4.scaling(0.4, 0.4, 0.6)) }]);
  document.getElementById('cam').textContent =
    `camera at (${camX.toFixed(2)}, 1, 5)  ->  view = translation(${(-camX).toFixed(2)}, -1, -5)`;
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>