Skip to main content

concinnity_toolchain/
metal_shaders.rs

1// Build-time precompilation of the engine's static Metal shaders.
2//
3// The device crate's build script hands this module the shader directory, the
4// names that must stay source-only (runtime-assembled templates), and the
5// shared fragments spliced into shaders at a marker. Every other `.metal` file
6// is assembled into OUT_DIR and compiled to a `.metallib` with the same
7// `xcrun metal` + `xcrun metallib` pair the cook's shader toolchain uses, and a
8// lookup function mapping shader name to embedded bytes is generated there too.
9//
10// When the Metal compiler is not installed (Command Line Tools without a full
11// Xcode) the generated lookup returns `None` for every name and the renderer
12// falls back to compiling its embedded source at startup, exactly as before.
13// A cargo warning flags the slower path so it is never a silent regression.
14
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18use concinnity_slang as slang;
19
20/// One single-source shader library to precompile to a metallib: the `.slang`
21/// file under the slang shader directory, the entry points linked into the
22/// library, and the `#define`s that select its variant. `name` is the key the
23/// renderer's lookup uses (distinct from `file` when one source yields several
24/// variant libraries).
25pub struct SlangLibSpec {
26    /// Lookup key the renderer resolves this library by.
27    pub name: &'static str,
28    /// The `.slang` file, relative to the shader directory.
29    pub file: &'static str,
30    /// Entry points linked into the library.
31    pub entries: &'static [&'static str],
32    /// `#define`s selecting this variant.
33    pub defines: &'static [(&'static str, &'static str)],
34}
35
36/// The single-source half of the precompile: where the `.slang` files live, the
37/// shared declarations spliced into the ones carrying a marker, and one spec per
38/// metallib variant. Grouped because they always travel together, and because
39/// the splice table has to match the renderer's `slang_source::assemble` exactly
40/// -- the two produce the same text or the content-addressed cache serves one
41/// path's bytes to the other.
42pub struct SlangShaders<'a> {
43    /// Directory holding the `.slang` sources.
44    pub dir: &'a Path,
45    /// Shared declarations spliced into sources carrying a marker.
46    pub fragments: &'a [(&'a str, &'a str)],
47    /// One spec per metallib variant.
48    pub specs: &'a [SlangLibSpec],
49}
50
51/// Precompile every eligible `.metal` under `shaders_dir`, plus every `.slang`
52/// spec in `slang`, into OUT_DIR and generate `engine_metallibs.rs` there.
53/// `fragments` pairs a source marker with the file under `shaders_dir` that
54/// replaces it, matching the substitution the renderer applies when it compiles
55/// the same shader from source. Panics if the Metal toolchain is present but a
56/// shader fails to compile: a broken shader must fail the build, not surface at
57/// renderer init.
58pub fn precompile_metal_shaders(
59    shaders_dir: &Path,
60    source_only: &[&str],
61    fragments: &[(&str, &str)],
62    slang: &SlangShaders,
63) {
64    println!("cargo:rerun-if-changed={}", shaders_dir.display());
65    println!("cargo:rerun-if-changed={}", slang.dir.display());
66    let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR"));
67    let generated = out_dir.join("engine_metallibs.rs");
68
69    let shaders = eligible_shaders(shaders_dir, source_only);
70    if !metal_toolchain_present() {
71        println!(
72            "cargo:warning=Metal compiler not found (full Xcode required); engine shaders \
73             will compile from source at startup"
74        );
75        std::fs::write(&generated, stub_lookup_source()).expect("write engine_metallibs.rs");
76        return;
77    }
78
79    let fragments = read_fragments(shaders_dir, fragments);
80    let slang_fragments = read_fragments(slang.dir, slang.fragments);
81
82    let lib_dir = out_dir.join("engine_shaders");
83    std::fs::create_dir_all(&lib_dir).expect("create engine_shaders dir");
84    let mut entries = Vec::with_capacity(shaders.len() + slang.specs.len());
85    for path in &shaders {
86        let name = path
87            .file_name()
88            .and_then(|n| n.to_str())
89            .expect("utf8 shader filename")
90            .to_string();
91        let source = std::fs::read_to_string(path)
92            .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
93        let assembled = lib_dir.join(&name);
94        std::fs::write(&assembled, assemble(&source, &fragments))
95            .unwrap_or_else(|e| panic!("write {}: {e}", assembled.display()));
96        let lib_path = lib_dir.join(&name).with_extension("metallib");
97        compile_metallib(&assembled, &lib_path);
98        entries.push((name, lib_path));
99    }
100    entries.extend(precompile_slang_libs(
101        slang.dir,
102        slang.specs,
103        &slang_fragments,
104        &lib_dir,
105    ));
106    std::fs::write(&generated, metallib_lookup_source(&entries))
107        .expect("write engine_metallibs.rs");
108}
109
110// Load each `(marker, file)` pair's replacement text from `dir`.
111fn read_fragments<'a>(dir: &Path, fragments: &'a [(&'a str, &'a str)]) -> Vec<(&'a str, String)> {
112    fragments
113        .iter()
114        .map(|(marker, file)| {
115            let path = dir.join(file);
116            let text = std::fs::read_to_string(&path)
117                .unwrap_or_else(|e| panic!("read fragment {}: {e}", path.display()));
118            (*marker, text)
119        })
120        .collect()
121}
122
123// Compile each single-source spec to a metallib via slangc. With slangc absent
124// the specs are skipped behind a cargo warning: their names miss the generated
125// lookup and the renderer compiles them at startup instead (which needs slangc
126// at runtime and errors clearly when it is missing there too).
127fn precompile_slang_libs(
128    slang_dir: &Path,
129    specs: &[SlangLibSpec],
130    fragments: &[(&str, String)],
131    lib_dir: &Path,
132) -> Vec<(String, PathBuf)> {
133    if specs.is_empty() {
134        return Vec::new();
135    }
136    if slang::slangc_path().is_none() {
137        println!(
138            "cargo:warning=slangc not found; single-source engine shaders will compile at \
139             startup (install the Vulkan SDK or a standalone Slang release)"
140        );
141        return Vec::new();
142    }
143    specs
144        .iter()
145        .map(|spec| {
146            let path = slang_dir.join(spec.file);
147            let source = std::fs::read_to_string(&path)
148                .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
149            let source = assemble(&source, fragments);
150            let job = slang::SlangJob {
151                source: &concinnity_render::slang_source::inject_defines(&source, spec.defines),
152                file_name: spec.name,
153                entries: spec.entries,
154                target: slang::SlangTarget::Metallib,
155            };
156            let bytes = slang::compile(&job, lib_dir)
157                .unwrap_or_else(|e| panic!("slang metallib precompile failed: {e}"));
158            let lib_path = lib_dir.join(spec.name).with_extension("metallib");
159            std::fs::write(&lib_path, bytes)
160                .unwrap_or_else(|e| panic!("write {}: {e}", lib_path.display()));
161            (spec.name.to_string(), lib_path)
162        })
163        .collect()
164}
165
166// Replace every fragment marker in `source`. Kept pure for unit testing.
167fn assemble(source: &str, fragments: &[(&str, String)]) -> String {
168    let mut out = source.to_string();
169    for (marker, text) in fragments {
170        out = out.replace(marker, text);
171    }
172    out
173}
174
175// Every `.metal` in the directory except the runtime-assembled template
176// fragments, sorted for a deterministic generated file.
177fn eligible_shaders(shaders_dir: &Path, source_only: &[&str]) -> Vec<PathBuf> {
178    let mut shaders: Vec<PathBuf> = std::fs::read_dir(shaders_dir)
179        .unwrap_or_else(|e| panic!("read {}: {e}", shaders_dir.display()))
180        .filter_map(|entry| Some(entry.ok()?.path()))
181        .filter(|path| {
182            path.extension().is_some_and(|ext| ext == "metal")
183                && path
184                    .file_name()
185                    .and_then(|n| n.to_str())
186                    .is_some_and(|name| !source_only.contains(&name))
187        })
188        .collect();
189    shaders.sort();
190    shaders
191}
192
193fn metal_toolchain_present() -> bool {
194    Command::new("xcrun")
195        .args(["--sdk", "macosx", "-f", "metal"])
196        .output()
197        .is_ok_and(|out| out.status.success())
198}
199
200// Same two-step pipeline the cook's Metal toolchain runs: source to AIR, AIR
201// linked into a single-file metallib.
202fn compile_metallib(source: &Path, lib_path: &Path) {
203    let air_path = lib_path.with_extension("air");
204    run_step(
205        Command::new("xcrun")
206            .args(["--sdk", "macosx", "metal", "-c"])
207            .arg(source)
208            .arg("-o")
209            .arg(&air_path),
210        source,
211        "xcrun metal",
212    );
213    run_step(
214        Command::new("xcrun")
215            .args(["--sdk", "macosx", "metallib"])
216            .arg(&air_path)
217            .arg("-o")
218            .arg(lib_path),
219        source,
220        "xcrun metallib",
221    );
222}
223
224fn run_step(cmd: &mut Command, source: &Path, what: &str) {
225    let output = cmd
226        .output()
227        .unwrap_or_else(|e| panic!("{what} failed to launch for {}: {e}", source.display()));
228    if !output.status.success() {
229        panic!(
230            "{what} failed for {}:\n{}\n{}",
231            source.display(),
232            String::from_utf8_lossy(&output.stdout),
233            String::from_utf8_lossy(&output.stderr),
234        );
235    }
236}
237
238// Generated lookup mapping a registered shader name to its precompiled
239// metallib bytes. Kept pure (paths in, source out) for unit testing.
240fn metallib_lookup_source(entries: &[(String, PathBuf)]) -> String {
241    let mut src = String::from(
242        "// @generated by concinnity-toolchain::precompile_metal_shaders\n\
243         pub(crate) fn embedded_metallib(name: &str) -> Option<&'static [u8]> {\n\
244         \x20   match name {\n",
245    );
246    for (name, lib_path) in entries {
247        src.push_str(&format!(
248            "        {name:?} => Some(include_bytes!({:?})),\n",
249            lib_path.display().to_string()
250        ));
251    }
252    src.push_str("        _ => None,\n    }\n}\n");
253    src
254}
255
256// Fallback when the Metal toolchain is unavailable at build time.
257fn stub_lookup_source() -> String {
258    "// @generated by concinnity-toolchain::precompile_metal_shaders (no Metal toolchain)\n\
259     pub(crate) fn embedded_metallib(_name: &str) -> Option<&'static [u8]> {\n\
260     \x20   None\n\
261     }\n"
262    .to_string()
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn lookup_source_maps_each_entry_and_falls_through() {
271        let entries = vec![
272            (
273                "post.metal".to_string(),
274                PathBuf::from("/out/post.metallib"),
275            ),
276            ("taa.metal".to_string(), PathBuf::from("/out/taa.metallib")),
277        ];
278        let src = metallib_lookup_source(&entries);
279        assert!(src.contains("\"post.metal\" => Some(include_bytes!(\"/out/post.metallib\"))"));
280        assert!(src.contains("\"taa.metal\" => Some(include_bytes!(\"/out/taa.metallib\"))"));
281        assert!(src.contains("_ => None"));
282        assert!(src.contains("fn embedded_metallib"));
283    }
284
285    #[test]
286    fn stub_lookup_source_returns_none_for_everything() {
287        let src = stub_lookup_source();
288        assert!(src.contains("fn embedded_metallib"));
289        assert!(src.contains("None"));
290        assert!(!src.contains("include_bytes!"));
291    }
292
293    #[test]
294    fn assemble_substitutes_every_marker() {
295        let fragments = vec![
296            (
297                "{OBJECT_DATA}",
298                "struct GpuObjectData { float4x4 model; };".to_string(),
299            ),
300            ("{OTHER}", "// other".to_string()),
301        ];
302        let out = assemble("head\n{OBJECT_DATA}\nmid\n{OTHER}\ntail\n", &fragments);
303        assert!(out.contains("struct GpuObjectData"));
304        assert!(out.contains("// other"));
305        assert!(!out.contains("{OBJECT_DATA}"));
306        assert!(!out.contains("{OTHER}"));
307        assert!(out.starts_with("head\n") && out.ends_with("tail\n"));
308    }
309
310    #[test]
311    fn assemble_replaces_every_occurrence_and_leaves_markerless_source_alone() {
312        let fragments = vec![("{OBJECT_DATA}", "RECORD".to_string())];
313        assert_eq!(
314            assemble("{OBJECT_DATA} a {OBJECT_DATA}", &fragments),
315            "RECORD a RECORD"
316        );
317        assert_eq!(assemble("no markers", &fragments), "no markers");
318    }
319
320    #[test]
321    fn eligible_shaders_excludes_source_only_and_non_metal() {
322        let dir =
323            std::env::temp_dir().join(format!("cn_metal_shaders_test_{}", std::process::id()));
324        std::fs::create_dir_all(&dir).unwrap();
325        for name in ["a.metal", "b.metal", "template.metal", "notes.txt"] {
326            std::fs::write(dir.join(name), "").unwrap();
327        }
328        let shaders = eligible_shaders(&dir, &["template.metal"]);
329        let names: Vec<_> = shaders
330            .iter()
331            .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
332            .collect();
333        assert_eq!(names, ["a.metal", "b.metal"]);
334        std::fs::remove_dir_all(&dir).unwrap();
335    }
336}