The pack
particlesGpu3d
function particlesGpu3d(): Pack<{ gpuParticles: GpuParticles3dApi }>
The GPU particle pool: continuous fields simulated in a compute shader and drawn as one instanced call.
Requires a 3D world, render-3d (whose depth pass and camera it joins) and a WebGPU or WebGL2 backend. WebGL2 uses transform feedback, retaining GPU-resident simulation.
Functions
particleSimulationDelta
function particleSimulationDelta(now: number, previous: number | null, firstStep: number): number
Consume elapsed fixed simulation time once per rendered frame, independent of display refresh rate.
registerGpuField3d
function registerGpuField3d(k: { registry: { add(kind: string, def: GpuFieldDef): void } }, def: GpuFieldDef): void
A field pack's attach is one line: register def under the registry kind 'gpu-field' so gpuParticles.preset(def.name) finds it.
spawnBudget
function spawnBudget(acc: number, burst: number, rate: number, dt: number, count: number): { n: number; acc: number }
The spawn budget for one step, kept fractional so a rate below one particle a frame still emits: the leftover carries to the next step rather than being rounded away. Exported because it is the whole of the rate's arithmetic, and a test can drive it without a GPU.
-
acc carried-over fraction from the last step
-
burst particles queued by
burst()since the last step -
rate particles per second
-
dt seconds this step
-
count the pool size — the window can never exceed it
-
returns: how many to release now, and the fraction to carry
Constants
COUNT_WGSL
const COUNT_WGSL: "\nstruct P { pos: vec4f, vel: vec4f, misc: vec4f };\n@group(0) @binding(0) var<storage, read> parts: array<P>;\n@group(0) @binding(1) var<storage, read_write> count: atomic<u32>;\n@compute @workgroup_size(64) fn cs(@builtin(global_invocation_id) gid: vec3u) {\n if (gid.x >= arrayLength(&parts)) { return; }\n let ... …
Count real GPU state after simulation, including early collision kills and variable lifetimes.
RENDER_WGSL
const RENDER_WGSL: "\nstruct P { pos : vec4<f32>, vel : vec4<f32>, misc : vec4<f32> };\nstruct Cam {\n viewProj : mat4x4<f32>,\n eye : vec4<f32>,\n sunDir : vec4<f32>, // xyz = direction the light travels, w = intensity\n sunCol : vec4<f32>,\n skyCol : vec4<f32>, // w = ambient strength\n groundCol: vec4<f32>,\n ... …
GPU particle billboard shader, exported for render-contract verification.
WGSL_FOR_TEST
const WGSL_FOR_TEST: "\nstruct P { pos : vec4<f32>, vel : vec4<f32>, misc : vec4<f32> };\nstruct U {\n origin : vec4<f32>, // xyz origin, w time\n spawn : vec4<f32>, // xyz extents/radius, w kind (0 box 1 sphere 2 shell 3 disc 4 ring)\n wrapv : vec4<f32>, // xyz extents, w on\n motion : vec4<f32>, // xyz gravity, w... …
The simulation kernel, exported so a test can read its uniform LAYOUT rather than a copy of it. The struct's declaration order is the layout, and getting the TypeScript writer out of step with it writes the collision mode into the last force slot — which is what happened, so a test now derives the offsets from this text.
Types
GpuCollide3d
type GpuCollide3d = 'off' | 'bounce' | 'kill' | 'rest' | 'respawn'
WHAT A PARTICLE DOES WHEN IT MEETS THE SCENE, tested against the depth buffer — so a spark skitters down a staircase and rain lands on a roof with NO colliders to set up anywhere. It sees whatever is on screen.
GpuField3d
interface GpuField3d
A running GPU field. Mutate the fields freely — the uniform is repacked every frame.
readonly count: number— Particles in the pool.readonly liveCount: number— How many are ALIVE right now — 151 during a boom and 0 between them, wherecountis the pool and never moves. Counted from actual GPU state, including variable lifetimes and collision kills. One integer is read asynchronously at most five times per second, so this is the latest completed sample rather than a synchronous count of the current frame. Zero before the first sample and after kill(). NamedliveCountrather thanalivebecause a handle'saliveis a BOOLEAN everywhere else in the engine — "has this been killed" — and one field meaning two things is worse than a longer name.x: number— Origin x.y: number— Origin y.z: number— Origin z.timeScale: number— Sim speed; 0 freezes it.rate: number— Particles per second, live. Only meaningful on a field that was created with arate.burst(n: number): void— Releasenparticles on the next step, on top of the rate — a firework, a puff, a hit.forces: GpuForce3d[]— Live-mutable forces.life: [number, number]— Seconds a particle lives,[min, max]— LIVE, and safe to retune between bursts. It is a SPAWN-TIME parameter: it bakes into each particle at birth, so the ones already flying keep what they were born with and only the next burst sees the change. That is what lets one field play several effects — a short hot crack and a long shard storm off the same pool. The RENDER parameters (color,alpha,blend,shrink,twinkle) are not like this: they are read every frame for every particle, so changing one retints the burst already in the air.speed: [number, number]— Launch speed,[min, max]. Live and spawn-time, likelife.size: [number, number]— Quad half-size (radius),[min, max]. Live and spawn-time, likelife.gravity: { x: number; y: number; z: number }— Gravity, live.collide: GpuCollide3d— What it does when it meets the scene, live.kill(): void— Release the pool.readonly alive: boolean— Still running.
GpuField3dConfig
interface GpuField3dConfig
How one GPU particle field is set up.
collide?: GpuCollide3d— Collide against the scene's own depth:bounceoff it,killon contact,rest(bounce until the speed bleeds off, then settle where it lies), orrespawn— die at the surface and be instantly reborn, which is what makes one burst of rain fall forever. Defaultoff.bounce?: number— How much speed a bounce keeps along the normal, 0..1. Default 0.35.friction?: number— How much a bounce scrubs off across the surface, 0..1. Default 0.2.contact?: number— How near a surface counts as contact, world units. Default 0.35.count?: number— How many particles. Default 50_000.rate?: number— CONTINUOUS SPAWN RATE, particles/second. Omit it and the pool is simply all alive at once, recycling on death — which is what a field of rain or stars is. SET it and the pool becomes a ring an emitter feeds:rate × lifeparticles are alive in the steady state, the field FILLS instead of arriving as one sheet, andrate: 0gives an emitter that does nothing untilburst(). Ifrate × lifeexceedscountthe ring recycles its oldest, exactly as an over-subscribed pool must.x?: number— Emitter origin x. Default 0.y?: number— Origin y. Default 0.z?: number— Origin z. Default 0.follow?: 'camera' | false— Move the emitter to the camera every frame — the large-area recipe. Default false.offset?: { x?: number; y?: number; z?: number }— Offset from whatever it follows.spawn?: GpuSpawnShape— Where particles are born and re-born. Default a POINT at the origin. A field that fills a volume — weather, dust, a starfield — says its box out loud, and every preset does. The default is a point because the other way round fails silently: an emitter meant to be a plume scatters across forty units and reads as a bug in the forces rather than a missing shape.wrap?: boolean | { w?: number; h?: number; d?: number }— Fold positions torus-style into this box around the origin —trueuses the spawn box. A zero extent leaves that axis unwrapped, sowrap: trueon a field that never named aspawnbox wraps NOTHING: a wrapping field always states its volume, and that is the pairing to remember.life?: [number, number]— Seconds a particle lives,[min, max]. Default [4, 8].speed?: [number, number]— Launch speed,[min, max]. Default [0, 0].direction?: 'omni' | { x: number; y: number; z: number }— Launch direction; 'omni' samples a sphere independently of spawn position. Omitted launches outward (a point uses omni).spread?: number— Full cone angle arounddirection, degrees (uniform solid-angle sampling). Default 0.gravity?: { x?: number; y?: number; z?: number }— Constant acceleration, units/s². Default none.drag?: number— Velocity damping per second, 0..1. Default 0.forces?: ReadonlyArray<GpuForce3d>— Forces, up to eight.size?: [number, number]— Quad half-size in world units (radius),[min, max]. Default [0.1, 0.2].shrink?: boolean | number— Size over life:trueshrinks to nothing,falseholds it, a number is the final multiplier (2 grows). Default false.twinkle?: number— Flicker the opacity on a per-particle phase, 0..1 — embers, stars, bokeh. Default 0.stretch?: number— Elongate along velocity by this × speed, the picture's width along the path — rain streaks, star trails,particle:sparkstreaks. Default 0.flutter?: number— The falling-leaf rock: a horizontal oscillating acceleration with a per-particle direction and phase. Units/s²; try 1.5–3 with gentle gravity. Default 0.flutterFreq?: number— Flutter frequency, cycles/s. Default 0.7.tumble?: number— Fake the quad turning over: its across-axis squashes on a per-particle cosine, so a petal reads flat rather than as a disc. 0..1, default 0.tumbleSpeed?: number— Tumble rate, turns/s. Default 0.6.spin?: number— Spin the quad in its own plane, up to ± this many radians/s. Default 0.color?: ReadonlyArray<string>— Colour over life: up to eight stops. Default white.palette?: ReadonlyArray<string>— Up to eight colours, one picked PER PARTICLE and kept for life — fireworks, confetti, bokeh. It is the other thing a list of colours can mean, and it is notcolor: walking a firework's palette over life gives every shell the same muddy gradient instead of six distinct colours in the sky.alpha?: [number, number, number]— Opacity at birth, peak and death. Default [0, 1, 0].fade?: boolean | number— Optional final-life fade window: true fades over the whole life, false holds opacity, a number fades over that final fraction. Uses alpha[1] as opacity and overrides the triplet envelope. Omit to retain the triplet.frame?: string | number— The frame to draw, on any atlas page or in a picture's own texture. Defaultparticle:soft.blend?: 'normal' | 'add'— Additive glows, normal occludes. Default 'add'.softFade?: number— SOFT PARTICLES: fade a quad out as it approaches the geometry behind it, over this many world units. It is what removes the hard line where a field of smoke or snow slices into a wall — the single thing that stops a volume of particles reading as a sheet of stickers. 0 turns it off. Default 0.25. A RESTED particle is exempt: it lies ON the surface, so fading it against that surface erases it.soft?: number— Analytic untextured disc: 0 crisp, 1 Gaussian glow. Omit to sample theframe.restPose?: boolean— Freeze spin/tumble on rest and settle flat over 0.2s. Default false.nearFade?: number— Fade a particle out as it approaches the camera, over this many units; 0 = off. Stops a star or a raindrop filling the screen as it passes the eye. Default 0.timeScale?: number— Sim speed multiplier. Default 1.seed?: number— Random seed.
GpuFieldDef
interface GpuFieldDef
A registered field preset — what a pack ships.
name: string— The namegpuParticles.preset()is called with.description?: string— One line for the planner.config: GpuField3dConfig— The config it starts from;preset()may override any of it.
GpuForce3d
type GpuForce3d = /** A constant push, units/s². */ | { kind: 'accel'; x?: number; y?: number; z?: number } /** Wind with a wandering gust. */ | { kind: 'wind'; x?: number; y?: number; z?: number; gust?: number; speed?: number } /** Velocity damping per second, 0..~5 — what stops a field accelerating for ever. */ | { kind: 'drag'; amount?: number } /** Curl noise: divergence-free swirl. `scale` is features per world unit, `speed` how fast the field itself evolves. */ | { kind: 'noise'; strength: number; scale?: number; speed?: number } /** Pull toward a point in emitter space (negative pushes away). `radius` softens the falloff so a particle at the centre is not flung. */ | { kind: 'attract'; x: number; y: number; z: number; strength: number; radius?: number } /** Swirl about an axis through a point — a tornado. The axis defaults to +Y. */ | { kind: 'vortex'; x: number; y: number; z: number; strength: number; ax?: number; ay?: number; az?: number } /** Orbit a point: a tangential drive plus a spring toward the `radius` shell — accretion discs, halos. */ | { kind: 'orbit'; x: number; y: number; z: number; strength: number; radius?: number; spring?: number }
A force on the whole field, as data. Up to eight are applied per step.
GpuParticles3dApi
interface GpuParticles3dApi
game.gpuParticles.
field(config: GpuField3dConfig): GpuField3d— Start a field from a config.preset(name: string, overrides?: Partial<GpuField3dConfig>): GpuField3d— Start a registered preset, optionally overriding parts of it.readonly available: ReadonlyArray<string>— Every registered preset name.readonly defs: ReadonlyArray<GpuFieldDef>— The registered definitions, for a menu.readonly fields: ReadonlyArray<GpuField3d>— Every running field.readonly total: number— Particles across every field's POOL — the ceiling, which never moves.readonly liveCount: number— Particles ALIVE across every field. This is the number a panel wants.
GpuSpawnShape
type GpuSpawnShape = /** Anywhere in a box of these half-extents. */ | { kind: 'box'; hx: number; hy: number; hz: number } /** Anywhere in a ball. */ | { kind: 'sphere'; r: number } /** On a ball's surface — a starfield shell, an omni burst. */ | { kind: 'shell'; r: number } /** A flat disc in the XZ plane. */ | { kind: 'disc'; r: number } /** The disc's rim only — a hollow column, which is what a tornado or a smoke ring is. */ | { kind: 'ring'; r: number; /** Vertical half-extent of birth jitter. Default 0. */ hy?: number }
How particles are born and re-born.


