Vehicles
Package @ferret/physics-vehicles, capability name vehicles, phase field (order 980: after the other force fields).
What it is for#
Things that float, dive or fly by displacement: a boat that rights itself or capsizes, a submarine that holds depth with its ballast tank and breaks at its crush depth, a ship that floods through a breach and settles, a sailboat that sails upwind to the angle the polar allows, a hot-air balloon that rises to where its lift equals its weight. It is not a car or aircraft model: there are no tyres or wings-and-engine flight here (a control surface is a foil in water or air).
The model#
A Vessel is one rigid body with parts. The parts read the water and the air and write forces:
| Part | What it is |
|---|---|
| hull | a box divided into cells that are tested against the water surface, so buoyancy and the centre of buoyancy come from the real submerged shape (draft, heel and trim follow) |
| tanks | ballast tanks with a volume, a position and a fill; fill and blow move the weight and the centre of gravity |
| compartments, breaches, doors | spaces that flood through holes at the Torricelli rate, and between each other through doors; free surface and sloshing are modelled |
| envelopes | hot (a burner heats the air inside) or gas (a lighter gas that expands with height and vents when full) |
| thrusters | axis, max thrust, medium (water or air), optional speed limit |
| surfaces | foils and rudders in water or air; sails |
Body axes: x to starboard, y up, z to the bow. The vessel integrates its own six degrees of freedom. On a rigid-body kernel (Havok) it also has a body there, so it meets the shore, a quay and other vessels; on the light kernel (no dynamic boxes) it runs on its own integrator and meets nothing. Everything is SI.
First use: a boat on the water#
import { Vessel, G, type VesselSpec } from '@ferret/physics-vehicles';
const size: [number, number, number] = [4, 1, 10], m = 12000; // a 4 x 1 x 10 m box hull, 12 t
const spec: VesselSpec = {
mass: m,
inertia: [m * (size[1] ** 2 + size[2] ** 2) / 12, m * (size[0] ** 2 + size[2] ** 2) / 12, m * (size[0] ** 2 + size[1] ** 2) / 12],
hull: { size, cells: [8, 8, 12] },
position: [0, 0.5 - 0.2, 0], // dropped with 0.2 m of draft
hullDamping: [0, 1e5, 0],
};
const boat = new Vessel(spec);
boat.step(1 / 120, 1); // second argument: sub-steps
The box floats at the draft m/(ρA), which is 0.3 m here (Archimedes), and without damping it heaves with the period 2π√(m/(ρgA)).
Readouts a game needs: draft, depth (of the gauge point), altitude, heel and trim (radians), pressure (Pa at the gauge), wetShare, total (mass now), and stability() which returns draft, volume, kb, kg, bm, gm (metacentric height, with free-surface correction) and freeSurface. The hull rights itself when gm > 0 and rolls away when it is negative.
Put it in the world#
import { PhysicsHost } from '@ferret/physics';
import { vehicles } from '@ferret/physics-vehicles';
const cap = vehicles();
const host = world.addService(new PhysicsHost({ capabilities: [cap] }));
host.enable('vehicles');
const e = world.spawn('sub', { position: new BABYLON.Vector3(0, 0, 0) });
const size: [number, number, number] = [4, 4, 20], dry = 250000;
const sub = cap.addVessel('sub', {
mass: dry, cog: [0, -0.5, 0],
inertia: [dry * (16 + 400) / 12, dry * (16 + 16) / 12, dry * (16 + 400) / 12],
hull: { size, cells: [8, 8, 12] },
tanks: [{ name: 'main', volume: 80, at: [0, -1, 0], rate: 5 }],
hullDamping: [2e5, 4e5, 2e5], angularDamping: [1e7, 1e7, 1e7],
}, e);
world.events.on('vessel:submerged', () => console.log('under'));
await world.commands.execute('vessel.fill', { vessel: 'sub', tank: 'main', fraction: 1 });
addVessel(name, spec, entity?, { body?, material? }) registers the vessel, steps it with the world and moves the entity's node to follow it. body: false keeps it on its own integrator even over Havok.
Recipes#
Ballast: dive, hold, surface#
A submarine's tank fills toward fraction at its rate (m³/s): sub.fill('main', 0.875) for neutral buoyancy, 1 to dive, sub.blow('main') to empty it with compressed air. For the hull above (320 m³, 250 t dry, 80 m³ tank) neutral is a fill of (320·1000 − 250000)/(80·1000) = 0.875. Events: vessel:submerged, vessel:surfaced. Set crushDepth and it breaks (vessel:crushed, sub.crushed) when the depth passes it; pressure follows 101325 + ρ g depth.
A hole in the hull#
breach({ name, compartment, at, area, Cd? }) opens a hole into a compartment; water enters at the Torricelli rate Cd·a·√(2 g h), the vessel settles by the added weight, and vessel:breach fires, later vessel:flooded. doors: [{ name, a, b, at, area, open }] between compartments: shut nothing passes, open the levels equalise and the volume is kept. A part-filled compartment takes metacentric height away by its free surface: a half-full tank makes a ship less stable than a full or an empty one.
Ship dynamics that matter#
addedMass: [x, y, z]: the water dragged along when the hull accelerates, as a share of the displaced water: 0.05–0.2 along a slender hull, near 1 across a flat bottom. It lengthens the heave period by√(1 + k).addedInertia: the same for rotation; lengthens the roll period by√((I + Iₐ)/I).hullDrag(quadratic, per axis, ½ρ·Cd·A) andhullDamping(linear),angularDamping: scaled by the submerged share.- Sloshing (
sloshon a compartment): the water across a part-filled tank swings atω² = (π g/B) tanh(π h/B).
Hot-air and gas balloons#
envelopes: [{ name, kind: 'hot', volume, temperature, burner (W), loss (W/K), vent }]. Lift is (ρ_air − ρ_gas) V g; it rises to where lift equals weight in the standard atmosphere and sinks when the burner stops: vessel.burner { on }. A gas envelope holds moles of a lighter gas, expands with height until full, then vents. Altitude is vessel.altitude. Drag against wind uses dragArea and dragCd.
Thrusters, and driving by command#
A thruster { name, at, axis, maxThrust, medium, maxSpeed? } is commanded 0..1 (negative for reverse): vessel.thrust { vessel, part, command }. maxSpeed is where thrust is gone (∞ = constant); source: (u) => N lets a machine supply it (see links: a screw on a shaft).
Rudders, foils, sails#
A surface { name, at, area, hinge, chord, medium, clAlpha, stall, cd0, k } is a foil: lift per radian clAlpha (2π·AR/(AR+2) for a finite foil), stall angle, induced drag Cd = cd0 + k·Cl². A rudder is a surface in water turned by vessel.surface { vessel, part, angle }; a sail is a surface in air, and sailPolar gives the polar a sailboat sails by: it makes way upwind to the angle the polar allows and holds a course with a keel (without one it slides sideways and is slower).
Forces from outside#
vessel.applyForce(f, worldPoint?) holds a force (N, world frame) for the step at a world point (the centre of gravity when none): an oar at its oarlock turns the boat by itself. applyTorque(t) holds a torque, applyImpulse(j, worldPoint?) changes the velocity and the spin at once. The lists clear after the step, so a source applies them again every step.
The environment#
waterLevel (m), waterDensity (kg/m³) and wind are scoped values of the capability; cap.surface is a function (x, z, t) → y when the sea is not flat (a wave field). Environment also carries the atmosphere, so a balloon's density follows the altitude.
Parameters#
VesselSpec fields (SI): mass, cog, inertia (principal, kg·m²), hull, tanks, compartments, doors, addedMass, addedInertia, envelopes, thrusters, surfaces, position, orientation, velocity, crushDepth, hullDrag, hullDamping, angularDamping, gravity (m/s², 9.80665; 0 holds a test vessel up), gauge.
| Capability value | Default | Effect |
|---|---|---|
waterLevel | 0 | height of the water surface |
waterDensity | 1000 | kg/m³ (1025 sea water) |
wind | [0,0,0] | m/s at all heights |
hull.cells: how finely the hull is tested against the surface: [8, 8, 12] is a good default; more cells = smoother buoyancy and more cost.
Commands and events#
Commands: vessel.info {vessel} (draft, depth, altitude, heel, trim, metacentric height, buoyancy against weight, tanks), vessel.fill, vessel.blow, vessel.thrust, vessel.surface, vessel.burner, vessel.breach. Events: vessel:breach, vessel:flooded, vessel:crushed, vessel:surfaced, vessel:submerged, vessel:capsized.
Interplay#
- Vessels compute their own weight and buoyancy, so the host marks their body
exemptfromgravity,mediaandsurface; they read the water level and density from their own scoped values, not from amediamedium. - With Havok the vessel meets static geometry and other bodies; two boats that meet push each other apart, and a boat driven at a quay stops at it.
links:screwOnShaftdrives a vessel's thruster from a shaft and loads the shaft with the torque the water takes back.
Budget#
A vessel costs its hull cells (default 8×8×12 ≈ 770 tests) per step plus the parts. A handful of vessels is cheap; hundreds is not: use a simple buoyancy (media) for background boats and a vessel for the ones the player is in or near.
Debugging#
| Symptom | Cause |
|---|---|
| The boat floats too high or low | mass and hull size do not match: draft = mass / (ρ × waterplane area) |
| It rolls over | gm is negative: lower the centre of gravity (cog), widen the hull, empty part-filled compartments |
| It never settles | no hullDamping or angularDamping: set them (a real ship is damped by its wake) |
| Heave looks too fast | no addedMass: add 0.1–1 depending on the hull |
| A submarine drifts at "neutral" | fill is not exactly the neutral fraction: use stability()/vessel.info buoyancy against weight |
| The vessel passes through the shore | it is on the light kernel or body: false: use Havok |
vessel.breach refused | the compartment name does not exist |
Reference#
vehicles, VehiclesCapability, Vessel, VesselSpec, HullSpec, TankSpec, CompartmentSpec, BreachSpec, DoorSpec, EnvelopeSpec, ThrusterSpec, SurfaceSpec, Environment, sailPolar, VEHICLES_DEFAULTS, @ferret/physics-vehicles.