Particles
A GPU particle system as a node. Give it a Material and emitter settings, add it
to the scene, and it streams particles — smoke, sparks, dust. Extends
Node: move/rotate the node to move the emitter. Every option is also a live setter,
so rate, color, gravity, … can change at runtime.
At a glance
const sparks = new Particles({
material: Material.unlit({ color: '#ffcc44' }),
rate: 80, // particles/second
shape: { type: 'point', v: [0, 0, 0] },
startVelocity: { dir: [0, 1, 0], speed: { min: 2, max: 4 } },
gravity: 9.8,
lifetime: { min: 0.6, max: 1.2 },
size: { min: 0.05, max: 0.12 },
color: dynamicColor('#ffcc44', 0.7, '#ffffff', 1, '#000000'), // fade out over life
})
sparks.position = [0, 1, 0]
scene.add(sparks)
crate.addEventListener('click', () => sparks.spawn(40)) // burst on top of the rateOptions
new Particles(options: {
material: Material // required — how each particle is drawn
rate?: number // particles emitted per second
shape?: { type: 'point', v: Vec3Like } // spawn at an offset (emitter-relative)
| { type: 'box', min: Vec3Like, max: Vec3Like } // spawn randomly inside the box
startVelocity?: Vec3Like | VelocityValue // see below
gravity?: number // downward acceleration, world units/s²
drag?: number // velocity damping — higher slows particles faster
lifetime?: number | { min, max } // seconds; range = random per particle
color?: DynamicColor // constant, random range, or dynamicColor() curve
size?: DynamicValue // constant, range, or dynamic() curve (alias of custom[0])
rotation?: DynamicValue // same (alias of custom[1])
noise?: { strength?, frequency?, speed? } | null // turbulence; all fields default 1
})Everything but material has a native default — new Particles({ material }) already emits.
Fields typed number | { min, max } take a flat value ("always this") or a range ("random per
particle").
rate only applies a plain number — a { min, max } range is typed but
silently ignored.
startVelocity
A plain Vec3Like is a fixed launch direction; 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
randomizeAngle?: { min: Vec3Like, max: Vec3Like } // jitter the launch angle within bounds
}sparks.startVelocity = {
dir: [0, 1, 0],
speed: { min: 2, max: 5 },
randomizeAngle: { min: [-15, 0, -15], max: [15, 0, 15] },
}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.shape = { type: 'box', min: [-1, 0, -1], max: [1, 0, 1] }
sparks.startVelocity = [0, 3, 0]
sparks.gravity = 0
sparks.drag = 0.5
sparks.lifetime = { min: 0.5, max: 1 }
sparks.color = '#88ccff'
sparks.size = dynamic(0.1, 'multiply', 0, 1, 1, 4)
sparks.rotation = { min: 0, max: 360 }
sparks.noise = { strength: 2 } // or null to disable
sparks.custom[0] = 0.2 // raw curve-param slots 0..3; size/rotation alias 0/1
sparks.custom[2] = dynamic(0, 'inc', 0, 0, 1, 1) // slots 2/3 feed a custom materialLifecycle: the system emits continuously from construction; sparks.destroy() (from Node)
removes it. For a one-shot effect, set rate: 0 and call spawn(n).
Curves over a particle's life — dynamic() / dynamicColor()
size, rotation, custom[i], and color can animate across each particle's lifetime. A curve
is a flat list of (t, value) stops, t running 0..1 over the particle's life.
dynamic(
baseValue: number | { min, max }, // value at birth (range = random per particle)
type: 'multiply' | 'inc', // curve multiplies the base, or adds to it
...stops: (number | { min, max })[] // flat t, value, t, value, … (value range = random per particle)
): DynamicValue
dynamicColor(
baseValue: string | { min, max }, // start color (range = random between two colors)
...stops: (number | string | { min, max })[] // flat t, color, t, color, …
): DynamicColor // the curve MULTIPLIES the base colorsparks.size = dynamic(0.1, 'multiply', 0, 1, 1, 4) // grow to 4× by end of life
sparks.size = dynamic(0.3, 'multiply', 0, 1, 1, 0) // shrink to nothing
sparks.color = dynamicColor('#ffaa33', 0.7, '#ffffff', 1, '#000000') // fade to black at the enddynamicColor multiplies the base, so its main use is fading brightness/alpha toward death. Keep
t ascending 0 → 1 and the stop count small — stops are baked into a short native curve, not an
arbitrary spline.
Pitfalls
// ✗ per-frame spawn() to fake an emission rate
setLoop(() => sparks.spawn(1))
// ✓ that's what rate is for
sparks.rate = 60
// ✗ expecting a rate range to work
new Particles({ material, rate: { min: 10, max: 50 } }) // ignored — see the note above