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.
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.
| Option | Type | Default | Notes |
|---|---|---|---|
rate | number | 0 | Particles/second. 0 = burst-only emitter |
pos | [x, y] | [0, 0] | Element-local px |
shape | object | point | { type: "circle", radius }, { type: "rect", width, height }, { type: "line", length } |
angle | range | [0, 360] | Emission direction in degrees; 0 = +X, 90 = down |
speed | range | 0 | px/second |
lifetime | range | 1 | Seconds |
size | range | 8 | px |
aspect | range | 1 | Width:height ratio. 1 = square, 0.2 = a vertical streak |
gravity | [x, y] | [0, 0] | px/second² |
drag | number | 0 | Velocity damping per second |
rotation, angularVel | range | 0 | Degrees, degrees/second |
additiveness | number | 0 | Blend mode: 0 = normal alpha, 1 = additive, between = mix |
colorOverLife | colors | white | 2–8 keys over the particle's lifetime |
sizeOverLife | numbers | 1 | 2–8 size multipliers over the lifetime |
tintPalette | colors | none | Up to 16; one picked per particle and multiplied into colorOverLife |
attract | object | none | Pulls particles to a point (see Attraction) |
edge | string | "none" | "kill", "bounce", "stick" (see Edges) |
bounciness | number | 0.5 | Restitution for edge: "bounce" |
texture | Texture2D | system texture | Per-emitter sprite override |
sheet | object | none | Play 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 })
}}
/>
)
}| Call | Effect |
|---|---|
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 = n | Change emission rate live |
fx.emitters[i].start() / .stop() | Toggle continuous emission |
fx.pause() / fx.resume() / fx.clear() | Whole-system control |
fx.aliveCount | Current 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 })| Field | Default | Notes |
|---|---|---|
pos | - | Target in element-local px |
strength | 1 | How 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).
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:
| Mode | Behavior | Use for |
|---|---|---|
"none" | Particles keep going (clipped by normal UI rules) | Default |
"kill" | Reclaimed immediately | Don't waste capacity on offscreen particles |
"bounce" | Reflects off the edge, scaled by bounciness | Confetti, bouncing coins |
"stick" | Freezes in place; the particle still ages and fades | Settled 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 },
}]| Field | Default | Notes |
|---|---|---|
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 |
fps | 24 | Frame rate for mode: "fps" |
randomStart | false | Offsets each particle to a random frame, so a burst does not animate in lockstep |
frameCount | cols * rows | Use 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. maxis 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 />.