concinnity_render/rt_topology.rs
1//! Backend-agnostic planner for the incremental RT acceleration-structure
2//! topology refresh. When the participating draw set changes at runtime (a
3//! cloned prop, a streamed chunk added/removed), the BLAS head must be brought
4//! back in line with the current set without rebuilding every BLAS: reuse every
5//! BLAS whose geometry slice is unchanged, build only the new ones, and retire
6//! the orphans. This module owns only the pure decision (which slot reuses which
7//! old BLAS, which are orphaned); the actual GPU allocation / build / retire is
8//! per-backend (directx/raytrace.rs, vulkan/raytrace.rs). Split out so the plan
9//! is unit-testable without a GPU.
10//!
11//! Consumed by the DirectX + Vulkan backends. The Metal backend predates this
12//! module and keeps its own equivalent copy (metal/raytrace.rs); a future
13//! cleanup could converge it here once Metal can be rebuilt alongside.
14
15use crate::render_types::DrawObject;
16use alloc::vec;
17use alloc::vec::Vec;
18
19/// Identifies the geometry a draw-object BLAS traces, on the shared
20/// vertex/index buffers. Two draw objects with the same signature trace
21/// identical geometry, so a topology refresh can reuse the existing BLAS instead
22/// of building a new one. A streamed mesh is placed wherever the sub-allocator
23/// has room, so a slot that streams out and back in generally returns on a
24/// different slice; the signature moves with it and the BLAS is rebuilt rather
25/// than wrongly reused. `base_vertex` + `index_offset` + `index_count` are
26/// exactly the inputs the per-backend geometry descriptor uses; `vertex_offset`
27/// is carried too so a static draw (whose `base_vertex` is 0) still
28/// distinguishes distinct vertex regions.
29///
30/// The slice location alone is not enough: an asset hot-reload rewrites a slot's
31/// bytes in place at unchanged offsets, which leaves every field above equal.
32/// `generation` (the draw object's `geometry_generation`) moves on each such
33/// rewrite so the stale BLAS is rebuilt instead of reused.
34#[derive(Clone, Copy, PartialEq, Eq, Debug)]
35pub struct GeomSig {
36 base_vertex: i32,
37 vertex_offset: usize,
38 index_offset: usize,
39 index_count: usize,
40 generation: u32,
41}
42
43impl GeomSig {
44 /// The topology class a draw record falls into.
45 pub fn of(obj: &DrawObject) -> Self {
46 Self {
47 base_vertex: obj.base_vertex,
48 vertex_offset: obj.vertex_offset,
49 index_offset: obj.index_offset,
50 index_count: obj.index_count,
51 generation: obj.geometry_generation,
52 }
53 }
54}
55
56/// Per-new-slot decision for a topology refresh of the draw-object BLAS head.
57pub struct TopologyPlan {
58 /// `reuse[j] == Some(k)`: new draw slot `j` reuses the old draw BLAS at index
59 /// `k` (its geometry is unchanged). `None`: build a fresh BLAS for slot `j`.
60 pub reuse: Vec<Option<usize>>,
61 /// Old draw BLAS indices no longer referenced by any new slot -- retire them.
62 pub retire: Vec<usize>,
63}
64
65/// Decide, for the draw-object BLAS head only, which BLAS to reuse, which to
66/// build, and which to retire when the participating draw set changes. Matches
67/// old and new slots by `draw_objects` index AND geometry signature: a slot whose
68/// geometry moved (a chunk slot recycled for a different chunk) does not match, so
69/// it rebuilds. Pure so it is unit-testable without a GPU.
70pub fn plan_topology_refresh(
71 old_indices: &[usize],
72 old_sigs: &[GeomSig],
73 new_indices: &[usize],
74 new_sigs: &[GeomSig],
75) -> TopologyPlan {
76 use hashbrown::HashMap;
77 // draw_objects index -> (position in the old draw BLAS head, its signature).
78 // `object_indices` entries are unique (one per draw slot), so this is 1:1.
79 let mut by_idx: HashMap<usize, (usize, GeomSig)> = HashMap::with_capacity(old_indices.len());
80 for (k, (&idx, &sig)) in old_indices.iter().zip(old_sigs).enumerate() {
81 by_idx.insert(idx, (k, sig));
82 }
83 let mut used = vec![false; old_indices.len()];
84 let mut reuse = Vec::with_capacity(new_indices.len());
85 for (&idx, &sig) in new_indices.iter().zip(new_sigs) {
86 match by_idx.get(&idx) {
87 Some(&(k, old_sig)) if old_sig == sig && !used[k] => {
88 used[k] = true;
89 reuse.push(Some(k));
90 }
91 _ => reuse.push(None),
92 }
93 }
94 let retire = used
95 .iter()
96 .enumerate()
97 .filter(|&(_, &u)| !u)
98 .map(|(k, _)| k)
99 .collect();
100 TopologyPlan { reuse, retire }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 // A distinct geometry signature keyed off `tag` (used as the index offset),
108 // so two slots with different tags never compare equal.
109 fn sig(tag: usize) -> GeomSig {
110 GeomSig {
111 base_vertex: tag as i32,
112 vertex_offset: tag * 100,
113 index_offset: tag,
114 index_count: 3,
115 generation: 0,
116 }
117 }
118
119 #[test]
120 fn topology_plan_reuses_an_unchanged_set() {
121 let old_i = [2usize, 5, 7];
122 let old_s = [sig(2), sig(5), sig(7)];
123 let plan = plan_topology_refresh(&old_i, &old_s, &old_i, &old_s);
124 assert_eq!(plan.reuse, vec![Some(0), Some(1), Some(2)]);
125 assert!(plan.retire.is_empty());
126 }
127
128 #[test]
129 fn topology_plan_builds_only_the_added_slot() {
130 let old_i = [2usize, 5];
131 let old_s = [sig(2), sig(5)];
132 let new_i = [2usize, 5, 9];
133 let new_s = [sig(2), sig(5), sig(9)];
134 let plan = plan_topology_refresh(&old_i, &old_s, &new_i, &new_s);
135 // The two existing slots reuse; the new one (9) builds fresh.
136 assert_eq!(plan.reuse, vec![Some(0), Some(1), None]);
137 assert!(plan.retire.is_empty());
138 }
139
140 #[test]
141 fn topology_plan_retires_a_removed_slot() {
142 let old_i = [2usize, 5, 7];
143 let old_s = [sig(2), sig(5), sig(7)];
144 let new_i = [2usize, 7];
145 let new_s = [sig(2), sig(7)];
146 let plan = plan_topology_refresh(&old_i, &old_s, &new_i, &new_s);
147 assert_eq!(plan.reuse, vec![Some(0), Some(2)]);
148 assert_eq!(plan.retire, vec![1]); // slot 5's old BLAS is orphaned
149 }
150
151 #[test]
152 fn topology_plan_rebuilds_a_recycled_slot_whose_geometry_moved() {
153 // Same draw index, different geometry signature: a chunk slot recycled for
154 // a different chunk. The old BLAS must NOT be reused; it is retired and a
155 // fresh one is built.
156 let old_i = [5usize];
157 let old_s = [sig(5)];
158 let new_i = [5usize];
159 let new_s = [sig(8)]; // moved geometry under the same draw index
160 let plan = plan_topology_refresh(&old_i, &old_s, &new_i, &new_s);
161 assert_eq!(plan.reuse, vec![None]);
162 assert_eq!(plan.retire, vec![0]);
163 }
164
165 #[test]
166 fn topology_plan_rebuilds_a_slot_rewritten_in_place() {
167 // A size-matched asset hot-reload overwrites the slot's bytes at its
168 // existing offsets, so every location field stays equal and only the
169 // generation moves. The BLAS traces the old contents and must rebuild.
170 let old_i = [5usize];
171 let old_s = [sig(5)];
172 let new_i = [5usize];
173 let mut moved = sig(5);
174 moved.generation = 1;
175 let plan = plan_topology_refresh(&old_i, &old_s, &new_i, &[moved]);
176 assert_eq!(plan.reuse, vec![None]);
177 assert_eq!(plan.retire, vec![0]);
178 }
179
180 #[test]
181 fn geom_sig_tracks_the_draw_object_generation() {
182 // A draw object whose slice never moves: only an in-place rewrite of
183 // its bytes (the generation bump) may change its signature.
184 let mut obj = DrawObject {
185 vertex_offset: 256,
186 vertex_count: 8,
187 index_offset: 12,
188 index_count: 6,
189 base_vertex: 0,
190 geometry_generation: 0,
191 shader_bucket: 0,
192 model: [[0.0; 4]; 4],
193 texture_slot: 0,
194 normal_map_slot: 0,
195 material: crate::render_types::MaterialUniforms::DEFAULT,
196 visible: true,
197 resident: true,
198 bb_min: [0.0; 3],
199 bb_max: [1.0; 3],
200 cull_distance: 0.0,
201 lod_alternates: Vec::new(),
202 };
203 let before = GeomSig::of(&obj);
204 assert_eq!(before, GeomSig::of(&obj));
205 obj.geometry_generation += 1;
206 assert_ne!(before, GeomSig::of(&obj));
207 }
208
209 #[test]
210 fn topology_plan_reuses_across_reorder_by_index() {
211 // The participating set is the same but its order changed; each slot still
212 // reuses its BLAS by draw index (the reuse points at the old position).
213 let old_i = [2usize, 5];
214 let old_s = [sig(2), sig(5)];
215 let new_i = [5usize, 2];
216 let new_s = [sig(5), sig(2)];
217 let plan = plan_topology_refresh(&old_i, &old_s, &new_i, &new_s);
218 assert_eq!(plan.reuse, vec![Some(1), Some(0)]);
219 assert!(plan.retire.is_empty());
220 }
221}