Builtin Library
README.md
deprecatedParticles Module (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.
The Luau surface for particle emitters of the earlier substrate, required as
local particles = require("@builtin::deprecated.deprecatedParticles.deprecatedParticles"). It creates emitters, enumerates the live ones, and answers what
each is simulating and drawing right now.
Reading what an emitter is doing
particles.observe() answers "what are this engine's emitters producing, and
why is one producing nothing" in a single read. Every number in it comes from
the engine's own state — the lifetimes the spawns wrote, the simulated time the
dispatches advanced, the entity that draws the emitter — rather than from a
value content set.
particles.observe() -> ParticleObservation— every live emitter plus the totals:{ emitters, count, alive, spawned, capacity, silent, bytes, takenAt }.particles.observe(sys) -> ParticleReading— one emitter's document.particles.list() -> { ParticleSystem }— the live emitters, from the emitter registry rather than by walking entities.particles.whySilent(sys) -> reason?, detail?— the reason and its detail line, or nil when the emitter is producing.particles.silenceReasons() -> { { reason, means } }— the closed set, in resolution order.
The three counts, and what each one means
| Field | What it counts |
|---|---|
alive | particles live right now — slots whose lifetime has not elapsed under simulatedTime |
spawned | slots emission has filled since the last clear(), saturating at capacity |
drawn | slots the renderer is submitting geometry for |
alive falls to 0 when the last particle ages out. spawned does not — it is
the emission schedule's own total, which is what a burst timeline is asserted
against.
drawn counts what the renderer was actually told to submit, which is a
different number on each of the two draw paths. 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 drawn
is its capacity however few particles are in it. A mesh emitter draws one
instance per filled slot, so drawn follows spawned. Either way a renderable
that has been despawned or switched off submits nothing, and drawn is 0.
sys:getActiveCount(), sys:getSpawnedCount() and sys:getSimulatedTime()
are the same three numbers on the emitter handle.
aliveBound says which kind of number alive is. A particle ends when its
lifetime elapses, and the emitter knows both the lifetime each slot was given
and the simulated time its dispatches have advanced, so alive is "exact".
The one response that ends a particle any other way is a collision response of
kill: it ends it where it touches a collider, ahead of the lifetime the slot
carries, and only the device sees that. An emitter carrying kill colliders
reads aliveBound = "upperBound", and confirmation.alive is the count that
includes those deaths.
The death clock costs 4 bytes of CPU memory per slot — 20 KB at a capacity of 5 000, 4 MB at a million — beside the 64 bytes per slot the state buffer holds on the device.
What a reading costs
Simulating costs the same whether or not anything observes: an emitter's
update measures 0.033 ms at a capacity of 5 000 and 0.033 ms at 100 000, the
same as before there was anything to read. The population is counted by the
caller that asks for it, once per simulated instant — 0.20 ms over 5 000 filled
slots and 4.0 ms over 100 000, and nothing at all for an emitter that has
spawned nothing or whose last scheduled death is behind its clock. Reading a
large emitter every frame is the one case that adds up; confirmation.alive
answers the same question off the device for the cost of a dispatch.
Confirming the population against the GPU
alive is derived from what the emitter wrote into the state buffer and what
its dispatches advanced. reading.confirmation is the other end of the same
question: a census pass counts the live slots on the device and reads the
counter back.
The pass rides on the emitter's dispatches, so the first reading of an emitter
carries state = "pending" and a later one carries the count — every state
carries a means line saying which of the two it is and what to do about it.
confirmation = {
state = "confirmed" | "pending" | "unavailable",
means = "…", -- what this state is, on every reading
alive = 128, -- what the device counted
stepped = 128, -- how many of those a dispatch had already aged
simTime = 2.5, -- the simulated time the census was taken at
derived = 128, -- what `alive` read at that same instant
agedBy = 0.033, -- how far the clock has moved since
matchesReading = true,
}
Reading an emitter arms the next census, and the answer arrives a few frames
later — state = "pending" until then, and until the emitter's first dispatch
runs, since the pass rides on it. derived is the reading taken at the very
instant the pass was queued, which is what makes matchesReading a comparison
of the same moment rather than of two different ones. stepped below alive
is a state buffer the emitter has filled and the simulation has not run over.
Why an emitter is producing nothing
reading.reason resolves to exactly one entry of silenceReasons(), nearest
cause first, and reading.detail names what that reason is about:
| Reason | Means |
|---|---|
destroyed | the emitter released its buffers and its renderable |
renderableMissing | the thing that draws this emitter is gone |
renderableInactive | the renderable exists and its hierarchy is inactive |
materialMissing | what draws this emitter has nothing to shade with |
simulationNeverStepped | no dispatch has run for this emitter yet |
simulationNotStepping | the last step is older than the stall window |
disabled | emission is switched off |
awaitingDelay | the timeline has not reached the start of emission |
durationElapsed | a one-shot timeline ran past its duration |
lifetimeZero | a particle's lifetime is zero, so it dies where it spawns |
rateZero | nothing is scheduled to spawn — no rate and no bursts |
allExpired | every particle the emitter spawned has aged out |
The render-side reasons resolve whether or not particles are alive: an emitter
simulating a full buffer into a renderable that has been despawned reads
renderableMissing, and a sprite emitter whose quads never got a material to
shade with reads materialMissing — which is what separates each of them from
a healthy emitter carrying the same counts.
materialBound says whether the material named in material is the one on
what draws the emitter, and materialFault carries the error from the last
bind that failed. An emitter that bound a material and then named one that does
not resolve keeps drawing under the material it had, so it reads
materialBound = false while it is still producing. A mesh emitter registers
its population under the renderer's error shader when the name reaches no
material, which puts magenta on the screen rather than nothing: it reads
materialBound = false with the resolve error in materialFault, and stays
silent = false.
A bind puts the named 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 shading carries it: shading.state reads
itsMaterial where the program the material names is what reached the screen,
errorMaterial where the renderer bound the magenta placeholder in its place —
with shading.reason from the closed set renderer.drawDiagnostics() names,
the compiler's message in shading.detail, the program the material asked for
in shading.program and the whole reading in one sentence in shading.means.
So an emitter whose shader compiles and whose pipeline the renderer refuses
reads materialBound = true beside shading.state = "errorMaterial", which is
what tells it apart from a healthy emitter carrying the same counts. 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; a shader
that compiles on a later edit puts the emitter back on itsMaterial.
A mesh emitter draws as a population registered against its material rather
than through a render entity, so its reading is the renderer's word about that
material. Preparing a material is what builds the pipeline its draws are looked
up by, so a program the renderer refuses leaves the key with no prepared
material at all and shading.state reads nothingBound with
shading.reason = "materialNotPrepared" beside the program the material names.
renderer.shadingOf(subject) is the same reading for any entity or material.
The reasons cover the population, the timeline driving it, and whether the thing
that draws the emitter is there, switched on and carrying its material;
shading covers what the renderer made of the draw. What a camera then makes of
it is renderer.drawStats() and a capture.
What an emitter costs
reading.bytes breaks the emitter's GPU footprint down by the buffer holding
it — state, params, and then vertices / prevVertices / indices for a
sprite emitter or transforms / instanceData for a mesh one — plus total.
Each emitter allocates its own buffers, so the total is attributable to that
emitter.