Skip to main content

khora_io/
shader_hot_reload.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! `.wgsl` hot-reload pump.
16//!
17//! A `PreExtract` data system that drains the shader
18//! [`AssetWatcher`] each tick, maps every changed `.wgsl` file to its
19//! logical shader name, hands the new source to the
20//! [`PipelineSystem`](khora_core::renderer::traits::PipelineSystem) backend as
21//! an overlay, and recomposes the affected cached pipelines in place. Lanes
22//! re-fetch pipelines by key every frame, so they pick up the rebuilt pipeline
23//! with no lane-side change.
24//!
25//! With no assets directory present, no watcher is registered and the backend
26//! keeps serving its embedded `include_str!` sources — the production path.
27
28use std::sync::Arc;
29
30use khora_core::lane::OutputDeck;
31use khora_core::renderer::traits::{GraphicsDevice, PipelineSystem};
32use khora_core::Runtime;
33use khora_data::ecs::{DataSystemRegistration, TickPhase, World};
34
35use crate::asset::AssetWatcher;
36
37/// Root segment every composable shader module path carries.
38const LOGICAL_ROOT: &str = "khora";
39
40/// Maps a watcher-relative `.wgsl` path to its logical shader module name.
41///
42/// The shader tree mirrors the logical namespace: `shaders/lib/std/camera.wgsl`
43/// ↔ `khora::std::camera`, `shaders/pipelines/standard_pbr.wgsl` ↔
44/// `khora::pipelines::standard_pbr`. The mapping strips the `shaders/` root and
45/// the `lib/` grouping segment (libs are namespaced directly under `khora::`),
46/// drops the `.wgsl` extension, replaces `/` with `::`, and prefixes `khora::`.
47/// Returns `None` for paths that are not `.wgsl` under `shaders/`.
48pub fn logical_name_for_shader_path(rel_path: &str) -> Option<String> {
49    let rel = rel_path.replace('\\', "/");
50    let stem = rel.strip_suffix(".wgsl")?;
51    // Locate the `shaders/` root anywhere in the path (the watcher root may be
52    // the assets dir, so the prefix is `.../shaders/...`).
53    let after_root = stem.split("shaders/").last()?;
54    // Group segment `lib/` is implicit in the logical namespace; pipelines keep
55    // their `pipelines/` segment.
56    let body = after_root.strip_prefix("lib/").unwrap_or(after_root);
57    if body.is_empty() {
58        return None;
59    }
60    let namespaced = body.replace('/', "::");
61    Some(format!("{LOGICAL_ROOT}::{namespaced}"))
62}
63
64fn shader_hot_reload_system(_world: &mut World, runtime: &Runtime, _deck: &mut OutputDeck) {
65    let Some(watcher) = runtime.resources.get::<Arc<AssetWatcher>>() else {
66        return; // No assets dir → embedded sources, nothing to pump.
67    };
68    let events = watcher.poll();
69    if events.is_empty() {
70        return;
71    }
72    let Some(pipeline_system) = runtime.resources.get::<Arc<dyn PipelineSystem>>() else {
73        return;
74    };
75    let Some(device) = runtime.backends.get::<Arc<dyn GraphicsDevice>>() else {
76        return;
77    };
78
79    let assets_root = watcher.assets_root();
80    let mut changed = false;
81    for event in events {
82        let Some(logical) = logical_name_for_shader_path(&event.rel_path) else {
83            continue; // Not a shader file.
84        };
85        let abs = assets_root.join(&event.rel_path);
86        match std::fs::read_to_string(&abs) {
87            Ok(source) => {
88                pipeline_system.set_overlay_source(&logical, source);
89                changed = true;
90            }
91            Err(e) => {
92                log::warn!(
93                    "shader hot-reload: failed to read {} for `{logical}`: {e}",
94                    abs.display()
95                );
96            }
97        }
98    }
99
100    if changed {
101        if let Err(e) = pipeline_system.recompose_dirty(device.as_ref()) {
102            log::error!("shader hot-reload: recompose failed: {e}");
103        }
104    }
105}
106
107inventory::submit! {
108    DataSystemRegistration {
109        name: "shader_hot_reload",
110        phase: TickPhase::PreExtract,
111        run: shader_hot_reload_system,
112        order_hint: -10,
113        runs_after: &[],
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn maps_lib_path_to_logical() {
123        assert_eq!(
124            logical_name_for_shader_path("shaders/lib/std/camera.wgsl").as_deref(),
125            Some("khora::std::camera")
126        );
127        assert_eq!(
128            logical_name_for_shader_path("shaders/lib/std/material_textures.wgsl").as_deref(),
129            Some("khora::std::material_textures")
130        );
131        assert_eq!(
132            logical_name_for_shader_path("shaders/lib/shadow/sample_cube.wgsl").as_deref(),
133            Some("khora::shadow::sample_cube")
134        );
135    }
136
137    #[test]
138    fn maps_pipeline_path_to_logical() {
139        assert_eq!(
140            logical_name_for_shader_path("shaders/pipelines/standard_pbr.wgsl").as_deref(),
141            Some("khora::pipelines::standard_pbr")
142        );
143        assert_eq!(
144            logical_name_for_shader_path("shaders/pipelines/light_culling.wgsl").as_deref(),
145            Some("khora::pipelines::light_culling")
146        );
147    }
148
149    #[test]
150    fn handles_nested_root_and_backslashes() {
151        assert_eq!(
152            logical_name_for_shader_path("assets\\shaders\\lib\\lighting\\structs.wgsl").as_deref(),
153            Some("khora::lighting::structs")
154        );
155    }
156
157    #[test]
158    fn rejects_non_shader_paths() {
159        assert_eq!(logical_name_for_shader_path("textures/foo.png"), None);
160        assert_eq!(
161            logical_name_for_shader_path("shaders/lib/std/camera.txt"),
162            None
163        );
164    }
165}