Skip to content

Z-Buffer & Depth Testing

From triangles always painting over each other to correct depth ordering.


The problem

With the basic rasterizer working, the next problem is obvious: what happens when two triangles overlap? Without any depth system, the last one drawn always ends up on top — regardless of whether it's actually behind the other one in 3D space. That's wrong.

What is a z-buffer

The z-buffer is a second array the same size as the framebuffer, but instead of storing colors, it stores the depth of the closest pixel drawn so far. Before painting a pixel, we compare its depth to what's already in the buffer. If it's closer, we paint and update. If not, we skip it.

std::vector<float> zbuffer(WIDTH * HEIGHT, HUGE_VALF);
// HUGE_VALF = positive infinity — initially "nothing has been drawn"

The per-pixel test:

// Depth test — only paint if this pixel is closer than what's already there
if (depth < zbuffer[j * WIDTH + i]) {
    zbuffer[j * WIDTH + i] = depth; // update stored depth
    framebuffer[j * WIDTH + i] = color; // paint pixel
} else {
    continue; // skip — something closer is already in front
}
● Interactive see how the depth test resolves overlap per pixel

Interpolating depth

The depth of each pixel is interpolated across the triangle using barycentric coordinates — just like color. But there's an important catch: you can't interpolate z directly. After perspective projection, z is no longer linear in screen space — points far away are compressed, so a straight interpolation would give the wrong depth. This will make more sense when UV mapping is covered, since the same distortion affects texture coordinates.

What is linear in screen space is 1/w — the inverse of each vertex's original depth (how far it is from the camera, before any projection). Interpolating 1/w and then inverting gives the correct depth:

// w1, w2, w3 = original depth of each vertex (distance from camera)
float depth = e1_norm*(1/w3) + e2_norm*(1/w1) + e3_norm*(1/w2);
depth = 1/depth; // invert back to get the real interpolated depth

Bugs

BUG Z-buffer appears inverted — far objects cover near ones
What happened Objects in the background were drawing on top of objects in the foreground.
Cause The depth value being stored was −w instead of w — the original depth of the vertex, negated. All depth comparisons had the wrong sign.
Fix Store +w — the positive original depth. Closer objects have smaller w, so a < comparison correctly picks the nearest one.
BUG With multiple objects, one always covers the other
What happened Regardless of distance, one object always appeared in front of the other.
Cause Each object had its own separate z-buffer. Each one only compared depth against itself, not against the other objects in the scene.
Fix A single global z-buffer shared across all objects. Every triangle, regardless of which mesh it belongs to, reads and writes the same depth buffer.

Two objects, one always covers the other

Two objects, one always on top regardless of distance — separate z-buffers don't see each other.

Z-buffer working correctly

Z-buffer working correctly with multiple objects — depth ordering is now accurate.

Result

With the z-buffer working, triangles are now sorted correctly by depth. The next step is probably the most important in the entire rasterizer: the math that makes perspective possible — the MVP pipeline.