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
| Part | Responsibility |
|---|---|
ParticleLayout | Declares the named state stored for each particle: position, velocity, age, lifetime, colour, or custom attributes. |
INIT graph | Runs for a newborn: chooses its position, velocity, lifetime and initial appearance. |
UPDATE graph | Advances 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
textureto 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: nullsamples white. - Curves:
curveLiteraltakes anAnimationCurve-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:
gradientRGBAtakesstops: [{ position, color: [r, g, b, a] }]. Feed normalized age (age / lifetime) to itstinput. These values are linear RGBA; alpha is not colour-space converted. - Randomness:
randomreturns 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
ParticleEffecthas 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; bindrender.frameto 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 authoring | ParticleEffect 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) |
ParticleLayer | One effect entity per independently textured/emitting layer |
imageURL, emissionRate | Component texture, spawn_rate |
| Lifetime, size, rotation, speed ranges | Random draws in INIT, stored in layout attributes |
| Emission shape and layer transform | Position-sampling nodes in INIT, added to the emitter’s world position |
| Full-width particle size | Half-extent size binding: divide old sizes by two |
| Colour/scale parameter tracks | gradientRGBA / curveLiteral sampled in UPDATE; initialize appearance in INIT too |
| Fixed-physics ageing, movement and death | Explicit age, integration and kill nodes in UPDATE |
Emitting flag | Component emitting boolean |
PreWarm flag | prewarm duration in seconds |
| Rebuild after structural edits | Compile 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.
Related pages
- Getting started - the ECS and a first rendered scene.
- Particle example and graph sources - the complete multi-layer effects.
- Jet Propulsion Alliance - vehicle-mounted effects and gameplay bursts.
- Frame features - AVBOIT and renderer settings.
- Trails - geometry extruded behind moving entities.