Home

06. donut.c · 2006

ASCII torus

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.

Browser APIs

  • Canvas 2D
  • requestAnimationFrame
  • matchMedia

If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. prefers-reduced-motion freezes the first still.

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 reduced = window.matchMedia("(prefers-reduced-motion: reduce)").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);
  }

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