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:
@@ -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;
|
||||
Reference in New Issue
Block a user