A Rotating ASCII Cube

1 Introduction

This project focuses on the development of a console-based application written in the C programming language that renders a rotating 3D cube using ASCII characters. The main objective is to simulate a three-dimensional object within a two-dimensional terminal environment by applying basic mathematical transformations and projection techniques.

The cube is displayed using ASCII characters, where edges and corners are represented by distinct characters to improve visual clarity and structure. The application allows the cube to rotate in any direction, creating the illusion of a three-dimensional object moving in space. To maintain a correct visual representation, only the faces of the cube that are visible from the viewer’s perspective are rendered, ensuring that hidden faces do not overlap or appear through the front surfaces.

This project is inspired by the Spinning Cube by Code Fiction.

In this guide, I will walk through the code of my solution and the fundamental theory behind a rotating cube in computer graphics. Hopefully, this will help you in your own projects. The source code of this project is available at https://github.com/Bennys-Quarter/Rotating-Ascii-Cube .

2 Computer Vision Thoery

2.1 Fundamental 3D Graphics

Most of us are familiar with computer graphics through video games. A 3D object in a video game is typically composed of polygons, most commonly triangles. These triangles are defined by their vertices. Each vertex has their own coordinates in 3D space.

To represent a cube, we first define its vertices, specify which pairs of vertices form edges, and then define the faces as triangles composed of three connected vertices.

const Cube UNIT_CUBE =
{
    .vertex =
    {
        {-1,1,1},{1,1,1},{-1,-1,1},{1,-1,1},        // Top vertices
        {-1,-1,-1}, {1,-1,-1}, {1,1,-1}, {-1,1,-1}  // Bottom vertices
    },
    .edge =
    {
        // 12 Outer Edges
        {0,1},{2,3},{3,1},{2,0},
        {5,6},{5,3},{1,6},{4,5},
        {4,2},{6,7},{7,0},{4,7},
    },
    .faces =
    {
        {5,4,7}, {6,5,7},{0,2,3}, {1,0,3},
        {3,2,4}, {3,4,5},{2,0,4}, {4,0,7},
        {0,1,7}, {1,6,7},{1,3,5}, {1,5,6}
    }
};

This definition is the reference for all cubes in a 3D scene (for this project we only need to create one), and the reference for all transformation functions. I choose the origin of my coordinate system to be at the center of my unit cube. Hence, all edges have a unit length of 2.

2.2 Rotation

The general 3D rotation matrix allows to rotate a vertex in 3D space choosing euler angles \(\alpha\), \(\beta\) and \(\gamma\).

\[ {\displaystyle {\begin{aligned}\\R=R_{x}(\gamma )\,R_{y}(\beta )\,R_{z}(\alpha )&={\overset {\text{roll}}{\begin{bmatrix}\cos \gamma &-\sin \gamma &0\\\sin \gamma &\cos \gamma &0\\0&0&1\\\end{bmatrix}}}{\overset {\text{pitch}}{\begin{bmatrix}\cos \beta &0&\sin \beta \\0&1&0\\-\sin \beta &0&\cos \beta \\\end{bmatrix}}}{\overset {\text{yaw}}{\begin{bmatrix}1&0&0\\0&\cos \alpha &-\sin \alpha \\0&\sin \alpha &\cos \alpha \\\end{bmatrix}}}\\&={\begin{bmatrix}\cos \beta \cos \gamma &\sin \alpha \sin \beta \cos \gamma -\cos \alpha \sin \gamma &\cos \alpha \sin \beta \cos \gamma +\sin \alpha \sin \gamma \\\cos \beta \sin \gamma &\sin \alpha \sin \beta \sin \gamma +\cos \alpha \cos \gamma &\cos \alpha \sin \beta \sin \gamma -\sin \alpha \cos \gamma \\-\sin \beta &\sin \alpha \cos \beta &\cos \alpha \cos \beta \\\end{bmatrix}}\end{aligned}}} \] (Source: https://en.wikipedia.org/wiki/Rotation_matrix)

The matrix with 8 vertices can be implemented like this:

void rotate(Cube* cnv)
{

    double cx = cos(cnv->rotation.x);
    double sx = sin(cnv->rotation.x);
    double cy = cos(cnv->rotation.y);
    double sy = sin(cnv->rotation.y);
    double cz = cos(cnv->rotation.z);
    double sz = sin(cnv->rotation.z);

    for (int i=0; i<8; i++)
    {

        cnv->vertex[i].x = UNIT_CUBE.vertex[i].x*(cy*cz) + UNIT_CUBE.vertex[i].y*(cy*sz) - UNIT_CUBE.vertex[i].z*sy;
        cnv->vertex[i].y = UNIT_CUBE.vertex[i].x*(sx*sy*cz - cx*sz) + UNIT_CUBE.vertex[i].y*(sx*sy*sz + cx*cz) + UNIT_CUBE.vertex[i].z*(sx*cy);
        cnv->vertex[i].z = UNIT_CUBE.vertex[i].x*(cx*sy*cz + sx*sz) + UNIT_CUBE.vertex[i].y*(cx*sy*sz - sx*cz) + UNIT_CUBE.vertex[i].z*(cx*cy);

    }
}

3 Cube Drawing Functions Implementation

3.1 Draw ASCII chars to console

One challenge is how to draw characters to the console. I decided to represent the cube in a 2D scene of size 30×50 ASCII characters. The symbolic constants V_MAX = 30 and H_MAX= 50 are defined in canvas.h.

Any character needs to be drawn to the console, so I go for a custom String data type solution.

typedef struct
{
    size_t len;
    char* str;
}String;

To create the canvas one can simply create an array of size V_MAX. By default the canvas is initialized with space ” ” characters.

String canvas[V_MAX];
canvas_init(canvas);

Any element of the canvas can now be accessed by writing canvas[i].str[j], where i<V_MAX and j<H_MAX. Furthermore, we can sequentially update the console based on the canvas content line by line by simply iterating through all array elements (in canvas.h). It is up to us, when and how to change the canvas content somewhere else in the code.

for (int i= 0; i<V_MAX; i++)
{
    draw(canvas[i]);
}

3.2 Draw Vertices

To display our cube on a 2D screen with finite pixels, we need to project the cube from the imaginary 3D space to 2D space i.e.: we perform a perspective projection.

Perceptive projection is related to similar triangles. Mathematically OpenGL defines the projection matrix as such

\[ \tilde{\textbf{P}} = \begin{pmatrix} f p_x \\ f p_y \\ \alpha p_z + \beta \\ -p_z \end{pmatrix} = \underbrace{ \begin{bmatrix} \frac{f} {aspect} & 0 & 0 & 0 \\ 0 & f & 0 & 0 \\ 0 & 0 & \alpha & \beta \\ 0 & 0 & -1 & 0 \end{bmatrix}}_{\textbf{A}} \begin{pmatrix} p_x \\ p_y \\ p_z \\ 1 \end{pmatrix} \]

With \(p_x, p_y, p_z\) being the 3D vertex coordinates and \(f = cotan(\frac{1}{2\cdot fov_y})\) an perspective function depending on the field of view (fov), with \(ascpect=\frac{width}{height}\). We assume the camera looks in z direction (so the 2D screen would reside in the xy-plane in 3D space). Solving for \(\alpha\) and \(\beta\) would give us:

\[ \alpha = \dfrac{z_f + z_n}{z_n -z_f} \] \[ \beta = \dfrac{2 z_f z_n}{z_n - z_f} \] \(z_n\) is the near-clipping and \(z_f\) is the far-clipping plane (OpenGL specific). Essentially, after multiplying a vertex (x, y, z, 1) with \(\textbf{A}\), what we do is effectively a perspective divide

\[ x' = \dfrac{x_c}{\omega_c} = \dfrac{p_x}{-p_z} \] \[ y' = \dfrac{y_c}{\omega_c} = \dfrac{p_y}{-p_z} \]

In general terms, the 2D screen coordinates \(x_p\), \(y_p\) for each vertex can be calculated by:

\[ \boxed{x_p = \dfrac{p_x \cdot s_x}{p_z + d} + c_x} \]

and

\[ \boxed{y_p = \dfrac{p_y \cdot s_y}{p_z + d} + c_y} \] where \(c_y\) and \(c_x\) are offset values for the center on screen, \(s_y\) and \(s_x\) are the scaling factors and \((z+d) = \omega_c\) is the perspective effect.

Vec2 projection_2d(Vec3 vert, int scr_width, int scr_height)
{
    /* Calculates the 3D to 2D projected coordinates
     * with the origin in the center
     */

    double y_m = 2*(1.0/0.3333333);
    double x_m = 4*(1.0/0.3333333);
    double x_p = (vert.x * x_m) / (vert.z + 2) + scr_width/2;
    double y_p = (vert.y * y_m) / (vert.z + 2) + scr_height/2;

    Vec2 coo = {round(x_p), round(y_p)};
    return coo;
}

3.3 Draw Edges

Drawing the edges is straight forward vector arithmetic. It can be done by projecting two 3D vertices \(\mathbf{v_0}\) and \(\mathbf{v_1}\) onto a 2D plane resulting in a vector \(\mathbf{p_0}\) and \(\mathbf{p_1}\).

\[ \mathbf{p_{01}} = \mathbf{p_0} \cdot \mathbf{p_1} \]

By subtracting these vectors we get a new vector \(\mathbf{p_{01}}\). By dividing \(\mathbf{p_{01}}\) by its norm we get a unit vector. This allows us to step wise add the unit vector to \(p_0\) until \(p_1\) is reached. At each iteration step a ASCII char is drawn.

\[ \mathbf{v_{step}}= \dfrac{\mathbf{p_{01}}}{|| \mathbf{p_{01}} ||_2} \]

\[ N = \text{trunc}(|| \mathbf{p_{01}} ||_2) \]

\[ \mathbf{v_n}^{(i)} = \mathbf{p_{0}} + i \cdot \mathbf{v_{step}} \]

This method is simple and effective, but works on a 2D plane an therefore the edges do not carry depth information by themselves.


void draw_edges(String *cnv, Cube* c, char ch , int n_edges)
{

    for (int j = 0; j<12;j++)
    {

        int x = (int)UNIT_CUBE.edge[j].x;
        int z = (int)UNIT_CUBE.edge[j].z;
        Vec2 p0 = projection_2d(c->vertex[x], 50, 30);
        Vec2 p1 = projection_2d(c->vertex[z], 50, 30);

        Vec2 p01 = subVec2(p1, p0);
        double abs_p01 = normVec2(p01);
        int N = (int)abs_p01;

        Vec2 v_step = {p01.x / abs_p01, p01.z / abs_p01};
        Vec2 v_n = {0.0, 0.0};

        for (int i=1; i<(N); i++)
        {
            Vec2 v_add = mulVec2Scalar(v_step, i);
            v_n = addVec2(p0, v_add);

            cnv[(int)v_n.z].str[(int)v_n.x] = ch;
        }
    }

}

3.4 Draw Faces

3.4.1 Back-Face-Culling

In a 3D model, surfaces are usually made out of triangles. Each triangle has a front side and a back side, determined by the order of its vertices (clockwise or counterclockwise).

Back Face Culling is a method where only the polygons that are facing the camera are actually drawn. To determine if a triangle is facing the camera every triangle needs a normal vector \(\mathbf{n}\) that is perpendicular to its face. Then the dot product can be used between the vector facing into the scene \(\mathbf{v}\) from the camera and \(\mathbf{n}\) of the face.

\[ d =\mathbf{n} \cdot \mathbf{v} \] Depending on \(d\) :

  • If \(d \lt 0 \rightarrow\) triangle faces the camera, so the face is rendered.
  • If \(d \gt 0 \rightarrow\) triangle faces away, so the face is culled.

The normal vector is calculated by the cross product of two edges from the triangle

\[ \mathbf{e_1} = \mathbf{v_0} - \mathbf{v_1}\\ \mathbf{e_2} = \mathbf{v_2} - \mathbf{v_2} \]

\[ \mathbf{n} = \mathbf{e_1} \times \mathbf{e_2} \]

Cube c;

Vec3 face = UNIT_CUBE.faces[n];

Vec3 V0 = c->vertex[(int)face.x];
Vec3 V1 = c->vertex[(int)face.y];
Vec3 V2 = c->vertex[(int)face.z];

/*Back face culling */
Vec3 edge1 = subVec3(V0,V1);
Vec3 edge2 = subVec3(V2,V1);
Vec3 normal = crossVec3(edge1, edge2);
normal.x = (fabs(normal.x) < EPSILON) ? 0.0 : normal.x * (-1.0);
normal.y = (fabs(normal.y) < EPSILON) ? 0.0 : normal.y;
normal.z = (fabs(normal.z) < EPSILON) ? 0.0 : normal.z;

Vec3 cam_pos = CAMERA_POS;

double dp = dotVec3(normal, cam_pos);

if (dp <= CULL_LIM){continue;} 

ℹ️ Note

The order of vertices in UNIT_CUBE.faces does matter. Depending on the order \(\mathbf{n}\) will flip its direction. Keep this in mind when defining the faces of the cube.

3.4.2 Scanline Triangle Fill

In the context of drawing the face, we rasterize triangles that are facing the camera. If a triangle is visible, we then proceed to fill it as explained in the previous section.

To draw the face first, we need to implement a algorithm called Scanline Triangle Fill:

  • To fill a triangle, we render it scanline by scanline across the screen. For each horizontal line that intersects the triangle, we compute where that line intersects the triangle’s edges formed by the vertices A, B, and C. These intersection points determine the start and end pixel positions for that scanline.

  • By drawing pixels only between these two intersection points, we correctly fill the triangle while staying within its boundaries and avoiding overshooting outside the triangle.

Concept Scanline Triangle Fill
Concept Scanline Triangle Fill

In the scanline triangle fill method we separate the triangle we want to fill in tow other triangles. One has a flat bottom and the other on has a flat top. We now have to calculate a new Midpoint coordinate M that is created through separation. To calculate \(i_M\) is simple: its just \(i_1\). For the \(j_m\) we draw another rectangular triangle and calculate the length to M by similarity of proportion:

\[ b' = \dfrac{|i_M - i_0|}{|i_2 - i_0|} \cdot |j_2 - j_0|\]

Freehand Drawing.svg
Freehand Drawing.svg

Now, that we have \(b'\) we can derive \(j_M\) by adding \(j_0\). So the coordinates of M calculate as such:

\[ M = \begin{pmatrix} i_M \\ j_M \end{pmatrix} =\begin{pmatrix} i_1 \\ \dfrac{|i_M - i_0|}{|i_2 - i_0|} \cdot |j_2 - j_0| + j_0 \end{pmatrix} \]

With M we can define the flat top and flat bottom triangles and draw them separately. Therefor, we need to calculate the slope k of the two edges that are not flat of both the flat bottom and the flat top triangle.

  • Edge slopes of flat bottom triangle: \[ k_{AC} = \dfrac{i_0 - i_1}{j_0 - j_1} \\ k_{AM} = \dfrac{i_0 - i_M}{j_0 -j_M} \]

  • Edge slopes of flat bottom top triangle: \[ k_{BC} = \dfrac{i_1 - i_2}{j_1 - j_2} \\ k_{MC} = \dfrac{i_M - i_2}{j_M -j_2} \]

That means each iteration in the draw function i increments by 1 and the start coordinate \(j_{start}\) and stop coordinate \(j_{stop}\) increment according to their edge slope.

Vec2 A = projection_2d(V0, 50, 30);
Vec2 B = projection_2d(V1, 50, 30);
Vec2 C = projection_2d(V2, 50, 30);


Vec2 tmp = {0.0, 0.0};
Vec2 M = {-1.0, -1.0};

  if (A.z > B.z) { tmp = A; A = B; B = tmp; }
  if (A.z > C.z) { tmp = A; A = C; C = tmp; }
  if (B.z > C.z) { tmp = B; B = C; C = tmp; }


  if (B.z == C.z && B.x < C.x && B.x == A.x) {
      tmp = B; B = C; C = tmp;
  }

  if (B.z == C.z && B.x > C.x && B.x == A.x) {
      tmp = B; B = C; C = tmp;
  }


  if (A.x < C.x)
  {
    M.x = A.x + (fabs(B.z - A.z)/(fabs(C.z - A.z))) * (fabs(C.x - A.x)) ;
    M.z = B.z;
  }
  else
  {
    M.x = A.x - (fabs(B.z - A.z)/(fabs(C.z - A.z))) * (fabs(C.x - A.x)) ;
    M.z = B.z;
  }

/* calc slope for top and bottom triangel */
double k_AB = A.x == B.x ? 0.0 : (A.z - B.z) / (A.x - B.x);
double k_AM = A.x == M.x ? 0.0 : (A.z - M.z) / (A.x - M.x);
double k_MC = M.x == C.x ? 0.0 : (M.z - C.z) / (M.x - C.x);
double k_BC = B.x == C.x ? 0.0 : (B.z - C.z) / (B.x - C.x);

double x_top_lim_1 = M.x;
double x_top_lim_2 = M.x;
double x_bottom_lim_1 = A.x;
double x_bottom_lim_2 = A.x;

/* flat bottom iteration */
for(int z=A.z; z<=M.z; z++)
{
    if (A.x != M.x)
    {
        x_bottom_lim_1 = A.x + (z-A.z) / (k_AM) ;
    }
    if( k_AB != 0)
    {
        x_bottom_lim_2 = A.x + (z-A.z) / (k_AB) ;
    }

    int xStart = (int)ceil(fmin(x_bottom_lim_1, x_bottom_lim_2));
    int xEnd   = (int)floor(fmax(x_bottom_lim_1, x_bottom_lim_2));

    for(int x = xStart; x<=xEnd; x++)
    {
        cnv[(int)z].str[(int)x] = (*ascii_fun)(sruface_count-1);
    }
}

/* flat top iteration */
for(int z=M.z; z<=C.z; z++)
{
    if (C.x != M.x)
    {
        x_top_lim_1 = M.x + (z-M.z) / (k_MC) ;
    }
    if( k_BC != 0)
    {
        x_top_lim_2 = B.x + (z-B.z) / (k_BC) ;
    }
    if(B.x == C.x)
    {
        x_top_lim_2 = B.x;
    }

    int xStart = (int)ceil(fmin(x_top_lim_1, x_top_lim_2));
    int xEnd   = (int)floor(fmax(x_top_lim_1, x_top_lim_2));

    for(int x = xStart; x<=xEnd; x++)
    {
        cnv[(int)z].str[(int)x] = (*ascii_fun)(sruface_count-1);
    }

}

4 Final Result

Once the 3D object and all our drawing functions are implemented we can finally draw the cube to the console, depending on what we want to see ( in cube.c) .

4.1 Rotating Cube, Edges & Vertices

void draw_cube(String* cnv, Cube* c)
{

    AsciiOperation *ascii_fun = NULL;

    const int n_vert = 8;
    const int n_edges = 30;
    const int n_faces = 12;

    set_ascii_operation(SEQUENCIAL, &ascii_fun);
    
    draw_edges(cnv, c, EDGE_ASCII_CH, n_edges);
    
    draw_vertices(cnv, c, VRTX_ASCII_CH, n_vert);

    // draw_faces(cnv, c, ascii_fun, n_faces);

}

4.2 Rotating Cube, Faces

void draw_cube(String* cnv, Cube* c)
{

    AsciiOperation *ascii_fun = NULL;

    const int n_vert = 8;
    const int n_edges = 30;
    const int n_faces = 12;

    set_ascii_operation(SEQUENCIAL, &ascii_fun);
    
    // draw_edges(cnv, c, EDGE_ASCII_CH, n_edges);
    
    // draw_vertices(cnv, c, VRTX_ASCII_CH, n_vert);

    draw_faces(cnv, c, ascii_fun, n_faces);

}

5 Conclusion

This project demonstrates how fundamental concepts of 3D computer graphics can be implemented in a simple console application using the C programming language. By defining a cube through its vertices, edges, and triangular faces, it becomes possible to simulate a three-dimensional object within a two-dimensional ASCII grid.

The implementation combines several key techniques: rotation matrices to transform the cube in 3D space, perspective projection to map 3D coordinates onto the 2D console, and rasterization methods to render vertices, edges, and faces. Back-face culling ensures that only surfaces facing the camera are drawn, improving the visual correctness of the result.

Despite the limitations of a text-based environment, the program follows the same fundamental rendering pipeline used in modern graphics systems. As a result, the rotating ASCII cube serves as a compact demonstration of how mathematical transformations and basic rendering algorithms create the illusion of 3D motion.


Thank you for reading.

I wish you all the best in your next project. Keep rotating

© Benedikt Görgei 2025