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:
- We read the input
color
- We modify only RGB, preserving alpha
- 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:
grayscale
: Convert to grayscale using proper weights (0.299R + 0.587G + 0.114B)sepia
: Apply sepia tone (hint: grayscale + brown tint)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:
- 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.
- 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.
- 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.
- 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).
- 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.