← snapshot
8899 bytes
"use client";
import type * as THREE from "three";
import { useEffect, useRef } from "react";
import type { LabState } from "./lab-types";
/**
* Three.js variants. Loaded dynamically so the ~150KB engine only lands on
* the lab route and never on the app the visitor actually uses.
*/
export type ThreeVariantId =
| "three-points"
| "three-wire-ico"
| "three-ribbons"
| "three-shell";
export function ThreeTile({
variant,
size,
state,
active,
}: {
variant: ThreeVariantId;
size: number;
state: React.RefObject<LabState>;
active: boolean;
}) {
const ref = useRef<HTMLCanvasElement | null>(null);
const activeRef = useRef(active);
activeRef.current = active;
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
let dispose = () => {};
let cancelled = false;
(async () => {
const THREE = await import("three");
if (cancelled) return;
const renderer = new THREE.WebGLRenderer({
canvas,
alpha: true,
antialias: true,
// Same reason as the GLSL tiles: keep the buffer sampleable so the
// lab check can tell "drew nothing" from "drew and swapped".
preserveDrawingBuffer: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(size, size, false);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 0.5, 4.2);
camera.lookAt(0, 0, 0);
const group = new THREE.Group();
scene.add(group);
const color = new THREE.Color(0x8aa6e0);
let update: (t: number, s: LabState) => void = () => {};
if (variant === "three-points") {
// A Fibonacci-sphere point cloud that breathes with the voice.
const N = 2600;
const pos = new Float32Array(N * 3);
const base = new Float32Array(N * 3);
const golden = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < N; i++) {
const y = 1 - (i / (N - 1)) * 2;
const r = Math.sqrt(Math.max(0, 1 - y * y));
const th = golden * i;
base[i * 3] = Math.cos(th) * r;
base[i * 3 + 1] = y;
base[i * 3 + 2] = Math.sin(th) * r;
}
pos.set(base);
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
const mat = new THREE.PointsMaterial({
size: 0.022,
color,
transparent: true,
opacity: 0.9,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const pts = new THREE.Points(geo, mat);
group.add(pts);
update = (t, s) => {
const amp = 0.1 + s.level * 0.34;
for (let i = 0; i < N; i++) {
const x = base[i * 3] as number;
const y = base[i * 3 + 1] as number;
const z = base[i * 3 + 2] as number;
const w =
Math.sin(x * 3 + t) * Math.sin(y * 3 + t * 0.8) * Math.sin(z * 3);
const k = 1 + amp * w;
pos[i * 3] = x * k;
pos[i * 3 + 1] = y * k;
pos[i * 3 + 2] = z * k;
}
geo.attributes.position.needsUpdate = true;
group.rotation.y = t * 0.25;
};
} else if (variant === "three-wire-ico") {
const geo = new THREE.IcosahedronGeometry(1.25, 3);
const wire = new THREE.WireframeGeometry(geo);
const mat = new THREE.LineBasicMaterial({
color,
transparent: true,
opacity: 0.55,
});
const lines = new THREE.LineSegments(wire, mat);
group.add(lines);
const inner = new THREE.Mesh(
new THREE.IcosahedronGeometry(1.02, 2),
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.12,
}),
);
group.add(inner);
update = (t, s) => {
const k = 1 + s.level * 0.16;
group.scale.setScalar(k);
group.rotation.y = t * 0.3;
group.rotation.x = Math.sin(t * 0.2) * 0.25;
mat.opacity = 0.45 + s.attract * 0.35;
};
} else if (variant === "three-ribbons") {
const ribbons: THREE.Mesh[] = [];
for (let i = 0; i < 4; i++) {
const curve = new THREE.TorusGeometry(1.05 + i * 0.16, 0.012, 8, 220);
const m = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.7,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const mesh = new THREE.Mesh(curve, m);
mesh.rotation.x = 0.4 + i * 0.5;
mesh.rotation.z = i * 0.7;
group.add(mesh);
ribbons.push(mesh);
}
update = (t, s) => {
ribbons.forEach((r, i) => {
const dir = i % 2 === 0 ? 1 : -1;
r.rotation.y = t * (0.3 + i * 0.12) * dir;
r.scale.setScalar(1 + s.level * 0.1);
});
group.rotation.y = t * 0.08;
};
} else {
// three-shell: a translucent displaced shell over a bright core.
const geo = new THREE.SphereGeometry(1.15, 64, 44);
const basePos = geo.attributes.position.array.slice() as Float32Array;
const mat = new THREE.MeshPhongMaterial({
color,
transparent: true,
opacity: 0.5,
// Hard specular against flat facets is what stops this reading as
// grey putty — the shell needs edges to catch light on.
shininess: 140,
specular: new THREE.Color(0xffffff),
side: THREE.DoubleSide,
flatShading: true,
});
const shell = new THREE.Mesh(geo, mat);
group.add(shell);
const core = new THREE.Mesh(
new THREE.SphereGeometry(0.55, 32, 24),
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.85 }),
);
group.add(core);
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
const key = new THREE.PointLight(0xffffff, 40);
key.position.set(3, 4, 3);
scene.add(key);
const arr = geo.attributes.position.array as Float32Array;
update = (t, s) => {
const amp = 0.12 + s.level * 0.3;
for (let i = 0; i < arr.length; i += 3) {
const x = basePos[i] as number;
const y = basePos[i + 1] as number;
const z = basePos[i + 2] as number;
const w =
Math.sin(x * 2.4 + t) * Math.sin(y * 2.4 + t * 0.7) * Math.sin(z * 2.4) +
0.45 * Math.sin(x * 5.1 - t * 1.3) * Math.sin(z * 5.1 + t);
const k = 1 + amp * w;
arr[i] = x * k;
arr[i + 1] = y * k;
arr[i + 2] = z * k;
}
geo.attributes.position.needsUpdate = true;
geo.computeVertexNormals();
group.rotation.y = t * 0.2;
core.scale.setScalar(1 + s.level * 0.3);
};
}
let raf = 0;
let clock = 0;
let last = performance.now();
const loop = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
if (activeRef.current) {
const s = state.current ?? {
level: 0,
attract: 0,
leanX: 0,
leanY: 0,
tempo: 1,
thread: [0.54, 0.65, 0.88] as [number, number, number],
};
clock += dt * s.tempo;
color.setRGB(s.thread[0], s.thread[1], s.thread[2]);
group.traverse((o) => {
const m = (o as THREE.Mesh).material as
| THREE.Material & { color?: THREE.Color }
| undefined;
if (m?.color) m.color.copy(color);
});
// Lean: the whole group turns toward the pointer.
group.rotation.z = -s.leanX * 0.2;
camera.position.x = s.leanX * 0.9;
camera.position.y = 0.5 - s.leanY * 0.7;
camera.lookAt(0, 0, 0);
update(clock, s);
renderer.render(scene, camera);
}
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
dispose = () => {
cancelAnimationFrame(raf);
renderer.forceContextLoss();
renderer.dispose();
scene.traverse((o) => {
const mesh = o as THREE.Mesh;
mesh.geometry?.dispose?.();
const m = mesh.material as THREE.Material | THREE.Material[] | undefined;
if (Array.isArray(m)) for (const x of m) x.dispose();
else m?.dispose?.();
});
};
})();
return () => {
cancelled = true;
dispose();
};
}, [variant, size, state]);
return <canvas ref={ref} style={{ width: size, height: size, display: "block" }} />;
}