Skip to main content

khora_infra/graphics/shader/
system.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//! `WgpuPipelineSystem` — the concrete [`PipelineSystem`] backend.
16//!
17//! Wraps `naga_oil` (shader composition + variant specialization) and the
18//! abstract `GraphicsDevice` (layout / pipeline creation), with three caches:
19//! bind-group layouts (by `LayoutCacheKey`), shader modules (by
20//! `(name, variant)`), and render pipelines (by `PipelineKey`). The `.wgsl`
21//! sources live next to this file (`shaders/`), embedded at compile time —
22//! they were relocated here from `khora-lanes` so the shader compiler is a
23//! backend, not lane code.
24
25use 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
49/// (logical import path, source) for each composable lib module.
50const 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
101/// (logical name, source) for each composable pipeline module.
102const 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
169/// Mutable interior state behind the system's `Mutex`.
170struct 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    /// Full specs of cached pipelines, retained so a hot-reload can rebuild
179    /// each pipeline in place under the same key.
180    pipeline_specs: HashMap<PipelineKey, PipelineSpec>,
181    compute_pipeline_specs: HashMap<ComputePipelineKey, ComputePipelineSpec>,
182    /// Hot-reload overrides: logical path (lib import path or pipeline name)
183    /// → new source. Consulted before the embedded `include_str!` source.
184    overlays: HashMap<String, String>,
185    /// Logical module names whose source changed and whose dependent
186    /// pipelines must recompose on the next [`PipelineSystem::recompose_dirty`].
187    dirty: HashSet<String>,
188}
189
190/// The wgpu/naga_oil [`PipelineSystem`] backend. Injected via
191/// `runtime.backends` as `Arc<dyn PipelineSystem>`.
192pub struct WgpuPipelineSystem {
193    inner: Mutex<Inner>,
194}
195
196impl WgpuPipelineSystem {
197    /// Builds the system: registers every lib module with the composer (fails
198    /// fast on a compose error). No graphics device needed at construction.
199    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        // Resolve bind-group layouts in order.
308        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        // Pipeline layout.
316        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        // Shader module (composed for the variant).
324        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        // Resolve bind-group layouts in order.
359        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        // Re-register any dirty lib modules with the composer so subsequent
409        // composition picks up their new source. A lib path is one the
410        // composer knows about (it has a `khora::...::` lib import path) — i.e.
411        // not a pipeline entry-point name.
412        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                    // Drop the bad overlay so the next edit can recover and we
420                    // don't keep re-failing on every recompose.
421                    inner.overlays.remove(path);
422                    continue;
423                }
424            }
425        }
426
427        // Compute the set of cached pipelines whose module graph includes any
428        // dirty source, then rebuild each in place under the same key.
429        let affected_render = affected_render_pipelines(&inner, &dirty);
430        let affected_compute = affected_compute_pipelines(&inner, &dirty);
431
432        // Drop cached module entries for every dirty name (all variants) so
433        // `make_module` recomposes them on the next build.
434        for path in &dirty {
435            inner.modules.retain(|(name, _), _| name != path);
436        }
437        // A lib change forces every affected pipeline's module to recompose
438        // too — drop their module-cache entries by pipeline name.
439        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
458/// Re-registers a dirty lib module with the composer using its overlay (or
459/// embedded) source, replacing the previous registration. `naga_oil` allows
460/// re-adding a composable module under the same import path.
461fn 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
479/// The current source for a lib import path: overlay first, then the embedded
480/// `include_str!` source. Returns `None` for an unknown lib path.
481fn 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
491/// The current source for a pipeline module name: overlay first, then the
492/// embedded source from the `pipelines` map.
493fn 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
500/// Cached render-pipeline keys whose module graph transitively includes any of
501/// the `dirty` logical names (the pipeline's own module or any imported lib).
502fn 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
512/// Cached compute-pipeline keys affected by any `dirty` logical name.
513fn 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
523/// True if pipeline `name`'s module graph (its own source plus the transitive
524/// closure of its `#import`ed lib modules) includes any of `dirty`.
525fn 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
548/// Extracts the imported module paths from a WGSL source: every `#import
549/// khora::a::b[::item]` yields the module path `khora::a::b` (the path minus
550/// any trailing imported-item / `{...}` selector).
551fn 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        // Path token ends at whitespace or the start of a `{...}` item list.
560        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        // A `khora::std::material_textures::sample_albedo` import names a
569        // function inside `khora::std::material_textures`; the module path is
570        // every segment that maps to a registered lib. Map by longest known
571        // lib-path prefix so item imports collapse to their module.
572        out.push(longest_lib_prefix(token).unwrap_or_else(|| token.to_string()));
573    }
574    out
575}
576
577/// Of the registered lib import paths, the longest that is a prefix of
578/// `token` (segment-wise). Falls back to `None` if none match.
579fn 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
590/// Rebuilds a single cached render pipeline in place under `key`. On a compose
591/// failure (e.g. the user just typed invalid WGSL), logs the error and keeps
592/// the previously-working pipeline — the cache is never poisoned.
593fn 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
675/// Rebuilds a single cached compute pipeline in place under `key`. Same
676/// keep-last-good resilience as [`rebuild_render_pipeline`].
677fn 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
750/// Resolves a single [`LayoutSpec`] to a cached [`BindGroupLayoutId`].
751fn 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
775/// Composes (or reuses) the shader module for `name` under `variant`.
776fn 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    // Overlay (hot-reload) source wins over the embedded source.
787    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    // Global defs + per-variant overlays.
792    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
859/// `naga_oil` `ShaderDefValue` is integer/bool only; float consts (e.g.
860/// `SHADOW_CUBE_NEAR`) are injected textually as a `const` at the source head.
861fn 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    /// Smoke test: every registered pipeline composes + validates + emits WGSL
876    /// at the empty variant (catches import-graph / `#define_import_path` drift
877    /// at boot, no GPU needed).
878    #[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    /// Composes the lit pipelines for every combination of material texture
910    /// variant flags, catching `#ifdef`-gated binding / import drift in the
911    /// material-textures lib at boot (no GPU needed). The full power set of
912    /// the five `HAS_*` flags exercises each gated declaration in isolation
913    /// and together.
914    #[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    /// The IBL bake shaders compose + validate (no GPU), catching WGSL errors
968    /// in the env-cube / irradiance bakes at boot.
969    #[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    /// `#import` lines collapse to their module path; item / `{...}` selectors
1001    /// and trailing `::item` are stripped to the registered lib module.
1002    #[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    /// A dirty lib that `standard_pbr` imports marks the PBR pipeline as
1020    /// affected; an unrelated dirty name does not.
1021    #[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        // The UI pipeline does not depend on the PBR material lib.
1033        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        // A pipeline always depends on its own module name.
1040        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    /// Setting an overlay for a lib marks it dirty and re-registering it with
1049    /// the composer (the device-free half of `recompose_dirty`) succeeds for a
1050    /// trivially-different-but-valid source; a dependent pipeline still
1051    /// composes against the overlaid lib.
1052    #[test]
1053    fn overlay_lib_recomposes_and_dependent_pipeline_still_composes() {
1054        let sys = WgpuPipelineSystem::new().expect("system init");
1055
1056        // A valid variant of the camera lib: original source plus a harmless
1057        // trailing comment, so the module is re-registered with new bytes.
1058        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        // Re-register the overlaid lib (device-free) and confirm a dependent
1075        // pipeline composes against the new module.
1076        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}