Z-Buffer - the backbone of the 3-D rendering
Published on
Updated 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.
The code
<!DOCTYPE html>
<html style="background: white;">
<head>
<title>3D ASCII Donut</title>
</head>
<body>
<label for="outerRadius">R:</label> <input type="range" min="0" max="100" value="15" id="outerRadius">
<label for="innerRadius">r:</label> <input type="range" min="0" max="100" value="7" id="innerRadius">
<label for="outerCoverage">Outer coverage:</label> <input type="range" min="0.02" max="2" value="2" step="0.02" id="outerCoverage">
<label for="innerCoverage">Inner coverage:</label> <input type="range" min="0.02" max="2" value="2" step="0.02" id="innerCoverage">
<pre id="output" style="font-size: 7px; font-weight: bolder;"></pre>
<script>
const gradient = ['.', '"', '?', '%', '%', '#', '@'];
let R, r, size, zBuffer, maxAngle;
function refreshSizes() {
R = document.getElementById('outerRadius').valueAsNumber;
r = document.getElementById('innerRadius').valueAsNumber;
maxAngleOuter = document.getElementById('outerCoverage').valueAsNumber * Math.PI;
maxAngleInner = document.getElementById('innerCoverage').valueAsNumber * Math.PI;
size = 2 * (R + r) + 10;
zBuffer = new Array(size);
for (let y = 0; y < size; ++y) {
const arr = new Array(size);
for (let x = 0; x < size; ++x) {
arr[x] = 0;
}
zBuffer[y] = arr;
}
}
document.getElementById('outerRadius').onchange =
document.getElementById('innerRadius').onchange =
document.getElementById('outerCoverage').onchange =
document.getElementById('innerCoverage').onchange = refreshSizes;
refreshSizes();
function renderTorus(xyPlaneAngle = 0, projectionRotationAngle = 0) {
const innerStep = maxAngleInner / size / 5;
const outerStep = maxAngleOuter / size / 5;
for (let inner = 0.0; inner <= maxAngleInner; inner += innerStep) {
const radius = R + r * Math.cos(inner);
const z = r * Math.sin(inner);
for (let outer = 0.0; outer <= maxAngleOuter; outer += outerStep) {
const x = radius * Math.cos(outer);
const y = radius * Math.sin(outer);
const x1 = x;
const y1 = y * Math.cos(xyPlaneAngle) - z * Math.sin(xyPlaneAngle);
const z1 = y * Math.sin(xyPlaneAngle) + z * Math.cos(xyPlaneAngle);
const x2 = Math.round(x1 * Math.cos(projectionRotationAngle) - y1 * Math.sin(projectionRotationAngle)) + size / 2;
const y2 = Math.round(x1 * Math.sin(projectionRotationAngle) + y1 * Math.cos(projectionRotationAngle)) + size / 2;
const z2 = z1 + size;
if (z2 > zBuffer[y2][x2]) {
zBuffer[y2][x2] = z2;
}
}
}
let s = '';
for (let y = 5; y < zBuffer.length - 5; ++y) {
for (let x = 5; x < zBuffer[y].length - 5; ++x) {
if (zBuffer[y][x] > 0) {
let angleCoefficient = 2 * zBuffer[y][x] - zBuffer[y + 1][x] - zBuffer[y][x + 1];
angleCoefficient = size * angleCoefficient / 12;
angleCoefficient = Math.min(angleCoefficient, gradient.length - 1);
angleCoefficient = Math.max(angleCoefficient, 0);
angleCoefficient = Math.round(angleCoefficient);
s += gradient[angleCoefficient];
s += gradient[angleCoefficient];
zBuffer[y][x] = 0;
} else {
s += ' ';
}
}
s += '\n';
}
document.getElementById('output').innerText = s;
}
let increment1 = 0, increment2 = 0;
let angle1 = 0, angle2 = 0;
let counter = 1;
setInterval(() => {
if (counter % 10 === 0) {
increment1 = 0.03 + Math.random() / 10;
increment2 = (Math.random() - 0.5) / 10;
}
angle1 += increment1;
angle2 += increment2;
renderTorus(angle1, angle2);
++counter;
}, 20);
</script>
</body>
</html>
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:
-
Re-reads the four inputs.
-
Computes
size = 2 x (R + r) + 10- a safe bounding box that fits the torus plus some margin. -
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:
- φ runs around the tube.
- θ spins that tube around the big circle.
For each (φ, θ) pair we calculate the 3-D point:
x = (R + r cos φ) cos θ
y = (R + r cos φ) sin θ
z = r sin φ
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;
}
- At the start of the frame every Z entry is zero.
-
Only the point with the greatest depth value survives (remember we added
size, so numerically larger depth ≈ closer to camera). - We don't store color yet-just the depth.
That single if implements hidden-surface removal: whichever bit of torus is closest to the viewer
wins.
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.length‑1);
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:
- Initialize a 2-D depth array once per frame.
- For every triangle fragment, compare its interpolated depth to the stored depth.
- 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
- A Z-buffer is the simplest, most reliable hidden-surface algorithm.
- You don't need matrices, shaders or GPUs to appreciate it - a
<pre>tag and 100 lines of JS suffice. - Post-processing (slope-based brightness, double-width glyphs) sells the illusion of continuous shading.
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.