Depth and the Z-Buffer

The MVP pipeline delivers triangles to pixels, but it never decided which triangle wins when two overlap. Draw a wall, then draw a chair behind it — without a referee, whichever you drew last paints over the other, and the chair shows through solid stone. The referee is the z-buffer: a hidden image, the same size as the screen, that remembers depth.

How the z-buffer decides, line by line

Step 1 — keep a depth per pixel. Alongside the colour buffer, the GPU holds a depth buffer storing, for each pixel, the depth of the nearest surface drawn there so far. Clear it each frame to "infinitely far", 1.0.

Step 2 — test every incoming fragment. When a triangle produces a fragment at pixel (i, j) with depth d, compare against the stored value d_{\text{buf}}:

\text{draw the fragment} \iff d < d_{\text{buf}}(i, j).

Step 3 — write only if closer. If the fragment is nearer, paint its colour and overwrite d_{\text{buf}}(i, j) \leftarrow d; otherwise discard it. Order of drawing no longer matters — the nearest surface always wins. That is correct hidden-surface removal, automatically.

Step 4 — recall where the stored depth came from. The depth handed to the buffer is the post-divide value. After the perspective divide, the stored depth is not the true distance z — it is essentially a function of 1/z. With near and far planes n and f, the buffer holds

d(z) = \frac{f}{f - n}\left(1 - \frac{n}{z}\right) \;\in\; [0, 1].

Step 5 — see why precision crowds near the camera. Because d depends on 1/z, equal steps in stored depth correspond to unequal steps in real distance. Half of all the buffer's precision is spent in the slab between n and 2n; the entire far half of the world shares only a sliver of depth values. Depth resolution is lavish up close and miserly far away.

Step 6 — meet z-fighting. When two far-apart-in-the-world surfaces fall into the same depth bucket, the test can't separate them. From frame to frame the winner flickers between them — the shimmering, stitched seam known as z-fighting:

d(z_1) = d(z_2), \quad z_1 \ne z_2 \;\Longrightarrow\; \text{flicker}.

Step 7 — fix it by moving the planes. The crowding is driven by the ratio f/n. Push the near plane out (raise n) and pull the far plane in (lower f), and the 1/z curve flattens — precision spreads more evenly and the fighting stops. The near plane does almost all the work: doubling n buys far more than halving f.

Hidden surfaces are resolved per pixel by a stored depth:

It feels harmless — even generous — to set the near plane to n = 0.001 so nothing ever clips against your nose. It is the opposite of harmless. The usable depth precision scales with the ratio f/n, and a 24-bit depth buffer has only about 16 million distinct values to spend across the whole view.

Drop n from 1 to 0.001 and you have multiplied f/n by a thousand: the 1/z curve becomes a near-vertical cliff at the camera, almost every depth value is hoarded in the first few centimetres, and everything beyond a metre is jammed into a handful of buckets. Distant walls, terrain, and shadows start fighting. The cure is unglamorous and free: choose the largest near plane you can bear and the smallest far plane that still contains the scene. Near plane discipline is the cheapest precision you will ever buy.

It is tempting to treat the depth buffer like a ruler — assume a stored value of 0.5 means "halfway between the near and far planes" in real-world distance. It almost never does. The perspective projection matrix bakes in a division by z, so stored depth is a curve in 1/z, not a straight line in z. A buffer value of 0.5 might sit only a few centimetres past the near plane — the far half of the value range is nowhere near the far half of the scene.

That is precisely why near/far plane placement is not a cosmetic choice: because precision is hoarded near the camera, a needlessly small n or needlessly large f starves distant geometry of buckets to live in, and two surfaces that are metres apart in the world can round to the same stored depth — the z-fighting from Step 6. Always reason about d(z), never about raw z, when you're staring at buffer values.