Color Mathematics

Core Concept: Color as computable vectors. Study RGB and HSV color spaces, SwiftUI Color integration, dark/light mode, and color interpolation/blending. Challenge: Theme-aware photo filter system.

You've Been Missing Half the Picture

In Chapter 2, you learned to use the position parameter to create patterns and shapes. But look at every shader function you've written:

half4 shader(float2 position, half4 color, float2 size)

See that color parameter? You've been ignoring it, treating shaders as pure generators. That's like using Photoshop only to draw rectangles.

The reality: Shaders are powerful because they can transform existing content. Every SwiftUI view, every image, every gradient you create becomes raw material for shader manipulation.

Concept Introduction: Color as Computation

Colors aren't "things" - they're vectors in 4D space. This isn't abstract mathematics; it's the fundamental insight that unlocks color manipulation:

  • Addition: Brightening, tinting, glowing
  • Multiplication: Darkening, masking, filtering
  • Interpolation: Blending, fading, transitioning
  • Transformation: Converting between color spaces

The color parameter gives you a starting point. Your job is to transform it.

Mathematical Foundation

Color Operations

Every color operation is vector math:

half4 c = half4(0.8, 0.4, 0.2, 1.0);  // Brownish color

// Arithmetic operations
c * 0.5              // Darken by 50%
c + 0.2              // Brighten (might overflow!)
1.0 - c              // Invert
c.rgb * c.a          // Premultiply alpha

The Mix Function

Linear interpolation is everywhere in graphics:

mix(a, b, t)  // Returns a when t=0, b when t=1
// Equivalent to: a * (1-t) + b * t

RGB: The Hardware Reality

RGB makes sense for computers:

  • Matches display hardware (red, green, blue subpixels)
  • Direct vector operations
  • No conversion overhead

But it's terrible for human intuition:

  • What RGB values make orange?
  • How do you make a color "more vivid"?
  • What's the "opposite" of purple in RGB?

HSV: The Human Model

HSV (Hue, Saturation, Value) maps to how we think:

  • Hue: What color? (0-360°, often normalized to 0-1)
  • Saturation: How pure? (0=gray, 1=vivid)
  • Value: How bright? (0=black, 1=bright)

Converting between them unlocks intuitive color manipulation.

Your First Image Processing Shader

[[ stitchable ]] half4 brighten(float2 position, half4 color, float2 size) {
    // Increase brightness by 20%
    return half4(color.rgb * 1.2, color.a);
}

Simple, but notice:

  1. We read the input color
  2. We modify only RGB, preserving alpha
  3. Values might exceed 1.0 (that's ok - they'll clamp)

Common Pitfalls

Pitfall 1: Forgetting Alpha Premultiplication

// WRONG - Ignores how alpha affects color
return half4(color.rgb + 0.5, color.a);

// CORRECT - Respects transparency
return half4(color.rgb + 0.5 * color.a, color.a);

Pitfall 2: Creating Invalid Colors

// WRONG - Can produce values > 1.0
half4 brightened = color * 2.0;

// CORRECT - Clamp to valid range
half4 brightened = clamp(color * 2.0, 0.0, 1.0);
// Or let the hardware clamp (often fine)

Pitfall 3: Assuming Input is Opaque

// WRONG - Assumes alpha = 1.0
return half4(1.0 - color.rgb, 1.0);

// CORRECT - Preserves original alpha
return half4(1.0 - color.rgb, color.a);

Color Space Conversions

// Simplified but functional RGB→HSV
float3 rgb2hsv(float3 c) {
    float4 K = float4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
    float4 p = mix(float4(c.bg, K.wz), float4(c.gb, K.xy), step(c.b, c.g));
    float4 q = mix(float4(p.xyw, c.r), float4(c.r, p.yzx), step(p.x, c.r));
    
    float d = q.x - min(q.w, q.y);
    float e = 1.0e-10;
    return float3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}

// HSV→RGB
float3 hsv2rgb(float3 c) {
    float4 K = float4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
    float3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
    return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}

Don't memorize this - understand what it enables:

[[ stitchable ]] half4 saturate(float2 position, half4 color, float2 size) {
    float3 hsv = rgb2hsv(color.rgb);
    hsv.y = clamp(hsv.y * 1.5, 0.0, 1.0);  // Increase saturation by 50%
    return half4(hsv2rgb(hsv), color.a);
}

Visual Explanation

RGB as a Cube

        Blue (0,0,1)
        /|\
       / | \
      /  |  \
     /   |   \
Black----+----Red (1,0,0)
(0,0,0)  |   /
     \   |  /
      \  | /
       \ |/
       Green (0,1,0)

HSV as a Cylinder

    Red (0°)
      |
Green---Yellow
(120°)  (60°)
  |       |
Cyan----Blue----Magenta
(180°) (240°)  (300°)

Saturation: Distance from center
Value: Height of cylinder

Challenges

Challenge 1: Basic Color Filters

Create these shaders:

  1. grayscale: Convert to grayscale using proper weights (0.299R + 0.587G + 0.114B)
  2. sepia: Apply sepia tone (hint: grayscale + brown tint)
  3. invert: Invert colors while preserving alpha

Challenge 2: Selective Color Modification

Create redBoost that:

  • Increases red channel by 50%
  • Only affects pixels that already have red > 0.5
  • Preserves original color elsewhere

Challenge 3: Color Temperature

Create warmth that:

  • Takes a parameter temperature (-1 to 1)
  • Negative values add blue (cold)
  • Positive values add orange (warm)
  • Preserves luminance

Challenge 4: Hue Rotation

Create hueRotate that:

  • Rotates all hues by a fixed amount (e.g., 120°)
  • Preserves saturation and value
  • Makes reds → greens, greens → blues, blues → reds

Challenge 5: Gradient Mapping

Create duotone that:

  • Converts input to grayscale
  • Maps black → deep blue, white → bright orange
  • Creates a cinematic "teal and orange" look

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 4:

  1. What happens when you add half4(0.5, 0.5, 0.5, 0.5) to a color?

It brightens the RGB channels by 0.5 and increases the alpha value by 0.5 (making it more opaque, since higher alpha = less transparent). This can push RGB values above 1.0, which will be clamped by the hardware.

  1. Why might color * 2.0 produce unexpected results?

It can produce RGB values greater than 1.0, which may be clamped to 1.0, causing highlights to blow out to pure white and losing color detail.

  1. How would you make a color 50% transparent?

Set the alpha channel to 0.5: half4(color.rgb, 0.5). Note: multiplying existing alpha by 0.5 (color.a * 0.5) would make it 50% more transparent than it currently is, not 50% transparent absolutely.

  1. What's the HSV representation of pure yellow?

Hue = 60° (or 1/6 in normalized 0-1 range), Saturation = 1.0 (fully saturated), Value = 1.0 (full brightness).

  1. Why preserve the original alpha in most effects?

Because alpha represents the intended transparency of the original content. Changing it would affect how the view composites with others behind it, breaking the expected blending behavior.

Debugging Tip

Visualize individual channels:

return half4(color.r, 0, 0, 1);  // Red channel only
return half4(color.a, color.a, color.a, 1);  // Alpha as grayscale

Further Exploration

  • Color Theory: Research complementary colors and color harmony
  • Perception: Why we use different weights for grayscale conversion
  • HDR: What happens beyond the 0-1 range?

Next Chapter Preview: You've learned to paint with solid colors and modify existing colors. Chapter 4 introduces the mathematical functions that create patterns: sin, cos, fract, and more. You'll create waves, pulses, and rhythms that bring static shaders to life.