all lessons / computer_vision_3d / 06 · rasterization lesson 6 / 13

The rasterization pipeline

Lesson 05 gave us surfaces — explicit triangle meshes and implicit fields, with marching cubes bridging them. But a representation is inert until you can turn it into an image, and every lesson so far has quietly assumed a renderer to close the loop. This lesson builds the classical one the entire real-time field runs on: rasterization. It is lesson 01's forward operator R made concrete for a mesh — the map from 3D triangles to 2D pixels that a GPU executes billions of times a second. We build it end to end: the transform chain that projects triangles through the camera, the coverage test that decides which pixels each triangle owns, and the z-buffer that resolves who is in front.

The plan
Five moves. (1) Contrast the two ways to answer "what geometry is at each pixel" — ray tracing (image-centric) vs rasterization (geometry-centric) — and see why the geometry-centric loop is what maps onto GPU hardware and dominates real time. (2) Build the geometry pipeline: the model → world → view → clip → NDC → screen transform chain, and derive the perspective-projection matrix as the trick that stuffs view-space Z into the clip-space w so the hardware's perspective divide produces 1/Z foreshortening. (3) Clip and cull before filling — the near plane you cannot divide past, and back-face culling that halves the work. (4) The coverage test: edge functions, which are signed areas, which are barycentric coordinates — one computation that both tests inside/outside and hands lesson 07 its interpolation weights. (5) The z-buffer: per-pixel, order-independent hidden-surface removal. We finish on the catch that reshapes the rest of the track — coverage is a hard, discontinuous decision, so this whole pipeline has no useful gradient.

1 · Rasterization vs ray tracing — two directions through the same question

Rendering answers one question: for each pixel, what piece of scene geometry is visible there, and what color should it be? There are exactly two ways to loop over that question, and they differ only in which index is on the outside.

Ray tracing is image-centric. Loop over pixels. For each pixel, construct the ray from the camera through that pixel and ask the scene "what do you hit first?" — an intersection query against every primitive (accelerated by a spatial data structure like a BVH). The loop is \text{for each pixel: for each object, intersect}. It is the natural formulation for global effects — a hit point can spawn secondary rays for shadows, reflections, and refractions — which is exactly why it is the physically-based, offline-render workhorse.

Rasterization is geometry-centric. Loop over triangles. For each triangle, project its three vertices to the screen and ask "which pixels does this cover?" — then write those pixels. The loop is \text{for each triangle: for each covered pixel, shade}. Nothing about triangle A's processing depends on triangle B, so the loop is a stream: triangles flow through a fixed sequence of stages, embarrassingly parallel, needing no global scene data structure held in memory.

AxisRay tracingRasterization
Outer looppixels (image-centric)triangles (geometry-centric)
Core operationray–primitive intersectionproject triangle, test pixel coverage
Needs a global structure?yes — BVH / kd-tree over the whole sceneno — one triangle at a time, streamed
Global effects (shadows, reflections)natural — spawn secondary rayshard — needs extra passes / tricks
Hardware fitirregular, memory-bound traversalperfect for GPUs — a fixed parallel stream
Dominant useoffline / photorealismreal time (games, AR, viewers)

Rasterization dominates real time for a structural reason, not an accidental one. Its inner loop touches one triangle's worth of data at a time and emits pixels independently, so it maps directly onto the GPU's model of thousands of threads running the same program over different data. There is no scene-wide acceleration structure to build or chase pointers through; the pipeline is fixed and feed-forward. That is why every mesh you have ever seen move at 60 fps — every game, every CAD viewer, every AR overlay, and (lesson 10) every Gaussian-splat renderer — went through a rasterizer.

In lesson 01's terms, this lesson computes the forward operator R for the mesh representation of lesson 05: \hat I = R(\text{mesh}, \text{camera}). Hold onto one word: forward. Everything here runs the loop's step ② in the easy direction, image out of geometry. The reason lessons 09–10 exist is that the inverse — geometry out of image, by backpropagating through R — is broken for this R, because (as §4 makes precise, and lesson 07 §5 develops) the coverage test is not differentiable. Build the forward pipeline first; feel exactly where it becomes non-invertible.

2 · The geometry pipeline — the transform chain

A triangle starts life as three vertices in the coordinate frame the artist modeled it in, and must end as three points in pixel coordinates. It gets there by a fixed chain of coordinate changes, each a matrix multiply, most of them the rigid-body and camera transforms of lessons 02 and 04:

model space │ M model matrix — place the object in the world (a rigid/affine transform) ▼ world space │ V view matrix — express the world in the CAMERA's frame; V = (camera pose)⁻¹ ∈ SE(3) (lesson 02) ▼ view / camera space │ P projection matrix — the pinhole, as a 4×4 that loads Z into w ▼ clip space (a 4-vector: x_c, y_c, z_c, w_c) │ ÷ w the PERSPECTIVE DIVIDE — hardware divides x,y,z by w ▼ NDC normalized device coords, the cube [−1,1]³ │ viewport transform — scale/shift NDC x,y into pixel coordinates ▼ screen / pixel space

The first two matrices are review. M places the object in the world (lesson 02's rigid transforms, optionally with scale). V re-expresses the world in the camera's own frame: if the camera's pose in the world is Twc ∈ SE(3), then V = Twc−1, because moving the world into camera coordinates is the inverse of placing the camera in the world. Composed, V·M takes a model-space vertex straight into view space, where the camera sits at the origin looking down its own axis — exactly the setup CV lesson 04's pinhole assumed.

Deriving the perspective projection

Now the one genuinely new matrix. From CV lesson 04, the pinhole camera projects a view-space point (X, Y, Z) onto the image plane by dividing by depth:

xscreen = f\,\dfrac{X}{Z},\qquad yscreen = f\,\dfrac{Y}{Z} .

That divide is the entire source of perspective — distant things (large Z) shrink. The problem: a matrix multiply is linear, and dividing by a coordinate is not a linear operation. You cannot write "divide by Z" as a 4×4 matrix acting on (X, Y, Z, 1). So the pipeline splits the job in two, and this is the key idea of the whole section: the matrix does not do the division — it sets up the division. The projection matrix's real job is to copy (a function of) view-space Z into the fourth, homogeneous coordinate w. Then a separate, fixed hardware step — the perspective divide — divides everything by that w, and the 1/Z foreshortening falls out for free.

Concretely, a projection matrix maps the view-space homogeneous point to a clip-space 4-vector whose w component is Z (up to sign/scale):

\begin{pmatrix} xc \\ yc \\ zc \\ wc \end{pmatrix} = P \begin{pmatrix} X \\ Y \\ Z \\ 1 \end{pmatrix}, \qquad wc = Z .

The perspective divide then produces NDC coordinates (xc/wc,\ yc/wc,\ zc/wc) = (xc/Z,\ yc/Z,\ zc/Z). If the matrix set xc = f·X, the divide yields f·X/Z — exactly the pinhole. The matrix stayed linear; the nonlinearity lives entirely in the one shared divide. That factorization — linear matrices, then a single divide by w — is precisely why homogeneous coordinates are the language of graphics.

Frustum → cube: the near and far planes

The projection matrix also handles depth. The camera sees a truncated pyramid — the view frustum, bounded by a near plane at distance n and a far plane at f (everything nearer than n or farther than f is discarded). Projection warps this frustum into the axis-aligned NDC cube [−1,1]³: the x,y extents map to [−1,1], and the depth range [n, f] maps monotonically to zndc ∈ [−1, 1] so that hidden-surface tests can compare a single number per pixel. The standard depth mapping is

zndc(Z) = \dfrac{f+n}{f-n} \;-\; \dfrac{2fn}{(f-n)\,Z} ,

which one checks sends Z=n → zndc=-1 and Z=f → zndc=+1. Notice its shape: it is affine in 1/Z, not in Z. That is the whole subtlety of the next callout.

A worked number

Pick a near plane n = 1 and a far plane f = 100 (a 100:1 range, typical), and push a view-space point at depth Z = 5 through the chain. Suppose its view-space lateral coordinates and focal-like term give clip xc = 3.0, and wc = Z = 5. The perspective divide gives

xndc = \dfrac{xc}{wc} = \dfrac{3.0}{5} = 0.60 .

For depth, plug into the mapping:

zndc(5) = \dfrac{101}{99} - \dfrac{2\cdot100\cdot1}{99\cdot5} = 1.0202 - 0.4040 = 0.6162 .

Read what just happened. Linearly, Z=5 sits only (5-1)/(100-1) ≈ 4\% of the way from the near plane to the far plane. Yet its NDC depth 0.616 is already (0.616-(-1))/2 ≈ 81\% of the way across the [-1,1] depth range. The first 4\% of physical depth consumed 81\% of the depth-buffer's range. Depth precision is lavished near the camera and starved far away.

The trap: nonlinear depth and z-fighting
Because zndc is affine in 1/Z, not Z, depth is mapped into the buffer nonlinearly — precision bunches near the camera (the worked number: 4% of depth ate 81% of the range). Far-away coplanar or near-coplanar surfaces get NDC depths that differ by less than a float's resolution, so the z-buffer cannot decide which is in front and they flicker pixel-to-pixel as the camera moves — z-fighting. This is the same 1/Z structure lesson 03 met as inverse depth (disparity), where stereo resolves near objects far better than far ones, and lesson 07 will meet again as the reason interpolation must be done in 1/Z. Practical fixes push the ratio f/n down (a near plane too close is the usual culprit) or use a reversed-Z / logarithmic buffer to redistribute the precision.

3 · Triangle setup, clipping, and culling

Between "projected vertices" and "filled pixels" the pipeline does two cheap rejections that save enormous downstream work.

Clipping. Triangles are trimmed to the view frustum before the perspective divide, and the near plane is not optional — it is a correctness requirement. The divide is (xc, yc, zc)/wc with wc = Z. For a point behind the camera, Z ≤ 0, so wc ≤ 0 and the divide is meaningless — dividing by zero, or by a negative that flips the point through the origin to a phantom position on screen. So a triangle straddling the camera plane must be clipped against the near plane first: the portion with Z < n is cut off, and the polygon is re-triangulated from the safe part (a triangle clipped by one plane can become a quad → two triangles). Only then is it safe to divide. Clipping against the other five frustum planes is an optimization (it avoids rasterizing off-screen pixels); near-plane clipping is a must.

Back-face culling. A closed (watertight) mesh shows you only its outward-facing triangles; the ones facing away are always occluded by the front of the object, so drawing them is wasted work. You detect them after projection from the winding order: compute the signed area of the projected triangle in screen space (the same signed area §4 is built on). A consistently-wound mesh (lesson 05's manifoldness) has front faces of one sign and back faces of the other; the pipeline discards, say, all clockwise-projected triangles. For a convex closed shape exactly half the triangles face away, so back-face culling roughly halves the fill work essentially for free — a single sign test per triangle, done before touching any pixel.

The reframe
Clipping and culling are the pipeline being lazy on purpose: spend a few operations per triangle to avoid the far larger per-pixel cost of shading geometry that is behind the camera or facing away. This is the streaming philosophy from §1 — cull early, so the expensive parallel stage only ever sees triangles that can actually contribute pixels.

4 · The coverage test — edge functions and barycentric coordinates

Now the heart of rasterization. A triangle's three vertices are in screen (pixel) coordinates V0, V1, V2. For a candidate pixel with sample point P (its center), the question is binary: is P inside the triangle? The classical answer is the edge function. For the directed edge from V0 to V1, define

E01(P) = (P - V0) × (V1 - V0) = (Px-V0x)(V1y-V0y) - (Py-V0y)(V1x-V0x) ,

the 2D cross product (a scalar). Geometrically it is twice the signed area of the triangle (V0, V1, P): its sign tells you which side of the directed line V0→V1 the point P lies on. Compute all three edge functions E01, E12, E20. Then

P \text{ is inside} \iff E01(P),\ E12(P),\ E20(P) \text{ all share the same sign.}

The picture: each edge, oriented consistently around the triangle, splits the plane into a "left" and "right" half; the triangle's interior is the one intersection where you are on the interior side of all three edges at once. All-same-sign is exactly that intersection.

The same numbers are the barycentric coordinates

Here is the fact that makes this the elegant center of the pipeline. Divide each edge function by the full triangle's signed area A = \tfrac12 E012 (itself an edge function on all three vertices). The three normalized quantities

\alpha = \dfrac{\text{area}(P,V1,V2)}{\text{area}(V0,V1,V2)},\quad \beta = \dfrac{\text{area}(V0,P,V2)}{\text{area}(V0,V1,V2)},\quad \gamma = \dfrac{\text{area}(V0,V1,P)}{\text{area}(V0,V1,V2)}

are the barycentric coordinates of P, and they satisfy

\alpha + \beta + \gamma = 1,\qquad P = \alpha V0 + \beta V1 + \gamma V2 .

(The three sub-triangle areas tile the whole triangle, so their fractions sum to one.) "All three edge functions same sign" is identically "all three barycentrics ≥ 0" — the coverage test is the sign of the barycentrics. So a single computation does double duty: the signs of (\alpha, \beta, \gamma) answer coverage, and their values are the interpolation weights. Any per-vertex attribute — depth, color, normal, texture coordinate — is carried to P by the very same weights, a(P) = \alpha\,a0 + \beta\,a1 + \gamma\,a2. That is the interpolation machinery lesson 07 builds shading on, and it is why §5 can interpolate depth for the z-buffer without any extra math. It is also why triangles are the primitive (lesson 05 §2): only a triangle has this unique, linear barycentric interpolation.

Two properties make this ideal for hardware. It is local — each pixel's test needs only the triangle's three vertices, nothing global. And it is parallel — every pixel in the triangle's screen-space bounding box can be tested independently, at the same time, by the same tiny program. A GPU rasterizer evaluates edge functions incrementally over a whole tile of pixels at once. This is the streaming loop of §1 realized down at the pixel level.

The catch that breaks the inverse
Coverage is a hard, discrete decision: a pixel is either fully in or fully out, with nothing in between. As a function of a vertex's position it is a step function — nudge a vertex by an infinitesimal amount and a boundary pixel abruptly flips from covered to uncovered. Its derivative is zero wherever the pixel doesn't flip and undefined at the instant it does. So ∂(\text{pixel})/∂(\text{vertex}) carries no usable signal, and you cannot backpropagate an image loss into mesh vertices through this pipeline. This is the concrete mechanism behind lesson 01 §4's abstract warning, and lesson 07 §5 develops it in full — it is the single reason the soft, volumetric representations of lessons 09–10 exist. The forward operator R is fast and exact; it is just not invertible by gradient descent.

5 · The z-buffer — hidden-surface removal

Coverage tells you which triangles touch a pixel, but many can touch the same one — a near wall and the far wall behind it both cover it. Which color survives? The winner is the nearest fragment, and the z-buffer (depth buffer) finds it with almost no bookkeeping.

Alongside the color framebuffer, keep a second buffer of the same size holding, per pixel, the nearest depth seen so far, initialized to +∞ (the far plane). As each triangle is rasterized, for every covered pixel produce a fragment and:

  1. Interpolate its depth with the barycentric weights from §4: zfrag = \alpha z0 + \beta z1 + \gamma z2 (in NDC/buffer depth). No extra geometry — the same weights that decided coverage.
  2. Depth test. If zfrag is nearer than the stored depth, the fragment wins: write its color to the framebuffer and overwrite the stored depth with zfrag. Otherwise it is occluded — discard it.

That is the whole algorithm. Its virtues are exactly what a streaming pipeline needs:

Contrast the painter's algorithm — sort triangles back-to-front and paint over. It needs a global sort (breaking the stream), and worse, it is simply wrong for interpenetrating triangles (which is in front depends on the pixel, not the triangle) and for cyclic overlaps (A over B over C over A, where no single ordering exists). The z-buffer resolves all of these per pixel, because it compares depths at the sample point rather than committing to one order for a whole triangle.

Depth precision, again
The z-buffer inherits §2's nonlinear depth: because stored depth is affine in 1/Z, distant fragments are packed into a sliver of the buffer's range, so two far coplanar surfaces can round to the same value and z-fight. The buffer's correctness is exact; its precision is the failure mode. And note what quantity this buffer actually holds — the depth of the visible surface per pixel. That is the same "expected depth" that lesson 09's volume rendering will produce as an integral over a soft density field, rather than a hard per-pixel minimum: the z-buffer is the hard-surface limit of the volumetric depth to come.

The widget puts §4 and §5 together at pixel scale: two triangles fighting over an overlap region, resolved by edge-function coverage per pixel and a per-pixel z-buffer. Slide one triangle's depth below the other's and watch it seize the contested pixels.

Rasterize two triangles with a z-buffer
The canvas is a coarse pixel grid (each cell is a "pixel"). Two triangles — A (blue) and B (amber) — overlap. Each cell is filled by true rasterization: for every pixel center we compute the three edge functions (signed sub-areas), test inside (all same sign), turn the sub-areas into barycentric weights, interpolate that fragment's depth z = b0z0 + b1z1 + b2z2, and keep it only if it is nearer than the z-buffer's stored depth. Triangle outlines and vertices are overlaid. Push B's depth below A's and B wins the overlap — the z-buffer resolving occlusion, per pixel, with no sorting. Watch the depth-test flips KPI: it counts pixels in the overlap where the depth test changed the winner.
A covered pixels
B covered pixels
Depth-test flips (overlap)
Total shaded pixels
Show the core JS
// signed area of triangle (a,b,c) = 2× area; sign encodes winding / which side.
function area(a, b, c){
  return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
}

// Rasterize one triangle into the pixel grid + z-buffer.
// tri.v = [v0,v1,v2] in canvas coords; tri.z = constant depth (per-vertex works the same).
function rasterize(tri, color, zbuf, colbuf, W, H, cols, rows){
  var v0 = tri.v[0], v1 = tri.v[1], v2 = tri.v[2];
  var Atot = area(v0, v1, v2);            // full-triangle signed area (2×)
  if (Atot === 0) return 0;               // degenerate
  var cw = W / cols, ch = H / rows;       // pixel size
  var covered = 0;
  for (var iy = 0; iy < rows; iy++){
    for (var ix = 0; ix < cols; ix++){
      var P = { x: (ix + 0.5) * cw, y: (iy + 0.5) * ch };   // pixel centre
      // three edge functions == three sub-triangle signed areas
      var w0 = area(v1, v2, P);           // opposite v0  -> weight of v0
      var w1 = area(v2, v0, P);           // opposite v1  -> weight of v1
      var w2 = area(v0, v1, P);           // opposite v2  -> weight of v2
      // inside iff all edge functions share the sign of the total area
      var inside = (Atot > 0) ? (w0 >= 0 && w1 >= 0 && w2 >= 0)
                              : (w0 <= 0 && w1 <= 0 && w2 <= 0);
      if (!inside) continue;
      covered++;
      // barycentric weights = sub-area / total area  (sum to 1)
      var b0 = w0 / Atot, b1 = w1 / Atot, b2 = w2 / Atot;
      // interpolate fragment depth (here z is constant, but this is the general form)
      var zf = b0 * tri.z + b1 * tri.z + b2 * tri.z;
      var idx = iy * cols + ix;
      if (zf < zbuf[idx]){                 // DEPTH TEST: keep the nearer fragment
        zbuf[idx] = zf;
        colbuf[idx] = color;
      }
    }
  }
  return covered;
}

Where this points next

We can now turn a mesh into an image: transform triangles through model → world → view → clip → NDC → screen, cut them at the near plane and cull the back faces, decide coverage with edge functions that double as barycentric weights, and resolve occlusion with an order-independent z-buffer. That is the complete forward operator R for a mesh — geometry in, pixels out, in real time. But the pixels we produced are flat: we interpolated depth, yet said nothing about their color, and even the interpolation hides a subtlety. Lesson 07 finishes the picture. It interpolates per-vertex attributes correctly — and shows why naive screen-space interpolation is wrong under perspective, so you must interpolate in 1/Z (the same inverse-depth structure from §2 and lesson 03) — then maps textures (and confronts their aliasing, fixed by mipmapping) and applies shading. It closes on the formal argument this lesson previewed in §4's red callout: the coverage step is a discontinuous function of geometry with no useful gradient, so the whole rasterizer is non-differentiable. That single fact is the hinge of the track: it is precisely why lesson 09 abandons hard surfaces for a soft, semi-transparent radiance field rendered by an integral you can backpropagate — turning the un-invertible forward map of this lesson into the differentiable one that inverse rendering needs.

Takeaway
Rasterization is the geometry-centric renderer — loop over triangles, find covered pixels — and it dominates real time because that loop is a stateless, embarrassingly parallel stream that maps onto GPU hardware, needing no global scene structure. It computes lesson 01's forward operator R for a mesh, in the forward direction only. The geometry pipeline is a matrix chain model → world → view (V = Twc-1 ∈ SE(3)) → clip → NDC → screen; the projection matrix stays linear by loading view-space Z into the clip w, so a single hardware perspective divide produces the pinhole's x = fX/Z. Depth maps into NDC affine in 1/Znonlinearly (worked: 4% of depth ate 81% of the range), the root of z-fighting and the same inverse-depth from lesson 03. Before filling, clip the near plane (you cannot divide by w ≤ 0) and cull back faces by signed-area sign (halving the work). The coverage test uses edge functions — 2D cross products / signed sub-areas — that are the barycentric coordinates (\alpha,\beta,\gamma): their signs decide inside/outside and their values are the interpolation weights lesson 07 needs. The z-buffer resolves occlusion per fragment by interpolating depth and keeping the nearest — O(1) and order-independent, unlike the painter's algorithm. And the whole thing is non-differentiable: coverage is a step function of vertex position with zero/undefined derivative (lesson 07 §5, lesson 01 §4) — the reason the soft, volumetric methods of lessons 09–10 exist.

Interview prompts