Physics¶
SimVX has one physics stack for rigid bodies, character bodies, sensor zones, joints and spatial
queries, in 2D and 3D. Every 3D class has a 2D sibling of the same shape (PhysicsBody3D /
PhysicsBody2D, Area3D / Area2D, and so on), so the patterns below transfer by swapping the
suffix and the vector type.
Two ideas carry the model:
A
PhysicsRoot(orPhysicsRoot2D) owns an isolated simulation world for its branch of the tree. Bodies under it simulate in that world.A body uses the world of its nearest
PhysicsRootancestor, or a per-scene default world when there is none. In the simple case you add aPhysicsRoot, put bodies under it, and the fixed-step loop advances them.
Every node with a body in a world (PhysicsBody3D, CharacterBody3D, Area3D and their 2D
siblings) derives from PhysicsObject3D / PhysicsObject2D, which is what an area’s
body_entered and a query hit hand you.
This page is the guide. Backend choice, capability differences and the full knob reference are in
Physics Backends. All names here are importable from simvx.core.
Bodies and modes¶
PhysicsBody3D (and PhysicsBody2D) is the one rigid-body node. Its mode is a BodyMode:
Mode |
Behaviour |
|---|---|
|
Simulated: gravity, impulses and contacts move it. Uses |
|
Immovable collider (floors, walls). Never integrated. |
|
Moved by code with |
mode can change at runtime (freeze a settled body to STATIC, wake a ragdoll to DYNAMIC), and
the flip keeps the body’s mass. mass is in kilograms and is clamped into (0.001, 100000).
A body needs collision geometry: the shape property for a single collider, or CollisionShape3D
children for a compound one (shape wins if both are present). A body with neither stays inert.
from simvx.core import BodyMode, BoxShape3D, PhysicsBody3D, PhysicsRoot, SphereShape3D, Vec3
root = self.add_child(PhysicsRoot(name="World")) # Y-up, gravity (0, -9.81, 0)
root.add_child(PhysicsBody3D(mode=BodyMode.STATIC, position=Vec3(0, -0.5, 0),
shape=BoxShape3D(half_extents=Vec3(20, 0.5, 20))))
root.add_child(PhysicsBody3D(mass=2.0, position=Vec3(0, 10, 0), shape=SphereShape3D(0.5)))
Every body property (mode, mass, shape, material, collision_layer, collision_mask,
continuous, can_sleep, linear_damping, angular_damping, gravity_scale) takes effect live.
The body is never rebuilt for a value change, so it keeps its pose, velocity and joints. A write
wakes the body, and a write that can take support away (mode, mass, shape, the collision
filter) also wakes whatever rests on it. Swapping shape is a teleport of the geometry: any new
overlap is pushed apart over the next steps. A body cannot become shapeless.
continuous enables swept collision for fast bodies where the backend supports it
(Capability.CONTINUOUS; see Physics Backends).
Shapes and materials are immutable values: to change one, assign a new one.
from dataclasses import replace
body.material = replace(body.material, friction=0.1)
Parenting a body under a moving node¶
A body’s pose has one owner, and the mode says who:
A
STATICbody follows its ancestors. Its pose is authored data, so moving a parent carries the collider with the sprite. Use this for authoring: a level chunk assembled under one origin, an obstacle nudged in the editor.A
DYNAMICorKINEMATICbody does not. The simulation owns its pose; a parent’s move stops at the node’s transform.
So a platform that must carry or push bodies at runtime is not a parent of them. Make it a
KINEMATIC body and move it with move_and_collide: a STATIC pose write is a teleport with no
velocity, so riders would be left behind.
Collision shapes¶
Shapes are geometry resources, not nodes. Put one on a body with shape= or on a
CollisionShape3D / CollisionShape2D child. A shape owns its backend collider, so one resource
shared across many bodies costs one collider. Share the resource, not the numbers: two resources of
the same size are two colliders.
3D shape |
Arguments |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
2D shape |
Arguments |
|---|---|
|
|
|
|
|
|
|
a line segment |
|
|
|
|
A CollisionShape3D with pickable=True receives CPU mouse picking (on_picked) through its
bounding sphere; see Input. Picking is off by default and costs nothing when unused.
Scale¶
A body collides at its node’s world_scale: a node scaled to 4 has a collider four times the size.
Scale belongs to the body, not the shape (a shape is shared), so a CollisionShape3D child’s own
scale does not resize the collider. CollisionShape3D.collider_scale reports the scale the
simulation applies.
Collider |
Scale |
|---|---|
box / rectangle, convex hull / polygon, triangle mesh / segment soup |
per axis |
cylinder |
free along Y, one factor across X and Z |
sphere, circle, capsule, segment |
uniform only |
A scale a collider cannot express raises ValueError naming the fix (scale uniformly, or use a box
or hull). Negative components mirror and count as uniform, so a scale.x = -1 sprite flip is fine.
A change to a parent’s scale does not reach a child body’s collider until something writes that body’s pose.
Materials¶
A PhysicsMaterial holds the surface coefficients for contacts. Share one instance across bodies
for a shared surface.
from simvx.core import PhysicsMaterial
rubber = PhysicsMaterial(friction=0.9, restitution=0.8)
ice = PhysicsMaterial(friction=0.05, restitution=0.0)
grippy = PhysicsMaterial(friction=0.9, friction_combine="max")
Field |
Default |
Meaning |
|---|---|---|
|
|
Coulomb friction coefficient (0 or more) |
|
|
Bounciness, 0 to 1 |
|
|
How the two bodies’ friction combines at a contact |
|
|
How restitution combines, independently |
CombineMode is AVERAGE, MIN, MAX or MULTIPLY, accepted by name ("max", "maximum",
any case). When two materials ask for different modes, MAX > MIN > MULTIPLY > AVERAGE, so a
deliberately grippy or bouncy surface dominates a neutral one.
Damping, gravity scale and world settings¶
Surface coefficients live on the material, per-object dynamics on the body, and settings for the whole space on the world. Defaults, and how each backend maps these, are in Physics Backends.
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 how fast a body sheds motion with nothing touching it, applied each step as
v = v * max(0, 1 - damping * dt) + a * dt;0coasts forever.gravity_scalemultiplies the world’s gravity for one body:0ignores it, a negative value falls upward.
World settings are on the world a PhysicsRoot owns:
world = self.node_at("World").world
world.gravity = Vec3(0, -3.7, 0) # Mars
world.solver_iterations = 24 # steadier tall stacks, at a cost while bodies are awake
world.position_iterations = 8 # tighter joint chains on the built-in solvers
world.sleep_time_threshold = 0.25 # park settled bodies sooner
world.sleep_velocity_threshold = 0.02
world.contact_slop = 0.001 # overlap left uncorrected
Forces, impulses and velocity¶
A body’s velocity is read and written directly:
ball.velocity += Vec3(0, 5, 0) # linear velocity, units/s
ball.spin = Vec3(0, 2, 0) # angular velocity, radians/s (3D)
Impulses apply at once; forces accumulate for the next fixed step and are then cleared, so a
sustained force is re-applied every on_fixed_update:
ball.push(Vec3(0, 20, 0)) # 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)) # angular impulse
def on_fixed_update(self, dt):
ball.add_force(Vec3(0, 0, -12))
ball.add_torque(Vec3(0, 1, 0))
All four do nothing on STATIC and KINEMATIC bodies, or on a body that is not in the tree.
Sleeping¶
A dynamic body that stops moving falls asleep: it is skipped until something disturbs it, which lets a large resting scene cost almost nothing.
crate.is_sleeping # True once it has settled
crate.wake() # simulate it again now
crate.sleep() # park it now
crate.can_sleep = False # never park it
A pile sleeps and wakes as a unit. No body in a touching group parks until all of them are at rest, and a change that takes support away wakes the whole pile, so pulling out the bottom crate brings the stack down.
STATICandKINEMATICbodies join no pile, so two stacks on one floor settle independently. A body withcan_sleep = Falsekeeps everything touching it awake.A sleeping body is frozen: it reads back the same pose every step, so a save or screenshot of a settled scene is stable.
Every write that changes what the solver reads wakes the body. For a write that is bookkeeping (streaming a level in, respawning a pooled object), pass
wake=Falseon the world call. It exists on the five calls that can take support away:set_body_transform,set_body_mode,set_body_mass,set_body_filterandset_body_shape.
crate.world.set_body_transform(crate.handle, (Vec3(0, 40, 0), Quat()), wake=False)
set_body_mode(..., wake=False) to DYNAMIC hands the body over parked: dynamic, asleep, velocity
zeroed, until something reaches it. A node does the same with its own calls, which keeps its mode
Property in step:
crate.mode = BodyMode.DYNAMIC # this write wakes it
crate.sleep() # so park it again before the next step
Sleeping needs Capability.SLEEP; on the pymunk backend nothing sleeps. See
examples/features/physics/sleeping.py.
Contacts and signals¶
Every body emits collided when it starts touching another and separated when it stops. The
payload is a Contact (3D) or Contact2D (2D): other is the peer body, and 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()
Prefer these signals to polling for overlaps.
A destroyed body is still named. A body destroyed while touching something sends its peer
separated (and an overlapping area body_exited) on the next step, naming the detached node:
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 moved away
Its tree, handle and world are None and its properties are still readable. Read what you
need and drop the reference. Both destroy() and remove_child() deliver the event.
Impact strength¶
Field |
Meaning |
Where |
|---|---|---|
|
the normal impulse the solver applied |
backends with |
|
the impulse that would arrest the approach, from the masses and the two bodies’ linear velocities |
every backend, same formula |
|
relative velocity at the contact point, spin included |
every backend |
impulse_estimate is the portable number for impact sounds and hit damage. It ignores restitution
and is 0.0 for a pair that is not closing, so it measures impacts, not load. None from
impulse is not 0.0, which is a real contact resolved with no push. Check support once at setup:
from simvx.core.physics.capability import Capability
has_impulse = Capability.CONTACT_IMPULSE in self.world.world.capabilities() # self.world: a PhysicsRoot
def _on_hit(self, contact):
self.play_thud(volume=min(1.0, contact.impulse_estimate / 10.0))
Porting from Unity: Collision.relativeVelocity is the difference of the two linear velocities,
while contact.velocity is measured at the contact point, so the two differ once a body spins.
collided arrives after the step, when the impact has already been arrested; for the
pre-impact speed along the normal against static geometry use impulse_estimate / mass, or
snapshot velocity in on_fixed_update.
Character bodies¶
CharacterBody3D / CharacterBody2D is a KINEMATIC body with a swept movement helper. It
has the same collision_layer and collision_mask as any body, so queries and areas see it,
dynamic bodies rest on it, and two characters block each other. Set velocity and call
move_and_slide(dt): it moves the body, slides the velocity along what it hits, and updates the
grounded state.
from simvx.core import Axis1D, CharacterBody2D, Input, InputContext, Key, RectangleShape2D, Vec2
class Player(CharacterBody2D):
input_context = InputContext({"walk": [Axis1D(low=Key.A, high=Key.D)],
"jump": [Key.SPACE]}, name="player")
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_fixed_update(self, dt):
vx = Input.value("walk") * 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.just_pressed("jump") and self.is_on_floor():
vy = -450.0
self.velocity = Vec2(vx, vy)
self.move_and_slide(dt)
Property |
Default |
Meaning |
|---|---|---|
|
|
steepest walkable slope, degrees; also the ceiling threshold |
|
|
highest step it climbs |
|
|
collide-and-slide iterations per move |
|
|
clearance the sweep leaves |
|
|
kg, used only for pushing |
|
|
how hard it shoves what it walks into; |
All are read per move, so they can change mid-game (a crouch lowering step_height). After a move,
is_on_floor(), is_on_wall(), is_on_ceiling() and floor_normal report what was hit.
up_direction classifies floor, wall and ceiling. velocity is a plain attribute: the character
is moved by move_and_slide, not by the integrator.
Pushing¶
At the default push_factor = 1 a character walking into a crate shoves it. Each blocking contact
except floor contacts and immovable bodies receives
push_factor * (M * m / (M + m)) * approach_speed along the normal (M the character’s mass, m
the crate’s), the impulse of a perfectly inelastic collision, so a crate never leaves faster than
the character approached. Standing on a dynamic body imparts nothing to it. A character carries
nothing: a body resting on it is not dragged along.
class Player(CharacterBody3D):
def __init__(self, **kwargs):
super().__init__(mass=80.0, push_factor=0.0, **kwargs) # blocked by crates like walls
For custom responses, collisions holds the blocking contacts of the last move as SweepHits
(body, point, normal, distance):
for hit in self.collisions:
self.world.apply_impulse(hit.body, -hit.normal * 3.0, at=hit.point)
Layers in a character-dense scene¶
A sweep pays for every body its mask matches, so give projectiles, pickups and NPCs their own
collision_layer and a collision_mask naming only what they should collide with (0 for
nothing).
Kinematic movement¶
A KINEMATIC PhysicsBody3D moves with move_and_collide(velocity, dt=1.0): one sweep along
velocity * dt that stops at the first blocker, updates the node at once, and returns the
SweepHit (or None if it went the full distance). It does not slide or fall. Sensors, the mover
itself, pairs the layers reject and surfaces the mover is moving away from never block it.
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 detects bodies passing through it and applies no collision
response. It fires body_entered / body_exited with the peer body and area_entered /
area_exited with the peer area, and keeps 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)
mobs = zone.get_overlapping_bodies(group="mobs") # also type=; both filters AND
areas = zone.get_overlapping_areas()
An area detects a body when area.collision_mask & body.collision_layer is non-zero.
monitoring=False makes it inert. Sleeping and STATIC bodies are detected too, without waking
them; on Jolt an area whose collider is a ConcaveMeshShape3D is the exception (give a trigger
convex geometry).
GravityArea3D / GravityArea2D adds gravity to the dynamic bodies inside it: gravity is a
uniform acceleration, and point_gravity adds a pull toward the centre of strength
point_strength (default 9.81).
Spatial queries¶
Ray and shape queries go through node.physics (3D) or node.physics_2d (2D), bound to the node’s
world. Hits carry the scene node.
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 |
|
the overlapping body nodes |
node.physics resolves to the nearest PhysicsRoot ancestor. A node that creates a child
PhysicsRoot and queries self.physics queries the wrong world; query through the root:
self.world = self.add_child(PhysicsRoot(name="World"))
hits = self.world.physics.raycast_all(origin, direction, distance=60.0)
Layers and masks¶
Every body and area has a collision_layer (the layers it is on) and a collision_mask (the layers
it scans), 32-bit integers defaulting to layer 1. Two bodies collide only when each opts in:
(a.collision_mask & b.collision_layer) and (b.collision_mask & a.collision_layer)
Queries and areas use a one-sided rule: a query considers a body when
body.collision_layer & mask is non-zero.
Name layers with your own IntFlag; it is an int, so it stores and serialises 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, body_a and body_b. It resolves them when it enters the tree, so
add a joint after both bodies are in the world; both must be in the same world.
3D joint |
Constrains |
|---|---|
|
the full relative transform: the bodies move as one |
|
a shared world point; rotation stays free |
|
a pin plus one free rotation axis (default |
|
a soft distance spring; |
The 2D set mirrors it (FixedJoint2D, PinJoint2D, HingeJoint2D, SpringJoint2D) and adds
GrooveJoint2D, which keeps a point of one body on a segment of the other.
from simvx.core import HingeJoint3D, PinJoint3D, Vec3
root.add_child(PinJoint3D(body_a=anchor, body_b=bead, anchor=anchor.world_position))
root.add_child(HingeJoint3D(body_a=post, body_b=door, anchor=hinge_pos, axis=Vec3(0, 1, 0)))
A joint reads its parameters when it enters the tree; re-enter it to change one. The anchor is
captured into each body’s own frame, so a pin on a spinning platform orbits with it on every
backend. Joints have no motors, limits or breaking; drive a mechanism with add_torque. How firm
joints are differs by backend: see Physics Backends.
Worlds and roots¶
Roots can nest, and the innermost wins, so a scene can run several independent worlds:
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 gravity Vec3(0, -9.81, 0). 2D worlds have no preferred axis; a game in
screen pixels with +Y down uses a positive-Y gravity. PhysicsRoot(backend=...) picks a backend
for that branch.
Fixed timestep¶
Physics advances on a fixed-step clock: put physics code in on_fixed_update(dt). Node hooks run
first each step, then every world advances once. The application’s physics_fps sets the rate
(60 by default):
App(title="Physics", physics_fps=120).run(GameScene())
Choosing a backend¶
The default 3D backend, BuiltinPhysics, is pure Python and runs everywhere, browser included. Its
main limits are no inertia tensor (so it does not model rolling: a pushed ball slides) and box and
hull collision that ignores rotation. Sliding boxes and characters behave the same as on a native
solver. For rolling props, oriented collision, stable stacking and high body counts, select the
native Jolt backend per root with PhysicsRoot(backend="jolt"). In 2D, installing pymunk makes
Chipmunk2D the default. Physics Backends has the comparison.
See also¶
Physics Backends: selecting a backend, capability differences and the knob reference.
examples/features/3d/:collision_world.py,raycast.py,joints.py.examples/features/2d/:collision_shapes.py,area2d.py,joints.py.examples/features/physics/:character_platformer.py,character_presence.py,body_knobs.py,sleeping.py,playground3d.py,playground2d.py.examples/demos/:physics_sandbox.py,waterfall.py.