Skip to main content

concinnity_physics/
budget.rs

1// concinnity-physics/src/budget.rs
2//
3// How much a world's physics is allowed to cost, derived from what the world
4// actually contains. Cook counts the authored content and the runtime counts
5// the loaded components; both feed the same derivation, so the number the
6// simulation reserves is the number cook promised.
7//
8// The split between counts and budget is deliberate: counts are what a world
9// has, the budget is what the simulation reserves for it. Only the second is
10// worth shipping, and only the first is worth comparing across the two sides.
11
12/// Tallies of a world's authored physics content.
13///
14/// Both cook (over the authored asset list) and the runtime (over the loaded
15/// component columns) produce one of these, which is what lets the two sides
16/// be compared for agreement.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub struct PhysicsCounts {
19    /// Collider-bearing entities with no dynamics: walls, scenery, floors.
20    pub static_colliders: u32,
21    /// Collider-bearing entities that are freely simulated.
22    pub dynamic_colliders: u32,
23    /// Sensor regions that report crossings but never collide.
24    pub trigger_volumes: u32,
25    /// Joints whose bodies both resolve to collider-bearing entities.
26    pub joints: u32,
27    /// The subset of `joints` anchored to the world rather than a second body.
28    pub world_anchored_joints: u32,
29    /// The player capsule: `1` when the world declares one, else `0`.
30    pub player_capsules: u32,
31    /// Kinematic capsules driven by root motion, one per character rig.
32    pub rig_capsules: u32,
33}
34
35/// The reservation a world's physics content implies.
36///
37/// Grouped by the kind of body the simulation builds rather than by the asset
38/// that asked for it, because that is what the simulation sizes its storage
39/// against.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub struct PhysicsBudget {
42    /// Immovable bodies: static colliders plus the world's floor.
43    pub fixed: u32,
44    /// Freely simulated bodies.
45    pub dynamic: u32,
46    /// Position-driven bodies: the player capsule and the character rigs.
47    pub kinematic: u32,
48    /// Sensor bodies, one per trigger volume.
49    pub sensors: u32,
50    /// Joints connecting two bodies.
51    pub joints: u32,
52    /// Hidden static bodies minted to anchor a world-anchored joint.
53    pub anchors: u32,
54    /// Bodies held back for entities created after init.
55    pub spawn_headroom: u32,
56}
57
58impl PhysicsBudget {
59    /// Derive the reservation for a world's `counts`, holding back
60    /// `spawn_headroom` bodies for entities created at runtime.
61    ///
62    /// The floor body is always built, so `fixed` is one past the static
63    /// collider count.
64    pub const fn derive(counts: &PhysicsCounts, spawn_headroom: u32) -> Self {
65        Self {
66            fixed: counts.static_colliders.saturating_add(1),
67            dynamic: counts.dynamic_colliders,
68            kinematic: counts.player_capsules.saturating_add(counts.rig_capsules),
69            sensors: counts.trigger_volumes,
70            joints: counts.joints,
71            anchors: counts.world_anchored_joints,
72            spawn_headroom,
73        }
74    }
75
76    /// Bodies the authored world builds at init.
77    pub const fn body_total(&self) -> u32 {
78        self.fixed
79            .saturating_add(self.dynamic)
80            .saturating_add(self.kinematic)
81            .saturating_add(self.sensors)
82            .saturating_add(self.anchors)
83    }
84
85    /// The hard ceiling on live bodies: everything init builds, plus the
86    /// headroom held back for runtime spawns. The simulation refuses to build
87    /// a body past this.
88    pub const fn body_cap(&self) -> u32 {
89        self.body_total().saturating_add(self.spawn_headroom)
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn an_empty_world_still_reserves_its_floor() {
99        let budget = PhysicsBudget::derive(&PhysicsCounts::default(), 0);
100        assert_eq!(budget.fixed, 1, "the floor body is always built");
101        assert_eq!(budget.body_total(), 1);
102        assert_eq!(budget.body_cap(), 1);
103    }
104
105    #[test]
106    fn each_count_lands_in_its_own_category() {
107        let counts = PhysicsCounts {
108            static_colliders: 4,
109            dynamic_colliders: 3,
110            trigger_volumes: 2,
111            joints: 5,
112            world_anchored_joints: 2,
113            player_capsules: 1,
114            rig_capsules: 6,
115        };
116        let budget = PhysicsBudget::derive(&counts, 0);
117        assert_eq!(budget.fixed, 5, "4 static colliders plus the floor");
118        assert_eq!(budget.dynamic, 3);
119        assert_eq!(budget.kinematic, 7, "1 player capsule plus 6 rig capsules");
120        assert_eq!(budget.sensors, 2);
121        assert_eq!(budget.joints, 5);
122        assert_eq!(budget.anchors, 2);
123        // Joints constrain bodies rather than being ones, so they stay out of
124        // the total; their hidden anchors do not.
125        assert_eq!(budget.body_total(), 5 + 3 + 7 + 2 + 2);
126    }
127
128    #[test]
129    fn headroom_lifts_the_cap_but_not_the_total() {
130        let counts = PhysicsCounts {
131            static_colliders: 2,
132            ..PhysicsCounts::default()
133        };
134        let budget = PhysicsBudget::derive(&counts, 32);
135        assert_eq!(budget.body_total(), 3);
136        assert_eq!(budget.body_cap(), 35);
137    }
138
139    #[test]
140    fn derivation_is_the_same_from_either_side() {
141        // The property the runtime debug-asserts: identical counts must give
142        // an identical budget, whoever counted them.
143        let counts = PhysicsCounts {
144            static_colliders: 9,
145            dynamic_colliders: 4,
146            rig_capsules: 2,
147            ..PhysicsCounts::default()
148        };
149        assert_eq!(
150            PhysicsBudget::derive(&counts, 8),
151            PhysicsBudget::derive(&counts, 8)
152        );
153    }
154
155    #[test]
156    fn absurd_counts_saturate_instead_of_wrapping() {
157        let counts = PhysicsCounts {
158            static_colliders: u32::MAX,
159            dynamic_colliders: u32::MAX,
160            ..PhysicsCounts::default()
161        };
162        let budget = PhysicsBudget::derive(&counts, u32::MAX);
163        assert_eq!(budget.fixed, u32::MAX);
164        assert_eq!(budget.body_cap(), u32::MAX);
165    }
166}