Every time an explosion tears through a battlefield, sparks shower from a grinder, or magical energy crackles around a sorcerer's hands, you are watching a particle system at work. From the campfire in The Legend of Zelda: Breath of the Wild to the screen-filling pyrotechnics of Doom Eternal, particle systems power most real-time visual effects. They work by applying a handful of straightforward rules to thousands of tiny objects at once, producing effects of considerable variety despite this simplicity.
This article covers how particle systems work: the data structures, the mathematics of motion, the rendering tricks, and the performance patterns that let games simulate tens of thousands of tiny objects at 60 frames per second.
The Anatomy of a Particle System
A particle system has three fundamental components: the emitter, which births new particles; the particle pool, which stores and updates their state; and the renderer, which draws them efficiently on screen.
The Emitter
The emitter controls when particles are created, how many, and their initial conditions — starting position, velocity, color, size, and lifetime. Emitters come in many shapes:
- Point emitter: All particles spawn from a single point. Classic for muzzle flashes, sparks, and magical orbs.
- Sphere emitter: Particles spawn at random positions on or inside a sphere. Ideal for explosions and dust clouds.
- Cone emitter: Particles shoot outward within a cone angle. Perfect for fire jets, water streams, and rocket thrusters.
- Box/volume emitter: Particles spawn within a rectangular volume. Used for rain, snow, or fog filling an interior.
- Mesh surface emitter: Particles spawn from the surface of a 3D mesh. Creates effects that hug geometry, like burning armor or sparkling enchanted weapons.
The initial velocity of each particle is computed by taking a base direction and adding random deviation within the emitter's spread angle:
function spawnFromConeEmitter(emitter) {
const angle = Math.random() * Math.PI * 2;
const spread = emitter.spreadAngle; // radians from central axis
const speed = emitter.minSpeed + Math.random() * (emitter.maxSpeed - emitter.minSpeed);
return {
position: { ...emitter.origin },
velocity: {
x: Math.cos(angle) * Math.sin(spread) * speed,
y: Math.cos(spread) * speed,
z: Math.sin(angle) * Math.sin(spread) * speed
},
lifetime: emitter.minLife + Math.random() * (emitter.maxLife - emitter.minLife)
};
}The Particle
Each individual particle is nothing more than a compact record of state. A typical particle stores:
- Position $\vec{p} = (x, y, z)$ — where the particle is in world space
- Velocity $\vec{v} = (v_x, v_y, v_z)$ — how fast and in what direction it moves
- Lifetime — seconds remaining before the particle dies
- Max lifetime — the original lifespan, used to compute the ratio $t = \text{life} / \text{maxLife}$
- Birth and death colors — interpolated over time
- Birth and death sizes — interpolated over time
Rotation, torque, and mass are intentionally absent. Particles are cheap because they are dumb. The illusion of complexity comes from having thousands of them simultaneously, each slightly different.
The Renderer
All particles must be drawn every frame. The key insight is that particles don't need complex geometry — they are rendered as billboarded quads: flat squares that always face the camera, textured with a soft circle or sprite. In WebGL and Three.js this is accomplished with THREE.Points (GL_POINTS), which draws each vertex as a screen-aligned square with configurable size.
Additive blending combined with soft transparent sprites creates the glowing, luminous quality of fire and magic. Where multiple particles overlap, their colors accumulate, just as physical light behaves. Dense clusters become bright hotspots with no additional logic required.
The Mathematics of Particle Motion
Moving a particle is numerical integration of Newton's second law. Each frame, we apply all forces to update velocity, then use velocity to update position. The simplest method is Explicit (Forward) Euler integration:
$$\vec{v}_{n+1} = \vec{v}_n + \vec{a} \cdot \Delta t$$
$$\vec{p}_{n+1} = \vec{p}_n + \vec{v}_{n+1} \cdot \Delta t$$
Where $\vec{a}$ is the net acceleration from all forces and $\Delta t$ is the timestep in seconds. In code:
function updateParticle(p, dt) {
// Apply forces to velocity
p.vy += GRAVITY * dt; // gravity: -9.8 m/s^2
p.vx *= Math.pow(0.97, dt * 60); // frame-rate-independent drag
p.vz *= Math.pow(0.97, dt * 60);
// Integrate position
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
// Age the particle
p.life -= dt;
}Euler integration accumulates error over time, but for particles that live only one to three seconds on average, this is entirely acceptable. Small inaccuracies are imperceptible and even desirable for organic-looking effects.
Forces and Behaviors
The variety of particle effects comes largely from the forces applied during simulation. These are implemented as per-particle accelerations added to velocity each frame.
Gravity
The most universal force. Adding a downward acceleration $\vec{a}_{\text{gravity}} = (0, -9.8, 0)$ makes particles arc naturally. Gravity is often tweaked per-effect — magical particles might rise (negative gravity), while debris gets full gravity for a realistic ballistic arc.
Drag
Without drag, particles would accelerate indefinitely. Drag simulates air resistance by gradually reducing velocity. Using the exponential form makes it frame-rate-independent:
$$\vec{v}_{n+1} = \vec{v}_n \cdot k^{\Delta t}$$
Where $k \in [0.9, 0.99]$ is the drag coefficient. A higher value means less drag (slower decay). At $k = 0.97$ with $\Delta t = 1/60$, velocity drops to about 18% of its original value per second — giving particles a satisfying, snappy deceleration.
Wind and Turbulence
Wind is a constant acceleration in a direction: $\vec{a}_{\text{wind}} = (w_x, w_y, w_z)$. More interesting is turbulence — a spatially-varying force that creates swirling, chaotic motion. The classic technique samples a 3D noise function using the particle's current position as input:
function turbulenceForce(p, time, strength) {
// Sample 3D Perlin noise at particle position + time offset
const nx = noise3D(p.x * 0.1, p.y * 0.1, time * 0.3);
const ny = noise3D(p.y * 0.1, p.z * 0.1, time * 0.3 + 100);
const nz = noise3D(p.z * 0.1, p.x * 0.1, time * 0.3 + 200);
return { x: nx * strength, y: ny * strength, z: nz * strength };
}Turbulence transforms a simple fountain into swirling smoke, or turns a straight energy beam into a crackling field of wild sparks.
Properties Over Lifetime
Static particles look artificial. The key to convincing effects is making every visual property change over the particle's lifetime. The lifetime ratio $t = \text{currentLife} / \text{maxLife}$ (1.0 at birth, 0.0 at death) drives interpolation between birth and death values.
Color Over Lifetime
A fire particle typically starts bright yellow-white, transitions through orange and red, and fades to dark grey. This uses simple linear interpolation (lerp):
$$\vec{C}(t) = \vec{C}_{\text{death}} + (\vec{C}_{\text{birth}} - \vec{C}_{\text{death}}) \cdot t$$
// t = 1 at birth, 0 at death
particle.r = deathColor.r + (birthColor.r - deathColor.r) * t;
particle.g = deathColor.g + (birthColor.g - deathColor.g) * t;
particle.b = deathColor.b + (birthColor.b - deathColor.b) * t;More sophisticated systems use a gradient curve — multiple color stops over the lifetime — allowing complex multi-stage transitions like: white → bright orange → dark red → transparent grey.
Size Over Lifetime
Shrinking a particle as it ages creates a natural fade-out. A typical linear shrink:
$$s(t) = s_{\text{birth}} \cdot t$$
A more organic version causes a brief initial growth followed by a longer decay, mimicking how real puffs of smoke expand before dissipating:
$$s(t) = s_{\text{birth}} \cdot \sin(\pi \cdot (1 - t))^{0.5}$$
In practice, many engines represent these as editable animation curves in their editors (Unity's Particle System, Unreal's Cascade/Niagara), letting artists shape exactly how each property evolves without touching code.
Object Pooling: The Performance Backbone
Creating a new object when a particle is born and deleting it when it dies is too slow at scale. JavaScript's garbage collector, C++'s heap allocator, and Unity's managed heap all struggle with thousands of tiny allocations per second.
The solution is an object pool backed by a pre-allocated flat buffer. Instead of dynamic allocation, you reserve a fixed array of slots upfront and reuse them:
const MAX_PARTICLES = 5000;
// Pre-allocate flat typed arrays for maximum cache efficiency
const positions = new Float32Array(MAX_PARTICLES * 3);
const velocities = new Float32Array(MAX_PARTICLES * 3);
const lifetimes = new Float32Array(MAX_PARTICLES);
const active = new Uint8Array(MAX_PARTICLES); // 0=dead, 1=alive
let poolCursor = 0; // Round-robin index
function spawnParticle(x, y, z, vx, vy, vz, life) {
const i = poolCursor;
poolCursor = (poolCursor + 1) % MAX_PARTICLES; // overwrite oldest if full
const i3 = i * 3;
positions[i3] = x; positions[i3+1] = y; positions[i3+2] = z;
velocities[i3] = vx; velocities[i3+1] = vy; velocities[i3+2] = vz;
lifetimes[i] = life;
active[i] = 1;
}Using typed arrays (Float32Array, Uint8Array) instead of regular JavaScript arrays is critical. Typed arrays store data in contiguous memory blocks, enabling the CPU's cache prefetcher to load data efficiently as the update loop iterates. The allocation cost of spawning a particle drops to effectively zero, and the update loop becomes a tight, cache-friendly scan.
Rendering Particles Efficiently
Once CPU-side positions and colors are updated, the data must reach the GPU every frame. In Three.js, we upload typed arrays as BufferAttributes and set needsUpdate = true. A custom ShaderMaterial enables per-particle size and soft circular rendering:
// Vertex Shader
attribute float aSize;
attribute vec3 aColor;
varying vec3 vColor;
void main() {
vColor = aColor;
vec4 mvPos = modelViewMatrix * vec4(position, 1.0);
// Scale point size by camera distance to maintain world-space size
gl_PointSize = aSize * (350.0 / -mvPos.z);
gl_Position = projectionMatrix * mvPos;
}
// Fragment Shader
varying vec3 vColor;
void main() {
vec2 uv = gl_PointCoord - 0.5;
float d = length(uv);
if (d > 0.5) discard; // clip to circle
float core = 1.0 - smoothstep(0.0, 0.2, d);
float halo = 1.0 - smoothstep(0.2, 0.5, d);
float alpha = core * 0.9 + halo * 0.5;
gl_FragColor = vec4(vColor + core * 0.4, alpha); // bright core
}Setting blend mode to THREE.AdditiveBlending means each particle's color is added to the framebuffer rather than replacing it. Dense particle clusters accumulate brightness, producing natural luminous highlights without any special logic. Additive particles also require no depth sorting, since overlapping additive layers always composite correctly regardless of draw order.
Depth Sorting for Non-Additive Effects
Effects that use standard alpha transparency (smoke, clouds, water spray) must be rendered back-to-front for correct compositing. Sorting $N$ particles is $O(N \log N)$ and can become a bottleneck. Common mitigations include:
- Sorting only every 3–5 frames, accepting minor temporal artifacts
- Approximating sort order using a spatial grid instead of exact distance
- Using a depth-prepass and alpha-to-coverage for certain effects
- Switching to additive blending wherever artistically acceptable
Many studios use a hybrid: additive blending for light-emitting particles (fire, sparks, magic) and sorted alpha for opaque-feeling effects (smoke, snow). This minimizes the sorted set to only the particles that truly require it.
Where Particle Systems Shine: Real Game Examples
- Doom Eternal (id Software): Demon gibs, plasma trails, and BFG impacts use GPU-simulated particles via compute shaders, enabling millions of simultaneous particles without CPU overhead. The chainsaw alone spawns hundreds of blood and bone particles per frame.
- The Legend of Zelda: Breath of the Wild (Nintendo): Subtle particle systems add life to the open world — pollen drifting in forests, embers rising from campfires, snow accumulating on mountain slopes. Parameters are tuned to feel painterly rather than physically accurate.
- Diablo IV (Blizzard): A single necromancer ability might compose ten separate emitters — bone fragments, blood spray, dark energy wisps, and ground impact dust — each with distinct lifetimes and forces, layered together into a cohesive visual.
- Minecraft (Mojang): Even stylized low-poly games use particles extensively. Breaking blocks spawns colored square particles; redstone emits red sparkles; fire produces simple animated quads. Particle systems adapt to any aesthetic.
- Halo Infinite (343 Industries): Plasma and energy weapon trails are path-integrated — each particle's position is computed from the weapon's trajectory during the frame interval, ensuring the trail precisely follows the projectile even at extreme speeds.
Putting It All Together
A complete particle system update loop integrating all the concepts above:
function updateParticleSystem(system, dt) {
// 1. Emit new particles
system.emitAccumulator += system.emissionRate * dt;
const toSpawn = Math.floor(system.emitAccumulator);
system.emitAccumulator -= toSpawn;
for (let i = 0; i < toSpawn; i++) spawnFromEmitter(system.emitter);
// 2. Update all active particles
for (let i = 0; i < MAX_PARTICLES; i++) {
if (!active[i]) continue;
// Forces
velocities[i*3+1] += GRAVITY * dt;
velocities[i*3] *= Math.pow(0.97, dt * 60);
velocities[i*3+2] *= Math.pow(0.97, dt * 60);
// Integrate
positions[i*3] += velocities[i*3] * dt;
positions[i*3+1] += velocities[i*3+1] * dt;
positions[i*3+2] += velocities[i*3+2] * dt;
// Age
lifetimes[i] -= dt;
if (lifetimes[i] <= 0) { active[i] = 0; sizes[i] = 0; continue; }
// Properties over lifetime
const t = lifetimes[i] / maxLifetimes[i];
colors[i*3] = lerp(deathColor.r, birthColor.r, t);
colors[i*3+1] = lerp(deathColor.g, birthColor.g, t);
colors[i*3+2] = lerp(deathColor.b, birthColor.b, t);
sizes[i] = birthSizes[i] * t;
}
// 3. Upload to GPU
positionAttribute.needsUpdate = true;
colorAttribute.needsUpdate = true;
sizeAttribute.needsUpdate = true;
}This loop drives every particle effect in a real-time game. Modern AAA particle VFX achieves its sophistication through parameter depth: dozens of curves, forces, sub-emitters, collision responses, and shader effects layered atop this same basic structure, not fundamentally different algorithms.
Particle systems reward experimentation. Tweak the gravity constant and watch the physics personality change. Adjust the lifetime and observe the visual density shift. Change the blend mode from additive to alpha and see how the layering transforms. Simple parameters, complex interactions.
Comments