2D Signed Distance Fields

Introduction

During Thanksgiving eve, while my daughters ran around the house playing with their cousin, I spent the day learning about SDFs (I owe you, Becca). Signed Distance Fields (SDFs) represent geometry by answering a simple question: How far is any point from a given shape? Unlike defining a circle by its center and radius, or a square by its corners, SDFs provide a unified mathematical function that works for any shape.

For each point in space, an SDF returns the shortest distance to the shape’s surface—positive if outside, negative if inside, zero on the boundary. This abstraction makes it trivial to combine shapes, create smooth blends, or render them efficiently.

Making a circle

Let’s start with a simple example: the SDF for a circle. Given a point $\mathbf{p}$, circle center $\mathbf{c}$, and radius $r$.

We compute the distance from the point to the circle’s center, then subtract the radius:

\[f(\mathbf{p}) = |\mathbf{p} - \mathbf{c}| - r\]

Here’s the implementation in code:

fn sdCircle(p: vec2<f32>, c: vec2<f32>, r: f32) -> f32 {
    return length(p - c) - r;
}

This gives us the signed distance for a given pixel: positive values mean the pixel is outside the circle, negative means inside, and zero means exactly on the boundary.

Calculate distance-to-shape per pixel

Rendering with SDFs

Once we have the signed distance for each pixel, we can use it to determine what to render. As you now know, the sign of the distance value tells us everything we need to know.

Color pixels within (-) the circle

Pixels with negative distances are inside the shape. These are the pixels we want to color.

Pixels outside (+) remain uncolored

Pixels with positive distances are outside the shape, so we leave them uncolored. When the distance is exactly zero, the pixel lies precisely on the boundary of the shape.

That’s pretty much it, and that’s the beauty of it. A single mathematical function gives us both the distance and the inside/outside information we need for rendering. We can create different SDFs to render different shapes such as squares and triangles. We can even render 3D geometry, though we would achieve this through different methods, such as raymarching.

Combining Multiple Shapes

Now lets throw some more shapes into the mix. Instead of dealing with complex geometric intersections, you can simply combine the distance functions. From the pixel perspective, not much changes since we only care about whether we are inside or outside of the shape.

Add more shapes!

Each shape has its own SDF function. Here’s an example of another common primitive, a square:

fn sdBox(p: vec2<f32>, c: vec2<f32>, size: f32) -> f32 {
    let d = abs(p - c) - vec2<f32>(size, size);
    return length(max(d, vec2<f32>(0.0))) + min(max(d.x, d.y), 0.0);
}

How the Square SDF Works

The square SDF is more complex than the circle because we need to handle different regions: inside the box, outside the box (in different quadrants), and on the edges. Let’s break down each step:

Step 1: Transform to box-local space

abs(p - c)

First, we translate the point p relative to the box center c, then take the absolute value. This effectively moves the point into the first quadrant (top-right), which simplifies our calculations since a box is symmetric.

Step 2: Compute distance to box edges

let d = abs(p - c) - vec2<f32>(size, size);

Here, size represents the half-width (and half-height) of the box. By subtracting size from each component, we get a vector d where:

  • If d.x or d.y is positive, the point is outside the box along that axis
  • If d.x or d.y is negative, the point is inside the box along that axis
  • If d.x or d.y is zero, the point is exactly on the edge along that axis

Step 3: Handle the outside case

length(max(d, vec2<f32>(0.0)))

When the point is outside the box, at least one component of d is positive. By clamping negative values to zero with max(d, vec2<f32>(0.0)), we get a vector pointing from the nearest corner of the box to the point. Taking the length of this vector gives us the Euclidean distance to the box.

Step 4: Handle the inside case

min(max(d.x, d.y), 0.0)

When the point is inside the box, both d.x and d.y are negative. The max(d.x, d.y) selects the component that’s closest to zero (least negative), which represents the distance to the nearest edge. The min(..., 0.0) ensures this value stays negative (or zero if on the edge).

Putting it together: The final return statement combines both cases:

  • If outside: length(max(d, vec2<f32>(0.0))) gives a positive distance, and min(max(d.x, d.y), 0.0) is zero
  • If inside: length(max(d, vec2<f32>(0.0))) is zero (since all components were clamped), and min(max(d.x, d.y), 0.0) gives a negative distance

This elegant formulation handles all cases in a single expression, giving us the signed distance to the box boundary.

Smooth Blending

To combine shapes, you can use operations like:

  • Union: Take the minimum of the two distances
  • Intersection: Take the maximum of the two distances
  • Subtraction: Combine with negation

These operations work on the distance values themselves, making it trivial to create complex scenes from simple primitives. For now, we’re going to use a method to take the smoothed union.**

In my opinion, one of the most interesting features of SDFs is the ability to smoothly blend shapes together, creating organic, fluid forms that would be difficult to achieve with traditional polygon-based approaches. Now we’re sculpting with math (and a rendering pipeline, but we’ll leave that for a different post).

Morph them together with a smoothing function

Instead of using a hard minimum or maximum to combine shapes, which would result in hard cutoffs when putting the shapes together, here we use smooth blending functions or smooth min to be exact. In general, these functions create gradual transitions between shapes, resulting in smooth, blob-like morphing effects.

The smooth minimum (or “smooth union”) function interpolates between the two distance values based on how close they are, creating a smooth transition zone. This is what allows SDFs to create fluid, organic shapes you see in modern graphics. The implementation below is by Inigo Quilez. Check out his blog for some pretty impressive use cases for smoothing functions:

// Author: Inigo Quilez
fn smoothMin(a: f32, b: f32, k: f32) -> f32 {
    let h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
    return mix(b, a, h) - k * h * (1.0 - h);
}

The k parameter controls the smoothness of the blend—larger values create wider, more gradual transitions between shapes.

Result

When putting it all together (ignoring a bunch of WebGPU pipeline setup) I got the following result:

Final rendered result

Full GitHub repo here.

As always, I hope you find this information useful or interesting. If you’d like to keep up on the latest, consider subscribing. Until next time!