crispigt.

a donut in the terminal

Spent half a day making a rotating ASCII donut in Rust. Followed the math in a1k0n's donut explanation, then wrote the implementation myself.

drawing

Started with printf and a small drawing API to put a character at a position and keep the math separate. Found the escape codes through Ghostty's terminal docs. \x1b[2J clears the display, \x1b[{r};{v}H positions the cursor, and \x1b[?25l hides it. Terminal coordinates start at one, so r = y + 1 and v = x + 1.

Considered clearing and redrawing every frame. Ended up keeping everything between draws in a Window struct. Width w, height h, current frame, previous p_frame, output buffer and depth zbuf.

The frame buffers are flat Vec<u8> arrays with w * h cells, indexed in row-major order with y * w + x. Each cell stores a shade index, one byte instead of Rust's four-byte char. The small shades table holds the actual characters.

Each frame resets frame and zbuf, keeping p_frame for comparison. Collect cursor moves and changed characters into the reusable output buffer, lock stdout once, write and flush. Spaces erase whatever moved away. Used terminal_size for dimensions. Resizing rebuilds Window and recalculates f, with a full display clear only then and at startup.

the math

Start with a circle, move it away from the origin, rotate it around the y-axis. That's the torus. Two more rotations, AA and BB, animate the whole thing. Expanded the rotation matrices directly using Rust's sin_cos() for the trig. Sample more densely around the larger ring, 300 steps for phi, 180 for thetha.

Handwritten notes showing a circle offset from the origin, swept around the y-axis into a torus, and the expanded rotation equations.
Circle, offset, rotation. My notes mix Swedish and English.

projection

The camera sits at the origin looking down z. In my notes, rC, rT, and ofo become radiusCirle, radiusTorus, and offsetFromOrigo in the code. Initially missed + ofo in z, so nothing rendered. The notes try different framing values. This is what I used in the implementation.

const preF: f32 = offsetFromOrigo / ((radiusCirle + radiusTorus) * 4.0);
let f = preF * h as f32;
let zInv = 1.0 / z;
let xP = (x * zInv * f) as i32 + (w / 2) as i32;
let yP = -(y * zInv * f) as i32 + (h / 2) as i32;

h is terminal height, w is width. Similar triangles give the x / z and y / z scaling, with f setting the image size. The casts truncate to cells, adding half the dimensions centers the result, and negating y makes rows run downwards. z already includes offsetFromOrigo. Reuse zInv for depth too, larger means closer. Skip projected cells outside the terminal before indexing.

Handwritten similar-triangle diagram working through camera distance, terminal height, and focal scale, including crossed-out framing assumptions.
Working out the projection and how much of the terminal the torus should fill.

lighting

Started by deriving the reflection vector, then realized I only needed Lambert diffuse shading. Normal dotted with light direction gives L. The code uses (0,1,−1)(0,1,-1), so divide L by 2\sqrt{2} to normalize its scale.

For positive L, idxL = (L * 9.0 / SQRT_2 + 1.0) as u8. Multiply by nine and add one, reserving index zero for a space. Store idxL in frame, then look up the character in shades when drawing. More light gives a denser character.

Handwritten lighting diagrams deriving the reflection vector and comparing it with Lambert shading from the surface normal and light direction.
From the reflection vector to a dot product for brightness.