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.

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
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
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 — 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 })
            }}
        />
    )
}
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].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.

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.
  • 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 />.