Skip to main content

khora_data/flow/
shadow.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//! `ShadowFlow` — derives, per shadow-casting light, the view-projection
16//! matrix the shadow pass will use to render its depth slice.
17//!
18//! Lives in the Substrate Pass: pure data derivation, no GPU work. The
19//! [`ShadowPassLane`] reads the resulting [`ShadowView`] from the
20//! [`LaneBus`](khora_core::lane::LaneBus) and only handles atlas allocation
21//! and depth rendering — the math has moved to where it belongs (Data).
22//!
23//! # Index alignment
24//!
25//! Light indices in [`ShadowView::matrices`] match light positions in
26//! `RenderWorld.lights`: both are produced by iterating
27//! `world.query::<(&Light, &GlobalTransform)>()` and skipping disabled
28//! lights, in the same order. The shadow lane and the lit lanes therefore
29//! agree on which `i` refers to which light.
30
31use std::collections::HashMap;
32
33use khora_core::math::{Mat4, Vec3, Vec4};
34use khora_core::renderer::light::LightType;
35use khora_core::Runtime;
36
37use crate::ecs::{GlobalTransform, Light, SemanticDomain, World};
38use crate::flow::{Flow, Selection};
39use crate::register_flow;
40use crate::render::{primary_view, ExtractedView};
41
42/// View-projection matrices a single shadow-casting light publishes.
43///
44/// Two variants reflect the two atlas binding surfaces consumed by the lit
45/// shader: directional / spot use a single matrix sampled against the 2D
46/// depth-array atlas, point lights use six matrices sampled against the
47/// cubemap atlas.
48///
49/// `Cube` is significantly larger than `Single` (6 × `Mat4` ≈ 384 B vs
50/// `Mat4` ≈ 64 B). The variants are boxed to even out memory usage, since
51/// most lights are directional / spot and a fat enum would inflate the
52/// per-frame `HashMap<usize, ShadowMatrices>`.
53#[derive(Debug, Clone)]
54pub enum ShadowMatrices {
55    /// One view-projection — directional or spot light.
56    Single(Mat4),
57    /// Six view-projections in [`khora_core::math::CubeFace::ALL`] order
58    /// (`[+X, -X, +Y, -Y, +Z, -Z]`) — point light.
59    Cube(Box<[Mat4; 6]>),
60}
61
62/// Output of [`ShadowFlow`].
63#[derive(Debug, Default, Clone)]
64pub struct ShadowView {
65    /// Number of enabled lights in the world (regardless of shadow-casting).
66    pub light_count: usize,
67    /// Per-light shadow data, keyed by the light's position in
68    /// `RenderWorld.lights`. Only shadow-casting lights have an entry.
69    pub matrices: HashMap<usize, ShadowMatrices>,
70}
71
72/// Computes shadow view-projection matrices.
73#[derive(Default)]
74pub struct ShadowFlow;
75
76impl Flow for ShadowFlow {
77    type View = ShadowView;
78
79    // No dedicated shadow domain — bucketed under Render for budget purposes.
80    const DOMAIN: SemanticDomain = SemanticDomain::Render;
81    const NAME: &'static str = "shadow";
82
83    /// Same inputs as `RenderFlow`: `Light` / `Camera` (Render domain),
84    /// `GlobalTransform` (Spatial domain), and `primary_view`'s editor
85    /// viewport override (runtime state, fingerprinted bit-for-bit).
86    fn cache_key(&self, world: &World, runtime: &Runtime) -> Option<u64> {
87        Some(crate::flow::combine_cache_key([
88            world.instance_id(),
89            world.domain_epoch(SemanticDomain::Render),
90            world.domain_epoch(SemanticDomain::Spatial),
91            crate::render::editor_override_fingerprint(runtime),
92        ]))
93    }
94
95    fn project(&self, world: &World, _sel: &Selection, runtime: &Runtime) -> Self::View {
96        let camera_view = primary_view(world, runtime);
97        let mut matrices: HashMap<usize, ShadowMatrices> = HashMap::new();
98        let mut light_count = 0;
99
100        // Mirror RenderFlow's iteration so indices align across views.
101        for (light, transform) in world.query::<(&Light, &GlobalTransform)>() {
102            if !light.enabled {
103                continue;
104            }
105            let light_index = light_count;
106            light_count += 1;
107
108            let casts_shadow = match &light.light_type {
109                LightType::Directional(d) => d.shadow_enabled,
110                LightType::Point(p) => p.shadow_enabled,
111                LightType::Spot(s) => s.shadow_enabled,
112            };
113            if !casts_shadow {
114                continue;
115            }
116
117            let position = transform.0.translation();
118
119            match &light.light_type {
120                LightType::Point(p) => {
121                    // Six view-proj matrices, one per cube face. The math
122                    // (90° FOV, near 0.1, far = light range, wgpu cubemap
123                    // basis) lives in `Mat4::cube_face_view_projs`.
124                    matrices.insert(
125                        light_index,
126                        ShadowMatrices::Cube(Box::new(Mat4::cube_face_view_projs(
127                            position, p.range,
128                        ))),
129                    );
130                }
131                LightType::Directional(_) | LightType::Spot(_) => {
132                    // Single matrix — needs the camera frustum for CSM /
133                    // perspective-spot derivations.
134                    let Some(camera) = camera_view.as_ref() else {
135                        continue;
136                    };
137                    let direction = match &light.light_type {
138                        LightType::Directional(d) => transform.0.rotation() * d.direction,
139                        LightType::Spot(s) => transform.0.rotation() * s.direction,
140                        LightType::Point(_) => unreachable!(
141                            "outer match restricts this arm to Directional/Spot lights"
142                        ),
143                    };
144                    let view_proj = compute_single_shadow_view_proj(
145                        &light.light_type,
146                        position,
147                        direction,
148                        camera,
149                    );
150                    matrices.insert(light_index, ShadowMatrices::Single(view_proj));
151                }
152            }
153        }
154
155        ShadowView {
156            light_count,
157            matrices,
158        }
159    }
160}
161
162register_flow!(ShadowFlow);
163
164// ─── pure shadow-matrix math (moved out of `ShadowPassLane`) ─────────
165
166fn compute_single_shadow_view_proj(
167    light_type: &LightType,
168    position: Vec3,
169    direction: Vec3,
170    camera: &ExtractedView,
171) -> Mat4 {
172    match light_type {
173        LightType::Directional(_) => directional_shadow_view_proj(direction, camera),
174        LightType::Spot(sl) => {
175            let up = if direction.y.abs() > 0.99 {
176                Vec3::Z
177            } else {
178                Vec3::Y
179            };
180            let view =
181                Mat4::look_at_rh(position, position + direction, up).unwrap_or(Mat4::IDENTITY);
182            let proj = Mat4::perspective_rh_zo(sl.outer_cone_angle * 2.0, 1.0, 0.1, sl.range);
183            proj * view
184        }
185        // Point lights are routed through `Mat4::cube_face_view_projs`
186        // directly in `project()`; they never reach this single-matrix path.
187        LightType::Point(_) => unreachable!(
188            "point lights are handled by Mat4::cube_face_view_projs in ShadowFlow::project"
189        ),
190    }
191}
192
193/// World-space distance the directional shadow cascade covers, measured
194/// from the camera near plane along each frustum edge.
195///
196/// A directional light has no position, so the shadow map must be fitted
197/// to a *bounded* slice of the camera frustum: fitting the full near→far
198/// range (cameras commonly use a 1000-unit far plane) spreads the 2048²
199/// atlas across ~2000 world units — roughly one world unit per texel —
200/// which is far too coarse to resolve sub-unit casters, so their shadows
201/// vanish entirely. Capping the cascade concentrates the texels near the
202/// viewer where shadows read; casters beyond this distance are covered by
203/// the ortho z-padding but not finely shadowed. This is a shadow-quality
204/// (representation) constant, not a scene-semantics value.
205const SHADOW_CASCADE_DISTANCE: f32 = 60.0;
206
207/// CSM (cascaded shadow map) view-projection for a directional light.
208fn directional_shadow_view_proj(direction: Vec3, camera: &ExtractedView) -> Mat4 {
209    // 1. Camera frustum corners in world space. Each (x, y) edge yields a
210    // near (z=0) and far (z=1) corner; the far corner is pulled back along
211    // the near→far ray so the cascade covers at most
212    // `SHADOW_CASCADE_DISTANCE` world units rather than the full far plane.
213    let inv_view_proj = camera.view_proj.inverse().unwrap_or(Mat4::IDENTITY);
214    let mut corners = Vec::with_capacity(8);
215    for x in &[-1.0_f32, 1.0] {
216        for y in &[-1.0_f32, 1.0] {
217            let near_h = inv_view_proj * Vec4::new(*x, *y, 0.0, 1.0);
218            let far_h = inv_view_proj * Vec4::new(*x, *y, 1.0, 1.0);
219            let near = near_h.truncate() / near_h.w;
220            let far = far_h.truncate() / far_h.w;
221
222            let edge = far - near;
223            let edge_len = edge.length();
224            let clamped_far = if edge_len > SHADOW_CASCADE_DISTANCE {
225                near + edge * (SHADOW_CASCADE_DISTANCE / edge_len)
226            } else {
227                far
228            };
229
230            corners.push(near);
231            corners.push(clamped_far);
232        }
233    }
234
235    // 2. Light view matrix centered on frustum center.
236    let light_dir = direction.normalize();
237    let up = if light_dir.y.abs() > 0.99 {
238        Vec3::Z
239    } else {
240        Vec3::Y
241    };
242
243    let mut center = Vec3::ZERO;
244    for p in &corners {
245        center = center + *p;
246    }
247    center = center / 8.0;
248
249    let light_view = Mat4::look_at_rh(center, center + light_dir, up).unwrap_or(Mat4::IDENTITY);
250
251    // 3. Frustum AABB in light space.
252    let mut min = Vec3::new(f32::MAX, f32::MAX, f32::MAX);
253    let mut max = Vec3::new(f32::MIN, f32::MIN, f32::MIN);
254    for p in corners {
255        let p_ls = light_view * Vec4::from_vec3(p, 1.0);
256        min.x = min.x.min(p_ls.x);
257        max.x = max.x.max(p_ls.x);
258        min.y = min.y.min(p_ls.y);
259        max.y = max.y.max(p_ls.y);
260        min.z = min.z.min(p_ls.z);
261        max.z = max.z.max(p_ls.z);
262    }
263
264    // 4. Texel snapping to prevent shimmer when the camera moves.
265    let shadow_map_size = 2048.0_f32;
266    let units_per_texel_x = (max.x - min.x) / shadow_map_size;
267    let units_per_texel_y = (max.y - min.y) / shadow_map_size;
268    min.x = (min.x / units_per_texel_x).floor() * units_per_texel_x;
269    max.x = (max.x / units_per_texel_x).floor() * units_per_texel_x;
270    min.y = (min.y / units_per_texel_y).floor() * units_per_texel_y;
271    max.y = (max.y / units_per_texel_y).floor() * units_per_texel_y;
272
273    // 5. Ortho projection — z padding for casters outside the frustum.
274    let z_padding = 100.0;
275    let light_proj = Mat4::orthographic_rh_zo(
276        min.x,
277        max.x,
278        min.y,
279        max.y,
280        min.z - z_padding,
281        max.z + z_padding,
282    );
283
284    light_proj * light_view
285}