Physics
pfx-physics holds what the apps simulate: SPH fluid, ripples and Verlet rope, flocks and schools, and wind, all on the GPU or in plain Verlet steps. It also holds the 3D rigid bodies that games need, in the rigid module, described below.
Rigid bodies
Section titled “Rigid bodies”pfx_physics::rigid simulates 3D rigid bodies with collisions. It is built on rapier3d 0.36.0 and parry3d 0.31.1, both pinned exactly, behind the engine’s own types: positions are [f32; 3], rotations [x, y, z, w] quaternions, and bodies and colliders are BodyId and ColliderId. No rapier type appears in the API, so the backend can change without touching a caller.
It is compiled when the physics crate’s rigid feature is on, which it is by default. An app that wants none of it depends on the crate with default-features = false. The enhanced-determinism feature turns on rapier’s and parry’s cross-platform determinism, for runs that must match across machines.
The world
Section titled “The world”use pfx_physics::rigid::*;
let mut world = World::new(WorldDesc { rate: 60.0, substeps: 1, iterations: 4, gravity: [0.0, -9.81, 0.0], ccd: true,});let floor = world.add_body(BodyDesc::fixed(Pose::at([0.0, -0.5, 0.0])));world.add_collider(floor, ColliderDesc::cuboid([50.0, 0.5, 50.0]));let card = world.add_body(BodyDesc::dynamic(Pose::at([0.0, 1.0, 0.0])).with_locks(Locks::PLANE_XY));world.add_collider(card, ColliderDesc::round_box([0.5, 0.05, 0.35], 0.02));world.step();let state = world.body(card);World::stepadvances one fixed step of1 / rateseconds (60 Hz by default), split intosubstepsequal solver steps. The caller decides how many steps to take for the time that passed, for example withpfx_core::motion::Stepper; the world never reads a clock.WorldDesc::iterationsis the solver’s iteration count per substep.WorldDesc::ccdis the world’s continuous-detection switch (see Colliders).gravity/set_gravityis the world gravity vector;time()andsteps()count the steps taken.
Bodies
Section titled “Bodies”BodyDesc::dynamic, kinematic and fixed start a body at a Pose:
| field | meaning |
|---|---|
kind |
Dynamic (simulated), Kinematic (moved by its pose, pushes dynamic bodies), Fixed |
linear_velocity, angular_velocity |
the starting velocities |
gravity_scale |
how much world gravity and gravity sources act on it (1 by default) |
linear_damping, angular_damping |
velocity damping |
locks |
translation and rotation axes held fixed, per axis |
ccd |
continuous detection against every body, for fast projectiles |
can_sleep, asleep |
whether it may sleep, and whether it starts asleep |
mass |
Some(Mass { mass, center, inertia }) sets mass, centre and principal inertia; None derives them from the colliders’ densities |
On a body: set_pose, set_velocity, apply_impulse, apply_impulse_at (a world point), apply_torque_impulse, add_force, add_force_at, add_torque, set_gravity_scale, set_damping, set_ccd, set_locks, sleep, wake, remove_body. Impulses change the velocity at once. Forces and torques act through the whole of the next step and are then cleared, so a steady force is added every step. body returns a BodyState with the pose, both velocities and whether it sleeps; mass and is_asleep answer directly.
Locked axes
Section titled “Locked axes”Locks { translation: [bool; 3], rotation: [bool; 3] } locks any of the three world translation axes and three world rotation axes; Locks::PLANE_XY keeps a body in its z plane turning only about z, and Locks::ROTATION stops all turning. The solver treats a locked axis as having infinite mass, and after every substep the world puts the locked coordinates back exactly where they were when the body was made, posed with set_pose or given new locks with set_locks, and zeroes the locked velocity components. A locked translation coordinate never changes by a single bit, and with one free rotation axis the rotation stays a pure turn about it from that anchor (with an identity anchor the other quaternion components stay exactly zero; tested over 10,000 steps of collisions, impulses and spin). With only one rotation axis locked, the angular velocity about it is held at zero.
Gravity sources
Section titled “Gravity sources”Any number of Sources act on every awake dynamic body, scaled by its gravity scale and sampled at its centre of mass. Each substep starts with their acceleration given to the body as a change of velocity, so the integration is symplectic Euler and an orbit stays bounded (a circular orbit held within 5% of its radius over ten turns, tested). add_source and set_gravity wake every sleeping body; changing a source through source_mut does not, so call wake for the bodies it should reach. There are two kinds:
Source::Point { center, strength, falloff, range, softening }accelerates towards the centre bystrength(negative repels) times1,1/dor1/d²(Falloff::Constant,Inverse,InverseSquare), withinrange, with the distance held at leastsoftening.Source::attractor(center, strength)is an inverse-square point with no range limit.Source::Zone { region, gravity, replace }applies inside aRegion::Sphereor a posedRegion::Box. A replacing zone takes the place of world gravity inside it (the last added wins where they overlap); an adding zone adds to it.
add_source returns a SourceId for source_mut and remove_source.
Colliders
Section titled “Colliders”add_collider(body, ColliderDesc) attaches a shape at an offset pose and returns its ColliderId, or None when the body is gone or a hull is degenerate.
Shape::Sphere,CylinderandCapsule(along y),Disc(a thin cylinder by radius and thickness),BoxandRoundBox(outer half extents and a corner radius, for cards) andHull(the convex hull of points).densitygives the mass when the body’s mass isn’t given.friction: Friction { still, sliding, combine }has a static coefficient, used while the contact’s tangential speed is underFriction::SLIP(0.01 m/s), and a dynamic one above it.restitutionandrestitution_combineset the bounce. A pair combines with the stronger of the two rules, in the orderAverage,Min,Multiply,Max.sensormakes the collider report overlaps without touching anything; sensors see every kind of body.layers: Layers { member, mask }: two colliders meet when each one’s member bits meet the other’s mask.impulse_thresholdturns onContactImpulseevents for the collider’s contacts whose impulse in a substep passes it.
Continuous detection has two tiers. With WorldDesc::ccd on, any dynamic body fast enough to tunnel is swept against fixed colliders automatically. A body with ccd set is swept against kinematic and dynamic bodies too. With the world switch off, nothing is swept.
Kinematic motion
Section titled “Kinematic motion”A kinematic body moves to a target pose each step and pushes the dynamic bodies it meets; substeps move it there in equal parts.
move_to(body, pose)sets the target for the next step.set_drive(body, Some(Drive::Spring(..)))follows apfx_core::motion::Spring<[f32; 3]>: change itstargetthroughdrive_mutand the body follows the spring’s value, keeping its rotation.Drive::ease(from, to, duration, curve)eases position and rotation from one pose to another overdurationseconds through anyfn(f32) -> f32, such asmotion::cubic_in_out.drive(body)reads the drive back;settled()says when it has arrived.
Queries
Section titled “Queries”Queries see the colliders as step left them, and as refresh_queries() leaves them: it moves each collider to its body’s current pose and rebuilds the query structure without stepping, so a collider added or a body placed with set_pose shows in queries at once, before any step. A Filter { mask, sensors, exclude } keeps colliders whose member bits meet mask (the query’s own mask decides, whatever the collider’s collision mask is, so a layer that collides with nothing is still found), skips sensors unless sensors is set, and can leave out one body.
raycast(origin, direction, max_distance, filter)returns the firstHit { collider, body, distance, point, normal }.shape_cast(shape, pose, direction, max_distance, filter)sweeps a shape and returns the firstHit, with the point on the swept shape at impact.point_overlaps(point, filter)andshape_overlaps(shape, pose, filter)return the colliders that contain the point or overlap the shape, sorted by id.
Events
Section titled “Events”events() lists what happened during the last step; the next step clears it.
ContactStarted { a, b, impulse }: two colliders started touching, with the total normal impulse their contact applied during the rest of that step.ContactStopped { a, b }.ContactImpulse { a, b, impulse }: the step’s summed impulse of a contact that passed a collider’simpulse_threshold.SensorEntered { sensor, other }andSensorExited { sensor, other }.
body_of(collider) gives the body a collider belongs to.
Determinism
Section titled “Determinism”The same world built with the same calls in the same order, stepped the same number of times, gives bit-identical poses, velocities, sleep states and events on one machine and one build. The wrapper walks its bodies in index order, runs on the calling thread and never reads the clock or system randomness, and rapier is deterministic for the same inputs in the same order without its parallel feature, which stays off. Two worlds stepping a scene of 43 bodies with a gravity source, a locked card, an eased kinematic sweeper and impulses for 1,000 steps compare equal bit for bit (rigid::tests). For runs that must match across machines, build with the enhanced-determinism feature, which makes rapier and parry use cross-platform floating-point paths.
500 spheres of 5 cm radius with CCD, thrown down at 10 m/s into a 1.6 m box at 60 Hz, cost 0.78 ms per step on the CPU while they fall and pile up (the first second), and 0.18 ms per step over the next nine seconds as they settle and sleep (one thread, release build, Ryzen 9 9950X, 2026-10-05). cargo test --release -p pfx-physics --lib five_hundred_spheres_timed -- --ignored --nocapture prints it; the debug build in the gate takes about 23 ms per step for the same scene.