Skip to main content

concinnity_core/render/
rt_geom.rs

1//! GPU-free builders for the ray-tracing geometry table plus the dynamic-update
2//! mode ladder, shared by the backends that hardware-ray-trace reflections. Each
3//! backend fills its own `RtGeomEntry` table from the participating draw set;
4//! the per-entry packing (index slice, resolved shared-pool texture indices,
5//! material, model matrix) is identical across backends and lives here. The
6//! per-backend TLAS instance transform (`MTLPackedFloat4x3` / `VkTransformMatrixKHR`
7//! / DXR `[f32; 12]`) is a real hardware type and stays in each backend.
8
9use crate::gfx::render_types::{
10    DrawObject, InstancedCluster, MaterialUniforms, RtGeomEntry, SkinnedDrawObject,
11    albedo_pool_index, normal_pool_index,
12};
13
14// Marks a `RtGeomEntry.normal_index` as belonging to a skinned object: the
15// reflection trace then fetches the hit triangle from the deformed-vertex / u16
16// skinned index buffers instead of the static u32 ones. Bit 31 is free (bindless
17// pool indices never approach 2^31); matches the flag in each backend's RT-hit
18// shader.
19pub(crate) const RT_SKINNED_FLAG: u32 = 0x8000_0000;
20
21/// Bytes to allocate for the shared u32 skinned index buffer holding
22/// `index_count` indices. One index per word, so the only rounding left is the
23/// floor: no backend accepts a zero-length buffer.
24pub fn skinned_index_buffer_bytes(index_count: usize) -> usize {
25    (index_count * core::mem::size_of::<u32>()).max(4)
26}
27
28/// How the scene acceleration structure is kept current when props move.
29/// Selected once at init from the launch's `--rt-dynamic` request; `Auto` is
30/// the shipping behaviour and what an unset request resolves to.
31#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
32pub enum RtDynamicMode {
33    /// Build once, never update. Forces a static BVH even if props move: the
34    /// pre-dynamic behaviour, kept as a fast path / diagnostic (`off`).
35    Off,
36    /// Default. Rebuild the TLAS + table (fresh allocations, static BLAS) only on
37    /// the frames a participating transform actually changed. Static scenes never
38    /// rebuild, so they pay only a cheap per-frame matrix compare.
39    #[default]
40    Auto,
41    /// Force a full BVH rebuild every frame, dirty or not. Diagnostic (`rebuild`);
42    /// the most expensive path.
43    Rebuild,
44    /// Force a fresh TLAS + table rebuild every frame, dirty or not. Diagnostic
45    /// (`tlas`); the same GPU work `Auto` does, minus the dirty gate.
46    Tlas,
47}
48
49impl RtDynamicMode {
50    /// Whether this mode updates the BVH after the initial build at all.
51    pub fn is_dynamic(self) -> bool {
52        self != Self::Off
53    }
54}
55
56// Shared-pool (albedo, normal) indices for a draw whose authored albedo /
57// normal-map slots are `texture_slot` / `normal_map_slot`. Albedo and normal
58// maps share one handle-indexed pool, so albedo = `texture_slot` and normal =
59// the normal map's own handle (or the flat-normal fallback slot when the draw
60// has none), resolved through the shared `render_types` helpers and matching the
61// bindless main pass. `texture_count` is the real-texture count (the flat-normal
62// fallback sits at `texture_count`).
63pub(crate) fn pool_indices(
64    texture_slot: usize,
65    normal_map_slot: usize,
66    texture_count: u32,
67) -> (u32, u32) {
68    (
69        albedo_pool_index(texture_slot, texture_count),
70        normal_pool_index(normal_map_slot, texture_count),
71    )
72}
73
74// The shared body of the three entry builders: everything but where the mesh
75// slice, the transform, and the skinned flag come from.
76fn entry(
77    index_offset: u32,
78    base_vertex: u32,
79    model: [[f32; 4]; 4],
80    albedo_index: u32,
81    normal_index: u32,
82    material: &MaterialUniforms,
83) -> RtGeomEntry {
84    RtGeomEntry {
85        index_offset,
86        base_vertex,
87        albedo_index,
88        normal_index,
89        tint: material.tint,
90        roughness: material.roughness,
91        metallic: material.metallic,
92        emissive: material.emissive,
93        model,
94        emissive_map_index: material.emissive_map_index,
95        _pad: [0; 3],
96    }
97}
98
99/// Build the geometry-table entry for one static draw object.
100pub fn geom_entry(obj: &DrawObject, texture_count: u32) -> RtGeomEntry {
101    let (albedo_index, normal_index) =
102        pool_indices(obj.texture_slot, obj.normal_map_slot, texture_count);
103    entry(
104        obj.index_offset as u32,
105        obj.base_vertex as u32,
106        obj.model,
107        albedo_index,
108        normal_index,
109        &obj.material,
110    )
111}
112
113/// Build the geometry-table entry for one instance of an instanced cluster: the
114/// cluster's shared mesh slice + material, with this instance's transform. Cluster
115/// geometry uses base_vertex 0 (its indices are already absolute).
116pub fn cluster_geom_entry(
117    cluster: &InstancedCluster,
118    model: [[f32; 4]; 4],
119    texture_count: u32,
120) -> RtGeomEntry {
121    let (albedo_index, normal_index) =
122        pool_indices(cluster.texture_slot, cluster.normal_map_slot, texture_count);
123    entry(
124        cluster.index_offset as u32,
125        0,
126        model,
127        albedo_index,
128        normal_index,
129        &cluster.material,
130    )
131}
132
133/// Build the geometry-table entry for one skinned object. The skinned BLAS is
134/// baked from the posed (model-space) deformed buffer with absolute u16 indices,
135/// so `base_vertex` is 0 and the model matrix brings the hit to world space. The
136/// skinned flag is OR'd into `normal_index` so the trace fetches from the
137/// deformed / u16 buffers. Albedo / normal resolve through the shared pool by the
138/// object's `texture_slot` / `normal_map_slot`, so skinned hits shade textured
139/// like static ones (the flag bit lives above any valid pool index).
140pub fn skinned_geom_entry(obj: &SkinnedDrawObject, texture_count: u32) -> RtGeomEntry {
141    let (albedo_index, normal_index) =
142        pool_indices(obj.texture_slot, obj.normal_map_slot, texture_count);
143    entry(
144        obj.index_offset as u32,
145        0,
146        obj.model,
147        albedo_index,
148        normal_index | RT_SKINNED_FLAG,
149        &obj.material,
150    )
151}
152
153/// True when any participating object's current model matrix differs from the one
154/// baked into the live TLAS. Pure (no GPU) so the dirty gate is unit-testable.
155pub fn models_dirty(cached: &[[[f32; 4]; 4]], current: &[[[f32; 4]; 4]]) -> bool {
156    cached.len() != current.len() || cached.iter().zip(current).any(|(a, b)| a != b)
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    use alloc::vec::Vec;
164    // One u32 index per word, and never a zero-length allocation.
165    #[test]
166    fn skinned_index_buffer_holds_one_word_per_index() {
167        assert_eq!(skinned_index_buffer_bytes(0), 4);
168        assert_eq!(skinned_index_buffer_bytes(1), 4);
169        assert_eq!(skinned_index_buffer_bytes(3), 12);
170        for count in 0..64usize {
171            let bytes = skinned_index_buffer_bytes(count);
172            assert!(
173                bytes >= count * 4,
174                "{count}: {bytes} cannot hold the indices"
175            );
176            assert_eq!(bytes % 4, 0, "{count}: {bytes} is not whole words");
177        }
178    }
179
180    #[test]
181    fn only_off_stops_updating_the_bvh() {
182        assert_eq!(RtDynamicMode::default(), RtDynamicMode::Auto);
183        assert!(RtDynamicMode::Auto.is_dynamic());
184        assert!(RtDynamicMode::Rebuild.is_dynamic());
185        assert!(RtDynamicMode::Tlas.is_dynamic());
186        assert!(!RtDynamicMode::Off.is_dynamic());
187    }
188
189    #[test]
190    fn pool_indices_share_one_handle_indexed_pool() {
191        use crate::gfx::render_types::NO_NORMAL_MAP_SLOT;
192        // Albedo and a real normal map both index the shared pool by their own
193        // handle. 5 real textures; the flat-normal fallback sits at slot 5.
194        assert_eq!(pool_indices(2, 1, 5), (2, 1));
195        // Out-of-range real slots clamp to the last real texture (4).
196        assert_eq!(pool_indices(9, 9, 5), (4, 4));
197        // A draw with no normal map addresses the flat-normal fallback slot.
198        assert_eq!(pool_indices(2, NO_NORMAL_MAP_SLOT, 5), (2, 5));
199    }
200
201    #[test]
202    fn models_dirty_detects_a_changed_transform() {
203        let a = [[
204            [1.0, 0.0, 0.0, 0.0],
205            [0.0, 1.0, 0.0, 0.0],
206            [0.0, 0.0, 1.0, 0.0],
207            [0.0, 0.0, 0.0, 1.0],
208        ]];
209        let mut b = a;
210        assert!(!models_dirty(&a, &b));
211        b[0][3][0] = 5.0;
212        assert!(models_dirty(&a, &b));
213        // A length change is dirty.
214        assert!(models_dirty(&a, &[]));
215    }
216
217    #[test]
218    fn skinned_flag_is_bit_31_and_masks_back_to_the_pool_index() {
219        // The flag occupies the top bit; the shader recovers the real bindless
220        // normal index with `normal_index & ~RT_SKINNED_FLAG`. Mirror both here.
221        assert_eq!(RT_SKINNED_FLAG, 1u32 << 31);
222        for normal_index in [0u32, 1, 5, 96, 1000] {
223            let flagged = normal_index | RT_SKINNED_FLAG;
224            assert_ne!(flagged & RT_SKINNED_FLAG, 0, "flag set");
225            assert_eq!(flagged & !RT_SKINNED_FLAG, normal_index, "masks back");
226        }
227        // Realistic bindless pool indices never reach the flag bit, so a static
228        // entry's normal index is never misread as skinned.
229        assert_eq!(96u32 & RT_SKINNED_FLAG, 0);
230    }
231
232    #[test]
233    fn skinned_geom_entry_flags_and_zeroes_base_vertex() {
234        use crate::gfx::render_types::{MaterialUniforms, SkinnedDrawObject};
235        let material = MaterialUniforms {
236            tint: [0.2, 0.4, 0.6],
237            roughness: 0.3,
238            metallic: 0.5,
239            emissive: [0.1, 0.0, 0.0],
240            ..MaterialUniforms::DEFAULT
241        };
242        let obj = SkinnedDrawObject {
243            vertex_base: 7,
244            vertex_count: 100,
245            index_offset: 42,
246            index_count: 300,
247            model: [
248                [1.0, 0.0, 0.0, 0.0],
249                [0.0, 1.0, 0.0, 0.0],
250                [0.0, 0.0, 1.0, 0.0],
251                [3.0, 4.0, 5.0, 1.0],
252            ],
253            texture_slot: 9,
254            normal_map_slot: 3,
255            material,
256            visible: true,
257            joint_count: 12,
258            local_bb_min: [-1.0, -1.0, -1.0],
259            local_bb_max: [1.0, 1.0, 1.0],
260            lod_alternates: Vec::new(),
261        };
262        let texture_count = 12u32;
263        let e = skinned_geom_entry(&obj, texture_count);
264        // The skinned BLAS bakes absolute indices, so base_vertex is folded to 0.
265        assert_eq!(e.base_vertex, 0);
266        // The skinned flag is set; masking it off recovers the real shared-pool
267        // index, computed the same way as a static draw (so skinned hits texture).
268        assert_ne!(e.normal_index & RT_SKINNED_FLAG, 0);
269        let (exp_albedo, exp_normal) =
270            pool_indices(obj.texture_slot, obj.normal_map_slot, texture_count);
271        assert_eq!(e.albedo_index, exp_albedo);
272        assert_eq!(e.normal_index & !RT_SKINNED_FLAG, exp_normal);
273        // Material + index offset carry through; the model lifts the hit to world.
274        assert_eq!(e.index_offset, 42);
275        assert_eq!(e.tint, [0.2, 0.4, 0.6]);
276        assert_eq!(e.model[3], [3.0, 4.0, 5.0, 1.0]);
277    }
278
279    // A static draw's entry carries the mesh slice as authored: the index
280    // offset and the base vertex both come from the draw, because static
281    // indices are relative to the shared vertex buffer.
282    #[test]
283    fn a_static_entry_keeps_the_draws_own_mesh_slice() {
284        let obj = crate::test_support::draw_object();
285        let texture_count = 12u32;
286        let e = geom_entry(&obj, texture_count);
287
288        assert_eq!(e.index_offset, obj.index_offset as u32);
289        assert_eq!(e.base_vertex, obj.base_vertex as u32);
290        // Nothing about a static draw is skinned, so the flag bit stays clear
291        // and the shader reads the static u32 index buffer.
292        assert_eq!(e.normal_index & RT_SKINNED_FLAG, 0);
293        let (albedo, normal) = pool_indices(obj.texture_slot, obj.normal_map_slot, texture_count);
294        assert_eq!((e.albedo_index, e.normal_index), (albedo, normal));
295        assert_eq!(e.model, obj.model);
296        assert_eq!(e.tint, obj.material.tint);
297        assert_eq!(e.roughness, obj.material.roughness);
298    }
299
300    // A cluster instance shares the cluster's mesh slice and material but
301    // carries its own transform, and its indices are already absolute, so the
302    // base vertex folds to zero the way the skinned path's does.
303    #[test]
304    fn a_cluster_instance_zeroes_the_base_vertex_and_takes_its_own_model() {
305        use crate::gfx::render_types::{InstancedCluster, MaterialUniforms};
306        let cluster = InstancedCluster {
307            vertex_offset: 64,
308            vertex_count: 24,
309            index_offset: 36,
310            index_count: 36,
311            texture_slot: 4,
312            normal_map_slot: 2,
313            material: MaterialUniforms {
314                tint: [0.9, 0.8, 0.7],
315                roughness: 0.25,
316                ..MaterialUniforms::DEFAULT
317            },
318            cluster_bb_min: [-1.0; 3],
319            cluster_bb_max: [1.0; 3],
320            local_bb_min: [-1.0; 3],
321            local_bb_max: [1.0; 3],
322            cull_distance: 0.0,
323            instances: Vec::new(),
324            lod_alternates: Vec::new(),
325        };
326        let model = [
327            [1.0, 0.0, 0.0, 0.0],
328            [0.0, 1.0, 0.0, 0.0],
329            [0.0, 0.0, 1.0, 0.0],
330            [8.0, 9.0, 10.0, 1.0],
331        ];
332        let texture_count = 12u32;
333        let e = cluster_geom_entry(&cluster, model, texture_count);
334
335        assert_eq!(e.base_vertex, 0, "cluster indices are already absolute");
336        assert_eq!(e.index_offset, cluster.index_offset as u32);
337        assert_eq!(
338            e.model, model,
339            "the instance's own transform, not the cluster's"
340        );
341        assert_eq!(e.normal_index & RT_SKINNED_FLAG, 0);
342        let (albedo, normal) =
343            pool_indices(cluster.texture_slot, cluster.normal_map_slot, texture_count);
344        assert_eq!((e.albedo_index, e.normal_index), (albedo, normal));
345        assert_eq!(e.tint, [0.9, 0.8, 0.7]);
346        assert_eq!(e.roughness, 0.25);
347    }
348}