FERRETGame engine over Babylon.js
Ferret

Liquids

Package @ferret/physics-liquids. Capabilities: liquids (reservoirs and pipes), surface (a water surface), fluid (particles). Foils and the Magnus force are plain functions.

Which tool#

"Water" in a game means very different things, and the cheapest model that is right for the job should win.

You needUseWhat it is
A tank that fills and drains, plumbing, a pump, a valve puzzleReservoirs (liquids)containers and links: levels from volumes, flows from pressure differences
A lake, a harbour or a river with waves, things that float, a currentSurface (surface)a shallow-water height field on a grid
A splash, a pour, a dam break, a wave over a deckParticles (fluid)particle liquid with a budget
Lift and drag of a wing, keel, sail or rudder; a ball that curvesFoilsfoilForce, thinFoil, magnus

All of them share the idea of a liquid material: EXAMPLE_LIQUIDS has water, oil, honey, mercury and alcohol, each with density, viscosity, surface tension and a contact angle per solid. You can add your own.

Reservoirs and pipes#

A Reservoir is a container with a volume of liquid. The container can be a box, a cylinder, a sphere, or a profile made from a list of cross-sections (a funnel, a bottle, a ship's hold). The level follows the volume, whatever the shape.

A link joins two reservoirs, or a reservoir and the air. It can be a pipe, an orifice, a valve, a pump or a check valve. The flow comes from the pressure difference, laminar or turbulent as the pipe demands. What you get without extra work: levels in two connected tanks equalise with the right time constant, a tank with a hole drains the way Torricelli says it should, and two liquids in a U-tube balance in proportion to their densities.

import { LiquidNetwork, LiquidLibrary, EXAMPLE_LIQUIDS, boxContainer } from '@ferret/physics-liquids';

const net = new LiquidNetwork(new LiquidLibrary(EXAMPLE_LIQUIDS), 9.80665);
const low = net.addReservoir({ id: 'low', liquid: 'water', container: boxContainer(1, 1, 5), level: 2 });
net.addReservoir({ id: 'high', liquid: 'water', container: boxContainer(1, 1, 5), level: 0.5, position: [0, 3, 0] });
const up = net.addLink({ id: 'up', from: 'low', to: 'high', fromElevation: 0.1, toElevation: 3.1,
                         diameter: 0.05, length: 4, check: true });
up.def.pump = { pressure: 80000 };         // 80 kPa lifts water about 8 m
net.step(0.1);

Link settings you will reach for: diameter, length, roughness and loss for the plumbing; valve from 0 (shut) to 1; pump as a pressure or a rate; check: true for a one-way valve; orifice for a hole; main for a supply at fixed pressure; profile for a pipe whose bore changes (a Venturi shows the pressure drop where it narrows).

A closed tank holds a cushion of gas that compresses as it fills. A body in a tank raises the level by the volume it displaces. When media is on, the reservoir is also a medium, so wood floats at the ratio of densities and drags like in a lake.

More that comes with it: the force on a vertical gate (gateForce, and where it acts), capillary rise in a thin tube (water climbs, mercury is pushed down), a wick that soaks a fabric, and the pressure inside a droplet.

In a world, the capability is liquids(). Its commands (reservoir.add, link.add, link.valve, reservoir.state, link.state, gate.force, ...) are enough for an agent to build a plumbing puzzle without writing code.

A lake, a harbour, a river#

WaterSurface is a height field on a grid. Waves travel at a speed set by the local depth, so they slow down on a shallow beach, bounce off walls and banks, and fade at the open edges where the surface is allowed to leak out. A steady current carries the waves and everything floating on them.

It is deliberately not a flow solver around objects. Bodies push the surface (disturb) and read it (height, velocity).

import { WaterSurface } from '@ferret/physics-liquids';

const s = new WaterSurface({ nx: 400, nz: 4, dx: 0.5, depth: 2 });
s.step(0.05);

In a world, waterSurface() owns several surfaces:

cap.add('river', { nx: 128, nz: 128, dx: 0.5, depth: 2, current: [1, 0], open: ['minX', 'maxX'], spongeCells: 8 });
cap.float({ body, radius: 0.4, push: true });   // the body floats and makes rings

A floating body settles at its draft (the depth at which it displaces its own weight) and is carried along by the current. depth can be a number or a function of (x, z), so a beach is a depth that reaches zero. friction damps waves; gravity can be changed. surface.normals hands normals to the renderer for shading (see fx). A 128 × 128 surface fits in the frame.

Particles#

particleLiquid() (capability fluid) steps particle liquids with the world. A body joins as a moving solid and is pushed back, so a ball floats and a paddle splashes.

A reservoir stays the truth about how much liquid there is. A pour takes its volume from a reservoir as particles; a drain puts what arrives back. Nothing is made or lost on the way.

const cap = particleLiquid();
host.register(cap);
host.enable('fluid');

const a = new Reservoir({ id: 'a', liquid: 'water', container: boxContainer(1, 1, 1), volume: 0.01 });
const b = new Reservoir({ id: 'b', liquid: 'water', container: boxContainer(1, 1, 1), volume: 0 });
const f = cap.add('jet', { spacing: 0.01 });                         // 1 cm particles
f.colliders.push({ sdf: planeSdf([0, 1, 0], 0), friction: 0 });      // a floor
cap.pour('jet', { from: a, at: [0, 0.3, 0], velocity: [1, 0, 0], radius: 0.02, rate: 2e-4 });
cap.drain('jet', { into: b, sdf: (x) => 0.3 - x });                  // everything past x = 0.3 goes down the drain

The liquid behaves like the real one: a still column presses on the floor with its weight, a dam break runs out at the right speed and never faster than physics allows, honey flows slower than water, and a droplet on a wall holds the contact angle the wall asks for.

The budget is the main control. budget (6000 by default) is the most particles stepped at full quality per liquid; above it the solver halves its work and says so. The quality tier gives a smaller number (fluidParticles: 400 desktop, 200 console, 120 mobile, 60 low), enough for a splash, not a lake. spacing sets the particle size: about 2 cm for a splash, 1 cm for detail. cap.view(name) returns positions and speeds for a renderer to draw (the fx package does).

Foils and spin#

foilForce(def, velocity, flow, rho) gives the lift and drag on a wing, sail, keel, fin or rudder. Lift is across the flow and the span, drag along the flow. The shape is described by a polar: lift and drag as functions of the angle of attack. thinFoil({ aspect, Cd0, stall }) builds a plausible one for a flat plate: lift grows with the angle until the stall, drag has a base part and a part that grows with lift.

const polar = thinFoil({ aspect: 6, Cd0: 0.01, stall: 0.3 });
const { force } = foilForce({ area: 2, polar, chord, span }, [50, 0, 0], [0, 0, 0], 1.2);

This is the effect of a wing as a game uses it, not a solved flow around the shape.

magnus(v, ω, radius, ρ) is the sideways force on a spinning ball. It acts across the spin and the velocity, grows with the spin and saturates, which is how a football bends and a tennis ball dips.

Parameters#

CapabilityValueDefaultEffect
liquidsliquidsthe examplesadd or override liquid materials
fluidbudget6000particles at full quality per liquid
surfacesurface definitionnonegrid nx, nz, dx, level, depth, current, friction, open, spongeCells, gravity

Works with#

media: a reservoir can serve as a medium for floating and drag. vehicles: a vessel reads the water level from its scoped values, and a wave field can be its surface function. fx: draws the surface mesh and the particle views.

Cost#

Reservoirs are almost free: hundreds are fine. A surface costs one update per cell: 128 × 128 fits in the step; reduce the grid before reducing the step rate. Particles are the expensive one: stay inside the tier's number and let the solver halve itself if it must. When the step runs over budget, the governor switches fluid off first, then surface.

When it does not behave#

SymptomCause
A tank never emptiesthe orifice is tiny for the head; check units (metres, not centimetres)
Water runs uphilla pump on the link, or wrong elevations: fromElevation and toElevation are the world heights of each pipe end
reservoir "x" not foundlink ends name reservoirs by id; 'air' is the only other possible end
Waves blow upthe step is over the stability limit; use a smaller step or a larger dx
Waves never fadeall edges are walls; name the open edges or add friction
Particle liquid looks heavy and slowover budget: the solver halved its work; lower the count or raise the spacing
A mesh collider is refused by particlesbodies join by shape; a hull stands in as its bounding box, meshes are refused

Worked numbers (draining time against Torricelli, pumps and check valves, wave speed, foil lift, Magnus) are in tests/unit/examples/physics-liquids.test.ts.

Reference#

liquids, LiquidNetwork, Reservoir, boxContainer, profileContainer, EXAMPLE_LIQUIDS, WaterSurface, waterSurface, particleLiquid, foilForce, thinFoil, magnus.

Hydraulics (PLAN-DYNAMICS.md stage A11)#

HydraulicCircuit(liquid, { bulk? }) in @ferret/physics-liquids is a network of compressible nodes (bulk modulus K, so a closed line is an oil spring, and a locked cylinder holds). All pressures are found each sub-step by one fully implicit Newton solve (trust region, backtracking, step halving as the fallback), so pumping a stiff chamber conserves the liquid. circuit.liquidVolume() shows it.

partcall
tank (atmosphere), linetank, line(id, volume, pressure?)
pump (constant flow)pump(id, node, rate)
orifice, tap, check valve, relief`link(id, { from, to, cdAlaminar, open?, check?, relief? }), relief(id, line, { crack, cdA }), setOpen(id, x)`
accumulator (gas law, precharge, gas, gamma)accumulator(id, …), stored(id)
cylinder (bore, rod, stroke, mass, coulomb, viscous, leak, end stops)cylinder(id, def); nodes id.cap, id.rod
hand pump (lever 0..1, handleForce out)handPump(id, { bore, stroke, to })
4/3 directional valve (spool -1..1)directional(id, { supply, a, b, cdA })

A jack from two cylinders is Pascal's law with the stroke volumes (tested: hand force = load · a/A within 12 %). A closed valve locks the cylinder; a relief valve limits the pressure at the end stop.

In a level: physics.hydraulics is a list of circuits (CircuitDef: lines, accumulators, pumps, cylinders, links, valves, handPumps, reliefs). A cylinder with attach: { a, b, axis } has no mass of its own: it reads the extension and speed of body b relative to a along axis and pushes both equally and oppositely. Mistakes (an unknown node, a rod thicker than the bore) are refused at load, naming the node.

Commands: hydraulic.state, hydraulic.spool {circuit, valve, value}, hydraulic.open {circuit, link, value}, hydraulic.lever {circuit, pump, value}, edit.physics.hydraulics.set/remove.

In the language: hydraulicForms(bridge, cap) gives (spool "circuit" "valve" :extend|:hold|:retract|x), (valve "circuit" "link" x), (cylinder-position "circuit" "cyl"), (cylinder-force "circuit" "cyl").