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}