Introduction
This is the second part of a series of posts about 2D SDF, check out the first one where I show some simple shapes and explain the basics to visualize them!
I think it’s very useful and valuable knowledge for every Technical or Environment Artist, but really for anyone that works with materials.
In this post, I will cover ways to combine and merge these shapes, as well as share some extra tips I’ve learned along the way.
As I mentioned before, everything I will cover is not new knowledge, I’ve learned it all by reading Ronja and Inigo Quilez’s articles which I highly recommend for getting a better understanding.
The focus of my posts is to show how their content can be recreated in Unreal Engine and its Material Graph.
I’m sorry again if this is another long post, but I hope it’s worth reading, enjoy!!
Shape Morphing / Interpolate
I want to cover this first because, even if it’s probably the simplest function, I think it’s the more interesting and visually powerful.
Just by lerping between two sdf you can smoothly morph from one shape to the other.
float interpolate(float ShapeA, float ShapeB, float Alpha)
{
return lerp(Shape1, Shape2, Alpha);
}

In the example below I’m lerping from a circle to a rectangle using a remapped sine wave as the alpha:

Combine Shapes
For this section, I’m going to use two shapes, a circle, and a square. The settings will always be the same so that the different results can be more easily understood.


Merge, Intersect, Subtract
With some simple operations, the shapes can be merged, intersected, and subtracted from each other. There are 3 simple functions, but they are extremely useful to create more complex shapes.
Below you can see them in action:


Merge
The Merging result can be easily achieved with a Min operation:
float merge(float ShapeA, float ShapeB)
{
return min(ShapeA, ShapeB);
}

Intersect
On the other hand, intersecting can be done using a Max node:
float intersect(float ShapeA, float ShapeB)
{
return max(ShapeA, ShapeB);
}

Subtract
While a subtraction can be done using the Intersect function and feeding the subtracting shape after multiplying it by -1.
So it’s essentially doing a Max operation but with one of the shapes inverted.
float subtract(float Base, float Subtraction)
{
return intersect(Base, -Subtraction);
}

Rounded Versions
Using the functions shown before, we can create rounded versions as well, with control on the Roundness.
I’ll try to explain them as best as I can but, If you want a more in-depth explanation of how this works, check out Ronja’s tutorial.

Round Merge
By creating a vector2 using the sdf of the two shapes, getting the min value with 0 as the second input, and then the get its length, we can define an intersection Space. Then by growing the two shapes with a Radius parameter, the intersection becomes more rounded, but the two shapes are also getting bigger, which can be countered by subtracting the Radius again after the length function.
This gives us the result we wanted, but at this point, the sdf outside of the shape is currently broken, which can be fixed by doing the same operations but with inverted math to calculate the outside separately. Then two results can easily be combined for the output.
Here’s the function:
float round_merge(float ShapeA, float ShapeB, float Radius)
{
float2 intersectionSpace = float2(ShapeA - Radius, ShapeB - Radius);
intersectionSpace = min(intersectionSpace, 0);
float insideDistance = -length(intersectionSpace);
float simpleUnion = merge(ShapeA, ShapeB);
float outsideDistance = max(simpleUnion, Radius);
return outsideDistance + insideDistance;
}

Round Intersect
For the round intersect function the logic is the same, we just need to change a couple of things.
For the intersectionSpace we subtract Radius instead of adding it and we replace min with max.
We invert the length result for the inside distance and replace the Merge function with an Intersect one.
So that the function becomes:
float round_intersect(float ShapeA, float ShapeB, float Radius)
{
float2 intersectionSpace = float2(ShapeA + Radius, ShapeB + Radius);
intersectionSpace = max(intersectionSpace, 0);
float outsideDistance = length(intersectionSpace);
float simpleIntersection = intersect(ShapeA, ShapeB);
float insideDistance = min(simpleIntersection, -Radius);
return outsideDistance + insideDistance;
}

Round Subtract
For the Round Subtract function you can just use the Round Intersect one and invert the sdf of the subtraction shape:
float round_subtract(float Base, float Subtraction, float Radius)
{
round_intersect(Base, -Subtraction, Radius);
}

Chamfer Versions
Similarly, we can create a chamfer at the transition points, with control on its position.

Chamfer Merge
The chamfer can be calculated by adding the sdf of the two shapes and dividing the result by the square root of 2, which it’s the same as multiplying by the square root of 0.5. You probably already know this, but computers and software are faster at calculating multiplications rather than divisions (even if in this case it probably wouldn’t really make a difference, I prefer using multiplication where I can).
To make things even faster, we could even feed directly the result as a float value, which is 0.70710678118. This way the shader doesn’t need to calculate the square root at all.
After that, the champfer location can be controlled by subtracting a value from it, then by merging it with the 2 merged shapes it gives the desired result.
Function:
float champfer_merge(float ShapeA, float ShapeB, float ChampferSize)
{
const float SQRT_05 = 0.70710678118;
float simpleMerge = merge(ShapeA, ShapeB);
float champfer = (ShapeA + ShapeB) * SQRT_05;
champfer = champfer - ChampferSize;
return merge(simpleMerge, champfer);
}

Chamfer Intersect
For the intersection version, we just need to replace the simple Merge functions with the Intersect ones, and to control the location of the chamfer we do an addition instead of a subtraction.
Function:
float champfer_intersect(float ShapeA, float ShapeB, float ChampferSize)
{
const float SQRT_05 = 0.70710678118;
float simpleIntersect = intersect(ShapeA, ShapeB);
float champfer = (ShapeA + ShapeB) * SQRT_05;
champfer = champfer + ChampferSize;
return intersect(simpleIntersect, champfer);
}

Chamfer Subtract
For the Chamfer Subtract, like the Round Subtract, you can just use the Champfer Intersect function and invert the sdf of the subtraction shape.
Function:
float champfer_subtract(float Base, float Subtraction, float ChampferSize)
{
return chamfer_intersect(Base, -Subtraction, VhampferSize);
}

Groove and Round Intersection
Groove

This function creates a groove in one shape at the position of the border of another shape.
We need 4 inputs, the first two will take the sdf of the shapes, one will be the Base, while the other one will be the shape that drives the Groove location. The other 2 inputs are Width and Depth.
First, we subtract the Width from the absolute value of the Groove shape. Then we feed the result in the simple Combine Subtract function I showed earlier, and for the subtraction input of the function, we use the Base shape with the Depth added to it.
To get the final result we just need to use another simple Combine Subtract function, subtracting what we just calculated to the Base sdf shape:
float groove_border(float Base, float Groove, float Width, float Depth)
{
float circleBorder = abs(Groove) - Width;
float grooveShape = subtract(circleBorder, Base + Depth);
return subtract(Base, grooveShape);
}

Round Intersection

This function, instead of combining the two shapes using boolean operations, creates new round shapes where the borders of the two sdf overlap.
To achieve this, as I showed in the Round Merge function, we need to define the Intersection Space by interpreting the two shapes as the X and Y axis of vector2, this way the intersection points will be coordinate 0;0.

Then, as we do for a simple circle shape, we can create rounded shapes by calculating the distance from the origin and subtracting an arbitrary value to control the Radius:
float round_intersection(float ShapeA, float ShapeB, float Radius)
{
float2 position = float2(ShapeA, ShapeB);
float distanceFromBorderIntersection = length(position);
return distanceFromBorderIntersection - Radius;
}

Conclusion
The next part will be about manipulating, in more advanced ways, the coordinate space used to sample the SDFs.
I hope you found this post useful, have a great rest of the day!
Leave a Reply