Rendering

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

ClassRole
ParticularEnginetop-level manager — holds all emitters, runs simulation steps, owns the BVH
ParticleEmitterone emitter — contains ParticleLayer list, ParameterSet, flags, transform
ParticleLayerone emission layer inside an emitter — shape, rate, life, size, speed, sprite URL
ParticlePoolSOA particle buffer — typed arrays for each attribute
ParticleEmitterSystemECS system that wires ParticleEmitter + Transform and drives ParticularEngine

Particle attributes

Each live particle carries:

Attribute constantMeaning
PARTICLE_ATTRIBUTE_POSITIONworld-space XYZ
PARTICLE_ATTRIBUTE_VELOCITYXYZ velocity
PARTICLE_ATTRIBUTE_AGEseconds since spawn
PARTICLE_ATTRIBUTE_DEATH_AGElifespan in seconds
PARTICLE_ATTRIBUTE_SIZE / PARTICLE_ATTRIBUTE_SIZE_INITIALcurrent / initial sprite size
PARTICLE_ATTRIBUTE_ROTATION / PARTICLE_ATTRIBUTE_ROTATION_SPEEDangle (rad) / angular speed (rad/s)
PARTICLE_ATTRIBUTE_UVatlas UV patch
PARTICLE_ATTRIBUTE_LAYER_POSITIONwhich layer spawned this particle
PARTICLE_ATTRIBUTE_BLENDblending mode
PARTICLE_ATTRIBUTE_COLORRGBA colour

Emission shapes and sources

ParticleLayer.emissionShape selects the spawn geometry:

EmissionShapeTypeDescription
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:

EmissionFromTypeDescription
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:

StepEffect
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:

FlagEffect
PreWarmon startup, fast-forward the emitter by averageLifetime to fill the volume
DepthSortingsort particles back-to-front before each render (needed for correct alpha)
AlignOnVelocityrotate each billboard to face its velocity direction
Litcompile the billboard shader with lighting support
DepthReadDisabledskip depth-buffer read (no soft particles)
DepthSoftDisabledread depth but don’t apply soft-particle fade
Sleepingemitter 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:

NodeGLSL output
FloatConstantliteral float
AddFloatNodea + b
Vector3Add/Subtract/Multiply/Dividecomponent-wise vec3 arithmetic
Vector3Split / Vector3Mergepack/unpack channels
ReadVector3Attribute / WriteVector3Attributeread/write named particle attributes
ReadFloatUniformread a named uniform
CurlNoiseNodecurlNoise(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.