khora_lanes/render_lane/
lit_forward_lane.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//! Implements a lit forward rendering strategy with shader complexity tracking.
16//!
17//! The `LitForwardLane` is a rendering pipeline that performs lighting calculations
18//! in the fragment shader using a forward rendering approach. It supports multiple
19//! light types and tracks shader complexity for GORNA resource negotiation.
20//!
21//! # Shader Complexity Tracking
22//!
23//! The cost estimation for this lane includes a shader complexity factor that scales
24//! with the number of lights in the scene. This allows GORNA to make informed decisions
25//! about rendering strategy selection based on performance budgets.
26
27use crate::render_lane::RenderLane;
28
29use super::RenderWorld;
30use khora_core::{
31    asset::{AssetUUID, Material},
32    renderer::{
33        api::{
34            command::{
35                LoadOp, Operations, RenderPassColorAttachment, RenderPassDescriptor, StoreOp,
36            },
37            PrimitiveTopology,
38        },
39        traits::CommandEncoder,
40        GpuMesh, RenderContext, RenderPipelineId,
41    },
42};
43use khora_data::assets::Assets;
44use std::sync::RwLock;
45
46/// Constants for cost estimation.
47const TRIANGLE_COST: f32 = 0.001;
48const DRAW_CALL_COST: f32 = 0.1;
49/// Cost multiplier per light in the scene.
50const LIGHT_COST_FACTOR: f32 = 0.05;
51
52/// Shader complexity levels for resource budgeting and GORNA negotiation.
53///
54/// This enum represents the relative computational cost of different shader
55/// configurations, allowing the rendering system to communicate workload
56/// estimates to the resource allocation system.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
58pub enum ShaderComplexity {
59    /// No lighting calculations, vertex colors only.
60    /// Fastest rendering path.
61    Unlit,
62    /// Basic Lambertian diffuse + simple specular.
63    /// Moderate performance cost.
64    #[default]
65    SimpleLit,
66    /// Full PBR with Cook-Torrance BRDF.
67    /// Highest quality, highest cost.
68    FullPBR,
69}
70
71impl ShaderComplexity {
72    /// Returns a cost multiplier for the given complexity level.
73    ///
74    /// This multiplier is applied to the base rendering cost to estimate
75    /// the total GPU workload for different shader configurations.
76    pub fn cost_multiplier(&self) -> f32 {
77        match self {
78            ShaderComplexity::Unlit => 1.0,
79            ShaderComplexity::SimpleLit => 1.5,
80            ShaderComplexity::FullPBR => 2.5,
81        }
82    }
83
84    /// Returns a human-readable name for this complexity level.
85    pub fn name(&self) -> &'static str {
86        match self {
87            ShaderComplexity::Unlit => "Unlit",
88            ShaderComplexity::SimpleLit => "SimpleLit",
89            ShaderComplexity::FullPBR => "FullPBR",
90        }
91    }
92}
93
94/// A lane that implements forward rendering with lighting support.
95///
96/// This lane renders meshes with lighting calculations performed in the fragment
97/// shader. It supports multiple light types (directional, point, spot) and
98/// includes shader complexity tracking for GORNA resource negotiation.
99///
100/// # Performance Characteristics
101///
102/// - **O(meshes × lights)** fragment shader complexity
103/// - **Suitable for**: Scenes with moderate light counts (< 20 lights)
104/// - **Shader complexity tracking**: Integrates with GORNA for adaptive quality
105///
106/// # Cost Estimation
107///
108/// The cost estimation includes:
109/// - Base triangle and draw call costs (same as `SimpleUnlitLane`)
110/// - Shader complexity multiplier based on the configured complexity level
111/// - Per-light cost scaling based on the number of active lights
112#[derive(Debug)]
113pub struct LitForwardLane {
114    /// The shader complexity level to use for cost estimation.
115    pub shader_complexity: ShaderComplexity,
116    /// Maximum number of directional lights supported per pass.
117    pub max_directional_lights: u32,
118    /// Maximum number of point lights supported per pass.
119    pub max_point_lights: u32,
120    /// Maximum number of spot lights supported per pass.
121    pub max_spot_lights: u32,
122}
123
124impl Default for LitForwardLane {
125    fn default() -> Self {
126        Self {
127            shader_complexity: ShaderComplexity::SimpleLit,
128            max_directional_lights: 4,
129            max_point_lights: 16,
130            max_spot_lights: 8,
131        }
132    }
133}
134
135impl LitForwardLane {
136    /// Creates a new `LitForwardLane` with default settings.
137    pub fn new() -> Self {
138        Self::default()
139    }
140
141    /// Creates a new `LitForwardLane` with the specified shader complexity.
142    pub fn with_complexity(complexity: ShaderComplexity) -> Self {
143        Self {
144            shader_complexity: complexity,
145            ..Default::default()
146        }
147    }
148
149    /// Returns the effective number of lights that will be used for rendering.
150    ///
151    /// This clamps the actual light counts to the maximum supported per pass.
152    pub fn effective_light_counts(&self, render_world: &RenderWorld) -> (usize, usize, usize) {
153        let dir_count = render_world
154            .directional_light_count()
155            .min(self.max_directional_lights as usize);
156        let point_count = render_world
157            .point_light_count()
158            .min(self.max_point_lights as usize);
159        let spot_count = render_world
160            .spot_light_count()
161            .min(self.max_spot_lights as usize);
162
163        (dir_count, point_count, spot_count)
164    }
165
166    /// Calculates the light-based cost factor for the current frame.
167    fn light_cost_factor(&self, render_world: &RenderWorld) -> f32 {
168        let (dir_count, point_count, spot_count) = self.effective_light_counts(render_world);
169        let total_lights = dir_count + point_count + spot_count;
170
171        // Base cost of 1.0 even with no lights (ambient only)
172        1.0 + (total_lights as f32 * LIGHT_COST_FACTOR)
173    }
174}
175
176impl RenderLane for LitForwardLane {
177    fn strategy_name(&self) -> &'static str {
178        "LitForward"
179    }
180
181    fn get_pipeline_for_material(
182        &self,
183        material_uuid: Option<AssetUUID>,
184        materials: &Assets<Box<dyn Material>>,
185    ) -> RenderPipelineId {
186        // If a material is specified, verify it exists in the cache
187        if let Some(uuid) = material_uuid {
188            if materials.get(&uuid).is_none() {
189                // Material not found, will use default pipeline
190                let _ = uuid;
191            }
192        }
193
194        // Currently all lit materials use the same pipeline (ID 1).
195        // Future work: differentiate based on material properties,
196        // texture presence, alpha mode, etc.
197        RenderPipelineId(1)
198    }
199
200    fn render(
201        &self,
202        render_world: &RenderWorld,
203        encoder: &mut dyn CommandEncoder,
204        render_ctx: &RenderContext,
205        gpu_meshes: &RwLock<Assets<GpuMesh>>,
206        materials: &RwLock<Assets<Box<dyn Material>>>,
207    ) {
208        // Acquire read locks on the caches
209        let gpu_mesh_assets = gpu_meshes.read().unwrap();
210        let material_assets = materials.read().unwrap();
211
212        // Pre-compute all pipelines for each mesh
213        let pipelines: Vec<RenderPipelineId> = render_world
214            .meshes
215            .iter()
216            .map(|mesh| self.get_pipeline_for_material(mesh.material_uuid, &material_assets))
217            .collect();
218
219        // Configure the render pass
220        let color_attachment = RenderPassColorAttachment {
221            view: render_ctx.color_target,
222            resolve_target: None,
223            ops: Operations {
224                load: LoadOp::Clear(render_ctx.clear_color),
225                store: StoreOp::Store,
226            },
227        };
228
229        let render_pass_desc = RenderPassDescriptor {
230            label: Some("Lit Forward Pass"),
231            color_attachments: &[color_attachment],
232        };
233
234        // Begin the render pass
235        let mut render_pass = encoder.begin_render_pass(&render_pass_desc);
236
237        // Track the last pipeline to avoid redundant state changes
238        let mut current_pipeline: Option<RenderPipelineId> = None;
239
240        // Note: In a full implementation, we would bind light uniform buffers here.
241        // The light data from render_world.lights would be uploaded to GPU and bound.
242        // For now, this is a placeholder for the rendering logic.
243
244        // Iterate over all extracted meshes and issue draw calls
245        for (i, extracted_mesh) in render_world.meshes.iter().enumerate() {
246            if let Some(gpu_mesh_handle) = gpu_mesh_assets.get(&extracted_mesh.gpu_mesh_uuid) {
247                let pipeline = &pipelines[i];
248
249                // Only bind pipeline if it changed
250                if current_pipeline != Some(*pipeline) {
251                    render_pass.set_pipeline(pipeline);
252                    current_pipeline = Some(*pipeline);
253                }
254
255                // Bind vertex buffer
256                render_pass.set_vertex_buffer(0, &gpu_mesh_handle.vertex_buffer, 0);
257
258                // Bind index buffer
259                render_pass.set_index_buffer(
260                    &gpu_mesh_handle.index_buffer,
261                    0,
262                    gpu_mesh_handle.index_format,
263                );
264
265                // Issue draw call
266                render_pass.draw_indexed(0..gpu_mesh_handle.index_count, 0, 0..1);
267            }
268        }
269    }
270
271    fn estimate_cost(
272        &self,
273        render_world: &RenderWorld,
274        gpu_meshes: &RwLock<Assets<GpuMesh>>,
275    ) -> f32 {
276        let gpu_mesh_assets = gpu_meshes.read().unwrap();
277
278        let mut total_triangles = 0u32;
279        let mut draw_call_count = 0u32;
280
281        for extracted_mesh in &render_world.meshes {
282            if let Some(gpu_mesh) = gpu_mesh_assets.get(&extracted_mesh.gpu_mesh_uuid) {
283                // Calculate triangle count based on primitive topology
284                let triangle_count = match gpu_mesh.primitive_topology {
285                    PrimitiveTopology::TriangleList => gpu_mesh.index_count / 3,
286                    PrimitiveTopology::TriangleStrip => {
287                        if gpu_mesh.index_count >= 3 {
288                            gpu_mesh.index_count - 2
289                        } else {
290                            0
291                        }
292                    }
293                    PrimitiveTopology::LineList
294                    | PrimitiveTopology::LineStrip
295                    | PrimitiveTopology::PointList => 0,
296                };
297
298                total_triangles += triangle_count;
299                draw_call_count += 1;
300            }
301        }
302
303        // Base cost from triangles and draw calls
304        let base_cost =
305            (total_triangles as f32 * TRIANGLE_COST) + (draw_call_count as f32 * DRAW_CALL_COST);
306
307        // Apply shader complexity multiplier
308        let shader_factor = self.shader_complexity.cost_multiplier();
309
310        // Apply light-based cost scaling
311        let light_factor = self.light_cost_factor(render_world);
312
313        // Total cost combines all factors
314        base_cost * shader_factor * light_factor
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::render_lane::world::ExtractedMesh;
322    use khora_core::{
323        asset::{AssetHandle, AssetUUID},
324        math::affine_transform::AffineTransform,
325        renderer::{api::PrimitiveTopology, light::DirectionalLight, BufferId, IndexFormat},
326    };
327    use std::sync::Arc;
328
329    fn create_test_gpu_mesh(index_count: u32) -> GpuMesh {
330        GpuMesh {
331            vertex_buffer: BufferId(0),
332            index_buffer: BufferId(1),
333            index_count,
334            index_format: IndexFormat::Uint32,
335            primitive_topology: PrimitiveTopology::TriangleList,
336        }
337    }
338
339    #[test]
340    fn test_lit_forward_lane_creation() {
341        let lane = LitForwardLane::new();
342        assert_eq!(lane.strategy_name(), "LitForward");
343        assert_eq!(lane.shader_complexity, ShaderComplexity::SimpleLit);
344    }
345
346    #[test]
347    fn test_lit_forward_lane_with_complexity() {
348        let lane = LitForwardLane::with_complexity(ShaderComplexity::FullPBR);
349        assert_eq!(lane.shader_complexity, ShaderComplexity::FullPBR);
350    }
351
352    #[test]
353    fn test_shader_complexity_ordering() {
354        assert!(ShaderComplexity::Unlit < ShaderComplexity::SimpleLit);
355        assert!(ShaderComplexity::SimpleLit < ShaderComplexity::FullPBR);
356    }
357
358    #[test]
359    fn test_shader_complexity_cost_multipliers() {
360        assert_eq!(ShaderComplexity::Unlit.cost_multiplier(), 1.0);
361        assert_eq!(ShaderComplexity::SimpleLit.cost_multiplier(), 1.5);
362        assert_eq!(ShaderComplexity::FullPBR.cost_multiplier(), 2.5);
363    }
364
365    #[test]
366    fn test_cost_estimation_empty_world() {
367        let lane = LitForwardLane::new();
368        let render_world = RenderWorld::default();
369        let gpu_meshes = Arc::new(RwLock::new(Assets::<GpuMesh>::new()));
370
371        let cost = lane.estimate_cost(&render_world, &gpu_meshes);
372        assert_eq!(cost, 0.0, "Empty world should have zero cost");
373    }
374
375    #[test]
376    fn test_cost_estimation_with_meshes() {
377        let lane = LitForwardLane::new();
378
379        // Create a GPU mesh with 300 indices (100 triangles)
380        let mesh_uuid = AssetUUID::new();
381        let gpu_mesh = create_test_gpu_mesh(300);
382
383        let mut gpu_meshes = Assets::<GpuMesh>::new();
384        gpu_meshes.insert(mesh_uuid, AssetHandle::new(gpu_mesh));
385
386        let mut render_world = RenderWorld::default();
387        render_world.meshes.push(ExtractedMesh {
388            transform: AffineTransform::default(),
389            gpu_mesh_uuid: mesh_uuid,
390            material_uuid: None,
391        });
392
393        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
394        let cost = lane.estimate_cost(&render_world, &gpu_meshes_lock);
395
396        // Base cost without lights: 100 * 0.001 + 1 * 0.1 = 0.2
397        // With SimpleLit multiplier (1.5) and no lights (factor 1.0):
398        // 0.2 * 1.5 * 1.0 = 0.3
399        assert!(
400            (cost - 0.3).abs() < 0.0001,
401            "Cost should be 0.3 for 100 triangles with SimpleLit complexity, got {}",
402            cost
403        );
404    }
405
406    #[test]
407    fn test_cost_estimation_with_lights() {
408        use crate::render_lane::world::ExtractedLight;
409        use khora_core::{math::Vec3, renderer::light::LightType};
410
411        let lane = LitForwardLane::new();
412
413        // Create a GPU mesh
414        let mesh_uuid = AssetUUID::new();
415        let gpu_mesh = create_test_gpu_mesh(300);
416
417        let mut gpu_meshes = Assets::<GpuMesh>::new();
418        gpu_meshes.insert(mesh_uuid, AssetHandle::new(gpu_mesh));
419
420        let mut render_world = RenderWorld::default();
421        render_world.meshes.push(ExtractedMesh {
422            transform: AffineTransform::default(),
423            gpu_mesh_uuid: mesh_uuid,
424            material_uuid: None,
425        });
426
427        // Add 4 directional lights
428        for _ in 0..4 {
429            render_world.lights.push(ExtractedLight {
430                light_type: LightType::Directional(DirectionalLight::default()),
431                position: Vec3::ZERO,
432                direction: Vec3::new(0.0, -1.0, 0.0),
433            });
434        }
435
436        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
437        let cost = lane.estimate_cost(&render_world, &gpu_meshes_lock);
438
439        // Base cost: 0.2
440        // Shader multiplier (SimpleLit): 1.5
441        // Light factor: 1.0 + (4 * 0.05) = 1.2
442        // Total: 0.2 * 1.5 * 1.2 = 0.36
443        assert!(
444            (cost - 0.36).abs() < 0.0001,
445            "Cost should be 0.36 with 4 lights, got {}",
446            cost
447        );
448    }
449
450    #[test]
451    fn test_cost_increases_with_complexity() {
452        let mesh_uuid = AssetUUID::new();
453        let gpu_mesh = create_test_gpu_mesh(300);
454
455        let mut gpu_meshes = Assets::<GpuMesh>::new();
456        gpu_meshes.insert(mesh_uuid, AssetHandle::new(gpu_mesh));
457
458        let mut render_world = RenderWorld::default();
459        render_world.meshes.push(ExtractedMesh {
460            transform: AffineTransform::default(),
461            gpu_mesh_uuid: mesh_uuid,
462            material_uuid: None,
463        });
464
465        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
466
467        let unlit_lane = LitForwardLane::with_complexity(ShaderComplexity::Unlit);
468        let simple_lane = LitForwardLane::with_complexity(ShaderComplexity::SimpleLit);
469        let pbr_lane = LitForwardLane::with_complexity(ShaderComplexity::FullPBR);
470
471        let unlit_cost = unlit_lane.estimate_cost(&render_world, &gpu_meshes_lock);
472        let simple_cost = simple_lane.estimate_cost(&render_world, &gpu_meshes_lock);
473        let pbr_cost = pbr_lane.estimate_cost(&render_world, &gpu_meshes_lock);
474
475        assert!(
476            unlit_cost < simple_cost,
477            "Unlit should be cheaper than SimpleLit"
478        );
479        assert!(
480            simple_cost < pbr_cost,
481            "SimpleLit should be cheaper than PBR"
482        );
483    }
484
485    #[test]
486    fn test_effective_light_counts() {
487        use crate::render_lane::world::ExtractedLight;
488        use khora_core::{
489            math::Vec3,
490            renderer::light::{LightType, PointLight},
491        };
492
493        let lane = LitForwardLane {
494            max_directional_lights: 2,
495            max_point_lights: 4,
496            max_spot_lights: 2,
497            ..Default::default()
498        };
499
500        let mut render_world = RenderWorld::default();
501
502        // Add 5 directional lights (max is 2)
503        for _ in 0..5 {
504            render_world.lights.push(ExtractedLight {
505                light_type: LightType::Directional(DirectionalLight::default()),
506                position: Vec3::ZERO,
507                direction: Vec3::new(0.0, -1.0, 0.0),
508            });
509        }
510
511        // Add 3 point lights (max is 4)
512        for _ in 0..3 {
513            render_world.lights.push(ExtractedLight {
514                light_type: LightType::Point(PointLight::default()),
515                position: Vec3::ZERO,
516                direction: Vec3::ZERO,
517            });
518        }
519
520        let (dir, point, spot) = lane.effective_light_counts(&render_world);
521        assert_eq!(dir, 2, "Should be clamped to max 2 directional lights");
522        assert_eq!(point, 3, "Should use all 3 point lights (under max)");
523        assert_eq!(spot, 0, "Should have 0 spot lights");
524    }
525
526    #[test]
527    fn test_get_pipeline_for_material() {
528        let lane = LitForwardLane::new();
529        let materials = Assets::<Box<dyn Material>>::new();
530
531        // No material should return pipeline ID 1 (lit default)
532        let pipeline = lane.get_pipeline_for_material(None, &materials);
533        assert_eq!(pipeline, RenderPipelineId(1));
534
535        // Non-existent material should also return default
536        let pipeline = lane.get_pipeline_for_material(Some(AssetUUID::new()), &materials);
537        assert_eq!(pipeline, RenderPipelineId(1));
538    }
539}