Learn Creative Coding (#39) - Raymarching: Boolean Operations and Morphing

Last episode we built our first raymarched 3D scenes. A sphere on a ground plane, lit with Lambertian diffuse, orbiting cameras, material IDs for color. The entire renderer was about 50 lines of GLSL. No mesh data, no vertex buffers -- just distance functions evaluated at every pixel.
But the scenes were simple. Spheres and planes, combined with min(). That min() call was doing boolean union -- give me the closest surface, whichever object it belongs to. It works, but it gives us hard, separate objects sitting next to each other. What if we want to carve a hole through a sphere? Or melt two shapes together so they blend organically? Or morph a sphere into a box over time?
All of that is possible because we're working with distance fields, not polygons. In a mesh-based renderer, cutting a hole through an object means generating new geometry -- computing the intersection, building new triangles, updating the mesh. In a raymarcher, it's one line of math. The distance field makes boolean operations trivial. And beyond booleans, we can deform, repeat, twist, and bend space itself, because the SDF is just a function of position. Change the position before evaluating the function, and you change the shape.
This is where raymarching starts getting genuinely powerful. Let's dig in.
Boolean operations recap: from 2D to 3D
We covered boolean operations briefly in episode 33 with 2D SDFs, and used min() for union in episode 38. Let's formalize all three:
// union: the closest surface of either shape
float opUnion(float d1, float d2) {
return min(d1, d2);
}
// intersection: only the volume inside BOTH shapes
float opIntersection(float d1, float d2) {
return max(d1, d2);
}
// subtraction: carve shape 2 out of shape 1
float opSubtraction(float d1, float d2) {
return max(d1, -d2);
}
Union: min(d1, d2). Returns the shorter distance -- whichever surface is closer. This gives you both shapes coexisting. We already know this one.
Intersection: max(d1, d2). Returns the longer distance -- meaning a point must be inside BOTH shapes (negative distance to both) for the result to be negative (inside). Only the overlapping volume survives. Everything outside either shape gets a positive distance and the raymarcher skips past it.
Subtraction: max(d1, -d2). The negative sign on d2 flips its inside/outside. Points that were inside shape 2 (negative distance) become "outside" (positive distance), so the raymarcher treats them as empty space. Points inside shape 1 but outside shape 2 remain inside. The result: shape 2's volume is carved out of shape 1.
The negative sign is the key insight. Negating an SDF flips its interior and exterior. sdSphere(p, 1.0) is a solid sphere. -sdSphere(p, 1.0) is an infinite solid block with a spherical hole in it. Combining that with another shape via max() cuts the hole into the other shape.
Let's see all three in action:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
float sdSphere(vec3 p, float r) {
return length(p) - r;
}
float sdBox(vec3 p, vec3 b) {
vec3 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}
float sdPlane(vec3 p, float h) {
return p.y - h;
}
float scene(vec3 p) {
float ground = sdPlane(p, 0.0);
// left: union (sphere + box)
float s1 = sdSphere(p - vec3(-3.0, 1.0, 0.0), 1.0);
float b1 = sdBox(p - vec3(-3.0, 1.0, 0.0), vec3(0.7));
float left = min(s1, b1);
// center: intersection (sphere AND box)
float s2 = sdSphere(p - vec3(0.0, 1.0, 0.0), 1.0);
float b2 = sdBox(p - vec3(0.0, 1.0, 0.0), vec3(0.7));
float center = max(s2, b2);
// right: subtraction (sphere MINUS box)
float s3 = sdSphere(p - vec3(3.0, 1.0, 0.0), 1.0);
float b3 = sdBox(p - vec3(3.0, 1.0, 0.0), vec3(0.7));
float right = max(s3, -b3);
return min(ground, min(left, min(center, right)));
}
vec3 getNormal(vec3 p) {
float e = 0.001;
return normalize(vec3(
scene(p + vec3(e, 0, 0)) - scene(p - vec3(e, 0, 0)),
scene(p + vec3(0, e, 0)) - scene(p - vec3(0, e, 0)),
scene(p + vec3(0, 0, e)) - scene(p - vec3(0, 0, e))
));
}
float raymarch(vec3 ro, vec3 rd) {
float t = 0.0;
for (int i = 0; i < 80; i++) {
vec3 p = ro + rd * t;
float d = scene(p);
if (d < 0.001) break;
if (t > 100.0) break;
t += d;
}
return t;
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
vec3 ro = vec3(0.0, 4.0, -8.0);
vec3 target = vec3(0.0, 1.0, 0.0);
vec3 fwd = normalize(target - ro);
vec3 rgt = normalize(cross(vec3(0, 1, 0), fwd));
vec3 up = cross(fwd, rgt);
vec3 rd = normalize(uv.x * rgt + uv.y * up + 1.5 * fwd);
float t = raymarch(ro, rd);
vec3 color = vec3(0.05, 0.05, 0.1);
if (t < 100.0) {
vec3 p = ro + rd * t;
vec3 n = getNormal(p);
vec3 ld = normalize(vec3(1.0, 1.0, -0.5));
float diff = max(dot(n, ld), 0.0);
color = vec3(0.8, 0.65, 0.5) * (diff + 0.12);
}
gl_FragColor = vec4(color, 1.0);
}
Three objects on a ground plane. Left: union -- a sphere and a box overlapping, both fully visible. Center: intersection -- only the volume where the sphere and box overlap survives, giving you a rounded cube shape. Right: subtraction -- the box is carved out of the sphere, leaving a sphere with a rectangular bite taken out of it.
The box SDF (sdBox) is new. It works similarly to the 2D version from episode 33 but in three dimensions. abs(p) - b exploits the box's symmetry -- we only compute the distance in one octant and abs handles the other seven. The length(max(q, 0.0)) part handles points outside the box (Euclidean distance to the nearest corner/edge), and the min(max(...), 0.0) handles points inside (signed distance to the nearest face).
Smooth boolean operations: organic blending
Hard booleans give sharp edges where shapes meet. That's fine for mechanical objects but looks unnatural for organic forms. The smooth minimum -- smin -- blends shapes together with a controllable radius, like they're made of soft clay that merges when they get close:
float smin(float a, float b, float k) {
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * k * 0.25;
}
This is the polynomial smooth minimum. k controls the blending radius -- bigger k means shapes start blending from further away, giving a wider fillet. At k = 0.0 it behaves exactly like min(). At k = 1.0 shapes melt together from about one unit away. The h * h * k * 0.25 term subtracts a small amount from the minimum distance in the blending zone, which creates that rounded, organic transition between shapes.
We used smin back in episode 33 for 2D shapes. It works identically in 3D because it operates on scalar distance values, not on the coordinates themselves. You pass in two distances and get back a smoothly blended distance. Dimension doesn't matter.
float scene(vec3 p) {
float ground = sdPlane(p, 0.0);
// two spheres smoothly blended
float s1 = sdSphere(p - vec3(-0.6, 1.0, 0.0), 0.8);
float s2 = sdSphere(p - vec3(0.6, 1.0, 0.0), 0.8);
float blended = smin(s1, s2, 0.5);
return min(ground, blended);
}
Two spheres, slightly overlapping, with k = 0.5. Instead of the hard crease you'd get from min(), the junction between them has a smooth, organic curve. Like a snowman that's starting to melt. Or two soap bubbles merging. The fillet radius is visually about half a unit wide.
Try changing k to different values. At 0.1, the blending is barely noticable -- just a tiny rounding at the junction. At 1.0, the two spheres almost merge into an egg shape. At 2.0, they're completely absorbed into one big blob. The k parameter is your main creative lever.
You can build smooth versions of intersection and subtraction too:
float smax(float a, float b, float k) {
float h = max(k - abs(a - b), 0.0) / k;
return max(a, b) + h * h * k * 0.25;
}
// smooth intersection
float opSmoothIntersection(float d1, float d2, float k) {
return smax(d1, d2, k);
}
// smooth subtraction
float opSmoothSubtraction(float d1, float d2, float k) {
return smax(d1, -d2, k);
}
Same principle. smax is smin but for maximum instead of minimum. Smooth intersection rounds the edges where the two shapes' surfaces meet. Smooth subtraction rounds the edges of the carved-out region. Every hard boolean has a smooth counterpart.
Morphing between shapes
Here's something you can't easily do with mesh-based rendering. Linear interpolation between two distance fields morphs one shape into another:
float morph(float d1, float d2, float t) {
return mix(d1, d2, t);
}
That's it. mix(d1, d2, t) is d1 * (1.0 - t) + d2 * t. At t = 0.0 you get pure shape 1. At t = 1.0 you get pure shape 2. In between, you get something that smoothly transitions between them. A sphere melting into a box. A cylinder becoming a torus. Whatever two SDFs you have.
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
float sdSphere(vec3 p, float r) {
return length(p) - r;
}
float sdBox(vec3 p, vec3 b) {
vec3 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}
float sdPlane(vec3 p, float h) {
return p.y - h;
}
float scene(vec3 p) {
float ground = sdPlane(p, 0.0);
vec3 objP = p - vec3(0.0, 1.2, 0.0);
float sphere = sdSphere(objP, 1.0);
float box = sdBox(objP, vec3(0.8));
// morph: oscillate between sphere and box
float t = sin(u_time * 0.8) * 0.5 + 0.5;
float shape = mix(sphere, box, t);
return min(ground, shape);
}
vec3 getNormal(vec3 p) {
float e = 0.001;
return normalize(vec3(
scene(p + vec3(e, 0, 0)) - scene(p - vec3(e, 0, 0)),
scene(p + vec3(0, e, 0)) - scene(p - vec3(0, e, 0)),
scene(p + vec3(0, 0, e)) - scene(p - vec3(0, 0, e))
));
}
float raymarch(vec3 ro, vec3 rd) {
float t = 0.0;
for (int i = 0; i < 80; i++) {
vec3 p = ro + rd * t;
float d = scene(p);
if (d < 0.001) break;
if (t > 100.0) break;
t += d;
}
return t;
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
float angle = u_time * 0.2;
vec3 ro = vec3(sin(angle) * 4.0, 2.5, cos(angle) * 4.0);
vec3 target = vec3(0.0, 1.0, 0.0);
vec3 fwd = normalize(target - ro);
vec3 rgt = normalize(cross(vec3(0, 1, 0), fwd));
vec3 up = cross(fwd, rgt);
vec3 rd = normalize(uv.x * rgt + uv.y * up + 1.2 * fwd);
float dist = raymarch(ro, rd);
vec3 color = vec3(0.05, 0.05, 0.1);
if (dist < 100.0) {
vec3 p = ro + rd * dist;
vec3 n = getNormal(p);
vec3 ld = normalize(vec3(0.8, 1.0, -0.6));
float diff = max(dot(n, ld), 0.0);
vec3 mat = p.y < 0.01 ?
vec3(0.3 + 0.15 * mod(floor(p.x) + floor(p.z), 2.0)) :
vec3(0.7, 0.4, 0.3);
color = mat * (diff + 0.12);
}
gl_FragColor = vec4(color, 1.0);
}
Camera orbiting, a shape that breathes between sphere and box. The sin(u_time * 0.8) * 0.5 + 0.5 drives t from 0 to 1 and back, so the shape oscillates. When it's a sphere, the edges are perfectly round. When it's a box, the edges are perfectly sharp. In between, it's this organic rounded-cube thing with soft corners that tighten up as it approaches box form.
This works because the SDF contract is preserved during interpolation. Both sdSphere and sdBox return signed distances. A weighted average of two signed distances is still a reasonably accurate signed distance (not mathematically exact, but close enough for raymarching to converge). The raymarcher doesn't care what shape it's tracing -- it just follows the distance gradient. If the gradient smoothly transitions from spherical to cubic, the rendered surface transitions smoothly too.
Domain repetition: infinite copies from one SDF
This is one of my favorite tricks in all of shader art. You can create an infinite grid of objects by applying mod to the position before evaluating the SDF:
float scene(vec3 p) {
float ground = sdPlane(p, 0.0);
// repeat the position every 3 units on X and Z
vec3 q = p;
q.xz = mod(q.xz + 1.5, 3.0) - 1.5;
float sphere = sdSphere(q - vec3(0.0, 0.8, 0.0), 0.5);
return min(ground, sphere);
}
mod(q.xz + 1.5, 3.0) - 1.5 wraps the XZ position into a repeating cell of size 3.0. The + 1.5 and - 1.5 center the cell around the origin so the object sits in the middle of each repetition. The sphere SDF is evaluated once, but because the position wraps, the same sphere appears at every grid point -- infinitely in all directions.
An infinite field of spheres from five extra characters. With a mesh renderer, you'd need to instantiate hundreds or thousands of sphere objects. With raymarching, one mod call handles the entire infinite grid. The GPU doesn't know or care that there are "infinite" objects. It only evaluates the SDF at the points along each ray, and at each point, mod folds the position into one representative cell.
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
float sdSphere(vec3 p, float r) {
return length(p) - r;
}
float sdPlane(vec3 p, float h) {
return p.y - h;
}
float scene(vec3 p) {
float ground = sdPlane(p, 0.0);
vec3 q = p;
q.xz = mod(q.xz + 1.5, 3.0) - 1.5;
// vary sphere size with position
float size = 0.3 + 0.2 * sin(p.x * 0.7 + u_time) * sin(p.z * 0.5 + u_time * 0.6);
float sphere = sdSphere(q - vec3(0.0, 0.5 + size, 0.0), size);
return min(ground, sphere);
}
vec3 getNormal(vec3 p) {
float e = 0.001;
return normalize(vec3(
scene(p + vec3(e, 0, 0)) - scene(p - vec3(e, 0, 0)),
scene(p + vec3(0, e, 0)) - scene(p - vec3(0, e, 0)),
scene(p + vec3(0, 0, e)) - scene(p - vec3(0, 0, e))
));
}
float raymarch(vec3 ro, vec3 rd) {
float t = 0.0;
for (int i = 0; i < 100; i++) {
vec3 p = ro + rd * t;
float d = scene(p);
if (d < 0.001) break;
if (t > 50.0) break;
t += d;
}
return t;
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
float angle = u_time * 0.15;
vec3 ro = vec3(sin(angle) * 8.0, 4.0, cos(angle) * 8.0);
vec3 target = vec3(0.0, 0.5, 0.0);
vec3 fwd = normalize(target - ro);
vec3 rgt = normalize(cross(vec3(0, 1, 0), fwd));
vec3 up = cross(fwd, rgt);
vec3 rd = normalize(uv.x * rgt + uv.y * up + 1.2 * fwd);
float t = raymarch(ro, rd);
vec3 color = vec3(0.06, 0.06, 0.12);
if (t < 50.0) {
vec3 p = ro + rd * t;
vec3 n = getNormal(p);
vec3 ld = normalize(vec3(0.8, 1.0, -0.5));
float diff = max(dot(n, ld), 0.0);
vec3 mat = p.y < 0.01 ?
vec3(0.25 + 0.15 * mod(floor(p.x) + floor(p.z), 2.0)) :
vec3(0.75, 0.35, 0.25);
color = mat * (diff + 0.1);
color = mix(color, vec3(0.06, 0.06, 0.12), 1.0 - exp(-0.06 * t));
}
gl_FragColor = vec4(color, 1.0);
}
An infinite grid of pulsing spheres, each one breathing at a slightly different rate because the size depends on the original (pre-mod) position. The sin(p.x * 0.7 + u_time) uses p.x not q.x -- so even though every cell looks at the same local coordinates via mod, the size variation comes from the global position. Cells at x = 0 pulse differently from cells at x = 3 or x = -6. The wave propagates across the field.
The distance fog at the end (mix with exp(-0.06 * t)) hides the far repetitions so the infinite grid fades into the background naturally rather than just vanishing at the max distance cutoff.
Limited repetition: finite grids
Infinite repetition is cool but sometimes you want a specific number of copies. Clamp the repetition index:
float scene(vec3 p) {
float ground = sdPlane(p, 0.0);
// clamp to 5x5 grid
float spacing = 2.0;
vec3 q = p;
q.xz = q.xz - spacing * clamp(floor(q.xz / spacing + 0.5), -2.0, 2.0);
float sphere = sdSphere(q - vec3(0.0, 0.6, 0.0), 0.4);
return min(ground, sphere);
}
The floor(q.xz / spacing + 0.5) computes which grid cell we're in. The clamp(..., -2.0, 2.0) limits the cell index to -2 through 2 -- a 5x5 grid. Outside that range, the position doesn't get folded back to a cell center, so the SDF evaluates at the actual position and returns a large distance. No object appears. Inside the range, it works exactly like infinite repetition.
This pattern shows up constantly in creative raymarching. You can create architectural structures -- columns in a hallway, windows on a building facade, tiles on a floor -- all from a single SDF evaluated in a clamped repitition domain. Change the clamp limits and you change the number of columns. Change the spacing and you change the architecture's proportions.
Twist deformation
Now we leave boolean territory and enter space deformation. The idea: instead of changing the SDF function itself, we change the coordinate space before evaluating it. Twist the coordinates around an axis, and a straight object becomes twisted.
vec3 opTwist(vec3 p, float amount) {
float angle = p.y * amount;
float c = cos(angle);
float s = sin(angle);
vec2 xz = vec2(c * p.x - s * p.z, s * p.x + c * p.z);
return vec3(xz.x, p.y, xz.y);
}
This rotates the XZ plane by an angle that depends on Y. At the bottom (p.y = 0), no rotation. Higher up, more rotation. The effect: a vertical box becomes a twisted column. A cylinder becomes a barber pole. The rotation accumulates along the Y axis.
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
float sdBox(vec3 p, vec3 b) {
vec3 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}
float sdPlane(vec3 p, float h) {
return p.y - h;
}
vec3 opTwist(vec3 p, float amount) {
float angle = p.y * amount;
float c = cos(angle);
float s = sin(angle);
vec2 xz = vec2(c * p.x - s * p.z, s * p.x + c * p.z);
return vec3(xz.x, p.y, xz.y);
}
float scene(vec3 p) {
float ground = sdPlane(p, 0.0);
// twisted box
vec3 tp = opTwist(p - vec3(0.0, 1.5, 0.0), sin(u_time * 0.5) * 2.0);
float box = sdBox(tp, vec3(0.5, 1.5, 0.5));
return min(ground, box);
}
vec3 getNormal(vec3 p) {
float e = 0.001;
return normalize(vec3(
scene(p + vec3(e, 0, 0)) - scene(p - vec3(e, 0, 0)),
scene(p + vec3(0, e, 0)) - scene(p - vec3(0, e, 0)),
scene(p + vec3(0, 0, e)) - scene(p - vec3(0, 0, e))
));
}
float raymarch(vec3 ro, vec3 rd) {
float t = 0.0;
for (int i = 0; i < 100; i++) {
vec3 p = ro + rd * t;
float d = scene(p);
if (d < 0.001) break;
if (t > 50.0) break;
t += d;
}
return t;
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
float angle = u_time * 0.25;
vec3 ro = vec3(sin(angle) * 5.0, 3.0, cos(angle) * 5.0);
vec3 target = vec3(0.0, 1.5, 0.0);
vec3 fwd = normalize(target - ro);
vec3 rgt = normalize(cross(vec3(0, 1, 0), fwd));
vec3 up = cross(fwd, rgt);
vec3 rd = normalize(uv.x * rgt + uv.y * up + 1.2 * fwd);
float dist = raymarch(ro, rd);
vec3 color = vec3(0.05, 0.05, 0.1);
if (dist < 50.0) {
vec3 p = ro + rd * dist;
vec3 n = getNormal(p);
vec3 ld = normalize(vec3(1.0, 1.0, -0.5));
float diff = max(dot(n, ld), 0.0);
vec3 mat = p.y < 0.01 ?
vec3(0.3 + 0.15 * mod(floor(p.x) + floor(p.z), 2.0)) :
vec3(0.6, 0.5, 0.8);
color = mat * (diff + 0.12);
}
gl_FragColor = vec4(color, 1.0);
}
A box that twists back and forth over time. The sin(u_time * 0.5) * 2.0 varies the twist amount, so it goes from twisted one way to twisted the other. At amount = 0 the box is perfectly straight. At amount = 2.0, the top is rotated about 170 degrees relative to the bottom. The shape is always a box -- we never changed the sdBox function. We just rotated the input coordinates differently at every height.
Important caveat: space deformations break the Lipschitz condition of the distance field. After twisting, the distance returned by sdBox might be slightly wrong -- the real distance to the surface could be shorter than what the function reports. This means the raymarcher might step too far and miss thin features. For moderate deformations this is rarely a problem. For extreme twists you might need to multiply the step size by a safety factor (like 0.5) to take more conservative steps:
t += d * 0.7; // 70% of the reported distance, for safety
Bend deformation
Bending is similar to twisting but applies rotation based on position along a different axis. A straight bar becomes an arc:
vec3 opBend(vec3 p, float amount) {
float angle = p.x * amount;
float c = cos(angle);
float s = sin(angle);
vec2 xy = vec2(c * p.x - s * p.y, s * p.x + c * p.y);
return vec3(xy.x, xy.y, p.z);
}
This rotates the XY plane based on the X position. The left end of the object curves down, the right end curves up (or vice versa, depending on the sign of amount). A flat slab becomes a U-shape. A long box becomes a bridge arch.
The principle is the same as twist: change the coordinate space, not the shape. And the same Lipschitz warning applies -- the distance estimate may be slightly wrong for large bend amounts, so use a conservative step multiplier if needed.
Displacement: adding surface detail
Displacement adds detail to a surface by modifying the distance value itself. Instead of deforming space, you add noise directly to the SDF output:
float displace(vec3 p, float d) {
float noise = sin(p.x * 5.0) * sin(p.y * 5.0) * sin(p.z * 5.0) * 0.1;
return d + noise;
}
The sin * sin * sin creates a 3D bumpy pattern. Adding it to the distance field pushes the surface in and out by 0.1 units. A smooth sphere becomes a bumpy asteroid. A flat plane becomes a rolling terrain.
float scene(vec3 p) {
float ground = sdPlane(p, 0.0);
vec3 sp = p - vec3(0.0, 1.5, 0.0);
float sphere = sdSphere(sp, 1.0);
// displace the surface with multi-frequency sine
float disp = sin(sp.x * 8.0 + u_time) * sin(sp.y * 6.0) * sin(sp.z * 7.0) * 0.08;
disp += sin(sp.x * 15.0) * sin(sp.y * 13.0 + u_time * 0.5) * sin(sp.z * 14.0) * 0.03;
float displaced = sphere + disp;
return min(ground, displaced);
}
Two layers of sine-based displacement at different frequencies. The first layer at frequency 6-8 creates large bumps. The second layer at frequency 13-15 adds fine detail. Together they produce a rocky, organic-looking surface that shifts over time. This is the same principle behind the layered noise we built in episode 12 and ported to shaders in episode 35 -- multiple octaves of detail stacked at different scales. Here we're using simple sine products instead of proper Perlin noise for speed, but the visual principle is identical.
You could substitute a real noise function (like the gradient noise from episode 35) for more natural-looking displacement. The sine product creates a regular, crystal-lattice-like pattern. Noise creates irregular, organic bumps. Both have their uses.
Creative exercise: alien landscape
Let's combine everything from this episode into one scene. Smooth boolean operations, repetition, twist deformation, displacement. An alien landscape from pure math:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
float sdSphere(vec3 p, float r) { return length(p) - r; }
float sdBox(vec3 p, vec3 b) {
vec3 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}
float sdPlane(vec3 p, float h) { return p.y - h; }
float smin(float a, float b, float k) {
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * k * 0.25;
}
vec3 opTwist(vec3 p, float k) {
float c = cos(p.y * k);
float s = sin(p.y * k);
return vec3(c * p.x - s * p.z, p.y, s * p.x + c * p.z);
}
vec2 scene(vec3 p) {
// ground with displacement
float ground = p.y + sin(p.x * 0.5) * sin(p.z * 0.7) * 0.3;
// twisted pillars (repeated)
vec3 pillarP = p;
pillarP.xz = mod(pillarP.xz + 3.0, 6.0) - 3.0;
vec3 twisted = opTwist(pillarP, 0.5 + sin(u_time * 0.3) * 0.3);
float pillar = sdBox(twisted, vec3(0.3, 2.5, 0.3));
// floating blob (smooth union of 3 spheres)
float blob1 = sdSphere(p - vec3(sin(u_time * 0.4) * 2.0, 3.0, cos(u_time * 0.3) * 2.0), 0.7);
float blob2 = sdSphere(p - vec3(sin(u_time * 0.4 + 2.0) * 1.5, 3.5, cos(u_time * 0.3 + 1.5) * 1.8), 0.5);
float blob3 = sdSphere(p - vec3(sin(u_time * 0.4 + 4.0) * 1.8, 2.8, cos(u_time * 0.3 + 3.0) * 2.2), 0.6);
float blob = smin(smin(blob1, blob2, 0.8), blob3, 0.6);
// displacement on blob
vec3 bp = p * 6.0 + u_time * 0.5;
blob += sin(bp.x) * sin(bp.y) * sin(bp.z) * 0.04;
// combine
vec2 res = vec2(ground, 0.0);
if (pillar < res.x) res = vec2(pillar, 1.0);
float blobResult = blob;
if (blobResult < res.x) res = vec2(blobResult, 2.0);
return res;
}
vec3 getNormal(vec3 p) {
float e = 0.001;
return normalize(vec3(
scene(p + vec3(e, 0, 0)).x - scene(p - vec3(e, 0, 0)).x,
scene(p + vec3(0, e, 0)).x - scene(p - vec3(0, e, 0)).x,
scene(p + vec3(0, 0, e)).x - scene(p - vec3(0, 0, e)).x
));
}
vec2 raymarch(vec3 ro, vec3 rd) {
float t = 0.0;
float id = -1.0;
for (int i = 0; i < 120; i++) {
vec3 p = ro + rd * t;
vec2 res = scene(p);
if (res.x < 0.001) { id = res.y; break; }
if (t > 60.0) break;
t += res.x * 0.8;
}
return vec2(t, id);
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
float a = u_time * 0.12;
vec3 ro = vec3(sin(a) * 10.0, 4.0, cos(a) * 10.0);
vec3 target = vec3(0.0, 2.0, 0.0);
vec3 fwd = normalize(target - ro);
vec3 rgt = normalize(cross(vec3(0, 1, 0), fwd));
vec3 up = cross(fwd, rgt);
vec3 rd = normalize(uv.x * rgt + uv.y * up + 1.0 * fwd);
vec2 hit = raymarch(ro, rd);
vec3 color = vec3(0.02, 0.02, 0.06);
if (hit.x < 60.0) {
vec3 p = ro + rd * hit.x;
vec3 n = getNormal(p);
vec3 ld = normalize(vec3(0.6, 1.0, -0.4));
float diff = max(dot(n, ld), 0.0);
vec3 mat;
if (hit.y < 0.5) {
mat = vec3(0.25, 0.2, 0.15);
} else if (hit.y < 1.5) {
mat = vec3(0.5, 0.45, 0.6);
} else {
mat = vec3(0.8, 0.3, 0.2);
}
color = mat * (diff + 0.08);
color = mix(color, vec3(0.02, 0.02, 0.06), 1.0 - exp(-0.04 * hit.x));
}
gl_FragColor = vec4(color, 1.0);
}
A displaced ground surface with rolling hills. Twisted box pillars repeating infinitely across the landscape (the mod repetition). Three spheres smooth-blended into a floating organic blob with surface displacement adding texture. Distance fog fading everything into dark blue at the horizon. The camera orbits slowly, revealing the scene from all angles.
The * 0.8 in the raymarching step is the safety factor I mentioned -- the twist and displacement deformations break the exact distance guarantee, so we take slightly conservative steps to avoid artifacts. For a scene this complex, bumping the iteration count to 120 and adding that safety margin keeps things clean.
Modify it. Change the pillar spacing. Make the blob bigger. Add more spheres to the smooth union. Change the displacement frequency. Make the ground rougher. Every parameter tweak changes the world, and you can explore faster than I can describe possibilities.
A word on SDF correctness
I want to be honest about something. Not all the operations we covered today produce mathematically exact distance fields. The smooth minimum, for instance, returns a value that's slightly smaller than the true shortest distance in the blending zone. Displacement by adding noise changes the distance by the noise amplitude, which may overstate how far the surface actually is in some directions. Twist and bend deformations warp the distance metric so the returned value might be too large or too small.
For raymarching, "close enough" usually works. The raymarcher just needs the distance to be a reasonable underestimate of the true distance -- as long as it never reports a distance that's longer than the real distance, it won't step through surfaces. Most of these operations maintain that property approximately. When they don't (large deformations, aggressive displacement), the safety factor (t += d * 0.7 instead of t += d) compensates.
If you're doing precise modeling (like 3D printing from SDFs), exactness matters and you'd need more careful formulations. For creative coding and real-time rendering, the approximations are fine. You'll see artifacts before you see math errors, and artifacts are fixable with more iterations or smaller steps.
Where this leads
We've added a massive toolkit today. Boolean operations let you build complex shapes from simple primitives. Smooth booleans let you sculpt organic forms. Morphing lets you animate between any two shapes. Repetition creates structure from nothing. Deformations -- twist, bend, displacement -- add life and detail to static geometry.
In the next episodes we'll add realistic materials and lighting -- specular highlights, shadows that are actually computed by marching a second ray toward the light, and ambient occlusion. The geometry vocabulary we built today combined with proper lighting will let us create scenes that look genuinely photorealistic, all from a fragment shader.
And later, fractals. The Mandelbrot and Julia sets, rendered in 3D via raymarching. Those are essentially infinitely detailed SDFs -- the same marching algorithm, just with a distance estimator derived from fractal math. The code structure stays identical. The visuals become... something else entirely :-)
't Komt erop neer...
- Boolean union:
min(d1, d2)-- both shapes visible, closest surface wins - Boolean intersection:
max(d1, d2)-- only the overlapping volume of both shapes survives - Boolean subtraction:
max(d1, -d2)-- negate the second SDF to carve it out of the first - Smooth minimum (
smin): blends shapes with an organic fillet controlled by parameterk. Works identically in 2D and 3D - Morphing:
mix(sdf1, sdf2, t)-- linearly interpolate between two distance fields to smoothly transform one shape into another - Domain repetition:
mod(p + half, spacing) - halfcreates infinite copies of an object. Clamp the cell index for finite grids - Twist deformation: rotate the XZ plane by an angle proportional to Y position. Straight objects become twisted columns
- Bend deformation: rotate XY based on X position to curve straight objects into arcs
- Displacement: add noise or sine patterns directly to the SDF output for surface detail
- Space deformations (twist, bend) may break exact distance guarantees -- use a safety factor (
t += d * 0.7) for reliable raymarching
Sallukes! Thanks for reading.
X
Congratulations @femdev! You have completed the following achievement on the Hive blockchain And have been rewarded with New badge(s)
You can view your badges on your board and compare yourself to others in the Ranking
If you no longer want to receive notifications, reply to this comment with the word
STOP