"use client"; import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "motion/react"; type HologramStageProps = { /** Six-digit hex colour of the hologram. */ color?: string; /** Change the model by itself until a visitor picks one. */ auto?: boolean; /** Seconds on each model in auto mode. */ hold?: number; /** 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 = 1200000; const FOCAL = 2.7; const MODELS = ["Gopuram", "Lotus", "Lamp"]; const WASH_VERTEX = `attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}`; const WASH_FRAGMENT = `precision highp float; uniform vec2 res; uniform vec3 beam,cam,rt,upv,fwd; uniform float time,power; const float R=.62,H=.16; float hash(float n){return fract(sin(n)*43758.5453);} vec3 cone(vec3 o,vec3 d){ float down=dot(d.xz,d.xz); float s=max(-dot(o.xz,d.xz)/max(down,1e-4),0.); vec3 p=o+d*s; float reach=mix(.4,1.14,clamp((p.y-H)/2.2,0.,1.)); float across=length(p.xz)/reach; float a=smoothstep(1.,.25,across)*smoothstep(H,H+.12,p.y)*smoothstep(2.75,1.2,p.y); a*=.82+.18*sin(p.y*9.-time*1.3+sin(p.x*7.)*1.4); return beam*a*.2*power; } void main(){ vec2 uv=(gl_FragCoord.xy-.5*res)/(.5*min(res.x,res.y)); vec3 d=normalize(uv.x*rt+uv.y*upv+2.7*fwd); vec3 c=vec3(0.); float t=1e5; float a=dot(d.xz,d.xz),b=dot(cam.xz,d.xz),k=dot(cam.xz,cam.xz)-R*R,disc=b*b-a*k; bool done=false; if(d.y<0.){ float top=(H-cam.y)/d.y; vec3 p=cam+d*top; float r=length(p.xz); if(r0.){ float s=(-b-sqrt(disc))/a; vec3 p=cam+d*s; if(s>0.&&p.y>0.&&p.y void; function collector() { const lines: number[] = []; const add: Add = (...v) => lines.push(...v); return { lines, add }; } function ring(add: Add, y: number, rx: number, rz: number, parts: number) { for (let i = 0; i < parts; i++) { const a = (i / parts) * Math.PI * 2; const b = ((i + 1) / parts) * Math.PI * 2; add(Math.cos(a) * rx, y, Math.sin(a) * rz, Math.cos(b) * rx, y, Math.sin(b) * rz); } } function square(add: Add, y: number, w: number, d: number) { add(-w, y, -d, w, y, -d); add(w, y, -d, w, y, d); add(w, y, d, -w, y, d); add(-w, y, d, -w, y, -d); } /* Spins a side outline round the centre, with rings and long lines. */ function lathe(add: Add, shape: number[][], spokes: number, parts: number) { shape.forEach(([r, y]) => ring(add, y, r, r, parts)); for (let s = 0; s < spokes; s++) { const a = (s / spokes) * Math.PI * 2; for (let i = 0; i + 1 < shape.length; i++) { add(Math.cos(a) * shape[i][0], shape[i][1], Math.sin(a) * shape[i][0], Math.cos(a) * shape[i + 1][0], shape[i + 1][1], Math.sin(a) * shape[i + 1][0]); } } } /* A temple tower: tiers of pillars that step inward, a barrel roof and a row of pots on top. */ function gopuram() { const { lines, add } = collector(); square(add, 0, 0.82, 0.56); square(add, 0.3, 0.82, 0.56); for (let i = -6; i <= 6; i++) { if (Math.abs(i) > 1) { add((i * 0.82) / 6, 0, 0.56, (i * 0.82) / 6, 0.3, 0.56); add((i * 0.82) / 6, 0, -0.56, (i * 0.82) / 6, 0.3, -0.56); } } for (let i = 0; i < 12; i++) { const a = (i / 12) * Math.PI; const b = ((i + 1) / 12) * Math.PI; add(Math.cos(a) * 0.2, 0.18 + Math.sin(a) * 0.1, 0.56, Math.cos(b) * 0.2, 0.18 + Math.sin(b) * 0.1, 0.56); } add(-0.2, 0, 0.56, -0.2, 0.18, 0.56); add(0.2, 0, 0.56, 0.2, 0.18, 0.56); const tiers = 7; for (let t = 0; t < tiers; t++) { const k = 1 - t * 0.105; const w = 0.76 * k; const d = 0.5 * k; const y0 = 0.3 + t * 0.2; const y1 = y0 + 0.2; square(add, y0 + 0.012, w * 1.05, d * 1.05); square(add, y0 + 0.03, w, d); square(add, y1, w * 1.03, d * 1.03); const posts = 10 - t; for (let i = 0; i <= posts; i++) { const x = -w + (2 * w * i) / posts; const top = y0 + (i % 2 ? 0.2 : 0.14); add(x, y0 + 0.03, d, x, top, d); add(x, y0 + 0.03, -d, x, top, -d); if (i % 2) square2(add, x, y0 + 0.15, d); } for (let i = 0; i <= 4; i++) { const z = -d + (2 * d * i) / 4; add(w, y0 + 0.03, z, w, y1, z); add(-w, y0 + 0.03, z, -w, y1, z); } } const top = 0.3 + tiers * 0.2; const w = 0.34; for (let s = 0; s <= 7; s++) { const x = -w + (2 * w * s) / 7; for (let i = 0; i < 12; i++) { const a = (i / 12) * Math.PI; const b = ((i + 1) / 12) * Math.PI; add(x, top + Math.sin(a) * 0.2, Math.cos(a) * 0.17, x, top + Math.sin(b) * 0.2, Math.cos(b) * 0.17); if (s < 7) add(x, top + Math.sin(a) * 0.2, Math.cos(a) * 0.17, x + (2 * w) / 7, top + Math.sin(a) * 0.2, Math.cos(a) * 0.17); } } for (let i = -2; i <= 2; i++) { const shift = lines.length; lathe(add, [[0.015, 0], [0.04, 0.03], [0.018, 0.06], [0.005, 0.11]], 4, 8); for (let n = shift; n < lines.length; n += 3) { lines[n] += i * 0.15; lines[n + 1] += top + 0.2; } } return lines; } function square2(add: Add, x: number, y: number, z: number) { const s = 0.03; add(x - s, y, z, x, y + s, z); add(x, y + s, z, x + s, y, z); } /* A lotus: three rings of ribbed petals round a seed head. */ function lotus() { const { lines, add } = collector(); const petal = (turn: number, length: number, rise: number, wide: number, base: number) => { const at = (u: number, side: number) => { const r = 0.12 + length * u; const y = base + rise * Math.pow(u, 1.6); const half = wide * Math.sin(Math.PI * Math.pow(u, 0.72)) * side; return [Math.cos(turn) * r - Math.sin(turn) * half, y, Math.sin(turn) * r + Math.cos(turn) * half]; }; const sides = [-1, -0.5, 0, 0.5, 1]; for (let i = 0; i < 12; i++) { for (const side of sides) add(...(at(i / 12, side).concat(at((i + 1) / 12, side)) as [number, number, number, number, number, number])); if (i % 3 === 2) { for (let s = 0; s + 1 < sides.length; s++) add(...(at(i / 12, sides[s]).concat(at(i / 12, sides[s + 1])) as [number, number, number, number, number, number])); } } }; for (let i = 0; i < 11; i++) petal((i / 11) * Math.PI * 2, 0.92, 0.42, 0.25, 0.42); for (let i = 0; i < 9; i++) petal(((i + 0.5) / 9) * Math.PI * 2, 0.62, 0.86, 0.22, 0.46); for (let i = 0; i < 6; i++) petal((i / 6) * Math.PI * 2, 0.32, 1, 0.16, 0.5); lathe(add, [[0.12, 0.5], [0.15, 0.6], [0.1, 0.66]], 10, 16); ring(add, 0.42, 0.95, 0.95, 44); return lines; } /* A standing brass temple lamp with five wicks. */ function templeLamp() { const { lines, add } = collector(); lathe(add, [ [0.56, 0], [0.56, 0.05], [0.42, 0.1], [0.14, 0.16], [0.07, 0.24], [0.1, 0.28], [0.06, 0.33], [0.05, 0.7], [0.11, 0.76], [0.05, 0.82], [0.06, 1.12], [0.22, 1.16], [0.5, 1.22], [0.6, 1.3], [0.56, 1.33], [0.2, 1.3], [0.06, 1.34], [0.05, 1.56], [0.1, 1.61], [0.05, 1.66], [0.12, 1.72], [0.05, 1.8], [0.08, 1.86], [0.01, 1.98], ], 16, 28); for (let s = 0; s < 5; s++) { const a = (s / 5) * Math.PI * 2; const x = Math.cos(a) * 0.63; const z = Math.sin(a) * 0.63; for (let i = 0; i < 10; i++) { const t0 = (i / 10) * Math.PI * 2; const t1 = ((i + 1) / 10) * Math.PI * 2; const shape = (t: number) => 0.055 * Math.sin(t) * (1 - 0.6 * (0.5 - 0.5 * Math.cos(t))); add(x + shape(t0), 1.46 - Math.cos(t0) * 0.1, z, x + shape(t1), 1.46 - Math.cos(t1) * 0.1, z); add(x, 1.46 - Math.cos(t0) * 0.1, z + shape(t0), x, 1.46 - Math.cos(t1) * 0.1, z + shape(t1)); } } return lines; } /* All models get the same length so any one can fly into any other; short ones are filled with zero-length lines. */ function buildModels() { const raw = [gopuram(), lotus(), templeLamp()]; const size = Math.max(...raw.map((m) => m.length)); const scatter = (i: number, parts: number) => Math.floor(((i * 2654435761) >>> 0) / 4294967296 * parts); const models = raw.map((m) => { const out = new Float32Array(size); const parts = m.length / 6; const order = Array.from({ length: parts }, (_, i) => i); for (let i = parts - 1; i > 0; i--) { const j = scatter(i + 11, i + 1); [order[i], order[j]] = [order[j], order[i]]; } for (let i = 0; i < size / 6; i++) { const from = order[i % parts] * 6; for (let c = 0; c < 6; c++) out[i * 6 + c] = i < parts ? m[from + c] : m[from + (c % 3)]; } return out; }); const seeds = new Float32Array(size / 3); for (let i = 0; i < size / 6; i++) seeds[i * 2] = seeds[i * 2 + 1] = scatter(i + 3, 1000) / 1000; return { models, seeds, count: size / 3 }; } const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); function program(gl: WebGLRenderingContext, vertex: string, fragment: string) { 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; } /* HologramStage: a small theatre stage where a floor projector throws a turning wire hologram into the dark: a temple tower, a lotus or a lamp. Lines fly apart and rebuild on a change. Drag turns it. Runs only while on screen. */ export function HologramStage({ color = "#ffb347", auto = true, hold = 7, speed = 1, label = "A wire hologram on a small stage", className = "aspect-[4/3] w-[28rem]", }: HologramStageProps) { const reduce = useReducedMotion(); const boxRef = useRef(null); const stageRef = useRef(null); const plainRef = useRef(null); const pickRef = useRef<(index: number, byHand: boolean) => void>(() => {}); const [active, setActive] = useState(0); useEffect(() => { const box = boxRef.current; const stage = stageRef.current; const plain = plainRef.current; if (!box || !stage || !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 wash = gl && program(gl, WASH_VERTEX, WASH_FRAGMENT); const wire = gl && program(gl, LINE_VERTEX, LINE_FRAGMENT); if (!gl || !wash || !wire) return; const corners = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, corners); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); const corner = gl.getAttribLocation(wash, "p"); const { models, seeds, count } = buildModels(); const fromBuffer = gl.createBuffer(); const toBuffer = gl.createBuffer(); const seedBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, seedBuffer); gl.bufferData(gl.ARRAY_BUFFER, seeds, gl.STATIC_DRAW); const slotA = gl.getAttribLocation(wire, "a"); const slotB = gl.getAttribLocation(wire, "b"); const slotSeed = gl.getAttribLocation(wire, "seed"); let current = 0; const load = (buffer: WebGLBuffer | null, index: number) => { gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, models[index], gl.DYNAMIC_DRAW); }; load(fromBuffer, 0); load(toBuffer, 0); const cam = [0, 1.42, 5.6]; const target = [0, 0.98, 0]; const fwd = cam.map((v, i) => target[i] - v); const size = Math.hypot(...fwd); fwd.forEach((v, i) => (fwd[i] = v / size)); const rt = [1, 0, 0]; const upv = [0, -fwd[2], fwd[1]]; const shared = (made: WebGLProgram) => { gl.useProgram(made); gl.uniform3fv(gl.getUniformLocation(made, "cam"), cam); gl.uniform3fv(gl.getUniformLocation(made, "rt"), rt); gl.uniform3fv(gl.getUniformLocation(made, "upv"), upv); gl.uniform3fv(gl.getUniformLocation(made, "fwd"), fwd); gl.uniform3fv(gl.getUniformLocation(made, "beam"), rgb(color)); }; shared(wash); shared(wire); const washAt = (name: string) => gl.getUniformLocation(wash, name); const wireAt = (name: string) => gl.getUniformLocation(wire, name); let raf = 0; let last = 0; let inView = false; let time = 0; let yaw = 0.55; let spin = 0.3; let blend = 1; let tear = 0; let waited = 0; let keepAuto = auto; let held = false; let lastX = 0; let width = 1; let height = 1; const lines = (mirror: number, x: number, y: number) => { gl.uniform1f(wireAt("mirror"), mirror); gl.uniform2f(wireAt("nudge"), x, y); gl.drawArrays(gl.LINES, 0, count); }; const draw = () => { const power = 0.92 + 0.05 * Math.sin(time * 31) * Math.sin(time * 7.3) + tear * 0.25; gl.disable(gl.BLEND); gl.useProgram(wash); gl.disableVertexAttribArray(slotA); gl.disableVertexAttribArray(slotB); gl.disableVertexAttribArray(slotSeed); gl.bindBuffer(gl.ARRAY_BUFFER, corners); gl.enableVertexAttribArray(corner); gl.vertexAttribPointer(corner, 2, gl.FLOAT, false, 0, 0); gl.uniform2f(washAt("res"), width, height); gl.uniform1f(washAt("time"), time % 1000); gl.uniform1f(washAt("power"), power); gl.drawArrays(gl.TRIANGLES, 0, 3); gl.disableVertexAttribArray(corner); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE); gl.useProgram(wire); gl.bindBuffer(gl.ARRAY_BUFFER, fromBuffer); gl.enableVertexAttribArray(slotA); gl.vertexAttribPointer(slotA, 3, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, toBuffer); gl.enableVertexAttribArray(slotB); gl.vertexAttribPointer(slotB, 3, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, seedBuffer); gl.enableVertexAttribArray(slotSeed); gl.vertexAttribPointer(slotSeed, 1, gl.FLOAT, false, 0, 0); const side = Math.min(width, height); gl.uniform2f(wireAt("fit"), side / width, side / height); gl.uniform1f(wireAt("yaw"), yaw); gl.uniform1f(wireAt("blend"), blend); gl.uniform1f(wireAt("time"), time % 1000); gl.uniform1f(wireAt("tear"), tear); gl.uniform1f(wireAt("power"), power); const px = 2 / width; lines(1, 0, 0); lines(1, px, 0); lines(-1, 0, 0); }; const pick = (index: number, byHand: boolean) => { if (byHand) keepAuto = false; if (index === current) return; load(fromBuffer, current); load(toBuffer, index); current = index; blend = reduce ? 1 : 0; tear = reduce ? 0 : 1; waited = 0; setActive(index); if (reduce) draw(); }; pickRef.current = pick; const frame = (now: number) => { const dt = Math.min(0.05, (now - last) / 1000 || 0); last = now; time += dt; if (!held) { yaw += spin * dt * speed; spin += (0.3 - spin) * Math.min(1, dt * 2); } blend = Math.min(1, blend + dt / 1.5); tear = Math.max(0, tear - dt * 1.7); if (keepAuto && blend === 1 && !held) { waited += dt; if (waited >= hold) pick((current + 1) % MODELS.length, false); } draw(); raf = inView ? requestAnimationFrame(frame) : 0; }; const start = () => { if (inView && !raf && !reduce) raf = requestAnimationFrame(frame); }; const fit = () => { const r = stage.getBoundingClientRect(); if (!r.width || !r.height) return; const scale = Math.min(Math.max(window.devicePixelRatio, 1.5), 2, Math.sqrt(MAX_PIXELS / (r.width * r.height))); width = canvas.width = Math.round(r.width * scale); height = canvas.height = Math.round(r.height * scale); gl.viewport(0, 0, width, height); draw(); }; const press = (e: PointerEvent) => { held = true; lastX = e.clientX; stage.setPointerCapture(e.pointerId); }; const drag = (e: PointerEvent) => { if (!held) return; const dx = e.clientX - lastX; lastX = e.clientX; yaw += dx * 0.01; spin = dx * 0.5; if (reduce) draw(); }; const release = () => { held = false; }; fit(); stage.append(canvas); plain.hidden = true; const sized = new ResizeObserver(fit); sized.observe(stage); const watch = new IntersectionObserver(([entry]) => { inView = entry.isIntersecting; start(); }); watch.observe(stage); const lost = () => { canvas.remove(); plain.hidden = false; }; canvas.addEventListener("webglcontextlost", lost); stage.addEventListener("pointerdown", press); stage.addEventListener("pointermove", drag); stage.addEventListener("pointerup", release); stage.addEventListener("pointercancel", release); return () => { pickRef.current = () => {}; cancelAnimationFrame(raf); sized.disconnect(); watch.disconnect(); canvas.removeEventListener("webglcontextlost", lost); stage.removeEventListener("pointerdown", press); stage.removeEventListener("pointermove", drag); stage.removeEventListener("pointerup", release); stage.removeEventListener("pointercancel", release); gl.getExtension("WEBGL_lose_context")?.loseContext(); canvas.remove(); plain.hidden = false; }; }, [color, auto, hold, speed, reduce]); return (
); }