donuts in the terminal

February 25, 2025

0. How this works

  • brightness levels are mapped to ASCII chars - from dimmest to brightest: .,-~:;=!*#$@
  • sample points on the torus. for each point:
    1. compute its position in 3D: (x, y, z)
    2. project it onto a 2D plane: (x', y')
    3. compute the amount of light it receives
    4. print corresponding ASCII char onto terminal

1. Compute 3D coordinates

Create point

We begin at (R2,0,0)(R_{2}, 0, 0).

Create circle

(x,y,z)=(R2,0,0)+(R1cos⁡θ,R1sin⁡θ,0)(x, y, z)=\left(R_2, 0,0\right)+\left(R_1 \cos \theta, R_1 \sin \theta, 0\right)

Create torus

Rotate the circle about the y-axis by ϕ\phi. To do so, multiply the above by the rotation matrix

(cos⁡ϕ0sin⁡ϕ010−sin⁡ϕ0cos⁡ϕ)\left(\begin{array}{ccc} \cos \phi & 0 & \sin \phi \\ 0 & 1 & 0 \\ -\sin \phi & 0 & \cos \phi \end{array}\right)

Create spin

Rotate the torus about the x-axis by A, and z-axis by B. To do so, multiply again by the following rotation matrices:

(1000cos⁡Asin⁡A0−sin⁡Acos⁡A)⋅(cos⁡Bsin⁡B0−sin⁡Bcos⁡B0001)\left(\begin{array}{ccc} 1 & 0 & 0 \\ 0 & \cos A & \sin A \\ 0 & -\sin A & \cos A \end{array}\right) \cdot\left(\begin{array}{ccc} \cos B & \sin B & 0 \\ -\sin B & \cos B & 0 \\ 0 & 0 & 1 \end{array}\right)

Resulting position

Combining the above, we have

(x,y,z)=(R2+R1cos⁡θ,R1sin⁡θ,0)⋅(cos⁡ϕ0sin⁡ϕ010−sin⁡ϕ0cos⁡ϕ)⋅(1000cos⁡Asin⁡A0−sin⁡Acos⁡A)⋅(cos⁡Bsin⁡B0−sin⁡Bcos⁡B0001)=((R2+R1cos⁡θ)(cos⁡Bcos⁡ϕ+sin⁡Asin⁡Bsin⁡ϕ)−R1cos⁡Asin⁡Bsin⁡θ(R2+R1cos⁡θ)(cos⁡ϕsin⁡B−cos⁡Bsin⁡Asin⁡ϕ)+R1cos⁡Acos⁡Bsin⁡θcos⁡A(R2+R1cos⁡θ)sin⁡ϕ+R1sin⁡Asin⁡θ)⊤\begin{aligned} (x, y, z) &= \left(\begin{array}{lll} R_2+R_1 \cos \theta, & R_1 \sin \theta, & 0 \end{array}\right) \cdot\left(\begin{array}{ccc} \cos \phi & 0 & \sin \phi \\ 0 & 1 & 0 \\ -\sin \phi & 0 & \cos \phi \end{array}\right) \cdot\left(\begin{array}{ccc} 1 & 0 & 0 \\ 0 & \cos A & \sin A \\ 0 & -\sin A & \cos A \end{array}\right) \cdot\left(\begin{array}{ccc} \cos B & \sin B & 0 \\ -\sin B & \cos B & 0 \\ 0 & 0 & 1 \end{array}\right) \\[1em] &= \left(\begin{array}{c} \left(R_2+R_1 \cos \theta\right)(\cos B \cos \phi+\sin A \sin B \sin \phi)-R_1 \cos A \sin B \sin \theta \\ \left(R_2+R_1 \cos \theta\right)(\cos \phi \sin B-\cos B \sin A \sin \phi)+R_1 \cos A \cos B \sin \theta \\ \cos A\left(R_2+R_1 \cos \theta\right) \sin \phi+R_1 \sin A \sin \theta \end{array}\right)^{\top} \end{aligned}

2. Project onto terminal

We now need to map the 3D point (x,y,z)(x,y,z) onto a 2D terminal, i.e. find (x′,y′)(x', y').

Terminal coordinates increase rightward and downward. (0,0)(0,0) is at its top-left corner. Denoting its width and height with WW and HH, the center of the terminal is (W/2,H/2)(W/2,H/2).

First, move the torus a fixed distance dd away from the viewer. The point's coordinates become (x,y,z′)(x,y,z'), where

z′=d+zz'=d+z

Choose dd large enough that z′>0z'>0 for every point.

Now place an imaginary screen in front of the viewer. Let KK be its distance from the viewer, measured in terminal-cell units. Draw a straight line from the viewer through the point. Where that line crosses the screen is where we draw the point.

Screenx = 20uViewerPointK = 11z′ = 44

u = K · x / z′ = 11 · 20 / 44 = 5.00 · drag the point or the screen

Let uu and vv be its horizontal and vertical offsets from the terminal's center, also measured in cells. Similar triangles give

uK=xz′,vK=yz′.\frac{u}{K}=\frac{x}{z'}, \qquad \frac{v}{K}=\frac{y}{z'}.

Therefore,

u=Kxz′,v=Kyz′.u=K\frac{x}{z'}, \qquad v=K\frac{y}{z'}.

Adding the terminal's center and accounting for rows increasing downward gives

x′=W2+Kxz′,x' = \frac{W}{2} + K\frac{x}{z'}, y′=H2−Kyz′y' = \frac{H}{2} - K\frac{y}{z'}

KK controls how large the torus appears on the terminal. Larger values make the torus appear larger.

The 1/z′1/z' factor is what creates perspective: points farther from the viewer appear closer to the center and therefore smaller.

3. Compute brightness

Suppose we choose a light with direction

l=(0,1,−1),\mathbf l=(0,1,-1),

which points upward and toward the viewer.

The mechanics

To determine the brightness at each point on the torus, we look at the angle between the light source and its surface normal, or the direction perpendicular to the surface:

(cos⁡θ,sin⁡θ,0).(\cos\theta,\sin\theta,0).

This is the normal before rotation. We apply the same rotations to it as we did to the point, and write the rotated normal as

n=(Nx,Ny,Nz),\mathbf n=(N_x,N_y,N_z),

The angle α\alpha between the normal and the light source tells us how directly the surface faces the light. We use the dot product as a brightness score, or luminance LL:

L=n⋅l=∥n∥ ∥l∥cos⁡α.L=\mathbf n\cdot\mathbf l =\|\mathbf n\|\,\|\mathbf l\|\cos\alpha.

Here, the normal has length 11, and the light has length 2\sqrt2, so

L=n⋅l=2cos⁡α,L=\mathbf n\cdot\mathbf l=\sqrt2\cos\alpha,

LL changes with cos⁡α\cos\alpha:

  • α=0∘\alpha=0^\circ: the surface faces the light directly; the value is largest.
  • α=90∘\alpha=90^\circ: the light runs along the surface; the value is 00.
  • α>90∘\alpha>90^\circ: the surface faces away from the light; the value is negative, which we treat as unlit.

Since −1≤cos⁡α≤1-1\le\cos\alpha\le1, we have

−2≤L≤2.-\sqrt2\le L\le\sqrt2.

We ignore points with L≤0L\le0, then map positive values of LL to increasingly bright ASCII characters.

The math

We have

L=n⋅l=Nx⋅0+Ny⋅1+Nz⋅(−1)=Ny−Nz.L=\mathbf n\cdot\mathbf l =N_x\cdot0+N_y\cdot1+N_z\cdot(-1) =N_y-N_z.

To expand this, apply the same three rotations to the original normal. Below, xix_i, yiy_i, and ziz_i denote the normal's components at stage ii, rather than the point's coordinates. Start with

(x0,y0,z0)=(cos⁡θ,sin⁡θ,0).(x_0,y_0,z_0)=(\cos\theta,\sin\theta,0).

1. Rotate by ϕ\phi around yy. The yy-component stays fixed, while xx and zz mix. Using the rotation convention in our matrix,

x1=x0cos⁡ϕ−z0sin⁡ϕ=cos⁡θcos⁡ϕ,y1=y0=sin⁡θ,z1=x0sin⁡ϕ+z0cos⁡ϕ=cos⁡θsin⁡ϕ.\begin{aligned} x_1&=x_0\cos\phi-z_0\sin\phi =\cos\theta\cos\phi,\\ y_1&=y_0=\sin\theta,\\ z_1&=x_0\sin\phi+z_0\cos\phi =\cos\theta\sin\phi. \end{aligned}

2. Rotate by AA around xx. Now xx stays fixed, while yy and zz mix:

x2=x1=cos⁡θcos⁡ϕ,y2=y1cos⁡A−z1sin⁡A=sin⁡θcos⁡A−cos⁡θsin⁡ϕsin⁡A,z2=y1sin⁡A+z1cos⁡A=sin⁡θsin⁡A+cos⁡θsin⁡ϕcos⁡A.\begin{aligned} x_2&=x_1=\cos\theta\cos\phi,\\ y_2&=y_1\cos A-z_1\sin A\\ &=\sin\theta\cos A-\cos\theta\sin\phi\sin A,\\ z_2&=y_1\sin A+z_1\cos A\\ &=\sin\theta\sin A+\cos\theta\sin\phi\cos A. \end{aligned}

The minus sign comes from the chosen rotation direction: the positive zz direction turns toward negative yy, while positive yy turns toward positive zz.

3. Rotate by BB around zz. This mixes xx and yy and leaves zz fixed. We only need the final yy- and zz-components for LL:

Ny=x2sin⁡B+y2cos⁡B=cos⁡θcos⁡ϕsin⁡B+cos⁡B(sin⁡θcos⁡A−cos⁡θsin⁡ϕsin⁡A),Nz=z2=sin⁡θsin⁡A+cos⁡θsin⁡ϕcos⁡A.\begin{aligned} N_y&=x_2\sin B+y_2\cos B\\ &=\cos\theta\cos\phi\sin B\\ &\quad+\cos B \left(\sin\theta\cos A-\cos\theta\sin\phi\sin A\right),\\ N_z&=z_2\\ &=\sin\theta\sin A+\cos\theta\sin\phi\cos A. \end{aligned}

4. Substitute into L=Ny−NzL=N_y-N_z.

L=Ny−Nz=cos⁡θcos⁡ϕsin⁡B+cos⁡B(sin⁡θcos⁡A−cos⁡θsin⁡ϕsin⁡A)−(sin⁡θsin⁡A+cos⁡θsin⁡ϕcos⁡A)=cos⁡ϕcos⁡θsin⁡B−cos⁡Acos⁡θsin⁡ϕ−sin⁡Asin⁡θ+cos⁡B(cos⁡Asin⁡θ−cos⁡θsin⁡Asin⁡ϕ).\begin{aligned} L&=N_y-N_z\\ &=\cos\theta\cos\phi\sin B\\ &\quad+\cos B \left(\sin\theta\cos A-\cos\theta\sin\phi\sin A\right)\\ &\quad-\left(\sin\theta\sin A+\cos\theta\sin\phi\cos A\right)\\ &=\cos\phi\cos\theta\sin B -\cos A\cos\theta\sin\phi -\sin A\sin\theta\\ &\quad+\cos B \left( \cos A\sin\theta -\cos\theta\sin A\sin\phi \right). \end{aligned}

4. Print ASCII chars

Different points on the torus can project onto the same terminal cell. We only want to display the point closest to the viewer.

For every terminal cell, we store a z-buffer containing

1z′\frac{1}{z'}

A point closer to the viewer has a smaller z′z', and thus a larger 1/z′1/z'.

Whenever a new point projects onto a cell, we draw it only if

1z′>zbuffer(x′,y′).\frac{1}{z'} > z_{\text{buffer}}(x',y').

This prevents points on the back of the torus from being drawn over points on the front.

To animate the donut, we repeatedly render the torus while changing the rotation angles AA and BB.

donut — zsh
~/torus $ gcc donut.c -o donut -lm && ./donut






















click to run
▶ donut.c