engine
Deprecated.** This belongs to the earlier particle substrate. It stays loadable by identity and guid for content that already uses it; nothing new should use it, and search does not offer it. Particles are procgen sim nodes (`sim.spawn`, `sim.particles`, `sim.shader`) played thro…
modules.particles — Generic GPU-driven Particle System (deprecated)
Deprecated. This belongs to the earlier particle substrate. It stays loadable by identity and guid for content that already uses it; nothing new should use it, and search does not offer it. Particles are procgen sim nodes (
sim.spawn,sim.particles,sim.shader) played through an effect: use@builtin::assetTypes.effect.
A generic, GPU-driven particle substrate for VFX. Every active particle lives
in a GPU storage buffer; physics runs entirely as a compute dispatch each
frame; per-frame vertex output feeds the renderer directly via
renderer.mesh.create — no CPU readback, no re-upload. Spawning happens on
the CPU (cost bounded by the emission rate, not the active particle
count) and writes new particle slots directly into the GPU state buffer.
It is the substrate behind the ParticleEmitter component but works equally
well as a script-side API for hand-rolled effects, custom WGSL update
shaders, and per-particle scripted state.
Quick start
local particles = require("@builtin::deprecated.deprecatedParticles.engine")
local NumberSequence = particles.NumberSequence
local ColorSequence = particles.ColorSequence
local fire = particles.create({
maxCount = 2000,
rate = 120, -- particles/sec
lifetime = { 0.8, 1.6 },
speed = { 1.5, 3.0 },
shape = { kind = "cone", size = {0.6, 1.2, 0.6}, partial = 0.4 },
gravity = { 0, 1.5, 0 }, -- buoyant
drag = 0.4,
size = NumberSequence.new({
{ time = 0, value = 0.25 },
{ time = 0.3, value = 0.7, envelope = 0.15 },
{ time = 1, value = 0.0 },
}),
color = ColorSequence.new({1, 0.9, 0.3}, {1, 0.2, 0.05}),
transparency = NumberSequence.new(0, 1),
blendMode = "additive",
brightness = 2.0,
})
-- Each frame, in your update tick:
fire:setOrigin(torch.position.x, torch.position.y, torch.position.z)
fire:update(dt)
For a declarative wrapper that lives on an entity, use the
DeprecatedSimpleParticleEmitter component (deprecated/deprecatedParticles.package/DeprecatedSimpleParticleEmitter.component).
Why GPU-driven
| CPU per-particle | GPU per-particle | |
|---|---|---|
| 1 000 particles | OK | OK |
| 10 000 particles | starts skipping frames | trivial |
| 100 000 particles | impractical | trivial |
| 1 000 000 particles | impossible | feasible on mid-tier GPU |
| Custom physics | per-particle Lua call | one WGSL update shader |
| Trail / mesh particles | extra CPU work | extra WGSL pass |
The substrate also leapfrogs Roblox-class engines on the long list of features they can't offer in their CPU-only model: mesh particles, particle ↔ world collision, neighbour queries, custom force fields, curl noise.
Architecture
Storage buffers, per system
Every system holds its particle state and its params. What it holds beyond that depends on how it draws.
| Buffer | Size | Written by | Read by |
|---|---|---|---|
<id>_state | maxCount * 64 B | CPU spawn, GPU update | GPU update |
<id>_params | 1556 floats (~6 KB) | CPU each frame + on change | GPU update |
A sprite emitter builds a quad per particle:
| Buffer | Size | Written by | Read by |
|---|---|---|---|
<id>_verts | maxCount * 4 * 92 B (GPU vertex buffer, carrying the per-particle channel in its tangent words) | GPU update | renderer, and the material's fragment stage |
<id>_indices | maxCount * 6 * 4 B (GPU index buffer) | CPU once at create | renderer |
The vertex + index buffers are created with vertex=true/index=true
flags so renderer.mesh.create can wrap them as a renderable mesh with
zero CPU copy. The renderer binds the same GPU allocation the compute
shader just wrote to.
A mesh emitter uploads its source mesh once and draws it once per particle, so it holds a placement and a colour instead of geometry:
| Buffer | Size | Written by | Read by |
|---|---|---|---|
<id>_xforms | maxCount * 64 B (one column-major 4x4 per particle) | GPU update | renderer |
<id>_instdata | maxCount * 64 B (four vec4 shader-data lanes per particle) | GPU update | the material's fragment stage |
Those two buffers are what renderer.mesh.drawInstanced reads: the matrices
place each copy, and the lanes arrive in the fragment stage as
input.shader_data[lane], which is how copies sharing one mesh and one material
each keep their own colour. 100 000 cube particles (24 verts) hold 12.8 MB this
way, against the 220 MB the same emitter would hold as replicated geometry.
What one particle carries into its material
A material a particle system draws with reads four numbers belonging to the
particle covering the fragment — its life (normalised age, 0 → 1), its own
seed in [0, 1] fixed for its whole life, the world-space size it is drawn
at, and alive. Include the reader and take them off either emitter kind:
#include "@builtin::deprecated.deprecatedParticles.particle_data"
fn fragment(input: FragmentData) -> vec4<f32> {
let p = particle_sprite_data(input); // particle_mesh_data for a mesh emitter
let flicker = 0.6 + 0.4 * sin(input.time * 8.0 + p.seed * 6.2831);
return vec4<f32>(input.vertex_color.rgb * flicker, input.vertex_color.a * (1.0 - p.life));
}
A sprite emitter carries the four in the four words of each vertex the render
pipeline reads as the tangent, a mesh emitter in the instance lane beside its
colour, and particle_layout declares the order both compute passes pack. The
seed is what lets a population vary out of unison: it differs between the
particles of one emitter and reproduces for the same particle at any age, so a
noise phase or an atlas frame chosen from it holds still while that particle
lives. @builtin::deprecated.deprecatedParticles.fx.fx_noise takes it as a phase or an offset.
renderer.instanceData is the per-ENTITY channel: an emitter is one entity, so
the lanes it sets there are shared by every particle the emitter draws.
Per-particle state (16 f32 = 64 bytes)
[0..2] position (xyz)
[3] age (seconds since spawn; >= lifetime means dead)
[4..6] velocity (xyz)
[7] lifetime (seconds)
[8..10] tint rgb (per-particle multiplicative tint; default 1)
[11] base size (sampled at spawn)
[12] rotation (degrees, current)
[13] rot speed (degrees/sec)
[14] per-particle seed in [0, 1] (used for envelope sampling)
[15] reserved / user data
Params buffer layout
The first 32 floats are per-frame data; the next 32 are a user-controlled
param block for custom WGSL update shaders; the next 4 × 49 floats are
NumberSequence packs (size, transparency, squash, rotSpeed); the final 129
floats are the ColorSequence pack. See init.luau's PARAM_LAYOUT for
the canonical offsets and particle_layout.shaderModule for the matching
WGSL constants. Past the ColorSequence pack come the collision header, the
collider records, and the grid a bound world distance field covers.
Frame loop
1. CPU: spawn pending particles → writeBuffer on state buffer
2. CPU: pack per-frame uniforms → writeBuffer (first 32 floats only) on params
3. GPU: dispatch update shader → reads state, integrates, samples curves,
and writes this particle's output: a sprite
emitter's 4 vertices, or a mesh emitter's
matrix + shader-data lanes; collapsed to
nothing with area if dead
4. GPU: renderer draws the mesh — as an entity's model for a sprite emitter,
as a population for a mesh emitter
Vertex buffer is rebuilt every frame from the latest particle state. Dead particles emit a zero-area quad that the rasterizer drops for sub-pixel cost. That quad sits on the particle's own position, and a slot that has never carried a particle holds the emitter's origin — the vertex buffer is part of the scene's geometry, so a pass that gathers it (a distance field, a bake, a BVH) covers an extent the emitter actually occupies.
Curves: NumberSequence + ColorSequence
-- constant
NumberSequence.new(1.0)
-- two-point lerp
NumberSequence.new(0, 1)
-- full keypoint table (up to 16 keypoints; envelope is ± range per keypoint)
NumberSequence.new({
{ time = 0, value = 0.0 },
{ time = 0.3, value = 1.0, envelope = 0.2 },
{ time = 1, value = 0.0 },
})
-- a number keypoint list also takes its entries written positionally
NumberSequence.new({ { 0, 0.0 }, { 0.3, 1.0, 0.2 }, { 1, 0.0 } })
ColorSequence.new({ 1, 0.5, 0 }) -- constant
ColorSequence.new({ 1, 0, 0 }, { 0, 0, 1 }) -- lerp
ColorSequence.new({
{ time = 0, value = { 1, 1, 1 }, envelope = { 0.1, 0.0, 0.1 } },
{ time = 1, value = { 0, 0, 0 } },
})
-- a keypoint list also takes its entries written positionally, and a list of
-- bare colours spreads its stops evenly across [0, 1]
ColorSequence.new({ { 0, { 1, 1, 1 } }, { 1, { 0, 0, 0 } } })
ColorSequence.new({ { 1, 0.85, 0.35 }, { 1, 0.35, 0.05 } })
Every field of a keypoint reaches the GPU pack as one float, so a time, value, channel or envelope that is not a number is refused by the constructor, naming the keypoint's index and the types it read. Both classes pack a fixed 16 stops: a longer list is resampled down to 16 keeping both endpoints, and the resample logs a warning naming the cap and how many stops it dropped.
Sampling:
seq:evaluate(t) -- deterministic
seq:sample(t) -- envelope-randomized (math.random under the hood)
seq:sample(t, seed) -- envelope-randomized with a specific seed
The GPU shader samples each curve per particle per frame using the per-particle seed stored at state slot 14, so envelope randomness is deterministic across frames for a given particle and runs with no per-frame CPU cost.
Emission shapes
shape = {
kind = "cone", -- "point" / "box" / "sphere" / "cylinder" / "disc" / "cone"
size = { x=1, y=1, z=1 },
style = "volume", -- "volume" | "surface"
inOut = "outward", -- "outward" | "inward" | "inandout"
partial = 0, -- shape-specific (hemisphere cap, annulus inner, taper)
spreadAngle = 0, -- cone broadening around the sampled direction, degrees
}
Sampling runs on the CPU at spawn time only — the cost is per emitted
particle, not per active particle. Shapes are pure functions in
shapes.module/init.luau.
Collision
An emitter meets things when collision names what a hit does — "bounce",
"stop" or "kill" — and its collider list says what it can hit:
local sys = particles.create({
rate = 400, lifetime = 6, speed = 0, gravity = { 0, -9.81, 0 },
collision = "bounce", restitution = 0.3, friction = 0.4,
colliders = {
{ kind = "world" }, -- the scene itself
{ kind = "sphere", position = { 0, 2, 0 }, radius = 1.5 },
},
})
plane, sphere and box are shapes the emitter carries — eight at most,
because they ride the params buffer the update shader already binds.
world is the scene's own geometry: terrain, stairs, a rock, anything nobody
described as a shape. It reads the distance field sceneProxy builds — the
same coarse volumetric stand-in long-range lighting queries use — so contact
costs one texture read wherever a particle happens to be, on- or off-screen.
The first emitter to ask for one builds it (worldResolution voxels per axis,
64 by default); after the scene changes, sceneProxy.refresh() rebuilds it and
the emitter picks the new grid up on its next update.
Two things follow from the field being coarse: contact lands within about one
voxel of the true surface — raise worldResolution or the collider's radius
to place particles where you want them — and a particle that starts deep inside
geometry has no direction to be pushed out along, so it is pushed up.
The field is unsigned — it says how far the nearest surface is, not which side
of it a point is on — so the world collider resolves against the segment the
particle travelled this frame rather than against where it ended up. The march
starts from the position the particle held last frame, which the previous
resolve left outside, and stops where that path first comes within radius of a
surface. Contact therefore lands on the side the particle approached from, and a
step of any length is caught rather than carrying the particle through.
The world collider is resolved by the built-in sprite update. A mesh emitter runs the mesh-instance update, which integrates without resolving colliders, so every collider it carries reports no contact. An emitter running its own WGSL binds no distance field, so the world collider reports no contact there while its other colliders resolve as that shader resolves them. Both are said out loud, once per emitter, the first time one runs.
Shadow casting and render layers
An emitter declares whether its particles block light and which render layers they draw on. Both reach the sprite path and the mesh path through the same value, so an emitter's kind decides neither one:
local debris = particles.create({
mesh = "cube", maxCount = 300,
castsShadows = true,
renderLayer = "vfx",
})
debris:setCastsShadows(false)
debris:setRenderLayer("vfx fpsWeapon")
print(debris:getRenderLayer()) -- "vfx fpsWeapon"
print(debris:observe().castsShadows) -- false
castsShadows defaults to false. A caster is drawn into the shadow map as
the geometry it is — a sprite emitter's quads as quads, a mesh emitter's copies
as copies — so a soft alpha card casts the hard outline of its whole quad. That
is right for debris, rubble and solid mesh copies, which declare it, and wrong
for the smoke, dust and spark sheets that make up most of what an emitter draws,
which leave it alone.
renderLayer takes a layer name, an array of names, or a space-separated
string, and defaults to "default" — the layer a renderable with no layer of
its own sits on. A camera or a capture whose include spec names the layer draws
the emitter and one excluding it does not, so a reflection probe, a portal
camera, a minimap, a clean screenshot or an agent's capture takes the scene
with the effect in it or without:
capture({ renderLayers = "all !vfx" }) -- the scene, no emitters
Naming a layer interns it, which is what puts it in renderLayer.list and makes
it addressable from a capture's include spec.
Both are readable back — :getCastsShadows(), :getRenderLayer() — and both
are reported by observe(), so what an emitter declares is checkable without
capturing a frame and without reaching for the render host.
Depth fade
A particle card that runs into a floor, a wall or a prop draws a straight line
along the intersection, which is what makes a large soft sprite read as a flat
billboard. depthFade is the distance, in world units, over which the card
dissolves into that surface instead:
local smoke = particles.create({
maxCount = 400, rate = 60, lifetime = { 2, 4 },
size = NumberSequence.new(0.5, 3),
depthFade = 1.5, -- world units
})
smoke:setDepthFade(2.5)
print(smoke:getDepthFade()) -- 2.5
print(smoke:observe().depthFade)
The quantity the distance is compared against is the straight-line distance
between a fragment and the surface already drawn behind it, both taken in world
space — so the ramp covers the same number of metres however far the camera
stands from it. A fragment at least depthFade in front of that surface keeps
its alpha whole; one standing on the surface is fully transparent; and one with
nothing behind it keeps its alpha, there being nothing to dissolve into.
The default is 0, which is off: an emitter that declares no fade draws through a program that reads no depth at all, and the frame it draws in copies none for it. Both emitter kinds take it — a sprite emitter's card and a mesh emitter's copy alike. The value has to be a finite number at or above 0; anything else is refused where it is declared rather than accepted and thrown once a frame.
An emitter drawing an authored material (material = ... / :setMaterial)
shades through that material's own shader, and fades over the distance that
shader reads from the material's own depth_fade. The emitter says so once,
because the material is shared by every entity drawing it and holds one distance
for all of them. A particle material carries a fade by declaring a depth_fade
float property, turning its shader's DEPTH_FADE feature on, and shading
through a shader whose block for that feature includes zero::scene_depth and
scales its alpha by zero_scene_depth_fade(frag_coord.xy, world_position, depth_fade). particle_sprite.shader is the worked form.
Custom WGSL update shaders
Pass updateShader = "/source/path/to/file.wgsl" to particles.create.
The shader must match the binding layout:
// sprite emitter
@group(0) @binding(0) var<storage, read_write> particles: array<f32>;
@group(0) @binding(1) var<storage, read_write> vertices: array<u32>;
@group(0) @binding(2) var<storage, read_write> params: array<f32>;
// mesh emitter
@group(0) @binding(0) var<storage, read_write> particles: array<f32>;
@group(0) @binding(1) var<storage, read_write> transforms: array<f32>;
@group(0) @binding(2) var<storage, read_write> instance_data: array<f32>;
@group(0) @binding(3) var<storage, read_write> params: array<f32>;
Inside the shader, read per-frame data from the params array at the
offsets documented in particle_update.computeShader/shader.wgsl (and exported
as Lua constants from particles.PARAM_LAYOUT). A sprite shader writes the
engine's standard 23-u32 Vertex layout into vertices; a mesh shader writes a
column-major 4x4 per particle into transforms and four vec4 lanes per
particle into instance_data, the shape
particle_mesh_instance.computeShader/shader.wgsl documents.
User uniforms live at params[32..63]. Set them from Luau:
system:setUserParam(1, 0.5) -- writes params[32] = 0.5
system:setParam("user1", 0.5) -- same
See demos/particles_showcase/ParticlesShowcase.component/init.luau for a
worked example with a curl-noise field driving 4000 particles.
Performance notes
-
Compute pass cost is proportional to
maxCount, not to active count — dead slots still execute one workgroup invocation each, but only do the dead-check + degenerate-quad write (~10 instructions). -
CPU spawn cost scales with the per-frame emit count (
rate × dt + bursts), not with maxCount. A typical effect doing 100 particles/sec at 60 fps spends ~1.7 spawns/frame on the CPU. -
GPU memory is dominated by the vertex buffer at
368 B/particle. For 100 k particles that's ~37 MB. For 1 M particles ~370 MB — still fits on a mid-tier GPU but profile first. -
Sorting: a sprite emitter that alpha-blends rewrites its own index buffer every frame, on the GPU, into the order that walks its particles back to front against the camera the frame is drawn from. While the emitter can be holding at most 2 048 live particles it does that in ONE compute dispatch (
particle_sort_local: one workgroup sorts the live particles' depth keys in workgroup memory and writes the index buffer), so a step costs two dispatches, the simulation and the sort, whatevermaxCountis, and the sort holds 32 B of buffers. Past 2 048 live it orders them by radix: a key pass, three dispatches per eight bits of key and the index write, eight dispatches at the default bucket count, overmaxCount, in buffers of 12 B/particle plus a 1 KB digit histogram for every block of 256 slots. What a sorted emitter spends is dispatch overhead far more than per-particle work, so the dispatch count is the cost to read: eleven emitters of 2 100 slots holding ~800 particles each spend 22 dispatches a frame rather than 99.sys:observe()reportssortPath("local"or"radix"),dispatchesPerStepfor the population the emitter holds now,lastStepDispatches, and the running totalscomputeDispatches,sortDispatchesandlocalSorts. An emitter that adds skips all of it: addition commutes, so its draw order cannot change its image.sort = falseon the spec (orsys:setSorted(false)) turns it off;sort = trueturns it on for an additive emitter.sys:observe().sortedreports which way an emitter resolved, andgetGpuBytes().sortwhat the buffers cost it.The order is a total order on (depth bucket, slot): two particles the bucketing cannot tell apart keep slot order between them, and both paths produce the same order, byte for byte in the index buffer.
sortBucketsis that resolution: 65535 by default, each bucket spanning 0.018% of its own depth, which at five metres is under a millimetre. The one-dispatch sort needs the bucket and the slot index to fit 32 bits together, which the default does for every emitter of up to 65 536 slots. On the radix path every eight bits of bucket index is one more radix pass and three more dispatches, sosortBuckets = 255sorts in five dispatches instead of eight at 2.7% of depth per bucket.simulatereorders once at the end of the prewarm rather than once a step: each step rewrites the index buffer the step before it wrote, so only the last step's order can reach a draw.What this orders is one emitter's particles against each other. Two emitters are two draws, and the renderer orders draws rather than the particles inside them, so two overlapping alpha plumes still composite by draw order. Particles composite against opaque geometry through the depth test, as they always did, and against other transparent surfaces by draw order. A mesh emitter draws instanced copies rather than an index buffer, and is not sorted.
-
Frustum culling: a sprite emitter's mesh AABB is set to ±10 000 m so per-emitter culling never trims active particles — emitter-level culling happens at the entity level via the host entity's transform. A mesh emitter's copies are culled one by one, each against the source mesh's own extent under that particle's matrix. A dead particle's matrix is zero, which parks its copy at the world origin with no area — it survives the cull whenever the origin is on screen and then emits no fragments. The submitted count is the slots the emitter has ever filled, not its cap: emission walks a write pointer from slot 0, so an emitter with a cap of 100 000 that has spawned five particles submits five instances.
Roadmap (follow-up issues)
The substrate is intentionally minimal. The follow-up issues track features that build on top:
| Issue | Layer |
|---|---|
| #2674 ParticleEmitter component (38-property Roblox-shape surface) | shipped here as deprecated/deprecatedParticles.package/DeprecatedSimpleParticleEmitter.component |
| #2682 GPU compute backend (VFX-Graph-shaped) | shipped here |
| #2673 NumberSequence + ColorSequence Luau bindings | shipped here in Luau (Rust FFI is the future home) |
| #2679 Emission shapes | shipped here |
| #2680 Orientation modes | FacingCamera + FacingCameraWorldUp + VelocityParallel + VelocityPerpendicular shipped here |
| #2678 Flipbook texture animation | future — needs texture-atlas UV math |
| #2684 Particle ↔ world collision | analytic shapes and the scene's own geometry shipped here; see "Collision" below |
| #2685 Force fields / curl noise | curl-noise demoed via custom WGSL; first-class ForceField component is future work |
| #2681 LightEmission / LightInfluence HDR | brightness shipped; LightInfluence (read GI) future |
| #2683 Mesh particles | shipped here — one instanced draw of the source mesh, placed and coloured per particle by the compute pass |
| #2690 VFX editor (curve inspector, profiler) | future |
| Back-to-front sorting for alpha-blended emitters | shipped here — see "Sorting" above |
API reference
See the --!desc doc-comments at every public method in init.luau,
curves.module/init.luau, and shapes.module/init.luau for the
authoritative API surface. lsp.describe("particles/create") and
lsp.search("particles") from the engine also work after the engine has
indexed the module.
Interface
What this asset declares: the schema it conforms to, what it exposes, and the rendered structured payload.
conforms to
zero/source-extract/v2module Particles Generic GPU-driven particle system — compute-shader simulation, zero-copy rendering. require deprecated/deprecatedParticles.package/engine method create(spec: table) -> ParticleSystem Allocate a new particle system: GPU buffers, registered compute shader, renderer.mesh.create-wrapped renderable. See the `about` block for the full spec field list. Returns a handle whose methods are summarised below. method ParticleSystem:emit(count: number) One-shot burst of N particles, independent of `rate` / `enabled`. method ParticleSystem:update(dt: number) Step the simulation. Drives continuous emission, dispatches the compute update pass, and re-packs per-frame uniforms. Call once per frame. method ParticleSystem:setRate(r: number) Set continuous emission rate (particles/sec). method ParticleSystem:setEnabled(b: boolean) Pause / resume continuous emission. Does not kill live particles. method ParticleSystem:setVisible(on: boolean) Whether the population the emitter holds reaches the frame. Emission is `setEnabled`; the draw is this, and a hidden emitter keeps its particles. method ParticleSystem:isVisible() -> boolean Whether the emitter's population reaches the frame. method ParticleSystem:setOrigin(x, y, z: number) Move the emitter to a new world-space origin. method ParticleSystem:setDirection(x, y, z: number) Aim emission: the shape's local +Y axis maps onto this world-space vector (positions and initial velocities both rotate). Pass nil to reset to the native +Y frame. method ParticleSystem:play(restart?: boolean) Start (or re-arm) the emission timeline: resets the clock to -delay, re-arms scheduled bursts, and enables emission. `restart = true` also clears live particles. method ParticleSystem:stop(clearParticles?: boolean) Stop emitting. Live particles finish their lifetime unless `clearParticles = true`. method ParticleSystem:isPlaying() -> boolean True while the timeline emits: enabled, past `delay`, and inside `duration` (or looping / untimed). method ParticleSystem:simulate(t: number, stepDt?: number) Deterministically advance the system by `t` seconds in fixed steps (default 1/60). Drives the full update — timeline, bursts, compute passes — so a system can be prewarmed or scrubbed to a known state. method ParticleSystem:setBursts(bursts: table) Replace the scheduled-burst list ({ {time, count}, ... }); nil/empty removes the schedule. method ParticleSystem:setGravity(x, y, z: number) Set the gravity vector (m/s^2). Default {0, -9.81, 0}. method ParticleSystem:setWind(x, y, z: number) Set the wind vector (m/s). method ParticleSystem:setAcceleration(x, y, z: number) Set the constant acceleration vector (combined with gravity + wind). method ParticleSystem:setDrag(d: number) Set the velocity damping factor (per-second exponential decay). method ParticleSystem:setLifetime(min: number, max?: number) Per-particle lifetime range in seconds. method ParticleSystem:setSpeed(min: number, max?: number) Per-particle initial-speed range. method ParticleSystem:setRotation(min: number, max?: number) Per-particle initial rotation range (degrees). method ParticleSystem:setRotSpeed(min: number, max?: number) Per-particle rotation-speed range (degrees/sec). method ParticleSystem:setSize(value: NumberSequence | number | {min, max} | {table}) Size, read the way `create` reads its `size`: a curve — a NumberSequence or the keypoint list `{{time, value, envelope?}, ...}` one is built from — is sampled per particle every frame on the GPU over base 1, and a number or `{min, max}` pair is the base each spawn samples under a constant curve of 1. Either way a particle draws at the size that was written. method ParticleSystem:setTransparency(value: NumberSequence | number | {start, finish} | {table}) Transparency curve (0 = opaque, 1 = invisible). A `{start, finish}` pair of numbers is read as a two-stop ramp — a table holding one number as that value across the whole life — and a keypoint list as the curve through its stops. method ParticleSystem:setSquash(value: NumberSequence | number | {start, finish} | {table}) X-dimension scale curve (>1 stretches, <1 squashes). Written the same four ways as the transparency curve. method ParticleSystem:setRotSpeedCurve(value: NumberSequence | number | {start, finish} | {table}) Rotation-speed curve — multiplies each particle's own `rotSpeed` across its life. Written the same four ways as the transparency curve. method ParticleSystem:setColor(value: ColorSequence | {number}) Per-particle color curve. A `{number}` array `{r, g, b}` is read as a constant color, and a list of such arrays as a ramp through them. method ParticleSystem:setShape(opts: table) Replace the emission shape — `{ kind, size, style, inOut, partial, spreadAngle }` (any subset). Existing values are kept for omitted fields. method ParticleSystem:setOrientation(mode: string) "FacingCamera" | "FacingCameraWorldUp" | "VelocityParallel" | "VelocityPerpendicular". method ParticleSystem:setBlendMode(mode: string) "alpha" (default) or "additive". method ParticleSystem:setSorted(on: boolean?) State whether this emitter reorders its own particles back to front every frame. `nil` hands the choice back to the blend mode. method ParticleSystem:isSorted() -> boolean Whether this emitter reorders its own particles back to front every frame. method ParticleSystem:setDepthFade(distance: number) Set the distance, in world units, over which this emitter's particles dissolve into the surface drawn behind them. 0 turns the fade off. method ParticleSystem:getDepthFade() -> number That distance. method ParticleSystem:setLightInfluence(share: number) How much of the scene's light these particles take, from 0 to 1. method ParticleSystem:getLightInfluence() -> number That share. method ParticleSystem:setLightEmission(share: number) How much of itself each particle emits whatever the scene's light is, from 0 to 1. At 1 a particle is self-illuminated. method ParticleSystem:getLightEmission() -> number That share. method ParticleSystem:setTexture(texture: string) Set the sprite texture (asset id or VFS path). method ParticleSystem:setMaterial(materialName: string) Replace the material applied to the render entity entirely. method ParticleSystem:setUserParam(index: number, value: number) Write one slot (1..32) of the 32-float user param block. Custom WGSL update shaders read these at `params[31 + index]`. method ParticleSystem:setParam(name: string, value: number) Generic setter — matches PARAM_LAYOUT keys or "user<N>" for the user block. method ParticleSystem:clear() Kill every live particle by re-zeroing the state buffer, and take them out of the frame on the same call. method ParticleSystem:destroy() Release every GPU buffer and the render entity. Idempotent. method ParticleSystem:getActiveCount() -> number How many particles are alive right now: the slots whose lifetime has not elapsed under the simulated time this emitter's dispatches have advanced. Falls to 0 when the last particle ages out. method ParticleSystem:getSpawnedCount() -> number How many slots the emitter has filled since the last `clear()`, saturating at `maxCount`. The emission schedule's own total, and the span the draw covers. method ParticleSystem:getSimulatedTime() -> number Seconds of simulation this emitter's dispatches have advanced. Every particle's age is measured against this clock. method ParticleSystem:getMaxCount() -> number Buffer cap. method ParticleSystem:getRenderEntity() -> string? Entity id of the renderable a sprite emitter spawns at world origin; nil for a mesh emitter, which draws as a GPU population instead. method ParticleSystem:getGpuBytes() -> table Every GPU buffer this emitter holds, by name, plus their `total`. method ParticleSystem:requestCensus() Ask the GPU to count its own live slots on the next dispatch. The answer arrives on a later frame and reads back through `observe().confirmation`. method ParticleSystem:observe() -> table Everything the engine holds for this emitter in one document: population, capacity, timeline, GPU bytes, render-side liveness, the GPU's own confirmation of the population, and — when it is producing nothing — the reason from a closed set. method M.list() -> {ParticleSystem} Every live particle system this VM has created, newest last. method M.observe(system: ParticleSystem?) -> table One emitter's document, or — with no argument — the whole world's: `{ emitters, count, alive, capacity, spawned, silent, bytes, frame }`. method M.whySilent(system: ParticleSystem) -> (string?, string?) The reason one emitter is producing nothing and the detail line naming what it is about, or nil when it is producing. method M.silenceReasons() -> table The closed set of reasons an emitter can be producing nothing, in the order they are resolved, each with what it means. about A generic GPU particle substrate for VFX. Every active particle lives in a GPU storage buffer; the simulation runs entirely as compute dispatches; the per-frame vertex output is consumed directly by the renderer via `renderer.mesh.create` (no readback, no CPU re-upload). Spawning happens on the CPU (the bookkeeping cost is bounded by emission rate, not particle count) and writes new particle slots straight into the GPU state buffer. The module is one-stop: the high-level `ParticleEmitter` component declared elsewhere in `src/lua/lib/components/` is a thin wrapper around this API. Usage: local particles = require("@builtin::deprecated.deprecatedParticles.engine") local fire = particles.create({ maxCount = 2000, rate = 100, -- particles/sec lifetime = { 0.8, 1.6 }, speed = { 2.5, 4.0 }, shape = { kind = "cone", size = {1, 1, 1}, partial = 0.3 }, gravity = { 0, 1.5, 0 }, -- buoyant drag = 0.6, size = NumberSequence.new({ { time = 0, value = 0.4 }, { time = 0.3, value = 1.0, envelope = 0.2 }, { time = 1, value = 0.0 }, }), color = ColorSequence.new({ 1, 0.9, 0.3 }, { 0.8, 0.1, 0 }), transparency = NumberSequence.new(0, 1), texture = "@builtin::textures.fire_sprite", blendMode = "additive", }) -- Each frame: fire:setOrigin(torch.position.x, torch.position.y, torch.position.z) fire:update(dt) See `references/luau-cookbook.md#particles` (zero-engine skill) for more recipes — explosion bursts, magic swirls, custom WGSL update injection. constant PARTICLE_STRIDE_FLOATS Per-particle state stride (16 f32 = 64 bytes). constant MAX_PARTICLES_DEFAULT Default `maxCount` (5000).
warn(msg: ?) → void
| arg | type | description |
|---|---|---|
| msg | ? |
resolveBlendMode(mode: any) → string
The mode an emitter ends up drawing with. A name no material can carry says so and leaves the emitter on alpha, so `blendMode` reads back as the equation reaching the frame rather than as the word that was asked for.
| arg | type | description |
|---|---|---|
| mode | any |
readDepthFade(value: any) → void
The distance, in world units, over which an emitter's particles dissolve into the surface drawn behind them. 0 is off — the emitter draws through a program that reads no depth at all. Answers the distance, or the reason the value is not one. A malformed fade is refused where it is declared rather than accepted and thrown on every frame that would have drawn it.
| arg | type | description |
|---|---|---|
| value | any |
readTurbulence(value: any) → void
A turbulence declaration: `{ strength, scale, speed }`, or a bare number read as the strength over a one-metre field drifting at half a cell a second. Answers the three numbers, or the reason the value is not a declaration.
| arg | type | description |
|---|---|---|
| value | any |
readStretch(value: any) → void
Velocity stretch: a finite number >= 0, per unit of speed.
| arg | type | description |
|---|---|---|
| value | any |
readLightShare(value: any, field: string) → void
A share of the scene's light, in 0..1 — how much of it an emitter's particles take (`lightInfluence`), and how much of themselves they emit regardless of it (`lightEmission`). Answers the share, or the reason the value is not one. A share outside the range is refused where it is declared rather than accepted and shaded with every frame that would have drawn it.
| arg | type | description |
|---|---|---|
| value | any | |
| field | string |
RENDER_LAYER_FAULT(value: any) → string
| arg | type | description |
|---|---|---|
| value | any |
readCastsShadows(value: any) → void
Whether an emitter's particles block light — the same declaration a Model carries, made once for the emitter and honoured on the sprite path and the mesh path alike. Answers the flag, or the reason the value is not one.
| arg | type | description |
|---|---|---|
| value | any |
applyVisibility(self: any) → void
What the thing drawing this emitter is told about the two declarations. A sprite emitter draws through a render host, which carries both the way any entity does; a mesh emitter draws through a population whose registration fixes both at registration time, so the registration is remade.
| arg | type | description |
|---|---|---|
| self | any |
readRenderLayer(value: any) → void
The render layers an emitter's particles belong to, normalised to the space-separated name string every render-layer surface in the engine speaks. A name, an array of names, or a space-separated string all arrive here and leave in the one form the sprite host and the population registration are both given. Answers the names, or the reason the value is not a layer spec.
| arg | type | description |
|---|---|---|
| value | any |
renderLayerMask(names: string) → number
The per-world membership bitmask the names resolve to. Referencing a layer interns it, so an emitter naming a layer nothing else uses creates it, which is what makes the name visible to `renderLayer.list` and to a capture's include spec.
| arg | type | description |
|---|---|---|
| names | string |
worldGridSignature(grid: { [string]: any }) → string
What a bound field's grid looks like, as one comparable string. The shader reads a world position through this grid, so a field rebuilt over a different extent has to reach the params buffer before the next dispatch.
| arg | type | description |
|---|---|---|
| grid | { [string]: any } |
ensureDefaultUpdateShaderRegistered( ) → void
ensureCensusShaderRegistered( ) → void
ensureMeshInstanceShaderRegistered( ) → void
ensureWorldUpdateShaderRegistered( ) → void
The sprite update with the scene's distance field bound. Resolved the first time an emitter actually carries a world collider, so an emitter that never collides with the world never compiles it.
declaresPrevVertices(ref: any) → boolean
Whether an update shader takes the previous-frame vertex stream as a fourth resource. The built-in sprite update declares `prev_vertices` and writes it for a slot that spawned this frame; an update shader written against the three-binding layout declares no such name and is dispatched over the three it does declare.
| arg | type | description |
|---|---|---|
| ref | any |
sortProgram(identity: string) → any
The six passes an emitter sorts through, resolved on first use so an engine with no alpha-blended emitter in it never compiles any of them. A missing one is remembered, so an emitter falls back to its creation-time index order rather than resolving the same absent asset every frame.
| arg | type | description |
|---|---|---|
| identity | string |
sortKeyBits(maxKey: number) → number
Bits needed to represent every key value in [0, maxKey].
| arg | type | description |
|---|---|---|
| maxKey | number |
newSpawnBatches( ) → SpawnBatches
swapBatches(b: SpawnBatches, i: number, j: number) → void
| arg | type | description |
|---|---|---|
| b | SpawnBatches | |
| i | number | |
| j | number |
liftBatch(b: SpawnBatches, at: number) → void
Lift the entry at `at` toward the root until its parent leaves no later.
| arg | type | description |
|---|---|---|
| b | SpawnBatches | |
| at | number |
sinkBatch(b: SpawnBatches, at: number) → void
Sink the entry at `at` away from the root until both children leave no earlier.
| arg | type | description |
|---|---|---|
| b | SpawnBatches | |
| at | number |
popBatch(b: SpawnBatches) → void
Take the entry that leaves first out of the heap.
| arg | type | description |
|---|---|---|
| b | SpawnBatches |
sweepOverwrittenBatches(b: SpawnBatches, capacity: number) → void
Drop every entry a later placement has overwritten whole, and rebuild the heap from the entries left.
| arg | type | description |
|---|---|---|
| b | SpawnBatches | |
| capacity | number |
pushBatch(b: SpawnBatches, leavesAt: number, count: number, capacity: number) → void
File a batch of `count` particles, the last of which leaves the bound at `leavesAt`, into an emitter of `capacity` slots.
| arg | type | description |
|---|---|---|
| b | SpawnBatches | |
| leavesAt | number | |
| count | number | |
| capacity | number |
batchesHeldAt(b: SpawnBatches, now: number) → number
Drop every entry whose clock is at or behind `now`, and answer how many particles the entries left placed.
| arg | type | description |
|---|---|---|
| b | SpawnBatches | |
| now | number |
forgetBatches(b: SpawnBatches) → void
Forget every batch: what `clear()` does to the population.
| arg | type | description |
|---|---|---|
| b | SpawnBatches |
sceneProxy( ) → any
worldFieldExcludes( ) → void
forgetSystem(sys: any) → void
| arg | type | description |
|---|---|---|
| sys | any |
readCreatorKey(value: any, field: string, level: number) → string
What a creator key takes: a non-empty string naming a thing, or nothing at all. `field` names it in a refusal, and `level` is how far out that refusal is reported, counted from this function, so it lands on the call that wrote it.
| arg | type | description |
|---|---|---|
| value | any | |
| field | string | |
| level | number |
creatorSite( ) → void
The nearest frame outside this surface: the code that asked for an emitter. Walking outward rather than counting frames is what makes the answer the same whether the caller reached `create` through the `particles` global or required this module directly, which stand a different number of frames away.
creatorActor( ) → void
The caller this call is attributed to, which labels an emitter whose creator stated no key of its own. The identity names the calling session where the transport attested one and the ZeroMind account otherwise, so an emitter labelled from here belongs to the one caller that made it. A key a sweep matches on is stated by its author rather than resolved from here.
creatorDocument(spec: any, level: number) → void
The document an emitter carries for as long as it lives, built once from what its creator stated and what the call it was made in says about itself.
| arg | type | description |
|---|---|---|
| spec | any | |
| level | number |
readCreatorFilter(filter: any, level: number) → void
What a listing filter takes: the owner key on its own as a string, or a table naming any of the fields a document can be matched on. Every field a table names has to match, so a filter narrows the listing rather than widening it, and a table naming none is refused rather than read as "everything" — a filter built out of values that all turned out absent is a mistake, not a request for the whole world.
| arg | type | description |
|---|---|---|
| filter | any | |
| level | number |
matchesCreator(sys: any, want: { [string]: string }) → boolean
Whether one emitter's document holds every value a filter asked for.
| arg | type | description |
|---|---|---|
| sys | any | |
| want | { [string]: string } |
spriteBounds(sys: any) → void
The box a sprite emitter declares as its rendered extent, in the space of its render host — which sits at the world origin, because the compute pass billboards each quad straight into world space. The box is centred on the emitter's own origin, so its centre is where the emitter stands. That centre is what the renderer measures a blended object's distance from, and it is the whole of what orders one emitter's quads against another's: two emitters sharing a centre are a tie, and one standing nearer the camera than another draws after it.
| arg | type | description |
|---|---|---|
| sys | any |
_spawnRenderHost( ) → void
The entity a sprite emitter draws through, built the one way it is ever built. It is infrastructure the emitter spawns for itself: temporary keeps it out of scene saves and the authoring change ledger, internal keeps it out of user-facing entity listings, and the module's registry of these ids keeps their far-flung quads out of the world field. A mesh emitter draws as a population and has none.
_ensureRenderHost( ) → void
Put a live render host under a sprite emitter that has none, and bind the emitter's material to it. The host is released by whatever releases an entity: unloading the layer it was spawned into, which a mode flip does to the root, and an owner despawning it directly. The emitter owns the host, so an emitter that outlives it builds another and goes on drawing. `renderHostRebuilds` counts every time that has happened, so a reading names the churn. What it builds one for is a population to carry: an emitter holding a live particle, or producing them. One holding neither takes its host again on the step that does — the rule the mesh path's registration already follows, which is what leaves an emitter a pool has parked out of the frame between plays.
isFiniteVec3(v: { number }?) → boolean
Whether every component of a 3-vector is a real number. A component that is NaN compares false against itself, and infinity survives arithmetic all the way into the vertex the quad is written from, so both are refused here.
| arg | type | description |
|---|---|---|
| v | { number }? |
dot3(a: { number }, b: { number }) → number
| arg | type | description |
|---|---|---|
| a | { number } | |
| b | { number } |
cross3(a: { number }, b: { number }) → void
| arg | type | description |
|---|---|---|
| a | { number } | |
| b | { number } |
unit3(v: { number }) → void
The unit vector along `v`, or nil where it has no length to speak of.
| arg | type | description |
|---|---|---|
| v | { number } |
squareUpFrame(right: { number }, up: { number }) → void
A right/up pair that spans a plane, built from a sampled one. The pair a camera hands over is already orthonormal in the ordinary case and comes back unchanged; a pair that is degenerate, parallel or not finite is squared up here instead of reaching the compute pass, where it would place a quad with no area and no way to say so. Returns the pair and whether the sample it was built from was usable.
| arg | type | description |
|---|---|---|
| right | { number } | |
| up | { number } |
readVec3(value: any) → void
Read a proxy's world-space vector field as three numbers, or nil where the surface does not answer with one.
| arg | type | description |
|---|---|---|
| value | any |
makeUniqueName(prefix: ?) → void
| arg | type | description |
|---|---|---|
| prefix | ? |
releaseBuffer(buf: any) → void
Release a GPU buffer this system holds. Destroy is idempotent from the caller's side: a handle that was never made, or was already released, is nothing to release.
| arg | type | description |
|---|---|---|
| buf | any |
clampInt(v: ?, lo: ?, hi: ?) → void
| arg | type | description |
|---|---|---|
| v | ? | |
| lo | ? | |
| hi | ? |
isSequenceSpec(value: any) → boolean
Whether a spec is written as a curve rather than as a range. A curve arrives as a NumberSequence, as the API handle one is carried across a boundary as, or as the keypoint list one is built from — and a keypoint list is a list whose entries are themselves tables, which is how `asNumberSequence` tells it from the `{min, max}` pair of numbers a range is written as.
| arg | type | description |
|---|---|---|
| value | any |
isLongRun(value: any) → boolean
Whether a spec is a positional run of more than two numbers. Two numbers are the ends of a range and the two stops of a ramp, so every field here reads a table of numbers as a pair; a run carrying a third value is a curve written without its times, which is neither reading, and it is answered at the call that wrote it rather than read as its first two values.
| arg | type | description |
|---|---|---|
| value | any |
shapeRead(value: any, lo: any, hi: any) → string
How a value that did not read as a shape its field takes reads instead, for that message. `lo` and `hi` are the two entries a reader took out of a table, so a table that is a pair in shape but not in content says which end of it went wrong.
| arg | type | description |
|---|---|---|
| value | any | |
| lo | any | |
| hi | any |
asNumberSequence(value: any, fallback: any, field: string, level: number) → any
Read a curve spec into the NumberSequence sampled across a particle's life. A curve is a number the whole life holds, the `{start, finish}` pair of numbers a two-stop ramp is written as, a table holding one number — the degenerate pair, read the way the range reader beside this one reads it, so both answer a one-value table the same — a NumberSequence, or the keypoint list one is built from. Every one of those reaches the GPU as a sampled curve, so a value that reads as anything else — a longer run of numbers, a string — is refused here, naming the field and the shape it read as, rather than leaving the field at the curve it already carried. `field` names the field in that message, and `level` is how far out the raise is reported, counted from this function, so it lands on the call that wrote the value.
| arg | type | description |
|---|---|---|
| value | any | |
| fallback | any | |
| field | string | |
| level | number |
asColorSequence(value: ?, fallback: ?) → void
| arg | type | description |
|---|---|---|
| value | ? | |
| fallback | ? |
rangeArgs(min: any, max: any) → any
The value a range setter called as `(min, max)` was written with. Two arguments name the two ends of the range, which is the `{min, max}` the one-argument form takes written out; one argument is read in whichever of that form's shapes it carries.
| arg | type | description |
|---|---|---|
| min | any | |
| max | any |
readSize(value: any, fallbackSeq: any, level: number) → void
Read a `size` spec into both factors the GPU draws a particle with: the base each spawn samples, and the curve sampled across that particle's life. The drawn size is their product, so one written size decides one factor and the other stays 1 — a size written as a curve is that curve over base 1, and a size written as a range is that base under a constant curve. Reading it once into both is what keeps a size drawn at the size it was written as. `fallbackSeq` is the curve a size that was not written keeps, and `level` is how far out a refusal is reported, counted from this function, so it lands on the call that wrote the value — one further out at the reader this hands the value to, which raises from a frame of its own.
| arg | type | description |
|---|---|---|
| value | any | |
| fallbackSeq | any | |
| level | number |
readDirection(dir: ?) → void
Read a direction vector spec. Returns nil for absent/zero-length input (meaning: keep the shape's native +Y emission frame).
| arg | type | description |
|---|---|---|
| dir | ? |
readBursts(bursts: ?) → void
Normalize a burst schedule: sorted { {time, count} } with positive counts, or nil when the input holds no usable entries.
| arg | type | description |
|---|---|---|
| bursts | ? |
readNumber(value: any, field: string, default: number, level: number) → number
Reading a spec Every field an emitter is written with is read through one of the readers below, and each reader states what its field takes. A value the field does not take is refused at the call that supplied it, naming the field and the set or type it takes, so the spelling that works is in the message the author reads. `create` and the setters read the same fields through the same readers, so a value one of them accepts is a value the other accepts. Read a field written as a single number. `level` is how far out the raise is reported, counted from this function.
| arg | type | description |
|---|---|---|
| value | any | |
| field | string | |
| default | number | |
| level | number |
readBoolean(value: any, field: string, default: boolean?, level: number) → boolean
Read a field written as a boolean. `default` is what an absent field takes, which for a field whose absence means "decide from something else" is nil.
| arg | type | description |
|---|---|---|
| value | any | |
| field | string | |
| default | boolean? | |
| level | number |
readTable(value: any, field: string, what: string, level: number) → any
Read a field written as a table. `what` names the shape the field takes, for the message a value of some other type is refused with.
| arg | type | description |
|---|---|---|
| value | any | |
| field | string | |
| what | string | |
| level | number |
nameSet(names: { string }) → void
| arg | type | description |
|---|---|---|
| names | { string } |
rangeKeyFor(key: string) → string
The range key a `<field>Min` / `<field>Max` spelling belongs to, or nil when the key names no range.
| arg | type | description |
|---|---|---|
| key | string |
reportUnknownSpecKeys(spec: any) → void
Report every key of a spec that `create` reads nothing from. Each key is reported on its own, naming what it would take to write the thing it looks like it was written for.
| arg | type | description |
|---|---|---|
| spec | any |
reportUnknownShapeKeys(shapeSpec: any) → void
Report every key of an emission shape that the shape reads nothing from.
| arg | type | description |
|---|---|---|
| shapeSpec | any |
hasWork( ) → void
step(dt: number) → void
| arg | type | description |
|---|---|---|
| dt | number |
create(spec: any) → any
Create a GPU particle system from a spec, allocating its state, vertex, index and params buffers and registering it with the auto-update driver. optional and falls back to the system's default. `owner` and `name` say whose the emitter is and survive into `list()` and `:getCreator()`. per-system methods.
| arg | type | description |
|---|---|---|
| spec | any | `{ maxCount, rate, enabled, owner, name, ... }` — every field is |
examples
local sys = Particles.create({ maxCount = 5000, rate = 200 })textureRef(tex: ?) → string
Materialise the per-system `texture` to the RESIDENT KEY the renderer binds material slots by. A `.texture` asset uploads under its guid via `ref:handle()` (idempotent Disk→CPU→GPU); the renderer resolves slots by exact key against that cache, so the slot must carry the resident guid. Procedural (`color:` / `default:`) and runtime (`runtime:<id>`) references are already their own resident key and pass through unchanged.
| arg | type | description |
|---|---|---|
| tex | ? |
lookTuningFor(look: string) → void
The `energy` / `detail` a look's shipped material carries, read from the material itself — the one place those two numbers are authored — rather than a second, hand-copied table that can drift from it. A runtime material this package builds on a look's shader (a look with a depth fade, a texture, or a declared light share) carries these so it renders the way the shipped material does rather than at the shader's bare defaults. Raises rather than falling back to the shader's defaults on a look whose shipped material cannot be read, since a silent fallback would draw that runtime material differently from the shipped one it is meant to match.
| arg | type | description |
|---|---|---|
| look | string |
_noteFadeOnAuthoredMaterial( ) → void
Say, once per emitter, that the fade it declares is drawn by the authored material it binds rather than by this distance. An authored material shades through its author's own shader and is shared by every entity drawing it, so it fades over the distance that shader reads from its own `depth_fade`. The notice names what such a material declares to carry one.
_noteLightOnAuthoredMaterial( ) → void
Say, once per emitter, that the light it declares is shaded by the authored material it binds rather than by these two values. An authored material shades through its author's own shader, so what the scene's light does to these particles is what that shader does with it. The notice names what such a material declares to carry the declaration.
_applyLookImplications( ) → void
What the current look implies for `blendMode` / `depthFade` / `lightInfluence`, applied to whichever of the three this emitter has not itself stated — by the spec at `create`, or by `setBlendMode` / `setDepthFade` / `setLightInfluence` since. A field the emitter did state stands: this is the one place either `create` or `setLook` resolves what the look fills in, so a look switch after an explicit `setBlendMode` never reverts it, while one before it always starts from the new look's own blend, fade and light. Called with `self.look` already set to the look in force; a mesh emitter (which never carries one) leaves all three at whatever they already were.
_declaresLight( ) → boolean
Whether this emitter has a declaration about the scene's light on its particles. One that has takes a material of its own — the shipped materials and the ones a texture shares are drawn by every emitter that asks for them, and retuning a light value on one of those would retune it on all of them.
_writeLightShares(handle: any) → boolean
Put this emitter's two light values on one material of its own. Answers whether they landed; a material that refused them keeps what it had.
| arg | type | description |
|---|---|---|
| handle | any |
_dropOwnMaterial( ) → void
Release the material this emitter built for itself. Called where the emitter's shape has moved past it and where the emitter ends, so the one material a light declaration costs is given back rather than left on the renderer for the session.
_ensureMaterial( ) → void
_placedCount( ) → number
How many copies this emitter has placed: the slots emission has filled, bounded by capacity. The reservation covers every slot the emitter can ever fill; this is the span it HAS filled, and the registration draws exactly it. A cap of 100 000 that has emitted five particles submits five instances, and the count rises with the emitter instead of with its allocation. A filled slot holding a dead particle still has to be drawn — the dispatch writes it as a degenerate quad the rasterizer drops — so the span, not the population, is what bounds it, and an emitter that has filled no slot places nothing.
_populationMaterial( ) → void
The material key `drawInstanced` takes: a `MaterialHandle` for a runtime material, and for an authored one the handle its resolved ref hands over, which files the material's GPU record and makes its texture slots resident. Taking that step here is what puts the material on the renderer the moment the emitter names it, whether or not the emitter has a copy to place — so the renderer holds the equation and the depth state of the material an emitter names from the emitter's first frame, and a reading of what shades it answers from the renderer's own record rather than waiting on a spawn. The second return is why the name reached no material — `drawInstanced` accepts the bare name and draws it with the renderer's error shader, so this step is what separates the emitter carrying the material it names from the one carrying that shader.
_bindPopulation( ) → void
Bring the registration in line with what the emitter has placed and the material it names. An emitter holds a registration exactly while it has copies placed, so one that has placed none holds none and takes one on its first copy. A population's material is fixed at registration, so a material change is a drop and a fresh registration — constant work, and the buffers, the mesh and the particles all outlive it.
_dropPopulation( ) → void
Release the registration and the state naming it, leaving the buffers, the mesh and the particles — all of which outlive any one registration.
_syncPopulationCount( ) → void
Follow the emitter's placed copies with the registration: a registration drawing exactly the copies emission has placed for as long as any are placed, and no registration at all while none are. Setting the count is constant time on the engine side — no reservation, no re-registration — so this runs whenever the span moves and does nothing on the frames it does not.
_applyMaterial( ) → void
Bind this system's material. A mesh emitter draws as a population, which carries its material itself; a sprite emitter draws through a render entity, where a runtime material binds through its MaterialHandle on the ECS Material record and an authored one resolves through the Model component's asset ref.
_recordMaterialBind(bound: boolean, fault: string?) → void
Latch what the bind above did, so a reading answers from what happened to the material rather than from the name the emitter was handed.
| arg | type | description |
|---|---|---|
| bound | boolean | |
| fault | string? |
_materialIsBound( ) → boolean
Whether the material this emitter names is on the thing that draws it, answered from the latch the bind wrote — the population registration for a mesh emitter, the render entity for a sprite one.
_shadingReading( ) → void
What the renderer is shading this emitter with, in the renderer's own words. A bind puts a material on the thing that draws the emitter; whether the renderer then built a pipeline for the program that material names is the renderer's own record, and a refusal there leaves the emitter drawing the magenta placeholder with its bind intact. A sprite emitter draws through its render entity, so the renderer answers for that entity; a mesh emitter draws as a population registered against its material, so the renderer answers for the material, by the key its handle carries.
_shadingEverReached( ) → boolean
Whether what draws this emitter has anything to shade with. A mesh emitter's population carries its shading itself — the material it names once that resolves, the renderer's error shader while it does not — so a registered population puts pixels on the screen either way. A sprite emitter shades its quads through a material bound onto its render entity, so one that has never bound a material has nothing for the pixels it simulates to be drawn with.
_packAllParams( ) → void
Param writing We separate per-frame params (small, hot) from per-spec params (curves, ranges — only written on change). When in doubt, _writeFullParams() does the whole buffer at once.
_packColliders(p: ?) → void
Pack the collision header and every collider record. A response of `off` writes a count of zero, which is the shader's own early-out, so an emitter that never collides pays one comparison per particle.
| arg | type | description |
|---|---|---|
| p | ? |
_wantsWorldField( ) → boolean
Whether this emitter carries a collider that reads the scene's own geometry.
_warnWorldPath( ) → void
Say once what an emitter carrying a world collider gets from the update it actually runs. The world collider lives in the built-in sprite update: a mesh emitter runs the mesh update, which integrates without consulting any collider, and an emitter running its own WGSL resolves whatever that shader binds. Called from both dispatch paths, so the emitter that cannot use the field hears about it wherever it runs.
_worldPathAvailable( ) → boolean
Whether this emitter can run the update that binds the field.
_packWorldField(p: ?) → void
Describe the grid the bound distance field covers, so the shader can turn a particle's world position into a coordinate in it. The flag stays 0 while no field is available, which is what makes a world collider report no contact rather than contact at the wrong place.
| arg | type | description |
|---|---|---|
| p | ? |
_ensureWorldField( ) → string
The distance field to bind this frame, or nil when there is none to bind. Builds one the first time an emitter asks for a field nobody has built yet — once, so a scene with no geometry to voxelize costs one attempt and then falls back to the emitter's analytic colliders. A field that appears or is rebuilt over a different extent reaches the shader through a full param upload, because the grid it describes has changed.
_writeFullParams( ) → void
_writeFrameParams(dt: ?, time: ?, camPos: ?, camRight: ?, camUp: ?) → void
| arg | type | description |
|---|---|---|
| dt | ? | |
| time | ? | |
| camPos | ? | |
| camRight | ? | |
| camUp | ? |
_spawnBatch(count: ?) → void
Spawning (CPU side, batched into a single writeBuffer per frame)
| arg | type | description |
|---|---|---|
| count | ? |
fillRun(slotStart: ?, runLen: ?, baseFloatOffset: ?) → void
| arg | type | description |
|---|---|---|
| slotStart | ? | |
| runLen | ? | |
| baseFloatOffset | ? |
_liveBound( ) → number
At least as many particles as are alive in this emitter at the simulated clock, read from the spawn batches rather than from the slots: it costs the batches that died since the last call, whatever the emitter's capacity. The batches are a heap on the clock each one leaves at, so the first to leave is always on top whatever order they were spawned in. A slot a later batch overwrote is counted for both batches until the earlier one leaves or is overwritten whole, which keeps the bound above the live count, and the emitter's filled span caps it.
emit(count: ?) → void
Emit a one-shot burst of N particles, independent of `rate` / `enabled`.
| arg | type | description |
|---|---|---|
| count | ? | Number of particles to spawn (clamped to maxCount). |
examples
sys:emit(100)
_writeStaticIndices( ) → void
The index order a sprite emitter draws in when nothing reorders it: slot n's quad in run n, exactly as the slots stand in the state buffer. Written once at creation, and again whenever an emitter stops sorting, so a permutation left over from the frames it did sort never outlives the sorting.
_sortsItsParticles( ) → boolean
Whether this emitter reorders its particles back to front every frame. `sort` on the spec states it outright; without one it follows the blend mode, because an equation that commutes reads the same in any order and one that does not needs the order the sort gives it. A mesh emitter draws instanced copies rather than an index buffer, so there is no order in it to change.
_sortFitsOneWord( ) → boolean
Whether this emitter's (bucket, slot) words fit the one-dispatch sort: the highest live bucket and the highest slot share one 32-bit word.
_sortPath( ) → string
Which ordering the next sort runs: `"local"`, the one-dispatch sort, while the population the emitter can be holding fits it, or `"radix"`.
_ensureSortView( ) → boolean
The buffers both orderings read, allocated the first frame this emitter sorts: the view the passes measure depth against, and the one-dispatch sort's four words of state. Every value the view holds is fixed for the life of the emitter, so it is written here rather than re-uploaded per frame. Every program either ordering runs is resolved here, on the first frame the emitter sorts, so the radix passes are compiled ahead of the first frame an emitter's population outgrows the one-dispatch sort.
_ensureRadixBuffers( ) → boolean
The buffers the radix path works in, allocated the first frame this emitter holds more than the one-dispatch sort orders: the per-slot key, the two permutations the radix passes ping-pong between, the digit histogram and one constants buffer per pass. Twelve bytes per slot, plus a kilobyte of histogram for every block of 256 slots: sixteen bytes a slot once an emitter fills its blocks, and a kilobyte floor below that, against the ~1.5 KB a slot already holds. Every value the passes read is fixed for the life of the emitter, so the constants are written here rather than re-uploaded per frame.
_releaseSortBuffers( ) → void
_sortPassCount( ) → number
How many radix passes this emitter's radix path runs: one for every eight bits of the key space its bucket count spans. Each pass is three dispatches, paid on the steps the population is past what the one-dispatch sort orders.
_dispatchesPerStep( ) → number
The compute dispatches the next step issues for the population the emitter can be holding now: the simulation pass, and, where it orders its particles, the one-dispatch sort or the radix path's key pass, three per radix pass and the index write.
_dispatchSort( ) → number
Rewrite the index buffer into back-to-front order against the camera the frame params were just written with. Queued behind this frame's simulation dispatch, so the keys measure the positions that dispatch just wrote, and ahead of the mesh re-registration, so the draw walks the order this leaves. Answers the compute dispatches it issued.
_stepReachesTheDevice( ) → boolean
Whether this step has anything for the device to do. Two things reach it. A population the last dispatch has not carried over is one: a spawn and a `clear()` move `_spawnEpoch`, and a dispatch records the value it covered, so the two differing means the device is behind. Live particles are the other, and the furthest death this emitter has scheduled answers that, because a population whose last death is behind the simulated clock holds nobody. Both come from values the emitter already carries, so asking costs the same whatever its capacity is. The step a particle's lifetime elapses on still reaches the device: that death lies ahead of the clock this step reads, so the step dispatches, and the dispatch is what writes the slot out as the degenerate quad the rasterizer drops. The steps after it are the quiet ones.
_dispatchSimulation(dt: number) → number
Write this step's frame parameters and dispatch the update pass over every slot: one invocation per particle on both paths. A sprite invocation writes that particle's 4-vertex quad, and a slot holding no particle as the degenerate quad the rasterizer drops; a mesh invocation writes the matrix that places its copy of the source mesh and the shader-data block its material reads. Returns the compute passes it issued.
| arg | type | description |
|---|---|---|
| dt | number |
_registerSlab( ) → void
Re-register a sprite emitter's mesh over its vertex buffer, so the slab the main render pass draws from carries the vertices the last dispatch wrote. The box rides every re-registration: a registration that leaves it out is one the emitter's declared extent does not survive, and the box it falls back to is a unit cube at the world origin.
update(dt: ?) → void
Drive simulation for `dt` seconds. Call once per frame. A step reaches the device when the emitter holds live particles or when a spawn or a `clear()` has moved its population since the last dispatch. A step with neither writes no frame parameters, dispatches no simulation pass, orders nothing and re-registers no mesh, so an emitter costs the frames it is producing something and not the frames it is idle. `observe().dispatches` counts the steps that reached the device, and `observe().secondsSinceStep` measures the last step taken whether it reached the device or not.
| arg | type | description |
|---|---|---|
| dt | ? | Delta time in seconds. |
examples
sys:update(deltaTime)
readCameraFrame( ) → void
_getCameraFrame( ) → void
billboard( ) →
The camera frame this emitter's geometry was last built against: which camera supplied it, the world point it was read at, the right/up pair, and the direction that pair faces. A sprite is a flat card placed in the compute pass against ONE camera frame, so a view looking along `normal` sees it face-on and a view looking across `normal` sees its edge. A mesh emitter spins each copy about the axis running from it to `position`. When a frame drawn from somewhere else shows an emitter that every count reports as producing, this is the reading that says where its geometry is turned, and `means` names which part of the frame this emitter's kind builds from.
examples
local b = sys:billboard(); print(b.source, b.normal[1], b.normal[2], b.normal[3])
setRate(r: ?) → void
Set continuous emission rate in particles/sec. 0 disables continuous emission.
| arg | type | description |
|---|---|---|
| r | ? | Particles per second; clamped at 0. |
examples
sys:setRate(120)
setEnabled(b: ?) → void
Toggle continuous emission. Writes to the ParticleSystem's own enabled flag — distinct from the component framework's onEnable/onDisable lifecycle (which the ParticleEmitter component forwards into here). finish their lifetime and no new ones spawn.
| arg | type | description |
|---|---|---|
| b | ? | When true, the system emits at `rate`; when false, existing particles |
examples
sys:setEnabled(false)
setVisible(on: ?) → void
Whether the population this emitter holds reaches the frame. An emitter draws through infrastructure of its own — a render host standing at the world origin for a sprite emitter, an instanced registration for a mesh one — and neither stands in the hierarchy the emitter's owner is in, so an owner switched off in that hierarchy leaves what it simulated in the frame until this says otherwise. `setEnabled` governs emission; this governs the draw, so an emitter hidden mid-flight keeps its particles, its buffers and its timeline and puts them back in the frame the moment it is shown again.
| arg | type | description |
|---|---|---|
| on | ? | True to draw what the emitter holds, false to draw none of it. |
examples
sys:setVisible(false)
isVisible( ) → boolean
Whether this emitter's population reaches the frame.
examples
if not sys:isVisible() then sys:setVisible(true) end
setOrigin(x: ?, y: ?, z: ?) → void
Set the emitter's world-space origin.
| arg | type | description |
|---|---|---|
| x | ? | World-space X coordinate. |
| y | ? | World-space Y coordinate. |
| z | ? | World-space Z coordinate. |
examples
sys:setOrigin(torch.position.x, torch.position.y, torch.position.z)
setDirection(x: ?, y: ?, z: ?) → void
Aim emission: the shape's local +Y axis (cone axis, point direction, disc normal, ...) maps onto this world-space vector. Positions and initial velocities both rotate. Applies to particles spawned after the call — live particles keep their velocity.
| arg | type | description |
|---|---|---|
| x | ? | Direction X, or nil to reset to the shape's native +Y frame. |
| y | ? | Direction Y. |
| z | ? | Direction Z. |
examples
sys:setDirection(cam.transform.forward.x, cam.transform.forward.y, cam.transform.forward.z)
play(restart: ?) → void
Start (or re-arm) the emission timeline: resets the clock to -delay, re-arms scheduled bursts, and enables emission. A pooled one-shot effect (duration set, looping = false) replays its full schedule on each call.
| arg | type | description |
|---|---|---|
| restart | ? | When true, also kills live particles so the replay starts clean. |
examples
muzzleFlash:play()
stop(clearParticles: ?) → void
Stop emitting. Live particles finish their lifetime naturally.
| arg | type | description |
|---|---|---|
| clearParticles | ? | When true, also kills live particles immediately. |
examples
sys:stop()
setBursts(bursts: ?) → void
Replace the scheduled-burst list. Entries fire when the cycle clock crosses their time (see the `bursts` create-spec field).
| arg | type | description |
|---|---|---|
| bursts | ? | { {time=sec, count=n}, ... }, or nil/empty to remove the schedule. |
examples
sys:setBursts({ { time = 0, count = 12 } })isPlaying( ) →
Whether the emission timeline is currently emitting: enabled, past `delay`, and inside `duration` (looping and untimed systems stay true while enabled).
examples
if not fx:isPlaying() then fx:play() end
simulate(t: ?, stepDt: ?) → void
Deterministically advance the system by `t` seconds in fixed steps. Drives the full update each step — timeline, scheduled bursts, compute dispatch — so the system reaches the same state a live run would. A step holding particles dispatches a compute pass; prewarming several seconds is a one-time cost at creation, not something to call per frame.
| arg | type | description |
|---|---|---|
| t | ? | Seconds to advance. |
| stepDt | ? | Fixed step size in seconds (default 1/60). |
examples
torchSmoke:simulate(2) -- already smoking when first seen
_fireBurstsBetween(from: ?, to: ?) → void
Fire every scheduled burst whose time lies in the half-open cycle-time interval [from, to).
| arg | type | description |
|---|---|---|
| from | ? | |
| to | ? |
setGravity(x: ?, y: ?, z: ?) → void
Set the gravity vector (m/s^2). Defaults to {0, -9.81, 0}.
| arg | type | description |
|---|---|---|
| x | ? | World-space gravity X component. |
| y | ? | World-space gravity Y component (negative pulls down). |
| z | ? | World-space gravity Z component. |
examples
sys:setGravity(0, -9.81, 0)
setWind(x: ?, y: ?, z: ?) → void
Set the wind vector (m/s).
| arg | type | description |
|---|---|---|
| x | ? | Wind X component. |
| y | ? | Wind Y component. |
| z | ? | Wind Z component. |
examples
sys:setWind(0.5, 0, 0.2)
setAcceleration(x: ?, y: ?, z: ?) → void
Set the constant acceleration vector. Combined with gravity and wind.
| arg | type | description |
|---|---|---|
| x | ? | Acceleration X component. |
| y | ? | Acceleration Y component. |
| z | ? | Acceleration Z component. |
examples
sys:setAcceleration(0, 5, 0) -- buoyant fire-like rise
setDrag(d: ?) → void
Set the velocity damping factor (per-second exponential decay).
| arg | type | description |
|---|---|---|
| d | ? | Drag coefficient — 0 disables, 1 halves velocity each second, etc. |
examples
sys:setDrag(0.5)
vec3of(v: any, fallback: { number }) → void
| arg | type | description |
|---|---|---|
| v | any | |
| fallback | { number } |
setCollision(mode: string?, opts: { [string]: any }?) → void
Choose what a particle does when it meets a collider. normal speed survives a bounce, `friction` how much tangential speed is lost on contact — both in [0, 1].
| arg | type | description |
|---|---|---|
| mode | string? | `"off"`, `"bounce"`, `"stop"` or `"kill"`. |
| opts | { [string]: any }? | Optional `{ restitution, friction }`. `restitution` is how much |
examples
sys:setCollision("bounce", { restitution = 0.5, friction = 0.3 })_uploadCollision( ) → void
The per-frame write covers only the first 32 floats, so a collision change reaches the GPU through a full param upload.
setColliders(colliders: { any }?) → number
Replace the shapes this emitter's particles collide against. Each entry is `{ kind = "plane" | "sphere" | "box" | "world", position = {x,y,z}, ... }` — a plane also takes `normal`, a sphere `radius`, a box `halfExtents`. A `world` entry is the scene's own geometry, read from the distance field `sceneProxy` builds: it takes only `radius`, how far from a surface a particle counts as touching it. At most 8 are carried; the rest are dropped and reported.
| arg | type | description |
|---|---|---|
| colliders | { any }? | Array of collider descriptions. |
examples
sys:setColliders({ { kind = "world" } })getColliders( ) →
The colliders currently in force, in the shape `setColliders` takes — so what comes out of one goes back into the other.
examples
local n = #sys:getColliders()
setLifetime(min: ?, max: ?) → void
Set the per-particle lifetime range in seconds.
| arg | type | description |
|---|---|---|
| min | ? | Minimum lifetime (or single constant value when `max` is omitted). |
| max | ? | Maximum lifetime; defaults to `min` if nil. |
examples
sys:setLifetime(0.8, 1.6)
setSpeed(min: ?, max: ?) → void
Set the per-particle initial-speed range.
| arg | type | description |
|---|---|---|
| min | ? | Minimum initial speed. |
| max | ? | Maximum initial speed; defaults to `min` if nil. |
examples
sys:setSpeed(2, 4)
setRotation(min: ?, max: ?) → void
Set the per-particle initial rotation range (degrees).
| arg | type | description |
|---|---|---|
| min | ? | Minimum initial rotation in degrees. |
| max | ? | Maximum initial rotation in degrees; defaults to `min` if nil. |
examples
sys:setRotation(0, 360)
setRotSpeed(min: ?, max: ?) → void
Set the per-particle rotation-speed range (degrees/sec).
| arg | type | description |
|---|---|---|
| min | ? | Minimum rotation speed in degrees/sec (negative for CCW). |
| max | ? | Maximum rotation speed in degrees/sec; defaults to `min` if nil. |
examples
sys:setRotSpeed(-180, 180)
setSize(value: ?) → void
Set the size, reading the value the way `create` reads its `size`, so an emitter draws a size the same whether it was created with it or set to it. A curve — a NumberSequence, or the keypoint list one is built from — is sampled per particle every frame on the GPU over base 1; a number or {min, max} is the base each spawn samples, under a constant curve of 1.
| arg | type | description |
|---|---|---|
| value | ? | NumberSequence | number | {min, max} | {{time, value, envelope?}, ...} |
examples
sys:setSize(NumberSequence.new(0.2, 1.0))
sys:setSize({ { time = 0, value = 0.18 }, { time = 1, value = 0 } })setTransparency(value: ?) → void
Set the transparency curve (0 = opaque, 1 = invisible), sampled across each particle's life. A `{start, finish}` pair of numbers is a two-stop ramp, and a table holding one number is that value across the whole life.
| arg | type | description |
|---|---|---|
| value | ? | NumberSequence | number | {start, finish} | {{time, value, envelope?}, ...} |
examples
sys:setTransparency(NumberSequence.new(0, 1))
setSquash(value: ?) → void
Set the squash curve (>1 stretches X, <1 squashes), sampled across each particle's life.
| arg | type | description |
|---|---|---|
| value | ? | NumberSequence | number | {start, finish} | {{time, value, envelope?}, ...} |
examples
sys:setSquash(NumberSequence.new({{time=0, value=1}, {time=1, value=2}}))setRotSpeedCurve(value: ?) → void
Set the rotation-speed curve, which multiplies across each particle's life the spin it was drawn with at spawn.
| arg | type | description |
|---|---|---|
| value | ? | NumberSequence | number | {start, finish} | {{time, value, envelope?}, ...} |
examples
sys:setRotSpeedCurve(NumberSequence.new(1, 0))
setColor(value: ?) → void
Set the ColorSequence (sampled per-particle every frame).
| arg | type | description |
|---|---|---|
| value | ? | ColorSequence | {r,g,b} constant | (start, end) — coerced to a ColorSequence. |
examples
sys:setColor(ColorSequence.new({1, 0.9, 0.3}, {1, 0.1, 0}))setShape(opts: ?) → void
Replace the emission shape. Every key is read the way `create` reads it: `kind`, `style` and `inOut` each name one of a closed set, so a name outside that set is refused here with the whole set in the message, and a key the shape reads nothing from is reported on its own.
| arg | type | description |
|---|---|---|
| opts | ? | Partial table — any subset of { kind, size, style, inOut, partial, spreadAngle }. |
examples
sys:setShape({ kind = "cone", size = {x=1, y=1, z=1}, partial = 0.3 })setOrientation(mode: ?) → void
Set the orientation mode. The mode names one of a closed set, read the way `create` reads it, so a name outside that set is refused here with the whole set in the message. | "VelocityPerpendicular".
| arg | type | description |
|---|---|---|
| mode | ? | One of "FacingCamera" | "FacingCameraWorldUp" | "VelocityParallel" |
examples
sys:setOrientation("VelocityParallel")setBlendMode(mode: ?) → void
Set the blend mode, and draw with the material carrying that equation. The emitter re-resolves its material, so the change reaches the frame the same way it would have had the emitter been created with the mode.
| arg | type | description |
|---|---|---|
| mode | ? | "alpha" (default), "additive", "premultiplied" or "multiply". |
examples
sys:setBlendMode("multiply")setDepthFade(distance: number) → void
Set the distance, in world units, over which this emitter's particles dissolve into the surface drawn behind them — so a card meeting a floor settles onto it instead of cutting a straight line along it. The emitter re-resolves its material, so the change reaches the frame the same way it would have had the emitter been created with the distance. which is what an emitter draws like when it is never given one.
| arg | type | description |
|---|---|---|
| distance | number | A finite number >= 0, in world units. 0 turns the fade off, |
examples
smoke:setDepthFade(1.5)
getDepthFade( ) → number
The distance, in world units, over which this emitter's particles dissolve into the surface drawn behind them.
examples
print(smoke:getDepthFade())
setTurbulence(strength: number, scale: number?, speed: number?) → void
method ParticleSystem:setTurbulence(strength: number, scale?: number, speed?: number) The curl-noise field added to every particle's velocity: `strength` in m/s^2, `scale` in metres per field cell, `speed` in cells per second the field drifts by. 0 strength is off.
| arg | type | description |
|---|---|---|
| strength | number | |
| scale | number? | |
| speed | number? |
setStretch(stretch: number) → void
method ParticleSystem:setStretch(stretch: number) How much a travelling particle's quad lengthens along its velocity, per unit of speed. 0 is off.
| arg | type | description |
|---|---|---|
| stretch | number |
_applyLightShares( ) → void
Put this emitter's two light values on the thing that shades its particles. An emitter already drawing a material of its own is retuned in place; one that has just taken a declaration up, or just put the last one down, resolves its material again so it moves between its own and the shared one.
setLightInfluence(share: number) → void
Set how much of the scene's light this emitter's particles take. At 0 they draw at the colour their curves and tint give them; at 1 they draw at that colour under the scene's own light — the ambient term plus the sun through a wrapped diffuse. Reaches the sprite path and the mesh path alike.
| arg | type | description |
|---|---|---|
| share | number | A number from 0 to 1. |
examples
smoke:setLightInfluence(1)
getLightInfluence( ) → number
How much of the scene's light this emitter's particles take.
examples
print(smoke:getLightInfluence())
setLightEmission(share: number) → void
Set how much of itself each of this emitter's particles emits whatever the scene's light is. At 1 a particle is self-illuminated: it draws at the colour its curves and tint give it however dark the scene is, which is what a spark, a flame or a magic mote does. Reaches the sprite path and the mesh path alike.
| arg | type | description |
|---|---|---|
| share | number | A number from 0 to 1. |
examples
sparks:setLightEmission(1)
getLightEmission( ) → number
How much of itself each of this emitter's particles emits whatever the scene's light is.
examples
print(sparks:getLightEmission())
setCastsShadows(on: boolean?) → void
State whether this emitter's particles block light. A caster is drawn into the shadow map as the geometry it is — a sprite emitter's quads as quads, a mesh emitter's copies as copies — and the surfaces behind it are shaded in shadow. The declaration reaches both emitter kinds, so it means the same thing whichever one carries it. emitter goes back to the default, which is letting it through.
| arg | type | description |
|---|---|---|
| on | boolean? | `true` to block light, `false` to let it through. Omitted, the |
examples
debris:setCastsShadows(true)
getCastsShadows( ) → boolean
Whether this emitter's particles block light.
examples
print(debris:getCastsShadows())
setRenderLayer(layers: any) → void
Put this emitter's particles on named render layers. A camera or a capture including the layer draws them and one excluding it does not, so a reflection probe, a portal camera, a minimap or a clean screenshot can take the scene with the emitter's effect in it or without. The layers reach both emitter kinds. Omitted, the emitter goes back to the `default` layer.
| arg | type | description |
|---|---|---|
| layers | any | A layer name, an array of names, or a space-separated string. |
examples
smoke:setRenderLayer("vfx")getRenderLayer( ) → string
The render layers this emitter's particles draw on, as the space-separated name string every render-layer surface speaks.
examples
print(smoke:getRenderLayer())
setSorted(on: boolean?) → void
State whether this emitter reorders its own particles back to front every frame, overriding the choice the blend mode makes. An emitter left to itself sorts when its equation depends on the order its particles draw in, and draws in slot order when the equation commutes. blend mode again.
| arg | type | description |
|---|---|---|
| on | boolean? | `true` to sort, `false` to draw in slot order, `nil` to follow the |
examples
sys:setSorted(false)
isSorted( ) → boolean
Whether this emitter reorders its own particles back to front every frame, resolved the same way the frame resolves it.
examples
if sys:isSorted() then print("back to front") endsetTexture(texture: ?) → void
Set the texture (asset identity, guid, or ref). Builds (or reuses) the shared runtime material binding it and applies it: to the render entity on a sprite emitter, and by re-registering the population on a mesh emitter. The sampled texel multiplies the per-particle color on both.
| arg | type | description |
|---|---|---|
| texture | ? | Asset identity, guid, or ref of the texture. |
examples
sys:setTexture("@my-game::textures.spark")setLook(look: string) → void
method ParticleSystem:setLook(look: string) The look this sprite emitter is drawn as — one of "puff", "flame", "spark", "glow", "ember", "mote", "droplet", "ring". Rebinds the material.
| arg | type | description |
|---|---|---|
| look | string | One of the eight look names. |
examples
sys:setLook("glow")setMaterial(materialName: ?) → void
Swap the material this emitter draws with entirely — the one on the render entity for a sprite emitter, the one its population is registered under for a mesh emitter.
| arg | type | description |
|---|---|---|
| materialName | ? | Identity of a registered material (full identity or short name). |
examples
sys:setMaterial("@my-game::materials.glowing_spark")setUserParam(index: ?, value: ?) → void
Set a single float in the 32-slot user param block (index 1..32). Custom WGSL update shaders read this at `params[31 + i]` (1-based index `i` maps to 0-based shader slot `P_USER_BLOCK + i - 1`).
| arg | type | description |
|---|---|---|
| index | ? | 1-based slot index in [1, 32]. |
| value | ? | Float value to write. |
examples
sys:setUserParam(1, 6.0) -- e.g. curl-noise strength
setParam(name: ?, value: ?) → void
Generic per-name parameter setter — sets the matching PARAM_LAYOUT slot if `name` is a known key, otherwise treats `name` as `userParam<N>` for custom shader uniforms.
| arg | type | description |
|---|---|---|
| name | ? | Named param (matches PARAM_LAYOUT, case-insensitive) or "user<N>". |
| value | ? | Float value to write. |
examples
sys:setParam("DRAG", 0.6)sys:setParam("user2", 12.5)_writeDeadState( ) → void
The state a slot holds while it carries no particle: age past lifetime, so the update shader reads it as dead, on the emitter's own origin. That position is where the shader collapses the slot's geometry, and the vertex buffer is part of the scene's geometry — a pass that gathers it covers the points these vertices reach — so an unused slot occupies the emitter's own point.
clear( ) → void
Kill all live particles instantly, returning every slot to the emitter's origin and taking them out of the frame on this call.
examples
sys:clear()
destroy( ) → void
Release every GPU buffer and the render entity. Idempotent.
examples
sys:destroy()
_aliveIsExact( ) → boolean
Observation Every reading below resolves against one derivation — the death clock the spawn wrote and the simulated time the dispatches advanced — so the count a script reads, the count the document carries and the count a test asserts on are the same number arrived at once. Whether the population the death clock gives is the exact live count or an upper bound on it. A `kill` response ends a particle where it touches a collider, ahead of the lifetime its slot carries — the shader writes that into the state buffer as an age at the lifetime, which the emitter's own clock cannot see. Every other response leaves the lifetime as the only way a particle ends, and the two agree exactly.
_countAlive( ) → number
How many of the emitter's filled slots still hold a live particle. The scan covers the filled span only, and stops before it starts in the two cases that dominate: an emitter that has never spawned, and one whose last scheduled death is already behind the clock. Between counts nothing changes the answer except time passing or a spawn arriving, so a repeat read at the same instant reuses the count rather than walking the span again.
getActiveCount( ) → number return self:_countAlive
How many particles are alive in this emitter right now — the slots whose lifetime has not elapsed under the simulated time this emitter's dispatches have advanced, which is the same test the update shader applies to decide what to draw. Falls to 0 when the last one ages out. Counting walks the filled span once per simulated instant and is paid by the caller that asks: 0.20 ms over 5 000 filled slots, 4.0 ms over 100 000, and nothing at all for an emitter that has spawned nothing or whose last scheduled death is behind the clock. Simulating costs the same whether or not anything reads this. An emitter whose collision response is `kill` ends particles ahead of their lifetime on the device, and there this count is an upper bound — `observe().aliveBound` says which of the two it is, and `observe().confirmation.alive` is the device's own count.
examples
print("alive:", sys:getActiveCount())getSpawnedCount( ) → number
How many slots emission has filled since the last `clear()`, saturating at `maxCount` — the emission schedule's own total, and the span the draw covers. A slot counts once however many times it has been recycled.
examples
Test.expect(sys:getSpawnedCount()).toBe(12)
getSimulatedTime( ) → number return self
Seconds of simulation this emitter's dispatches have advanced. Every particle's age is measured against this clock, so it moves only when `update` runs and by exactly what the shader integrated.
examples
local t = sys:getSimulatedTime()
getMaxCount( ) → number return self
Returns the buffer cap (maxCount).
examples
local cap = sys:getMaxCount()
getCreator( ) →
Whose this emitter is. `owner` and `name` are the keys its creator stated on the spec, and `source` and `line` are the code that made the call, read off the stack — so an emitter nobody tagged still names the module it came from. `actor` is the caller the creating call was attributed to and `actorName` the ZeroMind account it authenticated as, and `createdAt` is when the emitter was made, which orders two emitters one creator built across separate loads.
examples
local who = sys:getCreator(); print(who.owner, who.source, who.line)
getRenderEntity( ) → string
The entity id of the renderable that draws a SPRITE emitter — useful to apply a different material, parent the renderer under another entity, and so on. A mesh emitter draws as a GPU population instead of through an entity and answers nil; reach its look through `setMaterial` / `setTexture`.
examples
local renderEnt = sys:getRenderEntity()
getGpuBytes( ) →
Every GPU buffer this emitter holds, by the name it holds it under, plus the `total` they sum to. The bytes belong to this emitter alone — each system allocates its own — so an emitter's footprint is attributable to that emitter rather than to the pool its buffers land in.
examples
print(sys:getGpuBytes().total)
_ensureCensus( ) → boolean
GPU census — the simulation's own count of its live slots The population above is derived from what the emitter wrote and what its dispatches advanced. The census is the other end of the same question: a pass over the state buffer the shaders read, counting the slots that satisfy the alive test on the device. It runs only for an emitter that asked for it, so an emitter nobody is observing pays nothing, and its answer arrives on a later frame — the reading carries the simulated time it was taken at.
requestCensus( ) → void
Ask the GPU to count its own live slots on the next dispatch. The answer lands a few frames later and reads back through `observe().confirmation`; asking again while one is in flight joins it.
examples
sys:requestCensus()
_advanceCensusRead( ) → boolean
Carry a census already dispatched toward its answer: issue its read-back, or take the count once that read-back has landed. Answers whether a census was in flight.
_stepCensus( ) → void
_readCensus( ) → void
What a reading does for the census: carry one in flight toward its answer, or, when the emitter's last step left a state no census has counted, take the one the reading asked for of that state.
_renderableState( ) → void
Whether the thing that draws this emitter is there and switched on, and the reason it is not. Every reading resolves the render side through here, so the drawn span and the silence reason answer from the same state.
_drawnSlots( ) → number
How many of this emitter's slots the renderer is submitting geometry for. A sprite emitter draws through one renderable registered over every slot it can ever fill — a slot holding a dead particle is written as a degenerate quad the rasterizer drops — so the draw spans its capacity however few particles are in it. A mesh emitter draws one instance per filled slot, and the span is the count its population was last set to. Either way, a renderable that is gone or switched off submits nothing.
_resolveSilence(alive: number) → void
| arg | type | description |
|---|---|---|
| alive | number |
observe( ) →
Everything the engine holds for this emitter, in one document: the live population, the slots the renderer submits geometry for, the timeline driving it, the GPU bytes it costs, whether the thing that draws it is still there with the emitter's material on it, the GPU's own confirmation of the population, and — when it is producing nothing — which of `silenceReasons()` explains it. A sprite emitter draws through a render host it spawned for itself, and builds another on the step that finds that one released — by a layer unload, or by an owner despawning it — for as long as it holds a live particle or is producing them. One holding neither takes its host again on the step that does. `renderHostRebuilds` counts how many it has had to build, so a reading names that churn. `visible` is whether what the emitter holds reaches the frame at all. It stands apart from `enabled`, which governs emission: an emitter can hold a full population, be producing more, and draw none of it. A reading of one that draws none says so as `reason = "hidden"`. A bind is what puts the material on the thing that draws the emitter, and `shading` is the renderer's own word about what it then drew with: `shading.state = "itsMaterial"` where the program that material names is what reached the screen, `"errorMaterial"` where the renderer bound the magenta placeholder in its place, with `shading.reason`, `shading.detail` and `shading.program` naming the refusal and `shading.means` stating it in a sentence. A mesh emitter draws as a population registered against its material, so its reading is the renderer's word about that material, and a material the renderer prepared nothing for reads `shading.state = "nothingBound"` with `shading.reason = "materialNotPrepared"`. Reading arms the renderer's per-draw recording, so the first reading after boot can carry `shading.state = "pending"` and a later one carries the answer. Reading asks the device to count its own live slots. That pass rides on the emitter's next dispatches, so the first reading carries `confirmation.state = "pending"` and a later one carries the count under `"confirmed"`; every reading's `confirmation.means` says which it is. Readings carry the pass along as steps do: a reading of an emitter whose last step no census has counted takes that count, and the readings after it read it back and collect it, so an emitter held still after a step is confirmed by reading it. What the emitter costs the frame is read in dispatches. A step that has work issues one for the simulation and, where the emitter orders its particles, the sort: ONE more dispatch while the population it can be holding is at most 2048 (`sortPath = "local"`), and otherwise the radix path (`sortPath = "radix"`): a key pass, three per radix pass and the index write. `dispatchesPerStep` is what the next step issues for the population the emitter holds now, and `lastStepDispatches` what the last step that reached the device issued. `sortPasses` is how many radix passes `sortBuckets` buys on the radix path. `dispatches` (steps that reached the device), `sortDispatches` (steps that ordered the particles), `localSorts` (of those, the ones ordered in one dispatch) and `computeDispatches` (every dispatch those steps issued) are totals since creation, so the difference between two readings is what the frames between them actually cost.
examples
local r = sys:observe(); if r.silent then print(r.reason, r.detail) end
list(filter: any?) →
Every particle system this VM has created and not destroyed, in creation order — or, given a filter, the ones whose creator matches it. An emitter is something this module made, so the list is answered from its own registry rather than by walking entities. Called with nothing it answers with every emitter in the VM, which is what makes it the way to reach one whose creator has lost its handle, and `:getCreator()` on an entry says whose that one is. A filter narrows it to one creator's own, so a module sweeps what it left behind on a previous load without touching anybody else's. table matches every one of `owner`, `name` and `source` that it names.
| arg | type | description |
|---|---|---|
| filter | any? | Optional. A string matches the `owner` key a creator stated; a |
examples
for _, sys in ipairs(Particles.list()) do print(sys:getActiveCount()) end
for _, sys in ipairs(Particles.list("starfield")) do sys:destroy() endobserve(system: any?) →
One emitter's reading, or — called with no argument — the whole world's: every live emitter's document plus the totals they sum to. An engine with no emitters answers `count = 0` with an empty list, which reads differently from an engine whose emitters are all silent.
| arg | type | description |
|---|---|---|
| system | any? | Optional ParticleSystem to read; omitted, every live one. |
examples
local world = Particles.observe(); print(world.alive, world.silent)
whySilent(system: any) →
Why one emitter is producing nothing, from the closed set `silenceReasons()` enumerates — or nil when it is producing. The second
| arg | type | description |
|---|---|---|
| system | any | The particle system to ask about. |
examples
local why, detail = Particles.whySilent(fire)
silenceReasons( ) →
The closed set of reasons an emitter can be producing nothing, in the order a reading resolves them — nearest cause first — each with what it means. Every `observe().reason` is one of these.
examples
for _, r in ipairs(Particles.silenceReasons()) do print(r.reason, r.means) end
BlendModeSpec = { weighted: number, identity: number, commutes: boolean }Sub-parts
Everything contained inside this part. Assets are composite children (clickable cards). Files are leaf payloads. Expand any row to view its source.
Showing up to 3 levels deep. Open a part to see what it holds further down.
Problems
Everything affecting this asset right now: its own problems, anything wrong inside it, and problems on its direct dependencies.
These counts cover only the parts this page shows. Problems on the parts it leaves out are not counted here.
Inside
3 problems across 1 itemagent_score is exposed.+ quality × 0.35
+ performance × 0.25
± compat factor
Usability ratings
Did the part work as advertised when consumers tried to drop it in. Separate from upvotes: those are taste; this is "did it function".
Scoped to this part · feeds back into the world's score.