Skip to main content

concinnity_core/render/slang_programs/
surface.rs

1//! What a world `Shader` compiles to, on every backend.
2//!
3//! A world Shader defines two hooks, `transform` and `shade`, and the engine's
4//! own main-pass entries call them. So a world shader compiles as the engine's
5//! main-pass programs do, from `main_bindless.slang`, with the world's files
6//! spliced at the hook markers in place of the engine's defaults. The cook
7//! iterates this table to compile a Shader ahead of time and each renderer
8//! iterates it to find what the cook left.
9
10use alloc::string::{String, ToString};
11use alloc::vec::Vec;
12
13use crate::platform::Platform;
14use crate::render::slang_source;
15use crate::render::uniforms::{BINDLESS_POOL_SIZE, MAX_PROBES};
16
17/// The marker the world's `vertex` file is spliced at.
18pub const VERTEX_MARKER: &str = "{SURFACE_VERTEX}";
19/// The marker the world's `fragment` file is spliced at.
20pub const FRAGMENT_MARKER: &str = "{SURFACE_FRAGMENT}";
21
22/// Whether an entry runs at the vertex or the fragment stage.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Stage {
25    /// Places a vertex; carries the world's `transform`.
26    Vertex,
27    /// Shades a surface; carries the world's `shade`.
28    Fragment,
29}
30
31/// One entry point of one main-pass file.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct Program {
34    /// The shader file the entry compiles from.
35    pub file: &'static str,
36    /// Entry point name, as the source spells it.
37    pub entry: &'static str,
38    /// Which stage it compiles for.
39    pub stage: Stage,
40}
41
42/// Every entry a world Shader compiles: the bindless pair, on every host.
43pub const ALL: &[Program] = &[
44    Program {
45        file: "main_bindless.slang",
46        entry: "vertex_main_bindless",
47        stage: Stage::Vertex,
48    },
49    Program {
50        file: "main_bindless.slang",
51        entry: "fragment_main_bindless",
52        stage: Stage::Fragment,
53    },
54];
55
56/// The entry named `entry`.
57pub fn program(entry: &str) -> Option<&'static Program> {
58    ALL.iter().find(|p| p.entry == entry)
59}
60
61/// The entries a host compiles for a world Shader.
62pub fn programs(_platform: Platform) -> impl Iterator<Item = &'static Program> {
63    ALL.iter()
64}
65
66/// How a host's entries group into artifacts. Metal takes the pair as one
67/// library: slangc emits one MSL translation unit and the runtime wants one
68/// library to pull both functions out of. The other two hosts take one
69/// artifact per entry.
70pub fn groups(platform: Platform) -> Vec<Vec<&'static Program>> {
71    if platform == Platform::Metal {
72        return alloc::vec![ALL.iter().collect()];
73    }
74    ALL.iter().map(|p| alloc::vec![p]).collect()
75}
76
77/// The world's two files, as text.
78#[derive(Debug, Clone, Copy)]
79pub struct Sources<'a> {
80    /// The `vertex` file, when declared.
81    pub vertex: Option<&'a str>,
82    /// The `fragment` file.
83    pub fragment: &'a str,
84}
85
86impl<'a> Sources<'a> {
87    /// The splices that put the declared files in place of the engine's
88    /// default hooks. An undeclared vertex file leaves the default.
89    pub fn splices(&self) -> Vec<(&'static str, &'a str)> {
90        let mut out = Vec::with_capacity(2);
91        if let Some(v) = self.vertex {
92            out.push((VERTEX_MARKER, v));
93        }
94        out.push((FRAGMENT_MARKER, self.fragment));
95        out
96    }
97}
98
99/// The variant defines for one entry on one host. `pool_size` and
100/// `probe_count` are the bindless texture-pool length and the probe cube
101/// array length the Vulkan host declares; the cook bakes the ceilings and a
102/// device that cannot seat them recompiles, exactly as the engine's own
103/// bindless programs do. Metal and DirectX bind fixed counts.
104pub fn defines(
105    _program: &Program,
106    platform: Platform,
107    pool_size: usize,
108    probe_count: usize,
109) -> Vec<(&'static str, String)> {
110    let probes = ("MAX_PROBES", MAX_PROBES.to_string());
111    match platform {
112        Platform::Metal => alloc::vec![
113            ("METAL_ABI", "1".into()),
114            ("POOL_SIZE", BINDLESS_POOL_SIZE.to_string()),
115            probes,
116        ],
117        Platform::Hlsl => alloc::vec![("DXIL_ABI", "1".into()), probes],
118        Platform::Glsl => alloc::vec![
119            ("POOL_SIZE", pool_size.to_string()),
120            ("MAX_PROBES", probe_count.to_string()),
121        ],
122    }
123}
124
125/// The exact source text one entry compiles for one host with the world's
126/// files spliced in. `resolve` lets a hot-reload build prefer the checkout's
127/// copy of the templates over the embedded ones.
128pub fn source_with(
129    program: &Program,
130    platform: Platform,
131    pool_size: usize,
132    probe_count: usize,
133    sources: &Sources<'_>,
134    resolve: impl Fn(&str) -> Option<&'static str>,
135) -> String {
136    let defines = defines(program, platform, pool_size, probe_count);
137    let defines: Vec<(&str, &str)> = defines.iter().map(|(k, v)| (*k, v.as_str())).collect();
138    slang_source::assemble_with_splices(program.file, &defines, resolve, &sources.splices())
139}
140
141/// The same source from the embedded templates alone.
142pub fn source(
143    program: &Program,
144    platform: Platform,
145    pool_size: usize,
146    probe_count: usize,
147    sources: &Sources<'_>,
148) -> String {
149    source_with(
150        program,
151        platform,
152        pool_size,
153        probe_count,
154        sources,
155        crate::render::shaders::embedded,
156    )
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    const SHADE: &str =
164        "float4 shade(VertexOut in, GpuObjectData od) { return float4(1.0, 0.0, 1.0, 1.0); }";
165
166    fn fragment_only() -> Sources<'static> {
167        Sources {
168            vertex: None,
169            fragment: SHADE,
170        }
171    }
172
173    // Every host compiles each entry it names exactly once, whichever way it
174    // groups them; a dropped entry is a pipeline that cannot be built at load.
175    #[test]
176    fn a_grouping_covers_every_entry_of_the_host_once() {
177        for platform in [Platform::Metal, Platform::Hlsl, Platform::Glsl] {
178            let mut grouped: Vec<&str> =
179                groups(platform).iter().flatten().map(|p| p.entry).collect();
180            grouped.sort_unstable();
181            let mut listed: Vec<&str> = programs(platform).map(|p| p.entry).collect();
182            listed.sort_unstable();
183            assert_eq!(grouped, listed, "{platform:?}");
184        }
185    }
186
187    // The bindless pair is the whole table on every host; Metal takes it as one
188    // library and the other two one artifact per entry.
189    #[test]
190    fn the_pair_is_the_whole_table_and_metal_groups_it() {
191        for platform in [Platform::Metal, Platform::Hlsl, Platform::Glsl] {
192            let entries: Vec<&str> = programs(platform).map(|p| p.entry).collect();
193            assert_eq!(
194                entries,
195                ["vertex_main_bindless", "fragment_main_bindless"],
196                "{platform:?}"
197            );
198        }
199        let metal = groups(Platform::Metal);
200        assert_eq!(metal.len(), 1);
201        assert_eq!(metal[0].len(), 2);
202        for platform in [Platform::Hlsl, Platform::Glsl] {
203            assert_eq!(groups(platform).len(), 2, "{platform:?}");
204            assert!(
205                groups(platform).iter().all(|g| g.len() == 1),
206                "{platform:?}"
207            );
208        }
209    }
210
211    #[test]
212    fn every_entry_is_found_by_name_and_names_are_unique() {
213        for p in ALL {
214            assert_eq!(program(p.entry).map(|q| q.entry), Some(p.entry));
215        }
216        assert!(program("no_such_entry").is_none());
217        let mut names: Vec<&str> = ALL.iter().map(|p| p.entry).collect();
218        names.sort_unstable();
219        names.dedup();
220        assert_eq!(names.len(), ALL.len());
221    }
222
223    // The fragment file replaces the engine's default `shade`; an undeclared
224    // vertex file leaves the default `transform` in place.
225    #[test]
226    fn the_world_fragment_replaces_the_default_and_the_vertex_default_stays() {
227        let frag = program("fragment_main_bindless").unwrap();
228        let src = source(
229            frag,
230            Platform::Metal,
231            BINDLESS_POOL_SIZE,
232            MAX_PROBES,
233            &fragment_only(),
234        );
235        assert!(src.contains(SHADE));
236        assert!(
237            !src.contains("return shade_surface(in, od);"),
238            "default shade replaced"
239        );
240        assert!(!src.contains(FRAGMENT_MARKER) && !src.contains(VERTEX_MARKER));
241        assert!(src.contains("return project_vertex(model, pos, normal, tangent, color, uv);"));
242
243        let both = Sources {
244            vertex: Some(
245                "VertexOut transform(float4x4 m, float3 p, float3 n, float3 t, float3 c, float2 uv) { return project_vertex(m, p, n, t, c, uv); }",
246            ),
247            fragment: SHADE,
248        };
249        let src = source(frag, Platform::Metal, BINDLESS_POOL_SIZE, MAX_PROBES, &both);
250        assert!(src.contains("VertexOut transform(float4x4 m,"));
251        assert!(!src.contains("return project_vertex(model, pos, normal, tangent, color, uv);"));
252    }
253
254    // The bindless file compiles both stages from one variant, so both hooks
255    // land in it and both stages assemble to identical text.
256    #[test]
257    fn the_pair_assembles_to_one_text() {
258        let vert = program("vertex_main_bindless").unwrap();
259        let frag = program("fragment_main_bindless").unwrap();
260        let a = source(
261            vert,
262            Platform::Metal,
263            BINDLESS_POOL_SIZE,
264            MAX_PROBES,
265            &fragment_only(),
266        );
267        let b = source(
268            frag,
269            Platform::Metal,
270            BINDLESS_POOL_SIZE,
271            MAX_PROBES,
272            &fragment_only(),
273        );
274        assert_eq!(a, b);
275        assert!(a.contains(SHADE));
276        assert!(a.contains("#define METAL_ABI 1"));
277    }
278
279    // Each host's defines are the ones its own program table bakes, and the
280    // Vulkan pool size is whatever the caller declares.
281    #[test]
282    fn defines_follow_the_host() {
283        let frag = program("fragment_main_bindless").unwrap();
284        let names =
285            |platform, pool| -> Vec<(&str, String)> { defines(frag, platform, pool, MAX_PROBES) };
286        assert_eq!(
287            names(Platform::Metal, 0),
288            [
289                ("METAL_ABI", "1".to_string()),
290                ("POOL_SIZE", "1024".to_string()),
291                ("MAX_PROBES", "8".to_string())
292            ]
293        );
294        assert_eq!(
295            names(Platform::Hlsl, 0),
296            [
297                ("DXIL_ABI", "1".to_string()),
298                ("MAX_PROBES", "8".to_string())
299            ]
300        );
301        assert_eq!(
302            names(Platform::Glsl, 37),
303            [
304                ("POOL_SIZE", "37".to_string()),
305                ("MAX_PROBES", "8".to_string())
306            ]
307        );
308        // Only the Vulkan host reads the probe count the device seats.
309        assert_eq!(
310            defines(frag, Platform::Glsl, 37, 4),
311            [
312                ("POOL_SIZE", "37".to_string()),
313                ("MAX_PROBES", "4".to_string())
314            ]
315        );
316        assert_eq!(
317            defines(frag, Platform::Metal, 0, 4),
318            defines(frag, Platform::Metal, 0, MAX_PROBES)
319        );
320    }
321}