LeCodesdocs

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

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

A textured flipbook effect (a sprite sheet of frames):

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

TypeScript
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

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

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

  • 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.
  • 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' (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.
TypeScript
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.

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

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

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

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

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

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

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

See also

  • MaterialMaterial.particles({...}) (the default draw material) and custom shaders
  • Node — transform (the emitter), destroy()
  • Scene — where the system lives