Particles & VFX

Author GPU particle simulations with ParticleEffect, explicit state layouts and INIT/UPDATE node graphs.

Use ParticleEffect for particle effects in an engine application. Pair it with Transform64 and register GPUParticleEmitterSystem. The component holds a compiled simulation program, its attribute layout, texture URL, emission settings and render bindings. The GPU spawns, simulates and draws the particles.

Use INIT and UPDATE node graphs to author a ParticleEffect; the migration table maps older Particular effects to this model.

The particles example contains five effects: buff, magic attack, healing, earth and chimney smoke. View source and Download include src/particleGraphs.js, the complete graph authoring code, plus the layer parameters and curves in src/specs/.

The three parts of a simulation

PartResponsibility
ParticleLayoutDeclares the named state stored for each particle: position, velocity, age, lifetime, colour, or custom attributes.
INIT graphRuns for a newborn: chooses its position, velocity, lifetime and initial appearance.
UPDATE graphAdvances that state and kills expired particles. Nothing ages, moves or expires automatically.

Build ordinary NodeGraph objects with particle_node and particle_wire, then compile them with create_particle_effect({ layout, init, update }). The result is { layout, program }, ready to spread into ParticleEffect.from. Compile once per effect and share the immutable layout/program between instances.

Render bindings connect attributes to the channels the renderer reads. A graph can store any state it needs, but drawing requires a world-space position; size is the billboard’s half-extent in metres, color is RGBA, and orientation is a unit quaternion (x, y, z, w) that turns the sprite inside its projection mode’s frame: under BILLBOARD a turn about +Z is a roll, so a roll angle a is vec4(0, 0, sin(a/2), cos(a/2)), and under FACING_WORLD it is the particle’s rotation in the world. Optional frame and velocity bindings support flipbooks and stretched billboards.

Build graphs from the stock node types. The engine also ships a library of node groups (quatFromAxisAngle, quatIntegrate, sampleRotation and others, under shade/renderer/particles/graph/groups/), but it registers itself through a side-effect import, and the package’s sideEffects field lets a bundler drop that import: a production Vite or Rollup build of a graph that names a group throws Unknown particle node type, while the dev server and Node run it.

A complete small graph

Save this as riseGraph.js. It emits white, untextured squares that rise for two seconds, shrink and fade. The example gallery adds textures, random distributions, colour gradients and separate layers to the same structure.

import { NodeGraph } from "@woosh/meep-engine/src/core/model/node-graph/NodeGraph.js";
import { ParticleLayout } from "@woosh/meep-engine/src/shade/renderer/particles/layout/ParticleLayout.js";
import { particle_node, particle_wire } from "@woosh/meep-engine/src/shade/renderer/particles/graph/particle_graph_authoring.js";
import { VM_BUILTIN } from "@woosh/meep-engine/src/shade/renderer/particles/isa/ParticleVMISA.js";
import { create_particle_effect } from "@woosh/meep-engine/src/shade/renderer/particles/runtime/create_particle_effect.js";

export function createRiseEffect() {
  const layout = new ParticleLayout([
    { name: "position", components: 3 },
    { name: "age", components: 1 },
    { name: "size", components: 1 },
    { name: "color", components: 4, type: "color" },
  ]);

  // Arrays are literal values; numbers returned by particle_node are node IDs.
  function node(graph, type, inputs = {}, parameters = {}) {
    const id = particle_node(graph, type, parameters);
    for (const [port, source] of Object.entries(inputs)) {
      particle_wire(graph, id, port, source);
    }
    return id;
  }
  const write = (graph, name, value) =>
    node(graph, "setAttribute", { value }, { name });
  const read = (graph, name) => node(graph, "attribute", {}, { name });

  const init = new NodeGraph();
  write(init, "position", node(init, "builtin", {}, { id: VM_BUILTIN.EMITTER_POSITION }));
  write(init, "age", [0]);
  write(init, "size", [0.1]);
  write(init, "color", [1, 1, 1, 1]);

  const update = new NodeGraph();
  const dt = node(update, "builtin", {}, { id: VM_BUILTIN.DELTA_TIME });
  const age = node(update, "add", { a: read(update, "age"), b: dt });
  write(update, "age", age);
  const expired = node(update, "compare", { a: age, b: [2] }, { op: "ge" });
  node(update, "kill", { condition: expired });

  const step = node(update, "scale", { v: [0, 0.5, 0], s: dt });
  write(update, "position", node(update, "add", { a: read(update, "position"), b: step }));
  const life = node(update, "div", { a: age, b: [2] });
  const fade = node(update, "sub", { a: [1], b: life });
  write(update, "size", node(update, "mul", { a: [0.1], b: fade }));
  write(update, "color", node(update, "vec4", { x: [1], y: [1], z: [1], w: fade }));

  return create_particle_effect({ layout, init, update });
}

The age node above feeds both the store and the expiry test. Wiring the new value explicitly makes the data dependency clear. The UPDATE graph reads the particle’s stored position, so previously emitted particles stay in world space when the emitter moves.

Register the system and spawn an effect

import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { ParticleEffect } from "@woosh/meep-engine/src/engine/graphics/ecs/particles/ParticleEffect.js";
import { GPUParticleEmitterSystem } from "@woosh/meep-engine/src/engine/graphics3/GPUParticleEmitterSystem.js";
import { createRiseEffect } from "./riseGraph.js";

let particles;
const engine = await EngineHarness.bootstrap({
  configuration(config, engine) {
    const scene = EngineHarness.shadeScene(engine);
    particles = new GPUParticleEmitterSystem(engine.graphics, scene, engine.assetManager);
    config.addSystem(particles);
  },
});
await EngineHarness.buildBasics({
  engine, enableTerrain: false, enableWater: false, enableLights: true,
  enableShadows: false, distance: 5,
});

const effect = ParticleEffect.from({
  ...createRiseEffect(),
  spawn_rate: 30,
  flags: { soft_depth: true },
  render: { position: "position", size: "size", color: "color" },
});
const entity = new Entity()
  .add(new Transform64())
  .add(effect)
  .build(engine.entityManager.dataset);

// Later, in response to gameplay:
// effect.emitting = false;       // stop spawning; existing particles finish
// particles.burst(entity, 40);   // queue a one-shot burst

The system’s FrameStart extension enables GPU particles and publishes its shared texture atlas. With the harness, buildBasics({ enableLights: true }) also connects the scene to the graphics engine. In a custom setup, follow the scene wiring in the rendering overview.

Assign component fields directly; the system detects changes each frame. Moving Transform64 still requires t64_announce_change(dataset, entity) after updating its matrix. Removing the entity retires its emitter and particles.

Gameplay bursts and vehicle mounts

For a one-shot effect, create an entity with spawn_rate: 0, then call particles.burst(entity, count). The system queues that count for the next render frame. Keep the entity alive until the longest particle lifetime has elapsed; removing it also retires its particles. Share the compiled program between bursts and clone the ParticleEffect component for each independently placed instance.

Jet Propulsion Alliance demonstrates continuous exhaust and boost, tyre-dust and collision bursts. Its smoke and dust set lighting: true, so their gradients are albedo; the additive boost flame stays unlit. Its fx/particleGraphs.js keeps the INIT and UPDATE graphs in source. INIT transforms a sampled emission volume into world space; UPDATE advances the stored position, so old particles stay behind as the car moves. The boost graph applies mount rotation to the emission box while keeping velocity car-backward. When constructing that basis, remember that EMITTER_DIRECTION is transformed −Z, while EMITTER_UP is +Y.

Textures, curves and layers

  • Textures: set texture to an image URL. The asset manager loads it and the system packs images into an atlas, sharing a patch for matching URLs. A missing or pending image samples transparent; texture: null samples white.
  • Curves: curveLiteral takes an AnimationCurve-shaped { keys: [{ time, value, inTangent, outTangent }] }. Tangents are slopes per unit time. The example supplies each segment’s slope to retain piecewise linear scale tracks.
  • Colours: gradientRGBA takes stops: [{ position, color: [r, g, b, a] }]. Feed normalized age (age / lifetime) to its t input. These values are linear RGBA; alpha is not colour-space converted.
  • Randomness: random returns uniform values in [0, 1). Build distributions from nodes; the example shows spherical-volume spawning and conical velocity. Each particle keeps its own sampled base size, lifetime and spin.
  • Layers: one ParticleEffect has one texture and one emission rate. Compose a multi-layer effect from several entities. The gallery’s five effects contain 13 layers, whose entities follow matching orbits.
  • Flipbooks: flipbook: [columns, rows] divides the texture region; bind render.frame to an attribute that the graph advances.

Rendering and capacity

The engine advances GPU particles using simulation seconds since the last drawn frame. Particle motion, ageing and emission therefore follow the simulation clock’s pause and speed modifiers, independent of the display refresh rate. Renderer animation and exposure adaptation use the same delta. Simulation time elapsed while rendering is disabled is included in the next drawn frame.

The normal Engine loop supplies this delta. If you call graphics.render(time_delta_seconds) yourself, pass simulation seconds explicitly to advance the frame; graphics.render() defaults to zero and redraws the current state without advancing it.

GPU particles draw through Shade’s AVBOIT transparency pipeline, compositing with transparent meshes without a particle depth sort. The legacy MBOIT mode simulates particles but does not draw them. Keep the renderer’s AVBOIT mode for these effects.

flags accepts blend (EMITTER_BLEND.ALPHA, ADDITIVE or PREMULTIPLIED), projection (EMITTER_PROJECTION.BILLBOARD, STRETCHED, HORIZONTAL, VERTICAL or FACING_WORLD), soft_depth and cull. Import the enums from shade/renderer/particles/data/PARTICLE_EMITTER_STRUCT.js. Set lighting: true to treat particle colour as diffuse albedo. Shade samples the froxel irradiance volume, combining the selected indirect-lighting mode with shadowed directional, point and spot lights, so a lit particle receives the light the surfaces beside it receive. Fog is composited over it afterwards, as over everything else. Light and shadow detail follows the froxel resolution. The Particles example’s effects are unlit; Jet Propulsion Alliance lights its smoke and dust.

Bounds are measured from live particles on the GPU. There is no authored bounds field. Enable cull to allow simulation culling; the example leaves it disabled so the orbiting effects continue while offscreen.

Set engine.graphics.renderer.particle_capacity before the scene’s particle system is first created. The pool is fixed after creation and shared by every emitter in that scene. Size it for the sum of emission rate × lifetime across layers, plus bursts and headroom.

prewarm specifies seconds of simulation before an effect is first shown. It defaults to zero, costs work at registration, and uses at most 32 warmup ticks. Longer durations use larger time steps.

Migrating Particular effects

Meep 3.57.0 does not ship Particular’s ParticleEmitter, ParticleEmitterSystem, ParticleLayer, Sprite or their serialization adapters. Re-author those effects as ParticleEffect; old Particular JSON and binary payloads do not migrate automatically.

Former Particular authoringParticleEffect authoring
ParticleEmitter.fromJSON(spec)Build INIT/UPDATE graphs and compile with create_particle_effect. Old JSON is not a ParticleEffect program.
ParticleEmitterSystem(graphics, assets)GPUParticleEmitterSystem(graphics, scene, assets)
ParticleLayerOne effect entity per independently textured/emitting layer
imageURL, emissionRateComponent texture, spawn_rate
Lifetime, size, rotation, speed rangesRandom draws in INIT, stored in layout attributes
Emission shape and layer transformPosition-sampling nodes in INIT, added to the emitter’s world position
Full-width particle sizeHalf-extent size binding: divide old sizes by two
Colour/scale parameter tracksgradientRGBA / curveLiteral sampled in UPDATE; initialize appearance in INIT too
Fixed-physics ageing, movement and deathExplicit age, integration and kill nodes in UPDATE
Emitting flagComponent emitting boolean
PreWarm flagprewarm duration in seconds
Rebuild after structural editsCompile a new immutable program/layout and update render bindings

The engine’s serialization registry includes ParticleEffectSerializationAdapter, so saved entities carry the compiled program. Authoring graphs need not ship in a game, but this example keeps them in source to teach the simulation.

Shade’s GPU particle system

Standalone renderer applications can register the renderer’s shade/renderer/particles/runtime/ParticleEmitter.js node at a scene entity’s transform row. That low-level node is the renderer node used by the ParticleEffect system. Engine applications should use ParticleEffect and let GPUParticleEmitterSystem manage registration, transforms and assets.