In this tutorial, you'll learn how to create an interactive 3D low poly sphere from scratch using only vanilla JavaScript and the Canvas API. No external libraries needed!
What you'll learn:
First, let's create the basic HTML structure and canvas setup.
Download the full HTML+JS file here: sphere.html or follow the steps below.
<canvas id="canvas"></canvas>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let width, height, centerX, centerY;
function resizeCanvas() {
width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight;
centerX = width / 2;
centerY = height / 2;
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
</script>
An icosphere is a sphere made from subdividing an icosahedron (20-sided polyhedron). This creates the low poly look.
function createIcosphere(radius, detail) {
// Golden ratio for perfect icosahedron
const t = (1 + Math.sqrt(5)) / 2;
// 12 vertices of an icosahedron
const vertices = [
[-1, t, 0], [1, t, 0], [-1, -t, 0], [1, -t, 0],
[0, -1, t], [0, 1, t], [0, -1, -t], [0, 1, -t],
[t, 0, -1], [t, 0, 1], [-t, 0, -1], [-t, 0, 1]
].map(v => normalize(v));
// ... continues below
}
let faces = [
[0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11],
[1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],
[3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9],
[4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1]
];
Each array represents a triangle face using indices into the vertices array.
for (let i = 0; i < detail; i++) {
const newFaces = [];
for (const face of faces) {
// Get the three vertices of this triangle
const a = vertices[face[0]];
const b = vertices[face[1]];
const c = vertices[face[2]];
// Find midpoints and normalize to sphere surface
const ab = normalize(midpoint(a, b));
const bc = normalize(midpoint(b, c));
const ca = normalize(midpoint(c, a));
// Add new vertices
const abIdx = vertices.length;
vertices.push(ab);
const bcIdx = vertices.length;
vertices.push(bc);
const caIdx = vertices.length;
vertices.push(ca);
// Split one triangle into four
newFaces.push([face[0], abIdx, caIdx]);
newFaces.push([face[1], bcIdx, abIdx]);
newFaces.push([face[2], caIdx, bcIdx]);
newFaces.push([abIdx, bcIdx, caIdx]);
}
faces = newFaces;
}
// Scale vertices to desired radius
return {
vertices: vertices.map(v => [v[0] * radius, v[1] * radius, v[2] * radius]),
faces
};
}
We need some basic 3D vector operations:
function normalize(v) {
const len = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
return [v[0] / len, v[1] / len, v[2] / len];
}
function midpoint(a, b) {
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2];
}
function dot(a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
normalize() makes a vector length 1 (unit vector)
midpoint() finds the point between two vertices
dot() calculates the dot product (used for lighting)
function rotateY(point, angle) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return [
point[0] * cos - point[2] * sin, // x' = x*cos - z*sin
point[1], // y stays the same
point[0] * sin + point[2] * cos // z' = x*sin + z*cos
];
}
function project(point) {
const scale = 200 / (point[2] + 5);
return {
x: centerX + point[0] * scale,
y: centerY - point[1] * scale,
z: point[2]
};
}
This converts 3D coordinates to 2D screen coordinates. Objects further away (larger z) appear smaller.
function calculateNormal(v0, v1, v2) {
// Two edge vectors
const u = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
const v = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
// Cross product gives perpendicular vector
return normalize([
u[1] * v[2] - u[2] * v[1],
u[2] * v[0] - u[0] * v[2],
u[0] * v[1] - u[1] * v[0]
]);
}
The normal is a vector perpendicular to the triangle face. It tells us which direction the surface is facing.
// Get face center
const center = [
(v0[0] + v1[0] + v2[0]) / 3,
(v0[1] + v1[1] + v2[1]) / 3,
(v0[2] + v1[2] + v2[2]) / 3
];
// Direction from face to light
const lightDir = normalize([
lightPos.x - center[0],
lightPos.y - center[1],
lightPos.z - center[2]
]);
// Calculate brightness using dot product
const normal = calculateNormal(v0, v1, v2);
const diffuse = Math.max(0, dot(normal, lightDir));
const ambient = 0.3; // Base lighting
const brightness = Math.min(1, ambient + diffuse * 0.7);
const baseColor = [74, 144, 226]; // Blue
const r = Math.floor(baseColor[0] * brightness);
const g = Math.floor(baseColor[1] * brightness);
const b = Math.floor(baseColor[2] * brightness);
const color = `rgb(${r}, ${g}, ${b})`;
We need to draw faces in the correct order (back to front) for proper 3D appearance.
function draw() {
// Clear canvas
ctx.fillStyle = 'rgb(30, 60, 114)';
ctx.fillRect(0, 0, width, height);
// Rotate all vertices
const rotatedVertices = sphere.vertices.map(v => rotateY(v, rotationY));
// Calculate depth and color for each face
const facesWithDepth = sphere.faces.map(face => {
const v0 = rotatedVertices[face[0]];
const v1 = rotatedVertices[face[1]];
const v2 = rotatedVertices[face[2]];
const center = [
(v0[0] + v1[0] + v2[0]) / 3,
(v0[1] + v1[1] + v2[1]) / 3,
(v0[2] + v1[2] + v2[2]) / 3
];
// ... lighting calculations ...
return {
depth: center[2], // Used for sorting
color: calculatedColor,
vertices: [v0, v1, v2]
};
});
// Sort by depth (painter's algorithm)
facesWithDepth.sort((a, b) => a.depth - b.depth);
// Draw faces back to front
facesWithDepth.forEach(({ vertices, color }) => {
const p0 = project(vertices[0]);
const p1 = project(vertices[1]);
const p2 = project(vertices[2]);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.closePath();
ctx.fill();
ctx.stroke();
});
}
function updateLightPosition(deltaX, deltaY) {
const sensitivity = 0.02;
// Convert Cartesian to spherical
const r = Math.sqrt(lightPos.x * lightPos.x +
lightPos.y * lightPos.y +
lightPos.z * lightPos.z);
const theta = Math.atan2(lightPos.z, lightPos.x);
const phi = Math.acos(lightPos.y / r);
// Update angles based on mouse movement
const newTheta = theta - deltaX * sensitivity;
const newPhi = Math.max(0.1, Math.min(Math.PI - 0.1,
phi + deltaY * sensitivity));
// Convert back to Cartesian
lightPos.x = r * Math.sin(newPhi) * Math.cos(newTheta);
lightPos.y = r * Math.cos(newPhi);
lightPos.z = r * Math.sin(newPhi) * Math.sin(newTheta);
}
This makes it easy to move the light around the sphere without changing its distance.
let isDragging = false;
let previousMousePosition = { x: 0, y: 0 };
canvas.addEventListener('mousedown', (e) => {
isDragging = true;
previousMousePosition = { x: e.clientX, y: e.clientY };
});
canvas.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const deltaX = e.clientX - previousMousePosition.x;
const deltaY = e.clientY - previousMousePosition.y;
updateLightPosition(deltaX, deltaY);
previousMousePosition = { x: e.clientX, y: e.clientY };
});
canvas.addEventListener('mouseup', () => {
isDragging = false;
});
// Touch events work the same way
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
if (e.touches.length === 1) {
isDragging = true;
previousMousePosition = {
x: e.touches[0].clientX,
y: e.touches[0].clientY
};
}
});
let rotationY = 0;
function animate() {
rotationY += 0.005; // Auto-rotate
draw();
requestAnimationFrame(animate);
}
// Create the sphere geometry
const sphere = createIcosphere(2, 1);
// Start the animation
animate();
requestAnimationFrame creates smooth 60fps animation by syncing with the display refresh rate.
Start with a simple icosahedron, then subdivide triangles and normalize to sphere surface.
World Space → Rotation → Projection → Screen Space
Use dot product between surface normal and light direction to calculate brightness.
Sort faces by z-coordinate and draw back-to-front (painter's algorithm).
Spherical coordinates make it easy to orbit the light around the sphere.