Physics¶
SimVX ships a single physics stack that covers rigid bodies, character bodies,
sensor zones, joints, and spatial queries, in both 2D and 3D. The
API is deliberately symmetric: every 3D class has a 2D sibling with the same
shape (PhysicsBody3D / PhysicsBody2D, Area3D / Area2D, and so on), so the
patterns below transfer between dimensions by swapping the suffix and the vector
type (Vec3 / Vec2).
Two ideas carry the whole model:
A
PhysicsRootnode owns an isolated simulation world for its sub-branch of the scene tree. Bodies placed under it simulate in that world.A body resolves its world by walking up to the nearest
PhysicsRootancestor (innermost wins), falling back to a per-scene default world if there is none. So in the simple case you never construct a world by hand: add aPhysicsRoot, put bodies under it, and the fixed-step loop advances them.
Every node that owns a body in a physics world – PhysicsBody3D,
CharacterBody3D, Area3D, and their 2D siblings – derives from
PhysicsObject3D / PhysicsObject2D. That base is the name to reach for wherever
the meaning is “a node with a body in the world”: it is what an Area’s
body_entered hands you and what a node-level query resolves a hit to, so those
surfaces need neither a union nor an isinstance list.
All names below are importable from simvx.core.
Bodies and modes¶
PhysicsBody3D (and PhysicsBody2D) is the one rigid-body node. It does not
come in Static / Rigid / Kinematic variants; instead it carries a mode
property whose value is a BodyMode:
Mode |
Behaviour |
|---|---|
|
Force-simulated. Responds to gravity, impulses, and contacts. Uses |
|
Immovable collider (floors, walls). Simulated as infinite mass, never integrated; |
|
Code-moved via |
mode is a normal property and may be flipped at runtime while the body is in
the tree (for example to freeze a settled body to STATIC, or wake a ragdoll to
DYNAMIC). A flip is lossless: freezing a body to STATIC and waking it later
gives back exactly the mass it was created with, on every backend.
mass is in kilograms and must be greater than zero. The two layers enforce that
differently on purpose: the node property clamps into (0.001, 100000), so
PhysicsBody3D(mass=0.0).mass is 0.001 rather than an error, while a direct
call to the backend seam with a non-positive mass raises ValueError. mass is
live: editing it on a body that is already in the tree pushes straight into the
solver, recomputing the inertia from the body’s current shape and keeping its
pose and velocity.
A body needs collision geometry to become active. Supply it either with the
shape convenience property or with a CollisionShape3D child; the shape
property wins if both are present. A body with neither stays inert (no collider,
no contacts) until it re-enters the tree with geometry.
from simvx.core import (
BodyMode, BoxShape3D, CollisionShape3D, PhysicsBody3D, PhysicsRoot,
SphereShape3D, Vec3,
)
class Ball(PhysicsBody3D):
def __init__(self, **kwargs):
# `shape=` is the single-collider shortcut; equivalent to adding a
# CollisionShape3D(shape=SphereShape3D(0.5)) child.
super().__init__(mode=BodyMode.DYNAMIC, mass=2.0, shape=SphereShape3D(0.5), **kwargs)
class Ground(PhysicsBody3D):
def __init__(self, **kwargs):
super().__init__(mode=BodyMode.STATIC, **kwargs)
self.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=Vec3(20, 0.5, 20))))
root = self.add_child(PhysicsRoot(name="World")) # Y-up, gravity (0, -9.81, 0)
root.add_child(Ground(position=Vec3(0, -0.5, 0)))
root.add_child(Ball(position=Vec3(0, 10, 0)))
Body properties (mode, mass, shape, material, collision_layer,
collision_mask, continuous, can_sleep, linear_damping, angular_damping,
gravity_scale) all take effect live: writing one on
a body that is already in the tree pushes it into the body the solver is already
simulating, on every backend. The body is never destroyed and rebuilt for a value
change, so it keeps its pose, its velocity and the joints anchored to it, and a
sleeping body wakes for the write, because the value it was being solved against
has changed under it. The four that can take support away – mode, mass,
shape and the collision filter – also wake whatever was resting on the body
(see Sleeping); material, continuous, the two damping rates and
gravity_scale reach only the body itself, and allowing can_sleep again wakes
nothing, since permitting sleep disturbs nobody.
Swapping shape
changes the geometry as a teleport: an overlap the new collider introduces is
pushed apart by the ordinary contact solve over the following steps.
Two limits are worth knowing. A body cannot become shapeless, so clearing the
last collider off a live body leaves it with the geometry it has (reported at
WARNING). And continuous needs a backend that advertises
Capability.CONTINUOUS: the built-in solvers and both Jolt backends do, pymunk
does not (Chipmunk2D has no swept collision), so turning it on there is reported
and ignored.
These are property writes, and the resources they carry are values: what
reaches the simulation is assigning a resource whose values differ. A
PhysicsMaterial is frozen, so there is no in-place edit to get wrong, and
assigning an equal-valued one is a no-op because the surface did not change:
from dataclasses import replace
body.material = replace(body.material, friction=0.1)
A Shape is immutable by contract for the same reason (it has no setters, and a
resource already handed to a body would otherwise have to re-enter every world it
is live in); reaching past that to write an attribute is not a write to
body.shape, so nothing is pushed. Assign a new shape resource instead.
Parenting a body under a moving node¶
A body’s pose has one owner, and the mode says who it is. That decides what
happens when an ancestor of the body moves – a Node2D group, a Control
whose rect is re-laid-out, a parent you drag in the editor viewport.
A STATIC body follows its ancestors. Nothing simulates it, so its pose is
authored data, and moving any node above it carries the collider with the
sprite. Parent a STATIC body under a moving node when that motion is
authoring: a group positioned once at setup, a level chunk assembled under a
common origin, a shape nudged in the editor. This is the case the seesaw pivots
and fixed obstacles in examples/projects/marble_rally are built on.
A DYNAMIC or KINEMATIC body does not. The simulation owns its pose, and
an ancestor’s move would be erased by the next write-back anyway; dragging a
character through its handle every time a parent moved would override
move_and_slide under it. The ancestor’s move reaches the node’s transform and
stops there.
So for a platform that must carry or push other bodies at runtime, do not
parent them to it. Make the platform a KINEMATIC body and move that body
itself with move_and_collide: a STATIC pose write is a teleport and carries
no derived velocity, so riders would be left standing still while the floor
slides out from under them, and a body pushed into would be resolved as a
penetration rather than transported.
This is a deliberate difference from engines that push the composed transform into every body type regardless of mode, where the same scene gives a simulated body a visible teleport instead.
Collision shapes¶
Shapes are plain geometry resources, not nodes. Carry one on a body via shape=
or on a CollisionShape3D / CollisionShape2D child.
A shape owns its backend collider, the same way a mesh owns its GPU buffer. Share one resource across as many bodies as you like and it costs one collider; let the resource go and the collider goes with it, so a hitbox rebuilt from fresh geometry every frame costs one collider at a time rather than one per distinct size forever. Bodies already built on it keep their geometry, so dropping a resource never pulls the collider out from under something that is simulating.
Two resources of the same size are two colliders, deliberately: that is what makes a resource’s lifetime its own. Share the resource, not the numbers.
3D shapes:
Shape |
Arguments |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
2D shapes:
Shape |
Arguments |
|---|---|
|
|
|
|
|
|
|
a line segment |
|
|
|
|
Scale¶
A body collides at its node’s scale. Scale is a property of the BODY, not of the
shape – a shape resource is shared, so it cannot carry one node’s size – and the
node pushes its world_scale through with its pose, so a node scaled to 4 has a
collider four times the size and rests where it looks like it should.
Not every collider can express every scale, and the ones that cannot say so rather than guessing:
Collider |
Scale |
|---|---|
box / rectangle, convex hull / polygon, triangle mesh / segment soup |
per axis |
cylinder |
free along Y, one factor across X/Z |
sphere, circle, capsule, segment |
uniform only |
Scaling a sphere collider to (1, 2, 1) raises ValueError naming the fix
(scale uniformly, or use a box or hull), because there is no radius that means
“squashed” and quietly picking one would put the collider back out of step with
what is drawn. Negative components mirror and count as uniform, so the
scale.x = -1 sprite flip is fine.
Scale is read off the BODY, so a CollisionShape3D child’s own local scale does
not resize the collider: scale the body, not the collider node. (The body has one
scale, and the seam gives a body one scale.) CollisionShape3D.collider_scale
reports the scale the simulation actually applies – the owning body’s, or the
node’s own when it has no body – and is what the editor’s collider overlay and
the pick sphere are sized by, so neither can drift from the simulation.
One gap worth knowing: a change to a PARENT’s scale does not reach a child body’s
collider until something else writes that child’s pose. The reconcile hook
deliberately ignores transform changes arriving from a parent, because dragging a
character through its handle would fight move_and_slide.
A CollisionShape3D can opt into CPU mouse-picking with pickable=True, which
makes it a target for SceneTree.input_cast / on_picked using its bounding
sphere. Picking is off by default and costs nothing when unused.
Use several CollisionShape3D children (rather than the shape property) to
build a compound collider from more than one piece of geometry.
Materials¶
A PhysicsMaterial groups the surface coefficients that drive contact response.
It is a value resource: assign the same instance to many bodies for a shared
surface, or give each body its own.
from simvx.core import CombineMode, PhysicsMaterial
rubber = PhysicsMaterial(friction=0.9, restitution=0.8)
ice = PhysicsMaterial(friction=0.05, restitution=0.0)
Field |
Default |
Meaning |
|---|---|---|
|
|
Coulomb friction coefficient (>= 0). |
|
|
Bounciness in |
|
|
How this body’s friction combines with the other body’s at a contact. |
|
|
How restitution combines, independently of friction. |
The two coefficients combine independently, each via one of four modes:
CombineMode.AVERAGE, MIN, MAX, or MULTIPLY. When the two contacting
materials request different modes for the same coefficient, the higher-priority
mode wins, in the order MAX > MIN > MULTIPLY > AVERAGE, so a deliberately
grippy or bouncy surface dominates a neutral neighbour.
A mode is accepted by name wherever it is accepted at all, so a call site and a
scene file can both say friction_combine="max" without importing the enum. The
canonical spellings are the full words ("average", "minimum", "maximum",
"multiply"), which are what a saved scene carries; "min" and "max" are
accepted as short forms, and matching ignores case.
grippy = PhysicsMaterial(friction=0.9, friction_combine="max")
Where each knob lives¶
A physics knob has exactly one home, and which one follows what the knob describes.
Home |
Knobs |
Because |
|---|---|---|
Material, per contact |
|
They describe a surface, which many bodies share. |
Body |
|
They describe this object’s dynamics. |
World |
|
They describe the space everything is in. |
Damping and gravity scale¶
feather = PhysicsBody3D(linear_damping=2.0, angular_damping=2.0)
balloon = PhysicsBody3D(gravity_scale=-0.2)
pickup = PhysicsBody3D(gravity_scale=0.0) # hangs where it is put
Damping is the rate at which a body sheds motion with nothing touching it, and it
is a body knob because it stands in for the drag of a particular object’s shape
and material: a feather and a cannonball want different values in the same air.
Both rates default to 0.05 and are applied once per step, to the velocity the
body already carries and before that step’s acceleration:
v = v * max(0, 1 - damping * dt) + a * dt. 0 coasts forever. Damping the
carried velocity rather than the sum is what keeps it out of the way of a resting
contact, whose whole job is to cancel the acceleration the body was just given.
Both native lanes damp at that rate and differ only in where their own integrator
applies it, which the seam leaves alone rather than replacing. Jolt damps after
adding the step’s acceleration, so a body under sustained acceleration runs a
relative damping * dt slower there: 0.08% at the default rate and 60 Hz, and
3.3% at a rate of 2. pymunk integrates position before damping the velocity,
so a coasting body travels one step’s worth of the speed it has shed further than
the formula says: a fixed damping * dt fraction of the coast, 0.083% at the
default rate and 0.83% at 0.5. Both are bounded rather than cumulative – Jolt’s
vanishes as soon as nothing is accelerating the body, and pymunk’s stays the same
fixed fraction of the distance however long the coast runs. See
Physics Backends.
That default is load-bearing rather than cosmetic. Before the seam stated it,
every backend ran whatever the library under it shipped, and the same scene
simulated differently on each: a pushed sphere coasted 3.6 m on the built-in
solver, which applied no damping at all, and 78 m on Jolt, which defaults to
0.05. 0.05 is Jolt’s own default, and also PhysX’s and Unity’s for spin; Box2D
ships 0 and Godot 0.1.
gravity_scale multiplies the world’s gravity for one body: 1 falls normally,
0 ignores gravity entirely, and a negative value falls upward. It multiplies,
so a world with no gravity has none whatever a body says.
World knobs¶
Reach them through the world a PhysicsRoot owns:
world = self.node_at("World").world
world.gravity = Vec3(0, -3.7, 0) # Mars
world.solver_iterations = 16 # tighter stacks, proportionally more time
world.position_iterations = 8 # tighter joint chains, same trade
world.sleep_time_threshold = 0.25 # park settled bodies sooner
world.sleep_velocity_threshold = 0.02
world.contact_slop = 0.001 # overlap left uncorrected (metres)
solver_iterations is the impulse-solver iteration count, the one convergence
dial every tier has, and defaults to 8. That is the value the built-in solver
is tuned at, rather than the 10 Jolt and Chipmunk ship: measured, three-high
stacks on the built-in solver never settle at 10 and all settle at 8, while
the other two backends are indifferent between the two.
position_iterations is its partner for the joints, and defaults to 3. A
sequential-impulse solve has two halves: the velocity loop stops a constraint’s
error growing, and the position passes drain the error it leaves behind. Three
of them is not much for a rope, and the shortfall is superlinear in the chain’s
length rather than a fixed cost per link – measured on the built-in 2D solver at
the default, one 1 kg bead hangs 0.7% long, two 1.8%, four 7.9%, six 22.0% and
eight 48.0%. Raising the dial drains it: the same six-link chain settles at
21.97% at the default, 8.08% at 8 and 1.81% at 32, the step costing about
1.14x at 8 and 2.1x at 32 of its default-setting time (absolute cost is the
machine’s). It drives the joints and not the contacts, whose positional
correction is deliberately one pass per step, so a scene with no joints pays
nothing for raising it and a settled stack does not move. Both built-in solvers
run it; the native lanes store it and read it back without running it, for
reasons Physics Backends gives.
Sleep is decided by two world-level
thresholds and a per-body boolean, which is the model the recommended backend
has: Jolt keeps both thresholds in its world settings struct and exposes only
allowSleeping per body. A body that must never sleep says so with
can_sleep = False; there is no per-body threshold, because emulating one
everywhere would buy nothing that can_sleep does not already give.
contact_slop is the overlap the contact solve tolerates without pushing it
back out. It defaults to 0.001, which suits metres, and it is a knob because
the engine fixes no world scale: too tight for the scene and a settled body is
corrected for ever and never gets to sleep, too loose and it visibly sinks into
what it stands on. A 2D game working in pixels – gravity around 980, sprites
50 units across – should raise it to roughly 0.1-0.5, which is the scale
Chipmunk’s own default was written for. Both native backends are displaced by the
seam stating it, not just Chipmunk; Physics Backends has the numbers.
examples/features/physics/body_knobs.py is a runnable scene of this whole
section: three balls falling at three damping rates, a balloon on a negative
gravity scale, a pickup on a zero one, one material shared by four crates, and
the four world knobs live on keys.
Forces, impulses, and velocity¶
A body’s live velocity is read and written directly, not through a serialized property:
ball.velocity += Vec3(0, 5, 0) # linear velocity (Vec3), units/s
ball.spin = Vec3(0, 2, 0) # angular velocity (Vec3), radians/s (3D)
Dynamic bodies also take forces and impulses. Impulses apply instantly; forces
accumulate for the next fixed step and are cleared afterwards, so a sustained
force must be re-applied every on_fixed_update:
ball.push(Vec3(0, 20, 0)) # instantaneous linear impulse
ball.push(Vec3(5, 0, 0), at=ball.world_position + Vec3(0, 1, 0)) # off-centre -> adds spin
ball.spin_up(Vec3(0, 3, 0)) # instantaneous angular impulse
def on_fixed_update(self, dt):
ball.add_force(Vec3(0, 0, -12)) # continuous force this step
ball.add_torque(Vec3(0, 1, 0)) # continuous torque this step
All four are inert on STATIC and KINEMATIC bodies (infinite mass), and a
no-op on a body that is not in the tree.
Sleeping¶
A dynamic body that stops moving falls asleep: it is skipped by integration and
by the contact solve until something disturbs it, which is what lets a large
resting scene cost almost nothing. is_sleeping reports it. STATIC and
KINEMATIC bodies are never asleep – they were never awake – and a body flipped
off DYNAMIC stops reporting as asleep at once.
crate.is_sleeping # True once it has settled
crate.wake() # simulate it again now
crate.sleep() # park it now, whatever its sleep timer had reached
crate.can_sleep = False # never park it: a body a script polls every frame
A pile sleeps as a unit. Each body runs its own timer, but none of them commits
until every dynamic body it is touching – and everything those touch in turn –
has come to rest as well, because a crate that parked while the crate above it was
still moving would be woken again by the next contact and neither would ever
settle. What a pile rests on does not join it: STATIC and KINEMATIC bodies
are in nobody’s pile, so two stacks sharing a floor settle independently. The
corollary is that can_sleep = False reaches further than the body it is set on:
a crate that never parks keeps everything touching it awake too, which is the
price of a pile that can settle at all.
A sleeping body is frozen, not merely un-integrated: it reads back the pose it fell asleep at on every step until something wakes it, so a save, a placement check or a screenshot of a settled scene reads the same numbers each time.
The settling therefore finishes as the pile parks rather than after it. On the builtin solvers a pile takes its last move into place in the step it falls asleep: whatever overlap the solve still owed is resolved then, in one frame. How far that carries depends on how much was still owed, which grows with the height of the pile and with the gap it was dropped through: a six-high stack of one-metre crates placed already touching moves about a tenth of a crate, the same stack dropped a couple of centimetres into place up to about a fifth, and a ten-high stack further again. A pile that cannot be resolved that far stays awake and tries again over the next few steps. Some overlap cannot be resolved at all – a crate wedged into a gap narrower than it is, one pinned under a ceiling too low for it – and that parks where it stands rather than keeping its pile awake for the life of the level.
Waking is a property of the engine rather than something a game arranges. Every write that changes what the solver reads wakes the body, and the five that can take support away also wake whatever was resting on it, because the change may have removed what was holding it up: shrink the pillar under a sleeping crate and the crate falls. That is what the ordinary wake-on-contact cannot do on its own, since by the time the collision pass runs the support has already gone.
A pile wakes as a unit, exactly as it sleeps as one. The wake is not limited to
the bodies the changed one was touching: it carries through the whole pile, so
pulling the bottom crate out of a settled stack brings the stack down rather than
the one crate that was sitting on it. It stops where the pile does, at STATIC
and KINEMATIC bodies, so picking a crate up off a shared floor leaves the rest
of the level asleep.
The opposite case – a write that is bookkeeping rather than a disturbance –
suppresses the wake with wake=False, which streaming a level in, respawning a
pooled object, or an editor writing a value the player cannot feel all want. It
lives on the world seam rather than on the node, reached through a body’s public
world and handle:
crate.world.set_body_transform(crate.handle, (Vec3(0, 40, 0), Quat()), wake=False)
Suppression is opt-in, because getting it wrong the other way strands a body in
mid-air and no later step recovers it. It is available on the five mutators that
can take support away (set_body_transform, set_body_mode, set_body_mass,
set_body_filter, set_body_shape); impulses and forces take no such flag, as
waking is the point of them.
set_body_mode is the one that hands a body to the simulation, so wake=False
there means something more than “disturb nothing”: a body flipped to DYNAMIC with
the wake suppressed is handed over parked – dynamic and asleep, its velocity
zeroed, staying exactly where it stands until something reaches it. That is the
whole point for a streamed-in section, whose geometry lands settled instead of
paying to fall into place; wake(), a pose write, an impulse or an awake body
arriving all resume it. A body with can_sleep = False has no parked state to be
handed to, so it is freed awake whichever way wake points.
A node reaches the same state through its own two calls, which is the form to use
wherever a node owns the body: writing the mode through the seam would leave the
node’s mode Property saying something the body no longer agrees with.
crate.mode = BodyMode.DYNAMIC # every write wakes, this one included
crate.sleep() # ...so park it again, before another step runs
crate.wake() # and later, hand it over for real
All of this needs a backend that advertises Capability.SLEEP. pymunk does not:
nothing there ever sleeps, so is_sleeping is permanently False, sleep() does
nothing, wake=False has no consequence because there is no sleeper to leave
undisturbed, and a body freed with it starts moving on the next step because there
is nowhere to park it. See Physics Backends.
examples/features/physics/sleeping.py is a runnable scene of the whole surface.
Contacts and signals¶
Every body emits collided when it begins touching another body and separated
when it stops. Both payloads are a Contact (3D) or Contact2D (2D) whose
other is the peer body; on collided the point, normal, impulse,
impulse_estimate and velocity describe the contact, oriented toward the
receiving body.
class Player(PhysicsBody3D):
def on_ready(self):
self.collided.connect(self._on_hit)
def _on_hit(self, contact):
if contact.other.is_in_group("hazards"):
self.take_damage()
The engine does the contact bookkeeping, so prefer connecting to these signals over polling for overlaps each frame.
A destroyed body is still named¶
A body that is DESTROYED while touching something has stopped touching it, so its
peer gets separated (and an overlapping area gets body_exited) on the step
after it goes, naming the destroyed node itself:
def _on_left(self, contact):
if contact.other.handle is None:
self.score += 1 # that one was destroyed
else:
self.chase(contact.other) # that one just moved away
The node in contact.other is real but detached: tree, handle and world
are all None, its properties are readable, and every physics call on it is a
no-op. The scene tree holds it for exactly the one step it takes to deliver the
event and then lets go, so keeping a reference of your own keeps a dead node
alive. Read what you need from it and drop it.
This holds however the body left. It is the scene tree’s one-step hold on the
destroyed body that keeps the peer nameable, not the deferral, so destroy() and
the immediate remove_child() both deliver the closing event. What deferral buys
you separately is that destroy() is safe to call from inside the handler that
is being dispatched to. See Node System.
contact.impulse is capability-gated¶
impulse is the normal impulse the solver actually applied at the contact, which
makes it the natural input for hit damage and impact sounds. Not every backend can
read it: Chipmunk and both builtin solvers expose their applied impulse, but
neither Jolt build gives the engine a hook into its solver’s applied lambda, so on
Jolt (desktop and web) impulse is None. That is a different fact from 0.0,
which means a real contact the solver resolved with no push (a separating
velocity). Check support once, at setup, rather than testing for None inside a
collision handler:
from simvx.core.physics.capability import Capability
class Level(Node):
def on_ready(self):
self._physics = self.add_child(PhysicsRoot(gravity=Vec3(0, -9.81, 0)))
self._has_impulse = Capability.CONTACT_IMPULSE in self._physics.world.capabilities()
Where it is absent, read contact.impulse_estimate instead. Every backend
fills that in on every contact, from the pair’s masses and the speed they are
closing at: the impulse it would take to arrest the approach. Being the same
formula everywhere it is the portable number, so a game that wants one impact
sound on all five backends can read it even where a measurement is available:
def _on_hit(self, contact):
self.play_thud(volume=min(1.0, contact.impulse_estimate / 10.0))
It is an estimate of an impact, and it is deliberately blind to the solver: it
ignores restitution (a bouncy pair really exchanges up to (1 + e) times it) and
it is 0.0 for any pair that is not closing, which includes a crate settling onto
ground that is itself falling and a pile pressing down at rest. Use impulse
where you have it and this everywhere else; use neither as a measure of load.
The closing speed it uses is the difference of the two bodies’ linear
velocities, and not the contact.velocity published beside it. That field is
the relative velocity at the contact point, spin included, and where in the
contact patch each backend puts that point is its own business – which is fine
for reading a skid or a grind off it, and no basis at all for a number that is
supposed to mean the same thing on five backends. A spinning crate lands on a
whole face, and half a crate’s difference in the point is enough to move the
apparent approach by more than a factor of two. Taking the two centre velocities
instead leaves nothing to disagree about, and a crate reports the same estimate
at every spin rate on the builtin solvers and pymunk.
Both Jolt lanes are the exception, because a Jolt contact listener cannot read a body’s linear velocity, so there the estimate is computed from the at-point value after all. It makes no difference to a ball or a wheel; it makes a spinning crate read several times high, or as no impact at all. Key a tumbling body’s effects off its own linear velocity if you ship on Jolt. The backends page has the measurements.
The capability governs the contact-event stream only. move_and_collide is a
sweep, not a solver pass, so it returns a SweepHit / SweepHit2D rather than a
node-level Contact: it reports what a sweep can measure (the blocker’s handle,
the contact point, the separating normal, the guaranteed-clear distance) and has no
impulse field to misread.
Porting note: contact.velocity is not Unity’s relativeVelocity¶
Unity’s Collision.relativeVelocity is the difference of the two rigidbodies’
linear velocities. SimVX’s contact.velocity (ContactEvent.rel_velocity at
the world seam) is the relative velocity at the contact point, so a spinning
body contributes its surface speed. Both are pre-impact, so the two agree
exactly whenever neither body is spinning – which is most ported code, and why
the difference is easy to miss. Once a body spins they part: a 0.5 m wheel
dropped spinning at 20 rad/s reports 10 m/s across the ground here, where Unity
reports none.
If you need Unity’s quantity, note that you cannot subtract it out inside the
handler. collided is delivered after the step, so by then the solver has
arrested the impact and self.velocity reads zero for the body that just
landed. Two things that do work:
contact.impulse_estimateis computed from that linear difference. Divided by the pair’s reduced mass it is the closing speed along the normal, which is what mostrelativeVelocity.magnitudecode is really after – for a body of massmhitting static geometry,impulse_estimate / m.For the full vector, snapshot the body’s own
velocityinon_fixed_updatebefore the step that lands it.
The at-point value is the one worth publishing, because deriving it yourself needs the contact point and both angular velocities; the linear one you already have.
Character bodies¶
CharacterBody3D / CharacterBody2D is a KINEMATIC physics body with a swept
movement helper. It lives in the same body table as every other body and carries
the same collision_layer / collision_mask, so raycasts, shape queries and areas
see it, dynamic bodies collide with it and rest on it, it produces contact events
against dynamic bodies, and two characters block each other when their layers and
masks mutually opt in. move_and_slide(dt) is the only thing that distinguishes it
from any other kinematic body.
Set velocity each frame and call move_and_slide(dt); it moves the body,
deflects the velocity along contact surfaces, and updates the grounded state.
from simvx.core import CharacterBody2D, Input, RectangleShape2D, Vec2
class Player(CharacterBody2D):
def __init__(self, **kwargs):
super().__init__(shape=RectangleShape2D(half_extents=Vec2(10, 14)), **kwargs)
self.up_direction = Vec2(0, -1) # screen space: up is -Y
def on_update(self, dt):
vx = (Input.get_strength("right") - Input.get_strength("left")) * 250.0
vy = 0.0 if self.is_on_floor() and self.velocity.y > 0 else self.velocity.y
vy += 1400.0 * dt # gravity
if Input.is_action_just_pressed("jump") and self.is_on_floor():
vy = -450.0 # a -Y (upward) kick
self.velocity = Vec2(vx, vy)
self.move_and_slide(dt)
Configuration is set as properties: slope_limit (degrees, default 45),
step_height (default 0), max_slides (default 4), skin_width
(default 0.001, the contact clearance the sweep is asked to leave, which also
scales the ground probe), plus mass (default 70, kg) and push_factor
(default 1), both covered below.
These six are read per move, not frozen at enter-tree, so they can be tuned
live (a crouch lowering step_height, a sprint raising max_slides), as are
collision_layer / collision_mask and the collider itself, like every other
physics node’s.
After a move, is_on_floor(), is_on_wall(), is_on_ceiling(), and
floor_normal report what was hit; they are valid until the next
move_and_slide. up_direction is an instance attribute (default
Vec3(0, 1, 0) in 3D) that classifies floor, wall, and ceiling contacts, with
slope_limit setting BOTH thresholds: a normal within slope_limit of +up is
walkable floor, one within slope_limit of -up is ceiling, and everything
between is wall.
velocity is a plain instance attribute, deliberately not the seam-backed
PhysicsBody3D.velocity: a character is position-driven, so its stored physics
velocity stays zero and the world’s integrator never re-applies the motion
move_and_slide has already performed.
Pushing, and carrying¶
A character does not carry anything, on any backend: a body resting on it is not dragged along when it walks away, because its stored velocity is zero so there is no friction source.
Pushing is two knobs and is ON by default: at push_factor = 1 a character
walking into a crate shoves it. Set the factor to 0 and the sweep is blocked by
a dynamic body exactly as it is by a static one:
class Player(CharacterBody3D):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.mass = 80.0 # kg; the character's own, read only by the push
self.push_factor = 0.0 # or set them per instance, or change them mid-game
(Assign on the instance, as here: a bare class attribute would shadow the
inherited Property descriptor and bypass its clamping.)
Each blocking contact of a move, except one classified as floor and except an immovable body, receives an impulse of
push_factor * (M * m / (M + m)) * approach_speed
newton-seconds along the normal, where M is the character’s mass, m is the
struck body’s mass, and the approach speed is the component of the character’s
velocity into that surface. So a sprinting character shoves harder than a walking
one, and a heavier character shoves harder than a lighter one.
M * m / (M + m) is the pair’s reduced mass, and times the approach speed it is
the impulse of a perfectly inelastic collision – the one that leaves both bodies
moving together. That is what makes 1.0 a safe default: one contact changes the
struck body’s velocity by push_factor * M / (M + m) * approach_speed, which at
1.0 is at most the character’s own approach speed and never more. Measured on
both 3D backends, a 70 kg character walking at 4 m/s hands a 1 kg crate
3.9437 m/s, an equal 70 kg crate 2.0000 m/s and a 1000 kg crate 0.2617 m/s. That
is a bound per contact, not a speed limit on the body: keep walking into a light
crate, catch it up and hit it again, and the kicks stack. Above 1.0 the impulse
stops being physical and becomes a shove-harder dial; the range is 0 to
1000. Both knobs are read per move, like the other four, so a “carrying something
heavy” state can lower the factor for as long as it lasts.
The impulse goes through the centre of mass, so a shove slides a crate rather than tumbling it. An immovable body – STATIC or KINEMATIC – has no finite mass to share, so the push skips it and a wall or a parked platform absorbs the walk.
A contact the move classified as floor is the one exemption: standing on a
dynamic body imparts nothing to it. A character resting on something presses into
it every step at whatever speed gravity has just given it, so pushing floor
contacts would turn its weight into a downward impulse; measured with the
exemption removed and the character falling at 18 m/s^2, a 70 kg character
dropped 8 m onto a 1 kg crate at push_factor = 1 drove it 0.2286 m into the
static floor it was resting on and left it 0.0589 m low once everything settled
(the built-in solver; 0.0005 m on Jolt, whose solver pushes it straight back
out). Walls, ceilings and slopes too steep to walk on are all pushed. If you want
a character’s weight to bear on what it stands on, apply it yourself from
collisions.
Whatever push_factor is, the engine hands the collisions back rather than
guessing what you wanted. After move_and_slide, collisions holds the blocking
contacts the move resolved, in slide order, with the contact point for a game that
wants its crates to topple, or to push one-way, or to take damage on impact:
def on_fixed_update(self, dt):
self.velocity = Vec3(self._input_x * 5.0, self.velocity.y - 9.81 * dt, 0.0)
self.move_and_slide(dt)
for hit in self.collisions: # SweepHit: body, point, normal, distance
self.world.apply_impulse(hit.body, -hit.normal * 3.0, at=hit.point)
Layers in a character-dense scene¶
Because a character IS a body, a project whose projectiles, pickups and NPCs are
all CharacterBody2D puts all of them into the solver’s pair loop and into every
other character’s sweep scan. Give each family its own collision_layer, and a
collision_mask naming only what it should physically collide with (0 for
“nothing: I resolve my own hits”). A sweep pays for every body it MATCHES by layer
and almost nothing for one it does not, so this is a performance decision as much
as a gameplay one.
Kinematic movement¶
A KINEMATIC PhysicsBody3D is moved with move_and_collide(velocity, dt=1.0).
It sweeps the body’s collider along velocity * dt, stops at the first blocker,
syncs the node transform immediately, and returns the SweepHit it hit (or
None if it travelled the full distance). Unlike a character it does not slide
and does not integrate gravity: one sweep, stop on contact.
A body blocks the sweep only when all of the following hold: it is not the mover;
it is not a sensor (a sensor is excluded from collision resolution, so it never
blocks a sweep); the pair passes the AND rule; in 2D the one-way filter does not
reject it; and the contact opposes the direction of travel, so a surface the mover
already rests on does not halt a sideways move. SweepHit.distance is a distance
along the motion at which the mover is guaranteed not to penetrate, and the body is
placed exactly there: a backend with an exact time-of-impact sweep subtracts the
requested clearance, while a backend that substeps reports the last substep it
proved clear, which can be exactly 0.0 for a move that begins in contact.
class MovingPlatform(PhysicsBody3D):
def __init__(self, **kwargs):
super().__init__(mode=BodyMode.KINEMATIC, shape=BoxShape3D(half_extents=Vec3(2, 0.2, 2)), **kwargs)
def on_fixed_update(self, dt):
self.move_and_collide(Vec3(0, math.sin(self.tree.now) * 2.0, 0), dt)
Areas and overlap¶
Area3D / Area2D is a sensor zone: it participates in the broadphase and
detects bodies passing through it, but applies no collision response. It fires
body_entered / body_exited (payload: the peer PhysicsObject, so a
PhysicsBody or a CharacterBody) and area_entered /
area_exited (payload: the peer area), and exposes the live overlap set:
from simvx.core import Area3D, BoxShape3D, CollisionShape3D, Vec3
zone = Area3D(name="Checkpoint", position=Vec3(0, 1, 0))
zone.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=Vec3(2, 2, 2))))
zone.body_entered.connect(self._on_enter)
root.add_child(zone)
# Or poll the maintained set (no per-frame tree scan), with optional filters:
mobs = zone.get_overlapping_bodies(group="mobs")
areas = zone.get_overlapping_areas()
get_overlapping_bodies accepts group= (a scene-tree group name) and type=
(a PhysicsBody3D subclass); both are ANDed when supplied. Area detection is
one-directional: an area detects a body when area.collision_mask & body.collision_layer is non-zero (the observing area decides). Set
monitoring=False to make an area inert.
A SLEEPING body is detected like any other – a pickup trigger moved over a
crate that settled minutes ago fires body_entered for it, and detecting it
does not wake it – and so is a BodyMode.STATIC one, on every backend:
Capability.SENSOR_DETECTS_STATIC is advertised by all five. It costs
something, because a trigger then runs narrow phase against the static geometry
it covers; physics_backends carries the measured numbers and the one
degradation path (poll world.overlap() for a trigger that is hot enough to
matter).
There is one exception, on Jolt only: an area whose collider is a
ConcaveMeshShape3D. Jolt requires a mesh collider to sit on a static body, and
a static body never drives the pair search, so such an area is found only from
the other side and therefore sees only what is awake and movable – it misses
both the sleeping crate and the static wall. Give the area convex geometry –
which is what a trigger volume usually is – and it behaves like every other,
whether that geometry is set at creation or swapped in later. See
Physics Backends.
GravityArea3D / GravityArea2D extends the sensor into a force field that adds
gravity to the dynamic bodies inside it, on top of world gravity. gravity is a
uniform acceleration vector; enabling point_gravity adds a pull toward the area
centre of magnitude point_strength (default 9.81).
Spatial queries¶
Ray and shape queries run through node.physics (3D) or node.physics_2d (2D),
which returns a query object bound to the node’s resolved world. Results carry
the resolved scene node, so you never handle raw physics handles.
hit = self.physics.raycast(origin, direction, distance=60.0, exclude={self})
if hit:
print(hit.node, hit.point, hit.normal, hit.distance)
for hit in self.physics.raycast_all(origin, direction, distance=60.0):
hit.node.flash()
contact = self.physics.shapecast(SphereShape3D(0.3), origin, direction, distance=10.0)
bodies = self.physics.overlap(BoxShape3D(half_extents=Vec3(1, 1, 1)), transform)
Method |
Returns |
|---|---|
|
nearest |
|
|
|
earliest |
|
list of overlapping body nodes |
A RayHit / ShapeHit has .node, .point, .normal, and .distance.
exclude takes a set of nodes to ignore (commonly {self}).
Note the resolution rule: node.physics resolves to the nearest PhysicsRoot
ancestor of that node, or the tree default world. If you place bodies under a
child PhysicsRoot and query from a node above it, you will query the wrong
world. Query through the root node itself:
self.world = self.add_child(PhysicsRoot(name="World"))
# bodies added under self.world ...
hits = self.world.physics.raycast_all(origin, direction, distance=60.0)
Layers and masks¶
Every body and area carries a collision_layer (which layers it occupies) and a
collision_mask (which layers it scans), both 32-bit integers defaulting to
layer 1. Two bodies collide only when each opts into the other’s layer:
(a.collision_mask & b.collision_layer) and (b.collision_mask & a.collision_layer)
With the defaults everything collides. Set disjoint layers and masks to stop
pairs from interacting (for example, giving pendulum beads a mask of 0 so they
swing through each other but still hit the ground).
Queries and sensors use a simpler one-sided rule: a raycast / shapecast /
overlap considers a body when body.collision_layer & mask is non-zero, and an
area detects a body when area.collision_mask & body.collision_layer is
non-zero.
For readable named layers, define your own IntFlag and assign combinations; an
IntFlag is an int, so it stores, serialises, and bit-tests unchanged:
from enum import KEEP, IntFlag, auto
class Layer(IntFlag, boundary=KEEP):
WORLD = auto()
PLAYER = auto()
ENEMY = auto()
body.collision_layer = Layer.PLAYER
body.collision_mask = Layer.WORLD | Layer.ENEMY
Joints¶
A joint constrains two bodies. Set body_a and body_b (plain attributes, or
constructor keywords) and add the joint to the tree. A joint resolves its bodies
once at enter-tree, so add a joint after both of its bodies are in the world;
a joint declared before its bodies silently stays inert. Both bodies must live in
the same world, or the joint raises ValueError.
3D joints:
Joint |
Constrains |
|---|---|
|
Welds the full relative transform: the two bodies move as one. |
|
Pins the bodies at a world-space point; rotation stays free. Falls back to the joint node’s own position if |
|
A pin plus a single free rotational axis ( |
|
A soft distance spring between the two centres. |
The 2D set mirrors it: FixedJoint2D, PinJoint2D, HingeJoint2D,
SpringJoint2D, plus GrooveJoint2D (constrains a body to slide along a groove
defined on the other).
from simvx.core import HingeJoint3D, PinJoint3D, Vec3
# A pendulum link pinned to the body above it.
root.add_child(PinJoint3D(body_a=anchor, body_b=bead, anchor=anchor.world_position))
# A door hinged about the vertical axis at the post.
root.add_child(HingeJoint3D(body_a=post, body_b=door, anchor=hinge_pos, axis=Vec3(0, 1, 0)))
Like body properties, a joint reads its parameters once at enter-tree; change a parameter or a body reference by re-entering the joint.
An anchor is a world-space point when you declare it, but it is captured into each body’s own frame at enter-tree, and rebuilt from that body’s current rotation on every solver pass. So the pivot goes where the body goes: 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. That is true on every backend, so a scene behaves the same way whichever one it resolves. Where the pure-Python solvers still fall short of the native ones is convergence and inertia, not anchoring: see Physics Backends.
Worlds and roots¶
PhysicsRoot (3D) and PhysicsRoot2D (2D) each own an isolated world for their
sub-branch. Nesting is allowed, and the innermost root wins for the bodies under
it, so a scene can run several independent worlds at once (different gravity,
independent stepping).
from simvx.core import PhysicsRoot, PhysicsRoot2D, Vec2, Vec3
space = self.add_child(PhysicsRoot(name="Space", gravity=Vec3(0, 0, 0)))
world = self.add_child(PhysicsRoot(name="World", gravity=Vec3(0, -18, 0)))
ui = self.add_child(PhysicsRoot2D(name="UI", gravity=Vec2(0, 0)))
PhysicsRoot defaults to Y-up gravity Vec3(0, -9.81, 0). 2D worlds are
coordinate-agnostic: many 2D examples work in screen pixels with +Y pointing
down, so gravity is a positive-Y Vec2. A body with no PhysicsRoot ancestor
resolves to a per-scene default world, which is fine for a single-world scene.
The world objects themselves (PhysicsWorld, Physics2DWorld) are a backend
seam you rarely touch directly; PhysicsRoot builds and steps one for you. Set
gravity per root via the gravity argument.
Fixed timestep¶
Physics advances on a fixed-step clock, so drive it from on_fixed_update(dt)
(there is no separate physics tick to override). Node on_fixed_update hooks run
first each step, then every world is advanced once. Set the rate with the
application’s physics_fps:
from simvx.graphics import App
App(title="Physics", width=1280, height=720, physics_fps=60).run(GameScene())
Backends and accuracy¶
The default backend is BuiltinPhysics, a pure-Python solver that runs
everywhere including the browser with no extra dependency. It is a capable
rigid-body simulator, but some cases are approximate: it has no inertia tensor
(off-centre and elongated bodies rotate more freely than a full solver would),
and it ignores collider rotation for boxes and hulls in the narrowphase and
sweeps. Character behaviour is NOT one of the approximations: the movement policy
is one shared implementation over a per-backend sweep primitive, so it is the same
on every backend.
Builtin does not model rolling¶
The most visible consequence of having no inertia tensor is that the Builtin solver does not roll anything. Its contact friction is a linear tangential impulse at the centre of mass, with no contact-point lever arm, so a contact can neither start a spin nor slow one down. A ball pushed along the floor therefore slides and is braked by friction, rather than rolling, and any spin it already has is never worn away.
The difference is large enough to design around. push(at=...) diverges for the
same reason: the off-centre lever arm is scaled by inverse mass as a stand-in for
inverse inertia, so the resulting spin is far smaller than a real solver’s.
Boxes and general props are much closer, because sliding is exactly what Builtin does model. If rolling props are central to your game, choose Jolt for those scenes.
This is a solver-tier difference, and it is the only one left in how far a pushed body travels. Damping and gravity scale used to add a second one on top of it, because neither was stated at the seam; both are now, so a body with nothing touching it coasts the same distance on every backend. Measured over thirty seconds at the default damping: the two built-in tiers and Jolt agree to four decimal places (155.2724 m each), and pymunk comes in 0.083% further (155.4019 m) because Chipmunk integrates position one step behind. That lag is worth about that much on any moving body and is not a damping difference.
Once the body is touching something, friction enters and the tier difference above is what you are left with, so put a number on it before choosing. A 10 m/s push, twenty seconds, Builtin against Jolt:
Body |
friction 0.02 |
friction 0.05 |
friction 0.1 |
|---|---|---|---|
Sliding box |
0.04% |
0.13% |
0.29% |
Rolling sphere |
6.6% |
36% |
59% |
A box agrees closely at any friction, because sliding is what Builtin models. A sphere does not, and the gap grows with friction, which is this section’s subject rather than a knob: the more grip there is, the more a full solver turns sliding into rolling and stops paying friction for it, while Builtin keeps sliding and keeps being braked. Do not read a single divergence figure as a property of the backend pair – it is a property of the scene’s friction.
For oriented collision, stable stacking, and high body counts, the optional native Jolt backend is a per-root, opt-in swap that mirrors this exact API. See Physics Backends for selecting and installing it.
See also¶
Physics Backends: choosing and installing the Builtin or Jolt backend.
examples/features/3d/:collision_world.py(bodies + raycasting),raycast.py,joints.py.examples/features/2d/:collision_shapes.py,area2d.py,joints.py.examples/features/physics/:character_platformer.py(character controller),character_presence.py,playground3d.py,playground2d.py.examples/demos/:physics_sandbox.py,waterfall.py(Jolt showcase).