Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commite8beb5604c

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-particleGPU per-particle
1 000 particlesOKOK
10 000 particlesstarts skipping framestrivial
100 000 particlesimpracticaltrivial
1 000 000 particlesimpossiblefeasible on mid-tier GPU
Custom physicsper-particle Lua callone WGSL update shader
Trail / mesh particlesextra CPU workextra 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.

BufferSizeWritten byRead by
<id>_statemaxCount * 64 BCPU spawn, GPU updateGPU update
<id>_params1556 floats (~6 KB)CPU each frame + on changeGPU update

A sprite emitter builds a quad per particle:

BufferSizeWritten byRead by
<id>_vertsmaxCount * 4 * 92 B (GPU vertex buffer, carrying the per-particle channel in its tangent words)GPU updaterenderer, and the material's fragment stage
<id>_indicesmaxCount * 6 * 4 B (GPU index buffer)CPU once at createrenderer

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:

BufferSizeWritten byRead by
<id>_xformsmaxCount * 64 B (one column-major 4x4 per particle)GPU updaterenderer
<id>_instdatamaxCount * 64 B (four vec4 shader-data lanes per particle)GPU updatethe 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, whatever maxCount is, 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, over maxCount, 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() reports sortPath ("local" or "radix"), dispatchesPerStep for the population the emitter holds now, lastStepDispatches, and the running totals computeDispatches, sortDispatches and localSorts. An emitter that adds skips all of it: addition commutes, so its draw order cannot change its image. sort = false on the spec (or sys:setSorted(false)) turns it off; sort = true turns it on for an additive emitter. sys:observe().sorted reports which way an emitter resolved, and getGpuBytes().sort what 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. sortBuckets is 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, so sortBuckets = 255 sorts in five dispatches instead of eight at 2.7% of depth per bucket.

    simulate reorders 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:

IssueLayer
#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 bindingsshipped here in Luau (Rust FFI is the future home)
#2679 Emission shapesshipped here
#2680 Orientation modesFacingCamera + FacingCameraWorldUp + VelocityParallel + VelocityPerpendicular shipped here
#2678 Flipbook texture animationfuture — needs texture-atlas UV math
#2684 Particle ↔ world collisionanalytic shapes and the scene's own geometry shipped here; see "Collision" below
#2685 Force fields / curl noisecurl-noise demoed via custom WGSL; first-class ForceField component is future work
#2681 LightEmission / LightInfluence HDRbrightness shipped; LightInfluence (read GI) future
#2683 Mesh particlesshipped 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 emittersshipped 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.