9

Additive vs Subtractive Color Mixing

Drag any circle to move it

A split-screen demo of additive and subtractive color mixing: on the left, red, green and blue spotlights overlap on black and their light adds toward white; on the right, cyan, magenta and yellow filters overlap on white and each one subtracts light, multiplying down toward black. Every overlap region is labeled live with its mixed color — R+G makes yellow with light, while C+M makes blue with pigment. Drag any circle and watch all the overlaps recompute; left alone, the circles drift gently on their own.

idle
106 lines · vanilla
view source
const ADD = ["#ff0000", "#00ff00", "#0000ff"];
const SUB = ["#00ffff", "#ff00ff", "#ffff00"];
const PAIRS = [
  [[0, 1, "R+G = YELLOW"], [1, 2, "G+B = CYAN"], [0, 2, "R+B = MAGENTA"]],
  [[0, 1, "C+M = BLUE"], [1, 2, "M+Y = RED"], [0, 2, "C+Y = GREEN"]],
];
const TRIPLE = ["R+G+B = WHITE", "C+M+Y = BLACK"];
let circles, dragIdx, wasDown;
let px, py;
let W = 0, H = 0;

function init({ width, height }) {
  W = width; H = height;
  circles = [];
  for (let p = 0; p < 2; p++) {
    for (let i = 0; i < 3; i++) {
      const a = (-90 + i * 120) * Math.PI / 180;
      circles.push({ panel: p, bx: Math.cos(a) * 0.55, by: Math.sin(a) * 0.55, ph: p * 2.1 + i * 1.7 });
    }
  }
  px = new Float32Array(6); py = new Float32Array(6);
  dragIdx = -1; wasDown = false;
}

function panelRect(k) {
  // side-by-side in landscape, stacked in portrait
  let x, y, w, h;
  if (W >= H) { w = W / 2; h = H; x = k * w; y = 0; }
  else { w = W; h = H / 2; x = 0; y = k * h; }
  const cx = x + w / 2, cy = y + 26 + (h - 26) / 2;
  const r = Math.min(w, h - 30) * 0.30;
  return { x, y, w, h, cx, cy, r };
}

function tick({ ctx, time, width, height, input }) {
  if (width !== W || height !== H) { W = width; H = height; }
  input.consumeClicks();

  // circle pixel positions (base + idle drift)
  for (let i = 0; i < 6; i++) {
    const c = circles[i], p = panelRect(c.panel);
    const dx = Math.sin(time * 0.5 + c.ph) * 0.12;
    const dy = Math.cos(time * 0.37 + c.ph * 1.7) * 0.12;
    px[i] = p.cx + (c.bx + dx) * p.r;
    py[i] = p.cy + (c.by + dy) * p.r;
    c._dx = dx; c._dy = dy;
  }

  // press + drag: grab the nearest circle, move its base so it tracks the pointer
  if (input.mouseDown && !wasDown) {
    let best = -1, bestD = 1e9;
    for (let i = 0; i < 6; i++) {
      const p = panelRect(circles[i].panel);
      const d = Math.hypot(input.mouseX - px[i], input.mouseY - py[i]);
      if (d < p.r * 1.25 && d < bestD) { bestD = d; best = i; }
    }
    dragIdx = best;
  }
  if (!input.mouseDown) dragIdx = -1;
  wasDown = input.mouseDown;
  if (dragIdx >= 0) {
    const c = circles[dragIdx], p = panelRect(c.panel);
    const mx = Math.max(p.x + 10, Math.min(p.x + p.w - 10, input.mouseX));
    const my = Math.max(p.y + 30, Math.min(p.y + p.h - 10, input.mouseY));
    c.bx = (mx - p.cx) / p.r - c._dx;
    c.by = (my - p.cy) / p.r - c._dy;
    px[dragIdx] = mx; py[dragIdx] = my;
  }

  // panels
  for (let k = 0; k < 2; k++) {
    const p = panelRect(k);
    ctx.save();
    ctx.beginPath(); ctx.rect(p.x, p.y, p.w, p.h); ctx.clip();
    ctx.fillStyle = k === 0 ? "#000" : "#fff";
    ctx.fillRect(p.x, p.y, p.w, p.h);
    ctx.globalCompositeOperation = k === 0 ? "lighter" : "multiply";
    const cols = k === 0 ? ADD : SUB;
    for (let i = 0; i < 3; i++) {
      const j = k * 3 + i;
      ctx.fillStyle = cols[i];
      ctx.beginPath(); ctx.arc(px[j], py[j], p.r, 0, Math.PI * 2); ctx.fill();
    }
    ctx.restore();
  }
  ctx.strokeStyle = "#555"; ctx.lineWidth = 1;
  if (W >= H) { ctx.beginPath(); ctx.moveTo(W / 2 + 0.5, 0); ctx.lineTo(W / 2 + 0.5, H); ctx.stroke(); }
  else { ctx.beginPath(); ctx.moveTo(0, H / 2 + 0.5); ctx.lineTo(W, H / 2 + 0.5); ctx.stroke(); }

  // overlap labels
  ctx.font = "bold 11px monospace"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
  for (let k = 0; k < 2; k++) {
    const p = panelRect(k);
    const o = k * 3;
    const gx = (px[o] + px[o + 1] + px[o + 2]) / 3, gy = (py[o] + py[o + 1] + py[o + 2]) / 3;
    ctx.fillStyle = k === 0 ? "#000" : "#fff";
    let all = true;
    for (const [a, b, label] of PAIRS[k]) {
      const i = o + a, j = o + b;
      const d = Math.hypot(px[i] - px[j], py[i] - py[j]);
      if (d < p.r * 1.6) {
        const mx = (px[i] + px[j]) / 2, my = (py[i] + py[j]) / 2;
        ctx.fillText(label, mx + (mx - gx) * 0.45, my + (my - gy) * 0.45);
      }
      if (d >= p.r * 1.45) all = false;
    }
    if (all) ctx.fillText(TRIPLE[k], gx, gy);
  }

  // titles + hints (HUD)
  for (let k = 0; k < 2; k++) {
    const p = panelRect(k);
    ctx.fillStyle = k === 0 ? "#fff" : "#000";
    ctx.font = "bold 13px monospace"; ctx.textAlign = "center"; ctx.textBaseline = "alphabetic";
    ctx.fillText(k === 0 ? "ADDITIVE — light (RGB)" : "SUBTRACTIVE — pigment (CMY)", p.x + p.w / 2, p.y + 18);
    ctx.font = "11px monospace";
    ctx.fillStyle = k === 0 ? "rgba(255,255,255,0.6)" : "rgba(0,0,0,0.55)";
    ctx.fillText("drag a circle", p.x + p.w / 2, p.y + p.h - 10);
  }
}

Comments (0)

Log in to comment.