< Back 

Spray

2026-09-28

Roblox ParticleEmitters drawn on the GUI with ImageLabels. Every particle is computed in closed form from a clock, so an effect can be paused, jumped and rewound, and the same seed always gives the same result.

LuauRobloxUIVFX
Go to quick view

GUI particles computed from a clock.

Abstract. Spray is a Luau library that plays Roblox ParticleEmitters inside the GUI. The effect is set up on a regular emitter in the Properties panel and drawn with ImageLabels. The decision that shapes the whole design is that nothing is integrated frame by frame: the state of every particle is a closed-form function of the effect’s clock, a seed and the particle’s index. That gives exact rewinding, no framerate drift and reproducibility, and it also sets the system’s limits, because no history can be kept. On the performance side, measurements showed that 83% of a frame’s cost is writing properties on engine instances, so optimization went into writing less rather than computing faster. With the same effect built in both libraries, Spray uses 1.3 to 1.8 times less CPU per frame than Emitter2D, and 2.9 times less when firing a burst on a pool that already exists.

1. The problem

A Roblox ParticleEmitter only draws when it’s inside a Part or an Attachment, which means in the 3D world. Game interfaces use particles all the time (the shine on a purchase button, confetti when a reward opens, sparks on a filling bar) and the GUI has no primitive for them. All you get are GuiObjects, so in practice a UI particle is an ImageLabel that someone moves, scales, rotates and fades by hand.

The classic particle system model comes from Reeves [1]: each particle is born with attributes drawn around a mean, gets updated every frame and is removed when its life runs out. Roblox’s ParticleEmitter follows that idea, and so does Emitter2D, the UI particle library used as the reference later on, which is configured through its own set of attributes and advances its particles every frame. When I started Spray I had three clear requirements.

The first one is about authoring. The effect is built on a real ParticleEmitter, in the Properties panel, just like a 3D one, so the emitter is the configuration and nobody has to learn a new config table (me included). Whatever a 3D emitter can’t express (the emission area on screen, a size multiplier, the particle cap) goes into Spray-prefixed attributes on the same emitter.

The second is that time should be an input and not a side effect. An effect has to be able to pause, jump to any moment and play backwards, and with a fixed seed it has to produce exactly the same particles every time. That’s useful for tuning a size curve while looking at a frozen moment, and for making a UI animation look the same on every playback.

The third is cost. UI effects stay on screen for a long time, often several at once, and very often on a phone.

2. The effect as a function of the clock

A Spray keeps a single state variable, its clock c, which counts seconds of effect time. While it’s playing, every frame it does c←c+τΔt, where τ is the emitter’s TimeScale, and everything on screen is evaluated from that number:

F(c)={R(Σ,s,i,c)|i∈K(c)}

Here Σ is an immutable snapshot of the emitter, s is the seed, K(c) is the set of keys of the particles that could be alive at c, and R returns the position, size, rotation, color, transparency and flipbook cell of particle i at that moment. None of the three functions keeps any memory between frames.

The usual alternative is to integrate: 𝐱n+1=𝐱n+𝐯nΔtn. With that, the state after n frames depends on the whole sequence of Δt, so the same particle ends up in slightly different places at 30 and at 144 fps, and the error piles up. To show second 3.2 you have to simulate from zero, and going back would mean storing earlier states.

With the state in closed form, 𝐱(c) doesn’t depend on how you got to c, and that has three direct consequences. There’s no framerate drift, because nothing accumulates. :SetTime(3.2) gives exactly the same frame as playing up to 3.2 seconds. And rewinding just means evaluating the same formula with a smaller c, with no approximation or reconstruction. The test suite checks the last two explicitly: one test verifies that jumping and stepping land on the same frame, and another that rewinding shows exactly what was seen on the way forward.

The price is that anything depending on the past has to be removed or turned into data. An :Emit(n) doesn’t create particles, it appends a tuple (Tb,nb,b) with the time, the count and an increasing index. LockedToPart = false was left out on purpose, because pinning particles to where their parent was when they spawned means remembering that point, and that’s history. Since the pool of ImageLabels hangs from the emitter’s GuiObject, Spray always behaves as LockedToPart = true: if the frame is tweened, the effect goes with it.

Playback semantics come out of the same model. An :Emit() on a paused Spray restarts the timeline (clock at 0, empty burst list and a new seed if UseRandom is on) and starts playing. On one that’s already playing, it adds the burst at the current clock time, the same as ParticleEmitter:Emit() in 3D. :Forward() and :SetTime() only move c and redraw, without changing whether the Spray is paused.

3. Randomness without state

A regular pseudorandom generator has state: you ask for the next number and it advances. That doesn’t work here, because to draw moment c you have to be able to ask what speed particle 400 rolled without going through the 399 before it. What’s needed is random access into the sequence, which is the idea behind the counter-based generators of Salmon et al. [3]: the n-th number is computed directly as a bijective, well-mixed function of n.

Spray uses a SplitMix-style mixer [2], which adds the golden-ratio constant and applies two rounds of xorshift and multiply. With every operation on 32-bit integers, that is, modulo 232:

z0=x+𝟶𝚡𝟿𝙴𝟹𝟽𝟽𝟿𝙱𝟿z1=(z0⊕︎(z0≫16))⋅𝟶𝚡𝟸𝟷𝙵𝟶𝙰𝙰𝙰𝙳z2=(z1⊕︎(z1≫15))⋅𝟶𝚡𝟽𝟹𝟻𝙰𝟸𝙳𝟿𝟽M(x)=z2⊕︎(z2≫15)

The mixer is used on two levels, one per particle and one per property:

stream⁡(s,i)=M(s⊕︎(i+1)φ1)u(s,i,k)=M(stream⁡(s,i)⊕︎(k+1)φ2)232∈[0,1)

with φ1=𝟶𝚡𝟿𝙴𝟹𝟽𝟽𝟿𝙱𝟷 and φ2=𝟶𝚡𝟾𝟻𝙴𝙱𝙲𝙰𝟽𝟽. The same (s,i,k) always gives the same number, in any order. φ1 is a prime very close to 232/ϕ, the golden ratio scaled to 32 bits, which is the usual constant in multiplicative hashing because multiplying by it spreads the index’s bits across the whole word.

3.1 Multiplying 32 bits with doubles

Luau numbers are doubles with a 53-bit mantissa, while the product of two 32-bit integers can take 64 bits. Multiplying directly drops the low bits without any error, and those are exactly the bits the hash needs. The symptom is pretty sneaky, because nothing errors: the particles just come out clumped instead of spread out, and you end up hunting for a bug in the physics. Multiply32 splits each operand into 16-bit halves, a=216ah+aℓ and b=216bh+bℓ, and uses the fact that the term 232ahbh vanishes modulo 232:

ab≡ 216[(ahbℓ+aℓbh)mod216]+aℓbℓ(mod232)

Every partial product is below 232 and the sum never goes past 234, so everything stays well under the mantissa’s limit.

3.2 Channels

The third argument, k, is a channel. Every rolled property has its own: lifetime, speed, spread angle, initial rotation, spin speed, the three envelope rolls (size, transparency and squash), the two emission-shape coordinates, the sign for ShapeInOut, the flipbook’s starting cell, its framerate and the random flipbook. That’s fourteen channels. Without that separation, changing the Speed range on the emitter would shift the whole stream of numbers, and with it the lifetime, angle and rotation of every particle. With channels, moving Speed only changes the speed.

The first mix depends only on the seed and the particle, so the code splits it off: Sampling.Stream(s, i) computes stream⁡(s,i) once and Sampling.Unit(stream, k) does the second mix per channel. Deriving a particle with n rolls goes from 2n mixes to n+1, roughly half, and the resulting numbers are identical to the ones from before the split because the math is the same.

The only “real” random number a Spray uses is the seed, drawn with math.random when the Spray is created and every time the effect restarts with UseRandom on. With UseRandom(false) a fixed seed is used, and the effect repeats particle by particle, down to the flipbook cell each one shows.

4. Which particles exist at time c

4.1 The steady stream

Particle i of the steady stream is born at ti=i/r, where r is the Rate. Since none lives longer than Lmax⁡ (the maximum of the Lifetime range), the only ones that can be alive at c form a window of indices:

Kstream(c)={i∈ℕ|⌈(c−Lmax⁡)r⌉≤i≤⌊cr⌋}

Walking that window is all the work there is. There’s no list of live particles, nothing gets inserted into or removed from any structure, and the cost per frame is proportional to rLmax⁡ no matter how long the effect has been running.

The window uses Lmax⁡ and not each particle’s actual lifetime because it has to be correct before knowing what lifetime each one rolled. The ones that already died get discarded afterwards, once their age passes their lifetime. That’s where I ran into the first serious performance problem. A particle that died early was still inside the window, and every frame it asked for a slot, derived all of its constants (about fourteen hash rolls) and gave the slot back. With big bursts I measured 1403 derivations per frame when only 274 were needed. Now, if a particle has no slot, only its lifetime is rolled first, and if it’s already dead it’s discarded right there, without touching the pool.

4.2 Capping the Rate with Little’s law

The equilibrium population of a steady stream follows Little’s law [4], N=λW: the number of items in a system is the arrival rate times how long each one stays. Here that gives N≈rL‾≤rLmax⁡. If the pool holds at most P particles (SprayMaxParticles), solving rLmax⁡≤P for r gives the cap Spray applies:

r′=min⁡(r,PLmax⁡)

The cap is there mostly because emitters that are only used for bursts tend to have their Rate left at some absurd value, which without it would fill the pool with particles nobody asked for.

4.3 Bursts and keys

Each burst (Tb,nb,b) covers the window [Tb, Tb+Lmax⁡) and is skipped with no further work when the clock is outside it. The list keeps the last 64. Without that limit, an emitter that fires every frame would keep growing it for the whole session, and the cost is that if :Emit() is called more than 64 times within one particle lifetime, the oldest bursts disappear early. For a steady flow, the right tool is Rate.

Each particle is identified by an integer, and the scheme avoids collisions with no coordination between the stream and the bursts:

κstream(i)=2iκburst(b,j)=2(bP+j)+1,0≤j<P

Parity separates the two sources, and the bP factor separates bursts from each other, because :Emit() caps every burst at P particles. Two bursts fired at the same moment never share a key, and since the key is also the hash index, every particle gets its own numbers.

4.4 Drawing only the new burst

My first version of :Emit() redrew the whole emitter, not just the new burst, and that cost an extra frame’s worth of drawing per burst. At 60 fps you barely notice. At 10 fps, though, bursts land every two or three frames and the redraw weighs more, which drops the fps even further. It was a cost that grew exactly when the game was already slow, and that explained why the measurements gave such different numbers from run to run. Now :Emit() only draws the new burst’s particles, since everything else is already where the clock put it.

5. Where each particle comes from

With SprayEmissionSize at zero the emitter is a point and there’s nothing to compute. It’s the most common case, and it’s also how a 3D emitter under an Attachment behaves. With an area of semi-axes a and b (fractions of the GuiObject), the shape is sampled with two rolls u,v∈[0,1).

A box volume is trivial: (x,y)=((2u−1)a, (2v−1)b). A box surface has a trap. The obvious approach is to pick an edge at random and then a point on it, but that gives a short edge the same probability as a long one, and on an elongated rectangle the short sides end up much denser. Spray walks the perimeter by arc length: ℓ=u(2a+2b) covers one horizontal and one vertical edge, and v<0.5 decides whether the particle goes to the opposite edge. That’s less arithmetic for the same uniformity.

Sphere, Cylinder and Disc all collapse to the same ellipse in 2D:

θ=2πu,ρ=v(x,y)=(ρacos⁡θ, ρbsin⁡θ)

The square root is the step people forget most. In a uniform disc, the probability of landing within distance r is proportional to the area, P(ρ≤r)=r2, so by inverse transform sampling ρ=F−1(v)=v. Using v directly crowds the center and empties the rim. With ShapeStyle = Surface, ρ=1.

The outgoing direction isn’t the radius either. The outward normal of f(x,y)=x2/a2+y2/b2=1 is the gradient,

∇f∝(xa2, yb2),

which only matches the radius when a=b. On a wide ellipse the difference is easy to see, because particles would come out skewed relative to the surface. The box doesn’t use any of this, because a face emits through itself and not away from its center, so its normal is the emitter’s EmissionDirection. ShapeInOut = Inward flips the normal, and in InAndOut the coin is flipped on its own channel so the decision holds when rewinding.

Finally, spread is a rotation in the plane by an angle symmetric around the base direction, where α is the SpreadAngle (a half-angle, so 180 is the full circle):

θ=(2u−1)α,𝐝′=(cos⁡θ−sin⁡θsin⁡θcos⁡θ)𝐝

6. Motion in closed form

With constant acceleration 𝐚 and linear drag k (the Drag), velocity satisfies

d𝐯dt=𝐚−k𝐯

which has an exact solution. Defining the terminal velocity 𝐯∞=𝐚/k and the “effective time” σ(t)=(1−e−kt)/k:

𝐯(t)=𝐯∞+(𝐯0−𝐯∞)e−kt𝐩(t)=𝐯∞t+(𝐯0−𝐯∞)σ(t)

σ(t) is close to t at first and saturates at 1/k once the particle has reached its terminal velocity. In the drag-free limit the expression reduces to the usual projectile motion,

limk→0⁡𝐩(t)=𝐯0t+12𝐚t2,

and since both branches agree in the limit, the code uses the parabolic one when |k|<10−4. That threshold doesn’t change the result, it just avoids dividing by almost zero.

There’s one more numerical case. With a negative Drag the exponent −kt turns positive and grows without bound, exp returns infinity, infinity times zero is NaN, and an ImageLabel with a NaN position simply vanishes with no error. That’s why the exponent is clamped to [−60,60]. A badly set Drag looks ugly, but at least you can see it.

The function returns position and velocity in the same pass, because they share e−kt and the terminal terms, and because the velocity is needed later to orient the particle.

7. Translating a 3D emitter to the screen

Everything that needs translating from 3D to 2D is translated in one place, Spec.Read, which produces a table frozen with table.freeze. EmissionDirection goes from a NormalId to a screen vector, where Front and Back (toward and away from the camera) aren’t directions on a plane and fall back to Top, which combined with the 180 SpreadAngle they’re usually paired with gives the full circle. Acceleration has its Y flipped, because on the GUI +y points down, so gravity written as (0,−10,0) still falls. Only the X of SpreadAngle is used, and every sequence is turned into a flat curve (section 8.1). After this step, the simulation and the renderer only see numbers and tables and never need to know what a NormalId is.

Attributes are read with a type check: if SprayScale holds a string, the default is used instead of passing a nil into the math, where it would blow up as a confusing error three modules later. The snapshot is read once, when the Spray is built, and :Cleanup() throws it away so the next use reads the emitter again.

7.1 Units

By default, one stud is the height in pixels of the parent GuiObject. That way the effect scales with the UI it lives in and not with the screen, and survives a window resize or a phone without touching anything. SprayUnit can pick the width, the shorter side, the longer one, or one stud per pixel. The final position mixes two scales on purpose:

X=oxW+px(t)ρsvspx=max⁡(S(τ),0)ρss

The first term of X is the spawn point, stored as a fraction ox of the parent’s width W, because SprayEmissionSize is in fractions too, and that way the emission area follows the parent when it resizes. The second is the physical displacement, in studs, converted with ρ pixels per stud and multiplied by the speed scale sv. Size uses the size scale ss, and SprayScale multiplies both. ρ is recomputed every frame instead of being stored in the snapshot, because the parent resizes with the viewport and a stored value leaves particles the wrong size until the next rebuild.

8. Appearance

8.1 Curves

Size, Transparency, Squash and Color are sequences of up to 20 keypoints read with linear interpolation. Finding the segment uses a linear scan instead of a binary search, because with 20 points at most the scan wins.

The detail that actually mattered was something else. Asking a NumberSequence for .Keypoints builds a new table of new objects on every access, and that was four accesses per particle per frame. Spec.Read converts each sequence once into a flat, frozen table of {Time, Value, Envelope}. That change on its own brought the cost of keeping 1000 particles on screen from 9.2 down to 7.5 ms.

8.2 Envelope

Roblox defines the envelope as a band of ±E around the curve’s value. Spray resolves it with a roll ϵ∈[−1,1) drawn once, when the particle is born, and kept for its whole life:

S(τ)=S‾(τ)+E(τ)ϵ,τ=agelifetime

That’s the difference between the envelope reading as variation between particles (some bigger, some smaller, each consistent with itself) and reading as noise that jitters frame to frame. There are three independent rolls, for size, transparency and squash, and E(τ) is interpolated between keypoints too, so a band that widens halfway through the life widens smoothly.

8.3 Squash

Roblox’s Squash stretches the particle along its long axis. Spray implements it preserving area: with ℓ=1+|q|,

(w,h)={(s/ℓ, sℓ)q≥0(sℓ, s/ℓ)q<0⇒wh=s2

8.4 Orientation

This is the easiest part to get backwards without noticing. On the GUI, +y points down and positive rotation is clockwise. With those two conventions, rotating the local axis (0,1) by θ gives (−sin⁡θ,cos⁡θ), and setting that equal to the velocity’s direction leaves

θ∗=atan2⁡(−vx, vy)

which is the angle that lines the particle’s local +y up with its motion. But Roblox, in VelocityParallel, lines the local x axis up with the velocity, not y, so the actual base is θ∗−90°, and VelocityPerpendicular adds another 90°:

θ=θ0+ωt+θ∗−90°

It looks like a detail, but a streak emitter is set up as VelocityParallel with Rotation = 90, and that 90 is exactly what puts the long axis on the velocity. If θ∗ were used as the base, every emitter like that would come out a quarter turn off, with its streaks going across the motion. Both FacingCamera modes skip all of this, because in 2D they’re already facing the camera.

8.5 Flipbooks

With a grid of g×g cells (N=g2), the starting cell n0, the particle’s framerate f and the window w=⌊tf⌋, the four modes work out like this:

  • OneShot: n=clamp⁡(n0+⌊τ(N−n0+1)⌋, 1, N)
  • Loop: n=1+(n0−1+w)modN
  • PingPong: n=1+min⁡(m, 2N−2−m), with m=(n0−1+w)mod(2N−2)
  • Random: n=1+⌊N⋅u(s,i, 14+32w)⌋

The clamp in OneShot catches the exact τ=1 case, which would otherwise go one cell past the end. PingPong is a triangle wave with period 2N−2, and the −2 keeps the end cells from showing twice at each turn.

Random mode is the neat one. Instead of calling math.random, it hashes the time window, using 14+32w as the channel. Within a window the result is constant, and rewinding over a particle shows exactly the same cells as the first time. The factor of 32 just keeps the windows apart from the particle’s other thirteen channels.

9. Light without additive blending

The Roblox GUI has a single blend mode, normal alpha, and there’s no additive mode to ask for. LightEmission can’t be honored literally, so it’s emulated in two ways.

The first is a tone mapping that’s always on. With g the Brightness, the color is scaled and, if any channel goes past 1 (p>1), it’s corrected like this:

𝐜′=g𝐜p=maxi⁡ci′𝐜′′=(1−β)𝐜′p+β𝟏β=1−1p

The idea is that a channel that overflows has nowhere to go but white. It’s renormalized to keep the hue, then pulled toward white by how much it overflowed. For a color with some channel at 1, Brightness = 2 gives β=0.5 and Brightness = 15 gives β≈0.93, a nearly white core with a faint tint, which is what a saturated additive particle looks like. Before this, a glow like that had to be baked into the texture.

Brightness and LightEmission are kept separate on purpose. Tying them together is tempting, but it would flatten everything set up as bright without emitting light: an impact flash at Brightness = 4 would come out as a flat mid-tone color instead of the blown-out cream it should be.

The second is optional halo layers (SprayGlowLayers, 1 to 8), copies of the particle behind the core that grow and fade in a geometric progression:

γn=1+0.45n,αn=(1−T)⋅0.45n⋅EL

where T is the particle’s transparency and EL its LightEmission, so an emitter that doesn’t emit light gets no halo. Each layer sits one ZIndex behind the previous one so it doesn’t cover the core. LightInfluence, for its part, blends the color toward 𝐜′′⊙𝐀, with 𝐀 the Lighting.Ambient, because a ScreenGui has no scene lighting and ambient light is the most reasonable stand-in.

Neither technique makes two overlapping particles add their light the way real additive blending would, and nothing available to a GuiObject allows it. On the dark panels these effects usually sit on, the difference is small.

10. The real cost is writing to the engine

10.1 The floor

Before optimizing anything, I measured what each individual operation costs, in a loop:

Operation Approximate cost
Writing an ImageLabel property 700 ns
One hash roll (Sampling.Unit) 450 ns
Inserting into a set and clearing it 200 ns
Color3:Lerp 80 ns
Reading .Visible 80 ns
UDim2.fromOffset 50 ns
Reading .X and .Y of a Vector2 18 ns
Reading an Enum 8 ns

That table explains almost everything that came after. A particle that changes position, size, rotation, transparency and color needs five writes, and with cw≈0.7 μs that’s about 3.5 μs per particle per frame that no ImageLabel-based library can avoid. A frame’s cost is reasonably modeled as

Cframe≈N(wcw+ccalc)

with N visible particles and w writes per particle. Taking the writes out, all of Spray’s computation for 1000 particles costs 1.0 ms of a 5.8 ms frame, meaning writes are 83% of it.

10.2 Why neither buffers nor Actors

With that ratio, Amdahl’s law [5] limits what speeding up the computation can buy. If a fraction f of the work is sequential, splitting the rest across n processors gives at most

S(n)=1f+(1−f)/n

Instance writes can only happen serially (an Actor can compute in parallel, but it can’t modify instances), so f≈0.83, which leaves S(4)≈1.15 and a ceiling of S(∞)≈1.2, assuming a perfect split and free synchronization. Both ideas sounded good, so I measured them before throwing them out. A kinematics core on buffers ran 2.7 times faster than on tables (0.031 against 0.083 ms per 1000 particles), but that saves 0.05 ms out of 5.8, less than 1%. A prototype with 4 Actors and 2000 particles each, computing in parallel and writing after task.synchronize(), tied with the serial version: one round went to the parallel one (30.7 against 33.2 ms) and the other to the serial one (36.2 against 40.7 ms). With a theoretical ceiling of 15%, it makes sense that synchronization eats all of it.

10.3 Not writing

If writing is the expensive part, what pays off most is writing less. The renderer keeps, in each slot, the last value it wrote to each ImageLabel and skips every write that wouldn’t change it. Comparing two numbers costs next to nothing against 700 ns, so an emitter with a flat color, no spin or a fixed size saves several writes per particle every frame. Visibility is tracked in that cache too, because reading .Visible back is another call into the engine.

Flat color gets its own treatment: if every Color keypoint is the same, the already graded color (with the tone mapping from section 9) is computed once when the renderer is built, and the curve is never read again. In a typical UI effect, with a flat color and no spin, Spray writes three properties per particle (position, size and transparency) while Emitter2D writes ten, and that’s where the gap between the two libraries grows the most.

10.4 Zero allocations per frame

Each particle’s constants (lifetime, initial velocity, envelope rolls and so on) are written into the table the slot already has instead of replacing it, and the function that resolves a particle writes into a single ParticleFrame that lives on the Spray. With that, walking 400 particles creates no tables. It does create the UDim2s that Position and Size need, and there’s no way around that.

To know which slots weren’t drawn, the first version cleared and refilled a set of keys every frame, at about 200 ns per particle, more than several of the math steps. Now every draw pass has a number, every slot that gets drawn is stamped with it, and at the end the ones with an old stamp are released.

10.5 The pool and prewarming

Each Spray has a pool of slots, and a slot is a particle’s set of ImageLabels (one per halo layer) plus its constants. When a particle dies, its slot is hidden and goes to the free list, ready for the next one. Acquire(k) returns the slot that already holds key k, or a free one, or nothing if SprayMaxParticles has been reached. A recycled slot still carries the previous particle’s constants, and the only sign that they need deriving again is that slot.Constants.Key doesn’t match k. That comparison is the whole “this particle is new” mechanism, with no flags or events.

Reusing slots takes care of the cost of later bursts, but not of the first one. Creating an ImageLabel and setting its fixed properties (texture, anchor, scale type, ZIndex, flipbook crop) is almost everything the first burst of an effect costs, and on a big burst that’s a visible hitch right when the player presses the button. :Prewarm(n) builds those slots ahead of time and leaves them hidden in the free list, so that cost is paid where nobody notices, like on a loading screen. The argument is the total the pool should hold, not how many to add, so it’s idempotent, and it never goes past SprayMaxParticles. The SprayPrewarm attribute does the same every time the Spray is built, including rebuilds after :Cleanup().

10.6 A single scheduler

Every Spray in the game shares one RenderStepped connection. With one connection per Spray, the engine would walk a list of connections to do what a single loop over a table already does, and having nine effects on screen at once is normal in a busy interface. The connection also only exists while something is playing, so a Spray paused between bursts costs nothing. The modules that run every frame are compiled with --!native, and the Enums compared per particle are read once when the module loads.

11. Engine details

Some of the renderer’s decisions don’t come from any model but from how the engine behaves, and they look odd if you don’t know why they’re there.

A hidden ImageLabel ignores ImageRectSize assignments. That’s why Visible = true is set before any other property. Otherwise, a flipbook particle whose first drawn frame is also its first visible one gets no crop and shows the whole sprite sheet. Since everything else is written in that same frame, nothing shows up unpositioned.

The engine also ignores an ImageRectSize set against a texture that hasn’t finished loading, and there’s no way to ask whether the assignment stuck. The fix is to reassert the crop every frame instead of trusting the one set when the slot was built. It costs one extra write per particle, only on flipbook emitters. One case is left uncovered, the first frame of the first burst with the texture still in flight, but since slots get recycled it can’t happen again.

The pool lives in its own zero-size Frame, anchored to the parent’s center, and each particle is positioned with a pixel offset from there. Having its own frame keeps a UIListLayout on the parent from trying to arrange the particles, and the frame has Archivable = false so cloning the UI doesn’t copy a stale pool and a place saved mid-preview doesn’t keep it. With SprayIgnoreClips, the frame moves above any ancestor with ClipsDescendants and is repositioned over the emitter’s origin every frame using AbsolutePosition, subtracting the GUI inset when the ScreenGui respects it.

ZOffset is added to the base ZIndex, so emitters sharing a parent stack the way they were set up. Under ZIndexBehavior.Sibling, however, no GuiObject can rise above its parent’s siblings, so no ZIndex gets a particle out of its own subtree. That’s an engine restriction, and the documentation tells users to put the effect under a parent that’s already in front.

Last, :Destroy() gives the object a metatable that throws on any access. A reference that was left alive by mistake fails where it’s used, instead of half working against an empty table and failing in some other module.

Since the engine keeps stepping frames in edit mode and Studio draws StarterGui over the viewport, the Preview module can play emitters without starting a playtest, straight from the command bar. It’s a development tool that nothing in the package requires, so it never runs in the game.

12. Evaluation

12.1 Method

The reference is Emitter2D, another UI particle library for Roblox. The same effect was built by hand in both, with the same texture, spread, speed, size and transparency curves, color gradient, rotation, gravity, drag and size on screen. Spray measures in parent heights and Emitter2D in scaled pixels, which is where the factor of 1.5 between the two setups’ numbers comes from. The scene is 10 emitters on 100 x 100 px frames.

The first thing I learned was that absolute times are useless. The same test ran twice as fast or twice as slow depending on the minute, because the machine changed pace mid-run. What did hold was the ratio between the two libraries when they ran back to back, so I built the benchmark around that ratio. There are three tests:

  • In steady, 100 to 4000 particles are kept alive and each library’s update is timed on its own, with 15 warmup frames and 120 sampled ones. Each count runs in A B B A order, and each library keeps the better of its two medians. It’s repeated with a simple version of the effect (flat color, no spin), closer to a typical UI effect.
  • Burst measures how long a 200-particle burst takes to get on screen, cold (no ImageLabels created yet) and warm (reusing the previous burst’s), over 5 rounds. Spray also runs it on a pool prepared with :Prewarm(), which Emitter2D has no equivalent for.
  • In live, both libraries run on their own schedulers, re-bursting every 0.25 s, for 3 rounds alternating who goes first, and each reports its median round.

12.2 Starting point

Honestly, the first measurement was nothing to brag about. With 1000 particles on screen they tied (9.2 against 9.4 ms), and with repeated bursts Spray lost, 22 against 14 ms in one run and 67 against 16 in another. The gap between those two runs was the symptom of the full redraw on every :Emit() (section 4.4).

12.3 Results

After the changes in sections 4, 8 and 10:

Test Spray Emitter2D Ratio
1000 particles on screen 6.4 ms 8.7 ms 1.36x
4000 particles 25.6 ms 36.2 ms 1.41x
1000, flat color and no spin 3.6 ms 6.0 ms 1.67x
4000, flat color and no spin 15.5 ms 28.4 ms 1.83x
200-particle burst reusing the pool 1.6 ms 4.7 ms 2.94x
Bursts every 0.25 s, ~1000 alive 8.6 ms 11.0 ms 1.28x

The pattern goes the way the cost model from section 10 predicts, since the advantage is biggest where Spray skips the most writes. In the full effect all five properties change every frame, so the cache barely helps, and Spray writes five against Emitter2D’s ten. In the simple effect it drops to three against ten, and the advantage also grows with the particle count, which is what you’d expect when the dominant term is Nwcw.

12.4 Correctness

The optimizations weren’t allowed to change the output, and they didn’t: with the same seed, Spray produces exactly the same particles as before optimizing. The 88-test TestEZ suite covers the hash (range, mean near 1/2, independence between channels, neighboring indices that don’t clump), the physics (free fall as v0t+at2/2, exponential decay under drag, convergence to a/k, continuity as k→0, finite values with negative drag), emission (spread cone, box and ellipse areas, Inward), area-preserving squash, streak orientation, the flipbook modes, attribute translation, the steady stream’s population (r×L particles alive, never past the pool) and the clock’s properties: jumping and stepping land on the same frame, and rewinding shows what was seen on the way forward.

13. Limitations and future work

The most visible limitation is light. Overlapping particles don’t add up, and some Roblox textures made for additive blending (fire_main, fire_sparks, forcefield_glow) come out dark on the GUI because they have no real alpha channel. ShapePartial and VelocityInheritance are read but not used yet. LockedToPart = false isn’t supported for the reason in section 2, and the 64-burst history is a practical limit for anyone calling :Emit() very often.

The most promising direction is an optional renderer on EditableImage, which has ImageCombineType.Add and therefore real additive blending. A probe in Studio measured the following. Clearing a 512 x 512 canvas with WritePixelsBuffer costs 0.11 ms. Drawing 1000 particles with DrawImageTransformed costs about 32 ms, the same with normal and additive blending, against about 5.8 ms with ImageLabels. That call also takes no per-particle color or transparency, and the .dds textures that ship with Roblox don’t load. Drawing particle by particle through the engine is then about five times slower, and would only make sense for small effects where additive blending matters more than particle count.

The option still left to measure is a custom rasterizer. Spray would write every particle’s pixels into a buffer and do a single WritePixelsBuffer per frame, so color, transparency and additive summing would become arithmetic, and writing to the engine would be one call instead of thousands. That also changes the analysis in section 10.2: if drawing stops being property writes, f stops being 0.83 and the work really can be split across Actors, for example by cutting the canvas into strips with one EditableImage per Actor so no memory is shared. The current architecture already allows it, because the simulation never touches instances and only the renderer would have to change. The open question is whether native Luau can blend about 300 thousand pixels per frame (1000 particles of 20 px) in a few milliseconds. If it can’t, ImageLabels keep winning.

14. What the project left me with

Evaluating instead of integrating is what makes rewinding possible, and it also simplified almost everything else: there are no lists of live particles to maintain, determinism comes for free and the tests can compare exact frames. The other lesson was about performance. Nearly all the time goes into writing to the engine, so the improvements that moved the numbers were the ones that stopped doing useless work, and making the math faster, like with the buffers, barely changed the total. And I only saw that once I started measuring the ratio against a fixed reference, before touching the code.

References

  1. W. T. Reeves. “Particle Systems: A Technique for Modeling a Class of Fuzzy Objects”. ACM Transactions on Graphics, 2(2), 1983.
  2. G. L. Steele Jr., D. Lea and C. H. Flood. “Fast Splittable Pseudorandom Number Generators”. OOPSLA, 2014.
  3. J. K. Salmon, M. A. Moraes, R. O. Dror and D. E. Shaw. “Parallel Random Numbers: As Easy as 1, 2, 3”. SC ’11, 2011.
  4. J. D. C. Little. “A Proof for the Queuing Formula: L = λW”. Operations Research, 9(3), 1961.
  5. G. M. Amdahl. “Validity of the Single Processor Approach to Achieving Large Scale Computing Capabilities”. AFIPS Spring Joint Computer Conference, 1967.

The code, the API documentation and the benchmark are at github.com/mastedore/spray. Spray is released under the Mozilla Public License 2.0.

This text by Mastedore. All rights reserved.