Ever thought building a 3D renderer requires years of graphics programming experience or a massive game engine? Think again! Today, we're going to build a fully functional 3D renderer from scratch using nothing but HTML Canvas and a beautifully simple mathematical formula. No WebGL, no libraries, no complexity - just pure, fundamental 3D graphics.
The Magic Formula
Here's the secret: to project a 3D point onto your 2D screen, you just need:
x' = x / z
y' = y / zThat's it. Take your 3D point's X coordinate and divide it by Z. Do the same for Y. What you get is the point's position on your screen. Simple, right?
Setting Up Our Canvas
Let's get practical. We'll use plain HTML and JavaScript - no fancy libraries needed.
First, create a basic HTML file:
<!DOCTYPE html>
<html>
<head>
<title>3D Renderer</title>
</head>
<body>
<canvas id="game"></canvas>
<script src="index.js"></script>
</body>
</html>Now here's a neat trick: if your HTML element has a valid JavaScript variable name as its ID, you can access it directly without document.getElementById(). So in our index.js, we can just write:
console.log(game); // The canvas element!Let's make our canvas a decent size:
game.width = 800;
game.height = 800;Getting Our Context
To actually draw on the canvas, we need a 2D rendering context:
const ctx = game.getContext('2d');Let's test it by drawing a rectangle:
ctx.fillStyle = '#00ff00';
ctx.fillRect(0, 0, 100, 100);You should see a green square in the top-left corner. Let's make things cleaner by setting up some constants:
const background = '#333333';
const foreground = '#00ff00';
function clear() {
ctx.fillStyle = background;
ctx.fillRect(0, 0, game.width, game.height);
}Creating a Point Function
We'll need a convenient way to draw points:
const s = 20; // point size
function point({x, y}) {
ctx.fillStyle = foreground;
ctx.fillRect(x - s/2, y - s/2, s, s);
}Notice we offset by half the size so the point is centered at the given coordinates.
Understanding Coordinate Systems
Here's where things get interesting. Our magic formula assumes the screen's center is at (0, 0), with X going from -1 to 1 and Y going from -1 to 1. But HTML Canvas has (0, 0) in the top-left corner!
We need a function to translate between these coordinate systems:
function screen(p) {
const x = (p.x + 1) / 2 * game.width;
const y = (1 - (p.y + 1) / 2) * game.height;
return {x, y};
}What's happening here?
- Add 1 to x: range goes from 0 to 2
- Divide by 2: range goes from 0 to 1
- Multiply by width: range goes from 0 to canvas width
- For y, we subtract from 1 to flip it (positive Y should go up, not down)
Let's test it:
clear();
point(screen({x: 0, y: 0})); // Center of the screenImplementing the Projection Formula
Now for the main event:
function project(p) {
return {
x: p.x / p.z,
y: p.y / p.z
};
}Let's try projecting a 3D point:
const p3d = {x: 0.5, y: 0, z: 1};
clear();
point(screen(project(p3d)));Important: Z can't be zero! That would put the point right in your eye with nowhere to project. Keep Z at least 1 or greater.
Adding Animation
A single point isn't very convincing. Let's animate it moving away from us:
const fps = 60;
const dt = 1 / fps;
let dz = 0;
function frame() {
clear();
const p3d = {x: 0.5, y: 0, z: 1 + dz};
point(screen(project(p3d)));
dz += dt;
setTimeout(frame, 1000 / fps);
}
frame();Watch it move! As the point moves away (increasing Z), it moves toward the center - just like objects do in real life when they move into the distance.
Creating a Cube
Let's define 8 vertices for a cube:
const vs = [
{x: -0.25, y: -0.25, z: 0.5}, // back face
{x: 0.25, y: -0.25, z: 0.5},
{x: 0.25, y: 0.25, z: 0.5},
{x: -0.25, y: 0.25, z: 0.5},
{x: -0.25, y: -0.25, z: 1}, // front face
{x: 0.25, y: -0.25, z: 1},
{x: 0.25, y: 0.25, z: 1},
{x: -0.25, y: 0.25, z: 1}
];And define which vertices connect to form faces:
const fs = [
[0, 1, 2, 3], // back face
[4, 5, 6, 7], // front face
[0, 4], // connecting edges
[1, 5],
[2, 6],
[3, 7]
];Adding Rotation
Static cubes are boring. Let's rotate around the Y-axis:
function rotateXZ(p, angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
return {
x: p.x * c - p.z * s,
y: p.y,
z: p.x * s + p.z * c
};
}
let angle = 0;
function frame() {
clear();
angle += Math.PI * dt; // one rotation per second
for (const v of vs) {
const rotated = rotateXZ(v, angle);
point(screen(project(rotated)));
}
setTimeout(frame, 1000 / fps);
}Drawing Wireframes
Points are nice, but let's connect them with lines:
function line(p1, p2) {
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = foreground;
ctx.lineWidth = 3;
ctx.stroke();
}Now render the faces:
for (const f of fs) {
for (let i = 0; i < f.length; i++) {
const v1 = vs[f[i]];
const v2 = vs[f[(i + 1) % f.length]]; // wrap around
const r1 = rotateXZ(v1, angle);
const r2 = rotateXZ(v2, angle);
line(screen(project(r1)), screen(project(r2)));
}
}Why Does This Even Work?
The formula works because of similar triangles! Imagine your eye at Z=0 and your screen at Z=1. When you draw a line from a 3D point P to your eye, it intersects the screen at some point P'.
The triangle formed by your eye, the screen intersection point, and the Z-axis is similar to the triangle formed by your eye, point P, and the Z-axis. Since the triangles are similar:
1 / Z = x' / xRearranging:
x' = x / ZThe same logic applies to the Y coordinate. Beautiful, isn't it?
The Power of Simplicity
What's amazing is that this simple formula can render arbitrarily complex 3D models. You just need vertices and faces. The example in the original video shows a model with 326 vertices and 626 faces - all rendered with just this basic projection formula and HTML5 Canvas. No WebGL, no WebGPU, no game engine required.
Try It Yourself
The full code is elegant in its simplicity. You can extend it by:
- Adding more complex models
- Implementing rotation around other axes
- Adding color or shading
- Creating interactive controls
The beauty of understanding the fundamentals is that you can build anything on top of them.
Understanding the Limitations
Now, let's be real - this method has limitations. It's important to understand what we've built and where it fits in the bigger picture of 3D rendering.
Performance Constraints
Our renderer is CPU-bound. Every calculation happens in JavaScript on your processor, which is relatively slow compared to what GPUs can do. Modern graphics cards can render millions of triangles per second; our method starts struggling with just thousands. There's also no hardware acceleration - we're drawing each line individually on the Canvas 2D context, which is inefficient for complex scenes.
Visual Issues
No hidden surface removal - this is a big one. You can see through surfaces because we don't have a depth buffer to determine what's in front of what. There's no back-face culling either, so we're drawing all faces even when they're facing away from you. To fix this properly, you'd need to implement the painter's algorithm and manually sort objects back-to-front.
We also can't do textures (just wireframes or solid colors), there's no lighting or shading, and no anti-aliasing (so lines can look jagged). Advanced effects like transparency, fog, or reflections? Not happening with this approach.
Feature Gaps
There's no perspective-correct texture mapping, limited clipping for objects behind the camera, and you'd need to manually implement every matrix operation for complex transformations.
Other 3D Rendering Methods
So what else is out there? Let's explore the landscape of 3D rendering techniques.
Rasterization (The Industry Standard)
This is what modern game engines use. WebGL, OpenGL, DirectX, and Vulkan all use rasterization - they render triangles by converting 3D geometry to 2D pixels. It's GPU-accelerated, incredibly fast, and supports textures, lighting, shadows, and complex shaders. This is what powers Three.js, Babylon.js, games, and CAD software.
The key difference? Rasterization uses a graphics pipeline with vertex shaders, fragment shaders, and hardware depth buffering. Instead of us calculating projection on the CPU, the GPU does it for millions of vertices simultaneously.
Ray Tracing (The Photorealistic Approach)
Ray tracing simulates light rays bouncing from the camera into the scene. It produces photorealistic results with accurate reflections, refractions, and shadows - think Pixar movies or architectural visualizations. Traditionally, it was too slow for real-time use, but modern GPUs (like NVIDIA RTX) now support real-time ray tracing.
Many modern games use hybrid rendering: rasterization for primary geometry combined with ray tracing for reflections, shadows, and global illumination.
Software Rendering
Our method is actually a form of software rendering! Other software approaches include:
Scanline rendering rasterizes triangles line by line with proper depth buffering, all in software. This is how 3D graphics worked before GPU acceleration became standard.
Raycasting casts rays for each screen column - it's a 2.5D technique used in early FPS games like Wolfenstein 3D and Doom. It's fast for specific geometries but limited in what it can represent.
Voxel Rendering
Instead of triangles, voxel rendering represents 3D space as a grid of cubes. Think Minecraft. It's excellent for terrain and destructible environments, and also used in medical imaging and some modern game engines.
Point Cloud Rendering
Rather than triangles or voxels, this renders millions of individual points. It's commonly used for LiDAR visualization and 3D scanning applications.
When This Method Actually Shines
Despite its limitations, our wireframe renderer is genuinely excellent for:
- Education: Understanding the fundamental math behind 3D graphics
- Prototyping: Quickly visualizing 3D concepts without setup overhead
- Retro aesthetics: Creating wireframe art and vector graphics styles
- Low-complexity scenes: CAD wireframes and architectural sketches
- Embedded systems: When you don't have GPU access
The Evolution Path
Here's how you'd typically progress as a graphics programmer:
- Our current method - Understanding projection fundamentals
- Add depth sorting - Implement painter's algorithm for solid surfaces
- Implement Z-buffer - Proper depth testing (still in software)
- Move to WebGL - GPU acceleration with shaders
- Add lighting models - Phong shading, PBR (Physically Based Rendering)
- Advanced techniques - Shadow mapping, post-processing, ray tracing
Modern Production Rendering
Today's game engines like Unity and Unreal use sophisticated hybrid approaches:
- Rasterization for real-time geometry
- Ray tracing for reflections, shadows, and global illumination
- Screen-space effects for ambient occlusion
- Compute shaders for physics and particles
- Deferred rendering pipelines
- Temporal anti-aliasing
They're rendering at 60+ frames per second with photorealistic lighting, dynamic shadows, particle effects, and post-processing - all while handling complex physics simulations.
The Foundation of Everything
Here's the beautiful part: even the most sophisticated GPU shader still uses our x/z and y/z projection formulas at its core. The difference? Modern GPUs execute these calculations billions of times per second with massive additional features layered on top.
Understanding this simple formula gives you insight into how all 3D graphics work. Whether you're debugging a shader in Unity, optimizing a WebGL scene, or just appreciating a beautiful game, you now understand the mathematical foundation that makes it all possible.
Every rendering technique - from the simplest wireframe to the most advanced path-traced cinematic - builds upon these fundamental principles of perspective projection. That's the power of understanding the basics.
This tutorial was inspired by the excellent video "One Formula That Demystifies 3D Graphics" - definitely check it out for a visual walkthrough of these concepts!