Primitive assembly & rasterization
How the GPU turns triangles in clip space into fragments, and blends the vertex outputs for each one.
How it works
Between the vertex shaderThe GPU program that runs once for every vertex of a mesh. It places the vertex on screen by writing gl_Position, and passes values on to the fragment shader. Vertex shader The GPU program that runs once for every vertex of a mesh. It places the vertex on screen by writing gl_Position, and passes values on to the fragment shader. Fragment shader The GPU program that runs once for every pixel a triangle covers and decides its color.normalize(), and why edges look jagged.
From vertices to a triangle on screen
- Primitive assembly groups the vertices into triangles, as the draw call asked: with
gl.TRIANGLES, every three vertices make one; withgl.TRIANGLE_STRIP, each new vertex makes a triangle with the two before it. - 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.
- 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 inw, far things shrink toward the center. - 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. - 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)withgl.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 varyingA value the vertex shader writes for each vertex (an out) and the fragment shader reads (an in). The rasterizer blends it across the triangle, so each pixel gets its own mix. Varying A value the vertex shader writes for each vertex (an out) and the fragment shader reads (an in). The rasterizer blends it across the triangle, so each pixel gets its own mix.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 normalsA unit vector perpendicular to a surface, pointing the way it faces. Lighting and Fresnel effects compare it with other directions.Fundamental
Normal
A unit vector perpendicular to a surface, pointing the way it faces. Lighting and Fresnel effects compare it with other directions.
Sourcesis shorter than 1, so shading code callsnormalize()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
flatqualifier 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.