UI/Renderable: Particles
GPU-simulated textured particle emitters for Koya overlays.
particles creates a modular GPU-simulated emitter as an element's renderable. Shape, motion, appearance, and noise modules cover sparks, smoke, fire, trails, rings, bursts, and similar Unity-style effects without copying Unity's inspector one-for-one. A particle detaches after spawning, so moving the element naturally leaves a trail.
Imports #
import * as UI from 'Helix/UserInterface';
import * as Particles from 'Helix/Renderable';Create an emitter #
const emitter = await UI.createElement(win, {
renderable: {
type: 'particles',
texture: '/rom/effects/sparks.png',
frameMode: 'random',
position: { x: 200, y: 240 },
config: {
running: true,
main: { seed: 1001, lifetime: { min: 1.0, max: 1.8 } },
emission: { rate: 90 },
shape: {
type: 'ellipse-edge', radius: { x: 12, y: 5 },
arc: { start: 0, angle: Math.PI },
direction: 'outward', coneAngle: 0.25
},
motion: {
speed: { min: 75, max: 135 },
acceleration: { x: 0, y: 105 },
velocityJitter: { x: 15, y: 8 },
drag: { curve: [{ time: 0, value: 0 }, { time: 1, value: 2.5 }] },
noise: {
strength: { x: 28, y: 10 }, frequency: 0.025,
scrollSpeed: 0.8, octaves: 3,
strengthOverLifetime: [{ time: 0, value: 0.2 }, { time: 0.4, value: 1 }, { time: 1, value: 0 }]
}
},
appearance: {
size: {
minCurve: [{ time: 0, value: 7 }, { time: 0.25, value: 13 }, { time: 1, value: 1 }],
maxCurve: [{ time: 0, value: 10 }, { time: 0.25, value: 17 }, { time: 1, value: 3 }]
},
colour: [
{ time: 0, colour: '#fff0a0ff' },
{ time: 0.35, colour: '#ff5c12ee' },
{ time: 1, colour: '#4d080000' }
],
initialRotation: { min: -Math.PI, max: Math.PI },
angularVelocity: { min: -5, max: 5 }
}
}
}
});
await UI.attachRoot(win, emitter);A Koya particle emitter starts running by default; set running: false for a burst-only or manually started emitter. It is created in the overlay channel. Omitting texture retains the original white quad, tinted by the configured colours.
Definition #
type'particles' - Renderable type identifier.configParticleEmitterConfig optional - Emitter configuration. Fields may alternatively be placed directly besidetype.texturestring optional - Image asset sampled by every particle. The full image is used whenframesis omitted; no texture uses a white fallback quad.framesParticleFrame[] optional - Pixel-coordinate rectangles withintexture. Empty or omitted means one full-image frame.frameMode'fixed' | 'random' | 'over-lifetime' optional - Frame selection strategy. Defaultfixed.framenumber optional - Zero-based frame used byfixed, clamped to the available frame count. Default0.position{ x: number, y: number } optional - Emitter position in the element's logical coordinate space. Default{ x: 0, y: 0 }.origin{ x: number, y: number } optional - Transform origin inherited from shared renderable properties.scale{ x: number, y: number } optional - Emitter transform scale. It affects the spawn position/direction transform; spawned particles are detached.rotationnumber optional - Emitter transform rotation in degrees, consistent with other Koya renderables. Spawned particles retain the transformed direction after emission.
channel'overlay' | 'world' optional - Runtime channel used bysetParticleConfig.UI.createElementalways creates the emitter asoverlayand ignores this field during creation. The full-replacement runtime default isworld.runningboolean optional - Creation-only initial running state. Defaulttrue. Runtime reconfiguration usesstartParticlesandstopParticles.mainParticleMainModule optional - Seed and lifetime shared by all particles.emissionParticleEmissionModule optional - Continuous spawning controls.shapeParticleShapeModule optional - Spawn region and initial direction.motionParticleMotionModule optional - Initial speed, force, drag, jitter, and coherent noise.appearanceParticleAppearanceModule optional - Size, colour, and rotation over normalized lifetime.
main{ seed?: number, lifetime?: ParticleScalarRange } -lifetimeis seconds and is clamped above zero.emission{ rate?: number } - Particles per second; fractional rates accumulate across frames.shapeParticleShapeModule -type:point,line,box,box-edge,ellipse,ellipse-edge(aliases:circle,disc,ring).sizeis the line vector or box dimensions;radiusis the ellipse radii.offset,innerRadius(0–1),arc: { start, angle }, andpositionJitterfurther restrict or vary the region.directionisfixed,outward,inward,tangent-cw, ortangent-ccw;vectorsupplies fixed direction andconeAngleadds randomized angular spread.motionParticleMotionModule -speedis a scalar range;acceleration(aliasforce) is constant.velocityJitteraccepts a symmetric point or{ min: Point, max: Point }.dragis a ParticleValue.noiseconfigures coherent velocity and display variation.appearanceParticleAppearanceModule -sizeis a ParticleValue,colour/colora ParticleGradient, andinitialRotationandangularVelocityare scalar ranges in radians.
strengthnumber | Point optional - Velocity acceleration amplitude per axis.frequencynumber optional - Spatial frequency; default0.05.scrollSpeednumber optional - Rate at which the field moves through time.octaves1 | 2 | 3 | 4 optional - Fractal detail layers.strengthOverLifetimeParticleValue optional - Multiplies velocity-noise strength over lifetime.positionAmountnumber | Point optional - Visual position displacement without changing velocity.rotationAmountnumber optional - Visual rotation modulation in radians.sizeAmountnumber optional - Visual size modulation.
{ aabb: { min, max } } is also accepted.min{ x: number, y: number } - Inclusive top-left pixel coordinate.max{ x: number, y: number } - Exclusive bottom-right pixel coordinate.
{ min, max }.minnumber optional - Lower endpoint. If greater thanmax, the endpoints are exchanged.maxnumber optional - Upper endpoint. Defaults tominwhen omitted.
curve{ time: number, value: number }[] optional - One lifetime curve. Passing the key array directly is equivalent.minCurve{ time: number, value: number }[] optional - Minimum lifetime curve.maxCurve{ time: number, value: number }[] optional - Maximum lifetime curve. Each particle chooses a stable blend between min and max.
gradient{ time: number, colour: Colour }[] optional - One lifetime gradient. Passing the key array directly is equivalent.minGradient{ time: number, colour: Colour }[] optional - Minimum lifetime gradient.maxGradient{ time: number, colour: Colour }[] optional - Maximum lifetime gradient. Each particle chooses a stable blend between min and max.
Values, curves, and gradients #
// Fixed or randomized scalar
motion: { speed: 80 }
motion: { speed: { min: 60, max: 100 } }
// One curve, or a randomized value between two curves
appearance: { size: [
{ time: 0, value: 2 },
{ time: 0.3, value: 12 },
{ time: 1, value: 0 }
] }
appearance: { size: { minCurve: [...], maxCurve: [...] } }
// One gradient, or a randomized blend between two gradients
appearance: { colour: [
{ time: 0, colour: '#ffffffff' },
{ time: 1, colour: '#ffffff00' }
] }
appearance: { colour: { minGradient: [...], maxGradient: [...] } }- Curve and gradient key
timeis normalized particle lifetime from0to1. Keys are sorted and linearly interpolated; at most four keys are retained. - A ParticleValue accepts a number, a key array,
{ curve },{ min, max }, or{ minCurve, maxCurve }. Each particle gets a stable random blend between its minimum and maximum curve. - A ParticleGradient accepts a fixed colour, a gradient key array,
{ gradient }, or{ minGradient, maxGradient }. Koya colours use normalized arrays or hexadecimal strings. - Changing a curve, gradient, drag, or noise profile also affects the emitter's already-live particles; spawn position, direction, speed, and lifetime remain fixed at birth.
Runtime control #
Particle mutation uses a renderable handle. Resolve the element's handle with UI.getRenderable, then pass it to Helix/Renderable. All calls are asynchronous and should normally be awaited.
const handle = await UI.getRenderable(win, emitter);
await Particles.setParticleTexture(handle, '/rom/effects/explosion.png');
await Particles.setParticleFrames(handle, {
frames: [
{ min: { x: 0, y: 0 }, max: { x: 64, y: 64 } },
{ min: { x: 64, y: 0 }, max: { x: 128, y: 64 } }
],
frameMode: 'over-lifetime'
});
await Particles.burstParticles(handle, 200);
await Particles.stopParticles(handle); // live particles finish naturally
await Particles.startParticles(handle);
await Particles.clearParticles(handle); // immediately retires this emitter's live particlesHelix/Renderable.UI.getRenderable(windowId, elementId): Promise<number>channel: 'overlay' to retain Koya overlay placement.Particles.setParticleConfig(handle, config): Promise<boolean>Particles.setParticleTexture(handle, path): Promise<boolean>Particles.setParticleFrames(handle, { frames, frameMode, frame }): Promise<boolean>Particles.clearTexture(handle): Promise<boolean>Particles.startParticles(handle): Promise<boolean>Particles.stopParticles(handle): Promise<boolean>0 has no effect.Particles.burstParticles(handle, count): Promise<boolean>Particles.clearParticles(handle): Promise<boolean>setParticleConfig replaces every configuration field but does not change the emitter's running state. Running state is controlled separately. Its colour parser accepts normalized arrays or hexadecimal strings. Always include channel: 'overlay' for the normal Koya overlay path; the runtime config default is world.
Channels and composition #
| Channel | Placement | Post-processing | Typical use |
|---|---|---|---|
overlay | After ordinary UI and final composition | Not affected by scene/UI post-FX | Koya effects, confetti, cursor trails |
world | At the renderer's scene/UI boundary | Receives scene-target post-FX | Shared renderer integrations; uncommon in a UI-only Koya window |
UI.createElementinitially creates Koya emitters inoverlay, regardless of a creation-timechannelfield.- Overlay particles are deliberately outside element clipping, masks, sibling order, subtree compositing, and composite opacity. They can escape a clipped parent and draw above other UI.
- The element supplies only the emitter transform. A spawned particle is detached from the element, so moving or animating the element produces a trail instead of dragging existing particles.
- Rendering an element into an offscreen texture does not simulate or draw its particles. The emitter is submitted once to the next main window frame.
Lifecycle, capacity, and determinism #
- Each window/renderer owns an independent shared pool. The default capacity is 65,536 live particles across both channels and all emitters in that window.
- Each renderer also owns one fixed 1024×1024×8 RGBA atlas. Texture paths are decoded and appended on first use, cached by path, and never repacked, so repeated explosions do not upload or allocate the same texture again.
- Atlas entries are immutable for the renderer lifetime. If generated
/ramimage bytes change, publish them under a new asset path before callingsetParticleTexture; reusing the same path deliberately reuses the existing atlas entry. - First use decodes and uploads the image synchronously. Configure commonly used explosion and smoke textures during UI/window setup to prewarm the atlas before latency-sensitive effects.
- Texture and frame changes affect new spawns only. Live particles keep stable atlas coordinates, preventing an animation or explosion from changing underneath them.
- A source texture must fit within one 1022×1022 atlas page after padding. If loading, decoding, atlas space, or the 4096-frame table fails, future particles use the white fallback and rendering continues.
- When the pool is full, excess spawns are dropped. Existing particles are not replaced, and normal rendering performs no particle-count readback.
- Expired and explicitly cleared slots are recycled on the GPU.
- Destroying the element stops future submissions but does not kill particles already detached from it. For explicit cleanup, call
clearParticlesand keep the enabled emitter attached until a main renderer frame has processed the request before destroying it. - The seed and logical spawn sequence determine sampled attributes independently of GPU slot allocation. Matching emitters given the same spawn timing and commands produce the same attribute stream; bit-identical floating-point motion across different GPU vendors is not guaranteed.
- Device/backend recreation discards transient live particles. A still-running emitter resumes continuous emission without replaying old bursts.
Current limitations #
- Textured particles use one image, optional sprite-sheet frames, colour tinting, and premultiplied-alpha blending. There are no per-particle material or sampler overrides.
- There is no depth or distance sorting, collision, local-space attachment, per-element clipping, runtime atlas eviction/repacking, arbitrary material batching, runtime pool growth, or CPU particle telemetry.
- The shared pool is fixed at renderer initialization; JavaScript cannot resize it at runtime.
Visual test #
The bundled particle-visual-test.js example exercises line, box, ellipse, ring, and arc emission; min/max curves and gradients; drag; coherent velocity/position noise; textured and fallback particles; moving detached trails; seeded twins; clipping independence; dense recycling; post-FX ordering; and Vulkan screenshot capture.