Early-Z and the Depth Pipeline
You met
the
z-buffer as an algorithm: shade a fragment, compare its depth against the buffer, keep
the closer one. Read that sentence again as a hardware engineer and it should horrify you. The
expensive step — running the fragment shader, with its texture fetches and lighting maths — happens
first, and only then does a cheap compare decide whether the result was wanted at
all. In a typical game scene each pixel is covered by several triangles (an overdraw
of 2–4×), so the naïve order shades two to four fragments per pixel and throws most of them away.
The single most profitable optimisation in the whole pixel pipeline follows from one observation:
The cheapest fragment is the one you never shade. This lesson is about the stack of
silicon tricks — early-Z, hierarchical Z, depth compression, fast clears — that exists purely to
kill invisible work as early, and as wholesale, as possible.
Early-Z: move the test before the shader
The fix sounds trivial: the
rasterizer
already interpolates each fragment's depth before any shading happens — so test it then. A
small depth unit sits between the rasterizer and the shader pool: it reads the stored depth,
compares, and if the incoming fragment loses, the fragment is dropped on the spot. No warp is ever
scheduled, no texture is ever fetched. This is early-Z (or early depth test), and
on modern GPUs it is the default path for essentially every opaque draw.
But it is a rearrangement of the spec, not the spec itself — the API still promises that
the depth test happens after shading. Early-Z is only legal when the reordering is invisible, which
means the shader must not change the two inputs the test depends on:
- It must not write its own depth. If the shader outputs a custom depth value,
the rasterizer's interpolated z is not the value the spec says to test — the test must
wait until the shader has run.
- It must not
discard fragments. A discarded fragment must leave the
depth buffer untouched; an early test that already wrote its z would corrupt the buffer for
everything behind it.
The hardware handles this automatically: when a shader is compiled, the driver inspects it, and any
depth write or discard silently routes the whole draw to the late-Z path — the
original test-after-shading order, performed in the render output units at the end of the pipe. Same
image, none of the savings.
Hierarchical Z: reject whole tiles with one compare
Early-Z kills fragments one at a time. Silicon can do better: kill them by the tile. The
depth unit maintains a small side buffer — the hierarchical Z (Hi-Z) buffer — with
one entry per screen tile (say 8\times 8 pixels) recording the
maximum (farthest) depth stored anywhere in that tile. Now when the coarse
rasterizer hands over a tile of a new triangle, the depth unit computes the triangle's
nearest possible depth over that tile — a conservative minimum, straight from the plane
equation — and makes one compare:
z_{\min}^{\text{tri}} > z_{\max}^{\text{tile}} \;\Rightarrow\; \text{every fragment in the tile loses — reject all 64 with one compare.}
Work the numbers. Three tiles, with an incoming triangle whose nearest depth over each tile is
0.55 (smaller = closer):
| Tile's stored z_{\max} | Compare | Verdict |
| 0.42 | 0.55 > 0.42 | Reject the whole tile — everything already there is closer than the triangle can possibly be |
| 0.60 | 0.55 < 0.60 | Survive — something in the tile is farther; per-pixel early-Z decides |
| 1.0 (cleared) | 0.55 < 1.0 | Survive — the tile is still background |
One compare against 64 per-pixel tests: for a heavily occluded triangle, Hi-Z discards almost all
of it before the fine rasterizer even stamps out coverage. The Hi-Z buffer is tiny (one value per
tile), lives in on-chip storage, and is updated conservatively as real depths are written.
The reject, drawn
Step through a toy screen of 4\times 3 tiles. A near triangle is already
in the depth buffer; watch the Hi-Z values it leaves behind, then watch a far triangle's tiles get
sentenced one compare at a time.
Depth traffic: compression and fast clears
Every surviving fragment reads the depth buffer, and every winner writes it — at 4 bytes per
sample, depth is one of the largest bandwidth consumers on the chip. Two tricks tame it, both
exploiting the same fact: within one tile, depth usually comes from one triangle, and a
triangle's depth is a plane.
- Plane-equation compression. If a tile's 64 depths all lie on one plane
z = a\,x + b\,y + c, the hardware stores just the three coefficients
(plus a tag) instead of 64 raw values — a huge reduction in the bytes actually moved to and from
memory. Tiles crossed by several triangles fall back to storing more planes, or raw values; the
compression is lossless, and exists purely to cut traffic, not capacity (the worst case
must still fit).
- Fast clears. "Clear the depth buffer" would naively write every byte of a
multi-megabyte buffer, every frame. Instead each tile has a one-bit cleared flag in a
tiny on-chip table: clearing the screen sets the flags and writes nothing. A tile's first real
read materialises the clear value on the fly. Clearing a 4K depth buffer becomes a few thousand
flag writes instead of ~33 MB of memory traffic.
Order is everything: front-to-back — and the transparency exception
Early-Z only rejects a fragment if something closer is already in the buffer — so the
savings depend entirely on draw order. Draw the scene front-to-back and the near
objects seed the depth buffer first; everything behind them is rejected before shading. Draw it
back-to-front and every fragment arrives before its occluder: everything passes the test, everything
is shaded, and the near objects simply overwrite the wasted work. Same image, up to
N\times the shading cost at overdraw N. This
is why engines sort opaque geometry roughly front-to-back (or run a cheap depth pre-pass
that lays down z first, so the main pass shades each pixel exactly once).
Transparency breaks the party. Alpha-blended geometry must be drawn
back-to-front for the blending maths to come out right — the exact opposite of the
rejection-friendly order — and a transparent fragment can't kill what's behind it anyway (you can
see through it, so the thing behind still contributes). Transparent surfaces get no early-Z help at
all: every layer is shaded, every time. It is a standing rule of real-time rendering that
transparency is expensive, and this is precisely why.
Count the kills yourself
A depth pipeline in miniature: two overlapping quads through a simulated early-Z stage, drawn in
both orders. The shader here is just a counter — which is the point: count how many times it runs.
// Two overlapping quads through simulated early-Z (smaller z = closer).
const W = 12, H = 8;
interface Quad { name: string; x0: number; y0: number; x1: number; y1: number; z: number; }
const NEAR: Quad = { name: "NEAR", x0: 1, y0: 1, x1: 8, y1: 6, z: 0.3 };
const FAR: Quad = { name: "FAR", x0: 4, y0: 3, x1: 11, y1: 7, z: 0.7 };
function render(order: Quad[]): { shaded: number; rejected: number } {
const depth: number[] = new Array(W * H).fill(1.0); // fast-cleared to the far plane
let shaded = 0, rejected = 0;
for (const q of order) {
for (let y = q.y0; y < q.y1; y++) {
for (let x = q.x0; x < q.x1; x++) {
const i = y * W + x;
if (q.z < depth[i]) { // the depth test — BEFORE any shading
depth[i] = q.z;
shaded++; // only now would the fragment shader run
} else {
rejected++; // killed pre-shader: zero shading cost
}
}
}
}
return { shaded, rejected };
}
const area = (q: Quad) => (q.x1 - q.x0) * (q.y1 - q.y0);
const overlap =
Math.max(0, Math.min(NEAR.x1, FAR.x1) - Math.max(NEAR.x0, FAR.x0)) *
Math.max(0, Math.min(NEAR.y1, FAR.y1) - Math.max(NEAR.y0, FAR.y0));
console.log(`NEAR covers ${area(NEAR)} px, FAR covers ${area(FAR)} px, overlap ${overlap} px\n`);
const ftb = render([NEAR, FAR]); // front-to-back: near first
const btf = render([FAR, NEAR]); // back-to-front: far first
console.log(`front-to-back: ${ftb.shaded} fragments shaded, ${ftb.rejected} rejected pre-shader`);
console.log(`back-to-front: ${btf.shaded} fragments shaded, ${btf.rejected} rejected pre-shader`);
console.log(`\nsame image either way — front-to-back shades ${btf.shaded - ftb.shaded} fewer fragments`);
Grow the overlap (drag FAR's rectangle over more of NEAR) and watch the
gap widen: the saving is exactly the occluded area, and it costs nothing but drawing in the right
order.
Depth precision: the near-plane hog and the reversed-Z one-liner
One last piece of depth-pipeline lore. Perspective projection stores not z
but (roughly) 1/z — and that curve spends almost all of its output range
on points near the camera. With a near plane at 0.1 m and a far plane at 1000 m, about
half of the representable depth values describe the first metre of the scene; distant geometry gets
squeezed into a sliver of the range, where two different surfaces can round to the same stored value
and flicker through each other — z-fighting.
The modern fix is a famous one-liner: reversed-Z. Map the near plane to
1.0 and the far plane to 0.0 (and
flip the compare to greater-than). A floating-point depth buffer has its representable values packed
densely near zero — which is now the far end, exactly where 1/z
was starving. The two non-linearities roughly cancel, precision becomes nearly uniform across the
whole range, and z-fighting on distant geometry all but vanishes. One projection-matrix tweak, one
comparison flip; the depth hardware — early-Z, Hi-Z, compression — neither knows nor cares which
direction "closer" points.
- early-Z: the per-fragment depth test runs before the shader — legal
only if the shader neither writes depth nor discards (else the hardware falls back to late-Z in
the ROPs);
- hierarchical Z: a per-tile z_{\max} buffer rejects
an entire tile with one conservative compare;
- depth compression stores per-tile plane equations instead of raw samples —
lossless, traffic-saving; fast clears are per-tile flags, not memory writes;
- rejection depends on front-to-back order; blended transparency must go
back-to-front and gets no early-Z help;
- reversed-Z (near → 1, far → 0, float buffer) evens out depth precision.
The early-Z fallback is silent. Write to the fragment depth output — or use
discard anywhere in the shader, even down a branch that never executes — and the
hardware quietly routes the entire draw to late-Z. Nothing breaks; the image is identical; the
profiler just shows your fragment count mysteriously doubled, because every occluded fragment is now
shaded before it dies. A classic real-world case: alpha-tested foliage (discard on a
leaf texture's transparent texels) turning a forest into a worst-case overdraw bill. Engines fight
back by drawing alpha-tested geometry in a separate pass, or laying depth down first in a pre-pass
so even late-Z draws shade each visible pixel once. The lesson generalises: the fast path
has entry conditions, and one innocent-looking shader line can walk you off it.
Put numbers on why this lesson's plumbing exists. A 4K frame is ~8.3 million pixels; at overdraw 3
that is ~25 million depth tests, each a 4-byte read and (for winners) a 4-byte write — roughly
150 MB of depth traffic per frame, 9 GB/s at 60 fps, before a single texture or colour
byte moves. Now stack the lesson's tricks: Hi-Z answers most occluded tiles from a tiny on-chip
buffer (no memory touch at all), plane-equation compression shrinks what does move by several times,
and fast clears delete the 33 MB-per-frame clearing cost outright. Together they turn the depth
buffer from the biggest consumer of memory bandwidth into a minor one — which is why every GPU
vendor, desktop and mobile alike, ships all three. Nobody's marketing ever mentions them: the best
silicon is the kind you only notice when it's gone.