Three.js Particle Systems: BufferGeometry, Points and GPU Compute
Build high-performance Three.js particle systems with BufferGeometry and Points, then push past 1M particles using GPU compute shaders. Real code, real tradeoffs.
Why Particles Are Still the Best Bang-for-Buck 3D Effect
Particle systems are one of those techniques that punch well above their visual weight. A galaxy of 200,000 glowing points costs almost nothing to render compared to the same scene with 200,000 actual mesh objects — and yet the result looks ten times more impressive. That asymmetry is why every hero section trying to convey "tech" ends up with floating specks of light.
That said, there's a huge gap between a naive particle implementation and one that actually runs at 60fps on mid-range hardware. Toss position data into a regular THREE.Geometry (deprecated since Three.js r125), mismanage your attribute updates, or forget to batch draw calls — and you're looking at 12fps on a 2022 MacBook. The difference is almost always in how you structure your data.
This article walks through the full stack: BufferGeometry with typed arrays, Points material configuration, custom shaders for per-particle behavior, and finally GPU compute via GPUComputationRenderer when you need to push past what the CPU can handle. Worth noting: examples here use Three.js r168, which ships with WebGPURenderer as an opt-in alongside the classic WebGLRenderer.
If you want to see particle aesthetics in action without writing a line of GLSL, check out what Empire UI's aurora components do with layered canvas gradients — it's a useful reference for the kind of depth and motion you're chasing.
BufferGeometry: The Only Geometry You Should Be Using
THREE.Points needs a geometry object with at least a position attribute. The old THREE.Geometry API let you push THREE.Vector3 objects into an array — comfortable but catastrophically slow at scale because Three.js had to serialize those objects to a typed array every frame. BufferGeometry skips the middleman. You hand it a Float32Array directly, and that array is what gets uploaded to the GPU.
Here's the minimum viable particle setup — 50,000 particles scattered in a cube:
import * as THREE from 'three';
const COUNT = 50_000;
const positions = new Float32Array(COUNT * 3); // x, y, z per particle
for (let i = 0; i < COUNT; i++) {
positions[i * 3] = (Math.random() - 0.5) * 200; // x
positions[i * 3 + 1] = (Math.random() - 0.5) * 200; // y
positions[i * 3 + 2] = (Math.random() - 0.5) * 200; // z
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
'position',
new THREE.BufferAttribute(positions, 3) // 3 components per vertex
);
const material = new THREE.PointsMaterial({
size: 0.5,
color: 0x88ccff,
sizeAttenuation: true, // particles shrink with distance
});
const particles = new THREE.Points(geometry, material);
scene.add(particles);The key detail is sizeAttenuation: true. Without it, every particle renders at the same pixel size regardless of depth — which looks flat and bizarre. With it, the perspective projection applies and distant particles appear smaller. That single flag does more for visual quality than most shader tricks.
One more thing — the 3 in new THREE.BufferAttribute(positions, 3) is the itemSize, telling Three.js each vertex consumes 3 consecutive values. If you add a color attribute later, that's also itemSize: 3 (r, g, b). An age scalar would be itemSize: 1. Get this wrong and your particles will be scrambled in the most confusing way possible — ask me how I know.
Honestly, this alone handles a surprising amount of use cases. 50k particles, static positions, uniform color — that's a starfield or a confetti burst right there. The complexity only starts when you want them to *move*.
Custom Attributes and Per-Particle Data
Real particle systems need per-particle variation: different sizes, different colors, different lifetimes. PointsMaterial gets you color and size globally but not per-vertex. For that, you drop to a ShaderMaterial and pass custom BufferAttribute arrays as vertex shader inputs.
Here's a setup with per-particle size and a lifecycle age attribute:
const COUNT = 100_000;
const positions = new Float32Array(COUNT * 3);
const sizes = new Float32Array(COUNT); // one float per particle
const ages = new Float32Array(COUNT); // 0.0 = new, 1.0 = dead
for (let i = 0; i < COUNT; i++) {
positions[i * 3] = (Math.random() - 0.5) * 100;
positions[i * 3 + 1] = (Math.random() - 0.5) * 100;
positions[i * 3 + 2] = (Math.random() - 0.5) * 100;
sizes[i] = Math.random() * 3.0 + 0.5; // 0.5px–3.5px
ages[i] = Math.random(); // stagger starting ages
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
geometry.setAttribute('age', new THREE.BufferAttribute(ages, 1));
const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uTexture: { value: new THREE.TextureLoader().load('/particle.png') },
},
vertexShader: /* glsl */`
attribute float size;
attribute float age;
varying float vAge;
void main() {
vAge = age;
vec4 mvPos = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = size * (300.0 / -mvPos.z); // manual attenuation
gl_Position = projectionMatrix * mvPos;
}
`,
fragmentShader: /* glsl */`
uniform sampler2D uTexture;
varying float vAge;
void main() {
vec4 texColor = texture2D(uTexture, gl_PointCoord);
// Fade out as age approaches 1.0
gl_FragColor = vec4(texColor.rgb, texColor.a * (1.0 - vAge));
}
`,
transparent: true,
depthWrite: false, // critical for correct blending
});The depthWrite: false line is non-obvious but mandatory. Without it, particles will occlude each other based on draw order rather than blending — you'll see hard rectangular cutouts where particles overlap. This is one of those Three.js gotchas that bites everyone at least once.
In your animation loop, you update uTime and also mutate the ages array to advance the lifecycle, then flag the attribute as dirty:
function animate(elapsed) {
material.uniforms.uTime.value = elapsed;
const ageAttr = geometry.attributes.age;
for (let i = 0; i < COUNT; i++) {
ageAttr.array[i] += 0.005;
if (ageAttr.array[i] > 1.0) ageAttr.array[i] = 0.0; // respawn
}
ageAttr.needsUpdate = true; // tells Three.js to re-upload to GPU
renderer.render(scene, camera);
}That needsUpdate = true flag is everything. Without it, the GPU buffer stays stale and nothing appears to change. With it, Three.js calls gl.bufferSubData() to push only the modified attribute — not the whole geometry. That's fine for 100k particles, but at 1M it starts to saturate the CPU-GPU bus. That's where compute shaders come in.
Giving Particles Physics: Velocity and Forces on the CPU
Before jumping to GPU compute, let's be clear about what CPU-side physics can realistically handle. For counts up to roughly 200k–300k particles, a simple Euler integration loop in JavaScript is fast enough — especially if you're careful about memory layout and avoid allocation inside the loop.
Keep a separate Float32Array for velocities and update positions in a tight loop:
const velocities = new Float32Array(COUNT * 3);
// Initialize velocities
for (let i = 0; i < COUNT; i++) {
velocities[i * 3] = (Math.random() - 0.5) * 0.02;
velocities[i * 3 + 1] = Math.random() * 0.05; // slight upward bias
velocities[i * 3 + 2] = (Math.random() - 0.5) * 0.02;
}
function updateParticles(dt) {
const pos = geometry.attributes.position.array;
const GRAVITY = -0.001;
for (let i = 0; i < COUNT; i++) {
const ix = i * 3, iy = ix + 1, iz = ix + 2;
velocities[iy] += GRAVITY * dt; // apply gravity
pos[ix] += velocities[ix] * dt;
pos[iy] += velocities[iy] * dt;
pos[iz] += velocities[iz] * dt;
// Respawn below floor
if (pos[iy] < -50) {
pos[iy] = 50;
velocities[iy] = Math.random() * 0.05;
}
}
geometry.attributes.position.needsUpdate = true;
}Quick aside: using const ix = i * 3 once per iteration and then indexing off it is meaningfully faster than recomputing i * 3, i * 3 + 1, i * 3 + 2 three times. The JIT will likely optimize this anyway, but being explicit about it means you're not accidentally reading stale array indices.
In practice, 200k particles with this loop runs at a stable 60fps on a 2023 M2 MacBook and drops to about 40fps on a Snapdragon 888 Android device. Beyond that threshold, you need to move the simulation to the GPU. The visual results of GPU-simulated particles are also qualitatively different — you can handle emergent behaviors like flocking and turbulence that are impractical in single-threaded JS.
GPUComputationRenderer: Simulating 1M+ Particles on the GPU
GPUComputationRenderer is a Three.js addon (lives in three/examples/jsm/misc/GPUComputationRenderer.js) that lets you run fragment shader programs over floating-point textures — effectively using the GPU as a compute device via render-to-texture ping-pong. Each texel stores particle state. The shader reads the current state texture and writes the next state. Your main particle draw call then samples that texture to get positions.
The mental model: instead of a 1D array of positions, you store positions in a 2D RGBA floating-point texture where each pixel = one particle. R, G, B = position x, y, z. A = whatever you want (age, speed, etc.). A 512×512 texture gives you 262,144 particles. A 1024×1024 gives you 1,048,576.
import { GPUComputationRenderer } from 'three/examples/jsm/misc/GPUComputationRenderer.js';
const WIDTH = 512; // 512*512 = 262k particles
const gpuCompute = new GPUComputationRenderer(WIDTH, WIDTH, renderer);
// Create initial position texture
const dtPosition = gpuCompute.createTexture();
const posData = dtPosition.image.data; // Float32Array, 4 channels
for (let i = 0; i < posData.length; i += 4) {
posData[i] = (Math.random() - 0.5) * 200; // x
posData[i + 1] = (Math.random() - 0.5) * 200; // y
posData[i + 2] = (Math.random() - 0.5) * 200; // z
posData[i + 3] = Math.random(); // age
}
// Add a compute variable — a shader that runs every frame
const posVariable = gpuCompute.addVariable(
'texturePosition',
/* glsl */`
uniform float uTime;
uniform float uDelta;
void main() {
vec2 uv = gl_FragCoord.xy / resolution.xy;
vec4 pos = texture2D(texturePosition, uv);
// Simple gravity + upward respawn
pos.y -= 0.1 * uDelta;
pos.w += uDelta * 0.5; // advance age
if (pos.w > 1.0) {
pos.y = 100.0; // respawn at top
pos.w = 0.0;
}
gl_FragColor = pos;
}
`,
dtPosition
);
// Variables can depend on other variables (for velocity textures etc.)
gpuCompute.setVariableDependencies(posVariable, [posVariable]);
posVariable.material.uniforms['uTime'] = { value: 0 };
posVariable.material.uniforms['uDelta'] = { value: 0 };
const error = gpuCompute.init();
if (error !== null) console.error('GPUComputationRenderer error:', error);Then in your particle vertex shader, instead of reading from a BufferAttribute, you sample the GPU texture by reconstructing the UV from gl_VertexID:
// vertex shader
uniform sampler2D texturePosition;
uniform float uWidth; // = 512.0
void main() {
// Compute which texel this vertex maps to
float idx = float(gl_VertexID);
float x = mod(idx, uWidth);
float y = floor(idx / uWidth);
vec2 uv = (vec2(x, y) + 0.5) / uWidth;
vec4 pos = texture2D(texturePosition, uv);
float age = pos.w;
vec4 mvPos = modelViewMatrix * vec4(pos.xyz, 1.0);
gl_PointSize = mix(4.0, 0.5, age) * (300.0 / -mvPos.z);
gl_Position = projectionMatrix * mvPos;
}And in your render loop: update uniforms, call gpuCompute.compute(), then pass the result texture to your particle material:
let lastTime = 0;
function animate(time) {
const delta = (time - lastTime) / 1000;
lastTime = time;
posVariable.material.uniforms.uTime.value = time / 1000;
posVariable.material.uniforms.uDelta.value = delta;
gpuCompute.compute(); // runs the fragment shader, updates the texture
particleMaterial.uniforms.texturePosition.value =
gpuCompute.getCurrentRenderTarget(posVariable).texture;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}Look, this is a meaningful architectural shift. You're no longer touching particle data on the CPU at all — everything lives on the GPU. That's what makes 1M particles feasible. The downside is debuggability: you can't console.log a texel mid-simulation. You learn to debug with color — set gl_FragColor = vec4(someValue, 0, 0, 1) to visualize a single channel visually.
Textures, Blending and Making Particles Look Good
Default gl_PointCoord rendering gives you squares. Nobody wants particle squares. The fix is a soft circular texture — either a small PNG with a radial gradient or a texture you generate procedurally in the fragment shader itself.
Procedural soft circle in GLSL, no external asset required:
// fragment shader
void main() {
vec2 center = gl_PointCoord - vec2(0.5);
float dist = length(center);
float alpha = 1.0 - smoothstep(0.3, 0.5, dist);
if (alpha < 0.01) discard; // skip transparent fragments entirely
gl_FragColor = vec4(vec3(0.5, 0.8, 1.0), alpha * vAlpha);
}The discard call is a real performance win for dense particle fields. Without it, the GPU still runs the full rasterization cost for every invisible fragment. With it, those pixels exit the pipeline early. At 1M particles, that's a measurable frame time difference.
For blending, THREE.AdditiveBlending is the go-to for glowing particle effects. It makes overlapping particles brighten rather than occlude — which is exactly the behavior you want for a nebula, a spell effect, or any sci-fi energy thing. Set it on the material: blending: THREE.AdditiveBlending. Pair it with depthWrite: false (always) and a dark background, and you get that classic luminous particle look with zero extra effort.
Worth noting: additive blending makes particles progressively brighter over a dark background but invisible over a light one. If your design uses a light theme — maybe you're building UI elements that complement Empire UI glassmorphism components on a frosted white surface — switch to THREE.NormalBlending with per-particle alpha fade instead. It's less dramatic but actually visible.
Practical Limits, Gotchas and What to Profile
What count should you actually target? Here's a rough guide based on real profiling rather than benchmarks: 50k particles with CPU simulation runs well everywhere. 200k is fine on desktop, borderline on mobile. 500k needs GPU compute and careful shader optimization. 1M+ requires GPU compute, float texture support checks, and probably a loading screen.
Always check renderer.capabilities.isWebGL2 before using GPUComputationRenderer at high counts — it requires WebGL 2 for float texture support. Most browsers since 2020 support this, but you should still handle the fallback gracefully rather than serving a broken white canvas.
The gl.MAX_TEXTURE_SIZE cap bites people at high particle counts. A 2048×2048 texture holds 4M particles, but some older mobile GPUs cap textures at 2048px. Query it: renderer.capabilities.maxTextureSize. If it's 2048, your 4M setup will silently fail or render garbage.
Profile with Chrome DevTools' Performance panel + the WebGL tab in Spector.js. The two most common culprits for bad frame times: texture uploads (gl.bufferSubData stalls on the CPU) and over-drawing (particle overdraw on dense clusters). The GPU compute approach eliminates the first problem entirely. For the second, tighten your discard threshold in the fragment shader — don't render fragments below alpha 0.01.
That said, if you're building UI components rather than a standalone 3D scene, you don't need to reinvent particle systems from scratch. The gradient generator and box shadow generator tools on Empire UI can help you nail the color palette and glow aesthetics before you write a single line of GLSL — getting the visual direction right first saves you from refactoring your shaders three times.
FAQ
With CPU-side simulation, around 200k–300k on modern desktop hardware. With GPUComputationRenderer offloading physics to the GPU, you can push past 1M — though mobile devices cap out much earlier, typically around 300k–500k even with GPU compute.
You're missing depthWrite: false on the material. Without it, each particle writes to the depth buffer and occludes the fragments behind it, leaving hard rectangular holes. Add depthWrite: false and the blending will work correctly.
Not directly — GPUComputationRenderer is designed for WebGL render-to-texture ping-pong. The WebGPURenderer path in Three.js r168+ has its own compute node system via tsl (Three.js Shading Language) that's more capable but has a different API entirely.
Yes, always. Call geometry.dispose() and material.dispose() when removing particles from the scene. Three.js doesn't garbage-collect GPU resources automatically, and particle systems are particularly prone to VRAM leaks because the typed arrays are large.