OpenGLContext ships a small, fast, game-style rigid-body physics engine that runs in real time alongside the scenegraph. It integrates gravity and drag, resolves collisions between primitives and meshes, supports triggers, gravity zones and joints, and drives a first-person character controller — all while the render tree, culling, shadows and picking stay untouched. The data model is not a private format: it is the OMI glTF physics extension family, so real Godot/Blender physics assets import with no translation layer and your scenes round-trip back out to glTF. The indented technical notes point at the code.
Every concept in the engine is an OMI concept. Rather than invent a
RigidBody node and map it onto glTF at the loader, the OMI schema
is the in-memory model — the loader, the nodes and the
simulation all speak the same structure.
| OMI extension | Provides | In code |
|---|---|---|
OMI_physics_shape |
box, sphere, capsule, cylinder, convex, trimesh | model.Shape |
OMI_physics_body |
motion / collider / trigger,
materials, collision filters |
model.Motion, Collider, Trigger,
Material, CollisionFilter |
OMI_physics_gravity |
global gravity and per-volume gravity zones | model.Gravity |
OMI_physics_joint |
limits + drives between bodies | model.Joint, joints.* |
The structures live in
omi_physics/model.py with the OMI spec defaults. The
reader/writer is omi_physics/omi_gltf.py; load_document() parses
a glTF's extension blocks into these structures and export_extensions()
writes them back — a near-identity round-trip. A future
KHR_physics_rigid_bodies reader drops onto the same
structures.
Object characteristics are just OMI motion fields: an
immobile Earth is type:"static"; weight is
mass × |g|; the centre of gravity is
centerOfMass.
The engine follows the standard real-time recipe (Catto/Box2D, Gaffer's Fix Your Timestep):
State lives in a flat structure-of-arrays
(world.py) beside the tree, synced to Transform nodes
only at step boundaries. The per-stage kernels sit behind a
backend.py seam with two implementations: NumpyBackend
(vectorized CPU) and GLComputeBackend (glcompute.py),
which runs the per-body force and position integration as GL 4.3 compute
shaders over the same columnar arrays — fused into one dispatch when a
step has no collision or joints, so the intermediate velocity never leaves the
GPU. It is ~2.4× faster than numpy at 105 movers.
The default auto policy runs on numpy and hands
off to the GPU only once the awake-body count crosses gpu_threshold
(10k) — below that, numpy wins because transfer overhead outweighs the
tiny per-body integrate — with hysteresis on the way back down and a numpy
fallback where GL 4.3 compute is absent.
OPENGLCONTEXT_PHYSICS_BACKEND=numpy|gpu|auto overrides. The GPU
computes in float32, so trajectories match the CPU backend within tolerance
rather than bit-for-bit. The broad phase, narrow phase, and solver still run on
the CPU; a full GPU-resident loop (LBVH broad phase, graph-colored/XPBD solver)
is the next step. tests/physics_stress.py toggles backends live
with the b key.
Most authored geometry has no hand-made collider, so
cookery.cook_shape() derives one from an arbitrary vertex array:
a best-fit primitive, a convex hull (default for
movers), a decompose compound of convex pieces for concave
movers, or a trimesh triangle-soup (default for static world
geometry). The physics-cook CLI bakes these into a glTF so import
is free.
Convex hulls and approximate convex decomposition are in
hull.py (no scipy dependency); results cache on the vertex array.
The physics_cook_view.py demo overlays the cooked proxy on the
render mesh so you can see the fit.
Walking is a capability of every interactive context, not
something a particular viewer implements.
OpenGLContext.move.physicswalk.PhysicsWalkMixin is mixed into
ViewPlatformMixin, so any context that has a camera can be asked
to hand it to an avatar instead. It costs nothing until it is asked for:
every physics import is inside a method, so a context that never enables it
never imports the physics package at all.
class MyWorld( BaseContext ):
def OnInit( self ):
self.sg = load_my_world()
self.setupPhysics( enable=True ) # binds 'g', and starts walking
Two navigators want the camera and only one may have it. While walking, the
avatar owns context.platform and the free-fly movement manager
is unbound; switching back rebinds it where the avatar left the view
standing. If both ran at once the camera would snap back on every key
release.
It is a run-time toggle rather than a start-up choice on purpose: a viewpoint that drops the avatar inside geometry must never be a trap. Press g to fly out, and g again to resume walking from wherever you got to.
| Method | What it does |
|---|---|
setupPhysics( enable=False ) |
Make walking available; bind the toggle key; optionally start walking. Returns whether it is walking — False when there was nothing walkable, which is not an error. |
enablePhysics( on ) |
Switch between walking and free-fly, leaving the camera where it is. |
stepPhysics( dt=None ) |
Advance the avatar one frame and put the camera where it ended up.
Call from OnIdle. dt defaults to wall-clock
since the last step, clamped — a stall is not a licence to teleport
through a wall. |
buildPhysicsWorld() |
The seam. Returns (world, (lo, hi)), or
None if nothing is walkable. The default cooks one static collision mesh
from self.sg; a context with a world of its own — a
terrain heightfield, a level format that ships its collision —
overrides this and never touches sg. |
characterCapabilities( scale ) |
The avatar's size, and the speeds it starts with. Override to make it something other than roughly a person. The movement mode in force retunes the walk, run, fly and swim speeds as it drives, so those are what the mode says rather than what was set here; the proportions, the jump and the crouch stay the body's. |
spawnAvatar( lo, hi, caps, viewpoints ) |
Stand the avatar somewhere it can walk out of — see below. |
moveAvatarToViewpoint( vp ) |
Put the avatar where a Viewpoint looks from, facing where
it faces. Safe-bound, and it starts flying if the viewpoint is
aerial, since falling out of the shot is not what asking for that view
meant. |
resolvePhysicsStep() |
Correct the avatar's pose once the character has solved its own step, and before the camera is taken from it — where a host whose ground is not in the collision world puts it back on the ground. Empty by default. |
getNavigationPlatform() |
What the declared movement modes drive: the avatar while walking, the camera otherwise. |
A landscape is the case the seams above exist for.
OpenGLContext.move.terrainwalk.TerrainWalkMixin is the same
capability with a different ground: the avatar, the declared modes and the
keys are the ones every other program here uses, but the surface is a
HeightField and the
obstacles are a field of cylinders — both answered analytically. A
four-kilometre landscape would be millions of triangles as a collision mesh,
and asking a height field how high the ground is costs the same wherever you
stand.
It fills in three of the seams and adds nothing to the frame loop:
buildPhysicsWorld() hands the character a world with nothing in
it, spawnAvatar() stands it on the ground under the camera the
scene placed (every point of a height field is standable, so there is
nothing to search for), and resolvePhysicsStep() lifts it to
the surface and pushes it out of the trunks after each step. The surface is
a floor rather than a rail, so a jump rises and an arrival from the
air falls; trunks stop a walker and not a flier, since flying is noclip.
oglc-forest is this, and so is anything else built on
scenegraph.terrain.
A viewer opens anything from a bolt to a city, and neither a stride nor
gravity means anything until they are in the same units as the model. The
avatar is built at 1/40 of the world's longest side
(physicsAvatarScale), and the declared
movement modes are re-declared at that same
scale by applyMovementModes(), so a stride is in the same units
as the model it is taken through.
The middle of a model is very often solid — a statue, thick walls, no
floor at all. spawnAvatar() therefore samples: authored camera
viewpoints first (they are curated open spots, and the first supplies the
heading), then the footprint centre, then rings outwards. Each candidate is
kept only if the avatar lands grounded and unstuck, and scored by how many of
the four horizontal directions it has room to move into, preferring the most
open and, among equals, the most central. A fully open spot is taken at once.
If nothing is walkable anywhere, the avatar goes to the centre regardless
— somewhere is better than nowhere, since flying out is one keypress
and an unplaced avatar has no pose for the camera to take at all.
The clearance probe places the whole capsule an arm's length along each direction and depenetrates it, which catches a wall the avatar's centre line would miss. It is a placement test and not a swept move, so a barrier thinner than the capsule is transparent to it, and its reach is a fixed margin rather than one scaled to the avatar — on a small model it therefore reports "open" more readily than it should.
Navigation uses a kinematic capsule with move-and-slide: tiered speed
(walk/run/sprint/crouch), jump when grounded, fly/noclip, step-up over small
ledges, and sliding on steep slopes — configured by a non-OMI
CharacterCapabilities node. Crucially, on every viewpoint bind it
runs safe placement: depenetrate from any overlapping
geometry, then snap the base onto the floor, so a camera authored low or
inside a wall never leaves the user stuck in the ground. If no free
space is found it enters fly rather than wedging.
Contact is discrete: each step places the capsule and then resolves whatever it overlaps. A step that carries the capsule clean past a floor leaves nothing overlapping, and nothing overlapping is nothing to be stopped by — a fall from three or four storeys does exactly that at any ordinary frame rate. So a frame is advanced in pieces short enough that the capsule cannot cross its own extent in one of them. The two axes get different allowances because the capsule is taller than it is wide, so an ordinary walk is not substepped at all while a fall is stepped finely for exactly as long as it is fast.
How much work that can ever be is calculated, not chosen.
CharacterCapabilities.terminalVelocity (55 m/s by default,
about what a person reaches) caps the fall, and the ceiling on substeps
follows from it: whatever the capsule is allowed to reach is what the
stepping is sized for, so the two cannot drift apart. A fixed ceiling is a
number nobody can check — raise the fall speed past what it allows and
a step outruns collision again, silently and only at speed. Setting
terminalVelocity to 0 lets a fall accelerate without limit and
gives up the guarantee along with it; max_substeps() reports
that rather than returning a reassuring number.
The other half is which way a contact pushes. Depth against a triangle is how far the capsule reaches past the face, resolved to the side the capsule is on — not the distance to the nearest point on it. Measuring the nearest point looks right while the capsule is barely touching and is exactly wrong once it is not: a hard landing puts the lower cap below the floor, that cap is then the nearest, and pushing toward it drives the character down through the surface it just hit while reporting no ground. The side is taken from the capsule rather than from the triangle's winding, because a triangle soup does not promise one — and taking it from the capsule is also what makes a ceiling push down.
The move direction is projected onto the surface underfoot before it is used, so a run up a ramp covers the same metres per second as a run along the flat and the climb is the vertical part of that. Moving horizontally instead makes the capsule penetrate the slope and be pushed back out along its normal, whose horizontal component opposes the motion, so the pace falls away as the ramp steepens — a walkable ramp then feels like wading. The step-down snap that keeps the capsule on the surface is likewise applied vertically only, since the seating that finds it also travels along the normal and would drag the capsule back downhill on every step of a climb.
What counts as ground is maxSlope, everywhere.
Standing, seating, and stepping up all ask the same question, so a face too
steep to walk cannot be stood on, snapped onto, or stepped up —
without which a cliff is climbable one stepHeight at a time by
anything moving fast enough.
A step is mounted in one motion, and owes back the difference. Getting onto a step means moving the capsule's centre past the edge — about a radius, and it has to happen in one go, because a capsule stopped against the riser is a radius behind it and a shorter probe never reaches over. That single motion is further than a frame of running covers, so a staircase taken one step per frame is climbed faster than the same distance on the flat, and faster still the better the frame rate. What a step advanced beyond its frame's due is therefore recorded and taken back out of the frames that follow, a little at a time so the capsule never stalls: stairs are climbed at running pace, whatever the frame rate.
A jump refused because grounded happened to be false on that
one frame is the commonest complaint about a first-person controller, and it
is worst where it is most noticed: running. A capsule at speed over a step,
a ramp lip or a seam between two colliders leaves the ground for a frame or
two at a time, and every press landing in one of those frames is swallowed
with no feedback at all. Two windows fix it, both in seconds so they hold at
any frame rate:
| Capability | Default | What it does |
|---|---|---|
coyoteTime | 0.12 s | A jump is still allowed for this long after walking off something. |
jumpBuffer | 0.12 s | A jump asked for this soon before landing fires on landing rather than being dropped. |
Both forgive falling, never jumping: a capsule that left the
ground under its own power has no coyote time, so there is no free double
jump, and a buffered press is spent once. Set either to 0 to switch it off.
A refusal on any other ground — crouching, canJump off
— is a refusal rather than a delay, and is not buffered.
A rising capsule is never grounded, whatever is beneath
it. When nothing touched the capsule during a step it looks
GROUND_PROBE (5 cm) below itself for floor, which is what keeps
a walker attached over the small gaps a step opens. One frame after a jump
the capsule has climbed only vy × dt, and on a
fast machine that is less than the probe reaches — so the
launch is snapped straight back down and its velocity zeroed in the frame it
started. The faster the machine the more jumps vanish, and because frame
times vary it takes some presses and not others. The capsule also still
touches the floor it is leaving, so the contact test says "ground" as well;
neither answer applies to something on its way up.
character.py holds the controller;
move/physicsplatform.py's PhysicsViewPlatform drives
a context camera from it. See the physics_navigate.py demo.
Physics bugs are visual, so a wireframe overlay draws, per a bit-flag mask: collision proxies, broad-phase AABBs, contact points and normals, joint connections, and — to make motion legible — per-body velocity, acceleration, and angular-velocity vectors. The spin vectors are drawn at the body's corners so opposite corners point opposite ways, making rotation visible at a glance. Sleeping bodies are colour-coded.
debugdraw.PhysicsDebugDraw builds an
IndexedLineSet (per-vertex colour) each frame, so it renders in
both the legacy and core profiles. Flags:
PROXIES | AABBS | CONTACTS | VELOCITY | ACCELERATION | ANGULAR | SLEEP |
JOINTS.
Every feature ships a runnable demo in tests/ that doubles as
its visual-regression test (auto-exit + screenshot capture):
physics_room_drop.py — objects fall into a room and
stack; the core engine + debug overlay.physics_bounce.py — a row of balls, restitution 0…1.physics_friction.py — boxes on ramps; the slide
threshold.physics_gravity_zones.py — a point-gravity planet.physics_triggers.py — sensor volumes and events.physics_joints.py — pendulum, chain and a motor.physics_cook_view.py — cook a collider and compare it to
the mesh.physics_navigate.py — first-person walk through walls,
a doorway, stairs and a ramp.physics_stress.py — scaling under load.The Add physics to a scene tutorial walks through building the first of these from scratch.