Projects

Simple Javascript 3D Graphics Engine

Simple Javascript 3D Graphics Engine

A simple 3D graphics engine made completely from scratch in plain JavaScript.

Published: 2025-03-16

A 3D Cube Standing in Front of JavaScript Code.

Programming | JS

3D graphics certainly add a level of depth (get it) to any game or coding project. Whether its for animation or for immesive video games, 3D rendering engines such as Blender and Unreal Engine have dominated the market. This project begins with nothing but JavaScript and the CanvasAPI, and builds knowledge from the ground up about 3D rendering through abstraction.

Matrix Multiplication

Many of the calculations required to successfully render a set of 3D coordinates onto a 2D canvas rely on matrix multiplication, mostly because it is convenient and can be done easily on the GPU. Consider the following matrix multiplication:

\(\begin{bmatrix} a & b \\ c & d \end{bmatrix} \times \begin{bmatrix} e & f \\ g & h \end{bmatrix} = \begin{bmatrix} ae + bg & af + bh \\ ce + dg & cf + dh \end{bmatrix}\)

The product of the matricies is the sum of the products of each row element of matrix 1 multiplied by each column element of matrix 2. This means that matrix 1 follows a row-major system while matrix 2 is column major. Row major means that each component of a vector stored in the matrix (e.g. x, y, z, w) is stored within the same row, while column major is the opposite. In order to perform repeated matrix multiplication for vertices, a multiplyMatrix() function is defined:

multiplyMatrix(vector, matrix) {
    const result = { x: 0, y: 0, z: 0, w: 0 };
    result.x = vector.x * matrix[0][0] + vector.y * matrix[1][0] + vector.z * matrix[2][0] + vector.w * matrix[3][0];
    result.y = vector.x * matrix[0][1] + vector.y * matrix[1][1] + vector.z * matrix[2][1] + vector.w * matrix[3][1];
    result.z = vector.x * matrix[0][2] + vector.y * matrix[1][2] + vector.z * matrix[2][2] + vector.w * matrix[3][2];
    result.w = vector.x * matrix[0][3] + vector.y * matrix[1][3] + vector.z * matrix[2][3] + vector.w * matrix[3][3];
    return result;
}

World Transform

With facilitated matrix multiplication, the first stage of 3D vertex transformation can be applied: the world transform.

In 3D graphics, objects can generally take in 3 main parameters: translation, rotation, & scale, each of which contains a 3 dimensional vector (x, y, z). Upon recieving a set of vertices as input, the world transform translates, rotates, and scales the object from model space into world space.

Imagine you created a simple unit cube out of a set of 8 3-dimensional vertices. The cube will be positioned relative to the origin of the world (0, 0, 0). The world transform will take this cube, scale it, rotate it, and then translate it (in that order) based on a set of input parameters. The transformations must be done in that order specifically, and to understand why, imagine translating the cube and then rotating around the world origin. The cube would not rotate around itself ((0, 0, 0) in model space), but instead around the pivot point (0, 0, 0) in world space. In order to apply these transformations, homogeneous coordinates will be used. This is because, while a 3x3 matrix can transform scale and rotation, it cannot transform translation (because it is additive rather than multiplicative). By including a fourth coordinate that is always set to 1 in the vertex definitions (x, y, z, 1), the bottom row of the matrix can be used for transformation.

The following matrices are required for scale, rotation, and translation. The matrices will be listed column major so that the vertices may be listed row major (a matter of preference):

\(S(x,y,z) = \begin{bmatrix} S_x & 0 & 0 & 0 \\ 0 & S_y & 0 & 0 \\ 0 & 0 & S_z & 0 \\ 0 & 0 & 0 & 1 \\ \end{bmatrix}\)

\(T(x,y,z) = \begin{bmatrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & 1 & 0 \\ T_x & T_y & T_z & 1 \\ \end{bmatrix}\)

The \(S_x\), \(S_y\) & \(S_x\) values represent the scale in the \(x\), \(y\) & \(z\) directions respectively. The same applies to variants of \(T\) for translation. Here are the three rotation matrices (one for each axis):

\(R_{x}(\theta) = \begin{bmatrix} 1 & 0 & 0 & 0 \\ 0 & \cos(\theta) & -\sin(\theta) & 0 \\ 0 & \sin(\theta) & \cos(\theta) & 0 \\ 0 & 0 & 0 & 1 \\ \end{bmatrix}\)

\(R_{y}(\theta) = \begin{bmatrix} \cos(\theta) & 0 & \sin(\theta) & 0 \\ 0 & 1 & 0 & 0 \\ -\sin(\theta) & 0 & \cos(\theta) & 0 \\ 0 & 0 & 0 & 1 \\ \end{bmatrix}\)

\(R_{z}(\theta) = \begin{bmatrix} \cos(\theta) & \sin(\theta) & 0 & 0 \\ -\sin(\theta) & \cos(\theta) & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \\ \end{bmatrix}\)

Each of the rotation matrices are derrived from their 2D rotation matrix counterpart and values are simply placed in positions where they affect each axis (based on the rotation axis). These matrices can be multiplied with each other with the previously defined multiplyMatrix() function. The issue with this method is that the time complexity is much higher than it needs to be, as it would have to loop through each of the 5 matrices every time a new 3D object is created. This redundant step can be removed by combining the matrices beforehand:

\(World Transform = \begin{bmatrix} \cos(\theta_y) \cdot \cos(\theta_z) \cdot S_x & \cos(\theta_y) \cdot \sin(\theta_z) \cdot S_x & \sin(\theta_y) \cdot S_x & 0 \\ (\sin(\theta_x) \cdot \sin(\theta_Y) \cdot \cos(\theta_z) + \cos(\theta_x) \cdot -\sin(\theta_z)) \cdot S_y & (\sin(\theta_x) \cdot \sin(\theta_y) \cdot \sin(\theta_z) + \cos(\theta_x) \cdot \cos(\theta_z)) \cdot S_y & -\sin(\theta_x) \cdot \cos(\theta_y) \cdot S_y & 0 \\ (\cos(\theta_x) \cdot -\sin(\theta_y) \cdot \cos(\theta_z) - \sin(\theta_x) \cdot \sin(\theta_z)) \cdot S_z & (\cos(\theta_x) \cdot -\sin(\theta_y) \cdot \sin(\theta_z) + \sin(\theta_x) \cos(\theta_z)) \cdot S_z & \cos(\theta_x) \cdot \cos(\theta_y) \cdot S_z & 0 \\ T_x & T_y & T_z & 1 \\ \end{bmatrix}\)

A given set of row-major vertices must be multiplied with this array to return their position in world space. By adding a couple lines to reference JavaScript's CanvasAPI and drawing lines between the x and y coordinates of each vertex, a simple wireframe triangle can be rotated in 3D space:

const canvas = document.getElementById("canvas");
const ctx = ctx.getContext("2d");

class Vec3 {
    constructor(x = 0, y = 0, z = 0, w = 1) {
        this.x = x;
        this.y = y;
        this.z = z;
        this.w = w;
    }
}

class Matrix {
    constructor(rows = 0, columns = 0) {
        this.rows = rows;
        this.columns = columns;
        this.value = Array.from({ length: rows }, () => Array(columns).fill(0));
    }
    static generateWorld(scale = new Vec3(1, 1, 1), rotation = new Vec3(), translation = new Vec3()) {
        const result = new Matrix(4, 4);
        const sinx = Math.sin(rotation.x);
        const siny = Math.sin(rotation.y);
        const sinz = Math.sin(rotation.z);
        const cosx = Math.cos(rotation.x);
        const cosy = Math.cos(rotation.y);
        const cosz = Math.cos(rotation.z);
        result.value = [
            [cosy * cosz * scale.x, cosy * sinz * scale.x, siny * scale.x, 0],
            [(sinx * siny * cosz + cosx * -sinz) * scale.y, (sinx * siny * sinz + cosx * cosz) * scale.y, -sinx * cosy * scale.y, 0],
            [(cosx * -siny * cosz - sinx * sinz) * scale.z, (cosx * -siny * sinz + sinx * cosz) * scale.z, cosx * cosy * scale.z, 0],
            [translation.x, translation.y, translation.z, 1]
        ];
        return result;
    }
}

class Triangle {
    constructor(v1 = new Vec3(), v2 = new Vec3(), v3 = new Vec3()) {
        this.vertices = [new Vec3(v1.x, v1.y, v1.z), new Vec3(v2.x, v2.y, v2.z), new Vec3(v3.x, v3.y, v3.z)];
    }
    static multiplyMatrix(triangle = new Triangle(), matrix = new Matrix(4, 4)) {
        const result = new Triangle();
        for (let i = 0; i < 3; i++) {
            const vertex = triangle.vertices[i];
            result.vertices[i].x = vertex.x * matrix.value[0][0] + vertex.y * matrix.value[1][0] + vertex.z * matrix.value[2][0] + vertex.w * matrix.value[3][0];
            result.vertices[i].y = vertex.x * matrix.value[0][1] + vertex.y * matrix.value[1][1] + vertex.z * matrix.value[2][1] + vertex.w * matrix.value[3][1];
            result.vertices[i].z = vertex.x * matrix.value[0][2] + vertex.y * matrix.value[1][2] + vertex.z * matrix.value[2][2] + vertex.w * matrix.value[3][2];
            result.vertices[i].w = vertex.x * matrix.value[0][3] + vertex.y * matrix.value[1][3] + vertex.z * matrix.value[2][3] + vertex.w * matrix.value[3][3];
        }
        return result;
    }
}

function drawTriangle(triangle, color) {
    ctx.save();
    ctx.strokeStyle = color;
    ctx.beginPath();
    ctx.moveTo(triangle.vertices[0].x, triangle.vertices[0].y);
    ctx.lineTo(triangle.vertices[1].x, triangle.vertices[1].y);
    ctx.lineTo(triangle.vertices[2].x, triangle.vertices[2].y);
    ctx.closePath();
    ctx.stroke();
    ctx.restore();
}

let rotation = 0;
setInterval(() => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    let triangle = new Triangle(new Vec3(-1, 1, 0), new Vec3(0, -1, 0), new Vec3(1, 1, 0));
    triangle = Triangle.multiplyMatrix(triangle, Matrix.generateWorld({x: 200, y: 200, z: 1}, {x: 0, y: rotation, z: 0}, {x: rotatingTriangleCanvas.width / 2, y: rotatingTriangleCanvas.height / 2, z: 0})); // Calculate the positions of each triangle vertex
    rotation += 0.1;
    drawTriangle(triangle);
}, 50);

View Transform

Now that transformations can be simulated in a 3D environment on any object model, it is time to implement a camera. The view transform stage of the 3D graphics pipeline converts coordinates from world space to view space; a set of coordinates mapped as they would be if viewed from a pespective camera. The view matrix still outputs 3D vertices, but they are mapped based on the position and rotation of a camera object. The first step to understanding how this matrix works is to understand the "point at" matrix. The point at matrix takes in four vectors, (up, right, forward & translation) and alters the input vector accordingly. The vectors represent the position and orientation of a hypothetical camera, and will impact the way the input vertex vector will be drawn on to the screen. The standard point-at matrix can be represented as follows:

Note that the rotation in the matrix is not inverted and will instead be inverted before being passed to the view matrix. This is to eliminate redundant repeated inversion. The view transformation matrix can be placed into a function and will be applied after the world transform.

\(PointAt =\begin{bmatrix} R_x & R_y & R_z & 0 \\ U_x & U_y & U_z & 0 \\ F_x & F_y & F_z & 0 \\ T_x & T_y & T_z & 0 \\ \end{bmatrix}\)

Projection Transform

The final stage of the transformation pipeline is projection transform. Represented in another matrix, the projection transform matrix converts coordinates from the camera space (3D) to the screen space (2D). This is the matrix that allows users to view perspective, and introduces clipping planes, rendering only vertices within a specified distance from the camera. It takes in parameters such as FOV (field of view), aspect ratio, and the near (\(zNear\)) and far (\(zFar\)) clipping planes of the camera:

\(ProjectionTransform = \begin{bmatrix} aspectRatio \cdot \frac{1}{\tan(\frac{FOV}{2})} & 0 & 0 & 0 \\ 0 & \frac{1}{\tan(\frac{FOV}{2})} & 0 & 0 \\ 0 & 0 & \frac{zFar}{zFar - zNear} & 1 \\ 0 & 0 & -\frac{zFar \cdot zNear}{zFar - zNear} & 0 \\ \end{bmatrix}\)

When multiplied, the projection matrix returns a set of transformed coordinates and stores the z value in the extra (w) coordinate. The perspective divide can then be applied after by dividing by w. Any coordinates between the clipping planes will be returned as values between -1.0 and 1.0.