Procedural Patterns

Core Concept: Infinite complexity from simple rules. Learn grid systems, space repetition, device adaptation, pattern scaling, and coordinate transformation patterns. Challenge: Dynamic wallpaper responding to device rotation.

The Universe in a Grid Square

In Chapter 4, you learned how mathematical functions create waves and patterns. Now we'll take this further: using simple rules to generate infinite complexity.

Before reading further, look at your fingerprint. See those swirls? Now look at a fern leaf, a snowflake, honeycomb, or zebra stripes.

These patterns share a secret: they're not drawn, they're grown. Simple rules, applied repeatedly in space, create infinite complexity. This chapter teaches you to grow patterns, not draw them.

The Aha Moment That Changes Everything

Here's the breakthrough: When you write fract(uv * 5), you're not just "repeating a pattern 5 times." You're creating 5×5 parallel universes, each unaware of the others, each computing the same rules in isolation.

This is procedural generation:

  • One rule → Applied everywhere → Emergent complexity
  • Local behavior → Global patterns
  • Simple math → Organic results

Concept Introduction: The Three Pillars of Procedural Magic

Pillar 1: Space Duplication with fract()

float2 grid = fract(uv * 10.0);

This single line creates 100 copies of your coordinate system. But here's the beautiful part - you can still access which copy you're in:

float2 grid = fract(uv * 10.0);      // Local: Where am I in my cell? (0-1)
float2 cellID = floor(uv * 10.0);    // Global: Which cell am I in? (0-9, 0-9)

Pillar 2: The Random Seed Magic

Each cell needs unique randomness, but shaders can't use random(). The solution? A beautiful hack:

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

This isn't really random - it's deterministic chaos. Same input always gives same output, but the output looks random.

Pillar 3: Pattern Transformation

Before repetition, transform. After repetition, combine. This order matters:

// Different results!
float2 rotated_then_grid = fract(rotate(uv) * 5.0);  // Rotated grid
float2 grid_then_rotated = rotate(fract(uv * 5.0));  // Each cell rotated

Your First Living Pattern

Let's build something that makes people say "How is this just math?"

[[ stitchable ]] half4 livingMosaic(float2 position, half4 color, float2 size, float time) {
    // Convert to UV coordinates (see Chapter 2 for detailed explanation)
    float2 uv = position / size;
    
    // Create grid
    float gridSize = 8.0;
    float2 grid = fract(uv * gridSize);
    float2 cellID = floor(uv * gridSize);
    
    // Unique random per cell
    float cellRandom = fract(sin(dot(cellID, float2(12.9898, 78.233))) * 43758.5453);
    
    // Each cell has different behavior based on its random value
    float pattern = 0.0;
    
    if (cellRandom < 0.33) {
        // Circles that pulse
        float2 center = grid - 0.5;
        float pulse = sin(time * 2.0 + cellRandom * 6.28) * 0.1 + 0.3;
        pattern = 1.0 - smoothstep(pulse - 0.05, pulse, length(center));
    } 
    else if (cellRandom < 0.66) {
        // Rotating crosses
        float angle = time + cellRandom * 6.28;
        float2 rotGrid = float2(
            grid.x * cos(angle) - grid.y * sin(angle),
            grid.x * sin(angle) + grid.y * cos(angle)
        );
        float cross = min(
            smoothstep(0.4, 0.45, abs(rotGrid.x - 0.5)),
            smoothstep(0.4, 0.45, abs(rotGrid.y - 0.5))
        );
        pattern = 1.0 - cross;
    }
    else {
        // Diagonal stripes
        float stripes = sin((grid.x + grid.y) * 10.0 + time * 3.0) * 0.5 + 0.5;
        pattern = stripes;
    }
    
    // Color based on cell position
    half3 cellColor = half3(
        sin(cellID.x * 0.3 + 1.0),
        sin(cellID.y * 0.3 + 2.0),
        sin(cellID.x * cellID.y * 0.1 + 3.0)
    ) * 0.5 + 0.5;
    
    return half4(pattern * cellColor.r, pattern * cellColor.g, pattern * cellColor.b, 1.0);
}

The Patterns That Broke My Brain (First Time I Saw Them)

Truchet Tiles: Order from Chaos

[[ stitchable ]] half4 truchetMagic(float2 position, half4 color, float2 size) {
    float2 uv = position / size;
    
    float2 grid = fract(uv * 8.0);
    float2 cellID = floor(uv * 8.0);
    
    // Random rotation per cell
    float rand = fract(sin(dot(cellID, float2(12.9898, 78.233))) * 43758.5453);
    
    // Rotate grid coordinates for some cells
    if (rand > 0.5) {
        grid = float2(1.0 - grid.y, grid.x);
    }
    
    // Draw quarter circles in opposite corners
    // Arc from bottom-left corner
    float arc1 = length(grid);
    // Arc from top-right corner
    float arc2 = length(grid - float2(1.0, 1.0));
    
    // Create the line pattern (where the arcs should be)
    float lineWidth = 0.03;
    float line = 0.0;
    
    // First arc (radius 0.5, centered at 0,0)
    if (abs(arc1 - 0.5) < lineWidth) {
        line = 1.0;
    }
    
    // Second arc (radius 0.5, centered at 1,1)
    if (abs(arc2 - 0.5) < lineWidth) {
        line = 1.0;
    }
    
    return half4(line, line, line, 1.0);
}

Why this is mind-blowing: Random tiles create continuous paths! The arcs always connect because math guarantees it.

Voronoi: Nature's Favorite Pattern

[[ stitchable ]] half4 voronoiOrganic(float2 position, half4 color, float2 size, float time) {
    float2 uv = position / size;
    float2 scaledUV = uv * 5.0;
    
    float2 gridID = floor(scaledUV);
    float2 gridUV = fract(scaledUV);
    
    float minDist1 = 10.0;  // Closest
    float minDist2 = 10.0;  // Second closest
    float2 closestID;
    
    // Check 3x3 grid of neighbors
    for (int y = -1; y <= 1; y++) {
        for (int x = -1; x <= 1; x++) {
            float2 neighbor = float2(x, y);
            float2 cellID = gridID + neighbor;
            
            // Animated random point in each cell
            float2 pointOffset = float2(
                fract(sin(dot(cellID, float2(12.9898, 78.233))) * 43758.5453),
                fract(sin(dot(cellID, float2(78.233, 12.9898))) * 43758.5453)
            );
            
            // Add subtle animation
            pointOffset += sin(time * 2.0 + pointOffset * 6.28) * 0.1;
            
            float2 point = neighbor + pointOffset;
            float dist = length(point - gridUV);
            
            if (dist < minDist1) {
                minDist2 = minDist1;
                minDist1 = dist;
                closestID = cellID;
            } else if (dist < minDist2) {
                minDist2 = dist;
            }
        }
    }
    
    // Color based on cell
    half3 cellColor = half3(
        fract(sin(closestID.x * 12.9898) * 43758.5453),
        fract(sin(closestID.y * 78.233) * 43758.5453),
        fract(sin(dot(closestID, float2(45.678, 98.765))) * 43758.5453)
    );
    
    return half4(cellColor, 1.0);
}

Advanced Techniques: When Simple Rules Create Complex Beauty

Hexagonal Grids (Why Bees Are Mathematicians)

[[ stitchable ]] half4 hexagonalHoney(float2 position, half4 color, float2 size) {
    float2 uv = position / size;
    
    // Aspect correction
    float aspect = size.x / size.y;
    uv.x *= aspect;
    
    // Scale
    float2 p = uv * 10.0;
    
    // Hexagonal grid math
    float2 hex = float2(1.0, 1.73205); // 1, sqrt(3)
    float2 a = fmod(p, hex) - hex * 0.5;
    float2 b = fmod(p - hex * 0.5, hex) - hex * 0.5;
    
    // Find closest hexagon center
    float2 gv = (dot(a, a) < dot(b, b)) ? a : b;
    
    // Proper hexagon distance (not circular!)
    float2 absGv = abs(gv);
    float d = max(dot(absGv, float2(0.866025, 0.5)), absGv.x);
    
    // Create honeycomb walls
    float walls = smoothstep(0.45, 0.40, d);
    
    // Honey color inside cells, dark walls
    half3 honeyColor = half3(1.0, 0.85, 0.0);
    half3 wallColor = half3(0.4, 0.2, 0.0);
    
    half3 finalColor = mix(wallColor, honeyColor, walls);
    
    return half4(finalColor.r, finalColor.g, finalColor.b, 1.0);
}

Domain Warping (The Secret to Organic Patterns)

[[ stitchable ]] half4 domainWarpPattern(float2 position, half4 color, float2 size, float time) {
    float2 uv = position / size;
    
    // First pattern to warp space
    float2 q = float2(
        sin(uv.x * 4.0 + time * 0.3),
        cos(uv.y * 4.0 + time * 0.2)
    ) * 0.1;
    
    // Warp the coordinates
    float2 warpedUV = uv + q;
    
    // Apply pattern to warped space
    float2 grid = fract(warpedUV * 8.0);
    float2 center = grid - 0.5;
    
    float circles = 1.0 - smoothstep(0.2, 0.25, length(center));
    
    // Second layer of warping for organic feel
    float warp2 = sin(length(uv - 0.5) * 10.0 - time) * 0.5 + 0.5;
    
    float r = circles * warp2;
    float g = circles * warp2 * 0.7;
    float b = circles * warp2 * 0.5;
    
    return half4(r, g, b, 1.0);
}

Pattern Philosophy: Why This Matters

Procedural patterns aren't just "cool effects." They represent a fundamental principle:

Complex systems emerge from simple rules + local interactions

This applies to:

  • Biology (cell division, plant growth)
  • Physics (crystal formation, fluid dynamics)
  • Society (traffic patterns, market behavior)
  • Art (Islamic geometry, Celtic knots)

When you master procedural patterns, you're learning to think like nature itself.

Common Pitfalls (And Their Fixes)

Pitfall 1: The Aspect Ratio Trap

// WRONG - Squares become rectangles!
float2 grid = fract(uv * 10.0);

// CORRECT - Perfect squares regardless of screen shape
float aspectRatio = size.x / size.y;
float2 grid = fract(float2(uv.x * aspectRatio, uv.y) * 10.0);

Pitfall 2: The Random Correlation Bug

// WRONG - X and Y randomness correlated
float randX = fract(sin(cellID.x * 12.9898) * 43758.5453);
float randY = fract(sin(cellID.y * 12.9898) * 43758.5453);  // Same multiplier!

// CORRECT - Independent randomness
float randX = fract(sin(cellID.x * 12.9898) * 43758.5453);
float randY = fract(sin(cellID.y * 78.233) * 43758.5453);   // Different multiplier

Pitfall 3: Performance Death by Neighbors

// WRONG - Checking too many cells kills performance
for (int y = -5; y <= 5; y++) {
    for (int x = -5; x <= 5; x++) {  // 121 iterations per pixel!

// CORRECT - Minimum necessary neighbors
for (int y = -1; y <= 1; y++) {
    for (int x = -1; x <= 1; x++) {  // Only 9 iterations

Challenges

Challenge 1: Checkerboard Plus

Create an animated checkerboard where white squares have rotating crosses and black squares have pulsing circles.

Challenge 2: Islamic Geometric Pattern

Create a pattern with 8-fold symmetry using rotated and mirrored grids. Think mosque tiles.

Challenge 3: Animated Ball Along Path

Draw a zigzag (triangular sine) path and a circular ball animated along the path.

Challenge 4: Reaction-Diffusion Approximation

Simulate organic growth patterns by combining multiple scales of voronoi noise.

Challenge 5: Quasicrystal Pattern

Create a Penrose-like pattern with 5-fold symmetry - seemingly impossible with regular grids!

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. What's the mathematical relationship between fract() and fmod()?

fract(x) is equivalent to fmod(x, 1.0) - both return the fractional part (0.0-1.0). However, fract() is specifically optimized for this common graphics operation and handles negative numbers consistently.

  1. How do you ensure each grid cell has unique but deterministic randomness?

Use the cell ID (from floor(uv * gridSize)) as input to a hash function like fract(sin(dot(cellID, float2(12.9898, 78.233))) * 43758.5453). Same cell ID always produces the same "random" value, but different cells get different values.

  1. Why do truchet tiles create continuous patterns despite being randomly oriented?

Because the tile designs (quarter circles) are mathematically designed to connect properly regardless of rotation. When rotated 90°, the arc endpoints still align with neighboring tiles' endpoints, guaranteeing continuous paths.

  1. What's the key insight behind voronoi patterns?

Find the closest point (seed) to each pixel by checking distance to random points in the current cell and all neighboring cells. Color each pixel based on which seed is closest, creating natural-looking cellular regions like those found in biology.

  1. How does domain warping create organic-looking patterns?

Apply one pattern to distort the coordinate space, then apply a second pattern to the warped coordinates. This breaks the mechanical regularity of grid-based patterns, mimicking how forces in nature (wind, gravity, growth) distort underlying structures.

  1. Why are hexagonal grids optimal for certain natural patterns?

Hexagons provide the most efficient packing (maximum area with minimum perimeter) and have 6-fold symmetry. This efficiency appears in nature (honeycomb, crystal structures) because it minimizes energy while maximizing utility.

Debug Visualization Tricks

// Visualize your grid structure
float2 cellID = floor(uv * 10.0);
float debugColor = fract(sin(dot(cellID, float2(12.9898, 78.233))) * 43758.5453);
return half4(debugColor, fract(debugColor * 3.14), fract(debugColor * 7.0), 1.0);

// Show grid boundaries
float2 grid = fract(uv * 10.0);
float boundary = step(0.95, grid.x) + step(0.95, grid.y);
return half4(boundary, 0.0, 0.0, 1.0);

Further Exploration

  • Penrose Tilings: True mathematical aperiodicity
  • Wang Tiles: Computer science meets art
  • L-Systems: Recursive pattern generation
  • Cellular Automata: Conway's Game of Life in shaders

Next Chapter Preview: You've mastered deterministic patterns. But nature isn't perfect - it has variation, randomness, texture. Chapter 6 introduces noise functions, the secret sauce that transforms mechanical patterns into organic beauty. You'll learn why Perlin noise won a Technical Oscar and how to create everything from clouds to marble to alien landscapes.