Building a Particle System in Three.js From Scratch

Introduction
A particle system is a list of tiny objects. Each object has properties: position, size, color, speed, lifetime. Every frame you spawn new ones, update existing ones, kill dead ones, and push the data to the GPU. That is the whole idea.
Three.js does not have a built-in particle system. It gives you THREE.Points, which draws vertices as screen-facing squares. You build everything else yourself.
1. The particle data structure
A particle is just a plain object. No class needed.
{
position: new THREE.Vector3(0, 0, 0),
velocity: new THREE.Vector3(0, 5, 0),
size: 4.0,
colour: new THREE.Color(1, 1, 1),
alpha: 1.0,
rotation: 0,
life: 5.0,
maxLife: 5.0,
}
life counts down every frame. When it hits zero, the particle is removed. maxLife stores the original value so you can compute how far along the particle is in its lifetime.
2. The geometry and material
Three.js needs a BufferGeometry to hold per-particle data, and a ShaderMaterial to render it.
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute([], 3));
geometry.setAttribute("size", new THREE.Float32BufferAttribute([], 1));
geometry.setAttribute("colour", new THREE.Float32BufferAttribute([], 4));
geometry.setAttribute("angle", new THREE.Float32BufferAttribute([], 1));
const material = new THREE.ShaderMaterial({
uniforms: {
diffuseTexture: { value: new THREE.TextureLoader().load("particle.png") },
pointMultiplier: {
value:
window.innerHeight / (2.0 * Math.tan((0.5 * fov * Math.PI) / 180.0)),
},
},
vertexShader: vertexShader,
fragmentShader: fragmentShader,
blending: THREE.AdditiveBlending,
depthTest: true,
depthWrite: false,
transparent: true,
vertexColors: true,
});
const points = new THREE.Points(geometry, material);
scene.add(points);
Each attribute is a flat array. position has 3 floats per particle (x, y, z). colour has 4 (r, g, b, alpha). size and angle have 1 each.
The pointMultiplier uniform makes particle sizes scale correctly with perspective. Without it, sizes would be in raw screen pixels and would not shrink with distance.
depthWrite: false is important. Particles are transparent. If they wrote to the depth buffer, a transparent particle could block another particle behind it. Setting this to false prevents that.
AdditiveBlending makes overlapping particles add their colors together. This gives a glowing look, good for fire. Use THREE.NormalBlending for smoke instead.
3. The shaders
Two small GPU programs. The vertex shader runs per particle. The fragment shader runs per pixel.
Vertex shader:
uniform float pointMultiplier;
attribute float size;
attribute float angle;
attribute vec4 colour;
varying vec4 vColour;
varying vec2 vAngle;
void main() {
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mvPosition;
gl_PointSize = size * pointMultiplier / gl_Position.w;
vAngle = vec2(cos(angle), sin(angle));
vColour = colour;
}
gl_PointSize sets how many pixels wide the point is on screen. Dividing by gl_Position.w makes far particles smaller.
vAngle passes the rotation to the fragment shader as precomputed cos and sin values.
Fragment shader:
uniform sampler2D diffuseTexture;
varying vec4 vColour;
varying vec2 vAngle;
void main() {
vec2 coords = (gl_PointCoord - 0.5) * mat2(vAngle.x, vAngle.y, -vAngle.y, vAngle.x) + 0.5;
gl_FragColor = texture2D(diffuseTexture, coords) * vColour;
}
gl_PointCoord gives you the UV coordinates within the point square, from 0 to 1. The mat2(...) line rotates those coordinates by the particle's angle. Then it samples the texture and multiplies by the particle's color and alpha.
4. Spawning particles
Each frame, you spawn a certain number of particles. Use an accumulator to handle fractional counts across frames.
_AddParticles(dt) {
this._spawnAccumulator += dt;
const rate = 75.0; // particles per second
const n = Math.floor(this._spawnAccumulator * rate);
this._spawnAccumulator -= n / rate;
for (let i = 0; i < n; i++) {
const life = (Math.random() * 0.75 + 0.25) * 10.0;
this._particles.push({
position: new THREE.Vector3(
(Math.random() * 2 - 1) * 1.0,
(Math.random() * 2 - 1) * 1.0,
(Math.random() * 2 - 1) * 1.0),
size: (Math.random() * 0.5 + 0.5) * 4.0,
colour: new THREE.Color(),
alpha: 1.0,
life: life,
maxLife: life,
rotation: Math.random() * 2.0 * Math.PI,
velocity: new THREE.Vector3(0, -15, 0),
});
}
}
The accumulator trick is necessary because frames are not evenly spaced. If a frame takes 16ms you might need 1 particle. If it takes 32ms you might need 2. The accumulator handles the remainder cleanly.
5. Animating with splines
This is where it gets interesting. You want a particle to change color, size, and opacity over its lifetime. You define these as keyframes.
A LinearSpline holds a list of (time, value) pairs and interpolates between them.
class LinearSpline {
constructor(lerp) {
this._points = [];
this._lerp = lerp;
}
AddPoint(t, d) {
this._points.push([t, d]);
}
Get(t) {
let p1 = 0;
for (let i = 0; i < this._points.length; i++) {
if (this._points[i][0] >= t) break;
p1 = i;
}
const p2 = Math.min(this._points.length - 1, p1 + 1);
if (p1 === p2) return this._points[p1][1];
return this._lerp(
(t - this._points[p1][0]) / (this._points[p2][0] - this._points[p1][0]),
this._points[p1][1],
this._points[p2][1],
);
}
}
The lerp parameter is a function that knows how to blend between two values. For numbers it is simple addition. For colors you use Three.js's built-in Color.lerp.
Define the curves:
// Alpha: fade in fast, hold, fade out
const alphaSpline = new LinearSpline((t, a, b) => a + t * (b - a));
alphaSpline.AddPoint(0.0, 0.0);
alphaSpline.AddPoint(0.1, 1.0);
alphaSpline.AddPoint(0.6, 1.0);
alphaSpline.AddPoint(1.0, 0.0);
// Color: yellow to red
const colourSpline = new LinearSpline((t, a, b) => a.clone().lerp(b, t));
colourSpline.AddPoint(0.0, new THREE.Color(0xffff80));
colourSpline.AddPoint(1.0, new THREE.Color(0xff8080));
// Size: start small, peak in the middle, shrink
const sizeSpline = new LinearSpline((t, a, b) => a + t * (b - a));
sizeSpline.AddPoint(0.0, 1.0);
sizeSpline.AddPoint(0.5, 5.0);
sizeSpline.AddPoint(1.0, 1.0);
The t value here is the particle's normalized age. 0 = just born. 1 = about to die.
6. Updating particles
Every frame, update each particle's state.
_UpdateParticles(dt) {
// Age and kill
for (let p of this._particles) {
p.life -= dt;
}
this._particles = this._particles.filter(p => p.life > 0.0);
// Animate
for (let p of this._particles) {
const t = 1.0 - p.life / p.maxLife;
p.alpha = this._alphaSpline.Get(t);
p.currentSize = p.size * this._sizeSpline.Get(t);
p.colour.copy(this._colourSpline.Get(t));
p.rotation += dt * 0.5;
// Move
p.position.add(p.velocity.clone().multiplyScalar(dt));
// Drag (slows particles down over time)
const drag = p.velocity.clone();
drag.multiplyScalar(dt * 0.1);
drag.x = Math.sign(p.velocity.x) * Math.min(Math.abs(drag.x), Math.abs(p.velocity.x));
drag.y = Math.sign(p.velocity.y) * Math.min(Math.abs(drag.y), Math.abs(p.velocity.y));
drag.z = Math.sign(p.velocity.z) * Math.min(Math.abs(drag.z), Math.abs(p.velocity.z));
p.velocity.sub(drag);
}
// Sort back-to-front for correct transparency
this._particles.sort((a, b) => {
const d1 = this._camera.position.distanceTo(a.position);
const d2 = this._camera.position.distanceTo(b.position);
return d2 - d1;
});
}
The drag calculation subtracts a fraction of velocity each frame. The Math.min and Math.sign clamping prevents drag from reversing the direction. It only slows down, never pushes back.
Sorting is critical. Transparent objects must be drawn back-to-front. If a far particle draws after a close one, it overwrites the close one visually. Sorting fixes this.
7. Pushing data to the GPU
After updating, rebuild the buffer arrays and send them to the geometry.
_UpdateGeometry() {
const positions = [];
const sizes = [];
const colours = [];
const angles = [];
for (let p of this._particles) {
positions.push(p.position.x, p.position.y, p.position.z);
colours.push(p.colour.r, p.colour.g, p.colour.b, p.alpha);
sizes.push(p.currentSize);
angles.push(p.rotation);
}
this._geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
this._geometry.setAttribute('size', new THREE.Float32BufferAttribute(sizes, 1));
this._geometry.setAttribute('colour', new THREE.Float32BufferAttribute(colours, 4));
this._geometry.setAttribute('angle', new THREE.Float32BufferAttribute(angles, 1));
this._geometry.attributes.position.needsUpdate = true;
this._geometry.attributes.size.needsUpdate = true;
this._geometry.attributes.colour.needsUpdate = true;
this._geometry.attributes.angle.needsUpdate = true;
}
This runs every frame. You are rebuilding the arrays and telling Three.js to re-upload them to the GPU.
8. The main loop
Tie it all together. Every frame: spawn, update, push.
Step(dt) {
this._AddParticles(dt);
this._UpdateParticles(dt);
this._UpdateGeometry();
}
Call this from your requestAnimationFrame loop with dt in seconds.
What to tweak for different effects
Fire. Additive blending. Yellow-to-red color spline. Fast fade-in, slow fade-out.
Smoke. Normal blending. Gray color. Slow upward velocity. High drag. Particles grow over their lifetime.
Explosion. Spawn all particles at once instead of continuously. Velocity pointing outward in random directions. Short lifetime.
Sparks. Very small size. High initial velocity. Minimal drag. Long lifetime. Bright white or yellow.
Snow. Normal blending. White. Slow downward velocity. Slight random horizontal drift. No drag.
The system is the same in every case. You change the splines, spawn behavior, velocity, drag, blend mode, and texture. That is all.






