v0.5.0

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.

Warning

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 }
      }
    }
  }]
});
Note

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 #

ParticleDescriptor
World particle renderable accepted by Simulation.spawn and Entity.createRenderable.
  • type 'particles' — Renderable type identifier.
  • config ParticleEmitterConfig optional — Emitter configuration. Configuration fields may alternatively be placed directly beside type.
  • texture string optional — Image asset sampled by every particle. Omitting it uses the white fallback quad.
  • frames ParticleFrame[] optional — Pixel-coordinate rectangles within texture. Empty or omitted means one full-image frame.
  • frameMode 'fixed' | 'random' | 'over-lifetime' optional — Frame selection strategy. Default fixed.
  • frame number optional — Zero-based frame used by fixed, clamped to the available frame count. Default 0.
  • layer number 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.
  • scale Point optional — Renderable transform scale applied when deriving each spawn origin/direction. Default { x: 1, y: 1 }.
ParticleFrame
A pixel rectangle inside the source texture. { aabb: { min, max } } is also accepted.
  • min Point — Inclusive top-left pixel coordinate.
  • max Point — Exclusive bottom-right pixel coordinate.
ParticleEmitterConfig
Modules describing particle lifetime, spawning, motion, and appearance.
  • channel 'world' | 'overlay' optional — Runtime channel used by setParticleConfig. Entity creation always uses world and ignores this field. The full-replacement runtime default is world; overlay is intended for Koya/UI coordinate space.
  • main ParticleMainModule optional — Seed and lifetime shared by all particles.
  • emission ParticleEmissionModule optional — Continuous spawning controls.
  • shape ParticleShapeModule optional — Spawn region and initial direction.
  • motion ParticleMotionModule optional — Initial speed, force, drag, jitter, and coherent noise.
  • appearance ParticleAppearanceModule optional — Size, colour, and rotation over normalized lifetime.
Particle modules
Module fields are optional and compose independently.
  • main { seed?: number, lifetime?: ParticleScalarRange } lifetime is seconds and is clamped above zero.
  • emission { rate?: number } — Particles per second; fractional rates accumulate across frames.
  • shape ParticleShapeModule type: point, line, box, box-edge, ellipse, ellipse-edge (aliases: circle, disc, ring). size is the line vector or box dimensions; radius is ellipse radii. offset, innerRadius (0–1), arc: { start, angle }, and positionJitter further define the region. direction is fixed, outward, inward, tangent-cw, or tangent-ccw; vector supplies fixed direction and coneAngle adds spread.
  • motion ParticleMotionModule speed is a scalar range; acceleration (alias force) is constant. velocityJitter accepts a symmetric point or { min: Point, max: Point }. drag is a ParticleValue. noise configures coherent velocity and display variation.
  • appearance ParticleAppearanceModule size is a ParticleValue, colour/color a ParticleGradient, and initialRotation and angularVelocity are scalar ranges in radians.
ParticleNoiseModule
Coherent fractal noise evaluated in world/time space, not independent frame-to-frame randomness.
  • strength number | Point optional — Velocity acceleration amplitude per axis.
  • frequency number optional — Spatial frequency; default 0.05.
  • scrollSpeed number optional — Rate at which the field moves through time.
  • octaves 1 | 2 | 3 | 4 optional — Fractal detail layers.
  • strengthOverLifetime ParticleValue optional — Multiplies velocity-noise strength over lifetime.
  • positionAmount number | Point optional — Visual position displacement without changing velocity.
  • rotationAmount number optional — Visual rotation modulation in radians.
  • sizeAmount number optional — Visual size modulation.
ParticleScalarRange
A fixed number or inclusive random range { min, max }.
  • min number optional — Lower endpoint. If greater than max, the endpoints are exchanged.
  • max number optional — Upper endpoint. Defaults to min when omitted.
ParticleColour
Normalized RGBA object used by Syncromesh particle descriptors.
  • r number — Red channel, clamped to [0,1].
  • g number — Green channel, clamped to [0,1].
  • b number — Blue channel, clamped to [0,1].
  • a number — Alpha channel, clamped to [0,1].
ParticleValue
A scalar constant, curve, or randomized blend between two curves.
  • 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.
ParticleGradient
A fixed colour, gradient, or randomized blend between two gradients.
  • 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 time is normalized lifetime from 0 to 1. 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 particles
Entity.getRenderable
Returns an opaque renderable handle. Spawn descriptor order determines renderableIndex; the first renderable is index 0.
Entity.getRenderable(entityId, renderableIndex): Promise<number>
Particles.setParticleConfig
Replaces the complete emitter configuration. It is not a partial merge; omitted fields return to defaults. Use normalized colour arrays or hexadecimal strings in this runtime API.
Particles.setParticleConfig(handle, config): Promise<boolean>
Particles.setParticleTexture
Changes the texture used by future spawns without needing a window ID. Existing particles retain their atlas frame.
Particles.setParticleTexture(handle, path): Promise<boolean>
Particles.setParticleFrames
Changes sprite-sheet rectangles and selection for future spawns. Passing an array directly is shorthand for fixed mode, frame 0.
Particles.setParticleFrames(handle, { frames, frameMode, frame }): Promise<boolean>
Particles.clearTexture
Restores the white fallback for future spawns; existing particles are unchanged.
Particles.clearTexture(handle): Promise<boolean>
Particles.startParticles
Starts continuous rate-based emission. Calling it repeatedly does not duplicate the emitter.
Particles.startParticles(handle): Promise<boolean>
Particles.stopParticles
Stops future continuous emission; already spawned particles remain detached and finish naturally.
Particles.stopParticles(handle): Promise<boolean>
Particles.burstParticles
Requests one explicit burst. It works while the emitter is stopped; count 0 has no effect.
Particles.burstParticles(handle, count): Promise<boolean>
Particles.clearParticles
Requests retirement of all live particles belonging to this emitter on the next main particle simulation, without changing its running state.
Particles.clearParticles(handle): Promise<boolean>
Warning

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 /ram image bytes change, publish them under a new asset path before calling setParticleTexture; 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 clearParticles and 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.