When drawing basic primitives we want nice smooth edges. This means both pixel correctness and antialiasing. In my previous article about drawing a rectangle I showed how to use a distance function. Here I show how to add antialiasing.
The aliasing problem
Given the distance to the edge of the shape I showed this function to determine the pixel opacity:
pixel_opacity = distance_to_edge < 0 ? 1 : 0
This has a problem that it’s all or nothing. Along the border of the shape the pixel will either be completely opaque or completely transparent. Though sometimes we may want this, in general we don’t. We’d like a nice smooth edge.
Compare the images below: the left one is using the hard edges and the right one antialiasing. Note: Try resetting your browser zoom, otherwise the scaling will affect what you see here.
The difference is in how the edges are drawn, in particular the pixels that don’t fall completely in or out of the shape. Here I’ve zoomed into a small fragment so we can see that difference better.
Antialiasing with the edge distance
To get from the hard edges to the smoothed ones requires a relatively small change to the opacity function. This function assumes the distance is being measured in pixels.
pixel_opacity = clamp( 0.5 - distance_to_edge, 0, 1 )
Instead of choosing a 1 or 0 as opacity we allow it to be a range between 0 and 1. Any pixel more than one away from the edge will still get exactly 1 or 0, which is what we want. It’s only the pixels near the edge where this results in a range of values.
But why does this work? Let’s look at how the shape and distance appear to the GPU while drawing. Values for both a pixel aligned and offset edge are shown in the diagram.
- “distance to edge” is the distance from the edge of the shape to the grid lines, which represent the edges of pixels.
- When drawing the GPU takes the position at the “pixel center” of each pixel.
- “distance to pixel” is the distance from the shape edge to the pixel being drawn.
- “opacity” is the result of the formula shown above.
It’s important here to recognize that pixels have an actual size, thus having a center and an edge. Our formula considers precisely what our
distance_to_edge means in relation to the location of individual pixels.
On the left side of the diagram the edge aligns perfectly on the bounds of the pixel. The resulting opacity values are then either exactly 1 or 0, producing a crisp pixel border.
The right side of the diagram shows what happens if the shape edge is not aligned to the edge of a pixel. The pixel right on the edge has an opacity of 0.5. This is what we’d expect for a pixel that is both half inside and half outside of the shape. As the line shifts slightly more to the left or right the opacity of that pixel will either increase or decrease accordingly.
I’ve used straight lines here since it’s easier to diagram. The same logic applies to the circular sections on the corners of the rectangle. On curves, or even slanted lines, virtually no pixel is perfectly in or out of the shape so we end up with a lot more partially opaque pixels.
A completed rectangle
That basically completes everything I needed to create a beautifully drawn rectangle. To summarize, my rectangle now has a fixed tessellation using a constant vertex buffer. The dimensions are sent as a small set of uniforms to the drawing program. The antialiasing is achieved using the distance function as shown above. The distance function can also be used to draw strokes.
I still need to draw an ellipse, star, and regular polygon with the same approach, which completes our set of basic shapes. Later I will also make the distance fields more open, as there are many effects that can be achieved with them. I’ll be sure to write about those as I do them.