Skip to main content

concinnity_render/
slang_source.rs

1//! Source assembly for the single-source `.slang` engine shaders.
2//!
3//! Every consumer that compiles one of `src/shaders/*.slang` assembles the same
4//! text the same way: the file's body, its `{...}` fragment markers replaced,
5//! and the program's variant defines injected ahead of it. Both the fragments
6//! and the defines ride the text rather than an include path or a command line,
7//! so the backends' content-addressed shader cache keys them -- which is what
8//! keeps two pool sizes, two Hi-Z variants, or two revisions of a shared helper
9//! from ever sharing an artifact.
10//!
11//! It lives here, below the device backends, because the assembly is what they
12//! and the build script have to agree on: a build script's precompile and a
13//! renderer's runtime compile must produce byte-identical source for the
14//! content-addressed cache to be sound across them. Keeping one implementation
15//! is what makes that true by construction rather than by review.
16//!
17//! Reading a shader off disk is not part of it. Hot-reload wants the checkout's
18//! copy to win over the embedded one, and that is a `std` filesystem concern in
19//! a `no_std` crate -- so `assemble_with` takes a resolver and the device crate
20//! supplies the one that reads disk first.
21
22use alloc::borrow::Cow;
23use alloc::string::{String, ToString};
24
25use crate::shaders;
26
27/// The shared fragments, as (marker, file). A shader carrying a marker has the
28/// named file's text spliced in at that point.
29///
30/// Two of them come in pairs, because a shader's resource bindings sit between
31/// the halves: PROBE_TYPES / RT_TYPES declare the records a binding names, and
32/// PROBE_COMMON / RT_TRACE the code that reads the bound resources.
33/// PARTICLE_TYPES is the one shared by two halves of a *system* rather than of
34/// a shader: the simulation kernel writes the pool the render pair reads.
35pub const FRAGMENTS: &[(&str, &str)] = &[
36    ("{POST_COMMON}", "post_common.slang"),
37    ("{OBJECT_COMMON}", "object_common.slang"),
38    ("{PROBE_TYPES}", "probe_types.slang"),
39    ("{PROBE_COMMON}", "probe_common.slang"),
40    ("{RT_TYPES}", "rt_types.slang"),
41    ("{RT_TRACE}", "rt_trace.slang"),
42    ("{PARTICLE_TYPES}", "particle_types.slang"),
43];
44
45/// Prepend a `#define` line per `(name, value)` pair. The defines become part
46/// of the source text on purpose: the shader cache keys on the assembled
47/// source, so two pool sizes can never share an artifact.
48pub fn inject_defines(source: &str, defines: &[(&str, &str)]) -> String {
49    if defines.is_empty() {
50        return source.to_string();
51    }
52    let mut out = String::with_capacity(source.len() + defines.len() * 32);
53    for (name, value) in defines {
54        out.push_str("#define ");
55        out.push_str(name);
56        out.push(' ');
57        out.push_str(value);
58        out.push('\n');
59    }
60    out.push_str(source);
61    out
62}
63
64/// The exact source text a program compiles, from the embedded shaders alone.
65/// `file` names the `.slang` under `src/shaders/`.
66pub fn assemble(file: &str, defines: &[(&str, &str)]) -> String {
67    assemble_with(file, defines, shaders::embedded)
68}
69
70/// Digest of one assembled source text, identifying the artifact a build script
71/// compiled from it.
72///
73/// A precompiled artifact is only usable if the source still matches the one it
74/// was built from, and the name alone cannot say so: hot-reload exists precisely
75/// to compile an edited shader, and a build-time artifact keyed by name would
76/// shadow the edit. Comparing digests makes the embedded copy a content hit --
77/// used whenever the text is unchanged, skipped the moment it is not, in any
78/// build and under any flag.
79///
80/// FNV-1a, because it is over a few kilobytes on a path that then either does
81/// nothing or invokes a compiler; the cost has to disappear next to both.
82pub fn source_digest(source: &str) -> u64 {
83    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
84    const PRIME: u64 = 0x1000_0000_01b3;
85    let mut hash = OFFSET;
86    for byte in source.as_bytes() {
87        hash ^= u64::from(*byte);
88        hash = hash.wrapping_mul(PRIME);
89    }
90    hash
91}
92
93/// The same assembly against a caller-supplied resolver, which is how the
94/// device crate lets a hot-reload build prefer the checkout's copy of a shader
95/// (and of every fragment it splices) over the embedded one. A resolver that
96/// returns `None` falls back to the embedded text, so a missing file loses the
97/// edit rather than the shader.
98pub fn assemble_with(
99    file: &str,
100    defines: &[(&str, &str)],
101    resolve: impl Fn(&str) -> Option<&'static str>,
102) -> String {
103    let mut spliced = read(file, &resolve);
104    for (marker, fragment_file) in FRAGMENTS {
105        if spliced.contains(marker) {
106            let text = read(fragment_file, &resolve);
107            spliced = Cow::Owned(spliced.replace(marker, &text));
108        }
109    }
110    inject_defines(&spliced, defines)
111}
112
113fn read(file: &str, resolve: &impl Fn(&str) -> Option<&'static str>) -> Cow<'static, str> {
114    match resolve(file).or_else(|| shaders::embedded(file)) {
115        Some(text) => Cow::Borrowed(text),
116        // A name no table carries: leave it empty rather than panicking in a
117        // renderer. The compile that follows reports the real error.
118        None => Cow::Borrowed(""),
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use alloc::vec::Vec;
126
127    #[test]
128    fn defines_lead_the_body() {
129        assert_eq!(
130            inject_defines("BODY\n", &[("A", "1"), ("B", "2")]),
131            "#define A 1\n#define B 2\nBODY\n"
132        );
133        assert_eq!(inject_defines("BODY\n", &[]), "BODY\n");
134    }
135
136    // The marker is replaced by the shared text, and the replacement lands in
137    // the assembled source rather than behind an include path -- which is what
138    // makes the content-addressed cache key cover it.
139    #[test]
140    fn the_post_common_marker_is_spliced_into_the_body() {
141        let src = assemble_with("x.slang", &[], |f| {
142            (f == "x.slang").then_some("A\n{POST_COMMON}\nB\n")
143        });
144        assert!(!src.contains("{POST_COMMON}"));
145        assert!(src.contains("float2 combined_size("));
146        assert!(src.starts_with("A\n") && src.ends_with("\nB\n"));
147    }
148
149    // The probe and ray-tracing fragments each splice in two halves, and the
150    // order matters: the record declarations have to precede the helpers that
151    // read them, because a shader puts its resource bindings between the two.
152    #[test]
153    fn the_paired_fragments_splice_records_before_helpers() {
154        let body = "{PROBE_TYPES}\n{PROBE_COMMON}\n{RT_TYPES}\n{RT_TRACE}\n";
155        let src = assemble_with("x.slang", &[], |f| (f == "x.slang").then_some(body));
156        for (marker, _) in FRAGMENTS {
157            assert!(!src.contains(marker), "unspliced {marker}");
158        }
159        assert!(src.find("struct ProbeSet") < src.find("float3 probe_set_specular("));
160        assert!(src.find("struct RtGeomEntry") < src.find("bool rt_trace_reflection("));
161    }
162
163    // A resolver that answers wins over the embedded copy; one that declines
164    // falls back to it. This is the whole of what hot-reload needs from here.
165    #[test]
166    fn the_resolver_overrides_the_embedded_copy_and_declining_falls_back() {
167        let overridden = assemble_with("fog.slang", &[], |f| {
168            (f == "fog.slang").then_some("OVERRIDDEN\n")
169        });
170        assert_eq!(overridden, "OVERRIDDEN\n");
171        assert_eq!(
172            assemble_with("fog.slang", &[], |_| None),
173            assemble("fog.slang", &[])
174        );
175    }
176
177    // A body without the marker keeps its text byte for byte, so the splice
178    // cannot perturb the key of a program that does not use it.
179    #[test]
180    fn a_body_without_the_marker_is_untouched() {
181        let src = assemble_with("x.slang", &[], |f| (f == "x.slang").then_some("BODY\n"));
182        assert_eq!(src, "BODY\n");
183    }
184
185    // Every fragment the table names has to exist, or a shader carrying its
186    // marker would silently splice in nothing.
187    #[test]
188    fn every_fragment_the_table_names_is_embedded() {
189        for (marker, file) in FRAGMENTS {
190            assert!(
191                shaders::embedded(file).is_some(),
192                "{marker} names a missing {file}"
193            );
194        }
195    }
196
197    // The lookup and the table are the same set, and every name is unique --
198    // a duplicate would make `embedded` return whichever came first.
199    #[test]
200    fn the_source_table_is_a_unique_set_the_lookup_covers() {
201        let mut names: Vec<&str> = shaders::SOURCES.iter().map(|(n, _)| *n).collect();
202        names.sort_unstable();
203        let count = names.len();
204        names.dedup();
205        assert_eq!(names.len(), count, "duplicate shader name");
206        for (name, text) in shaders::SOURCES {
207            assert_eq!(shaders::embedded(name), Some(*text));
208        }
209        assert_eq!(shaders::embedded("not_a_shader.slang"), None);
210    }
211}