Particles
Particles is a GPU particle system as a node — smoke, sparks, dust, fire, rain, debris: add it to
the scene and it emits on its own. Trail is its sibling, a ribbon that follows a node
(sword swings, missile trails). A mark that stays on a surface (a bullet hole, a skid mark) is a
decal, not a particle.
- make sparks, smoke, fire or dust at a point in the scene
- emit a burst of particles on a hit or an explosion
- play a flipbook sprite sheet on each particle
- leave smoke or dust behind a moving car or character
- fade, shrink or recolor particles over their lifetime
- draw a ribbon trail behind a sword swing or a missile
- throw bouncing debris or shell casings as 3D meshes
- soften sprites where they cut into the ground
Platforms: iOS, Android, desktop, the web, the web player, the le.codes viewer app; headless
tests simulate particles but draw none. depthFade and lit are ignored on the web (Web Lite);
lit takes effect only where a baked lightmap loads (desktop; not iOS).
At a glance
const scene = new Scene()
scene.add(Light.sun())
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)
scene.open()
scene.addEventListener('click', () => sparks.spawn(40)) // a burst on top of the rateFlipbook sprite sheets: fire, smoke, explosions — map + sheet
A textured flipbook effect plays a sprite sheet of frames on every particle:
Texture.load(asset('./flame-sheet.png')).then((flames) => {
scene.add(new Particles({
map: flames,
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: explosionSheet,
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)Emitter options — new Particles(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
depthFade?: number // soft particles: fade over N metres into the geometry behind (default 0 = off)
lit?: boolean // colours × the baked light where each particle is (see Lit sprites below; default false)
render?: 'point' | 'quad' | 'stretch' // point sprites (default) / real quads / velocity-stretched quads
stretch?: number // ('stretch') extra length per unit of speed (default 0.05)
emissiveMap?: Texture // (quad/stretch) a second sheet on the same grid, ADDED × emissiveColor
emissiveColor?: ColorInput // its multiplier; an [r, g, b] array may exceed 1 (default 0 = off)
frameBlend?: boolean // (quad/stretch) crossfade flipbook frames (default false)
material?: Material // custom compiled shader — the look options above are ignored
mesh?: Geometry // draw each particle as a lit 3D mesh (see Mesh particles)
angularVelocity?: Vec3Like | { min, max } // (mesh) spin, rad/s per axis, randomized at spawn
ground?: { height?, radius?, bounce?, friction? } | null // a plane to bounce on and settle (any mode)
renderPriority?: number // coarse draw order 0…7 among blended draws (default 5)
order?: number // tie-break among blended systems: higher draws on top (default 0)
// --- emitter ---
maxParticles?: number // particle pool capacity (default 1000)
rate?: number // particles emitted per second (default 5)
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)
smooth?: boolean // spread those along a curve through the emitter's path (default false)
shape?: { type: 'point', v: Vec3Like } // spawn at an offset (emitter-relative) — the default is the origin
| { type: 'box', min: Vec3Like, max: Vec3Like } // random inside the box
| { type: 'circle', center: Vec3Like, radius: number } // random on an XZ disc
| { type: 'sphere' | 'hemisphere', center?, radius, thickness? } // see Spawn shapes
| { type: 'cone', center?, radius, angle?, length?, thickness? }
startVelocity?: Vec3Like | VelocityValue // see below; unset = particles stand still
velocityOverLife?: { radial?, x?, y?, z? } // extra velocity from curves (see below)
gravity?: number // world units/s² — POSITIVE PULLS DOWN (sugar for acceleration: [0, -g, 0])
acceleration?: Vec3Like // constant acceleration vector (gravity, wind, updraft…); wins over gravity
drag?: number // velocity damping per second — higher slows particles faster
lifetime?: number | { min, max } // seconds (default 5); range = random per particle
color?: ColorInput | { min, max } | colorCurve(…) // constant, random between two colors, or a curve; default white
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?, ramp? } | null // turbulence; strength/frequency/speed default 1, ramp 0
seed?: number // fixed random seed — deterministic tests
})Everything has a default — new Particles() already emits soft white dots, five a second, each
living five seconds. Fields typed number | { min, max } take a flat value ("always this") or a
range ("random per particle"). Every option is also a live setter, so rate, color, gravity,
… can change at runtime.
Particles extends Node: move or rotate the node to move the emitter (particles
simulate in the emitter's local space unless space: 'world'). They draw with the default
particle material (Material.particles() — camera-facing point sprites, soft round dots until
you give it a map) unless you pass a custom compiled shader via material.
the pool is capped per mode: maxParticles up to 65 536 points, 16 000 quads
(quad / stretch), 8192 trail points; a mesh system's vertex stream at ~1 M vertices.
Launch direction and speed — 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
}A speed with no dir / from / to launches each particle in a random direction — or, from a
sphere, hemisphere or cone shape, outward along the shape.
sparks.startVelocity = { dir: [0, 1, 0], speed: { min: 2, max: 5 }, spread: 0.25 }Spawn shapes: sphere, hemisphere, cone — shape
sphere, hemisphere and cone have an axis of +Y (the hemisphere is the +Y half; the cone
opens along +Y from its base disc at center, half-angle angle in radians, emitting up to
length along the axis) — aim the node to point them. thickness 0 emits from the surface only,
1 (default) from the whole volume.
Drift and radial bursts over life — velocityOverLife
velocityOverLife: { radial?: ParticleValue, x?: ParticleValue, y?: ParticleValue, z?: ParticleValue }Extra velocity evaluated from its curves every frame and ADDED as a displacement (not accumulated
like acceleration): radial pushes outward along the particle's birth direction (a burst that
decays: curve(4).to(0)), x / y / z drift in the simulation space (rising smoke:
y: curve(2).from(0).to(1)).
Smoke and dust trails behind a moving emitter — space: 'world'
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.smooth: truelays them along a curve through the emitter's recent path instead of the straight chord between frames, which a fast, curving emitter shows as facets — worse the lower the frame rate.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 sprites, quads, velocity streaks — render
'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, scaled by how much of the velocity the viewer can see — a streak flying straight at or away from the camera foreshortens to a dot, like real geometry.
const streaks = 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 },
})The quad modes take two more look options: emissiveMap (with emissiveColor) adds a second
sheet on the same grid, keyed by map's alpha and not tinted by the particle colour — a fireball
is a smoke map whose colour curve goes dark plus a fire emissiveMap that keeps glowing while
the alpha lasts; frameBlend: true crossfades each flipbook frame into the next instead of
snapping.
Which effect draws on top — order / renderPriority
Blended systems are depth-sorted by their node, so the emitters of one effect tie
and the engine would pick who covers whom per frame (smoke popping over a fireball): order
breaks the tie — a fireball wants 2, its smoke 1, a smoke trail −1. renderPriority (default 5;
meshes sit at 4, decals at 3) is the coarse layer, so smoke covers a car's glass and its skid
marks whatever the camera does.
Mesh particles: debris and shell casings — mesh / ground
Pass a Geometry and every particle is that mesh: shell casings, rubble, shrapnel, leaves, coins
— lit by the scene, tumbling in 3D, thousands of them in one draw call (the mesh is transformed
per particle on the CPU into one vertex stream, so it draws with any regular material: the default
is Material.lit(); pass material for color / metallic / roughness or a map).
const brass = new Particles({
mesh: Geometry.box().scale(0.009, 0.009, 0.04), // a 9×9×40 mm case
material: Material.lit({ color: '#c9a24a', metallic: 1, roughness: 0.35 }),
space: 'world', rate: 0, maxParticles: 256,
startVelocity: { dir: [1, 0.8, 0.2], speed: { min: 1.6, max: 2.6 }, spread: 0.35 },
angularVelocity: { min: [-30, -10, -30], max: [30, 10, 30] }, // rad/s, random per casing
gravity: 9.8,
ground: { height: 0, radius: 0.005, bounce: 0.35, friction: 10 },
lifetime: 4,
})
// per shot: place the emitter at the ejection port facing along the gun, then
brass.worldMatrix = ejectionPortMatrix
brass.spawn(1)- Particles are born in the emitter's orientation (with
space: 'world', its world rotation), andstartVelocity/angularVelocityare in emitter space — so aiming the node aims the ejection; no per-shot config. sizescales the mesh (a curve shrinks debris as it dies);rotation,opacityandframeare sprite-only.colorreaches the vertex color (getColor()) for custom materials — the stocklitmaterial ignores it, so tint through the material.groundworks for every render mode: particles belowheight + radiusare pushed up and bounce withbounceof their vertical speed (default 0.3); tangential speed and spin decay atfrictionper second (default 8) while touching, and a slow particle comes to rest (it still dies atlifetime). The height is in the simulation space — world y withspace: 'world'.- Capacity: the vertex stream is capped at ~1 M vertices per system (
maxParticles× mesh vertices), so keep debris meshes small (a box is 24 vertices; 40 k boxes fit).
Ribbon trails behind a sword or missile — 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 naturallynew Trail(options?: {
time?: number // seconds each point lives (default 0.5)
width?: ParticleValue // strip width, world units; a curve tapers it head → tail (default 0.1)
minDistance?: number // emitter movement between points, world units (default 0.05)
maxPoints?: number // capacity (default 128); a point that does not fit is dropped
smooth?: boolean // follow a curve through the path, not the chord between frames (default false)
orient?: 'camera' | 'x' | 'y' | 'z' // which way the width points (default 'camera')
faceCamera?: number // with an axis: 0..1 floor under the apparent width (default 0.4)
lit?: boolean // the baked light where each point is (see Lit sprites; default false)
color?, opacity? // like Particles; opacity defaults to fading the tail out
// + the look options: map, sheet, emissive, blend, soft, depthFade, emissiveMap, emissiveColor, frameBlend
})width / 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). A map's u axis runs along the
ribbon, head → tail. A fast emitter at a small minDistance covers metres inside one time
window — give maxPoints headroom.
orient: 'camera' rolls the strip about its length to stay flat to the viewer, so it never
disappears but bears no relation to what drew it. An axis uses that axis of the emitter's own
transform as it was when each point was laid: on a node riding a sword's blade, 'z' (the blade)
makes the strip the surface the blade swept. Such a strip goes edge-on — and vanishes — when the
swing happens in the plane of view; faceCamera is the fraction of its real width below which it
rolls back toward the camera (0 = never, 1 = always, the same as 'camera').
Live setters: time, width, opacity, color, minDistance, smooth, lit, orient,
faceCamera, emitting; trail.material reads the material.
Fade, grow or recolor 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); default 1
mode?: 'multiply' | 'add') // the curve scales the base (default) or offsets it
.from(v) // value at t = 0 — must come first (throws otherwise)
.via(t, v) // value at t (ascending order)
.to(v) // value at t = 1
.fade(in = 0.15, out = in) // the classic envelope: rise over `in`, fall over the last `out`
colorCurve(base?: ColorInput | { min, max }) // always multiplies the base color; default white
.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 strictly ascending within 0..1 — they bake into a short native curve,
not an arbitrary spline. A literal chain that breaks the rule is a compile warning; at run time the
engine rejects the curve ([creator-particles] paramCurve: bad record in the host log) and the
previous one stays.
Change an emitter at runtime — live setters, spawn()
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.smooth = true
sparks.shape = { type: 'box', min: [-1, 0, -1], max: [1, 0, 1] }
sparks.startVelocity = [0, 3, 0]
sparks.velocityOverLife = { y: curve(2).from(0).to(1) }
sparks.gravity = 9.8 // positive = down
sparks.acceleration = [0.5, 0, 0] // wind
sparks.drag = 0.5
dust.lit = true // take the level's baked light (see Lit sprites)
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.seed = 7
sparks.order = 2
sparks.renderPriority = 6 // 0…7
debris.mesh = Geometry.sphere({ radius: 0.02 }) // (mesh mode) swap the particle mesh
debris.angularVelocity = { min: [-5, -5, -5], max: [5, 5, 5] }
debris.ground = { height: -1, bounce: 0.5 } // 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-3Turbulence — noise
noise is a turbulence field sampled by position: without ramp every particle born at the same
spot is pushed the same way, so a plume's root wanders off its emitter by up to strength metres;
ramp: 1 scales the push by age — nothing at birth, full at death — which a visible source (an
exhaust pipe, a muzzle) wants.
One-shot bursts and removal — spawn() / destroy()
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).
Soft particles: no hard edge where sprites meet the ground — depthFade
A sprite that cuts into the ground or a wall shows a hard, flickering line where its quad crosses
the surface. depthFade: 0.5 fades each fragment out over the last 0.5 m before the geometry
behind it, so smoke sits on the floor instead of slicing through it. Pick the value by sprite
size: roughly a quarter to a half of the particle's radius (ground dust 0.2–0.3, a 2 m fireball
0.5–1). It reads the scene depth buffer, which the engine keeps alive only while a soft system is
actually on screen — nothing is paid for idle emitters. Depth comes from opaque geometry only
(other particles and transparent meshes are not in it), and it is a half-resolution buffer, so
expect a 1–2 px halo at hard silhouettes. scene.setDepthEffects(false) turns every depth-reading
effect off engine-wide (see Scene). Web Lite (lecodes web) has no depth pass and
draws soft sprites unfaded.
Texture.load(asset('./smoke.png')).then((smoke) => {
scene.add(new Particles({
map: smoke,
render: 'quad',
depthFade: 0.3, // fade over the last 0.3 m before the floor behind
shape: { type: 'circle', center: [0, 0, 0], radius: 1 },
startVelocity: { dir: [0, 1, 0], speed: 0.3 },
size: { min: 1, max: 1.5 },
lifetime: 3,
}))
})Lit sprites: smoke and dust darker in the shade — lit
A sprite is unlit: its colour is the picture. That is right for fire, sparks and magic, and wrong for
dust, smoke and steam — a puff kicked up under an awning glows as if it stood in the sun. In a level
with a baked light grid (env.lightmap.volume, see Lightmap) lit: true multiplies
every particle's colour by the light where that particle is, relative to the level's open ground:
const dust = new Particles({ map: smoke, sheet: 5, render: 'quad', space: 'world', rate: 0,
color: '#cdb58c', // the colour OUT IN THE SUN — author it there
lit: true })- in the open it is exactly the authored colour, so switching
liton re-tunes nothing; under a roof it falls to the ambient's share of the open light, tinted the way the shade is; - it is per particle, not per system: a world-space trail of puffs laid from the sun into the shade is lit along its length, and a puff drifting out of a doorway brightens as it goes;
- alpha is untouched, and so is the
emissivesheet of a fireball (a glow is not lit); - without a grid — a level with no bake, a host that has none (iOS, Web Lite) — the colours are drawn as authored;
Trailtakes it too; mesh particles do not need it: their material is lit by the scene.
Pitfalls
// ✗ spawn() every frame to fake an emission rate
class Emit extends System<'emit'> { update(): void { 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