use std::collections::BTreeMap;
pub const FREE_SLOT_LOCATIONS: [u32; 5] = [2, 3, 4, 6, 7];
pub const SLOT_ALIASES: [&str; 5] = ["vec_a", "vec_b", "vec_c", "vec_d", "vec_e"];
pub fn slot_index_for_location(location: u32) -> Option<usize> {
FREE_SLOT_LOCATIONS.iter().position(|&l| l == location)
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ShaderSlotMap {
names: BTreeMap<String, u32>,
}
impl ShaderSlotMap {
pub fn from_pairs<I, S>(pairs: I) -> Self
where
I: IntoIterator<Item = (S, u32)>,
S: Into<String>,
{
let names = pairs
.into_iter()
.filter(|(_, loc)| slot_index_for_location(*loc).is_some())
.map(|(n, loc)| (n.into(), loc))
.collect();
Self { names }
}
pub fn location_of(&self, name: &str) -> Option<u32> {
self.names.get(name).copied()
}
pub fn declared(&self) -> impl Iterator<Item = (&str, u32)> {
self.names.iter().map(|(n, l)| (n.as_str(), *l))
}
pub fn is_empty(&self) -> bool {
self.names.is_empty()
}
}
#[cfg(feature = "shader-introspect")]
#[derive(Debug)]
pub enum IntrospectError {
Parse(String),
NoVertexEntry,
NotVec4 { name: String, location: u32 },
}
#[cfg(feature = "shader-introspect")]
impl std::fmt::Display for IntrospectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IntrospectError::Parse(e) => write!(f, "WGSL parse error: {e}"),
IntrospectError::NoVertexEntry => write!(f, "no @vertex entry point"),
IntrospectError::NotVec4 { name, location } => write!(
f,
"instance attribute `{name}` (@location({location})) must be vec4<f32> — \
the quad instance layout feeds every free slot as Float32x4",
),
}
}
}
#[cfg(feature = "shader-introspect")]
pub fn introspect_wgsl(wgsl: &str) -> Result<ShaderSlotMap, IntrospectError> {
let module = naga::front::wgsl::parse_str(wgsl)
.map_err(|e| IntrospectError::Parse(e.message().to_string()))?;
let vertex = module
.entry_points
.iter()
.find(|ep| ep.stage == naga::ShaderStage::Vertex)
.ok_or(IntrospectError::NoVertexEntry)?;
let mut names = BTreeMap::new();
let mut visit = |name: Option<&String>,
ty: naga::Handle<naga::Type>,
binding: Option<&naga::Binding>|
-> Result<(), IntrospectError> {
let Some(naga::Binding::Location { location, .. }) = binding else {
return Ok(()); };
if slot_index_for_location(*location).is_none() {
return Ok(()); }
let name = name.cloned().unwrap_or_default();
let is_vec4 = matches!(
module.types[ty].inner,
naga::TypeInner::Vector {
size: naga::VectorSize::Quad,
scalar: naga::Scalar {
kind: naga::ScalarKind::Float,
width: 4,
},
}
);
if !is_vec4 {
return Err(IntrospectError::NotVec4 {
name,
location: *location,
});
}
if !name.is_empty() {
names.insert(name, *location);
}
Ok(())
};
for arg in &vertex.function.arguments {
match &module.types[arg.ty].inner {
naga::TypeInner::Struct { members, .. } => {
for member in members {
visit(member.name.as_ref(), member.ty, member.binding.as_ref())?;
}
}
_ => visit(arg.name.as_ref(), arg.ty, arg.binding.as_ref())?,
}
}
Ok(ShaderSlotMap { names })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slot_indices_match_quad_instance_order() {
assert_eq!(slot_index_for_location(2), Some(0)); assert_eq!(slot_index_for_location(4), Some(2)); assert_eq!(slot_index_for_location(6), Some(3)); assert_eq!(slot_index_for_location(7), Some(4)); assert_eq!(slot_index_for_location(1), None); assert_eq!(slot_index_for_location(5), None); }
#[test]
fn from_pairs_drops_library_owned_locations() {
let map = ShaderSlotMap::from_pairs([("fill", 2u32), ("rect", 1u32)]);
assert_eq!(map.location_of("fill"), Some(2));
assert_eq!(map.location_of("rect"), None);
}
#[cfg(feature = "shader-introspect")]
#[test]
fn introspect_extracts_named_free_slots() {
let wgsl = r#"
struct VertexInput { @location(0) corner_uv: vec2<f32> };
struct InstanceInput {
@location(1) rect: vec4<f32>,
@location(2) xyz_to_rgb_c0: vec4<f32>,
@location(3) xyz_to_rgb_c1: vec4<f32>,
@location(4) xyz_to_rgb_c2: vec4<f32>,
@location(6) view_bounds: vec4<f32>,
};
struct VertexOutput { @builtin(position) pos: vec4<f32> };
@vertex
fn vs_main(v: VertexInput, i: InstanceInput) -> VertexOutput {
var out: VertexOutput;
out.pos = vec4<f32>(v.corner_uv * i.rect.zw + i.xyz_to_rgb_c0.xy
+ i.xyz_to_rgb_c1.xy + i.xyz_to_rgb_c2.xy + i.view_bounds.xy, 0.0, 1.0);
return out;
}
@fragment
fn fs_main() -> @location(0) vec4<f32> { return vec4<f32>(1.0); }
"#;
let map = introspect_wgsl(wgsl).expect("introspect");
assert_eq!(map.location_of("xyz_to_rgb_c0"), Some(2));
assert_eq!(map.location_of("xyz_to_rgb_c2"), Some(4));
assert_eq!(map.location_of("view_bounds"), Some(6));
assert_eq!(map.location_of("rect"), None, "library-owned");
assert_eq!(map.declared().count(), 4);
}
#[cfg(feature = "shader-introspect")]
#[test]
fn introspect_rejects_non_vec4_free_slot() {
let wgsl = r#"
struct InstanceInput {
@location(2) radius: f32,
};
@vertex
fn vs_main(i: InstanceInput) -> @builtin(position) vec4<f32> {
return vec4<f32>(i.radius, 0.0, 0.0, 1.0);
}
@fragment
fn fs_main() -> @location(0) vec4<f32> { return vec4<f32>(1.0); }
"#;
match introspect_wgsl(wgsl) {
Err(IntrospectError::NotVec4 { name, location }) => {
assert_eq!(name, "radius");
assert_eq!(location, 2);
}
other => panic!("expected NotVec4, got {other:?}"),
}
}
}