Signed Distance Fields can be used to render beautiful images. They are often used not just for shapes that blend together well, but also shadows, displacements, and even rendering fonts.
I'm mostly interested in SDFs as a mechanism for powering a technique called Raymarching, which I'll cover in a different post, so we'll be talking about 2d SDFs in this post, even though everything I'll cover translates to 3d as well.
What are Signed Distance Fields?
If you don't already understand Signed Distance Fields (SDFs), then you can think of them from the perspective of an image. If you think of the pixels in an image as a 2d grid, then each pixel has a x coordinate and a y coordinate.
At each pixel, you take the x,y position and give it to the SDF function which then returns how far away you are from the closest edge of the SDF shape. The shape can be anything from a single circle to combinations of whole sets of objects.
For all of the pixels in the image, we can calculate this corresponding f32
representing the distance from the current pixel to the edge of the SDF shape. Visualizing this is straightforward, if a bit awkward because the f32 distance can be infinitely big or small (because we can be infinitely far from the SDF) so we have to constrain that somehow.
The biggest thing to remember is that Signed Distance Fields are signed, as in they are positive distances outside of the shape, and negative distances inside of the shape. We also treat the center of the image as the origin, aka point (0,0)
.
If you're interested in the specifics of how we manipulate the results to show this visualization the source code is here but its not important moving foreward.
A Circle as a Signed Distance Field
Here's a function that defines a circle as an SDF in Rust. Given an (x,y)
point in 2d space, it returns us how far we are from the circle.
fn sd_circle(point: Vec2) -> f32 {
let radius = 0.5;
let center_of_circle = Vec2::splat(0.);
let distance_from_origin = point.distance(center_of_circle);
distance_from_origin - radius
}
point
is some point in 2d space: any x and y value. We talked about thinking of these points as pixels earlier, but in reality the input can be any pair of f32
values in our case.
We've also defined radius
to be 0.5
and the center of the circle to be at the origin. So this circle is centered at the origin of our 2d space, point (0,0)
, and has a radius
of 0.5
.
If you draw your mouse over the circle, you'll find the black parts of the image are the parts closest to the distance 0.5
, which is the radius of our circle.
What this means is that the equation is calculating the distance from the center of the circle to the point
minus the radius
. This results in our equation's f32
return value being the distance from the circle we've defined, to the point we're asking about.
Because we already know this is a circle, we can visualize this distance. *Although it is important to remember that the SDF doesn't give us the direction so if we didn't already know this was a circle, say if we were given a game level defined as a bunch of SDFs, we wouldn't be able to draw this line.
The distance from the circle to our point isn't super useful on its own. All we have is, given some point in 2d space, how far we are from the circle edge we've defined.
Visualizing SDFs
Circles are the some of the smallest SDF functions, but we can build pretty much anything using enough math. This 2D distance functions page shows many of them.
Its useful to mention that any point can be treated as a vector from the origin to that point. So a point at
x=3
andy=4
can be thought of as a vector from(0,0)
to(3,4)
. This is what thelength()
is: The distance from the origin to that point. This is relevant when looking at the above list of functions.
The equation for a box gets a little more complicated, and starts using a very common trick that you'll see often if you look at enough SDFs. All of the corners of our box are the same, so we can treat them all as if they were the top-right corner.
fn sd_box(point: Vec2) -> f32 {
let size = Vec2::new(0.5, 0.5);
let distance = point.abs() - size;
distance.max(Vec2::ZERO).length()
+ distance.x.max(distance.y).min(0.0)
}
point.abs()
moves our point
from wherever it is to make it look like its in the top right quadrant of our 2d space, and then the rest of the equation operates as if we're in the top-right quadrant.
If you imagine the four corners of a 2x2 box (yes, that is a square), the you end up with corners at these four points.
( 0.5, 0.5)
( 0.5, -0.5)
(-0.5, 0.5)
(-0.5, -0.5)
All corners of the box are defined in the same way, so we can make all of them behave like they're the top right corner, (0.5, 0.5)
. The top-right corner point is exactly the size
we've defined.
point.abs()
will move whatever point
we give the sd_box
function into the top-right quadrant, so we can treat it like its a offset from the top-right corner.
Then point.abs() - size
gives us the vector from the corner of the box to our point
. This is because a vector from (0,0)
to point.abs()
and a second vector from (0,0)
to size
give us two sides of a triangle. Subtracting the two vectors gives us the vector that would fit as the third side of the triangle.
Given a point
of (-2,-3)
, .abs()
gives us (2,3)
and subtracting a size of (2,2)
leaves us with (0,1)
.
All we have is distance
When the distance returned from the sdf shape function is 0, you are on the edge of the shape. If it's negative, you're inside the shape, and if its positive you're outside the shape.
Even though we've visualized the algorithms with additional information, the distance is all the information we get from this algorithm. So from the perspective of our program, all we know is that there is something N distance away from the point we've chosen. We don't know what direction the shape is in, just how far away the closest point is.
We can visualize this by taking the position of our mouse cursor as the point
, using that as input to the sdf, and drawing a circle with a radius of the f32
result around our cursor. As we move the cursor around the scene, the circle is always touching the closest shape in the scene.
So if you move the cursor around enough, you can figure out what sdf function we're using, but any given individual position isn't enough.
It may be easier if we "turn the lights on". We can render the sdf shape by using every pixel location on the screen as a point
input to the sdf function. Any distance result for these points that is 0 or lower means we're on the shape's edge or inside of it, so we'll render that one color. Any positive distance we'll render as another color.