7

Newton's Color-Disk Spin

arrows or mouse-Y to spin faster/slower

Newton's color-mixing experiment, recreated. Seven wedges painted in ROYGBIV sit on a disc that spins at user-controlled angular velocity . Each frame the disc is re-rendered at multiple sub-rotations spread across , alpha-blended at — a digital model of the eye's persistence of vision, which integrates luminance over roughly 1/30 s before the next sample arrives. As rises past the flicker-fusion threshold (~50–80 Hz for a single sector crossing the fovea), the temporal mean overwhelms any individual hue and the disc fuses to a creamy off-white. The instrument in the corner reads the actual per-frame mean RGB inside the disc area, so you can watch the channels converge as the rpm climbs.

idle
234 lines · vanilla
view source
// Newton's Color-Disk Spin.
// Seven ROYGBIV wedges on a spinning disc. As angular velocity climbs, the
// eye's persistence of vision smears the sectors into a creamy off-white.
// We fake POV by drawing the disc N times per frame, each rotated by a slice
// of the angular distance swept over dt, with alpha=1/N on a fresh buffer.
// That's a true temporal average of where each wedge was during the frame.
// A small instrument samples the mean RGB of the disc area each frame.
//
// Controls: ↑/↓ spin up/down. W/S as aliases. Mouse-Y maps to ω when held.

const TAU = Math.PI * 2;

// Realistic-ish spectral RGB approximations for ROYGBIV (sRGB 8-bit).
// Slightly desaturated versus pure RGB so the fused gray is convincingly
// creamy rather than greenish (which is what pure 0,255,0 would produce).
const WEDGE_COLORS = [
  [220,  40,  40], // red
  [240, 130,  30], // orange
  [240, 220,  50], // yellow
  [ 60, 190,  70], // green
  [ 40, 110, 220], // blue
  [ 70,  50, 180], // indigo
  [150,  60, 200], // violet
];
const N_WEDGES = WEDGE_COLORS.length;

let W = 0, H = 0;
let cx = 0, cy = 0, R = 0;

// Pre-rendered disc at angle 0. We rotate it via setTransform+drawImage so
// each sub-sample is a single bitmap blit, not 7 wedge fills.
let stampCanvas = null;
let stampCtx = null;
let stampSize = 0;

// Compositing buffer that accumulates the N rotated copies for the current
// frame. Drawn onto the main canvas in one go.
let smear = null;
let smearCtx = null;

// Tiny readback target for the mean-color instrument. Downsampling the disc
// to ~64x64 before getImageData keeps the per-frame cost cheap.
const READBACK = 64;
let readback = null;
let readbackCtx = null;

let theta = 0;       // current rotation angle
let omega = 0;       // angular velocity, rad/s
let omegaTarget = 0; // smoothed target (keyboard nudges it)
let lastMeanR = 128, lastMeanG = 128, lastMeanB = 128;

// Smoothed display values so the readout doesn't jitter at low ω.
let displayMean = [128, 128, 128];

function rebuildStamps(width, height) {
  W = width; H = height;
  cx = W / 2;
  cy = H / 2;
  R = Math.min(W, H) * 0.36;

  // Stamp at the maximum dimension that fits, with a small margin so
  // antialiased pixels at the rim aren't clipped during rotation.
  stampSize = Math.ceil(R * 2 + 8);
  stampCanvas = new OffscreenCanvas(stampSize, stampSize);
  stampCtx = stampCanvas.getContext('2d');
  drawStamp();

  smear = new OffscreenCanvas(stampSize, stampSize);
  smearCtx = smear.getContext('2d');

  readback = new OffscreenCanvas(READBACK, READBACK);
  readbackCtx = readback.getContext('2d');
}

function drawStamp() {
  const s = stampCtx;
  const r = R;
  const c = stampSize / 2;
  s.clearRect(0, 0, stampSize, stampSize);

  // Wedge fills. Start at -π/2 so the seam between the first and last wedge
  // points up, which makes the rotation visually readable when ω is low.
  const slice = TAU / N_WEDGES;
  for (let i = 0; i < N_WEDGES; i++) {
    const a0 = -Math.PI / 2 + i * slice;
    const a1 = a0 + slice;
    const [r8, g8, b8] = WEDGE_COLORS[i];
    s.beginPath();
    s.moveTo(c, c);
    s.arc(c, c, r, a0, a1);
    s.closePath();
    s.fillStyle = `rgb(${r8},${g8},${b8})`;
    s.fill();
  }

  // Subtle hub + rim so the spin is visible at intermediate ω before it
  // fully fuses. A pure tone-only disc looks frozen until the mean averages.
  s.strokeStyle = 'rgba(20,20,20,0.85)';
  s.lineWidth = 2;
  s.beginPath();
  s.arc(c, c, r, 0, TAU);
  s.stroke();

  s.fillStyle = '#1a1a1a';
  s.beginPath();
  s.arc(c, c, r * 0.06, 0, TAU);
  s.fill();

  // A single radial fiducial — a thin black tick on the red wedge — gives
  // the eye a phase reference at low ω. Once ω rises the tick blurs into
  // a faint ring, which is the demonstration in microcosm.
  s.strokeStyle = 'rgba(10,10,10,0.9)';
  s.lineWidth = 2;
  s.beginPath();
  s.moveTo(c, c - r * 0.92);
  s.lineTo(c, c - r * 0.20);
  s.stroke();
}

function init({ width, height }) {
  rebuildStamps(width, height);
}

// Number of sub-samples this frame. Driven by the angular distance the disc
// will sweep over dt: 1 sub-sample per ~6°, clamped to [1, 24].
function pickSubsamples(dOmega, dt) {
  const sweep = Math.abs(dOmega) * dt;            // radians swept this frame
  const perSample = (6 * Math.PI) / 180;           // 6° per sample
  const n = Math.ceil(sweep / perSample);
  if (n < 1) return 1;
  if (n > 24) return 24;
  return n;
}

function drawSmearedDisc(ctx, dt) {
  const N = pickSubsamples(omega, dt);
  const sweep = omega * dt;
  const c = stampSize / 2;
  const alpha = 1 / N;

  // Fresh buffer, transparent. We draw N rotated copies with equal alpha so
  // a pixel covered by k of the N wedge orientations ends up at (k/N) of
  // that color — exactly the time-average of what that pixel "saw".
  smearCtx.clearRect(0, 0, stampSize, stampSize);
  smearCtx.globalAlpha = alpha;

  for (let i = 0; i < N; i++) {
    // Spread sub-samples uniformly across the frame. The (i + 0.5)/N midpoint
    // gives an unbiased trapezoid-like estimate of the sweep interval.
    const phase = (i + 0.5) / N;
    const a = theta + sweep * phase;
    smearCtx.setTransform(
      Math.cos(a), Math.sin(a),
      -Math.sin(a), Math.cos(a),
      c, c
    );
    smearCtx.drawImage(stampCanvas, -c, -c);
  }
  smearCtx.setTransform(1, 0, 0, 1, 0, 0);
  smearCtx.globalAlpha = 1;

  // Composite onto main canvas, centered.
  ctx.drawImage(smear, Math.round(cx - c), Math.round(cy - c));
}

function sampleMeanColor() {
  // Pull the smeared disc into a 64×64 buffer, then average. Restricting
  // the read to a circular mask inside the buffer keeps black margin pixels
  // from dragging the mean toward zero.
  readbackCtx.clearRect(0, 0, READBACK, READBACK);
  readbackCtx.drawImage(smear, 0, 0, stampSize, stampSize, 0, 0, READBACK, READBACK);
  const data = readbackCtx.getImageData(0, 0, READBACK, READBACK).data;

  let rSum = 0, gSum = 0, bSum = 0, n = 0;
  const mid = READBACK / 2;
  const rr = mid * 0.95;
  const rr2 = rr * rr;
  for (let y = 0; y < READBACK; y++) {
    const dy = y + 0.5 - mid;
    for (let x = 0; x < READBACK; x++) {
      const dx = x + 0.5 - mid;
      if (dx * dx + dy * dy > rr2) continue;
      const idx = (y * READBACK + x) * 4;
      const a = data[idx + 3];
      if (a < 8) continue;
      // Un-premultiply (canvas stores premultiplied alpha). Disc pixels at
      // low ω have a=255 anyway; at high ω each sub-sample contributes ~a/N.
      const aa = a / 255;
      rSum += data[idx] / aa;
      gSum += data[idx + 1] / aa;
      bSum += data[idx + 2] / aa;
      n++;
    }
  }
  if (n === 0) return [0, 0, 0];
  return [rSum / n, gSum / n, bSum / n];
}

function drawBackground(ctx) {
  ctx.fillStyle = '#0a0a0a';
  ctx.fillRect(0, 0, W, H);
}

function drawInstrument(ctx, rgb) {
  const pad = 14;
  const w = Math.min(220, W * 0.34);
  const h = 96;
  const x = W - w - pad;
  const y = H - h - pad;

  ctx.fillStyle = 'rgba(0,0,0,0.55)';
  ctx.strokeStyle = 'rgba(255,255,255,0.18)';
  ctx.lineWidth = 1;
  ctx.fillRect(x, y, w, h);
  ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);

  // Swatch of the measured mean.
  const sw = 56;
  const sx = x + 10;
  const sy = y + 10;
  ctx.fillStyle = `rgb(${rgb[0] | 0},${rgb[1] | 0},${rgb[2] | 0})`;
  ctx.fillRect(sx, sy, sw, sw);
  ctx.strokeStyle = 'rgba(255,255,255,0.25)';
  ctx.strokeRect(sx + 0.5, sy + 0.5, sw - 1, sw - 1);

  ctx.fillStyle = '#cfd2d6';
  ctx.font = '11px ui-monospace, monospace';
  ctx.textBaseline = 'top';
  ctx.fillText('measured mean RGB', sx + sw + 10, sy);
  ctx.fillStyle = '#9aa0a6';
  ctx.fillText(`R ${rgb[0].toFixed(0).padStart(3, ' ')}`, sx + sw + 10, sy + 18);
  ctx.fillText(`G ${rgb[1].toFixed(0).padStart(3, ' ')}`, sx + sw + 10, sy + 32);
  ctx.fillText(`B ${rgb[2].toFixed(0).padStart(3, ' ')}`, sx + sw + 10, sy + 46);

  ctx.fillStyle = '#6b7178';
  ctx.fillText('disc area, per frame', sx, sy + sw + 6);
}

function drawSpeedBar(ctx) {
  const pad = 14;
  const w = Math.min(260, W * 0.4);
  const h = 56;
  const x = pad;
  const y = H - h - pad;

  ctx.fillStyle = 'rgba(0,0,0,0.55)';
  ctx.strokeStyle = 'rgba(255,255,255,0.18)';
  ctx.fillRect(x, y, w, h);
  ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);

  const omegaMax = 100;
  const frac = Math.max(0, Math.min(1, omega / omegaMax));
  const barX = x + 10, barY = y + 30, barW = w - 20, barH = 8;
  ctx.fillStyle = 'rgba(255,255,255,0.10)';
  ctx.fillRect(barX, barY, barW, barH);
  // Color the fill by a gradient that goes from spectral red to white as ω
  // climbs — visually echoes the fusion happening on the disc.
  const grd = ctx.createLinearGradient(barX, barY, barX + barW, barY);
  grd.addColorStop(0.0, '#dc2828');
  grd.addColorStop(0.5, '#d6a83a');
  grd.addColorStop(1.0, '#f3f0e6');
  ctx.fillStyle = grd;
  ctx.fillRect(barX, barY, barW * frac, barH);

  const rpm = (omega * 60) / TAU;
  ctx.fillStyle = '#cfd2d6';
  ctx.font = '12px ui-monospace, monospace';
  ctx.textBaseline = 'top';
  ctx.fillText(`ω = ${omega.toFixed(2)} rad/s   ${rpm.toFixed(1)} rpm`, x + 10, y + 8);
  ctx.fillStyle = '#6b7178';
  ctx.font = '10px ui-monospace, monospace';
  ctx.fillText('↑/↓ or mouse-Y', x + 10, y + 42);
}

function drawHeader(ctx) {
  ctx.fillStyle = '#cfd2d6';
  ctx.font = '13px ui-sans-serif, system-ui';
  ctx.textBaseline = 'top';
  ctx.fillText("Newton's Color-Disk Spin", 14, 12);
  ctx.fillStyle = '#7e848b';
  ctx.font = '11px ui-sans-serif, system-ui';
  ctx.fillText('persistence of vision averages the wedges into gray-white', 14, 30);
}

function handleInput(input, dt) {
  // Keyboard target with a smooth approach so spamming arrows doesn't
  // overshoot. Mouse-Y, when the cursor sits over the canvas, blends in as
  // a second source so it Just Works on touch / hover.
  const KEY_RATE = 18;   // rad/s² ish when held
  let kb = 0;
  if (input.keyDown('ArrowUp') || input.keyDown('w') || input.keyDown('W')) kb += KEY_RATE * dt;
  if (input.keyDown('ArrowDown') || input.keyDown('s') || input.keyDown('S')) kb -= KEY_RATE * dt;
  omegaTarget += kb;

  // Mouse-Y override: only when cursor is inside the canvas. Top = fast,
  // bottom = slow. Acts as a coarse setter, not a delta.
  const mx = input.mouseX, my = input.mouseY;
  if (mx > 0 && mx < W && my > 0 && my < H && input.mouseDown) {
    const t = 1 - my / H;
    omegaTarget = t * 100;
  }

  omegaTarget = Math.max(0, Math.min(100, omegaTarget));
  // First-order glide toward the target. Time-constant ~0.25s feels punchy
  // without being instant.
  const k = 1 - Math.exp(-dt / 0.25);
  omega = omega + (omegaTarget - omega) * k;
}

function tick({ dt, ctx, width, height, input }) {
  if (width !== W || height !== H) rebuildStamps(width, height);

  // Cap dt: a tab-restore producing dt=2.0 would smear the disc into a
  // single uniform circle on the first frame back.
  const dtc = Math.min(dt, 1 / 30);

  handleInput(input, dtc);
  theta = (theta + omega * dtc) % TAU;

  drawBackground(ctx);
  drawSmearedDisc(ctx, dtc);

  // Sample the just-drawn smear. Smooth the readout so the digits don't
  // flicker on the last bit at low rpm.
  const m = sampleMeanColor();
  const a = 0.25;
  displayMean[0] = displayMean[0] * (1 - a) + m[0] * a;
  displayMean[1] = displayMean[1] * (1 - a) + m[1] * a;
  displayMean[2] = displayMean[2] * (1 - a) + m[2] * a;

  drawHeader(ctx);
  drawSpeedBar(ctx);
  drawInstrument(ctx, displayMean);
}

Comments (0)

Log in to comment.