Skip to main content

concinnity_core/physics/budget/
derive.rs

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