{"slug":"ascii","number":"06","title":"ASCII torus","era":"donut.c · 2006","summary":"A rotating 3D torus rendered with a luminance character ramp.","description":"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.","apis":["Canvas 2D","requestAnimationFrame","matchMedia"],"tags":["ascii","canvas"],"hasGlsl":false,"interaction":null,"url":"https://3d-retro.com/experiments/ascii","example_url":"https://3d-retro.com/examples/ascii.html","api_url":"https://3d-retro.com/api/v1/experiments/ascii","html":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <meta name=\"description\" content=\"A rotating 3D torus rendered with a luminance character ramp.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/ascii\" />\n  <title>ASCII torus · 3d retro graphics</title>\n  <style>\n    html, body {\n      margin: 0;\n      width: 100%;\n      height: 100%;\n      overflow: hidden;\n      background: #0b0c0e;\n      color: #e8e6e1;\n      position: absolute;\n      inset: 0;\n    }\n    canvas {\n      display: block;\n      position: absolute;\n      inset: 0;\n      width: 100%;\n      height: 100%;\n    }\n    .fallback {\n      position: absolute;\n      inset: 0;\n      display: grid;\n      place-items: center;\n      padding: 24px;\n      font: 14px/1.5 ui-monospace, \"IBM Plex Mono\", monospace;\n      text-align: center;\n      color: #e8e6e1;\n      background: #0b0c0e;\n    }\n    .fallback[hidden] { display: none; }\n  </style>\n</head>\n<body>\n<canvas id=\"c\"></canvas>\n<div id=\"fallback\" class=\"fallback\" hidden></div>\n<script>\n(function () {\n  const PARAMS = {\n    color: \"#6ee7b7\",\n    speed: 1\n  };\n\n  const canvas = document.getElementById(\"c\");\n  const fallback = document.getElementById(\"fallback\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) {\n    fallback.hidden = false;\n    fallback.textContent = \"Canvas 2D is not available in this browser.\";\n    return;\n  }\n\n  const RAMP = \" .:-=+*#%@\";\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  let A = 0.8;\n  let B = 0.6;\n  let last = 0;\n  let raf = 0;\n\n  function resize() {\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));\n    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));\n    if (canvas.width !== w || canvas.height !== h) {\n      canvas.width = w;\n      canvas.height = h;\n    }\n  }\n\n  function frame(now) {\n    const dt = Math.min(0.1, last ? (now - last) * 0.001 : 0.016);\n    last = now;\n    if (!reduced) {\n      A += dt * 0.85 * PARAMS.speed;\n      B += dt * 0.42 * PARAMS.speed;\n    }\n    resize();\n\n    const w = canvas.width;\n    const h = canvas.height;\n    ctx.fillStyle = \"#0b0c0e\";\n    ctx.fillRect(0, 0, w, h);\n\n    const cell = Math.max(8, Math.floor(Math.min(w, h) / 42));\n    const cols = Math.max(24, Math.floor(w / cell));\n    const rows = Math.max(16, Math.floor(h / cell));\n    const zbuf = new Float32Array(cols * rows);\n    const cells = new Uint8Array(cols * rows);\n    zbuf.fill(-1e9);\n\n    const cosA = Math.cos(A), sinA = Math.sin(A);\n    const cosB = Math.cos(B), sinB = Math.sin(B);\n    const R1 = 1.0, R2 = 2.0, K2 = 5.0;\n    const K1 = cols * K2 * 3 / (8 * (R1 + R2));\n\n    for (let theta = 0; theta < 6.283; theta += 0.07) {\n      const ct = Math.cos(theta), st = Math.sin(theta);\n      for (let phi = 0; phi < 6.283; phi += 0.02) {\n        const cp = Math.cos(phi), sp = Math.sin(phi);\n        const cx = R2 + R1 * ct;\n        const x = cx * (cosB * cp + sinA * sinB * sp) - R1 * cosA * sinB * st;\n        const y = cx * (sinB * cp - sinA * cosB * sp) + R1 * cosA * cosB * st;\n        const z = K2 + cosA * cx * sp + R1 * sinA * st;\n        const ooz = 1 / z;\n        const xp = Math.floor(cols / 2 + K1 * ooz * x);\n        const yp = Math.floor(rows / 2 - K1 * ooz * y * 0.55);\n        if (xp < 0 || yp < 0 || xp >= cols || yp >= rows) continue;\n        const L =\n          cp * ct * sinB -\n          cosA * ct * sp -\n          sinA * st +\n          cosB * (cosA * st - ct * sinA * sp);\n        if (L <= 0) continue;\n        const idx = xp + yp * cols;\n        if (ooz <= zbuf[idx]) continue;\n        zbuf[idx] = ooz;\n        cells[idx] = Math.min(RAMP.length - 1, Math.floor(L * 8));\n      }\n    }\n\n    ctx.font = cell + \"px ui-monospace, 'IBM Plex Mono', monospace\";\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    ctx.fillStyle = PARAMS.color;\n    const ox = (w - cols * cell) * 0.5;\n    const oy = (h - rows * cell) * 0.5;\n    for (let y = 0; y < rows; y++) {\n      for (let x = 0; x < cols; x++) {\n        const lum = cells[x + y * cols];\n        if (!lum) continue;\n        ctx.globalAlpha = 0.35 + lum / (RAMP.length - 1) * 0.75;\n        ctx.fillText(RAMP.charAt(lum), ox + (x + 0.5) * cell, oy + (y + 0.5) * cell);\n      }\n    }\n    ctx.globalAlpha = 1;\n\n    if (!reduced) raf = requestAnimationFrame(frame);\n  }\n\n  if (reduced) frame(performance.now());\n  else raf = requestAnimationFrame(frame);\n})();\n</script>\n</body>\n</html>\n"}