06. donut.c · 2006
ASCII torus
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);
})();