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>
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");
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()
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 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
]
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();
}
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])
This is what you should see at this point.
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])
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,0is the top left corner. - X value increase as you move horizontally to the right.
- Y value increase as you move vertically downward.
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.

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,
}
}
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
]
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
]
change from
drawLine(vertices[0], vertices[1])
drawLine(vertices[1], vertices[2])
drawLine(vertices[2], vertices[3])
drawLine(vertices[3], vertices[0])
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]))
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])
)
}
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
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},
];
Now you have a 3D object, but it still seems 2D

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])
)
}
Step 3: Rotate the cube
To rotate a cube or any 3D object you would use these formula
In this case, we will try to rotate the cube in Y-axis
Now try to derive x, y, z coordinates from the formula
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),
}
}
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
]
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()
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.
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},
];
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)
}
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.
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()
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
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,
}
}
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,
}
}
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()
Result:

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};
}
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()
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.
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()
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
- Coordinate Systems: How to convert between different coordinate spaces (NDC vs screen space)
- 3D Transformations: How rotation matrices transform points in 3D space
- Perspective Projection: How dividing by depth (z) creates realistic perspective
- 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:
- WebGL Fundamentals - Deep dive into 3D graphics
- 3Blue1Brown Linear Algebra - Understanding the math behind transformations
- Scratchapixel - 3D rendering from scratch
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)