Home

06. donut.c · 2006 · Retro techniques

ASCII torus

Demos are animating. Following your system setting.
Colors and knobs

These rewrite the live file. Copy HTML picks up whatever you set.

About

Andy Sloane's donut.c, drawn on a 2D canvas instead of stdout. Two nested angle loops sample a torus, project with a 1/z perspective divide, and keep a per-cell z-buffer. Lighting is a simple Lambert term mapped onto .:-=+*#%@. Phosphor green on charcoal.

Reference

Andy Sloane, "Have a donut", a1k0n.net, 2006

Browser APIs

  • Canvas 2D
  • requestAnimationFrame
  • matchMedia

If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. By default a demo holds a still frame when your system asks for reduced motion; the Motion buttons override that, and a standalone copy animates with #motion=play on its URL.

Source

(function () {
  const PARAMS = {
    color: "#6ee7b7",
    speed: 1
  };

  const canvas = document.getElementById("c");
  const fallback = document.getElementById("fallback");
  const ctx = canvas.getContext("2d");
  if (!ctx) {
    fallback.hidden = false;
    fallback.textContent = "Canvas 2D is not available in this browser.";
    return;
  }

  const RAMP = " .:-=+*#%@";
  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reduced = motionQuery.matches;
  let A = 0.8;
  let B = 0.6;
  let last = 0;
  let raf = 0;

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;
      canvas.height = h;
    }
  }

  function frame(now) {
    const dt = Math.min(0.1, last ? (now - last) * 0.001 : 0.016);
    last = now;
    if (!reduced) {
      A += dt * 0.85 * PARAMS.speed;
      B += dt * 0.42 * PARAMS.speed;
    }
    resize();

    const w = canvas.width;
    const h = canvas.height;
    ctx.fillStyle = "#0b0c0e";
    ctx.fillRect(0, 0, w, h);

    const cell = Math.max(8, Math.floor(Math.min(w, h) / 42));
    const cols = Math.max(24, Math.floor(w / cell));
    const rows = Math.max(16, Math.floor(h / cell));
    const zbuf = new Float32Array(cols * rows);
    const cells = new Uint8Array(cols * rows);
    zbuf.fill(-1e9);

    const cosA = Math.cos(A), sinA = Math.sin(A);
    const cosB = Math.cos(B), sinB = Math.sin(B);
    const R1 = 1.0, R2 = 2.0, K2 = 5.0;
    const K1 = cols * K2 * 3 / (8 * (R1 + R2));

    for (let theta = 0; theta < 6.283; theta += 0.07) {
      const ct = Math.cos(theta), st = Math.sin(theta);
      for (let phi = 0; phi < 6.283; phi += 0.02) {
        const cp = Math.cos(phi), sp = Math.sin(phi);
        const cx = R2 + R1 * ct;
        const x = cx * (cosB * cp + sinA * sinB * sp) - R1 * cosA * sinB * st;
        const y = cx * (sinB * cp - sinA * cosB * sp) + R1 * cosA * cosB * st;
        const z = K2 + cosA * cx * sp + R1 * sinA * st;
        const ooz = 1 / z;
        const xp = Math.floor(cols / 2 + K1 * ooz * x);
        const yp = Math.floor(rows / 2 - K1 * ooz * y * 0.55);
        if (xp < 0 || yp < 0 || xp >= cols || yp >= rows) continue;
        const L =
          cp * ct * sinB -
          cosA * ct * sp -
          sinA * st +
          cosB * (cosA * st - ct * sinA * sp);
        if (L <= 0) continue;
        const idx = xp + yp * cols;
        if (ooz <= zbuf[idx]) continue;
        zbuf[idx] = ooz;
        cells[idx] = Math.min(RAMP.length - 1, Math.floor(L * 8));
      }
    }

    ctx.font = cell + "px ui-monospace, 'IBM Plex Mono', monospace";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillStyle = PARAMS.color;
    const ox = (w - cols * cell) * 0.5;
    const oy = (h - rows * cell) * 0.5;
    for (let y = 0; y < rows; y++) {
      for (let x = 0; x < cols; x++) {
        const lum = cells[x + y * cols];
        if (!lum) continue;
        ctx.globalAlpha = 0.35 + lum / (RAMP.length - 1) * 0.75;
        ctx.fillText(RAMP.charAt(lum), ox + (x + 0.5) * cell, oy + (y + 0.5) * cell);
      }
    }
    ctx.globalAlpha = 1;

    if (!reduced) raf = requestAnimationFrame(frame);
  }

  // Motion. The system's reduced-motion preference decides by default. A
  // standalone copy animates anyway with "#motion=play" in its URL (or holds a
  // frame with "#motion=still"); an embedding page can post {motion: "play"}.
  let motionMode = "auto";
  function applyMotion() {
    const next = motionMode === "still" || (motionMode === "auto" && motionQuery.matches);
    if (next === reduced) return;
    reduced = next;
    last = 0;
    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }
    else if (!raf) raf = requestAnimationFrame(frame);
  }
  function setMotion(mode) {
    if (mode === "play" || mode === "still" || mode === "auto") { motionMode = mode; applyMotion(); }
  }
  function readMotionHash() {
    const m = /motion=(play|still|auto)/.exec(location.hash);
    let mode = m ? m[1] : "auto";
    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.
    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === "string") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }
    setMotion(mode);
  }
  function wake() { if (reduced) frame(performance.now()); }
  if (motionQuery.addEventListener) motionQuery.addEventListener("change", applyMotion);
  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);
  window.addEventListener("hashchange", readMotionHash);
  window.addEventListener("message", function (e) { if (e.data && typeof e.data.motion === "string") setMotion(e.data.motion); });
  window.addEventListener("resize", wake);

  if (reduced) frame(performance.now());
  else raf = requestAnimationFrame(frame);
  readMotionHash();
})();