GPU Particles
World-space GPU particle emitters attached to Syncromesh entities.
Syncromesh's particles renderable is a modular GPU-simulated emitter attached to an entity. Shape, motion, appearance, and coherent-noise modules cover sparks, smoke, fire, trails, rings, bursts, and similar Unity-style effects without mirroring Unity's inspector one-for-one. Particles remain in world space after spawning, so moving entities naturally leave detached trails.
Entity and renderable operations are available only in board scripts. The orchestrator owns board/window setup and must not call Simulation.spawn, Entity.getRenderable, or Helix/Renderable particle mutations.
Imports #
import * as Simulation from 'Syncromesh/Simulation';
import * as Entity from 'Syncromesh/Entity';
import * as Particles from 'Helix/Renderable';Spawn a world emitter #
// Run inside the board's bootstrap/default export.
const entityId = await Simulation.spawn({
position: { x: 4, y: 2 },
renderable: [{
type: 'particles', layer: 10,
texture: '/rom/effects/explosion.png',
frameMode: 'over-lifetime',
config: {
main: { seed: 808, lifetime: { min: 1.0, max: 1.9 } },
emission: { rate: 95 },
shape: {
type: 'ellipse', radius: { x: 0.35, y: 0.2 },
innerRadius: 0.55, direction: 'outward', coneAngle: 0.3
},
motion: {
speed: { min: 3.5, max: 7.5 },
acceleration: { x: 0, y: 3.8 },
velocityJitter: { x: 0.5, y: 0.2 },
drag: [{ time: 0, value: 0 }, { time: 1, value: 2 }],
noise: {
seed: 91, strength: { x: 2.0, y: 0.7 }, frequency: 0.8,
scrollSpeed: 0.65, octaves: 3, positionAmount: { x: 0.04, y: 0.02 }
}
},
appearance: {
size: [{ time: 0, value: 0.1 }, { time: 0.2, value: 0.38 }, { time: 1, value: 0.02 }],
colour: [
{ time: 0, colour: { r: 1, g: 0.8, b: 0.15, a: 1 } },
{ time: 0.35, colour: { r: 1, g: 0.18, b: 0.02, a: 0.9 } },
{ time: 1, colour: { r: 0.3, g: 0.02, b: 0, a: 0 } }
],
initialRotation: { min: -Math.PI, max: Math.PI },
angularVelocity: { min: -6, max: 6 }
}
}
}]
});An entity-created particle emitter uses the world channel and starts continuous emission immediately. Set emission.rate: 0 or omit the emission module for a burst-only emitter.
Descriptor definition #
Simulation.spawn and Entity.createRenderable.type'particles' — Renderable type identifier.configParticleEmitterConfig optional — Emitter configuration. Configuration fields may alternatively be placed directly besidetype.texturestring optional — Image asset sampled by every particle. Omitting it uses the 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.layernumber optional — Renderable submission layer. All world particles are ultimately compacted into one world-channel batch at the scene/UI boundary, so this does not sort individual particles against ordinary world renderables.scalePoint optional — Renderable transform scale applied when deriving each spawn origin/direction. Default{ x: 1, y: 1 }.
{ aabb: { min, max } } is also accepted.minPoint — Inclusive top-left pixel coordinate.maxPoint — Exclusive bottom-right pixel coordinate.
channel'world' | 'overlay' optional — Runtime channel used bysetParticleConfig. Entity creation always usesworldand ignores this field. The full-replacement runtime default isworld;overlayis intended for Koya/UI coordinate space.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 ellipse radii.offset,innerRadius(0–1),arc: { start, angle }, andpositionJitterfurther define the region.directionisfixed,outward,inward,tangent-cw, ortangent-ccw;vectorsupplies fixed direction andconeAngleadds 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.
{ min, max }.minnumber optional — Lower endpoint. If greater thanmax, the endpoints are exchanged.maxnumber optional — Upper endpoint. Defaults tominwhen omitted.
rnumber — Red channel, clamped to[0,1].gnumber — Green channel, clamped to[0,1].bnumber — Blue channel, clamped to[0,1].anumber — Alpha channel, clamped to[0,1].
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: ParticleColour }[] optional — One lifetime gradient. Passing the key array directly is equivalent.minGradient{ time: number, colour: ParticleColour }[] optional — Minimum lifetime gradient.maxGradient{ time: number, colour: ParticleColour }[] optional — Maximum lifetime gradient. Each particle chooses a stable blend between min and max.
Values, curves, and gradients #
// Fixed or randomized scalar
motion: { speed: 5 }
motion: { speed: { min: 3, max: 7 } }
// One curve, or a randomized value between two curves
appearance: { size: [
{ time: 0, value: 0.1 },
{ time: 0.3, value: 0.5 },
{ time: 1, value: 0 }
] }
appearance: { size: { minCurve: [...], maxCurve: [...] } }
// One gradient, or a randomized blend between two gradients
appearance: { colour: [
{ time: 0, colour: { r: 1, g: 1, b: 1, a: 1 } },
{ time: 1, colour: { r: 1, g: 1, b: 1, a: 0 } }
] }
appearance: { colour: { minGradient: [...], maxGradient: [...] } }- Curve and gradient key
timeis normalized 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 }. Creation descriptors accept normalized colour objects or arrays; the shared runtime API also accepts Koya hexadecimal colours. - 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 #
Simulation.spawn returns an entity ID, while particle mutation requires the renderable's opaque handle. Resolve it from the entity and renderable index with Entity.getRenderable. These calls are asynchronous and belong in the board script.
const handle = await Entity.getRenderable(entityId, 0);
await Particles.setParticleTexture(handle, '/rom/effects/smoke.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: 'random'
});
await Particles.stopParticles(handle); // live particles remain
await Particles.burstParticles(handle, 180); // works while stopped
await Particles.startParticles(handle);
await Particles.clearParticles(handle); // removes this emitter's live particlesrenderableIndex; the first renderable is index 0.Entity.getRenderable(entityId, renderableIndex): Promise<number>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 the entire configuration. Include channel: 'world' and every module you want to retain. The same modular schema is accepted by creation descriptors and the shared Helix/Renderable runtime API. Running state is controlled separately.
Reconfigure an emitter #
await Particles.setParticleConfig(handle, {
channel: 'world',
main: { seed: 808, lifetime: { min: 0.5, max: 1.0 } },
emission: { rate: 40 },
shape: { type: 'box-edge', size: { x: 1.5, y: 0.8 }, direction: 'outward' },
motion: {
speed: { min: 2, max: 5 }, acceleration: { x: 0, y: 1 },
drag: 1.2,
noise: { strength: 1.5, frequency: 0.6, scrollSpeed: 0.4, octaves: 2 }
},
appearance: {
size: [{ time: 0, value: 0.25 }, { time: 1, value: 0 }],
colour: [
{ time: 0, colour: [1, 0.4, 0.1, 1] },
{ time: 1, colour: [0.3, 0, 0, 0] }
]
}
});Movement, camera, and draw order #
- Move emitter entities with normal board-owned systems such as locomotion, waypoints, or physics. Do not update entity positions from the orchestrator.
- The entity and renderable transforms are sampled only for new spawns. Existing particles remain at their detached world positions while the emitter moves.
- World particles use the attached window's camera transform, so camera movement affects them like other world content.
- All world particles share one batch drawn at the scene/UI boundary. They appear above ordinary world draws and below Koya UI. There is no per-particle or per-emitter layer sorting.
- World particles are rendered into the scene target and receive scene post-processing when that path is active.
- The overlay channel is intended for Koya UI emitters. Changing a world entity to
channel: 'overlay'uses screen/UI coordinates and bypasses world-camera semantics.
Detached trail with locomotion #
await Entity.setLocomotion(entityId, {
enabled: true,
maxSpeed: 3.2,
adjust: 12,
turn: 10,
arriveDistance: 0.35,
directWaypointThrust: true,
faceTravelDirection: true
});
await Entity.setWaypointOptions(entityId, {
enabled: true,
closeEnoughDistance: 0.18
});
await Entity.setWaypoints(entityId, [
{ x: 6, y: 2 },
{ x: 6, y: 5 },
{ x: 2, y: 5 },
{ x: 2, y: 2 }
]);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. A texture path is decoded and appended on first use, cached by path, and never repacked. Repeated explosions reuse the same entry with no per-explosion texture upload or Vulkan allocation.
- 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 board/window setup to prewarm the atlas before latency-sensitive effects.
- Texture and frame changes affect new spawns only. Existing detached particles keep stable atlas coordinates until they expire.
- 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.
- The same emitter rendered in two windows has independent GPU state and capacity consumption in each renderer.
- When a pool is full, excess spawns are dropped. Existing particles are not replaced, and normal rendering performs no count readback.
- Expired and cleared slots are recycled on the GPU.
- Destroying an entity stops future emitter submissions but does not kill its detached live particles. For explicit cleanup, call
clearParticlesand keep the emitter entity alive and enabled until an attached window has processed another main renderer frame 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 GPU vendors is not guaranteed.
- Headless runs may construct particle renderable descriptions but create no Vulkan particle backend. Device/backend recreation discards transient live particles; continuous emitters resume without replaying historical 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 sorting, collision, local-space attachment, runtime atlas eviction/repacking, arbitrary material/layer batches, runtime pool growth, asynchronous compute, or CPU particle telemetry.
- Pool capacity is fixed at renderer initialization and cannot be resized from JavaScript.
Visual test #
The bundled particle_visual_board.js board test covers line, box, ellipse, ring, and arc emission; min/max curves and gradients; drag; coherent velocity/position noise; sprite frames and atlas reuse; a locomotion-driven detached trail; deterministic twins; radial bursts; stopping/restarting emission; clearing; and dense free-list recycling. Its index_particle_visual_test.js orchestrator only creates the window/camera and attaches the board.