← Mike Higgins Home
Custom Code Experiment •

Sphere 2

Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SVG: Animated Glyph - Glitch Icosphere Edition</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }

        body {
            background-color: #000;
            overflow: hidden;
            height: 100vh;
            width: 100vw;
            display: flex;
            justify-content: center;
            align-items: center;
            position: relative;
        }

        .container {
            width: 100%; height: 100%;
            display: flex;
            justify-content: center;
            align-items: center;
            position: relative;
            z-index: 1;
        }

        canvas {
            position: absolute;
            top: 0; left: 0;
            width: 100%; height: 100%;
            z-index: 1;
        }

        .instructions {
            position: absolute;
            bottom: 20px;
            color: #0ff;
            font-family: 'Courier New', monospace;
            font-size: 14px;
            text-align: center;
            width: 100%;
            text-shadow: 0 0 10px #0ff, 0 0 20px #0ff;
            z-index: 2;
            animation: textGlitch 5s infinite;
        }


    </style>
</head>
<body>
    <div class="container">
        <canvas id="c"></canvas>
    </div>

    <script>
    const canvas = document.getElementById('c');
    const ctx = canvas.getContext('2d');

    function resize() {
        canvas.width = window.innerWidth;
        canvas.height = window.innerHeight;
    }
    resize();
    window.addEventListener('resize', resize);

    // --- Icosphere geometry (subdivision level 2) ---
    const t = (1 + Math.sqrt(5)) / 2;
    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];
    }

    // Base icosahedron vertices
    let verts = [
        [-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(normalize);

    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]
    ];

    // Subdivide once
    function midpoint(a, b, cache, verts) {
        const key = [Math.min(a,b), Math.max(a,b)].join('_');
        if (cache[key] !== undefined) return cache[key];
        const v = normalize([
            (verts[a][0]+verts[b][0])/2,
            (verts[a][1]+verts[b][1])/2,
            (verts[a][2]+verts[b][2])/2
        ]);
        cache[key] = verts.length;
        verts.push(v);
        return cache[key];
    }

    function subdivide(verts, faces) {
        const cache = {};
        const newFaces = [];
        for (const [a,b,c] of faces) {
            const ab = midpoint(a,b,cache,verts);
            const bc = midpoint(b,c,cache,verts);
            const ca = midpoint(c,a,cache,verts);
            newFaces.push([a,ab,ca],[b,bc,ab],[c,ca,bc],[ab,bc,ca]);
        }
        return newFaces;
    }

    faces = subdivide(verts, faces);
    faces = subdivide(verts, faces);

    // Unique edges
    const edgeSet = new Set();
    const edges = [];
    for (const [a,b,c] of faces) {
        for (const [i,j] of [[a,b],[b,c],[c,a]]) {
            const key = [Math.min(i,j),Math.max(i,j)].join('_');
            if (!edgeSet.has(key)) { edgeSet.add(key); edges.push([i,j]); }
        }
    }

    // --- Projection ---
    let rotX = 0, rotY = 0, rotZ = 0;

    function rotateXYZ(v, rx, ry, rz) {
        let [x,y,z] = v;
        // Rotate X
        let y2 = y*Math.cos(rx) - z*Math.sin(rx);
        let z2 = y*Math.sin(rx) + z*Math.cos(rx);
        y=y2; z=z2;
        // Rotate Y
        let x2 = x*Math.cos(ry) + z*Math.sin(ry);
        z2 = -x*Math.sin(ry) + z*Math.cos(ry);
        x=x2; z=z2;
        // Rotate Z
        x2 = x*Math.cos(rz) - y*Math.sin(rz);
        y2 = x*Math.sin(rz) + y*Math.cos(rz);
        return [x2, y2, z];
    }

    function project(v, cx, cy, scale) {
        const fov = 4;
        const z = v[2] + fov;
        return [cx + (v[0] / z) * scale, cy + (v[1] / z) * scale];
    }

    function draw(ts) {
        const W = canvas.width, H = canvas.height;
        const cx = W/2, cy = H/2;
        const scale = Math.min(W,H) * 1.4;

        ctx.clearRect(0,0,W,H);

        rotX += 0.003;
        rotY += 0.007;
        rotZ += 0.001;

        // Project all vertices
        const projected = verts.map(v => {
            const rv = rotateXYZ(v, rotX, rotY, rotZ);
            return project(rv, cx, cy, scale);
        });

        // Compute depth for faces (for back-face sorting + shading)
        const faceDepths = faces.map((f, i) => {
            const rv0 = rotateXYZ(verts[f[0]], rotX, rotY, rotZ);
            const rv1 = rotateXYZ(verts[f[1]], rotX, rotY, rotZ);
            const rv2 = rotateXYZ(verts[f[2]], rotX, rotY, rotZ);
            const z = (rv0[2]+rv1[2]+rv2[2])/3;
            // Normal (for backface culling)
            const ax = rv1[0]-rv0[0], ay = rv1[1]-rv0[1];
            const bx = rv2[0]-rv0[0], by = rv2[1]-rv0[1];
            const nz = ax*by - ay*bx; // z component of cross product
            return { i, z, visible: nz < 0 };
        });

        faceDepths.sort((a,b) => a.z - b.z);

        // Draw faces — depth + diffuse + camera-facing colour baked per triangle
        const hueShift = ts * 0.0004;
        const cycleR = Math.floor(128 + 127 * Math.sin(hueShift));
        const cycleG = Math.floor(128 + 127 * Math.sin(hueShift + 2.09));
        const cycleB = Math.floor(128 + 127 * Math.sin(hueShift + 4.19));

        for (const {i, z, visible} of faceDepths) {
            if (!visible) continue;
            const [a,b,c] = faces[i];
            const pa = projected[a], pb = projected[b], pc = projected[c];

            // Rotated vertices for normal calculation
            const rv0 = rotateXYZ(verts[a], rotX, rotY, rotZ);
            const rv1 = rotateXYZ(verts[b], rotX, rotY, rotZ);
            const rv2 = rotateXYZ(verts[c], rotX, rotY, rotZ);

            // Face normal via cross product
            const ax = rv1[0]-rv0[0], ay = rv1[1]-rv0[1], az = rv1[2]-rv0[2];
            const bx = rv2[0]-rv0[0], by = rv2[1]-rv0[1], bz = rv2[2]-rv0[2];
            const nx = ay*bz - az*by;
            const ny = az*bx - ax*bz;
            const nz2 = ax*by - ay*bx;
            const nlen = Math.sqrt(nx*nx + ny*ny + nz2*nz2) || 1;

            // Light from top-right-front: normalised (0.5, -0.5, 1)
            const lx = 0.5/1.22, ly = -0.5/1.22, lz = 1/1.22;
            const diffuse = Math.max(0, (nx/nlen)*lx + (ny/nlen)*ly + (nz2/nlen)*lz);

            // Camera-facing factor: dot of face normal with view direction (0,0,1)
            // nz2/nlen = 1 means face points straight at camera, 0 = edge-on
            const camDot = Math.max(0, -(nz2 / nlen)); // negated because backface-culled normals point away
            const camFacing = Math.pow(camDot, 2.5); // sharpen so only the most front-facing triangles glow

            // Depth factor
            const d = Math.max(0, Math.min(1, (z + 1) / 2));

            // Base diffuse colour
            const light = 0.08 + diffuse * 0.92;
            let r  = light * (20  + d * 160);
            let g  = light * (80  + d * 150);
            let bl = light * (120 + d * 135);

            // Blend in cycling colour on camera-facing triangles
            r  = r  + camFacing * (cycleR - r);
            g  = g  + camFacing * (cycleG - g);
            bl = bl + camFacing * (cycleB - bl);

            const alpha = 0.15 + diffuse * 0.6 + d * 0.15 + camFacing * 0.25;

            ctx.beginPath();
            ctx.moveTo(pa[0],pa[1]);
            ctx.lineTo(pb[0],pb[1]);
            ctx.lineTo(pc[0],pc[1]);
            ctx.closePath();
            ctx.fillStyle = `rgba(${Math.floor(r)},${Math.floor(g)},${Math.floor(bl)},${Math.min(1, alpha)})`;
            ctx.fill();
        }

        // Draw edges
        const edgeDepths = edges.map(([a,b]) => {
            const ra = rotateXYZ(verts[a], rotX, rotY, rotZ);
            const rb = rotateXYZ(verts[b], rotX, rotY, rotZ);
            return (ra[2]+rb[2])/2;
        });

        for (let ei = 0; ei < edges.length; ei++) {
            const [a,b] = edges[ei];
            const pa = projected[a], pb = projected[b];
            const depth = edgeDepths[ei];
            const alpha = 0.08 + Math.max(0, depth+1) * 0.18;

            ctx.beginPath();
            ctx.moveTo(pa[0],pa[1]);
            ctx.lineTo(pb[0],pb[1]);
            ctx.strokeStyle = `rgba(0,255,255,${alpha})`;
            ctx.lineWidth = 0.5;
            ctx.stroke();
        }

        // Glow outline pass
        for (let ei = 0; ei < edges.length; ei++) {
            const [a,b] = edges[ei];
            const ra = rotateXYZ(verts[a], rotX, rotY, rotZ);
            if (ra[2] < 0.3) continue;
            const pa = projected[a], pb = projected[b];

            ctx.beginPath();
            ctx.moveTo(pa[0],pa[1]);
            ctx.lineTo(pb[0],pb[1]);
            ctx.strokeStyle = `rgba(0,255,255,0.12)`;
            ctx.lineWidth = 2.5;
            ctx.shadowColor = '#0ff';
            ctx.shadowBlur = 6;
            ctx.stroke();
            ctx.shadowBlur = 0;
        }

        requestAnimationFrame(draw);
    }

    requestAnimationFrame(draw);
    </script>
</body>
</html>