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

TypeScript
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 rate

Flipbook sprite sheets: fire, smoke, explosions — map + sheet

A textured flipbook effect plays a sprite sheet of frames on every particle:

TypeScript
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]:

TypeScript
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)

TypeScript
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.

Note

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:

TypeScript
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.

TypeScript
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

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

  • rateOverDistance emits per world unit moved (on top of rate), with spawn points spread evenly along the path — trail density independent of speed, no per-frame clumping. smooth: true lays 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.
  • inheritVelocity hands each particle a fraction of the emitter's velocity at spawn, so smoke looks thrown off a moving car instead of appearing from nowhere.
TypeScript
// 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 * driftAmount

Switching 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. stretch adds stretch · |velocity| world units of length on top of size, 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.
TypeScript
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).

TypeScript
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), and startVelocity / angularVelocity are in emitter space — so aiming the node aims the ejection; no per-shot config.
  • size scales the mesh (a curve shrinks debris as it dies); rotation, opacity and frame are sprite-only. color reaches the vertex color (getColor()) for custom materials — the stock lit material ignores it, so tint through the material.
  • ground works for every render mode: particles below height + radius are pushed up and bounce with bounce of their vertical speed (default 0.3); tangential speed and spin decay at friction per second (default 8) while touching, and a slow particle comes to rest (it still dies at lifetime). The height is in the simulation space — world y with space: '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.

TypeScript
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 naturally
TypeScript
new 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.

TypeScript
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.

TypeScript
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 black

colorCurve 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()

TypeScript
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-3

Turbulence — 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.

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

TypeScript
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 lit on 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 emissive sheet 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;
  • Trail takes it too; mesh particles do not need it: their material is lit by the scene.

Pitfalls

TypeScript
// ✗ 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

See also

  • Material — Material.particles({...}) (the default draw material) and custom shaders
  • Decals — marks that stay on surfaces (bullet holes, skid marks)
  • Node — transform (the emitter), destroy()
  • Scene — where the system lives, setDepthEffects