frust_gpu/shader.rs
1//! The WGSL shader module registry: [`ShaderLibrary`] and [`ShaderId`].
2//!
3//! Every shader the engine ever draws with is registered **once**, at
4//! library-build time, and referred to afterwards by an opaque [`ShaderId`].
5//! That indirection is what lets [`crate::pipeline::RenderPipelineDesc`] stay
6//! a cheap, hashable value: a pipeline variant names its program by id instead
7//! of carrying a module handle or a source string around.
8//!
9//! ## Registered at build time, created on first use
10//!
11//! Registering a shader ([`ShaderLibrary::register_wgsl`]) stores its name and
12//! its WGSL source and mints the id; it does not touch the device. The
13//! `wgpu::ShaderModule` is created on the first request for that id
14//! ([`ShaderLibrary::module`]) by whichever thread asks — the pipeline warm-up
15//! worker for a listed variant, the render thread for one it steals, or a
16//! thread started only to create modules ahead of a frame
17//! ([`crate::pipeline::PipelineCache::warm_up_shaders`]) — and exactly once:
18//! each entry holds its module in a once-cell, so a second thread asking for a
19//! module that is mid-creation blocks until the first one publishes it rather
20//! than creating a second copy. The source is dropped once its module exists.
21//!
22//! The deferral is the point: a module's creation is wgpu parsing and
23//! validating the whole WGSL text (naga), which costs milliseconds per module
24//! on a phone and grows with the source's length, so creating every module of
25//! a large program family at start-up put all of it in front of the first
26//! frame. [`ShaderLibrary::insert_wgsl`] is the eager form — register and
27//! create on the calling thread at once — and [`ShaderLibrary::compile_all`]
28//! creates every module still missing, for a caller that wants the old
29//! behaviour.
30//!
31//! The library is append-only and keyed by name, so building it is a
32//! start-up-time act and everything after it is a lookup. Once built it is
33//! wrapped in an `Arc` and shared — a `wgpu::Device` and a
34//! `wgpu::ShaderModule` are `Send + Sync`, so the pipeline warm-up worker
35//! compiles against exactly the same modules the render thread does, with no
36//! second compile of the source.
37//!
38//! Every module is created with a label (`frust-gpu shader: <name>`) so a
39//! capture in RenderDoc/Xcode names the shader rather than showing an
40//! anonymous module.
41
42use std::borrow::Cow;
43use std::collections::HashMap;
44use std::sync::{Mutex, OnceLock, PoisonError};
45
46/// An opaque handle to a WGSL module held by one [`ShaderLibrary`].
47///
48/// Only [`ShaderLibrary::register_wgsl`] (and its eager form,
49/// [`ShaderLibrary::insert_wgsl`]) mints one, and an id is only meaningful
50/// against the library that minted it — [`ShaderLibrary::module`] on a foreign
51/// id returns `None` rather than a wrong module, but the pipeline layer treats
52/// that as a caller bug (see [`crate::pipeline::PipelineCache::get_or_create`]).
53#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
54pub struct ShaderId(u32);
55
56impl ShaderId {
57 /// The library-local index this id addresses — the value the pipeline
58 /// key packs into its program field.
59 pub(crate) const fn raw(self) -> u32 {
60 self.0
61 }
62
63 /// Rebuilds an id from a raw index, for the pipeline layer's key packing
64 /// and for device-free tests that need an id without a library.
65 pub(crate) const fn from_raw(index: u32) -> Self {
66 Self(index)
67 }
68}
69
70/// One entry: the name it was registered under, its source until the module
71/// exists, and the module once some thread has asked for it.
72struct Entry {
73 name: String,
74 /// The WGSL text, taken (and dropped) by the creation that succeeds. Only
75 /// the thread running the once-cell's initializer ever locks it.
76 source: Mutex<Option<Cow<'static, str>>>,
77 module: OnceLock<wgpu::ShaderModule>,
78}
79
80impl std::fmt::Debug for Entry {
81 // The source is tens of kilobytes of WGSL for the engine's strip
82 // programs; a debug print names the entry and its state instead.
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.debug_struct("Entry")
85 .field("name", &self.name)
86 .field("created", &self.module.get().is_some())
87 .finish()
88 }
89}
90
91impl Entry {
92 /// The module, created now by the calling thread if no thread has created
93 /// it yet, or waited for if another thread is creating it right now.
94 fn module(&self, device: &wgpu::Device) -> &wgpu::ShaderModule {
95 self.module.get_or_init(|| {
96 let mut source = self.source.lock().unwrap_or_else(PoisonError::into_inner);
97 let module = {
98 let wgsl: &str = source.as_deref().unwrap_or_else(|| {
99 // Unreachable: the source is taken only after a creation
100 // returned, and the once-cell then holds that module. An
101 // empty module fails validation through the device's
102 // error handler, like any malformed one.
103 log::error!(
104 "frust-gpu: shader `{}` has no source left to create its module from",
105 self.name
106 );
107 ""
108 });
109 device.create_shader_module(wgpu::ShaderModuleDescriptor {
110 label: Some(&format!("frust-gpu shader: {}", self.name)),
111 source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(wgsl)),
112 })
113 };
114 // Taken only once the creation has returned: a creation that
115 // unwinds (wgpu's default error handler panics on a malformed
116 // module) leaves the source for the next request to retry with.
117 *source = None;
118 module
119 })
120 }
121}
122
123/// A name-keyed, append-only set of WGSL modules, each created on first use.
124///
125/// Build it at start-up, then share it (`Arc<ShaderLibrary>`) with the
126/// pipeline cache and its warm-up worker. Registration is idempotent per name:
127/// re-registering a name that is already present returns the existing id and
128/// keeps the first source, so a caller that cannot easily prove it only
129/// registers once still pays for one module.
130#[derive(Debug, Default)]
131pub struct ShaderLibrary {
132 entries: Vec<Entry>,
133 by_name: HashMap<String, ShaderId>,
134}
135
136impl ShaderLibrary {
137 /// An empty library.
138 #[must_use]
139 pub fn new() -> Self {
140 Self::default()
141 }
142
143 /// Registers `src` as WGSL under `name` and returns its id, without
144 /// creating its module: that happens on the first [`Self::module`]
145 /// request for the id, on whichever thread makes it.
146 ///
147 /// If `name` is already registered the existing id is returned and `src`
148 /// is dropped — the library is append-only, so a name never changes
149 /// meaning once it is in.
150 pub fn register_wgsl(
151 &mut self,
152 name: impl Into<String>,
153 src: impl Into<Cow<'static, str>>,
154 ) -> ShaderId {
155 let name = name.into();
156 if let Some(existing) = self.by_name.get(&name) {
157 return *existing;
158 }
159 let id = ShaderId::from_raw(self.entries.len() as u32);
160 self.entries.push(Entry {
161 name: name.clone(),
162 source: Mutex::new(Some(src.into())),
163 module: OnceLock::new(),
164 });
165 self.by_name.insert(name, id);
166 id
167 }
168
169 /// Registers `src` under `name` ([`Self::register_wgsl`]) and creates its
170 /// module on the calling thread at once — the eager form, for a caller
171 /// that wants the module to exist before any pipeline asks for it.
172 ///
173 /// The module is labelled with `name`. If `name` is already registered the
174 /// existing id is returned, `src` is not used, and the registered source's
175 /// module is created if it does not exist yet.
176 ///
177 /// Compilation errors are not raised here: wgpu reports a malformed module
178 /// through the device's error scope / uncaptured-error handler, and the
179 /// resulting pipeline creation fails there. This mirrors `frust-render`'s
180 /// shader-effect path, which records the failure rather than panicking.
181 pub fn insert_wgsl(
182 &mut self,
183 device: &wgpu::Device,
184 name: impl Into<String>,
185 src: &str,
186 ) -> ShaderId {
187 let id = self.register_wgsl(name, src.to_owned());
188 let _module = self.module(device, id);
189 id
190 }
191
192 /// The module `id` addresses, creating it now if no thread has yet —
193 /// or waiting for the thread creating it right now — or `None` if `id`
194 /// was minted by a different library.
195 ///
196 /// Each module is created at most once however many threads ask at the
197 /// same time. Errors surface as for [`Self::insert_wgsl`], on the thread
198 /// that creates the module.
199 #[must_use]
200 pub fn module(&self, device: &wgpu::Device, id: ShaderId) -> Option<&wgpu::ShaderModule> {
201 self.entries
202 .get(id.raw() as usize)
203 .map(|entry| entry.module(device))
204 }
205
206 /// Creates every registered module that does not exist yet, on the
207 /// calling thread — the eager start-up behaviour, for tests and for a
208 /// caller that wants every module in place before its first frame.
209 pub fn compile_all(&self, device: &wgpu::Device) {
210 for entry in &self.entries {
211 let _module = entry.module(device);
212 }
213 }
214
215 /// The module `id` addresses if it has already been created, or `None` —
216 /// either because no thread has asked for it yet (see [`Self::module`]),
217 /// or because `id` was minted by a different library.
218 #[must_use]
219 pub fn get(&self, id: ShaderId) -> Option<&wgpu::ShaderModule> {
220 self.entries
221 .get(id.raw() as usize)
222 .and_then(|entry| entry.module.get())
223 }
224
225 /// Whether `id`'s module has been created (`false` for a foreign id).
226 #[must_use]
227 pub fn is_compiled(&self, id: ShaderId) -> bool {
228 self.get(id).is_some()
229 }
230
231 /// The id registered under `name`, if any.
232 #[must_use]
233 pub fn id_of(&self, name: &str) -> Option<ShaderId> {
234 self.by_name.get(name).copied()
235 }
236
237 /// The name `id` was registered under — used for pipeline labels.
238 #[must_use]
239 pub fn name_of(&self, id: ShaderId) -> Option<&str> {
240 self.entries.get(id.raw() as usize).map(|e| e.name.as_str())
241 }
242
243 /// How many distinct modules are registered, created or not.
244 #[must_use]
245 pub fn len(&self) -> usize {
246 self.entries.len()
247 }
248
249 /// How many of the registered modules have been created so far.
250 #[must_use]
251 pub fn compiled_len(&self) -> usize {
252 self.entries
253 .iter()
254 .filter(|entry| entry.module.get().is_some())
255 .count()
256 }
257
258 /// Whether no module is registered yet.
259 #[must_use]
260 pub fn is_empty(&self) -> bool {
261 self.entries.is_empty()
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn an_empty_library_resolves_nothing() {
271 let lib = ShaderLibrary::new();
272 assert!(lib.is_empty());
273 assert_eq!(lib.len(), 0);
274 assert_eq!(lib.compiled_len(), 0);
275 assert!(lib.get(ShaderId::from_raw(0)).is_none());
276 assert!(!lib.is_compiled(ShaderId::from_raw(0)));
277 assert!(lib.name_of(ShaderId::from_raw(0)).is_none());
278 assert!(lib.id_of("missing").is_none());
279 }
280
281 #[test]
282 fn ids_round_trip_through_their_raw_index() {
283 for index in [0u32, 1, 7, u32::MAX] {
284 assert_eq!(ShaderId::from_raw(index).raw(), index);
285 }
286 assert_ne!(ShaderId::from_raw(0), ShaderId::from_raw(1));
287 }
288
289 /// Registration needs no device and creates no module: the ids, names and
290 /// count are all there, and nothing is compiled until a module is asked
291 /// for. A repeated name keeps its first id and its first source.
292 #[test]
293 fn registering_mints_ids_without_creating_a_module() {
294 let mut lib = ShaderLibrary::new();
295 let a = lib.register_wgsl("a", "@fragment fn fs_main() {}");
296 let b = lib.register_wgsl("b", String::from("@fragment fn fs_main() {}"));
297 assert_ne!(a, b);
298 assert_eq!(lib.register_wgsl("a", "something else"), a);
299 assert_eq!(lib.len(), 2);
300 assert!(!lib.is_empty());
301 assert_eq!(lib.compiled_len(), 0, "registration created a module");
302 assert!(lib.get(a).is_none());
303 assert!(!lib.is_compiled(b));
304 assert_eq!(lib.id_of("b"), Some(b));
305 assert_eq!(lib.name_of(a), Some("a"));
306 assert!(
307 format!("{lib:?}").contains("created: false"),
308 "a debug print names each entry's state, not its source"
309 );
310 }
311}