Introduction

Every time a GPU draws a 3D scene, it faces a question millions of times per frame: which surface is closest to the camera at this pixel? Everything behind that surface must be hidden. Get it wrong and a distant mountain pokes through a wall, or a character's arm floats in front of their face.

Before the mid-1970s, the standard answer was the painter's algorithm: sort every polygon by depth, then paint them back-to-front so closer ones cover farther ones. It sounds reasonable, until two polygons overlap each other cyclically — a situation that simply has no valid painting order.

In 1974, Edwin Catmull proposed a radical simplification: forget sorting the whole scene. Instead, keep a depth value (the zz-coordinate) for every pixel on screen. Each time a surface covers a pixel, compare its depth against the stored value. If it is closer, update the pixel's color and its stored depth. If it is farther, discard it. One array of numbers — the z-buffer — replaces all that sorting, and hidden-surface removal becomes an O(n)O(n) per-pixel test rather than an O(nlogn)O(n \log n) sort.

That insight is now baked into every GPU ever made.

Try It

The canvas below renders two overlapping colored quads in a simple software rasterizer. Use the toggle to enable or disable the depth buffer.

<!-- {{c_html_intro}} -->
<div class="controls">
  <label class="toggle-label">
    <input type="checkbox" id="depthToggle" checked />
    <span class="toggle-track"><span class="toggle-thumb"></span></span>
    <span id="toggleText">{{label_depth_on}}</span>
  </label>
  <button id="resetBtn" type="button" class="ghost">{{btn_reset}}</button>
</div>
<canvas id="canvas" width="400" height="280"></canvas>
<div id="info" class="info">{{info_default}}</div>
<div class="legend">
  <span class="leg-item"><span class="leg-swatch green"></span> {{legend_green}}</span>
  <span class="leg-item"><span class="leg-swatch red"></span> {{legend_red}}</span>
</div>
/* {{c_css_intro}} */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #f7f9fb; }
.controls { display: flex; align-items: center; gap: 1rem; margin-bottom: .7rem; flex-wrap: wrap; }
.toggle-label { display: flex; align-items: center; gap: .5rem; cursor: pointer; font-weight: 600; font-size: .95rem; }
.toggle-track { width: 44px; height: 24px; border-radius: 12px; background: #aaa; position: relative; transition: background .2s; flex-shrink: 0; }
input[type=checkbox]:checked + .toggle-track { background: #1d6fa5; }
.toggle-thumb { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: left .2s; box-shadow: 0 1px 3px #0003; }
input[type=checkbox]:checked + .toggle-track .toggle-thumb { left: 23px; }
input[type=checkbox] { display: none; }
canvas { display: block; width: 100%; max-width: 400px; border-radius: 10px; border: 1px solid #cdd9e3; background: #1a1a2e; }
.info { font-size: .88rem; color: #555; margin-top: .5rem; min-height: 1.3em; }
.legend { display: flex; gap: 1.2rem; margin-top: .5rem; font-size: .85rem; }
.leg-item { display: flex; align-items: center; gap: .35rem; }
.leg-swatch { width: 14px; height: 14px; border-radius: 3px; display: inline-block; }
.leg-swatch.green { background: #4ade80; }
.leg-swatch.red { background: #f87171; }
button.ghost { font: 600 13px system-ui, sans-serif; padding: .35rem .8rem; border: 1px solid #1d3557; background: #fff; color: #1d3557; border-radius: 8px; cursor: pointer; }
// Code not found

With the depth buffer on, the green quad (closer to the camera) correctly covers the red quad where they overlap. With it off, whichever quad was drawn last simply paints over the other — order matters and the result is wrong. The depth buffer frees the renderer from caring about draw order at all.

The Real Complexity

Hidden-surface removal looks like a sorting problem, and for decades it was treated as one. The z-buffer reframed it entirely.

  • The painter's algorithm sorts nn polygons by depth: O(nlogn)O(n \log n) in the best case, but it fails on cyclic overlaps — three polygons A, B, C where A is partly in front of B, B in front of C, and C in front of A. No back-to-front order exists.
  • Binary Space Partitioning (BSP) pre-processes the scene into a tree that always gives a valid painting order, but the pre-processing is expensive and the tree must be rebuilt for every scene change.
  • The z-buffer is O(np)O(n \cdot p) where nn is the number of triangles and pp is the average number of pixels each triangle covers. For a typical scene that is effectively O(n)O(n) — a single pass, no sorting, no BSP tree.
  • The cost is memory: one depth value per pixel. At 4K resolution (3840×21603840 \times 2160) that is about 33 million depth values. With 32-bit floats, roughly 128 MB — well within a modern GPU's frame buffer.

The z-buffer is a classic space-time tradeoff: spend O(wh)O(w \cdot h) memory (screen width times height) to avoid a global sort. It also introduces precision artifacts when two surfaces are very close in depth (zz-fighting), because the limited precision of the depth values cannot distinguish them.

One subtle issue: standard z-buffers distribute precision non-uniformly. Values near the camera get more precision than distant ones. The reversed-z trick (mapping the near plane to 1.0 and the far plane to 0.0) improves this, and is now standard in modern engines.

Where It Matters

The z-buffer is so fundamental that it has become invisible — it is simply assumed by every rasterization pipeline:

  • Real-time games and engines: every frame drawn by a GPU uses a depth buffer. Disabling it is only done deliberately for effects like transparent surfaces or UI overlays.
  • Shadow mapping: to compute shadows, a depth buffer is rendered from the light's point of view. A pixel in the main scene is in shadow if its depth from the light is greater than the stored value — the same comparison, reused.
  • Deferred rendering: modern engines render scene geometry into a G-buffer (geometry buffer) that stores depth, normals, and albedo in separate textures. Lighting is then computed in a second pass using only the visible surfaces — all selected by the depth buffer.
  • Augmented reality: AR headsets must blend virtual objects with the real world. A depth sensor provides real-world zz-values; virtual objects write to the same depth buffer and are correctly occluded by real surfaces.
  • Medical imaging: CT and MRI volume rendering uses depth compositing to correctly display layered tissue.

Even the painter's algorithm is still used for a narrow case: transparent surfaces, which must be sorted and blended back-to-front after all opaque geometry has been depth-tested. The two approaches coexist in every modern rendering pipeline.

Conclusion

Edwin Catmull's insight — store one depth value per pixel and update it greedily — transformed hidden-surface removal from an O(nlogn)O(n \log n) sorting puzzle into an O(n)O(n) linear scan. The tradeoff is memory, and on any hardware made after 1990 that memory is essentially free.

The z-buffer does not solve every depth problem: zz-fighting, transparent surfaces, and the limitations of finite depth precision all require extra care. But as a default assumption baked into the silicon of every GPU, it is one of the most successful algorithmic simplifications in the history of computing.

The next time a distant mountain stays behind a wall in a video game, or a virtual chair sits correctly on a real floor in an AR headset, the z-buffer is doing its quiet per-pixel work — one comparison, millions of times per second.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/z-buffer/Content licensed under CC BY-NC 4.0.