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

0.25
#485b93

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

0.07

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

0.35
0.03
#485b93

That’s it for now! I hope to write more about SDFs and Radiance Cascade in a future article.