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}