Particles
Spawn and tune particle systems for environmental and reactive effects.
Particles
ParticleSystem is a Drawable that manages thousands of animated sprites with data-oriented performance. Instead of creating and destroying individual Sprite instances per particle, the system stores particle state in parallel typed arrays (Struct-of-Arrays) and mutates them in bulk. This keeps the work per-particle extremely lean — no allocations, no GC pressure, no per-sprite transform tree overhead.
Note:
ParticleSystemships as an official ExoJS extension package. Install@codexo/exojs-particlesalongside@codexo/exojs:npm install @codexo/exojs @codexo/exojs-particles
The mental model: you register modules that describe how particles spawn, what they do over their lifetime, and what happens when they die. The system calls those modules each frame against its internal arrays. You never write a per-particle update loop.
Setup
Register the extension when creating your Application:
import { Application } from '@codexo/exojs';
import { particlesExtension } from '@codexo/exojs-particles';
const app = new Application({ extensions: [particlesExtension] });
Or use the /register convenience import for global registration:
import '@codexo/exojs-particles/register';
import { Application } from '@codexo/exojs';
const app = new Application(); // picks up particlesExtension automatically
Construction
A particle system needs a texture and a capacity:
import { BlendModes, Texture } from '@codexo/exojs';
import { ParticleSystem } from '@codexo/exojs-particles';
const system = new ParticleSystem(loader.get(Texture, 'spark'), {
capacity: 4000,
});
capacity (default 4096) is the maximum number of particles the system can have alive at once. It’s fixed at construction — the backing typed arrays are allocated immediately.
The system is a Drawable, so it has a position, rotation, scale, tint, blend mode, and participates in the scene graph like any sprite:
system.setPosition(400, 500);
system.setBlendMode(BlendModes.Additive);
Particle positions are local to the system — setting the system’s position moves the whole emitter.
Spawn modules
A spawn module creates new particles each frame. Two built-in spawners cover the common cases:
RateSpawn — continuous emission at a configurable rate (particles per second):
import { ConeDirection, Constant, RateSpawn, Range, Vector } from '@codexo/exojs-particles';
system.addSpawnModule(new RateSpawn({
rate: new Constant(180), // 180 particles / second
lifetime: new Range(0.6, 1.4), // random lifetime in seconds
velocity: new ConeDirection(-Math.PI / 2, Math.PI / 5, 70, 180),
scale: new Constant(new Vector(0.35, 0.35)),
}));
BurstSpawn — named bursts at scheduled times, with optional looping:
import { BurstSpawn, ConeDirection, Constant, Range, Vector } from '@codexo/exojs-particles';
const burst = new BurstSpawn({
schedule: [{ time: 0, count: 100 }], // 100 particles at t=0
lifetime: new Range(0.5, 1.2),
velocity: new ConeDirection.omni(80, 240), // full 360° spread
scale: new Constant(new Vector(0.4, 0.4)),
});
system.addSpawnModule(burst);
// Re-trigger the burst schedule from t=0
burst.reset();
Every spawn config property that takes a value — lifetime, position, velocity, scale, rotation, rotationSpeed, tint, textureIndex — accepts a Distribution<T> rather than a fixed value. Distributions are sampled per-particle at spawn time.
Distributions
Distributions let each spawned particle get a different value. The most commonly used:
| Distribution | What it produces |
|---|---|
Constant(value) | The same value every time |
Range(min, max) | Uniform random number in [min, max] |
VectorRange(xMin, xMax, yMin, yMax) | Independent uniform random per axis |
ConeDirection(angle, halfAngle, minSpeed, maxSpeed) | Velocity vector within a directional cone |
ConeDirection.omni(minSpeed, maxSpeed) | Full 360° omnidirectional velocity |
Curve(keys) | Piecewise-linear spline evaluated over a particle’s lifetime normalised progress (0..1) |
ColorGradient(keys) | Same as Curve but interpolates Color values |
Curve and ColorGradient are LifetimeFunction<T> rather than Distribution<T> — they’re evaluated with a normalised lifetime t in 0..1, not sampled at spawn. They’re typically used with update modules, not spawn modules.
Update modules
Update modules mutate particle state each frame. Every built-in update module that works on both backends declares a wgsl() contribution — when a WebGPU backend is active and every registered update module is GPU-eligible, the system auto-compiles a composite WGSL compute shader and runs the full update pipeline on the GPU in a single dispatch. When any module lacks a wgsl() contribution, or when running on WebGL2, the system falls back to CPU. You don’t configure this — it’s automatic.
The frequently-used update modules:
import { Color } from '@codexo/exojs';
import {
AlphaFadeOverLifetime,
ApplyForce,
ColorOverLifetime,
ColorGradient,
Curve,
Drag,
ScaleOverLifetime,
Turbulence,
} from '@codexo/exojs-particles';
// Constant acceleration (gravity, wind)
system.addUpdateModule(new ApplyForce(0, 240));
// Speed-based drag
system.addUpdateModule(new Drag(0.1));
// Fade alpha over lifetime (requires a Curve)
system.addUpdateModule(new AlphaFadeOverLifetime(
new Curve([{ t: 0, v: 1 }, { t: 1, v: 0 }])
));
// Full color interpolation over lifetime
system.addUpdateModule(new ColorOverLifetime(
new ColorGradient([
{ t: 0, color: new Color(255, 200, 100, 1) },
{ t: 1, color: new Color(0, 0, 0, 0) },
])
));
// Animated scale
system.addUpdateModule(new ScaleOverLifetime(
new Curve([{ t: 0, v: 0.5 }, { t: 0.3, v: 1.2 }, { t: 1, v: 0.1 }])
));
// Procedural noise-based motion
system.addUpdateModule(new Turbulence(30, 0.01));
Update modules must be registered before the first update() call. After that, the module list is locked. This is because GPU mode compiles the composite shader at first update — adding modules later would require a recompile.
Other available update modules: RotateOverLifetime, VelocityOverLifetime, AttractToPoint, RepelFromPoint, OrbitalForce, ColorOverSpeed. The API reference documents each one’s constructor options and GPU eligibility.
Death modules
A death module fires once per particle when its lifetime expires. The only built-in death module is SpawnOnDeath:
import { SpawnOnDeath } from '@codexo/exojs-particles';
system.addDeathModule(new SpawnOnDeath(
childSystem, // target ParticleSystem
childBurst, // SpawnModule that spawns into childSystem
3 // spawn childBurst.apply(...) this many times
));
SpawnOnDeath forwards the dying particle’s position to the child system’s spawn, so each child burst appears at the parent’s death location.
Per-frame loop
ParticleSystem is a Drawable — it renders itself when you call context.render(system) in draw. Call system.update(delta) in your scene’s update:
update(delta) {
this.system.update(delta);
}
draw(context) {
context.backend.clear();
context.render(this.system);
}
The update loop runs spawn modules, advances particle state (velocity integration, elapsed time), runs update modules, compacts dead particles, and — in GPU mode — uploads dirty slots and dispatches the compute shader. render() draws the system as a single instanced draw call, regardless of particle count.
SoA channels (direct access)
For manual spawning or custom logic, the system exposes its internal arrays as public readonly references:
system.posX[i] // X position of particle i
system.posY[i] // Y position
system.velX[i] // X velocity
system.velY[i] // Y velocity
system.scaleX[i] // X scale factor
system.scaleY[i] // Y scale factor
system.rotations[i] // rotation in radians
system.rotationSpeeds[i] // rotation speed in rad/s
system.color[i] // packed 0xAABBGGRR uint32
system.elapsed[i] // seconds since spawn
system.lifetime[i] // total lifetime; -1 sentinel = dead in GPU mode
Mutate these arrays directly if you need per-particle logic that doesn’t fit a module. system.spawn() allocates one slot and returns its index (or -1 at capacity). system.clearParticles() resets to zero alive. system.aliveCount reports how many particles are currently active. system.gpuMode reports whether the GPU composite shader path is active.
GPU auto-routing
The decision is per-system and automatic. When:
- A
WebGpuBackendis active - Every registered update module implements
wgsl()
…the system compiles a composite WGSL compute shader that integrates position, velocity, rotation, and every module’s dynamic behavior in one dispatch, writing directly into the renderer’s instance vertex buffer. No CPU readback in the steady state. The gpu-particles example demonstrates this at 60,000 particles.
If conditions aren’t met (WebGL2 backend, or any module without wgsl()), the system runs on CPU with the same API. Your scene code is identical — no branching, no backend checks.
Examples
A single upward emitter with gravity and fade — RateSpawn + ApplyForce + AlphaFadeOverLifetime.
A bonfire effect with additive blending — RateSpawn with random position and upward velocity, plus ColorOverLifetime from ember-orange to transparent black.
Where to go next
The next chapter, Post-processing, covers scene-wide multi-pass rendering — how to combine RenderTexture targets with filter chains for bloom, trails, and composited color grading.