Skip to main content

concinnity_core/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::gfx::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::gfx::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}