concinnity_core/render/skinned_pool.rs
1//! Free pool for pre-reserved skinned instance slots. A skinned mesh that opts
2//! into runtime spawning (SkinnedMesh.max_instances > 0) has that many hidden
3//! bind-pose copies appended to the skinned geometry at load. Each copy is its
4//! own skinned draw object with its own vertex region in the shared skinned
5//! buffer, which is required because the GPU skin fold writes the deformed
6//! buffer keyed by global vertex index: two live instances sharing a region
7//! would clobber each other's pose. This pool tracks, per template, which of
8//! those copies are currently free so a spawn can claim one and a despawn can
9//! return it. Slot indices are stable skinned-draw-object indices; nothing is
10//! compacted, so the per-frame skinned arrays that parallel them stay valid.
11//!
12//! Built and consumed by every graphics backend's runtime skinned-spawn path
13//! (Metal, DirectX, Vulkan).
14
15use alloc::vec::Vec;
16use hashbrown::HashMap;
17
18#[derive(Debug, Default)]
19/// Recycles skinned draw slots as skinned instances spawn and despawn.
20pub struct SkinnedInstancePool {
21 // template skinned-draw-object index -> its currently free instance slots.
22 free: HashMap<usize, Vec<usize>>,
23 // instance slot -> the template it belongs to, so `release` returns it to
24 // the right pool. Set once at `reserve` and never changed (a copy always
25 // belongs to the template it was expanded from).
26 owner: HashMap<usize, usize>,
27}
28
29impl SkinnedInstancePool {
30 /// An empty pool.
31 pub fn new() -> Self {
32 Self::default()
33 }
34
35 /// Record a pre-reserved instance slot as free and owned by `template`.
36 /// Called once per expanded copy at load.
37 pub fn reserve(&mut self, template: usize, instance: usize) {
38 self.owner.insert(instance, template);
39 self.free.entry(template).or_default().push(instance);
40 }
41
42 /// Claim a free instance slot for `template`, or `None` when the reserve is
43 /// exhausted (more live copies than were pre-reserved).
44 pub fn acquire(&mut self, template: usize) -> Option<usize> {
45 self.free.get_mut(&template).and_then(|slots| slots.pop())
46 }
47
48 /// Return a live instance slot to its template's free list. Returns false if
49 /// the slot was never a pre-reserved instance (e.g. an authored template
50 /// slot), so the caller can tell a recyclable slot from a fixed one.
51 pub fn release(&mut self, instance: usize) -> bool {
52 let Some(&template) = self.owner.get(&instance) else {
53 return false;
54 };
55 self.free.entry(template).or_default().push(instance);
56 true
57 }
58
59 /// Total free slots across every template. Surfaced through the debug
60 /// profile so a probe can watch the pool drain on spawn and refill on
61 /// despawn, a direct check on the free-list recycle.
62 pub fn total_free(&self) -> usize {
63 self.free.values().map(Vec::len).sum()
64 }
65}
66
67#[cfg(test)]
68mod tests {
69 use super::*;
70
71 #[test]
72 fn acquire_then_release_recycles_the_same_slot() {
73 let mut pool = SkinnedInstancePool::new();
74 // Template 0 owns two pre-reserved copies: slots 1 and 2.
75 pool.reserve(0, 1);
76 pool.reserve(0, 2);
77 assert_eq!(pool.total_free(), 2);
78
79 let a = pool.acquire(0).expect("first claim");
80 let b = pool.acquire(0).expect("second claim");
81 assert!(pool.acquire(0).is_none(), "reserve exhausted");
82 assert_eq!(pool.total_free(), 0);
83
84 // Releasing a claimed slot makes it available again, and the next claim
85 // hands it back out instead of growing.
86 assert!(pool.release(a));
87 assert_eq!(pool.total_free(), 1);
88 let reused = pool.acquire(0).expect("reuse after release");
89 assert_eq!(reused, a, "a freed instance slot is recycled");
90 let _ = b;
91 }
92
93 #[test]
94 fn slots_return_only_to_their_own_template() {
95 let mut pool = SkinnedInstancePool::new();
96 pool.reserve(0, 10); // template 0
97 pool.reserve(5, 20); // template 5
98 let s0 = pool.acquire(0).unwrap();
99 let s5 = pool.acquire(5).unwrap();
100 assert_eq!((s0, s5), (10, 20));
101 pool.release(s0);
102 pool.release(s5);
103 // Each slot went back to its own template's pool, not the other's.
104 assert_eq!(pool.acquire(0), Some(10));
105 assert_eq!(pool.acquire(5), Some(20));
106 }
107
108 #[test]
109 fn releasing_an_unknown_slot_is_a_clean_false() {
110 let mut pool = SkinnedInstancePool::new();
111 pool.reserve(0, 1);
112 // Slot 99 was never reserved (e.g. an authored template slot): release
113 // reports it is not a pool slot and changes nothing.
114 assert!(!pool.release(99));
115 assert_eq!(pool.total_free(), 1);
116 }
117
118 #[test]
119 fn total_free_sums_across_templates() {
120 let mut pool = SkinnedInstancePool::new();
121 pool.reserve(0, 1);
122 pool.reserve(0, 2);
123 pool.reserve(3, 4);
124 assert_eq!(pool.total_free(), 3);
125 pool.acquire(0);
126 assert_eq!(pool.total_free(), 2);
127 }
128}