frust_engine/gpu/shader_src.rs
1//! The engine's WGSL sources, assembled at compile time.
2//!
3//! Every shader lives in `crates/frust-engine/shaders/` as its own `.wgsl`
4//! file and reaches Rust through `include_str!` — never as an inline string
5//! literal — so the downlevel lint that scans that directory
6//! (`frust_gpu::lint::lint_wgsl_dir`, driven by `frust-gpu`'s
7//! `downlevel_rules` test) sees every line the GPU ever compiles.
8//!
9//! ## The helper prelude
10//!
11//! The sources are ported from a WESL reference whose entry-point modules
12//! pull shared code in with `import package::helpers::...`. WESL's own
13//! resolver is not available here — its crate family needs a newer toolchain
14//! than this workspace's MSRV — so the import graph is resolved two ways:
15//! within [`HELPERS`] by hand-inlining, and between an entry-point module and
16//! its helpers by `concat!`, exactly the shape `frust-render`'s shader-effect
17//! `VERTEX_PRELUDE` uses.
18//!
19//! Prepending the whole helper file to every entry-point module is safe
20//! because [`HELPERS`] declares no `@group`/`@binding` global: a helper that
21//! reads a texture takes it as a parameter. An entry-point module's derived
22//! bind-group layout is therefore exactly what its own bindings say, with or
23//! without the prelude. [`FILTER_KERNELS`] is a second prelude on the same
24//! terms, prepended to [`FILTER`] alone.
25//!
26//! Every module the engine compiles is assembled here and nowhere else, so
27//! [`MODULES`] is the whole list — the one
28//! [`crate::gpu::pipelines::EngineShaders::register`] walks, and the one the
29//! validation tests below walk.
30
31/// Binding-free helper functions shared by every entry-point module:
32/// packing, quad geometry, extend modes, encoded-paint accessors, gradient
33/// and blur evaluation, and atlas/external image sampling.
34///
35/// Not a shader in its own right — it has no entry point. It is prepended to
36/// each of [`STRIP`], [`CLEAR`] and [`COPY`].
37pub const HELPERS: &str = include_str!("../../shaders/helpers.wgsl");
38
39/// The sparse-strip rasterizer: `vs_main` + `fs_main`, four pipeline variants
40/// (see [`crate::gpu::pipelines`]).
41pub const STRIP: &str = concat!(
42 include_str!("../../shaders/helpers.wgsl"),
43 include_str!("../../shaders/strip.wgsl")
44);
45
46/// Region and fullscreen clears of an intermediate texture: `vs_main` /
47/// `vs_main_fullscreen` + `fs_main`.
48pub const CLEAR: &str = concat!(
49 include_str!("../../shaders/helpers.wgsl"),
50 include_str!("../../shaders/clear.wgsl")
51);
52
53/// Rectangular region copies between intermediate textures: `vs_main` +
54/// `fs_main`.
55pub const COPY: &str = concat!(
56 include_str!("../../shaders/helpers.wgsl"),
57 include_str!("../../shaders/copy.wgsl")
58);
59
60/// The Gaussian-blur kernels [`FILTER`]'s fragment stage dispatches into: the
61/// two rescaling steps a decimated blur is built from, and the separable
62/// convolution run between them.
63///
64/// A second prelude rather than a module of its own: like [`HELPERS`] it
65/// declares no entry point and no `@group`/`@binding` global — a kernel that
66/// reads a texture takes the texture and the sampler as parameters — so
67/// prepending it cannot disturb [`FILTER`]'s derived bind-group layout.
68pub const FILTER_KERNELS: &str = include_str!("../../shaders/filters_blur.wgsl");
69
70/// The drop-shadow passes [`FILTER`]'s fragment stage dispatches into beyond
71/// what [`FILTER_KERNELS`] already covers: the shadow's own device-space
72/// shift, and the recolour from a blurred alpha mask into the shadow's
73/// premultiplied colour.
74///
75/// A third prelude on the same terms as [`FILTER_KERNELS`]: no entry point, no
76/// `@group`/`@binding` global, so prepending it cannot disturb [`FILTER`]'s
77/// derived bind-group layout either. The layouts and constants it reads are
78/// [`crate::filters::drop_shadow`]'s; that module's own tests pin the two
79/// sides against each other.
80pub const DROP_SHADOW_KERNELS: &str = include_str!("../../shaders/filters_drop_shadow.wgsl");
81
82/// One filter pass over one destination page: `vs_main` + `fs_main`, one
83/// pipeline (see [`crate::gpu::pipelines::EnginePipeline::Filter`]).
84///
85/// The only module assembled from more than one prelude: the binding-free
86/// helpers every module gets, then [`FILTER_KERNELS`], then
87/// [`DROP_SHADOW_KERNELS`], then the entry-point module that declares the
88/// bindings and dispatches on the pass kind. The layouts and constants it
89/// reads are [`crate::filters::blur`]'s and [`crate::filters::drop_shadow`]'s;
90/// those modules' own tests pin all three sides against each other.
91pub const FILTER: &str = concat!(
92 include_str!("../../shaders/helpers.wgsl"),
93 include_str!("../../shaders/filters_blur.wgsl"),
94 include_str!("../../shaders/filters_drop_shadow.wgsl"),
95 include_str!("../../shaders/filter.wgsl")
96);
97
98/// The name [`STRIP`] is registered under in the shader library.
99pub const STRIP_NAME: &str = "frust-engine strip";
100
101/// The name [`CLEAR`] is registered under in the shader library.
102pub const CLEAR_NAME: &str = "frust-engine clear";
103
104/// The name [`COPY`] is registered under in the shader library.
105pub const COPY_NAME: &str = "frust-engine copy";
106
107/// The name [`FILTER`] is registered under in the shader library.
108pub const FILTER_NAME: &str = "frust-engine filter";
109
110/// Every module the engine compiles, as `(name, source)` pairs — the list
111/// [`crate::gpu::pipelines::EngineShaders::register`] walks, and the list the
112/// validation tests walk.
113pub const MODULES: [(&str, &str); 4] = [
114 (STRIP_NAME, STRIP),
115 (CLEAR_NAME, CLEAR),
116 (COPY_NAME, COPY),
117 (FILTER_NAME, FILTER),
118];
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use crate::gpu::{GpuConfig, GpuStrip};
124 use wgpu::naga;
125
126 /// Parses and validates `src` the way a `wgpu::Device` would, returning
127 /// the validated module.
128 ///
129 /// `wgpu` re-exports the same `naga` its own WGSL front end uses, so this
130 /// is the device's validation path minus the device: no GPU, no adapter,
131 /// and a failure surfaces here as a test failure instead of as a
132 /// pipeline-creation error at run time. The device-backed counterpart
133 /// (`gpu::pipelines`' real-pipeline test) additionally proves the
134 /// *pipelines* are accepted, but it needs hardware and is `#[ignore]`d.
135 fn validate(name: &str, src: &str) -> naga::Module {
136 let module = naga::front::wgsl::parse_str(src)
137 .unwrap_or_else(|err| panic!("{name} failed to parse: {err:?}"));
138 naga::valid::Validator::new(
139 naga::valid::ValidationFlags::all(),
140 naga::valid::Capabilities::empty(),
141 )
142 .validate(&module)
143 .unwrap_or_else(|err| panic!("{name} failed validation: {err:?}"));
144 module
145 }
146
147 /// The byte size naga computed for the struct named `name`.
148 fn struct_span(module: &naga::Module, name: &str) -> u32 {
149 module
150 .types
151 .iter()
152 .find_map(|(_, ty)| match (&ty.name, &ty.inner) {
153 (Some(ty_name), naga::TypeInner::Struct { span, .. }) if ty_name == name => {
154 Some(*span)
155 }
156 _ => None,
157 })
158 .unwrap_or_else(|| panic!("the module declares no struct named {name}"))
159 }
160
161 #[test]
162 fn every_module_parses_and_validates() {
163 for (name, src) in MODULES {
164 validate(name, src);
165 }
166 }
167
168 #[test]
169 fn the_helper_prelude_validates_on_its_own() {
170 // It has no entry point, so nothing else would catch a syntax error
171 // in a helper that no current entry point happens to call.
172 validate("helpers", HELPERS);
173 }
174
175 #[test]
176 fn every_module_is_the_helper_prelude_followed_by_its_own_source() {
177 for (name, src) in MODULES {
178 assert!(
179 src.starts_with(HELPERS),
180 "{name} must be assembled as the helper prelude followed by its own source"
181 );
182 assert!(
183 src.len() > HELPERS.len(),
184 "{name} must contribute source of its own"
185 );
186 }
187 }
188
189 #[test]
190 fn the_helper_prelude_declares_no_bindings() {
191 // The whole reason every entry-point module can be prefixed with it
192 // without disturbing its derived bind-group layout. Comment lines are
193 // skipped: this file's own header describes the rule. The blur
194 // kernels are held to the same rule for the same reason — they are a
195 // prelude too, just one only the filter module gets.
196 for (name, prelude) in [
197 ("helpers", HELPERS),
198 ("filter kernels", FILTER_KERNELS),
199 ("drop shadow kernels", DROP_SHADOW_KERNELS),
200 ] {
201 let declaration = prelude
202 .lines()
203 .find(|line| !line.trim_start().starts_with("//") && line.contains("@group"));
204 assert!(
205 declaration.is_none(),
206 "a prelude that declared a binding would change the bind-group layout of every \
207 module it is prepended to ({name}): {declaration:?}"
208 );
209 }
210 }
211
212 #[test]
213 fn the_filter_module_is_every_prelude_followed_by_its_own_source() {
214 // `every_module_is_the_helper_prelude_followed_by_its_own_source`
215 // above only pins the first prelude; the filter program is the one
216 // module assembled from three, and each has to sit between the helpers
217 // it calls and the entry point that calls it.
218 assert!(FILTER.starts_with(HELPERS));
219 assert!(FILTER[HELPERS.len()..].starts_with(FILTER_KERNELS));
220 assert!(FILTER[HELPERS.len() + FILTER_KERNELS.len()..].starts_with(DROP_SHADOW_KERNELS));
221 assert!(FILTER.len() > HELPERS.len() + FILTER_KERNELS.len() + DROP_SHADOW_KERNELS.len());
222 }
223
224 #[test]
225 fn the_filter_instance_matches_its_rust_layout() {
226 let module = validate(FILTER_NAME, FILTER);
227 assert_eq!(
228 struct_span(&module, "FilterInstanceData") as usize,
229 size_of::<crate::filters::blur::FilterInstanceData>(),
230 "the shader's `FilterInstanceData` must stay byte-identical to the Rust one"
231 );
232 }
233
234 #[test]
235 fn the_strip_config_uniform_matches_its_rust_layout() {
236 let module = validate(STRIP_NAME, STRIP);
237 assert_eq!(
238 u64::from(struct_span(&module, "Config")),
239 GpuConfig::SIZE,
240 "the shader's `Config` must stay byte-identical to `GpuConfig`"
241 );
242 }
243
244 #[test]
245 fn the_strip_instance_matches_its_rust_layout() {
246 let module = validate(STRIP_NAME, STRIP);
247 assert_eq!(
248 struct_span(&module, "StripInstance") as usize,
249 size_of::<GpuStrip>(),
250 "the shader's `StripInstance` must stay byte-identical to `GpuStrip`"
251 );
252 }
253}