Perfect Shapes on the GPU
I wanted to understand how to draw smooth, perfect shapes on the GPU — so I tried using fragment shaders and SDFs. Just one quad, no geometry tricks — and it works beautifully.
I’ve been playing around with fragment shaders lately, while trying to understand how Radiance Cascades work in more detail. These are fascinating and I’ll probably write more about it later, but a crucial element for the raymarching algorithm used are Signed Distance Fields, or SDFs.
SDFs are a way to represent any sort of shape as a mathematical function that returns the distance of a point to the edge of a shape. The distance is positive outside the shape and negative inside. So if you sample every single point in the space you’re looking at, you can literally see that function taking shape.
That’s a popular way to draw 2D shapes in computer graphics because you can draw the shape perfectly for every pixel. It doesn’t have the limitations of rasterization, where you have to sample curves into triangles at regular, discrete intervals for example, which will be visible on the screen. Plus, SDFs are giving us things like antialiasing or boolean operations for free.
The circle is probably the simplest shape we can draw, so that’s where I started:
A simple disc
The bulk of our fragment shader is as such:
float circleSDF(vec2 pos, float radius) { // Distance between the given point and the center of the circle which is // always assumed at the origin (0, 0) return length(pos) - radius; } void main() { // Distance to the circle shape float signedDistance = circleSDF(v_uv, u_radius); // Based on the distance, decide whether we draw a color (inside the cicle) // or we don't draw anything (transparent) fragColor = signedDistance < 0.0f ? vec4(u_color, 1.0f) : vec4(u_color, 0.0f); }
You can find many more standard SDF functions in this article by Inigo Quilez.
By convention we center our shapes onto the origin (0, 0)
. This is because
if we need to rotate, translate or scale them it’s easier using a 2D matrix
than building-in the transformation into the SDF function itself.
If you squint really hard (especially on high-density displays which is probably the case for you), you can see it’s a bit jagged at the circumference of the circle. This makes sense, because when we’re at the edge of the circle, the signed distance is either slightly above or below zero, and we are doing a straight transition from the color, to pure transparent:
fragColor = signedDistance < 0.0f ? vec4(u_color, 1.0f) : vec4(u_color, 0.0f);
What we can do instead, is use the
smoothstep
function to seamlessly transition between the color to transparent. What we
don’t know however is how “large” this transition should be. We can use the
fwidth
function to get this information:
float edge = fwidth(signedDistance); float alpha = smoothstep(0.5f * edge, -0.5f * edge, signedDistance);
What this does is smooth the transition between both sides of the signed
distance by a very small number. The fwidth
function returns the
difference between side-to-side values of the signedDistance
variable—in
modern GPUs, fragment shaders are always run in lockstep in quads of 2x2, so
fwidth
and other functions can compare the values between pixels of the same
quad.
In practice, that means fwidth
returns us the “size” of a pixel in the screen
space coordinates. This allows us to achieve display-independent antialiasing.
On a low resolution, legacy display, your signed distance might be, say -0.3 on
one side and 0.2 on the other side of the circle’s edge in screen space
coordinates. So fwidth(signedDistance)
might return 0.5. If you’re on a
high-resolution display, the difference between each side of the signed distance
function might be, say, 0.1, so we apply the aliasing over a smaller range, just
to cover an edge of 1 pixel.
To visualize the antialiasing, I made a small shader that ‘zoom onto’ the circle and display individual pixels. You can see how the antialiasing smudges the edge of both sides of the circle by flicking the switch on/off: solid blue pixels become slightly transparent and pixels technically outside the shape become slightly blue. The overall shape appears ‘rounder’ than the original pixels.
Zoomed-in disk
Drawing the edge of the shapes is very similar to antialiasing, with a fixed
width. SDFs become negative inside the shape, so for example we might want to
draw an edge between values -0.1 and 0 of the signed distance. We can use
fwidth
and smoothstep
again to draw a smooth transition between the inner
color and the stroke color.
Now, another thing I wanted to try was to combine multiple shapes. It’s
notoriously easy to do with SDFs, you can combine signed distances by using
mostly the min()
and max()
functions. For example the min(A, B)
of two
signed distances A
and B
results in a signed distance that represents the
union of the two shapes. It makes sense intuitively: remember SDFs are negative
inside the shape they represent, so the min
of two SDFs is negative when
inside either shape.
Conversely, max(A, -B)
results in subtracting B from A. max
does
intersections, so it’s a bit like doing the intersection of A with the
complement of B, ie. the entire space excluding B
.
Here’s a final demo of antialiasing, edge drawing and boolean operations. I’ve added a box SDF for good measure.
Antialising and boolean operations
That’s it for now! I hope to write more about SDFs and Radiance Cascade in a future article.