Distance Fields

Core Concept: Mathematical shape definition. Work with signed distance functions, create UI elements with SDFs, scalable shader graphics, and shape combination. Challenge: Morphing UI elements with pressure sensitivity.

The Mathematical Revolution in Computer Graphics

Every vector graphics system, every font renderer, every modern UI framework has a secret. They've abandoned the ancient approach of "draw lines between points" for something mathematically elegant: describing shapes as mathematical equations.

But here's what breaks most people's brains: Distance fields don't store shapes as collections of vertices or pixels. They store shapes as functions that answer one question: "How far am I from the edge?"

This single insight has revolutionized computer graphics, enabled infinite zoom without pixelation, and powers the smooth UI effects you see every day without realizing it.

The Aha Moment: Shape as Mathematics

Stop thinking about shapes as things you draw. Start thinking about them as regions in space defined by mathematical rules.

// Traditional thinking: "Draw a circle by plotting points"
for (angle = 0; angle < 2π; angle += step) {
    x = centerX + radius * cos(angle);
    y = centerY + radius * sin(angle);
    plot(x, y);
}

// Distance field thinking: "A circle is all points distance R from center"
float distanceToCircle(float2 point, float2 center, float radius) {
    return length(point - center) - radius;
    // Negative = inside, Positive = outside, Zero = exactly on edge
}

Why this changes everything: The distance field approach works at any resolution, can be combined mathematically, and enables effects impossible with traditional graphics.

Mathematical Foundation: The Language of Distance

Signed Distance Functions (SDFs)

A Signed Distance Function returns:

  • Negative values: Inside the shape
  • Positive values: Outside the shape
  • Zero: Exactly on the boundary
// Circle SDF - the fundamental building block
float circleSDF(float2 point, float2 center, float radius) {
    // Calculate distance from point to center
    float distanceToCenter = length(point - center);
    
    // Subtract radius: 
    // - If we're closer than radius → negative (inside)
    // - If we're farther than radius → positive (outside)
    return distanceToCenter - radius;
}

// Rectangle SDF - trickier but more useful
float rectangleSDF(float2 point, float2 center, float2 size) {
    // Translate so rectangle is centered at origin
    float2 translated = point - center;
    
    // Work in absolute space (first quadrant only)
    float2 absPoint = abs(translated);
    
    // Distance to rectangle corner
    float2 halfSize = size * 0.5;
    float2 cornerDistance = absPoint - halfSize;
    
    // Outside distance: how far we are from the corner
    float outsideDistance = length(max(cornerDistance, 0.0));
    
    // Inside distance: how far we are from the nearest edge
    float insideDistance = min(max(cornerDistance.x, cornerDistance.y), 0.0);
    
    return outsideDistance + insideDistance;
}

Your First Distance Field Shader

[[ stitchable ]] half4 sdfCircle(
    float2 position, 
    half4 color, 
    float2 size,
    float radius        // Circle radius (0.0 to 0.5)
) {
    // Convert to UV coordinates
    float2 uv = position / size;
    
    // Center the coordinate system
    float2 centered = uv - 0.5;
    
    // Calculate signed distance to circle
    float distance = length(centered) - radius;
    
    // Convert distance to color
    // Method 1: Hard edge using step
    float hardEdge = step(0.0, -distance);  // 1 inside, 0 outside
    
    // Method 2: Smooth edge using smoothstep
    float pixelWidth = fwidth(distance);    // How fast distance changes per pixel
    float smoothEdge = 1.0 - smoothstep(-pixelWidth, pixelWidth, distance);
    
    // Method 3: Visualize the distance field itself
    float distanceViz = distance * 0.5 + 0.5;  // Remap to 0-1 for visualization
    
    // Choose which visualization to return
    float result = smoothEdge;  // Use smooth edge for final result
    
    return half4(result, result, result, 1.0);
}

The Power of Boolean Operations

The real magic happens when you combine distance fields. Traditional graphics requires complex algorithms to merge shapes. Distance fields use simple math:

// Boolean operations on distance fields
float unionSDF(float d1, float d2) {
    return min(d1, d2);    // Closest surface wins
}

float intersectionSDF(float d1, float d2) {
    return max(d1, d2);    // Farthest surface wins
}

float differenceSDF(float d1, float d2) {
    return max(d1, -d2);   // Remove d2 from d1
}

// Smooth versions for organic blending
float smoothUnion(float d1, float d2, float smoothness) {
    float h = max(smoothness - abs(d1 - d2), 0.0) / smoothness;
    return min(d1, d2) - h * h * smoothness * 0.25;
}

float smoothSubtraction(float d1, float d2, float smoothness) {
    float h = max(smoothness - abs(-d1 - d2), 0.0) / smoothness;
    return max(-d1, d2) + h * h * smoothness * 0.25;
}

float smoothIntersection(float d1, float d2, float smoothness) {
    float h = max(smoothness - abs(d1 - d2), 0.0) / smoothness;
    return max(d1, d2) + h * h * smoothness * 0.25;
}

Combining Shapes Demo

[[ stitchable ]] half4 sdfBooleanOps(
    float2 position, 
    half4 color, 
    float2 size,
    float operation,    // 0=union, 1=intersection, 2=difference
    float smoothness    // 0=hard, 0.1=smooth
) {
    float2 uv = position / size;
    float2 centered = uv - 0.5;
    
    // Define two overlapping shapes
    float circle1 = length(centered - float2(-0.1, 0.0)) - 0.15;  // Left circle
    float circle2 = length(centered - float2(0.1, 0.0)) - 0.15;   // Right circle
    
    float result = 0.0;
    
    if (operation < 0.5) {
        // Union: combine both shapes
        if (smoothness > 0.001) {
            result = smoothUnion(circle1, circle2, smoothness);
        } else {
            result = min(circle1, circle2);
        }
    } else if (operation < 1.5) {
        // Intersection: only where both shapes overlap
        if (smoothness > 0.001) {
            result = smoothIntersection(circle1, circle2, smoothness);
        } else {
            result = max(circle1, circle2);
        }
    } else {
        // Difference: subtract right circle from left circle
        if (smoothness > 0.001) {
            result = smoothSubtraction(circle2, circle1, smoothness);
        } else {
            result = max(circle1, -circle2);
        }
    }
    
    // Convert SDF to visible shape
    float pixelWidth = fwidth(result);
    float shape = 1.0 - smoothstep(-pixelWidth, pixelWidth, result);
    
    return half4(shape, shape, shape, 1.0);
}

Advanced Shape Library

Star SDF

float starSDF(float2 point, float2 center, float radius, int points) {
    float2 p = point - center;
    
    // Convert to polar coordinates
    float angle = atan2(p.y, p.x);
    float distance = length(p);
    
    // Create star pattern by modulating radius
    float angleStep = 6.28318530718 / float(points);  // 2π divided by number of points
    
    // Use fmod for proper modulo operation, then center the range
    float sectorAngle = fmod(angle + 3.14159265359, angleStep) - angleStep * 0.5;
    
    // Inner and outer radius for star points
    float outerRadius = radius;
    float innerRadius = radius * 0.5;
    
    // Distance varies based on angle - creates star points
    // Use absolute value for symmetry within each sector
    float t = abs(sectorAngle) / (angleStep * 0.5);
    float starRadius = mix(outerRadius, innerRadius, t);
    
    return distance - starRadius;
}

Rounded Rectangle SDF

float roundedRectSDF(float2 point, float2 center, float2 size, float cornerRadius) {
    // Translate to center
    float2 p = abs(point - center);
    
    // Shrink rectangle by corner radius
    float2 halfSize = size * 0.5;
    float2 shrunkSize = halfSize - cornerRadius;
    
    // Distance to the shrunken rectangle's corner
    float2 cornerDist = p - shrunkSize;
    
    // Outside the shrunken rectangle: distance to rounded corner
    float outsideDist = length(max(cornerDist, 0.0));
    
    // Inside the shrunken rectangle: distance to nearest edge
    float insideDist = min(max(cornerDist.x, cornerDist.y), 0.0);
    
    // Subtract corner radius to account for rounding
    return outsideDist + insideDist - cornerRadius;
}

Heart SDF (Because Why Not?)

float heartSDF(float2 point, float2 center, float scale) {
    // Translate and scale
    float2 p = (point - center) / scale;
    
    // Heart equation: (x²+y²-1)³ - x²y³ = 0
    // We solve for the distance to this curve
    
    // Flip Y to make heart right-side up
    p.y = -p.y;
    
    // Heart math (this is complex but beautiful)
    float x = p.x;
    float y = p.y - 0.5;  // Adjust center
    
    // Approximate distance to heart curve
    float heartEq = pow(x*x + y*y - 1.0, 3.0) - x*x * y*y*y;
    
    // Convert equation result to approximate distance
    float distance = heartEq / (3.0 * pow(x*x + y*y, 1.5) + 1.0);
    
    return distance * scale;
}

Transformation Operations

Distance fields can be transformed just like any coordinate system:

// Rotation transformation
float2 rotate(float2 point, float angle) {
    float s = sin(angle);
    float c = cos(angle);
    return float2(
        point.x * c - point.y * s,
        point.x * s + point.y * c
    );
}

// Scale transformation  
float2 scale(float2 point, float factor) {
    return point / factor;  // Note: division for scaling in SDF space
}

// Example: Rotating star
[[ stitchable ]] half4 rotatingStarSDF(
    float2 position, 
    half4 color, 
    float2 size,
    float time,         // Animation time
    float rotationSpeed // How fast it rotates
) {
    float2 uv = position / size;
    float2 centered = uv - 0.5;
    
    // Rotate the coordinate system
    float angle = time * rotationSpeed;
    float2 rotatedPoint = rotate(centered, angle);
    
    // Calculate star distance in rotated space
    float starDist = starSDF(rotatedPoint, float2(0.0, 0.0), 0.2, 5);
    
    // Render with smooth edges
    float pixelWidth = fwidth(starDist);
    float star = 1.0 - smoothstep(-pixelWidth, pixelWidth, starDist);
    
    // Add color based on distance for depth effect
    float innerGlow = 1.0 - smoothstep(-0.15, -0.05, starDist);
    half3 starColor = mix(
        half3(1.0, 0.8, 0.2),  // Golden edge
        half3(1.0, 1.0, 0.9),  // Bright center
        innerGlow
    );
    
    return half4(starColor * star, star);
}

Complex Scene Construction

[[ stitchable ]] half4 sdfScene(
    float2 position,
    half4 color,
    float2 size,
    float time
) {
    float2 uv = position / size;
    float2 p = (uv - 0.5) * 2.0;  // -1 to 1 range
    p.y = -p.y;
    // Head shape - rounded square for a friendlier look
    float2 headSize = float2(0.5, 0.55);
    float2 d = abs(p) - headSize;
    float head = length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - 0.15;
    
    // Eyes - not holes, but friendly ovals
    float eyeSquish = 0.8; // Make eyes slightly oval
    float2 leftEyePos = p - float2(-0.18, 0.12);
    leftEyePos.y *= eyeSquish;
    float leftEye = length(leftEyePos) - 0.06;
    
    float2 rightEyePos = p - float2(0.18, 0.12);
    rightEyePos.y *= eyeSquish;
    float rightEye = length(rightEyePos) - 0.06;
    
    // Animated pupils that follow a gentle pattern
    float lookX = sin(time * 0.8) * 0.02;
    float lookY = cos(time * 1.2) * 0.01;
    
    float2 leftPupilPos = p - float2(-0.18 + lookX, 0.12 + lookY);
    float leftPupil = length(leftPupilPos) - 0.03;
    
    float2 rightPupilPos = p - float2(0.18 + lookX, 0.12 + lookY);
    float rightPupil = length(rightPupilPos) - 0.03;
    
    // Eye sparkles for life
    float2 leftSparklePos = p - float2(-0.20, 0.14);
    float leftSparkle = length(leftSparklePos) - 0.015;
    
    float2 rightSparklePos = p - float2(0.16, 0.14);
    float rightSparkle = length(rightSparklePos) - 0.015;
    
    // Nose - small and cute
    float2 nosePos = p - float2(0.0, 0.0);
    float nose = length(nosePos * float2(1.5, 1.0)) - 0.04;
    
    // Mouth - proper smile using SDF curve
    float2 mouthPos = p - float2(0.0, -0.15);
    
    // Create smile curve using parabola
    float mouthCurve = mouthPos.y + mouthPos.x * mouthPos.x * 2.0;
    float mouthWidth = 0.15 + sin(time * 2.0) * 0.03; // Gentle animation
    float mouth = abs(mouthCurve) - 0.02;
    
    // Limit mouth to center area
    if (abs(mouthPos.x) > mouthWidth) {
        mouth = 1000.0;
    }
    
    // Cheeks - animated blush circles
    float blushSize = 0.08 + sin(time * 3.0) * 0.02;
    float leftCheek = length(p - float2(-0.28, -0.05)) - blushSize;
    float rightCheek = length(p - float2(0.28, -0.05)) - blushSize;
    
    // Eyebrows for expression
    float2 leftBrowPos = p - float2(-0.18, 0.25);
    float leftBrow = abs(leftBrowPos.y - leftBrowPos.x * 0.3) - 0.015;
    if (abs(leftBrowPos.x) > 0.1) leftBrow = 1000.0;
    
    float2 rightBrowPos = p - float2(0.18, 0.25);
    float rightBrow = abs(rightBrowPos.y + rightBrowPos.x * 0.3) - 0.015;
    if (abs(rightBrowPos.x) > 0.1) rightBrow = 1000.0;
    
    // Build the face using proper layering
    float pixelWidth = length(fwidth(p)) * 0.5;
    
    // Start with head
    float faceShape = 1.0 - smoothstep(-pixelWidth, pixelWidth, head);
    half3 faceColor = half3(1.0, 0.9, 0.8); // Skin tone
    
    // Add eyes (white part)
    float eyeWhites = min(leftEye, rightEye);
    float eyeShape = 1.0 - smoothstep(-pixelWidth, pixelWidth, eyeWhites);
    faceColor = mix(faceColor, half3(1.0, 1.0, 1.0), eyeShape * faceShape);
    
    // Add pupils
    float pupils = min(leftPupil, rightPupil);
    float pupilShape = 1.0 - smoothstep(-pixelWidth, pixelWidth, pupils);
    faceColor = mix(faceColor, half3(0.2, 0.3, 0.4), pupilShape * faceShape);
    
    // Add sparkles
    float sparkles = min(leftSparkle, rightSparkle);
    float sparkleShape = 1.0 - smoothstep(-pixelWidth * 0.5, pixelWidth * 0.5, sparkles);
    faceColor = mix(faceColor, half3(1.0, 1.0, 1.0), sparkleShape * faceShape);
    
    // Add nose
    float noseShape = 1.0 - smoothstep(-pixelWidth, pixelWidth, nose);
    faceColor = mix(faceColor, half3(0.9, 0.8, 0.7), noseShape * faceShape * 0.3);
    
    // Add mouth
    float mouthShape = 1.0 - smoothstep(-pixelWidth, pixelWidth, mouth);
    faceColor = mix(faceColor, half3(0.8, 0.4, 0.5), mouthShape * faceShape);
    
    // Add cheeks (blush with transparency)
    float cheeks = min(leftCheek, rightCheek);
    float cheekShape = 1.0 - smoothstep(-pixelWidth * 2.0, pixelWidth * 2.0, cheeks);
    faceColor = mix(faceColor, half3(1.0, 0.7, 0.8), cheekShape * faceShape * 0.4);
    
    // Add eyebrows
    float brows = min(leftBrow, rightBrow);
    float browShape = 1.0 - smoothstep(-pixelWidth, pixelWidth, brows);
    faceColor = mix(faceColor, half3(0.7, 0.6, 0.5), browShape * faceShape);
    
    // Background
    half3 bgColor = half3(0.3, 0.5, 0.7); // Soft blue
    half3 finalColor = mix(bgColor, faceColor, faceShape);
    
    // Add subtle rim light
    float rimLight = 1.0 - smoothstep(-0.02, 0.02, head);
    finalColor += half3(0.1, 0.1, 0.2) * rimLight * (1.0 - faceShape);
    
    return half4(finalColor, 1.0);
}

Performance Considerations

Optimization Techniques

// EXPENSIVE: Nested function calls
float complexSDF(float2 p) {
    float shape1 = circleSDF(p, float2(0.1, 0.1), 0.2);
    float shape2 = rectangleSDF(p, float2(-0.1, -0.1), float2(0.3, 0.3));
    float shape3 = starSDF(p, float2(0.0, 0.0), 0.15, 5);
    return min(min(shape1, shape2), shape3);
}

// OPTIMIZED: Inline simple operations
float optimizedSDF(float2 p) {
    // Inline circle calculation
    float circle = length(p - float2(0.1, 0.1)) - 0.2;
    
    // Inline rectangle calculation
    float2 rectP = abs(p - float2(-0.1, -0.1)) - float2(0.15, 0.15);
    float rect = length(max(rectP, 0.0)) + min(max(rectP.x, rectP.y), 0.0);
    
    // Only call complex function if needed
    float result = min(circle, rect);
    if (result > 0.1) return result;  // Early exit if we're far away
    
    float star = starSDF(p, float2(0.0, 0.0), 0.15, 5);
    return min(result, star);
}

Performance Hierarchy (Fast to Slow)

  1. Circle SDF: length(p) - r (fastest)
  2. Box SDF: Few operations, very fast
  3. Rounded shapes: Moderate cost
  4. Polygon SDFs: More expensive due to loops
  5. Smooth operations: Additional smoothstep calculations
  6. Complex shapes: Hearts, stars, custom curves

Common Pitfalls

Pitfall 1: Incorrect Distance Scaling

// WRONG: Distance doesn't scale properly with transformations
float scaledCircle = circleSDF(p * 2.0, center, radius);  // Distance is wrong!

// CORRECT: Account for scaling in distance calculation
float scaledCircle = circleSDF(p * 2.0, center, radius) / 2.0;  // Divide by scale factor

Pitfall 2: Anti-aliasing at Wrong Scale

// WRONG: Fixed anti-aliasing width
float shape = 1.0 - smoothstep(-0.01, 0.01, sdf);  // Breaks at different resolutions

// CORRECT: Resolution-independent anti-aliasing
float pixelWidth = fwidth(sdf);
float shape = 1.0 - smoothstep(-pixelWidth, pixelWidth, sdf);

Pitfall 3: Boolean Operation Confusion

// Common misunderstanding: thinking in terms of "adding" shapes
float wrong = shape1 + shape2;  // This doesn't make sense for SDFs

// Correct: Use proper SDF boolean operations
float union = min(shape1, shape2);           // Combine shapes
float intersection = max(shape1, shape2);    // Only where both exist
float difference = max(shape1, -shape2);     // Subtract shape2 from shape1

Pitfall 4: Performance Death by Complexity

// WRONG: Computing too many shapes every pixel
float result = 1000.0;
for (int i = 0; i < 100; i++) {  // 100 shapes per pixel!
    result = min(result, someSDF(p, i));
}

// CORRECT: Use spatial subdivision or early exits
if (roughDistance > 0.5) return roughDistance;  // Early exit when far away
// Only compute detailed SDF when close

Chapter 8 Challenges: Playing with Digital Clay

Challenge 1: Gooey Button Modifier

Create a gooeyBackground shader that transforms a button into a dripping honey-like object.

What You'll Learn:

  • How smooth distance field operations create organic merging effects
  • Implementing realistic gravity physics in shaders
  • Managing emergence animations to prevent visual artifacts
  • Working within shader constraints (color-only modifications)

Metal Shader Goal:

// Create a viscous dripping effect with:
// - Drops that emerge from within the button shape (not sudden spawning)
// - Gravity-based acceleration using physics formula: y = 0.5 * g * t²
// - Fixed X positions distributed across button width
// - Teardrop shapes that elongate based on fall velocity
// - Smooth merging with button and other drops (smooth union with viscosity)
// - Proper handling of canvas boundaries (skip drops, not fade)

SwiftUI Scaffolding:

struct GooeyButtonView: View {
    @State private var animationTime: Float = 0.0
    @State private var viscosity: Float = 0.1
    @State private var dropColor = Color.blue
    let timer = Timer.publish(every: 1/60.0, on: .main, in: .common).autoconnect()
    
    var body: some View {
        VStack(spacing: 30) {
            Text("Gooey Button Demo")
                .font(.title2)
                .padding()
            
            // The actual gooey button
            Button(action: { print("Gooey tap!") }) {
                Text("Tap Me")
                    .font(.title3)
                    .foregroundColor(.white)
                    .padding(.horizontal, 40)
                    .padding(.vertical, 20)
            }
            .background(
                RoundedRectangle(cornerRadius: 15)
                    .fill(dropColor)
                    .visualEffect { content, proxy in
                        content.colorEffect(ShaderLibrary.gooeyBackground(
                            .float2(proxy.size),
                            .float(animationTime),
                            .float(viscosity)
                        ))
                    }
            )
            
            // Controls
            VStack(alignment: .leading, spacing: 15) {
                HStack {
                    Text("Viscosity:")
                    Slider(value: $viscosity, in: 0.05...0.3)
                    Text("\(viscosity, specifier: "%.2f")")
                }
                
                HStack {
                    Text("Color:")
                    ColorPicker("", selection: $dropColor)
                        .labelsHidden()
                }
            }
            .padding()
            .background(Color.gray.opacity(0.1))
            .cornerRadius(10)
        }
        .onReceive(timer) { _ in
            animationTime += 1/60.0
        }
    }
}

Hints:

  • Use smoothUnion with a large smoothness value (0.1-0.3) for the gooey effect
  • Animate drops using sin(time + dropID) for organic movement
  • The puddle is just another circle with a larger radius at the bottom

Challenge 2: Lava Lamp Simulator

Create a lavaLamp shader that simulates the mesmerizing blob physics of a real lava lamp.

What You'll Learn: How to combine multiple animated SDFs with smooth operations to create complex, organic motion.

Metal Shader Goal:

// Create a lava lamp effect with:
// - 3-5 blobs that rise and fall at different speeds
// - Blobs that merge when close and split when moving apart
// - Heat distortion effect using sin waves
// - Color gradient based on blob temperature (height)

SwiftUI Scaffolding:

struct LavaLampView: View {
    @State private var animationTime: Float = 0.0
    @State private var temperature: Float = 0.5
    @State private var blobCount: Float = 3.0
    let timer = Timer.publish(every: 1/60.0, on: .main, in: .common).autoconnect()
    
    var body: some View {
        VStack {
            Text("Lava Lamp Simulator")
                .font(.title2)
                .padding()
            
            // Lava lamp container
            ZStack {
                // Glass container outline
                RoundedRectangle(cornerRadius: 50)
                    .stroke(Color.gray, lineWidth: 3)
                
                // The lava effect
                RoundedRectangle(cornerRadius: 47)
                    .fill(Color.black)
                    .visualEffect { content, proxy in
                        content.colorEffect(ShaderLibrary.lavaLamp(
                            .float2(proxy.size),
                            .float(animationTime),
                            .float(temperature),
                            .float(blobCount)
                        ))
                    }
                    .padding(3)
            }
            .frame(width: 200, height: 400)
            
            // Controls
            VStack(spacing: 15) {
                HStack {
                    Text("Heat: \(temperature, specifier: "%.1f")")
                    Slider(value: $temperature, in: 0.1...1.0)
                }
                
                HStack {
                    Text("Blobs: \(Int(blobCount))")
                    Slider(value: $blobCount, in: 2...5, step: 1)
                }
            }
            .padding()
        }
        .onReceive(timer) { _ in
            animationTime += 1/60.0 * temperature
        }
    }
}

Hints:

  • Each blob's Y position: 0.5 + 0.4 * sin(time * speed + phase)
  • Use different speeds for each blob to create natural movement
  • Smooth union parameter should vary with distance between blobs
  • Add slight horizontal wobble with sin(time * frequency + y)

Challenge 3: Magnetic Blob Field

Create a magneticBlobs shader that renders multiple draggable blobs that merge smoothly when they get close, like magnetic fluid or mercury droplets.

What You'll Learn: How to use SDF smooth minimum (smin) to create organic, gooey connections between shapes.

Metal Shader Goal:

// Create an effect where:
// - Up to 4 circular blobs can be positioned on screen
// - Blobs merge smoothly when distance < ~100 pixels
// - Each blob has its own color that blends in merge zones
// - Add subtle animation (breathing/pulsing)
// - Include soft glow effect around blobs
// Use smin() for smooth blending between SDFs

SwiftUI Scaffolding:

import SwiftUI

struct MagneticGooView: View {
    @State private var nodes: [GooNode] = [
        GooNode(position: CGPoint(x: 150, y: 300), color: UIColor(red:0.0, green: 0.8, blue: 1.0, alpha: 1.0)), // Cyan
        GooNode(position: CGPoint(x: 250, y: 300), color: UIColor(red:0.8, green: 0.0, blue: 1.0, alpha: 1.0)), // Purple
        GooNode(position: CGPoint(x: 200, y: 400), color: UIColor(red:1.0, green: 0.5, blue: 0.0, alpha: 1.0)),  // Orange
        GooNode(position: CGPoint(x: 100, y: 400), color: UIColor.systemPink)  // Orange
    ]
    
    @State private var draggedNode: GooNode.ID?
    let startTime = Date()
    
    var body: some View {
        TimelineView(.animation) { timeline in
            ZStack {
                // Background with goo effect
                Rectangle()
                    .fill(.black)
                    .colorEffect(
                        ShaderLibrary.magneticGoo(
                            .float(Float(timeline.date.timeIntervalSince(startTime))),
                            .float2(nodes[safe: 0]?.position ?? .zero),
                            .float4(
                                colorForNode(0).x,
                                colorForNode(0).y,
                                colorForNode(0).z,
                                1.0
                            ),
                            .float2(nodes[safe: 1]?.position ?? .zero),
                            .float4(
                                colorForNode(1).x,
                                colorForNode(1).y,
                                colorForNode(1).z,
                                1.0
                            ),
                            .float2(nodes[safe: 2]?.position ?? .zero),
                            .float4(
                                colorForNode(2).x,
                                colorForNode(2).y,
                                colorForNode(2).z,
                                1.0
                            ),
                            .float2(nodes[safe: 3]?.position ?? .zero),
                            .float4(
                                colorForNode(3).x,
                                colorForNode(3).y,
                                colorForNode(3).z,
                                1.0
                            ),
                            .float(Float(nodes.count))
                        )
                    )
                
                // Invisible drag targets
                ForEach(nodes) { node in
                    Circle()
                        .fill(.white.opacity(0.01)) // Almost invisible
                        .frame(width: 100, height: 100)
                        .position(node.position)
                        .gesture(
                            DragGesture()
                                .onChanged { value in
                                    if draggedNode == nil {
                                        draggedNode = node.id
                                    }
                                    if draggedNode == node.id,
                                       let index = nodes.firstIndex(where: { $0.id == node.id }) {
                                        nodes[index].position = value.location
                                    }
                                }
                                .onEnded { _ in
                                    draggedNode = nil
                                }
                        )
                }
            }
            .ignoresSafeArea()
        }
    }
    
    private func colorForNode(_ node: Int) -> SIMD4<Float> {
        (nodes[safe: node]?.color ?? .white).toFloat4
    }
}

struct GooNode: Identifiable {
    let id = UUID()
    var position: CGPoint
    var color: UIColor
}

// Helper extensions
extension CGPoint {
    var simd2: SIMD2<Float> {
        SIMD2<Float>(Float(x), Float(y))
    }
}

extension UIColor {
    var toFloat4: SIMD4<Float> {
        var red: CGFloat = 0
        var green: CGFloat = 0
        var blue: CGFloat = 0
        var alpha: CGFloat = 0
        
        // Get the RGBA components of the UIColor
        self.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
        
        // Convert to SIMD4<Float>
        return SIMD4<Float>(Float(red), Float(green), Float(blue), Float(alpha))
    }
}

extension Array {
    subscript(safe index: Int) -> Element? {
        indices.contains(index) ? self[index] : nil
    }
}

Hints:

  • smin(a, b, k) where k controls smoothness (try k = 30-50)
  • Weight colors by 1.0 / (1.0 + distance * distance * 0.001)
  • Add breathing with radius + sin(time + blobIndex) * 5.0
  • Glow effect: exp(-distance * 0.02) when outside the blob
  • Edge highlight: smoothstep(-20.0, 0.0, field)

Performance Optimization Strategies

Early Exit Optimizations

// Bounding box check before expensive SDF calculation
float boundingBoxSDF(float2 p, float2 center, float2 size) {
    float2 d = abs(p - center) - size;
    return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}

float optimizedComplexSDF(float2 p) {
    // Quick bounding box check
    float boundingBox = boundingBoxSDF(p, float2(0.0, 0.0), float2(0.5, 0.5));
    
    // If we're far from the bounding box, return early
    if (boundingBox > 0.1) {
        return boundingBox;
    }
    
    // Only compute expensive SDF when we're close
    return expensiveDetailedSDF(p);
}

Level of Detail (LOD) for SDFs

float adaptiveSDF(float2 p, float distanceToCamera) {
    if (distanceToCamera > 10.0) {
        // Far away: use simple approximation
        return length(p) - 0.5;  // Simple circle
    } else if (distanceToCamera > 5.0) {
        // Medium distance: moderate detail
        return roundedRectSDF(p, float2(0.0, 0.0), float2(1.0, 1.0), 0.1);
    } else {
        // Close up: full detail
        return complexStarSDF(p, float2(0.0, 0.0), 0.5, 8);
    }
}

Want the Challenges Solutions?

Get the full Xcode project with solutions to all challenges, bonus examples, and clean, runnable code.

Get the Full Project →

Validation Questions

Before proceeding to Chapter 9, test your understanding:

1. What does it mean for a Signed Distance Function to return negative values?

Answer: Negative values indicate points that are inside the shape. The magnitude tells you how far inside you are. For example, if a circle SDF returns -0.1 at a point, that point is 0.1 units inside the circle from the nearest edge. This sign convention is crucial for boolean operations - you can't properly combine shapes without knowing which side of the boundary you're on.

2. Why do we use min(d1, d2) for union and max(d1, d2) for intersection in SDF boolean operations?

Answer: This stems from the definition of distance fields:

  • Union: We want the surface closest to us, so we take the minimum distance
  • Intersection: We only want the region where both shapes exist (both distances are negative), so we take the maximum distance (the "more restrictive" condition)

Think of it this way: Union = "I'm inside if I'm inside ANY shape" (closest surface wins). Intersection = "I'm inside only if I'm inside ALL shapes" (farthest surface from origin determines the boundary).

3. How does fwidth() ensure resolution-independent anti-aliasing in SDF rendering?

Answer: fwidth() calculates how quickly the SDF value changes between adjacent pixels. This tells us the "size" of one pixel in SDF space. By using this as the transition width in smoothstep(), we ensure the anti-aliasing transition is exactly one pixel wide regardless of screen resolution or zoom level. Without this, shapes would look jaggy on high-DPI screens or blurry on low-resolution displays.

4. Why must distance scaling be handled carefully when transforming SDFs?

Answer: When you scale coordinates before passing them to an SDF, you must scale the returned distance by the same factor. This is because SDFs return distance in coordinate space units. If you scale coordinates by 2x (making shapes twice as big), distances are also scaled by 2x. To get correct distance values, divide the result by the scale factor: sdf(p * scale) / scale.

5. What makes smooth boolean operations "smooth" and when would you use them?

Answer: Smooth boolean operations use interpolation functions (like smoothstep) to gradually blend between the two input distance fields instead of making hard choices. Regular boolean ops create sharp corners where shapes meet; smooth versions create organic, rounded junctions. Use smooth operations for:

  • Organic/natural-looking combinations
  • Avoiding harsh intersections that look artificial
  • Creating flowing, liquid-like merges between shapes
  • UI elements that need subtle, elegant transitions

6. How do SDF transformations differ from traditional graphics transformations?

Answer: Traditional graphics transforms modify vertex positions directly. SDF transformations modify the coordinate space that you query, not the SDF itself. For rotation, instead of rotating the shape, you rotate the input coordinates in the opposite direction. For scaling, you scale input coordinates AND the output distance. This "inverse transformation" approach is counterintuitive but powerful - one SDF function can represent infinite variations through coordinate transformation.


Next Chapter Preview: You've mastered mathematical shape representation with distance fields. Chapter 9 introduces Time-Based Animation - how to make your mathematical universe come alive. You'll learn to synchronize shader time with SwiftUI animations, create organic motion patterns, and build effects that feel alive rather than mechanical. The key insight: animation is just another dimension in your mathematical space.