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:

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}CompareVerdict
0.420.55 > 0.42Reject the whole tile — everything already there is closer than the triangle can possibly be
0.600.55 < 0.60Survive — something in the tile is farther; per-pixel early-Z decides
1.0 (cleared)0.55 < 1.0Survive — 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.

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.

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.