Skip to main content

unlit_wgpu/
compose.rs

1//! Composing a WESL module of the built-in shader package into WGSL.
2//!
3//! Every built-in entry shader — `lit`, `line`, `blit`, `fullscreen_vertex` —
4//! is a module of the package generated at build time from `shaders/*.wesl`.
5//! This module is the one place that turns one of them into a WGSL string: it
6//! sets the conditional-compilation flags, keeps the module's own entry points,
7//! and, when a caller extends a shader, mounts the spliced source in place of
8//! the package's copy. Both the built-in pipelines and a caller's own pipeline
9//! reach the same facilities here; nothing in the composition is private to one
10//! built-in implementation.
11
12/// The comment the built-in entry shaders mark their module-scope splice with.
13pub const MODULE_HOOK: &str = "// unlit_wgpu::hook module";
14/// The comment the built-in entry shaders mark their vertex-stage splice with.
15pub const VERTEX_HOOK: &str = "// unlit_wgpu::hook vertex";
16/// The comment the built-in entry shaders mark their fragment-stage splice with.
17pub const FRAGMENT_HOOK: &str = "// unlit_wgpu::hook fragment";
18
19/// `source` with each hook mark replaced by the snippet belonging to it.
20///
21/// The splice happens on WESL, before anything is parsed or compiled, so a
22/// snippet takes part in the same conditional compilation as the code around it
23/// and a diagnostic inside one points at the line it was written on.
24///
25/// # Panics
26///
27/// If `source` does not carry all three marks. They are checked rather than
28/// trusted because a mark is a bare comment: losing one while editing a shader
29/// would otherwise silently drop every snippet aimed at it.
30pub fn splice(source: &str, module: &str, vertex: &str, fragment: &str) -> String {
31    let spliced = splice_at(source, MODULE_HOOK, module);
32    let spliced = splice_at(&spliced, VERTEX_HOOK, vertex);
33    let spliced = splice_at(&spliced, FRAGMENT_HOOK, fragment);
34    assert!(
35        !spliced.contains(MODULE_HOOK)
36            && !spliced.contains(VERTEX_HOOK)
37            && !spliced.contains(FRAGMENT_HOOK),
38        "the built-in entry shader carries all three hook marks"
39    );
40    spliced
41}
42
43/// `source` with the line carrying the first `mark` replaced by `snippet`.
44///
45/// The whole line goes, indentation included, so the snippet stands where the
46/// mark stood and an empty snippet leaves a blank line rather than an indented
47/// tail. A missing mark leaves the source untouched; [`splice`] is what rejects
48/// one.
49fn splice_at(source: &str, mark: &str, snippet: &str) -> String {
50    let Some(at) = source.find(mark) else {
51        return source.to_owned();
52    };
53    let line_start = source[..at].rfind('\n').map_or(0, |newline| newline + 1);
54    let line_end = source[at..]
55        .find('\n')
56        .map_or(source.len(), |newline| at + newline);
57    let mut spliced = String::with_capacity(source.len() + snippet.len());
58    spliced.push_str(&source[..line_start]);
59    spliced.push_str(snippet);
60    spliced.push_str(&source[line_end..]);
61    spliced
62}
63
64/// Compose `module` of the built-in WESL package into one WGSL module.
65///
66/// `module` names a top-level module of the package (`lit`, `line`, `blit`, …).
67/// `features` are the conditional-compilation flags set before composing; the
68/// module's own declarations — its entry points among them — stay under their
69/// own names, so a pipeline can address them.
70///
71/// When `replacement` is `Some`, that source replaces the package's own copy of
72/// `module`: it is mounted at exactly that one path, so the imports around it
73/// (`unlit_wgpu::…` and the shader's own `package::…` alike) still resolve to
74/// the package that owns them. It is how a caller's snippets are spliced into
75/// an entry shader without that shader gaining a private copy of the compiler.
76pub fn compose(
77    module: &str,
78    features: &[(&'static str, bool)],
79    replacement: Option<&str>,
80) -> Result<String, ComposeError> {
81    let main_path = wesl::syntax::ModulePath::new(
82        wesl::syntax::PathOrigin::Package("unlit_wgpu".to_owned()),
83        vec![module.to_owned()],
84    );
85    let mut compile_options = wesl::CompileOptions::default();
86    for (name, enabled) in features {
87        compile_options.features.set(name, *enabled);
88    }
89    compile_options.keep_main = true;
90
91    let mut packages = wesl::resolver::PackageResolver::new();
92    packages.add_package(&crate::shader::PACKAGE);
93
94    let composed = match replacement {
95        None => {
96            wesl::Compiler::new_with_resolver(compile_options, packages).compile_module(&main_path)
97        }
98        Some(source) => {
99            let mut modules: wesl::resolver::VirtualResolver<'static> =
100                wesl::resolver::VirtualResolver::new();
101            modules.add_module(
102                wesl::syntax::ModulePath::new(wesl::syntax::PathOrigin::Absolute, Vec::new()),
103                source.to_owned().into(),
104            );
105            let mut resolver = wesl::resolver::Router::new();
106            resolver.mount_resolver(main_path.clone(), modules);
107            resolver.mount_fallback_resolver(packages);
108            wesl::Compiler::new_with_resolver(compile_options, resolver).compile_module(&main_path)
109        }
110    };
111    composed
112        .map(|result| result.syntax.to_string())
113        .map_err(ComposeError::Compile)
114}
115
116/// Failure reasons reported by [`compose`].
117#[derive(Debug, thiserror::Error)]
118pub enum ComposeError {
119    /// The WESL compiler rejected the module.
120    #[error("WESL compilation failed: {0}")]
121    Compile(#[from] wesl::Error),
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    /// The stage names survive composition, which is what lets a pipeline
129    /// address them.
130    #[test]
131    fn each_module_keeps_its_entry_point_names() {
132        let vertex =
133            compose("fullscreen_vertex", &[], None).expect("the fullscreen vertex composes");
134        assert!(vertex.contains("fn fullscreen_vertex_shader"));
135
136        let fragment = compose("blit", &[], None).expect("the blit fragment composes");
137        assert!(fragment.contains("fn fs_main"));
138
139        let line = compose("line", &[], None).expect("the line module composes");
140        assert!(line.contains("fn vs_main"));
141        assert!(line.contains("fn fs_main"));
142    }
143}