Guide

Guide Effects Particles

Particles

Spawn and tune particle systems for environmental and reactive effects.

Intermediate ~5 min read

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: ParticleSystem ships as an official ExoJS extension package. Install @codexo/exojs-particles alongside @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:

DistributionWhat 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:

  1. A WebGpuBackend is active
  2. 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

Emitter Basics Open in Playground View source
import { Application, Color, Scene, Texture, Vector } from '@codexo/exojs';
import {
    ApplyForce,
    ColorGradient,
    ColorOverLifetime,
    ConeDirection,
    Constant,
    Curve,
    particlesExtension,
    ParticleSystem,
    Range,
    RateSpawn,
    ScaleOverLifetime,
} from '@codexo/exojs-particles';
import { mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    extensions: [particlesExtension],
});

class EmitterBasicsScene extends Scene {
    private system!: ParticleSystem;
    private hud!: ReturnType<typeof mountControls>;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { particle: assets.demo.textures.particleLight });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;

        this.system = new ParticleSystem(loader.get(Texture, 'particle'), { capacity: 4000 });
        this.system.setPosition(width / 2, height - 80);

        // Rate, lifetime, and a cone-shaped velocity spread: a fountain that
        // shoots upward (-π/2) with a ±36° spread and 70–180 px/s speed.
        this.system.addSpawnModule(
            new RateSpawn({
                rate: new Constant(180),
                lifetime: new Range(0.6, 1.4),
                velocity: new ConeDirection(-Math.PI / 2, Math.PI / 5, 70, 180),
            }),
        );

        // Gravity pulls the fountain back down.
        this.system.addUpdateModule(new ApplyForce(0, 240));

        // Start/end size: each particle grows in fast, then shrinks to nothing
        // as it ages. ScaleOverLifetime sets the absolute scale every frame from
        // a curve sampled at elapsed / lifetime.
        this.system.addUpdateModule(
            new ScaleOverLifetime(
                new Curve([
                    { t: 0, v: 0.15 },
                    { t: 0.2, v: 0.55 },
                    { t: 1, v: 0 },
                ]),
            ),
        );

        // Colour gradient over lifetime: white-hot core → orange → deep red, then
        // fading to transparent so particles dissolve at the end of their life.
        this.system.addUpdateModule(
            new ColorOverLifetime(
                new ColorGradient([
                    { t: 0, color: new Color(255, 244, 200, 1) },
                    { t: 0.35, color: new Color(255, 168, 64, 1) },
                    { t: 0.75, color: new Color(220, 60, 40, 0.8) },
                    { t: 1, color: new Color(120, 20, 20, 0) },
                ]),
            ),
        );

        this.hud = mountControls({
            title: 'Emitter Basics',
            controls: [{ keys: 'Auto', action: 'continuous fountain emission' }],
            status: 'Rate 180/s · lifetime 0.6–1.4s · gravity',
            hint: 'RateSpawn drives emission; ScaleOverLifetime sizes and ColorOverLifetime tints each particle as it ages.',
        });
    }

    override update(delta): void {
        this.system.update(delta);
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.system);
    }
}

app.start(new EmitterBasicsScene());

A single upward emitter with gravity and fade — RateSpawn + ApplyForce + AlphaFadeOverLifetime.

Bonfire Open in Playground View source
import {
    Application,
    BlendModes,
    Color,
    Scene,
    Texture,
} from '@codexo/exojs';
import {
    ColorGradient,
    ColorOverLifetime,
    ConeDirection,
    Constant,
    particlesExtension,
    ParticleSystem,
    Range,
    RateSpawn,
    VectorRange,
} from '@codexo/exojs-particles';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    extensions: [particlesExtension],
});

class BonfireScene extends Scene {
    private fireSystem!: ParticleSystem;
    private smokeSystem!: ParticleSystem;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { flame: assets.demo.textures.particleFlame, smoke: assets.demo.textures.particleSmoke });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;

        this.fireSystem = new ParticleSystem(loader.get(Texture, 'flame'));
        this.fireSystem.setPosition(width * 0.5, height * 0.75);
        this.fireSystem.setBlendMode(BlendModes.Additive);

        this.fireSystem.addSpawnModule(
            new RateSpawn({
                rate: new Constant(50),
                lifetime: new Range(5, 10),
                position: new VectorRange(-50, 50, -10, 10),
                velocity: new ConeDirection(-Math.PI / 2, Math.PI / 36, 60, 80),
            }),
        );

        this.fireSystem.addUpdateModule(
            new ColorOverLifetime(
                new ColorGradient([
                    { t: 0, color: new Color(194, 64, 30, 1) },
                    { t: 1, color: new Color(0, 0, 0, 0) },
                ]),
            ),
        );

        this.smokeSystem = new ParticleSystem(loader.get(Texture, 'smoke'));
        this.smokeSystem.setPosition(width * 0.5, height * 0.75 - 40);
        this.smokeSystem.setBlendMode(BlendModes.Normal);

        this.smokeSystem.addSpawnModule(
            new RateSpawn({
                rate: new Constant(8),
                lifetime: new Range(8, 14),
                position: new VectorRange(-30, 30, -5, 5),
                velocity: new ConeDirection(-Math.PI / 2, Math.PI / 12, 20, 35),
            }),
        );

        this.smokeSystem.addUpdateModule(
            new ColorOverLifetime(
                new ColorGradient([
                    { t: 0, color: new Color(120, 100, 80, 0.4) },
                    { t: 1, color: new Color(60, 55, 50, 0) },
                ]),
            ),
        );
    }

    override update(delta): void {
        this.fireSystem.update(delta);
        this.smokeSystem.update(delta);
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.smokeSystem);
        context.render(this.fireSystem);
    }
}

app.start(new BonfireScene());

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.