khora_io/
shader_hot_reload.rs1use 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
37const LOGICAL_ROOT: &str = "khora";
39
40pub 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 let after_root = stem.split("shaders/").last()?;
54 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; };
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; };
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}