Perspective Projection

Perspective Projection and the Frustum

A perspective projection makes distant things smaller. Its visible volume is a frustum, a pyramid with its tip at the eye, cut by the near and far planes, described by the vertical field of view, the aspect ratio and the two distances (the classic frustum() takes six planes instead). The matrix copies -z into w, and the GPU divides x, y and z by w afterward:

webgl-helper.js, tenth part: a perspective projectionJavaScript
// Section 3.6: perspective projection; fovy is the vertical field of view in degrees
GLH.m4.perspective = function (fovy, aspect, near, far) {
  const f = 1 / Math.tan(fovy * Math.PI / 360), d = near - far;
  return new Float32Array([f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) / d, -1,
    0, 0, 2 * far * near / d, 0]);
};
The same point at three depths, before and after the divide by wJavaScript
const persp = GLH.m4.perspective(45, 2, 0.1, 100);
for (const z of [-1, -2, -4]) {
  const [x, y, cz, w] = GLH.m4.apply(persp, [1, 0.5, z]);
  console.log(`z ${z}: clip ${x}, ${y}, ${cz}, ${w} -> ndc x ${(x / w).toFixed(3)},`,
    `depth ${(cz / w).toFixed(4)}`);
}
Output
z -1: clip 1.207, 1.207, 0.802, 1 -> ndc x 1.207, depth 0.8020
z -2: clip 1.207, 1.207, 1.804, 2 -> ndc x 0.604, depth 0.9020
z -4: clip 1.207, 1.207, 3.808, 4 -> ndc x 0.302, depth 0.9520

Halving the distance doubles the size; the nearest point, beyond NDC x = 1, is outside the view. Depth is far from linear, spent mostly near the near plane, so keep near as large as you can (Z-Fighting and Depth Precision).

A perspective camera's view (left) and its frustum seen from outside (right), with a changing field of viewHTMLLive
<!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="250"></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],
  identity: () => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
  // fovy is the vertical field of view in degrees; the matrix copies -z into w.
  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;
  },
  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 uViewProjection, uModel; uniform bool uFlat; out float vLight;
void main() {
  vLight = uFlat ? 1.0 : 0.5 + 0.5 * max(dot(normalize(mat3(uModel) * aNormal), normalize(vec3(0.5, 0.8, 0.4))), 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 u = (n) => gl.getUniformLocation(program, n);

const cubeVAO = gl.createVertexArray();
gl.bindVertexArray(cubeVAO);
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);
const lineVAO = gl.createVertexArray();
gl.bindVertexArray(lineVAO);
const lineBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, lineBuffer);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
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.multiply(m4.translation(-2 + i, 0.6, -2 - i * 1.2), m4.scaling(0.5, 1.2, 0.2)) }));
const eye = [0, 1, 3], target = [0, 0.6, -3], near = 1, far = 7, aspect = 296 / 250;

function frustumLines(fovy) {         // corners in eye space, moved to the world by the camera's axes
  const view = m4.lookAt(eye, target);
  const toWorld = (p) => [0, 1, 2].map(i =>   // inverse of a rigid view: transpose rotation, add eye
    view[i * 4] * p[0] + view[i * 4 + 1] * p[1] + view[i * 4 + 2] * p[2] + eye[i]);
  const corners = [near, far].map(d => {
    const h = d * Math.tan(fovy * Math.PI / 360), w = h * aspect;
    return [[-w, -h, -d], [w, -h, -d], [w, h, -d], [-w, h, -d]].map(toWorld);
  });
  const lines = [];
  for (let i = 0; i < 4; i++) {
    const j = (i + 1) % 4;
    lines.push(...corners[0][i], ...corners[0][j], ...corners[1][i], ...corners[1][j], ...eye, ...corners[1][i]);
  }
  return new Float32Array(lines);
}
function drawBooks() {
  gl.uniform1i(u('uFlat'), 0);
  gl.bindVertexArray(cubeVAO);
  for (const { color, model } of books) {
    gl.uniformMatrix4fv(u('uModel'), false, model);
    gl.uniform3fv(u('uColor'), color);
    gl.drawArrays(gl.TRIANGLES, 0, 36);
  }
}
function frame(ms) {
  const fovy = 50 + 20 * Math.sin(ms * 0.0008);
  gl.clearColor(0.97, 0.96, 0.93, 1);
  gl.viewport(0, 0, 296, 250); gl.scissor(0, 0, 296, 250);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  gl.uniformMatrix4fv(u('uViewProjection'), false, m4.multiply(m4.perspective(fovy, aspect, near, far), m4.lookAt(eye, target)));
  drawBooks();

  gl.clearColor(0.9, 0.88, 0.83, 1);
  gl.viewport(304, 0, 296, 250); gl.scissor(304, 0, 296, 250);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  gl.uniformMatrix4fv(u('uViewProjection'), false, m4.multiply(m4.perspective(45, aspect, 0.1, 60), m4.lookAt([15, 11, 9], [0, 0, -2.5])));
  drawBooks();
  gl.uniform1i(u('uFlat'), 1);
  gl.uniformMatrix4fv(u('uModel'), false, m4.identity());
  gl.uniform3f(u('uColor'), 0.1, 0.1, 0.1);
  gl.bindVertexArray(lineVAO);
  gl.bindBuffer(gl.ARRAY_BUFFER, lineBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, frustumLines(fovy), gl.DYNAMIC_DRAW);
  gl.drawArrays(gl.LINES, 0, 24);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

// The page's table: one point at three depths, before and after the divide by w.
const persp = m4.perspective(45, 2, 0.1, 100);
document.getElementById('out').textContent = [-1, -2, -4].map(z => {
  const [x, , cz, w] = m4.apply(persp, [1, 0.5, z]);
  return `z ${z}: clip w ${w.toFixed(1)} -> ndc x ${(x / w).toFixed(3)}, depth ${(cz / w).toFixed(4)}`;
}).join('\n');
</script>