Particles & VFX
The two particle systems - Particular, the CPU one wired to the ECS, and Shade's GPU-driven one - what each is for, and the authoring surface of both.
meep has two particle systems, and they are chosen rather than layered.
Particular is the ECS one: it emits, ages, integrates and colours particles on the CPU, and Shade draws the result as camera-facing billboards, one draw call per emitter. An emitter is a component on an entity, authored as layers with curves. A few thousand particles for fire, smoke, sparks, rain and hit effects. Most of this page is about it.
Shade’s GPU particle system is the other: effects are node graphs compiled to bytecode, and one simulation dispatch runs every particle of every emitter through a shader VM. Spawning is the GPU’s decision and the CPU never walks the particles. Engine applications use the serializable ParticleEffect ECS component; standalone renderer applications can add emitter scene nodes directly. It is off by default; the section at the end covers what it takes to use it.
Particular’s simulation lives under @woosh/meep-engine/src/engine/graphics/particles/particular/. The ECS system that drives it is @woosh/meep-engine/src/engine/graphics3/ParticleEmitterSystem.js.
Core classes
| Class | Role |
|---|---|
ParticularEngine | top-level manager - holds every registered emitter and runs the simulation steps |
ParticleEmitter | one emitter - a list of ParticleLayers, a parameter set, flags, and a local transform |
ParticleLayer | one emission layer inside an emitter - shape, rate, life, size, speed, sprite URL, parameter tracks |
ParticlePool | SOA particle buffer - one typed array per attribute |
ParticleEmitterSystem | ECS system - wires ParticleEmitter + Transform64, drives ParticularEngine, records the draw |
GPUParticularRenderer | the packer and the Shade pass - it owns the one billboard pipeline every emitter draws through |
All of these are named exports. ParticularEngine, ParticleEmitter, ParticleLayer and ParticlePool sit under graphics/particles/particular/engine/; ParticleEmitterSystem is at graphics3/ParticleEmitterSystem.js and GPUParticularRenderer at graphics3/particles/GPUParticularRenderer.js.
Particle attributes
Each live particle carries:
| Attribute constant | Meaning |
|---|---|
PARTICLE_ATTRIBUTE_POSITION | world-space XYZ |
PARTICLE_ATTRIBUTE_VELOCITY | XYZ velocity |
PARTICLE_ATTRIBUTE_AGE | seconds since spawn |
PARTICLE_ATTRIBUTE_DEATH_AGE | lifespan in seconds |
PARTICLE_ATTRIBUTE_SIZE / PARTICLE_ATTRIBUTE_SIZE_INITIAL | current / initial sprite size |
PARTICLE_ATTRIBUTE_ROTATION / PARTICLE_ATTRIBUTE_ROTATION_SPEED | angle (rad) / angular speed (rad/s) |
PARTICLE_ATTRIBUTE_UV | atlas UV patch |
PARTICLE_ATTRIBUTE_LAYER_POSITION | which layer spawned this particle |
PARTICLE_ATTRIBUTE_BLEND | blending mode |
PARTICLE_ATTRIBUTE_COLOR | RGBA colour |
Emission shapes and sources
ParticleLayer.emissionShape selects the spawn geometry:
EmissionShapeType | Description |
|---|---|
Point (3) | all particles spawn at the layer’s local origin |
Sphere (0) | positions distributed across a sphere |
Box (1) | positions distributed across an axis-aligned box |
ParticleLayer.emissionFrom selects where on the shape particles spawn:
EmissionFromType | Description |
|---|---|
Shell (0) | on the surface of the shape |
Volume (1) | anywhere inside the shape |
Simulation steps
ParticularEngine runs a fixed set of simulation steps each tick. Each step is an AbstractSimulationStep subclass selected by SimulationStepType:
| Step | Effect |
|---|---|
SimulationStepFixedPhysics (0) | integrates position from velocity (Verlet), ages particles, retires the dead, spins via rotation_speed, samples the parameter tracks |
SimulationStepApplyForce (3) | applies a constant acceleration vector to velocity |
SimulationStepCurlNoiseAcceleration (1) | adds curl-noise-derived acceleration to velocity |
SimulationStepCurlNoiseVelocity (2) | directly sets velocity from curl noise |
Layers opt into steps by adding SimulationStepDefinition entries to layer.steps. SimulationStepFixedPhysics runs unconditionally for every layer; the curl-noise and force steps are per-layer opt-in.
Parameter tracks
A layer animates size and colour over a particle’s life through layer.parameterTracks, a ParameterTrackSet of named ParameterTracks. Two names are meaningful, from ParticleParameters:
| Track name | Items | Effect |
|---|---|---|
'scale' | 1 | multiplier over PARTICLE_ATTRIBUTE_SIZE_INITIAL, written to PARTICLE_ATTRIBUTE_SIZE |
'color' | 4 | RGBA in 0..1, written to PARTICLE_ATTRIBUTE_COLOR as bytes |
Each track holds a ParameterLookupTable sampled by normalized age (0 at spawn, 1 at death), so a puff of smoke that grows and fades is two curves and no code. Sampling happens on the CPU inside SimulationStepFixedPhysics; there is no GPU curve texture.
import { ParameterTrack }
from "@woosh/meep-engine/src/engine/graphics/particles/particular/engine/parameter/ParameterTrack.js";
import { ParameterLookupTable }
from "@woosh/meep-engine/src/core/math/lookup/ParameterLookupTable.js";
const fade = new ParameterLookupTable(4);
fade.write([1, 0.6, 0.2, 1, 1, 0.2, 0.05, 0], [0, 1]); // values, positions
layer.parameterTracks.add(new ParameterTrack("color", fade));
Emitter flags
ParticleEmitterFlag bits. Three of them are serialized and ignored by the renderer:
| Flag | Effect |
|---|---|
Emitting | the emitter may spawn new particles. Clearing it lets the live ones finish and die out - this is the gate to use |
PreWarm | on initialize(), pre-spawn each layer as if it had already been emitting for its average particle lifetime, so the volume starts full |
AlignOnVelocity | roll each billboard to face its screen-space direction of travel. Resolved on the CPU during packing, from the camera’s right and up axes, and folded into the particle’s rotation |
DepthSoftDisabled | draw without the soft-particle fade. The depth test still applies |
Sleeping | pauses simulation. ParticleEmitterSystem clears it on link and never sets it again - there is no visibility-driven sleep - but it is honoured if you set it yourself |
DepthSorting | inert. Sorting is unconditional and global (see below) |
DepthReadDisabled | inert. The pass always depth-tests |
Lit | inert. The billboard pass is built unlit; there is no lit variant on this path |
The remaining bits (Built, Initialized, SpritesNeedUpdate, PositionChanged, HashNeedUpdate, the bounds-dirty bits) are internal bookkeeping.
Using via ECS
import { ParticleEmitterSystem }
from "@woosh/meep-engine/src/engine/graphics3/ParticleEmitterSystem.js";
import { ParticleEmitter }
from "@woosh/meep-engine/src/engine/graphics/particles/particular/engine/emitter/ParticleEmitter.js";
import { ParticleLayer }
from "@woosh/meep-engine/src/engine/graphics/particles/particular/engine/emitter/ParticleLayer.js";
import { EmissionShapeType }
from "@woosh/meep-engine/src/engine/graphics/particles/particular/engine/emitter/EmissionShapeType.js";
await em.addSystem(new ParticleEmitterSystem(engine.graphics, engine.assetManager));
const emitter = new ParticleEmitter();
const layer = new ParticleLayer();
layer.imageURL = "assets/smoke.png";
layer.emissionRate = 20; // particles/second
layer.emissionShape = EmissionShapeType.Sphere;
layer.particleLife.set(1, 3); // 1-3 seconds
layer.particleSize.set(0.2, 0.5);
layer.particleSpeed.set(0.5, 1.5);
emitter.addLayer(layer);
entity.add(emitter).add(transform).build(ecd);
The system takes the graphics facade and the asset manager, not the engine: new ParticleEmitterSystem(graphics, assets). Its dependencies are [ParticleEmitter, Transform64], so an emitter without a Transform64 is never linked. On startup it registers an ImageRGBADataLoader for asset type 'image' if the asset manager has none, which is what makes layer.imageURL resolve out of the box.
system.emitters exposes the linked emitters in link order, read-only.
Rebuilding an edited emitter
The particle pool, the parameter tracks and the atlas references are all built from the emitter’s description, so a structural edit - a layer added or removed, a sprite swapped, a curve redrawn - is only visible once they are built again. That is what rebuild is for:
particleEmitterSystem.rebuild(emitter); // emitter must already be linked
It preserves the Sleeping flag and rebuilds everything else. Editing emissionRate or a NumericInterval in place needs no rebuild.
Automatic atlas packing
Sprites across all registered emitters are packed into a single ManagedAtlas, owned by ShaderManager. Each layer names a sprite by URL; the atlas acquires it asynchronously and hands the layer an AtlasPatch with the correct UV sub-rectangle. A repack moves every patch, so ShaderManager raises SpritesNeedUpdate on every registered emitter afterwards and the UVs are refreshed. No manual atlas management is required.
A layer whose sprite has not arrived yet simply draws nothing that frame - it is skipped during packing rather than drawn untextured.
Despite the name, ShaderManager compiles nothing. It is atlas bookkeeping only: register(emitter), deregister(emitter), update(), dispose(), and the spriteAtlas itself.
How a frame is drawn
ParticleEmitterSystem registers a ParticleExtension at FramePhase.AfterTransparency, so particles land on the finished HDR scene at internal render resolution, before the upscale and inside the tonemapper. Fog of war declares itself after this extension, which is why fog conceals particles.
GPUParticularRenderer packs and uploads inside the pass. Each frame it walks the emitters, skips any that are not built or have no live particles, and writes:
- one particle record per live particle - position, half-size, RGBA, rotation, and the index of the emitter record it belongs to;
- one emitter record per particle layer - the sprite’s atlas rectangle, the blend mode and the soft-depth bit. Records are per layer rather than per emitter because in meep the sprite belongs to the layer, so one emitter’s particles can come from several sprites and still draw in one call.
Buffers grow with 1.5x headroom and are never shrunk.
There is one pipeline, built by create_particle_billboard_pipeline with depth_compare: "greater" (Shade is reverse-Z) and cached against the "<color_format>/<depth_format>" pair it was built for. Billboard expansion, atlas lookup, the soft fade and the premultiplied blend are all Shade’s shader; nothing here compiles per emitter or per flag combination.
The draw is instanced - six vertices, one instance per particle - against a read-only depth attachment. Depth is tested and never written, and the same depth texture is bound as a sampled resource in the same pass for the soft fade.
Sorting
Transparency is order-dependent and particles overlap constantly, so sorting is unconditional and global: every live particle in the frame is sorted against every other by squared distance from the camera, back to front, and drawn in that order. There is no flag to turn it off, and no per-emitter ordering left to get wrong.
ParticleEmitter.sort(direction_x, direction_y, direction_z) exists and works, but nothing in the engine calls it.
No render layer, no cull BVH, no automatic sleep
There is no CPU render culling: every linked emitter simulates, and the GPU decides what is on screen. An emitter that should not simulate has to say so, by clearing Emitting.
Soft particles
A billboard that intersects solid geometry cuts a hard line across it. The billboard shader samples scene depth at the fragment and fades alpha with smoothstep over a fixed range of 0.5 in view depth, which dissolves the intersection. It is on for every layer unless the emitter sets DepthSoftDisabled.
Nothing needs wiring for this: the pass reads the frame’s own depth texture. There is no framebuffer to hand it and no depth-texture plumbing to configure.
Blending modes
emitter.blendingMode is a BlendingType, per emitter rather than per layer. One premultiplied blend state serves everything, and the shader picks behaviour by how it premultiplies - so the six authored modes collapse to two:
BlendingType | Drawn as |
|---|---|
Normal (0), NoBlending (5) | premultiplied alpha |
Add (1), Subtract (2), Multiply (3), MultiplyAdd (4) | additive |
Multiply is not expressible on this path and arrives as additive. An effect that depends on multiply blending needs re-authoring.
Flipbook sprite-sheet animation is not wired: every layer record is written with a 1x1 grid, and the frame and velocity attributes the renderer can read are marked absent.
Shade’s GPU particle system
The second system lives in src/shade/renderer/particles/ and is wired into the frame: turn it on with
engine.graphics.renderer.feature_particles_enabled = true;
and every ParticleEmitter node in the scene is simulated on the GPU each frame and drawn through the AVBOIT transparency pipeline as a side channel of the transparent meshes - order-independently against them and against the opaque scene. Under ShadeTransparencyMode.MBOIT the particles simulate and are not drawn; that path is legacy and gets no particle channel.
Its ParticleEmitter (shade/renderer/particles/runtime/ParticleEmitter.js, not to be confused with Particular’s component of the same name) is a Node3D, not an ECS component: add one to a scene and it takes a row in the transforms table like any node, parent it to a joint and the GPU composes its world matrix from the joint’s every frame, animation included, with nothing copied through the CPU.
For engine applications, register GPUParticleEmitterSystem to drive entities carrying ParticleEffect and Transform64, load their textures through the asset manager and maintain a shared sprite atlas:
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { GPUParticleEmitterSystem }
from "@woosh/meep-engine/src/engine/graphics3/GPUParticleEmitterSystem.js";
const scene = EngineHarness.shadeScene(engine);
await engine.entityManager.addSystem(
new GPUParticleEmitterSystem(engine.graphics, scene, engine.assetManager),
);
The system owns an emitter scene node for each entity with a compiled effect. Write effect fields on the component: it detects changes each frame, while transform changes follow the usual t64_announce_change path. Its FrameStart extension enables GPU particles and publishes atlas pixels and regions before simulation and drawing. Images share a patch by URL; pending or failed images sample transparent, while an effect with no texture samples white. Nodes added directly to the scene receive the white region when this system owns the atlas. Standalone renderer users supply their own atlas and regions.
What an effect is
An effect is a per-particle attribute layout plus two node graphs - INIT, which seeds a newborn, and UPDATE, which advances it - compiled together into VM bytecode:
import { ParticleLayout } from "@woosh/meep-engine/src/shade/renderer/particles/layout/ParticleLayout.js";
import { create_particle_effect } from "@woosh/meep-engine/src/shade/renderer/particles/runtime/create_particle_effect.js";
import { ParticleEmitter } from "@woosh/meep-engine/src/shade/renderer/particles/runtime/ParticleEmitter.js";
import { EMITTER_BLEND, EMITTER_PROJECTION } from "@woosh/meep-engine/src/shade/renderer/particles/data/PARTICLE_EMITTER_STRUCT.js";
const layout = new ParticleLayout([
{ name: "position", components: 3 },
{ name: "velocity", components: 3 },
{ name: "age", components: 1 },
{ name: "lifetime", components: 1 },
{ name: "size", components: 1 },
{ name: "color", components: 4 },
]);
// `init` and `update` are NodeGraphs built with particle_node / particle_wire
const effect = create_particle_effect({ layout, init, update });
const emitter = ParticleEmitter.from({
...effect,
name: "smoke",
spawn_rate: 60, // particles per second
prewarm: 2, // seconds simulated before first display
flags: { blend: EMITTER_BLEND.ALPHA, projection: EMITTER_PROJECTION.BILLBOARD, soft_depth: true },
render: { position: "position", size: "size", color: "color" },
position: [-1, 2.5, 0],
});
scene.add(emitter);
To place the same compiled effect through the ECS instead of adding the node above:
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";
new Entity()
.add(new Transform64())
.add(ParticleEffect.from({
...effect,
spawn_rate: 60,
prewarm: 2,
render: { position: "position", size: "size", color: "color" },
}))
.build(engine.entityManager.dataset);
ParticleEffect stores the compiled program and layout, texture URL, emission controls and render bindings. Set emitting = false to stop spawning while live particles finish. GPUParticleEmitterSystem.burst(entity, count) queues a burst. The engine’s serialization registry includes ParticleEffectSerializationAdapter, so saved entities carry the compiled effect without requiring the authoring graphs at runtime.
There is no built-in particle state: age, lifetime and position exist because the layout declares them and the graphs write them. render binds the channels the draw reads (position, size, color, rotation, frame, velocity) to attributes of that layout by name, so what a particle is and what is drawn are separate decisions.
The graphs are built with particle_node(graph, type, params) and particle_wire(graph, node, port, source) from graph/particle_graph_authoring.js, over the standard node library: constants, attribute get/set, arithmetic and vector maths, random draws (uniform, sphere, disk, cone), curve sampling, curl noise, integration, gravity, emission shapes, compare/select and kill. src/shade/playground/particle_system/particle_prototype.js in the package is a worked fire-and-smoke effect, graph and all, and the shipped DESIGN.md beside the system is the reference for everything below the authoring surface.
Facts worth knowing before you build one
- Bounds are measured on the GPU. A pass reduces the live particles into an emitter AABB used for culling and AVBOIT depth-slice occupancy. There is no authored
boundsfield. Emitters without a measured population have unknown bounds and remain eligible to spawn; thecullflag controls whether an emitter opts into culling. - Prewarming is an initial simulation. Set
prewarmto a duration in seconds before adding the emitter to the scene. On registration the system simulates a private population, then merges it into the scene before the first visible frame. The default is0. Warmup adds work to the initial frame and runs at most 32 simulation ticks; longer durations use larger time steps. - Fields are plain data with no setters. After changing one, set
needsUpdate = trueon the emitter - the same change counter the renderer already reads for transforms - and the registry restages the row on its next flush. - The pool is fixed.
renderer.particle_capacityis read once, when the scene’s system is created on the first frame the feature is on for it, and does not grow afterwards. - An ECS texture is a URL. Set
ParticleEffect.texture;GPUParticleEmitterSystemloads and packs the image.flipbook: [cols, rows]runs a grid over its atlas region. With no texture, particles sample a white texel. - Lit emitters are unfinished. The blend, projection, soft-depth, sort and cull flags all work; the
lightingflag is on the emitter but the shading variant it wants is the one piece the renderer integration still lists as remaining.
Both systems have ECS components and serialization. Particular retains its layer-and-curve authoring model; the GPU system runs compiled node graphs, with GPUParticleEmitterSystem managing entity placement and assets for engine applications.
Related pages
- Rendering overview - how a Shade frame is put together, and where
AfterTransparencysits in it. - Trails - the other CPU-simulated, GPU-drawn VFX component.
- Effects - decals, outlines and the dynamic-mesh path.