Builtin Library
- ..
- README.md25.0 KB
- init.luau242 KB
- spawnBatches.module
- spec.module
README.md
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 |
Parts
The module is a folder of parts, each a module of its own that init.luau requires:
Particles.spec: the readers that check each emitter spec field against the shapes it takes.Particles.spawnBatches: the spawn batches an emitter keeps and the particles they can still hold alive.
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.