#[derive(Debug, Clone, Default)]
pub struct ShaderCompileArgs {
pub source_path: String,
pub asset_name: String,
pub kind: String,
pub required_entry: Option<String>,
}
pub trait ShaderToolchain: Send + Sync {
fn compile_metal(
&self,
_source: &str,
args: &ShaderCompileArgs,
) -> Result<Vec<u8>, std::io::Error> {
Err(unsupported_language(".metal", args))
}
fn compile_hlsl(
&self,
_source: &str,
args: &ShaderCompileArgs,
) -> Result<Vec<u8>, std::io::Error> {
Err(unsupported_language(".hlsl", args))
}
fn compile_glsl(&self, args: &ShaderCompileArgs) -> Result<Vec<u8>, std::io::Error> {
Err(unsupported_language("GLSL", args))
}
}
fn unsupported_language(language: &str, args: &ShaderCompileArgs) -> std::io::Error {
std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!(
"Asset '{}': {language} shaders are not supported by this build's shader toolchain",
args.asset_name
),
)
}
static SHADER_TOOLCHAIN: std::sync::OnceLock<Box<dyn ShaderToolchain>> = std::sync::OnceLock::new();
pub fn set_shader_toolchain(toolchain: Box<dyn ShaderToolchain>) {
let _ = SHADER_TOOLCHAIN.set(toolchain);
}
fn require<'a>(
toolchain: Option<&'a dyn ShaderToolchain>,
args: &ShaderCompileArgs,
) -> Result<&'a dyn ShaderToolchain, std::io::Error> {
toolchain.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!(
"Asset '{}': no shader toolchain is registered, so no shader can be compiled",
args.asset_name
),
)
})
}
pub trait ShaderBuildValidator: Send + Sync {
fn validate_metal(&self, source: &str, kind: &str, asset_name: &str) -> Result<(), String>;
fn validate_metal_entry(
&self,
source: &str,
entry: &str,
asset_name: &str,
) -> Result<(), String> {
let _ = (source, entry, asset_name);
Ok(())
}
}
static SHADER_BUILD_VALIDATOR: std::sync::OnceLock<Box<dyn ShaderBuildValidator>> =
std::sync::OnceLock::new();
pub fn set_shader_build_validator(validator: Box<dyn ShaderBuildValidator>) {
let _ = SHADER_BUILD_VALIDATOR.set(validator);
}
fn validate_compiled_metal(source: &str, args: &ShaderCompileArgs) -> Result<(), std::io::Error> {
let Some(validator) = SHADER_BUILD_VALIDATOR.get() else {
return Ok(());
};
let invalid = |msg| std::io::Error::new(std::io::ErrorKind::InvalidData, msg);
validator
.validate_metal(source, &args.kind, &args.asset_name)
.map_err(invalid)?;
if let Some(entry) = &args.required_entry {
validator
.validate_metal_entry(source, entry, &args.asset_name)
.map_err(invalid)?;
}
Ok(())
}
pub fn compile_shader(args: ShaderCompileArgs) -> Result<Vec<u8>, std::io::Error> {
compile_with(SHADER_TOOLCHAIN.get().map(|t| t.as_ref()), &args)
}
fn compile_with(
toolchain: Option<&dyn ShaderToolchain>,
args: &ShaderCompileArgs,
) -> Result<Vec<u8>, std::io::Error> {
let ext = std::path::Path::new(&args.source_path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
match ext {
"metal" => {
let source = read_shader_source(&args.source_path)?;
let bytes = require(toolchain, args)?.compile_metal(&source, args)?;
validate_compiled_metal(&source, args)?;
Ok(bytes)
}
"hlsl" => {
let source = read_shader_source(&args.source_path)?;
require(toolchain, args)?.compile_hlsl(&source, args)
}
_ => require(toolchain, args)?.compile_glsl(args),
}
}
fn read_shader_source(source_path: &str) -> Result<String, std::io::Error> {
std::fs::read_to_string(source_path).map_err(|e| {
std::io::Error::new(
e.kind(),
format!("Failed to read shader source '{}': {}", source_path, e),
)
})
}
#[cfg(test)]
pub(crate) fn install_stub_toolchain() {
struct StubToolchain;
impl ShaderToolchain for StubToolchain {
fn compile_metal(
&self,
_source: &str,
_args: &ShaderCompileArgs,
) -> Result<Vec<u8>, std::io::Error> {
Ok(b"stub-shader".to_vec())
}
fn compile_hlsl(
&self,
_source: &str,
_args: &ShaderCompileArgs,
) -> Result<Vec<u8>, std::io::Error> {
Ok(b"stub-shader".to_vec())
}
fn compile_glsl(&self, _args: &ShaderCompileArgs) -> Result<Vec<u8>, std::io::Error> {
Ok(b"stub-shader".to_vec())
}
}
set_shader_toolchain(Box::new(StubToolchain));
}
#[cfg(test)]
fn args_for(asset_name: &str, source_path: &str) -> ShaderCompileArgs {
ShaderCompileArgs {
source_path: source_path.to_string(),
asset_name: asset_name.to_string(),
kind: "fragment".to_string(),
..Default::default()
}
}
#[cfg(test)]
mod dispatch_tests {
use super::*;
use super::args_for as args;
struct ArmNamingToolchain;
impl ShaderToolchain for ArmNamingToolchain {
fn compile_metal(
&self,
source: &str,
_args: &ShaderCompileArgs,
) -> Result<Vec<u8>, std::io::Error> {
Ok(format!("metal:{source}").into_bytes())
}
fn compile_hlsl(
&self,
source: &str,
_args: &ShaderCompileArgs,
) -> Result<Vec<u8>, std::io::Error> {
Ok(format!("hlsl:{source}").into_bytes())
}
fn compile_glsl(&self, args: &ShaderCompileArgs) -> Result<Vec<u8>, std::io::Error> {
Ok(format!("glsl:{}", args.source_path).into_bytes())
}
}
fn routed(source_path: &str) -> Result<String, std::io::Error> {
compile_with(Some(&ArmNamingToolchain), &args("user", source_path))
.map(|b| String::from_utf8(b).expect("test toolchain emits utf8"))
}
fn source_file(dir: &std::path::Path, name: &str, text: &str) -> String {
let path = dir.join(name);
std::fs::write(&path, text).expect("write source");
path.to_string_lossy().into_owned()
}
#[test]
fn missing_metal_source_fails_at_read_before_any_compile() {
let err = routed("/no/such/user_frag.metal").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
assert!(
err.to_string().contains("Failed to read shader source"),
"got: {err}"
);
}
#[test]
fn an_hlsl_source_routes_to_the_hlsl_arm() {
let dir = tempfile::tempdir().unwrap();
let path = source_file(dir.path(), "user_frag.hlsl", "// hlsl");
assert!(
routed(&path).unwrap().starts_with("hlsl:"),
"expected the hlsl arm"
);
}
#[test]
fn missing_hlsl_source_fails_at_read_before_any_compile() {
let err = routed("/no/such/user_frag.hlsl").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
assert!(
err.to_string().contains("Failed to read shader source"),
"got: {err}"
);
}
#[test]
fn other_extensions_route_to_the_glsl_arm_without_reading() {
assert_eq!(
routed("/no/such/user_frag.glsl").unwrap(),
"glsl:/no/such/user_frag.glsl"
);
}
#[test]
fn a_metal_source_routes_to_the_metal_arm_with_its_text() {
let dir = tempfile::tempdir().unwrap();
let path = source_file(dir.path(), "user.metal", "vertex void main() {}");
let out = routed(&path).unwrap();
assert!(out.starts_with("metal:"), "expected the metal arm: {out}");
assert!(out.contains("vertex"), "expected the source text");
}
#[test]
fn without_a_toolchain_a_resolvable_source_reports_the_missing_toolchain() {
let dir = tempfile::tempdir().unwrap();
let path = source_file(dir.path(), "user.metal", "// msl");
let err = compile_with(None, &args("user", &path)).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::Unsupported);
assert!(
err.to_string()
.contains("no shader toolchain is registered"),
"got: {err}"
);
assert!(err.to_string().contains("user"), "names the asset: {err}");
}
#[test]
fn without_a_toolchain_an_unreadable_source_still_fails_at_read() {
let err = compile_with(None, &args("user", "/no/such/user_frag.metal")).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}
#[test]
fn a_read_error_reports_the_path_and_keeps_the_io_error_kind() {
let err = read_shader_source("/no/such/user.metal").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
assert!(
err.to_string()
.starts_with("Failed to read shader source '/no/such/user.metal'"),
"got: {err}"
);
}
#[test]
fn an_on_disk_source_is_read_verbatim() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("user.metal");
std::fs::write(&path, "fragment float4 f() { return 0; }").unwrap();
assert_eq!(
read_shader_source(&path.to_string_lossy()).unwrap(),
"fragment float4 f() { return 0; }"
);
}
}
#[cfg(test)]
mod toolchain_tests {
use super::args_for as args;
use super::*;
struct NoLanguages;
impl ShaderToolchain for NoLanguages {}
#[test]
fn a_language_the_toolchain_does_not_implement_is_unsupported() {
let t = NoLanguages;
let a = args("user", "user_frag.hlsl");
for err in [
t.compile_metal("src", &a).unwrap_err(),
t.compile_hlsl("src", &a).unwrap_err(),
t.compile_glsl(&a).unwrap_err(),
] {
assert_eq!(err.kind(), std::io::ErrorKind::Unsupported);
assert!(err.to_string().contains("user"), "names the asset: {err}");
}
}
#[test]
fn a_partial_toolchain_keeps_its_own_arms() {
struct MetalOnly;
impl ShaderToolchain for MetalOnly {
fn compile_metal(
&self,
_source: &str,
_args: &ShaderCompileArgs,
) -> Result<Vec<u8>, std::io::Error> {
Ok(vec![1])
}
}
let a = args("user", "user_frag.metal");
assert_eq!(MetalOnly.compile_metal("src", &a).unwrap(), vec![1]);
assert_eq!(
MetalOnly.compile_hlsl("src", &a).unwrap_err().kind(),
std::io::ErrorKind::Unsupported
);
}
}
#[cfg(test)]
mod hook_tests {
use super::args_for as args;
use super::*;
struct SentinelValidator;
const SENTINEL: &str = "__layout_hook_sentinel__";
impl ShaderBuildValidator for SentinelValidator {
fn validate_metal(
&self,
_source: &str,
_kind: &str,
asset_name: &str,
) -> Result<(), String> {
if asset_name == SENTINEL {
Err("sentinel layout mismatch".to_string())
} else {
Ok(())
}
}
}
#[test]
fn validator_hook_dispatches_per_asset() {
set_shader_build_validator(Box::new(SentinelValidator));
let err = validate_compiled_metal("frag source", &args(SENTINEL, "user_frag.metal"))
.expect_err("sentinel must fail");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(err.to_string().contains("sentinel layout mismatch"));
validate_compiled_metal("frag source", &args("ok_asset", "user_frag.metal"))
.expect("non-sentinel assets pass");
}
}