frust_gpu/shader.rs
1//! The WGSL shader module registry: [`ShaderLibrary`] and [`ShaderId`].
2//!
3//! Every shader the engine ever draws with is compiled into a
4//! `wgpu::ShaderModule` **once**, at library-build time, and referred to
5//! afterwards by an opaque [`ShaderId`]. That indirection is what lets
6//! [`crate::pipeline::RenderPipelineDesc`] stay a cheap, hashable value: a
7//! pipeline variant names its program by id instead of carrying a module
8//! handle or a source string around.
9//!
10//! The library is append-only and keyed by name, so building it is a
11//! start-up-time act (`insert_wgsl` per shader) and everything after it is a
12//! lookup. Once built it is wrapped in an `Arc` and shared — a
13//! `wgpu::ShaderModule` is `Send + Sync`, so the pipeline warm-up worker
14//! compiles against exactly the same modules the render thread does, with no
15//! second compile of the source.
16//!
17//! Every module is created with a label (`frust-gpu shader: <name>`) so a
18//! capture in RenderDoc/Xcode names the shader rather than showing an
19//! anonymous module.
20
21use std::collections::HashMap;
22
23/// An opaque handle to a WGSL module held by one [`ShaderLibrary`].
24///
25/// Only [`ShaderLibrary::insert_wgsl`] mints one, and an id is only meaningful
26/// against the library that minted it — [`ShaderLibrary::get`] on a foreign id
27/// returns `None` rather than a wrong module, but the pipeline layer treats
28/// that as a caller bug (see [`crate::pipeline::PipelineCache::get_or_create`]).
29#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub struct ShaderId(u32);
31
32impl ShaderId {
33 /// The library-local index this id addresses — the value the pipeline
34 /// key packs into its program field.
35 pub(crate) const fn raw(self) -> u32 {
36 self.0
37 }
38
39 /// Rebuilds an id from a raw index, for the pipeline layer's key packing
40 /// and for device-free tests that need an id without a library.
41 pub(crate) const fn from_raw(index: u32) -> Self {
42 Self(index)
43 }
44}
45
46/// One entry: the compiled module plus the name it was registered under.
47#[derive(Debug)]
48struct Entry {
49 name: String,
50 module: wgpu::ShaderModule,
51}
52
53/// A name-keyed, append-only set of compiled WGSL modules.
54///
55/// Build it at start-up, then share it (`Arc<ShaderLibrary>`) with the
56/// pipeline cache and its warm-up worker. Insertion is idempotent per name:
57/// re-inserting a name that is already present returns the existing id
58/// without recompiling, so a caller that cannot easily prove it only
59/// registers once still pays for one compile.
60#[derive(Debug, Default)]
61pub struct ShaderLibrary {
62 entries: Vec<Entry>,
63 by_name: HashMap<String, ShaderId>,
64}
65
66impl ShaderLibrary {
67 /// An empty library.
68 #[must_use]
69 pub fn new() -> Self {
70 Self::default()
71 }
72
73 /// Compiles `src` as WGSL and registers it under `name`, returning its id.
74 ///
75 /// The module is labelled with `name`. If `name` is already registered the
76 /// existing id is returned and `src` is *not* compiled again — the library
77 /// is append-only, so a name never changes meaning once it is in.
78 ///
79 /// Compilation errors are not raised here: wgpu reports a malformed module
80 /// through the device's error scope / uncaptured-error handler, and the
81 /// resulting pipeline creation fails there. This mirrors `frust-render`'s
82 /// shader-effect path, which records the failure rather than panicking.
83 pub fn insert_wgsl(
84 &mut self,
85 device: &wgpu::Device,
86 name: impl Into<String>,
87 src: &str,
88 ) -> ShaderId {
89 let name = name.into();
90 if let Some(existing) = self.by_name.get(&name) {
91 return *existing;
92 }
93
94 let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
95 label: Some(&format!("frust-gpu shader: {name}")),
96 source: wgpu::ShaderSource::Wgsl(src.into()),
97 });
98
99 let id = ShaderId::from_raw(self.entries.len() as u32);
100 self.entries.push(Entry {
101 name: name.clone(),
102 module,
103 });
104 self.by_name.insert(name, id);
105 id
106 }
107
108 /// The module `id` addresses, or `None` if it was minted by a different
109 /// library.
110 #[must_use]
111 pub fn get(&self, id: ShaderId) -> Option<&wgpu::ShaderModule> {
112 self.entries.get(id.raw() as usize).map(|e| &e.module)
113 }
114
115 /// The id registered under `name`, if any.
116 #[must_use]
117 pub fn id_of(&self, name: &str) -> Option<ShaderId> {
118 self.by_name.get(name).copied()
119 }
120
121 /// The name `id` was registered under — used for pipeline labels.
122 #[must_use]
123 pub fn name_of(&self, id: ShaderId) -> Option<&str> {
124 self.entries.get(id.raw() as usize).map(|e| e.name.as_str())
125 }
126
127 /// How many distinct modules are registered.
128 #[must_use]
129 pub fn len(&self) -> usize {
130 self.entries.len()
131 }
132
133 /// Whether no module is registered yet.
134 #[must_use]
135 pub fn is_empty(&self) -> bool {
136 self.entries.is_empty()
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn an_empty_library_resolves_nothing() {
146 let lib = ShaderLibrary::new();
147 assert!(lib.is_empty());
148 assert_eq!(lib.len(), 0);
149 assert!(lib.get(ShaderId::from_raw(0)).is_none());
150 assert!(lib.name_of(ShaderId::from_raw(0)).is_none());
151 assert!(lib.id_of("missing").is_none());
152 }
153
154 #[test]
155 fn ids_round_trip_through_their_raw_index() {
156 for index in [0u32, 1, 7, u32::MAX] {
157 assert_eq!(ShaderId::from_raw(index).raw(), index);
158 }
159 assert_ne!(ShaderId::from_raw(0), ShaderId::from_raw(1));
160 }
161}