Particles & VFX
The Particular CPU particle engine — emitter shapes, layered simulation steps, billboard rendering, automatic atlas packing, and soft particles — plus the node-based GLSL codegen system.
Meep ships two particle systems. Particular is a CPU-simulated, billboard-rendered system suitable for most in-game VFX: fire, smoke, sparks, rain. The node-based system provides a GLSL code-generation graph for writing custom per-particle simulation logic compiled into GPU shaders. Both live under @woosh/meep-engine/src/engine/graphics/particles/.
Particular engine
Core classes
| Class | Role |
|---|---|
ParticularEngine | top-level manager — holds all emitters, runs simulation steps, owns the BVH |
ParticleEmitter | one emitter — contains ParticleLayer list, ParameterSet, flags, transform |
ParticleLayer | one emission layer inside an emitter — shape, rate, life, size, speed, sprite URL |
ParticlePool | SOA particle buffer — typed arrays for each attribute |
ParticleEmitterSystem | ECS system that wires ParticleEmitter + Transform and drives ParticularEngine |
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 |
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.
Emitter flags
Key ParticleEmitterFlag bits that affect observable behaviour:
| Flag | Effect |
|---|---|
PreWarm | on startup, fast-forward the emitter by averageLifetime to fill the volume |
DepthSorting | sort particles back-to-front before each render (needed for correct alpha) |
AlignOnVelocity | rotate each billboard to face its velocity direction |
Lit | compile the billboard shader with lighting support |
DepthReadDisabled | skip depth-buffer read (no soft particles) |
DepthSoftDisabled | read depth but don’t apply soft-particle fade |
Sleeping | emitter is off-screen; simulation is paused |
Using via ECS
import { ParticleEmitterSystem }
from "@woosh/meep-engine/src/engine/graphics/particles/ecs/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";
em.addSystem(new ParticleEmitterSystem(engine));
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);
ParticleEmitterSystem.startup creates a render layer named 'particles-system', registers it with the graphics engine’s frustum-culling BVH, and connects the depth texture from the colour-and-depth framebuffer for soft-particle support.
Automatic atlas packing
Sprites across all registered emitters are automatically packed into a single ManagedAtlas (managed by ShaderManager). Each layer references a sprite by URL; the atlas acquires it asynchronously and assigns the layer an AtlasPatch with the correct UV sub-rectangle. When the atlas is repainted (new sprite added), UV attributes for all live particles are flagged for refresh. No manual atlas management is required.
Billboard rendering and soft particles
Each emitter is a Three.js Points object whose geometry is backed by the ParticlePool’s typed arrays — no copy per frame. The billboard shader (make_particle_billboard_shader) is compiled once per unique combination of the four material flags (depth read, soft depth, velocity align, lit). ShaderManager caches compiled materials in a small graveyard so that emitters cycling in and out of view don’t trigger shader recompilation.
Soft particles require the depth texture from the scene’s colour-and-depth framebuffer. When DEPTH_SOFT_ENABLED is set, the shader samples the scene depth at the fragment’s screen position and fades out particles whose depth difference is small, eliminating hard intersections with solid geometry.
ParticleEmitter.sort runs a quicksort over particle positions each frame when DepthSorting is set, ordering particles front-to-back against the camera’s near-plane normal so that alpha blending produces correct results without a depth write.
Node-based particle system
The node-based system provides a visual graph that compiles to GLSL, allowing custom simulation logic to run on the GPU.
GLSL code generation
GLSLCodeGenerator performs a topological sort over a NodeGraph (dependency-first) and emits a #version 300 es GLSL transform-feedback shader. Nodes contribute code through ShaderNode.generate_code; attributes are declared as in/out pairs so the GPU can read and write them in-place per tick.
Available node types registered in populateNodeRegistry:
| Node | GLSL output |
|---|---|
FloatConstant | literal float |
AddFloatNode | a + b |
Vector3Add/Subtract/Multiply/Divide | component-wise vec3 arithmetic |
Vector3Split / Vector3Merge | pack/unpack channels |
ReadVector3Attribute / WriteVector3Attribute | read/write named particle attributes |
ReadFloatUniform | read a named uniform |
CurlNoiseNode | curlNoise(p) via bundled 3D simplex + curl module |
Dependencies (like the simplex noise GLSL functions) are pulled in automatically by the code generator through FunctionModuleRegistry.
GLSLSimulationShader wraps the generated GLSL string and manages the WebGL program; GLSLParticleSimulator executes one simulation step against a particle geometry buffer.
The node-based system is lower-level than Particular and requires more setup, but any per-particle logic expressible in GLSL can be authored as a node graph and compiled without writing shader code by hand.