Visual effects
When to read this
- You want an explosion (or any other effect) and would otherwise start from an empty emitter.
- You have an effect and want it bigger, a different colour, or shorter.
- You want to know what an effect costs before firing several at once.
- You are authoring a new effect and want to know what the asset has to carry.
Core idea
An effect is an asset, the same way a material or a mesh is. It carries a
declaration — its family, its parameters and its measured cost — and a
definition that builds it on screen. Gameplay code plays one through the
effects global:
effects.play("@builtin::systems.effects.combat.explosion", { position = { 0, 2, 0 } })
That is the whole call. The effect allocates what it needs, plays, and gives it all back when it finishes. Nothing ticks it and nothing tears it down. Every entity it spawns is temporary, so nothing it draws enters a saved scene or replicates to other players.
To stick one on something already in the world:
effects.playOn("@builtin::systems.effects.combat.explosion", drum, { params = { scale = 3 } })
To make the effect belong to that thing — start with it, follow it as it
moves, go when it goes, and come back with it out of a saved world — add the
Effect component instead of calling anything:
drum.component.add("Effect", {
effect = "@builtin::systems.effects.combat.explosion",
scale = 2.0,
})
That is the durable form of the same playback path. The call is what gameplay code fires; the component is what an entity carries.
effects is where gameplay code lives. The asset itself is still reachable —
asset.ref(identity, "effect"):play { … } runs the same effect on the same
handle — and the difference is what happens to the memory: effects.play leases
its emitters from a shared pool and hands them back, so firing the same effect a
hundred times allocates once. ref:play is the unpooled reference path, which
is what a preview or a single measurement wants.
Write the identity at the call, the way it is written above. On a component or a
scene entrypoint an asset reference is pinned from the source, so an identity
that reaches asset.ref through a variable is refused there — a tool may compute
one, gameplay code names it.
Identity
Effects are addressed by their scope-qualified identity — what
describe().identity reports, and the spelling to write down anywhere you save
a reference:
@builtin::systems.effects.combat.explosion
asset.resolve("explosion", "effect") also answers, by short name, where the
short name reaches exactly one effect. Where this world authors its own effect
under that name, the short name reaches two and the call reports both
candidates so you pick — @root::explosion names the world's own, and the
scope-qualified identity above names this one. The identity reaches the same
effect in every world, so prefer it in anything durable.
Finding what exists
for _, identity in ipairs(effects.list()) do
print(identity)
end
Effects declare a family, and the catalogue lists filtered by it — so "what is there for a gunfight?" is one call:
for _, f in ipairs(effects.families()) do print(f) end
for _, identity in ipairs(effects.list({ family = "combat" })) do print(identity) end
and to see what one is before you play it:
local d = effects.describe("@builtin::systems.effects.combat.explosion")
print(d.family, d.summary)
print(d.cost.gpuMs, "ms ", d.cost.vramBytes, "bytes")
for _, p in ipairs(d.params) do
print(p.name, p.type, p.default, p.min, p.max, p.desc)
end
describe() is the one call to make against an unfamiliar effect. It answers
from the asset's own declaration, so what it reports is what the effect was
authored and measured as.
The combat family
Six effects cover the gun loop, and every one of them is played the same way —
effects.play(identity, { position = …, params = { … } }).
| Identity | What it is |
|---|---|
@builtin::systems.effects.combat.muzzleFlash | the bloom at a barrel the instant a weapon fires |
@builtin::systems.effects.combat.tracer | a round in flight between two world points |
@builtin::systems.effects.combat.plasmaBolt | a slower, self-lit bolt with a pulsing core |
@builtin::systems.effects.combat.impact | the burst where a round lands, answering to the surface it hit |
@builtin::systems.effects.combat.shockwaveRing | the pressure front a blast pushes out across a surface |
@builtin::systems.effects.combat.explosion | the fireball, smoke and debris |
@builtin::systems.effects.demo.combat_showcase is a scene that runs the whole
loop with no setup — load it to see all of them firing.
A shot, end to end
The flash goes off at the muzzle, the round flies to what it hit, and the impact answers to what that was. The tracer takes both ends of the flight and a muzzle velocity, so its length is the span divided by the speed:
local FLASH = "@builtin::systems.effects.combat.muzzleFlash"
local TRACER = "@builtin::systems.effects.combat.tracer"
local IMPACT = "@builtin::systems.effects.combat.impact"
local hit = physics.raycast(muzzle.position, aim, 200)
local landsAt = if hit then hit.point else { muzzle.position.x + aim.x * 200,
muzzle.position.y + aim.y * 200, muzzle.position.z + aim.z * 200 }
effects.play(FLASH, { position = muzzle.position, params = { direction = aim, size = 0.28 } })
local round = effects.play(TRACER, { params = { from = muzzle.position, to = landsAt, speed = 260 } })
if hit then
task.wait(round.duration)
effects.play(IMPACT, {
position = hit.point,
params = { normal = hit.normal, surface = "metal" },
})
end
normal takes exactly what a cast reports: every physics.raycast /
raycastAll / raycastScreen / sphereCast hit carries { entityId, point, normal, distance, startedInside }.
An impact answers to the surface it hit
surface is an enum — it takes one of a closed list of names, and a name
outside the list raises reporting the whole list. Each name is a different burst,
not a different tint:
effects.play(IMPACT, { position = p, params = { normal = n, surface = "metal" } }) -- hot sparks, no dust
effects.play(IMPACT, { position = p, params = { normal = n, surface = "dirt" } }) -- no sparks, a slow cloud
effects.describe(IMPACT) lists the names in that parameter's options, so the
choices are readable before you fire one.
Recipes
A grenade and a fuel tanker, from one asset
An effect's parameters are what make one asset cover a range. The builtin explosion takes its blast radius in metres, and proportions its fireball, smoke, debris and light off it:
local BLAST = "@builtin::systems.effects.combat.explosion"
effects.play(BLAST, { position = grenade.position, params = { scale = 1.5 } })
effects.play(BLAST, { position = tanker.position, params = { scale = 6.0, duration = 4.5 } })
Two plays of one asset, not two assets — and the second one costs no new memory, because it re-seats what the first gave back.
Recolouring an effect
Colour parameters take a linear { r, g, b } triple:
-- a plasma charge rather than petrol
effects.play(BLAST, {
position = { 0, 2, 0 },
params = { scale = 2.5, coreColor = { 0.35, 0.7, 1.0 }, smokeColor = { 0.1, 0.12, 0.2 } },
})
Stopping one early, moving it, re-tuning it
play returns a handle:
local h = effects.play(BLAST, { position = { 0, 2, 0 } })
print(h.duration) -- seconds until it has finished on its own
print(h:stats().particlesAlive)
h:setParam("coreColor", { 0.2, 0.5, 1 }) -- re-tune while it runs
h:retarget(rocket) -- move it: a position, or something in the world
h:stop() -- stop producing; what is drawing finishes
h:cancel() -- stop and take it away now
print(h:isFinished())
setParam carries whatever the effect declares — a number, a range, a colour, a
vec2, a vec3, an entity, a flag, or one name out of an enum's
options. A beam's endpoints and an impact's host are not scalars, and the channel
carries them as themselves.
An effect that belongs to a thing in the world
A torch that burns while it is carried, a rocket trailing smoke, a wrecked cart still smouldering when the world is loaded again — none of those are a call somebody has to remember to make. They are a component on the entity:
local fx = cart.component.add("Effect", {
effect = "@builtin::systems.effects.combat.explosion",
playOnStart = true,
looping = true,
scale = 1.6,
params = { coreColor = { 0.25, 0.55, 1.0 }, duration = 3.0 },
})
fx:play() -- restart it from the top
fx:setParam("coreColor", { 1, 0.4, 0.1 }) -- re-tune the burning one
fx:stop() -- end it and release what it held
print(fx:describe().overrides) -- what it is actually sending
The effect follows the entity as it moves, and ends when the entity leaves the
world — despawned, cleared with the scene, unloaded with its layer, or the
component removed. Nothing is left leased afterwards; effects.observe() reads
that back.
scale, intensity and tint are the fields effects share, and they reach the
bound effect by name — an effect that declares no parameter of that name has
the field reported by describe().ignored rather than silently dropped.
Everything else goes in params, by the name the effect declares. All of them
are declared fields, which is what makes an authored value survive a save and a
load.
Unlike a fired call, a component's effect draws in edit mode too: the
component is there every frame, so it owns the effect's clock and walks it
forward from editorUpdate.
An effect authored in this world binds exactly the way a builtin one does —
effect = "myFire" on the component, the same as effect = "@builtin::systems.effects.combat.explosion". Its init.luau is loaded through
the world's own module scope, and editing that file while the component is live
re-runs the new definition on the next play().
@builtin::systems.effects.demo.effect_component_showcase is the scene that
shows it — three carts, two of them driving across the floor with their blast
following.
When nothing appears
Ask, rather than guessing. Every reason comes from a closed set:
local why, detail = h:whySilent()
print(why, detail) -- nil while it is producing
local o = effects.observe()
print(o.live, o.silent, o.leased, o.pooled, o.bytes)
for _, r in ipairs(effects.silenceReasons()) do print(r.reason, r.means) end
The first one to expect is notSimulating: particles advance and draw on the
play clock, so an effect fired in edit mode holds live particles and submits
nothing. Press play, or set engine.mode = "play", before judging an effect.
Firing a lot of them
Repeated firing does not allocate repeatedly. The runtime leases every backend an effect is made of — its emitters, its geometry, its decal, all of it — from one pool, and re-seats them on the next shot. A hundred impacts one after another cost what the first one did.
The pool is sized by concurrency, not by shot count. It grows to the highest
number of effects it has had to cover at the same time, and then holds there.
Five blasts at once allocate for five; the sixth round of five allocates nothing.
So a total that climbs while a burst gets wider is a pool warming up, and a total
that climbs while the burst stays the same width is something leaking — and
observe() reports both numbers so you never have to guess which you are looking
at:
local o = effects.observe()
print(o.live, o.leased, o.pooled, o.bytes) -- what is held right now
print(o.peakLive, o.peakLeased) -- the most it has ever covered
A measurement of "does this get heavier?" wants real time between shots, the way a player produces them. A synchronous loop with no waiting fires every shot into one frame, which is a wider burst than the effect ever meets in play — the pool grows once to cover that peak and holds, and read in a hurry that plateau looks like a leak.
The pool keeps what it has leased for as long as the engine runs. That is the
whole retention policy — there is no idle timer — and effects.drain() is the
one call that gives it back:
effects.drain() -- give it all back
Reading the same frame twice
An effect played normally runs on the clock, which is no use to a screenshot or a test. Start it held and drive the clock yourself:
local blast = asset.ref(BLAST, "effect")
local h = blast:play { position = { 0, 2, 0 }, params = { scale = 3 }, held = true }
h:seek(0.4) -- the same frame every time
h:seek(0.9) -- forward again, to a later one
Seeks run forward. An emitter simulates in steps and has no reverse, so a target behind the handle's own clock raises — play the effect again and seek to it.
What it costs
local c = effects.describe(BLAST).cost
print(c.gpuMs, c.vramBytes, c.measuredOn)
The numbers come from the asset's own declaration — a measurement its author took and wrote down — so you can budget how many to fire at once before you fire them.
Authoring an effect
asset.create("effect", "my_blast")
scaffolds my_blast.effect/ from the type's template: effect.yaml (the
declaration), init.luau (the definition), and a README. Fill both in and it
plays like any builtin one.
The definition builds itself out of the ctx the runtime hands it — ctx.lease
for a backend, ctx.spawn for an entity it owns, ctx.setDuration for its
lifetime, ctx.onTick for what changes each frame. It never allocates directly,
which is what lets the same definition run pooled from effects.play and
unpooled from ref:play.
An effect is not always particles. The runtime hosts five backend kinds, and an effect leases whichever ones it is made of:
ctx.lease(kind, …) | What it is |
|---|---|
"emitter" | a particle or mesh emitter |
"geometry" | generated geometry the effect owns — a ribbon between two points |
"material" | a material applied to a mesh the caller already has |
"decal" | an oriented projector stamped onto whatever it covers |
"feature" | a render feature held for the length of the play |
effects.backends() lists what is registered, and
effects.registerBackend(kind, backend) adds one — a family that needs a way of
drawing the runtime does not ship registers it rather than widening the runtime.
The type's own reference — the folder shape, the parameter vocabulary, the
handle contract and how the cost declaration is meant to be filled — is at
man /zero/source/libs/@builtin/assetTypes/effect.assetType.
Effects are built programmatically where that is the better tool: noise, signed
distance fields, gradients and animated masks computed in WGSL rather than
sampled from authored flipbooks. The shared maths every effect shader draws on
lives in @builtin::systems.effects.fx_noise, …fx_sdf and …fx_shaping.
A family shares its look rather than re-rolling it. The five combat effects
above all draw their lit parts through one additive material,
@builtin::systems.effects.combat.materials.combat_glow, which carries four
forms — a bloom, a ring, a streak and a bolt — chosen per entity on an
instance-data lane. @builtin::systems.effects.combat.glow is the module that
leases the card a form is drawn on and writes those lanes, and it is the pattern
to copy for the next family.
Gotchas
- A returned screenshot is not evidence. A fully transparent emitter reports live particles, reports that it drew, and changes the image hash. Read a magnitude out of the frame and compare it against a control that is known to move it.
- The effect owns its curves. What a caller passes are values — a size, a colour, a count, a duration. The shape of the fade over a particle's life is what the asset IS, and it is not a per-play override.
- Slots, not particles, cost memory. An emitter allocates for its declared maximum, so an effect's VRAM figure is the same whether one particle is alive or all of them.
- A pool warming up is not a leak. The pool is sized by how many effects run
at once, so a burst wider than any before it allocates once and then holds.
Compare
observe().bytesagainstobserve().peakLeasedbefore concluding anything is growing. ParticleEmitteris ambiguous between two builtin components, andcomponent.add("ParticleEmitter", …)is refused for that reason. Effects do not go through it.- A fired effect draws nothing in edit mode.
effects.playleaves its clock to the runtime's driver, which advances on the play clock — so an effect fired while the engine is ineditholds live particles and submits nothing, andhandle:whySilent()answersnotSimulating. TheEffectcomponent is the exception: it advances its own play fromeditorUpdate, so an effect an author attaches in the editor is visible there.whySilent()still answersnotSimulatingfor it, because that reason is read off the engine's mode rather than off the play — readstats().ageclimbing instead. - An effect far from the world origin can report live particles and draw nothing. If a blast reads as alive but the frame is empty, move the test closer to the origin before concluding the effect is broken.
See also
man /zero/source/libs/@builtin/assetTypes/effect.assetType— the effect asset type: folder shape, parameter vocabulary, handle contract.man effects— the global's own reference.man /zero/source/libs/@builtin/systems/effects.package— the runtime, its five backends, its pool, and the shared WGSL its shaders are built on.guides { path: "topics/rendering" }— materials, shaders and the per-entity instance-data lane an effect's material reads.man particles— the emitter substrate underneath the particle-backed effects.