Particles
A GPU particle system as a node. Add it to the scene and it streams particles —
smoke, sparks, dust. Extends Node: move/rotate the node to move the emitter
(particles simulate in the emitter's local space). Every option is also a live setter, so
rate, color, gravity, … can change at runtime.
Particles draw with the default particle material (Material.particles() — camera-facing
point sprites, soft round dots until you give it a texture) unless you pass a custom compiled
shader via material.
At a glance
const sparks = new Particles({
blend: 'add', // additive — sparks glow
rate: 80, // particles/second
shape: { type: 'point', v: [0, 0, 0] },
startVelocity: { dir: [0, 1, 0], speed: { min: 2, max: 4 }, spread: 0.3 },
gravity: 9.8, // positive pulls DOWN
lifetime: { min: 0.6, max: 1.2 },
size: { min: 0.05, max: 0.12 },
color: colorCurve('#ffcc44').via(0.7, '#ffffff').to('#00000000'), // fade out over life
})
sparks.position = [0, 1, 0]
scene.add(sparks)
crate.addEventListener('click', () => sparks.spawn(40)) // burst on top of the rateA textured flipbook effect (a sprite sheet of frames):
const fire = new Particles({
map: await Texture.load(asset('./flame-sheet.png')),
sheet: 8, // 8×8 frames; auto-cycles once per particle lifetime
blend: 'add',
emissive: 1.5, // extra glow
rate: 2,
lifetime: 3,
size: { min: 1.2, max: 2 },
})By default each particle starts its cycle on a random frame — loops like fire or smoke stay
desynchronized. For a sheet that tells a story in order (an explosion sequence), start at 0; for
a non-square sheet, give the grid as [cols, rows]:
const boom = new Particles({
map: await Texture.load(asset('./explosion.png')),
sheet: [8, 4], // 8 columns × 4 rows = 32 frames…
startFrame: 0, // …played in order, once per particle lifetime
rate: 0,
lifetime: 0.8,
})
boom.spawn(1)Options
new Particles(options?: {
// --- look (the default material; see Material.particles in material.md) ---
map?: Texture // sprite texture; unset = soft round dot
sheet?: number | [cols, rows] // flipbook grid: N = N×N, or explicit cols×rows (default 1)
startFrame?: number | 'random' // where the default flipbook cycle begins (default 'random')
emissive?: number // extra brightness for a glow look (default 0)
blend?: 'alpha' | 'add' // compositing: smoke/dust vs fire/sparks (default 'alpha')
soft?: boolean // radial falloff; default: on without a map, off with one
render?: 'point' | 'quad' | 'stretch' // point sprites (default) / real quads / velocity-stretched quads
stretch?: number // ('stretch') extra length per unit of speed (default 0.05)
material?: Material // custom compiled shader — the look options above are ignored
// --- emitter ---
maxParticles?: number // particle pool capacity (default 1000)
rate?: number // particles emitted per second
space?: 'local' | 'world' // 'world': particles stay where born as the emitter moves (default 'local')
inheritVelocity?: number // fraction of the emitter's velocity given to particles at spawn
rateOverDistance?: number // extra particles per world unit the emitter MOVES (see below)
shape?: { type: 'point', v: Vec3Like } // spawn at an offset (emitter-relative)
| { type: 'box', min: Vec3Like, max: Vec3Like } // random inside the box
| { type: 'circle', center: Vec3Like, radius: number } // random on an XZ disc
startVelocity?: Vec3Like | VelocityValue // see below
gravity?: number // world units/s² — POSITIVE PULLS DOWN (sugar for acceleration: [0, -g, 0])
acceleration?: Vec3Like // constant acceleration vector (gravity, wind, updraft…)
drag?: number // velocity damping — higher slows particles faster
lifetime?: number | { min, max } // seconds; range = random per particle
color?: ColorInput | { min, max } | colorCurve(…) // constant, random between two colors, or a curve
size?: number | { min, max } | curve(…) // world-unit diameter (alias of custom[0]); default 1
rotation?: … // radians (alias of custom[1])
opacity?: … // 0..1 (alias of custom[2]); default 1
frame?: … // flipbook frame index (alias of custom[3])
noise?: { strength?, frequency?, speed? } | null // turbulence; all fields default 1
seed?: number // fixed random seed — deterministic tests
})Everything has a default — new Particles() already emits soft white dots. Fields typed
number | { min, max } take a flat value ("always this") or a range ("random per particle").
startVelocity
A plain Vec3Like is a fixed launch velocity (its length is the speed); an object picks a
direction mode plus optional randomization:
startVelocity: {
dir?: Vec3Like // launch along this direction, or…
from?: Vec3Like // …away from this point, or…
to?: Vec3Like // …toward this point
speed?: number | { min: number, max: number } // scales the direction; range = per particle
spread?: number // cone half-angle (radians) — jitter within it
randomizeAngle?: { min, max } // asymmetric jitter: two angles (x/y), radians
}sparks.startVelocity = { dir: [0, 1, 0], speed: { min: 2, max: 5 }, spread: 0.25 }Moving emitters — trails
By default the whole system rides its node: move the emitter and every live particle moves with
it (a torch carried through a level). For trails — skid smoke, mud, wake, exhaust, footstep
dust — you want the opposite: particles stay in the world where they were born while the emitter
moves on. That's space: 'world', plus two companions:
rateOverDistanceemits per world unit moved (on top ofrate), with spawn points spread evenly along the path — trail density independent of speed, no per-frame clumping.inheritVelocityhands each particle a fraction of the emitter's velocity at spawn, so smoke looks thrown off a moving car instead of appearing from nowhere.
// drift smoke at a wheel: attach to the wheel node and just drive
const smoke = new Particles({
map: smokeTex,
sheet: 8,
space: 'world',
rate: 0,
rateOverDistance: 12, // 12 puffs per meter of drift, however fast you go
inheritVelocity: 0.4, // carried along, then left behind
shape: { type: 'circle', center: [0, 0, 0], radius: 0.15 },
startVelocity: { dir: [0, 1, 0], speed: 0.5, spread: 0.6 },
lifetime: { min: 1, max: 2 },
size: curve({ min: 0.4, max: 0.7 }).to(2),
opacity: curve(0.5).fade(0.1, 0.5),
})
wheel.add(smoke)
// modulate the amount from gameplay — both are live setters:
smoke.rateOverDistance = 12 * driftAmountSwitching space at runtime resets live particles (they're stored in the old space). Both
companions only make sense with space: 'world' — in local space the emitter never "moves"
relative to its particles.
Render modes
'point'(default) — GPU point sprites: cheapest on every backend, but drivers clamp the max on-screen size (extreme close-ups cap out) and rotation crops the sprite's corners.'quad'— real camera-facing quads: no size clamp, true geometric rotation. Slightly more CPU/upload (4 vertices per particle).'stretch'— quads stretched along each particle's velocity, projected to the screen: sparks, rain, speed streaks.stretchaddsstretch · |velocity|world units of length on top ofsize.
const sparks = new Particles({
render: 'stretch',
stretch: 0.08, // a 4 u/s spark draws ~0.3 wu longer than it is wide
blend: 'add',
startVelocity: { dir: [0, 1, 0], speed: { min: 2, max: 5 }, spread: 0.4 },
gravity: 9.8,
size: 0.05,
lifetime: { min: 0.4, max: 0.9 },
})Ribbons — Trail
A Trail is not an emitter but a ribbon strip that follows its node through the world — the
sword-swing arc, skid marks, missile trails. Move the node (or anything it's parented to): the
trail lays down a point every minDistance of movement, each point lives time seconds, and
the strip stays glued to the node at its head.
const slash = new Trail({
time: 0.25, // how long the ribbon lingers
width: curve(0.3).to(0), // full width at the blade, tapering to nothing at the tail
color: '#8df0ff',
blend: 'add',
})
swordTip.add(slash)
// swing the sword — the arc appears by itself; between swings, stop laying points:
slash.emitting = false // existing points still age out, so the arc fades naturallywidth / opacity / color take the same curves as Particles, evaluated over each point's
life — i.e. along the ribbon from head (fresh) to tail (dying). The default opacity already
fades the tail out. Live setters: time, width, opacity, color, minDistance,
emitting. Options also accept the shared look fields (map/sheet/emissive/blend/soft);
a map's u axis runs along the ribbon, head → tail.
Curves over a particle's life — curve() / colorCurve()
size, rotation, opacity, frame, and color can animate across each particle's lifetime.
A curve reads as a journey: .from(v) at birth → .via(t, v) waypoints → .to(v)
at death, t running 0..1 over the particle's life.
curve(base?: number | { min, max }, // the particle's value (range = random per particle)
mode?: 'multiply' | 'add') // the curve scales the base (default) or offsets it
.from(v) // value at t = 0 — must come first
.via(t, v) // value at t (ascending order)
.to(v) // value at t = 1
.fade(in?, out?) // the classic envelope: rise over `in`, fall over the last `out`
colorCurve(base?: ColorInput | { min, max }) // always multiplies the base color
.from(c).via(t, c).to(c)Any stop value can be a { min, max } range — randomized per particle. A chain with no .from
starts at the identity (1 for multiply, 0 for add); with no .to it holds its last value.
sparks.size = curve(0.1).to(4) // grow to 4× by end of life
sparks.size = curve(0.3).to(0) // shrink to nothing
sparks.opacity = curve(0.3).fade(0.15, 0.4) // = .from(0).via(0.15,1).via(0.6,1).to(0)
sparks.rotation = curve({ min: 0, max: 6.28 }, 'add').to({ min: -2, max: 2 }) // spin ±2 rad over life
sparks.color = colorCurve('#ffaa33').via(0.7, '#ffffff').to('#000000') // fade to blackcolorCurve multiplies the base, so its main use is fading brightness/alpha toward death. Keep
stops few (max 8) and t ascending — they bake into a short native curve, not an arbitrary
spline; an invalid curve is rejected with a console warning and the previous one stays.
Live setters & methods
sparks.spawn(count): this // emit a burst right now, on top of `rate` — chainable
sparks.material // get/set — swap the draw material live
// write-only live setters (same types as the options):
sparks.rate = 120
sparks.space = 'world' // switching resets live particles
sparks.inheritVelocity = 0.5
sparks.rateOverDistance = 12
sparks.shape = { type: 'box', min: [-1, 0, -1], max: [1, 0, 1] }
sparks.startVelocity = [0, 3, 0]
sparks.gravity = 9.8 // positive = down
sparks.acceleration = [0.5, 0, 0] // wind
sparks.drag = 0.5
sparks.lifetime = { min: 0.5, max: 1 }
sparks.color = '#88ccff'
sparks.size = curve(0.1).to(4)
sparks.rotation = { min: 0, max: 6.28 }
sparks.noise = { strength: 2 } // or null to disable
sparks.opacity = curve().fade(0.2) // fade in and out
sparks.frame = curve({ min: 0, max: 63 }, 'add').to(128) // flipbook: 2 cycles per life
sparks.custom[0] = 0.2 // raw curve-param slots 0..3; size/rotation/opacity/frame alias 0-3Lifecycle: the system emits continuously from construction; sparks.destroy() (from Node)
removes it and frees its GPU buffers. For a one-shot effect, set rate: 0 and call spawn(n).
Pitfalls
// ✗ per-frame spawn() to fake an emission rate
setLoop(() => sparks.spawn(1))
// ✓ that's what rate is for
sparks.rate = 60
// ✗ expecting curve(2).to(4) to animate 2 → 4
sparks.size = curve(2).to(4) // base 2 × curve(1 → 4): animates 2 → 8
// ✓ the base is the particle's value; put the journey in the curve
sparks.size = curve().from(2).to(4) // animates 2 → 4