1use std::path::{Path, PathBuf};
16use std::process::Command;
17
18use concinnity_slang as slang;
19
20pub struct SlangLibSpec {
26 pub name: &'static str,
28 pub file: &'static str,
30 pub entries: &'static [&'static str],
32 pub defines: &'static [(&'static str, &'static str)],
34}
35
36pub struct SlangShaders<'a> {
43 pub dir: &'a Path,
45 pub fragments: &'a [(&'a str, &'a str)],
47 pub specs: &'a [SlangLibSpec],
49}
50
51pub 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
110fn 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
123fn 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
166fn 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
175fn 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
200fn 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
238fn 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
256fn 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}