This tutorial explains how to create complex 3D shapes inside volumetric shaders. Signed Distance Functions (often referred as Fields) are mathematical tools used to describe geometrical shapes such as sphere, boxes and tori. Compared to traditional 3D models made out of triangles, signed distance functions provide virtually infinite resolution, and are amenable to geometric manipulation. The following animation, from formulanimation tutorial :: making a snail, shows how a snail can be created using simpler shapes:

You can find here all the other posts in this series:

Introduction

The way most modern 3D engines – such as Unity – handle geometries is by using triangles. Every objects, no matter how complex, must be composed of those primitive triangles. Despite being the de-facto standard in computer graphics, there are objects which cannot be represented with triangles. Spheres, and all other curved geometries, are impossible to tessellate with flat entities. It is indeed true that we can approximate a sphere by covering its surface with lot of small triangles, but this comes at the cost of adding more primitives to draw.

Alternative ways to represent geometries exist. One of this uses signed distance functions, which are mathematical descriptions of the objects we want to represent. When you replace the geometry of a sphere with its very equation, you have suddenly removed any approximation error from your 3D engine. You can think of signed distance fields as the SVG equivalent of triangles. You can scale up and zoom SDF geometries without ever losing detail. A sphere will always be smooth, regardless how close you are to its edges.

Signed distance functions are based on the idea that every primitive object must be represented with a function. It takes a 3D point as a parameter, and returns a value that indicates how distant that point is to the object surface.

SDF Sphere

In the first post of this series, Volumetric Rendering, we’ve seen a hit function that indicates if we are inside a sphere or not:

bool sphereHit (float3 p) { return distance(p,_Centre) < _Radius; } 1 2 3 4 bool sphereHit ( float3 p ) { return distance ( p , _Centre ) < _Radius ; }

We can change this function so that it returns the distance from the sphere surface instead:

float sdf_sphere (float3 p, float3 c, float r) { return distance(p,c) - r; } 1 2 3 4 float sdf_sphere ( float3 p , float3 c , float r ) { return distance ( p , c ) - r ; }

If sdf_sphere returns a positive distance, we’re not hitting the sphere. A negative distance indicates that we are inside the sphere, while zero is reserved for the points of the space which actually make up the surface.

Union and Intersection

The concept of signed distance function was briefly introduced in Raymarching tutorial, where it guided the advancement of the camera rays into the material. There is another reason why SDFs are used. And it is because they are amenable to composition. Given the SDFs of two different spheres, how can we merge them into a single SDF?

We can think about this from the perspective of a camera rays, advancing into the material. At each step, the ray must find its closest obstacle. If there are two spheres, we should evaluate the distance from both and get the smallest. We don’t want to overshoot the sphere, so we must advance by the most conservative estimation.

This toy example can be extended to any two SDFs. Taking the minimum value between them returns another SDF which corresponds to their union:

float map (float3 p) { return min ( sdf_sphere(p, - float3 (1.5, 0, 0), 2), // Left sphere sdf_sphere(p, + float3 (1.5, 0, 0), 2) // Right sphere ); } 1 2 3 4 5 6 7 8 float map ( float3 p ) { return min ( sdf_sphere ( p , - float3 ( 1.5 , 0 , 0 ) , 2 ) , // Left sphere sdf_sphere ( p , + float3 ( 1.5 , 0 , 0 ) , 2 ) // Right sphere ) ; }

The result can be seen in the following picture (which also features few other visual enhancements that will be discussed in the next post on Ambient Occlusion):

With the same reasoning, it’s easy to see that taking the maximum value between two SDFs returns their intersection:

float map (float3 p) { return max ( sdf_sphere(p, - float3 (1.5, 0, 0), 2), // Left sphere sdf_sphere(p, + float3 (1.5, 0, 0), 2) // Right sphere ); } 1 2 3 4 5 6 7 8 float map ( float3 p ) { return max ( sdf_sphere ( p , - float3 ( 1.5 , 0 , 0 ) , 2 ) , // Left sphere sdf_sphere ( p , + float3 ( 1.5 , 0 , 0 ) , 2 ) // Right sphere ) ; }

⭐ Suggested Unity Assets ⭐ Unity is free, but you can upgrade to Unity Pro or Unity Plus subscriptions plans to get more functionality and training resources to power up your projects.

SDF Box

Many geometries can be constructed with what we already know. If we want to push out knowledge further, we need to introduce a new SDF primitive: the half-space. As the name suggests, it is nothing more than just a primitive that occupies half of the 3D space.

// X Axis d = + p.x - c.x; // Left half-space full d = - p.x + c.x; // Right half-space full // Y Axis d = + p.y - c.y; // Left half-space full d = - p.y + c.y; // Right half-space full // Z Axis d = + p.z - c.z; // Left half-space full d = - p.z + c.z; // Right half-space full 1 2 3 4 5 6 7 8 9 10 11 // X Axis d = + p . x - c . x ; // Left half-space full d = - p . x + c . x ; // Right half-space full // Y Axis d = + p . y - c . y ; // Left half-space full d = - p . y + c . y ; // Right half-space full // Z Axis d = + p . z - c . z ; // Left half-space full d = - p . z + c . z ; // Right half-space full

The trick is to intersect six planes in order to create a box with the given size s, like shown in the animation below:

float sdf_box (float3 p, float3 c, float3 s) { float x = max ( p.x - c.x - float3(s.x / 2., 0, 0), c.x - p.x - float3(s.x / 2., 0, 0) ); float y = max ( p.y - c.y - float3(s.y / 2., 0, 0), c.y - p.y - float3(s.y / 2., 0, 0) ); float z = max ( p.z - c.z - float3(s.z / 2., 0, 0), c.z - p.z - float3(s.z / 2., 0, 0) ); float d = x; d = max(d,y); d = max(d,z); return d; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 float sdf_box ( float3 p , float3 c , float3 s ) { float x = max ( p . x - c . x - float3 ( s . x / 2. , 0 , 0 ) , c . x - p . x - float3 ( s . x / 2. , 0 , 0 ) ) ; float y = max ( p . y - c . y - float3 ( s . y / 2. , 0 , 0 ) , c . y - p . y - float3 ( s . y / 2. , 0 , 0 ) ) ; float z = max ( p . z - c . z - float3 ( s . z / 2. , 0 , 0 ) , c . z - p . z - float3 ( s . z / 2. , 0 , 0 ) ) ; float d = x ; d = max ( d , y ) ; d = max ( d , z ) ; return d ; }

There are more compact (yet less precise) ways to create a box, which take advantage of the symmetries around the centre:

float vmax(float3 v) { return max(max(v.x, v.y), v.z); } float sdf_boxcheap(float3 p, float3 c, float3 s) { return vmax(abs(p-c) - s); } 1 2 3 4 5 6 7 8 9 float vmax ( float3 v ) { return max ( max ( v . x , v . y ) , v . z ) ; } float sdf_boxcheap ( float3 p , float3 c , float3 s ) { return vmax ( abs ( p - c ) - s ) ; }

Shape Blending

If you are familiar with the concept of alpha blending, you will probably recognise the following piece of code:

float sdf_blend(float d1, float d2, float a) { return a * d1 + (1 - a) * d2; } 1 2 3 4 float sdf_blend ( float d1 , float d2 , float a ) { return a * d1 + ( 1 - a ) * d2 ; }

It’s purpose is to create a blending between two values, d1 and d2 , controller by a value a (from zero to one). The exact same code use to blend colours can also be used to blend shapes. For instance, the following code blends a sphere into a cube:

d = sdf_blend ( sdf_sphere(p, 0, r), sdf_box(p, 0, r), (_SinTime[3] + 1.) / 2. ); 1 2 3 4 5 6 d = sdf_blend ( sdf_sphere ( p , 0 , r ) , sdf_box ( p , 0 , r ) , ( _SinTime [ 3 ] + 1. ) / 2. ) ;

Smooth Union

In a previous section we’ve seen how two SDFs can be merged together using min. If it is true that SDF union is indeed effective, it is also true that its results is rather unnatural. Working with SDFs allows for many ways in which primitives can be blended together. One of this technique, exponential smoothing (link: Smooth Minimum), has been used extensively in the original animations of this tutorial.

float sdf_smin(float a, float b, float k = 32) { float res = exp(-k*a) + exp(-k*b); return -log(max(0.0001,res)) / k; } 1 2 3 4 5 float sdf_smin ( float a , float b , float k = 32 ) { float res = exp ( - k * a ) + exp ( - k * b ) ; return - log ( max ( 0.0001 , res ) ) / k ; }

When two shapes are joined using this new operator, they merge softly, creating a gentle step that removes any sharp edge. In the following animation, you can see how the spheres merges together:

SDF Algebra

As you can anticipate, all those SDF primitives and operators are part of a signed distance function algebra. Rotations, scaling, bending, twisting… all those operations can be performed with signed distance functions.

In his article title Modeling With Distance Functions, Íñigo Quílez has worked on a vast collection of SDFs that can be used as primitive for the construction of more complex geometries. You can see some of them by clicking in the interactive ShaderToy below:

An even larger collection of primitives and operators is available in the library hg_sdf (link here) curated by the MERCURY group. Despite being written in GLSL, the functions are easily portable to Unity’s Cg/HLSL.

Conclusion

The number of transformations that can be performed with SDFs is virtually endless. This post provided just a quick introduction to the topic. If you really want to master volumetric rendering, improving your knowledge of SDFs is a good starting point.

Other Resources

⚠ Part 6 of this series is available for preview on Patreon, as the text needs to be completed. If you are interested in volumetric rendering for non-solid materials (clouds, smoke, …) or transparent ones (water, glass, …) the topic is resumed in detailed in the Atmospheric Volumetric Scattering series!