Skip to main content

concinnity_core/physics/
system.rs

1// The rigid-body simulation driver. An internal system (not a declarable
2// asset): a schedule constructs one when the world declares a `PhysicsConfig`,
3// a `RigidBody`, a `PropBody`, or a `TriggerVolume`, reading the optional
4// `PhysicsConfig` for the floor / terrain.
5
6use alloc::boxed::Box;
7use alloc::collections::{BTreeMap, BTreeSet};
8use alloc::vec::Vec;
9
10use crate::physics::{
11    BodyHandle, CharacterCapsule, CharacterMoveInput, ColliderShape, ContactHit, GRAVITY,
12    PhysicsBudget, SensorCrossing, SimConfig, Simulation,
13};
14
15use crate::components::{
16    BodyDynamics, Camera3D, Collider, ContactEvent, Held, PhysicsConfig, PhysicsJoint, Pickup,
17    RigidBody, Transform, TriggerFilter, TriggerVolume, VolumeEvent,
18};
19use crate::ecs::asset_id::AssetId;
20use crate::ecs::{
21    Entity, EntityByName, EventCursor, MenuActive, PipelineContext, ScheduleMode, SimTiming,
22    StepResult, System, WorldPhysicsBudget,
23};
24use crate::math::{cos, sin, sqrt};
25
26use super::budget::DriverCapacities;
27use super::contacts::{ContactBatch, ContactGate};
28use super::convert::{collider_shape, joint_spec};
29use super::fanout::{PhysicsFanout, SerialFanout};
30use super::index::SortedMap;
31use super::interp::PointInterp;
32use super::layers::{LAYER_CHARACTER, LAYER_PROP, LAYER_TRIGGER, LAYER_WORLD, LayerTable};
33use super::props::{PropBodies, PropCollSnap, STATIC_FRICTION};
34use super::terrain::{TerrainParams, build_heightfield, build_heightfield_collider};
35
36// Reach distance for picking up a Prop, in world units.
37const PICKUP_REACH: f32 = 3.0;
38// Minimum facing dot product (~60-degree cone) for a pickup.
39const PICKUP_MIN_DOT: f32 = 0.5;
40// Distance ahead of the camera a carried prop hovers.
41const HOLD_DISTANCE: f32 = 1.8;
42// Drop of a carried prop below eye level.
43const HOLD_DROP: f32 = 0.35;
44// Launch speed applied to a prop when it is dropped/thrown.
45const THROW_SPEED: f32 = 6.0;
46
47/// Rigid-body simulation behavior. Constructed internally by `World::start`
48/// from the world's `PhysicsConfig`; never a declarable asset.
49///
50/// [`new`](PhysicsSystem::new) steps the simulation on the calling thread; a
51/// host lends it threads through [`with_fanout`](PhysicsSystem::with_fanout).
52#[derive(Debug)]
53pub struct PhysicsSystem {
54    // Camera eye Y at spawn; the flat-floor fallback derives nothing from it,
55    // but it seeds a sensible fallback camera position.
56    floor_y: f32,
57    // Terrain parameters. None when terrain_subdivisions == 0 (flat floor).
58    terrain: Option<TerrainParams>,
59    // Reference to a `ProceduralMesh` asset whose `heightfield` generator
60    // drives the physics collider. Resolved against the live component list
61    // at `init`. Takes precedence over `terrain` when both are set.
62    terrain_mesh: Option<AssetId>,
63    // World-space Y offset applied to whichever terrain source is active
64    // (procedural noise or heightfield mesh). Matches the rendering Prop's
65    // `position[1]`.
66    terrain_offset_y: f32,
67    // The simulation, built in init() and sized from the world's budget.
68    world: Option<Simulation>,
69    // The player capsule, when the world has a Camera3D + RigidBody.
70    player: Option<PlayerPhysics>,
71    // One capsule per root-motion character rig (see `super::rig`).
72    rigs: Vec<super::rig::RigPhysics>,
73    // One entry per Prop that carries a collider.
74    props: PropBodies,
75    // Reader cursor over the `RootMotionEvent` event queue.
76    root_cursor: EventCursor,
77    // Scratch for the per-tick scan for freshly spawned collider-bearing
78    // entities, refilled every step.
79    new_props: Vec<(Entity, PropCollSnap)>,
80    // Per-step drain scratch, reused so the event handoffs never reallocate.
81    motion_scratch: Vec<crate::components::RootMotionEvent>,
82    contact_scratch: Vec<ContactHit>,
83    sensor_scratch: Vec<SensorCrossing>,
84    // Index into `props` of the prop currently being carried.
85    held: Option<usize>,
86    // Sensor tag -> the TriggerVolume it senses for, with its filter. Tags are
87    // the volume's AssetId, stamped into the sensor collider's user_data.
88    sensor_filters: SortedMap<u64, (AssetId, TriggerFilter)>,
89    // Layer-name resolution and the collide matrix from the PhysicsConfig.
90    layers: LayerTable,
91    // Minimum contact impulse for publishing a ContactEvent.
92    contact_min_impulse: f32,
93    // Strongest contact per body pair across the frame's ticks.
94    contact_batch: ContactBatch,
95    // Per-pair refractory so sustained contact reports once per impact.
96    contact_gate: ContactGate,
97    // Bodies the world authored room for beyond the ones it declares.
98    spawn_headroom: u32,
99    // Hard ceiling on live bodies: the simulation's whole reservation, so a
100    // spawn past it is refused rather than silently declined by a full pool.
101    // Resolved at init, along with the reservation itself.
102    body_cap: u32,
103    // Where a step's independent work runs. `SerialFanout` until a host lends
104    // its own.
105    fanout: Box<dyn PhysicsFanout>,
106}
107
108// Runtime physics state for the player camera capsule.
109#[derive(Debug)]
110struct PlayerPhysics {
111    handle: BodyHandle,
112    // The capsule each tick's move is resolved against. Its dimensions come
113    // from the RigidBody at init and never change afterwards.
114    shape: CharacterCapsule,
115    // Camera eye Y minus capsule-centre Y.
116    eye_offset: f32,
117    // False for a free-flying camera (no RigidBody): no gravity, no jump.
118    has_gravity: bool,
119    gravity_scale: f32,
120    jump_height: f32,
121    // Current vertical velocity (world units/second).
122    vy: f32,
123    // Whether the capsule rested on a surface last tick.
124    grounded: bool,
125    // Authoritative simulated capsule centre with its render blend snapshots.
126    center: PointInterp,
127    // The eye position written back last frame. A Camera3D position that
128    // differs was moved externally (free-fly, a teleport) and is adopted with
129    // no blend across the jump.
130    written_eye: Option<[f32; 3]>,
131}
132
133impl PhysicsSystem {
134    // Number of rigid bodies the physics world holds. Test-only observable for
135    // the body-reaping path.
136    #[cfg(test)]
137    fn physics_body_count(&self) -> usize {
138        self.world.as_ref().map_or(0, |w| w.body_count())
139    }
140
141    // Number of colliders the physics world holds. Test-only observable for
142    // the spawn/despawn leak checks.
143    #[cfg(test)]
144    fn physics_collider_count(&self) -> usize {
145        self.world.as_ref().map_or(0, |w| w.collider_count())
146    }
147
148    /// Build the simulation from the world's `PhysicsConfig` (floor / terrain).
149    /// Bodies and colliders are added from the ECS in [`System::init`].
150    pub fn new(config: PhysicsConfig) -> Self {
151        let terrain = if config.terrain_subdivisions > 0 {
152            Some(TerrainParams {
153                half_width: config.terrain_half_width,
154                half_depth: config.terrain_half_depth,
155                subdivisions: config.terrain_subdivisions,
156                amplitude: config.terrain_amplitude,
157                offset_y: config.terrain_offset_y,
158            })
159        } else {
160            None
161        };
162        Self {
163            floor_y: config.floor_y,
164            terrain,
165            terrain_mesh: config.terrain_mesh,
166            terrain_offset_y: config.terrain_offset_y,
167            world: None,
168            player: None,
169            rigs: Vec::new(),
170            props: PropBodies::default(),
171            root_cursor: EventCursor::default(),
172            new_props: Vec::new(),
173            motion_scratch: Vec::new(),
174            contact_scratch: Vec::new(),
175            sensor_scratch: Vec::new(),
176            held: None,
177            sensor_filters: SortedMap::default(),
178            layers: LayerTable::new(&config),
179            contact_min_impulse: config.contact_min_impulse.max(0.0),
180            contact_batch: ContactBatch::default(),
181            contact_gate: ContactGate::default(),
182            spawn_headroom: config.spawn_headroom,
183            body_cap: 0,
184            fanout: Box::new(SerialFanout),
185        }
186    }
187
188    /// Run the step's independent work through `fanout` rather than on the
189    /// calling thread. What a step hands out and the order its results load
190    /// back in are the simulation's; a fan-out decides only where the work
191    /// runs, so this cannot change the state a tick lands on.
192    pub fn with_fanout(mut self, fanout: Box<dyn PhysicsFanout>) -> Self {
193        self.fanout = fanout;
194        self
195    }
196
197    // The world's body budget: the record cook shipped, or, when no record
198    // shipped, the same derivation over the loaded components with the
199    // headroom its `PhysicsConfig` authored. Only a shipped record is checked
200    // against the live world.
201    //
202    // The two headrooms can differ: a shipped one is already raised to cover
203    // the spawners whose cadence bounds their population, while a directly
204    // constructed World gets only what its config authored, since nothing
205    // counted its spawners.
206    //
207    // Whichever it came from, the budget is the whole reservation: the
208    // simulation is sized from it and never grows, so its cap is the ceiling
209    // spawns are refused against.
210    fn resolve_budget(&self, ctx: &PipelineContext) -> PhysicsBudget {
211        let scan = super::budget::scan_counts(ctx);
212        let Some(record) = ctx.resource::<WorldPhysicsBudget>().map(|b| b.0) else {
213            return PhysicsBudget::derive(&scan, self.spawn_headroom);
214        };
215        let shipped = super::budget::budget_of(&record);
216        debug_assert_eq!(
217            shipped,
218            PhysicsBudget::derive(&scan, record.spawn_headroom),
219            "the shipped physics budget does not match the loaded world"
220        );
221        shipped
222    }
223
224    // Size every container the driver holds per body from the budget, once,
225    // before anything is built.
226    fn reserve(&mut self, budget: &PhysicsBudget) {
227        let caps = DriverCapacities::derive(budget);
228        self.props = PropBodies::with_capacity(&caps);
229        self.rigs = Vec::with_capacity(caps.rigs);
230        self.new_props = Vec::with_capacity(caps.new_props);
231        self.motion_scratch = Vec::with_capacity(caps.root_motions);
232        self.contact_scratch = Vec::with_capacity(caps.contacts);
233        self.sensor_scratch = Vec::with_capacity(caps.sensor_crossings);
234        self.contact_batch = ContactBatch::with_capacity(caps.contact_pairs);
235        self.contact_gate = ContactGate::with_capacity(caps.contact_pairs);
236        self.sensor_filters = SortedMap::with_capacity(caps.sensor_filters);
237    }
238
239    // Build one body per collider-bearing entity from its per-instance
240    // components (Transform + Collider + optional BodyDynamics + the Pickup
241    // tag), keying `body_handles` by AssetId (via the name index's inverse)
242    // so the joint wiring resolves.
243    fn build_prop_bodies(
244        &mut self,
245        ctx: &PipelineContext,
246        world: &mut Simulation,
247        body_handles: &mut BTreeMap<AssetId, BodyHandle>,
248    ) {
249        let entity_name: BTreeMap<Entity, AssetId> = ctx
250            .resource::<EntityByName>()
251            .map(|n| n.0.iter().map(|(&id, &e)| (e, id)).collect())
252            .unwrap_or_default();
253        let pickup: BTreeSet<Entity> = ctx.query_with_entity::<Pickup>().map(|(e, _)| e).collect();
254        let dynamics: BTreeMap<Entity, BodyDynamics> = ctx
255            .query_with_entity::<BodyDynamics>()
256            .map(|(e, b)| (e, *b))
257            .collect();
258        let snaps: Vec<(Entity, PropCollSnap)> = ctx
259            .join2::<Collider, Transform>()
260            .map(|(entity, collider, transform)| {
261                (
262                    entity,
263                    PropCollSnap {
264                        shape: collider_shape(&collider.0, transform.scale),
265                        layer: collider.0.layer.clone(),
266                        position: transform.position,
267                        rotation_deg: transform.rotation_deg,
268                        pickup: pickup.contains(&entity),
269                        dynamics: dynamics.get(&entity).copied(),
270                    },
271                )
272            })
273            .collect();
274
275        for (entity, snap) in snaps {
276            let Some(handle) = self.props.add(&self.layers, world, entity, snap) else {
277                continue;
278            };
279            if let Some(&id) = entity_name.get(&entity) {
280                body_handles.insert(id, handle);
281            }
282        }
283    }
284}
285
286impl System for PhysicsSystem {
287    fn init(&mut self, ctx: &mut PipelineContext) {
288        // Before anything is built, and before the joint wiring below drains
289        // the column the scan counts.
290        let budget = self.resolve_budget(ctx);
291        self.body_cap = budget.body_cap();
292        self.reserve(&budget);
293
294        // The simulation reserves the whole budget here, so nothing on the
295        // step path allocates and a body past the reservation is refused
296        // rather than grown into.
297        let mut world = Simulation::new(
298            SimConfig {
299                gravity: GRAVITY,
300                ..SimConfig::default()
301            },
302            budget.body_cap() as usize,
303        );
304        world.set_contact_min_impulse(self.contact_min_impulse, SimTiming::TICK_DT);
305        // The step's per-worker scratch, reserved from the schedule this world
306        // will run under. A serial schedule reserves one worker's worth, which
307        // is what a simulation that is never lent threads keeps.
308        world.reserve_workers(
309            self.fanout
310                .worker_count(ScheduleMode::current(ctx.resources)),
311        );
312        let world_mask = self.layers.mask(LAYER_WORLD);
313
314        // floor: heightfield-mesh-driven, procedural noise, or flat slab
315        let mut floor_built = false;
316        if let Some(mesh_id) = self.terrain_mesh {
317            let mesh_snap = ctx
318                .query::<crate::components::ProceduralMesh>()
319                .find(|m| m.asset_id == mesh_id)
320                .cloned();
321            // Anything else (missing asset, wrong generator, a collider that
322            // fails to build) leaves `floor_built` false and falls through to
323            // the flat-slab fallback below.
324            if let Some(m) = mesh_snap
325                && m.generator == "heightfield"
326                && build_heightfield_collider(
327                    &mut world,
328                    &m,
329                    self.terrain_offset_y,
330                    world_mask,
331                    ctx,
332                )
333                .is_ok()
334            {
335                floor_built = true;
336            }
337        }
338        if !floor_built {
339            if let Some(terrain) = self.terrain.clone() {
340                build_heightfield(&mut world, &terrain, world_mask);
341            } else {
342                // A large thin slab whose top face sits at Y = 0.
343                world.add_fixed(
344                    &ColliderShape::Cuboid {
345                        half_extents: [500.0, 5.0, 500.0],
346                    },
347                    [0.0, -5.0, 0.0],
348                    [0.0; 3],
349                    STATIC_FRICTION,
350                    world_mask,
351                );
352            }
353        }
354
355        // Sensor regions: one fixed sensor body per TriggerVolume, tagged with
356        // the volume's AssetId so step's crossing drain maps back to it.
357        let trigger_mask = self.layers.mask(LAYER_TRIGGER);
358        let volumes: Vec<TriggerVolume> = ctx.query::<TriggerVolume>().cloned().collect();
359        for volume in &volumes {
360            let shape = collider_shape(&volume.collider, [1.0; 3]);
361            let tag = u64::from(volume.asset_id.0);
362            if world
363                .add_sensor(
364                    &shape,
365                    volume.position,
366                    volume.rotation_deg,
367                    tag,
368                    trigger_mask,
369                )
370                .is_none()
371            {
372                continue;
373            }
374            self.sensor_filters
375                .insert(tag, (volume.asset_id, volume.detects));
376        }
377
378        // Prop name -> BodyHandle, populated alongside `self.prop_bodies`.
379        // Joints resolve their `body_a`/`body_b` references through this map.
380        let mut body_handles: BTreeMap<AssetId, BodyHandle> = BTreeMap::new();
381        self.build_prop_bodies(ctx, &mut world, &mut body_handles);
382
383        // joints
384        // Each PhysicsJoint references one or two Props by AssetId. Cross-reference
385        // validation already guarantees the Prop exists; here we additionally
386        // require the Prop to own a collider (and therefore a body). A PhysicsJoint
387        // with body_b empty anchors body_a to a hidden static body created on
388        // demand at the world-space `anchor_b`.
389        let joints: Vec<PhysicsJoint> = ctx.drain::<PhysicsJoint>();
390        for joint in joints {
391            let Some(body_a_id) = joint.body_a else {
392                continue;
393            };
394            let Some(handle_a) = body_handles.get(&body_a_id).copied() else {
395                continue;
396            };
397            let handle_b = if let Some(body_b_id) = joint.body_b {
398                match body_handles.get(&body_b_id).copied() {
399                    Some(h) => h,
400                    None => {
401                        continue;
402                    }
403                }
404            } else {
405                // Static world anchor at anchor_b. Sub-millimetre ball so it
406                // takes effectively no space in the broad phase.
407                let anchor = world.add_fixed(
408                    &ColliderShape::Ball { radius: 0.001 },
409                    joint.anchor_b,
410                    [0.0; 3],
411                    0.0,
412                    world_mask,
413                );
414                match anchor {
415                    Some(handle) => handle,
416                    None => continue,
417                }
418            };
419            // When body_b is the implicit world anchor, the anchor sits at the
420            // origin of that hidden body, not at the authored offset.
421            let anchor_b = if joint.body_b.is_some() {
422                joint.anchor_b
423            } else {
424                [0.0, 0.0, 0.0]
425            };
426            if !world.add_joint(
427                handle_a,
428                handle_b,
429                joint.anchor_a,
430                anchor_b,
431                joint_spec(&joint),
432            ) {
433                continue;
434            }
435        }
436        // player capsule for the Camera3D
437        // Every first-person camera is collided as a capsule. A RigidBody
438        // upgrades it from a free-flying spectator to a grounded,
439        // gravity-bound character. A third-person camera (a controller with
440        // a `follow` block) gets no capsule: it is a virtual orbit around the
441        // followed character, whose own rig capsule is the collided body.
442        let camera_pos = ctx
443            .query::<Camera3D>()
444            .next()
445            .filter(|c| {
446                c.controller
447                    .as_ref()
448                    .is_none_or(|ctrl| ctrl.follow.is_none())
449            })
450            .map(|c| c.position);
451        if let Some(cam_pos) = camera_pos {
452            let rb_opt = ctx.query::<RigidBody>().next().cloned();
453            let has_gravity = rb_opt.is_some();
454            let rb = rb_opt.unwrap_or_default();
455            if self.floor_y == 0.0 {
456                self.floor_y = cam_pos[1];
457            }
458            let radius = rb.capsule_radius.max(0.05);
459            let half_height = ((rb.capsule_height * 0.5) - radius).max(0.05);
460            // a grounded character's eye sits at the capsule top; a flying
461            // camera's capsule is centred on the eye.
462            let eye_offset = if has_gravity {
463                (rb.capsule_height * 0.5).max(radius + 0.05)
464            } else {
465                0.0
466            };
467            let center = [cam_pos[0], cam_pos[1] - eye_offset, cam_pos[2]];
468            world.configure_character(rb.max_slope_deg, rb.step_height, has_gravity);
469            let handle = world.add_character(
470                half_height,
471                radius,
472                center,
473                self.layers.mask(LAYER_CHARACTER),
474            );
475            self.player = handle.map(|handle| PlayerPhysics {
476                handle,
477                shape: CharacterCapsule::new(half_height, radius),
478                eye_offset,
479                has_gravity,
480                gravity_scale: rb.gravity_scale.max(0.0),
481                jump_height: rb.jump_height.max(0.0),
482                vy: 0.0,
483                grounded: true,
484                center: PointInterp::new(center),
485                written_eye: None,
486            });
487        }
488
489        // Kinematic capsules for the root-motion character rigs published by
490        // GraphicsSystem (which ran init first this tick).
491        super::rig::init_rigs(
492            &mut world,
493            ctx,
494            self.layers.mask(LAYER_CHARACTER),
495            &mut self.rigs,
496        );
497
498        // Published from the built world: the simulation's own storage is only
499        // knowable once it is reserved.
500        super::budget::publish_reservation(crate::memory::ledger(), &budget, &world);
501        self.world = Some(world);
502    }
503
504    fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
505        // Freeze while a menu is open: skip the solve and the write-back so the
506        // world truly pauses (and an external editor's edits to simulated
507        // Transforms are not stomped by a stale blend). The simulation clock
508        // holds its accumulator across the pause, so resuming costs one normal
509        // frame. The flag is published by whichever system owns the menu, which
510        // runs first in the table, so it reflects this same tick.
511        if ctx.resource::<MenuActive>().is_some_and(|m| m.0) {
512            return StepResult::Continue;
513        }
514
515        if self.world.is_none() {
516            return StepResult::Continue;
517        }
518
519        // The frame's fixed-tick budget and render blend factor. Absent (a
520        // directly-stepped world with no App), every step runs exactly one tick
521        // and writes the freshly simulated state.
522        let timing = ctx.resource::<SimTiming>().copied().unwrap_or_default();
523
524        // snapshot reads (released before any query_mut below)
525        let (cam_pos, cam_yaw, cam_pitch, desired_move, jump_req, interact_req) = ctx
526            .query::<Camera3D>()
527            .next()
528            .map(|c| {
529                (
530                    c.position,
531                    c.yaw,
532                    c.pitch,
533                    c.desired_move,
534                    c.jump_requested,
535                    c.interact_requested,
536                )
537            })
538            .unwrap_or(([0.0, self.floor_y, 0.0], 0.0, 0.0, [0.0; 3], false, false));
539
540        // camera-space basis vectors
541        let fwd_flat = [-sin(cam_yaw), 0.0, -cos(cam_yaw)];
542        let fwd_full = [
543            -(sin(cam_yaw) * cos(cam_pitch)),
544            -sin(cam_pitch),
545            -(cos(cam_yaw) * cos(cam_pitch)),
546        ];
547
548        // Whichever pool the schedule names, read once for the frame. Both
549        // land in the same place; only how long the step takes differs.
550        let mode = ScheduleMode::current(ctx.resources);
551
552        let world = self.world.as_mut().expect("world checked above");
553
554        // Reap bodies whose entity was despawned before the step, keeping
555        // self.held a valid index into the compacted list.
556        self.held = self
557            .props
558            .reap(world, self.held, |entity| ctx.is_alive(entity));
559
560        // Adopt collider-bearing entities that appeared since init (runtime
561        // spawns): each gets a body at its spawn transform, with its pose
562        // snapshots seeded there so the render blend starts clean. The scan
563        // materializes into scratch because adopting one mutates the tracked
564        // set the scan itself reads.
565        self.new_props.clear();
566        self.new_props.extend(
567            ctx.join2::<Collider, Transform>()
568                .filter(|(entity, _, _)| {
569                    !self.props.is_tracked(*entity) && !self.props.is_refused(*entity)
570                })
571                .map(|(entity, collider, transform)| {
572                    (
573                        entity,
574                        PropCollSnap {
575                            shape: collider_shape(&collider.0, transform.scale),
576                            layer: collider.0.layer.clone(),
577                            position: transform.position,
578                            rotation_deg: transform.rotation_deg,
579                            pickup: false,
580                            dynamics: None,
581                        },
582                    )
583                }),
584        );
585        for (entity, mut snap) in self.new_props.drain(..) {
586            snap.pickup = ctx.get::<Pickup>(entity).is_some();
587            snap.dynamics = ctx.get::<BodyDynamics>(entity).copied();
588            self.props
589                .adopt(&self.layers, world, entity, snap, self.body_cap);
590        }
591
592        // pickup / drop on the interact edge; held_changed carries the entity to
593        // toggle the Held tag on in the write-back.
594        let mut held_changed: Option<(Entity, bool)> = None;
595        if interact_req {
596            if let Some(held_idx) = self.held.take() {
597                // drop: hand the prop back to dynamic simulation with a throw.
598                let pp = self.props.get(held_idx).expect("held index is valid");
599                let throw = [
600                    fwd_full[0] * THROW_SPEED,
601                    fwd_full[1] * THROW_SPEED + 1.0,
602                    fwd_full[2] * THROW_SPEED,
603                ];
604                world.make_dynamic(pp.handle, throw);
605                held_changed = Some((pp.entity, false));
606            } else {
607                // pickup: nearest carriable prop within reach the player faces.
608                // Entity positions for the reach test, read from the Transform
609                // column only on the interact edge (not every frame).
610                let entity_positions: BTreeMap<Entity, [f32; 3]> = ctx
611                    .query_with_entity::<Transform>()
612                    .map(|(e, t)| (e, t.position))
613                    .collect();
614                let mut best: Option<(f32, usize)> = None;
615                for (idx, pp) in self.props.iter().enumerate() {
616                    if !pp.pickup {
617                        continue;
618                    }
619                    let pos = entity_positions.get(&pp.entity).copied().unwrap_or(cam_pos);
620                    let dx = pos[0] - cam_pos[0];
621                    let dz = pos[2] - cam_pos[2];
622                    let dist = sqrt(dx * dx + dz * dz);
623                    if dist >= PICKUP_REACH || dist <= 0.0 {
624                        continue;
625                    }
626                    let dot = (fwd_flat[0] * dx + fwd_flat[2] * dz) / dist;
627                    if dot > PICKUP_MIN_DOT && best.is_none_or(|(d, _)| dist < d) {
628                        best = Some((dist, idx));
629                    }
630                }
631                if let Some((_, idx)) = best {
632                    let pp = self.props.get(idx).expect("scanned index is valid");
633                    world.make_kinematic(pp.handle);
634                    held_changed = Some((pp.entity, true));
635                    self.held = Some(idx);
636                }
637            }
638        }
639
640        // Adopt an externally moved camera (free-fly, a teleport): a position
641        // that differs from the eye written back last frame was not ours, so
642        // the capsule snaps to it with no blend across the jump.
643        if let Some(player) = self.player.as_mut()
644            && player.written_eye != Some(cam_pos)
645        {
646            player
647                .center
648                .snap([cam_pos[0], cam_pos[1] - player.eye_offset, cam_pos[2]]);
649        }
650
651        // The carried prop's hover point in front of the camera, refreshed
652        // from this frame's camera pose.
653        let hold_pos = [
654            cam_pos[0] + fwd_full[0] * HOLD_DISTANCE,
655            cam_pos[1] + fwd_full[1] * HOLD_DISTANCE - HOLD_DROP,
656            cam_pos[2] + fwd_full[2] * HOLD_DISTANCE,
657        ];
658
659        // Root-motion displacements published since last frame, applied on the
660        // frame's first tick. Rig capsules whose entity moved externally snap
661        // before any tick runs.
662        super::rig::drain_motions_into(ctx, &mut self.root_cursor, &mut self.motion_scratch);
663        super::rig::sync_rigs(ctx, &mut self.rigs);
664
665        for tick in 0..timing.ticks {
666            let dt = timing.tick_dt;
667
668            // carried prop hovers in front of the camera
669            if let Some(prop) = self.held.and_then(|idx| self.props.get(idx)) {
670                world.set_kinematic_translation(prop.handle, hold_pos);
671            }
672
673            // move the player capsule
674            if let Some(player) = self.player.as_mut() {
675                if player.has_gravity {
676                    if tick == 0 && jump_req && player.grounded && player.jump_height > 0.0 {
677                        player.vy = sqrt(2.0 * GRAVITY * player.gravity_scale * player.jump_height);
678                    }
679                    player.vy -= GRAVITY * player.gravity_scale * dt;
680                }
681
682                let center = player.center.current();
683                let desired = [desired_move[0] * dt, player.vy * dt, desired_move[2] * dt];
684                let moved = world.move_character(
685                    &player.shape,
686                    &CharacterMoveInput {
687                        center,
688                        desired,
689                        dt,
690                        exclude: player.handle,
691                        mask: self.layers.mask(LAYER_CHARACTER),
692                    },
693                );
694                let new_center = [
695                    center[0] + moved.translation[0],
696                    center[1] + moved.translation[1],
697                    center[2] + moved.translation[2],
698                ];
699                world.set_kinematic_translation(player.handle, new_center);
700
701                player.grounded = moved.grounded;
702                if moved.grounded && player.vy < 0.0 {
703                    player.vy = 0.0;
704                }
705                player.center.push(new_center);
706            }
707
708            // move the root-motion character rig capsules
709            super::rig::tick_rigs(
710                world,
711                ctx,
712                &mut self.rigs,
713                if tick == 0 { &self.motion_scratch } else { &[] },
714                dt,
715                GRAVITY,
716                self.layers.mask(LAYER_CHARACTER),
717            );
718
719            // advance the simulation
720            self.fanout.step(world, dt, mode);
721
722            // batch the tick's contact hits (strongest per pair this frame)
723            self.contact_gate.advance_tick();
724            world.drain_contact_hits_into(&mut self.contact_scratch);
725            for hit in self.contact_scratch.drain(..) {
726                self.contact_batch.add(hit);
727            }
728
729            // record the tick's dynamic prop poses for the render blend
730            self.props.record_tick_poses(world);
731        }
732
733        // answer the IK ground probes and the follow camera's occlusion probe
734        super::probes::step_probes(
735            world,
736            ctx,
737            &self.rigs,
738            self.layers
739                .query_mask(LAYER_CHARACTER, &[LAYER_WORLD, LAYER_PROP]),
740        );
741
742        // publish the frame's contact events: one per body pair that passed
743        // the impulse threshold, gated by the per-pair refractory. `a` is
744        // always a prop entity; a hit whose sides both lack one (terrain,
745        // capsules) has no consumer-visible subject and is dropped.
746        for hit in self.contact_batch.drain() {
747            let event = match (self.props.entity_of(hit.a), self.props.entity_of(hit.b)) {
748                (Some(a), b) => ContactEvent {
749                    a,
750                    b,
751                    point: hit.point,
752                    normal: hit.normal,
753                    impulse: hit.impulse,
754                },
755                (None, Some(b)) => ContactEvent {
756                    a: b,
757                    b: None,
758                    point: hit.point,
759                    normal: [-hit.normal[0], -hit.normal[1], -hit.normal[2]],
760                    impulse: hit.impulse,
761                },
762                (None, None) => continue,
763            };
764            if self.contact_gate.admit(&hit) {
765                ctx.events_mut::<ContactEvent>().send(event);
766            }
767        }
768
769        // publish the sensor boundary crossings that pass their volume's
770        // filter. A crossing whose body was removed this same step has no
771        // `other` to classify, so only an `any` volume reports it.
772        world.drain_sensor_crossings_into(&mut self.sensor_scratch);
773        for crossing in self.sensor_scratch.drain(..) {
774            let Some(&(volume, filter)) = self.sensor_filters.get(&crossing.tag) else {
775                continue;
776            };
777            let passes = match filter {
778                TriggerFilter::Player => crossing.other.is_some_and(|h| {
779                    self.player.as_ref().is_some_and(|p| p.handle == h)
780                        || self.rigs.iter().any(|r| r.handle == h)
781                }),
782                TriggerFilter::Props => crossing
783                    .other
784                    .is_some_and(|h| self.props.entity_of(h).is_some()),
785                TriggerFilter::Any => true,
786            };
787            if passes {
788                ctx.events_mut::<VolumeEvent>().send(VolumeEvent {
789                    volume,
790                    entered: crossing.entered,
791                });
792            }
793        }
794
795        // Write each dynamic prop's blended pose back to its Transform:
796        // positions lerped, rotations slerped as quaternions, with the Euler
797        // decomposition happening only here at the write boundary.
798        let alpha = timing.alpha;
799        for &(entity, pos, rot) in self.props.sample_poses(alpha) {
800            if let Some(t) = ctx.get_mut::<Transform>(entity) {
801                t.position = pos;
802                t.rotation_deg = rot;
803            }
804        }
805        if let Some((entity, is_held)) = held_changed {
806            if is_held {
807                if ctx.get::<Held>(entity).is_none() {
808                    ctx.insert(entity, Held);
809                }
810            } else {
811                ctx.remove::<Held>(entity);
812            }
813        }
814
815        // write the blended camera position + view matrix
816        let mut grounded = true;
817        if let Some(player) = self.player.as_mut() {
818            let center = player.center.sample(alpha);
819            let eye = [center[0], center[1] + player.eye_offset, center[2]];
820            player.written_eye = Some(eye);
821            grounded = player.grounded;
822            for camera in ctx.query_mut::<Camera3D>() {
823                camera.position = eye;
824                camera.view_matrix =
825                    crate::gfx::camera::view_matrix(camera.position, camera.yaw, camera.pitch);
826            }
827        }
828
829        // publish grounded state for jump gating
830        for body in ctx.query_mut::<RigidBody>() {
831            body.is_grounded = grounded;
832        }
833
834        // write the blended rig positions for the render follow
835        super::rig::publish_rigs(ctx, &mut self.rigs, alpha);
836
837        StepResult::Continue
838    }
839}
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844    use alloc::string::ToString;
845    use alloc::vec;
846
847    use crate::components::{
848        CameraController, CharacterRig, FollowController, ProceduralMesh, PropCollider,
849    };
850    use crate::ecs::SkinnedMeshHandle;
851    use crate::physics::LayerMask;
852    use crate::physics::budget::{record_of, scan_counts};
853    use crate::physics::test_world::TestWorld;
854
855    // Make a spawned prop dynamic, exactly as the load-time PropBody
856    // decomposition would.
857    fn make_dynamic(world: &mut TestWorld, entity: Entity) {
858        world.components.insert_typed(entity, ball_dynamics());
859    }
860
861    fn controlled_camera() -> Camera3D {
862        Camera3D {
863            fov_y_degrees: 75.0,
864            near: 0.05,
865            far: 200.0,
866            view_matrix: [[0.0; 4]; 4],
867            position: [0.0, 1.0, 0.0],
868            yaw: 0.0,
869            pitch: 0.0,
870            desired_move: [0.0; 3],
871            jump_requested: false,
872            interact_requested: false,
873            controller: Some(CameraController::default()),
874        }
875    }
876
877    // A third-person camera is a virtual orbit: no player capsule is created
878    // for it. (Regression: the spectator capsule spawned at the camera eye
879    // overlapped the followed rig's capsule and squeezed it through the
880    // floor.) A first-person camera keeps its capsule. The schedule gate that
881    // builds the system at all is covered by the engine's schedule tests.
882    #[test]
883    fn third_person_camera_gets_no_player_capsule() {
884        // Third-person (follow) camera: a virtual orbit, so no player capsule.
885        let mut world = TestWorld::new();
886        let mut camera = controlled_camera();
887        camera.controller = Some(CameraController {
888            follow: Some(FollowController {
889                target: Some(SkinnedMeshHandle(1)),
890                ..FollowController::default()
891            }),
892            ..CameraController::default()
893        });
894        world.components.push_typed(camera);
895        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
896        physics.init(&mut world.ctx());
897        assert!(physics.player.is_none(), "no capsule for the orbit camera");
898
899        // First-person camera keeps its capsule.
900        let mut world = TestWorld::new();
901        world.components.push_typed(controlled_camera());
902        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
903        physics.init(&mut world.ctx());
904        assert!(
905            physics.player.is_some(),
906            "first-person camera keeps its capsule"
907        );
908    }
909
910    fn ball_dynamics() -> BodyDynamics {
911        BodyDynamics {
912            mass: 1.0,
913            friction: 0.5,
914            linear_damping: 0.0,
915            ..Default::default()
916        }
917    }
918
919    // A camera looking down -Z with the interact field latched on, so the
920    // PhysicsSystem (which reads Camera3D.interact_requested) triggers a pickup.
921    fn interacting_camera(position: [f32; 3]) -> Camera3D {
922        Camera3D {
923            interact_requested: true,
924            controller: None,
925            position,
926            ..controlled_camera()
927        }
928    }
929
930    // The simulated pose is written back to the prop's Transform. With no
931    // `SimTiming` published, each step runs exactly one fixed tick.
932    #[test]
933    fn dynamic_prop_writes_transform() {
934        let id = AssetId(1);
935        let mut world = TestWorld::new();
936        let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
937        make_dynamic(&mut world, entity);
938
939        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
940        physics.init(&mut world.ctx());
941        for _ in 0..10 {
942            physics.step(&mut world.ctx());
943        }
944
945        let transform_y = world.components.get::<Transform>(entity).unwrap().position[1];
946        assert!(
947            transform_y < 5.0,
948            "the simulated pose falls the Transform (y={transform_y})"
949        );
950    }
951
952    // A menu freezes the solve: while `MenuActive(true)` is published the body
953    // does not fall, and clearing it resumes the fall from where it froze.
954    // (The App-level simulation clock additionally holds its accumulator
955    // across the pause, so a live run resumes without a catch-up burst.)
956    #[test]
957    fn menu_active_freezes_then_resumes_physics() {
958        let id = AssetId(1);
959        let mut world = TestWorld::new();
960        let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
961        make_dynamic(&mut world, entity);
962
963        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
964        physics.init(&mut world.ctx());
965
966        // Paused: the body stays put however many frames pass.
967        world.resources.insert(MenuActive(true));
968        for _ in 0..5 {
969            physics.step(&mut world.ctx());
970        }
971        let y_paused = world.components.get::<Transform>(entity).unwrap().position[1];
972        assert!(
973            (y_paused - 5.0).abs() < 1e-3,
974            "the body must not fall while a menu is active (y={y_paused})"
975        );
976
977        // Resumed: the body falls again.
978        world.resources.insert(MenuActive(false));
979        for _ in 0..5 {
980            physics.step(&mut world.ctx());
981        }
982        let y_resumed = world.components.get::<Transform>(entity).unwrap().position[1];
983        assert!(
984            y_resumed < y_paused - 1e-3,
985            "the body must fall once the menu closes (y={y_resumed})"
986        );
987    }
988
989    // A zero-tick frame (the accumulator has not crossed a tick) advances
990    // nothing; the written Transform blends between the last two ticks by the
991    // frame's alpha.
992    #[test]
993    fn zero_tick_frames_blend_between_the_last_two_ticks() {
994        let id = AssetId(1);
995        let mut world = TestWorld::new();
996        let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
997        make_dynamic(&mut world, entity);
998
999        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1000        physics.init(&mut world.ctx());
1001
1002        let tick = |ticks, alpha| SimTiming {
1003            ticks,
1004            tick_dt: SimTiming::TICK_DT,
1005            alpha,
1006        };
1007        world.resources.insert(tick(1, 1.0));
1008        physics.step(&mut world.ctx());
1009        let y_prev = world.components.get::<Transform>(entity).unwrap().position[1];
1010        physics.step(&mut world.ctx());
1011        let y_curr = world.components.get::<Transform>(entity).unwrap().position[1];
1012        assert!(y_curr < y_prev, "the body falls tick over tick");
1013
1014        // No tick, alpha 0: the write-back returns to the previous tick's pose.
1015        world.resources.insert(tick(0, 0.0));
1016        physics.step(&mut world.ctx());
1017        let y_alpha0 = world.components.get::<Transform>(entity).unwrap().position[1];
1018        assert!(
1019            (y_alpha0 - y_prev).abs() < 1e-6,
1020            "alpha 0 samples the previous tick"
1021        );
1022
1023        // No tick, alpha 0.5: halfway between the two ticks.
1024        world.resources.insert(tick(0, 0.5));
1025        physics.step(&mut world.ctx());
1026        let y_mid = world.components.get::<Transform>(entity).unwrap().position[1];
1027        let expected = (y_prev + y_curr) * 0.5;
1028        assert!(
1029            (y_mid - expected).abs() < 1e-6,
1030            "alpha 0.5 blends the tick poses (y={y_mid}, expected {expected})"
1031        );
1032    }
1033
1034    // The same number of fixed ticks produces bit-identical world state
1035    // however they are grouped into frames: 30 fps frames (two ticks each)
1036    // against 120 fps frames (a tick every other frame).
1037    #[test]
1038    fn tick_grouping_does_not_change_the_outcome() {
1039        let run = |frames: &[u32]| -> ([f32; 3], [f32; 3]) {
1040            let id = AssetId(1);
1041            let mut world = TestWorld::new();
1042            let entity = world.spawn_prop(id, [0.3, 5.0, 0.1], false);
1043            make_dynamic(&mut world, entity);
1044
1045            let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1046            physics.init(&mut world.ctx());
1047            for &ticks in frames {
1048                world.resources.insert(SimTiming {
1049                    ticks,
1050                    tick_dt: SimTiming::TICK_DT,
1051                    alpha: 1.0,
1052                });
1053                physics.step(&mut world.ctx());
1054            }
1055            let t = world.components.get::<Transform>(entity).unwrap();
1056            (t.position, t.rotation_deg)
1057        };
1058
1059        // One simulated second: 30 frames of 2 ticks vs 120 frames alternating
1060        // 0 and 1 ticks. Both run exactly 60 fixed ticks.
1061        let thirty: Vec<u32> = core::iter::repeat_n(2, 30).collect();
1062        let one_twenty: Vec<u32> = (0..120).map(|i| i % 2).collect();
1063        assert_eq!(
1064            run(&thirty),
1065            run(&one_twenty),
1066            "the fixed-tick outcome must not depend on frame grouping"
1067        );
1068    }
1069
1070    // Despawning a decomposed prop reaps its body, so it stops simulating
1071    // (and colliding) once its entity is gone.
1072    #[test]
1073    fn despawning_a_prop_reaps_its_physics_body() {
1074        let id = AssetId(1);
1075        let mut world = TestWorld::new();
1076        let ball = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
1077        make_dynamic(&mut world, ball);
1078
1079        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1080        physics.init(&mut world.ctx());
1081
1082        // Settle so the body is live and falling.
1083        for _ in 0..2 {
1084            physics.step(&mut world.ctx());
1085        }
1086        let before = physics.physics_body_count();
1087
1088        // Despawn the ball (stand-in for GraphicsSystem) and step: PhysicsSystem
1089        // reaps the orphaned body.
1090        world.components.despawn(ball);
1091        physics.step(&mut world.ctx());
1092        let after = physics.physics_body_count();
1093        assert_eq!(after, before - 1, "the despawned prop's body was removed");
1094
1095        // The sim keeps running cleanly with the body gone (no further removals).
1096        physics.step(&mut world.ctx());
1097        assert_eq!(
1098            physics.physics_body_count(),
1099            after,
1100            "no further bodies removed"
1101        );
1102    }
1103
1104    // Picking up a carriable prop tags its entity with Held.
1105    #[test]
1106    fn pickup_sets_held_tag() {
1107        let id = AssetId(1);
1108        let mut world = TestWorld::new();
1109        let carriable = world.spawn_prop(id, [0.0, 1.0, -2.0], true);
1110        make_dynamic(&mut world, carriable);
1111        world
1112            .components
1113            .push_typed(interacting_camera([0.0, 1.0, 0.0]));
1114
1115        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1116        physics.init(&mut world.ctx());
1117
1118        physics.step(&mut world.ctx());
1119
1120        assert_eq!(
1121            world.ctx().query::<Held>().count(),
1122            1,
1123            "pickup inserts the Held tag on the entity"
1124        );
1125    }
1126
1127    // A collider-bearing entity that appears after init (a runtime spawn) is
1128    // adopted on the next step: it gets a body and falls like an authored one.
1129    // The headroom is what reserves the body for it: the simulation is sized
1130    // once at init and a spawn past the reservation is refused.
1131    #[test]
1132    fn runtime_spawned_prop_gets_a_body_and_falls() {
1133        let mut world = TestWorld::new();
1134        let mut physics = PhysicsSystem::new(PhysicsConfig {
1135            spawn_headroom: 1,
1136            ..PhysicsConfig::default()
1137        });
1138        physics.init(&mut world.ctx());
1139        let baseline = physics.physics_body_count();
1140
1141        let spawned = world.spawn_prop(AssetId(7), [0.0, 5.0, 0.0], false);
1142        make_dynamic(&mut world, spawned);
1143        physics.step(&mut world.ctx());
1144        assert_eq!(
1145            physics.physics_body_count(),
1146            baseline + 1,
1147            "the spawned entity got a body on its first step"
1148        );
1149        for _ in 0..30 {
1150            physics.step(&mut world.ctx());
1151        }
1152        let y = world.components.get::<Transform>(spawned).unwrap().position[1];
1153        assert!(y < 4.5, "the spawned body falls (y = {y})");
1154        for _ in 0..300 {
1155            physics.step(&mut world.ctx());
1156        }
1157        let y = world.components.get::<Transform>(spawned).unwrap().position[1];
1158        assert!(
1159            (y - 0.5).abs() < 0.1,
1160            "the spawned ball rests on the flat floor (y = {y})"
1161        );
1162    }
1163
1164    // Spawn, despawn, and respawn leave no bodies or colliders behind: a
1165    // reaped body hands its slot back, so one body's worth of headroom covers
1166    // any number of rounds.
1167    #[test]
1168    fn spawn_despawn_respawn_cycle_is_leak_free() {
1169        let mut world = TestWorld::new();
1170        let mut physics = PhysicsSystem::new(PhysicsConfig {
1171            spawn_headroom: 1,
1172            ..PhysicsConfig::default()
1173        });
1174        physics.init(&mut world.ctx());
1175        let bodies = physics.physics_body_count();
1176        let colliders = physics.physics_collider_count();
1177
1178        for round in 0..3 {
1179            let spawned = world.spawn_prop(AssetId(100 + round), [0.0, 3.0, 0.0], false);
1180            make_dynamic(&mut world, spawned);
1181            physics.step(&mut world.ctx());
1182            assert_eq!(physics.physics_body_count(), bodies + 1, "round {round}");
1183            world.components.despawn(spawned);
1184            physics.step(&mut world.ctx());
1185            assert_eq!(
1186                physics.physics_body_count(),
1187                bodies,
1188                "round {round} reaped the body"
1189            );
1190            assert_eq!(
1191                physics.physics_collider_count(),
1192                colliders,
1193                "round {round} reaped the collider"
1194            );
1195        }
1196    }
1197
1198    // A world whose shipped budget was counted from the same content it holds
1199    // passes the init assert, and reserves the cap that budget implies.
1200    #[test]
1201    fn a_shipped_budget_matching_the_world_is_adopted() {
1202        let mut world = TestWorld::new();
1203        let entity = world.spawn_prop(AssetId(1), [0.0, 3.0, 0.0], false);
1204        make_dynamic(&mut world, entity);
1205        world.components.push_typed(controlled_camera());
1206
1207        // The record cook would have written for this world: one dynamic prop,
1208        // the floor, the player capsule, and room for two spawns.
1209        let counts = scan_counts(&world.ctx());
1210        let budget = PhysicsBudget::derive(&counts, 2);
1211        assert_eq!(budget.dynamic, 1);
1212        assert_eq!(budget.kinematic, 1, "the first-person camera capsule");
1213        world
1214            .resources
1215            .insert(WorldPhysicsBudget(record_of(&budget)));
1216
1217        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1218        physics.init(&mut world.ctx());
1219        assert_eq!(physics.body_cap, budget.body_cap());
1220        assert_eq!(
1221            physics.physics_body_count(),
1222            budget.body_total() as usize,
1223            "init built exactly the bodies the budget reserved"
1224        );
1225    }
1226
1227    // A world with no shipped budget (built in memory, as every test world is)
1228    // reserves from what it holds plus the headroom its config authored, and
1229    // caps spawns there. The cap used to be left open for such a world, on the
1230    // grounds that nothing had counted its spawns; a fixed-capacity simulation
1231    // cannot honour that -- a spawn past the reservation gets no body either
1232    // way, and the cap is what turns a silently declined one into a refusal
1233    // naming the knob to raise.
1234    #[test]
1235    fn a_world_without_a_shipped_budget_reserves_from_what_it_holds() {
1236        let mut world = TestWorld::new();
1237        let entity = world.spawn_prop(AssetId(1), [0.0, 3.0, 0.0], false);
1238        make_dynamic(&mut world, entity);
1239
1240        let mut physics = PhysicsSystem::new(PhysicsConfig {
1241            spawn_headroom: 4,
1242            ..PhysicsConfig::default()
1243        });
1244        physics.init(&mut world.ctx());
1245
1246        assert_eq!(
1247            physics.physics_body_count(),
1248            2,
1249            "the floor and the one authored prop"
1250        );
1251        assert_eq!(physics.body_cap, 2 + 4, "plus the authored headroom");
1252    }
1253
1254    // Past the cap, a spawned prop gets no body and the world keeps stepping.
1255    // The refusal happens once: the next tick's scan passes over the entity
1256    // rather than re-refusing it.
1257    #[test]
1258    fn a_spawn_past_the_shipped_budget_is_refused() {
1259        let mut world = TestWorld::new();
1260        let authored = world.spawn_prop(AssetId(1), [0.0, 3.0, 0.0], false);
1261        make_dynamic(&mut world, authored);
1262
1263        // A budget with no headroom: the floor and the one authored prop.
1264        let counts = scan_counts(&world.ctx());
1265        let budget = PhysicsBudget::derive(&counts, 0);
1266        world
1267            .resources
1268            .insert(WorldPhysicsBudget(record_of(&budget)));
1269
1270        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1271        physics.init(&mut world.ctx());
1272        let full = physics.physics_body_count();
1273        assert_eq!(full, budget.body_cap() as usize, "the budget is spent");
1274
1275        let spawned = world.spawn_prop(AssetId(2), [0.0, 6.0, 0.0], false);
1276        make_dynamic(&mut world, spawned);
1277        physics.step(&mut world.ctx());
1278        assert_eq!(physics.physics_body_count(), full, "no body was built");
1279        assert!(physics.props.is_refused(spawned));
1280
1281        // Stepping on keeps the refusal: the entity is skipped, not retried,
1282        // and the authored prop carries on simulating.
1283        for _ in 0..10 {
1284            physics.step(&mut world.ctx());
1285        }
1286        assert_eq!(physics.physics_body_count(), full);
1287        let refused_y = world.components.get::<Transform>(spawned).unwrap().position[1];
1288        assert_eq!(refused_y, 6.0, "a refused prop is not simulated at all");
1289        let live_y = world
1290            .components
1291            .get::<Transform>(authored)
1292            .unwrap()
1293            .position[1];
1294        assert!(live_y < 3.0, "the authored prop still falls");
1295    }
1296
1297    // A rig capsule authored standing exactly on the floor stays there: it
1298    // neither sinks tick after tick nor is lifted off it. The driver used to
1299    // spawn one a fingernail above its authored position, because the
1300    // controller ignored a hit a downward move started already touching and
1301    // the capsule sank a little every frame; the controller now separates
1302    // along the contact normal instead, so the authored position is the one
1303    // that holds.
1304    #[test]
1305    fn a_rig_capsule_spawned_on_the_floor_neither_sinks_nor_rises() {
1306        let identity = [
1307            [1.0, 0.0, 0.0, 0.0],
1308            [0.0, 1.0, 0.0, 0.0],
1309            [0.0, 0.0, 1.0, 0.0],
1310            [0.0, 0.0, 0.0, 1.0],
1311        ];
1312        let mut world = TestWorld::new();
1313        world.components.push_typed(CharacterRig::new(
1314            SkinnedMeshHandle(1),
1315            0,
1316            identity,
1317            0.6,
1318            0.3,
1319        ));
1320
1321        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1322        physics.init(&mut world.ctx());
1323        let rig_y = |world: &mut TestWorld| {
1324            world
1325                .ctx()
1326                .query::<CharacterRig>()
1327                .next()
1328                .expect("the rig is there")
1329                .position[1]
1330        };
1331        assert_eq!(
1332            rig_y(&mut world),
1333            0.0,
1334            "the capsule spawns at its authored position, unlifted"
1335        );
1336
1337        for _ in 0..30 {
1338            physics.step(&mut world.ctx());
1339        }
1340        let settled = rig_y(&mut world);
1341        for _ in 0..300 {
1342            physics.step(&mut world.ctx());
1343        }
1344        let held = rig_y(&mut world);
1345
1346        assert!(
1347            settled.abs() < 0.01,
1348            "the capsule stayed on the floor (y = {settled})"
1349        );
1350        assert!(
1351            (held - settled).abs() < 1.0e-4,
1352            "and stopped moving ({settled} -> {held})"
1353        );
1354    }
1355
1356    // A hard landing publishes one ContactEvent naming the prop; resting
1357    // afterwards stays silent.
1358    #[test]
1359    fn contact_event_fires_on_impact_and_not_at_rest() {
1360        let id = AssetId(1);
1361        let mut world = TestWorld::new();
1362        let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
1363        make_dynamic(&mut world, entity);
1364
1365        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1366        physics.init(&mut world.ctx());
1367
1368        let mut cursor = EventCursor::default();
1369        let mut impacts: Vec<ContactEvent> = Vec::new();
1370        for _ in 0..120 {
1371            physics.step(&mut world.ctx());
1372            let ctx = world.ctx();
1373            if let Some(events) = ctx.events::<ContactEvent>() {
1374                impacts.extend(events.read(&mut cursor).copied());
1375            }
1376        }
1377        assert_eq!(impacts.len(), 1, "one landing, one event: {impacts:?}");
1378        let impact = impacts[0];
1379        assert_eq!(impact.a, entity);
1380        assert_eq!(impact.b, None, "the floor slab has no entity");
1381        assert!(
1382            impact.impulse > 3.0 && impact.impulse < 50.0,
1383            "impulse {} out of the plausible landing range",
1384            impact.impulse
1385        );
1386
1387        // Settled: hundreds of resting ticks publish nothing further.
1388        for _ in 0..300 {
1389            physics.step(&mut world.ctx());
1390            let ctx = world.ctx();
1391            if let Some(events) = ctx.events::<ContactEvent>() {
1392                assert_eq!(
1393                    events.read(&mut cursor).count(),
1394                    0,
1395                    "resting contact must not publish events"
1396                );
1397            }
1398        }
1399    }
1400
1401    // A sensor region reports a prop crossing it, in and then out, naming the
1402    // volume that saw it.
1403    #[test]
1404    fn a_trigger_volume_reports_a_prop_crossing_it() {
1405        let volume_id = AssetId(9);
1406        let mut world = TestWorld::new();
1407        world.components.push_typed(TriggerVolume {
1408            asset_id: volume_id,
1409            position: [0.0, 3.0, 0.0],
1410            rotation_deg: [0.0; 3],
1411            collider: PropCollider {
1412                shape: "cuboid".to_string(),
1413                half_extents: [1.0, 0.5, 1.0],
1414                ..Default::default()
1415            },
1416            detects: TriggerFilter::Props,
1417        });
1418        let ball = world.spawn_prop(AssetId(1), [0.0, 6.0, 0.0], false);
1419        make_dynamic(&mut world, ball);
1420
1421        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1422        physics.init(&mut world.ctx());
1423
1424        let mut cursor = EventCursor::default();
1425        let mut crossings: Vec<VolumeEvent> = Vec::new();
1426        for _ in 0..180 {
1427            physics.step(&mut world.ctx());
1428            let ctx = world.ctx();
1429            if let Some(events) = ctx.events::<VolumeEvent>() {
1430                crossings.extend(events.read(&mut cursor).copied());
1431            }
1432        }
1433        assert_eq!(crossings.len(), 2, "in, then out: {crossings:?}");
1434        assert!(crossings.iter().all(|c| c.volume == volume_id));
1435        assert!(crossings[0].entered, "the ball entered first");
1436        assert!(!crossings[1].entered, "and left afterwards");
1437    }
1438
1439    // A joint anchored to the world holds its body up: the hidden anchor body
1440    // the driver mints for it is part of the reservation, and the prop hangs
1441    // off it instead of falling.
1442    #[test]
1443    fn a_world_anchored_joint_holds_its_prop_up() {
1444        let bob_id = AssetId(1);
1445        let mut world = TestWorld::new();
1446        let bob = world.spawn_prop(bob_id, [1.0, 4.0, 0.0], false);
1447        make_dynamic(&mut world, bob);
1448        world.components.push_typed(PhysicsJoint {
1449            asset_id: AssetId(2),
1450            kind: "spherical".to_string(),
1451            body_a: Some(bob_id),
1452            body_b: None,
1453            // The bob's own centre hangs one unit from the anchor point.
1454            anchor_a: [-1.0, 0.0, 0.0],
1455            anchor_b: [0.0, 4.0, 0.0],
1456            ..Default::default()
1457        });
1458
1459        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1460        physics.init(&mut world.ctx());
1461        for _ in 0..240 {
1462            physics.step(&mut world.ctx());
1463        }
1464
1465        let position = world.components.get::<Transform>(bob).unwrap().position;
1466        let reach =
1467            ((position[0]).powi(2) + (position[1] - 4.0).powi(2) + (position[2]).powi(2)).sqrt();
1468        assert!(
1469            (reach - 1.0).abs() < 0.05,
1470            "the bob hangs one unit from the anchor, at {position:?} ({reach})"
1471        );
1472        assert!(
1473            position[1] > 2.5,
1474            "and is held up rather than falling ({position:?})"
1475        );
1476    }
1477
1478    // The config's no_collide pairs reach the built colliders: a prop on a
1479    // layer that ignores `world` falls straight through the floor.
1480    #[test]
1481    fn no_collide_config_lets_a_layered_prop_fall_through_the_floor() {
1482        let config = PhysicsConfig {
1483            layers: vec!["ghost".to_string()],
1484            no_collide: vec![["ghost".to_string(), "world".to_string()]],
1485            ..PhysicsConfig::default()
1486        };
1487
1488        let mut world = TestWorld::new();
1489        let entity = world.spawn_prop(AssetId(1), [0.0, 2.0, 0.0], false);
1490        make_dynamic(&mut world, entity);
1491        world
1492            .components
1493            .get_mut::<Collider>(entity)
1494            .unwrap()
1495            .0
1496            .layer = "ghost".to_string();
1497
1498        let mut physics = PhysicsSystem::new(config);
1499        physics.init(&mut world.ctx());
1500        for _ in 0..240 {
1501            physics.step(&mut world.ctx());
1502        }
1503        let y = world.components.get::<Transform>(entity).unwrap().position[1];
1504        assert!(
1505            y < -10.0,
1506            "the ghost-layer prop fell through the floor (y = {y})"
1507        );
1508    }
1509
1510    // Where the floor sits under (x, z), by dropping a ray onto it.
1511    fn floor_height(physics: &PhysicsSystem, x: f32, z: f32) -> Option<f32> {
1512        physics
1513            .world
1514            .as_ref()?
1515            .raycast([x, 100.0, z], [0.0, -1.0, 0.0], 200.0, None, LayerMask::ALL)
1516            .map(|hit| hit.point[1])
1517    }
1518
1519    fn terrain_config() -> PhysicsConfig {
1520        PhysicsConfig {
1521            terrain_half_width: 32.0,
1522            terrain_half_depth: 32.0,
1523            terrain_subdivisions: 32,
1524            terrain_amplitude: 4.0,
1525            ..PhysicsConfig::default()
1526        }
1527    }
1528
1529    // A config that authors subdivisions gets a noise floor whose height
1530    // varies with position, rather than the flat slab a bare config gets.
1531    #[test]
1532    fn authored_subdivisions_build_a_noise_floor_instead_of_a_slab() {
1533        let mut world = TestWorld::new();
1534        let mut physics = PhysicsSystem::new(terrain_config());
1535        assert!(physics.terrain.is_some(), "the config authored a terrain");
1536        physics.init(&mut world.ctx());
1537
1538        let a = floor_height(&physics, 0.0, 0.0).expect("the ray meets the floor");
1539        let b = floor_height(&physics, 12.0, -7.0).expect("the ray meets the floor");
1540        assert_ne!(a, b, "a noise floor is not level");
1541    }
1542
1543    #[test]
1544    fn no_subdivisions_leaves_a_level_slab() {
1545        let mut world = TestWorld::new();
1546        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1547        assert!(physics.terrain.is_none());
1548        physics.init(&mut world.ctx());
1549
1550        let a = floor_height(&physics, 0.0, 0.0).expect("the ray meets the floor");
1551        let b = floor_height(&physics, 12.0, -7.0).expect("the ray meets the floor");
1552        assert_eq!(a, b, "a slab is level");
1553    }
1554
1555    // A named terrain mesh that does not resolve to a usable heightfield
1556    // collider falls through to the fallback floor rather than leaving the
1557    // world without one. Each way it can fail to resolve takes that path:
1558    // no such asset, the wrong generator, and a payload the store cannot read
1559    // (this world carries no blob, so the read always fails).
1560    #[test]
1561    fn an_unusable_terrain_mesh_falls_through_to_the_fallback_floor() {
1562        let cases: [(&str, Option<ProceduralMesh>); 3] = [
1563            ("no such asset", None),
1564            (
1565                "wrong generator",
1566                Some(ProceduralMesh {
1567                    asset_id: AssetId(7),
1568                    generator: "box".to_string(),
1569                    ..ProceduralMesh::default()
1570                }),
1571            ),
1572            (
1573                "unreadable payload",
1574                Some(ProceduralMesh {
1575                    asset_id: AssetId(7),
1576                    generator: "heightfield".to_string(),
1577                    ..ProceduralMesh::default()
1578                }),
1579            ),
1580        ];
1581
1582        for (what, mesh) in cases {
1583            let mut world = TestWorld::new();
1584            if let Some(mesh) = mesh {
1585                world.components.push_typed(mesh);
1586            }
1587            let mut config = terrain_config();
1588            config.terrain_mesh = Some(AssetId(7));
1589            let mut physics = PhysicsSystem::new(config);
1590            physics.init(&mut world.ctx());
1591            assert!(
1592                floor_height(&physics, 0.0, 0.0).is_some(),
1593                "{what}: the world was left with no floor at all"
1594            );
1595        }
1596    }
1597
1598    // A capsule that jumps leaves the ground, then lands: the impulse is
1599    // applied on the tick the request arrives, gravity brings it back, and
1600    // touching down clears the downward velocity rather than letting it
1601    // accumulate into the floor. The grounded state is published back to the
1602    // RigidBody, which is what gates the next jump.
1603    #[test]
1604    fn a_grounded_player_jumps_and_lands() {
1605        let mut world = TestWorld::new();
1606        world.components.push_typed(RigidBody {
1607            gravity_scale: 1.0,
1608            capsule_radius: 0.3,
1609            capsule_height: 1.8,
1610            jump_height: 1.0,
1611            ..RigidBody::default()
1612        });
1613        let mut camera = controlled_camera();
1614        camera.jump_requested = true;
1615        world.components.push_typed(camera);
1616
1617        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1618        physics.init(&mut world.ctx());
1619        assert!(
1620            physics.player.as_ref().expect("a capsule").has_gravity,
1621            "a RigidBody in the world is what makes the capsule fall"
1622        );
1623
1624        physics.step(&mut world.ctx());
1625        let launched = physics.player.as_ref().expect("a capsule").vy;
1626        assert!(
1627            launched > 0.0,
1628            "the jump did not lift the capsule: {launched}"
1629        );
1630
1631        // Stop asking, and let it come back down.
1632        for camera in world.ctx().query_mut::<Camera3D>() {
1633            camera.jump_requested = false;
1634        }
1635        for _ in 0..240 {
1636            physics.step(&mut world.ctx());
1637        }
1638
1639        let player = physics.player.as_ref().expect("a capsule");
1640        assert!(player.grounded, "the capsule never landed");
1641        assert_eq!(player.vy, 0.0, "landing clears the downward velocity");
1642        assert!(
1643            world
1644                .ctx()
1645                .query::<RigidBody>()
1646                .all(|body| body.is_grounded),
1647            "the grounded state that gates the next jump was not published"
1648        );
1649    }
1650
1651    // Interacting twice picks the prop up and then throws it: the second
1652    // interaction hands it back to dynamic simulation and drops the Held tag,
1653    // so a carried prop cannot be left tagged after it has been released.
1654    #[test]
1655    fn interacting_twice_picks_up_then_throws_the_prop() {
1656        let id = AssetId(1);
1657        let mut world = TestWorld::new();
1658        let carriable = world.spawn_prop(id, [0.0, 1.0, -2.0], true);
1659        make_dynamic(&mut world, carriable);
1660        world
1661            .components
1662            .push_typed(interacting_camera([0.0, 1.0, 0.0]));
1663
1664        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1665        physics.init(&mut world.ctx());
1666
1667        physics.step(&mut world.ctx());
1668        assert_eq!(world.ctx().query::<Held>().count(), 1, "picked up");
1669        assert!(physics.held.is_some());
1670
1671        // The camera is still asking, so the next step is the drop.
1672        physics.step(&mut world.ctx());
1673        assert!(physics.held.is_none(), "the prop was not released");
1674        assert_eq!(
1675            world.ctx().query::<Held>().count(),
1676            0,
1677            "the Held tag outlived the throw"
1678        );
1679    }
1680
1681    // Build a world holding one trigger volume with the given filter, plus a
1682    // falling prop, and report the crossings `steps` steps produce.
1683    fn crossings_through(detects: TriggerFilter, steps: usize) -> Vec<VolumeEvent> {
1684        let volume_id = AssetId(9);
1685        let mut world = TestWorld::new();
1686        world.components.push_typed(TriggerVolume {
1687            asset_id: volume_id,
1688            position: [0.0, 3.0, 0.0],
1689            rotation_deg: [0.0; 3],
1690            collider: PropCollider {
1691                shape: "cuboid".to_string(),
1692                half_extents: [1.0, 0.5, 1.0],
1693                ..Default::default()
1694            },
1695            detects,
1696        });
1697        let ball = world.spawn_prop(AssetId(1), [0.0, 6.0, 0.0], false);
1698        make_dynamic(&mut world, ball);
1699
1700        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1701        physics.init(&mut world.ctx());
1702
1703        let mut cursor = EventCursor::default();
1704        let mut crossings = Vec::new();
1705        for _ in 0..steps {
1706            physics.step(&mut world.ctx());
1707            let ctx = world.ctx();
1708            if let Some(events) = ctx.events::<VolumeEvent>() {
1709                crossings.extend(events.read(&mut cursor).copied());
1710            }
1711        }
1712        crossings
1713    }
1714
1715    // An `any` volume takes whatever crosses it, so the same prop that a
1716    // `props` volume reports is reported here too.
1717    #[test]
1718    fn an_any_volume_reports_a_prop_crossing_it() {
1719        let crossings = crossings_through(TriggerFilter::Any, 180);
1720        assert_eq!(crossings.len(), 2, "in, then out: {crossings:?}");
1721    }
1722
1723    // A `player` volume classifies by body: a prop is not the player, so the
1724    // same crossing that an `any` volume reports is filtered out here.
1725    #[test]
1726    fn a_player_volume_ignores_a_prop_crossing_it() {
1727        let crossings = crossings_through(TriggerFilter::Player, 180);
1728        assert!(
1729            crossings.is_empty(),
1730            "a prop is not the player: {crossings:?}"
1731        );
1732    }
1733}