SolverMACFluid: Two-Way Coupled Incompressible Fluid for Newton

A 3D incompressible Newtonian fluid solver on a dense staggered MAC grid, built for two-way coupling with articulated rigid-body solvers through Newton's experimental coupled-solver framework. MuJoCo owns the bodies, joints, and actuators; the fluid solver treats them as moving immersed boundaries and feeds hydrodynamic wrenches back — buoyancy, drag, reaction torque, and swimming propulsion all emerge from the coupling.

Eric Heiden · 2026-07-16 · branch eric-heiden/mac-fluid-solver of eric-heiden/newton · NVIDIA L40, Warp 1.15.0.dev

Divergence reduction (projection)
1.1e+06×
Action–reaction error (relative)
3.2e-07
Coupled-restart state restore
bit-exact
Step time, 48³ (CUDA graph, 60 steps/s sim)
6.7 ms 2.5× real time

Feature overview

Implemented and testedOut of scope / future work
  • 3D incompressible Newtonian fluid, closed (sealed) domains
  • Staggered MAC grid: pressure at cell centers, velocity on faces
  • Semi-Lagrangian RK2 advection, trilinear MAC interpolation; optional clamped second-order MacCormack scheme
  • Explicit viscosity with stability check
  • Gravity + uniform external acceleration
  • Matrix-free Jacobi-preconditioned CG pressure projection, fixed iteration count, zero host sync
  • Closed-domain null-space (compatibility) handling
  • Moving rigid boundaries from analytic shape SDFs (sphere, box, capsule, cylinder, cone, ellipsoid) and triangle meshes
  • Approximate no-slip (binary voxelized boundary, O(dx))
  • Solver-native hydrodynamic wrench collection (pressure + viscous impulses per body)
  • Two-way coupling via SolverCoupledProxy (staggered mode) with MuJoCo
  • Fluid-state restoration on coupling-iteration restarts (bit-exact)
  • GPU + CPU execution, CUDA-graph capture, deterministic reductions
  • Diagnostics: divergence, residual, no-slip error, per-body wrench, momentum balance, stage timings
  • Free surfaces, multiphase flow, inflow/outflow
  • Sparse or adaptive grids
  • Turbulence models
  • Differentiability
  • Cut-cell / variational boundary fractions (forces converge first order)
  • Implicit viscosity
  • Strong (added-mass-stable) coupling — light thin bodies need dense bodies or feedback relaxation
  • FLIP/APIC advection
  • Multigrid pressure preconditioning

MAC grid layout

The fluid occupies a closed axis-aligned box of uniform cells. Pressure (and cell labels: fluid / static solid / rigid-body index) is stored at cell centers; the x, y, z velocity components are stored on the corresponding cell faces. The staggering makes the discrete divergence and pressure gradient adjoint, so the projection removes divergence without checkerboard modes. Rigid bodies are voxelized each step by sampling the union signed-distance field of their collision shapes; every face adjacent to a solid cell is constrained to the rigid-body velocity at that face position.

p(i,j,k) p(i+1,j,k) u(i+1,j,k) u(i,j,k) w(i,j,k+1) w(i+1,j,k+1) solid cell (label = body id) constrained faces = body velocity x–z slice: pressure at centers, velocity components on faces
MAC staggering in a x–z slice. Faces bordering solid cells (dark) are constrained to the body's velocity; pressure sees them as Neumann boundaries.

Architecture: coupling with MuJoCo

newton.Model + State (shared) bodies, joints, shapes, controls — one model, per-entry ModelViews SolverMuJoCo owns bodies, joints, actuators integrates articulation dynamics (source entry, substepped) SolverMACFluid owns the fluid grid only bodies = moving immersed boundaries (destination entry, in-place) SolverCoupledProxy staggered proxy coupling, N passes/step body poses q, twists u (proxy sync) hydrodynamic wrenches (impulse / dt)
One shared newton.Model; per-entry ModelViews scope each solver to what it owns. The fluid never integrates rigid state.

Pose transfer and wrench feedback

Each coupled step, SolverCoupledProxy runs one or more coupling passes:

  1. MuJoCo steps the articulation (substepped), including last pass's fluid wrenches as external body forces.
  2. The coupler syncs the resulting body poses body_q and spatial velocities body_qd onto the proxy bodies in the fluid's model view.
  3. The fluid rasterizes the proxies into its grid, advects, applies forces and viscosity, enforces the boundary velocities, and projects pressure.
  4. coupling_harvest_proxy_wrenches converts the fluid's accumulated per-body boundary impulses to wrenches (impulse/dt) — solver-native collection, not rigid-momentum differencing.
  5. On multi-pass steps, the coupler redistributes the beginning-of-step state; the fluid restores its checkpointed velocity grid (iteration_restart), so coupling iterations never advance the fluid extra physical time (verified bit-exact).

The per-body wrench is assembled from two discrete momentum exchanges that are applied equal-and-opposite to the fluid interior: the pressure surface impulse ρ·q·A·n on every fluid/solid interface face (buoyancy, form drag, added-mass reaction), and the viscous exchange impulse where the diffusion stencil couples fluid faces to constrained faces (skin friction). Discrete action–reaction therefore holds to float32 roundoff: measured max error 2.7e-05 N·s against boundary impulses of 82.2 N·s (relative 3.2e-07).

Coupling mode. The examples use the framework's staggered proxy mode. The generic free-body velocity rewind of lagged mode subtracts a fictitious center-of-mass velocity change that a joint-constrained body never had, which injects spurious boundary velocity and destabilizes the loop; staggered mode simply enforces the post-step body state. The usual weak-coupling limit applies either way: a body is stable when its inertia exceeds the hydrodynamic added inertia of its immersed surface (the paddle and swimmer use dense links for this reason; a rigid lid case additionally uses Aitken feedback relaxation).

Validation

TestMetricResult
MAC interpolationtrilinear reproduction of a linear velocity fieldexact to 1e-5 (float32)
Pressure projectionRMS divergence of a random field, before → after 21.3 → 2.0e-05 s⁻¹ (1.1e+06×)
Closed-domain null spacehydrostatic tank: max residual velocity; ∂p/∂z vs ρg 1.1e-06 m/s; gradient error 2.0e-07
Viscositydiffusion operator vs 7-point reference; sinusoid decay ratematches to 1e-6
Momentum balancefluid ΔP vs external + boundary impulses 3.2e-07 relative
Coupled-iteration restorerepeat of the same interval after iteration_restart bit-exact (deterministic reductions)
CPU / CUDA consistencymax velocity-field difference after 3 steps 8.9e-07 m/s
CUDA graph capturecapture one step, replay 5×passes (finite fields, buoyancy preserved)

Buoyancy convergence (stationary sphere, r = 0.2 m)

In discrete hydrostatic equilibrium the pressure surface force on the voxelized body is compared to the analytic Archimedes force ρ V g. The error is dominated by the O(dx) binary voxelization and converges first order:

GridMeasured Fz [N]Voxel-volume ρVg [N]Analytic ρVg [N]Error vs analytic
16³402.4325.7328.7+22.4%
24³394.6340.6328.7+20.0%
32³362.8325.7328.7+10.4%
48³348.8323.6328.7+6.1%

Example 1 — Settling and rising sphere

A sphere (r = 0.12 m) in a 1 m³ sealed water tank. At ρ = 1500 kg/m³ it settles: it accelerates, approaches terminal velocity (peak 1.08 m/s), and lands on the tank floor, where the steady fluid force is 47 N (a floor-seated sphere carries less than the free-buoyancy 71 N since no fluid pushes from below). At ρ = 500 kg/m³ it rises and comes to rest against a rigid lid at z = 0.80 m, with the fluid supporting its weight. The dry (no fluid) run free-falls.

Settling sphere (ρ = 1500). The slice shows velocity magnitude; red = fast.

Rising sphere (ρ = 500) coming to rest under the lid.

sphere plots

Sphere height, velocity, hydrodynamic force, and fluid diagnostics. The divergence drops ~3 orders of magnitude through each projection. The action–reaction error (0.14 N·s) is float32 reduction noise relative to the ~1 N·s gross hydrostatic boundary impulse exchanged per step (relative 0e+00).

Example 2 — Motor-driven paddle

A dense two-blade paddle (0.64 × 0.05 × 0.2 m) on a revolute joint with a MuJoCo velocity servo (target 4 rad/s, gain 8 N·m·s). Dry, the servo reaches 4.00 rad/s. Coupled, the fluid reaction torque (mean -7.6 N·m) loads the motor down to 3.04 rad/s — a 24% speed reduction under fluid load — while the blade sheds a rotating wake.

Coupled paddle stirring the tank (horizontal velocity slice at blade height).

paddle plots

Actuator speed dry vs. wet, reaction torque, and fluid diagnostics.

Example 3 — Articulated underwater swimmer

A five-link swimmer driven by sinusoidal joint-position targets with a phase lag per joint (traveling wave). Gravity is off, so all net motion must come from fluid interaction. Over 8 s the forward wave produces +1.54 m of travel (cruise ≈ 0.29 m/s), the reversed wave -1.51 m — the swimming direction follows the wave direction — while the dry run drifts only +0.04 m (rigid-solver numerical drift, ~36× smaller than the propulsion). The tank walls are fluid boundaries only: near the end of the run the head links pass out of the fluid domain, where their hydrodynamic force is exactly zero and they coast at constant momentum (gravity is off) while the still-submerged links keep thrusting.

Forward gait: the traveling wave sheds a coherent wake and drives the swimmer forward.

Reversed wave: the same articulation swims the other way.

swimmer plots

COM displacement for forward / reversed / dry gaits, swimming speed, and per-link hydrodynamic force magnitudes.

Wake fidelity and scale realism (50 cm robot)

The wakes in the earlier rollouts fade within a couple of body lengths. That is mostly numerical, not physical: first-order semi-Lagrangian advection has an effective numerical viscosity of roughly u·dx/2 ≈ 3×10⁻³ m²/s at these grids — about 30× larger than the explicit viscosity and ~3000× water. It is tunable on three axes: the new clamped MacCormack advection option (SolverMACFluid.Config(advection="maccormack"), a second-order error-corrected scheme that retains ≈1.8× the kinetic energy of semi-Lagrangian over 1 s of inviscid evolution at 32³), finer grids, and true water viscosity.

The scenarios here also fix the scale: a 0.50 m five-link robot (the earlier swimmers were ≈0.93 m and 2500 kg/m³ for coupling-stability margin). At an 7.8 mm grid the added-mass stability margin allows a near-realistic 1200 kg/m³ body (with Aitken feedback relaxation), and the fluid uses real water viscosity (ν = 10⁻⁶ m²/s). The robot cruises at 0.24 m/s ≈ 0.5 body lengths/s — squarely in the range of real undulatory swimming robots — and covers 5.1 m in 26 s including a turnaround. (At this speed the physical Reynolds number is ≈10⁵; the resolved effective Reynolds number is a few thousand, so the wake is a laminar-scale model of the real turbulent one.)

50 cm robot at ρ = 1200 kg/m³, water viscosity, MacCormack advection, 2.66 M cells (7.8 mm). The slice shows vorticity: the alternating-sign vortex street now persists many body lengths behind the robot.

Semi-Lagrangian vs. MacCormack: identical cruises

MacCormack: the shed vortices survive and the wake trail spans the tank.

Semi-Lagrangian: the same gait, but the wake diffuses within ~1–2 body lengths.

wake plots

Kinetic energy left in the water during the two identical cruises: with MacCormack the fluid retains 486 mJ at the end vs 158 mJ with semi-Lagrangian (3.1× more wake energy preserved). Swimming speed itself changes only mildly — thrust is dominated by near-body pressure, which both schemes resolve.

ScenarioFluid cellsDurationsim-only ms/frame× real time
50 cm robot, out and back (4×0.8×0.4 m, MacCormack)2,663,42426 s99.30.17×
Wake cruise, MacCormack2,663,42415 s99.30.17×
Wake cruise, semi-Lagrangian2,663,42415 s99.40.17×

Large-scale long-horizon rollouts

The swimmer example scales to larger tanks, more links, and several swimmers sharing one fluid domain, and supports a smooth mid-run reversal of the traveling wave (--reverse-at) so the swimmers turn around instead of leaving the fluid (the reversal dips the gait amplitude to zero and flips the wave at the quiet point — cross-blending the two waves would pass through a standing-wave regime that loads all joints simultaneously and can destabilize weak coupling). The rollouts below run 28–33 simulated seconds under one CUDA graph per frame; the videos play in real time.

Out-and-back marathon — 5-link swimmer, 8 m tank (0.86M cells, 30 s)

The swimmer cruises 4.1 m down the tank, reverses its gait wave at t = 13 s, turns around, and swims 4.8 m back — 8.9 m of total travel powered purely by fluid interaction.

35 s out-and-back rollout. The dotted line in the trajectory plot below marks the wave reversal.

Three-swimmer race — shared fluid domain (1.71M cells, 28 s)

Three identical swimmers with different gait frequencies race out and back in one 8 × 2.4 m tank (three articulations, 15 bodies, one fluid). Swimming speed follows gait frequency: 0.8 Hz → +1.6 m, 1.2 Hz → +3.4 m, 1.6 Hz → +5.4 m at the turn.

Nine-link eel — 14 m tank (1.97M cells, 33 s)

A 1.7 m nine-link eel cruises 13.1 m one way down the largest tank (1.97M cells): more links carry the traveling wave more smoothly, and even at a gentler 0.7 Hz gait it is the fastest swimmer here.

large rollout plots

Large-rollout performance (NVIDIA L40, 160 pressure iterations, CUDA graph)

RolloutFluid cellsBodiesDuration sim-only ms/frame+ diagnostics readback× real time (60 Hz)
Marathon (5 links, 8×2×0.6 m tank)857,472530 s34.636.80.48×
Race (3×5 links, 8×3.2×0.6 m tank)1,714,9441528 s65.367.30.26×
Eel (9 links, 14×1.6×0.8 m tank)1,966,272933 s74.276.10.22×

"sim-only" launches the captured coupled frame graph (MuJoCo substeps + fluid); "+ diagnostics" adds the per-frame host readback of wrenches and fluid diagnostics used for the metrics logs. Rendering and video encoding are excluded. At 60 steps per simulated second these large rollouts run at 0.2–0.5× real time (e.g. the 30.5 s marathon simulates in ≈63 s); the standard-size examples above them run faster than real time.

Performance

Step rate and real time. Everything here steps at a fixed 60 coupled steps per simulated second (frame dt = 1/60 s): the fluid takes exactly one step per frame, and MuJoCo's 4 substeps run inside that same coupled step. So simulating 1 s of wall-clock physics costs 60 steps, and real time means ≤ 16.7 ms per step. The "× real time" columns below are (1000/60) / (ms per step): above 1× the simulation runs faster than the physics it depicts.

Standalone solver benchmark: cubic tank with one rigid sphere, 120 pressure CG iterations per step, viscosity on, full diagnostics on. The pressure solve dominates; at small grids the step is launch-overhead-bound, so CUDA graph capture gives up to 7× (the whole coupled step, including MuJoCo, is captured in the examples). With graph capture the solver stays real-time up to roughly 64³ (≈260 k cells) at this iteration count.

GridCellsDeviceModems / stepsteps / s× real time
32³32,768NVIDIA L40eager26.5380.63×
32³32,768NVIDIA L40CUDA graph3.62764.59×
48³110,592NVIDIA L40eager21.3470.78×
48³110,592NVIDIA L40CUDA graph6.71502.50×
64³262,144NVIDIA L40eager25.3400.66×
64³262,144NVIDIA L40CUDA graph12.3811.35×
96³884,736NVIDIA L40eager37.1270.45×
96³884,736NVIDIA L40CUDA graph35.4280.47×
32³32,768CPU (single core)CPU342.330.05×

Shipped coupled examples (measured, CUDA graph, MuJoCo + fluid)

All three standard examples run faster than real time on the L40:

ExampleFluid cellsBodiesms / coupled step× real time
Settling sphere (48³ tank)110,59217.22.3×
Paddle (48×48×24 tank)55,29615.13.3×
Swimmer, default 2 m tank (48×19×14)12,76853.54.7×
benchmark plots
Stage (64³, eager)ms / step
pressure_solve21.27
diagnostics3.98
forces1.03
viscosity0.42
projection0.29
boundary0.27
advection0.17

API example

import newton
from newton.solvers import SolverMACFluid, SolverMuJoCo
from newton.solvers.experimental.coupled import SolverCoupledProxy

fluid_cfg = SolverMACFluid.Config(
    resolution=(48, 48, 48), cell_size=1.0 / 48.0, origin=(-0.5, -0.5, 0.0),
    density=1000.0, kinematic_viscosity=1.0e-4, pressure_iterations=120,
)

solver = SolverCoupledProxy(
    model=model,
    entries=[
        SolverCoupledProxy.Entry(name="mjc", solver=lambda v: SolverMuJoCo(model=v),
                                 bodies=rigid_bodies, joints=joints, substeps=4),
        SolverCoupledProxy.Entry(name="fluid", solver=lambda v: SolverMACFluid(v, fluid_cfg),
                                 in_place=True),
    ],
    coupling=SolverCoupledProxy.Config(
        proxies=[SolverCoupledProxy.Proxy(source="mjc", destination="fluid",
                                          bodies=rigid_bodies, mode="staggered",
                                          collision_pipeline=lambda m: None)],
        iterations=1,
    ),
)

solver.step(state, state, control, contacts, dt)          # one coupled frame
fluid = solver.solver("fluid")
print(fluid.read_diagnostics()["body_wrench"])            # hydrodynamic wrench per body

Known limitations and next steps

Deliverables