1use alloc::boxed::Box;
7use alloc::collections::{BTreeMap, BTreeSet};
8use alloc::vec::Vec;
9
10use crate::physics::{
11 BodyHandle, CharacterCapsule, CharacterMoveInput, ColliderShape, ContactHit, GRAVITY,
12 PhysicsBudget, SensorCrossing, SimConfig, Simulation,
13};
14
15use crate::components::{
16 BodyDynamics, Camera3D, Collider, ContactEvent, Held, PhysicsConfig, PhysicsJoint, Pickup,
17 RigidBody, Transform, TriggerFilter, TriggerVolume, VolumeEvent,
18};
19use crate::ecs::asset_id::AssetId;
20use crate::ecs::{
21 Entity, EntityByName, EventCursor, MenuActive, PipelineContext, ScheduleMode, SimTiming,
22 StepResult, System, WorldPhysicsBudget,
23};
24use crate::math::{cos, sin, sqrt};
25
26use super::budget::DriverCapacities;
27use super::contacts::{ContactBatch, ContactGate};
28use super::convert::{collider_shape, joint_spec};
29use super::fanout::{PhysicsFanout, SerialFanout};
30use super::index::SortedMap;
31use super::interp::PointInterp;
32use super::layers::{LAYER_CHARACTER, LAYER_PROP, LAYER_TRIGGER, LAYER_WORLD, LayerTable};
33use super::props::{PropBodies, PropCollSnap, STATIC_FRICTION};
34use super::terrain::{TerrainParams, build_heightfield, build_heightfield_collider};
35
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 return PhysicsBudget::derive(&scan, self.spawn_headroom);
214 };
215 let shipped = super::budget::budget_of(&record);
216 debug_assert_eq!(
217 shipped,
218 PhysicsBudget::derive(&scan, record.spawn_headroom),
219 "the shipped physics budget does not match the loaded world"
220 );
221 shipped
222 }
223
224 fn reserve(&mut self, budget: &PhysicsBudget) {
227 let caps = DriverCapacities::derive(budget);
228 self.props = PropBodies::with_capacity(&caps);
229 self.rigs = Vec::with_capacity(caps.rigs);
230 self.new_props = Vec::with_capacity(caps.new_props);
231 self.motion_scratch = Vec::with_capacity(caps.root_motions);
232 self.contact_scratch = Vec::with_capacity(caps.contacts);
233 self.sensor_scratch = Vec::with_capacity(caps.sensor_crossings);
234 self.contact_batch = ContactBatch::with_capacity(caps.contact_pairs);
235 self.contact_gate = ContactGate::with_capacity(caps.contact_pairs);
236 self.sensor_filters = SortedMap::with_capacity(caps.sensor_filters);
237 }
238
239 fn build_prop_bodies(
244 &mut self,
245 ctx: &PipelineContext,
246 world: &mut Simulation,
247 body_handles: &mut BTreeMap<AssetId, BodyHandle>,
248 ) {
249 let entity_name: BTreeMap<Entity, AssetId> = ctx
250 .resource::<EntityByName>()
251 .map(|n| n.0.iter().map(|(&id, &e)| (e, id)).collect())
252 .unwrap_or_default();
253 let pickup: BTreeSet<Entity> = ctx.query_with_entity::<Pickup>().map(|(e, _)| e).collect();
254 let dynamics: BTreeMap<Entity, BodyDynamics> = ctx
255 .query_with_entity::<BodyDynamics>()
256 .map(|(e, b)| (e, *b))
257 .collect();
258 let snaps: Vec<(Entity, PropCollSnap)> = ctx
259 .join2::<Collider, Transform>()
260 .map(|(entity, collider, transform)| {
261 (
262 entity,
263 PropCollSnap {
264 shape: collider_shape(&collider.0, transform.scale),
265 layer: collider.0.layer.clone(),
266 position: transform.position,
267 rotation_deg: transform.rotation_deg,
268 pickup: pickup.contains(&entity),
269 dynamics: dynamics.get(&entity).copied(),
270 },
271 )
272 })
273 .collect();
274
275 for (entity, snap) in snaps {
276 let Some(handle) = self.props.add(&self.layers, world, entity, snap) else {
277 continue;
278 };
279 if let Some(&id) = entity_name.get(&entity) {
280 body_handles.insert(id, handle);
281 }
282 }
283 }
284}
285
286impl System for PhysicsSystem {
287 fn init(&mut self, ctx: &mut PipelineContext) {
288 let budget = self.resolve_budget(ctx);
291 self.body_cap = budget.body_cap();
292 self.reserve(&budget);
293
294 let mut world = Simulation::new(
298 SimConfig {
299 gravity: GRAVITY,
300 ..SimConfig::default()
301 },
302 budget.body_cap() as usize,
303 );
304 world.set_contact_min_impulse(self.contact_min_impulse, SimTiming::TICK_DT);
305 world.reserve_workers(
309 self.fanout
310 .worker_count(ScheduleMode::current(ctx.resources)),
311 );
312 let world_mask = self.layers.mask(LAYER_WORLD);
313
314 let mut floor_built = false;
316 if let Some(mesh_id) = self.terrain_mesh {
317 let mesh_snap = ctx
318 .query::<crate::components::ProceduralMesh>()
319 .find(|m| m.asset_id == mesh_id)
320 .cloned();
321 if let Some(m) = mesh_snap
325 && m.generator == "heightfield"
326 && build_heightfield_collider(
327 &mut world,
328 &m,
329 self.terrain_offset_y,
330 world_mask,
331 ctx,
332 )
333 .is_ok()
334 {
335 floor_built = true;
336 }
337 }
338 if !floor_built {
339 if let Some(terrain) = self.terrain.clone() {
340 build_heightfield(&mut world, &terrain, world_mask);
341 } else {
342 world.add_fixed(
344 &ColliderShape::Cuboid {
345 half_extents: [500.0, 5.0, 500.0],
346 },
347 [0.0, -5.0, 0.0],
348 [0.0; 3],
349 STATIC_FRICTION,
350 world_mask,
351 );
352 }
353 }
354
355 let trigger_mask = self.layers.mask(LAYER_TRIGGER);
358 let volumes: Vec<TriggerVolume> = ctx.query::<TriggerVolume>().cloned().collect();
359 for volume in &volumes {
360 let shape = collider_shape(&volume.collider, [1.0; 3]);
361 let tag = u64::from(volume.asset_id.0);
362 if world
363 .add_sensor(
364 &shape,
365 volume.position,
366 volume.rotation_deg,
367 tag,
368 trigger_mask,
369 )
370 .is_none()
371 {
372 continue;
373 }
374 self.sensor_filters
375 .insert(tag, (volume.asset_id, volume.detects));
376 }
377
378 let mut body_handles: BTreeMap<AssetId, BodyHandle> = BTreeMap::new();
381 self.build_prop_bodies(ctx, &mut world, &mut body_handles);
382
383 let joints: Vec<PhysicsJoint> = ctx.drain::<PhysicsJoint>();
390 for joint in joints {
391 let Some(body_a_id) = joint.body_a else {
392 continue;
393 };
394 let Some(handle_a) = body_handles.get(&body_a_id).copied() else {
395 continue;
396 };
397 let handle_b = if let Some(body_b_id) = joint.body_b {
398 match body_handles.get(&body_b_id).copied() {
399 Some(h) => h,
400 None => {
401 continue;
402 }
403 }
404 } else {
405 let anchor = world.add_fixed(
408 &ColliderShape::Ball { radius: 0.001 },
409 joint.anchor_b,
410 [0.0; 3],
411 0.0,
412 world_mask,
413 );
414 match anchor {
415 Some(handle) => handle,
416 None => continue,
417 }
418 };
419 let anchor_b = if joint.body_b.is_some() {
422 joint.anchor_b
423 } else {
424 [0.0, 0.0, 0.0]
425 };
426 if !world.add_joint(
427 handle_a,
428 handle_b,
429 joint.anchor_a,
430 anchor_b,
431 joint_spec(&joint),
432 ) {
433 continue;
434 }
435 }
436 let camera_pos = ctx
443 .query::<Camera3D>()
444 .next()
445 .filter(|c| {
446 c.controller
447 .as_ref()
448 .is_none_or(|ctrl| ctrl.follow.is_none())
449 })
450 .map(|c| c.position);
451 if let Some(cam_pos) = camera_pos {
452 let rb_opt = ctx.query::<RigidBody>().next().cloned();
453 let has_gravity = rb_opt.is_some();
454 let rb = rb_opt.unwrap_or_default();
455 if self.floor_y == 0.0 {
456 self.floor_y = cam_pos[1];
457 }
458 let radius = rb.capsule_radius.max(0.05);
459 let half_height = ((rb.capsule_height * 0.5) - radius).max(0.05);
460 let eye_offset = if has_gravity {
463 (rb.capsule_height * 0.5).max(radius + 0.05)
464 } else {
465 0.0
466 };
467 let center = [cam_pos[0], cam_pos[1] - eye_offset, cam_pos[2]];
468 world.configure_character(rb.max_slope_deg, rb.step_height, has_gravity);
469 let handle = world.add_character(
470 half_height,
471 radius,
472 center,
473 self.layers.mask(LAYER_CHARACTER),
474 );
475 self.player = handle.map(|handle| PlayerPhysics {
476 handle,
477 shape: CharacterCapsule::new(half_height, radius),
478 eye_offset,
479 has_gravity,
480 gravity_scale: rb.gravity_scale.max(0.0),
481 jump_height: rb.jump_height.max(0.0),
482 vy: 0.0,
483 grounded: true,
484 center: PointInterp::new(center),
485 written_eye: None,
486 });
487 }
488
489 super::rig::init_rigs(
492 &mut world,
493 ctx,
494 self.layers.mask(LAYER_CHARACTER),
495 &mut self.rigs,
496 );
497
498 super::budget::publish_reservation(crate::memory::ledger(), &budget, &world);
501 self.world = Some(world);
502 }
503
504 fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
505 if ctx.resource::<MenuActive>().is_some_and(|m| m.0) {
512 return StepResult::Continue;
513 }
514
515 if self.world.is_none() {
516 return StepResult::Continue;
517 }
518
519 let timing = ctx.resource::<SimTiming>().copied().unwrap_or_default();
523
524 let (cam_pos, cam_yaw, cam_pitch, desired_move, jump_req, interact_req) = ctx
526 .query::<Camera3D>()
527 .next()
528 .map(|c| {
529 (
530 c.position,
531 c.yaw,
532 c.pitch,
533 c.desired_move,
534 c.jump_requested,
535 c.interact_requested,
536 )
537 })
538 .unwrap_or(([0.0, self.floor_y, 0.0], 0.0, 0.0, [0.0; 3], false, false));
539
540 let fwd_flat = [-sin(cam_yaw), 0.0, -cos(cam_yaw)];
542 let fwd_full = [
543 -(sin(cam_yaw) * cos(cam_pitch)),
544 -sin(cam_pitch),
545 -(cos(cam_yaw) * cos(cam_pitch)),
546 ];
547
548 let mode = ScheduleMode::current(ctx.resources);
551
552 let world = self.world.as_mut().expect("world checked above");
553
554 self.held = self
557 .props
558 .reap(world, self.held, |entity| ctx.is_alive(entity));
559
560 self.new_props.clear();
566 self.new_props.extend(
567 ctx.join2::<Collider, Transform>()
568 .filter(|(entity, _, _)| {
569 !self.props.is_tracked(*entity) && !self.props.is_refused(*entity)
570 })
571 .map(|(entity, collider, transform)| {
572 (
573 entity,
574 PropCollSnap {
575 shape: collider_shape(&collider.0, transform.scale),
576 layer: collider.0.layer.clone(),
577 position: transform.position,
578 rotation_deg: transform.rotation_deg,
579 pickup: false,
580 dynamics: None,
581 },
582 )
583 }),
584 );
585 for (entity, mut snap) in self.new_props.drain(..) {
586 snap.pickup = ctx.get::<Pickup>(entity).is_some();
587 snap.dynamics = ctx.get::<BodyDynamics>(entity).copied();
588 self.props
589 .adopt(&self.layers, world, entity, snap, self.body_cap);
590 }
591
592 let mut held_changed: Option<(Entity, bool)> = None;
595 if interact_req {
596 if let Some(held_idx) = self.held.take() {
597 let pp = self.props.get(held_idx).expect("held index is valid");
599 let throw = [
600 fwd_full[0] * THROW_SPEED,
601 fwd_full[1] * THROW_SPEED + 1.0,
602 fwd_full[2] * THROW_SPEED,
603 ];
604 world.make_dynamic(pp.handle, throw);
605 held_changed = Some((pp.entity, false));
606 } else {
607 let entity_positions: BTreeMap<Entity, [f32; 3]> = ctx
611 .query_with_entity::<Transform>()
612 .map(|(e, t)| (e, t.position))
613 .collect();
614 let mut best: Option<(f32, usize)> = None;
615 for (idx, pp) in self.props.iter().enumerate() {
616 if !pp.pickup {
617 continue;
618 }
619 let pos = entity_positions.get(&pp.entity).copied().unwrap_or(cam_pos);
620 let dx = pos[0] - cam_pos[0];
621 let dz = pos[2] - cam_pos[2];
622 let dist = sqrt(dx * dx + dz * dz);
623 if dist >= PICKUP_REACH || dist <= 0.0 {
624 continue;
625 }
626 let dot = (fwd_flat[0] * dx + fwd_flat[2] * dz) / dist;
627 if dot > PICKUP_MIN_DOT && best.is_none_or(|(d, _)| dist < d) {
628 best = Some((dist, idx));
629 }
630 }
631 if let Some((_, idx)) = best {
632 let pp = self.props.get(idx).expect("scanned index is valid");
633 world.make_kinematic(pp.handle);
634 held_changed = Some((pp.entity, true));
635 self.held = Some(idx);
636 }
637 }
638 }
639
640 if let Some(player) = self.player.as_mut()
644 && player.written_eye != Some(cam_pos)
645 {
646 player
647 .center
648 .snap([cam_pos[0], cam_pos[1] - player.eye_offset, cam_pos[2]]);
649 }
650
651 let hold_pos = [
654 cam_pos[0] + fwd_full[0] * HOLD_DISTANCE,
655 cam_pos[1] + fwd_full[1] * HOLD_DISTANCE - HOLD_DROP,
656 cam_pos[2] + fwd_full[2] * HOLD_DISTANCE,
657 ];
658
659 super::rig::drain_motions_into(ctx, &mut self.root_cursor, &mut self.motion_scratch);
663 super::rig::sync_rigs(ctx, &mut self.rigs);
664
665 for tick in 0..timing.ticks {
666 let dt = timing.tick_dt;
667
668 if let Some(prop) = self.held.and_then(|idx| self.props.get(idx)) {
670 world.set_kinematic_translation(prop.handle, hold_pos);
671 }
672
673 if let Some(player) = self.player.as_mut() {
675 if player.has_gravity {
676 if tick == 0 && jump_req && player.grounded && player.jump_height > 0.0 {
677 player.vy = sqrt(2.0 * GRAVITY * player.gravity_scale * player.jump_height);
678 }
679 player.vy -= GRAVITY * player.gravity_scale * dt;
680 }
681
682 let center = player.center.current();
683 let desired = [desired_move[0] * dt, player.vy * dt, desired_move[2] * dt];
684 let moved = world.move_character(
685 &player.shape,
686 &CharacterMoveInput {
687 center,
688 desired,
689 dt,
690 exclude: player.handle,
691 mask: self.layers.mask(LAYER_CHARACTER),
692 },
693 );
694 let new_center = [
695 center[0] + moved.translation[0],
696 center[1] + moved.translation[1],
697 center[2] + moved.translation[2],
698 ];
699 world.set_kinematic_translation(player.handle, new_center);
700
701 player.grounded = moved.grounded;
702 if moved.grounded && player.vy < 0.0 {
703 player.vy = 0.0;
704 }
705 player.center.push(new_center);
706 }
707
708 super::rig::tick_rigs(
710 world,
711 ctx,
712 &mut self.rigs,
713 if tick == 0 { &self.motion_scratch } else { &[] },
714 dt,
715 GRAVITY,
716 self.layers.mask(LAYER_CHARACTER),
717 );
718
719 self.fanout.step(world, dt, mode);
721
722 self.contact_gate.advance_tick();
724 world.drain_contact_hits_into(&mut self.contact_scratch);
725 for hit in self.contact_scratch.drain(..) {
726 self.contact_batch.add(hit);
727 }
728
729 self.props.record_tick_poses(world);
731 }
732
733 super::probes::step_probes(
735 world,
736 ctx,
737 &self.rigs,
738 self.layers
739 .query_mask(LAYER_CHARACTER, &[LAYER_WORLD, LAYER_PROP]),
740 );
741
742 for hit in self.contact_batch.drain() {
747 let event = match (self.props.entity_of(hit.a), self.props.entity_of(hit.b)) {
748 (Some(a), b) => ContactEvent {
749 a,
750 b,
751 point: hit.point,
752 normal: hit.normal,
753 impulse: hit.impulse,
754 },
755 (None, Some(b)) => ContactEvent {
756 a: b,
757 b: None,
758 point: hit.point,
759 normal: [-hit.normal[0], -hit.normal[1], -hit.normal[2]],
760 impulse: hit.impulse,
761 },
762 (None, None) => continue,
763 };
764 if self.contact_gate.admit(&hit) {
765 ctx.events_mut::<ContactEvent>().send(event);
766 }
767 }
768
769 world.drain_sensor_crossings_into(&mut self.sensor_scratch);
773 for crossing in self.sensor_scratch.drain(..) {
774 let Some(&(volume, filter)) = self.sensor_filters.get(&crossing.tag) else {
775 continue;
776 };
777 let passes = match filter {
778 TriggerFilter::Player => crossing.other.is_some_and(|h| {
779 self.player.as_ref().is_some_and(|p| p.handle == h)
780 || self.rigs.iter().any(|r| r.handle == h)
781 }),
782 TriggerFilter::Props => crossing
783 .other
784 .is_some_and(|h| self.props.entity_of(h).is_some()),
785 TriggerFilter::Any => true,
786 };
787 if passes {
788 ctx.events_mut::<VolumeEvent>().send(VolumeEvent {
789 volume,
790 entered: crossing.entered,
791 });
792 }
793 }
794
795 let alpha = timing.alpha;
799 for &(entity, pos, rot) in self.props.sample_poses(alpha) {
800 if let Some(t) = ctx.get_mut::<Transform>(entity) {
801 t.position = pos;
802 t.rotation_deg = rot;
803 }
804 }
805 if let Some((entity, is_held)) = held_changed {
806 if is_held {
807 if ctx.get::<Held>(entity).is_none() {
808 ctx.insert(entity, Held);
809 }
810 } else {
811 ctx.remove::<Held>(entity);
812 }
813 }
814
815 let mut grounded = true;
817 if let Some(player) = self.player.as_mut() {
818 let center = player.center.sample(alpha);
819 let eye = [center[0], center[1] + player.eye_offset, center[2]];
820 player.written_eye = Some(eye);
821 grounded = player.grounded;
822 for camera in ctx.query_mut::<Camera3D>() {
823 camera.position = eye;
824 camera.view_matrix =
825 crate::gfx::camera::view_matrix(camera.position, camera.yaw, camera.pitch);
826 }
827 }
828
829 for body in ctx.query_mut::<RigidBody>() {
831 body.is_grounded = grounded;
832 }
833
834 super::rig::publish_rigs(ctx, &mut self.rigs, alpha);
836
837 StepResult::Continue
838 }
839}
840
841#[cfg(test)]
842mod tests {
843 use super::*;
844 use alloc::string::ToString;
845 use alloc::vec;
846
847 use crate::components::{CameraController, CharacterRig, FollowController, PropCollider};
848 use crate::ecs::SkinnedMeshHandle;
849 use crate::physics::budget::{record_of, scan_counts};
850 use crate::physics::test_world::TestWorld;
851
852 fn make_dynamic(world: &mut TestWorld, entity: Entity) {
855 world.components.insert_typed(entity, ball_dynamics());
856 }
857
858 fn controlled_camera() -> Camera3D {
859 Camera3D {
860 fov_y_degrees: 75.0,
861 near: 0.05,
862 far: 200.0,
863 view_matrix: [[0.0; 4]; 4],
864 position: [0.0, 1.0, 0.0],
865 yaw: 0.0,
866 pitch: 0.0,
867 desired_move: [0.0; 3],
868 jump_requested: false,
869 interact_requested: false,
870 controller: Some(CameraController::default()),
871 }
872 }
873
874 #[test]
880 fn third_person_camera_gets_no_player_capsule() {
881 let mut world = TestWorld::new();
883 let mut camera = controlled_camera();
884 camera.controller = Some(CameraController {
885 follow: Some(FollowController {
886 target: Some(SkinnedMeshHandle(1)),
887 ..FollowController::default()
888 }),
889 ..CameraController::default()
890 });
891 world.components.push_typed(camera);
892 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
893 physics.init(&mut world.ctx());
894 assert!(physics.player.is_none(), "no capsule for the orbit camera");
895
896 let mut world = TestWorld::new();
898 world.components.push_typed(controlled_camera());
899 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
900 physics.init(&mut world.ctx());
901 assert!(
902 physics.player.is_some(),
903 "first-person camera keeps its capsule"
904 );
905 }
906
907 fn ball_dynamics() -> BodyDynamics {
908 BodyDynamics {
909 mass: 1.0,
910 friction: 0.5,
911 linear_damping: 0.0,
912 ..Default::default()
913 }
914 }
915
916 fn interacting_camera(position: [f32; 3]) -> Camera3D {
919 Camera3D {
920 interact_requested: true,
921 controller: None,
922 position,
923 ..controlled_camera()
924 }
925 }
926
927 #[test]
930 fn dynamic_prop_writes_transform() {
931 let id = AssetId(1);
932 let mut world = TestWorld::new();
933 let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
934 make_dynamic(&mut world, entity);
935
936 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
937 physics.init(&mut world.ctx());
938 for _ in 0..10 {
939 physics.step(&mut world.ctx());
940 }
941
942 let transform_y = world.components.get::<Transform>(entity).unwrap().position[1];
943 assert!(
944 transform_y < 5.0,
945 "the simulated pose falls the Transform (y={transform_y})"
946 );
947 }
948
949 #[test]
954 fn menu_active_freezes_then_resumes_physics() {
955 let id = AssetId(1);
956 let mut world = TestWorld::new();
957 let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
958 make_dynamic(&mut world, entity);
959
960 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
961 physics.init(&mut world.ctx());
962
963 world.resources.insert(MenuActive(true));
965 for _ in 0..5 {
966 physics.step(&mut world.ctx());
967 }
968 let y_paused = world.components.get::<Transform>(entity).unwrap().position[1];
969 assert!(
970 (y_paused - 5.0).abs() < 1e-3,
971 "the body must not fall while a menu is active (y={y_paused})"
972 );
973
974 world.resources.insert(MenuActive(false));
976 for _ in 0..5 {
977 physics.step(&mut world.ctx());
978 }
979 let y_resumed = world.components.get::<Transform>(entity).unwrap().position[1];
980 assert!(
981 y_resumed < y_paused - 1e-3,
982 "the body must fall once the menu closes (y={y_resumed})"
983 );
984 }
985
986 #[test]
990 fn zero_tick_frames_blend_between_the_last_two_ticks() {
991 let id = AssetId(1);
992 let mut world = TestWorld::new();
993 let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
994 make_dynamic(&mut world, entity);
995
996 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
997 physics.init(&mut world.ctx());
998
999 let tick = |ticks, alpha| SimTiming {
1000 ticks,
1001 tick_dt: SimTiming::TICK_DT,
1002 alpha,
1003 };
1004 world.resources.insert(tick(1, 1.0));
1005 physics.step(&mut world.ctx());
1006 let y_prev = world.components.get::<Transform>(entity).unwrap().position[1];
1007 physics.step(&mut world.ctx());
1008 let y_curr = world.components.get::<Transform>(entity).unwrap().position[1];
1009 assert!(y_curr < y_prev, "the body falls tick over tick");
1010
1011 world.resources.insert(tick(0, 0.0));
1013 physics.step(&mut world.ctx());
1014 let y_alpha0 = world.components.get::<Transform>(entity).unwrap().position[1];
1015 assert!(
1016 (y_alpha0 - y_prev).abs() < 1e-6,
1017 "alpha 0 samples the previous tick"
1018 );
1019
1020 world.resources.insert(tick(0, 0.5));
1022 physics.step(&mut world.ctx());
1023 let y_mid = world.components.get::<Transform>(entity).unwrap().position[1];
1024 let expected = (y_prev + y_curr) * 0.5;
1025 assert!(
1026 (y_mid - expected).abs() < 1e-6,
1027 "alpha 0.5 blends the tick poses (y={y_mid}, expected {expected})"
1028 );
1029 }
1030
1031 #[test]
1035 fn tick_grouping_does_not_change_the_outcome() {
1036 let run = |frames: &[u32]| -> ([f32; 3], [f32; 3]) {
1037 let id = AssetId(1);
1038 let mut world = TestWorld::new();
1039 let entity = world.spawn_prop(id, [0.3, 5.0, 0.1], false);
1040 make_dynamic(&mut world, entity);
1041
1042 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1043 physics.init(&mut world.ctx());
1044 for &ticks in frames {
1045 world.resources.insert(SimTiming {
1046 ticks,
1047 tick_dt: SimTiming::TICK_DT,
1048 alpha: 1.0,
1049 });
1050 physics.step(&mut world.ctx());
1051 }
1052 let t = world.components.get::<Transform>(entity).unwrap();
1053 (t.position, t.rotation_deg)
1054 };
1055
1056 let thirty: Vec<u32> = core::iter::repeat_n(2, 30).collect();
1059 let one_twenty: Vec<u32> = (0..120).map(|i| i % 2).collect();
1060 assert_eq!(
1061 run(&thirty),
1062 run(&one_twenty),
1063 "the fixed-tick outcome must not depend on frame grouping"
1064 );
1065 }
1066
1067 #[test]
1070 fn despawning_a_prop_reaps_its_physics_body() {
1071 let id = AssetId(1);
1072 let mut world = TestWorld::new();
1073 let ball = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
1074 make_dynamic(&mut world, ball);
1075
1076 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1077 physics.init(&mut world.ctx());
1078
1079 for _ in 0..2 {
1081 physics.step(&mut world.ctx());
1082 }
1083 let before = physics.physics_body_count();
1084
1085 world.components.despawn(ball);
1088 physics.step(&mut world.ctx());
1089 let after = physics.physics_body_count();
1090 assert_eq!(after, before - 1, "the despawned prop's body was removed");
1091
1092 physics.step(&mut world.ctx());
1094 assert_eq!(
1095 physics.physics_body_count(),
1096 after,
1097 "no further bodies removed"
1098 );
1099 }
1100
1101 #[test]
1103 fn pickup_sets_held_tag() {
1104 let id = AssetId(1);
1105 let mut world = TestWorld::new();
1106 let carriable = world.spawn_prop(id, [0.0, 1.0, -2.0], true);
1107 make_dynamic(&mut world, carriable);
1108 world
1109 .components
1110 .push_typed(interacting_camera([0.0, 1.0, 0.0]));
1111
1112 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1113 physics.init(&mut world.ctx());
1114
1115 physics.step(&mut world.ctx());
1116
1117 assert_eq!(
1118 world.ctx().query::<Held>().count(),
1119 1,
1120 "pickup inserts the Held tag on the entity"
1121 );
1122 }
1123
1124 #[test]
1129 fn runtime_spawned_prop_gets_a_body_and_falls() {
1130 let mut world = TestWorld::new();
1131 let mut physics = PhysicsSystem::new(PhysicsConfig {
1132 spawn_headroom: 1,
1133 ..PhysicsConfig::default()
1134 });
1135 physics.init(&mut world.ctx());
1136 let baseline = physics.physics_body_count();
1137
1138 let spawned = world.spawn_prop(AssetId(7), [0.0, 5.0, 0.0], false);
1139 make_dynamic(&mut world, spawned);
1140 physics.step(&mut world.ctx());
1141 assert_eq!(
1142 physics.physics_body_count(),
1143 baseline + 1,
1144 "the spawned entity got a body on its first step"
1145 );
1146 for _ in 0..30 {
1147 physics.step(&mut world.ctx());
1148 }
1149 let y = world.components.get::<Transform>(spawned).unwrap().position[1];
1150 assert!(y < 4.5, "the spawned body falls (y = {y})");
1151 for _ in 0..300 {
1152 physics.step(&mut world.ctx());
1153 }
1154 let y = world.components.get::<Transform>(spawned).unwrap().position[1];
1155 assert!(
1156 (y - 0.5).abs() < 0.1,
1157 "the spawned ball rests on the flat floor (y = {y})"
1158 );
1159 }
1160
1161 #[test]
1165 fn spawn_despawn_respawn_cycle_is_leak_free() {
1166 let mut world = TestWorld::new();
1167 let mut physics = PhysicsSystem::new(PhysicsConfig {
1168 spawn_headroom: 1,
1169 ..PhysicsConfig::default()
1170 });
1171 physics.init(&mut world.ctx());
1172 let bodies = physics.physics_body_count();
1173 let colliders = physics.physics_collider_count();
1174
1175 for round in 0..3 {
1176 let spawned = world.spawn_prop(AssetId(100 + round), [0.0, 3.0, 0.0], false);
1177 make_dynamic(&mut world, spawned);
1178 physics.step(&mut world.ctx());
1179 assert_eq!(physics.physics_body_count(), bodies + 1, "round {round}");
1180 world.components.despawn(spawned);
1181 physics.step(&mut world.ctx());
1182 assert_eq!(
1183 physics.physics_body_count(),
1184 bodies,
1185 "round {round} reaped the body"
1186 );
1187 assert_eq!(
1188 physics.physics_collider_count(),
1189 colliders,
1190 "round {round} reaped the collider"
1191 );
1192 }
1193 }
1194
1195 #[test]
1198 fn a_shipped_budget_matching_the_world_is_adopted() {
1199 let mut world = TestWorld::new();
1200 let entity = world.spawn_prop(AssetId(1), [0.0, 3.0, 0.0], false);
1201 make_dynamic(&mut world, entity);
1202 world.components.push_typed(controlled_camera());
1203
1204 let counts = scan_counts(&world.ctx());
1207 let budget = PhysicsBudget::derive(&counts, 2);
1208 assert_eq!(budget.dynamic, 1);
1209 assert_eq!(budget.kinematic, 1, "the first-person camera capsule");
1210 world
1211 .resources
1212 .insert(WorldPhysicsBudget(record_of(&budget)));
1213
1214 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1215 physics.init(&mut world.ctx());
1216 assert_eq!(physics.body_cap, budget.body_cap());
1217 assert_eq!(
1218 physics.physics_body_count(),
1219 budget.body_total() as usize,
1220 "init built exactly the bodies the budget reserved"
1221 );
1222 }
1223
1224 #[test]
1232 fn a_world_without_a_shipped_budget_reserves_from_what_it_holds() {
1233 let mut world = TestWorld::new();
1234 let entity = world.spawn_prop(AssetId(1), [0.0, 3.0, 0.0], false);
1235 make_dynamic(&mut world, entity);
1236
1237 let mut physics = PhysicsSystem::new(PhysicsConfig {
1238 spawn_headroom: 4,
1239 ..PhysicsConfig::default()
1240 });
1241 physics.init(&mut world.ctx());
1242
1243 assert_eq!(
1244 physics.physics_body_count(),
1245 2,
1246 "the floor and the one authored prop"
1247 );
1248 assert_eq!(physics.body_cap, 2 + 4, "plus the authored headroom");
1249 }
1250
1251 #[test]
1255 fn a_spawn_past_the_shipped_budget_is_refused() {
1256 let mut world = TestWorld::new();
1257 let authored = world.spawn_prop(AssetId(1), [0.0, 3.0, 0.0], false);
1258 make_dynamic(&mut world, authored);
1259
1260 let counts = scan_counts(&world.ctx());
1262 let budget = PhysicsBudget::derive(&counts, 0);
1263 world
1264 .resources
1265 .insert(WorldPhysicsBudget(record_of(&budget)));
1266
1267 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1268 physics.init(&mut world.ctx());
1269 let full = physics.physics_body_count();
1270 assert_eq!(full, budget.body_cap() as usize, "the budget is spent");
1271
1272 let spawned = world.spawn_prop(AssetId(2), [0.0, 6.0, 0.0], false);
1273 make_dynamic(&mut world, spawned);
1274 physics.step(&mut world.ctx());
1275 assert_eq!(physics.physics_body_count(), full, "no body was built");
1276 assert!(physics.props.is_refused(spawned));
1277
1278 for _ in 0..10 {
1281 physics.step(&mut world.ctx());
1282 }
1283 assert_eq!(physics.physics_body_count(), full);
1284 let refused_y = world.components.get::<Transform>(spawned).unwrap().position[1];
1285 assert_eq!(refused_y, 6.0, "a refused prop is not simulated at all");
1286 let live_y = world
1287 .components
1288 .get::<Transform>(authored)
1289 .unwrap()
1290 .position[1];
1291 assert!(live_y < 3.0, "the authored prop still falls");
1292 }
1293
1294 #[test]
1302 fn a_rig_capsule_spawned_on_the_floor_neither_sinks_nor_rises() {
1303 let identity = [
1304 [1.0, 0.0, 0.0, 0.0],
1305 [0.0, 1.0, 0.0, 0.0],
1306 [0.0, 0.0, 1.0, 0.0],
1307 [0.0, 0.0, 0.0, 1.0],
1308 ];
1309 let mut world = TestWorld::new();
1310 world.components.push_typed(CharacterRig::new(
1311 SkinnedMeshHandle(1),
1312 0,
1313 identity,
1314 0.6,
1315 0.3,
1316 ));
1317
1318 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1319 physics.init(&mut world.ctx());
1320 let rig_y = |world: &mut TestWorld| {
1321 world
1322 .ctx()
1323 .query::<CharacterRig>()
1324 .next()
1325 .expect("the rig is there")
1326 .position[1]
1327 };
1328 assert_eq!(
1329 rig_y(&mut world),
1330 0.0,
1331 "the capsule spawns at its authored position, unlifted"
1332 );
1333
1334 for _ in 0..30 {
1335 physics.step(&mut world.ctx());
1336 }
1337 let settled = rig_y(&mut world);
1338 for _ in 0..300 {
1339 physics.step(&mut world.ctx());
1340 }
1341 let held = rig_y(&mut world);
1342
1343 assert!(
1344 settled.abs() < 0.01,
1345 "the capsule stayed on the floor (y = {settled})"
1346 );
1347 assert!(
1348 (held - settled).abs() < 1.0e-4,
1349 "and stopped moving ({settled} -> {held})"
1350 );
1351 }
1352
1353 #[test]
1356 fn contact_event_fires_on_impact_and_not_at_rest() {
1357 let id = AssetId(1);
1358 let mut world = TestWorld::new();
1359 let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
1360 make_dynamic(&mut world, entity);
1361
1362 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1363 physics.init(&mut world.ctx());
1364
1365 let mut cursor = EventCursor::default();
1366 let mut impacts: Vec<ContactEvent> = Vec::new();
1367 for _ in 0..120 {
1368 physics.step(&mut world.ctx());
1369 let ctx = world.ctx();
1370 if let Some(events) = ctx.events::<ContactEvent>() {
1371 impacts.extend(events.read(&mut cursor).copied());
1372 }
1373 }
1374 assert_eq!(impacts.len(), 1, "one landing, one event: {impacts:?}");
1375 let impact = impacts[0];
1376 assert_eq!(impact.a, entity);
1377 assert_eq!(impact.b, None, "the floor slab has no entity");
1378 assert!(
1379 impact.impulse > 3.0 && impact.impulse < 50.0,
1380 "impulse {} out of the plausible landing range",
1381 impact.impulse
1382 );
1383
1384 for _ in 0..300 {
1386 physics.step(&mut world.ctx());
1387 let ctx = world.ctx();
1388 if let Some(events) = ctx.events::<ContactEvent>() {
1389 assert_eq!(
1390 events.read(&mut cursor).count(),
1391 0,
1392 "resting contact must not publish events"
1393 );
1394 }
1395 }
1396 }
1397
1398 #[test]
1401 fn a_trigger_volume_reports_a_prop_crossing_it() {
1402 let volume_id = AssetId(9);
1403 let mut world = TestWorld::new();
1404 world.components.push_typed(TriggerVolume {
1405 asset_id: volume_id,
1406 position: [0.0, 3.0, 0.0],
1407 rotation_deg: [0.0; 3],
1408 collider: PropCollider {
1409 shape: "cuboid".to_string(),
1410 half_extents: [1.0, 0.5, 1.0],
1411 ..Default::default()
1412 },
1413 detects: TriggerFilter::Props,
1414 });
1415 let ball = world.spawn_prop(AssetId(1), [0.0, 6.0, 0.0], false);
1416 make_dynamic(&mut world, ball);
1417
1418 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1419 physics.init(&mut world.ctx());
1420
1421 let mut cursor = EventCursor::default();
1422 let mut crossings: Vec<VolumeEvent> = Vec::new();
1423 for _ in 0..180 {
1424 physics.step(&mut world.ctx());
1425 let ctx = world.ctx();
1426 if let Some(events) = ctx.events::<VolumeEvent>() {
1427 crossings.extend(events.read(&mut cursor).copied());
1428 }
1429 }
1430 assert_eq!(crossings.len(), 2, "in, then out: {crossings:?}");
1431 assert!(crossings.iter().all(|c| c.volume == volume_id));
1432 assert!(crossings[0].entered, "the ball entered first");
1433 assert!(!crossings[1].entered, "and left afterwards");
1434 }
1435
1436 #[test]
1440 fn a_world_anchored_joint_holds_its_prop_up() {
1441 let bob_id = AssetId(1);
1442 let mut world = TestWorld::new();
1443 let bob = world.spawn_prop(bob_id, [1.0, 4.0, 0.0], false);
1444 make_dynamic(&mut world, bob);
1445 world.components.push_typed(PhysicsJoint {
1446 asset_id: AssetId(2),
1447 kind: "spherical".to_string(),
1448 body_a: Some(bob_id),
1449 body_b: None,
1450 anchor_a: [-1.0, 0.0, 0.0],
1452 anchor_b: [0.0, 4.0, 0.0],
1453 ..Default::default()
1454 });
1455
1456 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1457 physics.init(&mut world.ctx());
1458 for _ in 0..240 {
1459 physics.step(&mut world.ctx());
1460 }
1461
1462 let position = world.components.get::<Transform>(bob).unwrap().position;
1463 let reach =
1464 ((position[0]).powi(2) + (position[1] - 4.0).powi(2) + (position[2]).powi(2)).sqrt();
1465 assert!(
1466 (reach - 1.0).abs() < 0.05,
1467 "the bob hangs one unit from the anchor, at {position:?} ({reach})"
1468 );
1469 assert!(
1470 position[1] > 2.5,
1471 "and is held up rather than falling ({position:?})"
1472 );
1473 }
1474
1475 #[test]
1478 fn no_collide_config_lets_a_layered_prop_fall_through_the_floor() {
1479 let config = PhysicsConfig {
1480 layers: vec!["ghost".to_string()],
1481 no_collide: vec![["ghost".to_string(), "world".to_string()]],
1482 ..PhysicsConfig::default()
1483 };
1484
1485 let mut world = TestWorld::new();
1486 let entity = world.spawn_prop(AssetId(1), [0.0, 2.0, 0.0], false);
1487 make_dynamic(&mut world, entity);
1488 world
1489 .components
1490 .get_mut::<Collider>(entity)
1491 .unwrap()
1492 .0
1493 .layer = "ghost".to_string();
1494
1495 let mut physics = PhysicsSystem::new(config);
1496 physics.init(&mut world.ctx());
1497 for _ in 0..240 {
1498 physics.step(&mut world.ctx());
1499 }
1500 let y = world.components.get::<Transform>(entity).unwrap().position[1];
1501 assert!(
1502 y < -10.0,
1503 "the ghost-layer prop fell through the floor (y = {y})"
1504 );
1505 }
1506}