Skip to main content

concinnity_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::render_types::{
10    DrawObject, InstancedCluster, RtGeomEntry, SkinnedDrawObject, albedo_pool_index,
11    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/// Build the geometry-table entry for one static draw object.
75pub fn geom_entry(obj: &DrawObject, texture_count: u32) -> RtGeomEntry {
76    let (albedo_index, normal_index) =
77        pool_indices(obj.texture_slot, obj.normal_map_slot, texture_count);
78    RtGeomEntry {
79        index_offset: obj.index_offset as u32,
80        base_vertex: obj.base_vertex as u32,
81        albedo_index,
82        normal_index,
83        tint: obj.material.tint,
84        roughness: obj.material.roughness,
85        metallic: obj.material.metallic,
86        emissive: obj.material.emissive,
87        model: obj.model,
88        emissive_map_index: obj.material.emissive_map_index,
89        _pad: [0; 3],
90    }
91}
92
93/// Build the geometry-table entry for one instance of an instanced cluster: the
94/// cluster's shared mesh slice + material, with this instance's transform. Cluster
95/// geometry uses base_vertex 0 (its indices are already absolute).
96pub fn cluster_geom_entry(
97    cluster: &InstancedCluster,
98    model: [[f32; 4]; 4],
99    texture_count: u32,
100) -> RtGeomEntry {
101    let (albedo_index, normal_index) =
102        pool_indices(cluster.texture_slot, cluster.normal_map_slot, texture_count);
103    RtGeomEntry {
104        index_offset: cluster.index_offset as u32,
105        base_vertex: 0,
106        albedo_index,
107        normal_index,
108        tint: cluster.material.tint,
109        roughness: cluster.material.roughness,
110        metallic: cluster.material.metallic,
111        emissive: cluster.material.emissive,
112        model,
113        emissive_map_index: cluster.material.emissive_map_index,
114        _pad: [0; 3],
115    }
116}
117
118/// Build the geometry-table entry for one skinned object. The skinned BLAS is
119/// baked from the posed (model-space) deformed buffer with absolute u16 indices,
120/// so `base_vertex` is 0 and the model matrix brings the hit to world space. The
121/// skinned flag is OR'd into `normal_index` so the trace fetches from the
122/// deformed / u16 buffers. Albedo / normal resolve through the shared pool by the
123/// object's `texture_slot` / `normal_map_slot`, so skinned hits shade textured
124/// like static ones (the flag bit lives above any valid pool index).
125pub fn skinned_geom_entry(obj: &SkinnedDrawObject, texture_count: u32) -> RtGeomEntry {
126    let (albedo_index, normal_index) =
127        pool_indices(obj.texture_slot, obj.normal_map_slot, texture_count);
128    RtGeomEntry {
129        index_offset: obj.index_offset as u32,
130        base_vertex: 0,
131        albedo_index,
132        normal_index: normal_index | RT_SKINNED_FLAG,
133        tint: obj.material.tint,
134        roughness: obj.material.roughness,
135        metallic: obj.material.metallic,
136        emissive: obj.material.emissive,
137        model: obj.model,
138        emissive_map_index: obj.material.emissive_map_index,
139        _pad: [0; 3],
140    }
141}
142
143/// True when any participating object's current model matrix differs from the one
144/// baked into the live TLAS. Pure (no GPU) so the dirty gate is unit-testable.
145pub fn models_dirty(cached: &[[[f32; 4]; 4]], current: &[[[f32; 4]; 4]]) -> bool {
146    cached.len() != current.len() || cached.iter().zip(current).any(|(a, b)| a != b)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    use alloc::vec::Vec;
154    // One u32 index per word, and never a zero-length allocation.
155    #[test]
156    fn skinned_index_buffer_holds_one_word_per_index() {
157        assert_eq!(skinned_index_buffer_bytes(0), 4);
158        assert_eq!(skinned_index_buffer_bytes(1), 4);
159        assert_eq!(skinned_index_buffer_bytes(3), 12);
160        for count in 0..64usize {
161            let bytes = skinned_index_buffer_bytes(count);
162            assert!(
163                bytes >= count * 4,
164                "{count}: {bytes} cannot hold the indices"
165            );
166            assert_eq!(bytes % 4, 0, "{count}: {bytes} is not whole words");
167        }
168    }
169
170    #[test]
171    fn only_off_stops_updating_the_bvh() {
172        assert_eq!(RtDynamicMode::default(), RtDynamicMode::Auto);
173        assert!(RtDynamicMode::Auto.is_dynamic());
174        assert!(RtDynamicMode::Rebuild.is_dynamic());
175        assert!(RtDynamicMode::Tlas.is_dynamic());
176        assert!(!RtDynamicMode::Off.is_dynamic());
177    }
178
179    #[test]
180    fn pool_indices_share_one_handle_indexed_pool() {
181        use crate::render_types::NO_NORMAL_MAP_SLOT;
182        // Albedo and a real normal map both index the shared pool by their own
183        // handle. 5 real textures; the flat-normal fallback sits at slot 5.
184        assert_eq!(pool_indices(2, 1, 5), (2, 1));
185        // Out-of-range real slots clamp to the last real texture (4).
186        assert_eq!(pool_indices(9, 9, 5), (4, 4));
187        // A draw with no normal map addresses the flat-normal fallback slot.
188        assert_eq!(pool_indices(2, NO_NORMAL_MAP_SLOT, 5), (2, 5));
189    }
190
191    #[test]
192    fn models_dirty_detects_a_changed_transform() {
193        let a = [[
194            [1.0, 0.0, 0.0, 0.0],
195            [0.0, 1.0, 0.0, 0.0],
196            [0.0, 0.0, 1.0, 0.0],
197            [0.0, 0.0, 0.0, 1.0],
198        ]];
199        let mut b = a;
200        assert!(!models_dirty(&a, &b));
201        b[0][3][0] = 5.0;
202        assert!(models_dirty(&a, &b));
203        // A length change is dirty.
204        assert!(models_dirty(&a, &[]));
205    }
206
207    #[test]
208    fn skinned_flag_is_bit_31_and_masks_back_to_the_pool_index() {
209        // The flag occupies the top bit; the shader recovers the real bindless
210        // normal index with `normal_index & ~RT_SKINNED_FLAG`. Mirror both here.
211        assert_eq!(RT_SKINNED_FLAG, 1u32 << 31);
212        for normal_index in [0u32, 1, 5, 96, 1000] {
213            let flagged = normal_index | RT_SKINNED_FLAG;
214            assert_ne!(flagged & RT_SKINNED_FLAG, 0, "flag set");
215            assert_eq!(flagged & !RT_SKINNED_FLAG, normal_index, "masks back");
216        }
217        // Realistic bindless pool indices never reach the flag bit, so a static
218        // entry's normal index is never misread as skinned.
219        assert_eq!(96u32 & RT_SKINNED_FLAG, 0);
220    }
221
222    #[test]
223    fn skinned_geom_entry_flags_and_zeroes_base_vertex() {
224        use crate::render_types::{MaterialUniforms, SkinnedDrawObject};
225        let material = MaterialUniforms {
226            tint: [0.2, 0.4, 0.6],
227            roughness: 0.3,
228            metallic: 0.5,
229            emissive: [0.1, 0.0, 0.0],
230            ..MaterialUniforms::DEFAULT
231        };
232        let obj = SkinnedDrawObject {
233            vertex_base: 7,
234            vertex_count: 100,
235            index_offset: 42,
236            index_count: 300,
237            model: [
238                [1.0, 0.0, 0.0, 0.0],
239                [0.0, 1.0, 0.0, 0.0],
240                [0.0, 0.0, 1.0, 0.0],
241                [3.0, 4.0, 5.0, 1.0],
242            ],
243            texture_slot: 9,
244            normal_map_slot: 3,
245            material,
246            visible: true,
247            joint_count: 12,
248            local_bb_min: [-1.0, -1.0, -1.0],
249            local_bb_max: [1.0, 1.0, 1.0],
250            lod_alternates: Vec::new(),
251        };
252        let texture_count = 12u32;
253        let e = skinned_geom_entry(&obj, texture_count);
254        // The skinned BLAS bakes absolute indices, so base_vertex is folded to 0.
255        assert_eq!(e.base_vertex, 0);
256        // The skinned flag is set; masking it off recovers the real shared-pool
257        // index, computed the same way as a static draw (so skinned hits texture).
258        assert_ne!(e.normal_index & RT_SKINNED_FLAG, 0);
259        let (exp_albedo, exp_normal) =
260            pool_indices(obj.texture_slot, obj.normal_map_slot, texture_count);
261        assert_eq!(e.albedo_index, exp_albedo);
262        assert_eq!(e.normal_index & !RT_SKINNED_FLAG, exp_normal);
263        // Material + index offset carry through; the model lifts the hit to world.
264        assert_eq!(e.index_offset, 42);
265        assert_eq!(e.tint, [0.2, 0.4, 0.6]);
266        assert_eq!(e.model[3], [3.0, 4.0, 5.0, 1.0]);
267    }
268}