Smooth Transitions

Core Concept: Smoothstep and anti-aliasing for quality. Understand smoothstep curves, integrate with SwiftUI transitions, create custom view modifiers, and use mask/fade techniques. Challenge: Custom page transition using distortionEffect.

Chapter 7: Smooth Transitions

The Difference Between Amateur and Professional

Show someone two circles: one with sharp, pixelated edges, another with silky-smooth boundaries. Without any graphics knowledge, they'll instantly know which looks "professional." But why?

Here's the uncomfortable truth: Every shape you've created so far screams "computer generated." Those harsh transitions between inside and outside? That's not how light behaves in reality. Real edges are battlegrounds where photons dance, materials blend, and physics creates gradual transitions at the microscopic level.

This chapter teaches you to stop drawing shapes and start painting light.

The Physics Your Eyes Expect

Your visual system evolved to process a world without pixels. When you see a sharp digital edge, your brain knows something is wrong. It expects:

  • Sub-pixel precision: Real edges exist between pixel boundaries
  • Anti-aliasing: Natural objects have microscopic roughness that softens edges
  • Atmospheric perspective: Even sharp objects soften with distance
  • Fresnel effects: Edges catch light differently than centers

The function that approximates all of this? Smoothstep. It's not just a convenience function - it's a mathematical model of how transitions occur in nature.

Mathematical Foundation: The Curves of Reality

Linear Interpolation: The Robot's Way

// Linear interpolation - mathematically correct, visually wrong
float linearFade(float x) {
    return x;  // 0 to 1 linearly
}

This creates constant-speed transitions. Nothing in nature moves at constant speed - objects accelerate and decelerate.

Smoothstep: Nature's Acceleration Curve

// The smoothstep polynomial
float smoothstep(float edge0, float edge1, float x) {
    // Scale and bias x to 0..1 range
    float t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0);
    
    // This specific polynomial has magical properties
    return t * t * (3.0 - 2.0 * t);
}

Why this specific formula t * t * (3.0 - 2.0 * t)? Let's understand:

At t = 0: result = 0 * 0 * 3 = 0
At t = 0.5: result = 0.5 * 0.5 * 2 = 0.5
At t = 1: result = 1 * 1 * 1 = 1

But the magic is in the derivatives:
First derivative at t=0: 0 (starts from rest)
First derivative at t=1: 0 (comes to rest)
Second derivative is continuous (no jerky motion)

Smootherstep: When Smooth Isn't Smooth Enough

// Even smoother with zero second derivative at edges
float smootherstep(float edge0, float edge1, float x) {
    float t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0);
    // 6t^5 - 15t^4 + 10t^3
    return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
}

Visual Comparison

[[ stitchable ]] half4 transitionCurves(
    float2 position, 
    half4 color, 
    float2 size
) {
    float2 uv = position / size;
    
    // Three different transition curves
    float linear = uv.x;
    float smooth = smoothstep(0.0, 1.0, uv.x);
    float smoother = smootherstep(0.0, 1.0, uv.x);
    
    // Plot each curve
    float plot_linear = step(abs(uv.y - linear), 0.01);
    float plot_smooth = step(abs(uv.y - smooth), 0.01);
    float plot_smoother = step(abs(uv.y - smoother), 0.01);
    
    // Color code: red=linear, green=smoothstep, blue=smootherstep
    return half4(
        plot_linear,
        plot_smooth,
        plot_smoother,
        1.0
    );
}

The Anti-Aliasing Revolution

Why Pixels Aren't Enough

A circle's mathematical edge exists at radius = 0.5. But what about the pixel at radius = 0.4999? It's 99.98% inside, but step() makes it fully inside. The pixel at 0.5001? Fully outside. This creates the harsh edge.

The Sub-Pixel Solution

[[ stitchable ]] half4 antiAliasedCircle(
    float2 position, 
    half4 color, 
    float2 size,
    float edgeSoftness  // 0.001 to 0.1
) {
    float2 uv = position / size;
    float2 center = uv - 0.5;
    
    float radius = 0.3;
    float dist = length(center);
    
    // The magic: transition over multiple pixels
    // fwidth() gives us the rate of change of dist across pixels
    float pixelWidth = fwidth(dist);
    
    // Create smooth transition exactly 1 pixel wide
    float circle = 1.0 - smoothstep(
        radius - pixelWidth * edgeSoftness,
        radius + pixelWidth * edgeSoftness,
        dist
    );
    
    return half4(circle, circle, circle, 1.0);
}

Understanding fwidth()

// fwidth() is approximately:
// abs(dFdx(value)) + abs(dFdy(value))
// 
// It tells you: "How much does this value change between adjacent pixels?"
// This is CRUCIAL for resolution-independent rendering

Building Professional Transitions

Smooth Masking

[[ stitchable ]] half4 smoothMask(
    float2 position, 
    half4 color, 
    float2 size,
    float maskPosition,     // 0-1, where the mask is
    float maskSoftness,     // 0-0.5, how soft the edge is
    float maskAngle         // rotation angle in radians
) {
    float2 uv = position / size;
    
    // Rotate UV coordinates around center
    float2 centered = uv - 0.5;
    float cosAngle = cos(maskAngle);
    float sinAngle = sin(maskAngle);
    float2 rotated = float2(
        centered.x * cosAngle - centered.y * sinAngle,
        centered.x * sinAngle + centered.y * cosAngle
    );
    
    // Create smooth diagonal mask
    float mask = smoothstep(
        maskPosition - maskSoftness,
        maskPosition + maskSoftness,
        rotated.x + 0.5  // Back to 0-1 range
    );
    
    // Apply mask to original color
    return half4(color.rgb * mask, color.a * mask);
}

Animated Reveal Effect

[[ stitchable ]] half4 circularReveal(
    float2 position, 
    half4 color, 
    float2 size,
    float2 revealCenter,    // UV coordinates of reveal center
    float revealProgress,   // 0-1, animation progress
    float edgeGlow          // 0-1, intensity of edge glow
) {
    float2 uv = position / size;
    
    // Distance from reveal center
    float dist = distance(uv, revealCenter);
    
    // Animated radius with easing
    float maxRadius = length(float2(1.0, 1.0));  // Corner to corner
    float currentRadius = revealProgress * maxRadius;
    
    // Create smooth reveal with glowing edge
    float edgeWidth = 0.1;
    float reveal = smoothstep(
        currentRadius + edgeWidth,
        currentRadius,
        dist
    );
    
    // Add glow at the edge
    float glowMask = smoothstep(
        currentRadius - edgeWidth * 2.0,
        currentRadius - edgeWidth,
        dist
    ) * smoothstep(
        currentRadius,
        currentRadius + edgeWidth,
        dist
    );
    
    // Combine reveal and glow
    half3 finalColor = color.rgb * reveal;
    finalColor += half3(edgeGlow, edgeGlow * 0.7, edgeGlow * 0.3) * glowMask;
    
    return half4(finalColor, color.a * reveal);
}

Professional Gradient Techniques

[[ stitchable ]] half4 smoothGradient(
    float2 position, 
    half4 color, 
    float2 size,
    float3 color1,          // Start color
    float3 color2,          // End color  
    float angle,            // Gradient angle
    float smoothness        // 0-1, gradient smoothness
) {
    float2 uv = position / size;
    
    // Create directional gradient
    float2 dir = float2(cos(angle), sin(angle));
    float grad = dot(uv - 0.5, dir) + 0.5;
    
    // Apply smoothness (higher = smoother)
    if (smoothness > 0.0) {
        // Multi-level smoothing for ultra-smooth gradients
        grad = smoothstep(0.0, smoothness, grad) * 
               smoothstep(1.0, 1.0 - smoothness, grad);
    }
    
    // Interpolate colors
    half3 result = mix(color1, color2, grad);
    
    return half4(result, 1.0);
}

SwiftUI Integration: Smooth Transitions in Practice

Custom Transition Modifier

struct SmoothRevealModifier: ViewModifier {
    let progress: Double
    
    func body(content: Content) -> some View {
        content
            .visualEffect { content, proxy in
                content.colorEffect(
                    ShaderLibrary.circularReveal(
                        .float2(proxy.size),
                        .float2(0.5, 0.5),  // Center reveal
                        .float(Float(progress)),
                        .float(0.8)  // Edge glow
                    )
                )
            }
    }
}

extension AnyTransition {
    static var smoothReveal: AnyTransition {
        .modifier(
            active: SmoothRevealModifier(progress: 0),
            identity: SmoothRevealModifier(progress: 1)
        )
    }
}

Smooth Edge Detection

[[ stitchable ]] half4 edgeDetection(
    float2 position, 
    half4 color, 
    float2 size,
    float threshold,        // Edge detection sensitivity
    float smoothing         // Edge smoothness
) {
    // Sample surrounding pixels using fwidth
    float2 uv = position / size;
    
    // Get color luminance
    float center = dot(color.rgb, float3(0.299, 0.587, 0.114));
    
    // Detect edges using luminance changes
    float2 grad = float2(
        dFdx(center),  // Horizontal change
        dFdy(center)   // Vertical change
    );
    
    float edge = length(grad);
    
    // Smooth threshold
    float edgeMask = smoothstep(threshold - smoothing, threshold + smoothing, edge);
    
    // Stylized output: white background, black edges
    float output = 1.0 - edgeMask;
    
    return half4(output, output, output, 1.0);
}

Common Pitfalls

Pitfall 1: Forgetting Resolution Independence

// WRONG - Edge width depends on resolution
float circle = 1.0 - smoothstep(0.49, 0.51, dist);  // Fixed pixel width

// CORRECT - Edge width scales with resolution
float pixelWidth = fwidth(dist);
float circle = 1.0 - smoothstep(
    0.5 - pixelWidth, 
    0.5 + pixelWidth, 
    dist
);

Pitfall 2: Over-Smoothing

// WRONG - Too much smoothing creates blurry mess
float edge = smoothstep(0.0, 1.0, dist);  // Entire range!

// CORRECT - Controlled smoothing
float edge = smoothstep(0.48, 0.52, dist);  // Just enough

Pitfall 3: Ignoring Gamma Correction

// WRONG - Linear blending in sRGB space
half3 blended = mix(color1, color2, 0.5);

// CORRECT - Gamma-correct blending
half3 linear1 = pow(color1, 2.2);
half3 linear2 = pow(color2, 2.2);
half3 blended = pow(mix(linear1, linear2, 0.5), 1.0/2.2);

Pitfall 4: Performance vs Quality

// EXPENSIVE - Multiple smoothsteps per pixel
float mask1 = smoothstep(0.2, 0.3, dist);
float mask2 = smoothstep(0.4, 0.5, dist);
float mask3 = smoothstep(0.6, 0.7, dist);
float combined = mask1 * mask2 * mask3;

// OPTIMIZED - Single smoothstep with calculated ranges
float combined = smoothstep(0.2, 0.7, dist);

Advanced Techniques

Distance Field Rendering

[[ stitchable ]] half4 distanceFieldShape(
    float2 position, 
    half4 color, 
    float2 size,
    float shapeType,        // 0=circle, 1=square, 2=star
    float borderWidth,      // Width of border
    float3 fillColor,
    float3 borderColor
) {
    float2 uv = position / size;
    float2 centered = uv - 0.5;
    
    float dist = 0.0;
    
    // Calculate distance based on shape
    if (shapeType < 0.5) {
        // Circle
        dist = length(centered) - 0.3;
    } else if (shapeType < 1.5) {
        // Square
        float2 d = abs(centered) - 0.3;
        dist = length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
    } else {
        // Star (simplified 5-point)
        float angle = atan2(centered.y, centered.x);
        float r = length(centered);
        float star = 0.3 + 0.1 * cos(5.0 * angle);
        dist = r - star;
    }
    
    // Create smooth shape with border
    float shape = 1.0 - smoothstep(-borderWidth, 0.0, dist);
    float border = smoothstep(-borderWidth, -borderWidth * 0.5, dist);
    
    // Combine colors
    half3 result = mix(borderColor, fillColor, border) * shape;
    
    return half4(result, shape);
}

Morphing Transitions

[[ stitchable ]] half4 shapeMorph(
    float2 position, 
    half4 color, 
    float2 size,
    float morphProgress     // 0=circle, 1=square
) {
    float2 uv = position / size;
    float2 centered = (uv - 0.5) * 2.0;  // -1 to 1 range
    
    // Distance functions for both shapes
    float circleDist = length(centered) - 0.6;
    float squareDist = max(abs(centered.x), abs(centered.y)) - 0.6;
    
    // Smooth interpolation between shapes
    float dist = mix(circleDist, squareDist, smoothstep(0.0, 1.0, morphProgress));
    
    // Anti-aliased edge
    float shape = 1.0 - smoothstep(-0.01, 0.01, dist);
    
    // Add inner gradient for depth
    float innerGrad = 1.0 - smoothstep(-0.6, -0.1, dist);
    half3 shapeColor = mix(
        half3(0.2, 0.3, 0.8),  // Edge color
        half3(0.4, 0.6, 1.0),  // Center color
        innerGrad
    );
    
    return half4(shapeColor * shape, shape);
}

Challenges

Challenge 1: Smooth Wave Transition

Create a waveTransition shader that reveals content with a smooth wave pattern.

Metal Shader Hint:

Use sin() for wave shape
Use smoothstep() for smooth edges
Animate with time parameter

SwiftUI Template:

struct WaveTransitionView: View {
    @State private var progress: Float = 0.0
    
    var body: some View {
        VStack {
            Image("sample_image")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .visualEffect { content, proxy in
                    content.colorEffect(ShaderLibrary.waveTransition(
                        .float2(proxy.size),
                        .float(progress)
                    ))
                }
                .frame(height: 300)
            
            HStack {
                Text("Progress")
                Slider(value: $progress, in: 0...1)
            }
            .padding()
            
            Button("Animate") {
                withAnimation(.easeInOut(duration: 2.0)) {
                    progress = progress == 0 ? 1 : 0
                }
            }
        }
    }
}

Challenge 2: Glow Effect

Create a glowingEdge shader that adds a smooth glow around shapes.

Metal Shader Hint:

Detect edges using distance
Create multiple smoothstep layers for glow
Combine with original shape

SwiftUI Template:

struct GlowingEdgeView: View {
    @State private var glowIntensity: Float = 1.0
    @State private var glowRadius: Float = 0.1
    @State private var pulseAnimation: Float = 0.0
    
    let timer = Timer.publish(every: 1/60.0, on: .main, in: .common).autoconnect()
    
    var body: some View {
        VStack {
            Rectangle()
                .fill(Color.black)
                .visualEffect { content, proxy in
                    content.colorEffect(ShaderLibrary.glowingEdge(
                        .float2(proxy.size),
                        .float(glowIntensity),
                        .float(glowRadius),
                        .float(pulseAnimation)
                    ))
                }
                .frame(height: 300)
            
            VStack {
                HStack {
                    Text("Intensity")
                    Slider(value: $glowIntensity, in: 0...2)
                }
                HStack {
                    Text("Radius")
                    Slider(value: $glowRadius, in: 0.05...0.3)
                }
            }
            .padding()
        }
        .onReceive(timer) { _ in
            pulseAnimation += 1/60.0
        }
    }
}

Challenge 3: Smooth Kaleidoscope

Create a kaleidoscope shader with smooth transitions between segments.

Metal Shader Hint:

Use polar coordinates
Apply modulo to angle for repetition
Smooth transitions at segment boundaries

SwiftUI Template:

struct KaleidoscopeView: View {
    @State private var segments: Float = 6.0
    @State private var rotation: Float = 0.0
    @State private var smoothness: Float = 0.1
    
    var body: some View {
        VStack {
            Image("colorful_image")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .visualEffect { content, proxy in
                    content.colorEffect(ShaderLibrary.kaleidoscope(
                        .float2(proxy.size),
                        .float(segments),
                        .float(rotation),
                        .float(smoothness)
                    ))
                }
                .frame(height: 300)
            
            VStack {
                HStack {
                    Text("Segments: \(Int(segments))")
                    Slider(value: $segments, in: 3...12, step: 1)
                }
                HStack {
                    Text("Rotation")
                    Slider(value: $rotation, in: 0...6.28)
                }
                HStack {
                    Text("Smoothness")
                    Slider(value: $smoothness, in: 0...0.3)
                }
            }
            .padding()
        }
    }
}

Challenge 4: Smooth Radar Sweep

Create a radarSweep shader that simulates a radar display with a rotating sweep line that leaves a fading trail. Detected objects should appear as blips that pulse smoothly when hit by the sweep.

Metal Shader Requirements:

  • Create a rotating sweep line with perfect anti-aliasing
  • Add a fading trail that follows the sweep
  • Generate random "blips" that represent detected objects
  • Make blips pulse smoothly when the sweep passes over them
  • Include range rings with smooth edges

Why this is valuable: Combines polar coordinates with smooth transitions, temporal effects, and shows how to create professional UI elements using smoothstep for anti-aliasing in rotational contexts.

SwiftUI Template:

struct RadarSweepView: View {
    @State private var timeValue: Float = 0.0
    @State private var sweepSpeed: Float = 1.0
    @State private var trailLength: Float = 0.3
    @State private var blipCount: Float = 8.0
    @State private var showRangeRings: Bool = true
    
    let timer = Timer.publish(every: 1/60.0, on: .main, in: .common).autoconnect()
    
    var body: some View {
        VStack {
            Text("Radar Sweep")
                .font(.title2)
                .padding()
            
            Rectangle()
                .fill(Color.black)
                .visualEffect { content, proxy in
                    content.colorEffect(ShaderLibrary.radarSweep(
                        .float2(proxy.size),
                        .float(timeValue),
                        .float(sweepSpeed),
                        .float(trailLength),
                        .float(blipCount),
                        .float(showRangeRings ? 1.0 : 0.0)
                    ))
                }
                .frame(width: 350, height: 350)
                .clipShape(Circle())
                .overlay(Circle().stroke(Color.green.opacity(0.5), lineWidth: 2))
            
            VStack(spacing: 15) {
                HStack {
                    Text("Sweep Speed")
                    Slider(value: $sweepSpeed, in: 0.5...3.0)
                }
                
                HStack {
                    Text("Trail Length")
                    Slider(value: $trailLength, in: 0.1...0.5)
                }
                
                HStack {
                    Text("Objects: \(Int(blipCount))")
                    Slider(value: $blipCount, in: 0...20, step: 1)
                }
                
                Toggle("Range Rings", isOn: $showRangeRings)
            }
            .padding()
        }
        .onReceive(timer) { _ in
            timeValue += 1/60.0
        }
    }
}

Performance Optimization for Smooth Effects

// EXPENSIVE: Multiple texture samples
float blur = 0.0;
for(int i = -5; i <= 5; i++) {
    for(int j = -5; j <= 5; j++) {
        blur += texture.sample(position + float2(i, j));
    }
}

// OPTIMIZED: Use built-in smoothing
float smooth = smoothstep(0.4, 0.6, value);

// EXPENSIVE: Complex distance calculation every pixel
float dist = length(uv - center);
float glow1 = smoothstep(0.5, 0.4, dist);
float glow2 = smoothstep(0.6, 0.5, dist);
float glow3 = smoothstep(0.7, 0.6, dist);

// OPTIMIZED: Reuse distance calculation
float dist = length(uv - center);
float glow = smoothstep(0.7, 0.4, dist) * 
             (1.0 - smoothstep(0.4, 0.5, dist));

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 and Answers

1. Why does smoothstep(0.4, 0.6, x) create better edges than step(0.5, x)?

Answer: Step creates an instant transition at exactly 0.5, causing aliasing (jagged edges) because pixels are either fully in or fully out. Smoothstep creates a gradual transition from 0.4 to 0.6, allowing pixels in that range to be partially shaded. This matches how real edges work - they're not infinitely sharp but have a transition zone at the sub-pixel level.

2. What does fwidth() do and why is it crucial for resolution-independent rendering?

Answer: fwidth() calculates how fast a value changes between adjacent pixels (the sum of absolute derivatives in x and y directions). This tells you the "size of one pixel" in your coordinate space. Using fwidth() to scale your smoothstep transitions ensures edges look equally smooth whether on a retina display or standard resolution, because the transition width adapts to the pixel density.

3. How does the smoothstep polynomial t²(3-2t) achieve smooth acceleration?

Answer: This polynomial has special properties:

  • At t=0: value=0, slope=0 (starts from rest)
  • At t=1: value=1, slope=0 (comes to rest)
  • The second derivative is continuous, meaning no "jerk"
  • It's the simplest polynomial meeting these constraints

This mirrors natural motion where objects accelerate from rest and decelerate to rest, unlike linear interpolation which has instant velocity changes.

4. When should you use smoothstep vs smootherstep?

Answer: Use smoothstep for most transitions - it's efficient and looks natural. Use smootherstep when you need extra smoothness, particularly for:

  • Camera movements where any jerk is noticeable
  • High-contrast edges where artifacts are more visible
  • Scientific visualization requiring mathematical smoothness
  • The cost is minimal (a few extra operations) but usually unnecessary

5. How do you anti-alias a complex shape like a star?

Answer: Calculate the signed distance to the shape, then use smoothstep with a width based on fwidth():

float dist = distanceToStar(position);
float pixelWidth = fwidth(dist);
float antiAliased = 1.0 - smoothstep(-pixelWidth, pixelWidth, dist);

The key is having a continuous distance function and scaling the transition width to pixel size.

6. What's the relationship between smoothstep and SwiftUI's animation curves?

Answer: SwiftUI's .easeInOut animation curve is conceptually similar to smoothstep - both start and end with zero velocity. However, SwiftUI uses a cubic-bezier curve while smoothstep uses a cubic polynomial. When syncing shader animations with SwiftUI, use .easeInOut for the closest match to smoothstep transitions. For exact matching, you could implement custom animation curves.

Further Exploration

  • Signed Distance Fields (SDF): The foundation of modern UI rendering
  • Analytical Anti-Aliasing: Exact coverage calculations
  • Temporal Anti-Aliasing: Using motion vectors for smoothing
  • Bézier Curve Rendering: Smooth curves in shaders

Next Chapter Preview: You've mastered smooth transitions and anti-aliasing. Chapter 8 introduces Distance Fields - the technique that powers everything from font rendering to complex UI effects. You'll learn to describe shapes mathematically and combine them in ways that would require thousands of vertices in traditional 3D graphics.