"use client"; import { useEffect, useRef } from "react"; import { useReducedMotion } from "motion/react"; type FloatingPlanetProps = { /** Three six-digit hex colours for the cloud bands. */ colors?: [string, string, string]; /** Show the rings. */ rings?: boolean; /** Show twinkling stars behind it. */ stars?: boolean; /** 1 is the default pace; 2 is twice as fast. */ speed?: number; /** 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 = `precision highp float; uniform vec2 res,drift; uniform vec3 up,c1,c2,c3; uniform float time,bob,rings,stars; const vec3 sun=vec3(-.62,.42,.66); float hash(vec3 p){return fract(sin(dot(p,vec3(12.9898,78.233,37.719)))*43758.5453);} float noise(vec3 p){ vec3 i=floor(p),f=fract(p); f=f*f*(3.-2.*f); return mix(mix(mix(hash(i),hash(i+vec3(1.,0.,0.)),f.x),mix(hash(i+vec3(0.,1.,0.)),hash(i+vec3(1.,1.,0.)),f.x),f.y), mix(mix(hash(i+vec3(0.,0.,1.)),hash(i+vec3(1.,0.,1.)),f.x),mix(hash(i+vec3(0.,1.,1.)),hash(i+vec3(1.,1.,1.)),f.x),f.y),f.z); } float fbm(vec3 p){ float v=0.,a=.5; for(int i=0;i<4;i++){v+=a*noise(p);p=p*2.03+7.1;a*=.5;} return v; } float band(float r){ float s=(r-1.38)/.82; if(s<0.||s>1.)return 0.; float a=.55+.28*sin(s*38.)+.17*sin(s*91.+1.); a*=smoothstep(0.,.06,s)*(1.-smoothstep(.9,1.,s)); a*=smoothstep(.02,.05,abs(s-.62)); return clamp(a,0.,1.)*rings; } vec4 over(vec4 a,vec4 b){return a+b*(1.-a.a);} void main(){ float side=min(res.x,res.y); vec2 uv=(gl_FragCoord.xy-.5*res)/(.5*side); vec3 o=vec3(0.,0.,6.),d=normalize(vec3(uv,-2.6)); vec3 C=vec3(drift.x,bob+drift.y,0.); vec3 e1=normalize(cross(up,vec3(0.,0.,1.))),e3=cross(e1,up); vec4 col=vec4(0.); if(stars>0.){ vec2 g=(uv-drift*.05)*22.,cell=floor(g); float k=hash(vec3(cell,1.)); if(k>.86){ vec2 spot=fract(g)-.5-(vec2(hash(vec3(cell,2.)),hash(vec3(cell,3.)))-.5)*.6; float s=(1.-smoothstep(0.,.07+k*.04,length(spot)))*(.55+.45*sin(time*(1.+k*3.)+k*40.)); col=vec4(vec3(s),s*.9); } } vec3 q=o-C; float b=dot(q,d),miss=sqrt(max(dot(q,q)-b*b,0.)),h=1.-miss*miss; float tBall=h>0.?-b-sqrt(h):1e5; float tRing=dot(C-o,up)/dot(d,up); vec3 onRing=o+d*tRing-C; float ring=tRing>0.?band(length(onRing)):0.; float aura=exp(-max(miss-1.,0.)*9.)*.5; col=over(vec4(c1*aura,aura*.9),col); vec4 hoop=vec4(0.); if(ring>0.){ float toSun=dot(onRing,sun); float seen=toSun<0.?smoothstep(.96,1.06,sqrt(max(dot(onRing,onRing)-toSun*toSun,0.))):1.; vec3 tone=mix(c2,c3,.5+.5*sin(length(onRing)*24.))*(.12+.95*seen); hoop=vec4(tone*ring,ring); } if(tRing>tBall)col=over(hoop,col); if(h>0.){ vec3 n=q+d*tBall; float spin=time*.12; vec3 l=vec3(dot(n,e1),dot(n,up),dot(n,e3)); vec3 s=vec3(l.x*cos(spin)-l.z*sin(spin),l.y,l.x*sin(spin)+l.z*cos(spin)); float w=fbm(s*vec3(3.,9.,3.)); vec3 body=mix(c1,c2,sin(l.y*11.+w*5.)*.5+.5); body=mix(body,c3,smoothstep(.5,.72,fbm(s*vec3(2.,14.,2.)+3.))); float cross_=-dot(n,up)/dot(sun,up); float shade=cross_>0.?1.-.75*band(length(n+sun*cross_)):1.; float lit=smoothstep(-.12,.95,dot(n,sun))*shade; vec3 surface=body*(.05+1.15*lit)+c1*pow(1.+dot(d,n),3.)*.75*lit; float cover=1.-smoothstep(1.-3.2/side,1.,miss); col=over(vec4(surface*cover,cover),col); } if(tRing<=tBall)col=over(hoop,col); gl_FragColor=col; }`; const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); function program(gl: WebGLRenderingContext) { const made = gl.createProgram(); for (const [type, text] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) { const shader = gl.createShader(type); if (!made || !shader) return null; gl.shaderSource(shader, text); gl.compileShader(shader); gl.attachShader(made, shader); } if (!made) return null; gl.linkProgram(made); return gl.getProgramParameter(made, gl.LINK_STATUS) ? made : null; } /* FloatingPlanet: a ringed planet with drifting cloud bands bobs in space and leans gently toward the mouse. Runs only while on screen. */ export function FloatingPlanet({ colors = ["#a78bfa", "#f472b6", "#fde68a"], rings = true, stars = true, speed = 1, label = "A ringed planet floating in space", className = "aspect-square w-80", }: FloatingPlanetProps) { const reduce = useReducedMotion(); const boxRef = useRef(null); const plainRef = useRef(null); const hues = colors.join(); 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: true, antialias: false }); const made = gl && program(gl); 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 [one, two, three] = hues.split(",").map(rgb); gl.uniform3fv(at("c1"), one); gl.uniform3fv(at("c2"), two); gl.uniform3fv(at("c3"), three); gl.uniform1f(at("rings"), rings ? 1 : 0); gl.uniform1f(at("stars"), stars ? 1 : 0); let raf = 0; let last = 0; let inView = false; let time = reduce ? 5 : 0; let lookX = 0; let lookY = 0; let goalX = 0; let goalY = 0; const draw = () => { const lean = -0.42 + lookX * 0.1; const front = 0.34 + lookY * 0.12; const size = Math.hypot(Math.sin(lean), Math.cos(lean), front); gl.uniform3f(at("up"), Math.sin(lean) / size, Math.cos(lean) / size, front / size); gl.uniform2f(at("drift"), lookX * 0.09, lookY * 0.09); gl.uniform1f(at("bob"), reduce ? 0 : Math.sin(time * 0.8) * 0.07); gl.uniform1f(at("time"), time % 1000); gl.drawArrays(gl.TRIANGLES, 0, 3); }; const frame = (now: number) => { const dt = Math.min(0.05, (now - last) / 1000 || 0); last = now; time += dt * speed; lookX += (goalX - lookX) * 0.06; lookY += (goalY - lookY) * 0.06; draw(); raf = inView ? requestAnimationFrame(frame) : 0; }; const start = () => { if (inView && !raf && !reduce) 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 point = (e: PointerEvent) => { if (e.pointerType !== "mouse") return; const r = box.getBoundingClientRect(); goalX = ((e.clientX - r.left) / r.width - 0.5) * 2; goalY = (0.5 - (e.clientY - r.top) / r.height) * 2; }; const leave = () => { goalX = 0; goalY = 0; }; fit(); box.append(canvas); plain.hidden = true; const sized = new ResizeObserver(fit); sized.observe(box); const watch = new IntersectionObserver(([entry]) => { inView = entry.isIntersecting; start(); }); watch.observe(box); const lost = () => { canvas.remove(); plain.hidden = false; }; canvas.addEventListener("webglcontextlost", lost); box.addEventListener("pointermove", point); box.addEventListener("pointerleave", leave); return () => { cancelAnimationFrame(raf); sized.disconnect(); watch.disconnect(); canvas.removeEventListener("webglcontextlost", lost); box.removeEventListener("pointermove", point); box.removeEventListener("pointerleave", leave); gl.getExtension("WEBGL_lose_context")?.loseContext(); canvas.remove(); plain.hidden = false; }; }, [hues, rings, stars, speed, reduce]); return (
); }