{"id":"mastedore/spray","name":"spray","scope":"mastedore","platform":"roblox","description":"ParticleEmitters rendered as GUI. Plays an emitter's authored effect out of ImageLabels, with a scrubbable, deterministic clock.","version":"1.0.0","latest":"1.0.0","versions":["1.0.0"],"license":"MPL-2.0","licenseRating":"caution","licenseCaveats":["File-level copyleft: if you modify this package's own source files, those modified files must be made available under MPL-2.0. Using it unmodified in a closed-source game is fine."],"licenseVerified":true,"dependencies":{},"integrity":"fe1db83c1daaf98deb58d932d656717ea6bb6419e6fb7f06660e963584771774","likes":0,"downloads":0,"install":"forest install mastedore/spray","url":"https://forest.dev/p/roblox/mastedore/spray","files":"https://api.forest.dev/ai/package/roblox/mastedore/spray/files","readme":"<p align=\"center\">\r\n  <img src=\"https://github.com/mastedore/spray/blob/main/resources/spray_logo_smaller.png\" alt=\"Spray logo\"/>\r\n</p>\r\n<h1 align=\"center\">Spray! The Roblox 2D ParticleEmitter library.</h1>\r\n\r\n# Documentation and API reference: [mastedore.github.io/spray](https://mastedore.github.io/spray/)\r\n\r\n<!--moonwave-hide-before-this-line-->\r\n\r\nParticleEmitters for Roblox GUI. Put a regular ParticleEmitter inside a Frame, author it in the Properties panel the way you would in 3D, and Spray plays it on screen in 2D with ImageLabels.\r\n\r\n```lua\r\nlocal Spray = require(ReplicatedStorage.Packages.Spray)\r\n\r\nlocal sparkles = Spray.New(button.Sparkles) -- a ParticleEmitter inside a GuiObject\r\nsparkles:Emit(30)\r\n```\r\n\r\nThe emitter is the config. Texture, Color, Size, Transparency, Squash, Speed, Drag, Acceleration, shapes, flipbooks, Rate and TimeScale are read from it directly. The few settings a 3D emitter has no property for (the emission area on screen, a size multiplier, the particle cap) are attributes on that same emitter.\r\n\r\nParticles aren't stepped frame by frame. Where each one is gets computed from the Spray's clock, so an effect can be paused, jumped to any moment with `:SetTime()` or played backwards, and with a fixed seed it produces the same particles every time.\r\n\r\n## Installing\r\n\r\nWith [Wally](https://wally.run):\r\n\r\n```toml\r\n[dependencies]\r\nSpray = \"mastedore/spray@1.0.0\"\r\n```\r\n\r\nWith the [Studio plugin](https://github.com/mastedore/spray/blob/main/plugin/README.md), press **Import** on the Spray toolbar. It puts the latest version from this repository at `ReplicatedStorage.Packages.Spray`.\r\n\r\nOr build the model yourself with `rojo build default.project.json -o Spray.rbxm` and drop it wherever you keep your packages.\r\n\r\nSpray draws on the client, so require it from a LocalScript.\r\n\r\n## Usage\r\n\r\n```lua\r\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\r\nlocal Spray = require(ReplicatedStorage.Packages.Spray)\r\n\r\nlocal button = script.Parent -- a TextButton with a ParticleEmitter named Confetti inside\r\nlocal confetti = Spray.New(button.Confetti)\r\n\r\nbutton.Activated:Connect(function()\r\n\tconfetti:Emit(40)\r\nend)\r\n```\r\n\r\nA new Spray starts paused. Calling `:Emit()` on a paused Spray rewinds it to zero and plays from the start, and on one that's already playing it adds another burst on top, the same as `ParticleEmitter:Emit()`. An emitter with `Enabled` on and a `Rate` above zero also streams particles for as long as the Spray is playing, so `:Resume()` is enough to start it.\r\n\r\nSpray reads the emitter once. If you change its properties while the game runs, call `:Cleanup()` and the next use reads them again.\r\n\r\nTo freeze or replay an exact moment:\r\n\r\n```lua\r\nlocal explosion = Spray.New(frame.Explosion)\r\nexplosion:UseRandom(false)\r\nexplosion:RandomSeed(7)   -- same seed, same particles, down to the flipbook frame\r\n\r\nexplosion:Emit(60)\r\nexplosion:Pause()\r\nexplosion:SetTime(0.35)   -- exactly what you'd see 0.35 s into the effect\r\nexplosion:Forward(-0.1)   -- back to 0.25 s\r\n```\r\n\r\n### API\r\n\r\nThe [API reference](https://mastedore.github.io/spray/api/Spray) explains each of these in more detail, with examples.\r\n\r\n| Call | What it does |\r\n| --- | --- |\r\n| `Spray.New(emitter)` | Wraps a ParticleEmitter that has a GuiObject above it (a Folder in between is fine) and builds its pool. Starts paused at time 0. |\r\n| `:Emit(count)` | Releases `count` particles, capped at `SprayMaxParticles`. Rewinds and plays if the Spray was paused, stacks on top if it was playing. |\r\n| `:Pause()` | Stops the clock. Particles freeze where they are. |\r\n| `:Resume()` | Starts the clock again from where it stopped. |\r\n| `:Forward(seconds)` | Moves the clock by that many seconds (negative rewinds) and redraws. Doesn't change whether the Spray is playing. |\r\n| `:SetTime(seconds)` | Jumps the clock to a time and redraws. Doesn't change whether the Spray is playing either. |\r\n| `:UseRandom(use)` | `true` (the default) rolls a new seed every time the effect restarts. `false` replays the seed from `:RandomSeed()`. |\r\n| `:RandomSeed(seed)` | Sets the seed used while `:UseRandom(false)` is on. |\r\n| `:Prewarm(count)` | Builds `count` ImageLabels now (all of `SprayMaxParticles` if left out), so later bursts reuse them instead of creating any. Call it where a hitch doesn't matter, like a loading screen. |\r\n| `:Cleanup()` | Throws away the pool and the snapshot of the emitter. The next call rebuilds both and reads the emitter again. |\r\n| `:Destroy()` | Cleans up for good. Using the object afterwards throws an error. |\r\n\r\n## Attributes\r\n\r\nAll optional, all set on the ParticleEmitter.\r\n\r\n| Attribute | Type | Default | What it does |\r\n| --- | --- | --- | --- |\r\n| `SprayScale` | number | `1` | Multiplies size and speed together. It rescales the whole effect without changing its shape, so try this one first. |\r\n| `SpraySizeScale` | number | `1` | Size only, on top of `SprayScale`. |\r\n| `SpraySpeedScale` | number | `1` | Speed only, on top of `SprayScale`. |\r\n| `SprayUnit` | string | `RelativeYY` | Which edge of the parent counts as one stud: `RelativeYY`, `RelativeXX`, `RelativeMin`, `RelativeMax`, or `Offset` (1 stud = 1 pixel). |\r\n| `SprayEmissionSize` | Vector2 | `0, 0` | Emission area as a fraction of the parent's size. Zero is a point emitter. |\r\n| `SprayMaxParticles` | number | `400` | Most particles alive at once. `:Emit()` is capped to it, and the stream's rate is lowered to fit it. |\r\n| `SprayGlowLayers` | number | `1` | Copies drawn per particle, from 1 to 8. The extra copies are the halo that stands in for LightEmission. |\r\n| `SprayZIndex` | number | parent's ZIndex | Base ZIndex that `ZOffset` is added to. |\r\n| `SprayFlipbookGrid` | number | `4` | Frames per row, for `FlipbookLayout = Custom`. |\r\n| `SprayFlipbookResolution` | number | `1024` | Size of the flipbook texture in pixels. |\r\n| `SprayIgnoreClips` | boolean | `false` | Moves the pool above any ancestor that clips its descendants, so particles can leave the frame. |\r\n| `SprayPrewarm` | number | `0` | ImageLabels to build as soon as the Spray is built, the same as calling `:Prewarm()` with that count. |\r\n\r\n## From 3D to the screen\r\n\r\nOne stud is the height in pixels of the GuiObject the emitter sits in. The effect scales with its UI instead of with the screen, so it keeps its look after a resize or on a phone.\r\n\r\n`Acceleration` gets its Y flipped because +Y points down in GUI space, so gravity authored as `(0, -10, 0)` still pulls particles down. `EmissionDirection` maps Top, Bottom, Left and Right to the screen, and Front and Back fall back to Top. Only the X of `SpreadAngle` is used, and 180 covers the whole circle.\r\n\r\n`ZOffset` is added to the base ZIndex, so emitters stack the way they were authored. Under `ZIndexBehavior.Sibling` a particle can't rise above its host's siblings, though, so if an effect has to sit in front of some other UI, put it under a host that's already in front.\r\n\r\nGUI only has normal alpha blending, so `LightEmission` is emulated. Brightness above 1 is tone-mapped toward white, which gives you the blown-out core that additive particles have, and with `SprayGlowLayers` above 1 each particle gets fading halo copies behind it, scaled by `LightEmission`. Overlapping particles still don't add up the way real additive blending would. `LightInfluence` tints the colour toward `Lighting.Ambient`.\r\n\r\nParticles move with their parent GuiObject, as if `LockedToPart` were on. Tween the frame and the effect goes with it.\r\n\r\n## Performance\r\n\r\nMost of what a UI particle costs is the engine writing ImageLabel properties. Spray writes only the ones whose value changed since the last frame, keeps its ImageLabels in a pool, and runs every Spray from a single RenderStepped connection that only exists while something is playing. Against Emitter2D with the same effect, it used about a quarter less CPU per frame on an effect where every property changes on every frame, and about 40% less on one with a flat colour and no spin. The benchmark and how to run it are in [bench](https://github.com/mastedore/spray/blob/main/bench/README.md), and [docs/optimization.md](https://github.com/mastedore/spray/blob/main/docs/optimization.md) goes through what was measured and changed (in Spanish).\r\n\r\n## Previewing in Studio\r\n\r\n`Spray.Preview` plays emitters in edit mode, without a plugin or a playtest. Select a ParticleEmitter in StarterGui (or anything that contains some) and run this in the command bar:\r\n\r\n```lua\r\nrequire(game.ReplicatedStorage.Packages.Spray.Preview).Play()\r\n```\r\n\r\nIt loops the selection in bursts. Property changes show up the next time you call `.Play()`. `.Scrub(0.15)` freezes every previewed emitter at one moment, which helps while you edit a Size or Transparency curve, and `.Step(0.02)` nudges that moment forward. Call `.Stop()` before saving, because the preview turns on any hidden UI it needs to show the emitters and `.Stop()` is what turns it back off.\r\n\r\nThe [Spray plugin](https://github.com/mastedore/spray/blob/main/plugin/README.md) adds a toolbar with Import, Remove, Playground and Preview. Its Preview plays the selected emitters in place with a playback window and rebuilds on every edit. Playground opens a sandbox where you build effects on a stage and export them to StarterGui, with the LocalScript that plays them if you want it. The same playground also runs as a game: [Spray Playground](https://www.roblox.com/games/130730172678468).\r\n\r\n## Limitations\r\n\r\n- The emitter has to be inside a GuiObject. Emitters on Parts and Attachments aren't drawn.\r\n- `LockedToPart = false` isn't supported, on purpose. Pinning particles to where their parent was when they spawned means keeping history, and not keeping any is what makes scrubbing possible. If you need it, use a second emitter parented higher up.\r\n- `ShapePartial` and `VelocityInheritance` are read but not used yet.\r\n- Textures made for additive blending come out dark on a GUI. Some of Roblox's built-in ones do this (`fire_main`, `fire_sparks`, `forcefield_glow`). Textures with a real alpha channel look right.\r\n- Spray remembers the last 64 `:Emit()` calls. If you call `:Emit()` more often than that within one particle lifetime, the oldest bursts disappear early, so use `Rate` for continuous emission.\r\n- With a flipbook, the first burst after a Spray is built can show the whole sheet for one frame while the texture loads. Slots are reused afterwards, so it doesn't happen again.\r\n\r\n## Working on Spray\r\n\r\nRojo and Wally are pinned in `aftman.toml`:\r\n\r\n```bash\r\naftman install\r\nwally install\r\n```\r\n\r\n- `rojo serve test.project.json`, then Run in Studio, runs the TestEZ suite and prints the result to the output.\r\n- `rojo serve place.project.json` serves the playground place.\r\n- `rojo build plugin.project.json --plugin SprayPlugin.rbxm` builds the plugin straight into Studio's plugins folder.\r\n- `npx moonwave@1.4.2 dev --code Spray/init.luau --code Spray/Preview.luau` serves the docs site at `localhost:3000` and reloads it when you save. It needs Node.js 18 or newer. The API pages come from the `--[=[ ]=]` comments in those two files, and the guides from `docs/`. Pushing to `main` publishes the site through `.github/workflows/docs.yml`.\r\n\r\n## License\r\n\r\nSpray is licensed under the [Mozilla Public License 2.0](https://github.com/mastedore/spray/blob/main/LICENSE.md). In plain words, you can use it in anything, open or closed source, free or paid. If you change Spray's own files and share the result, those files stay under MPL-2.0 and their source has to be available. Your own code that uses Spray can be under whatever license you want.\r\n\r\n\r\n# I'd like to see cool stuff made with Spray! :>","readmeTruncated":false}