Three.js Particle System Tutorial
How to render 100,000 animated particles at 60 fps in
the browser — using BufferGeometry and
Float32Array
to keep all particle data on the GPU without touching the JavaScript
garbage collector once per frame.
Why BufferGeometry (not Geometry)
Three.js has two ways to define geometry. The legacy
Geometry
class stores data as JavaScript objects and arrays — you could write
geometry.vertices.push(new THREE.Vector3(...)). It was
convenient but slow: every frame spent on reading positions from the
GPU, running JS GC on thousands of Vector3 objects, and re-uploading to
the GPU.
BufferGeometry stores data as typed arrays —
Float32Array, Uint16Array, etc. — which are
contiguous memory blocks
that can be handed directly to the GPU as buffer objects. The
advantages:
- Zero GC pressure: no JavaScript object allocations in the hot path — the GC never sees particle data during animation.
-
GPU-friendly layout: position data is interleaved as
[x0,y0,z0, x1,y1,z1, …]— exactly the layout OpenGL/WebGL expects. -
Partial updates: set
needsUpdate = trueonly on the attributes you changed; unchanged data stays on the GPU.
Geometry class was
removed in Three.js r125 (2021). All modern Three.js code uses
BufferGeometry.
Project Setup
Import Three.js from a CDN or install via npm:
Minimal HTML scaffold:
Allocating Positions with Float32Array
Allocate the typed array once before the loop. Three floats per particle (x, y, z). Never create a new array inside the animation loop.
r * Math.cbrt(Math.random()) ensures uniform
volume
density rather than surface concentration at the poles.
PointsMaterial and the Points Object
THREE.Points renders each vertex as a screen-space sprite —
the fastest primitive for particles; no triangles, no indices.
PointsMaterial controls size and colour.
AdditiveBlending means overlapping particles add their
colours together, creating a "glow" where densely packed particles
become bright white — the classic star/nebula look. Use
THREE.NormalBlending for opaque particles (e.g. sand,
smoke).
Per-Particle Vertex Colours
Add a color attribute with three floats per particle (r, g,
b in the range 0–1) and enable vertexColors: true:
_color object is allocated once outside the loop.
setHSL() mutates it in place. Creating
new THREE.Color() inside the loop would allocate 100,000
objects — triggering GC during the next frame.
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.
The loop above modifies ~1.2 MB of Float32Array data per frame entirely in JavaScript. For CPU-intensive simulations (SPH fluid, gravity N-body) you would move this logic into a GLSL vertex shader, letting the GPU update all positions in parallel and eliminating the typed array copy entirely.
Custom Shader: Circular Sprites
By default, PointsMaterial renders square billboards. A
custom shader that discards fragments outside a circle gives round
particles:
dot(uv, uv) and
comparing against 0.25 (which is 0.5²) avoids a
sqrt() call per fragment. Fragment shaders run for every
rasterised pixel, so small per-fragment optimisations multiply across
millions of invocations.
Performance Reference
| Approach | 100k particles, dGPU desktop | 100k particles, mobile | GC pressure |
|---|---|---|---|
| Float32Array + BufferGeometry (this tutorial) | ~0.5 ms/frame CPU | ~2–5 ms/frame | None (after init) |
| Legacy Geometry (Vector3 objects) | ~5–15 ms/frame CPU | Not viable | High — GC pauses every few seconds |
| GPU compute (compute shaders / transform feedback) | <0.1 ms/frame CPU | <1 ms/frame | None |
For particle counts above ~500k, or simulations that require per-particle physics (gravity, collisions, SPH), 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.
Next Steps
-
Texture sprites: Pass a
maptexture toPointsMaterialfor smoke, fire, or star circle sprites pre-rendered in Canvas. -
Instanced mesh: If each particle needs a full 3D mesh
(sphere, box), use
THREE.InstancedMesh— one draw call for all instances. See the Instanced Mesh tutorial. -
GPU particles: Use WebGPU
(
THREE.WebGPURenderer, r160+) and WGSL compute shaders to simulate millions of particles entirely on the GPU. -
React Three Fiber: If you are using React, r3f wraps
Three.js declaratively. All the same
BufferGeometry/Float32Arrayprinciples apply.