Add Three.js page graphics and GSAP site-wide motion system

- Home hero: full-height, with an interactive 3D wireframe of my face
  (MediaPipe landmarks from my-photo.png) rendered as a network graph
  with packets, scan sweeps and an assemble-on-load entry animation
- Inner pages: shared PageHero with a themed scene per page
  (About helix, Skills geodesic sphere, Work infra stack, Blog signal
  waves, Contact globe with connection arcs)
- Shared Three.js stage (framing, GSAP entry, pointer, off-screen
  pausing, reduced motion) with lazily loaded per-scene chunks
- GSAP motion system: data-reveal / data-reveal-stagger / data-parallax
  attributes, SplitText headings, divider draw-in, page exit fade,
  no-JS and reduced-motion fallbacks
- Mobile: graphic stacks above the text

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-11-03 11:57:05 +01:00
co-authored by Claude Opus 5.5
parent 8c280be7b8
commit d93550ed29
34 changed files with 2562 additions and 143 deletions
+183
View File
@@ -0,0 +1,183 @@
import * as THREE from "three";
import type { SceneFactory } from "../stage";
import {
createPackets,
glowGeometry,
glowLines,
glowPoints,
smooth,
stagger,
} from "../materials";
/**
* Work: an isometric infrastructure stack — edge, app, data, storage
* layers as gridded plates, services as nodes, traffic flowing between
* layers. Entry: plates drop into place bottom-up, then links light up.
*/
const LAYERS = 4;
const SPACING = 0.62;
const SIZE = 1.9;
const GRID = 8;
const SERVICES_PER_LAYER = 5;
const LINKS_PER_GAP = 6;
const stack: SceneFactory = (stage) => {
const { scene, colors } = stage;
const root = new THREE.Group();
root.rotation.set(0.62, Math.PI / 4, 0);
scene.add(root);
const half = SIZE / 2;
const cell = SIZE / GRID;
const layerY = (i: number) => (i - (LAYERS - 1) / 2) * SPACING;
type Layer = {
group: THREE.Group;
gridMat: THREE.ShaderMaterial;
borderMat: THREE.ShaderMaterial;
nodeMat: THREE.ShaderMaterial;
services: THREE.Vector3[];
};
const layers: Layer[] = [];
for (let li = 0; li < LAYERS; li++) {
const group = new THREE.Group();
const grid: number[] = [];
for (let k = 1; k < GRID; k++) {
const c = -half + k * cell;
grid.push(c, 0, -half, c, 0, half, -half, 0, c, half, 0, c);
}
const gridMat = glowLines({ color: colors.line, opacity: 0.1 });
group.add(new THREE.LineSegments(glowGeometry(grid), gridMat));
const b = [
[-half, -half],
[half, -half],
[half, half],
[-half, half],
];
const border: number[] = [];
for (let k = 0; k < 4; k++) {
const [x1, z1] = b[k]!;
const [x2, z2] = b[(k + 1) % 4]!;
border.push(x1!, 0, z1!, x2!, 0, z2!);
}
// corner ticks give plates a "hardware" feel
b.forEach(([x, z]) => {
border.push(x!, 0, z!, x!, -0.06, z!);
});
const borderMat = glowLines({ color: colors.line, opacity: 0.55 });
group.add(new THREE.LineSegments(glowGeometry(border), borderMat));
const services: THREE.Vector3[] = [];
const used = new Set<string>();
while (services.length < SERVICES_PER_LAYER) {
const gx = 1 + Math.floor(Math.random() * (GRID - 1));
const gz = 1 + Math.floor(Math.random() * (GRID - 1));
if (used.has(`${gx},${gz}`)) continue;
used.add(`${gx},${gz}`);
services.push(
new THREE.Vector3(-half + gx * cell, 0, -half + gz * cell),
);
}
const nodeMat = glowPoints(stage, {
color: li === LAYERS - 1 ? colors.accent : colors.line,
size: 9,
});
group.add(
new THREE.Points(
glowGeometry(services.flatMap((s) => [s.x, s.y, s.z])),
nodeMat,
),
);
root.add(group);
layers.push({ group, gridMat, borderMat, nodeMat, services });
}
// ---- inter-layer links ----
type Link = { lower: number; a: number; b: number };
const links: Link[] = [];
for (let li = 0; li < LAYERS - 1; li++) {
for (let k = 0; k < LINKS_PER_GAP; k++) {
links.push({
lower: li,
a: Math.floor(Math.random() * SERVICES_PER_LAYER),
b: Math.floor(Math.random() * SERVICES_PER_LAYER),
});
}
}
const linkGeo = glowGeometry(new Float32Array(links.length * 6), {
dynamic: true,
});
const linkMat = glowLines({ color: colors.accent, opacity: 0.28 });
root.add(new THREE.LineSegments(linkGeo, linkMat));
// packet paths are rebuilt in place each frame as layers bob
const linkPaths = links.map(() => [
new THREE.Vector3(),
new THREE.Vector3(),
]);
const packets = createPackets(stage, linkPaths, {
perPath: 1,
speed: 0.5,
trail: 6,
size: 8,
});
root.add(packets.points);
const world = (li: number, v: THREE.Vector3, out: THREE.Vector3) =>
out.copy(v).add(layers[li]!.group.position);
return {
frame: { width: 3.6, height: 4.1 },
update(t, dt) {
const e = stage.entry;
layers.forEach((L, i) => {
const local = stagger(e, i, LAYERS, 0.45);
const bob = stage.reducedMotion
? 0
: Math.sin(t * 0.8 + i * 1.3) * 0.035;
L.group.position.y = layerY(i) + bob + (1 - local) * 1.6;
L.gridMat.uniforms.uOpacity!.value = 0.1 * local;
L.borderMat.uniforms.uOpacity!.value = 0.55 * local;
L.nodeMat.uniforms.uOpacity!.value = smooth(
(local - 0.6) / 0.4,
);
});
const pos = linkGeo.attributes.position as THREE.BufferAttribute;
links.forEach((lk, i) => {
const pa = world(
lk.lower,
layers[lk.lower]!.services[lk.a]!,
linkPaths[i]![0]!,
);
const pb = world(
lk.lower + 1,
layers[lk.lower + 1]!.services[lk.b]!,
linkPaths[i]![1]!,
);
pos.setXYZ(i * 2, pa.x, pa.y, pa.z);
pos.setXYZ(i * 2 + 1, pb.x, pb.y, pb.z);
});
pos.needsUpdate = true;
const linksOn = smooth((e - 0.7) / 0.3);
linkMat.uniforms.uOpacity!.value = 0.28 * linksOn;
if (stage.reducedMotion) return;
packets.update(dt, linksOn);
root.rotation.y =
Math.PI / 4 +
Math.sin(t * 0.15) * 0.15 +
stage.pointer.x * 0.35;
root.rotation.x = 0.62 + stage.pointer.y * 0.12;
},
};
};
export default stack;