"use client"; import { useEffect, useRef } from "react"; import { useReducedMotion } from "motion/react"; type CubeMorphShape = "cube" | "sphere" | "diamond" | "ring"; type CubeMorphProps = { /** Shapes in order. It loops back to the first. */ shapes?: CubeMorphShape[]; /** Seconds it rests on each shape. */ hold?: number; /** Two six-digit hex colours. */ colors?: [string, string]; /** 1 is the default turning pace; 2 is twice as fast. */ speed?: number; /** What the button says to screen readers. */ label?: string; /** Set the size here. */ className?: string; }; const ALL_SHAPES: CubeMorphShape[] = ["cube", "sphere", "diamond", "ring"]; const MAX_PIXELS = 900000; const MORPH = 1.3; const VERTEX = `attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}`; const FRAGMENT = `precision highp float; uniform vec2 res; uniform vec4 mixer; uniform mat3 turn; uniform vec3 c1,c2; const vec3 sun=vec3(.45,.68,.58); float cube(vec3 p){vec3 q=abs(p)-.72;return length(max(q,0.))+min(max(q.x,max(q.y,q.z)),0.)-.11;} float diamond(vec3 p){p=abs(p);return (p.x+p.y+p.z-1.42)*.577;} float ring(vec3 p){return length(vec2(length(p.xz)-.74,p.y))-.3;} float map(vec3 p){ p=turn*p; return mixer.x*cube(p)+mixer.y*(length(p)-1.05)+mixer.z*diamond(p)+mixer.w*ring(p); } vec3 normal(vec3 p){ vec2 e=vec2(.004,-.004); return normalize(e.xyy*map(p+e.xyy)+e.yyx*map(p+e.yyx)+e.yxy*map(p+e.yxy)+e.xxx*map(p+e.xxx)); } void main(){ float side=min(res.x,res.y); vec2 uv=(gl_FragCoord.xy-.5*res)/(.5*side); vec3 o=vec3(0.,0.,4.),d=normalize(vec3(uv,-2.45)); float t=2.1,best=1.,bestT=t; for(int i=0;i<64;i++){ float m=map(o+d*t); if(m6.)break; t+=m*.9; } if(best<.0002)bestT=t; float a=1.-smoothstep(0.,3.5/side,best); if(a<=0.){gl_FragColor=vec4(0.);return;} vec3 p=o+d*bestT,n=normal(p),r=reflect(d,n); float f=pow(1.+dot(d,n),3.); vec3 col=mix(c1,c2,clamp(.34+.55*n.y+.25*n.x,0.,1.))*(.2+.9*max(dot(n,sun),0.)); col+=mix(c2,c1,.5+.5*n.x)*f*1.1; col+=pow(max(dot(r,sun),0.),42.)*.9; gl_FragColor=vec4(col*a,a); }`; 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; } /* CubeMorph: one solid melts from shape to shape as it turns. A click, Enter or Space moves on to the next shape at once. Runs only while on screen. */ export function CubeMorph({ shapes = ALL_SHAPES, hold = 2, colors = ["#7c3aed", "#22d3ee"], speed = 1, label = "Change the shape", className = "aspect-square w-72", }: CubeMorphProps) { const reduce = useReducedMotion(); const boxRef = useRef(null); const plainRef = useRef(null); const order = shapes.join(); 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 [first, second] = hues.split(",").map(rgb); gl.uniform3fv(at("c1"), first); gl.uniform3fv(at("c2"), second); const list = order.split(",").map((name) => ALL_SHAPES.indexOf(name as CubeMorphShape)); let raf = 0; let last = 0; let inView = false; let from = 0; let to = 0; let blend = 1; let waited = 0; let kick = 0; let angleX = 0.62; let angleY = 0.72; const draw = () => { const e = blend * blend * (3 - 2 * blend); gl.uniform4fv(at("mixer"), [0, 1, 2, 3].map((i) => (list[from] === i ? 1 - e : 0) + (list[to] === i ? e : 0))); const c = Math.cos(angleY), s = Math.sin(angleY), ct = Math.cos(angleX), st = Math.sin(angleX); gl.uniformMatrix3fv(at("turn"), false, [c, 0, s, st * s, ct, -st * c, -ct * s, st, ct * c]); gl.drawArrays(gl.TRIANGLES, 0, 3); }; const next = () => { if (blend < 1 || list.length < 2) return; to = (from + 1) % list.length; blend = 0; }; const frame = (now: number) => { const dt = Math.min(0.05, (now - last) / 1000 || 0); last = now; if (blend < 1) { blend = Math.min(1, blend + dt / MORPH); if (blend === 1) { from = to; waited = 0; } } else if ((waited += dt) >= hold) { next(); } kick *= 0.95; angleY += dt * (0.5 + kick) * speed; angleX += dt * (0.22 + kick * 0.4) * speed; draw(); raf = inView ? requestAnimationFrame(frame) : 0; }; const wake = () => { 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 click = () => { if (reduce) { if (list.length > 1) from = to = (from + 1) % list.length; draw(); return; } kick = 3; next(); }; 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("click", click); return () => { cancelAnimationFrame(raf); sized.disconnect(); watch.disconnect(); canvas.removeEventListener("webglcontextlost", lost); box.removeEventListener("click", click); gl.getExtension("WEBGL_lose_context")?.loseContext(); canvas.remove(); plain.hidden = false; }; }, [order, hues, hold, speed, reduce]); return ( ); }