Skip to main content

oxigdal_gpu/
shader_reload.rs

1//! Shader hot-reload support for the GPU rendering pipeline.
2//!
3//! Provides [`ShaderWatcher`] for tracking WGSL shader sources and their
4//! versions, and [`HotReloadRegistry`] for mapping render pipelines to the
5//! shaders they depend on so that pipelines can be invalidated automatically
6//! when their source changes.
7
8use std::collections::{HashMap, HashSet};
9
10// ─── Entry points & stage ────────────────────────────────────────────────────
11
12/// Shader pipeline stage.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ShaderStage {
15    Vertex,
16    Fragment,
17    Compute,
18}
19
20/// A named entry point within a shader module.
21#[derive(Debug, Clone)]
22pub struct EntryPoint {
23    pub name: String,
24    pub stage: ShaderStage,
25}
26
27impl EntryPoint {
28    /// Create a new entry point descriptor.
29    pub fn new(name: impl Into<String>, stage: ShaderStage) -> Self {
30        Self {
31            name: name.into(),
32            stage,
33        }
34    }
35}
36
37// ─── ShaderSource ─────────────────────────────────────────────────────────────
38
39/// A versioned WGSL shader source record.
40#[derive(Debug, Clone)]
41pub struct ShaderSource {
42    /// Human-readable label used as the map key.
43    pub label: String,
44    /// Raw WGSL text.
45    pub wgsl_source: String,
46    /// Declared entry points (computed on insertion / update).
47    pub entry_points: Vec<EntryPoint>,
48    /// Monotonically increasing version counter; starts at `1`, increments on
49    /// every call to [`ShaderWatcher::update_source`].
50    pub version: u64,
51    /// Unix timestamp (seconds) of the last modification.
52    /// In an embedded / no-filesystem context this defaults to `0`.
53    pub last_modified: u64,
54}
55
56impl ShaderSource {
57    /// Construct an initial `ShaderSource` at version `1`.
58    fn new(label: impl Into<String>, wgsl_source: impl Into<String>) -> Self {
59        let wgsl = wgsl_source.into();
60        let entry_points = parse_entry_points(&wgsl);
61        Self {
62            label: label.into(),
63            wgsl_source: wgsl,
64            entry_points,
65            version: 1,
66            last_modified: 0,
67        }
68    }
69
70    /// Bump the version and replace the WGSL source.
71    fn bump(&mut self, new_wgsl: impl Into<String>) {
72        self.wgsl_source = new_wgsl.into();
73        self.entry_points = parse_entry_points(&self.wgsl_source);
74        self.version += 1;
75        self.last_modified = current_unix_secs();
76    }
77}
78
79/// Cheaply parse entry-point names and stages from WGSL source text.
80///
81/// Looks for `@vertex`, `@fragment`, and `@compute` annotations followed by
82/// a `fn <name>` declaration on the same or next line.
83fn parse_entry_points(wgsl: &str) -> Vec<EntryPoint> {
84    let mut entries = Vec::new();
85    let mut lines = wgsl.lines().peekable();
86
87    while let Some(line) = lines.next() {
88        let trimmed = line.trim();
89
90        // Determine if this line has a stage attribute.
91        let stage_opt = if trimmed.contains("@vertex") {
92            Some(ShaderStage::Vertex)
93        } else if trimmed.contains("@fragment") {
94            Some(ShaderStage::Fragment)
95        } else if trimmed.contains("@compute") {
96            Some(ShaderStage::Compute)
97        } else {
98            None
99        };
100
101        if let Some(stage) = stage_opt {
102            // The fn declaration may be on the same line or the next.
103            let fn_name = extract_fn_name(trimmed)
104                .or_else(|| lines.peek().and_then(|next| extract_fn_name(next.trim())));
105
106            if let Some(name) = fn_name {
107                entries.push(EntryPoint::new(name, stage));
108            }
109        }
110    }
111
112    entries
113}
114
115/// Extract the function name from a line of the form `fn <name>(...)`.
116fn extract_fn_name(line: &str) -> Option<String> {
117    let idx = line.find("fn ")?;
118    let after = line[idx + 3..].trim();
119    // Name ends at '(' or whitespace.
120    let end = after
121        .find(|c: char| c == '(' || c.is_whitespace())
122        .unwrap_or(after.len());
123    if end == 0 {
124        return None;
125    }
126    Some(after[..end].to_owned())
127}
128
129/// Returns the current time as Unix seconds.  Falls back to `0` if the
130/// platform does not expose `SystemTime`.
131fn current_unix_secs() -> u64 {
132    use std::time::{SystemTime, UNIX_EPOCH};
133    SystemTime::now()
134        .duration_since(UNIX_EPOCH)
135        .map(|d| d.as_secs())
136        .unwrap_or(0)
137}
138
139// ─── ShaderChangeEvent ────────────────────────────────────────────────────────
140
141/// Emitted by [`ShaderWatcher::poll_changes`] when a source version has
142/// changed since the last snapshot.
143#[derive(Debug, Clone)]
144pub struct ShaderChangeEvent {
145    pub label: String,
146    pub old_version: u64,
147    pub new_version: u64,
148}
149
150// ─── ShaderWatcher ────────────────────────────────────────────────────────────
151
152/// Watches a collection of named WGSL shader sources for changes.
153///
154/// In a no-filesystem context (embedded, WASM, tests) changes are driven
155/// explicitly by calling [`ShaderWatcher::update_source`].  On native
156/// targets an optional polling loop can check file modification times
157/// (see [`ShaderWatcher::poll_changes`]).
158pub struct ShaderWatcher {
159    /// Filesystem paths added via [`Self::add_path`]; stored for future polling.
160    pub watch_paths: Vec<String>,
161    /// Polling interval hint in milliseconds (informational only).
162    pub poll_interval_ms: u64,
163    /// All tracked sources keyed by label.
164    pub sources: HashMap<String, ShaderSource>,
165    /// Snapshot of versions from the last [`Self::poll_changes`] call.
166    snapshot: HashMap<String, u64>,
167}
168
169impl ShaderWatcher {
170    /// Create a new watcher with the given poll interval.
171    pub fn new(poll_interval_ms: u64) -> Self {
172        Self {
173            watch_paths: Vec::new(),
174            poll_interval_ms,
175            sources: HashMap::new(),
176            snapshot: HashMap::new(),
177        }
178    }
179
180    /// Register a filesystem path to watch.
181    ///
182    /// The label used for the source will be the path string itself.
183    /// The file is not loaded immediately; call [`Self::update_source`] with its
184    /// content or rely on a future polling implementation.
185    pub fn add_path(&mut self, path: impl Into<String>) {
186        self.watch_paths.push(path.into());
187    }
188
189    /// Register an inline WGSL source by label.  If a source with the same
190    /// label already exists it is replaced (version resets to `1`).
191    pub fn add_inline(&mut self, label: impl Into<String>, wgsl: impl Into<String>) {
192        let lbl: String = label.into();
193        let src = ShaderSource::new(lbl.clone(), wgsl);
194        self.snapshot.insert(lbl.clone(), src.version);
195        self.sources.insert(lbl, src);
196    }
197
198    /// Check whether any tracked sources have changed since the last call to
199    /// `poll_changes`.
200    ///
201    /// In the current implementation changes are detected purely by comparing
202    /// in-memory version numbers; actual filesystem polling is not yet wired up.
203    /// Returns the list of [`ShaderChangeEvent`]s describing what changed.
204    pub fn poll_changes(&mut self) -> Vec<ShaderChangeEvent> {
205        let mut events = Vec::new();
206
207        for (label, src) in &self.sources {
208            let snap_version = self.snapshot.get(label).copied().unwrap_or(0);
209            if src.version != snap_version {
210                events.push(ShaderChangeEvent {
211                    label: label.clone(),
212                    old_version: snap_version,
213                    new_version: src.version,
214                });
215            }
216        }
217
218        // Update snapshot to current state.
219        for (label, src) in &self.sources {
220            self.snapshot.insert(label.clone(), src.version);
221        }
222
223        events
224    }
225
226    /// Force-update the WGSL source for an existing label, bumping its
227    /// version.  Returns `true` on success, `false` if the label is unknown.
228    pub fn update_source(&mut self, label: &str, new_wgsl: impl Into<String>) -> bool {
229        if let Some(src) = self.sources.get_mut(label) {
230            src.bump(new_wgsl);
231            true
232        } else {
233            false
234        }
235    }
236
237    /// Poll the native filesystem backend and apply any on-disk shader changes
238    /// to the in-memory sources.
239    ///
240    /// For every [`crate::shader_reload_native::PolledChange`] reported by
241    /// `poller` that is a modification or creation, the path is mapped to a
242    /// registered source **label** by its string form (the convention used by
243    /// [`Self::add_path`], which keys a source by its path string).  When a
244    /// matching label exists the file is re-read via
245    /// [`crate::shader_reload_native::read_shader_source`] and applied through
246    /// [`Self::update_source`], bumping the source version and producing a
247    /// [`ShaderChangeEvent`].
248    ///
249    /// Polled paths with no matching label, deletions, and files that fail to
250    /// re-read are skipped gracefully (no event, no panic).  The returned list
251    /// is ordered to match the poller's deterministic change ordering.
252    #[cfg(feature = "shader-hot-reload")]
253    pub fn poll_filesystem(
254        &mut self,
255        poller: &mut crate::shader_reload_native::FilesystemPoller,
256    ) -> Vec<ShaderChangeEvent> {
257        use crate::shader_reload_native::{PolledChangeKind, read_shader_source};
258
259        let mut events = Vec::new();
260
261        for change in poller.poll() {
262            // Only modifications and (re-)creations carry new content to apply.
263            match change.kind {
264                PolledChangeKind::Modified | PolledChangeKind::Created => {}
265                PolledChangeKind::Deleted => continue,
266            }
267
268            // Map the path to a registered source label by its string form.
269            let label = change.path.to_string_lossy().into_owned();
270            let old_version = match self.sources.get(&label) {
271                Some(src) => src.version,
272                // No source registered under this path string — skip gracefully.
273                None => continue,
274            };
275
276            // Re-read the file; skip silently if it cannot be read (e.g. it was
277            // removed between the poll and this read, or is not valid UTF-8).
278            let new_wgsl = match read_shader_source(&change.path) {
279                Ok(text) => text,
280                Err(_) => continue,
281            };
282
283            if self.update_source(&label, new_wgsl) {
284                if let Some(src) = self.sources.get(&label) {
285                    events.push(ShaderChangeEvent {
286                        label,
287                        old_version,
288                        new_version: src.version,
289                    });
290                }
291            }
292        }
293
294        events
295    }
296
297    /// Look up a source by label.
298    pub fn get_source(&self, label: &str) -> Option<&ShaderSource> {
299        self.sources.get(label)
300    }
301
302    /// Return the current version of a source, or `None` if the label is
303    /// not registered.
304    pub fn source_version(&self, label: &str) -> Option<u64> {
305        self.sources.get(label).map(|s| s.version)
306    }
307}
308
309// ─── HotReloadRegistry ────────────────────────────────────────────────────────
310
311/// Maps render pipeline IDs to the shader labels they depend on and
312/// automatically marks pipelines as invalidated when their shaders change.
313pub struct HotReloadRegistry {
314    pub watcher: ShaderWatcher,
315    /// Set of pipeline IDs that need to be rebuilt.
316    pub invalidated_pipelines: HashSet<String>,
317    /// Total number of successful reloads processed.
318    pub reload_count: u64,
319    /// pipeline_id → set of shader labels it depends on.
320    pipeline_deps: HashMap<String, HashSet<String>>,
321}
322
323impl Default for HotReloadRegistry {
324    fn default() -> Self {
325        Self::new()
326    }
327}
328
329impl HotReloadRegistry {
330    /// Create a new registry with a default watcher (500 ms poll interval).
331    pub fn new() -> Self {
332        Self {
333            watcher: ShaderWatcher::new(500),
334            invalidated_pipelines: HashSet::new(),
335            reload_count: 0,
336            pipeline_deps: HashMap::new(),
337        }
338    }
339
340    /// Register a pipeline as depending on a shader label.
341    ///
342    /// A pipeline may depend on multiple shaders; call this method once per
343    /// shader dependency.
344    pub fn register_pipeline(&mut self, pipeline_id: impl Into<String>, shader_label: &str) {
345        self.pipeline_deps
346            .entry(pipeline_id.into())
347            .or_default()
348            .insert(shader_label.to_owned());
349    }
350
351    /// Poll for shader changes and mark dependent pipelines as invalidated.
352    ///
353    /// Returns the list of pipeline IDs that have been newly invalidated.
354    pub fn process_changes(&mut self) -> Vec<String> {
355        let events = self.watcher.poll_changes();
356        if events.is_empty() {
357            return Vec::new();
358        }
359
360        let changed_labels: HashSet<&str> = events.iter().map(|e| e.label.as_str()).collect();
361
362        let mut newly_invalidated = Vec::new();
363
364        for (pipeline_id, deps) in &self.pipeline_deps {
365            if deps.iter().any(|l| changed_labels.contains(l.as_str()))
366                && !self.invalidated_pipelines.contains(pipeline_id)
367            {
368                newly_invalidated.push(pipeline_id.clone());
369            }
370        }
371
372        for id in &newly_invalidated {
373            self.invalidated_pipelines.insert(id.clone());
374        }
375
376        self.reload_count += events.len() as u64;
377        newly_invalidated
378    }
379
380    /// Returns `true` if the pipeline is currently marked as invalidated.
381    pub fn is_invalidated(&self, pipeline_id: &str) -> bool {
382        self.invalidated_pipelines.contains(pipeline_id)
383    }
384
385    /// Clear the invalidation flag for a pipeline after it has been rebuilt.
386    pub fn clear_invalidated(&mut self, pipeline_id: &str) {
387        self.invalidated_pipelines.remove(pipeline_id);
388    }
389}
390
391// ─── Tests ────────────────────────────────────────────────────────────────────
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    // ── ShaderSource parsing ─────────────────────────────────────────────────
398
399    #[test]
400    fn test_parse_entry_points_compute() {
401        let wgsl = "@compute @workgroup_size(64)\nfn main() {}";
402        let eps = parse_entry_points(wgsl);
403        assert_eq!(eps.len(), 1);
404        assert_eq!(eps[0].name, "main");
405        assert_eq!(eps[0].stage, ShaderStage::Compute);
406    }
407
408    #[test]
409    fn test_parse_entry_points_vertex_fragment() {
410        let wgsl = "@vertex fn vs_main() {}\n@fragment fn fs_main() {}";
411        let eps = parse_entry_points(wgsl);
412        assert_eq!(eps.len(), 2);
413        assert!(eps.iter().any(|e| e.name == "vs_main"));
414        assert!(eps.iter().any(|e| e.name == "fs_main"));
415    }
416
417    #[test]
418    fn test_parse_no_entry_points() {
419        let wgsl = "struct Foo { x: f32 }";
420        assert!(parse_entry_points(wgsl).is_empty());
421    }
422
423    // ── ShaderWatcher ────────────────────────────────────────────────────────
424
425    #[test]
426    fn test_add_inline_and_get() {
427        let mut w = ShaderWatcher::new(100);
428        w.add_inline("my_shader", "@compute fn main() {}");
429        let src = w.get_source("my_shader");
430        assert!(src.is_some());
431        let src = src.expect("source should exist");
432        assert_eq!(src.label, "my_shader");
433        assert_eq!(src.version, 1);
434    }
435
436    #[test]
437    fn test_get_unknown_label_returns_none() {
438        let w = ShaderWatcher::new(100);
439        assert!(w.get_source("unknown").is_none());
440    }
441
442    #[test]
443    fn test_source_version_initial() {
444        let mut w = ShaderWatcher::new(100);
445        w.add_inline("s", "@compute fn main() {}");
446        assert_eq!(w.source_version("s"), Some(1));
447    }
448
449    #[test]
450    fn test_source_version_unknown() {
451        let w = ShaderWatcher::new(100);
452        assert_eq!(w.source_version("nope"), None);
453    }
454
455    #[test]
456    fn test_update_source_bumps_version() {
457        let mut w = ShaderWatcher::new(100);
458        w.add_inline("s", "@compute fn main() {}");
459        let ok = w.update_source("s", "@compute fn main_v2() {}");
460        assert!(ok);
461        assert_eq!(w.source_version("s"), Some(2));
462    }
463
464    #[test]
465    fn test_update_source_unknown_returns_false() {
466        let mut w = ShaderWatcher::new(100);
467        assert!(!w.update_source("ghost", "@compute fn x() {}"));
468    }
469
470    #[test]
471    fn test_update_source_multiple_bumps() {
472        let mut w = ShaderWatcher::new(100);
473        w.add_inline("s", "fn main() {}");
474        for expected in 2..=5_u64 {
475            w.update_source("s", format!("fn main_{expected}() {{}}"));
476            assert_eq!(w.source_version("s"), Some(expected));
477        }
478    }
479
480    #[test]
481    fn test_poll_changes_after_update() {
482        let mut w = ShaderWatcher::new(100);
483        w.add_inline("s", "@compute fn main() {}");
484        // First poll — nothing has changed since add_inline sets the snapshot.
485        let first = w.poll_changes();
486        assert!(first.is_empty(), "first poll should be empty");
487
488        // Update source, then poll again.
489        w.update_source("s", "@compute fn main_v2() {}");
490        let second = w.poll_changes();
491        assert_eq!(second.len(), 1);
492        assert_eq!(second[0].label, "s");
493        assert_eq!(second[0].old_version, 1);
494        assert_eq!(second[0].new_version, 2);
495    }
496
497    #[test]
498    fn test_poll_changes_clears_on_second_poll() {
499        let mut w = ShaderWatcher::new(100);
500        w.add_inline("s", "fn main() {}");
501        w.update_source("s", "fn main_v2() {}");
502        let _ = w.poll_changes();
503        // Without another update, second poll returns nothing.
504        assert!(w.poll_changes().is_empty());
505    }
506
507    #[test]
508    fn test_add_path_stores_path() {
509        let path = std::env::temp_dir().join("oxigdal_test_shader_bx9f.wgsl");
510        let path_str = path.to_string_lossy().into_owned();
511        let mut w = ShaderWatcher::new(100);
512        w.add_path(path_str.clone());
513        assert_eq!(w.watch_paths, vec![path_str]);
514    }
515
516    #[test]
517    fn test_multiple_inline_sources() {
518        let mut w = ShaderWatcher::new(100);
519        w.add_inline("a", "fn a() {}");
520        w.add_inline("b", "fn b() {}");
521        assert_eq!(w.sources.len(), 2);
522    }
523
524    // ── HotReloadRegistry ────────────────────────────────────────────────────
525
526    #[test]
527    fn test_registry_new_not_invalidated() {
528        let reg = HotReloadRegistry::new();
529        assert!(!reg.is_invalidated("pipeline_a"));
530    }
531
532    #[test]
533    fn test_registry_process_changes_invalidates_pipeline() {
534        let mut reg = HotReloadRegistry::new();
535        reg.watcher.add_inline("my_shader", "@compute fn main() {}");
536        reg.register_pipeline("pipeline_a", "my_shader");
537
538        // Consume the "add" snapshot diff, then update.
539        reg.watcher.poll_changes();
540        reg.watcher
541            .update_source("my_shader", "@compute fn main_v2() {}");
542
543        let invalidated = reg.process_changes();
544        assert!(invalidated.contains(&"pipeline_a".to_owned()));
545        assert!(reg.is_invalidated("pipeline_a"));
546    }
547
548    #[test]
549    fn test_registry_process_changes_no_change() {
550        let mut reg = HotReloadRegistry::new();
551        reg.watcher.add_inline("s", "@compute fn main() {}");
552        reg.register_pipeline("p", "s");
553        reg.watcher.poll_changes(); // drain snapshot diff
554        let invalidated = reg.process_changes();
555        assert!(invalidated.is_empty());
556    }
557
558    #[test]
559    fn test_registry_clear_invalidated() {
560        let mut reg = HotReloadRegistry::new();
561        reg.watcher.add_inline("s", "@compute fn main() {}");
562        reg.register_pipeline("p", "s");
563        reg.watcher.poll_changes();
564        reg.watcher.update_source("s", "@compute fn new_main() {}");
565        reg.process_changes();
566        assert!(reg.is_invalidated("p"));
567        reg.clear_invalidated("p");
568        assert!(!reg.is_invalidated("p"));
569    }
570
571    #[test]
572    fn test_registry_reload_count_increments() {
573        let mut reg = HotReloadRegistry::new();
574        reg.watcher.add_inline("s", "fn main() {}");
575        reg.register_pipeline("p", "s");
576        reg.watcher.poll_changes();
577        reg.watcher.update_source("s", "fn main_v2() {}");
578        reg.process_changes();
579        assert_eq!(reg.reload_count, 1);
580        reg.watcher.update_source("s", "fn main_v3() {}");
581        reg.process_changes();
582        assert_eq!(reg.reload_count, 2);
583    }
584
585    #[test]
586    fn test_registry_unrelated_shader_does_not_invalidate() {
587        let mut reg = HotReloadRegistry::new();
588        reg.watcher.add_inline("shader_a", "fn a() {}");
589        reg.watcher.add_inline("shader_b", "fn b() {}");
590        reg.register_pipeline("pipeline_a", "shader_a");
591        reg.watcher.poll_changes();
592
593        // Only change shader_b.
594        reg.watcher.update_source("shader_b", "fn b_v2() {}");
595        let invalidated = reg.process_changes();
596        assert!(!invalidated.contains(&"pipeline_a".to_owned()));
597        assert!(!reg.is_invalidated("pipeline_a"));
598    }
599
600    #[test]
601    fn test_entry_point_new() {
602        let ep = EntryPoint::new("vs_main", ShaderStage::Vertex);
603        assert_eq!(ep.name, "vs_main");
604        assert_eq!(ep.stage, ShaderStage::Vertex);
605    }
606
607    #[test]
608    fn test_shader_source_entry_points_populated() {
609        let mut w = ShaderWatcher::new(100);
610        w.add_inline("s", "@compute\nfn my_compute() {}");
611        let src = w.get_source("s").expect("source should exist");
612        assert_eq!(src.entry_points.len(), 1);
613        assert_eq!(src.entry_points[0].name, "my_compute");
614    }
615
616    #[test]
617    fn test_update_source_refreshes_entry_points() {
618        let mut w = ShaderWatcher::new(100);
619        w.add_inline("s", "@compute fn compute_v1() {}");
620        w.update_source("s", "@vertex fn vs_main() {}");
621        let src = w.get_source("s").expect("source should exist");
622        assert_eq!(src.entry_points[0].stage, ShaderStage::Vertex);
623        assert_eq!(src.entry_points[0].name, "vs_main");
624    }
625}