← All posts

14,000 GPU particles in React Three Fiber — what I learned

Building the particle field for the Immersive 3D Portfolio: curl-noise flow, mouse repel, scroll dispersion, and keeping 60fps on budget hardware.

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.