khora_core/renderer/forward_plus.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//! Defines data structures for Forward+ (Tiled Forward) rendering.
16//!
17//! Forward+ is an advanced rendering technique that optimizes multi-light
18//! scenarios by dividing the screen into tiles and pre-computing which lights
19//! affect each tile using a compute shader pass.
20//!
21//! # SAA Integration
22//!
23//! The `ForwardPlusLane` is a **strategy** of the `RenderAgent` ISA. The agent
24//! can select between `LitForwardLane` and `ForwardPlusLane` based on:
25//! - Scene light count (Forward+ typically wins when > 20 lights)
26//! - GORNA budget allocation
27//!
28//! # Performance Characteristics
29//!
30//! - **Complexity**: O(meshes × lights_per_tile) vs O(meshes × lights) for Forward
31//! - **Overhead**: Fixed compute pass cost for light culling (~0.5ms)
32//! - **Memory**: Light grid and index buffers scale with screen resolution
33
34use bytemuck::{Pod, Zeroable};
35
36/// The tile size for Forward+ light culling.
37///
38/// Smaller tiles provide more precise culling but increase compute overhead.
39/// Larger tiles reduce overhead but may include more lights per tile.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
41pub enum TileSize {
42 /// 16×16 pixel tiles (standard, precise culling).
43 #[default]
44 X16,
45 /// 32×32 pixel tiles (less overhead, coarser culling).
46 X32,
47}
48
49impl TileSize {
50 /// Returns the tile size in pixels.
51 #[inline]
52 pub const fn pixels(&self) -> u32 {
53 match self {
54 TileSize::X16 => 16,
55 TileSize::X32 => 32,
56 }
57 }
58
59 /// Calculates the number of tiles needed for a given screen dimension.
60 #[inline]
61 pub const fn tile_count(&self, screen_size: u32) -> u32 {
62 screen_size.div_ceil(self.pixels())
63 }
64}
65
66/// Configuration for Forward+ tiled rendering.
67///
68/// This configuration is **adaptive** and can be adjusted by GORNA or the
69/// `RenderAgent` based on runtime conditions.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct ForwardPlusTileConfig {
72 /// The tile size for light culling.
73 pub tile_size: TileSize,
74 /// Maximum number of lights per tile.
75 /// Higher values handle dense light clusters but use more memory.
76 pub max_lights_per_tile: u32,
77 /// Whether to use a depth pre-pass to improve light culling.
78 /// Adds ~0.5ms but improves culling by 20-30% for scenes with depth variation.
79 pub use_depth_prepass: bool,
80}
81
82impl Default for ForwardPlusTileConfig {
83 fn default() -> Self {
84 Self {
85 tile_size: TileSize::X16,
86 max_lights_per_tile: 128,
87 use_depth_prepass: false,
88 }
89 }
90}
91
92impl ForwardPlusTileConfig {
93 /// Creates a new configuration with default values.
94 pub const fn new() -> Self {
95 Self {
96 tile_size: TileSize::X16,
97 max_lights_per_tile: 128,
98 use_depth_prepass: false,
99 }
100 }
101
102 /// Creates a configuration optimized for many lights.
103 pub const fn high_light_count() -> Self {
104 Self {
105 tile_size: TileSize::X16,
106 max_lights_per_tile: 256,
107 use_depth_prepass: true,
108 }
109 }
110
111 /// Creates a configuration optimized for low overhead.
112 pub const fn low_overhead() -> Self {
113 Self {
114 tile_size: TileSize::X32,
115 max_lights_per_tile: 64,
116 use_depth_prepass: false,
117 }
118 }
119
120 /// Calculates the tile grid dimensions for a given screen size.
121 #[inline]
122 pub const fn tile_dimensions(&self, screen_width: u32, screen_height: u32) -> (u32, u32) {
123 (
124 self.tile_size.tile_count(screen_width),
125 self.tile_size.tile_count(screen_height),
126 )
127 }
128
129 /// Calculates the total number of tiles for a given screen size.
130 #[inline]
131 pub fn total_tiles(&self, screen_width: u32, screen_height: u32) -> u32 {
132 let (tiles_x, tiles_y) = self.tile_dimensions(screen_width, screen_height);
133 tiles_x * tiles_y
134 }
135
136 /// Calculates the required light index buffer size in bytes.
137 pub fn light_index_buffer_size(&self, screen_width: u32, screen_height: u32) -> u64 {
138 let total_tiles = self.total_tiles(screen_width, screen_height) as u64;
139 total_tiles * self.max_lights_per_tile as u64 * std::mem::size_of::<u32>() as u64
140 }
141
142 /// Calculates the required light grid buffer size in bytes.
143 /// Each tile stores (offset: u32, count: u32).
144 pub fn light_grid_buffer_size(&self, screen_width: u32, screen_height: u32) -> u64 {
145 let total_tiles = self.total_tiles(screen_width, screen_height) as u64;
146 total_tiles * 2 * std::mem::size_of::<u32>() as u64
147 }
148}
149
150/// GPU-friendly representation of a light source for compute shader processing.
151///
152/// This structure is designed for efficient GPU transfer and compute shader access.
153/// It uses a unified layout that can represent all light types.
154///
155/// # Memory Layout
156///
157/// Total size: 72 bytes (18 × 4-byte fields), padded from 64 after shadow fields were added.
158#[repr(C)]
159#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable)]
160pub struct GpuLight {
161 /// Light position in world space (ignored for directional lights).
162 pub position: [f32; 3],
163 /// Maximum range of the light (point/spot lights only).
164 pub range: f32,
165
166 /// Light color (RGB, linear space).
167 pub color: [f32; 3],
168 /// Light intensity multiplier.
169 pub intensity: f32,
170
171 /// Light direction (normalized, for directional/spot lights).
172 pub direction: [f32; 3],
173 /// Light type: 0 = directional, 1 = point, 2 = spot.
174 pub light_type: u32,
175
176 /// Cosine of inner cone angle (spot lights only).
177 pub inner_cone_cos: f32,
178 /// Cosine of outer cone angle (spot lights only).
179 pub outer_cone_cos: f32,
180
181 /// Index into the shadow texture array, or -1 if no shadow.
182 pub shadow_map_index: i32,
183 /// Shadow bias.
184 pub shadow_bias: f32,
185 /// Shadow normal bias.
186 pub shadow_normal_bias: f32,
187 /// Far plane of the per-face shadow projection (point lights only —
188 /// matches `ShadowEntry::Cube.far_plane`). Unused for directional
189 /// and spot lights; left at `0.0` in that case.
190 pub shadow_far_plane: f32,
191}
192
193impl GpuLight {
194 /// Light type constant for directional lights.
195 pub const TYPE_DIRECTIONAL: u32 = 0;
196 /// Light type constant for point lights.
197 pub const TYPE_POINT: u32 = 1;
198 /// Light type constant for spot lights.
199 pub const TYPE_SPOT: u32 = 2;
200
201 /// Creates a `GpuLight` from world-space position, direction, and light properties.
202 pub fn from_parts(
203 position: [f32; 3],
204 direction: [f32; 3],
205 ty: &super::light::LightType,
206 ) -> Self {
207 match ty {
208 super::light::LightType::Directional(l) => Self {
209 position: [0.0; 3],
210 range: 0.0,
211 color: [l.color.r, l.color.g, l.color.b],
212 intensity: l.intensity,
213 direction,
214 light_type: Self::TYPE_DIRECTIONAL,
215 inner_cone_cos: 0.0,
216 outer_cone_cos: 0.0,
217 shadow_map_index: -1,
218 shadow_bias: l.shadow_bias,
219 shadow_normal_bias: l.shadow_normal_bias,
220 shadow_far_plane: 0.0,
221 },
222 super::light::LightType::Point(l) => Self {
223 position,
224 range: l.range,
225 color: [l.color.r, l.color.g, l.color.b],
226 intensity: l.intensity,
227 direction: [0.0; 3],
228 light_type: Self::TYPE_POINT,
229 inner_cone_cos: 0.0,
230 outer_cone_cos: 0.0,
231 shadow_map_index: -1,
232 shadow_bias: l.shadow_bias,
233 shadow_normal_bias: l.shadow_normal_bias,
234 shadow_far_plane: 0.0,
235 },
236 super::light::LightType::Spot(l) => Self {
237 position,
238 range: l.range,
239 color: [l.color.r, l.color.g, l.color.b],
240 intensity: l.intensity,
241 direction,
242 light_type: Self::TYPE_SPOT,
243 inner_cone_cos: l.inner_cone_angle.cos(),
244 outer_cone_cos: l.outer_cone_angle.cos(),
245 shadow_map_index: -1,
246 shadow_bias: l.shadow_bias,
247 shadow_normal_bias: l.shadow_normal_bias,
248 shadow_far_plane: 0.0,
249 },
250 }
251 }
252}
253
254impl Default for GpuLight {
255 fn default() -> Self {
256 Self {
257 position: [0.0, 0.0, 0.0],
258 range: 10.0,
259 color: [1.0, 1.0, 1.0],
260 intensity: 1.0,
261 direction: [0.0, -1.0, 0.0],
262 light_type: Self::TYPE_POINT,
263 inner_cone_cos: 0.9, // ~25 degrees
264 outer_cone_cos: 0.7, // ~45 degrees
265 shadow_map_index: -1,
266 shadow_bias: 0.01,
267 shadow_normal_bias: 0.0,
268 shadow_far_plane: 0.0,
269 }
270 }
271}
272
273/// Uniforms for the light culling compute shader.
274///
275/// This structure is uploaded to GPU each frame with the current camera
276/// and screen state for the light culling pass.
277#[repr(C)]
278#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable)]
279pub struct LightCullingUniforms {
280 /// View-projection matrix for frustum calculations.
281 pub view_projection: [[f32; 4]; 4],
282 /// Inverse projection matrix for reconstructing view-space positions.
283 pub inverse_projection: [[f32; 4]; 4],
284
285 /// Screen dimensions in pixels (width, height).
286 pub screen_dimensions: [f32; 2],
287 /// Tile grid dimensions (tiles_x, tiles_y).
288 pub tile_count: [u32; 2],
289
290 /// Number of active lights in the light buffer.
291 pub num_lights: u32,
292 /// Tile size in pixels.
293 pub tile_size: u32,
294 /// Index of the first directional light's shadow map.
295 pub shadow_atlas_index: i32,
296 /// Padding for 16-byte alignment.
297 pub _padding: [f32; 1],
298}
299
300impl Default for LightCullingUniforms {
301 fn default() -> Self {
302 Self {
303 view_projection: [[0.0; 4]; 4],
304 inverse_projection: [[0.0; 4]; 4],
305 screen_dimensions: [1920.0, 1080.0],
306 tile_count: [120, 68], // 1920/16, 1080/16 rounded up
307 num_lights: 0,
308 tile_size: 16,
309 shadow_atlas_index: -1,
310 _padding: [0.0; 1],
311 }
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn test_tile_size_pixels() {
321 assert_eq!(TileSize::X16.pixels(), 16);
322 assert_eq!(TileSize::X32.pixels(), 32);
323 }
324
325 #[test]
326 fn test_tile_count_calculation() {
327 // 1920 / 16 = 120 tiles exactly
328 assert_eq!(TileSize::X16.tile_count(1920), 120);
329 // 1080 / 16 = 67.5 -> 68 tiles (rounded up)
330 assert_eq!(TileSize::X16.tile_count(1080), 68);
331 // 1920 / 32 = 60 tiles exactly
332 assert_eq!(TileSize::X32.tile_count(1920), 60);
333 }
334
335 #[test]
336 fn test_forward_plus_tile_config_default() {
337 let config = ForwardPlusTileConfig::default();
338 assert_eq!(config.tile_size, TileSize::X16);
339 assert_eq!(config.max_lights_per_tile, 128);
340 assert!(!config.use_depth_prepass);
341 }
342
343 #[test]
344 fn test_tile_dimensions() {
345 let config = ForwardPlusTileConfig::default();
346 let (tiles_x, tiles_y) = config.tile_dimensions(1920, 1080);
347 assert_eq!(tiles_x, 120);
348 assert_eq!(tiles_y, 68);
349 }
350
351 #[test]
352 fn test_gpu_light_size_and_alignment() {
353 // GpuLight should be exactly 72 bytes (18 x 4-byte fields)
354 // Updated from 64 after shadow fields (shadow_map_index, shadow_bias, shadow_normal_bias, shadow_far_plane) were added.
355 assert_eq!(std::mem::size_of::<GpuLight>(), 72);
356 }
357
358 #[test]
359 fn test_light_culling_uniforms_size() {
360 // LightCullingUniforms should be a multiple of 16 bytes for GPU alignment
361 let size = std::mem::size_of::<LightCullingUniforms>();
362 assert_eq!(
363 size % 16,
364 0,
365 "LightCullingUniforms should be 16-byte aligned"
366 );
367 }
368
369 #[test]
370 fn test_gpu_light_default() {
371 let light = GpuLight::default();
372 assert_eq!(light.light_type, GpuLight::TYPE_POINT);
373 assert_eq!(light.color, [1.0, 1.0, 1.0]);
374 }
375
376 #[test]
377 fn test_buffer_size_calculation() {
378 let config = ForwardPlusTileConfig::default();
379 // 120 * 68 = 8160 tiles
380 // Light index buffer: 8160 * 128 * 4 = 4,177,920 bytes
381 let index_size = config.light_index_buffer_size(1920, 1080);
382 assert_eq!(index_size, 8160 * 128 * 4);
383
384 // Light grid buffer: 8160 * 2 * 4 = 65,280 bytes
385 let grid_size = config.light_grid_buffer_size(1920, 1080);
386 assert_eq!(grid_size, 8160 * 2 * 4);
387 }
388}