🎨 Building a Low Poly 3D Sphere with Pure JavaScript

Drag on the blue box to move the light source

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:

Step 1: Setup the Canvas

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>
💡 Tip: Always store the center coordinates. You'll use them constantly for 3D projection.

Step 2: Create the Icosphere Geometry

An icosphere is a sphere made from subdividing an icosahedron (20-sided polyhedron). This creates the low poly look.

2.1 Define the Icosahedron Vertices

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
}
📝 Note: The golden ratio (phi = 1.618...) creates perfectly spaced vertices on a sphere. We normalize each vertex to ensure they all lie on a unit sphere.

2.2 Define the Triangle Faces

    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.

2.3 Subdivide for More Detail

    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
    };
}
🔍 How subdivision works: Each triangle is split into 4 smaller triangles by creating midpoints on each edge. These midpoints are then pushed out to the sphere surface.

Step 3: Vector Math Utilities

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)

Step 4: 3D Transformations

4.1 Rotation

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
    ];
}
📝 Rotation Matrix: This is the Y-axis rotation matrix. It rotates points around the vertical axis, making the sphere spin.

4.2 Perspective Projection

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.

💡 Why +5? The camera is positioned at z = -5, so we add 5 to get the distance from camera to object.

Step 5: Lighting Calculations

5.1 Calculate Face Normal

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.

5.2 Calculate Brightness

// 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);
🔍 Lighting Math:

5.3 Apply Color

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})`;

Step 6: Rendering with Depth Sorting

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();
    });
}
📝 Painter's Algorithm: Draw distant objects first, then closer objects on top. Simple but effective for convex objects like spheres.

Step 7: Interactive Light Control

7.1 Convert Light Position to Spherical Coordinates

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);
}
🔍 Spherical Coordinates:

This makes it easy to move the light around the sphere without changing its distance.

7.2 Mouse and Touch Events

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
        };
    }
});

Step 8: Animation Loop

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.

Key Concepts Summary

1. Geometry Generation

Start with a simple icosahedron, then subdivide triangles and normalize to sphere surface.

2. 3D Pipeline

World Space → Rotation → Projection → Screen Space

3. Lighting

Use dot product between surface normal and light direction to calculate brightness.

4. Depth Sorting

Sort faces by z-coordinate and draw back-to-front (painter's algorithm).

5. Interactivity

Spherical coordinates make it easy to orbit the light around the sphere.

💡 Enhancement Ideas

Performance Tips

More JS tutorials

Spinning squares - visual effect (25 lines)

Oldschool fire effect (20 lines)

Fireworks (60 lines)

Animated fractal (32 lines)

Physics engine for beginners

Physics engine - interactive sandbox

Physics engine - silly contraption

Starfield (21 lines)

Yin Yang with a twist (4 circles and 20 lines)

Tile map editor (70 lines)

Sine scroller (30 lines)

Interactive animated sprites

Image transition effect (16 lines)