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}