Skip to content

3D physics features

Crate sparklight-physics-fx (crates/sparklight-physics-fx) puts game-grade physics features on top of rapier3d 0.22: joints, spatial queries, ragdolls, destruction, a character controller, raycast vehicles, ropes and chains, and position-based cloth. Everything is data plus plain Rust APIs: ragdoll profiles and destructible assets are versioned JSON with schemas, every feature runs headless under a fixed step, and the same inputs give the same state hash. Design background: docs/AAA_ROADMAP.md (physics rows), docs/AGENT_FIRST.md. Open integration work (Space3D components and commands) is listed at the end.

Units are metres, kilograms, seconds, radians (ragdoll profiles use degrees, noted per field). Y is up. Positions are glam::Vec3, rotations glam::Quat; JSON writes them as [x, y, z] and [x, y, z, w].

use glam::Vec3;
use sparklight_physics_fx::*;
let mut world = PhysicsWorld::new(WorldConfig::default()); // 60 Hz, 8 solver iterations
world.spawn(BodyDesc::fixed(Vec3::new(0.0, -0.5, 0.0)), ColliderDesc::cuboid(Vec3::new(50.0, 0.5, 50.0)))?;
// A ragdoll from a skeleton and a profile, dropped from 3 m.
let skeleton = SkeletonDesc::humanoid(1.8);
let mut ragdoll = Ragdoll::build(&mut world, &skeleton, &RagdollProfile::humanoid(), Pose::from_position(Vec3::new(0.0, 3.0, 0.0)))?;
for _ in 0..600 {
ragdoll.drive(&mut world, None)?; // before the step (Dynamic mode needs no pose)
world.step(); // exactly 1/60 s
}
let bones = ragdoll.pose_world(&world); // world transform per bone, for skinning
println!("state hash {:016x}", world.state_hash());
# Ok::<(), FxError>(())

Per frame, in this order: ragdoll.drive, character.step, vehicle.step, world.step, then destructible.apply_contact_events and destructible.update, cloth.step. A runnable scene with all of it:

cargo run --release -p sparklight-physics-fx --example headless_demo -- [frames] [seed]

It writes trace.json (per-frame summary, final hashes) and frame.svg (a side-view capture) to .gwdev/out/physics-fx/ cargo run --release -p sparklight-physics-fx --example physics_bench measures the cost of each feature on the machine and writes bench.json next to it.

  • One binary on one machine: bit-identical results for the same inputs (tested for the world, ragdolls, destruction, the character controller, vehicles, ropes and cloth by running twice and comparing hashes).
  • Across machines and operating systems (an arm64 Mac against x86_64 Linux; Windows is not verified, see below): build with the facade feature deterministic3d (sparklight-scene3d/deterministic, sparklight-physics-fx/deterministic). Verified arm64 macOS against x86_64 Linux by pinned hashes (the det3d_ tests). Without it every 3D hash holds for one binary on one machine only: on a Mac and a PC the ragdoll demo differs after its first second.
  • Randomness only comes from seeds (sparklight_core::Rng); iteration orders are fixed (arena order, BTreeMap).
  • PhysicsWorld::state_hash() hashes every body pose and velocity; Destructible::state_hash() and Cloth::state_hash() hash their own state. PhysicsWorld::hash_into(&mut StateHasher) lets you fold several into one value.

PhysicsWorld owns the rapier sets. Handles (BodyHandle, ColliderHandle, JointHandle) are small Copy types with .raw() -> (index, generation) for traces.

Area Calls
Create add_body(BodyDesc), add_collider(body, ColliderDesc), spawn(body, collider), remove_body, remove_collider
Shapes (ShapeDesc) Box, Sphere, Capsule (Y axis), Cylinder, Cone, ConvexHull{points}, TriMesh{vertices, indices} (static only). Invalid sizes give E_FX_SHAPE_INVALID
Layers CollisionLayers{membership, filter}: two colliders interact when each one’s membership intersects the other’s filter. The same masks drive queries
State pose, next_pose, linear_velocity, angular_velocity, mass, world_center_of_mass, velocity_at_point, kinetic_energy, is_sleeping
Drive set_pose (teleport), set_next_kinematic_pose, set_linear_velocity, apply_impulse, apply_impulse_at_point, apply_torque_impulse, add_force (persists until reset_forces)
Time step() (exactly dt), advance(frame_dt) (accumulator, at most max_steps_per_advance steps), step_count, time
Events drain_events() returns PhysicsEvent::{CollisionStarted, CollisionStopped, ContactForce{force, point}, JointBroken}; collider flags collision_events and contact_force_threshold switch them on
Presets WorldConfig::fast() (4 solver iterations), default() (8), precise() (16)

Solver iterations are the main quality dial for joints (see the measured table below).

world.add_joint(body1, body2, &JointDesc). Anchors and frames are local to each body; the joint frame X axis is the hinge or slider axis.

Constructor Free axes
JointDesc::fixed(a1, a2) none
revolute(axis, a1, a2) rotation about the axis
prismatic(axis, a1, a2) translation along the axis
spherical(a1, a2) / cone_twist(a1, a2, twist, swing_y, swing_z) three rotations (cone twist limits them)
generic([bool; 6], a1, a2) any of LinX..AngZ (JointAxisId)

Builders: .with_limit(axis, min, max), .with_motor(axis, MotorDesc::velocity(target, strength, max_force) | MotorDesc::position(target, stiffness, damping, max_force)), .with_break_force(N), .with_break_torque(Nm), .with_contacts(bool) (default off: the two bodies do not collide), .with_frames(q1, q2). A joint over its break limit is removed and a PhysicsEvent::JointBroken is raised. joint_load(joint) gives the current force and torque, joint_anchor_error(joint) the gap between the anchors (drift), set_joint_motor retargets a live motor.

All take a QueryFilter (layers mask, excluding_body, excluding_collider, with_sensors(), only_dynamic(), only_fixed()). Sensors are ignored unless asked for.

Call Result
raycast(origin, dir, max_distance, &filter) nearest RayHit{collider, body, distance, point, normal}; a ray starting inside a solid hits at distance 0
raycast_all(...) every hit, nearest first
shape_cast(&ShapeDesc, pos, rot, dir, max_distance, &filter) first ShapeHit{distance, point, normal, ...}
overlap(&ShapeDesc, pos, rot, &filter) / overlap_bodies colliders / distinct bodies, sorted by handle
closest_point(point, &filter) ClosestPoint{point, distance, inside, ...}
point_overlap(point, &filter) colliders containing the point

After moving bodies by hand without a step, call refresh_queries() first.

Files: schemas/ragdoll.schema.json (profile and optional embedded skeleton). Code: src/ragdoll.rs.

Skeleton (SkeletonDesc, from the animation system or hand written): bones listed parents first, each with name, parent (null for the single root), local_position, local_rotation (rest pose relative to the parent), axis (the direction the bone points in its own space, default +Y), length, radius. SkeletonDesc::humanoid(height) is a 16-bone humanoid (pelvis, spine, chest, head, arms, legs; arms down, facing +Z). validate() reports E_FX_RAGDOLL_BONE_PARENT, E_FX_RAGDOLL_BONE_DUPLICATE, E_FX_RAGDOLL_ROOT, E_FX_RAGDOLL_BONE_DIMENSIONS, E_FX_RAGDOLL_EMPTY.

Profile (RagdollProfile, JSON): total_mass, friction, restitution, damping, no_collide_distance, layers, follow_time and one entry per bone: shape (capsule | sphere | box), mass_fraction (bones without one share the rest by volume), joint (ball {twist_deg, swing_deg} | hinge {axis, limits_deg} | fixed) and strength (default blend strength). RagdollProfile::humanoid() has anatomical limits (knees [0, 140], elbows [-145, 0], …). Errors: E_FX_RAGDOLL_VERSION, E_FX_RAGDOLL_MASS, E_FX_RAGDOLL_LIMIT, E_FX_RAGDOLL_PROFILE_BONE.

{ "version": 1, "name": "humanoid", "total_mass": 75.0, "friction": 0.8, "restitution": 0.0, "linear_damping": 0.05,
"angular_damping": 0.6, "no_collide_distance": 2, "follow_time": 0.06,
"bones": [ { "bone": "forearm_l", "shape": {"type": "capsule"}, "mass_fraction": 0.02,
"joint": {"type": "hinge", "axis": [1, 0, 0], "limits_deg": [-145, 0]}, "strength": 0.5 } ] }

Build and run: Ragdoll::build(&mut world, &skeleton, &profile, root_pose) makes one rigid body per bone (origin at the joint, collider along the bone), limited joints between them, and no collision between bones closer than no_collide_distance in the tree. Modes (set_mode):

Mode Bones drive(&mut world, target)
Kinematic follow the pose exactly and push other bodies needs a world-space SkeletonPose
Dynamic pure physics target ignored (None)
Blended each bone chases the pose with strength in [0, 1] (1 = animation, 0 = physics); set_strength(bone, s) needs a pose

Leaving Kinematic gives every bone the velocity of its last motion (momentum carries into the physics). Hit reactions: hit(&mut world, point, impulse, radius, recover_time) applies the impulse to the nearest bone (a share to bones within the radius), drops their animation strength and recovers it over recover_time; a kinematic ragdoll switches to blended for the duration and goes back. apply_impulse_at_point is the plain version. Reading back: pose_world(&world) (world transforms per bone) and pose_local(&world, root, keep_rest_translations) for a skinning pipeline. Diagnostics: max_joint_error, kinetic_energy, potential_energy, center_of_mass, closest_bone.

Files: schemas/destructible.schema.json. Code: src/geometry.rs (mesh clipping), src/fracture.rs, src/destructible.rs.

Runtime Voronoi fracture: fracture(&TriMesh, &FractureConfig) -> DestructibleAsset. The mesh must be a closed, outward-wound triangle mesh (convex or not; TriMesh::cuboid / cylinder / sphere / convex_hull build common ones). Seeds come from a seeded generator: pattern is Uniform, Radial{center, radius, concentration} (denser near an impact) or Grid{cells, jitter} (bricks); relax_iterations evens out chunk sizes. Each Voronoi cell is cut out of the mesh with exact plane clipping and capped, so chunks are watertight meshes that tile the source mesh: chunk volumes sum to the mesh volume (measured relative error 3e-9 for a box, 1e-8 for an L-shaped non-convex prism and for cylinders). A non-convex mesh that a cell cuts into islands gives one chunk per island. The same seed and mesh give the same JSON bytes. anchor marks chunks that touch the ground (Plane{normal, offset}). Errors: E_FX_FRACTURE_MESH (open or inside-out mesh), E_FX_FRACTURE_CONFIG.

Asset format (DestructibleAsset, JSON, version 1): chunks (centroid, volume, vertices relative to the centroid, triangles with the first surface_triangles on the original surface and the rest interior cut faces), bonds (a, b, contact area, health = area times bond_health_per_area, center), anchors, density, damage and debris parameters. DestructibleAsset::from_json validates (E_FX_DESTRUCTIBLE_VERSION | _INDEX | _CHUNK | _BOND). Offline tools can write the same format (pre-fractured assets); there is nothing runtime-only about it.

Runtime: Destructible::spawn(&mut world, asset, origin_pose, root_kind, layers, seed). Anchored chunks form a fixed body; a structure without anchors is one body of root_kind. Then:

  • apply_damage(&mut world, point, radius, damage) and apply_impact(&mut world, &Impact): bonds within the radius take damage * (1 - (d / radius)^2); Impact::with_graph_decay(0.3) also spreads damage along the bond graph (a bond h bonds away takes damage * decay^(h+1), cut off below 2 percent); with_radial_impulse / with_directional_impulse push the pieces released by the impact.
  • Bonds with no health left break. Every group of chunks that lost its connection to an anchor becomes one dynamic body with a compound collider (debris), carrying the velocity of the body it came from (v + w x r). Single chunks are just one-chunk groups.
  • apply_contact_events(&mut world, &world.drain_events()) turns contact forces on the structure’s colliders into damage at the contact point (damage.impulse_to_damage * (force - threshold) * dt), so thrown objects break walls; shatter breaks everything.
  • Debris: debris.lifetime (seeded jitter), fade_time, max_active (the oldest piece is recycled when the budget is exceeded), sleeping pieces age faster. Call update(&mut world) once per step. debris() lists pieces with a fade factor (1 to 0) for scale or alpha.
  • Events (drain_events()): BondBroken, PiecesDetached{body, chunks}, DebrisExpired, DebrisRecycled.
  • Rendering: chunk_pose(&world, chunk) is the world pose of a chunk’s mesh; draw chunks[i].triangles with the outer material for the first surface_triangles and the inner material for the rest.

CharacterController::spawn(&mut world, centre_position, CharacterConfig); per step before world.step(): let state = controller.step(&mut world, CharacterInput{move_velocity, jump}). Kinematic capsule (rapier’s shape sweeps) mirrored into a kinematic body so crates are pushed and queries see it. Handles: walkable slopes up to max_slope and sliding down steeper ones, steps up to step_height (own up-forward-down test; slopes are not mistaken for steps), ground snapping (snap_distance), jumping with coyote time and jump buffering, head bumps, moving platforms (a kinematic platform’s next pose must be set before step; platform_yaw reports its rotation for the camera), pushing dynamic bodies. CharacterState has position, velocity, grounded, ground{collider, body, normal, slope}, sliding, jumped.

Full reference: docs/agent/vehicles.md (driving model, crash damage, crumpling meshes, detachable parts, components, commands, measurements). Vehicle::spawn(&mut world, VehicleDesc::sedan(), pose), vehicle.set_input(VehicleInput{throttle, brake, steer, handbrake, clutch, shift, gear, analog}), vehicle.step(&mut world) before world.step(); vehicle.telemetry() gives speed, RPM, gear, per-wheel slip, load, skid and surface. Raycast wheels with spring, asymmetric damper, bump stop and anti-roll bars, Pacejka tires with load sensitivity and a friction ellipse, an engine with torque curve, inertia and a clutch solved implicitly with the wheels, automatic or manual gearbox, open / limited-slip / locked differentials and a centre split, ABS and traction control, handbrake drifts, drag and downforce, keyboard and gamepad input shaping. VehicleDamage (damage.rs) turns contact-force events into dents, torn-off parts and system damage that feeds back into the driving model; DeformMesh (deform.rs) crumples a triangle mesh. PhysicsEvent::ContactForce carries the contact normal; PhysicsWorld::set_collider_surface tags colliders with a surface (grip and rolling resistance). VehicleDesc::validate() gives E_FX_VEHICLE_DESC, DamageDesc::validate() E_FX_DAMAGE_DESC, the mesh E_FX_DEFORM_DESC and E_FX_DEFORM_MESH.

Rope::build(&mut world, &RopeDesc::rope(start, end, segments)) or RopeDesc::chain(...): rigid links with ball joints (chain: boxes with a bend limit and almost no twist). Attach either end with attach_start / attach_end = Some(Attachment::new(body, local_anchor)) (a fixed body pins it to the world). break_force makes a rope that snaps (raises JointBroken), cut(&mut world, index) cuts it. points(&world) gives the node positions for drawing, max_stretch, max_tension, current_length diagnose it. Ropes share layer ROPE_LAYER and do not collide with each other. For heavy loads use WorldConfig::precise().

Cloth::new(ClothDesc), cloth.step(dt, &ClothEnv{wind, colliders, world}). Position-based (XPBD, substeps x iterations Gauss-Seidel passes in a fixed colour order): stretch, bend (across shared edges) constraints, pinned vertices (pin, unpin, set_pin_position for attachments that move), wind (aerodynamic drag per triangle), gravity, damping and collision with ClothCollider::{Sphere, Capsule, Plane, Box} and with any collider of a PhysicsWorld (closest-point queries through env.world = Some((&world, QueryFilter::default()))) with friction and a thickness margin. ClothDesc::grid(width, depth, nx, ny, centre) builds a sheet; any triangle mesh works. positions(), normals(), max_stretch() read it back. No self collision yet. Errors: E_FX_CLOTH_DESC.

Every error is FxError{code, message, path?, hint?}; to_value() is {code, message, path?, hint?} as everywhere in the engine. The list is sparklight_physics_fx::error::ERROR_CODES.

All numbers are produced by the tests (cargo test --release -p sparklight-physics-fx -- --nocapture) on the development Mac and are deterministic. Test counts: 75 in tests/ (world and joints 12, ragdoll 13, destruction 14, character 10, vehicle 8, rope 6, cloth 8, schemas 3, error codes 1).

Claim Measured
Ragdoll falls 3 m and lies still for 10 s peak joint gap 9.9 mm at impact, 0.26 mm after 6 s; kinetic plus potential energy never above the start (2790.9 J of 2791.0 J); end KE 0.001 J
Ragdoll hits a wall at 25 m/s peak KE 23.7 kJ (the start energy); no bone leaves the arena; joints close to under 2 mm afterwards. Peak gap by solver iterations 4 / 8 / 16: 170 / 64 / 7 mm
5 ragdolls dropped in a pile for 7 s peak joint gap 51 mm, 0.07 J left at the end
Knee hinge limits 0 to 140 degrees reached -3.6 to 141.0 degrees under 60 N m impulses
Kinematic mode worst bone error 0 m over a walking and waving pose
Blended strength 0.3 / 0.7 / 1.0 mean bone error 46 / 34 / 32 mm against a walking pose (strength 0 falls away)
Hit reaction (300 N s on the chest) chest displaced 0.33 m, back to kinematic after 48 steps
Fracture of a 4 x 2.4 x 0.3 m wall into 40 chunks volume error 2.8e-9, all chunks watertight, 108 bonds
Fracture of an L-shaped (non-convex) prism 13 chunks, volume error 1.1e-8
Fracture of spheres, cylinders and hulls with uniform and radial seeds volume errors 6e-10 to 1e-8
Wall under a 5000-damage impact 90 of 177 bonds break, 18 pieces detach, deterministic hashes across runs
60 kg ball at 30 m/s into a 60-chunk wall 9 bonds broken through contact events, wall mostly stands (168 of 177 bonds)
Debris budget 10, lifetime 2 s never more than 10 live pieces, all expired after the lifetime
Character walks 3.98 m/s for a 4 m/s request, jump apex 1.257 m (analytic 1.278 m), 62 degree slope slides 2.2 m in 1.5 s, 30 degree slope drops 3 mm, climbs a 0.3 m step, stopped by a 1.2 m wall
Character on platforms offset on a platform moving 2 m/s changes by 33 mm over 3 s (one step of lag, no drift); on a platform turning 1.5 rad it stays within 13 mm of its platform-local spot and platform_yaw sums to 1.492 rad
Vehicle rest load 11772 N = weight; 0 to 100 km/h in 8.4 s (flywheel, clutch and 88 percent driveline efficiency, traction control on); stops from 21.1 m/s in 25 m (0.93 g); yaw rate 0.316 rad/s against 0.321 from the bicycle model; no roll-over at full lock at 25 m/s; more in docs/agent/vehicles.md
Chain with a 40 kg load 3.814 m for a 3.8 m chain, joint gap 5 mm, tension 379 N (weight 432 N)
Rope pendulum, 5 m, 2 kg length 5.003 m after 10 s of swinging
Cloth hanging by its top edge max stretch 0.21 percent, KE 0 after 6 s
Cloth on a sphere / capsule / world box rests at the collider surface plus the 10 mm margin, no particle inside
Joints pendulum period 2.872 s (physical model 2.902 s), fixed joint holds a 20 kg cantilever with 0 sag and reads 196 N, spherical gap 0.56 mm, velocity motor reaches 6.000 of 6.0 rad/s, position motor 0.489 of 0.5 rad against gravity

step read from ps (the machine was shared with other builds, so wall time was useless), best of three runs of 300 steps with sleeping switched off. A 60 Hz frame is 16.67 ms. Treat them as the order of magnitude for this Mac, not as a guarantee.

Scene Cost per step Fits into 16.67 ms
500 / 1000 / 2000 boxes in a resting grid (4 solver iterations) 0.23 / 0.53 / 1.13 ms about 29000 boxes
1 / 10 / 30 humanoid ragdolls on the ground (16 bones each, 8 iterations) 0.10 / 1.27 / 4.33 ms about 115 ragdolls (1800 bones)
100 character controllers walking 0.80 ms about 2000 characters
10 vehicles driving 0.035 ms for the driving model, 0.049 ms with the physics step thousands (4 rays, tires, drivetrain; per car 2 to 8 microseconds, vehicle_bench)
20 ropes of 32 links (640 bodies) 3.0 ms about 109 ropes
Cloth 32x32 (1089 particles) / 64x64 (4225), 10 substeps x 4 iterations, sphere and plane 1.40 / 5.37 ms about 13000 particles
Fracture of a wall into 100 / 500 / 1000 chunks 7.3 / 54 / 130 ms (once, not per frame) 100 chunks fit into a frame budget twice over
Spawning that destructible (bodies and colliders) 4.1 / 20 / 43 ms
One impact that detaches pieces (all bonds in the radius broken) 3.0 / 16 / 39 ms
  • Impulse joints: very heavy loads on light chains stretch (the chain test reports its measured gap). joint_load reads the last solver substep, so tension readings are within about 15 percent of the true value.
  • Rapier’s character controller is only used for sweeps; steps and steep slopes are handled in the wrapper because the controller’s own autostep and slope limit proved unreliable for a capsule resting on the floor.
  • Vehicle: no tire wear or temperature, one box collider for the chassis, dents are visual (they do not change the collision shape); see the limits in docs/agent/vehicles.md.
  • Cloth has no self collision and no tearing; destruction has no continuous chunk damage (only bonds break) and chunk colliders are convex hulls, so a chunk cut from a non-convex mesh is approximated by its hull (mass comes from the true volume).
  • Ragdoll limits are Euler-style limits in the joint frame, symmetric swing only.
  • PhysicsWorld::snapshot() / restore() are in-memory (a deep copy; bit-exact continuation is tested). There is no snapshot file format for the physics world: space3d.log replays a run instead.
  • Tests are slow in debug builds (the physics is 10 to 30 times slower); run them with --release.

Done in task space3d-commands (full reference: docs/agent/space3d.md):

  1. World ownership: Space3D owns one PhysicsWorld (60 Hz by default, physics.hz and physics.solver_iterations in the scene file) and steps it itself with an accumulator; Body components are BodyDesc + ColliderDesc with the entity id in user_data. Continuous collision (ccd), sensors, mesh colliders (ConvexHull, TriMesh from the loaded glTF) and the layer table are properties of Body.
  2. Components with schemas: Joint, Ragdoll, Destructible, CharacterController, Vehicle, VehicleDamage, DeformableMesh, DetachablePart, Rope, Cloth. They are specs in crates/sparklight-scene3d/src/specs.rs (serde plus a JSON Schema from space3d.component_schema) that build the objects of this crate; RopeDesc needed no serde because the Rope component names the objects its ends are pinned to.
  3. Commands: space3d.raycast, shape_cast, overlap, closest (queries, by layer name, results name the object and the part), space3d.ragdoll.hit|set_mode|set_strength, space3d.destructible.damage|shatter, space3d.character.move, space3d.vehicle.input|telemetry|damage|repair|tune|reset, space3d.rope.cut, and space3d.events fed from the world’s and the structures’ event queues. destructible.fracture (mesh asset to asset) is not a command: a Destructible fractures its shape when it is built.
  4. Skeletal animation: Animator output feeds Ragdoll::drive through RagdollLink (export_pose, then blend_from_ragdoll with the ragdoll’s strengths); see docs/agent/animation3d.md.
  5. Rendering: the scene’s draw list includes ragdoll bones, destructible chunks (chunk_pose, generated meshes), debris, rope links, the vehicle chassis and wheels (wheel_states), character capsules and cloth sheets (positions and normals of the frame). Debris shrinks over its fade (the scene’s draw list scales the chunk about its centre; Destructible.debris_fade_time), and a ragdoll is drawn as a skinned character (Space3D::skinned_draws) unless Ragdoll.draw is bones.
  6. Frame order: animation and IK, ragdoll drive, characters, vehicles, world.step(), destructible contact damage and update, cloth, ragdoll blend. One dt for everything.
  7. Layers: Space3D::layers, a named table over the 32 CollisionLayers bits, used by bodies, characters, ragdolls, destructibles and queries.
  8. Snapshots: PhysicsWorld::snapshot() / restore() copy the whole rapier state (bit-exact continuation is tested); Ragdoll, Destructible, CharacterController, Vehicle, Rope, Cloth and the skeletal Animator are Clone. Space3D::snapshot() takes all of them together with the ECS world; space3d.snapshot.take|restore.

Still open: a dedicated command to fracture an asset offline, snapshots written to files (they are in memory; the space3d.log replays a run), Lua access to the features.