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.
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
NativeLibraryalive past the world (keep(lib)at the end of a test; theAppdeclares the library field last so it is destroyed last, seedocs/MOJO_NOTES.md). - Shapes are handles.
ShapeisImplicitlyCopyable; copy it freely into as manyBodyDescs as you like and calldestroy_shapeexactly once. Jolt reference-counts the shape, so a body keeps its shape alive and you may destroy the handle right aftercreate_body(the tests'_floorhelper does this).scaled(shape, ...)is a new shape that also needs destroying. A destroyedShapecopy still converts toTrue; do not reuse it. The world remembers the shapes it created:destroy_shapeon one that is already destroyed (through another copy) or that came from another world releases nothing, leaveslive_shapesalone and adds an ERROR record todiagnostics. (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_bodyreturns aUInt32;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_bodychecks the id with Jolt first:INVALID_BODY, an id never created or one already destroyed is ignored,live_bodiesis left alone and an ERROR record is added todiagnostics(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. Callleak_report()(None when nothing leaked) orlive_objects()before teardown to observe it; the tests andApp.live_objects()assert zero explicitly. - Pointer stability.
CharacterControllerstores aPointer[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.Appkeeps its world in aBoxed[PhysicsWorld](cinder/core/boxed.mojo) and hands outapp.physics.ptr(); tests keep the world as a local that is never moved and build the pointer with a small_ptrhelper, consuming the controller (_ = hero^) before the world is destroyed.
Shapes and bodies #
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 onlymesh 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.
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 1The 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:
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).
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 #
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().fractionBoth 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.
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 worldCharacterDesc(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.
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_kinematicsruns before the step. For every kinematicRigidBodywhoseLocalTransformchanged sincelast_pushed_tick(ECS change ticks, which is why the loop callsworld.advance_tick()first) it callsmove_kinematictowards the transform's translation and rotation overdt, records the pose and the tick, and returns the count. Unchanged kinematics cost nothing and are not pushed again.pull_bodiesruns after the step. For every dynamicRigidBodyit shiftspositionandrotationintoprevious_*, reads the new pose from Jolt and marks the component changed.write_transforms(alpha)runs once per frame with the accumulator's fraction (Apppassesfixed.alpha()). For dynamic bodies it writesMat4.trs(lerp(prev, cur, alpha), slerp(prev, cur, alpha), scale)intoLocalTransform, adding the component if the entity has none and skipping entities whose matrix is unchanged, so resting bodies do not dirty the hierarchy. Withinterpolate = Falseit writes the latest pose. Static bodies are never touched: aRigidBodywithMotionType.static()is purely a record. All three functions skip aRigidBodywhosebodyisINVALID_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 #
stephands the islands to Jolt's ownJPH_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_eventspops on the thread that calls it. Overflow is counted indropped_events, never silent (docs/THREADING.md). - Every
PhysicsWorldandCharacterControllermethod is called from one thread, the one running the fixed step (the main thread inApp). 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 (seedocs/MOJO_NOTES.md, "Untracked pointers do not keep their pointee alive"). Follow the same pattern if you add a binding. - Call
updateon a controller before the step, not after; callpoll_eventsafter the step. move_kinematicwants the next target, not a delta; call it every step while the platform moves.set_position_rotationteleports and does not carry riders.desc.massis only applied to dynamic bodies;desc.linear_velocityonly 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). LayerConfigis copied into the world; adding pairs afterwards has no effect.write_transformsafterpull_bodieson a body that was just created shows the creation pose for both previous and current, so there is no first-frame jump.
See also #
docs/adr/0012-physics-animation-audio-input.md, physics decisions 1 to 7.docs/adr/0003-native-shim-pull-model.md, why callbacks stay in C and events are pulled.docs/adr/0014-sample-game-and-performance.md, mesh colliders from glTF, query-based placement, the zero-allocation frame.docs/THREADING.md,docs/ARCHITECTURE.md(frame loop and conventions).docs/api/physics.md, the generated reference.tests/physics/test_physics_world.mojo: bodies, layers, sensors, kinematics and events; queries and mesh/hull/scaled colliders; the character controller; ECS sync with interpolation; deterministic replay.docs/guides/anim.mdfor driving a skinned character from the controller.