One Formula to Understand 3D Graphics
The entire field of 3D-to-2D projection can be understood through a single formula: x' = x/z and y' = y/z. This tutorial builds a complete 3D wireframe renderer in vanilla JavaScript using nothing but 2D Canvas, demonstrating perspective projection, rotation, and a spinning cube — with no WebGL, no Three.js, and no graphics API.
What if you could understand the core of 3D graphics with a single formula? Not a matrix stack, not a shader pipeline, not a graphics API — just one mathematical relationship that turns a 3D world into a 2D image. That formula is:
x' = x / z
y' = y / z
Everything else in a software 3D renderer — rotation, animation, wireframes, even complex 3D models — is built on top of this. This tutorial will prove it by building a complete spinning 3D cube renderer from scratch in JavaScript, using only the browser's 2D Canvas API.
Setting Up the Canvas
No external libraries, no WebGL context — just a plain HTML canvas element and a 2D drawing context:
const BACKGROUND = "#101010"
const FOREGROUND = "#50FF50"
game.width = 800
game.height = 800
const ctx = game.getContext("2d")
function clear() {
ctx.fillStyle = BACKGROUND
ctx.fillRect(0, 0, game.width, game.height)
}
function point({x, y}) {
const s = 20;
ctx.fillStyle = FOREGROUND
ctx.fillRect(x - s/2, y - s/2, s, s)
}
The Projection Formula and Why It Works
Imagine you are standing at the origin (0, 0, 0) and looking down the positive Z-axis. A screen sits one unit in front of you, at Z = 1. A 3D point P = (x, y, z) sits somewhere in the space behind the screen.
The question is: where does P appear on the screen? The answer comes from similar triangles. The ray from your eye to P passes through the screen at a point (x', y', 1). By similar triangles:
x' / 1 = x / z → x' = x/z
y' / 1 = y / z → y' = y/z
That is all. The further away an object is (large z), the smaller it appears on screen (small x', y'). Objects twice as far appear half as large. This is perspective projection.
Bridging to Screen Coordinates
The projection formula gives us coordinates in the range roughly (-1, 1). HTML Canvas uses a different coordinate system: origin at the top-left, y increasing downward. A conversion function handles this:
function screen(p) {
return {
x: (p.x + 1) / 2 * game.width,
y: (1 - (p.y + 1) / 2) * game.height,
}
}
This maps x from [-1, 1] to [0, 800] and flips y so that positive is up, matching the conventional 3D coordinate system.
The Projection Function
function project({x, y, z}) {
return {
x: x / z,
y: y / z,
}
}
Three lines of code. This function is the entire 3D engine's core.
Animating Along the Z-Axis
To verify perspective works, animate a single point moving away along Z:
const FPS = 60;
let dz = 0;
function frame() {
const dt = 1 / FPS;
dz += 1 * dt;
clear()
point(screen(project({x: 0.5, y: 0, z: 1 + dz})))
setTimeout(frame, 1000 / FPS);
}
frame();
The point starts at z=1 and moves away. As z increases, x/z decreases, and the rendered point visibly migrates toward the center of the screen — exactly as perspective demands.
3D Rotation
Rotation in 3D is applied before projection. Rotating in the XZ plane (spinning around the Y-axis) uses a standard rotation matrix:
function rotate_xz({x, y, z}, angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
return {
x: x * c - z * s,
y,
z: x * s + z * c,
};
}
The Y component is unchanged because we are rotating around the Y-axis. Similarly, rotate_yz handles tilting the scene:
function rotate_yz({x, y, z}, angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
return {
x,
y: y * c - z * s,
z: y * s + z * c,
};
}
Building a Cube
A cube has 8 vertices. Centered at the origin with half-extent 0.25:
const vs = [
{x: 0.25, y: 0.25, z: 0.25},
{x: -0.25, y: 0.25, z: 0.25},
{x: -0.25, y: -0.25, z: 0.25},
{x: 0.25, y: -0.25, z: 0.25},
{x: 0.25, y: 0.25, z: -0.25},
{x: -0.25, y: 0.25, z: -0.25},
{x: -0.25, y: -0.25, z: -0.25},
{x: 0.25, y: -0.25, z: -0.25},
]
The edges are defined as lists of vertex indices. Faces with 4 vertices form the square faces; pairs form the connecting edges between front and back:
const fs = [
[0, 1, 2, 3], // front face
[4, 5, 6, 7], // back face
[0, 4], // top-right edge
[1, 5], // top-left edge
[2, 6], // bottom-left edge
[3, 7], // bottom-right edge
]
Rendering the Wireframe
function line(p1, p2) {
ctx.strokeStyle = FOREGROUND
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
}
let angle = 0;
function frame() {
const dt = 1 / FPS;
angle += 1 * dt;
clear();
// Transform all vertices: rotate then push back along Z
const transformed = vs.map(v => {
let p = rotate_xz(v, angle);
p = rotate_yz(p, angle * 0.7);
p.z += 2; // push the cube 2 units in front of the camera
return p;
});
// Project all vertices to 2D
const projected = transformed.map(v => screen(project(v)));
// Draw edges
for (const face of fs) {
for (let i = 0; i < face.length; i++) {
const a = projected[face[i]];
const b = projected[face[(i + 1) % face.length]];
line(a, b);
}
}
setTimeout(frame, 1000 / FPS);
}
frame();
Scaling Up: Complex 3D Models
The same engine handles arbitrarily complex geometry without modification. The tutorial demonstrates this with "Penger" — a 3D penguin model loaded from an OBJ file containing approximately 326 vertices and 626 faces. The projection function does not change. The rotation functions do not change. Only the vertex and face data grows.
Loading an OBJ file is straightforward: parse lines beginning with v as vertices, lines beginning with f as faces, and pass the resulting arrays into the same rendering loop. The entire 3D renderer, including OBJ loading, fits in well under 100 lines of plain JavaScript.
What We Didn't Use
This entire renderer was built without:
- OpenGL, WebGL, or WebGPU
- Three.js, Babylon.js, or any 3D library
- Transformation matrices or a matrix stack
- Shaders or GPU programming
- Z-buffering or lighting models
Just two divisions and a 2D drawing context. The formula x' = x/z, y' = y/z is not a simplification of 3D graphics — it is 3D graphics, in its most essential form. Everything else is refinement.
The full source code is available on GitHub: github.com/tsoding/formula. The Penger model used in the demo is at github.com/Max-Kawula/penger-obj.