SDF Anti-Aliasing
The GPU is very good at drawing straight lines, and axis-aligned rectangles, but whenever you want to draw curves or diagonal lines, the result would by default look jagged and pixelated. One common approach to solving this is utilizing SDF anti-aliasing.
But how exactly does it work? I couldn't find a good explanation, so once I figured it out, I decided to write one myself.
Everything I'm going to present here is done per-pixel, distances are measured in pixel length, and areas are measured in whole pixels.
Pixel Coverage and Linear RGB
SDF anti-aliasing approximates pixel-coverage anti-aliasing, so let's start there:
The basic idea behind pixel-coverage anti-aliasing is if a pixel is 80% blue and 20% red,
the resulting color of the pixel should be 0.8*blue + 0.2*red
.
This computation only works correctly if we assume linear RGB color space.
On the face of it, it might not be clear why this is the case.
In both Linear RGB and sRGB the resulting color will be a shade of purple.
What makes the Linear RGB the “correct” one?
It is because Linear RGB is a color space that preserves the proportional amount of light under linear transformations,
i.e. 0.8*blue
has 80% of the light that blue has,
and that blue + red
light is the sum of the lights blue and red have.
Let's assume that we have a background
color and a shape filled with a foreground
color.
To create a pixel-coverage anti-aliasing we need to figure out what fraction of each pixel is covered by the shape,
if we mark it by a
we can compute a*foreground + (1 - a)*background
to get the correct color.
Unfortunately figuring out the exact coverage is a very computationally heavy thing to do.
Approximation I: Use tangents
Take the point on the perimeter of the shape that is closest to the center of the pixel, and draw a tangent line to that shape at that point. The tangent will split the pixel in two, and will form a coverage that is a close approximation to the shape's coverage.
I have created a small interactive visualization that helps illustrate the idea. You can drag the red disc around and see the coverage changes.
(Foreshadowing: Did you notice that when the center of the pixel is on the shape's perimeter, the coverage is exactly 0.5?)
Unfortunately, this computation requires several branching use-cases, the coverage shape can be either a triangle, a trapezoid or a pentagon, which makes it still not a good fit to run per pixel.
We want to find something simpler.
Approximation II: Pretend the pixel is a disc
What if instead of using squares as pixels, we pretend that the pixels are shaped as a disc of area 1. That would mean that the radius of the disc is R = 1/√π ≈ 0.56418958354775628695. And there is only one coverage shape: Circular segment.
Did you notice that the only parameter we need to compute the circular segment area is the distance from the pixel center to its closest point on the perimeter? I have added a line segment that illustrates this.
Signed Distance Field (SDF)
Well more precisely we need the "signed distance". It is similar to distance, but it has a negative value if the pixel center is inside the shape. You don't need to figure out the formula for the signed distance (although it can be fun to do so), other people have already done this for you.
This is the formula to compute circular segment coverage, where x is the signed distance:
\[ a(x) = \begin{cases} 1 & x < -\frac{1}{\sqrt{\pi}} \\ 0 & x > \frac{1}{\sqrt{\pi}} \\ \frac{1}{\pi} \cdot \arccos\left( x \sqrt{\pi} \right) - x \sqrt{ \frac{1}{\pi} - x^2 } & \text{otherwise} \\ \end{cases} \]
At last this is a simple formula that we can easily plug in to our shaders. So what's next?
Approximation III: Smoothstep
Although the formula from the previous section is a very good approximation, there is a simpler formula that is much easier to compute and is often good enough.
It is the smoothstep formula that is an intrinsic in both glsl and hlsl, which means the GPU can compute it very efficiently.
The code goes like this: a = smoothstep(-smoothness, smoothness, -sd);
where "smoothness" is a value between 0.5, and √0.5 ≈ 0.7071067811865475244,
the distance from the pixel center to its borders.
(You might have seen a = 1 - smoothstep(-smoothness, smoothness, sd);
,
i.e: Instead of negating the sd, you invert the final result, both are equivalent.)
This is equivalent to:
\[ a(x) = \begin{cases} 1 & x < -s \\ 0 & x > s \\ 3\left(\frac{s-x}{2s}\right)^{2}-2\left(\frac{s-x}{2s}\right)^3 & \text{otherwise} \\ \end{cases} \]
Where x is the signed distance and s is the smoothness.
You can compare the graphs of the two formulae using desmos.
Choosing the smoothness parameter
If you wonder what is the "best" smoothness value, then 0.5 works best on right angles, and √0.5 works best on 45 degrees. You can use the average distance from a pixel center to a point on its borders, that value is 0.573896787348 (note how close this is to 1/√π). This would be the best smoothness value if the angle is unknown and the shape either covers very little of the pixel, or almost all of the pixel.
To compute this yourself create a curve function γ that goes along the pixel borders, and measures the distance to its center, compute ∫γ(t)dt, and divide by the length of the curve.
You might want to use a smoothness parameter that minimizes the error between the two formulae. 0.643339 seems to minimizes the error across the entire range, and 0.661616 minimizes the error closest to the center of the graph, when the shape covers around half the pixel.
Conclusion
When I started investigating what makes SDF anti-aliasing works, I wanted to find a perfect formula that will be my gold standard, one that I will forever use. I did not find one. Instead I found a bunch of approximations, each with different errors, depending on angles and how close the shape is to the edges of the pixel.
But maybe one day I will...