"use client"; import { useEffect, useRef } from "react"; import { useReducedMotion } from "motion/react"; type SphereWorldProps = { /** Three six-digit hex colours. The spheres take turns. */ colors?: [string, string, string]; /** How many spheres, the big one included. 2 to 24. */ count?: number; /** 1 is the default pace; 2 is twice as fast. */ speed?: number; /** Six-digit hex colour behind the spheres. */ background?: string; /** Name read out by screen readers. */ label?: string; /** Set the size here. */ className?: string; }; const MAX_PIXELS = 1400000; const VERTEX = `attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}`; const fragment = (count: number) => `precision highp float; uniform vec2 res; uniform mat3 cam; uniform vec3 back,glow; uniform vec4 balls[${count}]; uniform vec3 hues[${count}]; const vec3 sun=vec3(.5,.7,.51); bool first(vec3 o,vec3 d,out float t,out vec3 c,out vec3 h,out float r){ t=1e5; for(int i=0;i<${count};i++){ vec3 q=o-balls[i].xyz; float b=dot(q,d),e=b*b-dot(q,q)+balls[i].w*balls[i].w; if(e<0.)continue; float x=-b-sqrt(e); if(x>.002&&x [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); function program(gl: WebGLRenderingContext, text: string) { const made = gl.createProgram(); for (const [type, source] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, text]] as const) { const shader = gl.createShader(type); if (!made || !shader) return null; gl.shaderSource(shader, source); gl.compileShader(shader); gl.attachShader(made, shader); } if (!made) return null; gl.linkProgram(made); return gl.getProgramParameter(made, gl.LINK_STATUS) ? made : null; } /* SphereWorld: lit spheres circle one big sphere and show in each other's surface. Drag or use the arrow keys to walk around them. Runs only while on screen. */ export function SphereWorld({ colors = ["#a78bfa", "#22d3ee", "#f472b6"], count = 14, speed = 1, background = "#0b0a14", label = "Spheres circling a large sphere", className = "aspect-square w-80", }: SphereWorldProps) { const reduce = useReducedMotion(); const boxRef = useRef(null); const plainRef = useRef(null); const hues = colors.join(); const total = Math.max(2, Math.min(24, Math.round(count))); useEffect(() => { const box = boxRef.current; const plain = plainRef.current; if (!box || !plain) return; const canvas = document.createElement("canvas"); canvas.setAttribute("aria-hidden", "true"); canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%"; const gl = canvas.getContext("webgl", { alpha: false, antialias: false }); const made = gl && program(gl, fragment(total)); if (!gl || !made) return; gl.useProgram(made); gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); const corner = gl.getAttribLocation(made, "p"); gl.enableVertexAttribArray(corner); gl.vertexAttribPointer(corner, 2, gl.FLOAT, false, 0, 0); const at = (name: string) => gl.getUniformLocation(made, name); const tints = hues.split(",").map(rgb); gl.uniform3fv(at("back"), rgb(background)); gl.uniform3fv(at("glow"), tints[0]); gl.uniform3fv(at("hues"), Array.from({ length: total }, (_, i) => tints[i % 3]).flat()); const moons = Array.from({ length: total - 1 }, (_, i) => { const ring = 1.55 + ((i * 0.37) % 1) * 1.1; const seed = (i + 1) * 2.399; return { ring, size: 0.13 + ((i * 0.61) % 1) * 0.24, lean: Math.sin(seed) * 0.42, phase: seed * 1.7, pace: (0.5 + ((i * 0.83) % 1) * 0.6) / Math.sqrt(ring) }; }); let raf = 0; let last = 0; let inView = false; let time = reduce ? 3 : 0; let yaw = 0.3; let pitch = 0.32; let drift = 0; let dragging = false; let lastX = 0; let lastY = 0; const draw = () => { const c = Math.cos(yaw), s = Math.sin(yaw), ct = Math.cos(pitch), st = Math.sin(pitch); gl.uniformMatrix3fv(at("cam"), false, [c, 0, s, st * s, ct, -st * c, -ct * s, st, ct * c]); const balls = [0, 0, 0, 1]; for (const m of moons) { const a = m.phase + time * m.pace * speed; balls.push(Math.cos(a) * m.ring, Math.sin(a) * m.lean * m.ring, Math.sin(a) * m.ring, m.size); } gl.uniform4fv(at("balls"), balls); gl.drawArrays(gl.TRIANGLES, 0, 3); }; const frame = (now: number) => { const dt = Math.min(0.05, (now - last) / 1000 || 0); last = now; if (!reduce) time += dt; if (!dragging) { yaw += drift; drift *= 0.94; } draw(); raf = inView && !reduce ? requestAnimationFrame(frame) : 0; }; const wake = () => { if (inView && !raf) raf = requestAnimationFrame(frame); }; const fit = () => { const { width, height } = box.getBoundingClientRect(); if (!width || !height) return; const scale = Math.min(Math.max(window.devicePixelRatio, 1.5), 2, Math.sqrt(MAX_PIXELS / (width * height))); canvas.width = Math.round(width * scale); canvas.height = Math.round(height * scale); gl.viewport(0, 0, canvas.width, canvas.height); gl.uniform2f(at("res"), canvas.width, canvas.height); draw(); }; const down = (e: PointerEvent) => { dragging = true; drift = 0; lastX = e.clientX; lastY = e.clientY; box.setPointerCapture(e.pointerId); }; const move = (e: PointerEvent) => { if (!dragging) return; const step = (e.clientX - lastX) * 0.008; yaw += step; pitch = Math.max(-1.1, Math.min(1.1, pitch + (e.clientY - lastY) * 0.006)); drift = reduce ? 0 : step; lastX = e.clientX; lastY = e.clientY; wake(); }; const up = () => { dragging = false; wake(); }; const key = (e: KeyboardEvent) => { const turn = { ArrowLeft: [-0.2, 0], ArrowRight: [0.2, 0], ArrowUp: [0, -0.15], ArrowDown: [0, 0.15] }[e.key]; if (!turn) return; e.preventDefault(); yaw += turn[0]; pitch = Math.max(-1.1, Math.min(1.1, pitch + turn[1])); wake(); }; fit(); box.append(canvas); plain.hidden = true; const sized = new ResizeObserver(fit); sized.observe(box); const watch = new IntersectionObserver(([entry]) => { inView = entry.isIntersecting; wake(); }); watch.observe(box); const lost = () => { canvas.remove(); plain.hidden = false; }; canvas.addEventListener("webglcontextlost", lost); box.addEventListener("pointerdown", down); box.addEventListener("pointermove", move); box.addEventListener("pointerup", up); box.addEventListener("pointercancel", up); box.addEventListener("keydown", key); return () => { cancelAnimationFrame(raf); sized.disconnect(); watch.disconnect(); canvas.removeEventListener("webglcontextlost", lost); box.removeEventListener("pointerdown", down); box.removeEventListener("pointermove", move); box.removeEventListener("pointerup", up); box.removeEventListener("pointercancel", up); box.removeEventListener("keydown", key); gl.getExtension("WEBGL_lose_context")?.loseContext(); canvas.remove(); plain.hidden = false; }; }, [hues, total, speed, background, reduce]); return (
); }