Digital Salmon
Title Icon
Signed Distance Functions

What is a Signed Distance Function?

and why should you care?

Signed Distance Functions allow you to generate graphics that can be applied to a surface in a game. Unlike textures, SDF masks can be resolution-independant, require almost zero memory, and can be parameterised to animate in ways that are simply impossible with textures alone. They are a powerful and essential tool in a technical artists toolbelt.

Let's clarify some of these terms:

Signed Distance Functions are functions that operate on a domain and return the distance to the surface of a shape.

The distance value that is returned is a Signed Distance Field, and this field can be sampled to generate several useful things, including but not limited to a shapes, surface properties, raymarched 3D renders, text glyphs and more.

Domain

The domain is the input of a signed distance function. It is a (usually continuous) space upon which signed distance functions operate. Domains can be 1D, 2D, 3D, or more, when needed.

In games, the most common 2D domain is UV Coordinates

The most common 3D domain is World Position

Vertex colour and Screen position are also fairly commonly used.

Field

A field is the output of a signed distance function.

In a continuous domain, a field can be thought of as a gradient where negative numbers indicate the distance inside a shape, a value of 0 indicates the surface of a shape, and positive numbers indicate the distance outside a shape.

Shape

A greyscale mask which can be used to render your result. A shape might be a n-dimensional primitive, such as a sphere, cube, torus, cone, star, or ngon.

Not all fields/shapes are generated procedurally, as we will look at in texture-based fields later.

Sampling

Sampling is the method by which we generate shapes from fields. Which sampling technique you use depends on how many dimensions you're working with, and how you intend to use the result.

2D Sampling usually uses smoothstep to generate a mask from a field mask.

3D Sampling usually uses raymarching to draw a 3D scene inside a 2D renderer. Raymarching is a relatively expensive operation and whilst it can be viable, it's less common in games.

A Simple Example

To demonstrate an example SDF application, let's look at a simple sdSphere operation.

Though simple, this exact method has been used countless times in ui elements, weapon pickups, achievement notifications, even on 2D animated characters.

Since the domain is in uv space, the units for radius and offset are "uv". A value of 0.5 for offset will give the result "half a uv tile".

// Psuedo Example

// Generate a 2D Distance Field for a 2D sphere (aka. a circle) of a given radius.
float sdSphere(float2 domain, float radius) {
	return length(domain) - radius;
}

// Use smoothstep to sample our field between the surface of the field and just inside it.
// This is a "fixed" sampling method. Other methods use ddx/ddy to get "variable" smoothness depending on view angle/distance.
float sampleSmooth(float field, float smoothing) {
	// I like to use -0.01 when sampling on uvs so that a smoothing value of 1 gives a nice result.
	smoothstep(0., -0.01 * smoothing, field);
} 

// Get the UV of the mesh (In this case, a quad). 
float2 uv; 

// Offset in x and y to "move" our domain. 0.5 will center the domain.
uv -= offset;// 2D Domain

// Run our distance function to get our field.
field = sdSphere(uv, radius); // 2D Field

// Sample our field to get a 2D mask.
return sampleSmooth(field, smoothing); // 2D Mask

2D Domain

2D Field

2D Mask

Uniforms

Shader Parameters

Shader parameters can be passed in from your engine to dynamically change the results of your field.

Offset

0.5

Radius

0.5

Smoothness

1

Shader Graphs

Node based SDF

It is, of course, perfectly possible to translate almost all SDF techniques to material function/subgraph versions, for use in a shader graph.

By wrapping the field generation functions, field operations, and sampling operations into their own nodes, fields can be easily used even by artists who are less comfortable with maths.

Sphere Distance FieldSmooth Field SamplingFull Spherical Field Graph

Visualized Field

Visualizing Fields

A Quick Trick

When trying to observe a field, all the negative values (distance values inside the shape) appear black, and it can be tricky to even see the exact edge of the shape.

From here, I'll run the field value through a visualise function - grey values will be outside the shape, and blue values will be inside the shape. This function also serves to demonstrate how easy it is to build beautiful and complex graphics from even simple fields.

float3 visualize(float d) {
	// Start with solid blue inside and solid grey outside.
	float3 col = (d>0.0) ? float3(0.25) : float3(0.054,0.450,.819);
	
	// Have brightness falloff from the surface exponentially.
	col *= 1.0 - exp(-6.0*abs(d));

	// Use a fixed smoothstep sampling technique
	// on a modified version of the field to draw concentric rings.
	float m = smoothstep(0.35, 0.45, abs(frac(d * 25.) - 0.5));
	col *= 0.8 + (0.2 * m);

	// Draw a thin white line around the surface.
	col = lerp( col, float3(1.0), 1.0-smoothstep(0.0,0.005,abs(d)) );

	return col;
}

Writing Distance Functions

Get started in Unreal & Unity

You can't write about SDF without mentioning the brilliant Inigo Quilez.

Most of the mathematics that I use when working with SDF comes from IQ.

Booleans

Unlike in polygon based 3D geometry, boolean operations with distance fields (When those fields are constructed properly) actually work.

By combining multiple fields in interesting ways, complex shapes can be built up that are useful far beyond simple primitives.

Union, Subtraction, and Intersection are the basic building blocks of boolean operations, but there are also special "smooth" boolean operations which give you a smoother transition at the cost of field integrity.

Smooth booleans can distort the field leading inconsistent field density - You can see this in the stretching of the lines in the demos below. This can be somewhat counteracted with this technique, or when your result is going to be a mask, dynamic sampling approaches (ddx/ddy) can also help.

It's worth noting that subtraction does not result in a accurate external distance field. In most cases, this isn't a problem.

float2 opUnion(float a, float b) { 
	return min(a,b);
}

float opSubtraction(float a, float b) {
	return max(-b, a);
}

float opIntersect(float a, float b) {
	return max(a, b);
}

float opExclusion(float a, float b) {
	return opUnion(opSubtraction(a,b), opSubtraction(b,a));
}

float opSmoothUnion(float d1, float d2, float k) {
    float h = clamp( 0.5 + 0.5*(d2-d1)/k, 0.0, 1.0 );
    return mix( d2, d1, h ) - k*h*(1.0-h); 
}

float opSmoothSubtraction(float d1, float d2, float k) {
    float h = clamp( 0.5 - 0.5*(d2+d1)/k, 0.0, 1.0 );
    return mix( d2, -d1, h ) + k*h*(1.0-h); 
}

float opSmoothIntersection(float d1, float d2, float k) {
    float h = clamp( 0.5 - 0.5*(d2-d1)/k, 0.0, 1.0 );
    return mix( d2, d1, h ) + k*h*(1.0-h); 
}

Union

min(a,b)

Subtraction

max(-b, a)

Intersection

max(a,b)

Exclusion

min(max(-b,a), max(-a,b))

Smooth Union

Smooth Subtraction

Smooth Intersection

Uniforms

Shader Parameters

Adjust the boolean operators and smoothing k-value.

Offset

0.125

K Val

0.1

Rounding

Making a distance field rounded is simple and useful. By subtracting a radius value from the field, you can essentially offset the surface of the shape being represented, which implictly rounds corners.

Since we usually want to round corners without "inflating" the shape, I usually adjust the size parameter of the shape I'm working with to subtract the radius before passing the resulting field to the rounding function, in order to counteract the upcoming inflation.

It can sometimes also be a good idea to clamp the radius to avoid cases where the radius exceeds the size of the shape. Other times, unclamped values can give useful results.

// Consider using the r value in your initial distance function so your field compensates for the "inflation" this will manifest.
float opRound(float field, float r) {
    return field - r;
}

Box

sdBox(size)

Rounded Box

sdRoundedBox(size, radius)

Compensated Box

size = size - c(radius)

Uniforms

Shader Parameters

Adjust the Size and Radius to see how they affect the field result.

Size

0.25

Radius

0.25

Annular

Ring-Shaped

We can "fold" all negative field values using abs. If all distance values are considered positive, and a radius parameter is subtracted, we find the field that desribes the shell of a shape.

In a simple sphere field, this operation would return a ring field.

In the double annular example we simple apply the ring function twice, with two different thickness values.

// Returns the annular field with thickness "r".
float opRing(float field, float r) {
	return abs(field)-r;
}

Ring

opRing(sdSphere())

Annular Star

opRing(sdStar())

Double Annular Rounded Box

opRing( opRing( sdRoundedBox() ) )

Uniforms

Shader Parameters

Shape properties and annular thickness.

Shape Size

1

Secondary Size

1

Annular Thickness

0.1

Conclusion

With a bit of practise, these techniques can easily be applied to your projects, allowing you to leverage some massive benefits.

The best results come about when you combine multiple techniques - Build primitives, combine them with boolean operations, distort your domain to warp your results, and use fancy sampling to get your results.

This articles doesn't even touch on 3D distance fields, nor Raytracing/Raymarching, the current standard sampling technique for 3 dimensions. We haven't look at storing additional shape information in field dimensions (such a material, normal, or lighting values).

For further reading, I'd highly recommend Inigo Quillez, and The Art of Code


- Benjamin Salmon