Shadertoy Tutorial Part 14 - SDF Operations
Greetings, friends! Welcome to Part 14 of my Shadertoy tutorial series! Have you ever wondered how people draw complex shapes and scenes in Shadertoy? We learned how to make spheres and cubes, but what about more complicated objects? In this tutorial, we'll learn how to use SDF operations popularized by the talented Inigo Quilez, one of the co-creators of Shadertoy!
Initial Setup
Below, I have created a ray marching template that may prove useful for you if you plan on developing 3D models using Shadertoy and ray marching. We will start with this code for this tutorial.
const int MAX_MARCHING_STEPS = 255;
const float MIN_DIST = 0.0;
const float MAX_DIST = 100.0;
const float PRECISION = 0.001;
const float EPSILON = 0.0005;
const float PI = 3.14159265359;
const vec3 COLOR_BACKGROUND = vec3(.741, .675, .82);
const vec3 COLOR_AMBIENT = vec3(0.42, 0.20, 0.1);
mat2 rotate2d(float theta) {
float s = sin(theta), c = cos(theta);
return mat2(c, -s, s, c);
}
float sdSphere(vec3 p, float r, vec3 offset)
{
return length(p - offset) - r;
}
float scene(vec3 p) {
return sdSphere(p, 1., vec3(0, 0, 0));
}
float rayMarch(vec3 ro, vec3 rd) {
float depth = MIN_DIST;
float d; // distance ray has travelled
for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
vec3 p = ro + depth * rd;
d = scene(p);
depth += d;
if (d < PRECISION || depth > MAX_DIST) break;
}
d = depth;
return d;
}
vec3 calcNormal(in vec3 p) {
vec2 e = vec2(1, -1) * EPSILON;
return normalize(
e.xyy * scene(p + e.xyy) +
e.yyx * scene(p + e.yyx) +
e.yxy * scene(p + e.yxy) +
e.xxx * scene(p + e.xxx));
}
mat3 camera(vec3 cameraPos, vec3 lookAtPoint) {
vec3 cd = normalize(lookAtPoint - cameraPos);
vec3 cr = normalize(cross(vec3(0, 1, 0), cd));
vec3 cu = normalize(cross(cd, cr));
return mat3(-cr, cu, -cd);
}
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;
vec2 mouseUV = iMouse.xy/iResolution.xy;
if (mouseUV == vec2(0.0)) mouseUV = vec2(0.5); // trick to center mouse on page load
vec3 col = vec3(0);
vec3 lp = vec3(0);
vec3 ro = vec3(0, 0, 3); // ray origin that represents camera position
float cameraRadius = 2.;
ro.yz = ro.yz * cameraRadius * rotate2d(mix(-PI/2., PI/2., mouseUV.y));
ro.xz = ro.xz * rotate2d(mix(-PI, PI, mouseUV.x)) + vec2(lp.x, lp.z);
vec3 rd = camera(ro, lp) * normalize(vec3(uv, -1)); // ray direction
float d = rayMarch(ro, rd); // signed distance value to closest object
if (d > MAX_DIST) {
col = COLOR_BACKGROUND; // ray didn't hit anything
} else {
vec3 p = ro + rd * d; // point discovered from ray marching
vec3 normal = calcNormal(p); // surface normal
vec3 lightPosition = vec3(0, 2, 2);
vec3 lightDirection = normalize(lightPosition - p) * .65; // The 0.65 is used to decrease the light intensity a bit
float dif = clamp(dot(normal, lightDirection), 0., 1.) * 0.5 + 0.5; // diffuse reflection mapped to values between 0.5 and 1.0
col = vec3(dif) + COLOR_AMBIENT;
}
fragColor = vec4(col, 1.0);
}
When you run this code, you should see a sphere appear in the center of the screen.
Let's analyze the code to make sure we understand how this ray marching template works. At the beginning of the code, we are defining constants we learned about in Part 6 of this tutorial series.
const int MAX_MARCHING_STEPS = 255;
const float MIN_DIST = 0.0;
const float MAX_DIST = 100.0;
const float PRECISION = 0.001;
const float EPSILON = 0.0005;
const float PI = 3.14159265359;
const vec3 COLOR_BACKGROUND = vec3(.741, .675, .82);
const vec3 COLOR_AMBIENT = vec3(0.42, 0.20, 0.1);
We are defining the background color and ambient light color using variables, so we can quickly change how the 3D object will look under different colors.
Next, we are defining the rotate2d
function for rotating an object along a 2D plane. This was discussed in Part 10. We'll use it to move the camera around our 3D model with our mouse.
mat2 rotate2d(float theta) {
float s = sin(theta), c = cos(theta);
return mat2(c, -s, s, c);
}
The following functions are basic utility functions for creating a 3D scene. We learned about these in Part 6 when we first learned about ray marching. The sdSphere
function is an SDF used to create a sphere. The scene
function is used to render all the objects in our scene. You may often see this called the map
function as you read other peoples' code on Shadertoy.
float sdSphere(vec3 p, float r, vec3 offset)
{
return length(p - offset) - r;
}
float scene(vec3 p) {
return sdSphere(p, 1., vec3(0));
}
float rayMarch(vec3 ro, vec3 rd) {
float depth = MIN_DIST;
float d; // distance ray has travelled
for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
vec3 p = ro + depth * rd;
d = scene(p);
depth += d;
if (d < PRECISION || depth > MAX_DIST) break;
}
d = depth;
return d;
}
vec3 calcNormal(in vec3 p) {
vec2 e = vec2(1, -1) * EPSILON;
return normalize(
e.xyy * scene(p + e.xyy) +
e.yyx * scene(p + e.yyx) +
e.yxy * scene(p + e.yxy) +
e.xxx * scene(p + e.xxx));
}
Next, we have the camera
function that is used to define our camera model with a lookat point. This was discussed in Part 10. The lookat point camera model lets us point the camera at a target.
mat3 camera(vec3 cameraPos, vec3 lookAtPoint) {
vec3 cd = normalize(lookAtPoint - cameraPos);
vec3 cr = normalize(cross(vec3(0, 1, 0), cd));
vec3 cu = normalize(cross(cd, cr));
return mat3(-cr, cu, -cd);
}
Now, let's analyze the mainImage
function. We are setting up the UV coordinates so that the pixel coordinates will be between -0.5
and 0.5
. We also account for the aspect ratio, which means the x-axis will have values that will go between different values, but still go between a negative value and positive value.
vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;
Since we're using the mouse to rotate around the 3D object, we need to setup mouseUV
coordinates. We'll setup such that the coordinates go between zero and one when we click on the canvas.
vec2 mouseUV = iMouse.xy/iResolution.xy;
There's an issue though. When we publish our shader on Shadertoy, and a user loads our shader for the first time, the coordinates will start at (0, 0) for the mouseUV
coordinates. We can "trick" the shader by assigning it a new value when this happens.
if (mouseUV == vec2(0.0)) mouseUV = vec2(0.5); // trick to center mouse on page load
Next, we declare a color variable, col
, with an arbitrary starting value. Then, we setup the lookat point, lp
, and the ray origin, ro
. This was also discussed in Part 10. Our sphere currently has no offset in the scene
function, so it's located at (0, 0, 0). We should make the lookat point have the same value, but we can adjust it as needed.
vec3 col = vec3(0);
vec3 lp = vec3(0); // lookat point
vec3 ro = vec3(0, 0, 3); // ray origin that represents camera position
We can use the mouse to rotate around the camera, but we have to be conscious of how far away the camera is from the 3D object. As we learned at the end of Part 10, we can use the rotate2d
function to move the camera around and use cameraRadius
to control how far away the camera is.
float cameraRadius = 2.;
ro.yz = ro.yz * cameraRadius * rotate2d(mix(-PI/2., PI/2., mouseUV.y));
ro.xz = ro.xz * rotate2d(mix(-PI, PI, mouseUV.x)) + vec2(lp.x, lp.z);
vec3 rd = camera(ro, lp) * normalize(vec3(uv, -1)); // ray direction
I hope that makes sense! There are alternative ways to implement cameras out there on Shadertoy. Each person sets it up slightly different. Choose whichever approach works best for you.
Combination 3D SDF Operations
Now that we understand the ray marching template I have provided, let's learn about 3D SDF Operations! I covered 2D SDF operations in Part 5 of this tutorial series. 3D SDF operations are a bit similar. We will use utility functions to combine shapes together or subtract shapes from one another. These functions can be found on Inigo Quilez's 3D SDFs page.
Define the utility functions near the top of your code and then use it inside the scene
function.
Union: combine two shapes together or show multiple shapes on the screen. We should be familiar with a union operation by now. We've been using the min
function to draw multiple shapes.
float opUnion(float d1, float d2) {
return min(d1, d2);
}
float scene(vec3 p) {
float d1 = sdSphere(p, 1., vec3(0, -1, 0));
float d2 = sdSphere(p, 0.75, vec3(0, 0.5, 0));
return opUnion(d1, d2);
}
Smooth Union: combine two shapes together and blend them at the edges using the parameter, k
. A value of k
equal to zero will result in a normal union operation.
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 scene(vec3 p) {
float d1 = sdSphere(p, 1., vec3(0, -1, 0));
float d2 = sdSphere(p, 0.75, vec3(0, 0.5, 0));
return opSmoothUnion(d1, d2, 0.2);
}
Intersection: take only the part where the two shapes intersect.
float opIntersection(float d1, float d2) {
return max(d1,d2);
}
float scene(vec3 p) {
float d1 = sdSphere(p, 1., vec3(0, -1, 0));
float d2 = sdSphere(p, 0.75, vec3(0, 0.5, 0));
return opIntersection(d1, d2);
}
Smooth Intersection: combine two shapes together and blend them at the edges using the parameter, k
. A value of k
equal to zero will result in a normal intersection operation.
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);
}
float scene(vec3 p) {
float d1 = sdSphere(p, 1., vec3(0, -1, 0));
float d2 = sdSphere(p, 0.75, vec3(0, 0.5, 0));
return opSmoothIntersection(d1, d2, 0.2);
}
Subtraction: subtract d1 from d2.
float opSubtraction(float d1, float d2 ) {
return max(-d1, d2);
}
float scene(vec3 p) {
float d1 = sdSphere(p, 1., vec3(0, -1, 0));
float d2 = sdSphere(p, 0.75, vec3(0, 0.5, 0));
return opSubtraction(d1, d2);
}
Smooth Subtraction: subtract d1 from d2 smoothly around the edges using k
.
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 scene(vec3 p) {
float d1 = sdSphere(p, 1., vec3(0, -1, 0));
float d2 = sdSphere(p, 0.75, vec3(0, 0.5, 0));
return opSmoothSubtraction(d1, d2, 0.2);
}
Subtraction 2: subtract d2 from d1.
float opSubtraction2(float d1, float d2 ) {
return max(d1, -d2);
}
float scene(vec3 p) {
float d1 = sdSphere(p, 1., vec3(0, -1, 0));
float d2 = sdSphere(p, 0.75, vec3(0, 0.5, 0));
return opSubtraction2(d1, d2);
}
Smooth Subtraction 2: subtract d2 from d1 smoothly around the edges using k
.
float opSmoothSubtraction2(float d1, float d2, float k) {
float h = clamp( 0.5 - 0.5*(d2+d1)/k, 0.0, 1.0 );
return mix( d1, -d2, h ) + k*h*(1.0-h);
}
float scene(vec3 p) {
float d1 = sdSphere(p, 1., vec3(0, -1, 0));
float d2 = sdSphere(p, 0.75, vec3(0, 0.5, 0));
return opSmoothSubtraction2(d1, d2, 0.2);
}
Positional 3D SDF Operations
Inigo Quilez's 3D SDFs page describes a set of positional 3D SDF operations we can use to help save us some work when drawing 3D objects. Some of these operations help save on performance as well, since we don't have to run the ray marching loop extra times.
We've learned in previous tutorials how to rotate shapes with a transformation matrix and translate 3D shapes with an offset. If you need to scale a shape, you can simply change the dimensions of the SDF.
If you're drawing a symmetrical scene, then it may be useful to use the opSymX
operation. This operation will create a duplicate 3D object along the x-axis using the SDF you provide. If we draw the sphere at an offset of vec3(1, 0, 0)
, then an equivalent sphere will be drawn at vec3(-1, 0, 0)
.
float opSymX(vec3 p, float r, vec3 o)
{
p.x = abs(p.x);
return sdSphere(p, r, o);
}
float scene(vec3 p) {
return opSymX(p, 1., vec3(1, 0, 0));
}
If you want to use symmetry along the y-axis or z-axis, you can replace p.x
with p.y
or p.z
, respectively. Don't forget to adjust the sphere offset as well.
If you want to draw spheres along two axes instead of just one, then you can use the opSymXZ
operation. This will create a duplicate along the XZ plane, resulting in four spheres. If we draw a sphere with an offset of vec3(1, 0, 1)
, then a sphere will be drawn at vec3(1, 0, 1)
, vec3(-1, 0, 1)
, vec3(1, 0, -1)
, and vec3(-1, 0, -1)
.
float opSymXZ(vec3 p, float r, vec3 o)
{
p.xz = abs(p.xz);
return sdSphere(p, r, o);
}
float scene(vec3 p) {
return opSymXZ(p, 1., vec3(1, 0, 1));
}
Sometimes, you want to create an infinite number of 3D objects across one or more axes. You can use the opRep
operation to repeat spheres along the axes of your choice. The parameter, c
, is a vector used to control the spacing between the 3D objects along each axis.
float opRep(vec3 p, float r, vec3 o, vec3 c)
{
vec3 q = mod(p+0.5*c,c)-0.5*c;
return sdSphere(q, r, o);
}
float scene(vec3 p) {
return opRep(p, 1., vec3(0), vec3(8));
}
If you want to repeat the 3D objects only a certain number of times instead of an infinite amount, you can use the opRepLim
operation. The parameter, c
, is now a float value and still controls the spacing between each repeated 3D object. The parameter, l
, is a vector that lets you control how many times the shape should be repeated along a given axis. For example, a value of vec3(1, 0, 1)
would draw an extra sphere along the positive and negative x-axis and z-axis.
float opRepLim(vec3 p, float r, vec3 o, float c, vec3 l)
{
vec3 q = p-c*clamp(round(p/c),-l,l);
return sdSphere(q, r, o);
}
float scene(vec3 p) {
return opRepLim(p, 0.5, vec3(0), 2., vec3(1, 0, 1));
}
You can also perform deformations or distortions to an SDF by manipulating the value of p
and adding it to the value returned from an SDF. Inside the opDisplace
operation, you can create any type of mathematical operation you want to displace the value of p
and then add that result to the original value you get back from an SDF.
float opDisplace(vec3 p, float r, vec3 o)
{
float d1 = sdSphere(p, r, o);
float d2 = sin(p.x)*sin(p.y)*sin(p.z) * cos(iTime);
return d1 + d2;
}
float scene(vec3 p) {
return opDisplace(p, 1., vec3(0));
}
You can find the finished code, including an example of each 3D SDF operation, below.
const int MAX_MARCHING_STEPS = 255;
const float MIN_DIST = 0.0;
const float MAX_DIST = 100.0;
const float PRECISION = 0.001;
const float EPSILON = 0.0005;
const float PI = 3.14159265359;
const vec3 COLOR_BACKGROUND = vec3(.741, .675, .82);
const vec3 COLOR_AMBIENT = vec3(0.42, 0.20, 0.1);
mat2 rotate2d(float theta) {
float s = sin(theta), c = cos(theta);
return mat2(c, -s, s, c);
}
float sdSphere(vec3 p, float r, vec3 offset)
{
return length(p - offset) - r;
}
float opUnion(float d1, float d2) {
return min(d1, d2);
}
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 opIntersection(float d1, float d2) {
return max(d1, d2);
}
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);
}
float opSubtraction(float d1, float d2) {
return max(-d1, d2);
}
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 opSubtraction2(float d1, float d2) {
return max(d1, -d2);
}
float opSmoothSubtraction2(float d1, float d2, float k) {
float h = clamp( 0.5 - 0.5*(d2+d1)/k, 0.0, 1.0 );
return mix( d1, -d2, h ) + k*h*(1.0-h);
}
float opSymX(vec3 p, float r, vec3 o)
{
p.x = abs(p.x);
return sdSphere(p, r, o);
}
float opSymXZ(vec3 p, float r, vec3 o)
{
p.xz = abs(p.xz);
return sdSphere(p, r, o);
}
float opRep(vec3 p, float r, vec3 o, vec3 c)
{
vec3 q = mod(p+0.5*c,c)-0.5*c;
return sdSphere(q, r, o);
}
float opRepLim(vec3 p, float r, vec3 o, float c, vec3 l)
{
vec3 q = p-c*clamp(round(p/c),-l,l);
return sdSphere(q, r, o);
}
float opDisplace(vec3 p, float r, vec3 o)
{
float d1 = sdSphere(p, r, o);
float d2 = sin(p.x)*sin(p.y)*sin(p.z) * cos(iTime);
return d1 + d2;
}
float scene(vec3 p) {
float d1 = sdSphere(p, 1., vec3(0, -1, 0));
float d2 = sdSphere(p, 0.75, vec3(0, 0.5, 0));
//return d1;
//return d2;
//return opUnion(d1, d2);
//return opSmoothUnion(d1, d2, 0.2);
//return opIntersection(d1, d2);
//return opSmoothIntersection(d1, d2, 0.2);
//return opSubtraction(d1, d2);
//return opSmoothSubtraction(d1, d2, 0.2);
//return opSubtraction2(d1, d2);
//return opSmoothSubtraction2(d1, d2, 0.2);
//return opSymX(p, 1., vec3(1, 0, 0));
//return opSymXZ(p, 1., vec3(1, 0, 1));
//return opRep(p, 1., vec3(0), vec3(8));
//return opRepLim(p, 0.5, vec3(0), 2., vec3(1, 0, 1));
return opDisplace(p, 1., vec3(0));
}
float rayMarch(vec3 ro, vec3 rd) {
float depth = MIN_DIST;
float d; // distance ray has travelled
for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
vec3 p = ro + depth * rd;
d = scene(p);
depth += d;
if (d < PRECISION || depth > MAX_DIST) break;
}
d = depth;
return d;
}
vec3 calcNormal(in vec3 p) {
vec2 e = vec2(1, -1) * EPSILON;
return normalize(
e.xyy * scene(p + e.xyy) +
e.yyx * scene(p + e.yyx) +
e.yxy * scene(p + e.yxy) +
e.xxx * scene(p + e.xxx));
}
mat3 camera(vec3 cameraPos, vec3 lookAtPoint) {
vec3 cd = normalize(lookAtPoint - cameraPos);
vec3 cr = normalize(cross(vec3(0, 1, 0), cd));
vec3 cu = normalize(cross(cd, cr));
return mat3(-cr, cu, -cd);
}
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;
vec2 mouseUV = iMouse.xy/iResolution.xy;
if (mouseUV == vec2(0.0)) mouseUV = vec2(0.5); // trick to center mouse on page load
vec3 col = vec3(0);
vec3 lp = vec3(0);
vec3 ro = vec3(0, 0, 3); // ray origin that represents camera position
float cameraRadius = 2.;
ro.yz = ro.yz * cameraRadius * rotate2d(mix(-PI/2., PI/2., mouseUV.y));
ro.xz = ro.xz * rotate2d(mix(-PI, PI, mouseUV.x)) + vec2(lp.x, lp.z);
vec3 rd = camera(ro, lp) * normalize(vec3(uv, -1)); // ray direction
float d = rayMarch(ro, rd); // signed distance value to closest object
if (d > MAX_DIST) {
col = COLOR_BACKGROUND; // ray didn't hit anything
} else {
vec3 p = ro + rd * d; // point discovered from ray marching
vec3 normal = calcNormal(p); // surface normal
vec3 lightPosition = vec3(0, 2, 2);
vec3 lightDirection = normalize(lightPosition - p) * .65; // The 0.65 is used to decrease the light intensity a bit
float dif = clamp(dot(normal, lightDirection), 0., 1.) * 0.5 + 0.5; // diffuse reflection mapped to values between 0.5 and 1.0
col = vec3(dif) + COLOR_AMBIENT;
}
fragColor = vec4(col, 1.0);
}
Conclusion
In this tutorial, we learned how to use "combination" SDF operations such as unions, intersections, and subtractions. We also learned how to use "positional" SDF operations to help draw duplicate objects to the scene along different axes. In the resources, I have included a link to the ray marching template I created at the beginning of this tutorial and a link to my shader that includes examples of each 3D SDF operation.
There are many other 3D SDF operations that I didn't discuss in this article. Please check out the other resources below to see examples created by Inigo Quilez on how to use them.