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.
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 — your components do no per-frame work. Additive effects read best over dark backgrounds.
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 |
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 |
"#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 — emission and bursts clamp here), 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].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 |
createParticles(element, config) and call dispose() yourself.
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.
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 — 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.
Local vs Panel Space#
space: "local" (default) keeps particles in the element's coordinate space — 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; 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 />.