I like learning by building programs that are useless but fun. Lately I’d been feeling my mental energy running low, and I wanted the kind of fun that refills it, like Jazzlang(Korean), which I built for fun last year. While looking around for something to do, I remembered a YouTube video I had watched around this time last year. In the video, someone quietly codes a program that spins a cube in the terminal, from start to finish.

I thought it was a really fresh idea. I happened to be in the mood to build something, so I started wondering, if a cube works, couldn't other objects be rendered in 3D in the terminal too? Then I went ahead and built it. The finished renderer from this article is in the GitHub repository and on Chromatic.

How Do You Make ASCII Look 3D?

3D rendering covers some very difficult techniques, like light and shadow, optimization, shaders, collision handling, and physics. But since all we’re going to do is draw a 3D object on the screen, a few basic techniques are enough. Why do we perceive an object on a screen as sitting in three-dimensional space? The answer is simple. The screen expresses perspective. There are many ways to make a person feel perspective, but in an ordinary terminal the only ones available are lines1 and shading.

A few lines are enough to feel a space
A few lines are enough to feel a space

Expressing perspective with lines is simple. You use the fact that things far away eventually converge to a single point. In art this is called a vanishing point. There can be more than one.

Light and shadow give a sense of solidity
Light and shadow give a sense of solidity

Expressing perspective with shading is even simpler. Draw it bright where there is light and dark where there is none. That gives the object a sense of solid form.

But can you express perspective and solidity with nothing but ASCII? To give the conclusion first, think of ASCII characters as slightly large pixels. Look at the image below.

On the left is the original image, and on the right is the same image with a pixelate effect. Using ASCII is not much different from producing the image on the right, only with characters instead of pixels. There are artworks that use this idea.

The Wikipedia logo drawn with characters
The Wikipedia logo drawn with characters

The image above shows that text alone is enough to express an image. And since closer parts are drawn with denser characters and farther parts with lighter ones, assuming the light comes from the direction you are looking, it manages to express perspective and shading as well.

The spacing has to be uniform, so a monospaced font is a must. Shading done the way the cube in that YouTube video does it looks like this.

It looks like a spinning box

The example above is my own port of the program from the video, made to run in the browser. The shading isn’t based on a light source, but each face has a different shade, so you get a sense of solid form. Next, let’s go over what we need to know to build this.

From 3D Coordinates to 2D

The object we want to show exists in 3D space, but a monitor is a 2D surface. So to display it, we need to convert the 3D space defined by values into something visible on the 2D space of the screen. This conversion process is called the rendering pipeline.

The rendering pipeline
The rendering pipeline

The goal of this article is only to draw a 3D object with ASCII, so we’ll implement just part of the rendering pipeline. For that reason I won’t explain the whole thing in detail, only the parts we need. Broadly, the rendering pipeline consists of three stages.

  • Vertex processing
  • Rasterization
  • Fragment processing

Let’s look at each stage in turn.

Vertex Processing

To represent 3D space, you need 3D coordinates. 3D coordinates use the Cartesian coordinate system, expressing a position along three axes as (x, y, z). It sounds like a difficult term, but it just means describing a position with three values.

The Cartesian coordinate system
The Cartesian coordinate system

A coordinate expressed this way is called a vertex, meaning a point positioned in 3D space. A single vertex can’t represent an object, so vertices are gathered together to represent one. The smallest unit used to represent an object is called a polygon. Apart from special cases2, a polygon is usually a triangle made of three vertices. The reason it is a triangle is that a triangle is the smallest unit that can form a surface, and it is efficient.

It looks angular because it uses few polygons / Virtua Fighter
It looks angular because it uses few polygons / Virtua Fighter

A single object made of vertices and polygons like this is called a polygon mesh. Usually a developer loads a 3D model file made by a designer and builds the polygon mesh from it. A 3D model file holds a variety of information, including the vertices and polygons, and that information is what lets us represent the object. The step in this process where vertices are transformed is called vertex processing.

Various things can be done to vertices in the vertex processing stage, but transformation is the most basic. The transformation here is the core of moving 3D space onto a 2D screen. In vertex processing, transformation usually goes through three steps.

  • World transform
  • View transform
  • Projection transform

Each transform is handled with a matrix. Let’s look at each one.

World Transform

The world transform places the vertices held in the 3D model file into a 3D space called the world. The 3D model file has fixed values in its own space, but new values have to be assigned depending on where in the world it is placed, how large it is, and how it is rotated. Because it changes the model’s coordinates to place it in the world, it is also called the model transform.

Placing something somewhere in the world is the world transform
Placing something somewhere in the world is the world transform

As mentioned earlier, coordinate transforms are handled with matrices. Even though we are dealing with three-dimensional coordinates, the matrix used for the transform is 4x4. If you only handled one of them at a time, a 3x3 matrix would do, but to handle translation, scale, and rotation all at once you need a 4x4 matrix.3

Multiply the coordinate column vector by a 4×4 transform matrix and you get the transformed coordinate
Multiply the coordinate column vector by a 4×4 transform matrix and you get the transformed coordinate

There are matrices for translation, scale, and rotation, but for now it’s enough to know that they exist. To put it briefly, multiplying the column vector for a vertex position (a 1×4 matrix) by each of these matrices is the world transform. Once the world transform is done, the loaded model has been placed in the world with absolute coordinates.

View Transform

The world transform placed the model in the world with absolute coordinates, but it still can’t be shown on the screen. To show it on the screen, we need to know from which position, and from which viewpoint, the object is being looked at. The thing that holds that position and viewpoint is called the camera, and how the object appears changes with the camera’s position. So we place the camera’s position and direction in the world and transform the model’s coordinates relative to the camera’s position. This is called the view transform. Since it depends on the camera’s values, it is also called the camera transform.

Three values describe a camera.

  • Camera position (Eye)
  • Camera direction (Look)
  • Camera up vector (Up)

The position is where in the world the camera sits, the direction is which way it faces, and the up vector tells it which way is up. The up vector has to be perpendicular to the direction. These three values make up a coordinate system that describes the camera, called the camera coordinate system.

When the view transform runs, it uses the camera coordinate system to transform the coordinates of every other object relative to the camera. So you apply the inverse matrix of the camera to every object. The view transform does not yet express perspective, though. For perspective you need the projection transform.

Projection Transform

As mentioned above, the projection transform is what gives us perspective. Projection transforms divide broadly into orthographic and perspective projection, but orthographic projection doesn’t express perspective4, so 3D rendering uses perspective projection.

Perspective projection
Perspective projection

Perspective projection handles the transform through four properties.

  • Field of view
  • Aspect ratio
  • Near plane
  • Far plane

Field of view is how wide an angle the camera takes in, aspect ratio is the screen’s width relative to its height, and the near and far planes bound how much of the scene the camera can see. Perspective projection is computed from these four.

From these four properties you can build the projection matrix. The projection matrix is a 4x4 matrix, and it converts coordinates that have been through the view transform into 2D coordinates. We’ll see exactly how it’s built in the implementation below.

After the projection transform, the 3D coordinates have become 2D coordinates. Now rasterization has to turn those 2D coordinates into pixel coordinates.

Rasterization

Once vertex processing has turned 3D coordinates into 2D coordinates, turning those 2D coordinates into pixel coordinates is called rasterization. Rasterization usually goes through these steps.

  • Clipping
  • Perspective division
  • Back-face culling
  • Viewport transform
  • Scan conversion

Clipping is cutting away polygons that lie outside the camera’s view after the projection transform. Clipping cuts down on unnecessary computation.

Perspective division is the step that converts to 2D coordinates after the projection transform. This is where sizes get scaled according to the depth of the object as seen from the camera, producing perspective. The x, y, and z coordinates are divided by the w value of the 4D coordinate obtained from the projection transform.

Back-face culling is removing the back faces of objects, which cannot be seen from the camera. Like clipping, this cuts down on unnecessary computation.

The viewport transform is converting 2D coordinates into pixel coordinates. In this article, the viewport transform converts 2D coordinates into coordinates that can be shown in the terminal.

Scan conversion is filling in the space between the coordinates after they have been converted to pixels.

Fragment Processing

Fragment processing handles the coordinates that rasterization has turned into pixels. A variety of things can be done here. For example:

  • Lighting calculation
  • Texture mapping
  • Alpha blending

Since we’re rendering ASCII, we won’t do texture mapping, which expresses the surface material, or alpha blending, which adjusts transparency. All we need is the lighting calculation, to work out the shading.

The lighting calculation uses light and shadow to compute shading. To compute light and shadow, you need the light’s position and direction. The light’s position and direction make up a coordinate system that describes the light, called the light coordinate system. Like the camera coordinate system, it is a coordinate system expressing the light’s position and direction.

Implementation

Now that we’ve covered the basics of the rendering pipeline, let’s use them to implement a rendering pipeline that draws a 3D object with ASCII. The code is too long to fit in the article, so I’ll explain only the necessary parts and leave a link to the full code in the GitHub repository.

Since we’re drawing with nothing but ASCII in the terminal, there are a couple of constraints.

  1. Exact pixel coordinates can’t be used.
  2. Textures can’t be applied.

Even if we know the exact pixel coordinates, the terminal can’t show them, so they have to be converted into coordinates that can be expressed in a grid of characters. Here we’ll use a two-dimensional array. And since textures can’t be applied, we need to define in advance the characters that will be used to express shading. The implementation has to take both into account.

Groundwork

We’ll need math everywhere, so let’s build the objects for it first.

class Matrix44 { /* ... */ }
class Vector2 { /* ... */ }
class Vector3 { /* ... */ }
class Vector4 { /* ... */ }

Source code

I won’t write out the detailed code here, but each class needs to implement the basic add, subtract, multiply, and divide operations, along with the rotation, scale, and translation transforms described above.

Building the Model Loader

The model loader’s job is to load a 3D model file and extract the vertices and polygons. Here we’ll implement it for the obj format.

o Cube
v -1.000000 1.000000 1.000000
v -1.000000 -1.000000 1.000000
v -1.000000 1.000000 -1.000000
v -1.000000 -1.000000 -1.000000
v 1.000000 1.000000 1.000000
v 1.000000 -1.000000 1.000000
v 1.000000 1.000000 -1.000000
v 1.000000 -1.000000 -1.000000
f 5 3 1
f 3 8 4
f 7 6 8
f 2 8 6
f 1 4 2
f 5 2 6
f 5 7 3
f 3 7 8
f 7 5 6
f 2 4 8
f 1 3 4
f 5 1 2

Most obj files look like the above. v marks a vertex, and f is a face element, the shape formed by connecting vertices. Let’s use this to build the model loader.

class Loader {
  static loadFromFile(file: File): Promise<Polygon[]> {
    /* ... */
  }

  static loadFromString(string: string): Polygon[] {
    return this.parseOBJ(string);
  }

  private static parseOBJ(data: string): Polygon[] {
    const lines = data.split("\n");

    const vertices: Vector3[] = [];
    const polygons: Polygon[] = [];

    for (const line of lines) {
      const parts = line.trim().split(" ");
      if (parts[0] === "v") {
        vertices.push(
          new Vector3(
            parseFloat(parts[1]),
            parseFloat(parts[2]),
            parseFloat(parts[3])
          )
        );
      } else if (parts[0] === "f") {
        polygons.push(
          new Polygon([
            vertices[parseInt(parts[1]) - 1],
            vertices[parseInt(parts[2]) - 1],
            vertices[parseInt(parts[3]) - 1],
          ])
        );
      }
    }

    return polygons;
  }
}

Source code

The code above parses the contents of an obj file and extracts the vertices and polygons. Nothing here is hard. It just splits the string on whitespace and stores the values. The polygons are built from the vertex information that has already been collected.

Building the Renderer

On to the renderer. It is made up of the following logic.

  • Decide the positions of the light source and the camera.
  • Load a 3D model.
  • Apply the world, view, and projection transforms to the model’s vertices.
  • Rasterize into coordinates that can be shown in the terminal.
  • Compute shading based on the position of the light source.
  • Output ASCII characters according to the shading.

We haven’t implemented the light source or the camera yet, so let’s implement each transform first. The camera will come naturally along the way. First, the basic frame of the renderer.

export class ASCII3DRenderer {
  el: HTMLElement;
  width: number;
  height: number;
  frameBuffer: string[][];
  depthBuffer: number[][];

  private Shade = '.;ox%@';

  constructor(_el: HTMLElement, width: number, height: number) {
    this.el = _el;
    this.width = width;
    this.height = height;
    this.frameBuffer = new Array(height + 1).fill(null).map(() => new Array(width + 1).fill(' '));
    this.depthBuffer = new Array(height + 1).fill(null).map(() => new Array(width + 1).fill(255));
  }

  run() {
    // FPS logic
  }

  private render() {
    this.clearFrameBuffer();
    this.process();
    this.drawFrameBuffer();
  }

  private update() {
    // ...
  }

  private process() {
    // ...
  }

  private clearFrameBuffer() {
    // clear the buffers
  }

  private drawFrameBuffer() {
    // output to the screen
  }
}

Source code

I called it a frame, but the code isn’t short. Each field in the class holds information needed for rendering. el is the element the rendering result is written to, width and height are the size of the result, frameBuffer is the space that holds the result, and depthBuffer is the depth buffer, which stores the depth of the result. Depth here means the distance from the camera.

Shade is the predefined string of characters used to express shading.

As for the methods, run keeps rendering at 60 frames per second, and render handles the rendering by clearing what was drawn and drawing the pipeline’s result. process performs the rendering pipeline work, such as vertex processing and rasterization. Once this part is filled in, the renderer is essentially complete. process will proceed like this.

  1. Decide the camera’s position and direction.
  2. Apply the world transform to the objects.
  3. Apply the view transform to the objects.
  4. Apply the projection transform to the objects.
  5. Rasterize into coordinates that can be shown in the terminal.
  6. Output ASCII characters according to the shading.

Setting Up Objects

Before implementing the camera, let’s first add an object for the renderer to render. An object can do the following.

  1. Load a polygon mesh from an obj file.
  2. Have its own rotation, position, and scale.
  3. Transform its vertices through matrix operations.

First, the object class.

export class Object {
  mesh: Polygon[];   // polygon mesh
  position: Vector3; // position
  rotate: Vector3;   // rotation
  scale: Vector3;    // scale

  constructor() {
    // initialize
    this.mesh = [];
    this.position = new Vector3(0, 0, 0);
    this.rotate = new Vector3(0, 0, 0);
    this.scale = new Vector3(1, 1, 1);
  }

  // load a polygon mesh from an obj file
  async loadFromFile(file: File) {
    this.mesh = await Loader.loadFromFile(file);
  }

  // load a polygon mesh from an obj string
  async loadFromString(string: string) {
    this.mesh = await Loader.loadFromString(string);
  }

  // a method for changing the object every frame
  update() {
    // override this method
  }

  // world transform
  transform(v: Vector4) {
    const matrix = Matrix44.identity()
      .multiply(Matrix44.rotateX(this.rotate.x))
      .multiply(Matrix44.rotateY(this.rotate.y))
      .multiply(Matrix44.rotateZ(this.rotate.z))
      .multiply(Matrix44.scale(this.scale))
      .multiply(Matrix44.translate(this.position));

    return v.transform(matrix);
  }

  /* Setter */

  setTranslate(v: Vector3) {
    this.position = v;
  }

  setScale(v: Vector3) {
    this.scale = v;
  }

  setRotateX(angle: number) {
    this.rotate.x = angle;
  }

  setRotateY(angle: number) {
    this.rotate.y = angle;
  }

  setRotateZ(angle: number) {
    this.rotate.z = angle;
  }
}

Source code

This is the Object class. Object holds a polygon mesh in its mesh field and its position, rotation, and scale in the position, rotate, and scale fields. The loadFromFile and loadFromString methods load an obj file and set the polygon mesh, and the transform method transforms the vertices.

The matrix that transforms the vertices is the identity matrix multiplied by the rotation, scale, and translation transform matrices.

The update method is for changing the object every frame, and it’s meant to be overridden. The setTranslate, setScale, setRotateX, setRotateY, and setRotateZ methods set the position, scale, and rotation. As the update method suggests, the Object class is designed to be subclassed.

Next, modify the renderer so objects can be added to it.

export class ASCII3DRenderer {
  objects: Object[] = [];

  placeObject(object: Object) {
    this.objects.push(object);
  }

  // ...
}

That covers the basic object implementation and adding objects to the renderer. Next, vertex processing.

Vertex Processing

Vertex processing applies the world, view, and projection transforms to an object’s vertices. Let’s start by applying the world transform to the renderer’s objects.

export class ASCII3DRenderer {
  // ...

  private process() {
    for (const object of this.objects) {
      for (const polygon of object.mesh) {
        // convert to Vector4 for 4x4 matrix math
        let v1 = new Vector4(polygon.vertices[0].x, polygon.vertices[0].y, polygon.vertices[0].z, 1);
        let v2 = new Vector4(polygon.vertices[1].x, polygon.vertices[1].y, polygon.vertices[1].z, 1);
        let v3 = new Vector4(polygon.vertices[2].x, polygon.vertices[2].y, polygon.vertices[2].z, 1);

        // world transform
        v1 = object.transform(v1);
        v2 = object.transform(v2);
        v3 = object.transform(v3);

        // ...
      }
    }
  }
}

Since we need 4x4 matrix math, we walk the object’s polygons and convert each vertex into a 4D coordinate. Then object.transform applies the world transform to each vertex. The math functions are already implemented, so applying them is simple. Now the view transform. The view transform needs a camera first, so let’s build the Camera class.

export class Camera {
  eye: Vector3;       // camera position
  look: Vector3;      // direction the camera is facing
  up: Vector3;        // camera's up direction
  rotate: Vector3;    // camera rotation angles

  constructor() {
    // set the camera's initial position, direction, up direction, and rotation
    this.eye = new Vector3(0, 0, 0);
    this.look = new Vector3(0, 0, 1);
    this.up = new Vector3(0, 1, 0);
    this.rotate = new Vector3(0, 0, 0);  // rotation angles around the x, y, and z axes
  }

  transform(v: Vector4) {
    // compute the camera's forward, right, and up vectors
    const forward = this.look.subtract(this.eye).normalize();
    const newUp = forward.cross(this.up).normalize();
    const right = newUp.cross(forward);

    const matrix = new Matrix44(
      right.x, right.y, right.z, 0,
      newUp.x, newUp.y, newUp.z, 0,
      forward.x, forward.y, forward.z, 0,
      this.eye.x, this.eye.y, this.eye.z, 1
    );

    return v.transform(matrix);
  }
}

Source code

The code above implements the Camera class with the fields eye, look, up, and rotate, which hold the camera’s position, direction, up vector, and rotation. The transform method applies the view transform. Looking at the implementation, it’s more involved than the world transform. Let’s take it piece by piece.

forward, newUp, and right, in that order, are the forward vector, right vector, and up vector, and each is used to express the camera’s direction and position.

  • Forward vector: the difference between where the camera looks and where it sits, normalized. This is the camera’s forward direction, and it’s what gives objects their depth.
  • Right vector: the cross product of the forward vector and the up vector, normalized. It points to the camera’s right, which is what left-right movement and rotation of objects are built on.
  • Up vector: the cross product of the right vector and the forward vector, normalized. It points up from the camera and handles up-down movement.

Normalizing a vector means keeping its direction but making its length 1. The vectors obtained this way are used to build the view transform matrix, which is applied to the vertices. Time to add the camera to the renderer and apply the view transform.

export class ASCII3DRenderer {
  // ...

  camera: Camera = new Camera();

  private process() {
    this.camera.eye = new Vector3(0, 0, -3);

    for (const object of this.objects) {
      for (const polygon of object.mesh) {
        // convert to Vector4 for 4x4 matrix math
        let v1 = new Vector4(polygon.vertices[0].x, polygon.vertices[0].y, polygon.vertices[0].z, 1);
        let v2 = new Vector4(polygon.vertices[1].x, polygon.vertices[1].y, polygon.vertices[1].z, 1);
        let v3 = new Vector4(polygon.vertices[2].x, polygon.vertices[2].y, polygon.vertices[2].z, 1);

        // world transform
        v1 = object.transform(v1);
        v2 = object.transform(v2);
        v3 = object.transform(v3);

        // view transform
        v1 = this.camera.transform(v1);
        v2 = this.camera.transform(v2);
        v3 = this.camera.transform(v3);

        // ...
      }
    }
  }
}

Only the projection transform is left. Let’s build a Projection class to handle it.

export class Projection {
  fov: number;
  aspect: number;
  near: number;
  far: number;

  constructor(fov: number, aspect: number, near: number, far: number) {
    this.fov = fov;
    this.aspect = aspect;
    this.near = near;
    this.far = far;
  }

  transform(v: Vector4) {
    const fovRad = this.fov * (Math.PI / 180);
    const f = 1.0 / Math.tan(fovRad / 2);
    const rangeInv = 1.0 / (this.near - this.far);

    // prettier-ignore
    const matrix = new Matrix44(
      f / this.aspect, 0, 0, 0,
      0, f, 0, 0,
      0, 0, (this.near + this.far) * rangeInv, -1,
      0, 0, this.near * this.far * rangeInv * 2, 0
    );

    const transformed = v.transform(matrix);

    transformed.x /= transformed.w;
    transformed.y /= transformed.w;
    transformed.z /= transformed.w;

    return transformed;
  }
}

The Projection class has the fields fov, aspect, near, and far, which hold the field of view, aspect ratio, near plane, and far plane. The transform method applies the projection transform. The explanation of the formula is a bit involved, so I’ll skip it here. Then apply the projection transform in the renderer.

export class ASCII3DRenderer {
  // ...
  projection: Projection;

  constructor(_el: HTMLElement, width: number, height: number) {
    // ...
    this.projection = new Projection(70, width / 2 / height, 0.1, 100);
  }

  private process() {
    this.camera.eye = new Vector3(0, 0, -3);

    for (const object of this.objects) {
      for (const polygon of object.mesh) {
        // convert to Vector4 for 4x4 matrix math
        let v1 = new Vector4(polygon.vertices[0].x, polygon.vertices[0].y, polygon.vertices[0].z, 1);
        let v2 = new Vector4(polygon.vertices[1].x, polygon.vertices[1].y, polygon.vertices[1].z, 1);
        let v3 = new Vector4(polygon.vertices[2].x, polygon.vertices[2].y, polygon.vertices[2].z, 1);

        // world transform
        v1 = object.transform(v1);
        v2 = object.transform(v2);
        v3 = object.transform(v3);

        // view transform
        v1 = this.camera.transform(v1);
        v2 = this.camera.transform(v2);
        v3 = this.camera.transform(v3);

        // projection transform
        v1 = this.projection.transform(v1);
        v2 = this.projection.transform(v2);
        v3 = this.projection.transform(v3);
      }
    }
  }
}

If you’ve made it this far, vertex processing is done.

Rasterization

You may be getting bored, since we haven’t seen any output yet. Once rasterization is implemented, we’ll finally get to see real results. Before it gets any more tedious, let’s do something light and connect the three coordinates of each vertex with lines.

export class ASCII3DRenderer {
  private process() {
    this.camera.eye = new Vector3(0, 0, -3);

    for (const object of this.objects) {
      for (const polygon of object.mesh) {
        // ...

        // rasterization
        this.rasterize(v1, v2, v3);
      }
    }
  }

  private rasterize(v1: Vector4, v2: Vector4, v3: Vector4) {
    // convert the points to screen coordinates
    const p1 = new Vector2(((v1.x + 1) * this.width) / 2, ((1 - v1.y) * this.height) / 2);
    const p2 = new Vector2(((v2.x + 1) * this.width) / 2, ((1 - v2.y) * this.height) / 2);
    const p3 = new Vector2(((v3.x + 1) * this.width) / 2, ((1 - v3.y) * this.height) / 2);

    // draw the lines
    this.drawLine(p1, p2);
    this.drawLine(p2, p3);
    this.drawLine(p3, p1);
  }

  private drawLine(p1: Vector2, p2: Vector2) {
    const result = p2.subtract(p1);
    const len = result.length();
    const normalized = result.normalize();

    for (let i = 0; i < len; i++) {
      const current = normalized.multiply(i);
      const p = p1.add(current);
      
      // ignore coordinates that fall off the screen
      if (p.x >= 0 && p.x < this.width && p.y >= 0 && p.y < this.height) {
        this.frameBuffer[Math.floor(p.y)][Math.floor(p.x)] = '#';
      }
    }
  }
}

In the code above, the rasterize method converts the vertices to screen coordinates and draws lines with the drawLine method. drawLine finds the distance between two points and puts a ’#’ at each coordinate along the way. Now let’s add an object to the renderer and render it. Here I’ll create a Cube object and add that.

import { Vector3 } from '../math';
import { Object } from '../object';

export class Cube extends Object {
  angle = 0;

  constructor() {
    super();
    this.loadFromString(mesh);
  }

  override update(): void {
    this.setRotateX(-this.angle * 2);
    this.setRotateY(-this.angle * 2);
    this.setRotateZ(-this.angle);
    this.setTranslate(new Vector3(0, 0, -5));
    this.angle += 0.007;
  }
}

export const mesh = /* contents of the obj file */;

As you can see, angle rotates the object a little every frame. Add it to the renderer and render. The code doesn’t have to look exactly like this.

const renderer = new ASCII3DRenderer(document.getElementById('app'), 150, 50);
const cube = new Cube();
renderer.placeObject(cube);
renderer.run();

With this in place you can see the rendering result in the browser. Let’s take a look.

It’s a little awkward, but it does look like a cube rotating. This time, let’s fill in the space between the vertices.

export class ASCII3DRenderer {
  private process() {
    this.camera.eye = new Vector3(0, 0, -3);

    for (const object of this.objects) {
      for (const polygon of object.mesh) {
        // ...

        // rasterization
        this.rasterize(v1, v2, v3);
      }
    }
  }

  private rasterize(v1: Vector4, v2: Vector4, v3: Vector4) {
    // convert the points to screen coordinates
    const p1 = new Vector2(((v1.x + 1) * this.width) / 2, ((1 - v1.y) * this.height) / 2);
    const p2 = new Vector2(((v2.x + 1) * this.width) / 2, ((1 - v2.y) * this.height) / 2);
    const p3 = new Vector2(((v3.x + 1) * this.width) / 2, ((1 - v3.y) * this.height) / 2);

    // compute the triangle's bounding box
    const minX = Math.floor(Math.max(0, Math.min(p1.x, p2.x, p3.x)));
    const minY = Math.floor(Math.max(0, Math.min(p1.y, p2.y, p3.y)));
    const maxX = Math.floor(Math.min(this.width, Math.max(p1.x, p2.x, p3.x)));
    const maxY = Math.floor(Math.min(this.height, Math.max(p1.y, p2.y, p3.y)));

    for (let x = minX; x <= maxX; x++) {
      for (let y = minY; y <= maxY; y++) {
        const p = new Vector2(x, y);

        // if the point is inside the triangle
        if (this.isPointInTriangle(p, p1, p2, p3)) {
          this.setPixel(x, y);
        }
      }
    }
  }

  private isPointInTriangle(p: Vector2, p1: Vector2, p2: Vector2, p3: Vector2): boolean {
    // compute the sign of the three sub-triangles
    const b1 = this.sign(p, p1, p2) < 0;
    const b2 = this.sign(p, p2, p3) < 0;
    const b3 = this.sign(p, p3, p1) < 0;

    // if all sub-triangles have the same sign, the point is inside the triangle
    return b1 === b2 && b2 === b3;
  }

  // ...
}

Now the inside of each triangle is filled in too, and the drawLine method is no longer used. A cube without shading looks awkward, so I rendered a cow object instead.

Lighting

Finally, let’s add shading. For that we need to add a light source.

export class ASCII3DRenderer {
  // ...

  private process() {
    this.camera.eye = new Vector3(0, 0, -2);

    for (const object of this.objects) {
      for (const polygon of object.mesh) {
        // convert to Vector4 for 4x4 matrix math
        let v1 = new Vector4(polygon.vertices[0].x, polygon.vertices[0].y, polygon.vertices[0].z, 1);
        let v2 = new Vector4(polygon.vertices[1].x, polygon.vertices[1].y, polygon.vertices[1].z, 1);
        let v3 = new Vector4(polygon.vertices[2].x, polygon.vertices[2].y, polygon.vertices[2].z, 1);

        // world transform
        v1 = object.transform(v1);
        v2 = object.transform(v2);
        v3 = object.transform(v3);

        // view transform
        v1 = this.camera.transform(v1);
        v2 = this.camera.transform(v2);
        v3 = this.camera.transform(v3);

        // lighting
        const brightness = this.calculateLight(v1, v2, v3);

        // projection transform
        v1 = this.projection.transform(v1);
        v2 = this.projection.transform(v2);
        v3 = this.projection.transform(v3);

        // rasterization
        this.rasterize(v1, v2, v3, brightness);
      }
    }
  }

  private calculateLight(v1: Vector4, v2: Vector4, v3: Vector4): number {
    // direction vector of the light source
    // uses the direction the camera is facing, (0, 0, 1)
    const lightDirection = new Vector3(0, 0, 1).normalize();

    // compute the triangle's surface normal
    const normal = this.calculateSurfaceNormal(
      new Vector3(v1.x, v1.y, v1.z),
      new Vector3(v2.x, v2.y, v2.z),
      new Vector3(v3.x, v3.y, v3.z)
    );

    // compute the angle between the light and the surface normal
    const cosAngle = normal.dot(lightDirection);

    // decide the pixel's brightness from the angle of the light
    const brightness = Math.max(0, cosAngle);

    return brightness;
  }

  private calculateSurfaceNormal(v1: Vector3, v2: Vector3, v3: Vector3): Vector3 {
    // define two edges
    const edge1 = v3.subtract(v2);
    const edge2 = v1.subtract(v3);

    // compute the cross product and return the surface normal
    return edge1.cross(edge2).normalize();
  }
  
  // ...
}

The lighting calculation has to happen between the view transform and the projection transform. After the projection transform, the coordinates have already become 2D, so it has to be done before that. In the code above, the calculateLight method uses the light’s direction vector and the triangle’s surface normal to compute the angle between them. A normal vector is a vector that represents the direction a plane is facing. The angle of the light against it decides the brightness of the pixel.

Normal vector. Not the friendliest name..
Normal vector. Not the friendliest name..

Now let’s output ASCII characters according to the shading.

export class ASCII3DRenderer {
  // ...

  private process() {
    this.camera.eye = new Vector3(0, 0, -2);

    for (const object of this.objects) {
      for (const polygon of object.mesh) {
        // convert to Vector4 for 4x4 matrix math
        let v1 = new Vector4(polygon.vertices[0].x, polygon.vertices[0].y, polygon.vertices[0].z, 1);
        let v2 = new Vector4(polygon.vertices[1].x, polygon.vertices[1].y, polygon.vertices[1].z, 1);
        let v3 = new Vector4(polygon.vertices[2].x, polygon.vertices[2].y, polygon.vertices[2].z, 1);

        // world transform
        v1 = object.transform(v1);
        v2 = object.transform(v2);
        v3 = object.transform(v3);

        // view transform
        v1 = this.camera.transform(v1);
        v2 = this.camera.transform(v2);
        v3 = this.camera.transform(v3);

        // lighting
        const brightness = this.calculateLight(v1, v2, v3);

        // projection transform
        v1 = this.projection.transform(v1);
        v2 = this.projection.transform(v2);
        v3 = this.projection.transform(v3);

        // rasterization
        this.rasterize(v1, v2, v3, brightness);
      }
    }
  }

  private rasterize(v1: Vector4, v2: Vector4, v3: Vector4, brightness: number) {
    // convert the points to screen coordinates
    const p1 = new Vector2(((v1.x + 1) * this.width) / 2, ((1 - v1.y) * this.height) / 2);
    const p2 = new Vector2(((v2.x + 1) * this.width) / 2, ((1 - v2.y) * this.height) / 2);
    const p3 = new Vector2(((v3.x + 1) * this.width) / 2, ((1 - v3.y) * this.height) / 2);

    // compute the triangle's bounding box
    const minX = Math.floor(Math.max(0, Math.min(p1.x, p2.x, p3.x)));
    const minY = Math.floor(Math.max(0, Math.min(p1.y, p2.y, p3.y)));
    const maxX = Math.floor(Math.min(this.width, Math.max(p1.x, p2.x, p3.x)));
    const maxY = Math.floor(Math.min(this.height, Math.max(p1.y, p2.y, p3.y)));

    // walk the inside of the box
    for (let y = minY; y <= maxY; y++) {
      for (let x = minX; x <= maxX; x++) {
        // ignore anything off the screen
        if (x < 0 || x >= this.width || y < 0 || y >= this.height) {
          continue;
        }

        const p = new Vector2(x, y);

        // if the point is inside the triangle
        if (this.isPointInTriangle(p, p1, p2, p3)) {
          // overwrite if the depth is lower
          if ((v1.w + v2.w + v3.w) / 3.0 <= this.depthBuffer[y][x]) {
            const shade = this.Shade[Math.round(brightness * (this.Shade.length - 1))];
            this.frameBuffer[y][x] = shade;
            this.depthBuffer[y][x] = (v1.w + v2.w + v3.w) / 3.0;
          }
        }
      }
    }
  }

  // ...
}

The shading picks a level from Shade according to the brightness value. depthBuffer also makes its first appearance here. The depth buffer stores the depth of each pixel drawn on the screen, and a pixel with a lower depth is closer to the front. It keeps pixels that are behind from overwriting pixels that are in front.

The final result
The final result

Using Extended ASCII

At this point it’s done. With a small change we can make the shading contrast more strongly, using extended ASCII.

The shading is more distinct
The shading is more distinct

Nothing to it. Just change the Shade array.

private Shade = '·┼╬░▒▓█';
Not bad
Not bad

Closing

Since it’s implemented in JavaScript (built from TypeScript), it runs in the browser. The result can be seen on the web like this.

The shading comes through clearly

There are more examples on the separately deployed Chromatic page.

I started this project half for fun, but the implementation wasn’t as easy as I expected. I became a developer because I wanted to make games, but it has already been ten years since I last studied anything related to game development. Even so, working on this after such a long time was fun, and it felt like my brain was waking up again. It reminded me once more that learning comes down to trying things you don’t usually do and repeating them until they become familiar. If I get the chance, I’d like to do another fun project like this.

  1. Strictly speaking, they aren’t lines. Just as small squares called pixels come together to look like a line, ASCII characters come together to look like one.

  2. 3D modeling tools offer quad polygons for convenience while working. These are converted to triangles in post-processing.

  3. This is because of homogeneous coordinates. A homogeneous coordinate system extends 3D coordinates to 4D by adding a directional component, which lets rotation, translation, and scaling of 3D coordinates be handled in a single operation.

  4. For that reason it is commonly used for UI that needs to sit flat against the screen.