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 concinnity_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            tracing::debug!("PhysicsSystem: no shipped budget; reserving from the loaded world");
214            return PhysicsBudget::derive(&scan, self.spawn_headroom);
215        };
216        let shipped = super::budget::budget_of(&record);
217        debug_assert_eq!(
218            shipped,
219            PhysicsBudget::derive(&scan, record.spawn_headroom),
220            "the shipped physics budget does not match the loaded world"
221        );
222        shipped
223    }
224
225    // Size every container the driver holds per body from the budget, once,
226    // before anything is built.
227    fn reserve(&mut self, budget: &PhysicsBudget) {
228        let caps = DriverCapacities::derive(budget);
229        self.props = PropBodies::with_capacity(&caps);
230        self.rigs = Vec::with_capacity(caps.rigs);
231        self.new_props = Vec::with_capacity(caps.new_props);
232        self.motion_scratch = Vec::with_capacity(caps.root_motions);
233        self.contact_scratch = Vec::with_capacity(caps.contacts);
234        self.sensor_scratch = Vec::with_capacity(caps.sensor_crossings);
235        self.contact_batch = ContactBatch::with_capacity(caps.contact_pairs);
236        self.contact_gate = ContactGate::with_capacity(caps.contact_pairs);
237        self.sensor_filters = SortedMap::with_capacity(caps.sensor_filters);
238    }
239
240    // Build one body per collider-bearing entity from its per-instance
241    // components (Transform + Collider + optional BodyDynamics + the Pickup
242    // tag), keying `body_handles` by AssetId (via the name index's inverse)
243    // so the joint wiring resolves.
244    fn build_prop_bodies(
245        &mut self,
246        ctx: &PipelineContext,
247        world: &mut Simulation,
248        body_handles: &mut BTreeMap<AssetId, BodyHandle>,
249    ) {
250        let entity_name: BTreeMap<Entity, AssetId> = ctx
251            .resource::<EntityByName>()
252            .map(|n| n.0.iter().map(|(&id, &e)| (e, id)).collect())
253            .unwrap_or_default();
254        let pickup: BTreeSet<Entity> = ctx.query_with_entity::<Pickup>().map(|(e, _)| e).collect();
255        let dynamics: BTreeMap<Entity, BodyDynamics> = ctx
256            .query_with_entity::<BodyDynamics>()
257            .map(|(e, b)| (e, *b))
258            .collect();
259        let snaps: Vec<(Entity, PropCollSnap)> = ctx
260            .join2::<Collider, Transform>()
261            .map(|(entity, collider, transform)| {
262                (
263                    entity,
264                    PropCollSnap {
265                        shape: collider_shape(&collider.0, transform.scale),
266                        layer: collider.0.layer.clone(),
267                        position: transform.position,
268                        rotation_deg: transform.rotation_deg,
269                        pickup: pickup.contains(&entity),
270                        dynamics: dynamics.get(&entity).copied(),
271                    },
272                )
273            })
274            .collect();
275
276        for (entity, snap) in snaps {
277            let Some(handle) = self.props.add(&self.layers, world, entity, snap) else {
278                continue;
279            };
280            if let Some(&id) = entity_name.get(&entity) {
281                body_handles.insert(id, handle);
282            }
283        }
284    }
285}
286
287impl System for PhysicsSystem {
288    fn init(&mut self, ctx: &mut PipelineContext) {
289        // Before anything is built, and before the joint wiring below drains
290        // the column the scan counts.
291        let budget = self.resolve_budget(ctx);
292        self.body_cap = budget.body_cap();
293        self.reserve(&budget);
294
295        // The simulation reserves the whole budget here, so nothing on the
296        // step path allocates and a body past the reservation is refused
297        // rather than grown into.
298        let mut world = Simulation::new(
299            SimConfig {
300                gravity: GRAVITY,
301                ..SimConfig::default()
302            },
303            budget.body_cap() as usize,
304        );
305        world.set_contact_min_impulse(self.contact_min_impulse, SimTiming::TICK_DT);
306        // The step's per-worker scratch, reserved from the schedule this world
307        // will run under. A serial schedule reserves one worker's worth, which
308        // is what a simulation that is never lent threads keeps.
309        world.reserve_workers(
310            self.fanout
311                .worker_count(ScheduleMode::current(ctx.resources)),
312        );
313        let world_mask = self.layers.mask(LAYER_WORLD);
314
315        // floor: heightfield-mesh-driven, procedural noise, or flat slab
316        let mut floor_built = false;
317        if let Some(mesh_id) = self.terrain_mesh {
318            let mesh_snap = ctx
319                .query::<crate::components::ProceduralMesh>()
320                .find(|m| m.asset_id == mesh_id)
321                .cloned();
322            match mesh_snap {
323                Some(m) if m.generator == "heightfield" => {
324                    match build_heightfield_collider(
325                        &mut world,
326                        &m,
327                        self.terrain_offset_y,
328                        world_mask,
329                        ctx,
330                    ) {
331                        Ok(()) => floor_built = true,
332                        Err(e) => tracing::warn!(
333                            "physics: heightfield collider load failed ({}); falling back to flat slab",
334                            e
335                        ),
336                    }
337                }
338                Some(m) => {
339                    tracing::warn!(
340                        "physics: terrain_mesh '{}' has generator '{}', expected 'heightfield'; falling back",
341                        mesh_id,
342                        m.generator
343                    );
344                }
345                None => {
346                    tracing::warn!(
347                        "physics: terrain_mesh asset {} not found; falling back",
348                        mesh_id
349                    );
350                }
351            }
352        }
353        if !floor_built {
354            let floor = if let Some(terrain) = self.terrain.clone() {
355                build_heightfield(&mut world, &terrain, world_mask)
356            } else {
357                // A large thin slab whose top face sits at Y = 0.
358                world.add_fixed(
359                    &ColliderShape::Cuboid {
360                        half_extents: [500.0, 5.0, 500.0],
361                    },
362                    [0.0, -5.0, 0.0],
363                    [0.0; 3],
364                    STATIC_FRICTION,
365                    world_mask,
366                )
367            };
368            if floor.is_none() {
369                tracing::error!("physics: the world's reservation had no room for its floor");
370            }
371        }
372
373        // Sensor regions: one fixed sensor body per TriggerVolume, tagged with
374        // the volume's AssetId so step's crossing drain maps back to it.
375        let trigger_mask = self.layers.mask(LAYER_TRIGGER);
376        let volumes: Vec<TriggerVolume> = ctx.query::<TriggerVolume>().cloned().collect();
377        for volume in &volumes {
378            let shape = collider_shape(&volume.collider, [1.0; 3]);
379            let tag = u64::from(volume.asset_id.0);
380            if world
381                .add_sensor(
382                    &shape,
383                    volume.position,
384                    volume.rotation_deg,
385                    tag,
386                    trigger_mask,
387                )
388                .is_none()
389            {
390                continue;
391            }
392            self.sensor_filters
393                .insert(tag, (volume.asset_id, volume.detects));
394        }
395        if !volumes.is_empty() {
396            tracing::debug!("PhysicsSystem: {} trigger volume(s)", volumes.len());
397        }
398
399        // Prop name -> BodyHandle, populated alongside `self.prop_bodies`.
400        // Joints resolve their `body_a`/`body_b` references through this map.
401        let mut body_handles: BTreeMap<AssetId, BodyHandle> = BTreeMap::new();
402        self.build_prop_bodies(ctx, &mut world, &mut body_handles);
403        tracing::debug!(
404            "PhysicsSystem: {} prop bodies ({} dynamic)",
405            self.props.len(),
406            self.props.dynamic_count(),
407        );
408
409        // joints
410        // Each PhysicsJoint references one or two Props by AssetId. Cross-reference
411        // validation already guarantees the Prop exists; here we additionally
412        // require the Prop to own a collider (and therefore a body). A PhysicsJoint
413        // with body_b empty anchors body_a to a hidden static body created on
414        // demand at the world-space `anchor_b`.
415        let joints: Vec<PhysicsJoint> = ctx.drain::<PhysicsJoint>();
416        let mut wired = 0usize;
417        for joint in joints {
418            let Some(body_a_id) = joint.body_a else {
419                tracing::warn!(
420                    "PhysicsJoint '{}': body_a is required; skipping",
421                    joint.asset_id
422                );
423                continue;
424            };
425            let Some(handle_a) = body_handles.get(&body_a_id).copied() else {
426                tracing::warn!(
427                    "PhysicsJoint '{}': body_a Prop has no collider; skipping",
428                    joint.asset_id
429                );
430                continue;
431            };
432            let handle_b = if let Some(body_b_id) = joint.body_b {
433                match body_handles.get(&body_b_id).copied() {
434                    Some(h) => h,
435                    None => {
436                        tracing::warn!(
437                            "PhysicsJoint '{}': body_b Prop has no collider; skipping",
438                            joint.asset_id
439                        );
440                        continue;
441                    }
442                }
443            } else {
444                // Static world anchor at anchor_b. Sub-millimetre ball so it
445                // takes effectively no space in the broad phase.
446                let anchor = world.add_fixed(
447                    &ColliderShape::Ball { radius: 0.001 },
448                    joint.anchor_b,
449                    [0.0; 3],
450                    0.0,
451                    world_mask,
452                );
453                match anchor {
454                    Some(handle) => handle,
455                    None => continue,
456                }
457            };
458            // When body_b is the implicit world anchor, the anchor sits at the
459            // origin of that hidden body, not at the authored offset.
460            let anchor_b = if joint.body_b.is_some() {
461                joint.anchor_b
462            } else {
463                [0.0, 0.0, 0.0]
464            };
465            if !world.add_joint(
466                handle_a,
467                handle_b,
468                joint.anchor_a,
469                anchor_b,
470                joint_spec(&joint),
471            ) {
472                tracing::warn!(
473                    "PhysicsJoint '{}': the simulation declined it; skipping",
474                    joint.asset_id
475                );
476                continue;
477            }
478            wired += 1;
479        }
480        if wired > 0 {
481            tracing::debug!("PhysicsSystem: wired {} joint(s)", wired);
482        }
483
484        // player capsule for the Camera3D
485        // Every first-person camera is collided as a capsule. A RigidBody
486        // upgrades it from a free-flying spectator to a grounded,
487        // gravity-bound character. A third-person camera (a controller with
488        // a `follow` block) gets no capsule: it is a virtual orbit around the
489        // followed character, whose own rig capsule is the collided body.
490        let camera_pos = ctx
491            .query::<Camera3D>()
492            .next()
493            .filter(|c| {
494                c.controller
495                    .as_ref()
496                    .is_none_or(|ctrl| ctrl.follow.is_none())
497            })
498            .map(|c| c.position);
499        if let Some(cam_pos) = camera_pos {
500            let rb_opt = ctx.query::<RigidBody>().next().cloned();
501            let has_gravity = rb_opt.is_some();
502            let rb = rb_opt.unwrap_or_default();
503            if self.floor_y == 0.0 {
504                self.floor_y = cam_pos[1];
505            }
506            let radius = rb.capsule_radius.max(0.05);
507            let half_height = ((rb.capsule_height * 0.5) - radius).max(0.05);
508            // a grounded character's eye sits at the capsule top; a flying
509            // camera's capsule is centred on the eye.
510            let eye_offset = if has_gravity {
511                (rb.capsule_height * 0.5).max(radius + 0.05)
512            } else {
513                0.0
514            };
515            let center = [cam_pos[0], cam_pos[1] - eye_offset, cam_pos[2]];
516            world.configure_character(rb.max_slope_deg, rb.step_height, has_gravity);
517            let handle = world.add_character(
518                half_height,
519                radius,
520                center,
521                self.layers.mask(LAYER_CHARACTER),
522            );
523            self.player = handle.map(|handle| PlayerPhysics {
524                handle,
525                shape: CharacterCapsule::new(half_height, radius),
526                eye_offset,
527                has_gravity,
528                gravity_scale: rb.gravity_scale.max(0.0),
529                jump_height: rb.jump_height.max(0.0),
530                vy: 0.0,
531                grounded: true,
532                center: PointInterp::new(center),
533                written_eye: None,
534            });
535            tracing::debug!(
536                "PhysicsSystem: player capsule r={:.2} h={:.2} gravity={}",
537                radius,
538                half_height,
539                has_gravity,
540            );
541        }
542
543        // Kinematic capsules for the root-motion character rigs published by
544        // GraphicsSystem (which ran init first this tick).
545        super::rig::init_rigs(
546            &mut world,
547            ctx,
548            self.layers.mask(LAYER_CHARACTER),
549            &mut self.rigs,
550        );
551
552        // Everything the budget reserved has now been built. A shortfall means
553        // the counts the reservation came from disagree with what the world
554        // actually holds, which leaves bodies missing from the simulation
555        // rather than merely mis-sized.
556        let built = world.body_count() as u32;
557        if built != budget.body_total() {
558            tracing::error!(
559                "physics: the world built {} of the {} bodies its budget reserved",
560                built,
561                budget.body_total()
562            );
563        }
564
565        // Published from the built world: the simulation's own storage is only
566        // knowable once it is reserved.
567        super::budget::publish_reservation(concinnity_memory::ledger(), &budget, &world);
568        self.world = Some(world);
569    }
570
571    fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
572        // Freeze while a menu is open: skip the solve and the write-back so the
573        // world truly pauses (and an external editor's edits to simulated
574        // Transforms are not stomped by a stale blend). The simulation clock
575        // holds its accumulator across the pause, so resuming costs one normal
576        // frame. The flag is published by whichever system owns the menu, which
577        // runs first in the table, so it reflects this same tick.
578        if ctx.resource::<MenuActive>().is_some_and(|m| m.0) {
579            return StepResult::Continue;
580        }
581
582        if self.world.is_none() {
583            return StepResult::Continue;
584        }
585
586        // The frame's fixed-tick budget and render blend factor. Absent (a
587        // directly-stepped world with no App), every step runs exactly one tick
588        // and writes the freshly simulated state.
589        let timing = ctx.resource::<SimTiming>().copied().unwrap_or_default();
590
591        // snapshot reads (released before any query_mut below)
592        let (cam_pos, cam_yaw, cam_pitch, desired_move, jump_req, interact_req) = ctx
593            .query::<Camera3D>()
594            .next()
595            .map(|c| {
596                (
597                    c.position,
598                    c.yaw,
599                    c.pitch,
600                    c.desired_move,
601                    c.jump_requested,
602                    c.interact_requested,
603                )
604            })
605            .unwrap_or(([0.0, self.floor_y, 0.0], 0.0, 0.0, [0.0; 3], false, false));
606
607        // camera-space basis vectors
608        let fwd_flat = [-sin(cam_yaw), 0.0, -cos(cam_yaw)];
609        let fwd_full = [
610            -(sin(cam_yaw) * cos(cam_pitch)),
611            -sin(cam_pitch),
612            -(cos(cam_yaw) * cos(cam_pitch)),
613        ];
614
615        // Whichever pool the schedule names, read once for the frame. Both
616        // land in the same place; only how long the step takes differs.
617        let mode = ScheduleMode::current(ctx.resources);
618
619        let world = self.world.as_mut().expect("world checked above");
620
621        // Reap bodies whose entity was despawned before the step, keeping
622        // self.held a valid index into the compacted list.
623        self.held = self
624            .props
625            .reap(world, self.held, |entity| ctx.is_alive(entity));
626
627        // Adopt collider-bearing entities that appeared since init (runtime
628        // spawns): each gets a body at its spawn transform, with its pose
629        // snapshots seeded there so the render blend starts clean. The scan
630        // materializes into scratch because adopting one mutates the tracked
631        // set the scan itself reads.
632        self.new_props.clear();
633        self.new_props.extend(
634            ctx.join2::<Collider, Transform>()
635                .filter(|(entity, _, _)| {
636                    !self.props.is_tracked(*entity) && !self.props.is_refused(*entity)
637                })
638                .map(|(entity, collider, transform)| {
639                    (
640                        entity,
641                        PropCollSnap {
642                            shape: collider_shape(&collider.0, transform.scale),
643                            layer: collider.0.layer.clone(),
644                            position: transform.position,
645                            rotation_deg: transform.rotation_deg,
646                            pickup: false,
647                            dynamics: None,
648                        },
649                    )
650                }),
651        );
652        for (entity, mut snap) in self.new_props.drain(..) {
653            snap.pickup = ctx.get::<Pickup>(entity).is_some();
654            snap.dynamics = ctx.get::<BodyDynamics>(entity).copied();
655            self.props
656                .adopt(&self.layers, world, entity, snap, self.body_cap);
657        }
658
659        // pickup / drop on the interact edge; held_changed carries the entity to
660        // toggle the Held tag on in the write-back.
661        let mut held_changed: Option<(Entity, bool)> = None;
662        if interact_req {
663            if let Some(held_idx) = self.held.take() {
664                // drop: hand the prop back to dynamic simulation with a throw.
665                let pp = self.props.get(held_idx).expect("held index is valid");
666                let throw = [
667                    fwd_full[0] * THROW_SPEED,
668                    fwd_full[1] * THROW_SPEED + 1.0,
669                    fwd_full[2] * THROW_SPEED,
670                ];
671                world.make_dynamic(pp.handle, throw);
672                held_changed = Some((pp.entity, false));
673            } else {
674                // pickup: nearest carriable prop within reach the player faces.
675                // Entity positions for the reach test, read from the Transform
676                // column only on the interact edge (not every frame).
677                let entity_positions: BTreeMap<Entity, [f32; 3]> = ctx
678                    .query_with_entity::<Transform>()
679                    .map(|(e, t)| (e, t.position))
680                    .collect();
681                let mut best: Option<(f32, usize)> = None;
682                for (idx, pp) in self.props.iter().enumerate() {
683                    if !pp.pickup {
684                        continue;
685                    }
686                    let pos = entity_positions.get(&pp.entity).copied().unwrap_or(cam_pos);
687                    let dx = pos[0] - cam_pos[0];
688                    let dz = pos[2] - cam_pos[2];
689                    let dist = sqrt(dx * dx + dz * dz);
690                    if dist >= PICKUP_REACH || dist <= 0.0 {
691                        continue;
692                    }
693                    let dot = (fwd_flat[0] * dx + fwd_flat[2] * dz) / dist;
694                    if dot > PICKUP_MIN_DOT && best.is_none_or(|(d, _)| dist < d) {
695                        best = Some((dist, idx));
696                    }
697                }
698                if let Some((_, idx)) = best {
699                    let pp = self.props.get(idx).expect("scanned index is valid");
700                    world.make_kinematic(pp.handle);
701                    held_changed = Some((pp.entity, true));
702                    self.held = Some(idx);
703                }
704            }
705        }
706
707        // Adopt an externally moved camera (free-fly, a teleport): a position
708        // that differs from the eye written back last frame was not ours, so
709        // the capsule snaps to it with no blend across the jump.
710        if let Some(player) = self.player.as_mut()
711            && player.written_eye != Some(cam_pos)
712        {
713            player
714                .center
715                .snap([cam_pos[0], cam_pos[1] - player.eye_offset, cam_pos[2]]);
716        }
717
718        // The carried prop's hover point in front of the camera, refreshed
719        // from this frame's camera pose.
720        let hold_pos = [
721            cam_pos[0] + fwd_full[0] * HOLD_DISTANCE,
722            cam_pos[1] + fwd_full[1] * HOLD_DISTANCE - HOLD_DROP,
723            cam_pos[2] + fwd_full[2] * HOLD_DISTANCE,
724        ];
725
726        // Root-motion displacements published since last frame, applied on the
727        // frame's first tick. Rig capsules whose entity moved externally snap
728        // before any tick runs.
729        super::rig::drain_motions_into(ctx, &mut self.root_cursor, &mut self.motion_scratch);
730        super::rig::sync_rigs(ctx, &mut self.rigs);
731
732        for tick in 0..timing.ticks {
733            let dt = timing.tick_dt;
734
735            // carried prop hovers in front of the camera
736            if let Some(prop) = self.held.and_then(|idx| self.props.get(idx)) {
737                world.set_kinematic_translation(prop.handle, hold_pos);
738            }
739
740            // move the player capsule
741            if let Some(player) = self.player.as_mut() {
742                if player.has_gravity {
743                    if tick == 0 && jump_req && player.grounded && player.jump_height > 0.0 {
744                        player.vy = sqrt(2.0 * GRAVITY * player.gravity_scale * player.jump_height);
745                    }
746                    player.vy -= GRAVITY * player.gravity_scale * dt;
747                }
748
749                let center = player.center.current();
750                let desired = [desired_move[0] * dt, player.vy * dt, desired_move[2] * dt];
751                let moved = world.move_character(
752                    &player.shape,
753                    &CharacterMoveInput {
754                        center,
755                        desired,
756                        dt,
757                        exclude: player.handle,
758                        mask: self.layers.mask(LAYER_CHARACTER),
759                    },
760                );
761                let new_center = [
762                    center[0] + moved.translation[0],
763                    center[1] + moved.translation[1],
764                    center[2] + moved.translation[2],
765                ];
766                world.set_kinematic_translation(player.handle, new_center);
767
768                player.grounded = moved.grounded;
769                if moved.grounded && player.vy < 0.0 {
770                    player.vy = 0.0;
771                }
772                player.center.push(new_center);
773            }
774
775            // move the root-motion character rig capsules
776            super::rig::tick_rigs(
777                world,
778                ctx,
779                &mut self.rigs,
780                if tick == 0 { &self.motion_scratch } else { &[] },
781                dt,
782                GRAVITY,
783                self.layers.mask(LAYER_CHARACTER),
784            );
785
786            // advance the simulation
787            self.fanout.step(world, dt, mode);
788
789            // batch the tick's contact hits (strongest per pair this frame)
790            self.contact_gate.advance_tick();
791            world.drain_contact_hits_into(&mut self.contact_scratch);
792            for hit in self.contact_scratch.drain(..) {
793                self.contact_batch.add(hit);
794            }
795
796            // record the tick's dynamic prop poses for the render blend
797            self.props.record_tick_poses(world);
798        }
799
800        // answer the IK ground probes and the follow camera's occlusion probe
801        super::probes::step_probes(
802            world,
803            ctx,
804            &self.rigs,
805            self.layers
806                .query_mask(LAYER_CHARACTER, &[LAYER_WORLD, LAYER_PROP]),
807        );
808
809        // publish the frame's contact events: one per body pair that passed
810        // the impulse threshold, gated by the per-pair refractory. `a` is
811        // always a prop entity; a hit whose sides both lack one (terrain,
812        // capsules) has no consumer-visible subject and is dropped.
813        for hit in self.contact_batch.drain() {
814            let event = match (self.props.entity_of(hit.a), self.props.entity_of(hit.b)) {
815                (Some(a), b) => ContactEvent {
816                    a,
817                    b,
818                    point: hit.point,
819                    normal: hit.normal,
820                    impulse: hit.impulse,
821                },
822                (None, Some(b)) => ContactEvent {
823                    a: b,
824                    b: None,
825                    point: hit.point,
826                    normal: [-hit.normal[0], -hit.normal[1], -hit.normal[2]],
827                    impulse: hit.impulse,
828                },
829                (None, None) => continue,
830            };
831            if self.contact_gate.admit(&hit) {
832                ctx.events_mut::<ContactEvent>().send(event);
833            }
834        }
835
836        // publish the sensor boundary crossings that pass their volume's
837        // filter. A crossing whose body was removed this same step has no
838        // `other` to classify, so only an `any` volume reports it.
839        world.drain_sensor_crossings_into(&mut self.sensor_scratch);
840        for crossing in self.sensor_scratch.drain(..) {
841            let Some(&(volume, filter)) = self.sensor_filters.get(&crossing.tag) else {
842                continue;
843            };
844            let passes = match filter {
845                TriggerFilter::Player => crossing.other.is_some_and(|h| {
846                    self.player.as_ref().is_some_and(|p| p.handle == h)
847                        || self.rigs.iter().any(|r| r.handle == h)
848                }),
849                TriggerFilter::Props => crossing
850                    .other
851                    .is_some_and(|h| self.props.entity_of(h).is_some()),
852                TriggerFilter::Any => true,
853            };
854            if passes {
855                ctx.events_mut::<VolumeEvent>().send(VolumeEvent {
856                    volume,
857                    entered: crossing.entered,
858                });
859            }
860        }
861
862        // Write each dynamic prop's blended pose back to its Transform:
863        // positions lerped, rotations slerped as quaternions, with the Euler
864        // decomposition happening only here at the write boundary.
865        let alpha = timing.alpha;
866        for &(entity, pos, rot) in self.props.sample_poses(alpha) {
867            if let Some(t) = ctx.get_mut::<Transform>(entity) {
868                t.position = pos;
869                t.rotation_deg = rot;
870            }
871        }
872        if let Some((entity, is_held)) = held_changed {
873            if is_held {
874                if ctx.get::<Held>(entity).is_none() {
875                    ctx.insert(entity, Held);
876                }
877            } else {
878                ctx.remove::<Held>(entity);
879            }
880        }
881
882        // write the blended camera position + view matrix
883        let mut grounded = true;
884        if let Some(player) = self.player.as_mut() {
885            let center = player.center.sample(alpha);
886            let eye = [center[0], center[1] + player.eye_offset, center[2]];
887            player.written_eye = Some(eye);
888            grounded = player.grounded;
889            for camera in ctx.query_mut::<Camera3D>() {
890                camera.position = eye;
891                camera.view_matrix =
892                    crate::gfx::camera::view_matrix(camera.position, camera.yaw, camera.pitch);
893            }
894        }
895
896        // publish grounded state for jump gating
897        for body in ctx.query_mut::<RigidBody>() {
898            body.is_grounded = grounded;
899        }
900
901        // write the blended rig positions for the render follow
902        super::rig::publish_rigs(ctx, &mut self.rigs, alpha);
903
904        StepResult::Continue
905    }
906}
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911    use alloc::string::ToString;
912    use alloc::vec;
913
914    use crate::components::{CameraController, CharacterRig, FollowController, PropCollider};
915    use crate::ecs::SkinnedMeshHandle;
916    use crate::physics::budget::{record_of, scan_counts};
917    use crate::physics::test_world::TestWorld;
918
919    // Make a spawned prop dynamic, exactly as the load-time PropBody
920    // decomposition would.
921    fn make_dynamic(world: &mut TestWorld, entity: Entity) {
922        world.components.insert_typed(entity, ball_dynamics());
923    }
924
925    fn controlled_camera() -> Camera3D {
926        Camera3D {
927            fov_y_degrees: 75.0,
928            near: 0.05,
929            far: 200.0,
930            view_matrix: [[0.0; 4]; 4],
931            position: [0.0, 1.0, 0.0],
932            yaw: 0.0,
933            pitch: 0.0,
934            desired_move: [0.0; 3],
935            jump_requested: false,
936            interact_requested: false,
937            controller: Some(CameraController::default()),
938        }
939    }
940
941    // A third-person camera is a virtual orbit: no player capsule is created
942    // for it. (Regression: the spectator capsule spawned at the camera eye
943    // overlapped the followed rig's capsule and squeezed it through the
944    // floor.) A first-person camera keeps its capsule. The schedule gate that
945    // builds the system at all is covered by the engine's schedule tests.
946    #[test]
947    fn third_person_camera_gets_no_player_capsule() {
948        // Third-person (follow) camera: a virtual orbit, so no player capsule.
949        let mut world = TestWorld::new();
950        let mut camera = controlled_camera();
951        camera.controller = Some(CameraController {
952            follow: Some(FollowController {
953                target: Some(SkinnedMeshHandle(1)),
954                ..FollowController::default()
955            }),
956            ..CameraController::default()
957        });
958        world.components.push_typed(camera);
959        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
960        physics.init(&mut world.ctx());
961        assert!(physics.player.is_none(), "no capsule for the orbit camera");
962
963        // First-person camera keeps its capsule.
964        let mut world = TestWorld::new();
965        world.components.push_typed(controlled_camera());
966        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
967        physics.init(&mut world.ctx());
968        assert!(
969            physics.player.is_some(),
970            "first-person camera keeps its capsule"
971        );
972    }
973
974    fn ball_dynamics() -> BodyDynamics {
975        BodyDynamics {
976            mass: 1.0,
977            friction: 0.5,
978            linear_damping: 0.0,
979            ..Default::default()
980        }
981    }
982
983    // A camera looking down -Z with the interact field latched on, so the
984    // PhysicsSystem (which reads Camera3D.interact_requested) triggers a pickup.
985    fn interacting_camera(position: [f32; 3]) -> Camera3D {
986        Camera3D {
987            interact_requested: true,
988            controller: None,
989            position,
990            ..controlled_camera()
991        }
992    }
993
994    // The simulated pose is written back to the prop's Transform. With no
995    // `SimTiming` published, each step runs exactly one fixed tick.
996    #[test]
997    fn dynamic_prop_writes_transform() {
998        let id = AssetId(1);
999        let mut world = TestWorld::new();
1000        let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
1001        make_dynamic(&mut world, entity);
1002
1003        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1004        physics.init(&mut world.ctx());
1005        for _ in 0..10 {
1006            physics.step(&mut world.ctx());
1007        }
1008
1009        let transform_y = world.components.get::<Transform>(entity).unwrap().position[1];
1010        assert!(
1011            transform_y < 5.0,
1012            "the simulated pose falls the Transform (y={transform_y})"
1013        );
1014    }
1015
1016    // A menu freezes the solve: while `MenuActive(true)` is published the body
1017    // does not fall, and clearing it resumes the fall from where it froze.
1018    // (The App-level simulation clock additionally holds its accumulator
1019    // across the pause, so a live run resumes without a catch-up burst.)
1020    #[test]
1021    fn menu_active_freezes_then_resumes_physics() {
1022        let id = AssetId(1);
1023        let mut world = TestWorld::new();
1024        let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
1025        make_dynamic(&mut world, entity);
1026
1027        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1028        physics.init(&mut world.ctx());
1029
1030        // Paused: the body stays put however many frames pass.
1031        world.resources.insert(MenuActive(true));
1032        for _ in 0..5 {
1033            physics.step(&mut world.ctx());
1034        }
1035        let y_paused = world.components.get::<Transform>(entity).unwrap().position[1];
1036        assert!(
1037            (y_paused - 5.0).abs() < 1e-3,
1038            "the body must not fall while a menu is active (y={y_paused})"
1039        );
1040
1041        // Resumed: the body falls again.
1042        world.resources.insert(MenuActive(false));
1043        for _ in 0..5 {
1044            physics.step(&mut world.ctx());
1045        }
1046        let y_resumed = world.components.get::<Transform>(entity).unwrap().position[1];
1047        assert!(
1048            y_resumed < y_paused - 1e-3,
1049            "the body must fall once the menu closes (y={y_resumed})"
1050        );
1051    }
1052
1053    // A zero-tick frame (the accumulator has not crossed a tick) advances
1054    // nothing; the written Transform blends between the last two ticks by the
1055    // frame's alpha.
1056    #[test]
1057    fn zero_tick_frames_blend_between_the_last_two_ticks() {
1058        let id = AssetId(1);
1059        let mut world = TestWorld::new();
1060        let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
1061        make_dynamic(&mut world, entity);
1062
1063        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1064        physics.init(&mut world.ctx());
1065
1066        let tick = |ticks, alpha| SimTiming {
1067            ticks,
1068            tick_dt: SimTiming::TICK_DT,
1069            alpha,
1070        };
1071        world.resources.insert(tick(1, 1.0));
1072        physics.step(&mut world.ctx());
1073        let y_prev = world.components.get::<Transform>(entity).unwrap().position[1];
1074        physics.step(&mut world.ctx());
1075        let y_curr = world.components.get::<Transform>(entity).unwrap().position[1];
1076        assert!(y_curr < y_prev, "the body falls tick over tick");
1077
1078        // No tick, alpha 0: the write-back returns to the previous tick's pose.
1079        world.resources.insert(tick(0, 0.0));
1080        physics.step(&mut world.ctx());
1081        let y_alpha0 = world.components.get::<Transform>(entity).unwrap().position[1];
1082        assert!(
1083            (y_alpha0 - y_prev).abs() < 1e-6,
1084            "alpha 0 samples the previous tick"
1085        );
1086
1087        // No tick, alpha 0.5: halfway between the two ticks.
1088        world.resources.insert(tick(0, 0.5));
1089        physics.step(&mut world.ctx());
1090        let y_mid = world.components.get::<Transform>(entity).unwrap().position[1];
1091        let expected = (y_prev + y_curr) * 0.5;
1092        assert!(
1093            (y_mid - expected).abs() < 1e-6,
1094            "alpha 0.5 blends the tick poses (y={y_mid}, expected {expected})"
1095        );
1096    }
1097
1098    // The same number of fixed ticks produces bit-identical world state
1099    // however they are grouped into frames: 30 fps frames (two ticks each)
1100    // against 120 fps frames (a tick every other frame).
1101    #[test]
1102    fn tick_grouping_does_not_change_the_outcome() {
1103        let run = |frames: &[u32]| -> ([f32; 3], [f32; 3]) {
1104            let id = AssetId(1);
1105            let mut world = TestWorld::new();
1106            let entity = world.spawn_prop(id, [0.3, 5.0, 0.1], false);
1107            make_dynamic(&mut world, entity);
1108
1109            let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1110            physics.init(&mut world.ctx());
1111            for &ticks in frames {
1112                world.resources.insert(SimTiming {
1113                    ticks,
1114                    tick_dt: SimTiming::TICK_DT,
1115                    alpha: 1.0,
1116                });
1117                physics.step(&mut world.ctx());
1118            }
1119            let t = world.components.get::<Transform>(entity).unwrap();
1120            (t.position, t.rotation_deg)
1121        };
1122
1123        // One simulated second: 30 frames of 2 ticks vs 120 frames alternating
1124        // 0 and 1 ticks. Both run exactly 60 fixed ticks.
1125        let thirty: Vec<u32> = core::iter::repeat_n(2, 30).collect();
1126        let one_twenty: Vec<u32> = (0..120).map(|i| i % 2).collect();
1127        assert_eq!(
1128            run(&thirty),
1129            run(&one_twenty),
1130            "the fixed-tick outcome must not depend on frame grouping"
1131        );
1132    }
1133
1134    // Despawning a decomposed prop reaps its body, so it stops simulating
1135    // (and colliding) once its entity is gone.
1136    #[test]
1137    fn despawning_a_prop_reaps_its_physics_body() {
1138        let id = AssetId(1);
1139        let mut world = TestWorld::new();
1140        let ball = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
1141        make_dynamic(&mut world, ball);
1142
1143        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1144        physics.init(&mut world.ctx());
1145
1146        // Settle so the body is live and falling.
1147        for _ in 0..2 {
1148            physics.step(&mut world.ctx());
1149        }
1150        let before = physics.physics_body_count();
1151
1152        // Despawn the ball (stand-in for GraphicsSystem) and step: PhysicsSystem
1153        // reaps the orphaned body.
1154        world.components.despawn(ball);
1155        physics.step(&mut world.ctx());
1156        let after = physics.physics_body_count();
1157        assert_eq!(after, before - 1, "the despawned prop's body was removed");
1158
1159        // The sim keeps running cleanly with the body gone (no further removals).
1160        physics.step(&mut world.ctx());
1161        assert_eq!(
1162            physics.physics_body_count(),
1163            after,
1164            "no further bodies removed"
1165        );
1166    }
1167
1168    // Picking up a carriable prop tags its entity with Held.
1169    #[test]
1170    fn pickup_sets_held_tag() {
1171        let id = AssetId(1);
1172        let mut world = TestWorld::new();
1173        let carriable = world.spawn_prop(id, [0.0, 1.0, -2.0], true);
1174        make_dynamic(&mut world, carriable);
1175        world
1176            .components
1177            .push_typed(interacting_camera([0.0, 1.0, 0.0]));
1178
1179        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1180        physics.init(&mut world.ctx());
1181
1182        physics.step(&mut world.ctx());
1183
1184        assert_eq!(
1185            world.ctx().query::<Held>().count(),
1186            1,
1187            "pickup inserts the Held tag on the entity"
1188        );
1189    }
1190
1191    // A collider-bearing entity that appears after init (a runtime spawn) is
1192    // adopted on the next step: it gets a body and falls like an authored one.
1193    // The headroom is what reserves the body for it: the simulation is sized
1194    // once at init and a spawn past the reservation is refused.
1195    #[test]
1196    fn runtime_spawned_prop_gets_a_body_and_falls() {
1197        let mut world = TestWorld::new();
1198        let mut physics = PhysicsSystem::new(PhysicsConfig {
1199            spawn_headroom: 1,
1200            ..PhysicsConfig::default()
1201        });
1202        physics.init(&mut world.ctx());
1203        let baseline = physics.physics_body_count();
1204
1205        let spawned = world.spawn_prop(AssetId(7), [0.0, 5.0, 0.0], false);
1206        make_dynamic(&mut world, spawned);
1207        physics.step(&mut world.ctx());
1208        assert_eq!(
1209            physics.physics_body_count(),
1210            baseline + 1,
1211            "the spawned entity got a body on its first step"
1212        );
1213        for _ in 0..30 {
1214            physics.step(&mut world.ctx());
1215        }
1216        let y = world.components.get::<Transform>(spawned).unwrap().position[1];
1217        assert!(y < 4.5, "the spawned body falls (y = {y})");
1218        for _ in 0..300 {
1219            physics.step(&mut world.ctx());
1220        }
1221        let y = world.components.get::<Transform>(spawned).unwrap().position[1];
1222        assert!(
1223            (y - 0.5).abs() < 0.1,
1224            "the spawned ball rests on the flat floor (y = {y})"
1225        );
1226    }
1227
1228    // Spawn, despawn, and respawn leave no bodies or colliders behind: a
1229    // reaped body hands its slot back, so one body's worth of headroom covers
1230    // any number of rounds.
1231    #[test]
1232    fn spawn_despawn_respawn_cycle_is_leak_free() {
1233        let mut world = TestWorld::new();
1234        let mut physics = PhysicsSystem::new(PhysicsConfig {
1235            spawn_headroom: 1,
1236            ..PhysicsConfig::default()
1237        });
1238        physics.init(&mut world.ctx());
1239        let bodies = physics.physics_body_count();
1240        let colliders = physics.physics_collider_count();
1241
1242        for round in 0..3 {
1243            let spawned = world.spawn_prop(AssetId(100 + round), [0.0, 3.0, 0.0], false);
1244            make_dynamic(&mut world, spawned);
1245            physics.step(&mut world.ctx());
1246            assert_eq!(physics.physics_body_count(), bodies + 1, "round {round}");
1247            world.components.despawn(spawned);
1248            physics.step(&mut world.ctx());
1249            assert_eq!(
1250                physics.physics_body_count(),
1251                bodies,
1252                "round {round} reaped the body"
1253            );
1254            assert_eq!(
1255                physics.physics_collider_count(),
1256                colliders,
1257                "round {round} reaped the collider"
1258            );
1259        }
1260    }
1261
1262    // A world whose shipped budget was counted from the same content it holds
1263    // passes the init assert, and reserves the cap that budget implies.
1264    #[test]
1265    fn a_shipped_budget_matching_the_world_is_adopted() {
1266        let mut world = TestWorld::new();
1267        let entity = world.spawn_prop(AssetId(1), [0.0, 3.0, 0.0], false);
1268        make_dynamic(&mut world, entity);
1269        world.components.push_typed(controlled_camera());
1270
1271        // The record cook would have written for this world: one dynamic prop,
1272        // the floor, the player capsule, and room for two spawns.
1273        let counts = scan_counts(&world.ctx());
1274        let budget = PhysicsBudget::derive(&counts, 2);
1275        assert_eq!(budget.dynamic, 1);
1276        assert_eq!(budget.kinematic, 1, "the first-person camera capsule");
1277        world
1278            .resources
1279            .insert(WorldPhysicsBudget(record_of(&budget)));
1280
1281        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1282        physics.init(&mut world.ctx());
1283        assert_eq!(physics.body_cap, budget.body_cap());
1284        assert_eq!(
1285            physics.physics_body_count(),
1286            budget.body_total() as usize,
1287            "init built exactly the bodies the budget reserved"
1288        );
1289    }
1290
1291    // A world with no shipped budget (built in memory, as every test world is)
1292    // reserves from what it holds plus the headroom its config authored, and
1293    // caps spawns there. The cap used to be left open for such a world, on the
1294    // grounds that nothing had counted its spawns; a fixed-capacity simulation
1295    // cannot honour that -- a spawn past the reservation gets no body either
1296    // way, and the cap is what turns a silently declined one into a refusal
1297    // naming the knob to raise.
1298    #[test]
1299    fn a_world_without_a_shipped_budget_reserves_from_what_it_holds() {
1300        let mut world = TestWorld::new();
1301        let entity = world.spawn_prop(AssetId(1), [0.0, 3.0, 0.0], false);
1302        make_dynamic(&mut world, entity);
1303
1304        let mut physics = PhysicsSystem::new(PhysicsConfig {
1305            spawn_headroom: 4,
1306            ..PhysicsConfig::default()
1307        });
1308        physics.init(&mut world.ctx());
1309
1310        assert_eq!(
1311            physics.physics_body_count(),
1312            2,
1313            "the floor and the one authored prop"
1314        );
1315        assert_eq!(physics.body_cap, 2 + 4, "plus the authored headroom");
1316    }
1317
1318    // Past the cap, a spawned prop gets no body and the world keeps stepping.
1319    // The refusal happens once: the next tick's scan passes over the entity
1320    // rather than re-refusing it.
1321    #[test]
1322    fn a_spawn_past_the_shipped_budget_is_refused() {
1323        let mut world = TestWorld::new();
1324        let authored = world.spawn_prop(AssetId(1), [0.0, 3.0, 0.0], false);
1325        make_dynamic(&mut world, authored);
1326
1327        // A budget with no headroom: the floor and the one authored prop.
1328        let counts = scan_counts(&world.ctx());
1329        let budget = PhysicsBudget::derive(&counts, 0);
1330        world
1331            .resources
1332            .insert(WorldPhysicsBudget(record_of(&budget)));
1333
1334        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1335        physics.init(&mut world.ctx());
1336        let full = physics.physics_body_count();
1337        assert_eq!(full, budget.body_cap() as usize, "the budget is spent");
1338
1339        let spawned = world.spawn_prop(AssetId(2), [0.0, 6.0, 0.0], false);
1340        make_dynamic(&mut world, spawned);
1341        physics.step(&mut world.ctx());
1342        assert_eq!(physics.physics_body_count(), full, "no body was built");
1343        assert!(physics.props.is_refused(spawned));
1344
1345        // Stepping on keeps the refusal: the entity is skipped, not retried,
1346        // and the authored prop carries on simulating.
1347        for _ in 0..10 {
1348            physics.step(&mut world.ctx());
1349        }
1350        assert_eq!(physics.physics_body_count(), full);
1351        let refused_y = world.components.get::<Transform>(spawned).unwrap().position[1];
1352        assert_eq!(refused_y, 6.0, "a refused prop is not simulated at all");
1353        let live_y = world
1354            .components
1355            .get::<Transform>(authored)
1356            .unwrap()
1357            .position[1];
1358        assert!(live_y < 3.0, "the authored prop still falls");
1359    }
1360
1361    // A rig capsule authored standing exactly on the floor stays there: it
1362    // neither sinks tick after tick nor is lifted off it. The driver used to
1363    // spawn one a fingernail above its authored position, because the
1364    // controller ignored a hit a downward move started already touching and
1365    // the capsule sank a little every frame; the controller now separates
1366    // along the contact normal instead, so the authored position is the one
1367    // that holds.
1368    #[test]
1369    fn a_rig_capsule_spawned_on_the_floor_neither_sinks_nor_rises() {
1370        let identity = [
1371            [1.0, 0.0, 0.0, 0.0],
1372            [0.0, 1.0, 0.0, 0.0],
1373            [0.0, 0.0, 1.0, 0.0],
1374            [0.0, 0.0, 0.0, 1.0],
1375        ];
1376        let mut world = TestWorld::new();
1377        world.components.push_typed(CharacterRig::new(
1378            SkinnedMeshHandle(1),
1379            0,
1380            identity,
1381            0.6,
1382            0.3,
1383        ));
1384
1385        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1386        physics.init(&mut world.ctx());
1387        let rig_y = |world: &mut TestWorld| {
1388            world
1389                .ctx()
1390                .query::<CharacterRig>()
1391                .next()
1392                .expect("the rig is there")
1393                .position[1]
1394        };
1395        assert_eq!(
1396            rig_y(&mut world),
1397            0.0,
1398            "the capsule spawns at its authored position, unlifted"
1399        );
1400
1401        for _ in 0..30 {
1402            physics.step(&mut world.ctx());
1403        }
1404        let settled = rig_y(&mut world);
1405        for _ in 0..300 {
1406            physics.step(&mut world.ctx());
1407        }
1408        let held = rig_y(&mut world);
1409
1410        assert!(
1411            settled.abs() < 0.01,
1412            "the capsule stayed on the floor (y = {settled})"
1413        );
1414        assert!(
1415            (held - settled).abs() < 1.0e-4,
1416            "and stopped moving ({settled} -> {held})"
1417        );
1418    }
1419
1420    // A hard landing publishes one ContactEvent naming the prop; resting
1421    // afterwards stays silent.
1422    #[test]
1423    fn contact_event_fires_on_impact_and_not_at_rest() {
1424        let id = AssetId(1);
1425        let mut world = TestWorld::new();
1426        let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
1427        make_dynamic(&mut world, entity);
1428
1429        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1430        physics.init(&mut world.ctx());
1431
1432        let mut cursor = EventCursor::default();
1433        let mut impacts: Vec<ContactEvent> = Vec::new();
1434        for _ in 0..120 {
1435            physics.step(&mut world.ctx());
1436            let ctx = world.ctx();
1437            if let Some(events) = ctx.events::<ContactEvent>() {
1438                impacts.extend(events.read(&mut cursor).copied());
1439            }
1440        }
1441        assert_eq!(impacts.len(), 1, "one landing, one event: {impacts:?}");
1442        let impact = impacts[0];
1443        assert_eq!(impact.a, entity);
1444        assert_eq!(impact.b, None, "the floor slab has no entity");
1445        assert!(
1446            impact.impulse > 3.0 && impact.impulse < 50.0,
1447            "impulse {} out of the plausible landing range",
1448            impact.impulse
1449        );
1450
1451        // Settled: hundreds of resting ticks publish nothing further.
1452        for _ in 0..300 {
1453            physics.step(&mut world.ctx());
1454            let ctx = world.ctx();
1455            if let Some(events) = ctx.events::<ContactEvent>() {
1456                assert_eq!(
1457                    events.read(&mut cursor).count(),
1458                    0,
1459                    "resting contact must not publish events"
1460                );
1461            }
1462        }
1463    }
1464
1465    // A sensor region reports a prop crossing it, in and then out, naming the
1466    // volume that saw it.
1467    #[test]
1468    fn a_trigger_volume_reports_a_prop_crossing_it() {
1469        let volume_id = AssetId(9);
1470        let mut world = TestWorld::new();
1471        world.components.push_typed(TriggerVolume {
1472            asset_id: volume_id,
1473            position: [0.0, 3.0, 0.0],
1474            rotation_deg: [0.0; 3],
1475            collider: PropCollider {
1476                shape: "cuboid".to_string(),
1477                half_extents: [1.0, 0.5, 1.0],
1478                ..Default::default()
1479            },
1480            detects: TriggerFilter::Props,
1481        });
1482        let ball = world.spawn_prop(AssetId(1), [0.0, 6.0, 0.0], false);
1483        make_dynamic(&mut world, ball);
1484
1485        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1486        physics.init(&mut world.ctx());
1487
1488        let mut cursor = EventCursor::default();
1489        let mut crossings: Vec<VolumeEvent> = Vec::new();
1490        for _ in 0..180 {
1491            physics.step(&mut world.ctx());
1492            let ctx = world.ctx();
1493            if let Some(events) = ctx.events::<VolumeEvent>() {
1494                crossings.extend(events.read(&mut cursor).copied());
1495            }
1496        }
1497        assert_eq!(crossings.len(), 2, "in, then out: {crossings:?}");
1498        assert!(crossings.iter().all(|c| c.volume == volume_id));
1499        assert!(crossings[0].entered, "the ball entered first");
1500        assert!(!crossings[1].entered, "and left afterwards");
1501    }
1502
1503    // A joint anchored to the world holds its body up: the hidden anchor body
1504    // the driver mints for it is part of the reservation, and the prop hangs
1505    // off it instead of falling.
1506    #[test]
1507    fn a_world_anchored_joint_holds_its_prop_up() {
1508        let bob_id = AssetId(1);
1509        let mut world = TestWorld::new();
1510        let bob = world.spawn_prop(bob_id, [1.0, 4.0, 0.0], false);
1511        make_dynamic(&mut world, bob);
1512        world.components.push_typed(PhysicsJoint {
1513            asset_id: AssetId(2),
1514            kind: "spherical".to_string(),
1515            body_a: Some(bob_id),
1516            body_b: None,
1517            // The bob's own centre hangs one unit from the anchor point.
1518            anchor_a: [-1.0, 0.0, 0.0],
1519            anchor_b: [0.0, 4.0, 0.0],
1520            ..Default::default()
1521        });
1522
1523        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1524        physics.init(&mut world.ctx());
1525        for _ in 0..240 {
1526            physics.step(&mut world.ctx());
1527        }
1528
1529        let position = world.components.get::<Transform>(bob).unwrap().position;
1530        let reach =
1531            ((position[0]).powi(2) + (position[1] - 4.0).powi(2) + (position[2]).powi(2)).sqrt();
1532        assert!(
1533            (reach - 1.0).abs() < 0.05,
1534            "the bob hangs one unit from the anchor, at {position:?} ({reach})"
1535        );
1536        assert!(
1537            position[1] > 2.5,
1538            "and is held up rather than falling ({position:?})"
1539        );
1540    }
1541
1542    // The config's no_collide pairs reach the built colliders: a prop on a
1543    // layer that ignores `world` falls straight through the floor.
1544    #[test]
1545    fn no_collide_config_lets_a_layered_prop_fall_through_the_floor() {
1546        let config = PhysicsConfig {
1547            layers: vec!["ghost".to_string()],
1548            no_collide: vec![["ghost".to_string(), "world".to_string()]],
1549            ..PhysicsConfig::default()
1550        };
1551
1552        let mut world = TestWorld::new();
1553        let entity = world.spawn_prop(AssetId(1), [0.0, 2.0, 0.0], false);
1554        make_dynamic(&mut world, entity);
1555        world
1556            .components
1557            .get_mut::<Collider>(entity)
1558            .unwrap()
1559            .0
1560            .layer = "ghost".to_string();
1561
1562        let mut physics = PhysicsSystem::new(config);
1563        physics.init(&mut world.ctx());
1564        for _ in 0..240 {
1565            physics.step(&mut world.ctx());
1566        }
1567        let y = world.components.get::<Transform>(entity).unwrap().position[1];
1568        assert!(
1569            y < -10.0,
1570            "the ghost-layer prop fell through the floor (y = {y})"
1571        );
1572    }
1573}