FERRETGame engine over Babylon.js
Ferret

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:

PartWhat it is
hulla 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)
tanksballast tanks with a volume, a position and a fill; fill and blow move the weight and the centre of gravity
compartments, breaches, doorsspaces that flood through holes at the Torricelli rate, and between each other through doors; free surface and sloshing are modelled
envelopeshot (a burner heats the air inside) or gas (a lighter gas that expands with height and vents when full)
thrustersaxis, max thrust, medium (water or air), optional speed limit
surfacesfoils 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) and hullDamping (linear), angularDamping: scaled by the submerged share.
  • Sloshing (slosh on 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 valueDefaultEffect
waterLevel0height of the water surface
waterDensity1000kg/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 exempt from gravity, media and surface; they read the water level and density from their own scoped values, not from a media medium.
  • 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: screwOnShaft drives 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#

SymptomCause
The boat floats too high or lowmass and hull size do not match: draft = mass / (ρ × waterplane area)
It rolls overgm is negative: lower the centre of gravity (cog), widen the hull, empty part-filled compartments
It never settlesno hullDamping or angularDamping: set them (a real ship is damped by its wake)
Heave looks too fastno 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 shoreit is on the light kernel or body: false: use Havok
vessel.breach refusedthe 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.