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}