Learn Creative Coding (#7) - Color Theory for Coders

in #stemyesterday (edited)

Learn Creative Coding (#7) - Color Theory for Coders

banner

What will I learn?

  • Why HSB is better than RGB for creative work;
  • complementary, analogous, and triadic color schemes;
  • generating harmonious palettes algorithmically;
  • lerp for smooth gradients;
  • practical techniques for making color choices that don't suck;
  • golden angle distribution for mathematically optimal palettes;
  • perceptual luminance correction using ITU-R BT.709;
  • building a complete generative art piece with balanced color.

Requirements

  • A modern web browser;
  • Episodes #1-6 of this series.

Difficulty

  • Beginner

Curriculum (of the Learn Creative Coding Series):


I used to pick colors by trying random RGB values until something looked okay. That's like tuning a guitar by randomly turning pegs until it sounds fine. It works eventually, but there's a much better way.

The problem with RGB

RGB (Red, Green, Blue) is how screens work — not how humans think about color. Quick: what RGB values make a warm sunset orange? fill(240, 150, 50)? fill(255, 130, 30)? You're guessing.

Now in HSB: "warm orange" is roughly hue 25, high saturation, high brightness. fill(25, 90, 95). Makes sense immediately.

Switch to HSB

function setup() {
  createCanvas(500, 100);
  colorMode(HSB, 360, 100, 100);
  
  noStroke();
  for (let x = 0; x < width; x++) {
    fill(map(x, 0, width, 0, 360), 80, 90);
    rect(x, 0, 1, height);
  }
}

This draws the entire hue spectrum. Red on the left, through orange, yellow, green, cyan, blue, purple, back to red. One number controls the "color" — the other two control intensity and lightness.

Hue (0-360): the color on the wheel. 0=red, 60=yellow, 120=green, 180=cyan, 240=blue, 300=magenta.

Saturation (0-100): how vivid. 0=grey, 100=full color.

Brightness (0-100): how light. 0=black, 100=full brightness.

From now on we work in HSB. Put colorMode(HSB, 360, 100, 100) at the top of setup and forget RGB exists.

Color relationships

Here's where actual color theory comes in. Certain hue combinations are mathematically guaranteed to look harmonious. They're all based on the color wheel.

Complementary (opposite)

Two hues 180 degrees apart. Maximum contrast, high energy.

function setup() {
  createCanvas(500, 300);
  colorMode(HSB, 360, 100, 100);
  background(0, 0, 95);
  noStroke();
  
  let baseHue = 200;  // blue
  let compHue = (baseHue + 180) % 360;  // orange
  
  fill(baseHue, 80, 85);
  rect(50, 50, 180, 200);
  
  fill(compHue, 80, 85);
  rect(270, 50, 180, 200);
}

Blue and orange. Teal and coral. Purple and yellow. Always works.

Analogous (neighbors)

Three hues within 30-60 degrees of each other. Calm, cohesive.

function setup() {
  createCanvas(500, 300);
  colorMode(HSB, 360, 100, 100);
  background(0, 0, 15);
  noStroke();
  
  let baseHue = 200;
  
  fill(baseHue - 30, 70, 80);
  rect(30, 50, 140, 200);
  
  fill(baseHue, 80, 85);
  rect(180, 50, 140, 200);
  
  fill(baseHue + 30, 70, 80);
  rect(330, 50, 140, 200);
}

Three shades of blue-to-cyan. Peaceful. This is what you want for backgrounds and atmospheric pieces.

Triadic (three evenly spaced)

Three hues 120 degrees apart. Vibrant but balanced.

function setup() {
  createCanvas(500, 300);
  colorMode(HSB, 360, 100, 100);
  background(0, 0, 95);
  noStroke();
  
  let baseHue = 10;  // red-orange
  
  fill(baseHue, 80, 85);
  ellipse(125, 150, 160, 160);
  
  fill((baseHue + 120) % 360, 80, 85);
  ellipse(250, 150, 160, 160);
  
  fill((baseHue + 240) % 360, 80, 85);
  ellipse(375, 150, 160, 160);
}

Red, green, blue — but more nuanced than that sounds. Triadic palettes are bold and work great for pieces with distinct visual elements.

Generating palettes algorithmically

Here's a function that creates a palette from any base hue:

function generatePalette(baseHue, scheme) {
  let hues = [];
  
  if (scheme === 'complementary') {
    hues = [baseHue, (baseHue + 180) % 360];
  } else if (scheme === 'analogous') {
    hues = [baseHue - 30, baseHue, baseHue + 30];
  } else if (scheme === 'triadic') {
    hues = [baseHue, (baseHue + 120) % 360, (baseHue + 240) % 360];
  } else if (scheme === 'split-complementary') {
    hues = [baseHue, (baseHue + 150) % 360, (baseHue + 210) % 360];
  }
  
  // add saturation and brightness variations
  let palette = [];
  for (let h of hues) {
    palette.push(color((h + 360) % 360, 80, 90));
    palette.push(color((h + 360) % 360, 50, 70));  // muted variant
  }
  
  return palette;
}

Call it with any hue and scheme, get back a usable palette. The muted variants (lower saturation, lower brightness) give you shading options.

Lerp: smooth gradients

lerpColor() blends between two colors smoothly:

function setup() {
  createCanvas(500, 200);
  colorMode(HSB, 360, 100, 100);
  
  let c1 = color(200, 80, 90);  // blue
  let c2 = color(350, 80, 90);  // pink
  
  noStroke();
  for (let x = 0; x < width; x++) {
    let t = x / width;
    fill(lerpColor(c1, c2, t));
    rect(x, 0, 1, height);
  }
}

A smooth gradient from blue to pink. The t value goes from 0 (100% first color) to 1 (100% second color). This is lerp (linear interpolation) applied to color.

Multi-stop gradients:

function multiGradient(colors, t) {
  let segment = 1 / (colors.length - 1);
  let index = floor(t / segment);
  index = constrain(index, 0, colors.length - 2);
  let localT = (t - index * segment) / segment;
  return lerpColor(colors[index], colors[index + 1], localT);
}

function setup() {
  createCanvas(500, 200);
  colorMode(HSB, 360, 100, 100);
  
  let stops = [
    color(240, 80, 30),   // dark blue
    color(200, 60, 60),   // medium blue
    color(40, 80, 95),    // orange
    color(50, 90, 100),   // yellow
  ];
  
  noStroke();
  for (let x = 0; x < width; x++) {
    fill(multiGradient(stops, x / width));
    rect(x, 0, 1, height);
  }
}

A sunset gradient with four color stops. This function works with any number of colors and you'll reuse it in countless projects.

Practical palette techniques

The 60-30-10 rule

Designers use this: 60% dominant color, 30% secondary, 10% accent. In code:

function setup() {
  createCanvas(500, 500);
  colorMode(HSB, 360, 100, 100);
  background(220, 15, 95);  // 60% — light blue-grey
  noStroke();
  
  // 30% — medium blue shapes
  fill(220, 50, 70);
  for (let i = 0; i < 30; i++) {
    ellipse(random(width), random(height), random(30, 80), random(30, 80));
  }
  
  // 10% — accent orange
  fill(25, 90, 95);
  for (let i = 0; i < 8; i++) {
    ellipse(random(width), random(height), random(10, 30), random(10, 30));
  }
}

The orange pops precisely because there's so little of it. If you made everything orange, nothing would stand out.

Desaturation for depth

Lower saturation on background elements, full saturation on foreground:

function setup() {
  createCanvas(500, 500);
  colorMode(HSB, 360, 100, 100);
  background(0, 0, 10);
  noStroke();
  
  // background layer — low sat
  for (let i = 0; i < 100; i++) {
    fill(200 + random(-20, 20), 20, 30, 50);
    ellipse(random(width), random(height), random(20, 60), random(20, 60));
  }
  
  // midground — medium sat
  for (let i = 0; i < 40; i++) {
    fill(200 + random(-10, 10), 50, 60, 70);
    ellipse(random(width), random(height), random(10, 30), random(10, 30));
  }
  
  // foreground — full sat
  for (let i = 0; i < 15; i++) {
    fill(30 + random(-10, 10), 90, 95);
    ellipse(random(width), random(height), random(5, 15), random(5, 15));
  }
}

This creates depth. Your eye is drawn to the bright, saturated foreground dots while the desaturated background recedes. Painters have used this trick for centuries — it's called aerial perspective.

Stealing palettes

No shame in this. Find art, photos, or design you love and extract the colors. Sites like coolors.co and colormind.io generate palettes. Or sample from a photo:

let palette = [
  '#2d3436', '#636e72', '#b2bec3', '#dfe6e9', '#e17055', '#d63031'
];

Hardcode five or six hex values and use random(palette) to pick from them. This is how most professional generative artists work — they curate palettes by eye, not by algorithm.

Going deeper: the golden ratio and perceptual luminance

Okay so the stuff above is "color theory 101" — the basics every designer knows. But we're coders, so lets go deeper and use some actual math to generate better palettes than most humans can pick by hand.

Golden angle distribution

Here's a problem: you want to generate N colors that are maximally spread across the hue wheel. Evenly spacing them works (360/N degrees apart), but if you later add more colors, the whole palette shifts. There's a better approach from nature — the golden angle.

The golden angle is 360 / φ² where φ (phi) is the golden ratio (1 + √5) / 2 ≈ 1.618. That gives us approximately 137.508 degrees. Sunflowers use this exact angle to pack seeds — each new seed rotates 137.5° from the last, and they never overlap no matter how many you add.

function setup() {
  createCanvas(600, 600);
  colorMode(HSB, 360, 100, 100);
  background(0, 0, 10);
  
  let goldenAngle = 137.508;
  let numColors = 12;
  
  // generate palette using golden angle
  let palette = [];
  for (let i = 0; i < numColors; i++) {
    let hue = (i * goldenAngle) % 360;
    palette.push(hue);
  }
  
  // draw color wheel with our distributed hues
  noStroke();
  let cx = width / 2;
  let cy = height / 2;
  let radius = 220;
  
  for (let i = 0; i < palette.length; i++) {
    let angle = radians(palette[i]);
    let x = cx + cos(angle) * radius;
    let y = cy + sin(angle) * radius;
    
    fill(palette[i], 85, 90);
    ellipse(x, y, 40, 40);
    
    // label with hue value
    fill(0, 0, 80);
    textAlign(CENTER);
    textSize(10);
    let lx = cx + cos(angle) * (radius + 30);
    let ly = cy + sin(angle) * (radius + 30);
    text(nf(palette[i], 0, 1) + '°', lx, ly);
  }
  
  // draw the wheel outline
  noFill();
  stroke(0, 0, 30);
  ellipse(cx, cy, radius * 2, radius * 2);
}

Run this. You'll get 12 hues beautifully spread across the wheel — and here's the key: you can change numColors to 5, 8, 20, 50, whatever, and the distribution ALWAYS looks good. No recalculation needed. That's the power of irrational numbers — they never repeat, so they never cluster.

Compare with 360 / N even spacing: if you go from 6 to 7 colors, every single hue shifts. With the golden angle, the first 6 stay exactly where they were and #7 just slots in between.

Perceptual luminance — why yellow looks brighter than blue

Here's something that'll change how you think about color: not all hues are equal brightness to the human eye. Pure yellow (hue 60) looks WAY brighter than pure blue (hue 240) even at the same saturation and brightness values. That's because our eyes have more green-sensitive cones than red or blue ones — an evolutionary leftover from needing to spot predators in foliage.

The ITU-R BT.709 standard (used in HDTV) defines perceptual luminance as:

L = 0.2126 × R + 0.7152 × G + 0.0722 × B

Those weights are NOT equal — green contributes 71.5% of perceived brightness! Lets build a function that compensates for this:

function perceivedBrightness(c) {
  // extract RGB from a p5 color object
  let r = red(c) / 255;
  let g = green(c) / 255;
  let b = blue(c) / 255;
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

function adjustForPerception(hue, targetLuminance) {
  // binary search for the brightness value that gives
  // us the target perceptual luminance at this hue
  let lo = 0, hi = 100;
  
  for (let i = 0; i < 20; i++) {
    let mid = (lo + hi) / 2;
    let c = color(hue, 80, mid);
    let lum = perceivedBrightness(c);
    
    if (lum < targetLuminance) {
      lo = mid;
    } else {
      hi = mid;
    }
  }
  
  return (lo + hi) / 2;
}

function setup() {
  createCanvas(600, 400);
  colorMode(HSB, 360, 100, 100);
  background(0, 0, 5);
  noStroke();
  
  let targetLum = 0.45;
  
  // top row: naive (same HSB brightness)
  for (let i = 0; i < 12; i++) {
    let hue = (i * 30) % 360;
    fill(hue, 80, 80);
    rect(i * 50, 30, 45, 140);
  }
  
  fill(0, 0, 70);
  textSize(12);
  text('Same HSB brightness (80) — notice yellow screams, blue hides', 10, 20);
  
  // bottom row: perceptually corrected
  for (let i = 0; i < 12; i++) {
    let hue = (i * 30) % 360;
    let correctedBri = adjustForPerception(hue, targetLum);
    fill(hue, 80, correctedBri);
    rect(i * 50, 230, 45, 140);
  }
  
  fill(0, 0, 70);
  text('Perceptually equal luminance — all hues look equally bright', 10, 220);
}

The top row is what most people do: same brightness value for every hue. Yellow screams at you while blue almost disappears. The bottom row uses the binary search to find what brightness each hue ACTUALLY needs to look equally bright. The difference is dramatic — this is how professional colorists work, and now you know the math behind it.

Putting it all together: a perceptually-balanced generative piece

Let's combine golden angle distribution + perceptual correction + everything we learned into one piece that generates a unique color-balanced composition:

let goldenAngle = 137.508;
let palette = [];
let shapes = [];

function setup() {
  createCanvas(800, 800);
  colorMode(HSB, 360, 100, 100);
  
  // generate perceptually balanced palette
  let baseHue = random(360);
  let numColors = 7;
  
  for (let i = 0; i < numColors; i++) {
    let hue = (baseHue + i * goldenAngle) % 360;
    let bri = adjustForPerception(hue, 0.5);
    palette.push({
      hue: hue,
      sat: 75 + random(15),
      bri: bri,
      weight: i === 0 ? 0.5 : (i < 3 ? 0.3 : 0.15)
    });
  }
  
  // generate shapes with 60-30-10 weighting
  for (let i = 0; i < 200; i++) {
    let ci = weightedColorPick(palette);
    let c = palette[ci];
    let layer = ci === 0 ? 'bg' : (ci < 3 ? 'mid' : 'fg');
    
    let size = layer === 'bg' ? random(60, 200) :
               layer === 'mid' ? random(20, 80) :
               random(5, 30);
    
    let satJitter = random(-10, 10);
    let briJitter = random(-15, 10);
    
    shapes.push({
      x: random(width),
      y: random(height),
      size: size,
      hue: c.hue,
      sat: constrain(c.sat + satJitter, 20, 100),
      bri: constrain(c.bri + briJitter, 10, 100),
      alpha: layer === 'bg' ? random(20, 50) :
             layer === 'mid' ? random(40, 75) : random(70, 100),
      type: random() > 0.6 ? 'circle' : 'rect'
    });
  }
  
  // sort by size so big shapes render first (painter's algorithm)
  shapes.sort((a, b) => b.size - a.size);
  
  // render
  background(palette[0].hue, 15, 95);
  noStroke();
  
  for (let s of shapes) {
    fill(s.hue, s.sat, s.bri, s.alpha / 100);
    if (s.type === 'circle') {
      ellipse(s.x, s.y, s.size);
    } else {
      push();
      translate(s.x, s.y);
      rotate(random(TWO_PI));
      rectMode(CENTER);
      rect(0, 0, s.size, s.size * random(0.5, 1.5));
      pop();
    }
  }
}

function weightedColorPick(pal) {
  let totalWeight = pal.reduce((sum, c) => sum + c.weight, 0);
  let r = random(totalWeight);
  let cumulative = 0;
  for (let i = 0; i < pal.length; i++) {
    cumulative += pal[i].weight;
    if (r <= cumulative) return i;
  }
  return pal.length - 1;
}

function perceivedBrightness(c) {
  let r = red(c) / 255;
  let g = green(c) / 255;
  let b = blue(c) / 255;
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

function adjustForPerception(hue, targetLuminance) {
  let lo = 0, hi = 100;
  for (let i = 0; i < 20; i++) {
    let mid = (lo + hi) / 2;
    let c = color(hue, 80, mid);
    let lum = perceivedBrightness(c);
    if (lum < targetLuminance) lo = mid;
    else hi = mid;
  }
  return (lo + hi) / 2;
}

Every time you run this, you get a unique composition with a mathematically balanced palette. The golden angle ensures the hues don't cluster, the perceptual correction means no single color dominates visually, and the weighted distribution gives you the 60-30-10 hierarchy automatically. Press Ctrl+Enter to regenerate endlessly — each one looks like someone spent 20 minutes picking colors by hand ;-)

Allez, wa weten we nu allemaal?

  • colorMode(HSB, 360, 100, 100) — switch to HSB and never look back
  • Hue = color (0-360), Saturation = vivid-ness (0-100), Brightness = lightness (0-100)
  • Complementary (+180), analogous (+/-30), triadic (+120, +240) — guaranteed harmony
  • lerpColor() for smooth gradients between any two colors
  • 60-30-10 rule: dominant, secondary, accent
  • Low saturation recedes, high saturation advances — use this for depth
  • Steal palettes from photos and art — no shame, everyone does it
  • Golden angle (137.508°) generates maximally-spread hues that scale to any N
  • Perceptual luminance (0.2126R + 0.7152G + 0.0722B) — use binary search to equalize perceived brightness across hues
  • Combine all of the above for generative art that looks professionally color-graded, automatically

Next episode: mini-project time. We'll combine everything from episodes 1-7 into a generative poster that creates a unique composition every time you run it. See you there. :-)

Merci voor het lezen!

X

@femdev

Sort:  

Thanks for your contribution to the STEMsocial community. Feel free to join us on discord to get to know the rest of us!

Please consider delegating to the @stemsocial account (85% of the curation rewards are returned).

Consider setting @stemsocial as a beneficiary of this post's rewards if you would like to support the community and contribute to its mission of promoting science and education on Hive. 
 

Congratulations @femdev! You have completed the following achievement on the Hive blockchain And have been rewarded with New badge(s)

You received more than 900 upvotes.
Your next target is to reach 1000 upvotes.

You can view your badges on your board and compare yourself to others in the Ranking
If you no longer want to receive notifications, reply to this comment with the word STOP