Skip to main content

concinnity_core/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::render::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/// Three of them come in pairs, because a shader's resource bindings sit
31/// between the halves: MAIN_TYPES / PROBE_TYPES / RT_TYPES declare the records
32/// a binding names, and MAIN_SHADING / PROBE_COMMON / RT_TRACE the code that
33/// reads the bound resources.
34///
35/// PARTICLE_TYPES is the one shared by two halves of a *system* rather than of
36/// a shader: the simulation kernel writes the pool the render pair reads.
37///
38/// The order is load-bearing where one fragment carries another's marker: the
39/// splice runs the table once, so a nested marker is only replaced if its own
40/// row comes later.
41pub const FRAGMENTS: &[(&str, &str)] = &[
42    ("{POST_COMMON}", "post_common.slang"),
43    // MAIN_TYPES leads OBJECT_COMMON because it carries that marker itself:
44    // the object record belongs with the rest of the main pass's vocabulary,
45    // and a fragment spliced after its own marker has been passed would land
46    // unreplaced.
47    ("{MAIN_TYPES}", "main_types.slang"),
48    ("{OBJECT_COMMON}", "object_common.slang"),
49    ("{PROBE_TYPES}", "probe_types.slang"),
50    ("{PROBE_COMMON}", "probe_common.slang"),
51    ("{RT_TYPES}", "rt_types.slang"),
52    ("{RT_TRACE}", "rt_trace.slang"),
53    ("{PARTICLE_TYPES}", "particle_types.slang"),
54    // RAYMARCH_TYPES leads LIGHT_TYPES for the reason MAIN_TYPES leads
55    // OBJECT_COMMON: it carries that marker itself, and the main pass's own
56    // splice carries it too, so the light records land whichever half asked.
57    ("{RAYMARCH_TYPES}", "raymarch_types.slang"),
58    ("{LIGHT_TYPES}", "light_types.slang"),
59    ("{RAYMARCH_COMMON}", "raymarch_common.slang"),
60    ("{MAIN_SHADING}", "main_shading.slang"),
61    // SHADOW_BIAS trails both halves that carry it: the cascade compare
62    // offset is shared by the main pass and the raymarched surfaces, and a
63    // row placed ahead of theirs would leave the marker unreplaced.
64    ("{SHADOW_BIAS}", "shadow_bias.slang"),
65    // The two hooks a world Shader defines, with the engine's own shading as
66    // the default. A world compile passes its files as caller splices for the
67    // same markers, which take precedence over these rows.
68    ("{SURFACE_VERTEX}", "surface_vertex_default.slang"),
69    ("{SURFACE_FRAGMENT}", "surface_fragment_default.slang"),
70];
71
72/// Prepend a `#define` line per `(name, value)` pair. The defines become part
73/// of the source text on purpose: the shader cache keys on the assembled
74/// source, so two pool sizes can never share an artifact.
75pub fn inject_defines(source: &str, defines: &[(&str, &str)]) -> String {
76    if defines.is_empty() {
77        return source.to_string();
78    }
79    let mut out = String::with_capacity(source.len() + defines.len() * 32);
80    for (name, value) in defines {
81        out.push_str("#define ");
82        out.push_str(name);
83        out.push(' ');
84        out.push_str(value);
85        out.push('\n');
86    }
87    out.push_str(source);
88    out
89}
90
91/// The exact source text a program compiles, from the embedded shaders alone.
92/// `file` names the `.slang` under `src/shaders/`.
93pub fn assemble(file: &str, defines: &[(&str, &str)]) -> String {
94    assemble_with(file, defines, shaders::embedded)
95}
96
97/// Digest of one assembled source text, identifying the artifact a build script
98/// compiled from it.
99///
100/// A precompiled artifact is only usable if the source still matches the one it
101/// was built from, and the name alone cannot say so: hot-reload exists precisely
102/// to compile an edited shader, and a build-time artifact keyed by name would
103/// shadow the edit. Comparing digests makes the embedded copy a content hit --
104/// used whenever the text is unchanged, skipped the moment it is not, in any
105/// build and under any flag.
106///
107/// FNV-1a, because it is over a few kilobytes on a path that then either does
108/// nothing or invokes a compiler; the cost has to disappear next to both.
109pub fn source_digest(source: &str) -> u64 {
110    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
111    const PRIME: u64 = 0x1000_0000_01b3;
112    let mut hash = OFFSET;
113    for byte in source.as_bytes() {
114        hash ^= u64::from(*byte);
115        hash = hash.wrapping_mul(PRIME);
116    }
117    hash
118}
119
120/// The same assembly against a caller-supplied resolver, which is how the
121/// device crate lets a hot-reload build prefer the checkout's copy of a shader
122/// (and of every fragment it splices) over the embedded one. A resolver that
123/// returns `None` falls back to the embedded text, so a missing file loses the
124/// edit rather than the shader.
125pub fn assemble_with(
126    file: &str,
127    defines: &[(&str, &str)],
128    resolve: impl Fn(&str) -> Option<&'static str>,
129) -> String {
130    assemble_with_splices(file, defines, resolve, &[])
131}
132
133/// The same assembly with caller-supplied text spliced in as well, for a shader
134/// whose source is only complete once something outside the shader tree is
135/// known. The raymarched SDF volumes are the case: a world authors the distance
136/// field, so `{SDF_BODY}` is filled from the volume's payload rather than from
137/// a file the table can name.
138///
139/// A caller's splice wins over a [`FRAGMENTS`] row for the same marker, which
140/// is how a world Shader's hooks replace the engine's default ones; the
141/// caller's splices also run again after the table, so a fragment may carry
142/// one of their markers and still have it filled. Like the defines and the
143/// shared fragments, they ride the text, which is what makes the
144/// content-addressed shader cache key two worlds' shaders apart.
145pub fn assemble_with_splices(
146    file: &str,
147    defines: &[(&str, &str)],
148    resolve: impl Fn(&str) -> Option<&'static str>,
149    splices: &[(&str, &str)],
150) -> String {
151    let mut spliced = read(file, &resolve);
152    // The table fills every marker the caller does not claim, then the
153    // caller's text lands once, after it, so a table marker spelled inside a
154    // world file is never expanded and a marker a fragment introduces is
155    // still reached.
156    for (marker, fragment_file) in FRAGMENTS {
157        if spliced.contains(marker) && !splices.iter().any(|(m, _)| m == marker) {
158            let text = read(fragment_file, &resolve);
159            spliced = Cow::Owned(spliced.replace(marker, &text));
160        }
161    }
162    spliced = splice_all(spliced, splices);
163    inject_defines(&spliced, defines)
164}
165
166fn splice_all<'a>(mut text: Cow<'a, str>, splices: &[(&str, &str)]) -> Cow<'a, str> {
167    for (marker, fill) in splices {
168        if text.contains(marker) {
169            text = Cow::Owned(text.replace(marker, fill));
170        }
171    }
172    text
173}
174
175fn read(file: &str, resolve: &impl Fn(&str) -> Option<&'static str>) -> Cow<'static, str> {
176    match resolve(file).or_else(|| shaders::embedded(file)) {
177        Some(text) => Cow::Borrowed(text),
178        // A name no table carries: leave it empty rather than panicking in a
179        // renderer. The compile that follows reports the real error.
180        None => Cow::Borrowed(""),
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use alloc::vec::Vec;
188
189    #[test]
190    fn defines_lead_the_body() {
191        assert_eq!(
192            inject_defines("BODY\n", &[("A", "1"), ("B", "2")]),
193            "#define A 1\n#define B 2\nBODY\n"
194        );
195        assert_eq!(inject_defines("BODY\n", &[]), "BODY\n");
196    }
197
198    // The marker is replaced by the shared text, and the replacement lands in
199    // the assembled source rather than behind an include path -- which is what
200    // makes the content-addressed cache key cover it.
201    #[test]
202    fn the_post_common_marker_is_spliced_into_the_body() {
203        let src = assemble_with("x.slang", &[], |f| {
204            (f == "x.slang").then_some("A\n{POST_COMMON}\nB\n")
205        });
206        assert!(!src.contains("{POST_COMMON}"));
207        assert!(src.contains("float2 combined_size("));
208        assert!(src.starts_with("A\n") && src.ends_with("\nB\n"));
209    }
210
211    // The probe and ray-tracing fragments each splice in two halves, and the
212    // order matters: the record declarations have to precede the helpers that
213    // read them, because a shader puts its resource bindings between the two.
214    #[test]
215    fn the_paired_fragments_splice_records_before_helpers() {
216        let body = "{PROBE_TYPES}\n{PROBE_COMMON}\n{RT_TYPES}\n{RT_TRACE}\n";
217        let src = assemble_with("x.slang", &[], |f| (f == "x.slang").then_some(body));
218        for (marker, _) in FRAGMENTS {
219            assert!(!src.contains(marker), "unspliced {marker}");
220        }
221        assert!(src.find("struct ProbeSet") < src.find("float3 probe_set_specular("));
222        assert!(src.find("struct RtGeomEntry") < src.find("bool rt_trace_reflection("));
223    }
224
225    // A resolver that answers wins over the embedded copy; one that declines
226    // falls back to it. This is the whole of what hot-reload needs from here.
227    #[test]
228    fn the_resolver_overrides_the_embedded_copy_and_declining_falls_back() {
229        let overridden = assemble_with("fog.slang", &[], |f| {
230            (f == "fog.slang").then_some("OVERRIDDEN\n")
231        });
232        assert_eq!(overridden, "OVERRIDDEN\n");
233        assert_eq!(
234            assemble_with("fog.slang", &[], |_| None),
235            assemble("fog.slang", &[])
236        );
237    }
238
239    // A caller-supplied splice fills a marker no file in the table names, which
240    // is how a world's own distance field reaches the raymarch source.
241    #[test]
242    fn a_caller_splice_fills_a_marker_the_table_does_not_name() {
243        let src = assemble_with_splices(
244            "x.slang",
245            &[],
246            |f| (f == "x.slang").then_some("A\n{SDF_BODY}\nB\n"),
247            &[("{SDF_BODY}", "float map() { return 1.0; }")],
248        );
249        assert_eq!(src, "A\nfloat map() { return 1.0; }\nB\n");
250    }
251
252    // The caller's splices run after the table's, so a shared fragment may
253    // carry one of their markers. Nothing does today; the ordering is what
254    // keeps that from becoming a silent unreplaced marker if one ever does.
255    #[test]
256    fn a_caller_splice_reaches_a_marker_inside_a_shared_fragment() {
257        let src = assemble_with_splices(
258            "x.slang",
259            &[],
260            |f| match f {
261                "x.slang" => Some("{POST_COMMON}\n"),
262                "post_common.slang" => Some("frag {SDF_BODY} end"),
263                _ => None,
264            },
265            &[("{SDF_BODY}", "FILLED")],
266        );
267        assert_eq!(src, "frag FILLED end\n");
268    }
269
270    // A world file lands verbatim: a table marker spelled inside it (in a
271    // comment, say) is not expanded, and its text is never rescanned.
272    #[test]
273    fn a_caller_splice_is_not_rescanned_for_table_markers() {
274        let src = assemble_with_splices(
275            "x.slang",
276            &[],
277            |f| (f == "x.slang").then_some("{SURFACE_FRAGMENT}\n"),
278            &[(
279                "{SURFACE_FRAGMENT}",
280                "// see {MAIN_TYPES} and {SURFACE_VERTEX}\n",
281            )],
282        );
283        assert_eq!(src, "// see {MAIN_TYPES} and {SURFACE_VERTEX}\n\n");
284    }
285
286    // A caller splice for a marker the table also names replaces the table's
287    // default: a world Shader's hooks land where the engine's own would.
288    #[test]
289    fn a_caller_splice_wins_over_a_table_row_for_the_same_marker() {
290        let body = "{SURFACE_VERTEX}\n{SURFACE_FRAGMENT}\n";
291        let src = assemble_with_splices(
292            "x.slang",
293            &[],
294            |f| (f == "x.slang").then_some(body),
295            &[("{SURFACE_FRAGMENT}", "WORLD SHADE")],
296        );
297        assert!(src.contains("WORLD SHADE"));
298        assert!(
299            !src.contains("float4 shade(VertexOut in, GpuObjectData od)\n{"),
300            "default shade replaced"
301        );
302        assert!(
303            src.contains("VertexOut transform("),
304            "default transform kept"
305        );
306    }
307
308    // The two halves of the raymarch splice land in the order the source needs:
309    // the records a binding names, then the body that reads them, then the
310    // world's own field after both. The light records arrive through the types
311    // half, so a raymarch shader never declares them itself.
312    #[test]
313    fn the_raymarch_source_assembles_records_then_body_then_the_world_field() {
314        let src = assemble_with_splices(
315            "raymarch.slang",
316            &[("RAYMARCH_METAL", "1"), ("RAYMARCH_SURFACE", "1")],
317            shaders::embedded,
318            &[("{SDF_BODY}", "// the world's field")],
319        );
320        for (marker, _) in FRAGMENTS {
321            assert!(!src.contains(marker), "unspliced {marker}");
322        }
323        assert!(!src.contains("{SDF_BODY}"));
324        let types = src.find("struct SdfVolumeUniforms").expect("records");
325        let lights = src.find("struct LightUniforms").expect("light records");
326        let body = src.find("RayHit coneRaymarch(").expect("marcher");
327        let field = src.find("// the world's field").expect("world field");
328        let entry = src
329            .find("RaymarchFragOut raymarch_fragment(")
330            .expect("entry");
331        assert!(lights < types, "light records precede the volume block");
332        assert!(types < body, "records precede the body that reads them");
333        assert!(
334            body < field,
335            "the field is declared after the helpers call it"
336        );
337        assert!(field < entry, "the entry points come last");
338    }
339
340    // Both halves of the light-record splice resolve: the main pass carries it
341    // through its own types fragment and the raymarch pass through its. A
342    // shader that declared these itself would be the drift the split removes.
343    #[test]
344    fn both_passes_take_the_light_records_from_one_fragment() {
345        for file in ["main_bindless.slang", "raymarch.slang"] {
346            let src = assemble(file, &[]);
347            assert!(!src.contains("{LIGHT_TYPES}"), "{file} left the marker");
348            assert_eq!(
349                src.matches("struct LightUniforms").count(),
350                1,
351                "{file} declares the light block other than once"
352            );
353        }
354    }
355
356    // A body without the marker keeps its text byte for byte, so the splice
357    // cannot perturb the key of a program that does not use it.
358    #[test]
359    fn a_body_without_the_marker_is_untouched() {
360        let src = assemble_with("x.slang", &[], |f| (f == "x.slang").then_some("BODY\n"));
361        assert_eq!(src, "BODY\n");
362    }
363
364    // Every fragment the table names has to exist, or a shader carrying its
365    // marker would silently splice in nothing.
366    #[test]
367    fn every_fragment_the_table_names_is_embedded() {
368        for (marker, file) in FRAGMENTS {
369            assert!(
370                shaders::embedded(file).is_some(),
371                "{marker} names a missing {file}"
372            );
373        }
374    }
375
376    // The lookup and the table are the same set, and every name is unique --
377    // a duplicate would make `embedded` return whichever came first.
378    #[test]
379    fn the_source_table_is_a_unique_set_the_lookup_covers() {
380        let mut names: Vec<&str> = shaders::SOURCES.iter().map(|(n, _)| *n).collect();
381        names.sort_unstable();
382        let count = names.len();
383        names.dedup();
384        assert_eq!(names.len(), count, "duplicate shader name");
385        for (name, text) in shaders::SOURCES {
386            assert_eq!(shaders::embedded(name), Some(*text));
387        }
388        assert_eq!(shaders::embedded("not_a_shader.slang"), None);
389    }
390}