DEV Community

Cover image for Build Your First 3D Rotating Cube from Scratch (No Libraries)
Melvin Cheah
Melvin Cheah

Posted on • Originally published at montmont20z.hashnode.dev

Build Your First 3D Rotating Cube from Scratch (No Libraries)

Build Your First 3D Rotating Cube from Scratch (No Libraries)

What You'll Build

Want to understand how 3D graphics really work? Most tutorials jump straight
into Three.js or WebGL, but the fundamentals get lost.

By the end of this tutorial, you'll understand the core concepts of 3D graphics by building a rotating cube from scratch using just JavaScript and HTML Canvas -- no Three.js, no WebGL, just pure fundamentals.

You'll learn:

  • How 3D coordinate systems work
  • How to convert between coordinate spaces
  • How rotation matrices transform 3D objects
  • How perspective projection creates depth

Prerequisites: Basic JavaScript knowledge. That's it!
Time needed: 30-45 minutes
Let's dive in!

Step 1: Draw a square

Open your favourite IDE. I use visual studio code in this case.
Create a file name index.html and write these codes.

<canvas id="canvas"></canvas>
<script src="cube.js"></script>
Enter fullscreen mode Exit fullscreen mode

We have defined a canvas element and a script element. The canvas element is where we will draw the Cube. We will load the file cube.js using the script element.

Then create a another new file name cube.js
Then initialize multiple variable

const BACKGROUND = "#404040"
const FOREGROUND = "#FF5050"
canvas.width = 800;
canvas.height = 800

const ctx = canvas.getContext("2d");
Enter fullscreen mode Exit fullscreen mode

We have created a grey background color and a orange foreground color for the cube . Then change the canvas's width and height to 800px. Then declare the canvas context.

function clear(){
    ctx.fillStyle=BACKGROUND
    ctx.fillRect(0,0,canvas.width,canvas.height);
}
clear()

Enter fullscreen mode Exit fullscreen mode

create a new function call clear(). This is use to fill up the background with gray color.
Open index.html you should be able to see the following

a gray background

A blank gray canvas.
Now let's define the vertices we will use for our square

let vertices = [
    {x: 100,y: 100}, // top left
    {x: 400,y: 100}, // top right
    {x: 400,y: 400}, // bottom right
    {x: 100,y: 400}, // bottom left
]
Enter fullscreen mode Exit fullscreen mode

Create a draw line function

function drawLine(p1, p2){
    ctx.strokeStyle = FOREGROUND;
    // ctx.lineWidth = 5.0 // (optional) make the line thicker
    ctx.beginPath();
    ctx.moveTo(p1.x,p1.y);
    ctx.lineTo(p2.x,p2.y);
    ctx.stroke();
}
Enter fullscreen mode Exit fullscreen mode

Then call the drawLine() function

drawLine(vertices[0], vertices[1])
drawLine(vertices[1], vertices[2])
drawLine(vertices[2], vertices[3])
drawLine(vertices[3], vertices[0])
Enter fullscreen mode Exit fullscreen mode

This is what you should see at this point.

square at top left corner

Full code until now

const BACKGROUND = "#404040"
const FOREGROUND = "#FF5050"
canvas.width = 800;
canvas.height = 800

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

function clear(){
    ctx.fillStyle=BACKGROUND
    ctx.fillRect(0,0,canvas.width,canvas.height);
}
clear()

let vertices = [
    {x: 100,y: 100}, // top left
    {x: 400,y: 100}, // top right
    {x: 400,y: 400}, // bottom right
    {x: 100,y: 400}, // bottom left
]

function drawLine(p1, p2){
    ctx.strokeStyle = FOREGROUND;
    // ctx.lineWidth = 5.0
    ctx.beginPath();
    ctx.moveTo(p1.x,p1.y);
    ctx.lineTo(p2.x,p2.y);
    ctx.stroke();
}

drawLine(vertices[0], vertices[1])
drawLine(vertices[1], vertices[2])
drawLine(vertices[2], vertices[3])
drawLine(vertices[3], vertices[0])
Enter fullscreen mode Exit fullscreen mode

Step 2: Draw a cube

Now, in order to draw a cube we need to first normalize the coordinate system as JavaScript's canvas use the World coordinate system.

  • Where 0,0 is the top left corner.
  • X value increase as you move horizontally to the right.
  • Y value increase as you move vertically downward. a square showing concept of canvas height and width

Because most mathematic formula assumes you are using the Normalized Device Coordinates
We need to change from Normalized Device Coordinates back to World coordinate system.
Normalize world coordinate
Normalized Device Coordinates

  • Where the center is (0, 0)
  • All axis's value range from -1 to 1
// NDC (-1..1) -> canvas pixels
function normalizeCoordinate(point){
    // translate from -1 .. 1 => 0..2 => 0..w/h
    return {
        x:      (point.x + 1) / 2 * canvas.width,
        y: (1 - (point.y + 1) / 2) * canvas.height,
    }
}
Enter fullscreen mode Exit fullscreen mode

So this function translate normalize coordinate to the coordinates system canvas uses.
Let's break it down.

The goal is translate coordinate with (-1 to 1) to (0,0 to w,h)
Step 1:
point.x + 1
Translate (-1 to 1) to (0 to 2)
Step 2:
(point.x + 1)/2
Translate (0 to 2) to (0 to 1)
Step 3:
(point.x + 1) / 2 * canvas.width
Translate (0 to 1) to (0 to canvas.width)

For the y-axis, canvas coordinates increase downward (opposite of mathematical convention), so we flip it:
(1 - (point.y + 1) / 2)

Then update these code
change from

let vertices = [
    {x: 100,y: 100}, // top left
    {x: 400,y: 100}, // top right
    {x: 400,y: 400}, // bottom right
    {x: 100,y: 400}, // bottom left
]
Enter fullscreen mode Exit fullscreen mode

To

let vertices = [
    {x:0.5,y:0.5}, // top left
    {x:-0.5,y:0.5}, // top right
    {x:-0.5,y:-0.5}, // bottom right
    {x: 0.5,y:-0.5}, // bottom left
]
Enter fullscreen mode Exit fullscreen mode

change from

drawLine(vertices[0], vertices[1])
drawLine(vertices[1], vertices[2])
drawLine(vertices[2], vertices[3])
drawLine(vertices[3], vertices[0])
Enter fullscreen mode Exit fullscreen mode

To

drawLine(normalizeCoordinate(vertices[0]), normalizeCoordinate(vertices[1]))
drawLine(normalizeCoordinate(vertices[1]), normalizeCoordinate(vertices[2]))
drawLine(normalizeCoordinate(vertices[2]), normalizeCoordinate(vertices[3]))
drawLine(normalizeCoordinate(vertices[3]), normalizeCoordinate(vertices[0]))
Enter fullscreen mode Exit fullscreen mode

Obvious we should use for loop for these

for (let i = 0; i < vertices.length; ++i){
    drawLine(
        normalizeCoordinate(vertices[i]),
        normalizeCoordinate(vertices[(i+1) % vertices.length])
    )
}
Enter fullscreen mode Exit fullscreen mode

Result:
Square at center

As you can see, the square move to the center because we use Normalized Device Coordinates and (0,0) is the center of the canvas.

Now add more vertices and change the vertices from 2D to 3D
Change from

    {x:0.5,y:0.5}, // top left
    {x:-0.5,y:0.5}, // top right
    {x:-0.5,y:-0.5}, // bottom right
    {x: 0.5,y:-0.5}, // bottom left
Enter fullscreen mode Exit fullscreen mode

To

let 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},
];
Enter fullscreen mode Exit fullscreen mode

Now you have a 3D object, but it still seems 2D
A cube that looks like square
That is why we need to rotate the cube to see all its side

Full code until now:

const BACKGROUND = "#404040"
const FOREGROUND = "#FF5050"
canvas.width = 800;
canvas.height = 800

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

function clear(){
    ctx.fillStyle=BACKGROUND
    ctx.fillRect(0,0,canvas.width,canvas.height);
}
clear()

let 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},
]

function drawLine(p1, p2){
    ctx.strokeStyle = FOREGROUND;
    // ctx.lineWidth = 5.0
    ctx.beginPath();
    ctx.moveTo(p1.x,p1.y);
    ctx.lineTo(p2.x,p2.y);
    ctx.stroke();
}

// NDC (-1..1) -> canvas pixels
function normalizeCoordinate(point){
    // translate from -1 .. 1 => 0..2 => 0..w/h
    return {
        x: (point.x + 1) / 2 * canvas.width,
        y: (1 - (point.y + 1) / 2) * canvas.height,
    }
}

for (let i = 0; i < vertices.length; ++i){
    drawLine(
        normalizeCoordinate(vertices[i]),
        normalizeCoordinate(vertices[(i+1) % vertices.length])
    )
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Rotate the cube

To rotate a cube or any 3D object you would use these formula

Matrix Rotate in Y-axis Rotate in Y-axis

Matrix Rotate in X-axis Rotate in X-axis

Matrix Rotate in Z-axis Rotate in Z -axis

In this case, we will try to rotate the cube in Y-axis
Now try to derive x, y, z coordinates from the formula

derive the rotation function

This is how we derive the rotation function. Add this to the code

function rotateY({x,y,z}, angle){
    return {
        x: x * Math.cos(angle) + z * Math.sin(angle),
        y,
        z: x * -Math.sin(angle) + z * Math.cos(angle),
    }
}
Enter fullscreen mode Exit fullscreen mode

Add vertices indices

let verticeIndexes = [
    [0,1,2,3], // front face 
    [4,5,6,7], // back face 
    [0,1,5,4], // top face 
    [2,3,7,6], // bottom face 
    [0,3,7,4], // right face 
    [1,2,6,5], // left face
]
Enter fullscreen mode Exit fullscreen mode

A cube has 8 vertices/corner and 6 faces. We use vertices index because we can reuse vertex without the need of repeatedly defined the same vertex. This is the standard of all modern graphics API as we can save memory and performance by reducing vertex count.

Then add this new function called frame(). We are going to start animating the rotation.

const FPS = 60
const dt = 1/FPS;
let angle = 0;

function frame(){
    angle += Math.PI * 0.5 * dt; // increment angle in each frame 
    clear()
    // Draw cube
    for (let i = 0; i < vertices.length; ++i){
        drawLine(
            normalizeCoordinate(rotateY(vertices[i], angle)),
            normalizeCoordinate(rotateY(vertices[(i+1) % vertices.length], angle))
        )
    }

    setTimeout(frame, 1000/FPS)
}
frame()
Enter fullscreen mode Exit fullscreen mode

Notice this is a recursion function. setTimeout(frame, 1000/FPS) For every 1000/60 = 16ms 16 milliseconds, the frame function will be called once, making this a loop that will run forever.

Note: We're using setTimeout for simplicity, but for production code, requestAnimationFrame is preferred as it syncs with the browser's refresh rate for smoother animations.

angle += Math.PI * 0.5 * dt;
Notice this line, for every seconds, the cube will rotate 1/4 of a rotation. To make every second 1 rotation, you can use angle += Math.PI * 2 * dt
2π equal 1 rotation.

Result:
Rotating cube

Notice the shape of the cube is not exactly correct. I will give you a small challenge here, try find out why the shape of the cube is not correct.
Hint: Look at how we're connecting the vertices. We're just connecting them in sequence (0→1→2→3→4→5→6→7→0), which doesn't actually form a proper cube structure!

Solution:

let 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},
];
Enter fullscreen mode Exit fullscreen mode

The order of the vertices we previously declare is incorrect. A simple solution is to reorder the vertices. But a more robust solution is to use the verticeIndex we previously created.

Update the frame() function

function frame(){
    angle += Math.PI * 0.5 * dt;
    clear();

    for (const face of verticeIndexes){ // Loop through each face
        for (let i = 0; i < face.length; ++i){ // Loop through vertices in this face
        // Connect vertices to form edges of this face
            const point1 = vertices[face[i]];
            const point2 = vertices[face[(i+1) % face.length] ];

            drawLine(
                normalizeCoordinate(rotateY(point1, angle)),
                normalizeCoordinate(rotateY(point2, angle))
            )
        }
    }
    setTimeout(frame, 1000/FPS)
}
Enter fullscreen mode Exit fullscreen mode

The problem wasn't the vertex positions themselves - it was that we were connecting vertices sequentially (0→1→2→...→7), which creates a tangled mess instead of proper cube faces. By using verticeIndexes, we explicitly define which vertices form each face, giving us proper cube geometry.

Result:
Rotating Cube Completed

Full code:

const BACKGROUND = "#404040"
const FOREGROUND = "#FF5050"
canvas.width = 800;
canvas.height = 800

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

function clear(){
    ctx.fillStyle=BACKGROUND
    ctx.fillRect(0,0,canvas.width,canvas.height);
}
clear()

let 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},
]

let verticeIndexes = [
    [0,1,2,3], // front face 
    [4,5,6,7], // back face 
    [0,1,5,4], // top face 
    [2,3,7,6], // bottom face 
    [0,3,7,4], // right face 
    [1,2,6,5], // left face
]

function drawLine(p1, p2){
    ctx.strokeStyle = FOREGROUND;
    // ctx.lineWidth = 5.0
    ctx.beginPath();
    ctx.moveTo(p1.x,p1.y);
    ctx.lineTo(p2.x,p2.y);
    ctx.stroke();
}

// NDC (-1..1) -> canvas pixels
function normalizeCoordinate(point){
    // translate from -1 .. 1 => 0..2 => 0..w/h
    return {
        x: (point.x + 1) / 2 * canvas.width,
        y: (1 - (point.y + 1) / 2) * canvas.height,
    }
}


function rotateY({x,y,z}, angle){
    return {
        x: x * Math.cos(angle) + z * Math.sin(angle),
        y,
        z: x * -Math.sin(angle) + z * Math.cos(angle),
    }
}

const FPS = 60
const dt = 1/FPS;
let angle = 0;

function frame(){
    angle += Math.PI * 0.5 * dt;
    clear();

    for (const face of verticeIndexes){
        for (let i = 0; i < face.length; ++i){
            const point1 = vertices[face[i]];
            const point2 = vertices[face[(i+1) % face.length] ];

            drawLine(
                normalizeCoordinate(perspectiveProject(rotateY(point1, angle))),
                normalizeCoordinate(perspectiveProject(rotateY(point2, angle)))
            )
        }
    }
    setTimeout(frame, 1000/FPS)
}
frame()
Enter fullscreen mode Exit fullscreen mode

Step 4: Apply perspective projection

Now the cube we created is not particularly realistic, it is because it is using orthographic projection implicitly, which means that the object in the distance would not become smaller. To make the object in the distance smaller, we need to apply a perspective projection using these formula.

x = x/z
y = y/z
Enter fullscreen mode Exit fullscreen mode

You are right, just as simple as these 2 lines.
This is because objects farther from the camera (larger z values) should be smaller. Therefore it gets divided by a bigger number, making them smaller on screen. This mimics how our eyes see the world!
Lets implement it.

function perspectiveProject({x,y,z}){
    return {
        x: x/z,
        y: y/z,
    }
}
Enter fullscreen mode Exit fullscreen mode

This function could work, but when z = 0 💥things could go very bad. That is why I opted for this solution.

function perspectiveProject({x,y,z}){
    const eps = 1e-6;
    if (z <= eps){
        return {
            x: x/eps,
            y: y/eps,
        };
    }
    return {
        x: x/z,
        y: y/z,
    }
}
Enter fullscreen mode Exit fullscreen mode

Here we use a very small number eps to prevent division by zero

Finally apply the perspective projection.

function frame(){
    angle += Math.PI * 0.5 * dt;
    clear();

    for (const face of verticeIndexes){
        for (let i = 0; i < face.length; ++i){
            const point1 = vertices[face[i]];
            const point2 = vertices[face[(i+1) % face.length] ];

            drawLine(
                normalizeCoordinate(perspectiveProject(rotateY(point1, angle))),
                normalizeCoordinate(perspectiveProject(rotateY(point2, angle)))
            )
        }
    }
    setTimeout(frame, 1000/FPS)
}
frame()
Enter fullscreen mode Exit fullscreen mode

Result:
Rotating Perspective cube
You will notice this weird line fly around. This is because the cube is too close to the camera. We need to move the cube further away (translate z).

function translateZ({x,y,z}, dz){
    return {x,y,z: z+dz};
}
Enter fullscreen mode Exit fullscreen mode

Add this translate Z function. Apply to the frame() function

function frame(){
    angle += Math.PI * 0.5 * dt;
    clear();

    for (const face of verticeIndexes){
        for (let i = 0; i < face.length; ++i){
            const point1 = vertices[face[i]];
            const point2 = vertices[face[(i+1) % face.length] ];
            const dz = 2

            drawLine(
                normalizeCoordinate(perspectiveProject(translateZ(rotateY(point1, angle),dz))),
                normalizeCoordinate(perspectiveProject(translateZ(rotateY(point2, angle), dz)))      
            )
        }
    }
    setTimeout(frame, 1000/FPS)
}
frame()
Enter fullscreen mode Exit fullscreen mode

const dz = 2
We translate the cube 2 units away from the camera (at z=0). This ensures
all vertices have positive z values after rotation, preventing division issues
and keeping the cube in front of the camera.

And finally we are done:
Rotating Perspective Cube Correct

Final Code:
👜 https://github.com/Montmont20z/JavaScript-3D-Cube

const BACKGROUND = "#404040"
const FOREGROUND = "#FF5050"
canvas.width = 800;
canvas.height = 800

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

function clear(){
    ctx.fillStyle=BACKGROUND
    ctx.fillRect(0,0,canvas.width,canvas.height);
}
clear()

let 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},
]

let verticeIndexes = [
    [0,1,2,3], // front face
    [4,5,6,7], // back face
    [0,1,5,4], // top face
    [2,3,7,6], // bottom face
    [0,3,7,4], // right face
    [1,2,6,5], // left face
]

function drawLine(p1, p2){
    ctx.strokeStyle = FOREGROUND;
    // ctx.lineWidth = 5.0
    ctx.beginPath();
    ctx.moveTo(p1.x,p1.y);
    ctx.lineTo(p2.x,p2.y);
    ctx.stroke();
}

// NDC (-1..1) -> canvas pixels
function normalizeCoordinate(point){
    // translate from -1 .. 1 => 0..2 => 0..w/h
    return {
        x: (point.x + 1) / 2 * canvas.width,
        y: (1 - (point.y + 1) / 2) * canvas.height,
    }
}

function perspectiveProject({x,y,z}){
    const eps = 1e-6;
    if (z <= eps){
        return {
            x: x/eps,
            y: y/eps,
        };
    }
    return {
        x: x/z,
        y: y/z,
    }
}

function rotateY({x,y,z}, angle){
    return {
        x: x * Math.cos(angle) + z * Math.sin(angle),
        y,
        z: x * -Math.sin(angle) + z * Math.cos(angle),
    }
}

function translateZ({x,y,z}, dz){
    return {x,y,z: z+dz};
}

const FPS = 60
const dt = 1/FPS;
let angle = 0;

function frame(){
    angle += Math.PI * 0.5 * dt;
    clear();

    for (const face of verticeIndexes){
        for (let i = 0; i < face.length; ++i){
            const point1 = vertices[face[i]];
            const point2 = vertices[face[(i+1) % face.length] ];
            const dz = 2

            drawLine(
                normalizeCoordinate(perspectiveProject(translateZ(rotateY(point1, angle),dz))),
                normalizeCoordinate(perspectiveProject(translateZ(rotateY(point2, angle), dz)))      
            )
        }
    }
    setTimeout(frame, 1000/FPS)
}
frame()
Enter fullscreen mode Exit fullscreen mode

Troubleshooting

Canvas is blank: Make sure you're opening index.html in a browser, not just viewing the file.

"canvas is not defined" error: The canvas variable is automatically created by the browser when you give an element id="canvas". Make sure your HTML has this.

Cube looks distorted: Check that your verticeIndexes array matches the vertex order exactly. The indices must correspond to the correct vertex positions.

Weird flickering lines: This happens when vertices pass through or very close to z=0. Our eps value in perspectiveProject helps prevent this.

What You've Learned

In this tutorial, you've built a 3D rendering engine from scratch and learned

  1. Coordinate Systems: How to convert between different coordinate spaces (NDC vs screen space)
  2. 3D Transformations: How rotation matrices transform points in 3D space
  3. Perspective Projection: How dividing by depth (z) creates realistic perspective
  4. Indexed Geometry: Why modern graphics APIs use vertex indices to save memory

These are the same fundamental concepts used in engines like Three.js, Unity, and Unreal Engine!

Where to Go From Here

Want to take this further? Try these challenges:

  • Add rotation on multiple axes: Combine rotateX, rotateY, and rotateZ
  • Add More Translation: Translate X, Translate Y, Translate Z
  • Add camera controls: Let users control the camera with mouse/keyboard

Learning Resources:

More Projects Like This

If you enjoyed this tutorial, check out my other 3D graphics projects:

  • 3D-Robot-Model-Renderer - A hierarchical 3D Gundam-style robot model built from scratch using OpenGL Fixed Pipeline and Win32 API
  • Splash Ground - A fast-paced 3D arena survival / territory-control 3D game

See all my work at https://github.com/Montmont20z

Questions? Suggestions? Let me know in the comments! 👇

Top comments (0)