Skip to main content

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}