The particle field in the Immersive Portfolio runs 14,000 particles at 60fps on most GPUs. Here's how — and what broke along the way.
Why the GPU, not the CPU
The naive approach: store 14k position arrays in JavaScript, update them in useFrame, upload to the GPU every frame. On a MacBook Pro M2 this runs fine. On a mid-range Windows laptop it crawls.
The correct approach: store positions in a BufferGeometry, write a vertex shader that computes the new position on the GPU using the current time and mouse uniforms. JavaScript uploads two floats per frame. The GPU handles the 14k × 3 float position calculations in parallel.
// particles.vert (simplified)
uniform float uTime;
uniform vec2 uMouse;
uniform float uScroll;
// Curl noise from 3 octaves of simplex
vec3 curl = curlNoise(position * 0.4 + uTime * 0.12);
// Mouse repel
vec2 toMouse = position.xy - uMouse;
float dist = length(toMouse);
vec3 repel = vec3(normalize(toMouse), 0.0) * smoothstep(0.8, 0.0, dist) * 0.6;
// Scroll dispersion
float disperse = uScroll * 2.0;
vec3 disperseDir = normalize(position) * disperse;
vec3 newPos = position + curl * 0.008 + repel * 0.01 + disperseDir * 0.003;
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
gl_PointSize = 1.8 * (300.0 / -mvPosition.z);
The key insight: gl_PointSize scales with depth. Particles far from the camera are small; close particles are large. This gives the field volume for free.
Lenis + GSAP sync
The scroll dispersion effect required knowing the scroll progress. Lenis exposes this via its on('scroll') callback. Feeding it to a GSAP-managed uniform means everything runs in the same RAF:
lenis.on('scroll', ({ progress }) => {
particlesMaterial.uniforms.uScroll.value = progress
})
No window.addEventListener('scroll') — that fires after paint and causes a one-frame lag.
Performance budget
| Thing | Cost | |-------|------| | 14k particles | ~0.4ms/frame GPU | | Curl noise (3 octaves) | ~0.8ms/frame GPU | | GSAP ScrollTrigger | ~0.1ms/frame CPU | | Lenis | ~0.05ms/frame CPU | | Total | well under 16ms |
The expensive part is the noise function, not the particle count. Three octaves of simplex noise is the right trade-off: rich, organic movement without visible repetition.
What I'd do differently
The mouse repel calculation happens in the vertex shader using a screen-space mouse uniform. This means the repel radius is view-dependent — it shrinks when zoomed out. A world-space calculation would be cleaner. I'll fix this in the next version.