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.