Most 3D hero sections are a tax the visitor pays so the developer can feel clever. I wanted the opposite: something that reads as me β an AI engineer β without ever costing the page its first paint. This is how the particle field on my homepage works, and the rules I gave it.
The one rule
Before writing a line of shader code, I wrote down a constraint: the canvas is never allowed to be the LCP. The headline and my portrait paint first, from server-rendered HTML. The WebGL is additive β it loads after, it can fail, it can be turned off entirely, and the page is still complete without it. Every decision below falls out of that one rule.
The concept
A "Neural Field": a couple thousand GPU points drifting in space, wired together by thin synapse lines when they drift close, slowly morphing between formations β a diffuse brain-cloud, a sphere, a lattice, and my initials. It's on-the-nose for an AI engineer, and that's the point. A hero should say something true about the person in about half a second.
Points, not meshes
The whole thing is a single THREE.Points object with a custom ShaderMaterial. No geometry per particle, no draw call per particle β one buffer, one draw. The fragment shader does the work of making each point a soft, glowing disc instead of a hard square:
// fragment β a soft round sprite with a hot core, additively blended
varying vec3 vColor;
uniform float uGlow;
void main() {
float d = length(gl_PointCoord - vec2(0.5));
if (d > 0.5) discard;
float core = smoothstep(0.5, 0.0, d);
vec3 col = vColor * (0.55 + 0.85 * smoothstep(0.5, 0.18, d)) * uGlow;
gl_FragColor = vec4(col, core * core);
}
Additive blending plus a tight core is what sells the "energy" look on a dark background. On light mode I swap to normal blending and darker colors β additive on white just washes out to nothing.
Making it move
The motion lives entirely in one useFrame loop. Each point eases toward a target position (the current formation), gets a little idle drift so it never looks frozen, and gets pushed away from the pointer. The repulsion is the part people actually feel:
useFrame((state, delta) => {
const dt = Math.min(delta, 0.05); // clamp after tab refocus
const ease = 1 - Math.exp(-dt * 2.3); // frame-rate independent
for (let i = 0; i < count; i++) {
const ix = i * 3;
// ease toward the active formation
pos[ix] += (target[ix] - pos[ix]) * ease;
pos[ix + 1] += (target[ix + 1] - pos[ix + 1]) * ease;
// push away from the cursor, then let the ease pull it back
const dx = pos[ix] - pointer.x;
const dy = pos[ix + 1] - pointer.y;
const d2 = dx * dx + dy * dy;
if (d2 < R2) {
const f = 1 - d2 / R2;
const inv = 1 / Math.sqrt(d2);
pos[ix] += dx * inv * f * f * PUSH;
pos[ix + 1] += dy * inv * f * f * PUSH;
}
}
geometry.attributes.position.needsUpdate = true;
});
Two details that matter more than they look. First, 1 - Math.exp(-dt * k) instead of a fixed lerp factor, so the animation is identical at 60Hz and 144Hz. Second, the repulsion adds offset while the ease restores it β the equilibrium is a moving "hole" around your cursor that fills back in the moment you leave. No springs, no state, just two forces fighting.
I also stopped tracking the pointer through R3F's canvas events. The canvas sits behind the hero text, so it rarely gets the pointer. A single window listener writing into a ref is simpler and works no matter what's on top.
The part everyone skips
Here's where the "earns its frame budget" claim gets cashed. The scene reads device capability once and scales itself:
let tier: Tier = "high";
if (!webgl) tier = "low";
else if (coarsePointer || smallScreen) tier = "low"; // phones
else if (deviceMemory <= 4 || cores <= 4) tier = "mid"; // weak laptops
That tier drives everything: ~2.4k points and synapse lines on a desktop, ~400 and no lines on a phone. On top of that:
prefers-reduced-motionfreezes the field into a still, composed frame and disables the pointer reaction. The image is still there; the motion isn't.- No WebGL falls back to a CSS gradient mesh. The hero never breaks.
- An IntersectionObserver flips the canvas to
frameloop="never"the moment it scrolls off, andvisibilitychangepauses it when the tab is hidden. A particle field you can't see should not be spending your battery. - The whole scene is a
dynamic(() => import(...), { ssr: false })chunk, so none of three.js is in the initial bundle.
What I'd tell past-me
Bold and expensive are not the same thing. The field looks like the most "expensive" thing on the site and is, by design, the most disposable. If you're adding 3D to a portfolio, decide up front what it's allowed to cost β in milliseconds, in battery, in bundle β and let that budget design the feature. The constraint didn't make it less fun to build. It made it shippable.
The source for all of this is on my GitHub, and the rest of the site is the live demo.