How to Build Your Own 3D Renderer from Scratch

How to Build Your Own 3D Renderer from Scratch

Kite Eugine

Kite Eugine • Jan 4, 2026

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 / z

That'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?

  1. Add 1 to x: range goes from 0 to 2
  2. Divide by 2: range goes from 0 to 1
  3. Multiply by width: range goes from 0 to canvas width
  4. 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 screen

Implementing 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' / x

Rearranging:

x' = x / Z

The 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:

  1. Our current method - Understanding projection fundamentals
  2. Add depth sorting - Implement painter's algorithm for solid surfaces
  3. Implement Z-buffer - Proper depth testing (still in software)
  4. Move to WebGL - GPU acceleration with shaders
  5. Add lighting models - Phong shading, PBR (Physically Based Rendering)
  6. 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!

Comments (0)

No comments yet. Be the first to comment!