Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commite8beb5604c

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

FieldWhat it counts
aliveparticles live right now — slots whose lifetime has not elapsed under simulatedTime
spawnedslots emission has filled since the last clear(), saturating at capacity
drawnslots 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:

ReasonMeans
destroyedthe emitter released its buffers and its renderable
renderableMissingthe thing that draws this emitter is gone
renderableInactivethe renderable exists and its hierarchy is inactive
materialMissingwhat draws this emitter has nothing to shade with
simulationNeverSteppedno dispatch has run for this emitter yet
simulationNotSteppingthe last step is older than the stall window
disabledemission is switched off
awaitingDelaythe timeline has not reached the start of emission
durationElapseda one-shot timeline ran past its duration
lifetimeZeroa particle's lifetime is zero, so it dies where it spawns
rateZeronothing is scheduled to spawn — no rate and no bursts
allExpiredevery 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.