A custom particle engine for Unity uGUI, written from scratch. Particles are simulated on the CPU and pushed straight to the CanvasRenderer โ no Shuriken, no Canvas rebuilds, zero GC allocations per frame.
Everything lives in a single scene: Assets/Scenes/SampleScene.unity โ a portrait mobile "reward screen" driving every engine feature at once.
| Coin reward (attractor) | Confetti (flipbook) | Fireworks (trails) |
|---|---|---|
![]() |
![]() |
![]() |
| Sample | Shows off |
|---|---|
UIParticleCoinReward |
Emit() burst + UIParticleAttractor + arrival callback driving a TMP counter |
UIParticleConfettiDemo |
Texture sheet animation (3ร3 debris flipbook, random start frame) |
UIParticleFireworkShow |
World-space simulation, per-shell color tinting, tapered ribbon trails |
UIParticleClickBurst |
Emitter teleporting to the pointer (heart bursts), world-space particles staying put |
| Falling stars (scene only) | Rect shape emission, prewarm, rotation over lifetime |
Note โ the demo art ships with the repository: a handful of particle sprites from Cartoon FX Remaster FREE by Jean Moreno (in
Samples/Textures) plus two simple UI sprites. The engine itself has no dependencies on any of it.
Rendering particles inside a uGUI canvas is awkward: ParticleSystem lives in world space and fights the canvas (sorting, masking, scaling), while naive Graphic-based solutions rebuild the canvas mesh every frame through SetVerticesDirty, allocating and re-batching constantly.
This engine takes a third path: a UIParticleEmitter is a regular MaskableGraphic, but it bypasses the uGUI rebuild pipeline entirely. Geometry is written into a persistent NativeArray with a compact 24-byte vertex layout and handed to the renderer via CanvasRenderer.SetMesh on double-buffered meshes โ once per frame, only when something actually changed.
- โ Renders inside the canvas: sorts, masks and scales like any other UI element
- โ
Familiar API: modules and playback controls mirror Unity's Shuriken
ParticleSystem - โ Zero per-frame GC allocations, verified by tests and profiling
- Direct mesh push โ
CanvasRenderer.SetMeshwith double-buffered meshes; noSetVerticesDirty, no canvas rebuild, no per-frame allocation - Shuriken-style modules โ main, emission (rate over time / distance + bursts), shape (cone / circle / rect / point), velocity & limit velocity, color / size / rotation over lifetime, texture sheet animation (flipbook), trails
- Trails โ per-particle ribbon trails with width & color curves, fixed zero-alloc ring buffers
- UI Particle Attractor โ pulls particles into a UI target (the classic coin-to-wallet effect) with a per-particle arrival callback
- World / Local simulation space โ world-space particles stay put while the emitter moves, and survive screen / Game-view resizes
- Flipbook animation โ texture sheet playback with cycles, random start frame and a frame-over-time curve
- Edit-mode preview โ play particles in the Scene view without entering Play mode
- Deterministic seeding โ replayable effects via explicit random seeds
- Curve/gradient LUT baking โ
AnimationCurve/Gradientlookups are baked to small LUTs per mesh build, replacing hundreds of thousands of managedโnativeEvaluatecalls - Shuriken-style playback API โ
Play/Pause/Stop(StopBehavior)/Clear/Emit(count)/Simulate/IsAlive - Custom inspector โ boxed module editor styled after the built-in ParticleSystem inspector
- Shuriken migrator โ
Tools โธ UI Particle Engine โธ Particle Migratorconverts existingParticleSystemprefabs intoUIParticleEmitterprefabs: modules mapped one-to-one, world units scaled to canvas pixels, material textures re-imported as sprites, and anything unsupported listed in a report
Window โธ Package Manager โธ + โธ Add package from git URL...
https://github.com/berkencami/ui-particle-engine.git?path=Assets/UIParticleEngine
Or add it to Packages/manifest.json directly:
{
"dependencies": {
"com.berkencami.ui-particle-engine": "https://github.com/berkencami/ui-particle-engine.git?path=Assets/UIParticleEngine"
}
}The engine is a self-contained folder with its own asmdefs โ copy Assets/UIParticleEngine/Runtime (and optionally Editor) into your project. There are no package dependencies; Samples additionally uses TextMeshPro.
Or clone the whole repository and open it as a Unity 6000.3+ project to explore the demo scene and tests.
Add a UI Particle Emitter (UI/Effects/UI Particle Emitter) to any RectTransform under a canvas, assign a sprite, press Play โ or drive it from code:
using UIParticleEngine;
var emitter = gameObject.AddComponent<UIParticleEmitter>();
emitter.Sprite = mySprite;
// Configure modules like Shuriken:
emitter.Main.StartLifetime = new RangeF(0.8f, 1.4f);
emitter.Main.StartSpeed = new RangeF(200f, 400f);
emitter.Main.GravityModifier = 0.3f;
emitter.Main.SimulationSpace = UIParticleEmitter.SimulationSpace.World;
emitter.Shape.ShapeType = UIParticleEmitter.ShapeType.Cone;
emitter.Shape.Angle = 25f;
emitter.ColorOverLifetime.Enabled = true;
emitter.Trails.Enabled = true;
// Playback:
emitter.Play();
emitter.Emit(30); // manual burst
emitter.Stop(UIParticleEmitter.StopBehavior.StopEmitting); // let alive particles finishvar attractor = walletIcon.AddComponent<UIParticleAttractor>();
attractor.Emitter = coinEmitter;
attractor.OnAttracted.AddListener(() => coins++); // fires once per consumed particle| Member | Behavior |
|---|---|
Play() / Pause() |
Start emission / freeze simulation (resumes from pause) |
Stop(StopBehavior) |
Stop emitting, optionally clearing alive particles |
Clear() |
Remove all particles immediately |
Emit(int count) |
Spawn particles now, ignoring emission rate |
Simulate(float dt) |
Step the simulation manually (incremental โ documented deviation from Shuriken) |
IsAlive() |
Any particles still alive or emission pending |
ParticleCount, IsPlaying, IsPaused |
State queries |
Already have ParticleSystem effects? Tools โธ UI Particle Engine โธ Particle Migrator converts a prefab (or scene object) into a ready-to-use UIParticleEmitter hierarchy:
- Every supported module is mapped one-to-one โ main, emission + bursts, shape, velocity, drag, color/size/rotation over lifetime, texture sheet, trails
- World units are scaled to canvas pixels (
Pixels Per Unit), radians to degrees,MinMaxGradientstart colors to a two-color blend - The renderer's main texture is re-imported as a Sprite and assigned to the emitter
- Anything Shuriken-only (noise, collision, sub-emitters, curves collapsed to ranges...) is listed in a migration report instead of failing silently
Measured with the built-in stress probe (simulation step + full mesh build per frame, averaged over 120 frames; Unity 6000.3 editor, Windows desktop):
| Particles | Trails | Simulate / frame | Mesh build / frame | GC / frame |
|---|---|---|---|---|
| 500 | โ | 0.013 ms | 0.19 ms | 0 B |
| 2,000 | โ | 0.050 ms | 0.71 ms | 0 B |
| 8,000 | โ | 0.198 ms | 2.73 ms | 0 B |
| 500 | โ (12 pts) | 0.025 ms | 1.98 ms | 0 B |
| 1,000 | โ (12 pts) | 0.050 ms | 3.97 ms | 0 B |
Compared to the initial SetVerticesDirty-based implementation, the SetMesh path made frame cost ~2โ2.5ร cheaper across the board.
Measured on a mid-range phone (Snapdragon 732G, Adreno 618, 1080ร2400@60Hz), IL2CPP ARM64 development build, results printed by the on-device benchmark in the sample scene:
| Particles | Trails | Simulate / frame | Mesh build / frame | GC / frame |
|---|---|---|---|---|
| 500 | โ | 0.002 ms | 0.08 ms | 0 B |
| 2,000 | โ | 0.011 ms | 0.23 ms | 0 B |
| 8,000 | โ | 0.041 ms | 0.99 ms | 0 B |
| 500 | โ (12 pts) | 0.009 ms | 0.87 ms | 0 B |
| 1,000 | โ (12 pts) | 0.019 ms | 1.86 ms | 0 B |
IL2CPP comes out faster than the Mono editor numbers across the board, and a live sample with 8,000 on-screen particles renders at the panel's 60 Hz ceiling (avg 16.9 ms/frame, 59.3 fps) โ the practical mobile limit is GPU overdraw, not the simulation.
- Bypassing the rebuild pipeline โ
UIParticleEmitternever dirties the canvas for geometry. A hidden updater ([DefaultExecutionOrder(32000)]) advances all registered emitters inUpdateand flushes dirty meshes once inLateUpdateviaCanvasRenderer.SetMesh. - Compact vertices โ a 24-byte vertex (
Vector3position,Color32,Vector2uv) in a persistentNativeArray, with prebuilt index buffers that auto-switch from 16-bit to 32-bit past 65k vertices. - Double buffering โ two meshes flip at most once per frame so the renderer never reads a mesh mid-write.
- Zero-GC simulation โ particles live in a plain struct array with swap-remove; trails use slot-indexed ring buffers; scratch buffers are grow-only statics.
- LUT baking โ every mesh build bakes active curves/gradients into 64-entry LUTs, turning per-particle
Evaluatecalls into array reads. - Culling-aware โ mesh flushes are skipped while the
CanvasRendereris culled (e.g. under aRectMask2D).
33 EditMode tests cover the RNG, emission timing (bursts, cycles, probability, start delay), lifetime, playback state machine, prewarm, seeded determinism, the attractor, trails + flipbook smoke tests, and the Shuriken migrator's module mapping:
Window โธ General โธ Test Runner โธ EditMode โธ Run All
Assets/UIParticleEngine/
โโ Runtime/ UIParticleEmitter, UIParticleAttractor, UIParticleUpdater, RangeF
โโ Editor/ Shuriken-style inspector, RangeF drawer, edit-mode preview, particle migrator
โโ Samples/ Coin reward, confetti, fireworks, click burst + debug probe + demo textures
โโ Tests/EditMode/ 33-test suite



