DocsGuides

Physics guide (cinder/physics)

cinder/physics is the safe Mojo layer over Jolt. Jolt is reached through joltc inside libcinder_native; the parts of the shim that need C callbacks live in native/src/physics_shim.c (query filters, closest-hit collectors, CharacterVirtual extended update defaults) and native/src/physics_events.c (contact and activation listeners). Mojo never runs inside a Jolt callback (ADR-0003): listeners write fixed-size events into the shim's lock-free queue and PhysicsWorld.poll_events() drains it on the calling thread.

The package sits beside render, anim and audio in the module map (docs/ARCHITECTURE.md): it imports cinder/core (math), cinder/ecs (for PhysicsSync), cinder/ffi and cinder/platform (the native library), and is owned by cinder/app. The three modules are:

Module Contents
physics/world.mojo LayerConfig, MotionType, Shape, BodyDesc, PhysicsEvent(Kind), RayHit, ShapeHit, PhysicsWorld, INVALID_BODY
physics/character.mojo CharacterDesc, GroundState, CharacterController
physics/sync.mojo RigidBody component, PhysicsSync, free pull_bodies / push_kinematics / write_transforms

The generated reference with every signature is docs/api/physics.md. The tests in tests/physics/test_physics_world.mojo exercise everything described here and are the best source of working snippets; the sample game (sample/game/) is the second.

Units and conventions #

Metres, seconds, kilograms, newtons; impulses in N·s. Right-handed, Y up, −Z forward, as everywhere in the engine. Quaternions are Quat(x, y, z, w); Quat.from_axis_angle takes radians, CharacterDesc.max_slope_degrees takes degrees. Body ids are UInt32 (INVALID_BODY = 0xFFFFFFFF). Capsules and cylinders stand along Y. A dynamic body with mass == 0 gets its mass from the shape volume at Jolt's default density, 1000 kg/m³.

Creating a world #

PhysicsWorld(lib, layers, max_bodies=4096, worker_threads=2, event_capacity=1 << 14) creates one Jolt PhysicsSystem, one JobSystemThreadPool with worker_threads workers, one event queue of event_capacity entries, the two listeners and one reusable body filter. lib is a NativePtr (NativeLibrary.ptr()). JPH_Init is idempotent in joltc, so every world calls it and the process-wide Jolt state is never shut down while a world exists.

mojo
from cinder.physics import BodyDesc, LayerConfig, MotionType, PhysicsWorld
from cinder.platform import NativeLibrary

comptime LAYER_STATIC = 0
comptime LAYER_MOVING = 1
comptime LAYER_GHOST = 2

def _layers() -> LayerConfig:
    """0 static, 1 moving (collides with everything but ghosts), 2 ghost (static only)."""
    var cfg = LayerConfig(3, 1)
    cfg.collide(LAYER_MOVING, LAYER_STATIC)
    cfg.collide(LAYER_MOVING, LAYER_MOVING)
    cfg.collide(LAYER_GHOST, LAYER_STATIC)
    return cfg^

var lib = NativeLibrary()
var physics = PhysicsWorld(lib.ptr(), _layers())

Layers are bits #

LayerConfig(count, static_mask=1) declares up to 32 object layers; layer i is bit i in every query mask, so "static only" is UInt32(1) << LAYER_STATIC and the default mask 0xFFFFFFFF is everything. static_mask marks the layers that go to the static broad-phase layer, so static geometry is never tested against itself; everything else shares the moving broad-phase layer. collide(a, b) enables a pair (symmetric); pairs are checked against count when the world is built and create_body rejects layers outside [0, count). LayerConfig.default() is two layers, 0 static and 1 moving, with moving colliding with both.

Ownership and lifetimes #

  • The world owns Jolt. Its destructor destroys the filters, the system (which destroys any body still registered), the listeners, the job system and the queue, in that order. Keep the NativeLibrary alive past the world (keep(lib) at the end of a test; the App declares the library field last so it is destroyed last, see docs/MOJO_NOTES.md).
  • Shapes are handles. Shape is ImplicitlyCopyable; copy it freely into as many BodyDescs as you like and call destroy_shape exactly once. Jolt reference-counts the shape, so a body keeps its shape alive and you may destroy the handle right after create_body (the tests' _floor helper does this). scaled(shape, ...) is a new shape that also needs destroying. A destroyed Shape copy still converts to True; do not reuse it. The world remembers the shapes it created: destroy_shape on one that is already destroyed (through another copy) or that came from another world releases nothing, leaves live_shapes alone and adds an ERROR record to diagnostics. (A stale copy whose address Jolt has since reused for a new shape cannot be told apart, so still destroy each shape once.)
  • Bodies are ids. create_body returns a UInt32; destroy_body(id) removes and destroys it. Jolt may reuse the index after destruction, with a new sequence number, so a stale id never names the new body. destroy_body checks the id with Jolt first: INVALID_BODY, an id never created or one already destroyed is ignored, live_bodies is left alone and an ERROR record is added to diagnostics (error_count() counts them).
  • Leak accounting, reported, no abort. The world counts shapes, bodies and characters; live_objects() is their sum and must be zero before the world goes away. The destructor does not abort on leaks (ADR-0012 §6), so a failing test still reports its real assertion; it prints one ERROR line instead, leak_report(), of the form [ERROR] physics: world destroyed with N live objects shapes=S bodies=B characters=C. Call leak_report() (None when nothing leaked) or live_objects() before teardown to observe it; the tests and App.live_objects() assert zero explicitly.
  • Pointer stability. CharacterController stores a Pointer[PhysicsWorld, MutUntrackedOrigin] and calls through it on every update and in its destructor. The world must therefore not move while a controller exists and must outlive it. App keeps its world in a Boxed[PhysicsWorld] (cinder/core/boxed.mojo) and hands out app.physics.ptr(); tests keep the world as a local that is never moved and build the pointer with a small _ptr helper, consuming the controller (_ = hero^) before the world is destroyed.

Shapes and bodies #

mojo
var ball = physics.sphere(0.5)
var cube = physics.box(Vec3(0.5, 0.5, 0.5))          # half extents, convex radius 0.05
var pill = physics.capsule(0.6, 0.3)                  # half cylinder height, radius
var wide = physics.scaled(cube, Vec3(2.0, 1.0, 1.0))

var pts = List[Vec3]()
pts.append(Vec3(0.0, 0.0, 0.0))
pts.append(Vec3(1.0, 0.0, 0.0))
pts.append(Vec3(0.0, 1.0, 0.0))
pts.append(Vec3(0.0, 0.0, 1.0))
var hull = physics.convex_hull(pts)                   # at least 4 points

var verts = List[Vec3]()                              # a triangle plateau at y = 2
verts.append(Vec3(15.0, 2.0, -5.0))
verts.append(Vec3(25.0, 2.0, -5.0))
verts.append(Vec3(25.0, 2.0, 5.0))
verts.append(Vec3(15.0, 2.0, 5.0))
var indices = List[UInt32]()
for i in [0, 2, 1, 0, 3, 2]:
    indices.append(UInt32(i))
var mesh = physics.mesh(verts, indices)               # static or kinematic bodies only

mesh validates that the index count is a non-zero multiple of 3 and that every index is inside the vertex list; Jolt mesh shapes have no mass properties and only work on static or kinematic bodies. The sample's Sponza level builds one from the imported glTF triangles (ADR-0014).

A body is described by BodyDesc(shape, position, motion, layer, rotation=Quat.identity()) and the public fields you set afterwards: friction (0.5), restitution (0), linear_damping and angular_damping (0.05), gravity_factor (1), is_sensor (contacts reported, no forces), continuous (linear-cast motion quality for fast small bodies), allow_sleeping, user_data (a UInt64 returned with activation events), linear_velocity (non-static bodies) and mass (dynamic bodies; a non-zero value overrides the mass and scales the shape's inertia to match). MotionType.static(), .kinematic() and .dynamic() are the three motion types.

mojo
var floor = physics.create_body(
    BodyDesc(cube, Vec3(0.0, -1.0, 0.0), MotionType.static(), LAYER_STATIC), False
)
var sensor_desc = BodyDesc(cube, Vec3(8.0, 1.5, 0.0), MotionType.static(), LAYER_STATIC)
sensor_desc.is_sensor = True
var sensor = physics.create_body(sensor_desc, False)

var crate_desc = BodyDesc(cube, Vec3(-3.0, 0.3, 0.0), MotionType.dynamic(), LAYER_MOVING)
crate_desc.mass = 10.0
crate_desc.friction = 1.0
var crate = physics.create_body(crate_desc)
physics.optimize_broad_phase()                        # after the static bodies, before step 1

The second argument of create_body is activate (default True); static geometry is created inactive. optimize_broad_phase() is worth calling once after placing many static bodies and before the first step. body_count() asks Jolt how many bodies exist.

Reading and driving bodies: position(id), rotation(id), transform(id) (a Mat4 with unit scale), set_position_rotation(id, p, q, activate=True), linear_velocity / set_linear_velocity, angular_velocity / set_angular_velocity, add_force, add_impulse, add_impulse_at(id, impulse, world_point), is_active, activate, motion_type / set_motion_type, user_data / set_user_data. An impulse on a sleeping body wakes it.

Kinematic bodies are moved with move_kinematic(id, target_position, target_rotation, dt), which sets the velocity that reaches the target over the next step of dt, so riders (crates, the character controller) inherit the platform's velocity instead of being teleported:

mojo
var platform_x: Float32 = -10.0
for step in range(240):
    if step < 120:
        platform_x += 1.0 * DT
        physics.move_kinematic(platform, Vec3(platform_x, 0.1, 0.0), Quat.identity(), DT)
    physics.step(DT)

Stepping and events #

step(dt, collision_steps=1) runs JPH_PhysicsSystem_Update on the world's own thread pool and raises if Jolt reports an error; steps counts successful calls. collision_steps is Jolt's sub-step count for fast scenes. Call poll_events() once after each step; it returns every queued PhysicsEvent and refreshes dropped_events, the number of events the fixed-capacity queue had to drop (asserted zero in the tests; raise event_capacity if it is not).

mojo
physics.step(DT)
for ev in physics.poll_events():
    if ev.kind == PhysicsEventKind.contact_added() and ev.pair(floor, moving):
        print("hit the floor, normal", ev.normal, "at", ev.point, ev.contact_count, "points")
    if ev.kind == PhysicsEventKind.contact_added() and ev.pair(sensor, through):
        print("sensor entered")
    if ev.kind == PhysicsEventKind.body_deactivated() and ev.body1 == moving:
        print("fell asleep, user data", ev.user_data)

PhysicsEventKind has five values: contact_added, contact_persisted, contact_removed, body_activated, body_deactivated. Contact events carry body1, body2, the world normal from body 1 to body 2, the first manifold point on body 1 and contact_count (contact_removed carries only the ids). Activation events carry body1 and that body's user_data; their body2 is INVALID_BODY. involves(id) and pair(a, b) test membership without caring about order. Sensors produce contact events and no forces. Events describe the step that produced them; nothing is kept across poll_events calls.

Queries #

mojo
var hit = physics.raycast(Vec3(0.0, 5.0, 0.0), Vec3(0.0, -10.0, 0.0))
if hit:
    print(hit.value().body, hit.value().fraction, hit.value().point, hit.value().normal)
var floor_only = physics.raycast(
    Vec3(0.0, 5.0, 0.0), Vec3(0.0, -10.0, 0.0), UInt32(1) << LAYER_STATIC
)
var not_me = physics.raycast(Vec3(0.0, 5.0, 0.0), Vec3(0.0, -10.0, 0.0), 0xFFFFFFFF, box)

var probe = physics.sphere(0.25)
var sweep = physics.shape_cast(
    probe, Mat4.translation(Vec3(0.0, 5.0, 0.0)), Vec3(0.0, -10.0, 0.0), UInt32(1) << LAYER_STATIC
)
if sweep and sweep.value().penetration_depth <= 0.0 and sweep.value().normal.y() > 0.7:
    var contact = Vec3(0.0, 5.0, 0.0) + Vec3(0.0, -10.0, 0.0) * sweep.value().fraction

Both queries return the closest hit along origin + t * direction with t in [0, 1]: the direction vector carries the range. layer_mask selects object layers by bit; ignore_body skips one body (the caster's own). RayHit gives body, fraction, point and the hit body's surface normal (read under a body lock in the shim). ShapeHit adds penetration_depth, which is greater than zero when the swept shape already overlaps at t = 0; point is on the hit body and normal is the hit body's surface normal. Shape casts use Jolt's default ShapeCastSettings. The sample's level placement is built on exactly these two calls (ray casts for floor height, capsule sweeps for standing spots, see sample/game/level.mojo).

Queries take mut self because the world caches one pair of broad-phase and object filters per distinct mask (created on first use with C procs from physics_shim.c) and reuses one body filter whose ignored id it rewrites before each cast.

The character controller #

CharacterController wraps Jolt's CharacterVirtual. It is not a body: it has no id, it never appears in events or query results, and it moves itself by sweeping its capsule against the layers that desc.layer collides with. Its position is at its feet (the capsule is offset through shapeOffset); only the lower hemisphere supports it, so standing on an edge counts as not_supported rather than ground.

mojo
var hero = CharacterController(
    _ptr(physics),                              # app.physics.ptr() in a game
    CharacterDesc(0.3, 1.8, LAYER_MOVING),      # radius, total height, layer
    Vec3(0.0, 0.0, 0.0),
)
for _ in range(120):
    hero.update(DT, Vec3(2.0, 0.0, 0.0))        # walk +X at 2 m/s
    physics.step(DT)
if hero.ground_state() == GroundState.on_ground():
    hero.update(DT, Vec3.zero(), 5.0)           # jump at 5 m/s
    physics.step(DT)
print(hero.position(), hero.velocity(), hero.ground_normal(), hero.ground_body())
_ = hero^                                       # destroy before the world

CharacterDesc(radius=0.3, height=1.8, layer=1, max_slope_degrees=50, step_height=0.4, mass=70) also has public gravity ((0, -9.81, 0)) and max_strength (100 N, the force used to push dynamic bodies). height must exceed 2 * radius + 0.01. The controller creates and owns its capsule shape and increments world.live_characters; its destructor releases both.

update(dt, desired_velocity, jump_speed=0) must run before PhysicsWorld.step with the same dt. It refreshes the ground velocity, then applies the engine's velocity rule: standing on the ground (and not already moving away from it) the vertical velocity becomes the ground's, so a kinematic platform carries the character, plus jump_speed upward when it is positive; otherwise the current vertical velocity continues under desc.gravity. The horizontal part of desired_velocity is added in both cases (its Y is ignored). Jolt's ExtendedUpdate then performs the move, walking up steps as high as step_height, sticking to the floor when walking down (step down 0.5 m, Jolt's default), sliding along slopes steeper than max_slope_degrees and pushing dynamic bodies with mass and max_strength.

State queries: position / set_position, rotation / set_rotation, velocity, ground_state() (on_ground, on_steep_ground, not_supported, in_air), is_supported(), ground_normal(), ground_velocity() and ground_body() (the body id under the feet, which the test checks equals the moving platform).

Implementation note: joltc's JPH_CharacterVirtualSettings_Init leaks the settings of the placeholder shape it installs, so character.mojo fills JPH_CharacterVirtualSettings by hand in 16-byte-aligned zeroed storage, writing Jolt's documented defaults explicitly. If joltc is updated, compare those values with Jolt's CharacterVirtualSettings constructor. The extended update settings come from cn_physics_extended_update_settings_init in the shim with walkStairsStepUp.y replaced by step_height.

ECS synchronisation and the fixed-step loop #

RigidBody (component id 7) links an entity to a body and keeps the two most recent simulated poses: RigidBody(body, motion, position, rotation, scale=Vec3.one(), interpolate=True). Jolt carries no scale, so the component's scale is reapplied whenever it writes a transform.

mojo
var falling = world.spawn()
var falling_body = physics.create_body(
    BodyDesc(cube, Vec3(0.0, 5.0, 0.0), MotionType.dynamic(), LAYER_MOVING)
)
world.add[RigidBody](
    falling,
    RigidBody(falling_body, MotionType.dynamic(), Vec3(0.0, 5.0, 0.0), Quat.identity(), Vec3(2.0, 2.0, 2.0)),
)
var lift = world.spawn()
var lift_body = physics.create_body(
    BodyDesc(cube, Vec3(5.0, 0.5, 0.0), MotionType.kinematic(), LAYER_MOVING)
)
world.add[RigidBody](lift, RigidBody(lift_body, MotionType.kinematic(), Vec3(5.0, 0.5, 0.0), Quat.identity()))
world.add[LocalTransform](lift, LocalTransform(Mat4.translation(Vec3(5.0, 0.5, 0.0))))

var sync = PhysicsSync()
# Fixed step (App.run_frame runs this `steps` times per frame):
_ = world.advance_tick()
_ = sync.push_kinematics(world, physics, DT)   # moved LocalTransforms -> MoveKinematic
physics.step(DT)
_ = sync.pull_bodies(world, physics)           # dynamic bodies: previous <- current <- Jolt
# Once per rendered frame:
_ = sync.write_transforms(world, alpha)        # LocalTransform at alpha in [0, 1]
  • push_kinematics runs before the step. For every kinematic RigidBody whose LocalTransform changed since last_pushed_tick (ECS change ticks, which is why the loop calls world.advance_tick() first) it calls move_kinematic towards the transform's translation and rotation over dt, records the pose and the tick, and returns the count. Unchanged kinematics cost nothing and are not pushed again.
  • pull_bodies runs after the step. For every dynamic RigidBody it shifts position and rotation into previous_*, reads the new pose from Jolt and marks the component changed.
  • write_transforms(alpha) runs once per frame with the accumulator's fraction (App passes fixed.alpha()). For dynamic bodies it writes Mat4.trs(lerp(prev, cur, alpha), slerp(prev, cur, alpha), scale) into LocalTransform, adding the component if the entity has none and skipping entities whose matrix is unchanged, so resting bodies do not dirty the hierarchy. With interpolate = False it writes the latest pose. Static bodies are never touched: a RigidBody with MotionType.static() is purely a record. All three functions skip a RigidBody whose body is INVALID_BODY (not linked to a Jolt body yet, or unlinked).

PhysicsSync keeps its QuerySpecs, chunk list and scratch lists between calls so steady-state frames allocate nothing (ADR-0014). The free functions pull_bodies, push_kinematics and write_transforms build a temporary instance and are fine for tests and tools.

The whole loop in App.run_frame (cinder/app/app.mojo) is: poll events; for each fixed step (60 Hz accumulator) advance_tick, push_kinematics, physics.step, pull_bodies, Game.fixed_update; then write_transforms(alpha), Game.update, animation, transform propagation, audio, render bridge, render. Character controllers are updated by the game inside fixed_update, which runs after the step of the same iteration, so their move lands in the next step with the same dt; events are polled by the game wherever it needs them (sample/game/game.mojo polls them from update to trigger impact sounds).

Threading #

  • step hands the islands to Jolt's own JPH_JobSystemThreadPool (worker_threads, 2 by default). The engine's job system is not involved: joltc's job system cannot be driven by an external scheduler without calling back into Mojo (ADR-0012 §1). Jolt's pool is idle outside the step so the two pools do not contend.
  • Contact and activation callbacks run on Jolt's workers in C only; they push into the world's MPMC CnEventQueue. poll_events pops on the thread that calls it. Overflow is counted in dropped_events, never silent (docs/THREADING.md).
  • Every PhysicsWorld and CharacterController method is called from one thread, the one running the fixed step (the main thread in App). Nothing in the Mojo layer is synchronised, and the body interface is used in its locking form only through Jolt's internals.

Determinism #

Jolt's deterministic mode is on by default, so a world fed the same sequence of calls with the same inputs produces bit-identical results. test_deterministic_replay_of_recorded_inputs builds two fresh worlds, creates 72 bodies in the same order, replays the same impulses at the same steps and asserts every final position is exactly equal, then shows a different stream diverges. For replay in a game this means: same LayerConfig, same creation order, same dt and collision_steps, same inputs applied at the same step. Floating-point positions read back between steps may be compared exactly, as the test does.

Pitfalls #

  • Values passed to C by pointer must be followed by keep(value) after the last C call; every wrapper in this package does so (see docs/MOJO_NOTES.md, "Untracked pointers do not keep their pointee alive"). Follow the same pattern if you add a binding.
  • Call update on a controller before the step, not after; call poll_events after the step.
  • move_kinematic wants the next target, not a delta; call it every step while the platform moves. set_position_rotation teleports and does not carry riders.
  • desc.mass is only applied to dynamic bodies; desc.linear_velocity only to non-static ones.
  • A convex hull needs at least four points; a mesh needs indices in multiples of three.
  • Destroy controllers before the world, bodies before shapes if you want live_objects() to read zero (the order does not matter to Jolt, only to the counter and to leak reports).
  • LayerConfig is copied into the world; adding pairs afterwards has no effect.
  • write_transforms after pull_bodies on a body that was just created shows the creation pose for both previous and current, so there is no first-frame jump.

See also #

View as MarkdownSource: docs/guides/physics.md