Physics

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 set origin once and the system never touches it.
  • Moving — pair the component with a Transform and 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

EffectorWhat it does
WakeFluidEffectorSplats 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.
ImpulseFluidEffectorA velocity splat in a spherical region with a smooth quadratic falloff — fans, jets, point explosions.
GlobalFluidEffectorThe 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_damping is 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_confinement re-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/.

  • 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.js carries extensive doc comments on every knob