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:
-
The z-buffer stores the nearest depth so far at each pixel, cleared to far each
frame.
-
A fragment is drawn only if it is closer
(d < d_{\text{buf}}), giving correct hidden-surface removal regardless of
draw order.
-
Stored depth is non-linear, roughly 1/z — precision is
concentrated near the camera and sparse far away.
-
The near/far ratio f/n controls precision; too small a
near plane wastes resolution and causes z-fighting.
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.