Z-Buffer - the backbone of the 3-D rendering

Published on

TL;DR - A Z-buffer (depth buffer) keeps, for every screen pixel, the distance to the closest object rendered so far. Any time a later fragment tries to draw to the same pixel we compare depths; only the nearer fragment survives. The idea is so simple that you can implement it in ~40 lines of vanilla JavaScript and render a spinning 3-D torus entirely out of ASCII characters.

Below I'll dissect the code behind the 3D ASCII Donut demo (GitHub), see how it builds and resets its Z-buffer each frame, and learn a few tricks (cheap lighting, parameterized surfaces, time-based animation) along the way.

Setting the stage

<pre id="output" style="font-size:7px; font-weight:bold;"></pre> <pre> gives us a fixed-width text canvas. Each pair of characters will act like a square pixel, so a 70x35 ASCII grid feels roughly VGA-sized.

The user changes four parameters with range sliders:

outer radius R // distance from donut centre to tube centre inner radius r // radius of the tube itself outerCoverage (θ_max) // how much of the big circle to draw innerCoverage (φ_max) // how much of the small circle to draw Any time a slider moves we call refreshSizes(), which:
  1. Re-reads the four inputs.

  2. Computes size = 2 x (R + r) + 10 - a safe bounding box that fits the torus plus some margin.

  3. Allocates zBuffer[size][size] and fills it with zeroes.

We use a full 2-D array instead of a 1-D list because the math is easier to read and JavaScript's optimizer doesn't really care at these sizes.

Marching around the torus surface

A torus is the Cartesian product of two circles:

For each (φ, θ) pair we calculate the 3-D point:

x = (R + r cos φ) cos θ y = (R + r cos φ) sin θ z = r sin φ
Point 3D
The 3-D point

In code:

for (let φ = 0; φ <= φ_max; φ += innerStep) { const radius = R + r * Math.cos(φ); const z = r * Math.sin(φ); for (let θ = 0; θ <= θ_max; θ += outerStep) { const x = radius * Math.cos(θ); const y = radius * Math.sin(θ); // … } }

innerStep and outerStep are chosen so we take about size / 5 samples along each ring—enough that no holes appear at the current font size.

Two tiny rotations = one relaxed camera

Instead of a full view matrix we fake a camera by rotating the torus around two axes:

// spin around x-axis const y1 = y * cos(α) - z * sin(α); const z1 = y * sin(α) + z * cos(α); // then around z-axis (like a top-down turntable) const x2 = x * cos(β) - y1 * sin(β); const y2 = x * sin(β) + y1 * cos(β);

α and β (called xyPlaneAngle and projectionRotationAngle in the demo) are slowly incremented every frame so the donut tumbles in space.

Perspective-ish projection

Because we're rendering to ASCII we can cheat: a simple orthographic projection with a depth bias is enough to feel 3-D.

const screenX = Math.round(x2) + size / 2; const screenY = Math.round(y2) + size / 2; const depth = z1 + size; // make every z positive

Now each sample knows where it wants to land in the output grid and how far away it is.

The heartbeat - Z-buffer test

if (depth > zBuffer[screenY][screenX]) { zBuffer[screenY][screenX] = depth; }

That single if implements hidden-surface removal: whichever bit of torus is closest to the viewer wins.

Depth buffer
The basic illustration of Z-Buffer (or depth buffer)

Converting depth differences into brightness

After all samples are processed we walk through the finished Z-buffer:

let angleCoeff = 2*z - zBelow - zRight; angleCoeff = (size * angleCoeff) / 12; angleCoeff = clamp(round(angleCoeff), 0, gradient.length1);

angleCoeff roughly measures the local slope: front faces differ less from neighbors than side faces, so the value is small for "bright" areas and large for "dark" ones.

Finally we index into a tiny luminance gradient:

const gradient = ['.', '"', '?', '%', '%', '#', '@']; s += gradient[angleCoeff] + gradient[angleCoeff];

Duplicating the character horizontally re-squares the aspect ratio (most monospaced glyphs are taller than they are wide).

Animation loop

setInterval(() => { if (frame % 10 === 0) { // every 10 frames shake things up incα = 0.03 + Math.random()/10; incβ = (Math.random() - 0.5)/10; } α += incα; β += incβ; renderTorus(α, β); ++frame; }, 20);

20 ms per tick ≈ 50 FPS. Every ten frames we randomize the angular velocities so the motion feels organic.

renderTorus flushes output.innerText with the freshly-drawn string, the <pre> repaints, and the donut spins on.

Why the Z-buffer scales

Even in a "real" rasterizer the logic is essentially the same:

  1. Initialize a 2-D depth array once per frame.
  2. For every triangle fragment, compare its interpolated depth to the stored depth.
  3. Keep the nearer fragment and optionally write color.

Complexity is O(pixels) - independent of scene geometry density - which is why Z-buffering conquered graphics hardware in the 1990s and still reigns today.

Our toy swaps triangles for parametric samples, colors for ASCII glyphs, but relies on the exact same hidden-surface test.

Takeaways

Next time you watch a AAA game or scroll Google Maps in 3-D remember: every frame, billions of fragments still perform that humble depth comparison we just coded in one if.





Read previous


This site uses cookies. By continuing to use this website, you agree to their use. To find out more, including how to control cookies, see here: Privacy & cookies.