Skip to main content

khora_lanes/render_lane/shadows_lane/algo/
state.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//! Shared state container + algorithm for any shadows lane.
16//!
17//! Each concrete lane (`StandardShadowsLane`, `LowResShadowsLane`, …)
18//! owns one [`ShadowsLaneState`] and exposes its own (hardcoded)
19//! dimensions to its `Lane` trait methods. The state is identical in
20//! shape; only the GPU resource sizes differ.
21
22use std::collections::HashMap;
23use std::sync::RwLock;
24
25use khora_core::renderer::api::{
26    command::BindGroupLayoutId,
27    pipeline::RenderPipelineId,
28    resource::{CameraUniformData, SamplerId},
29    scene::{GpuMesh, ModelUniforms},
30    util::dynamic_uniform_buffer::DynamicUniformRingBuffer,
31};
32use khora_core::renderer::{traits::CommandEncoder, GraphicsDevice};
33use khora_data::assets::Assets;
34use khora_data::render::{RenderWorld, ShadowEntry};
35
36use super::atlas_2d::Atlas2D;
37use super::atlas_cube::AtlasCube;
38use super::pass;
39
40/// Shared state used by every shadows lane.
41///
42/// The state's shape is identical across quality tiers; the per-lane
43/// **constants** (atlas resolution, max lights) are passed in at
44/// `init_gpu` time and recorded inside the resources (textures are
45/// sized accordingly). The lane itself is the source of truth for its
46/// constants — there is no config struct injected by the agent.
47pub struct ShadowsLaneState {
48    /// Depth-only render pipeline shared by both atlas paths.
49    pub pipeline: RwLock<Option<RenderPipelineId>>,
50    /// Shadow camera (light view-projection) bind group layout.
51    pub camera_layout: RwLock<Option<BindGroupLayoutId>>,
52    /// Shadow model-uniform bind group layout.
53    pub model_layout: RwLock<Option<BindGroupLayoutId>>,
54
55    /// 2D atlas resources (directional / spot lights).
56    pub atlas_2d: Atlas2D,
57    /// Cube atlas resources (point lights).
58    pub atlas_cube: AtlasCube,
59
60    /// Comparison sampler shared by both atlases.
61    pub shadow_sampler: RwLock<Option<SamplerId>>,
62
63    /// Per-light shadow entries for the per-frame `OutputDeck`.
64    pub shadow_results: RwLock<HashMap<usize, ShadowEntry>>,
65
66    /// Dynamic ring buffer for camera (light view-proj) uniforms — one
67    /// slot per pass (1 per directional/spot light + 6 per point).
68    pub camera_ring: RwLock<Option<DynamicUniformRingBuffer>>,
69    /// Dynamic ring buffer for per-mesh model uniforms.
70    pub model_ring: RwLock<Option<DynamicUniformRingBuffer>>,
71}
72
73impl Default for ShadowsLaneState {
74    fn default() -> Self {
75        Self {
76            pipeline: RwLock::new(None),
77            camera_layout: RwLock::new(None),
78            model_layout: RwLock::new(None),
79            atlas_2d: Atlas2D::default(),
80            atlas_cube: AtlasCube::default(),
81            shadow_sampler: RwLock::new(None),
82            shadow_results: RwLock::new(HashMap::new()),
83            camera_ring: RwLock::new(None),
84            model_ring: RwLock::new(None),
85        }
86    }
87}
88
89impl ShadowsLaneState {
90    /// One-time GPU initialisation sized to the lane's own constants.
91    ///
92    /// Called by the lane's [`khora_core::lane::Lane::on_initialize`].
93    #[allow(clippy::too_many_arguments)]
94    pub fn init_gpu(
95        &self,
96        device: &dyn GraphicsDevice,
97        pipeline_system: &dyn khora_core::renderer::traits::PipelineSystem,
98        atlas_2d_resolution: u32,
99        atlas_2d_max_lights: u32,
100        cube_face_resolution: u32,
101        cube_max_lights: u32,
102        label_prefix: &str,
103    ) -> Result<(), khora_core::renderer::error::RenderError> {
104        use crate::render_lane::util::lock::write_lock_render;
105        use khora_core::renderer::api::pipeline::enums::CompareFunction;
106        use khora_core::renderer::api::resource::{
107            AddressMode, FilterMode, MipmapFilterMode, SamplerDescriptor,
108        };
109        use std::borrow::Cow;
110
111        // 1. Bind Group Layouts — bespoke per-draw dynamic-offset uniforms,
112        // resolved + cached by the PipelineSystem so the pipeline and the ring
113        // buffers share one layout id.
114        let camera_layout = pipeline_system.inline_layout(
115            device,
116            SHADOW_CAMERA_LAYOUT_LABEL,
117            &shadow_camera_layout_entries(),
118        )?;
119        let model_layout = pipeline_system.inline_layout(
120            device,
121            SHADOW_MODEL_LAYOUT_LABEL,
122            &shadow_model_layout_entries(),
123        )?;
124
125        // 2. Pipeline (depth-only) — compiled + cached by the backend from
126        // `khora::pipelines::shadow_pass`.
127        let pipeline = pipeline_system.pipeline(device, &shadow_pipeline_spec())?;
128
129        *write_lock_render(&self.pipeline, "ShadowsLaneState.pipeline")? = Some(pipeline);
130        *write_lock_render(&self.camera_layout, "ShadowsLaneState.camera_layout")? =
131            Some(camera_layout);
132        *write_lock_render(&self.model_layout, "ShadowsLaneState.model_layout")? =
133            Some(model_layout);
134
135        // 3. Ring buffers — sized for this lane's atlas capacities.
136        use khora_core::renderer::api::util::dynamic_uniform_buffer::{
137            DEFAULT_MAX_ELEMENTS, MIN_UNIFORM_ALIGNMENT,
138        };
139        let camera_ring_capacity = atlas_2d_max_lights + cube_max_lights * 6;
140        let camera_ring = DynamicUniformRingBuffer::new(
141            device,
142            camera_layout,
143            0,
144            std::mem::size_of::<CameraUniformData>() as u32,
145            camera_ring_capacity,
146            MIN_UNIFORM_ALIGNMENT,
147            "Shadow Camera Ring",
148        )
149        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
150        let model_ring = DynamicUniformRingBuffer::new(
151            device,
152            model_layout,
153            0,
154            std::mem::size_of::<ModelUniforms>() as u32,
155            DEFAULT_MAX_ELEMENTS,
156            MIN_UNIFORM_ALIGNMENT,
157            "Shadow Model Ring",
158        )
159        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
160        *write_lock_render(&self.camera_ring, "ShadowsLaneState.camera_ring")? = Some(camera_ring);
161        *write_lock_render(&self.model_ring, "ShadowsLaneState.model_ring")? = Some(model_ring);
162
163        // 4. Atlases sized to the lane's constants.
164        let label_2d = format!("{label_prefix} Atlas 2D");
165        let label_cube = format!("{label_prefix} Atlas Cube");
166        self.atlas_2d
167            .create(device, atlas_2d_resolution, atlas_2d_max_lights, &label_2d)?;
168        self.atlas_cube
169            .create(device, cube_face_resolution, cube_max_lights, &label_cube)?;
170
171        // 5. Comparison sampler.
172        let sampler = device
173            .create_sampler(&SamplerDescriptor {
174                label: Some(Cow::Borrowed("Shadow Sampler")),
175                address_mode_u: AddressMode::ClampToEdge,
176                address_mode_v: AddressMode::ClampToEdge,
177                address_mode_w: AddressMode::ClampToEdge,
178                mag_filter: FilterMode::Linear,
179                min_filter: FilterMode::Linear,
180                mipmap_filter: MipmapFilterMode::Nearest,
181                lod_min_clamp: 0.0,
182                lod_max_clamp: 1.0,
183                compare: Some(CompareFunction::LessEqual),
184                anisotropy_clamp: 1,
185                border_color: None,
186            })
187            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
188        *write_lock_render(&self.shadow_sampler, "ShadowsLaneState.shadow_sampler")? =
189            Some(sampler);
190
191        log::info!(
192            "{label_prefix}: atlases initialized — 2D({}×{}, {} layers), Cube({}×{}, {} cubes = {} face layers)",
193            atlas_2d_resolution,
194            atlas_2d_resolution,
195            atlas_2d_max_lights,
196            cube_face_resolution,
197            cube_face_resolution,
198            cube_max_lights,
199            cube_max_lights * 6,
200        );
201
202        Ok(())
203    }
204
205    /// Renders one frame's shadows. The lane is responsible for
206    /// providing its own atlas capacities (used here only to **cap**
207    /// per-frame allocation; the texture is already sized correctly by
208    /// `init_gpu`).
209    #[allow(clippy::too_many_arguments)] // one parameter per render input; a config struct would obscure the call sites
210    pub fn render(
211        &self,
212        atlas_2d_max_lights: u32,
213        cube_max_lights: u32,
214        strategy_label: &str,
215        render_world: &RenderWorld,
216        shadow_view: Option<&khora_data::flow::ShadowView>,
217        device: &dyn GraphicsDevice,
218        encoder: &mut dyn CommandEncoder,
219        gpu_meshes: &std::sync::RwLock<Assets<GpuMesh>>,
220    ) {
221        use super::atlas_2d as atlas_2d_mod;
222        use super::atlas_cube as atlas_cube_mod;
223        use khora_core::math::Mat4;
224        use khora_data::flow::ShadowMatrices;
225
226        let pipeline = if let Some(p) =
227            *crate::lock_or_log!(self.pipeline.read(), "ShadowsLaneState.pipeline")
228        {
229            p
230        } else {
231            return;
232        };
233
234        let atlas_2d_view = if let Some(v) = self.atlas_2d.view_id() {
235            v
236        } else {
237            return;
238        };
239        let cube_face_views = self.atlas_cube.face_views_snapshot();
240
241        let mut camera_lock =
242            crate::lock_or_log!(self.camera_ring.write(), "ShadowsLaneState.camera_ring");
243        let camera_ring = match camera_lock.as_mut() {
244            Some(r) => r,
245            None => {
246                log::warn!("{strategy_label}: camera_ring not initialized");
247                return;
248            }
249        };
250        camera_ring.advance();
251
252        let mut model_lock =
253            crate::lock_or_log!(self.model_ring.write(), "ShadowsLaneState.model_ring");
254        let model_ring = match model_lock.as_mut() {
255            Some(r) => r,
256            None => {
257                log::warn!("{strategy_label}: model_ring not initialized");
258                return;
259            }
260        };
261        model_ring.advance();
262
263        let gpu_meshes_guard =
264            crate::lock_or_log!(gpu_meshes.read(), "ShadowsLaneState.gpu_meshes");
265
266        let mut shadow_results = crate::lock_or_log!(
267            self.shadow_results.write(),
268            "ShadowsLaneState.shadow_results"
269        );
270        shadow_results.clear();
271
272        let mut next_atlas_2d_index: u32 = 0;
273        let mut next_cube_layer: u32 = 0;
274        let mut passes: Vec<pass::AttachmentPass> = Vec::new();
275
276        for (i, light) in render_world.lights.iter().enumerate() {
277            let Some(matrices) = shadow_view.and_then(|sv| sv.matrices.get(&i)) else {
278                continue;
279            };
280
281            match matrices {
282                ShadowMatrices::Single(view_proj) => {
283                    if next_atlas_2d_index >= atlas_2d_max_lights {
284                        log::warn!(
285                            "{strategy_label}: 2D atlas full ({} / {}), dropping shadow for light {}",
286                            next_atlas_2d_index,
287                            atlas_2d_max_lights,
288                            i
289                        );
290                        continue;
291                    }
292                    let layer = next_atlas_2d_index;
293                    next_atlas_2d_index += 1;
294
295                    shadow_results.insert(
296                        i,
297                        ShadowEntry::Atlas2D {
298                            view_proj: *view_proj,
299                            atlas_index: layer as i32,
300                        },
301                    );
302
303                    if let Some(p) = atlas_2d_mod::collect_pass(
304                        atlas_2d_view,
305                        layer,
306                        light.position,
307                        *view_proj,
308                        device,
309                        render_world,
310                        &gpu_meshes_guard,
311                        camera_ring,
312                        model_ring,
313                    ) {
314                        passes.push(p);
315                    }
316                }
317                ShadowMatrices::Cube(face_view_projs) => {
318                    if next_cube_layer >= cube_max_lights {
319                        log::warn!(
320                            "{strategy_label}: cube atlas full ({} / {}), dropping shadow for point light {}",
321                            next_cube_layer,
322                            cube_max_lights,
323                            i
324                        );
325                        continue;
326                    }
327                    let cube_layer = next_cube_layer;
328                    next_cube_layer += 1;
329
330                    let far_plane = match &light.light_type {
331                        khora_core::renderer::light::LightType::Point(p) => p.range,
332                        _ => continue,
333                    };
334
335                    let face_view_projs_arr: [Mat4; 6] = **face_view_projs;
336                    shadow_results.insert(
337                        i,
338                        ShadowEntry::Cube {
339                            face_view_projs: Box::new(face_view_projs_arr),
340                            cube_array_index: cube_layer as i32,
341                            light_pos: light.position,
342                            far_plane,
343                        },
344                    );
345
346                    let cube_passes = atlas_cube_mod::collect_passes(
347                        &cube_face_views,
348                        cube_layer,
349                        light.position,
350                        &face_view_projs_arr,
351                        device,
352                        render_world,
353                        &gpu_meshes_guard,
354                        camera_ring,
355                        model_ring,
356                    );
357                    passes.extend(cube_passes);
358                }
359            }
360        }
361
362        drop(model_lock);
363        drop(camera_lock);
364
365        for ap in &passes {
366            pass::record_depth_pass(encoder, &pipeline, ap);
367        }
368    }
369
370    /// Publishes the shadow bindings (per-frame snapshot of atlas
371    /// resource ids) into the lane context. Lit consumer lanes read
372    /// this opaquely via [`khora_data::render::ShadowGpuBindings`].
373    pub fn shadow_bindings(&self) -> Option<khora_data::render::ShadowGpuBindings> {
374        super::bindings::build_bindings(
375            &self.atlas_2d,
376            &self.atlas_cube,
377            self.shadow_sampler.read().ok().and_then(|g| *g)?,
378        )
379    }
380
381    /// Releases every GPU resource owned by this state. No-op if
382    /// already shut down.
383    pub fn shutdown(&self, device: &dyn GraphicsDevice) {
384        if let Some(ring) = self.camera_ring.write().ok().and_then(|mut g| g.take()) {
385            ring.destroy(device);
386        }
387        if let Some(ring) = self.model_ring.write().ok().and_then(|mut g| g.take()) {
388            ring.destroy(device);
389        }
390        // The depth-only pipeline and the camera/model bind-group layouts are
391        // owned + cached by the `PipelineSystem` backend, so they are not
392        // destroyed here; just clear the lane's cached handles.
393        let _ = self.pipeline.write().map(|mut g| g.take());
394        let _ = self.camera_layout.write().map(|mut g| g.take());
395        let _ = self.model_layout.write().map(|mut g| g.take());
396        self.atlas_2d.destroy(device);
397        self.atlas_cube.destroy(device);
398        if let Some(sampler) = self.shadow_sampler.write().ok().and_then(|mut g| g.take()) {
399            if let Err(e) = device.destroy_sampler(sampler) {
400                log::warn!("ShadowsLaneState: failed to destroy sampler: {:?}", e);
401            }
402        }
403    }
404}
405
406// ─── Free functions (CLAD: declarative spec + bespoke layouts) ───
407
408/// Stable cache label for the shadow camera (light view-projection) layout.
409const SHADOW_CAMERA_LAYOUT_LABEL: &str = "shadow_camera_layout";
410/// Stable cache label for the shadow model-uniform layout.
411const SHADOW_MODEL_LAYOUT_LABEL: &str = "shadow_model_layout";
412
413/// Bespoke shadow camera layout: a single dynamic-offset uniform buffer
414/// (vertex stage only).
415fn shadow_camera_layout_entries() -> Vec<khora_core::renderer::api::command::BindGroupLayoutEntry> {
416    use khora_core::renderer::api::command::{
417        BindGroupLayoutEntry, BindingType, BufferBindingType,
418    };
419    use khora_core::renderer::api::util::ShaderStageFlags;
420    vec![BindGroupLayoutEntry {
421        binding: 0,
422        visibility: ShaderStageFlags::VERTEX,
423        ty: BindingType::Buffer {
424            ty: BufferBindingType::Uniform,
425            has_dynamic_offset: true,
426            min_binding_size: None,
427        },
428    }]
429}
430
431/// Bespoke shadow model layout: a single dynamic-offset uniform buffer
432/// (vertex stage only).
433fn shadow_model_layout_entries() -> Vec<khora_core::renderer::api::command::BindGroupLayoutEntry> {
434    shadow_camera_layout_entries()
435}
436
437/// The declarative depth-only pipeline spec for the shadow pass — built each
438/// call, deduped by the `PipelineSystem`. No fragment / color state.
439fn shadow_pipeline_spec() -> khora_core::renderer::api::pipeline::PipelineSpec {
440    use khora_core::renderer::api::pipeline::enums::{
441        CompareFunction, PrimitiveTopology, VertexFormat, VertexStepMode,
442    };
443    use khora_core::renderer::api::pipeline::state::{DepthBiasState, StencilFaceState};
444    use khora_core::renderer::api::pipeline::{
445        DepthStencilStateDescriptor, LayoutSpec, MultisampleStateDescriptor, PipelineSpec,
446        PrimitiveStateDescriptor, ShaderVariantKey, VertexAttributeDescriptor,
447        VertexBufferLayoutDescriptor,
448    };
449    use khora_core::renderer::api::util::{SampleCount, TextureFormat};
450    use std::borrow::Cow;
451
452    PipelineSpec {
453        label: "Shadow Pass Pipeline",
454        shader: "khora::pipelines::shadow_pass",
455        variant: ShaderVariantKey::empty(),
456        bind_group_layouts: vec![
457            LayoutSpec::Inline {
458                label: SHADOW_CAMERA_LAYOUT_LABEL,
459                entries: Cow::Owned(shadow_camera_layout_entries()),
460            },
461            LayoutSpec::Inline {
462                label: SHADOW_MODEL_LAYOUT_LABEL,
463                entries: Cow::Owned(shadow_model_layout_entries()),
464            },
465        ],
466        vertex_buffers: vec![VertexBufferLayoutDescriptor {
467            array_stride: 32,
468            step_mode: VertexStepMode::Vertex,
469            attributes: Cow::Owned(vec![VertexAttributeDescriptor {
470                format: VertexFormat::Float32x3,
471                offset: 0,
472                shader_location: 0,
473            }]),
474        }],
475        vs_entry: "vs_main",
476        fs_entry: None,
477        primitive: PrimitiveStateDescriptor {
478            topology: PrimitiveTopology::TriangleList,
479            ..Default::default()
480        },
481        depth_stencil: Some(DepthStencilStateDescriptor {
482            format: TextureFormat::Depth32Float,
483            depth_write_enabled: true,
484            depth_compare: CompareFunction::Less,
485            stencil_front: StencilFaceState::default(),
486            stencil_back: StencilFaceState::default(),
487            stencil_read_mask: 0,
488            stencil_write_mask: 0,
489            bias: DepthBiasState {
490                constant: 2,
491                slope_scale: 2.0,
492                clamp: 0.0,
493            },
494        }),
495        color_targets: vec![],
496        multisample: MultisampleStateDescriptor {
497            count: SampleCount::X1,
498            mask: !0,
499            alpha_to_coverage_enabled: false,
500        },
501    }
502}