Let's make a 3D engine from scratch

Actually, 3D engines are a very satisfying yet simple piece of code. Let's make one of them and render our 3D scene.

9 min readSwitch Case

3D engines exist because all of our screens are 2D surfaces. Their main job is to project 3D points onto 2D points in a way that creates the illusion of a 3D space. This projection is very similar to looking at something in a mirror, since mirrors are flat 2D surfaces that give a sense of 3D space. Since light follows the laws of physics, we can create the illusion through mathematical formulas.

The projection mathematics#

Let P be a 3D point with coordinates (X, Y, Z), and let P’ be the corresponding 2D point with coordinates (X′, Y′). Our goal is to find the projection that maps P to P’.

In Figure 1, the blue line is the line of sight, the green line is the screen, P is our 3D point, P’ — which lies on the screen — is its 2D projection, and E is the eye position. If we draw a line from P to E, it passes through P’ and forms two similar triangles (PEA and P’EA’) so their corresponding sides are proportional.

EP = (X, Y, Z)P′ = (X′, Y′)AA′screenline of sightXX′dZ
Figure 1.The ray from P to the eye E crosses the screen at P′, forming two similar right triangles — PEA and P′EA′ — whose matching sides give X′ = d·X / Z.

Look at each triangle’s two legs:

  • In PEA: the leg along the line of sight is EA = Z (the depth of P), and the leg up to the point is AP = X.
  • In P′EA′: the leg along the line of sight is EA′ = d (the distance from the eye to the screen), and the leg up to the projected point is A′P′ = X′.

Similar triangles mean their corresponding sides are in proportion:

A′P′AP = EA′EA

Substituting the lengths:

X′X = dZ

Solving for X′:

X′ = d · XZ

Running the exact same argument in the vertical direction (swap the X leg for the Y leg) gives:

Y′ = d · YZ

So, if our eye stands at (0, 0, 1) and the screen is at Z = 0, the constant d becomes 1 and our formulas simplify to X′ = X / Z and Y′ = Y / Z.

Let’s see it in action#

The scene#

First, we need a canvas to draw shapes on. To make it easy and interactive, I decided to use HTML canvas for drawing, so we can see the result here in real time.

<html>
    <body>
        <canvas id="canvas" style="width: 100%; height: 400px"/>
        <script src="main.js"></script>
    </body>
</html>
const compStyles = window.getComputedStyle(canvas);
canvas.width = parseInt(compStyles.getPropertyValue("width"));
canvas.height = parseInt(compStyles.getPropertyValue("height"));
const ctx = canvas.getContext("2d");
Snippet 1.Prepare the 2D canvas

Snippet 1 shows the minimum requirements to set up an HTML canvas.

  • Lines 1 to 3 set up the width and height of the canvas.
  • canvas.getContext returns a graphical context that lets us draw polygons and lines on the canvas.

Clear canvas and fill a pixel on canvas#

To clear the canvas with a specific color — white in our case — I draw a rect that covers the canvas with white color through the ctx.fillRect method. The 2D context’s fillStyle property lets us to set the fill color. Since, a single pixel is very hard to see on the canvas, I use fillRect here too — drawing a small rectangle instead of the actual pixel.

const compStyles = window.getComputedStyle(canvas);
canvas.width = parseInt(compStyles.getPropertyValue("width"));
canvas.height = parseInt(compStyles.getPropertyValue("height"));
const ctx = canvas.getContext("2d");
 
// clear the canvas to white
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
 
// fill a single pixel
ctx.fillStyle = "#b3261e";
ctx.fillRect(50, 50, 10, 10);
Snippet 2.Clear canvas and draw a pixel on it.

Define point and Helper functions#

So, let’s define point structure and some drawing and 3D functions.

const compStyles = window.getComputedStyle(canvas);
canvas.width = parseInt(compStyles.getPropertyValue("width"));
canvas.height = parseInt(compStyles.getPropertyValue("height"));
const ctx = canvas.getContext("2d");
 
function Clear() {
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, canvas.width, canvas.height);
}
 
function FillPixel({x, y}) {
    ctx.fillStyle = "#b3261e";
    ctx.fillRect(x - 5, y - 5, 10, 10);
}
 
function Polygon(points) {
    ctx.strokeStyle = "#b3261e";
    ctx.beginPath();
    ctx.moveTo(points[0].x, points[0].y);
    for(let i = 1; i < points.length; i++) {
        ctx.lineTo(points[i].x, points[i].y);
    }
    ctx.closePath();
    ctx.stroke();
}
 
function Project({x, y, z}) {
    return {x: x / z, y: y / z};
}
Snippet 3.Define the drawing and projection helpers
  • Clear: clears the canvas to white.
  • FillPixel: It draws a 10x10 rect and moves the center of the rect to the input point.
  • Polygon: draws a polygon connecting the array of input points.
  • Project: projects a 3D-point onto its corresponding 2D-point.

3D engine coordinate system VS canvas coordinate system#

A 3D engine’s coordinate system puts the (0, 0) point at the center of the screen and its coordinates range from -1 to +1 (inclusive). The HTML canvas, however, puts the (0, 0) point at the top-left corner: X runs from 0 to the width and Y from 0 to the height. So, we need to translate the engine’s coordinate system to the canvas’s. I named the function ToCanvasPoint.

function ToCanvasPoint({x, y}) {
    return {
        x: ((x + 1) / 2) * canvas.width,
        y: ((y + 1) / 2) * canvas.height
    };
}
Snippet 4.Translate engine coordinate system to canvas coordinate system

We still have one problem to solve. The graphics engine’s Y-axis runs from bottom to top, whereas the canvas’s runs from top to bottom, so we need to invert the Y-axis by subtracting the Y value from 1.

function ToCanvasPoint({x, y}) {
    return {
        x: ((x + 1) / 2) * canvas.width,
        y: (1 - (y + 1) / 2) * canvas.height
    };
}
Snippet 5.Invert the Y-axis

Define a cube by its points#

(−0.5, 0.5)(0.5, 0.5)(0.5, −0.5)(−0.5, −0.5)(−0.5, 0.5)(0.5, 0.5)(0.5, −0.5)(−0.5, −0.5)near face · z = 1z = 2
Figure 2.The same 1×1×1 cube under the perspective projection x/z, y/z, seen head-on along +Z — the shape the canvas actually renders. The near face (z = 1) projects to the large outer square; the far face (z = 2) to the small inner one. Each corner is labelled with its (x, y); z is given per face. (Both faces share the same x, y — only the depth differs.)

Figure 2 shows a cube centred at (0, 0, 1.5), with each corner’s position in 3D space, so let’s make an array of the vertices (points) and draw them on the canvas.

const vertices = [
    {x: -0.5, y: 0.5, z: 1},
    {x: 0.5, y: 0.5, z: 1},    
    {x: 0.5, y: -0.5, z: 1},
    {x: -0.5, y: -0.5, z: 1},
    {x: -0.5, y: 0.5, z: 2},
    {x: 0.5, y: 0.5, z: 2},
    {x: 0.5, y: -0.5, z: 2},
    {x: -0.5, y: -0.5, z: 2},
];
 
Clear();
 
vertices.map((point) => Project(point)).forEach((point) => FillPixel(point));
Snippet 6.Define the vertices array

The result isn’t very clear, so let’s draw a wireframe of the cube using the Polygon function. To draw a wireframe we need a faces array that defines which vertices make up each face. A cube has six faces: top, bottom, left, right, front and back. Each face is an array of the vertex indices.

const vertices = [
    {x: -0.5, y: 0.5, z: 1},
    {x: 0.5, y: 0.5, z: 1},    
    {x: 0.5, y: -0.5, z: 1},
    {x: -0.5, y: -0.5, z: 1},
    {x: -0.5, y: 0.5, z: 2},
    {x: 0.5, y: 0.5, z: 2},
    {x: 0.5, y: -0.5, z: 2},
    {x: -0.5, y: -0.5, z: 2},
];
 
const faces = [
    [0 ,1, 2, 3],
    [4 ,5, 6, 7],
    [0, 4, 7, 3],
    [1, 5, 6, 2],
    [0, 4, 5, 1],
    [3, 7, 6, 2],
]
Clear();
 
const screenPoints = vertices.map((point) => ToCanvasPoint(Project(point)));
faces.forEach((face) => {Polygon(face.map((index) => screenPoints[index]))});
Snippet 7.Define the faces array.

Let’s make it more interesting#

I want to rotate the shape around the Y-axis. The rotation is achievable through some trigonometric math which you can read about here. However, the simplified version of it is in Listing. There is one more problem to solve: the Rotate function rotates a point around the origin (0, 0, 0). So we need to move our object’s center to the origin, rotate it, and translate it back to its original position. I also wrote the Translate function in Listing.

To animate the rotation, I added an interval timer that redraws the shape every 1000/6 milliseconds, so the animation runs at 6 frames per second. Each call to the timer function, rotates the shape by 6 degrees, so after ten seconds the shape will have rotated a full 360 degrees, returning to its initial state.

 
const vertices = [
    {x: -0.5, y: 0.5, z: -0.5},
    {x: 0.5, y: 0.5, z: -0.5},
    {x: 0.5, y: -0.5, z: -0.5},
    {x: -0.5, y: -0.5, z: -0.5},    
    {x: -0.5, y: 0.5, z: 0.5},
    {x: 0.5, y: 0.5, z: 0.5},
    {x: 0.5, y: -0.5, z: 0.5},
    {x: -0.5, y: -0.5, z: 0.5},
];
 
const faces = [
    [0 ,1, 2, 3],
    [4 ,5, 6, 7],
    [0, 4, 7, 3],
    [1, 5, 6, 2],
    [0, 4, 5, 1],
    [3, 7, 6, 2],
]
 
function Rotate({x, y, z}, d) {
    return {
        x: x*Math.cos(d) - z*Math.sin(d), 
        y, 
        z: x*Math.sin(d) + z*Math.cos(d)};
}
 
function Translate({x, y, z}, {dx, dy, dz}) {
    return {
        x: x + dx,
        y: y + dy, 
        z: z + dz
    };
}
 
function Draw(d) {
    Clear();
    const screenPoints = vertices.map((vertex) => ToCanvasPoint(Project(Translate(Rotate(vertex, d), {dx: 0, dy: 0, dz: 1.5}))));
    faces.forEach((face) => {Polygon(face.map((index) => screenPoints[index]))});
}
 
let angle = 0;
Draw(angle);
setInterval(() => {
    angle += 2*Math.PI / 60;
    Draw(angle); 
}, 1000/6);
 
Snippet 8.Rotate and translate cube

What can this engine draw?#

To test our implementation, we need to implement one more thing: a converter from a Wavefront (.obj) file to our JS vertices and faces arrays.

A 3D object file is a text-based format where each line contains an element. The element can be one of the following:

  • vertex: Lines that start with v define a vertex which is normally followed by three space-separated floating-point numbers.
  • vertex texture: Lines that start with vt define a vertex texture which is followed by two space-separated floating-point numbers.
  • face: The face lines start with f and are followed by three indices of the vertices that were previously defined by the v element. The face vertex index may be followed by a vertex texture index in v/vt format.

The complete reference for the .obj file format is here.

I wrote a dead-simple 3D object Wavefront format parser to extract vertices and faces from the file and pass them to the Draw method to draw the shape on the screen.

function parseObj(content) {
    const lines = content.split("\n");
    const vertices = [];
    const faces = [];
    let scale = -1;
    for(let line of lines) {
        const parts = line.split(/\s+/);
        if (parts[0] == 'v') {
            vertices.push({x: parseFloat(parts[1]), y: parseFloat(parts[2]), z: parseFloat(parts[3])});
            scale = Math.max(scale, Math.abs(vertices[vertices.length - 1].x));
            scale = Math.max(scale, Math.abs(vertices[vertices.length - 1].y));
            scale = Math.max(scale, Math.abs(vertices[vertices.length - 1].z));
        } else if (parts[0] == 'f') {
            const face = [];
            for(let i = 1; i <= 3; i++) {
                const indices = parts[i].split("/");
                face.push(parseInt(indices[0]) - 1);
            }
            faces.push(face);
        }
    }
 
    for(let v of vertices) {
        v.x /= scale;
        v.y /= scale;
        v.z /= scale;
    }
 
    return [vertices, faces];
}
Snippet 9.Parse a Wavefront 3D object file

Since each element of the file is on a separate line, I split the file content into lines and store them in the lines array. Then the code loops over the lines and splits each line by whitespace and checks whether the line starts with v or f. If so, it stores the element in the corresponding array; otherwise, it skips the line. Because the Wavefront doesn’t restrict the coordinates to -1..1, I track the largest coordinate value of the vertices in the scale variable and then divide every coordinate by it to compress them into the -1..1 range.

I prepared a teapot object downloaded from Sketchfab, which you can see below.

Bonus: An in-browser Wavefront object renderer#

Below you can select your own 3D object file and render it with the implementation described above.

▸ stay subscribed

Liked this?

Drop your email and you'll get the next post when it's published. No tracking, one-click unsubscribe.