Skip to main content

concinnity_render/metal/
shader_layout.rs

1//! The engine's buffer-binding contract for user-authored Metal shaders, plus
2//! the pure comparison that catches CPU/GPU struct-layout mismatches. A custom
3//! Shader is linked into the engine's standard pipeline and inherits the
4//! engine's buffer bindings (per-frame view uniforms, per-object data, lights,
5//! shadow cascades). If the user declares one of those structs with a different
6//! layout than the engine's `#[repr(C)]` struct, the GPU reads the engine's
7//! bytes through the wrong offsets: garbage, and a GPU fault when a wrong stride
8//! walks a binding off the end of its buffer (the `RtGeomEntry` failure mode).
9//!
10//! This module is deliberately free of any Metal API: it defines what the engine
11//! expects (built from the real `#[repr(C)]` structs via `offset_of!`) and how to
12//! compare a backend-neutral reflected layout against it. The Metal reflection
13//! that produces the reflected layout lives in `shader_reflect.rs`; keeping the
14//! comparison separate makes it unit-testable without a GPU device.
15
16use alloc::format;
17use alloc::string::String;
18use alloc::vec;
19use alloc::vec::Vec;
20use core::mem::{offset_of, size_of};
21use hashbrown::HashMap;
22
23use crate::render_types::{
24    ClusterParams, GpuLight, GpuObjectData, LightUniforms, MaterialUniforms, ShadowPassPush,
25    ShadowUniforms, SpotShadowData,
26};
27
28use super::uniforms::ModelUniforms;
29use crate::uniforms::ViewUniforms;
30
31/// One field the engine guarantees at a fixed byte offset inside an
32/// engine-provided buffer struct.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub(crate) struct ExpectedField {
35    /// The field's name in the shader struct.
36    pub name: &'static str,
37    /// The field's byte offset in the shader struct.
38    pub offset: usize,
39}
40
41/// The engine's authoritative layout for one buffer struct a user shader may
42/// bind. `size` is the `#[repr(C)]` `size_of`; for a buffer bound as an array /
43/// pointer (e.g. `GpuObjectData`) it is the per-element stride, which is what a
44/// wrong-stride bug corrupts.
45#[derive(Clone, Debug)]
46pub(crate) struct ExpectedStruct {
47    /// The struct's name in the shader.
48    pub name: &'static str,
49    /// The struct's size in bytes.
50    pub size: usize,
51    /// Expected fields, in declaration order.
52    pub fields: Vec<ExpectedField>,
53}
54
55/// Which engine pipeline stage an entry point belongs to. A custom Shader stage
56/// declared `kind: "vertex"` can be a main vertex shader or a shadow caster;
57/// they bind different engine buffers, so the reflector resolves the stage from
58/// the entry-point name (see `shader_reflect.rs`).
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum EngineStage {
61    /// The vertex stage.
62    Vertex,
63    /// The fragment stage.
64    Fragment,
65    /// The shadow-pass vertex stage.
66    Shadow,
67}
68
69/// A struct layout as reflected from a compiled user shader. Backend-neutral so
70/// the comparison stays Metal-free; `shader_reflect.rs` fills it from Metal
71/// pipeline reflection, and the unit tests fill it by hand.
72#[derive(Clone, Debug, PartialEq)]
73pub struct ReflectedStruct {
74    /// The struct type name the shader declared at this binding.
75    pub name: String,
76    /// The binding's data size in bytes (struct size, or element stride for a
77    /// pointer/array binding).
78    pub size: usize,
79    /// The fields reflection reported, in declaration order.
80    pub fields: Vec<ReflectedField>,
81}
82
83#[derive(Clone, Debug, PartialEq)]
84/// One field as the shader compiler reported it.
85pub struct ReflectedField {
86    /// The field's name.
87    pub name: String,
88    /// The field's byte offset.
89    pub offset: usize,
90}
91
92// Build an ExpectedField from a real Rust struct field.
93macro_rules! field {
94    ($t:ty, $f:ident) => {
95        ExpectedField {
96            name: stringify!($f),
97            offset: offset_of!($t, $f),
98        }
99    };
100}
101
102/// The engine-owned buffer bindings for a stage: `(buffer_index, expected
103/// layout)`. Only indices the engine itself binds are listed: buffers the user
104/// fully owns, the `Vertex` stage-in at buffer(1), and the bindless texture
105/// argument buffer are intentionally absent and never validated.
106///
107/// The indices mirror the binds in `metal/draw/main.rs` and the shadow shader;
108/// the layouts are derived from the real `#[repr(C)]` structs (the single source
109/// of truth, the same ones the `*_layout_matches_msl` tests pin to MSL).
110pub(crate) fn engine_buffers(stage: EngineStage) -> Vec<(u32, ExpectedStruct)> {
111    match stage {
112        EngineStage::Vertex => vec![
113            (0, view_uniforms_layout()),
114            (2, model_uniforms_layout()),
115            (9, gpu_object_data_layout()),
116        ],
117        EngineStage::Fragment => vec![
118            (0, view_uniforms_layout()),
119            (3, material_uniforms_layout()),
120            (4, light_uniforms_layout()),
121            (5, shadow_uniforms_layout()),
122            (8, gpu_light_layout()),
123            (9, gpu_object_data_layout()),
124            (11, cluster_params_layout()),
125            (13, spot_shadow_data_layout()),
126        ],
127        EngineStage::Shadow => vec![
128            (0, shadow_uniforms_layout()),
129            (2, model_uniforms_layout()),
130            (7, shadow_pass_push_layout()),
131        ],
132    }
133}
134
135fn view_uniforms_layout() -> ExpectedStruct {
136    ExpectedStruct {
137        name: "ViewUniforms",
138        size: size_of::<ViewUniforms>(),
139        fields: vec![
140            field!(ViewUniforms, vp),
141            field!(ViewUniforms, view),
142            field!(ViewUniforms, elapsed),
143            field!(ViewUniforms, cam_pos),
144            field!(ViewUniforms, prefilter_mip_count),
145        ],
146    }
147}
148
149fn model_uniforms_layout() -> ExpectedStruct {
150    ExpectedStruct {
151        name: "ModelUniforms",
152        size: size_of::<ModelUniforms>(),
153        fields: vec![field!(ModelUniforms, model)],
154    }
155}
156
157fn gpu_object_data_layout() -> ExpectedStruct {
158    ExpectedStruct {
159        name: "GpuObjectData",
160        size: size_of::<GpuObjectData>(),
161        fields: vec![
162            field!(GpuObjectData, model),
163            field!(GpuObjectData, tint),
164            field!(GpuObjectData, roughness),
165            field!(GpuObjectData, emissive),
166            field!(GpuObjectData, metallic),
167            field!(GpuObjectData, albedo_index),
168            field!(GpuObjectData, normal_index),
169            field!(GpuObjectData, macro_variation),
170            field!(GpuObjectData, terrain_blend),
171            field!(GpuObjectData, bb_min),
172            field!(GpuObjectData, cull_distance),
173            field!(GpuObjectData, bb_max),
174        ],
175    }
176}
177
178fn material_uniforms_layout() -> ExpectedStruct {
179    ExpectedStruct {
180        name: "MaterialUniforms",
181        size: size_of::<MaterialUniforms>(),
182        fields: vec![
183            field!(MaterialUniforms, roughness),
184            field!(MaterialUniforms, metallic),
185            field!(MaterialUniforms, macro_variation),
186            field!(MaterialUniforms, terrain_blend),
187            field!(MaterialUniforms, tint),
188            field!(MaterialUniforms, emissive),
189        ],
190    }
191}
192
193fn light_uniforms_layout() -> ExpectedStruct {
194    ExpectedStruct {
195        name: "LightUniforms",
196        size: size_of::<LightUniforms>(),
197        fields: vec![
198            field!(LightUniforms, directional),
199            field!(LightUniforms, point),
200            field!(LightUniforms, num_directional),
201            field!(LightUniforms, num_point),
202        ],
203    }
204}
205
206fn gpu_light_layout() -> ExpectedStruct {
207    ExpectedStruct {
208        name: "GpuLight",
209        size: size_of::<GpuLight>(),
210        fields: vec![
211            field!(GpuLight, position),
212            field!(GpuLight, range),
213            field!(GpuLight, color),
214            field!(GpuLight, intensity),
215            field!(GpuLight, direction),
216            field!(GpuLight, kind),
217            field!(GpuLight, cos_inner),
218            field!(GpuLight, cos_outer),
219            field!(GpuLight, shadow_index),
220        ],
221    }
222}
223
224fn cluster_params_layout() -> ExpectedStruct {
225    ExpectedStruct {
226        name: "ClusterParams",
227        size: size_of::<ClusterParams>(),
228        fields: vec![
229            field!(ClusterParams, inv_view_proj),
230            field!(ClusterParams, cam_pos),
231            field!(ClusterParams, z_near),
232            field!(ClusterParams, view_forward),
233            field!(ClusterParams, z_far),
234            field!(ClusterParams, grid_x),
235            field!(ClusterParams, grid_y),
236            field!(ClusterParams, grid_z),
237            field!(ClusterParams, num_lights),
238            field!(ClusterParams, screen_w),
239            field!(ClusterParams, screen_h),
240            field!(ClusterParams, use_clusters),
241        ],
242    }
243}
244
245fn spot_shadow_data_layout() -> ExpectedStruct {
246    ExpectedStruct {
247        name: "SpotShadowData",
248        size: size_of::<SpotShadowData>(),
249        fields: vec![
250            field!(SpotShadowData, light_vp),
251            field!(SpotShadowData, depth_bias),
252            field!(SpotShadowData, normal_bias),
253        ],
254    }
255}
256
257fn shadow_uniforms_layout() -> ExpectedStruct {
258    ExpectedStruct {
259        name: "ShadowUniforms",
260        size: size_of::<ShadowUniforms>(),
261        fields: vec![
262            field!(ShadowUniforms, light_vps),
263            field!(ShadowUniforms, cascade_splits),
264            field!(ShadowUniforms, active_cascades),
265        ],
266    }
267}
268
269fn shadow_pass_push_layout() -> ExpectedStruct {
270    ExpectedStruct {
271        name: "ShadowPassPush",
272        size: size_of::<ShadowPassPush>(),
273        fields: vec![field!(ShadowPassPush, cascade_idx)],
274    }
275}
276
277/// Compare the engine's expected layout against the shader's reflected layout
278/// for one binding. Returns `Err(message)` on a mismatch, naming the binding,
279/// the field, and the expected-vs-actual offset.
280///
281/// Two checks, complementary:
282///   * The binding's data size must match the engine struct's size. A wrong
283///     field type (`float3` where the engine packs `[f32; 3]`) changes the
284///     stride even when every named offset still lines up: this is the check
285///     that would have caught the `RtGeomEntry` fault.
286///   * Every engine field the shader also declares (matched by name) must sit at
287///     the engine's offset. Fields the shader renames or omits are skipped: it
288///     only has to read the fields it uses from where the engine put them. The
289///     size check remains the backstop for the renamed-field case.
290pub(crate) fn compare_binding(
291    index: u32,
292    expected: &ExpectedStruct,
293    reflected: &ReflectedStruct,
294) -> Result<(), String> {
295    if expected.size != reflected.size {
296        return Err(format!(
297            "buffer({index}) binding '{}' is {} bytes but the engine's '{}' is {} bytes \
298             (a field-type mismatch such as `float3` vs `packed_float3` changes the stride \
299             and corrupts every following field / array element)",
300            reflected.name, reflected.size, expected.name, expected.size
301        ));
302    }
303    for ef in &expected.fields {
304        if let Some(rf) = reflected.fields.iter().find(|rf| rf.name == ef.name)
305            && rf.offset != ef.offset
306        {
307            return Err(format!(
308                "buffer({index}) binding '{}': field '{}' is at offset {} but the engine's \
309                     '{}' puts it at offset {}",
310                reflected.name, ef.name, rf.offset, expected.name, ef.offset
311            ));
312        }
313    }
314    Ok(())
315}
316
317/// One stage's reflected engine buffer structs, keyed by binding index. What a
318/// backend hands [`validate_stage`] after querying its shader reflection.
319pub type ReflectedStructs = HashMap<u32, ReflectedStruct>;
320
321/// Validate every engine-owned binding a shader stage uses against the engine's
322/// contract. `reflected` maps buffer index → the layout reflected at that index;
323/// indices the shader does not bind are simply absent and skipped. Returns the
324/// first mismatch, or `Ok(())` if every engine binding the shader uses matches.
325pub fn validate_stage(stage: EngineStage, reflected: &ReflectedStructs) -> Result<(), String> {
326    for (index, expected) in engine_buffers(stage) {
327        if let Some(found) = reflected.get(&index) {
328            compare_binding(index, &expected, found)?;
329        }
330    }
331    Ok(())
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    use alloc::string::ToString;
339    // Turn an ExpectedStruct into a faithful ReflectedStruct (what reflection
340    // would report for a correct user shader copying the engine struct).
341    fn faithful(expected: &ExpectedStruct) -> ReflectedStruct {
342        ReflectedStruct {
343            name: expected.name.to_string(),
344            size: expected.size,
345            fields: expected
346                .fields
347                .iter()
348                .map(|f| ReflectedField {
349                    name: f.name.to_string(),
350                    offset: f.offset,
351                })
352                .collect(),
353        }
354    }
355
356    fn view_expected() -> ExpectedStruct {
357        engine_buffers(EngineStage::Fragment)
358            .into_iter()
359            .find(|(i, _)| *i == 0)
360            .unwrap()
361            .1
362    }
363
364    #[test]
365    fn faithful_copy_passes_every_stage() {
366        // A shader that declares every engine struct exactly as the engine does
367        // validates clean on all three stages.
368        for stage in [
369            EngineStage::Vertex,
370            EngineStage::Fragment,
371            EngineStage::Shadow,
372        ] {
373            let reflected: ReflectedStructs = engine_buffers(stage)
374                .iter()
375                .map(|(i, e)| (*i, faithful(e)))
376                .collect();
377            assert!(validate_stage(stage, &reflected).is_ok());
378        }
379    }
380
381    #[test]
382    fn unused_bindings_are_skipped() {
383        // A shader that binds none of the engine buffers has nothing to check.
384        let reflected = HashMap::new();
385        assert!(validate_stage(EngineStage::Fragment, &reflected).is_ok());
386    }
387
388    #[test]
389    fn wrong_field_offset_is_rejected() {
390        let expected = view_expected();
391        let mut reflected = faithful(&expected);
392        // Shift cam_pos as a float3-vs-padded-vec mistake would.
393        let cam = reflected
394            .fields
395            .iter_mut()
396            .find(|f| f.name == "cam_pos")
397            .unwrap();
398        cam.offset += 4;
399        let err = compare_binding(0, &expected, &reflected).expect_err("must reject");
400        assert!(err.contains("cam_pos"), "message names the field: {err}");
401        assert!(err.contains("offset"));
402    }
403
404    #[test]
405    fn wrong_struct_size_is_rejected() {
406        // The RtGeomEntry failure mode: same named offsets, larger stride.
407        let expected = view_expected();
408        let mut reflected = faithful(&expected);
409        reflected.size += 16;
410        let err = compare_binding(0, &expected, &reflected).expect_err("must reject");
411        assert!(err.contains("bytes"), "message mentions the size: {err}");
412        assert!(err.contains("stride"));
413    }
414
415    #[test]
416    fn renamed_field_is_skipped_but_size_still_guards() {
417        // A renamed field can't be offset-checked, but a layout change that
418        // renames AND resizes is still caught by the size check.
419        let expected = view_expected();
420        let mut reflected = faithful(&expected);
421        for f in &mut reflected.fields {
422            if f.name == "cam_pos" {
423                f.name = "camera_position".to_string();
424            }
425        }
426        // Pure rename, same size: passes (we only check fields present on both).
427        assert!(compare_binding(0, &expected, &reflected).is_ok());
428        // Rename plus a stride change: rejected by the size guard.
429        reflected.size += 16;
430        assert!(compare_binding(0, &expected, &reflected).is_err());
431    }
432}