Encoding Color Numerically: RGB, color spaces, and gamma
Learning objectives
- Learner can explain why RGB numbers are meaningless without a color space, and normalize/encode them correctly
- Learner can trace trichromacy → CIE matching functions → XYZ hub → chromaticity diagram, and read a gamut and white point
- Learner can perform color math in linear light, convert between spaces by matrix, and reason about gamut/HDR edge cases
Capstone — one whole task that evidences the objectives
Build a small color-space toolkit in code: normalize RGB, linearize using the sRGB TRC, convert linear RGB↔XYZ via a matrix derived from primary matching, plot the working gamut on the xy chromaticity diagram with its white point, and demonstrate one out-of-gamut/extended-range case — all annotated with why each step must happen in linear light.
Prerequisite modules
Every live-coded visual ends as three numbers per pixel, and on a real rig those numbers get reinterpreted constantly: a vec3 in your shader, an sRGB PNG texture, a projector with its own primaries, an HDR-capable LED wall. When a gradient goes muddy mid-set or a palette shifts between your laptop and the venue screen, the cause is almost always color math done on encoded values or numbers moved between spaces without conversion. This module builds the whole task of a working color-space toolkit — the code you will lean on every time you blend, fade, or port visuals across displays.
The arc starts supported and concrete: first internalize that “RGB numeric values have no meaning without a color space” and drill normalizing 0–255 palettes into the 0.0–1.0 floats shaders expect. Then follow the science upward — trichromacy explains why three numbers suffice, the CIE color matching functions show how that was measured, and CIE XYZ emerges as the device-independent hub. With “matching pure primaries individually” as your JIT procedure and Grassmann’s laws as its license, you derive a 3×3 conversion matrix yourself rather than pasting one. The sRGB piecewise TRC and the principle that color arithmetic must happen in linear light become the habits you re-apply at every step, until the unsupported capstone — toolkit, gamut plot with white point, and one out-of-gamut or extended-range demonstration — needs no scaffolding.
Required atoms are exactly what the capstone cannot ship without: encoding, the XYZ pipeline, matrix derivation, and the edge-case concepts (negative primaries, extended range) your demonstration must exercise. Supporting atoms enrich judgment — why magenta is a brain construction, why the xy diagram lies about distance, why perceptual encoding exists at all — deepening the annotations without gating the build.
Walkthrough
Every colour on screen is three numbers — but the numbers mean nothing without a space and an encoding. You’ll build a small toolkit that normalizes, linearizes, converts to XYZ, and plots the gamut. Paste each sketch into the p5 web editor and press Run (▶). Each step is a complete, standalone sketch.
1 — normalize: the numbers need a range. p5 speaks 0–255 by default; shaders speak 0.0–1.0; both describe the same colour once you know the range. Divide by 255 to normalize, multiply back to denormalize — the two swatches here are identical ([[rgb-color-space-numeric-meaning]], [[normalized-rgb-range]]).
function setup() { createCanvas(400, 400); noStroke(); }
function draw() {
background(20);
let r255 = 200, g255 = 120, b255 = 60;
fill(r255, g255, b255); // 0–255 space
rect(40, 40, 150, 320);
let r = r255 / 255, g = g255 / 255, b = b255 / 255; // normalized 0..1
fill(r * 255, g * 255, b * 255); // denormalized back — identical colour
rect(210, 40, 150, 320);
}
2 — the sRGB tone curve (encoding). Stored RGB isn’t proportional to light — it’s gamma-encoded so bits cluster where the eye is sensitive. The sRGB TRC decodes an encoded value to linear light. The top ramp (encoded) looks even; the bottom (linearized) shows the real light distribution ([[srgb-tone-response-curve]], [[srgb-standard-color-space]]).
function toLinear(c) { return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4); }
function setup() { createCanvas(400, 400); noStroke(); }
function draw() {
background(20);
for (let x = 0; x < width; x++) {
let e = x / width; // encoded (sRGB) value
fill(e * 255); rect(x, 60, 1, 130); // encoded ramp — perceptually even
fill(toLinear(e) * 255); rect(x, 210, 1, 130); // linearized — physically even, looks dark
}
}
3 — do the math in linear light. Blending encoded values is wrong — it goes muddy. Decode to linear, average, re-encode: the correct mixture is brighter and cleaner. This is the habit behind every gradient, fade, and blend ([[linearize-before-color-math]]).
function toLinear(c) { return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4); }
function toSRGB(c) { return c <= 0.0031308 ? c * 12.92 : 1.055 * pow(c, 1 / 2.4) - 0.055; }
function setup() { createCanvas(400, 400); noStroke(); }
function draw() {
background(20);
let a = [1, 0, 0], b = [0, 1, 0]; // red, green
fill((a[0] + b[0]) / 2 * 255, (a[1] + b[1]) / 2 * 255, (a[2] + b[2]) / 2 * 255);
rect(40, 40, 150, 320); // naive encoded average — muddy
let mix = [0, 1, 2].map(i => toSRGB((toLinear(a[i]) + toLinear(b[i])) / 2));
fill(mix[0] * 255, mix[1] * 255, mix[2] * 255);
rect(210, 40, 150, 320); // linear-light average — correct, brighter
}
4 — to XYZ and chromaticity. CIE XYZ is the device-independent hub: a 3×3 matrix (from the sRGB primaries) maps linear RGB into it. Divide out brightness and you get xy chromaticity — where a colour lives on the horseshoe. Plot one point ([[cie-xyz-device-independent-color-space]], [[color-space-conversion-matrix]], [[xy-chromaticity-diagram]], [[trichromacy-three-cone-types]]).
function toLinear(c) { return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4); }
function rgb2xyz(r, g, b) {
r = toLinear(r); g = toLinear(g); b = toLinear(b);
return [0.4124 * r + 0.3576 * g + 0.1805 * b,
0.2126 * r + 0.7152 * g + 0.0722 * b,
0.0193 * r + 0.1192 * g + 0.9505 * b];
}
function setup() { createCanvas(400, 400); noStroke(); }
function draw() {
background(20);
let xyz = rgb2xyz(0.9, 0.3, 0.2);
let s = xyz[0] + xyz[1] + xyz[2];
let cx = xyz[0] / s, cy = xyz[1] / s;
fill(230, 120, 80);
circle(40 + cx * 320, height - 40 - cy * 320, 16); // this colour's xy position
}
5 — plot the gamut and white point. The sRGB gamut is the triangle of its three primaries in xy; every colour it can show lives inside. The D65 white point is where equal-energy RGB lands. Plotting them is the toolkit’s readout ([[color-space-gamut]], [[color-space-primary-colors]], [[white-point-color-space]], [[primary-matching-procedure]]).
function setup() { createCanvas(400, 400); }
function draw() {
background(18);
let R = [0.64, 0.33], G = [0.30, 0.60], B = [0.15, 0.06], W = [0.3127, 0.3290];
let X = p => 40 + p[0] * 320, Y = p => height - 40 - p[1] * 320;
stroke(200); noFill();
triangle(X(R), Y(R), X(G), Y(G), X(B), Y(B)); // the sRGB gamut
noStroke();
fill(255, 80, 80); circle(X(R), Y(R), 12);
fill(80, 255, 80); circle(X(G), Y(G), 12);
fill(80, 80, 255); circle(X(B), Y(B), 12);
fill(255); circle(X(W), Y(W), 10); // D65 white point
}
6 — the toolkit (the capstone). All of it: normalize → linearize → XYZ → xy, the gamut triangle with its white point, plus an out-of-gamut point (a chromaticity outside the triangle — a colour sRGB can only reach with negative primaries). One annotated readout of the whole pipeline ([[out-of-gamut-negative-primaries]]):
function toLinear(c) { return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4); }
function rgb2xyz(r, g, b) {
r = toLinear(r); g = toLinear(g); b = toLinear(b);
return [0.4124 * r + 0.3576 * g + 0.1805 * b, 0.2126 * r + 0.7152 * g + 0.0722 * b, 0.0193 * r + 0.1192 * g + 0.9505 * b];
}
function xy(r, g, b) { let c = rgb2xyz(r, g, b); let s = c[0] + c[1] + c[2]; return [c[0] / s, c[1] / s]; }
function setup() { createCanvas(400, 400); }
function draw() {
background(15);
let R = [0.64, 0.33], G = [0.30, 0.60], B = [0.15, 0.06], W = [0.3127, 0.3290];
let X = p => 40 + p[0] * 320, Y = p => height - 40 - p[1] * 320;
stroke(160); noFill(); triangle(X(R), Y(R), X(G), Y(G), X(B), Y(B));
noStroke();
fill(255, 80, 80); circle(X(R), Y(R), 12); fill(80, 255, 80); circle(X(G), Y(G), 12);
fill(80, 80, 255); circle(X(B), Y(B), 12); fill(255); circle(X(W), Y(W), 9);
let inside = xy(0.8, 0.4, 0.2); // an in-gamut sample
fill(255, 200, 120); circle(X(inside), Y(inside), 10);
stroke(255, 60, 60); strokeWeight(2); noFill();
circle(X([0.05, 0.75]), Y([0.05, 0.75]), 16); // out of sRGB gamut (needs negative primaries)
}
What good looks like. The toolkit should make invisible errors visible: the linear-vs-encoded ramps look obviously different, the correct blend is obviously brighter than the naive one, and the out-of-gamut point sits clearly outside the triangle. The beginner failure this whole module prevents is doing colour math on encoded values — the reason gradients go muddy and colours shift between your laptop and the venue projector. Annotate each step with which space the numbers are in. (Skill map: live-visualist Domain B2 / A3 — colour that survives being blended, faded, and ported across displays.)
Now make it yours. Draw the full spectral-locus horseshoe behind the triangle. Add a second (wider) gamut like Display-P3 and compare. Animate a colour walking from inside to outside the gamut. Feed the linear-light blend into your [[generating-palettes-in-hsb|palette generator]] so ramps stay clean. Demonstrate an extended-range (>1.0) value and where it lands.
Runnable examples
Generated from the context/ instrument corpus by concept (redistributable idioms only). Do not edit — regenerate with gen-module-examples.mjs.
palette-cycle
osc(30, 0.1, 1).colorama(0.1).out()
hydra-0015 · CC0-1.0
hsvrgb [fract (ft/6.28 + 0.1*time), 1, 1] >> rgb
punctual-0024 · CC0-1.0
gamma-correction
col = pow(col, vec3(1.0/2.2));
glsl-0021 · public-domain
pow ([lo,mid,hi]) 0.4545 >> rgb
punctual-0034 · CC0-1.0
Atoms in this module
Required — these gate the capstone
Supporting — enrichment, not gating
Part of curricula
- Live Visualist — zero to performing live-coded & generative visuals — Generative canvas — colour, motion, and Hydra live-coding required
- Shader Artist — real-time GPU craft to a demoscene-grade visual — The fragment shader as a per-pixel instrument recommended