The pack
particles3d
function particles3d(opts: Particles3dOptions = {}): Pack<{ particles3d: Particles3dApi }>
The 3D particles pack: emitters as roster objects, drawn through the aggregate seam as camera-facing
quads in their sorted place. Needs a 3D world (to draw into) and render-3d (to draw them). Effects
come from vfx3d-* packs through the registry, exactly as the 2D system's do.
Helpers
registerVfx3d
function registerVfx3d(k: { registry: { add(kind: string, def: Vfx3dDef): void } }, def: Vfx3dDef): void
Register an effect under the registry kind 'vfx3d' so game.particles3d.effect(def.name) finds it: a vfx3d-*
pack's attach is one such line, and a game's own effect is registerVfx3d(game.kernel, def). Throws on an emitter
without a name. The same name again replaces the definition, so a scene that registers its effects each time it
enters is safe; two packs registering one name still fail at compose.
Functions
alongShape
function alongShape(shape: Shape3d, u: number, out: { x: number; y: number; z: number }, n: { x: number; y: number; z: number }): void
The point a fraction u (0..1) of the way along a curve shape (path, line, ring), with the ring's outward
normal in n. Any other shape gives its centre.
edgeOrder
function edgeOrder(zone: EmitZone3d, index: number): number
Where the indexth particle of an ordered edge zone goes, as a fraction along its curve, or -1 when the zone is not
ordered (no quantity or stepRate, not fill: 'edge', or not a curve). A closed curve (a ring, a closed path)
wraps without doubling its start; an open one runs end to end, and yoyo brings it back.
insideShape
function insideShape(shape: Shape3d, x: number, y: number, z: number): boolean
Is a point (in emitter space) inside the shape? Used by death zones.
sampleShape
function sampleShape(shape: Shape3d, fill: ZoneFill, rng: Rng, out: { x: number; y: number; z: number }, n: { x: number; y: number; z: number }): void
Sample a point in a shape. Writes into out and returns the outward normal in n (for radial).
shapeLength
function shapeLength(shape: Shape3d): number
The length of a curve shape (path, line, ring), emitter units; 0 for any other shape.
Classes
SurfaceSampler
class SurfaceSampler
Samples points uniformly over a triangle soup, weighted by AREA.
Weighting is the whole point. Picking a triangle at random puts as many particles on a model's tiny chamfer faces as on its whole flank, which reads as the effect clustering wherever the mesh happens to be dense. The CDF is built once, over the unit geometry, so it survives the object moving and scaling.
readonly area: number— Total surface area of the soup, in unit-geometry units.constructor(private verts: Float32Array)— Interleaved vertices — 8 floats each (pos, normal, uv), three to a triangle.geometry3d.build()returns exactly this.get triangles(): number— How many triangles it covers.sample(r1: number, r2: number, r3: number, out: { x: number; y: number; z: number }, n: { x: number; y: number; z: number }): void— Sample with three uniform randoms. Writes the point intooutand the face normal inton.
Types
Burst3d
interface Burst3d
A timed burst on the emitter clock.
at: number— Seconds after the emitter starts.count: Op— How many particles.every?: number— Repeat every this many seconds; 0 = once. Default 0.probability?: number— The chance, 0..1, that each firing happens: a crackle that skips beats. Default 1.
ColorOp
type ColorOp = string | ReadonlyArray<string> | { random: ReadonlyArray<string> } | { values: ReadonlyArray<string>; ease?: EaseName | EaseFn } | { min: string; max: string }
A colour op, every colour any CSS colour: one colour · a ramp over life · pick from a list · keyed ramp with an ease · a blend between two.
DeathZone3d
interface DeathZone3d
Where particles die: inside a shape, or outside it.
shape: Shape3d— The shape, in emitter space.when?: 'inside' | 'outside'— 'inside' kills within it (a drain), 'outside' kills beyond it (a leash). Default 'inside'.
Effect3dHandle
interface Effect3dHandle
A running effect. Unlike the 2D effect handle (an anchor object and emitters by name), this one is the
emitters in def order and verbs over all of them.
readonly emitters: ReadonlyArray<Emitter3dHandle>— The emitters it spawned, in the def's order.moveTo(x: number, y: number, z: number): void— Move it: each emitter goes to this point plus its own offset. A following effect is moved by its object instead.stop(): void— Stop emitting; living particles finish.start(): void— Start again.kill(): void— Remove it now.readonly alive: boolean— Still alive.
Effect3dOptions
interface Effect3dOptions
What effect() takes. Each emitter in the def keeps its own x/y/z (and offset) as an offset from the
effect's position, scaled by scale, whether the effect is placed, moved or following an object.
x?: number— World x of the effect. Default 0 (or the followed object's).y?: number— World y, up. Default 0.z?: number— World z. Default 0.follow?: Handle— Follow an object each step; the effect's emitters die with it.offset?: { x?: number; y?: number; z?: number }— Withfollow, an offset from the object, world units, added to each emitter's own.scale?: number— Scale every size, speed, distance, zone and force by this, so the motion keeps its shape. Default 1.yaw?: number— Aim the whole effect: yaw in degrees, added to each emitter's own, so its zones and launch directions turn (a hit spark's fan, an exhaust) as an object of that yaw faces (90 faces −x); the emitters' offsets do not. Default 0.pitch?: number— Pitch of the whole effect, degrees, added to each emitter's own. Default 0.roll?: number— Roll of the whole effect, degrees, added to each emitter's own. Default 0.layer?: string— The layer.timeScale?: number— Sim speed. Default 1.
Emitter3dApi
interface Emitter3dApi
The live controls on one emitter.
rate: number— Particles per second.emitting: boolean— Emitting right now.timeScale: number— Sim speed multiplier.readonly alive: number— Particles alive.burst(n: number): void— Emitnnow, outside the flow.clear(): void— Stop and clear every living particle.moveTo(x: number, y: number, z: number): void— Move the emitter to a world point;space: 'local'particles move with it.jump(x: number, y: number, z: number): void— Move it WITHOUT travelling there: a respawn, a teleport, the first point of a path.rateOverDistancelays nothing for the move andinheritVelocitydoes not see it;space: 'local'particles still move with it.lit: boolean— Take the scene's lights, LIVE. It is a material swap rather than a per-particle flag, so flipping it retints the whole field at once — including the particles already in the air, which is what makes it an A/B you can actually read.
Emitter3dConfig
interface Emitter3dConfig
Everything an emitter can be told. Every value that can vary takes an Op.
x?: number— World x. Default 0.y?: number— World y — up. Default 0.z?: number— World z. Default 0.layer?: string— The layer. Default the world's first.lit?: boolean— Take the world's lighting — the sun, the ambient and every point and spot light in range. Default true, which is what a smoke puff catching a lantern wants.falsedraws the tint and texture flat, which is what stylised art and anything with its own light already baked in wants; additive fire is washed out by its own blend either way.soft?: number— SOFT: fade a translucent particle out as it closes on the geometry behind it, over this many times its own size, so a big smoke quad does not cut the floor or a wall with a hard line. 0 turns it off. Default 0.5, and 0 forfacing: 'flat', whose quads lie on the ground (a ripple, a scorch) and would fade to nothing against it.follow?: Handle— Follow this object each step; the emitter dies with it.offset?: { x?: number; y?: number; z?: number }— Offset from the followed object.space?: 'world' | 'local'— 'world': particles stay where they were born when the emitter moves. 'local': they move with it. Default 'world'.yaw?: number— Yaw of the whole emitter, degrees, turning as an object's yaw does (the kernel'sforward(): 90 faces −x): rotates its emit zone and launch directions (not gravity, force positions or the death zone). Default 0.pitch?: number— Pitch of the whole emitter, degrees. Default 0.roll?: number— Roll of the whole emitter, degrees. Default 0. It composes yaw → pitch → roll, exactly as an object's own pose does, so an emit zone shaped like a mesh can wear that mesh's whole attitude rather than two thirds of it.rate?: number— Particles per second while flowing. 0 = bursts only. Default 0.rateOverDistance?: number— Particles per world unit the emitter moves, laid along the way it went, while it emits: a wake, a dust trail, a blade's sparks. A step's movement past a quarter of the pool's worth is a jump (a respawn, a teleport), and lays none. Default 0.quantity?: Op— Particles per emission:rateemissions a second, each this many (rounded, at least 1). Default 1.bursts?: ReadonlyArray<Burst3d>— Timed bursts.duration?: number— Seconds of emission; 0 = forever. Default 0.loop?: boolean— Restart afterduration. Default false.delay?: number— Seconds before it starts. Default 0.stopAfter?: number— Stop after this many particles; 0 = no limit. Default 0.max?: number— Pool size. Default 500.maxAlive?: number— A live cap belowmax. Defaultmax.prewarm?: number— Run the sim this many seconds ahead at start (a smoke column already risen). Default 0.emitting?: boolean— Start emitting at once. Default true.timeScale?: number— Sim speed multiplier. Default 1.seed?: number— Random seed; omitted = from the kernel rng.lifespan?: Op— Seconds a particle lives. Default 1.emitZone?: EmitZone3d— Where particles are born. Default a point.deathZone?: DeathZone3d— Where they die.killBelowY?: number— Kill a particle that falls below this y (the floor).speed?: Op— Launch speed along the chosen direction. Default 0.angle?: Op— Launch yaw, degrees, in the emitter's own frame and the same sense asyaw: 0 is +z, 90 is −x. Default [0, 360].elevation?: Op— Launch elevation, degrees; 90 is straight up. Default 0.direction?: { x?: number; y?: number; z?: number }— CONE LAUNCH: the axis particles are thrown along, in the emitter's own frame (its yaw, pitch and roll turn it), any length. Withdirectionorspreadset, each particle's direction is drawn uniformly over the cone's solid angle, so a spray fills its cone evenly rather than crowding its axis, andangleandelevationare not read. Default up (+y) when onlyspreadis given.spread?: number— The cone's FULL angle, degrees: 0 a jet alongdirection, 90 a quarter sphere's spray, 360 every way. Default 0 with adirection.velocityX?: Op— Launch velocity components, used instead of speed/angle whenradialis false.velocityY?: Op— Launch velocity y.velocityZ?: Op— Launch velocity z.radial?: boolean— Launch by speed + angle/elevation (true) or by velocityX/Y/Z (false). Default true.inheritVelocity?: number— Fraction of the emitter's own velocity a particle is born with. Default 0.gravityX?: number— Gravity x, units/s². Default 0.gravityY?: number— Gravity y — negative falls. Default 0.gravityZ?: number— Gravity z. Default 0.drag?: number— Velocity damping per second, 0..1. Default 0.maxVelocity?: number— Speed cap; 0 = none. Default 0.sway?: number— SWAY: a smooth lateral (XZ) wander acceleration, units/s², on a per-particle phase — snow, dust motes, a smoke column that does not rise like a ruler. It is notflutter: that rocks a falling leaf about one axis, this wanders the whole horizontal plane on two frequencies so no two particles drift alike.swayFreq?: number— Sway oscillation frequency, cycles/s. Default 0.25.twinkle?: number— TWINKLE: cut the opacity on a per-particle phase, 0..1 — fireflies, embers, a starfield that breathes. It multiplies whatever the alpha op already produced, so it composes with a fade over life.twinkleFreq?: number— Twinkle frequency, cycles/s: about 1.75 breathes, 5 or more crackles like a sparkler. Default 11 / 2π, about 1.75.forces?: ReadonlyArray<Force3d>— Forces, as data.size?: Op— The quad's size in world units. Default 1.scale?: Op— Multiplied intosize(a lifetime op is how a puff grows). Default 1.aspect?: Op— Make the quad this many times taller than wide. Default 1.alpha?: Op— Opacity 0..1. Default 1.rotate?: Op— In-plane roll, degrees. Default 0.spin?: Op— Roll rate, degrees/s. Default 0.facing?: 'camera' | 'upright' | 'velocity' | 'flat'— How the quad faces: 'camera' (default), 'upright' (y-locked, for a flame), 'velocity' (stretched along travel, with the frame's width along the path, as a 2D particle's stretch, soparticle:sparkis a streak), 'flat' (the XZ plane, for a shockwave or a ripple, turned by the emitter'syaw,pitchandroll: pitch 90 stands it on a wall).stretch?: number— Withfacing: 'velocity', add this many world units of length per unit of speed. Default 0.color?: ColorOp— Colour over life, or per particle. Default white.hue?: number— Hue jitter per particle, ± degrees, on its colour or along its ramp. Default 0.frame?: FrameOp3d— The atlas frame(s). Defaultparticle:soft.blend?: 'normal' | 'add'— Blend for the whole emitter. Default 'normal'; 'add' is what makes fire and sparks glow.on?: { birth?: SubEmit3d; death?: SubEmit3d }— Sub-emitters by event.onEmit?: (p: ParticleView) => void— Called for each born particle.onDeath?: (p: ParticleView) => void— Called for each dying particle, whether of age, atkillBelowYor in thedeathZone.
Emitter3dHandle
type Emitter3dHandle = Handle & { emitter: Emitter3dApi }
An emitter is a roster object with an emitter sub-object.
EmitZone3d
interface EmitZone3d
Where particles are born: a shape, how it is filled, and optionally an outward launch.
shape: Shape3d— The shape.fill?: ZoneFill— Fill rule. Default 'volume'.radial?: boolean— Launch particles along the outward normal of the shape rather than byangle. Default false.quantity?: number— Withfill: 'edge'on apath,lineorring: particles are born IN ORDER along it, this many to a lap (a tracer running round a track, beads along a wire). Without it orstepRate, they land anywhere along it.stepRate?: number— Asquantity, but one every this many units along it.yoyo?: boolean— In order, back and forth along it rather than round again. Default false.
Force3d
type Force3d = /** A constant push, units/s². */ | { kind: 'accel'; x?: number; y?: number; z?: number } /** Velocity damping, 0..1 of the speed removed per second. */ | { kind: 'drag'; strength: number } /** A push that wanders — wind with gusts. */ | { kind: 'wind'; x?: number; y?: number; z?: number; gust?: number; speed?: number } /** Pull toward (or push from, with a negative strength) a point in emitter space. */ | { kind: 'attract'; x: number; y: number; z: number; strength: number; radius?: number } /** Swirl around an axis through a point — a tornado, a drain. */ | { kind: 'vortex'; x: number; y: number; z: number; strength: number; radius?: number; axis?: [number, number, number] } /** Orbit a point, keeping distance — sparks circling a core. */ | { kind: 'orbit'; x: number; y: number; z: number; strength: number } /** Curl noise: divergence-free swirl that makes smoke and fire lick rather than pour. */ | { kind: 'noise'; strength: number; scale?: number; speed?: number } /** Plain noise jitter — rougher than curl, good for embers and sparks. */ | { kind: 'turbulence'; strength: number; scale?: number; speed?: number }
A force applied to every particle each step, as data.
FrameOp3d
type FrameOp3d = string | number | ReadonlyArray<string | number> | { frames: ReadonlyArray<string | number>; cycle?: boolean }
An atlas frame, or a set to pick from.
Op
type Op = | number | readonly [number, number] | { min: number; max: number; int?: boolean } | { random: ReadonlyArray<number> } | { start: number; end: number; ease?: EaseName | EaseFn; random?: boolean } | { values: ReadonlyArray<number>; ease?: EaseName | EaseFn; interpolation?: 'linear' | 'catmull' | 'stepped' } | { steps: number; start: number; end: number; yoyo?: boolean } | { onEmit: (p: ParticleView, key: string, value: number) => number; onUpdate?: (p: ParticleView, key: string, t: number, value: number) => number }
A numeric op: constant · range · pick · eased over life · keyed over life · stepped per emission · callbacks.
Particles3dApi
interface Particles3dApi
game.particles3d.
emitter(config: Emitter3dConfig): Emitter3dHandle— Build an emitter from a config. An unknown frame name draws the atlas's frame 0 rather than throwing.effect(name: string, opts?: Effect3dOptions): Effect3dHandle— Spawn a registered effect by name. Throws naming the effects that are composed.readonly available: ReadonlyArray<string>— Every registered effect name.readonly defs: ReadonlyArray<Vfx3dDef>— The registered definitions, for a menu.readonly alive: number— Particles alive across every emitter.surface(verts: Float32Array): SurfaceSampler— Build a surface sampler over a triangle soup —geometry3d.build('sphere'), or a model part's vertices — foremitZone: { shape: { kind: 'mesh', surface } }. Build it ONCE and keep it: the area CDF is over the unit geometry, so it survives the object moving, turning and scaling.
Particles3dOptions
interface Particles3dOptions
Options for the pack.
maxEmitters?: number— Cap on emitters alive at once, default 64, checked only when a particle'son.birth/on.deathwould spawn a sub-effect: past it the sub-effect is skipped. Emitters and effects made by the game are never refused.
ParticleView
interface ParticleView
What a particle callback op sees: the particle's live fields, writable.
x: number— World x.y: number— World y — up.z: number— World z.vx: number— Velocity x, units/s.vy: number— Velocity y.vz: number— Velocity z.age: number— Seconds alive.life: number— Lifespan, seconds.t: number— Age / life, 0..1.rotation: number— In-plane roll, degrees.scaleX: number— Size x, world units.scaleY: number— Size y.alpha: number— Alpha 0..1.index: number— Index in the emitter's emission count.seed: number— Per-particle random in [0,1), fixed at birth.
Shape3d
type Shape3d = /** A single point. */ | { kind: 'point' } /** A ball of radius `r`; `inner` hollows it out. */ | { kind: 'sphere'; r: number; inner?: number } /** A box of half-extents. */ | { kind: 'box'; hx: number; hy: number; hz: number } /** A flat disc in the XZ plane (the ground), radius `r`, optional hole. */ | { kind: 'disc'; r: number; inner?: number } /** An upright cylinder: radius `r`, height `h`. */ | { kind: 'cylinder'; r: number; h: number; inner?: number } /** A cone opening upward: base radius `r` at y 0, apex at y `h`. */ | { kind: 'cone'; r: number; h: number } /** A ring in the XZ plane at radius `r`, thickness `t`. */ | { kind: 'ring'; r: number; t?: number } /** A straight line from `a` to `b`. */ | { kind: 'line'; a: [number, number, number]; b: [number, number, number] } /** * A POLYLINE: every point along a chain of segments, sampled by LENGTH so a long leg gets its share * and a dense corner does not hog them. It is what puts sparks along a wire, embers down a river or a * tracer around a whole track — feed it a path's own sampled points. */ | { kind: 'path'; points: ReadonlyArray<readonly [number, number, number]>; closed?: boolean } /** A flat rectangle in the XZ plane. */ | { kind: 'rect'; hx: number; hz: number } /** * A MESH's own surface — the shape that makes sparks crawl over a model rather than a box around it. * `scale` sizes the unit geometry to the object it belongs to; `radial` then launches along the face * normal, so a burning statue throws flame off its shoulders, not out of its middle. */ | { kind: 'mesh'; surface: SurfaceSampler; scale?: [number, number, number] }
A 3D shape, in emitter space. Every one is centred on the origin unless it says otherwise.
SubEmit3d
interface SubEmit3d
Spawn another effect when something happens to a particle.
effect: string— The registered effect name.chance?: number— Fraction of events that spawn one, 0..1. Default 1.inheritVelocity?: boolean— Give the child the particle's velocity. Default false.scale?: number— Scale the child by this. Default 1.
Vfx3dDef
interface Vfx3dDef
A registered 3D effect: emitters spawned together and anchored to one point.
name: string— The namegame.particles3d.effect()is called with.description?: string— One line for the planner.duration?: number— Seconds of emission for every emitter without adurationof its own; the effect is complete once each emitter has stopped and its particles have died. 0 leaves it to the emitters. Default 0.loop?: boolean— Every emitter without aloopof its own restarts after its duration, so the effect plays again and again. Default false.emitters: ReadonlyArray<Emitter3dConfig & { name: string }>— The emitters, drawn in order.
ZoneFill
type ZoneFill = 'volume' | 'surface' | 'edge'
How a shape is sampled: anywhere inside, on its surface, or evenly around its edge.


