Luna provides a pure UI Toolkit particle attractor for the classic "fly to" UX pattern: spawn a burst of pooled VisualElement particles at a source element and animate them through a two-stage scatter → home trajectory into a target element. No cameras, no RenderTextures, no ParticleSystem — just live UI Toolkit elements driven by style.translate.

Use it for coin pickups, XP orbs, gem rewards, heart drops, or any "absorb into the HUD" effect.

Features

  • Pure UI Toolkit: pooled VisualElements driven by style.translate — zero camera/RT cost, scales to hundreds of particles
  • Two-stage motion: configurable scatter duration + easing, then home-in duration + easing
  • Per-particle randomization: scale range, scatter angle, scatter distance range, spawn position jitter, start-time stagger
  • Live or snapshot target tracking: snapshot at burst start (cheap, deterministic) or follow a moving target each frame
  • VisualTreeAsset templates: any UXML can be a particle (icon + label + USS — anything UITK can render)
  • Auto-pooling: per-template element pool plus particle state pool, lazy-grow, no hard cap
  • Auto-bootstrap: UIAttractorManager lazily creates itself on first call — no scene wiring required
  • Three API surfaces: static code-first UIAttractorManager.Burst(...), Inspector-friendly UIAttractorComponent MonoBehaviour, or embed the [Serializable] UIAttractorBurstConfig struct on your own MonoBehaviour
  • String-keyed target registry: UIAttractorTarget registers UI elements under string keys; bursts route to the most recently activated target via LIFO
  • Per-particle and completion events: OnArrive(int index) per particle and OnAllArrived once
  • Time-scale aware: defaults to Time.unscaledDeltaTime so bursts animate during pause; toggle to honor Time.timeScale

Architecture

┌──────────────────────────────────────────────┐ │ UIAttractorManager │ │ (Singleton, frame ticker, particle pool) │ ├──────────────────────────────────────────────┤ │ │ │ per panel: ┌───────────────────────────┐ │ │ │ overlay VisualElement │ │ │ │ (auto-promoted at root) │ │ │ └───────────────────────────┘ │ │ │ │ per burst: ┌───────────────────────────┐ │ │ │ UIAttractorBurstConfig (config) │ │ │ │ + N pooled particles │ │ │ └───────────────────────────┘ │ │ │ │ Burst() returns ──► UIAttractorHandle │ └──────────────────────────────────────────────┘

Each frame, every active particle advances along its scatter point (random direction within the scatter radius range) and then toward the target rect center. Each particle has its own randomized scale and an optional start-time stagger so arrivals don't all land on the same frame.

Quick Start

Code-first

csharp
using CupkekGames.Luna; using CupkekGames.Fadeables; // for EasingType VisualElement source = root.Q<VisualElement>("coin-btn"); VisualElement target = root.Q<VisualElement>("counter"); UIAttractorBurstConfig burst = new UIAttractorBurstConfig { Template = coinParticleVTA, // a VisualTreeAsset Count = 12, ScatterDuration = 0.25f, ScatterEasing = EasingType.QuadOut, ScatterMinDistance = 60f, ScatterMaxDistance = 140f, HomeDuration = 0.55f, HomeEasing = EasingType.QuadIn, OnArrive = i => coinCount++, // per-particle absorb tick OnAllArrived = () => counterPulse.Play() }; UIAttractorHandle handle = UIAttractorManager.Burst(source, target, burst);

The first call to Burst() lazily creates the singleton GameObject if it isn't already in the scene — there's nothing to set up.

Inspector-friendly with UIAttractorComponent

For screens that wire bursts via the Inspector instead of code, UIAttractorComponent keeps its surface minimal — the entire motion configuration lives in a single nested Burst field that you expand to tune.

The component's Inspector is organized into three sections:

SectionFields
Source / Target ResolutionUIViewComponent, UIDocument, Source Name, Target Name, Target Key
Burst (foldout)Template, Count, Phase 1/Phase 2 durations + easings, scatter distances, scale range, spawn jitter, start stagger, target tracking, time mode, random seed
EventsOnArrive (int), OnAllArrived

To set up:

  1. Add UIAttractorComponent to a GameObject (typically alongside the screen's UIDocument).
  2. Reference a UIViewComponent or UIDocument and set Source Name plus either Target Name or Target Key (the latter resolves through the registry — see below).
  3. Expand the Burst foldout, assign Template (VisualTreeAsset), tune durations / easings / per-particle settings.
  4. Wire OnArrive (int) and OnAllArrived UnityEvents to scene callbacks if desired.
  5. Call Play() (or Play(int count) to override the Inspector count for one call) from any script that holds a reference.
csharp
[SerializeField] private UIAttractorComponent _coinAttractor; void OnPickup(int amount) => _coinAttractor.Play(amount); // count override void OnPickupRare(int amount) => _coinAttractor.Play(amount, "rare-bag"); // count + key override void OnTutorialFinish() => _coinAttractor.Play("tutorial-target"); // key override only

The component resolves source/target via either a UIViewComponent reference (preferred when you're using Luna's view system) or via a UIDocument fallback — same pattern as TransitionAnimator. When Target Key is set, it takes precedence over Target Name and the burst routes through UIAttractorTargetRegistry.

Play() overloads:

SignatureBehaviour
Play()All Inspector defaults.
Play(int count)Count override; everything else from Inspector.
Play(string targetKey)Registry key override; pass an empty string to bypass the registry and use Target Name instead.
Play(int count, string targetKey)Both overrides.
Play(VisualElement source, VisualElement target)Explicit elements; ignores Source Name, Target Name, and Target Key.

The component exposes public getters for SourceName, TargetKey, TargetName, and Burst so other scripts can read its configuration without duplicating it (e.g. a controller looking up the same source button by name).

Embed UIAttractorBurstConfig on your own MonoBehaviour

UIAttractorBurstConfig is [Serializable] — you can drop it as a [SerializeField] on any MonoBehaviour and skip UIAttractorComponent entirely. This is useful when the burst is one of several knobs on a custom screen controller and you don't want a second component:

csharp
public class CoinPickupScreen : MonoBehaviour { [SerializeField] private UIDocument _doc; [SerializeField] private UIAttractorBurstConfig _coinBurst; public void OnCoinsCollected(int amount) { VisualElement source = _doc.rootVisualElement.Q("pickup-zone"); _coinBurst.Count = amount; UIAttractorManager.Burst(source, "gold", _coinBurst); } }

Inspector for _coinBurst shows the same nested foldout with all motion knobs. Runtime-only fields (OnArrive, OnAllArrived, HostOverride) are [NonSerialized] and stay code-only.

Target Registry

For fly-to UX in apps with multiple HUDs, modals, or scenes, the target element you want particles to land on changes over time — the gold counter on the main HUD, then the gold counter on the shop overlay when it opens, then back. Hard-coding VisualElement references doesn't scale.

UIAttractorTargetRegistry is a global LIFO registry mapping string keys to live targets. The most recently activated registration wins; when it deactivates, the next-most-recent resurfaces automatically.

Registering with UIAttractorTarget

Add a UIAttractorTarget MonoBehaviour next to any HUD:

  1. Set Key (e.g. "gold").
  2. Reference a UIDocument or UIViewComponent and set Element Name.
  3. Leave Activate On Enable checked (default).

On OnEnable the resolved element is pushed to the top of the stack for its key; on OnDisable it is removed. No code wiring needed.

csharp
// Manual control if you don't want the OnEnable hook public UIAttractorTarget _goldTarget; void OpenShop() => _goldTarget.Activate(); void CloseShop() => _goldTarget.Deactivate();

Firing a burst at a key

csharp
UIAttractorManager.Burst(source, "gold", burst);

If multiple targets are registered for "gold", the most recently activated one wins. If none is currently registered, the manager logs a warning and the burst is skipped. Bursts via key automatically follow the active target as registrations come and go.

The same routing happens transparently when a UIAttractorComponent has its Target Key field set — Play() fires through the registry.

Particle Templates

A particle is just a VisualTreeAsset. Anything UI Toolkit can render is fair game: a single styled VisualElement, an Image, a composite "icon + amount" cluster — whatever you need. The manager clones the template once per particle, parents the clone into a per-panel overlay container, and animates style.translate and style.scale each frame.

xml
<!-- Particle template — single styled dot --> <engine:UXML xmlns:engine="UnityEngine.UIElements"> <Style src="MyParticles.uss"/> <engine:VisualElement class="my-coin-particle"/> </engine:UXML>
css
/* MyParticles.uss */ .my-coin-particle { width: 16px; height: 16px; border-radius: 8px; margin-left: -8px; /* recenter the box on the translate point */ margin-top: -8px; background-color: #f5c542; border-width: 2px; border-color: #b78a17; }

The margin-left/-top of half-the-size pattern recenters the element on its translate origin so particles visually center on the source/target, not anchor at top-left.

Opacity fade-in (optional)

Each particle is spawned with inline style.opacity = 0 and flipped to 1 the first frame its start delay ends — i.e. the moment scatter motion begins. This means every particle is invisible while waiting (so a staggered burst doesn't show a frozen pile of dots at the source).

To make the flip animate as a fade-in instead of snapping, add a vanilla USS transition on your particle class:

css
.my-coin-particle { /* ...visual styles... */ transition-property: opacity; transition-duration: 0.15s; transition-timing-function: ease-out; }

Without the transition rule, particles still appear at the right moment but pop into view.

Motion Model

The total burst duration is ScatterDuration + HomeDuration. For each particle:

  • Phase 1 (Scatter): lerp from the source center to a per-particle scatter point, easing applied to phase-local progress.
  • Phase 2 (Home): lerp from the scatter point to the live or snapshot target center, easing applied to phase-local progress.

The scatter point is sourceCenter + dir * Random.Range(min, max) where dir is uniformly sampled on the unit circle. There is no directional bias by default — particles spray outward in every direction before being sucked in. Pass a RandomSeed for deterministic playback (useful for tests or replay-style effects).

Target Tracking

By default the target rect is snapshotted at burst start. Set LiveTargetTracking = true to re-sample the target each frame. Live tracking is the right choice when:

  • The target HUD itself animates in or pulses while the burst is in flight.
  • The screen layout might shift mid-burst (resize, panel pop-in).

Snapshot is cheaper and deterministic — prefer it for fixed HUDs.

Pooling

UIAttractorManager maintains two pools so steady-state bursts allocate nothing:

  • VisualElement pool, keyed by VisualTreeAsset — cloned templates are detached and pushed back on arrival, then re-parented and reused on the next burst with the same template.
  • Particle state pool — internal UIAttractorParticle POCOs that hold per-particle motion state are recycled rather than re-allocated.

Both pools grow on demand and have no hard cap. The first burst of a given template allocates; every subsequent burst with the same template reuses pooled instances.

Settings Reference

FieldDefaultDescription
TemplateUXML cloned per particle. Required.
Count12Particles to spawn.
ScatterDuration0.25Phase-1 duration in seconds.
ScatterEasingQuadOutPhase-1 easing.
ScatterMinDistance60Lower bound of scatter radius.
ScatterMaxDistance140Upper bound of scatter radius.
HomeDuration0.55Phase-2 duration in seconds.
HomeEasingQuadInPhase-2 easing.
ScaleRange(0.85, 1.15)Per-particle uniform scale range.
StartStagger0.05Max random delay added per particle (seconds).
SpawnJitter0Per-particle spawn position jitter radius (pixels). 0 = all spawn at exact source center; larger values let spawns spread within a disc and overlap.
LiveTargetTrackingfalseRe-sample target each frame instead of snapshotting at start.
UseUnscaledTimetrueUse Time.unscaledDeltaTime (continues during pause). Set false to honor Time.timeScale.
RandomSeed00 = system random; non-zero = deterministic.
OnArrivenullAction<int> invoked per particle on arrival.
OnAllArrivednullAction invoked once all particles arrived (or on cancel).
HostOverridenullOptional VisualElement host that overrides the default panel-root overlay.

Gotchas

  • Particle anchoring. The manager sets style.translate to the world-to-local position of the target rect center. The element box still anchors at left: 0; top: 0. Recenter visually with negative margins equal to half the particle's size (the margin-left: -8px; margin-top: -8px pattern above). Otherwise particles will appear shifted by half their box.
  • Source/target must be attached. Both elements must be in a live panel when Burst() is called — worldBound is unreliable on detached elements. If your UI is gated by a fade-in, fire the burst from inside an OnFadeInComplete callback or a schedule.Execute(...) deferred call.
  • Cancellation still fires OnAllArrived. handle.Cancel() removes any in-flight particles and fires OnAllArrived once so callers can rely on it as a single completion signal. Per-particle OnArrive is not fired for cancelled particles.
  • Time scale. The manager defaults to Time.unscaledDeltaTime so bursts continue during pause menus. Set UseUnscaledTime = false on the burst (or on UIAttractorComponent) to honor Time.timeScale.
  • Zero worldBound. If you call Burst() on an element whose layout hasn't run yet, the manager logs a warning and skips the burst. Defer the call until after layout (a click handler, schedule.Execute, or post-fade-in callback).

See Also

  • UIRender — for full Unity ParticleSystem content rendered onto UI Toolkit (when you need GPU particles, trails, or VFX Graph effects rather than icon-fly-to UX).
  • Transition Animation — for sequenced UI tweens (scale, opacity, translate) that compose well with bursts (e.g. OnAllArrived → bump counter via a transition preset).

Settings

Theme

Light

Contrast

Material

Dark

Dim

Material Dark

System

Sidebar(Light & Contrast only)

Light
Dark

Font Family

DM Sans

Wix

Inclusive Sans

AR One Sans

Direction

LTR
RTL