Module 03

Color theory for the fragment shader

RGB vs. HSV — same color, different knobs

RGB is how the screen stores a color. HSV is how you'll actually think about one: hue picks the color family, saturation picks how vivid it is, value picks how bright. Want five colors that all feel like they belong to the same palette? Fix saturation and value, and just sweep the hue. Try doing that by hand in RGB — you can't, not without a mental model of the conversion.

One nearby trap: HSL looks almost identical to HSV but isn't. HSV's "value" tops out at pure, saturated color — turn value all the way up and you get the most vivid version of that hue. HSL's "lightness" keeps going past that point toward white, so a pure hue actually sits in the middle of the lightness range. If a design tool hands you HSL values and your shader math assumes HSV, colors will come out wrong in a way that's hard to spot just by looking at the numbers.

mix() is just linear interpolation

mix(a, b, t) walks from a to b as t goes from 0 to 1 — the same lerp you've used for motion, now applied to color. It's the entire mechanism behind gradients, cross-fades, and the palette trick below.

One formula, endless palettes

a (baseline) b (amplitude)
One channel of the formula, plotted plainly: a is where the wave centers, b is how far it swings. Stagger three of these — one per color channel — and you get a rainbow.

Inigo Quilez's cosine palette formula is deceptively small: color = a + b * cos(2π * (c*t + d)). Four vec3s control everything — a is the base brightness, b is the contrast, c is how many times each channel cycles across t, and d is a phase offset per channel. Stagger d differently for red, green, and blue and you get a smooth, cycling rainbow from one function. Keep all three channels identical and you get grayscale. It's the same handful of numbers either way — you're just choosing where each channel starts on its own wave.

Gamma — the thing every other resource assumes you already know

Here's a gap almost nobody fills in: the numbers you store in a color aren't linear with the light that actually comes out of the screen. Displays (and the sRGB standard your colors are usually stored in) apply a curve — roughly output = input^2.2 — so a stored value of 0.5 is nowhere near half as bright as 1.0.

Why this matters to you specifically: mix()-ing two colors, or blending noise into a gradient, does plain linear math on whatever numbers you hand it. Do that directly on sRGB-encoded values and the midpoint comes out darker and muddier than it should — because you averaged two points on a curve as if the curve were a straight line. The fix, when it matters, is to convert to linear space first (roughly pow(color, vec3(2.2))), do your math, then convert back (pow(color, vec3(1.0/2.2))). You won't need this for every blend in this course, but the first time a gradient looks "muddy in the middle" for no reason you can find, this is almost always why.

Predict the output

You mix() two bright colors straight from a color picker, and the middle of the gradient looks noticeably darker and muddier than either end. What's the most likely fix?