Particle systems in the browser, a first tour
A particle field looks like thousands of things moving independently, but in WebGL it is usually one object: a list of points, a small program that decides where each one sits and how it looks, and a few numbers that change every frame. This tour builds the smallest honest version, a field of points that morphs from a sphere into a ring, and uses it to explain the ideas that matter when you scale up. The example was run in headless Chrome against three.js 0.186.1 (software rendering) on 2026-10-17: it compiled without shader errors, drew 12,000 points in a single draw call and reported no WebGL error.
The parts
- Geometry: a list of vertices. For particles, each vertex is one particle. Besides position, a vertex can carry other numbers (attributes), such as a seed, a size or a second position.
- Points: a draw mode. Instead of triangles, the GPU draws each vertex as a square sprite. The vertex shader writes its side length into
gl_PointSize, which Khronos defines as measured in pixels. - Vertex shader: runs once per particle per frame and decides position and size.
- Fragment shader: runs once per pixel of the sprite and decides colour and transparency.
gl_PointCoordruns from 0 to 1 across the sprite, so a soft round dot is a fade based on distance from its centre. - Uniforms: values shared by every particle, such as time or a morph amount.
The key economy: a Points object with one material is drawn as one batch, so going from 100 particles to 100,000 adds vertex work and covered pixels, not draw calls. MDN's WebGL best practices make the same point in general form: fewer, larger draw calls tend to perform better than many small ones. Two things break the economy. The three.js docs say each geometry group is rendered in a separate draw call, so avoid groups and material arrays here. And every extra Points object adds its own call.
Morph targets: pick positions from shapes
A morph does not need animation data. You decide a position for every particle in shape A and another for the same particle in shape B, store both, and mix between them. The only real work is sampling a shape, which means generating N points that fill it.
For a sphere surface, a uniform height and a uniform angle give evenly spread points (picking latitude and longitude uniformly would crowd points at the poles). For a ring, pick an angle and a radius inside a band:
function sampleSphere(n, radius) {
const out = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
const u = Math.random() * 2 - 1; // height, -1..1
const a = Math.random() * Math.PI * 2; // angle
const s = Math.sqrt(1 - u * u);
out[i * 3] = radius * s * Math.cos(a);
out[i * 3 + 1] = radius * u;
out[i * 3 + 2] = radius * s * Math.sin(a);
}
return out;
}
function sampleRing(n, radius, thickness) {
const out = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const r = radius + (Math.random() - 0.5) * thickness;
out[i * 3] = r * Math.cos(a);
out[i * 3 + 1] = r * Math.sin(a);
out[i * 3 + 2] = (Math.random() - 0.5) * thickness;
}
return out;
}
The same pattern works for any shape you can describe. For a logo, render it to a hidden 2D canvas, read the pixels and keep the coordinates of the filled ones. For a 3D model, pick random points on its triangles, weighted by triangle area. Always produce exactly N points per shape so that index i in one shape corresponds to index i in the other. If a shape has fewer natural candidates than N, reuse some with a small random offset rather than leaving gaps.
The minimal example
This block uses the two sampling functions above and a canvas with id="field". Each line of the shader is annotated below the code.
import * as THREE from 'three';
const COUNT = 12000;
const canvas = document.querySelector('#field');
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
renderer.setSize(innerWidth, innerHeight, false);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
camera.position.z = 6;
const seeds = Float32Array.from({ length: COUNT }, Math.random);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(sampleSphere(COUNT, 1.6), 3));
geometry.setAttribute('aTarget', new THREE.BufferAttribute(sampleRing(COUNT, 2.2, 0.4), 3));
geometry.setAttribute('aSeed', new THREE.BufferAttribute(seeds, 1));
const material = new THREE.ShaderMaterial({
uniforms: {
uMix: { value: 0 },
uSize: { value: 18 },
uPixelRatio: { value: renderer.getPixelRatio() },
},
vertexShader: `
uniform float uMix, uSize, uPixelRatio;
attribute vec3 aTarget;
attribute float aSeed;
varying float vAlpha;
void main() {
float t = clamp(uMix * 1.4 - aSeed * 0.4, 0.0, 1.0); // staggered per particle
t = t * t * (3.0 - 2.0 * t); // smoothstep ease
vec4 mv = modelViewMatrix * vec4(mix(position, aTarget, t), 1.0);
gl_PointSize = uSize * uPixelRatio / -mv.z; // nearer = bigger
gl_Position = projectionMatrix * mv;
vAlpha = 0.35 + 0.65 * aSeed;
}`,
fragmentShader: `
varying float vAlpha;
void main() {
float d = length(gl_PointCoord - 0.5);
float a = (1.0 - smoothstep(0.0, 0.5, d)) * vAlpha; // soft round dot
gl_FragColor = vec4(0.35, 0.60, 1.0, a);
}`,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
const points = new THREE.Points(geometry, material);
points.frustumCulled = false; // the shader moves points; see below
scene.add(points);
let target = 0;
addEventListener('scroll', () => {
target = scrollY / (document.documentElement.scrollHeight - innerHeight);
});
renderer.setAnimationLoop(() => {
const u = material.uniforms.uMix;
u.value += (target - u.value) * 0.08; // ease toward the scroll position
points.rotation.y += 0.0015;
renderer.render(scene, camera);
});
What to notice:
positionis built in. The ShaderMaterial docs say built-in attributes and uniforms are passed to the shaders along with your code (RawShaderMaterial is the variant without them). You declare only your own attributes and uniforms.- The morph is one
mix(). All 12,000 particles move when a single number,uMix, changes. Per the same docs, uniforms are refreshed every frame, so assigning a newvalueis all the update needs. - The stagger is a seed. Subtracting a per-particle value from the global progress makes particles leave at different moments, which turns a flat crossfade into something that reads as a swarm.
- Size attenuation is a division. Dividing by
-mv.z(distance in front of the camera) makes near points larger. WithuSize18, a pixel ratio of 1.75 and a camera 6 units away, a point at the origin is 18 × 1.75 / 6 = 5.25 device pixels wide. - The smoothstep arguments run low to high. The Khronos reference page says
smoothstepresults are undefined ifedge0is greater than or equal toedge1, so the fade is written as1.0 - smoothstep(0.0, 0.5, d)and notsmoothstep(0.5, 0.0, d). - Point size has a ceiling. The hardware range is readable with
gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE). MDN lists a minimum of 1 to 100 as what effectively all systems support, so design for sprites below 100 pixels. The headless run above reported a maximum of 1023, which is a property of that software renderer, not a number to rely on. frustumCulled = false. three.js tests an object against the view with the bounding sphere of its geometry, which it computes from thepositionattribute (theFrustum.intersectsObjectmethod in the three.js source does exactly this, and the BufferGeometry docs say the engine computes the sphere on demand). Here the sphere has radius 1.6 while the ring reaches about 2.4, and the shader's movement is invisible to that test. Disable culling, or give the geometry a bounding sphere that covers both shapes.
Additive blending and its cost
Normal blending replaces what is behind a pixel. Additive blending adds the sprite's colour to it, so overlapping points get brighter, which is why it suits glow, stars and energy. The Material docs note that blending is set per material and that depthWrite controls whether the material writes to the depth buffer. Two consequences:
- Order stops mattering and depth writes stop helping. Addition gives the same sum in any order, so you do not need sorting, and
depthWrite: falsestops one sprite from hiding the ones behind it. The cost is that every pixel of every sprite is processed. - Overdraw is the real budget. Count fragments, not points. The fragment shader runs for every pixel of the square sprite, round or not. At the 5.25-pixel size above, one particle covers about 27.6 pixels, so 12,000 particles are about 330,000 fragments. Raise each sprite to 35 device pixels (20 CSS pixels at a ratio of 1.75) and one particle covers 1,225 pixels; 12,000 of them are 14.7 million fragments, about 3.7 times the 3.97 million pixels of a 1440 by 900 viewport at that ratio. The computed numbers are exact; whether a given GPU copes is something to measure. Same draw call, a very different frame time.
MDN gives one more lever: render into a smaller back buffer and let the browser scale it up. For a soft glow that costs almost no visible quality, and capping the pixel ratio as the example does is the same idea.
The additive sum also changes colour. Channels clip at 1, so bright overlaps drift toward white, and for badly chosen blues toward violet. A blue palette that does not turn purple covers choosing a base colour that clips toward cyan instead.
GPU or CPU updates
| GPU (shader) | CPU (JavaScript) | |
|---|---|---|
| Motion defined by | attributes, time, uniforms | any logic, any data |
| Per-frame cost | one uniform write | write the array, then upload it |
| Data uploaded | a few bytes | the whole attribute unless you update ranges: 144,000 bytes for 12,000 xyz floats |
| Particles interact | not without extra passes | yes |
| Good for | morphs, swirls, noise, waves, parallax | simulation from live data, collisions, user-drawn paths |
If motion can be written as a function of the particle's own attributes plus time, put it in the vertex shader. MDN's advice is the same in general: do as much work as you can in the vertex shader rather than the fragment shader, because fragment shaders generally run many more times per draw call. If particles need accumulated state (velocity integration, flocking), keep that state in textures and update it with extra render passes. three.js ships GPUComputationRenderer in its examples for this; its documentation describes RGBA float textures with ping-pong render targets. MDN warns not to assume you can render into float textures on every device, so test the fallback path.
On the CPU, mutate the existing Float32Array and set attribute.needsUpdate = true. The BufferAttribute docs offer two refinements: usage can be set to DynamicDrawUsage to declare that the data changes often, and addUpdateRange(start, count) marks only part of the array for upload. Both are worth trying before concluding that a CPU path is too slow.
Choosing a count: draw range and tiers
Hard-coding 12,000 is a decision for one machine. Allocate the largest count once and let a tier decide how many to draw:
const TIERS = { low: 3000, mid: 8000, high: 12000 };
const tier = 'mid'; // chosen from a capability check or a measured frame time
geometry.setDrawRange(0, TIERS[tier]);
setDrawRange(start, count) is documented on BufferGeometry: for non-indexed geometry, count is the number of vertices drawn. Nothing is reallocated, so switching tiers at runtime, for example after measuring a slow frame, costs almost nothing. One consequence: the first 3,000 particles must already form the whole shape. The random sampling above satisfies that. A logo sampled by scanning pixel rows does not, and would show only its top at the low tier, so shuffle those samples first.
A related trick is a second, much smaller Points object for foreground sparkle with larger sprites. It adds a call, but separates the few expensive big sprites from the many cheap small ones, so you can drop the big ones first.
When it goes wrong
| Symptom | Likely cause | Check |
|---|---|---|
| Nothing draws, no error | gl_PointSize ends up at 0 or the camera sits at the wrong distance | Log uSize and the camera position; try a constant size such as gl_PointSize = 8.0; |
| The whole field vanishes at some camera angles | The object is culled by its bounding sphere | Set frustumCulled = false |
| Dots are squares | The fragment shader never fades the corners | Use gl_PointCoord and an alpha that reaches 0 at the edge |
| Near points look smaller than the maths says | The size was capped by the hardware range | Read ALIASED_POINT_SIZE_RANGE; the PointsMaterial docs say size might be capped by it |
| Frame time rises as the camera moves closer | Closer points are larger, so overdraw grows | Clamp gl_PointSize with min() in the shader |
| The centre of a cluster turns white | Additive channels clip at 1 | Lower the alpha or the particle count, or choose a base colour that clips well |
The clamp in the fifth row is one line, gl_PointSize = min(gl_PointSize, 64.0);, and it makes the worst case a design decision instead of an accident.
Motion, power and fallbacks
A canvas that runs forever drains batteries and can bother people sensitive to motion. MDN notes that requestAnimationFrame callbacks are paused in most browsers for background tabs, which covers the hidden-tab case. A canvas scrolled out of view in a visible tab keeps drawing, so stop the loop yourself with renderer.setAnimationLoop(null) (in the three.js source, passing null stops the animation driver) when an IntersectionObserver reports it off screen, and restart it when it returns. Under reduced motion, render a single still frame, and give the page a designed fallback when WebGL is unavailable. The details are in prefers-reduced-motion, done properly, and the numbers that decide your particle count are in a performance budget for a site with WebGL.
Checklist
- One
Pointsobject, one material, no groups; the same number of samples per shape, in an order where any prefix still shows the whole shape. - Do the motion in the vertex shader when it depends only on attributes, time and uniforms.
- Keep sprites small, cap the pixel ratio and clamp
gl_PointSize; measure fragments. - Set
frustumCulled = false(or a covering bounding sphere) for shader-moved geometry. - Pause when off screen, show a still frame for reduced motion, and keep a designed fallback without WebGL.
Questions and answers
How many particles can a browser handle?
No single number holds. The cost depends on the GPU, on how many pixels each point covers and on the blending, as well as on the count. Choose a count per device tier, start low, and measure on the weakest device you support before raising it.
Is a Points object one draw call?
One Points object with a single material is drawn without splitting its vertices into groups, so the number of calls does not grow with the particle count. The three.js docs say geometry groups are rendered in separate draw calls, so avoid groups and material arrays on a particle field. The cost then sits in vertex work and in the pixels the sprites cover.
Should particles move on the CPU or the GPU?
Prefer the GPU when each particle's motion can be computed from its own attributes, a time value and a few uniforms. Use the CPU when particles must read each other's state or when the data comes from outside, and budget for re-uploading the changed buffer.
Why do my particles disappear when the camera moves?
three.js decides whether to draw an object from the bounding sphere of its geometry, which is computed from the position attribute. A vertex shader that moves points outside that sphere is invisible to this test, so the whole object can be skipped while still on screen. Set frustumCulled to false or recompute the sphere to cover the final shape.
Sources
- three.js docs: Points threejs.org
- three.js docs: BufferGeometry threejs.org
- three.js docs: ShaderMaterial threejs.org
- three.js docs: Material threejs.org
- MDN: WebGL best practices developer.mozilla.org