The Geometry Front End

Before a single pixel exists, every frame begins with a stampede of geometry: a modern game pushes millions of vertices through the GPU in a few milliseconds. The geometry front end is the run of hardware that receives that stampede — fetching vertices from memory, dispatching their shading, then clipping, culling and measuring each triangle — and its one goal is to hand the rasterizer a tidy stream of screen-space triangles with their maths precomputed. Everything here is about doing the least work possible: shade each vertex once (not six times), and throw away every triangle you can before anyone rasterizes it.

Vertex fetch: never shade the same vertex twice

A mesh arrives as two buffers: a vertex buffer (the actual positions, normals, UVs) and an index buffer (triangles described as triples of indices into it). The indices are the efficiency machine. In a typical grid-like mesh, each vertex is shared by around six triangles — so naively shading three vertices per triangle would run the vertex shader six times on the same data. The input assembler's fix is a small post-transform vertex cache: when a triangle references index 4217, the hardware first checks whether vertex 4217 was shaded recently; on a hit it reuses the stored result and the vertex shader never runs. With cache-friendly index order, a mesh of T triangles costs close to T/2 shader invocations instead of 3T — a ~6× saving from a lookup table.

The shading itself is not fixed-function at all: the assembler bundles un-cached vertices into warps and dispatches them to the SM pool (as the previous lesson mapped out), where your vertex shader runs the MVP pipeline: model, view and projection matrices carrying each position into clip space.

Clip, cull, divide, place

Shaded vertices return from the SMs in clip space, and now the fixed-function machinery takes over, applying four tests-and-transforms in quick succession:

Primitive setup: the handoff artefact

The front end's last act is primitive setup: for each surviving triangle it precomputes exactly the numbers the rasterizer will consume millions of times — the three edge-equation coefficients (a, b, c) for each edge (so that inside/outside becomes a sign check of ax + by + c), and the attribute gradients — how much each interpolated value (depth, UVs, colour) changes per pixel-step in x and y. Setup is done once per triangle so the rasterizer can spend zero per-pixel effort rederiving it. This little packet — edges plus gradients — is the entire interface between the geometry world and the pixel world.

And it has a price: setup costs the same whether the triangle covers ten thousand pixels or one. As artists push geometric density toward pixel-sized triangles, per-triangle overhead stops being noise and becomes the bill — the "small-triangle problem", and the one-line motivation for the modern mesh-shader pipelines that batch tiny triangles more cleverly.

Watch it happen

Step through one tiny frame's worth of front-end work: two triangles arrive, one leaves — clipped, measured and packaged for the rasterizer.

Count the savings yourself

Here is the front end's bookkeeping in miniature: a small indexed grid mesh runs through backface culling and the vertex cache, and the counters show why both circuits exist. Try flipping some winding orders or shrinking the cache-free path in your head first — then run it.

// A 3×2 grid of quads → 12 triangles sharing a 4×3 grid of 12 vertices. // Vertices: positions on a grid; a few triangles are wound backwards on purpose. const W = 4, H = 3; const verts: [number, number][] = []; for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) verts.push([x, y]); const tris: [number, number, number][] = []; for (let y = 0; y < H - 1; y++) { for (let x = 0; x < W - 1; x++) { const i = y * W + x; tris.push([i, i + 1, i + W]); // counter-clockwise (front-facing) tris.push([i + 1, i + W + 1, i + W]); // counter-clockwise (front-facing) } } // Sabotage: flip the winding of two triangles → back-facing. tris[3] = [tris[3][0], tris[3][2], tris[3][1]]; tris[8] = [tris[8][0], tris[8][2], tris[8][1]]; // Backface culling: signed area 2A = (Bx−Ax)(Cy−Ay) − (By−Ay)(Cx−Ax); keep 2A > 0. function signedArea2(t: [number, number, number]): number { const [A, B, C] = t.map((i) => verts[i]); return (B[0] - A[0]) * (C[1] - A[1]) - (B[1] - A[1]) * (C[0] - A[0]); } const kept = tris.filter((t) => signedArea2(t) > 0); console.log(`triangles in: ${tris.length}`); console.log(`culled (2A ≤ 0): ${tris.length - kept.length}`); console.log(`to rasterizer: ${kept.length}`); // Vertex-shader invocations: naive (3 per triangle) vs perfect post-transform cache. const naive = kept.length * 3; const unique = new Set(kept.flat()).size; console.log(`\nvertex shader runs, no cache: ${naive}`); console.log(`vertex shader runs, perfect cache: ${unique}`); console.log(`reuse factor: ${(naive / unique).toFixed(2)}×`);

Twelve vertices serve over thirty vertex-shader slots — the cache converts sharing in the mesh into shading you never pay for. On a real million-vertex mesh the same two counters are the difference between a frame that fits its budget and one that does not.

Setup builds a fixed-size packet per triangle, and the rasterizer stamps out pixels in parallel blocks — machinery that shines when a triangle covers hundreds of pixels. Film-quality assets invert the ratio: a statue scanned at sub-millimetre detail can average less than one pixel per triangle. Now the per-triangle cost (fetch, cache lookup, cull test, setup) dominates, the wide parallel pixel hardware stamps mostly-empty blocks, and effective throughput collapses — some hardware loses an order of magnitude of fill efficiency on pixel-sized triangles. That is why the newest pipelines add mesh shaders (compute-style shaders that cull and emit small batches of triangles wholesale) and why engines like Nanite rasterize their tiniest triangles in software on the SMs — when triangles shrink to pixels, the specialised hardware's assumptions dissolve, and the pendulum swings back toward programmability.

It feels obvious that the GPU should skip shading for triangles it will throw away. It cannot: facing is decided by the screen-space signed area, and screen positions are the vertex shader's output. The positions must exist before the determinant can be computed. So every back-facing triangle — typically half your mesh — pays the full vertex-shading bill and is then discarded. With a heavyweight vertex shader (skinning, morph targets, elaborate animation), that is real money burned on invisible geometry. This is exactly why engines cull coarsely and early on the CPU or in a compute pre-pass — frustum-testing whole objects, occlusion-testing clusters — so the hardware front end only ever sees geometry with a fighting chance of being drawn. The hardware cull is the last line of defence, not the plan.