A Software Rasterizer in a Fragment Shader
Computer Graphics, UCL ·
Overview
Between the vertex shader and the fragment shader, the hardware does a lot of work that never shows up in any source file. This includes clipping triangles against the frustum, discarding back faces, deciding which pixels each triangle covers and which triangle owns the pixels on a shared edge, resolving depth, and interpolating every attribute with perspective correction. In this project, I implemented all of it myself.
The same scene is drawn twice, once by the hardware and once by a shader that does the hardware's job, and the two are then compared pixel by pixel.
There are no dependencies. It uses an EGL surfaceless context, so it runs headless without a window, GLFW, SDL or GLEW.
The two paths, and their difference
The two images above are identical byte for byte, which is why the difference image below is completely black. Every step of the software path is implemented by hand: the projection, near-plane clipping in homogeneous space, backface culling, fixed-point vertex snapping, edge functions with the top-left fill rule, the depth test, perspective-correct interpolation, bilinear filtering and supersampling.
Try it
The same comparison runs in your browser below. If you untick a stage, you can see what the hardware was doing for you, as the difference panel lights up as soon as the software path skips that step.
Clipping and culling
After the vertex shader, triangles are in homogeneous clip space. They are clipped against the near plane, z = −w, before the perspective divide, since dividing by a w close to or below zero would send points behind the camera to the wrong place on screen. Back faces are then discarded using the sign of the triangle's area in screen space, which tells us whether its vertices appear clockwise or counter-clockwise.
Coverage and the fill rule
To decide which pixels a triangle covers, each of its edges defines an edge function, which is positive on one side of the edge and negative on the other. A pixel centre is inside the triangle when all three edge functions agree.
When two triangles share an edge, a pixel centre that lies exactly on it must be drawn by exactly one of them, with no gaps and no pixel drawn twice. The top-left fill rule decides which one, by only counting a pixel on an edge as inside if that edge is a top or left edge of the triangle.
Hardware rasterises on a grid
My first attempt used floating-point edge functions, and pixels differed along every silhouette. The reason is that graphics hardware first snaps vertices to a fixed-point subpixel grid and rasterises from there. By sweeping the grid size, I found that SwiftShader uses a grid of 1/16 of a pixel, while the Radeon Vega 8 uses 1/256. Since the grid differs between implementations, the rasteriser reads it from SUBPIXEL_BITS instead of assuming a fixed value.
Depth and perspective-correct interpolation
The depth test keeps the closest fragment at each pixel. For attributes like colour and texture coordinates, interpolating linearly in screen space is wrong under perspective, since a point halfway across a receding surface on screen is not halfway across it in the world. Interpolating each attribute divided by w, along with 1/w itself, and dividing the two at every pixel corrects this.