1use std::borrow::Cow;
26use std::collections::{HashMap, HashSet};
27use std::sync::Mutex;
28
29use khora_core::renderer::api::command::{
30 BindGroupLayoutDescriptor, BindGroupLayoutEntry, BindGroupLayoutId, ComputePipelineDescriptor,
31 ComputePipelineId,
32};
33use khora_core::renderer::api::core::{ShaderModuleDescriptor, ShaderModuleId, ShaderSourceData};
34use khora_core::renderer::api::pipeline::{
35 ComputePipelineKey, ComputePipelineSpec, LayoutCacheKey, LayoutKey, LayoutSpec, PipelineKey,
36 PipelineLayoutDescriptor, PipelineSpec, RenderPipelineDescriptor, RenderPipelineId,
37 ShaderDefScalar, ShaderVariantKey,
38};
39use khora_core::renderer::api::shader_defs::ShaderDefs;
40use khora_core::renderer::error::{RenderError, ResourceError};
41use khora_core::renderer::traits::{GraphicsDevice, PipelineSystem};
42
43use naga::valid::{Capabilities, ValidationFlags, Validator};
44use naga_oil::compose::{
45 ComposableModuleDescriptor, Composer, NagaModuleDescriptor, ShaderDefValue, ShaderLanguage,
46 ShaderType,
47};
48
49const LIB_MODULES: &[(&str, &str)] = &[
51 (
52 "khora::std::camera",
53 include_str!("shaders/lib/std/camera.wgsl"),
54 ),
55 (
56 "khora::std::model",
57 include_str!("shaders/lib/std/model.wgsl"),
58 ),
59 (
60 "khora::std::material",
61 include_str!("shaders/lib/std/material.wgsl"),
62 ),
63 (
64 "khora::std::material_textures",
65 include_str!("shaders/lib/std/material_textures.wgsl"),
66 ),
67 (
68 "khora::std::vertex",
69 include_str!("shaders/lib/std/vertex.wgsl"),
70 ),
71 (
72 "khora::lighting::structs",
73 include_str!("shaders/lib/lighting/structs.wgsl"),
74 ),
75 (
76 "khora::lighting::uniforms",
77 include_str!("shaders/lib/lighting/uniforms.wgsl"),
78 ),
79 (
80 "khora::lighting::attenuation",
81 include_str!("shaders/lib/lighting/attenuation.wgsl"),
82 ),
83 (
84 "khora::lighting::pbr",
85 include_str!("shaders/lib/lighting/pbr.wgsl"),
86 ),
87 (
88 "khora::shadow::bindings",
89 include_str!("shaders/lib/shadow/bindings.wgsl"),
90 ),
91 (
92 "khora::shadow::sample_2d",
93 include_str!("shaders/lib/shadow/sample_2d.wgsl"),
94 ),
95 (
96 "khora::shadow::sample_cube",
97 include_str!("shaders/lib/shadow/sample_cube.wgsl"),
98 ),
99];
100
101const PIPELINE_MODULES: &[(&str, &str)] = &[
103 (
104 "khora::pipelines::lit_forward",
105 include_str!("shaders/pipelines/lit_forward.wgsl"),
106 ),
107 (
108 "khora::pipelines::forward_plus",
109 include_str!("shaders/pipelines/forward_plus.wgsl"),
110 ),
111 (
112 "khora::pipelines::standard_pbr",
113 include_str!("shaders/pipelines/standard_pbr.wgsl"),
114 ),
115 (
116 "khora::pipelines::unlit",
117 include_str!("shaders/pipelines/unlit.wgsl"),
118 ),
119 (
120 "khora::pipelines::wireframe",
121 include_str!("shaders/pipelines/wireframe.wgsl"),
122 ),
123 (
124 "khora::pipelines::shadow_pass",
125 include_str!("shaders/pipelines/shadow_pass.wgsl"),
126 ),
127 (
128 "khora::pipelines::light_culling",
129 include_str!("shaders/pipelines/light_culling.wgsl"),
130 ),
131 (
132 "khora::pipelines::ui",
133 include_str!("shaders/pipelines/ui.wgsl"),
134 ),
135 (
136 "khora::pipelines::grid",
137 include_str!("shaders/pipelines/grid.wgsl"),
138 ),
139 (
140 "khora::pipelines::gizmo",
141 include_str!("shaders/pipelines/gizmo.wgsl"),
142 ),
143 (
144 "khora::pipelines::ibl_sky",
145 include_str!("shaders/pipelines/ibl_sky.wgsl"),
146 ),
147 (
148 "khora::pipelines::ibl_irradiance",
149 include_str!("shaders/pipelines/ibl_irradiance.wgsl"),
150 ),
151 (
152 "khora::pipelines::ibl_prefilter",
153 include_str!("shaders/pipelines/ibl_prefilter.wgsl"),
154 ),
155 (
156 "khora::pipelines::ibl_brdf_lut",
157 include_str!("shaders/pipelines/ibl_brdf_lut.wgsl"),
158 ),
159 (
160 "khora::pipelines::skybox",
161 include_str!("shaders/pipelines/skybox.wgsl"),
162 ),
163 (
164 "khora::pipelines::ibl_equirect",
165 include_str!("shaders/pipelines/ibl_equirect.wgsl"),
166 ),
167];
168
169struct Inner {
171 composer: Composer,
172 pipelines: HashMap<&'static str, &'static str>,
173 base_defs: HashMap<String, ShaderDefValue>,
174 layouts: HashMap<LayoutCacheKey, BindGroupLayoutId>,
175 modules: HashMap<(String, ShaderVariantKey), ShaderModuleId>,
176 pipeline_cache: HashMap<PipelineKey, RenderPipelineId>,
177 compute_pipeline_cache: HashMap<ComputePipelineKey, ComputePipelineId>,
178 pipeline_specs: HashMap<PipelineKey, PipelineSpec>,
181 compute_pipeline_specs: HashMap<ComputePipelineKey, ComputePipelineSpec>,
182 overlays: HashMap<String, String>,
185 dirty: HashSet<String>,
188}
189
190pub struct WgpuPipelineSystem {
193 inner: Mutex<Inner>,
194}
195
196impl WgpuPipelineSystem {
197 pub fn new() -> Result<Self, RenderError> {
200 let mut composer = Composer::default();
201
202 let base_defs: HashMap<String, ShaderDefValue> = HashMap::from([
203 (
204 "MAX_DIRECTIONAL_LIGHTS".to_string(),
205 ShaderDefValue::UInt(ShaderDefs::MAX_DIRECTIONAL_LIGHTS),
206 ),
207 (
208 "MAX_POINT_LIGHTS".to_string(),
209 ShaderDefValue::UInt(ShaderDefs::MAX_POINT_LIGHTS),
210 ),
211 (
212 "MAX_SPOT_LIGHTS".to_string(),
213 ShaderDefValue::UInt(ShaderDefs::MAX_SPOT_LIGHTS),
214 ),
215 (
216 "MAX_LIGHTS_PER_TILE".to_string(),
217 ShaderDefValue::UInt(ShaderDefs::MAX_LIGHTS_PER_TILE),
218 ),
219 ]);
220
221 for (path, source) in LIB_MODULES {
222 let patched = inject_float_defs(source);
223 composer
224 .add_composable_module(ComposableModuleDescriptor {
225 source: &patched,
226 file_path: path,
227 language: ShaderLanguage::Wgsl,
228 shader_defs: base_defs.clone(),
229 ..Default::default()
230 })
231 .map_err(|e| compose_err(path, e))?;
232 }
233
234 Ok(Self {
235 inner: Mutex::new(Inner {
236 composer,
237 pipelines: PIPELINE_MODULES.iter().copied().collect(),
238 base_defs,
239 layouts: HashMap::new(),
240 modules: HashMap::new(),
241 pipeline_cache: HashMap::new(),
242 compute_pipeline_cache: HashMap::new(),
243 pipeline_specs: HashMap::new(),
244 compute_pipeline_specs: HashMap::new(),
245 overlays: HashMap::new(),
246 dirty: HashSet::new(),
247 }),
248 })
249 }
250}
251
252impl PipelineSystem for WgpuPipelineSystem {
253 fn layout(
254 &self,
255 device: &dyn GraphicsDevice,
256 key: LayoutKey,
257 variant: &ShaderVariantKey,
258 ) -> Result<BindGroupLayoutId, RenderError> {
259 let mut inner = lock(&self.inner)?;
260 let cache_key = LayoutCacheKey::Named(key, variant.clone());
261 if let Some(id) = inner.layouts.get(&cache_key) {
262 return Ok(*id);
263 }
264 let entries = key.entries(variant);
265 let id = device
266 .create_bind_group_layout(&BindGroupLayoutDescriptor {
267 label: Some(layout_label(key)),
268 entries: &entries,
269 })
270 .map_err(RenderError::ResourceError)?;
271 inner.layouts.insert(cache_key, id);
272 Ok(id)
273 }
274
275 fn inline_layout(
276 &self,
277 device: &dyn GraphicsDevice,
278 label: &'static str,
279 entries: &[BindGroupLayoutEntry],
280 ) -> Result<BindGroupLayoutId, RenderError> {
281 let mut inner = lock(&self.inner)?;
282 let cache_key = LayoutCacheKey::Inline(label);
283 if let Some(id) = inner.layouts.get(&cache_key) {
284 return Ok(*id);
285 }
286 let id = device
287 .create_bind_group_layout(&BindGroupLayoutDescriptor {
288 label: Some(label),
289 entries,
290 })
291 .map_err(RenderError::ResourceError)?;
292 inner.layouts.insert(cache_key, id);
293 Ok(id)
294 }
295
296 fn pipeline(
297 &self,
298 device: &dyn GraphicsDevice,
299 spec: &PipelineSpec,
300 ) -> Result<RenderPipelineId, RenderError> {
301 let mut inner = lock(&self.inner)?;
302 let pkey = spec.key();
303 if let Some(id) = inner.pipeline_cache.get(&pkey) {
304 return Ok(*id);
305 }
306
307 let mut layout_ids: Vec<BindGroupLayoutId> =
309 Vec::with_capacity(spec.bind_group_layouts.len());
310 for ls in &spec.bind_group_layouts {
311 let id = resolve_layout(&mut inner, device, ls, &spec.variant)?;
312 layout_ids.push(id);
313 }
314
315 let pipeline_layout = device
317 .create_pipeline_layout(&PipelineLayoutDescriptor {
318 label: Some(Cow::Borrowed(spec.label)),
319 bind_group_layouts: &layout_ids,
320 })
321 .map_err(RenderError::ResourceError)?;
322
323 let module = make_module(&mut inner, device, spec.shader, &spec.variant)?;
325
326 let desc = RenderPipelineDescriptor {
327 label: Some(Cow::Borrowed(spec.label)),
328 vertex_shader_module: module,
329 vertex_entry_point: Cow::Borrowed(spec.vs_entry),
330 fragment_shader_module: spec.fs_entry.map(|_| module),
331 fragment_entry_point: spec.fs_entry.map(Cow::Borrowed),
332 vertex_buffers_layout: Cow::Owned(spec.vertex_buffers.clone()),
333 layout: Some(pipeline_layout),
334 primitive_state: spec.primitive,
335 depth_stencil_state: spec.depth_stencil.clone(),
336 color_target_states: Cow::Owned(spec.color_targets.clone()),
337 multisample_state: spec.multisample,
338 };
339 let id = device
340 .create_render_pipeline(&desc)
341 .map_err(RenderError::ResourceError)?;
342 inner.pipeline_cache.insert(pkey.clone(), id);
343 inner.pipeline_specs.insert(pkey, spec.clone());
344 Ok(id)
345 }
346
347 fn compute_pipeline(
348 &self,
349 device: &dyn GraphicsDevice,
350 spec: &ComputePipelineSpec,
351 ) -> Result<ComputePipelineId, RenderError> {
352 let mut inner = lock(&self.inner)?;
353 let ckey = spec.key();
354 if let Some(id) = inner.compute_pipeline_cache.get(&ckey) {
355 return Ok(*id);
356 }
357
358 let mut layout_ids: Vec<BindGroupLayoutId> =
360 Vec::with_capacity(spec.bind_group_layouts.len());
361 for ls in &spec.bind_group_layouts {
362 let id = resolve_layout(&mut inner, device, ls, &spec.variant)?;
363 layout_ids.push(id);
364 }
365
366 let pipeline_layout = device
367 .create_pipeline_layout(&PipelineLayoutDescriptor {
368 label: Some(Cow::Borrowed(spec.label)),
369 bind_group_layouts: &layout_ids,
370 })
371 .map_err(RenderError::ResourceError)?;
372
373 let module = make_module(&mut inner, device, spec.shader, &spec.variant)?;
374
375 let id = device
376 .create_compute_pipeline(&ComputePipelineDescriptor {
377 label: Some(Cow::Borrowed(spec.label)),
378 layout: Some(pipeline_layout),
379 shader_module: module,
380 entry_point: Cow::Borrowed(spec.entry_point),
381 })
382 .map_err(RenderError::ResourceError)?;
383 inner.compute_pipeline_cache.insert(ckey.clone(), id);
384 inner.compute_pipeline_specs.insert(ckey, spec.clone());
385 Ok(id)
386 }
387
388 fn set_overlay_source(&self, logical_path: &str, source: String) {
389 let mut inner = match lock(&self.inner) {
390 Ok(g) => g,
391 Err(_) => {
392 log::error!("set_overlay_source: pipeline-system mutex poisoned");
393 return;
394 }
395 };
396 inner.overlays.insert(logical_path.to_string(), source);
397 inner.dirty.insert(logical_path.to_string());
398 log::info!("shader hot-reload: overlay source set for `{logical_path}`");
399 }
400
401 fn recompose_dirty(&self, device: &dyn GraphicsDevice) -> Result<(), RenderError> {
402 let mut inner = lock(&self.inner)?;
403 if inner.dirty.is_empty() {
404 return Ok(());
405 }
406 let dirty: Vec<String> = inner.dirty.drain().collect();
407
408 for path in &dirty {
413 if !inner.pipelines.contains_key(path.as_str()) {
414 if let Err(e) = readd_lib_module(&mut inner, path) {
415 log::error!(
416 "shader hot-reload: failed to recompose lib `{path}`, \
417 keeping previous module: {e}"
418 );
419 inner.overlays.remove(path);
422 continue;
423 }
424 }
425 }
426
427 let affected_render = affected_render_pipelines(&inner, &dirty);
430 let affected_compute = affected_compute_pipelines(&inner, &dirty);
431
432 for path in &dirty {
435 inner.modules.retain(|(name, _), _| name != path);
436 }
437 let affected_names: HashSet<&'static str> = affected_render
440 .iter()
441 .map(|k| k.shader)
442 .chain(affected_compute.iter().map(|k| k.shader))
443 .collect();
444 inner
445 .modules
446 .retain(|(name, _), _| !affected_names.contains(name.as_str()));
447
448 for key in affected_render {
449 rebuild_render_pipeline(&mut inner, device, &key);
450 }
451 for key in affected_compute {
452 rebuild_compute_pipeline(&mut inner, device, &key);
453 }
454 Ok(())
455 }
456}
457
458fn readd_lib_module(inner: &mut Inner, path: &str) -> Result<(), RenderError> {
462 let source = resolve_lib_source(inner, path)
463 .ok_or_else(|| backend_err(&format!("lib module `{path}` has no source")))?;
464 let patched = inject_float_defs(&source);
465 let defs = inner.base_defs.clone();
466 inner
467 .composer
468 .add_composable_module(ComposableModuleDescriptor {
469 source: &patched,
470 file_path: path,
471 language: ShaderLanguage::Wgsl,
472 shader_defs: defs,
473 ..Default::default()
474 })
475 .map_err(|e| compose_err(path, e))?;
476 Ok(())
477}
478
479fn resolve_lib_source(inner: &Inner, path: &str) -> Option<String> {
482 if let Some(src) = inner.overlays.get(path) {
483 return Some(src.clone());
484 }
485 LIB_MODULES
486 .iter()
487 .find(|(p, _)| *p == path)
488 .map(|(_, s)| (*s).to_string())
489}
490
491fn resolve_pipeline_source(inner: &Inner, name: &str) -> Option<String> {
494 if let Some(src) = inner.overlays.get(name) {
495 return Some(src.clone());
496 }
497 inner.pipelines.get(name).map(|s| (*s).to_string())
498}
499
500fn affected_render_pipelines(inner: &Inner, dirty: &[String]) -> Vec<PipelineKey> {
503 let dirty_set: HashSet<&str> = dirty.iter().map(String::as_str).collect();
504 inner
505 .pipeline_specs
506 .keys()
507 .filter(|key| pipeline_depends_on(inner, key.shader, &dirty_set))
508 .cloned()
509 .collect()
510}
511
512fn affected_compute_pipelines(inner: &Inner, dirty: &[String]) -> Vec<ComputePipelineKey> {
514 let dirty_set: HashSet<&str> = dirty.iter().map(String::as_str).collect();
515 inner
516 .compute_pipeline_specs
517 .keys()
518 .filter(|key| pipeline_depends_on(inner, key.shader, &dirty_set))
519 .cloned()
520 .collect()
521}
522
523fn pipeline_depends_on(inner: &Inner, name: &str, dirty: &HashSet<&str>) -> bool {
526 if dirty.contains(name) {
527 return true;
528 }
529 let mut visited: HashSet<String> = HashSet::new();
530 let mut stack: Vec<String> = match resolve_pipeline_source(inner, name) {
531 Some(src) => parse_imports(&src),
532 None => return false,
533 };
534 while let Some(import) = stack.pop() {
535 if dirty.contains(import.as_str()) {
536 return true;
537 }
538 if !visited.insert(import.clone()) {
539 continue;
540 }
541 if let Some(src) = resolve_lib_source(inner, &import) {
542 stack.extend(parse_imports(&src));
543 }
544 }
545 false
546}
547
548fn parse_imports(source: &str) -> Vec<String> {
552 let mut out = Vec::new();
553 for line in source.lines() {
554 let line = line.trim();
555 let Some(rest) = line.strip_prefix("#import") else {
556 continue;
557 };
558 let rest = rest.trim();
559 let token: String = rest
561 .chars()
562 .take_while(|c| !c.is_whitespace() && *c != '{')
563 .collect();
564 let token = token.trim_end_matches("::");
565 if token.is_empty() {
566 continue;
567 }
568 out.push(longest_lib_prefix(token).unwrap_or_else(|| token.to_string()));
573 }
574 out
575}
576
577fn longest_lib_prefix(token: &str) -> Option<String> {
580 let mut best: Option<&str> = None;
581 for (path, _) in LIB_MODULES {
582 let is_prefix = token == *path || token.starts_with(&format!("{path}::"));
583 if is_prefix && best.is_none_or(|b| path.len() > b.len()) {
584 best = Some(path);
585 }
586 }
587 best.map(str::to_string)
588}
589
590fn rebuild_render_pipeline(inner: &mut Inner, device: &dyn GraphicsDevice, key: &PipelineKey) {
594 let Some(spec) = inner.pipeline_specs.get(key).cloned() else {
595 return;
596 };
597
598 let mut layout_ids: Vec<BindGroupLayoutId> = Vec::with_capacity(spec.bind_group_layouts.len());
599 for ls in &spec.bind_group_layouts {
600 match resolve_layout(inner, device, ls, &spec.variant) {
601 Ok(id) => layout_ids.push(id),
602 Err(e) => {
603 log::error!(
604 "shader hot-reload: layout resolve failed for `{}`: {e}",
605 spec.label
606 );
607 return;
608 }
609 }
610 }
611
612 let pipeline_layout = match device.create_pipeline_layout(&PipelineLayoutDescriptor {
613 label: Some(Cow::Borrowed(spec.label)),
614 bind_group_layouts: &layout_ids,
615 }) {
616 Ok(l) => l,
617 Err(e) => {
618 log::error!(
619 "shader hot-reload: pipeline layout failed for `{}`: {e}",
620 spec.label
621 );
622 return;
623 }
624 };
625
626 let module = match make_module(inner, device, spec.shader, &spec.variant) {
627 Ok(m) => m,
628 Err(e) => {
629 log::error!(
630 "shader hot-reload: recompose failed for `{}` — keeping previous \
631 pipeline: {e}",
632 spec.shader
633 );
634 return;
635 }
636 };
637
638 let desc = RenderPipelineDescriptor {
639 label: Some(Cow::Borrowed(spec.label)),
640 vertex_shader_module: module,
641 vertex_entry_point: Cow::Borrowed(spec.vs_entry),
642 fragment_shader_module: spec.fs_entry.map(|_| module),
643 fragment_entry_point: spec.fs_entry.map(Cow::Borrowed),
644 vertex_buffers_layout: Cow::Owned(spec.vertex_buffers.clone()),
645 layout: Some(pipeline_layout),
646 primitive_state: spec.primitive,
647 depth_stencil_state: spec.depth_stencil.clone(),
648 color_target_states: Cow::Owned(spec.color_targets.clone()),
649 multisample_state: spec.multisample,
650 };
651 let new_id = match device.create_render_pipeline(&desc) {
652 Ok(id) => id,
653 Err(e) => {
654 log::error!(
655 "shader hot-reload: pipeline build failed for `{}` — keeping \
656 previous pipeline: {e}",
657 spec.label
658 );
659 return;
660 }
661 };
662
663 let old_id = inner.pipeline_cache.insert(key.clone(), new_id);
664 if let Some(old_id) = old_id {
665 if let Err(e) = device.destroy_render_pipeline(old_id) {
666 log::warn!(
667 "shader hot-reload: failed to destroy old pipeline `{}`: {e}",
668 spec.label
669 );
670 }
671 }
672 log::info!("shader hot-reload: rebuilt pipeline `{}`", spec.label);
673}
674
675fn rebuild_compute_pipeline(
678 inner: &mut Inner,
679 device: &dyn GraphicsDevice,
680 key: &ComputePipelineKey,
681) {
682 let Some(spec) = inner.compute_pipeline_specs.get(key).cloned() else {
683 return;
684 };
685
686 let mut layout_ids: Vec<BindGroupLayoutId> = Vec::with_capacity(spec.bind_group_layouts.len());
687 for ls in &spec.bind_group_layouts {
688 match resolve_layout(inner, device, ls, &spec.variant) {
689 Ok(id) => layout_ids.push(id),
690 Err(e) => {
691 log::error!(
692 "shader hot-reload: layout resolve failed for `{}`: {e}",
693 spec.label
694 );
695 return;
696 }
697 }
698 }
699
700 let pipeline_layout = match device.create_pipeline_layout(&PipelineLayoutDescriptor {
701 label: Some(Cow::Borrowed(spec.label)),
702 bind_group_layouts: &layout_ids,
703 }) {
704 Ok(l) => l,
705 Err(e) => {
706 log::error!(
707 "shader hot-reload: pipeline layout failed for `{}`: {e}",
708 spec.label
709 );
710 return;
711 }
712 };
713
714 let module = match make_module(inner, device, spec.shader, &spec.variant) {
715 Ok(m) => m,
716 Err(e) => {
717 log::error!(
718 "shader hot-reload: recompose failed for `{}` — keeping previous \
719 compute pipeline: {e}",
720 spec.shader
721 );
722 return;
723 }
724 };
725
726 let new_id = match device.create_compute_pipeline(&ComputePipelineDescriptor {
727 label: Some(Cow::Borrowed(spec.label)),
728 layout: Some(pipeline_layout),
729 shader_module: module,
730 entry_point: Cow::Borrowed(spec.entry_point),
731 }) {
732 Ok(id) => id,
733 Err(e) => {
734 log::error!(
735 "shader hot-reload: compute pipeline build failed for `{}` — keeping \
736 previous: {e}",
737 spec.label
738 );
739 return;
740 }
741 };
742
743 inner.compute_pipeline_cache.insert(key.clone(), new_id);
744 log::info!(
745 "shader hot-reload: rebuilt compute pipeline `{}`",
746 spec.label
747 );
748}
749
750fn resolve_layout(
752 inner: &mut Inner,
753 device: &dyn GraphicsDevice,
754 spec: &LayoutSpec,
755 variant: &ShaderVariantKey,
756) -> Result<BindGroupLayoutId, RenderError> {
757 let cache_key = spec.cache_key(variant);
758 if let Some(id) = inner.layouts.get(&cache_key) {
759 return Ok(*id);
760 }
761 let (label, entries): (&str, Vec<_>) = match spec {
762 LayoutSpec::Named(key) => (layout_label(*key), key.entries(variant)),
763 LayoutSpec::Inline { label, entries } => (label, entries.to_vec()),
764 };
765 let id = device
766 .create_bind_group_layout(&BindGroupLayoutDescriptor {
767 label: Some(label),
768 entries: &entries,
769 })
770 .map_err(RenderError::ResourceError)?;
771 inner.layouts.insert(cache_key, id);
772 Ok(id)
773}
774
775fn make_module(
777 inner: &mut Inner,
778 device: &dyn GraphicsDevice,
779 name: &str,
780 variant: &ShaderVariantKey,
781) -> Result<ShaderModuleId, RenderError> {
782 let mkey = (name.to_string(), variant.clone());
783 if let Some(id) = inner.modules.get(&mkey) {
784 return Ok(*id);
785 }
786 let source = resolve_pipeline_source(inner, name)
788 .ok_or_else(|| backend_err(&format!("pipeline `{name}` not registered")))?;
789 let patched = inject_float_defs(&source);
790
791 let mut defs = inner.base_defs.clone();
793 for (k, v) in variant.defs() {
794 defs.insert(
795 (*k).to_string(),
796 match v {
797 ShaderDefScalar::Bool(b) => ShaderDefValue::Bool(*b),
798 ShaderDefScalar::Int(i) => ShaderDefValue::Int(*i),
799 ShaderDefScalar::UInt(u) => ShaderDefValue::UInt(*u),
800 },
801 );
802 }
803
804 let module = inner
805 .composer
806 .make_naga_module(NagaModuleDescriptor {
807 source: &patched,
808 file_path: name,
809 shader_defs: defs,
810 shader_type: ShaderType::Wgsl,
811 ..Default::default()
812 })
813 .map_err(|e| compose_err(name, e))?;
814
815 let module_info = Validator::new(ValidationFlags::all(), Capabilities::all())
816 .validate(&module)
817 .map_err(|e| backend_err(&format!("naga validation error in {name}: {e:?}")))?;
818
819 let wgsl = naga::back::wgsl::write_string(
820 &module,
821 &module_info,
822 naga::back::wgsl::WriterFlags::empty(),
823 )
824 .map_err(|e| backend_err(&format!("WGSL emit error in {name}: {e}")))?;
825
826 let id = device
827 .create_shader_module(&ShaderModuleDescriptor {
828 label: Some(name),
829 source: ShaderSourceData::Wgsl(Cow::Owned(wgsl)),
830 })
831 .map_err(RenderError::ResourceError)?;
832 inner.modules.insert(mkey, id);
833 Ok(id)
834}
835
836fn layout_label(key: LayoutKey) -> &'static str {
837 match key {
838 LayoutKey::Camera => "khora_camera_layout",
839 LayoutKey::Model => "khora_model_layout",
840 LayoutKey::Material => "khora_material_layout",
841 LayoutKey::Lighting => "khora_lighting_layout",
842 LayoutKey::LightingBuffer => "khora_lighting_buffer_layout",
843 }
844}
845
846fn lock(m: &Mutex<Inner>) -> Result<std::sync::MutexGuard<'_, Inner>, RenderError> {
847 m.lock()
848 .map_err(|_| backend_err("WgpuPipelineSystem mutex poisoned"))
849}
850
851fn backend_err(msg: &str) -> RenderError {
852 RenderError::ResourceError(ResourceError::BackendError(msg.to_owned()))
853}
854
855fn compose_err(path: &str, e: naga_oil::compose::ComposerError) -> RenderError {
856 backend_err(&format!("naga_oil compose error in {path}: {e}"))
857}
858
859fn inject_float_defs(source: &str) -> String {
862 let mut out = String::with_capacity(source.len() + 64);
863 out.push_str(&format!(
864 "const SHADOW_CUBE_NEAR: f32 = {};\n",
865 ShaderDefs::SHADOW_CUBE_NEAR
866 ));
867 out.push_str(source);
868 out
869}
870
871#[cfg(test)]
872mod tests {
873 use super::*;
874
875 #[test]
879 fn composes_every_pipeline() {
880 let sys = WgpuPipelineSystem::new().expect("system init");
881 let mut inner = sys.inner.lock().unwrap();
882 let pipelines: Vec<(&'static str, &'static str)> =
883 inner.pipelines.iter().map(|(n, s)| (*n, *s)).collect();
884 let defs = inner.base_defs.clone();
885 for (name, source) in pipelines {
886 let patched = inject_float_defs(source);
887 let module = inner
888 .composer
889 .make_naga_module(NagaModuleDescriptor {
890 source: &patched,
891 file_path: name,
892 shader_defs: defs.clone(),
893 shader_type: ShaderType::Wgsl,
894 ..Default::default()
895 })
896 .unwrap_or_else(|e| panic!("compose {name}: {e}"));
897 let info = Validator::new(ValidationFlags::all(), Capabilities::all())
898 .validate(&module)
899 .unwrap_or_else(|e| panic!("validate {name}: {e:?}"));
900 let _ = naga::back::wgsl::write_string(
901 &module,
902 &info,
903 naga::back::wgsl::WriterFlags::empty(),
904 )
905 .unwrap_or_else(|e| panic!("emit {name}: {e}"));
906 }
907 }
908
909 #[test]
915 fn composes_lit_pipelines_for_texture_variants() {
916 use khora_core::renderer::api::material::bindings::flag;
917
918 let flags = [
919 flag::HAS_BASE_COLOR_TEXTURE,
920 flag::HAS_METALLIC_ROUGHNESS_TEXTURE,
921 flag::HAS_NORMAL_MAP,
922 flag::HAS_EMISSIVE_TEXTURE,
923 flag::HAS_OCCLUSION_MAP,
924 ];
925 let lit_pipelines = [
926 "khora::pipelines::lit_forward",
927 "khora::pipelines::forward_plus",
928 "khora::pipelines::standard_pbr",
929 ];
930
931 let sys = WgpuPipelineSystem::new().expect("system init");
932 let mut inner = sys.inner.lock().unwrap();
933
934 for mask in 0u8..(1 << flags.len()) {
935 let mut variant = ShaderVariantKey::empty();
936 for (bit, name) in flags.iter().enumerate() {
937 if mask & (1 << bit) != 0 {
938 variant = variant.flag(name);
939 }
940 }
941 let mut defs = inner.base_defs.clone();
942 for (k, v) in variant.defs() {
943 if let ShaderDefScalar::Bool(b) = v {
944 defs.insert((*k).to_string(), ShaderDefValue::Bool(*b));
945 }
946 }
947 for name in lit_pipelines {
948 let source = *inner.pipelines.get(name).expect("pipeline registered");
949 let patched = inject_float_defs(source);
950 let module = inner
951 .composer
952 .make_naga_module(NagaModuleDescriptor {
953 source: &patched,
954 file_path: name,
955 shader_defs: defs.clone(),
956 shader_type: ShaderType::Wgsl,
957 ..Default::default()
958 })
959 .unwrap_or_else(|e| panic!("compose {name} (mask {mask:04b}): {e}"));
960 Validator::new(ValidationFlags::all(), Capabilities::all())
961 .validate(&module)
962 .unwrap_or_else(|e| panic!("validate {name} (mask {mask:04b}): {e:?}"));
963 }
964 }
965 }
966
967 #[test]
970 fn composes_ibl_bake_pipelines() {
971 let sys = WgpuPipelineSystem::new().expect("system init");
972 let mut inner = sys.inner.lock().unwrap();
973 for name in [
974 "khora::pipelines::ibl_sky",
975 "khora::pipelines::ibl_irradiance",
976 "khora::pipelines::ibl_prefilter",
977 "khora::pipelines::ibl_brdf_lut",
978 "khora::pipelines::skybox",
979 "khora::pipelines::ibl_equirect",
980 ] {
981 let defs = inner.base_defs.clone();
982 let source = *inner.pipelines.get(name).expect("ibl bake registered");
983 let patched = inject_float_defs(source);
984 let module = inner
985 .composer
986 .make_naga_module(NagaModuleDescriptor {
987 source: &patched,
988 file_path: name,
989 shader_defs: defs,
990 shader_type: ShaderType::Wgsl,
991 ..Default::default()
992 })
993 .unwrap_or_else(|e| panic!("compose {name}: {e}"));
994 Validator::new(ValidationFlags::all(), Capabilities::all())
995 .validate(&module)
996 .unwrap_or_else(|e| panic!("validate {name}: {e:?}"));
997 }
998 }
999
1000 #[test]
1003 fn parse_imports_collapses_to_module_paths() {
1004 let src = "\
1005#import khora::std::camera::camera
1006#import khora::std::vertex::{VertexInput, VertexOutput}
1007#import khora::std::material_textures::sample_albedo
1008#import khora::lighting::structs::DirectionalLight
1009// not an import
1010let x = 1;";
1011 let imports = parse_imports(src);
1012 assert!(imports.contains(&"khora::std::camera".to_string()));
1013 assert!(imports.contains(&"khora::std::vertex".to_string()));
1014 assert!(imports.contains(&"khora::std::material_textures".to_string()));
1015 assert!(imports.contains(&"khora::lighting::structs".to_string()));
1016 assert_eq!(imports.len(), 4);
1017 }
1018
1019 #[test]
1022 fn pipeline_dependency_tracking_is_transitive_and_precise() {
1023 let sys = WgpuPipelineSystem::new().expect("system init");
1024 let inner = sys.inner.lock().unwrap();
1025
1026 let dirty_material: HashSet<&str> = ["khora::std::material_textures"].into_iter().collect();
1027 assert!(
1028 pipeline_depends_on(&inner, "khora::pipelines::standard_pbr", &dirty_material),
1029 "standard_pbr imports material_textures so must be affected"
1030 );
1031
1032 let dirty_pbr_lib: HashSet<&str> = ["khora::lighting::attenuation"].into_iter().collect();
1034 assert!(
1035 !pipeline_depends_on(&inner, "khora::pipelines::ui", &dirty_pbr_lib),
1036 "ui must not depend on a lighting lib"
1037 );
1038
1039 let dirty_self: HashSet<&str> = ["khora::pipelines::ui"].into_iter().collect();
1041 assert!(pipeline_depends_on(
1042 &inner,
1043 "khora::pipelines::ui",
1044 &dirty_self
1045 ));
1046 }
1047
1048 #[test]
1053 fn overlay_lib_recomposes_and_dependent_pipeline_still_composes() {
1054 let sys = WgpuPipelineSystem::new().expect("system init");
1055
1056 let original = LIB_MODULES
1059 .iter()
1060 .find(|(p, _)| *p == "khora::std::camera")
1061 .map(|(_, s)| *s)
1062 .expect("camera lib registered");
1063 let overlaid = format!("{original}\n// hot-reload overlay marker\n");
1064
1065 sys.set_overlay_source("khora::std::camera", overlaid.clone());
1066
1067 let mut inner = sys.inner.lock().unwrap();
1068 assert!(inner.dirty.contains("khora::std::camera"));
1069 assert_eq!(
1070 resolve_lib_source(&inner, "khora::std::camera").as_deref(),
1071 Some(overlaid.as_str())
1072 );
1073
1074 readd_lib_module(&mut inner, "khora::std::camera").expect("re-add overlaid lib");
1077 let defs = inner.base_defs.clone();
1078 let pbr =
1079 resolve_pipeline_source(&inner, "khora::pipelines::standard_pbr").expect("pbr source");
1080 let patched = inject_float_defs(&pbr);
1081 inner
1082 .composer
1083 .make_naga_module(NagaModuleDescriptor {
1084 source: &patched,
1085 file_path: "khora::pipelines::standard_pbr",
1086 shader_defs: defs,
1087 shader_type: ShaderType::Wgsl,
1088 ..Default::default()
1089 })
1090 .expect("standard_pbr composes against the overlaid camera lib");
1091 }
1092}