Physics Backends¶
3D physics in SimVX runs behind a backend seam: every PhysicsRoot builds an
isolated PhysicsWorld, and which implementation it uses is chosen by a single
selection rule. Two backends ship today:
Backend |
Package |
Notes |
|---|---|---|
BuiltinPhysics (3D) |
|
Pure-Python dependency-free reference solver. No dependency, runs everywhere including the browser. See What the built-in 3D solver is good for. |
BuiltinPhysics2D |
|
Pure-Python 2D solver, a tier above its 3D sibling: real moment of inertia, rotation-honouring circle-box, poly-poly SAT with edge clipping. It refuses no shape pair and the 3D honesty list below does not apply to it. |
JoltPhysics |
|
Native Jolt (cffi on desktop, JoltPhysics.js WASM on web). 3D only. Full rigid-body solver, oriented-box collision, high body counts. |
PymunkPhysics2D |
|
Native Chipmunk2D. 2D only. |
Installing pymunk makes it the 2D default. Jolt is explicit opt-in: installing
simvx-physics-jolt makes it available by name but does not change the 3D
default, because a 3D solver swap changes gameplay outcomes.
What the seam promises¶
Three promises, and they are what makes swapping a backend a decision about performance and tier rather than a rewrite:
Identical contract behaviour. The same call has the same effect and the same observable consequences everywhere. A body created
DYNAMICfalls, a filter that fails in one direction rejects the pair, changing a body wakes what it was holding up, a pair reports exactly oneENTERand oneEXIT, a sensor reports the bodies that overlap it. Game code never branches on which backend it got in order to make a call. Where a backend cannot deliver one of those clauses the shortfall is written down rather than left silent, and sensing has two. One is queryable: aSTATICbody is reported only whereSENSOR_DETECTS_STATICis advertised, which today is every backend, with the one exception recorded under that capability (a Jolt sensor whose collider is a mesh). The other is part of what a sensor overlap is: one sensor inside another is reported per observer, each side firing only where its own mask admits the other, so an asymmetric pair fires on one side alone.Numbers only within a tolerance. Two solvers do not agree on where a crate rests or which step it settles on. Numeric equality between the pure-Python solvers and Jolt is explicitly not promised; the differences big enough to design around are the rest of this page.
Anything that genuinely differs is queryable. A backend-dependent feature or payload field is a
Capabilityyou can askworld.capabilities()about, never a silent substitute, and every one of them ships a documented way to degrade around it.
Two rules follow, and they constrain the engine as much as they describe it. No
gameplay-visible discrete decision may be derived from a quantity whose precision
is backend-dependent – that is why a character’s step-up is decided by the
walkable-slope test of the surface it lands on, and, when that surface is one a
rounded character can only perch on the edge of, by a forward probe asking whether
walkable ground continues ahead of the landing. Neither reads how precise the
sweep was. And every Capability ships a documented degradation path, which is why
CONTACT_IMPULSE, the one a game could otherwise be left with nothing for,
publishes its fallback on the event itself.
Both suites that hold the backends to this run per implementation of each ABC:
packages/core/tests/test_physics_contract_2d.py for the 2D pair and
packages/physics-jolt/tests/test_physics_contract_3d.py for the 3D three,
where the web arm drives the real JoltPhysics.js wasm under Node.
Selecting a backend¶
Precedence (highest first):
An explicit
PhysicsRoot(backend=...)on the subtree.The project
physics_backendsetting (.simvx/config.json).An installed native backend that opts into auto-discovery AND serves the dimension being built.
pymunkdoes (installing it moves a 2D game onto Chipmunk2D); Jolt does not, so it is reached only by 1 or 2.Otherwise:
BuiltinPhysics/BuiltinPhysics2D.
Level 3 is asked per dimension, so a backend never wins a dimension it cannot build. A 3D-only backend is not a candidate for a 2D world and vice versa, and neither installation affects the other’s default.
To keep the reference solver on a machine where a 2D native backend is installed,
name it: PhysicsRoot2D(backend="builtin"), or "physics_backend": "builtin".
Per-subtree¶
from simvx.core import PhysicsRoot
# Every body under this root simulates on Jolt; the rest of the scene is unaffected.
root = self.add_child(PhysicsRoot(backend="jolt"))
root.add_child(my_dynamic_body)
A scene can hold several PhysicsRoots with different backends at once: see
examples/features/physics/backend_compare.py, which runs Builtin and Jolt side by
side.
Project-wide¶
Create .simvx/config.json at the project root:
{ "general": { "physics_backend": "jolt" } }
Now any body with no explicit PhysicsRoot(backend=...) uses Jolt. If the
package is not installed, selection falls back to Builtin with a console
warning (it never hard-fails).
What the built-in 3D solver is good for¶
It is a dependency-free reference solver: always present, running everywhere the engine runs including the browser, and the behavioural parity target the seam’s cross-backend tests are written against. It is adequate for spheres and capsules on flat ground, low-density scenes, prototypes, and anything whose gameplay does not turn on the exact resting pose of a stack.
Exact. Every sphere and capsule pairing; sphere, capsule and box against a static triangle mesh (a box reports its own support, so it rests on a mesh floor at the height it rests on a box floor); cylinder-sphere and cylinder ray queries; convex-hull boolean overlap via GJK.
Approximate, and named where it is computed. Cylinder-box (AABB of the cylinder); cylinder-cylinder and capsule-cylinder (the cylinder is treated as a capsule, so the rims round off); convex-hull penetration depth and normal (EPA-lite); convex-hull raycast (AABB of the point cloud); contact response (one linear impulse at the centre of mass, no inertia tensor, so an off-centre impulse never spins a body). Swept queries substep rather than solving a true time of impact, so a reported distance is a lower bound.
Rotation ignored. Box-vs-box, hull point clouds, and a mesh body’s own orientation.
Refused. Convex hull against a triangle mesh. There is no routine for it and
the pair returned no contact at all, so a hull fell through a mesh floor in
silence. Install simvx-physics-jolt and name it for that pair. create_body,
set_body_shape, set_body_filter, shapecast and overlap raise:
BuiltinPhysics cannot report a correct contact for a ConvexHullShape3D against a
ConcaveMeshShape3D: the pair is refused rather than silently returning no contact.
Install simvx-physics-jolt and select it (PhysicsRoot(backend="jolt") or
physics_backend="jolt"), or give the two bodies non-overlapping collision layers.
Only a pair the layer/mask rule says could actually meet is refused, so separating the two bodies’ layers is a legitimate answer; putting them back together raises again.
What Jolt adds over Builtin¶
A production rigid-body solver (warm-started contacts, stable stacking).
Oriented collision shapes: a rotating box collider actually sweeps, so a spinning paddle herds objects.
A real inertia tensor, so an off-centre impulse spins what it hits.
Convex hull against a triangle mesh, the one pair Builtin refuses.
Exact swept queries rather than substepped ones.
Throughput for high body counts.
Character behaviour is deliberately NOT on that list. A CharacterBody3D is a
BodyMode.KINEMATIC body on every backend, moved by one shared collide-and-slide
policy over a per-backend sweep primitive, so dynamic bodies rest on it and two
characters block each other identically whichever backend is resolved. (Jolt used
to additionally push loose bodies aside with its CharacterVirtual; it no longer
does, because a character that pushes on one backend and not another is a parity
bug, not a feature. MoveResult.collisions hands the contacts back so game code
can apply its own impulse.)
Two backend-specific facts worth knowing:
Jolt keeps a kinematic body active. Jolt only searches colliding pairs over active bodies, so a kinematic body must be active for an
Area3Dto report it. Every pose write re-activates it, which a character does every fixed step. The cost is that each kinematic body is visited by the pair search every step; the residual gap is a kinematic body that is created and then never re-posed, which deactivates after Jolt’s sleep timer and drops out of the search until the next pose write. Sleeping is deliberately left at its default rather than pinned off for every kinematic body, so a scene with no characters pays nothing permanent; a body that must stay searchable while parked asks for it by name withcan_sleep=False(measured: still active after 900 idle steps, where the same body deactivates by default).Jolt needs a second cast for an in-contact sweep. With an exact time-of-impact sweep, a mover that starts at zero separation gives the narrowphase no separating axis, so the reported axis is not a surface normal. The backend re-decides that one case with a shrunken-shape cast, which recovers the true face. A substepped backend (Builtin, pymunk) never samples the start of its cast and so cannot report the case at all.
2D is unaffected: Jolt is 3D-only; 2D continues to use Builtin (or the optional
pymunk backend).
Installing the desktop backend¶
# in a clone of the monorepo: ask for it by group (compiles the vendored
# Jolt + joltc locally, and keeps the default groups, so pytest comes too)
uv sync --group native
# outside the workspace, or to install just this package into an existing venv
uv pip install -e packages/physics-jolt --no-build-isolation
Not uv sync --package simvx-physics-jolt: --package re-scopes the project and
uv sync is an exact sync, so it evicts everything outside that package’s
closure, test tooling included.
The package vendors pinned Jolt + joltc C++ sources and builds them via CMake at install time, so an sdist install always works provided a C++17 compiler and CMake are present (this is the first SimVX package that requires a compiler; the pure-Python default never does). Useful knobs:
SIMVX_SKIP_JOLT_BUILD=1: install the Python without compiling the native extension (Jolt then resolves to unavailable; selection falls back to Builtin).SIMVX_REQUIRE_JOLT_NATIVE=1: fail the install loudly if the native build cannot complete, instead of skipping.
A prebuilt Linux wheel may be available; it is built with a conservative SIMD baseline (no AVX2) so it runs on older CPUs, at some performance cost. There are no prebuilt macOS/Windows/musl wheels yet (the project runs no CI), so those platforms install from the sdist and need the toolchain above.
Web export¶
simvx export web automatically bundles the JoltPhysics.js (WASM) runtime when
the exported game opts into Jolt (it scans the sources for a backend="jolt" /
physics_backend selection). The same explicit-opt-in rule applies; the web
default stays BuiltinPhysics (pure Python under Pyodide).
uv run simvx export web examples/demos/waterfall.py -o waterfall.html
Web caveats:
Jolt and Pyodide live in separate WASM heaps, so transforms are copied across the boundary once per frame, so practical body counts are lower than desktop (a few thousand, not tens of thousands).
It is a separate backend that mirrors only the
PhysicsWorldAPI, not the desktop build.No determinism on web, and no measured contact impulse: see the capability table below, which both backends report through the same gate.
examples/features/physics/jolt_web.py is a minimal web Jolt scene; waterfall.py
and physics_backend_compare.py also export to web.
Body scale is parity, not a capability¶
Every backend honours a per-body scale, and they agree on which scales a given
collider can express (see Scale). Builtin and pymunk resize
their own shape parameters; both Jolt backends wrap the shared shape in Jolt’s
own ScaledShape. A 4x-scaled crate rests at the same height on all four, a
4x-scaled character stops against the scaled collider on all four (to each
backend’s own sweep resolution), and a
collider that cannot carry a non-uniform scale raises the same error on all four
rather than one backend approximating where another refuses.
Capability differences¶
Every backend implements the same rigid-body methods with the same behaviour, so
a node never branches on which backend it got in order to make a call. The few
things that genuinely differ are advertised through one gate,
world.capabilities(), which returns a frozenset[Capability]:
Capability |
Builtin 3D / 2D |
pymunk |
Jolt desktop |
Jolt web |
Degradation path where absent |
|---|---|---|---|---|---|
|
yes |
yes |
no |
no |
read |
|
yes |
no |
yes |
yes |
cap the per-step motion, or sweep it yourself |
|
yes |
no |
yes |
yes |
nothing sleeps; every body behaves as an awake one |
|
yes |
yes |
yes* |
yes* |
make the body |
|
no |
no |
no |
no |
replicate state rather than input |
|
no |
no |
no |
no |
wheels as raycasts plus |
|
no |
no |
no |
no |
a lattice of bodies and springs |
* On both Jolt lanes, with the single exception of a sensor whose collider is a MESH: see below.
Advertisement runs both ways: some capabilities only a native backend could
offer, and CONTACT_IMPULSE is one the pure-Python solver honours while the
optional native backend does not. Jolt desktop and Jolt web both report
ContactEvent.impulse is None because neither joltc nor JoltPhysics.js exposes
the solver’s applied lambda; the builtin solvers and pymunk report the impulse
they measured. None is never a stand-in for a measured 0.0.
Which is why every contact also carries impulse_estimate, on every backend
and whether or not the capability is advertised: the impulse it would take to
arrest the approach, computed from the pair’s masses and the difference of the
two bodies’ linear velocities – not from the event’s own rel_velocity,
which is the velocity at the contact point, and the third bullet below is why
that distinction is the whole reason the number is portable. It is the same
formula on all five implementations, so a game that keys hit sounds, screen
shake or damage off it always has a number to key off and one of the same kind
everywhere – which the measured impulse cannot give, being a different
solver’s number each time, or none at all.
Measured on a 1 kg box landing on static ground, the four locally runnable
backends’ estimates agree to 0.05%, and each backend that measures its own
applied impulse reports one within 3% of its estimate (the builtin 3D solver’s
two are the same number).
It is deliberately not a measurement, and the three places it differs from one are worth knowing:
It ignores restitution, so a bouncy pair really exchanges up to
(1 + e)times this.It is
0.0for a pair that is not approaching, which includes a body settling onto ground that is itself falling, and a pile pressing down at rest. Impacts are what it is for; a running measure of load is not.It ignores spin, and that is deliberate. The obvious way to compute it would be from the
rel_velocitypublished on the same event, but that value is measured at the contact point, and where in the contact patch the point sits is each narrow phase’s own choice: the builtin 2D solver takes the middle of its clipped manifold, Chipmunk takes one end of it, Jolt takes a manifold corner. For a round contact those are the same place, but a crate lands on a whole face, and the spin about half a crate’s difference goes straight into the approach. Computing the estimate from the two centre velocities instead removes the choice from the answer: a 1 kg crate meeting the ground at 2 m/s reports 1.999, 2.000 and 1.999 N*s on the builtin 2D solver, pymunk and the builtin 3D solver, unchanged at 0, 2, 5 and 20 rad/s of spin, and the builtin 3D figure is the same whether the crate is built as a box or as a convex hull of the same eight corners. A 0.5 m wheel spinning at 20 rad/s agrees to 0.04% too.Both Jolt lanes feed it the same two centre velocities, which is what makes that true of all five. A Jolt contact listener runs with the bodies locked, so the body interface must not be read from inside it – probed, the step never returns – but
JPH_Body’s own getters take no lock, and both adapters read the linear velocities there (JPH_Body_GetLinearVelocityon desktop,Body.GetLinearVelocityin the web shim). The same crate landing at 2 m/s reports 1.999 N*s at 0, 2, 5 and 20 rad/s on the builtin 3D solver and on Jolt desktop alike, as a box and as a convex hull of the same eight corners. Feeding the at-point velocity instead, which is what Jolt did before the linear reader was bound, gave 2.00 / 3.00 / 4.50 / 12.00 for the box and 2.00 / 1.00 / 0.00 / 0.00 for the hull – the last of them reporting no impact for a crate that is certainly hitting the ground.So: the estimate is portable everywhere, spinning or not. The spin itself is not lost – it is published at full fidelity, at the contact point, in
rel_velocity, which is where a skid or a grind reads it, and which is still each narrow phase’s own choice of point.
sound.volume = min(1.0, contact.impulse_estimate / 10.0) # portable, every backend
if contact.impulse is not None:
damage = contact.impulse * 0.5 # exact where measured
SENSOR_DETECTS_STATIC is the one capability about which bodies reach a
trigger at all. An Area3D / Area2D builds its sensor STATIC, and both
native libraries build colliding pairs from the MOVING side only, so a static
sensor is never searched, which would leave a trigger over level geometry
reporting nothing on Jolt or pymunk. All three native adapters therefore hold
their sensors KINEMATIC
– the caller’s BodyMode is unchanged, and so is everything the body
does: a kinematic body takes no gravity, force, impulse or torque, and the two
ways a velocity could reach one are both closed against the caller’s mode – a
write to a body the caller made STATIC while the library holds it KINEMATIC
is kept away from the library rather than integrated, and a velocity taken while
the caller had the body KINEMATIC is cleared inside the library when the mode
returns to STATIC – so the promotion cannot set a trigger volume moving. What such a
write leaves behind is now the same on all five: the value is held on the seam’s
side, so body_velocity and the bulk reader hand it back and it becomes the live
velocity if the body later turns movable, the way a pure-Python tier that stores
a STATIC body’s velocity and declines to integrate it does. A flip is lossless
in velocity as it is in mass, so a body frozen while moving reads back the speed
it was frozen at and resumes at it – with one exception, measured: pymunk zeroes
a body’s velocity as it arrives at caller-STATIC while the library is holding
it KINEMATIC (a promoted sensor, and only that case), and reports 0 there
where the other four report the speed. Both Jolt adapters keep that store because
Jolt has nowhere to put such a write at all: a static body has no
MotionProperties. What such a velocity MEANS beyond the readback still differs,
because that is the library’s business: on both 2D backends and the builtin 3D
solver it is also a surface velocity, carrying whatever rests on the body along
(a conveyor belt: a crate resting on the moving surface is carried with it on
each of the three, at a speed the solver’s friction model sets), and on Jolt it
is inert. That is the whole
fix on Chipmunk, whose moving index is queried against the static one. Jolt
needs one thing more, because it forms a pair only when one of the two bodies
is DYNAMIC (a kinematic body paired with
a sensor being the single exception), so both Jolt adapters also set
mCollideKinematicVsNonDynamic on every sensor, which lifts that rule for the
pairs the sensor is in. Measured on a sensor overlapping one box, all five
backends report one ENTER whatever mode the box is in, sleeping bodies
included on Jolt, and one for another static sensor too.
It is not free. A sensor runs narrow phase against the static geometry it
overlaps, which nothing else in the world pays for, and Jolt’s own header calls
the flag CPU-intensive. The cost scales with (sensors x static bodies each one
covers) and with nothing else – a world with no sensors is unchanged to within
noise. Measured on a mid-range desktop CPU, Jolt desktop with 64 dynamic
crates falling over a carpet of 900 static tiles: 0.004 ms/step with no
sensors either way, 0.25 -> 0.95 ms/step with four 4 m sensors on the carpet,
0.36 -> 1.75 with eight, 2.5 -> 9.3 with thirty-two. pymunk with 400 static bodies and 100
dynamic: 0.18 ms/step with no sensors either way, 0.18 -> 0.28 with twenty
sensors, 0.17 -> 1.07 with a hundred. What a sensor covers is what it pays for,
so the same eight sensors over ONE static floor instead of a carpet cost nothing
measurable (0.26 -> 0.25 ms/step); a scene with hundreds of trigger volumes over
hundreds of static props should budget for it, and can turn a hot trigger into a
world.overlap() poll instead.
One documented exception, on the two Jolt lanes: a sensor whose collider is a
MESH. Mesh colliders are STATIC-only, on the seam and in Jolt, so such a
sensor cannot be held KINEMATIC, drives no pair search of its own, and the
flag has nothing to act on. Measured: a mesh sensor straddling one box reports
it when it is KINEMATIC or DYNAMIC and never when it is STATIC, and it is
blind to sleepers for the same reason. Swapping convex geometry onto it lifts
both blindnesses – Jolt allocates a body’s motion properties once, at creation,
so the adapter rebuilds the body to do it, which the caller cannot see: same
handle, same pose, same everything else. Trigger volumes are boxes and spheres
in practice, so this is a corner rather than a wall.
SLEEP covers the whole settling surface: is_sleeping reporting True,
wake() / sleep(), the can_sleep body property, and the wake=False opt-out
on the live-edit setters (see Sleeping). pymunk is the one backend without
it, because Chipmunk2D disables sleeping unless the space is given a finite
sleep-time threshold, and asking a body in such a space to sleep aborts the
process at the C level rather than raising. Where it is absent nothing ever
sleeps, so every body already behaves as an always-awake one: is_sleeping is
permanently False, sleep() does nothing, wake=False costs nothing because
there is no sleeper to leave undisturbed, and a body flipped to DYNAMIC with it
starts moving on the next step rather than being handed over parked. A game that
needs a body parked – to stop polling it, or to freeze a finished pile – checks
here and keeps it awake instead.
Sleeping and waking are island-atomic wherever SLEEP is advertised: a pile
parks as one unit and a change that takes its support away wakes all of it,
however deep it is, so nothing a change took support from is left asleep. A pile
sharing a floor with another is a separate island, on every backend that sleeps:
static geometry joins nothing. Measured on Jolt, a four-high stack reports asleep
on the same step for all four boxes, wakes whole when the bottom box is
teleported out, and leaves a crate resting eight metres away on the same floor
asleep – which is what the builtin solvers now do as well.
What differs is how much more than the island a wake reaches. The builtin
solvers wake exactly the contact island, walked through dynamic bodies from the
change; Jolt has no contact-set query, so it wakes everything in the volume the
body is giving up, which is a superset that can also catch a neighbour merely
standing beside the pile. Both honour the contract; a game that branches on
is_sleeping may see a body woken on Jolt that the builtin solvers would have
left alone.
Asleep also means frozen wherever SLEEP is advertised: a sleeping body reads
back the pose it fell asleep at on every step until something wakes it, so a
save, a placement check or a golden image taken of a settled scene reads the same
numbers each time.
The two world-level sleep thresholds follow the capability: where SLEEP is
absent they are stored and read back and change nothing, because nothing sleeps
for them to govern.
CONTINUOUS is the same shape of gap: pymunk accepts the continuous flag and
integrates discretely anyway (Chipmunk2D has no swept collision), which the
engine reports once per body rather than failing, so a fast small body can tunnel
through a thin collider there.
Joints and constraints¶
Every backend anchors a joint the same way, and it is the way every production solver does it: the anchor you pass in world space is captured into each body’s own frame at create, and turned back into world axes by that body’s current rotation on every solver pass. So a pin on a spinning platform orbits with it, a hinge on a post that is itself turning keeps swinging about the post, and a weld carries its whole assembly round. Measured on a bead pinned a metre out from a hub turning at 1 rad/s for two seconds, the four locally runnable backends put it within 1 mm of the arm and within half a milliradian of the two radians the hub turned; the contract suites pin it on all of them, and a hinge axis follows its post to within a degree even when the door is kicked about an axis the post has already turned away from.
What differs is how hard the constraint is:
Builtin 3D / 2D |
pymunk |
Jolt |
|
|---|---|---|---|
Anchor frame |
body-local |
body-local |
body-local |
Convergence |
a few impulse iterations plus a soft positional bias |
Chipmunk’s iterated solver, no position pass |
warm-started, production |
Angular response |
3D stands in |
real |
real inertia tensor |
The convergence row is the one a scene notices. A loaded chain sags on the
builtin solvers, and it sags further the more links pull on each other: hang a
six-link pendulum of 1 kg beads under 1200 units/s^2 and the builtin 2D solver
settles it with its worst link 7.0% long, its tip 312 units below an anchor 300
units of chain away, while pymunk settles the same chain on its rest length to
five decimal places, tip at 300.000. Those are the figures for a chain that
hangs. A moving chain narrows the gap: examples/features/2d/joints.py kicks
this chain and prints the worst link it sees while the chain hangs and while it
swings, 22.9% on the builtin against 3.8% on pymunk. pymunk’s links stretch
under a swing where they do not under a hang, so read that pair as a swing
measurement and not as the sag. A rope that must not stretch wants a native
backend, or fewer, heavier links.
Motors, angular limits and breakable joints are absent from the seam itself, so
no backend has them: a joint you create is a passive constraint everywhere, and a
mechanism that needs driving is driven by apply_torque on the body. The 2D seam
adds one constraint the 3D one has no equivalent for, GrooveJoint2D, and the
convergence row shows up there as the firmness of its endpoint stop. A 1 kg
slider on a groove one unit long either side of its centre, pushed along the rail
for ten seconds: pymunk parks it on the endpoint to four decimal places under
every load from 0.1 N to 50 N, while the builtin’s soft positional stop lets it
past — 14% of the half-length out at 1 N, 69% at 5 N, and nearly seven
half-lengths out at 50 N. Kicked rather than pushed, the two overshoot the end by
a similar amount and pymunk then pulls the slider back into the groove while the
builtin leaves it a little outside. A slider that must not overrun its rail wants
pymunk, or a scene that does not lean on the endpoint as a hard stop.
What a groove promises everywhere is the anchor on the rail, and nothing about
the slider’s own heading. The rail is stated in the carrier’s frame and the
anchor in the slider’s, and both are turned into world axes by their own body’s
current rotation every pass, so the constrained point stays on the segment
however either body is turning: measured over ten seconds of a carrier at
1 rad/s under a slider spun at 3 rad/s about an anchor a quarter of a unit off
its centre, the worst excursion is 0.052 units on the builtin solver and 0.061
on pymunk, against a rail two units long. Where the slider ends up POINTING is a
different matter, because a groove leaves its rotation free and what turns it is
each solver’s residual torque about the anchor. On that same scenario the two
part company: 0.33 rad apart after two seconds and 3.14 rad – half a turn –
after ten, with both anchors still on the rail. Read a groove as a slider, not
as a bearing; a scene that needs the slider held at an angle welds or pins it.
test_physics_contract_2d.py pins the invariant on both backends and records the
divergence rather than asserting agreement on it.
Body knobs across the backends¶
linear_damping, angular_damping and gravity_scale are honoured by all five
implementations with the same meaning and the same defaults, so a scene coasts
and falls the same way whichever is running. Two implementation notes are worth
knowing rather than guessing at:
pymunk has no per-body damping or gravity factor at all. A body running the seam defaults is served by Chipmunk’s space-wide
damping, which costs nothing; one that deviates gets a per-body velocity integrator applying the seam’s formula exactly. That is a Python call per such body per step and it is not free: measured at about 5.5-6.3 us per deviating body per step (200 falling boxes: 0.21 ms/step on the default path, 1.48 ms/step with all of them deviating; 800 boxes: 3.89 ms against 8.28 ms). Give a handful of bodies their own damping freely; giving a thousand of them theirs is a cost worth knowing about. That backend also runs the seam’scontact_sloprather than Chipmunk’s own pixel-scale default; see thecontact_slopparagraph below for what that moves.Jolt maps them straight onto
mLinearDamping,mAngularDampingandmGravityFactor. A mesh collider carries no motion properties, so setting damping on one is recorded at the seam and does nothing native – there is no motion on aSTATIC-only collider to damp. Jolt’s own integrator damps the velocity after adding the step’s acceleration, where the other backends damp before, so a body under sustained acceleration ends up slightly slower there: one step’s worth of damping is taken off each step’s acceleration, which costs a relativedamping * dt. The shortfall does not grow with time, but it does grow with the damping rate. Measured over five seconds of free fall at 60 Hz, against the seam formula: 0.08% at the default0.05, 0.83% at0.5, 3.33% at2.0. With nothing accelerating the body – which is the case damping exists for – the two orders are identical, and that is the case the cross-backend coasting contract covers. If you need a heavily damped body to fall at exactly the same rate on Jolt as on the pure-Python tiers, damp it less and shape the motion withgravity_scaleinstead.
solver_iterations maps to the builtin velocity loop, Jolt’s velocity steps and
Chipmunk’s space.iterations.
position_iterations is the seam’s second convergence dial, and the one place a
world knob does something on two backends and nothing on the other three. It is
the count of Baumgarte position passes a step makes over the RIGID joints, and
both builtin solvers run it; it defaults to 3, which is what they have always
run, so adopting it moves no scene. Chipmunk has no position solver at all, so
the pymunk lane stores the value and reads it back without running anything –
which is also why a chain it hangs sits on its rest length while the builtin
solver’s sags. Both Jolt lanes store and read it back too, for a different
reason: Jolt keeps its own position-step count in the same settings struct the
seam already pushes, and the entry points the two adapters call (the native
simvx_jolt_set_world_settings and the shim’s setWorldSettings) do not carry
it, so a Jolt world runs the library’s default whatever the knob says. Raise it
where a jointed assembly on a builtin solver is too slack; a scene with no
joints pays nothing for it either way, since contacts keep their one positional
correction per step whatever it is set to.
contact_slop maps to the builtin solvers’ penetration deadband, Jolt’s
penetrationSlop and Chipmunk’s collision_slop, so no backend emulates it. It
defaults to 0.001 everywhere, which is a metre-scale number and the value
the built-in solvers have always used.
Stating it at the seam is what makes the four backends agree, and it displaces both native libraries’ own defaults, so a scene that ran on either before this existed will rest differently now:
Lane |
Its own default |
Where a landed 1 m crate rests at it |
At the seam’s |
|---|---|---|---|
Jolt |
|
|
|
Chipmunk (pymunk) |
|
|
|
So every resting Jolt body sits about 19 mm higher than it did, and a loaded
Chipmunk one up to ten centimetres higher: a tenth of a unit is nothing at the
50-100 unit sprites Chipmunk was written for and ten centimetres at this
engine’s metre scale. Both are measured stable at the seam value – no jitter over
ten seconds at rest, three-high stacks all asleep – and a scene that wants a
library’s own tolerance back says so in one line (world.contact_slop = 0.02).
The reason this is a knob is that a scene may want a library’s own tolerance
back, not that the value should track the scene’s scale. A settled body rests
exactly contact_slop inside what it is standing on, at any world scale, so on
a backend that sleeps, lower is strictly better and the default is as right in
pixels as it is in metres. examples/features/physics/body_knobs.py --test
prints the relation directly: crates rest at 0.4990 against a geometric 0.5
at the default and at 0.4030 at 0.1, and the pile sleeps 4/4 at the default.
This page used to claim that a settled four-high stack of 50-unit crates “shows
about 0.06 units of residual movement” at the default, and that a pixel-scale
game should raise the slop to fix it. Both are measured false on the built-in
solver: a settled pile shows no post-settle movement at any slop from 0.001
to 2.0, because the island sleeps, and raising the slop only buys rest error. A
lone 20-unit crate at pixel scale sinks 0.0008 / 0.0198 / 0.0998 / 0.4998
/ 1.9998 at slop 0.001 / 0.02 / 0.1 / 0.5 / 2.0: the resting depth is
the slop, to four decimals, at every value.
Note also that both native backends stop settling deeper once their own speculative-contact limit is reached, so very large values saturate rather than scaling on.
Check what you need at setup rather than mid-collision:
from simvx.core.physics.capability import Capability
if Capability.CONTACT_IMPULSE in root.world.capabilities():
... # key impact damage off contact.impulse
else:
... # fall back to contact.velocity, which every backend measures
See also¶
Physics: the collision detection layer.
examples/demos/waterfall.py: flagship Jolt showcase (glass mill, spinning paddle, adaptive flow control, framerate tied to the physics tick).