use crate::gpu_types::{InstanceRaw, LightData, SceneUniforms};
use std::mem::offset_of;
use std::path::{Path, PathBuf};
fn all_shaders() -> Vec<(String, String)> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/shaders");
let mut out: Vec<(String, String)> = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display()))
.flatten()
.map(|e| e.path())
.filter(|p: &PathBuf| p.extension().is_some_and(|x| x == "wgsl"))
.map(|p| {
let name = p.file_name().unwrap().to_string_lossy().into_owned();
(name, std::fs::read_to_string(&p).expect("shader is readable"))
})
.collect();
out.sort();
assert!(out.len() > 40, "shader walk found only {} files", out.len());
out
}
fn struct_members(shader: &str, src: &str, name_prefix: &str) -> Vec<(String, Vec<(String, u32)>, u32)> {
if !src.contains(&format!("struct {name_prefix}")) {
return Vec::new();
}
let stripped: String = src
.lines()
.map(|l| if l.trim_start().starts_with('#') { "" } else { l })
.collect::<Vec<_>>()
.join("\n");
let module = match naga::front::wgsl::parse_str(&stripped) {
Ok(m) => m,
Err(plain) => crate::pipeline::shaders::compose_module(
src,
shader,
crate::pipeline::shaders::native_render_defs(),
)
.unwrap_or_else(|composed| {
panic!(
"{shader} declares `{name_prefix}` but could not be read either way, so nothing \
checks its layout.\n as plain WGSL: {plain}\n composed: {composed}"
)
})
.0,
};
let mut found = Vec::new();
for (_, ty) in module.types.iter() {
let Some(ty_name) = ty.name.as_deref() else { continue };
if !ty_name.starts_with(name_prefix) {
continue;
}
if let naga::TypeInner::Struct { members, span } = &ty.inner {
let fields = members
.iter()
.filter_map(|m| m.name.clone().map(|n| (n, m.offset)))
.collect();
found.push((ty_name.to_string(), fields, *span));
}
}
found
}
fn scene_layout() -> Vec<(&'static str, u32)> {
vec![
("view_proj", offset_of!(SceneUniforms, view_proj) as u32),
("camera_pos", offset_of!(SceneUniforms, camera_pos) as u32),
("sun_direction", offset_of!(SceneUniforms, sun_direction) as u32),
("sun_color", offset_of!(SceneUniforms, sun_color) as u32),
("lights", offset_of!(SceneUniforms, lights) as u32),
("light_view_proj", offset_of!(SceneUniforms, light_view_proj) as u32),
("cascade_splits", offset_of!(SceneUniforms, cascade_splits) as u32),
("camera_forward", offset_of!(SceneUniforms, camera_forward) as u32),
("cascade_params", offset_of!(SceneUniforms, cascade_params) as u32),
("num_lights", offset_of!(SceneUniforms, num_lights) as u32),
("exposure", offset_of!(SceneUniforms, exposure) as u32),
("environment_blend_t", offset_of!(SceneUniforms, environment_blend_t) as u32),
("environment_preset", offset_of!(SceneUniforms, environment_preset) as u32),
("point_shadows_enabled", offset_of!(SceneUniforms, point_shadows_enabled) as u32),
("environment_preset_b", offset_of!(SceneUniforms, environment_preset_2) as u32),
("shading_mode", offset_of!(SceneUniforms, shading_mode) as u32),
("inv_view_proj", offset_of!(SceneUniforms, inv_view_proj) as u32),
]
}
fn instance_layout() -> Vec<(&'static str, u32)> {
let model = offset_of!(InstanceRaw, model) as u32;
vec![
("model_matrix_0", model),
("model_matrix_1", model + 16),
("model_matrix_2", model + 32),
("model_matrix_3", model + 48),
("albedo_color", offset_of!(InstanceRaw, albedo_color) as u32),
("pbr", offset_of!(InstanceRaw, roughness) as u32),
("ambient", offset_of!(InstanceRaw, ambient) as u32),
("emissive", offset_of!(InstanceRaw, emissive) as u32),
]
}
fn check(
shader: &str,
struct_name: &str,
fields: &[(String, u32)],
span: u32,
truth: &[(&str, u32)],
rust_size: usize,
problems: &mut Vec<String>,
) {
if span as usize > rust_size {
problems.push(format!(
"{shader}: `{struct_name}` is {span} bytes, the Rust struct is {rust_size} — the \
shader reads past the end of the buffer"
));
}
let mut last_truth_index = None;
for (name, offset) in fields {
if name.starts_with('_') {
continue;
}
let Some(index) = truth.iter().position(|(t, _)| t == name) else {
problems.push(format!(
"{shader}: `{struct_name}.{name}` is not a field of the Rust struct — a rename on \
one side only, or a field the CPU never uploads"
));
continue;
};
if truth[index].1 != *offset {
problems.push(format!(
"{shader}: `{struct_name}.{name}` sits at byte {offset}, Rust puts it at {} — this \
shader reads a different field than the one it names",
truth[index].1
));
}
if let Some(prev) = last_truth_index {
if index <= prev {
problems.push(format!(
"{shader}: `{struct_name}.{name}` appears out of order against the Rust struct"
));
}
}
last_truth_index = Some(index);
}
}
#[test]
fn every_scene_uniform_declaration_matches_the_bytes_rust_uploads() {
let truth = scene_layout();
let mut problems = Vec::new();
let mut checked = Vec::new();
for (shader, src) in all_shaders() {
for (struct_name, fields, span) in struct_members(&shader, &src, "SceneUniforms") {
checked.push(shader.clone());
check(
&shader,
&struct_name,
&fields,
span,
&truth,
std::mem::size_of::<SceneUniforms>(),
&mut problems,
);
}
}
assert!(
checked.contains(&"common.wgsl".to_string()),
"the source of truth was not among the shaders checked — the walk found {checked:?}"
);
assert!(
checked.len() >= 8,
"only {} shaders declare a scene block; the copies were expected too: {checked:?}",
checked.len()
);
assert!(problems.is_empty(), "scene uniform layout disagreements:\n {}", problems.join("\n "));
}
#[test]
fn the_shared_scene_declaration_is_the_whole_block() {
let src = std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR")).join("src/shaders/common.wgsl"),
)
.expect("common.wgsl");
let declarations = struct_members("common.wgsl", &src, "SceneUniforms");
let (_, fields, span) = declarations.first().expect("common.wgsl declares SceneUniforms");
assert_eq!(
*span as usize,
std::mem::size_of::<SceneUniforms>(),
"common.wgsl's block is {span} bytes and Rust's is {} — the shared declaration is not the \
whole block, so every importing shader is short",
std::mem::size_of::<SceneUniforms>()
);
for (name, want) in scene_layout() {
let got = fields.iter().find(|(n, _)| n == name);
assert_eq!(
got.map(|(_, o)| *o),
Some(want),
"common.wgsl is missing `{name}` (or has it at the wrong offset); Rust puts it at {want}"
);
}
}
#[test]
fn every_instance_declaration_matches_the_bytes_rust_uploads() {
let truth = instance_layout();
let mut problems = Vec::new();
let mut checked = Vec::new();
for (shader, src) in all_shaders() {
for prefix in ["InstanceRaw", "InstanceData"] {
for (struct_name, fields, span) in struct_members(&shader, &src, prefix) {
checked.push(shader.clone());
let named = fields.iter().filter(|(n, _)| !n.starts_with('_')).count();
if named != truth.len() {
problems.push(format!(
"{shader}: `{struct_name}` declares {named} instance fields, Rust has {} \
— `instances[i]` reads the wrong offsets for every i > 0",
truth.len()
));
}
check(
&shader,
&struct_name,
&fields,
span,
&truth,
std::mem::size_of::<InstanceRaw>(),
&mut problems,
);
}
}
}
assert!(
checked.len() >= 10,
"only {} shaders declare the instance element; the walk should have found the whole \
instanced set: {checked:?}",
checked.len()
);
assert!(problems.is_empty(), "instance layout disagreements:\n {}", problems.join("\n "));
}
#[test]
fn every_light_declaration_matches_the_bytes_rust_uploads() {
let truth = vec![
("position", offset_of!(LightData, position) as u32),
("color", offset_of!(LightData, color) as u32),
("direction", offset_of!(LightData, direction) as u32),
("params", offset_of!(LightData, params) as u32),
];
let mut problems = Vec::new();
let mut checked = 0;
for (shader, src) in all_shaders() {
for (struct_name, fields, span) in struct_members(&shader, &src, "LightData") {
checked += 1;
if span as usize != std::mem::size_of::<LightData>() {
problems.push(format!(
"{shader}: `{struct_name}` is {span} bytes against Rust's {} — the light array \
stride is wrong, which moves every field after `lights`",
std::mem::size_of::<LightData>()
));
}
check(
&shader,
&struct_name,
&fields,
span,
&truth,
std::mem::size_of::<LightData>(),
&mut problems,
);
}
}
assert!(checked >= 2, "only {checked} shaders declare LightData");
assert!(problems.is_empty(), "light layout disagreements:\n {}", problems.join("\n "));
}