Why BufferGeometry, not Geometry
Three.js's legacy Geometry class stored data as JavaScript objects and arrays — convenient but slow, since every frame spent time reading positions, running JS garbage collection on thousands of Vector3 objects, and re-uploading everything to the GPU. BufferGeometry stores data as typed arrays — Float32Array, Uint16Array — contiguous memory blocks handed directly to the GPU as buffer objects. The advantages: zero GC pressure (no JS allocations in the hot path), a GPU-friendly layout (position data interleaved as [x0,y0,z0, x1,y1,z1, …], exactly what WebGL expects), and partial updates — set needsUpdate = true only on the attributes that changed. The legacy Geometry class was removed entirely in Three.js r125 (2021).
Allocating positions once, mutating forever
Allocate the typed array once, before the loop — three floats per particle for (x, y, z). Never create a new array inside the animation loop:
const COUNT = 100_000;
const positions = new Float32Array(COUNT * 3); // ONE allocation, reused every frame
for (let i = 0; i < COUNT; i++) {
const r = 50 * Math.cbrt(Math.random()); // uniform sphere volume density
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
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);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
A common mistake is generating a random point in [−r, +r]³ and rejecting points outside the sphere. That works, but r * Math.cbrt(Math.random()) guarantees uniform volume density rather than surface concentration at the poles. THREE.Points then renders each vertex as a screen-space sprite — the fastest primitive for particles, with no triangles and no indices — and AdditiveBlending makes overlapping particles brighten each other for a classic star or nebula glow.
The animation loop: zero allocations
The golden rule: never allocate inside the animation loop. Declare all temporary variables before requestAnimationFrame begins, then mutate them in place each frame — reading and writing the Float32Array directly, then flipping posAttr.needsUpdate = true once per frame so Three.js knows the buffer changed. The loop modifies roughly 1.2 MB of Float32Array data per frame entirely in JavaScript; for CPU-intensive simulations like SPH fluid or gravity N-body, that logic moves into a GLSL vertex shader instead, letting the GPU update all positions in parallel and eliminating the typed-array copy entirely.
Performance reference
| Approach | 100k particles, desktop | GC pressure |
|---|---|---|
| Float32Array + BufferGeometry | ~0.5 ms/frame CPU | None (after init) |
| Legacy Geometry (Vector3 objects) | ~5-15 ms/frame CPU | High — GC pauses every few seconds |
| GPU compute (transform feedback) | <0.1 ms/frame CPU | None |
For particle counts above ~500k, or simulations that need per-particle physics like collisions, move all simulation logic into a GLSL compute shader (WebGPU) or a vertex shader with transform feedback (WebGL 2) — the CPU then only issues a single draw call and reads nothing back from the GPU.
Frequently asked questions
Why use BufferGeometry instead of the old Three.js Geometry class?
BufferGeometry stores data as typed arrays like Float32Array — contiguous memory blocks handed directly to the GPU as buffer objects — instead of JavaScript objects. This means zero GC pressure (no object allocations during animation), a GPU-friendly interleaved layout, and the ability to update only the attributes that changed. The legacy Geometry class was removed from Three.js entirely in r125 (2021).
Why should you never allocate objects inside the animation loop?
Creating new objects (like new THREE.Color() or a new array) inside requestAnimationFrame triggers garbage collection, which causes visible frame stutters. The fix is to declare all temporary variables once before the loop starts and mutate them in place every frame — for 100,000 particles, allocating one object per particle per frame would create 100,000 objects every 16ms, which no GC can keep up with smoothly.
What does AdditiveBlending do to overlapping particles?
AdditiveBlending makes overlapping particles add their colours together, creating a glow where densely packed particles become bright white — the classic star or nebula look used for fire, sparks and explosions. For opaque particles like sand or smoke where you don't want that brightening effect, THREE.NormalBlending is the appropriate choice instead.
Try it live
Everything above runs in your browser — open Particle System and tune emitter rate, lifetime, gravity and colour ramps to build fire, smoke and explosion effects the way game VFX artists do. Nothing is installed, nothing is uploaded.
▶ Open Particle System simulation