Skip to content

Projection, FOV and the View Frustum

What the projection matrix actually does, which is the loose end Sections 2.2 and 2.4 both left hanging: it writes depth into ww, and the divide that follows is what makes distant things small. Why vertical field of view is the one to set and horizontal is the one to derive. What the frustum is, and the neat fact that its six planes are already sitting in the matrix. And then the practical payoff: a tiny near plane wrecks your depth buffer, by a factor you can calculate.

Every matrix in Part 2 kept its bottom row at (0,0,0,1)(0, 0, 0, 1), so ww came out as 1 and a point stayed a point. Section 2.2 flagged that as a deliberate restriction and said one transform breaks it. This is that transform.

A perspective matrix puts 1-1 in the bottom row, under zz:

P=[1atan(θ/2)00001tan(θ/2)0000f+nfn2fnfn0010]P = \begin{bmatrix} \frac{1}{a\tan(\theta/2)} & 0 & 0 & 0 \\ 0 & \frac{1}{\tan(\theta/2)} & 0 & 0 \\ 0 & 0 & -\frac{f+n}{f-n} & -\frac{2fn}{f-n} \\ 0 & 0 & -1 & 0 \end{bmatrix}

That 1-1 copies the point’s view-space depth into ww. Then the hardware divides xx, yy and zz by ww - and dividing by depth is exactly what perspective is. Something twice as far away comes out half as wide, because it was divided by twice as much.

Orthographic projection is the same thing with the bottom row left alone. No ww, no divide, no convergence. Which means an orthographic projection is an ordinary affine transform of the kind Part 2 already covered, and that is the real difference between the two - not the look, the ww.

Below is one corridor of posts, projected by the code in this Section rather than by the renderer. The square is the NDC box; anything outside it is off screen.

One corridor, with and without the divide by w
The code that draws it src/lib/gamedev/demos/projcompare.scene.ts
/**
 * One corridor, projected by our own matrices, with and without the divide by w.
 */
import * as THREE from "three";
import { extentAt, ndcOf, orthographic, perspective } from "../projection.ts";
import type { Mat4, Vec3 } from "../matrices.ts";
import { makeCanvas, addSlider, addCheckbox, addReadout } from "./ui.ts";
import type { MountFn } from "./runner.ts";

const ASPECT = 16 / 9;
const NEAR = 0.5;
const FAR = 40;
/** Both projections frame the same amount of world here, so only the divide differs. */
const MATCH_AT = 4;

const GATE_ZS = [3, 4.5, 6.5, 9.5, 14, 20, 28];
const HALF_WIDTH = 2.2;
const FLOOR_Y = -1;
const GATE_TOP = 0.8;
const LANES = [-2.2, -1.1, 0, 1.1, 2.2];

const FLOOR = 0x565f6a;
const GATE = 0x39d3c3;
const FRAME = 0x6e7681;

type Seg = [Vec3, Vec3];

const mount: MountFn = (el) => {
  const { renderer, width, height, background } = makeCanvas(el, 300);

  const halfH = 1.12;
  const halfW = (halfH * width) / height;
  const scene = new THREE.Scene();
  scene.background = background;
  const camera = new THREE.OrthographicCamera(
    -halfW,
    halfW,
    halfH,
    -halfH,
    0.1,
    10,
  );
  camera.position.z = 5;

  const addSegments = (color: number, dashed = false) => {
    const geom = new THREE.BufferGeometry();
    const mesh = new THREE.LineSegments(
      geom,
      dashed
        ? new THREE.LineDashedMaterial({ color, dashSize: 0.06, gapSize: 0.05 })
        : new THREE.LineBasicMaterial({ color }),
    );
    scene.add(mesh);
    return (segs: Seg[], proj: Mat4) => {
      const pts: THREE.Vector3[] = [];
      for (const [a, b] of segs) {
        const na = ndcOf(proj, a);
        const nb = ndcOf(proj, b);
        // Projection maps straight lines to straight lines, so two endpoints are enough.
        if (na === null || nb === null) continue;
        pts.push(
          new THREE.Vector3(na.x, na.y, 0),
          new THREE.Vector3(nb.x, nb.y, 0),
        );
      }
      geom.setFromPoints(pts);
      if (dashed) mesh.computeLineDistances();
    };
  };

  // The NDC box: anything outside this is off screen.
  scene.add(
    new THREE.Line(
      new THREE.BufferGeometry().setFromPoints([
        new THREE.Vector3(-1, -1, 0),
        new THREE.Vector3(1, -1, 0),
        new THREE.Vector3(1, 1, 0),
        new THREE.Vector3(-1, 1, 0),
        new THREE.Vector3(-1, -1, 0),
      ]),
      new THREE.LineBasicMaterial({ color: FRAME }),
    ),
  );
  // Eye level. The floor lines run at it under perspective and never reach it otherwise.
  const horizon = new THREE.Line(
    new THREE.BufferGeometry().setFromPoints([
      new THREE.Vector3(-1, 0, 0),
      new THREE.Vector3(1, 0, 0),
    ]),
    new THREE.LineDashedMaterial({
      color: 0x484f58,
      dashSize: 0.05,
      gapSize: 0.04,
    }),
  );
  horizon.computeLineDistances();
  scene.add(horizon);

  const floorLines = addSegments(FLOOR);
  const gateLines = addSegments(GATE);

  const show = addReadout(el);
  const fov = addSlider(el, "vertical field of view", 42, 95, 55, draw);
  const ortho = addCheckbox(el, "orthographic, so no divide by w", false, draw);

  const nearZ = GATE_ZS[0];
  const farZ = GATE_ZS[GATE_ZS.length - 1];

  function draw() {
    const proj = ortho()
      ? orthographic(
          extentAt(fov(), ASPECT, MATCH_AT).halfHeight,
          ASPECT,
          NEAR,
          FAR,
        )
      : perspective(fov(), ASPECT, NEAR, FAR);

    // A floor grid: lanes running away, plus a rung at each gate.
    const floor: Seg[] = [];
    for (const x of LANES) {
      floor.push([
        { x, y: FLOOR_Y, z: -nearZ },
        { x, y: FLOOR_Y, z: -farZ },
      ]);
    }
    for (const z of GATE_ZS) {
      floor.push([
        { x: -HALF_WIDTH, y: FLOOR_Y, z: -z },
        { x: HALF_WIDTH, y: FLOOR_Y, z: -z },
      ]);
    }
    floorLines(floor, proj);

    // Gates: two uprights and a top bar at each depth.
    const gates: Seg[] = [];
    for (const z of GATE_ZS) {
      for (const side of [-1, 1]) {
        gates.push([
          { x: side * HALF_WIDTH, y: FLOOR_Y, z: -z },
          { x: side * HALF_WIDTH, y: GATE_TOP, z: -z },
        ]);
      }
      gates.push([
        { x: -HALF_WIDTH, y: GATE_TOP, z: -z },
        { x: HALF_WIDTH, y: GATE_TOP, z: -z },
      ]);
    }
    gateLines(gates, proj);

    const nearGate = ndcOf(proj, { x: HALF_WIDTH, y: FLOOR_Y, z: -nearZ })!;
    const farGate = ndcOf(proj, { x: HALF_WIDTH, y: FLOOR_Y, z: -farZ })!;
    const shrink = (farGate.x / nearGate.x) * 100;
    show(
      `${ortho() ? "orthographic" : "perspective"}  \u00B7  the gate at ${farZ} m is ` +
        `${shrink.toFixed(0)}% as wide as the one at ${nearZ} m`,
    );
    renderer.render(scene, camera);
  }

  draw();

  return () => renderer.dispose();
};

export default mount;

Both projections are set up to frame the same amount of world at the middle of the corridor, so the only difference you are seeing is the divide. Tick the checkbox and the rails snap parallel.

The build check pins the ratio: a point at 50 metres comes out at exactly one tenth the screen offset of the same point at 5 metres. Ten times further, ten times narrower, because it was divided by ten times more.

PerspectiveOrthographic
Bottom row(0,0,1,0)(0,0,-1,0)(0,0,0,1)(0,0,0,1)
Distant thingsshrinkdo not
Depth in NDCnon-linearlinear
Used forfirst and third personstrategy, isometric, shadow maps, UI

Field of view is the angle the frustum opens out at. There are two of them, and they are not independent - the aspect ratio ties them together:

tanθx2=atanθy2\tan\frac{\theta_x}{2} = a \cdot \tan\frac{\theta_y}{2}

Set the vertical one. This matters more than it sounds. If you set vertical FOV and the player widens their window, the extra pixels show more world to the left and right while the top and bottom stay put. If you set horizontal FOV instead, a wider window keeps the same horizontal view and crops the top and bottom off, which is a genuinely bad experience on an ultrawide monitor.

At 16:9, a vertical 60° works out to a horizontal 91°. The check confirms horizontal always exceeds vertical on a wide aspect, that the conversion round-trips, and that on a square viewport the two are identical.

The visible region is a box with a slanted side and a smaller near face - a frustum, which is just the word for a pyramid with its tip cut off. Six planes bound it: four sides from the FOV and the aspect ratio, plus the near and far planes.

The frustum as a solid, and what survives culling
The code that draws it src/lib/gamedev/demos/frustum.scene.ts
/**
 * The view frustum as a solid you can walk around, with objects coloured by whether they survive.
 */
import * as THREE from "three";
import { frustumCorners, fovXFromFovY } from "../projection.ts";
import { ASPECT, OBJECTS, RADIUS, visibility } from "./frustum-shared.ts";
import { makeCanvas, addSlider, addReadout } from "./ui.ts";
import type { MountFn } from "./runner.ts";

const EDGES: ReadonlyArray<readonly [number, number]> = [
  [0, 1],
  [1, 2],
  [2, 3],
  [3, 0], // near face
  [4, 5],
  [5, 6],
  [6, 7],
  [7, 4], // far face
  [0, 4],
  [1, 5],
  [2, 6],
  [3, 7], // the sides
];

const IN = 0x39d3c3;
const OUT = 0x484f58;

const mount: MountFn = (el) => {
  const { renderer, width, height, background } = makeCanvas(el, 330);

  const scene = new THREE.Scene();
  scene.background = background;
  const camera = new THREE.PerspectiveCamera(38, width / height, 0.1, 200);

  const frustumGeom = new THREE.BufferGeometry();
  frustumGeom.setAttribute(
    "position",
    new THREE.BufferAttribute(new Float32Array(EDGES.length * 6), 3),
  );
  scene.add(
    new THREE.LineSegments(
      frustumGeom,
      new THREE.LineBasicMaterial({ color: 0xd2a8ff }),
    ),
  );

  // The camera being visualised: a dot at its own origin and a stub along its -Z.
  scene.add(
    new THREE.Mesh(
      new THREE.SphereGeometry(0.22, 14, 10),
      new THREE.MeshBasicMaterial({ color: 0xf0883e }),
    ),
  );
  const forward = new THREE.Line(
    new THREE.BufferGeometry().setFromPoints([
      new THREE.Vector3(0, 0, 0),
      new THREE.Vector3(0, 0, -2),
    ]),
    new THREE.LineBasicMaterial({ color: 0xf0883e }),
  );
  scene.add(forward);

  const blobs = OBJECTS.map((p) => {
    const m = new THREE.Mesh(
      new THREE.SphereGeometry(RADIUS, 12, 9),
      new THREE.MeshBasicMaterial({ color: OUT }),
    );
    m.position.set(p.x, p.y, p.z);
    scene.add(m);
    return m;
  });

  const show = addReadout(el);
  const fov = addSlider(el, "vertical field of view", 25, 110, 55, draw);
  const near = addSlider(el, "near plane", 0.5, 5, 1.5, draw, " m", 0.1);
  const far = addSlider(el, "far plane", 6, 22, 16, draw, " m", 0.5);
  const spin = addSlider(el, "walk around it", -180, 180, 35, draw);

  function draw() {
    const corners = frustumCorners(fov(), ASPECT, near(), far());
    const pts: THREE.Vector3[] = [];
    for (const [a, b] of EDGES) {
      pts.push(
        new THREE.Vector3(corners[a].x, corners[a].y, corners[a].z),
        new THREE.Vector3(corners[b].x, corners[b].y, corners[b].z),
      );
    }
    frustumGeom.setFromPoints(pts);

    const seen = visibility(fov(), near(), far());
    blobs.forEach((m, i) => {
      (m.material as THREE.MeshBasicMaterial).color.setHex(seen[i] ? IN : OUT);
    });

    // Orbit the viewing camera around the middle of the frustum.
    const a = (spin() * Math.PI) / 180;
    const r = 26;
    camera.position.set(Math.sin(a) * r, 11, Math.cos(a) * r - far() * 0.5);
    camera.lookAt(0, 0, -far() * 0.5);

    show(
      `vertical ${fov().toFixed(0)}\u00B0 means horizontal ` +
        `${fovXFromFovY(fov(), ASPECT).toFixed(0)}\u00B0 at 16:9  \u00B7  ` +
        `${seen.filter(Boolean).length} of ${OBJECTS.length} objects need drawing`,
    );
    renderer.render(scene, camera);
  }

  draw();

  return () => renderer.dispose();
};

export default mount;

Walk around it with the last slider. Teal objects are inside the frustum and have to be drawn; grey ones are outside and can be skipped entirely.

That skipping is frustum culling, and it is the cheapest large optimisation in rendering: a handful of dot products per object decides whether to touch it at all. Drag the FOV down and watch the count fall.

Here is the part worth knowing rather than deriving with trigonometry.

“Inside the frustum” means all three NDC coordinates lie in [1,1][-1, 1]. Write out any one of those six inequalities in terms of the clip-space coordinates - wxw-w \le x \le w and so on - and each one is a row of the projection matrix added to or subtracted from the bottom row.

left=r3+r0right=r3r0near=r3+r2\text{left} = r_3 + r_0 \qquad \text{right} = r_3 - r_0 \qquad \text{near} = r_3 + r_2 \quad \dots

So the planes are not extra information you compute alongside the matrix. They were in the matrix the whole time. frustumPlanes reads them off, normalises them so the signed distances are real distances, and that is the entire implementation.

The build check verifies this the strongest way available: it asks 20,000 points whether they are visible, once by dividing by ww and testing the NDC box, and once by testing the six planes. Two different pieces of arithmetic, zero disagreements - and the sample is checked to straddle the boundary, since agreement on points that are all inside would prove nothing.

source Projection, the frustum, and depth precision src/lib/gamedev/projection.ts 356 lines
/**
 * The last matrix in the chain, and the one that behaves differently from all the others.
 *
 * Everything in Part 2 kept the bottom row at `(0, 0, 0, 1)`, so `w` stayed 1 and a point stayed a
 * point. Projection breaks that on purpose: it writes depth into `w`, and the divide by `w` that
 * follows is what makes distant things small. That divide is also where depth precision goes, which
 * is the practical half of this Section.
 */
import type { Mat4, Vec3, Vec4 } from "./matrices.ts";
import { applyMat4, rowsOf } from "./matrices.ts";

const DEG = Math.PI / 180;

/**
 * A perspective projection, from a **vertical** field of view.
 *
 * Vertical is the convention worth defaulting to, because it keeps the same amount of the world
 * visible when the window gets wider - only the horizontal extent grows. Do it the other way and
 * an ultrawide monitor crops the top and bottom off your game.
 */
export function perspective(
  fovYDegrees: number,
  aspect: number,
  near: number,
  far: number,
): Mat4 {
  const t = Math.tan(fovYDegrees * DEG * 0.5);
  return {
    i: { x: 1 / (aspect * t), y: 0, z: 0, w: 0 },
    j: { x: 0, y: 1 / t, z: 0, w: 0 },
    // The -1 in w is the whole trick: it copies view depth into clip w.
    k: { x: 0, y: 0, z: -(far + near) / (far - near), w: -1 },
    t: { x: 0, y: 0, z: (-2 * far * near) / (far - near), w: 0 },
  };
}

/**
 * An orthographic projection: no divide, so no convergence and no foreshortening.
 *
 * Its bottom row stays `(0, 0, 0, 1)`, which means it is an ordinary affine transform of the kind
 * Part 2 already covered. That is the real difference between the two - not the look, the `w`.
 */
export function orthographic(
  halfHeight: number,
  aspect: number,
  near: number,
  far: number,
): Mat4 {
  return {
    i: { x: 1 / (aspect * halfHeight), y: 0, z: 0, w: 0 },
    j: { x: 0, y: 1 / halfHeight, z: 0, w: 0 },
    k: { x: 0, y: 0, z: -2 / (far - near), w: 0 },
    t: { x: 0, y: 0, z: -(far + near) / (far - near), w: 1 },
  };
}

/** Horizontal field of view implied by a vertical one at a given aspect ratio. */
export function fovXFromFovY(fovYDegrees: number, aspect: number): number {
  return (2 * Math.atan(aspect * Math.tan(fovYDegrees * DEG * 0.5))) / DEG;
}

/** And back the other way, for a tool that insists on horizontal. */
export function fovYFromFovX(fovXDegrees: number, aspect: number): number {
  return (2 * Math.atan(Math.tan(fovXDegrees * DEG * 0.5) / aspect)) / DEG;
}

/**
 * Project a view-space point into normalised device coordinates, divide included.
 *
 * Anything with all three components inside `[-1, 1]` is on screen. Returns `null` when `w` has
 * collapsed, which happens exactly at the camera's own position - the one place projection has no
 * answer.
 */
export function ndcOf(proj: Mat4, p: Vec3): Vec3 | null {
  const clip: Vec4 = applyMat4(proj, { x: p.x, y: p.y, z: p.z, w: 1 });
  if (Math.abs(clip.w) < 1e-12) return null;
  return { x: clip.x / clip.w, y: clip.y / clip.w, z: clip.z / clip.w };
}

/** Half the height and width of the frustum at a given distance in front of the camera. */
export function extentAt(
  fovYDegrees: number,
  aspect: number,
  distance: number,
): { halfHeight: number; halfWidth: number } {
  const halfHeight = distance * Math.tan(fovYDegrees * DEG * 0.5);
  return { halfHeight, halfWidth: halfHeight * aspect };
}

/** The eight corners of the frustum in view space: near face first, then far. */
export function frustumCorners(
  fovYDegrees: number,
  aspect: number,
  near: number,
  far: number,
): Vec3[] {
  const out: Vec3[] = [];
  for (const distance of [near, far]) {
    const { halfHeight: h, halfWidth: w } = extentAt(
      fovYDegrees,
      aspect,
      distance,
    );
    out.push(
      { x: -w, y: -h, z: -distance },
      { x: w, y: -h, z: -distance },
      { x: w, y: h, z: -distance },
      { x: -w, y: h, z: -distance },
    );
  }
  return out;
}

// ---- Six planes, straight out of the matrix ----------------------------------------------

/** A plane as `x*px + y*py + z*pz + d >= 0` for the inside. */
export type Plane = { x: number; y: number; z: number; d: number };

/**
 * The frustum's six planes, read directly off the projection matrix rows.
 *
 * This is worth seeing rather than deriving trigonometrically. "Inside" means every NDC coordinate
 * lies in `[-1, 1]`, and each of those six inequalities is a row of the matrix added to or
 * subtracted from the last row. So the planes are not extra information - they were in the matrix
 * the whole time.
 */
export function frustumPlanes(proj: Mat4): Plane[] {
  const r = rowsOf(proj);
  const combine = (a: number[], b: number[], sign: number): Plane => {
    const raw = {
      x: b[0] + sign * a[0],
      y: b[1] + sign * a[1],
      z: b[2] + sign * a[2],
      d: b[3] + sign * a[3],
    };
    const len = Math.hypot(raw.x, raw.y, raw.z) || 1;
    return { x: raw.x / len, y: raw.y / len, z: raw.z / len, d: raw.d / len };
  };
  return [
    combine(r[0], r[3], 1), // left
    combine(r[0], r[3], -1), // right
    combine(r[1], r[3], 1), // bottom
    combine(r[1], r[3], -1), // top
    combine(r[2], r[3], 1), // near
    combine(r[2], r[3], -1), // far
  ];
}

/** Signed distance from a plane. Negative means outside. */
export const distanceToPlane = (plane: Plane, p: Vec3): number =>
  plane.x * p.x + plane.y * p.y + plane.z * p.z + plane.d;

/**
 * Is a sphere at least partly inside all six planes?
 *
 * A sphere rather than a point, because that is what culling actually tests - the object's bounding
 * volume. Fail any one plane by more than the radius and the object cannot be visible, which is why
 * this is the cheap early-out that runs before anything is drawn.
 */
export function insideFrustum(planes: Plane[], p: Vec3, radius = 0): boolean {
  for (const plane of planes) {
    if (distanceToPlane(plane, p) < -radius) return false;
  }
  return true;
}

// ---- Where depth precision goes ----------------------------------------------------------

/** NDC depth for a point straight ahead at `distance` in front of the camera. */
export function ndcDepth(proj: Mat4, distance: number): number {
  const ndc = ndcOf(proj, { x: 0, y: 0, z: -distance });
  return ndc === null ? NaN : ndc.z;
}

/**
 * How much world distance a single depth-buffer step covers, at a given distance out.
 *
 * Small is good: it is the thickness of the thinnest gap the depth buffer can still tell apart.
 * When two surfaces are closer together than this they fight for the same value and flicker, which
 * is z-fighting.
 *
 * Computed from the matrix by finite difference rather than from a remembered formula, so it cannot
 * drift away from whatever `perspective` actually builds.
 */
export function depthResolution(
  proj: Mat4,
  distance: number,
  bits = 24,
): number {
  const quantum = 2 / Math.pow(2, bits);
  const h = distance * 1e-6;
  const slope =
    (ndcDepth(proj, distance + h) - ndcDepth(proj, distance - h)) / (2 * h);
  return Math.abs(quantum / slope);
}

// ---- Pixels, and going backwards ---------------------------------------------------------

/** Where a pixel sits in NDC. Note the **Y flip**: pixels count down, NDC counts up. */
export function screenToNdc(
  px: number,
  py: number,
  width: number,
  height: number,
): { x: number; y: number } {
  return {
    x: (px / width) * 2 - 1,
    y: -((py / height) * 2 - 1),
  };
}

/** And back to pixels, flipping Y again. */
export function ndcToScreen(
  ndc: { x: number; y: number },
  width: number,
  height: number,
): { x: number; y: number } {
  return {
    x: (ndc.x * 0.5 + 0.5) * width,
    y: (0.5 - ndc.y * 0.5) * height,
  };
}

/**
 * The view-space point that a cursor position corresponds to, at a chosen distance.
 *
 * Read straight off the frustum's geometry rather than by inverting the projection matrix.
 * Section 5.1 already established that the frustum is `distance * tan(fov/2)` tall, so a
 * cursor at NDC `(x, y)` is that fraction of the way across it. No matrix inverse needed, and
 * `projectionCheck` confirms it round-trips through `ndcOf` exactly.
 *
 * Engines invert the matrix instead because they have to support projections this shortcut
 * does not cover - off-centre frusta for VR, oblique projections for portals.
 */
export function unprojectAt(
  fovYDegrees: number,
  aspect: number,
  ndc: { x: number; y: number },
  distance: number,
): Vec3 {
  const { halfHeight, halfWidth } = extentAt(fovYDegrees, aspect, distance);
  return { x: ndc.x * halfWidth, y: ndc.y * halfHeight, z: -distance };
}

/** A ray from the camera through a cursor position: origin plus a unit direction. */
export function rayThroughNdc(
  fovYDegrees: number,
  aspect: number,
  ndc: { x: number; y: number },
): { origin: Vec3; direction: Vec3 } {
  const at = unprojectAt(fovYDegrees, aspect, ndc, 1);
  const len = Math.hypot(at.x, at.y, at.z);
  return {
    origin: { x: 0, y: 0, z: 0 },
    direction: { x: at.x / len, y: at.y / len, z: at.z / len },
  };
}

/**
 * Where a world point lands on screen, and whether it should be drawn at all.
 *
 * `inFront` is the part people forget. Clip `w` is the point's distance in front of the camera,
 * so it goes **negative** behind the camera - and dividing by a negative number mirrors the
 * result through the origin. Skip that test and markers for things behind you appear on screen,
 * in the wrong place, upside down.
 */
export function projectToScreen(
  proj: Mat4,
  p: Vec3,
  width: number,
  height: number,
): { x: number; y: number; inFront: boolean; onScreen: boolean } {
  const clip = applyMat4(proj, { x: p.x, y: p.y, z: p.z, w: 1 });
  const inFront = clip.w > 1e-9;
  const w = inFront ? clip.w : 1;
  const ndc = { x: clip.x / w, y: clip.y / w, z: clip.z / w };
  const screen = ndcToScreen(ndc, width, height);
  return {
    ...screen,
    inFront,
    onScreen:
      inFront &&
      Math.abs(ndc.x) <= 1 &&
      Math.abs(ndc.y) <= 1 &&
      Math.abs(ndc.z) <= 1,
  };
}

/**
 * The nearest point where a ray enters a sphere, or `null` if it misses.
 *
 * Enough for picking, which is all this Section needs. Part 6 does intersection tests
 * properly, including the cases this one glosses over.
 */
export function raySphere(
  origin: Vec3,
  direction: Vec3,
  centre: Vec3,
  radius: number,
): number | null {
  const ox = origin.x - centre.x;
  const oy = origin.y - centre.y;
  const oz = origin.z - centre.z;
  const b = ox * direction.x + oy * direction.y + oz * direction.z;
  const c = ox * ox + oy * oy + oz * oz - radius * radius;
  const discriminant = b * b - c;
  if (discriminant < 0) return null;
  const root = Math.sqrt(discriminant);
  const near = -b - root;
  const far = -b + root;
  const t = near >= 0 ? near : far;
  return t >= 0 ? t : null;
}

// ---- Spherical coordinates, for an orbit camera ------------------------------------------

/**
 * A position on a sphere from two angles and a radius, which is what an orbit camera is.
 *
 * Azimuth sweeps around the Y axis, elevation tilts up and down. Storing a camera this way
 * means dragging maps onto the two angles directly, and zoom is the radius - all three controls
 * stay independent, which they do not if you store a position and try to rotate it.
 */
export function sphericalToCartesian(
  radius: number,
  azimuthDegrees: number,
  elevationDegrees: number,
): Vec3 {
  const az = azimuthDegrees * DEG;
  const el = elevationDegrees * DEG;
  const horizontal = radius * Math.cos(el);
  return {
    x: horizontal * Math.sin(az),
    y: radius * Math.sin(el),
    z: horizontal * Math.cos(az),
  };
}

/**
 * Back to angles. At the poles the azimuth is genuinely undefined - every value gives the same
 * point - so it reports 0 rather than whatever the floating point noise suggests.
 */
export function cartesianToSpherical(p: Vec3): {
  radius: number;
  azimuth: number;
  elevation: number;
} {
  const radius = Math.hypot(p.x, p.y, p.z);
  if (radius < 1e-12) return { radius: 0, azimuth: 0, elevation: 0 };
  const horizontal = Math.hypot(p.x, p.z);
  return {
    radius,
    azimuth: horizontal < 1e-12 ? 0 : Math.atan2(p.x, p.z) / DEG,
    elevation: Math.asin(Math.min(1, Math.max(-1, p.y / radius))) / DEG,
  };
}

Your Near Plane Is Wrecking Your Depth Buffer

Section titled “Your Near Plane Is Wrecking Your Depth Buffer”

Now the practical part, and the reason this Section is more than definitions.

The divide by ww makes depth non-linear in NDC. Almost all of the available depth range gets spent close to the camera, and almost none far away. So the depth buffer can distinguish surfaces a fraction of a millimetre apart near the camera and metres apart in the distance.

How badly depends almost entirely on the near plane:

What the near plane costs, and what the far plane does not
The code src/lib/gamedev/demos/depthprec.ts
/** What the near plane costs you in depth precision, and what the far plane does not. */
import { depthResolution, perspective } from "../projection.ts";
import type { Demo } from "./runner.ts";

const ASPECT = 16 / 9;
const BITS = 24;
const mm = (metres: number) => `${(metres * 1000).toFixed(2)} mm`;

const demo: Demo = (log) => {
  // A 24-bit depth buffer, measured at 100 metres out, for four choices of near plane.
  for (const near of [0.001, 0.01, 0.1, 1]) {
    const proj = perspective(60, ASPECT, near, 1000);
    log(
      `near ${near} m, depth precision at 100 m`,
      mm(depthResolution(proj, 100, BITS)),
      near === 0.001 ? "half a metre of uncertainty" : undefined,
    );
  }

  // Now move the far plane by the same factor of ten and watch nothing happen.
  const a = depthResolution(perspective(60, ASPECT, 0.1, 100), 50, BITS);
  const b = depthResolution(perspective(60, ASPECT, 0.1, 1000), 50, BITS);
  log(
    "far 100 m vs far 1000 m, measured at 50 m",
    `${(b / a).toFixed(4)}x worse`,
    "ten times the range costs almost nothing",
  );
};

export default demo;
near 0.001 m, depth precision at 100 m 596.05 mm // half a metre of uncertainty
near 0.01 m, depth precision at 100 m 59.60 mm
near 0.1 m, depth precision at 100 m 5.96 mm
near 1 m, depth precision at 100 m 0.60 mm
far 100 m vs far 1000 m, measured at 50 m 1.0009x worse // ten times the range costs almost nothing

Read the first four rows. With a 24-bit depth buffer at 100 metres out:

Near planeDepth precision at 100 m
1 mm596 mm
1 cm59.6 mm
10 cm5.96 mm
1 m0.60 mm

A near plane of one millimetre leaves you half a metre of depth uncertainty at 100 metres. Two surfaces closer together than that cannot be told apart, and they flicker against each other as the camera moves - z-fighting.

The relationship is exact, and the check verifies the numeric result against it:

precisionq(fn)d22fn\text{precision} \approx \frac{q\,(f - n)\,d^2}{2\,f\,n}

Two things fall out of that nn in the denominator and d2d^2 on top. Precision degrades with the square of distance. And the near plane is a direct multiplier: ten times larger is ten times better, which the check confirms as 10.000910.0009.

Meanwhile the far plane is in there too, but only inside (fn)/f(f-n)/f, which barely moves. Pushing the far plane from 100 m to 1000 m - ten times the view distance - costs 1.0009×1.0009\times the precision. Essentially nothing.

  • Z-fighting on distant geometry - decals, floor tiles, coplanar walls - which is this Section’s table and is usually fixed at the near plane rather than in the geometry.
  • Frustum culling, the first thing any renderer does per frame, using planes it already has.
  • Field of view settings in an options menu, which should be vertical FOV even when the label says something friendlier.
  • Ultrawide support, where a horizontal-FOV camera crops the view instead of extending it.
  • Shadow maps, which are orthographic projections from the light’s point of view, with their own near and far planes and their own precision problems.
  • Weapon viewmodels, often drawn with a second, narrower projection so a first-person gun does not clip through walls or look distorted.