FERRETGame engine over Babylon.js
Ferret

The host, capabilities and the step

This page is the working manual for PhysicsHost. The overview says what physics in Ferret is; here is how to use it, in the order you need it.

What it is for#

The host runs one fixed step of physics for the whole world: it asks every switched-on capability to add forces, lets the kernel move the bodies, then lets capabilities react to what the contacts did. It is for games that want rigid bodies, buoyancy, breakage, fire and the rest on demand. It is not a renderer and it does not move characters: a CharacterMovement is moved by its own code and only reads fields (gravity, medium) from the host.

The model#

Four things, and how they relate:

  • the kernel moves bodies (a rigid-body engine; see bodies and kernels);
  • a capability is one realistic behaviour with a name, a phase, defaults and hooks (gravity, friction, strength...);
  • the host owns the kernel and the capabilities, and decides the order;
  • the scoped config holds each capability's numbers at four scopes: world, level, zone, entity.

Registering a capability makes it available. Nothing runs until it is enabled. Enabling and disabling are cheap and can happen at any time: when a level loads, when a player enters a room, from a command.

First use#

import { PhysicsHost } from '@ferret/physics';
import { gravity } from '@ferret/physics-gravity';

const host = world.addService(new PhysicsHost({ capabilities: [gravity()] }));
host.enable('gravity');

const ball = world.spawn('ball', { position: new BABYLON.Vector3(0, 10, 0) });
ball.add('RigidBody', { shape: 'sphere', radius: 0.25, mass: 1 });

world.run(1);                                  // one simulated second: 60 fixed steps

world.run(seconds) and world.step() drive the world without a renderer. In a running game the loop does it: World.advance takes the frame time and runs as many fixed steps as fit.

The step, in order#

Every fixed step the host does this, and nothing else:

  1. Kinematic bodies take the velocity that carries them to where their node is now.
  2. Phase field: capabilities add forces (gravity, drag, buoyancy, explosions, springs). Within a phase they run by order, ties by name. Never by registration order, so two games that register the same capabilities in a different order get the same physics.
  3. The kernel integrates and resolves contacts. Contacts are collected.
  4. Phase strength, then phase stretch: capabilities that act on what the contacts did (damage, breakage), then those that integrate soft things.
  5. Every dynamic body's node is set to its body's pose.
  6. Contact events leave as physics:contact on the world's event bus, in one batch.
  7. The governor, if you set one, is told how long it took (budgets and tiers).

What follows from the order: a force added in field is in the same step's integration; damage from a hit shows in the same step it happened; a capability never sees a half-finished step of another.

Recipes#

Switch a capability on for one place only#

Capabilities have scoped values, and the narrowest scope that has a value wins: entity, zone, level, world, default.

import { PhysicsHost } from '@ferret/physics';
import { gravity } from '@ferret/physics-gravity';

const host = world.addService(new PhysicsHost({ capabilities: [gravity()] }));
host.enable('gravity');
host.config('gravity').set('world', 'scale', 1);
host.config('gravity').set('entity', 'scale', 0.16, 'moon-rock');       // one thing on the moon
const e = world.spawn('moon-rock');

A zone value needs the world's zone service (see zones): config.set('zone', key, value, zoneName) applies to every body whose position is inside that zone. A level value is cleared when the level goes (endLevel); a world value stays.

Apply a whole physics block#

A level manifest, a game or an agent can hand over everything at once: materials, pair rules, combine rules, gravity, the list of capabilities and each one's values. configure validates first and throws with every problem listed, so a typo in a level never half-applies:

import { PhysicsHost } from '@ferret/physics';
import { gravity } from '@ferret/physics-gravity';
import { friction } from '@ferret/physics-friction';

const host = world.addService(new PhysicsHost({ capabilities: [gravity(), friction()] }));
host.configure({
  capabilities: ['gravity', 'friction'],
  materials: { ice: { friction: 0.03, restitution: 0.1 } },
  gravity: { vector: [0, -3.7, 0] },
  friction: { scale: 1.2 },
}, 'world');

validatePhysicsConfig(block, knownNames) is the same check without applying: use it in tools and in the editor.

Load and unload a level's physics#

beginLevel(block) applies a block, links the constraints and fractures by node name and returns a list of warnings for what could not be linked. endLevel() undoes it: its constraints go, the capabilities it switched on go off (unless something else still needs them) and its level and zone values are cleared. The level package does this for you; do it yourself only for a game that manages its own scenes. The block's constraints and fractured are covered in the level physics block.

Ask and command it from outside#

The host registers debug commands, the same ones an agent or the inspector uses: physics.info (kernel, capabilities on and off, cost, last step time), physics.enable / physics.disable, physics.configure, physics.materials, body.state and body.impulse. Capabilities add their own (gravity.at, gravity.set...). From a test or a console: await world.commands.execute('physics.info', {}).

Write a capability of your own#

A capability is a plain object: it works alone and meets the others only through the kernel, the materials and the order of the step.

import { PhysicsHost, type Capability, type Vec3 } from '@ferret/physics';

/** a steady wind pushing everything that is dynamic */
const wind = (): Capability => {
  let off: (() => void) | null = null;
  return {
    name: 'wind', phase: 'field', order: 10,
    defaults: { force: [4, 0, 0] },
    cost: 'one vector add per body per step',
    enable(host) {
      off = host.kernel.addForceField(b => b.kind === 'dynamic' ? host.config('wind').get<Vec3>('force', { position: b.position }) : null);
    },
    disable() { off?.(); off = null; },
  };
};

const host = world.addService(new PhysicsHost({ capabilities: [wind()] }));
host.kernel.gravity = [0, 0, 0];
host.enable('wind');
const e = world.spawn('leaf', { position: new BABYLON.Vector3(0, 1, 0) });
e.add('RigidBody', { shape: 'sphere', radius: 0.1, mass: 1 });
world.run(1);

The rules a capability keeps: put the work in a phase (field for forces, strength and stretch for reactions); use addForceField for anything that depends on position so fast bodies feel the force where they are; give defaults for every number so the scopes work; declare requires for capabilities that must be on first and needsBodies if it needs a rigid-body kernel; clean up in disable.

Parameters#

PieceSettingMeaning
PhysicsHost optionskernelthe rigid-body engine; the light kernel when missing
materialsthe material library; the kernel's own when it has one
capabilitiesregistered at once; none is on until enabled
Capabilityname, phase, orderidentity and place in the step; lower order first
defaultsthe value at every scope unless overridden
needsBodiesrefuses to switch on if the kernel has no rigid bodies
requiresnames that must be on first (disable of them is refused while this one is on)
costwords for the inspector
host.enable(name, values?)valueswritten to the world scope, then switched on
host.governorsee budgets and tiers

Interplay#

  • Capabilities that provide a field others read set host.fields.* while they are on: fields.gravity(p) (used by characters), fields.medium(p) (water, mud, air with its flow), fields.column(p, h) (how much of a vertical segment stands in a dense medium). If the capability is off the field is absent and the reader falls back to its default.
  • Capabilities exchange data through materials, not code: a strength reads the material's strengths, a heat its heat capacity. A material without the number means "does not apply".
  • physics-links closes loops between capabilities (a fire heats a pot, steam turns a turbine) without either knowing the other.
  • Events leave on the world's bus: physics:enabled, physics:disabled, physics:contact, physics:degrade; each capability adds its own (terrain:flooded...).

Budget#

The host's own overhead is a sort of the enabled capabilities per step and a position sync per body. The cost of the step is the sum of what each capability declares in cost. host.lastStepMs is the last step's wall time; physics.info shows it per frame.

Debugging#

SymptomCause
capability "x" is already registeredtwo register calls with one name; register once
no capability "x" is registeredyou enabled before registering, or the name has a typo (the message lists what is registered)
needs rigid bodies, and the "light" kernel has nonea capability that needs real rigid bodies: give the host a Havok kernel (bodies and kernels)
needs "y" switched on firstenable in dependency order, or let configure do it: its list is enabled in the order given
a body does not fallgravity is not on, or the body is static, or its gravityScale is 0, or it is exempt from gravity (a vessel does its own)
a value seems ignoreda narrower scope overrides it: config.get(key, ctx) with the entity or position shows what wins
the node does not follow its bodythe RigidBody has rotate: false (position only) or the node has a parent whose transform is not what you expect

Reference#

PhysicsHost, Capability, Phase, ScopedConfig, PhysicsConfig, validatePhysicsConfig, PhysicsFields, RigidBody, @ferret/physics.