Skip to content
Rendering · Pipeline stagesIntermediate

Primitive assembly & rasterization

How the GPU turns triangles in clip space into fragments, and blends the vertex outputs for each one.

Live demo coming soon

How it works

Between the vertex shader and the fragment shader sits a stage you don’t program: the GPU does it in fixed hardware, configured by a few switches. It takes vertices in clip space and produces fragments, one for each pixel a triangle covers, each with its own interpolated inputs. Knowing what happens here explains why UVs don’t swim on a tilted plane, why interpolated normals need normalize(), and why edges look jagged.

From vertices to a triangle on screen

  1. Primitive assembly groups the vertices into triangles, as the draw call asked: with gl.TRIANGLES, every three vertices make one; with gl.TRIANGLE_STRIP, each new vertex makes a triangle with the two before it.
  2. Clipping keeps only what is inside the view volume, where −w ≤ x, y, z ≤ w. A triangle that crosses the edge is cut into smaller ones that don’t; one fully outside is dropped.
  3. The perspective divide turns clip space into normalized device coordinates (NDC): xyz / w. All three end up between −1 and 1, and since a perspective projection puts the distance in w, far things shrink toward the center.
  4. The viewport transform maps NDC to pixels, as set by gl.viewport(x, y, width, height): −1..1 becomes 0..width and 0..height, and z becomes a depth between 0 and 1.
  5. Face culling, when enabled, drops triangles by which way they face. The order of the three vertices on screen, counterclockwise by default, says which side is the front; gl.enable(gl.CULL_FACE) with gl.cullFace(gl.BACK) skips the back faces of closed meshes, which can’t be seen anyway.

Which pixels a triangle covers

The rasterizer then tests pixel centers against the triangle’s three edges. Every center inside becomes a fragment. A center that falls exactly on an edge shared by two triangles must go to exactly one of them: the OpenGL ES specification requires that no pixel is dropped or drawn twice along a shared edge, so meshes have no cracks and transparent meshes show no doubled seams.

One sample per pixel means a pixel is either in or out, which is where jagged, stair-stepped edges come from. Multisampling (MSAA) tests several sample points per pixel instead; how those samples are kept and merged belongs to the per-sample operations that follow.

Interpolation

Each fragment gets its own value of every varying, blended from the triangle’s three vertices by how close the fragment is to each. A triangle with a red, a green and a blue vertex shows all the mixes in between. The weights are perspective-correct: the hardware divides by w while blending, so a texture on a floor stretching into the distance keeps its lines straight instead of bending.

Interpolation is linear, and that has consequences:

  • Unit vectors shrink. The blend of two unit-length normals is shorter than 1, so shading code calls normalize() on interpolated normals before using them.
  • Curves become straight. A value computed per vertex is right only at the vertices; between them it is a flat blend. That is why a highlight computed per vertex looks faceted.
  • Some values shouldn’t blend. An id or a cell index makes no sense halfway. The flat qualifier turns interpolation off for one varying: every fragment gets the value of one vertex, the last of each triangle in OpenGL ES.
// in the vertex shader
flat out int vMaterialId; // not blended: the whole triangle gets one vertex's value
out vec3 vNormal;         // blended, perspective-correct (the default, "smooth")

// in the fragment shader
flat in int vMaterialId;
in vec3 vNormal;
// ...
vec3 n = normalize(vNormal); // shorter than 1 between the vertices, so normalize first

Integer varyings must be flat; GLSL ES 3.00 won’t interpolate them.

In Godot and on this site

Rasterization isn’t programmable in Godot either. A shader controls the parts it can: culling with render_mode cull_back (the default), cull_front or cull_disabled for two-sided surfaces, and what reaches fragment() through varying declarations.

On this site, a pass’s cull in render.json is "back", "front" or "none". The inverted-hull outline uses "front": it draws a slightly grown copy of the mesh with its front faces culled, so only its back faces show around the silhouette.

Further reading