1use 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
36const PICKUP_REACH: f32 = 3.0;
38const PICKUP_MIN_DOT: f32 = 0.5;
40const HOLD_DISTANCE: f32 = 1.8;
42const HOLD_DROP: f32 = 0.35;
44const THROW_SPEED: f32 = 6.0;
46
47#[derive(Debug)]
53pub struct PhysicsSystem {
54 floor_y: f32,
57 terrain: Option<TerrainParams>,
59 terrain_mesh: Option<AssetId>,
63 terrain_offset_y: f32,
67 world: Option<Simulation>,
69 player: Option<PlayerPhysics>,
71 rigs: Vec<super::rig::RigPhysics>,
73 props: PropBodies,
75 root_cursor: EventCursor,
77 new_props: Vec<(Entity, PropCollSnap)>,
80 motion_scratch: Vec<crate::components::RootMotionEvent>,
82 contact_scratch: Vec<ContactHit>,
83 sensor_scratch: Vec<SensorCrossing>,
84 held: Option<usize>,
86 sensor_filters: SortedMap<u64, (AssetId, TriggerFilter)>,
89 layers: LayerTable,
91 contact_min_impulse: f32,
93 contact_batch: ContactBatch,
95 contact_gate: ContactGate,
97 spawn_headroom: u32,
99 body_cap: u32,
103 fanout: Box<dyn PhysicsFanout>,
106}
107
108#[derive(Debug)]
110struct PlayerPhysics {
111 handle: BodyHandle,
112 shape: CharacterCapsule,
115 eye_offset: f32,
117 has_gravity: bool,
119 gravity_scale: f32,
120 jump_height: f32,
121 vy: f32,
123 grounded: bool,
125 center: PointInterp,
127 written_eye: Option<[f32; 3]>,
131}
132
133impl PhysicsSystem {
134 #[cfg(test)]
137 fn physics_body_count(&self) -> usize {
138 self.world.as_ref().map_or(0, |w| w.body_count())
139 }
140
141 #[cfg(test)]
144 fn physics_collider_count(&self) -> usize {
145 self.world.as_ref().map_or(0, |w| w.collider_count())
146 }
147
148 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 pub fn with_fanout(mut self, fanout: Box<dyn PhysicsFanout>) -> Self {
193 self.fanout = fanout;
194 self
195 }
196
197 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 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 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 let budget = self.resolve_budget(ctx);
292 self.body_cap = budget.body_cap();
293 self.reserve(&budget);
294
295 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 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 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 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 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 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 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 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 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 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 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 super::rig::init_rigs(
546 &mut world,
547 ctx,
548 self.layers.mask(LAYER_CHARACTER),
549 &mut self.rigs,
550 );
551
552 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 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 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 let timing = ctx.resource::<SimTiming>().copied().unwrap_or_default();
590
591 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 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 let mode = ScheduleMode::current(ctx.resources);
618
619 let world = self.world.as_mut().expect("world checked above");
620
621 self.held = self
624 .props
625 .reap(world, self.held, |entity| ctx.is_alive(entity));
626
627 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 let mut held_changed: Option<(Entity, bool)> = None;
662 if interact_req {
663 if let Some(held_idx) = self.held.take() {
664 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 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 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 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 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 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 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 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 self.fanout.step(world, dt, mode);
788
789 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 self.props.record_tick_poses(world);
798 }
799
800 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 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 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 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 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 for body in ctx.query_mut::<RigidBody>() {
898 body.is_grounded = grounded;
899 }
900
901 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 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 #[test]
947 fn third_person_camera_gets_no_player_capsule() {
948 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 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 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 #[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 #[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 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 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 #[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 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 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 #[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 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 #[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 for _ in 0..2 {
1148 physics.step(&mut world.ctx());
1149 }
1150 let before = physics.physics_body_count();
1151
1152 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 physics.step(&mut world.ctx());
1161 assert_eq!(
1162 physics.physics_body_count(),
1163 after,
1164 "no further bodies removed"
1165 );
1166 }
1167
1168 #[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 #[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 #[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 #[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 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 #[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 #[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 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 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 #[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 #[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 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 #[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 #[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 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 #[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}