Randomness and Noise

Core Concept: Controlled chaos for natural effects. Use hash functions, pseudo-randomness, seeded randomness, and multi-octave noise. Address performance for real-time effects. Challenge: Particle system controlled by scroll position.

The Texture of Reality

Look at concrete. Wood grain. Clouds. Your skin. Nothing in nature is perfectly smooth or perfectly patterned. The universe has texture - subtle variations that make materials feel real rather than computer-generated.

Here's the paradox: computers can't generate true randomness, yet they must simulate the organic randomness of nature. The solution? Deterministic chaos - mathematical functions that produce results so complex they appear random, yet remain perfectly reproducible.

This chapter reveals how to add the imperfections that make perfection.

The Fundamental Problem: Why random() Doesn't Exist in Shaders

In CPU programming, you can call random() and get a different value each time. In shaders, this is impossible. Why?

Remember: Every pixel executes the same function simultaneously. If random() existed, either:

  1. Every pixel would get the same "random" value (useless)
  2. Results would change every frame (flickering chaos)
  3. Adjacent pixels couldn't coordinate (no smooth gradients)

The solution is beautiful: We create functions that look random but are actually deterministic hash functions.

Mathematical Foundation: The Art of Fake Randomness

The Classic Hash Function

float random(float2 st) {
    return fract(sin(dot(st, float2(12.9898, 78.233))) * 43758.5453);
}

Let's dissect this magic:

// Step 1: dot product creates a single value from 2D input
float dotProduct = dot(st, float2(12.9898, 78.233));
// For st = (1,1): 12.9898 + 78.233 = 91.2228

// Step 2: sin() creates oscillation (-1 to 1)
float sineValue = sin(dotProduct);
// sin(91.2228) = some value between -1 and 1

// Step 3: Multiply by large prime number
float amplified = sineValue * 43758.5453;
// Amplifies tiny differences in sine values

// Step 4: fract() keeps only decimal part (0-1)
float result = fract(amplified);
// Ensures output is always 0-1 range

The magic numbers (12.9898, 78.233, 43758.5453) aren't special - they're just chosen to create good distribution. The pattern is what matters: input → nonlinear transform → amplify differences → normalize range.

Building Your First Noise Shader

[[ stitchable ]] half4 randomNoise(float2 position, half4 color, float2 size) {
    // Convert to UV coordinates (normalized 0-1 range, explained in Chapter 2)
    float2 uv = position / size;
    
    // Scale up to see individual random values
    float2 scaledUV = uv * 10.0;
    
    // Get the integer part (which cell we're in)
    float2 cellID = floor(scaledUV);
    
    // Generate random value for this cell
    float randomValue = fract(sin(dot(cellID, float2(12.9898, 78.233))) * 43758.5453);
    
    return half4(randomValue, randomValue, randomValue, 1.0);
}

This creates a grid of random gray values - digital static. Not very useful yet.

The Revolution: Gradient Noise (Perlin's Insight)

Ken Perlin's Academy Award-winning breakthrough wasn't just creating random values - it was creating smooth transitions between random values. This innovation revolutionized computer graphics.

Value Noise: The Smooth Random

// Generate smooth noise by interpolating between random values
float valueNoise(float2 st) {
    // Grid coordinates
    float2 i = floor(st);  // Integer (cell) position
    float2 f = fract(st);  // Fractional position within cell
    
    // Four corners of current cell
    float a = random(i);                        // Bottom-left
    float b = random(i + float2(1.0, 0.0));    // Bottom-right
    float c = random(i + float2(0.0, 1.0));    // Top-left
    float d = random(i + float2(1.0, 1.0));    // Top-right
    
    // Smooth interpolation curves (better than linear!)
    float2 u = f * f * (3.0 - 2.0 * f);  // Smoothstep curve
    
    // Bilinear interpolation
    float bottom = mix(a, b, u.x);  // Interpolate bottom edge
    float top = mix(c, d, u.x);     // Interpolate top edge
    return mix(bottom, top, u.y);   // Interpolate between edges
}

[[ stitchable ]] half4 smoothNoise(float2 position, half4 color, float2 size) {
    float2 uv = position / size;
    
    // Scale for visible noise pattern
    float noiseScale = 8.0;
    float noise = valueNoise(uv * noiseScale);
    
    return half4(noise, noise, noise, 1.0);
}

Multi-Octave Noise: The Secret to Natural Textures

Nature doesn't have just one scale of detail. Mountains have overall shape, smaller peaks, rocks, pebbles, and grains of sand. We simulate this with octaves:

[[ stitchable ]] half4 fractalNoise(
    float2 position, 
    half4 color, 
    float2 size,
    float time
) {
    float2 uv = position / size;
    
    float value = 0.0;      // Accumulated noise value
    float amplitude = 1.0;   // Strength of each octave
    float frequency = 1.0;   // Scale of each octave
    
    // Add 6 octaves of noise
    for(int i = 0; i < 6; i++) {
        // Add noise at current frequency and amplitude
        value += amplitude * valueNoise(uv * frequency * 4.0 + time * 0.1);
        
        // Each octave is twice as detailed but half as strong
        frequency *= 2.0;   // Double the frequency (smaller details)
        amplitude *= 0.5;   // Halve the amplitude (less influence)
    }
    
    // Normalize (sum of amplitudes: 1 + 0.5 + 0.25 + ... ≈ 2)
    value /= 2.0;
    
    return half4(value, value, value, 1.0);
}

Advanced Noise Types

Gradient Noise (True Perlin Noise)

Instead of interpolating random values, Perlin noise interpolates random gradients:

// Generate random gradient vector for a grid point
float2 randomGradient(float2 p) {
    // Random angle
    float angle = random(p) * 6.28318530718;  // 2π
    return float2(cos(angle), sin(angle));
}

float gradientNoise(float2 st) {
    float2 i = floor(st);
    float2 f = fract(st);
    
    // Calculate gradients at four corners
    float2 g00 = randomGradient(i);
    float2 g10 = randomGradient(i + float2(1.0, 0.0));
    float2 g01 = randomGradient(i + float2(0.0, 1.0));
    float2 g11 = randomGradient(i + float2(1.0, 1.0));
    
    // Calculate dot products
    float n00 = dot(g00, f);
    float n10 = dot(g10, f - float2(1.0, 0.0));
    float n01 = dot(g01, f - float2(0.0, 1.0));
    float n11 = dot(g11, f - float2(1.0, 1.0));
    
    // Smooth interpolation
    float2 u = f * f * (3.0 - 2.0 * f);
    
    // Interpolate
    float nx0 = mix(n00, n10, u.x);
    float nx1 = mix(n01, n11, u.x);
    return mix(nx0, nx1, u.y) * 0.5 + 0.5;  // Normalize to 0-1
}

Simplex Noise: The Performance King

Ken Perlin later created Simplex noise - faster and with better properties:

// 2D Simplex noise - more efficient than classic Perlin
float simplexNoise(float2 st) {
    const float F2 = 0.366025404;  // (sqrt(3) - 1) / 2
    const float G2 = 0.211324865;  // (3 - sqrt(3)) / 6
    
    // Skew the input space to determine simplex cell
    float s = (st.x + st.y) * F2;
    float2 i = floor(st + s);
    float t = (i.x + i.y) * G2;
    float2 i0 = i - t;  // Unskew cell origin back to (x,y) space
    float2 x0 = st - i0;  // The x,y distances from cell origin
    
    // Determine which simplex we're in
    float2 i1 = (x0.x > x0.y) ? float2(1.0, 0.0) : float2(0.0, 1.0);
    
    // Calculate corner offsets
    float2 x1 = x0 - i1 + G2;
    float2 x2 = x0 - 1.0 + 2.0 * G2;
    
    // Calculate contributions from three corners
    float n0 = max(0.0, 0.5 - dot(x0, x0));
    n0 = n0 * n0 * n0 * n0 * dot(randomGradient(i0), x0);
    
    float n1 = max(0.0, 0.5 - dot(x1, x1));
    n1 = n1 * n1 * n1 * n1 * dot(randomGradient(i0 + i1), x1);
    
    float n2 = max(0.0, 0.5 - dot(x2, x2));
    n2 = n2 * n2 * n2 * n2 * dot(randomGradient(i0 + 1.0), x2);
    
    // Scale output to [0, 1]
    return 70.0 * (n0 + n1 + n2) * 0.5 + 0.5;
}

Practical Applications

Marble Texture

[[ stitchable ]] half4 marbleTexture(
    float2 position, 
    half4 color, 
    float2 size,
    float time
) {
    float2 uv = position / size;
    
    // Create flowing marble veins
    float noise = 0.0;
    float frequency = 4.0;
    float amplitude = 1.0;
    
    // Multiple octaves for detail
    for(int i = 0; i < 4; i++) {
        noise += amplitude * valueNoise(uv * frequency + time * 0.02);
        frequency *= 2.1;  // Slightly non-doubling for more organic look
        amplitude *= 0.5;
    }
    
    // Create vein pattern with sine
    float marble = sin(uv.x * 10.0 + noise * 10.0) * 0.5 + 0.5;
    
    // Color: white marble with gray veins
    half3 white = half3(0.95, 0.95, 0.9);
    half3 gray = half3(0.4, 0.4, 0.45);
    half3 marbleColor = mix(gray, white, marble);
    
    return half4(marbleColor.r, marbleColor.g, marbleColor.b, 1.0);
}

Animated Clouds

[[ stitchable ]] half4 cloudySkies(
    float2 position, 
    half4 color, 
    float2 size,
    float time
) {
    float2 uv = position / size;
    
    // Two layers of clouds moving at different speeds
    float clouds1 = 0.0;
    float clouds2 = 0.0;
    
    // Layer 1: Large, slow clouds
    float2 offset1 = float2(time * 0.01, time * 0.005);
    for(int i = 0; i < 3; i++) {
        float freq = pow(2.0, float(i));
        clouds1 += valueNoise((uv + offset1) * freq * 2.0) / freq;
    }
    
    // Layer 2: Smaller, faster clouds
    float2 offset2 = float2(time * 0.02, -time * 0.01);
    for(int i = 0; i < 4; i++) {
        float freq = pow(2.0, float(i));
        clouds2 += valueNoise((uv + offset2) * freq * 4.0) / freq;
    }
    
    // Combine layers
    float cloudDensity = clouds1 * 0.7 + clouds2 * 0.3;
    cloudDensity = smoothstep(0.4, 0.7, cloudDensity);
    
    // Sky gradient
    float3 skyTop = float3(0.3, 0.5, 0.9);
    float3 skyBottom = float3(0.7, 0.8, 1.0);
    float3 skyColor = mix(skyBottom, skyTop, uv.y);
    
    // Cloud color
    float3 cloudColor = float3(1.0, 1.0, 1.0);
    
    // Mix sky and clouds
    float3 finalColor = mix(skyColor, cloudColor, cloudDensity);
    
    return half4(finalColor.r, finalColor.g, finalColor.b, 1.0);
}

Wood Grain

[[ stitchable ]] half4 woodGrain(
    float2 position, 
    half4 color, 
    float2 size
) {
    float2 uv = position / size;
    
    // Rotate for wood grain direction
    float angle = 0.1;  // Slight angle
    float2 rotatedUV = float2(
        uv.x * cos(angle) - uv.y * sin(angle),
        uv.x * sin(angle) + uv.y * cos(angle)
    );
    
    // Base wood ring pattern
    float rings = sin((rotatedUV.x + valueNoise(rotatedUV * 3.0) * 0.1) * 30.0);
    
    // Add fine detail
    float detail = valueNoise(rotatedUV * 50.0) * 0.1;
    float grain = rings + detail;
    
    // Wood colors
    half3 lightWood = half3(0.7, 0.5, 0.3);
    half3 darkWood = half3(0.4, 0.25, 0.1);
    
    half3 woodColor = mix(darkWood, lightWood, grain * 0.5 + 0.5);
    
    return half4(woodColor.r, woodColor.g, woodColor.b, 1.0);
}

Performance Optimization

Noise Performance Hierarchy (Fastest to Slowest)

  1. Hash-based random: Single calculation per pixel
  2. Value noise: 4 random lookups + interpolation
  3. Gradient noise: 4 gradient calculations + dot products
  4. Simplex noise: 3 corner contributions (better than 4!)
  5. Fractal noise: Multiple octaves multiply cost

Optimization Techniques

// EXPENSIVE: Calculating noise every frame
float noise = valueNoise(uv * 10.0 + time);

// OPTIMIZED: Pre-calculate when possible
float staticNoise = valueNoise(uv * 10.0);  // Calculate once
float animatedPart = sin(time + staticNoise * 2.0);  // Animate the result

// EXPENSIVE: Too many octaves
for(int i = 0; i < 10; i++) {  // 10 octaves is overkill

// OPTIMIZED: Minimum octaves for desired detail
for(int i = 0; i < 4; i++) {   // Usually sufficient

Common Pitfalls

Pitfall 1: Banding in Gradients

// WRONG: Visible stepping in smooth gradients
float noise = floor(valueNoise(uv * 10.0) * 10.0) / 10.0;  // Quantized

// CORRECT: Smooth gradients
float noise = valueNoise(uv * 10.0);

Pitfall 2: Incorrect Noise Scaling

// WRONG: Noise values can exceed 0-1 range
float noise = valueNoise(uv) * 2.0;  // Can be > 1!

// CORRECT: Keep in valid range or clamp
float noise = saturate(valueNoise(uv) * 2.0);

Pitfall 3: Performance Death by Octaves

// WRONG: Exponential performance cost
for(int octave = 0; octave < userOctaves; octave++) {  // Variable loop count

// CORRECT: Fixed, reasonable octave count
const int maxOctaves = 4;
for(int octave = 0; octave < maxOctaves; octave++) {

Challenges

Challenge 1: Static Texture Generator

Create a shader that generates TV static with controllable "grain size".

SwiftUI Template:

struct StaticTextureView: View {
    @State private var grainSize: Float = 100.0
    
    var body: some View {
        VStack {
            Rectangle() // Or replace with image
                .visualEffect { content, proxy in
                    content.colorEffect(ShaderLibrary.staticTexture(
                        .float2(proxy.size),
                        .float(grainSize)
                    ))
                }
                .frame(height: 300)
            
            HStack {
                Text("Grain Size")
                Slider(value: $grainSize, in: 10...200)
            }
            .padding()
        }
    }
}

Challenge 2: Animated Water Caustics

Create an animated water caustics effect using multiple layers of noise.

SwiftUI Template:

struct WaterCausticsView: View {
    @State private var timeValue: Float = 0.0
    let timer = Timer.publish(every: 1/60.0, on: .main, in: .common).autoconnect()
    
    var body: some View {
        Rectangle()
            .fill(Color.blue.opacity(0.3))
            .visualEffect { content, proxy in
                content.colorEffect(ShaderLibrary.waterCaustics(
                    .float2(proxy.size),
                    .float(timeValue)
                ))
            }
            .frame(height: 400)
            .onReceive(timer) { _ in
                timeValue += 1/60.0
            }
    }
}

Challenge 3: Procedural Fire

Create a fire effect using noise and color gradients.

SwiftUI Template:

struct ProceduralFireView: View {
    @State private var timeValue: Float = 0.0
    @State private var intensity: Float = 1.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.fireEffect(
                        .float2(proxy.size),
                        .float(timeValue),
                        .float(intensity)
                    ))
                }
                .frame(height: 300)
            
            HStack {
                Text("Intensity")
                Slider(value: $intensity, in: 0.5...2.0)
            }
            .padding()
        }
        .onReceive(timer) { _ in
            timeValue += 1/60.0
        }
    }
}

Challenge 4: Touch Particle System (Advanced)

Create a touchParticles shader that spawns particles at touch location with random properties using noise functions. Think about this as a magic drawing board.

Requirements:

  • Particles spawn at touch position with random velocities
  • Each particle has unique size, color variation, and lifetime
  • Particles fade out smoothly over time
  • Use noise for organic movement patterns
  • Visual: Like sparkles or fairy dust following your finger

SwiftUI Template:

struct TouchParticleView: View {
    @State private var touchPosition = CGPoint(x: 0.5, y: 0.5)
    @State private var isTouching = false
    @State private var timeValue: Float = 0.0
    @State private var particleIntensity: Float = 1.0
    @State private var colorScheme: Float = 0.0
    
    let timer = Timer.publish(every: 1/60.0, on: .main, in: .common).autoconnect()
    
    var body: some View {
        VStack {
            Text("Touch Particle System")
                .font(.title2)
                .padding()
            
            Text("Draw with your finger to create particles")
                .font(.caption)
                .foregroundColor(.secondary)
            
            GeometryReader { geometry in
                Rectangle()
                    .fill(Color.black)
                    .visualEffect { content, proxy in
                        content.colorEffect(ShaderLibrary.touchParticles(
                            .float2(proxy.size),
                            .float(timeValue),
                            .float2(Float(touchPosition.x), Float(touchPosition.y)),
                            .float(isTouching ? 1.0 : 0.0),
                            .float(particleIntensity),
                            .float(colorScheme)
                        ))
                    }
                    .gesture(
                        DragGesture(minimumDistance: 0)
                            .onChanged { value in
                                isTouching = true
                                touchPosition = CGPoint(
                                    x: value.location.x / geometry.size.width,
                                    y: value.location.y / geometry.size.height
                                )
                            }
                            .onEnded { _ in
                                isTouching = false
                            }
                    )
            }
            .frame(height: 400)
            .border(Color.gray, width: 1)
            
            VStack(spacing: 15) {
                HStack {
                    Text("Intensity")
                    Slider(value: $particleIntensity, in: 0.5...2.0)
                }
                
                Picker("Color Scheme", selection: Binding(
                    get: { Int(colorScheme) },
                    set: { colorScheme = Float($0) }
                )) {
                    Text("Fire").tag(0)
                    Text("Magic").tag(1)
                    Text("Ice").tag(2)
                }
                .pickerStyle(SegmentedPickerStyle())
            }
            .padding()
        }
        .onReceive(timer) { _ in
            timeValue += 1/60.0
        }
    }
}

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

  1. Why can't shaders use traditional random() functions?

    • Answer: Shaders execute the same code for every pixel simultaneously. A traditional random() would either return the same value for all pixels (defeating the purpose) or different values each frame (causing flickering). Shaders need deterministic functions that produce consistent "random" values based on position.
  2. What makes the hash function fract(sin(dot(st, float2(12.9898,78.233))) * 43758.5453) work?

    • Answer: The dot product combines 2D coordinates into a single value. Sin() creates non-linear variation. The large multiplier (43758.5453) amplifies tiny differences in the sine values. Fract() keeps the result in 0-1 range. The specific numbers aren't magical - they just produce good distribution.
  3. Why do we use multiple octaves of noise?

    • Answer: Natural textures have detail at multiple scales. Mountains have overall shape (low frequency) plus smaller rocks and details (high frequency). Each octave adds a different scale of detail, with decreasing amplitude to prevent high-frequency noise from dominating.
  4. What's the difference between value noise and gradient noise?

    • Answer: Value noise interpolates between random values at grid points, creating a smooth but somewhat blobby result. Gradient noise (Perlin) interpolates between random gradients/directions at grid points, creating more natural-looking variation with better properties for animation.
  5. How do you ensure noise-based animations don't flicker?

    • Answer: Use smooth, continuous movement through noise space (e.g., noise(uv + time * speed)) rather than using time to seed the randomness. The noise function itself should be continuous and smooth. Avoid recalculating random seeds every frame.
  6. Why is simplex noise better than classic Perlin noise?

    • Answer: Simplex noise uses triangular grids (simplexes) instead of square grids, requiring fewer calculations (3 corners vs 4 in 2D). It has better computational complexity, no directional artifacts, and scales better to higher dimensions. The visual quality is similar but performance is significantly better.

Further Exploration

  • Worley Noise: Cellular patterns based on distance to random points
  • Blue Noise: Even distribution without clumping
  • Curl Noise: Divergence-free noise for fluid simulation
  • Domain Warping: Using noise to distort noise for organic effects

Next Chapter Preview: You've learned to add organic randomness to your shaders. Chapter 7 introduces smoothstep and anti-aliasing techniques to create professional-quality edges and transitions. You'll understand why pixelated edges scream "amateur" and how to achieve the silky-smooth gradients that make viewers lean in closer.