Fluid simulation
A volumetric incompressible-flow solver on a staggered grid, run through ECS — fluid volumes are components, effectors stir them, and gameplay reads the velocity field back out.
Meep ships a real-time volumetric fluid solver — incompressible flow on a MAC (staggered) grid, with stable semi-Lagrangian or MacCormack transport and an exact pressure projection. It’s built for gameplay fluid: wind over a field, the wake of something fast, smoke drifting through a corridor — a velocity field your entities can both stir and read.
Like the rest of the engine, you use it through ECS: a volume is a component, the simulation is a system, and sources of motion are effector objects on entities.
The two-minute version
import { FluidComponent } from "@woosh/meep-engine/src/engine/physics/fluid/ecs/FluidComponent.js";
import { FluidEffectorsComponent } from "@woosh/meep-engine/src/engine/physics/fluid/ecs/FluidEffectorsComponent.js";
import { FluidSystem } from "@woosh/meep-engine/src/engine/physics/fluid/ecs/FluidSystem.js";
import { WakeFluidEffector } from "@woosh/meep-engine/src/engine/physics/fluid/effector/WakeFluidEffector.js";
// once, at startup
em.addSystem(new FluidSystem());
// a static volume of air over the scene
const fluid = new FluidComponent();
fluid.field.setResolution(51, 11, 34); // cells
fluid.field.build();
fluid.cell_size = 0.6; // world units per cell
fluid.origin = [-15, 0, -10]; // world position of cell (0,0,0)
new Entity().add(fluid).build(ecd);
// something that stirs it: a wake towed by a moving entity
const wake = new WakeFluidEffector();
wake.radius = 2; // world units
const effectors = new FluidEffectorsComponent();
effectors.addEffector(wake);
new Entity().add(transform).add(effectors).build(ecd); // the moving thing
// something that reads it: sample anywhere, in world space
const v = new Float32Array(3);
fluid.sampleVelocityAtWorld(v, x, y, z);
That’s the whole loop: the system steps every enabled volume on the fixed
timestep — effector splats, advection, pressure projection — and anything in
your game can sample the result. The
wake-field example is exactly this: one volume, one
wake effector on a flying ball, and 140 trees bending to
sampleVelocityAtWorld every frame.
The three pieces
FluidComponent is the volume: a velocity grid plus its placement in the
world. The mapping is uniform and axis-aligned — origin is the world
position of cell (0,0,0), cells are cell_size apart. Placement comes in
two flavours:
- Static — the entity has no
Transform; you setoriginonce and the system never touches it. - Moving — pair the component with a
Transformand the system re-anchors the grid to the entity every fixed tick, shifting the field by whole cells so the fluid appears to stay put in world space while the grid follows the entity. A hysteresis deadband stops a transform sitting on a cell boundary from re-shifting every frame. Use this for a volume that follows the player through a large world.
FluidEffectorsComponent holds the things that stir. Effectors are
deliberately decoupled from any specific volume: the system gathers all of
them and applies each to every field whose bounds it overlaps (a cheap AABB
broad-phase filters the rest). Pair the component with a Transform and
position-bearing effectors follow the entity automatically.
FluidSystem is the pump. It runs on fixedUpdate — fluid stability and
the warm-started pressure solve both want a constant dt — and owns one
simulator that services every volume.
Effectors
| Effector | What it does |
|---|---|
WakeFluidEffector | Splats the momentum of a moving source along its swept path — the wake of an agent passing through air or water. Transform-managed when its entity has a Transform; works at any radius down to sub-cell. |
ImpulseFluidEffector | A velocity splat in a spherical region with a smooth quadratic falloff — fans, jets, point explosions. |
GlobalFluidEffector | The atmosphere: every cell integrates dv/dt = force + drag · (wind − v). A bare force is a body force (note it grows without bound unless something dissipates it); wind + drag gives relaxation toward an ambient flow with a real terminal velocity. |
One sharp edge worth knowing: a teleporting wake source must call
wake.reset_trail() after the jump, or the effector treats the teleport as
one giant swept segment and splats a wall of momentum across the whole
field. (The wake-field example does this on every loop wrap.)
Custom effectors are a small class: extend AbstractFluidEffector,
implement apply(field, dt, world_to_grid) and getBoundingBox(out).
Reading the field
sampleVelocityAtWorld(out, x, y, z) returns trilinearly-interpolated
velocity in grid cells per second, deliberately not rescaled by
cell_size: read-side consumers like vegetation sway and dust drift want a
consistent “wind strength” regardless of grid resolution. Multiply by
cell_size if you need world-units-per-second. There’s also
sampleScalarAtWorld(name, x, y, z) for passive scalars (smoke density, dye)
declared on the field with field.addScalar(name) before build().
Reading is cheap — a few hundred samples per frame is nothing — and it’s the intended coupling point between the simulation and your game: trees bend, particles drift, cloth flutters, all without feeding anything back into the solver.
Tuning the simulator
The system’s simulator is shared by all volumes; its knobs are engine-level:
const sim = em.getSystem(FluidSystem).simulator;
sim.velocity_damping = 1.5; // exponential decay, per second — gusts die back to calm
sim.advection_scheme = AdvectionScheme.SEMI_LAGRANGIAN; // or MACCORMACK (default)
sim.vorticity_confinement = 0; // ε, per second — sharpens vortex cores; adds energy
Guidance that took us some trial and error in the wake-field example:
velocity_dampingis the knob for “how far does a disturbance travel”. The solver’s incompressible projection has no propagating pressure wave — what travels is the shed vortices, and damping decides how long they live.0(the default) never settles under sustained forcing.advection_scheme: MacCormack (default) is second-order and nearly energy-conserving — great for smoke plumes, but sustained stirring keeps ricocheting around the volume. Semi-Lagrangian smears small structures away, which for ambience-style wind is a feature: the gust passes, the field calms.vorticity_confinementre-sharpens vortices that coarse grids smear. On the MAC grid it’s rarely needed — the staggered discretization already preserves rotation well — and pairing it with low damping keeps a field churning indefinitely. Reach for it for visual smoke, not gameplay wind.
Scalars: velocity_diffusion_rate / scalar_diffusion_rate add physical
diffusion; the pressure solver can be switched from SOR to preconditioned
conjugate-gradient (pressure_solver = PressureSolver.MICPCG) for large
grids.
Sizing and cost
The solver is pure JavaScript and steps on the fixed timestep, so budget by
cell count: the wake-field example runs a 51×11×34 grid (~19k cells)
alongside rendering, trails, and 140 sampling consumers without drama.
Coarse grids are usually the right call — gameplay reads the field through
its consumers (bending trees, drifting particles), which forgive far lower
resolution than direct volumetric rendering would. Solid cells
(field.setSolidAt) let flow route around level geometry.
For splashy, free-surface liquids — water you can pour, mud, granular
material — the grid solver is the wrong tool; that’s the domain of the
separate MLS-MPM material-point solver under engine/physics/mls-mpm/.
Related
- Wake field example — the full loop: volume, wake effector, 140 read-side consumers
- Physics overview — the rigid-body side of the physics package
- Source:
engine/physics/fluid/—FluidSimulator.jscarries extensive doc comments on every knob