2D Particles#

OneJS includes a 2D particle engine for UI effects: sparks, glows, bursts, trails. Systems render inside regular elements (they clip, transform, and sort like any other UI content), the simulation runs entirely in C#, and each system draws in a single draw call with per-particle additive blending.

Use cases: button and reward effects, ambient backgrounds, cursor trails, hit feedback, loading flourishes, collect animations, victory confetti.

A loot stash where each item's aura comes straight from its rarity tier, so one config table styles the whole grid. The plain items cost nothing: a system with nothing alive stops repainting.

Quick Start#

Attach a system to an element with useParticles:

import { useRef } from "react"
import { View, useParticles, render } from "onejs-react"

function Fountain() {
    const ref = useRef(null)
    useParticles(ref, {
        max: 2000,
        emitters: [{
            rate: 200,                                // particles per second
            pos: [150, 350],                          // element-local px
            angle: [252, 288],                        // degrees; 0 = right, 90 = down
            speed: [260, 420],
            lifetime: [0.9, 1.7],
            size: [10, 20],
            gravity: [0, 420],
            additiveness: 1,                          // pure additive glow
            colorOverLife: ["#ffe9a0", "#ff9040", "#ff382000"],
            sizeOverLife: [1, 0.3],
        }],
    })
    return <View ref={ref} style={{ width: 300, height: 380, backgroundColor: "#181824" }} />
}

render(<Fountain />, __root)

That's a complete effect: golden sparks that spray upward, fall under gravity, shrink, and fade from warm white to transparent red.

The config crosses to C# once when the system is created. After that, emission, simulation, and rendering all happen on the C# side, so your components do no per-frame work. Additive effects read best over dark backgrounds.

Give the system its own element. A host is particle-owned: the system assigns style.unityMaterial to it, which replaces the standard UI material for that element's draw. A host that also carries its own borderWidth or borderRadius loses UI Toolkit's antialiasing on that chrome, and rounded corners come out visibly jagged. Style a parent or sibling and hand the particles a plain overlay <View>:

<View style={{ width: 58, height: 58 }}>
    <View style={{ position: "absolute", left: 0, top: 0, width: 58, height: 58,
                   borderRadius: 8, borderWidth: 1, borderColor: "#4a6fa5" }} />
    <View ref={ref} pickingMode="Ignore"
          style={{ position: "absolute", left: 0, top: 0, width: 58, height: 58 }} />
</View>

A flat backgroundColor with no radius or border is unaffected, which is why the example above is fine as-is.

Emitter Options#

Every option has a sensible default; a bare { rate: 100 } works. Options that take a range accept either a fixed number or a [min, max] pair sampled per particle.

OptionTypeDefaultNotes
ratenumber0Particles/second. 0 = burst-only emitter
pos[x, y][0, 0]Element-local px
shapeobjectpoint{ type: "circle", radius }, { type: "rect", width, height }, { type: "line", length }
anglerange[0, 360]Emission direction in degrees; 0 = +X, 90 = down
speedrange0px/second
lifetimerange1Seconds
sizerange8px
aspectrange1Width:height ratio. 1 = square, 0.2 = a vertical streak
gravity[x, y][0, 0]px/second²
dragnumber0Velocity damping per second
rotation, angularVelrange0Degrees, degrees/second
additivenessnumber0Blend mode: 0 = normal alpha, 1 = additive, between = mix
colorOverLifecolorswhite2–8 keys over the particle's lifetime
sizeOverLifenumbers12–8 size multipliers over the lifetime
tintPalettecolorsnoneUp to 16; one picked per particle and multiplied into colorOverLife
attractobjectnonePulls particles to a point (see Attraction)
edgestring"none""kill", "bounce", "stick" (see Edges)
bouncinessnumber0.5Restitution for edge: "bounce"
textureTexture2Dsystem texturePer-emitter sprite override
sheetobjectnonePlay the texture as a flipbook grid (see Flipbook)

Colors accept hex strings ("#f80", "#ff8800", "#ff880080") or [r, g, b, a] floats. Curve keys are spaced evenly by default; pass { t, color } / { t, v } entries for explicit timing:

colorOverLife: [{ t: 0, color: "#fff" }, { t: 0.2, color: "#ffd060" }, { t: 1, color: "#ff608000" }]

System-level options: max (particle capacity, default 1000, where emission and bursts clamp), space (see below), seed (deterministic playback), and texture.

Imperative Control#

useParticles returns a handle. Its methods map to single interop calls, so they're cheap enough for pointer-frequency use:

function Sparks() {
    const ref = useRef(null)
    const fx = useParticles(ref, {
        emitters: [{ rate: 0, speed: [60, 520], drag: 2.4, additiveness: 1,
                     colorOverLife: ["#ffffff", "#ffd060", "#ff608000"] }],
    })

    return (
        <View
            ref={ref}
            style={{ width: 300, height: 300 }}
            onPointerDown={(e) => {
                const wb = ref.current.worldBound   // events carry panel coords
                fx.burst({ x: e.x - wb.x, y: e.y - wb.y, count: 60 })
            }}
        />
    )
}
CallEffect
fx.burst({ x, y, count, emitter? })One-shot emission using an emitter's ranges
fx.emitters[i].pos(x, y)Move an emitter (e.g., follow the pointer)
fx.emitters[i].attract(x, y)Move the attraction target
fx.emitters[i].rate = nChange emission rate live
fx.emitters[i].start() / .stop()Toggle continuous emission
fx.pause() / fx.resume() / fx.clear()Whole-system control
fx.aliveCountCurrent live particle count

The hook disposes the system on unmount (and hot reload) automatically. Outside components, use createParticles(element, config) and call dispose() yourself.

Attraction#

attract pulls particles toward a point so they arrive there by the end of their life. This is the collect effect every game needs, whether that is coins flying into a currency counter, souls into a totem, or XP orbs into a bar:

const fx = useParticles(ref, {
    emitters: [{
        rate: 0,
        speed: [80, 240],                 // burst outward first...
        lifetime: 0.8,
        additiveness: 1,
        colorOverLife: ["#ffe9a0", "#ffb02000"],
        attract: { pos: [280, 20] },      // ...then converge on the counter
    }],
})
// fx.burst({ x, y, count: 24 })
FieldDefaultNotes
pos-Target in element-local px
strength1How completely the pull wins by end of life. 1 = exact arrival, 0.5 = halfway
ease"in""in" holds the spread then whooshes in; "linear" and "out" pull earlier

Gravity, drag, and initial velocity still apply, and the pull progressively overrides them, which is what gives the effect its characteristic arc. Call fx.emitters[i].attract(x, y) to retarget at runtime (one interop call, cheap per frame if the target is moving).

This is deliberately not a physical attractor. Real gravity wells overshoot and orbit; UI collect effects need to land. Set strength: 1 and particles hit the target exactly as they expire.

Edges#

edge decides what happens when a particle leaves the host element's rect:

ModeBehaviorUse for
"none"Particles keep going (clipped by normal UI rules)Default
"kill"Reclaimed immediatelyDon't waste capacity on offscreen particles
"bounce"Reflects off the edge, scaled by bouncinessConfetti, bouncing coins
"stick"Freezes in place; the particle still ages and fadesSettled snow, splatter, confetti piling up

Spawn inside the rect when using an edge mode: a particle born outside the bounds is already past the edge, so it dies or sticks on its first frame.

// Victory confetti: multicolored, tumbling, settling at the bottom
emitters: [{
    rate: 300,
    pos: [200, -20],
    shape: { type: "rect", width: 400, height: 10 },
    speed: [40, 120], angle: [70, 110],
    lifetime: [2.5, 4],
    size: [8, 14], aspect: [0.4, 0.7],     // paper strips, not dots
    angularVel: [-360, 360],               // tumble
    gravity: [0, 700], drag: 1.2,
    edge: "stick",
    tintPalette: ["#ff4d6d", "#ffd166", "#06d6a0", "#4d96ff", "#f8f9fa"],
    colorOverLife: [{ t: 0, color: "#fff" }, { t: 0.85, color: "#fff" }, { t: 1, color: "#ffffff00" }],
}]

tintPalette is what makes that one emitter multicolored: each particle picks one entry at spawn and it's multiplied into colorOverLife, so the fade ramp still controls the alpha.

Textures and Blending#

By default particles use a built-in soft radial sprite, which covers most glow and spark effects. Pass a Texture2D as texture for custom sprites. Author them with premultiplied alpha (RGB multiplied by A) so they blend correctly across the whole additiveness range.

texture works at the system level and per emitter, so one system can mix sparks and smoke. Emitters sharing a texture share a draw call, so keep the number of distinct sprites small: each one adds a draw.

additiveness is per emitter and continuous: 0 blends like normal UI (good for smoke, confetti), 1 adds light (glows, sparks, fire), and mid values mix the two. Different emitters in one system can use different values, and it's all still one draw call.

On platforms or pipelines where the particle shader isn't available, systems automatically fall back to normal alpha blending; effects stay visible, just without the additive glow.

Painted sprites: keep additiveness at 0#

The blend is One OneMinusSrcAlpha, so the sprite's alpha channel is an occlusion channel and its RGB is a light contribution. A texel with color but little alpha adds to whatever is behind it, while an opaque texel replaces it. One sprite can therefore carry a blown-out additive core and a soft occluding edge, which is exactly how hand-painted flames, glows and explosions are authored.

That effect is driven entirely by the texture, so leave additiveness at 0 for painted art. Setting it to 1 forces the whole quad additive and discards the alpha channel you painted. Reach for additiveness when you want to tint procedural sparks, not when the sprite already encodes the look.

Two import settings matter for this: turn Alpha Is Transparency off, since its color dilation rewrites the transparent-but-colored texels where the additive core lives, and avoid block compression, which distorts the low-alpha / high-RGB relationship first.

Flipbook Animation#

Curves animate a particle's color and size, but they cannot animate its shape. Hand-painted effects get their motion from a sprite sheet instead. Set sheet and the quad's UVs narrow to one cell, chosen from the particle's age:

emitters: [{
    rate: 24,
    lifetime: [0.7, 1.1],
    size: [48, 72],
    additiveness: 0,                     // the sheet's alpha drives the look
    texture: flameSheet,                 // a 4x4 CS Texture2D
    sheet: { cols: 4, rows: 4, randomStart: true },
}]
FieldDefaultNotes
cols, rows-Grid dimensions. Frame 0 is the top-left cell, advancing left to right, top to bottom
mode"life""life" plays the sheet once over the particle's lifetime; "fps" loops at a fixed rate
fps24Frame rate for mode: "fps"
randomStartfalseOffsets each particle to a random frame, so a burst does not animate in lockstep
frameCountcols * rowsUse only the first N cells, for sheets whose last row is padded

mode: "life" is the usual choice: every particle plays the animation exactly once and dies on the last frame, so lifetime alone controls the playback speed. Use mode: "fps" with randomStart for continuous effects like a torch, where particles should each be at a different point in a looping cycle.

Leave a pixel or two of padding around each cell. Frames sit next to each other in one texture, so bilinear filtering at a cell boundary can pull in a neighbouring frame.

Local vs Panel Space#

space: "local" (default) keeps particles in the element's coordinate space, so they move with the element, ideal for self-contained effects.

space: "panel" keeps spawned particles fixed in panel space, so they trail behind when the element (or emitter) moves. Use it for cursor trails and motion streaks.

Performance#

  • Simulation and rendering are C#-side; a running system costs zero JavaScript work per frame.
  • Each system is one draw call (one per distinct emitter texture); 10,000 particles cost roughly 1.5 ms of CPU mesh generation per frame, so typical UI budgets (a few hundred to a few thousand) are far below the noise floor.
  • max is your budget knob: emission and bursts never exceed it.
  • Idle systems (nothing alive, no active emitters) stop repainting entirely.

Sample#

The Particles Demo sample (Package Manager > OneJS > Samples) packages a three-effect showcase (fountain, click burst, pointer trail) as a UI Cartridge. Import the sample, drag the cartridge onto your JSRunner, and render <ParticlesDemo />.