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].
Quick start
Section titled “Quick start”use glam::Vec3;use sparklight_physics_fx::*;
let mut world = PhysicsWorld::new(WorldConfig::default()); // 60 Hz, 8 solver iterationsworld.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 skinningprintln!("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.
Determinism contract
Section titled “Determinism contract”- 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 (thedet3d_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()andCloth::state_hash()hash their own state.PhysicsWorld::hash_into(&mut StateHasher)lets you fold several into one value.
The world
Section titled “The world”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).
Joints
Section titled “Joints”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.
Queries
Section titled “Queries”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.
Ragdolls
Section titled “Ragdolls”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.
Destruction
Section titled “Destruction”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)andapply_impact(&mut world, &Impact): bonds within the radius takedamage * (1 - (d / radius)^2);Impact::with_graph_decay(0.3)also spreads damage along the bond graph (a bondhbonds away takesdamage * decay^(h+1), cut off below 2 percent);with_radial_impulse/with_directional_impulsepush 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;shatterbreaks everything.- Debris:
debris.lifetime(seeded jitter),fade_time,max_active(the oldest piece is recycled when the budget is exceeded), sleeping pieces age faster. Callupdate(&mut world)once per step.debris()lists pieces with afadefactor (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; drawchunks[i].triangleswith the outer material for the firstsurface_trianglesand the inner material for the rest.
Character controller
Section titled “Character controller”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.
Vehicles
Section titled “Vehicles”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.
Ropes and chains
Section titled “Ropes and chains”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.
Error codes
Section titled “Error codes”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.
Measured results
Section titled “Measured results”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 |
Cost (release build, one thread)
Section titled “Cost (release build, one thread)”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 |
Known limits
Section titled “Known limits”- Impulse joints: very heavy loads on light chains stretch (the chain test reports its measured gap).
joint_loadreads 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.logreplays a run instead.- Tests are slow in debug builds (the physics is 10 to 30 times slower); run them with
--release.
Integration into Space3D
Section titled “Integration into Space3D”Done in task space3d-commands (full reference: docs/agent/space3d.md):
- World ownership:
Space3Downs onePhysicsWorld(60 Hz by default,physics.hzandphysics.solver_iterationsin the scene file) and steps it itself with an accumulator;Bodycomponents areBodyDesc+ColliderDescwith the entity id inuser_data. Continuous collision (ccd), sensors, mesh colliders (ConvexHull,TriMeshfrom the loaded glTF) and the layer table are properties ofBody. - Components with schemas:
Joint,Ragdoll,Destructible,CharacterController,Vehicle,VehicleDamage,DeformableMesh,DetachablePart,Rope,Cloth. They are specs incrates/sparklight-scene3d/src/specs.rs(serde plus a JSON Schema fromspace3d.component_schema) that build the objects of this crate;RopeDescneeded no serde because theRopecomponent names the objects its ends are pinned to. - 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, andspace3d.eventsfed from the world’s and the structures’ event queues.destructible.fracture(mesh asset to asset) is not a command: aDestructiblefractures its shape when it is built. - Skeletal animation:
Animatoroutput feedsRagdoll::drivethroughRagdollLink(export_pose, thenblend_from_ragdollwith the ragdoll’s strengths); seedocs/agent/animation3d.md. - 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 itsfade(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) unlessRagdoll.drawisbones. - Frame order: animation and IK, ragdoll drive, characters, vehicles,
world.step(), destructible contact damage andupdate, cloth, ragdoll blend. Onedtfor everything. - Layers:
Space3D::layers, a named table over the 32CollisionLayersbits, used by bodies, characters, ragdolls, destructibles and queries. - Snapshots:
PhysicsWorld::snapshot()/restore()copy the whole rapier state (bit-exact continuation is tested);Ragdoll,Destructible,CharacterController,Vehicle,Rope,Clothand the skeletalAnimatorareClone.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.