A single GPU thread can process thousands of particles in parallel. A single JavaScript thread cannot. That fundamental truth is why GPU-based particle systems run at 60fps with a million points while CPU-based approaches choke at ten thousand.
In this article we'll build a high-performance 3D particle system entirely on the GPU using Three.js custom ShaderMaterial and raw GLSL—no postprocessing libraries, no helpers, just the metal.
Setting Up BufferGeometry
Every particle is a vertex. We pre-allocate a Float32Array and upload it once. The GPU owns it after that—we never touch it from JavaScript each frame.
const COUNT = 200_000;
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(COUNT * 3);
const randoms = new Float32Array(COUNT * 3);
const scales = new Float32Array(COUNT);
for (let i = 0; i < COUNT; i++) {
// Random sphere distribution
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const r = Math.cbrt(Math.random()) * 4;
positions[i*3] = r * Math.sin(phi) * Math.cos(theta);
positions[i*3+1] = r * Math.sin(phi) * Math.sin(theta);
positions[i*3+2] = r * Math.cos(phi);
randoms[i*3] = Math.random();
randoms[i*3+1] = Math.random();
randoms[i*3+2] = Math.random();
scales[i] = Math.random();
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 3));
geometry.setAttribute('aScale', new THREE.BufferAttribute(scales, 1));
The Vertex Shader
The vertex shader runs once per particle, per frame, on the GPU. We pass uTime as a uniform and animate position entirely on the GPU—zero JS per-frame cost.
// vertexShader.glsl
uniform float uTime;
uniform float uSize;
attribute vec3 aRandom;
attribute float aScale;
varying float vLife;
void main() {
vec3 pos = position;
// Orbit each particle at a unique speed + phase
float angle = uTime * (0.3 + aRandom.x * 0.7);
float radius = length(pos.xz);
pos.x = cos(angle) * radius;
pos.z = sin(angle) * radius;
// Breathing Y motion
pos.y += sin(uTime * aRandom.y * 2.0) * 0.2;
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
gl_Position = projectionMatrix * mv;
gl_PointSize = uSize * aScale * (1.0 / -mv.z);
vLife = aRandom.z;
}
The Fragment Shader
Each particle renders as a gl_POINTS quad. We shape it into a soft circle and apply colour from a gradient based on vLife.
// fragmentShader.glsl
varying float vLife;
void main() {
vec2 uv = gl_PointCoord - 0.5;
float d = length(uv);
if (d > 0.5) discard; // circular crop
float alpha = 1.0 - smoothstep(0.3, 0.5, d);
vec3 colA = vec3(0.486, 0.239, 0.929); // purple
vec3 colB = vec3(0.925, 0.447, 0.600); // pink
vec3 col = mix(colA, colB, vLife);
gl_FragColor = vec4(col, alpha * 0.85);
}
Animation Loop
The only thing we do per frame from JavaScript is increment the time uniform. The GPU handles everything else.
const material = new THREE.ShaderMaterial({
vertexShader, fragmentShader,
uniforms: {
uTime: { value: 0 },
uSize: { value: 120 * renderer.getPixelRatio() },
},
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
const particles = new THREE.Points(geometry, material);
scene.add(particles);
function animate(t) {
requestAnimationFrame(animate);
material.uniforms.uTime.value = t * 0.001; // only this per frame
renderer.render(scene, camera);
}
requestAnimationFrame(animate);
Performance Tips
- Set
depthWrite: falseand use additive blending for transparent particles—avoids expensive depth sorting - Cap
pixelRatioat 1.5 on mobile withrenderer.setPixelRatio(Math.min(devicePixelRatio, 1.5)) - Use
IntersectionObserverto pause the animation loop when the canvas is off-screen - Prefer
gl_PointSizeover instanced meshes for pure-point particles—cheaper overdraw - Use
powerPreference: 'low-power'on mobile to avoid draining the battery
200K
Particles @ 60fps
0.3ms
JS per frame
~4MB
GPU memory
Summary
- Allocate all geometry data once as typed arrays—let the GPU own it
- Drive all animation through uniforms—zero per-particle JS each frame
- Use additive blending +
discardfor soft circle particles - Pause the loop with IntersectionObserver when not visible
- Custom ShaderMaterial beats all high-level abstractions for raw particle counts