use crate::compiled::{TranspileMode, WgslShader};
use crate::SdfNode;
use naga::back::msl;
const MSL_LANG_VERSION: (u8, u8) = (2, 0);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MslError {
WgslParse(String),
Validation(String),
MslWrite(String),
NoEntryPoint,
}
impl core::fmt::Display for MslError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::WgslParse(e) => write!(f, "WGSL parse failed before MSL emit: {e}"),
Self::Validation(e) => write!(f, "WGSL validation failed before MSL emit: {e}"),
Self::MslWrite(e) => write!(f, "naga could not write MSL: {e}"),
Self::NoEntryPoint => write!(f, "no entry point in the WGSL module"),
}
}
}
impl std::error::Error for MslError {}
#[derive(Debug, Clone)]
pub struct MslShader {
pub source: String,
pub entry_point: String,
pub wgsl_entry_point: String,
pub workgroup_size: [u32; 3],
pub sizes_buffer_slot: u8,
pub wgsl_source: String,
}
impl MslShader {
pub fn transpile(node: &SdfNode, mode: TranspileMode) -> Result<Self, MslError> {
let wgsl = WgslShader::transpile(node, mode);
Self::from_wgsl(&wgsl.to_compute_shader())
}
pub fn from_wgsl(wgsl: &str) -> Result<Self, MslError> {
let module = naga::front::wgsl::parse_str(wgsl)
.map_err(|e| MslError::WgslParse(e.emit_to_string(wgsl)))?;
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.map_err(|e| MslError::Validation(format!("{e:?}")))?;
let entry = module
.entry_points
.first()
.ok_or(MslError::NoEntryPoint)?
.clone();
let mut resources = msl::BindingMap::new();
let mut max_binding: u8 = 0;
for (_, var) in module.global_variables.iter() {
let Some(ref br) = var.binding else { continue };
let slot = u8::try_from(br.binding).map_err(|_| {
MslError::MslWrite(format!(
"binding {} exceeds the Metal buffer slot range",
br.binding
))
})?;
max_binding = max_binding.max(slot);
let mutable = matches!(
var.space,
naga::AddressSpace::Storage { access } if access.contains(naga::StorageAccess::STORE)
);
resources.insert(
br.clone(),
msl::BindTarget {
buffer: Some(slot),
texture: None,
sampler: None,
mutable,
},
);
}
let sizes_buffer_slot = max_binding
.checked_add(1)
.ok_or_else(|| MslError::MslWrite("no free Metal buffer slot for sizes".into()))?;
let mut per_entry_point_map = msl::EntryPointResourceMap::new();
for ep in &module.entry_points {
per_entry_point_map.insert(
ep.name.clone(),
msl::EntryPointResources {
resources: resources.clone(),
push_constant_buffer: None,
sizes_buffer: Some(sizes_buffer_slot),
},
);
}
let options = msl::Options {
lang_version: MSL_LANG_VERSION,
per_entry_point_map,
fake_missing_bindings: false,
..msl::Options::default()
};
let (source, translation) =
msl::write_string(&module, &info, &options, &msl::PipelineOptions::default())
.map_err(|e| MslError::MslWrite(format!("{e:?}")))?;
let entry_point = translation
.entry_point_names
.first()
.ok_or(MslError::NoEntryPoint)?
.clone()
.map_err(|e| MslError::MslWrite(format!("entry point not translated: {e:?}")))?;
Ok(Self {
source,
entry_point,
wgsl_entry_point: entry.name,
workgroup_size: entry.workgroup_size,
sizes_buffer_slot,
wgsl_source: wgsl.to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sphere() -> SdfNode {
SdfNode::sphere(1.0)
}
#[test]
fn emits_metal_kernel_with_entry_point() {
let shader = MslShader::transpile(&sphere(), TranspileMode::Hardcoded)
.expect("sphere must transpile to MSL");
assert!(
shader.source.contains("#include <metal_stdlib>"),
"MSL must include the Metal standard library:\n{}",
shader.source
);
assert!(
shader.source.contains("kernel void"),
"MSL must declare a compute kernel:\n{}",
shader.source
);
assert_eq!(shader.wgsl_entry_point, "main");
assert!(
shader.source.contains(&shader.entry_point),
"renamed entry point {} must appear in the MSL:\n{}",
shader.entry_point,
shader.source
);
}
#[test]
fn workgroup_size_survives_translation() {
let shader = MslShader::transpile(&sphere(), TranspileMode::Hardcoded).unwrap();
let wgsl = WgslShader::transpile(&sphere(), TranspileMode::Hardcoded).to_compute_shader();
let decl = format!("@workgroup_size({}", shader.workgroup_size[0]);
assert!(
wgsl.contains(&decl),
"workgroup_size {:?} must come from the WGSL declaration",
shader.workgroup_size
);
assert!(shader.workgroup_size.iter().all(|&n| n > 0));
}
#[test]
fn bindings_map_to_matching_buffer_slots_and_sizes_slot_is_free() {
let shader = MslShader::transpile(&sphere(), TranspileMode::Hardcoded).unwrap();
for slot in 0..=2u8 {
assert!(
shader.source.contains(&format!("buffer({slot})")),
"binding {slot} must map to buffer({slot}):\n{}",
shader.source
);
}
assert!(
shader.sizes_buffer_slot > 2,
"sizes buffer slot {} must not collide with a real binding",
shader.sizes_buffer_slot
);
assert!(shader
.source
.contains(&format!("buffer({})", shader.sizes_buffer_slot)));
}
#[test]
fn dynamic_mode_adds_the_params_binding() {
let shader = MslShader::transpile(&sphere(), TranspileMode::Dynamic).unwrap();
assert!(
shader.source.contains("buffer(3)"),
"Dynamic mode must bind sdf_params at buffer(3):\n{}",
shader.source
);
assert!(shader.sizes_buffer_slot > 3);
}
#[test]
fn invalid_wgsl_is_reported_not_swallowed() {
let err = MslShader::from_wgsl("this is not wgsl").expect_err("must not succeed");
assert!(matches!(err, MslError::WgslParse(_)), "got {err:?}");
}
}