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
27#[allow(unused_imports)]
28use khora_core::math::{Extent2D, Extent3D, LinearRgba, Mat4, Origin3D};
29#[allow(unused_imports)]
30use khora_core::renderer::api::command::BindGroupLayoutId;
31
32use super::RenderWorld;
33use khora_core::renderer::api::util::uniform_ring_buffer::UniformRingBuffer;
34use khora_core::{
35    asset::Material,
36    renderer::{
37        api::{
38            command::{
39                LoadOp, Operations, RenderPassColorAttachment, RenderPassDepthStencilAttachment,
40                RenderPassDescriptor, StoreOp,
41            },
42            core::RenderContext,
43            pipeline::enums::PrimitiveTopology,
44            pipeline::RenderPipelineId,
45            scene::{
46                DirectionalLightUniform, GpuMesh, LightingUniforms, MaterialUniforms,
47                ModelUniforms, PointLightUniform, SpotLightUniform, MAX_DIRECTIONAL_LIGHTS,
48                MAX_POINT_LIGHTS, MAX_SPOT_LIGHTS,
49            },
50        },
51        traits::CommandEncoder,
52    },
53};
54use khora_data::assets::Assets;
55use std::sync::RwLock;
56
57/// Constants for cost estimation.
58const TRIANGLE_COST: f32 = 0.001;
59const DRAW_CALL_COST: f32 = 0.1;
60/// Cost multiplier per light in the scene.
61const LIGHT_COST_FACTOR: f32 = 0.05;
62
63/// Shader complexity levels for resource budgeting and GORNA negotiation.
64///
65/// This enum represents the relative computational cost of different shader
66/// configurations, allowing the rendering system to communicate workload
67/// estimates to the resource allocation system.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
69pub enum ShaderComplexity {
70    /// No lighting calculations, vertex colors only.
71    /// Fastest rendering path.
72    Unlit,
73    /// Basic Lambertian diffuse + simple specular.
74    /// Moderate performance cost.
75    #[default]
76    SimpleLit,
77    /// Full PBR with Cook-Torrance BRDF.
78    /// Highest quality, highest cost.
79    FullPBR,
80}
81
82impl ShaderComplexity {
83    /// Returns a cost multiplier for the given complexity level.
84    ///
85    /// This multiplier is applied to the base rendering cost to estimate
86    /// the total GPU workload for different shader configurations.
87    pub fn cost_multiplier(&self) -> f32 {
88        match self {
89            ShaderComplexity::Unlit => 1.0,
90            ShaderComplexity::SimpleLit => 1.5,
91            ShaderComplexity::FullPBR => 2.5,
92        }
93    }
94
95    /// Returns a human-readable name for this complexity level.
96    pub fn name(&self) -> &'static str {
97        match self {
98            ShaderComplexity::Unlit => "Unlit",
99            ShaderComplexity::SimpleLit => "SimpleLit",
100            ShaderComplexity::FullPBR => "FullPBR",
101        }
102    }
103}
104
105/// A lane that implements forward rendering with lighting support.
106///
107/// This lane renders meshes with lighting calculations performed in the fragment
108/// shader. It supports multiple light types (directional, point, spot) and
109/// includes shader complexity tracking for GORNA resource negotiation.
110///
111/// # Performance Characteristics
112///
113/// - **O(meshes × lights)** fragment shader complexity
114/// - **Suitable for**: Scenes with moderate light counts (< 20 lights)
115/// - **Shader complexity tracking**: Integrates with GORNA for adaptive quality
116///
117/// # Cost Estimation
118///
119/// The cost estimation includes:
120/// - Base triangle and draw call costs (same as `SimpleUnlitLane`)
121/// - Shader complexity multiplier based on the configured complexity level
122/// - Per-light cost scaling based on the number of active lights
123#[derive(Debug)]
124pub struct LitForwardLane {
125    /// The shader complexity level to use for cost estimation.
126    pub shader_complexity: ShaderComplexity,
127    /// Maximum number of directional lights supported per pass.
128    pub max_directional_lights: u32,
129    /// Maximum number of point lights supported per pass.
130    pub max_point_lights: u32,
131    /// Maximum number of spot lights supported per pass.
132    pub max_spot_lights: u32,
133    /// The stored render pipeline handle.
134    pipeline: std::sync::Mutex<Option<RenderPipelineId>>,
135    /// Layout for Camera (Group 0)
136    camera_layout: std::sync::Mutex<Option<BindGroupLayoutId>>,
137    /// Layout for Model (Group 1)
138    model_layout: std::sync::Mutex<Option<BindGroupLayoutId>>,
139    /// Layout for Material (Group 2)
140    material_layout: std::sync::Mutex<Option<BindGroupLayoutId>>,
141    /// Layout for Lighting (Group 3) — full layout with shadow atlas + sampler for pipeline.
142    light_layout: std::sync::Mutex<Option<BindGroupLayoutId>>,
143    /// Layout for the lighting uniform buffer only (1 binding) — used by the ring buffer.
144    lighting_buffer_layout: std::sync::Mutex<Option<BindGroupLayoutId>>,
145    /// Persistent ring buffer for camera uniforms (eliminates per-frame allocation).
146    camera_ring: std::sync::Mutex<Option<UniformRingBuffer>>,
147    /// Persistent ring buffer for lighting uniforms (eliminates per-frame allocation).
148    lighting_ring: std::sync::Mutex<Option<UniformRingBuffer>>,
149}
150
151impl Default for LitForwardLane {
152    fn default() -> Self {
153        Self {
154            shader_complexity: ShaderComplexity::SimpleLit,
155            max_directional_lights: 4,
156            max_point_lights: 16,
157            max_spot_lights: 8,
158            pipeline: std::sync::Mutex::new(None),
159            camera_layout: std::sync::Mutex::new(None),
160            model_layout: std::sync::Mutex::new(None),
161            material_layout: std::sync::Mutex::new(None),
162            light_layout: std::sync::Mutex::new(None),
163            lighting_buffer_layout: std::sync::Mutex::new(None),
164            camera_ring: std::sync::Mutex::new(None),
165            lighting_ring: std::sync::Mutex::new(None),
166        }
167    }
168}
169
170impl LitForwardLane {
171    /// Creates a new `LitForwardLane` with default settings.
172    pub fn new() -> Self {
173        Self::default()
174    }
175
176    /// Creates a new `LitForwardLane` with the specified shader complexity.
177    pub fn with_complexity(complexity: ShaderComplexity) -> Self {
178        Self {
179            shader_complexity: complexity,
180            ..Default::default()
181        }
182    }
183
184    /// Returns the effective number of lights that will be used for rendering.
185    ///
186    /// This clamps the actual light counts to the maximum supported per pass.
187    pub fn effective_light_counts(&self, render_world: &RenderWorld) -> (usize, usize, usize) {
188        let dir_count = render_world
189            .directional_light_count()
190            .min(self.max_directional_lights as usize);
191        let point_count = render_world
192            .point_light_count()
193            .min(self.max_point_lights as usize);
194        let spot_count = render_world
195            .spot_light_count()
196            .min(self.max_spot_lights as usize);
197
198        (dir_count, point_count, spot_count)
199    }
200
201    /// Calculates the light-based cost factor for the current frame.
202    fn light_cost_factor(&self, render_world: &RenderWorld) -> f32 {
203        let (dir_count, point_count, spot_count) = self.effective_light_counts(render_world);
204        let total_lights = dir_count + point_count + spot_count;
205
206        // Base cost of 1.0 even with no lights (ambient only)
207        1.0 + (total_lights as f32 * LIGHT_COST_FACTOR)
208    }
209}
210
211impl khora_core::lane::Lane for LitForwardLane {
212    fn strategy_name(&self) -> &'static str {
213        "LitForward"
214    }
215
216    fn lane_kind(&self) -> khora_core::lane::LaneKind {
217        khora_core::lane::LaneKind::Render
218    }
219
220    fn estimate_cost(&self, ctx: &khora_core::lane::LaneContext) -> f32 {
221        let render_world =
222            match ctx.get::<khora_core::lane::Slot<crate::render_lane::RenderWorld>>() {
223                Some(slot) => slot.get_ref(),
224                None => return 1.0,
225            };
226        let gpu_meshes = match ctx.get::<std::sync::Arc<
227            std::sync::RwLock<
228                khora_data::assets::Assets<khora_core::renderer::api::scene::GpuMesh>,
229            >,
230        >>() {
231            Some(arc) => arc,
232            None => return 1.0,
233        };
234        self.estimate_render_cost(render_world, gpu_meshes)
235    }
236
237    fn on_initialize(
238        &self,
239        ctx: &mut khora_core::lane::LaneContext,
240    ) -> Result<(), khora_core::lane::LaneError> {
241        let device = ctx
242            .get::<std::sync::Arc<dyn khora_core::renderer::GraphicsDevice>>()
243            .ok_or(khora_core::lane::LaneError::missing(
244                "Arc<dyn GraphicsDevice>",
245            ))?;
246        self.on_gpu_init(device.as_ref())
247            .map_err(|e| khora_core::lane::LaneError::InitializationFailed(Box::new(e)))
248    }
249
250    fn execute(
251        &self,
252        ctx: &mut khora_core::lane::LaneContext,
253    ) -> Result<(), khora_core::lane::LaneError> {
254        use khora_core::lane::{LaneError, Slot};
255        let device = ctx
256            .get::<std::sync::Arc<dyn khora_core::renderer::GraphicsDevice>>()
257            .ok_or(LaneError::missing("Arc<dyn GraphicsDevice>"))?
258            .clone();
259        let gpu_meshes = ctx
260            .get::<std::sync::Arc<
261                std::sync::RwLock<
262                    khora_data::assets::Assets<khora_core::renderer::api::scene::GpuMesh>,
263                >,
264            >>()
265            .ok_or(LaneError::missing("Arc<RwLock<Assets<GpuMesh>>>"))?
266            .clone();
267        let encoder = ctx
268            .get::<Slot<dyn khora_core::renderer::traits::CommandEncoder>>()
269            .ok_or(LaneError::missing("Slot<dyn CommandEncoder>"))?
270            .get();
271        let render_world = ctx
272            .get::<Slot<crate::render_lane::RenderWorld>>()
273            .ok_or(LaneError::missing("Slot<RenderWorld>"))?
274            .get_ref();
275        let color_target = ctx
276            .get::<khora_core::lane::ColorTarget>()
277            .ok_or(LaneError::missing("ColorTarget"))?
278            .0;
279        let depth_target = ctx
280            .get::<khora_core::lane::DepthTarget>()
281            .ok_or(LaneError::missing("DepthTarget"))?
282            .0;
283        let clear_color = ctx
284            .get::<khora_core::lane::ClearColor>()
285            .ok_or(LaneError::missing("ClearColor"))?
286            .0;
287        let shadow_atlas = ctx.get::<khora_core::lane::ShadowAtlasView>().map(|v| v.0);
288        let shadow_sampler = ctx
289            .get::<khora_core::lane::ShadowComparisonSampler>()
290            .map(|v| v.0);
291
292        let mut render_ctx = khora_core::renderer::api::core::RenderContext::new(
293            &color_target,
294            Some(&depth_target),
295            clear_color,
296        );
297        render_ctx.shadow_atlas = shadow_atlas.as_ref();
298        render_ctx.shadow_sampler = shadow_sampler.as_ref();
299
300        self.render(
301            render_world,
302            device.as_ref(),
303            encoder,
304            &render_ctx,
305            &gpu_meshes,
306        );
307        Ok(())
308    }
309
310    fn on_shutdown(&self, ctx: &mut khora_core::lane::LaneContext) {
311        if let Some(device) = ctx.get::<std::sync::Arc<dyn khora_core::renderer::GraphicsDevice>>()
312        {
313            self.on_gpu_shutdown(device.as_ref());
314        }
315    }
316
317    fn as_any(&self) -> &dyn std::any::Any {
318        self
319    }
320
321    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
322        self
323    }
324}
325
326impl LitForwardLane {
327    /// Returns the render pipeline for the given material (or default).
328    pub fn get_pipeline_for_material(
329        &self,
330        _material: Option<&khora_core::asset::AssetHandle<Box<dyn Material>>>,
331    ) -> RenderPipelineId {
332        // Return the stored pipeline. Fallback to pipeline 0 if on_gpu_init hasn't run yet.
333        self.pipeline.lock().unwrap().unwrap_or(RenderPipelineId(0))
334    }
335
336    fn render(
337        &self,
338        render_world: &RenderWorld,
339        device: &dyn khora_core::renderer::GraphicsDevice,
340        encoder: &mut dyn CommandEncoder,
341        render_ctx: &RenderContext,
342        gpu_meshes: &RwLock<Assets<GpuMesh>>,
343    ) {
344        use khora_core::renderer::api::{
345            command::{BindGroupDescriptor, BindGroupEntry, BindingResource, BufferBinding},
346            resource::{BufferDescriptor, BufferUsage},
347        };
348
349        // 1. Get Active Camera View
350        let view = if let Some(first_view) = render_world.views.first() {
351            first_view
352        } else {
353            return; // No camera, nothing to render
354        };
355
356        // 2. Prepare Global Uniforms via Persistent Ring Buffers
357        //    Instead of creating new GPU buffers every frame, we advance the ring
358        //    buffer to the next slot and write the updated data in-place.
359
360        // Camera Uniforms — write to persistent ring buffer
361        let camera_uniforms = khora_core::renderer::api::resource::CameraUniformData {
362            view_projection: view.view_proj.to_cols_array_2d(),
363            camera_position: [view.position.x, view.position.y, view.position.z, 1.0],
364        };
365
366        let camera_bind_group = {
367            let mut lock = self.camera_ring.lock().unwrap();
368            let ring = match lock.as_mut() {
369                Some(r) => r,
370                None => {
371                    log::warn!("LitForwardLane: camera ring buffer not initialized");
372                    return;
373                }
374            };
375            ring.advance();
376            if let Err(e) = ring.write(device, bytemuck::bytes_of(&camera_uniforms)) {
377                log::error!("Failed to write camera ring buffer: {:?}", e);
378                return;
379            }
380            *ring.current_bind_group() // Copy the BindGroupId out
381        };
382
383        // Lighting Uniforms — build struct CPU-side, then write to persistent ring buffer
384        let mut lighting_uniforms = LightingUniforms {
385            directional_lights: [DirectionalLightUniform {
386                direction: [0.0; 4],
387                color: khora_core::math::LinearRgba::BLACK,
388                shadow_view_proj: [[0.0; 4]; 4],
389                shadow_params: [0.0; 4],
390            }; MAX_DIRECTIONAL_LIGHTS],
391            point_lights: [PointLightUniform {
392                position: [0.0; 4],
393                color: khora_core::math::LinearRgba::BLACK,
394                shadow_params: [0.0; 4],
395            }; MAX_POINT_LIGHTS],
396            spot_lights: [SpotLightUniform {
397                position: [0.0; 4],
398                direction: [0.0; 4],
399                color: khora_core::math::LinearRgba::BLACK,
400                params: [0.0; 4],
401                shadow_view_proj: [[0.0; 4]; 4],
402                shadow_params: [0.0; 4],
403            }; MAX_SPOT_LIGHTS],
404            num_directional_lights: 0,
405            num_point_lights: 0,
406            num_spot_lights: 0,
407            _padding: 0,
408        };
409
410        for light in &render_world.lights {
411            match light.light_type {
412                khora_core::renderer::light::LightType::Directional(ref d) => {
413                    if (lighting_uniforms.num_directional_lights as usize) < MAX_DIRECTIONAL_LIGHTS
414                    {
415                        let idx = lighting_uniforms.num_directional_lights as usize;
416                        let shadow_index = light.shadow_atlas_index.unwrap_or(-1) as f32;
417                        lighting_uniforms.directional_lights[idx] = DirectionalLightUniform {
418                            direction: [
419                                light.direction.x,
420                                light.direction.y,
421                                light.direction.z,
422                                0.0,
423                            ],
424                            color: d.color.with_alpha(d.intensity),
425                            shadow_view_proj: light.shadow_view_proj.to_cols_array_2d(),
426                            shadow_params: [shadow_index, d.shadow_bias, d.shadow_normal_bias, 0.0],
427                        };
428                        lighting_uniforms.num_directional_lights += 1;
429                    }
430                }
431                khora_core::renderer::light::LightType::Point(ref p) => {
432                    if (lighting_uniforms.num_point_lights as usize) < MAX_POINT_LIGHTS {
433                        let idx = lighting_uniforms.num_point_lights as usize;
434                        let shadow_index = light.shadow_atlas_index.unwrap_or(-1) as f32;
435                        lighting_uniforms.point_lights[idx] = PointLightUniform {
436                            position: [
437                                light.position.x,
438                                light.position.y,
439                                light.position.z,
440                                p.range,
441                            ],
442                            color: p.color.with_alpha(p.intensity),
443                            shadow_params: [shadow_index, p.shadow_bias, p.shadow_normal_bias, 0.0],
444                        };
445                        lighting_uniforms.num_point_lights += 1;
446                    }
447                }
448                khora_core::renderer::light::LightType::Spot(ref s) => {
449                    if (lighting_uniforms.num_spot_lights as usize) < MAX_SPOT_LIGHTS {
450                        let idx = lighting_uniforms.num_spot_lights as usize;
451                        let shadow_index = light.shadow_atlas_index.unwrap_or(-1) as f32;
452                        lighting_uniforms.spot_lights[idx] = SpotLightUniform {
453                            position: [
454                                light.position.x,
455                                light.position.y,
456                                light.position.z,
457                                s.range,
458                            ],
459                            direction: [
460                                light.direction.x,
461                                light.direction.y,
462                                light.direction.z,
463                                s.inner_cone_angle.cos(),
464                            ],
465                            color: s.color.with_alpha(s.intensity),
466                            params: [s.outer_cone_angle.cos(), 0.0, 0.0, 0.0],
467                            shadow_view_proj: light.shadow_view_proj.to_cols_array_2d(),
468                            shadow_params: [shadow_index, s.shadow_bias, s.shadow_normal_bias, 0.0],
469                        };
470                        lighting_uniforms.num_spot_lights += 1;
471                    }
472                }
473            }
474        }
475
476        let (_lighting_bind_group, lighting_ring_buffer_id) = {
477            let mut lock = self.lighting_ring.lock().unwrap();
478            let ring = match lock.as_mut() {
479                Some(r) => r,
480                None => {
481                    log::warn!("LitForwardLane: lighting ring buffer not initialized");
482                    return;
483                }
484            };
485            ring.advance();
486            if let Err(e) = ring.write(device, bytemuck::bytes_of(&lighting_uniforms)) {
487                log::error!("Failed to write lighting ring buffer: {:?}", e);
488                return;
489            }
490            (*ring.current_bind_group(), ring.current_buffer())
491        };
492
493        // Shadow data (view-proj matrices, atlas indices) is already embedded
494        // in the lighting_uniforms via the ExtractedLight fields patched by
495        // ShadowPassLane::execute() Phase 2.  The shadow atlas texture and
496        // comparison sampler are passed through the RenderContext.
497
498        // Acquire locks
499        let gpu_mesh_assets = gpu_meshes.read().unwrap();
500
501        // Pipeline binding logic moved before render pass to avoid issues
502        let pipeline_id = self.pipeline.lock().unwrap().unwrap_or(RenderPipelineId(0));
503
504        // Prepare Draw Commands
505        let mut draw_commands = Vec::with_capacity(render_world.meshes.len());
506
507        let mut temp_buffers = Vec::new();
508        let mut temp_bind_groups = Vec::new();
509
510        for extracted_mesh in &render_world.meshes {
511            if let Some(gpu_mesh_handle) = gpu_mesh_assets.get(&extracted_mesh.cpu_mesh_uuid) {
512                // Create Per-Mesh Uniforms
513                let model_mat = extracted_mesh.transform.to_matrix();
514
515                // Strict check: if the matrix is not invertible, skip
516                let normal_mat = if let Some(inverse) = model_mat.inverse() {
517                    inverse.transpose()
518                } else {
519                    continue;
520                };
521
522                let mut base_color = khora_core::math::LinearRgba::WHITE;
523                let mut emissive = khora_core::math::LinearRgba::BLACK;
524                let mut specular_power = 32.0;
525
526                if let Some(mat_handle) = &extracted_mesh.material {
527                    base_color = mat_handle.base_color();
528                    emissive = mat_handle.emissive_color();
529                    specular_power = mat_handle.specular_power();
530                }
531
532                let model_uniforms = ModelUniforms {
533                    model_matrix: model_mat.to_cols_array_2d(),
534                    normal_matrix: normal_mat.to_cols_array_2d(),
535                };
536
537                let model_buffer = match device.create_buffer_with_data(
538                    &BufferDescriptor {
539                        label: None,
540                        size: std::mem::size_of::<ModelUniforms>() as u64,
541                        usage: BufferUsage::UNIFORM | BufferUsage::COPY_DST,
542                        mapped_at_creation: false,
543                    },
544                    bytemuck::bytes_of(&model_uniforms),
545                ) {
546                    Ok(b) => {
547                        temp_buffers.push(b);
548                        b
549                    }
550                    Err(_) => continue,
551                };
552
553                let material_uniforms = MaterialUniforms {
554                    base_color,
555                    emissive: emissive.with_alpha(specular_power),
556                    ambient: khora_core::math::LinearRgba::new(0.1, 0.1, 0.1, 0.0),
557                };
558                let material_buffer = match device.create_buffer_with_data(
559                    &BufferDescriptor {
560                        label: None,
561                        size: std::mem::size_of::<MaterialUniforms>() as u64,
562                        usage: BufferUsage::UNIFORM | BufferUsage::COPY_DST,
563                        mapped_at_creation: false,
564                    },
565                    bytemuck::bytes_of(&material_uniforms),
566                ) {
567                    Ok(b) => {
568                        temp_buffers.push(b);
569                        b
570                    }
571                    Err(_) => continue,
572                };
573
574                // Create Bind Groups 1 & 2
575                let mut model_bg = None;
576                if let Some(layout) = *self.model_layout.lock().unwrap() {
577                    if let Ok(bg) = device.create_bind_group(&BindGroupDescriptor {
578                        label: None,
579                        layout,
580                        entries: &[BindGroupEntry {
581                            binding: 0,
582                            resource: BindingResource::Buffer(BufferBinding {
583                                buffer: model_buffer,
584                                offset: 0,
585                                size: None,
586                            }),
587                            _phantom: std::marker::PhantomData,
588                        }],
589                    }) {
590                        model_bg = Some(bg);
591                        temp_bind_groups.push(bg);
592                    }
593                }
594
595                let mut material_bg = None;
596                if let Some(layout) = *self.material_layout.lock().unwrap() {
597                    if let Ok(bg) = device.create_bind_group(&BindGroupDescriptor {
598                        label: None,
599                        layout,
600                        entries: &[BindGroupEntry {
601                            binding: 0,
602                            resource: BindingResource::Buffer(BufferBinding {
603                                buffer: material_buffer,
604                                offset: 0,
605                                size: None,
606                            }),
607                            _phantom: std::marker::PhantomData,
608                        }],
609                    }) {
610                        material_bg = Some(bg);
611                        temp_bind_groups.push(bg);
612                    }
613                }
614
615                draw_commands.push(khora_core::renderer::api::command::DrawCommand {
616                    pipeline: pipeline_id,
617                    vertex_buffer: gpu_mesh_handle.vertex_buffer,
618                    index_buffer: gpu_mesh_handle.index_buffer,
619                    index_format: gpu_mesh_handle.index_format,
620                    index_count: gpu_mesh_handle.index_count,
621                    model_bind_group: model_bg,
622                    model_offset: 0,
623                    material_bind_group: material_bg,
624                    material_offset: 0,
625                });
626            }
627        }
628
629        // Render Pass
630        let color_attachment = RenderPassColorAttachment {
631            view: render_ctx.color_target,
632            resolve_target: None,
633            ops: Operations {
634                load: LoadOp::Clear(render_ctx.clear_color),
635                store: StoreOp::Store,
636            },
637            base_array_layer: 0,
638        };
639
640        let render_pass_desc = RenderPassDescriptor {
641            label: Some("Lit Forward Pass"),
642            color_attachments: &[color_attachment],
643            depth_stencil_attachment: render_ctx.depth_target.map(|depth_view| {
644                RenderPassDepthStencilAttachment {
645                    view: depth_view,
646                    depth_ops: Some(Operations {
647                        load: LoadOp::Clear(1.0),
648                        store: StoreOp::Store,
649                    }),
650                    stencil_ops: None,
651                    base_array_layer: 0,
652                }
653            }),
654        };
655
656        // Build Lighting Bind Group with Shadow Atlas
657        // The pipeline's group 3 layout expects 3 bindings: uniform buffer + shadow atlas + shadow sampler.
658        // All 3 must be present for the bind group to be valid.
659        let final_lighting_bind_group = if let Some(layout) = *self.light_layout.lock().unwrap() {
660            match (render_ctx.shadow_atlas, render_ctx.shadow_sampler) {
661                (Some(atlas), Some(sampler)) => {
662                    let entries = [
663                        BindGroupEntry {
664                            binding: 0,
665                            resource: BindingResource::Buffer(BufferBinding {
666                                buffer: lighting_ring_buffer_id,
667                                offset: 0,
668                                size: None,
669                            }),
670                            _phantom: std::marker::PhantomData,
671                        },
672                        BindGroupEntry {
673                            binding: 1,
674                            resource: BindingResource::TextureView(*atlas),
675                            _phantom: std::marker::PhantomData,
676                        },
677                        BindGroupEntry {
678                            binding: 2,
679                            resource: BindingResource::Sampler(*sampler),
680                            _phantom: std::marker::PhantomData,
681                        },
682                    ];
683
684                    match device.create_bind_group(&BindGroupDescriptor {
685                        label: Some("lit_forward_lighting_bind_group_dynamic"),
686                        layout,
687                        entries: &entries,
688                    }) {
689                        Ok(bg) => {
690                            temp_bind_groups.push(bg);
691                            bg
692                        }
693                        Err(e) => {
694                            log::error!(
695                                "LitForwardLane: Failed to create lighting bind group: {:?}",
696                                e
697                            );
698                            return;
699                        }
700                    }
701                }
702                _ => {
703                    log::warn!(
704                        "LitForwardLane: shadow atlas/sampler not available, skipping lit render"
705                    );
706                    return;
707                }
708            }
709        } else {
710            log::warn!("LitForwardLane: light layout not initialized");
711            return;
712        };
713
714        let mut render_pass = encoder.begin_render_pass(&render_pass_desc);
715
716        // Bind global camera and lighting
717        render_pass.set_bind_group(0, &camera_bind_group, &[]);
718        render_pass.set_bind_group(3, &final_lighting_bind_group, &[]);
719
720        let mut current_pipeline: Option<RenderPipelineId> = None;
721
722        for cmd in &draw_commands {
723            if current_pipeline != Some(pipeline_id) {
724                render_pass.set_pipeline(&pipeline_id);
725                current_pipeline = Some(pipeline_id);
726            }
727
728            if let Some(bg) = &cmd.model_bind_group {
729                render_pass.set_bind_group(1, bg, &[]);
730            }
731
732            if let Some(bg) = &cmd.material_bind_group {
733                render_pass.set_bind_group(2, bg, &[]);
734            }
735
736            render_pass.set_vertex_buffer(0, &cmd.vertex_buffer, 0);
737            render_pass.set_index_buffer(&cmd.index_buffer, 0, cmd.index_format);
738
739            render_pass.draw_indexed(0..cmd.index_count, 0, 0..1);
740        }
741
742        // Clean up temporary resources (they remain alive on the GPU until the command buffer finishes)
743        for bg in temp_bind_groups {
744            let _ = device.destroy_bind_group(bg);
745        }
746        for buf in temp_buffers {
747            let _ = device.destroy_buffer(buf);
748        }
749    }
750
751    fn estimate_render_cost(
752        &self,
753        render_world: &RenderWorld,
754        gpu_meshes: &RwLock<Assets<GpuMesh>>,
755    ) -> f32 {
756        let gpu_mesh_assets = gpu_meshes.read().unwrap();
757
758        let mut total_triangles = 0u32;
759        let mut draw_call_count = 0u32;
760
761        for extracted_mesh in &render_world.meshes {
762            if let Some(gpu_mesh) = gpu_mesh_assets.get(&extracted_mesh.cpu_mesh_uuid) {
763                // Calculate triangle count based on primitive topology
764                let triangle_count = match gpu_mesh.primitive_topology {
765                    PrimitiveTopology::TriangleList => gpu_mesh.index_count / 3,
766                    PrimitiveTopology::TriangleStrip => {
767                        if gpu_mesh.index_count >= 3 {
768                            gpu_mesh.index_count - 2
769                        } else {
770                            0
771                        }
772                    }
773                    PrimitiveTopology::LineList
774                    | PrimitiveTopology::LineStrip
775                    | PrimitiveTopology::PointList => 0,
776                };
777
778                total_triangles += triangle_count;
779                draw_call_count += 1;
780            }
781        }
782
783        // Base cost from triangles and draw calls
784        let base_cost =
785            (total_triangles as f32 * TRIANGLE_COST) + (draw_call_count as f32 * DRAW_CALL_COST);
786
787        // Apply shader complexity multiplier
788        let shader_factor = self.shader_complexity.cost_multiplier();
789
790        // Apply light-based cost scaling
791        let light_factor = self.light_cost_factor(render_world);
792
793        // Total cost combines all factors
794        base_cost * shader_factor * light_factor
795    }
796
797    fn on_gpu_init(
798        &self,
799        device: &dyn khora_core::renderer::GraphicsDevice,
800    ) -> Result<(), khora_core::renderer::error::RenderError> {
801        use crate::render_lane::shaders::LIT_FORWARD_WGSL;
802        use khora_core::renderer::api::{
803            command::{
804                BindGroupLayoutDescriptor, BindGroupLayoutEntry, BindingType, BufferBindingType,
805            },
806            core::{ShaderModuleDescriptor, ShaderSourceData},
807            pipeline::enums::{CompareFunction, VertexFormat, VertexStepMode},
808            pipeline::state::{ColorWrites, DepthBiasState, StencilFaceState},
809            pipeline::{
810                ColorTargetStateDescriptor, DepthStencilStateDescriptor,
811                MultisampleStateDescriptor, PrimitiveStateDescriptor, RenderPipelineDescriptor,
812                VertexAttributeDescriptor, VertexBufferLayoutDescriptor,
813            },
814            util::{SampleCount, ShaderStageFlags, TextureFormat},
815        };
816        use std::borrow::Cow;
817
818        log::info!("LitForwardLane: Initializing GPU resources...");
819
820        // 1. Create Bind Group Layouts
821
822        // Group 0: Camera
823        let camera_layout = device
824            .create_bind_group_layout(&BindGroupLayoutDescriptor {
825                label: Some("lit_forward_camera_layout"),
826                entries: &[BindGroupLayoutEntry {
827                    binding: 0,
828                    visibility: ShaderStageFlags::VERTEX | ShaderStageFlags::FRAGMENT,
829                    ty: BindingType::Buffer {
830                        ty: BufferBindingType::Uniform,
831                        has_dynamic_offset: false,
832                        min_binding_size: None,
833                    },
834                }],
835            })
836            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
837
838        // Group 1: Model
839        let model_layout = device
840            .create_bind_group_layout(&BindGroupLayoutDescriptor {
841                label: Some("lit_forward_model_layout"),
842                entries: &[BindGroupLayoutEntry {
843                    binding: 0,
844                    visibility: ShaderStageFlags::VERTEX,
845                    ty: BindingType::Buffer {
846                        ty: BufferBindingType::Uniform,
847                        has_dynamic_offset: false, // Using simple uniform for now, could be dynamic later
848                        min_binding_size: None,
849                    },
850                }],
851            })
852            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
853
854        // Group 2: Material
855        let material_layout = device
856            .create_bind_group_layout(&BindGroupLayoutDescriptor {
857                label: Some("lit_forward_material_layout"),
858                entries: &[BindGroupLayoutEntry {
859                    binding: 0,
860                    visibility: ShaderStageFlags::FRAGMENT,
861                    ty: BindingType::Buffer {
862                        ty: BufferBindingType::Uniform,
863                        has_dynamic_offset: false,
864                        min_binding_size: None,
865                    },
866                }],
867            })
868            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
869
870        // Group 3: Lights
871        use khora_core::renderer::api::command::{SamplerBindingType, TextureSampleType};
872        let light_layout = device
873            .create_bind_group_layout(&BindGroupLayoutDescriptor {
874                label: Some("lit_forward_light_layout"),
875                entries: &[
876                    BindGroupLayoutEntry {
877                        binding: 0,
878                        visibility: ShaderStageFlags::FRAGMENT,
879                        ty: BindingType::Buffer {
880                            ty: BufferBindingType::Uniform,
881                            has_dynamic_offset: false,
882                            min_binding_size: None,
883                        },
884                    },
885                    BindGroupLayoutEntry {
886                        binding: 1,
887                        visibility: ShaderStageFlags::FRAGMENT,
888                        ty: BindingType::Texture {
889                            sample_type: TextureSampleType::Depth,
890                            view_dimension:
891                                khora_core::renderer::api::command::TextureViewDimension::D2Array,
892                            multisampled: false,
893                        },
894                    },
895                    BindGroupLayoutEntry {
896                        binding: 2,
897                        visibility: ShaderStageFlags::FRAGMENT,
898                        ty: BindingType::Sampler(SamplerBindingType::Comparison),
899                    },
900                ],
901            })
902            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
903
904        // 2. Create Shader Module
905        let shader_module = device
906            .create_shader_module(&ShaderModuleDescriptor {
907                label: Some("lit_forward_shader"),
908                source: ShaderSourceData::Wgsl(Cow::Borrowed(LIT_FORWARD_WGSL)),
909            })
910            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
911
912        // 3. Create Pipeline
913        let vertex_attributes = vec![
914            VertexAttributeDescriptor {
915                format: VertexFormat::Float32x3,
916                offset: 0,
917                shader_location: 0,
918            },
919            VertexAttributeDescriptor {
920                format: VertexFormat::Float32x3,
921                offset: 12,
922                shader_location: 1,
923            },
924            VertexAttributeDescriptor {
925                format: VertexFormat::Float32x2,
926                offset: 24,
927                shader_location: 2,
928            },
929        ];
930
931        let vertex_layout = VertexBufferLayoutDescriptor {
932            array_stride: 32, // 3+3+2 floats * 4 bytes
933            step_mode: VertexStepMode::Vertex,
934            attributes: Cow::Owned(vertex_attributes),
935        };
936
937        let pipeline_layout_ids = vec![camera_layout, model_layout, material_layout, light_layout];
938        // Store bind group layouts for creating bind groups during rendering.
939        *self.camera_layout.lock().unwrap() = Some(camera_layout);
940        *self.model_layout.lock().unwrap() = Some(model_layout);
941        *self.material_layout.lock().unwrap() = Some(material_layout);
942        *self.light_layout.lock().unwrap() = Some(light_layout);
943
944        // Create the pipeline layout from our bind group layouts.
945        let pipeline_layout_desc = khora_core::renderer::api::pipeline::PipelineLayoutDescriptor {
946            label: Some(Cow::Borrowed("LitForward Pipeline Layout")),
947            bind_group_layouts: &pipeline_layout_ids,
948        };
949
950        let pipeline_layout_id = device
951            .create_pipeline_layout(&pipeline_layout_desc)
952            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
953
954        let pipeline_desc = RenderPipelineDescriptor {
955            label: Some(Cow::Borrowed("LitForward Pipeline")),
956            layout: Some(pipeline_layout_id),
957            vertex_shader_module: shader_module,
958            vertex_entry_point: Cow::Borrowed("vs_main"),
959            fragment_shader_module: Some(shader_module),
960            fragment_entry_point: Some(Cow::Borrowed("fs_main")),
961            vertex_buffers_layout: Cow::Owned(vec![vertex_layout]),
962            primitive_state: PrimitiveStateDescriptor {
963                topology: PrimitiveTopology::TriangleList,
964                ..Default::default()
965            },
966            depth_stencil_state: Some(DepthStencilStateDescriptor {
967                format: TextureFormat::Depth32Float,
968                depth_write_enabled: true,
969                depth_compare: CompareFunction::Less,
970                stencil_front: StencilFaceState::default(),
971                stencil_back: StencilFaceState::default(),
972                stencil_read_mask: 0,
973                stencil_write_mask: 0,
974                bias: DepthBiasState::default(),
975            }),
976            color_target_states: Cow::Owned(vec![ColorTargetStateDescriptor {
977                format: device
978                    .get_surface_format()
979                    .unwrap_or(TextureFormat::Rgba8UnormSrgb),
980                blend: None,
981                write_mask: ColorWrites::ALL,
982            }]),
983            multisample_state: MultisampleStateDescriptor {
984                count: SampleCount::X1,
985                mask: !0,
986                alpha_to_coverage_enabled: false,
987            },
988        };
989
990        let pipeline_id = device
991            .create_render_pipeline(&pipeline_desc)
992            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
993
994        let mut pipeline_lock = self.pipeline.lock().unwrap();
995        *pipeline_lock = Some(pipeline_id);
996
997        // 4. Create Persistent Ring Buffers for camera and lighting uniforms.
998        // This eliminates per-frame buffer allocation in the render hot path.
999
1000        let camera_ring = UniformRingBuffer::new(
1001            device,
1002            camera_layout,
1003            0,
1004            std::mem::size_of::<khora_core::renderer::api::resource::CameraUniformData>() as u64,
1005            "Camera Uniform Ring",
1006        )
1007        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
1008
1009        // The lighting ring buffer needs its own 1-binding layout (just the uniform buffer).
1010        // The full 3-binding light_layout (uniform + shadow atlas + shadow sampler) is used
1011        // for the pipeline and per-frame bind group creation in render().
1012        let lighting_buffer_layout = device
1013            .create_bind_group_layout(&BindGroupLayoutDescriptor {
1014                label: Some("lit_forward_lighting_buffer_layout"),
1015                entries: &[BindGroupLayoutEntry {
1016                    binding: 0,
1017                    visibility: ShaderStageFlags::FRAGMENT,
1018                    ty: BindingType::Buffer {
1019                        ty: BufferBindingType::Uniform,
1020                        has_dynamic_offset: false,
1021                        min_binding_size: None,
1022                    },
1023                }],
1024            })
1025            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
1026
1027        *self.lighting_buffer_layout.lock().unwrap() = Some(lighting_buffer_layout);
1028
1029        let lighting_ring = UniformRingBuffer::new(
1030            device,
1031            lighting_buffer_layout,
1032            0,
1033            std::mem::size_of::<LightingUniforms>() as u64,
1034            "Lighting Uniform Ring",
1035        )
1036        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
1037
1038        *self.camera_ring.lock().unwrap() = Some(camera_ring);
1039        *self.lighting_ring.lock().unwrap() = Some(lighting_ring);
1040
1041        log::info!(
1042            "LitForwardLane: Persistent ring buffers created (camera: {} bytes, lighting: {} bytes, {} slots each)",
1043            std::mem::size_of::<khora_core::renderer::api::resource::CameraUniformData>(),
1044            std::mem::size_of::<LightingUniforms>(),
1045            khora_core::renderer::api::core::MAX_FRAMES_IN_FLIGHT,
1046        );
1047
1048        Ok(())
1049    }
1050
1051    fn on_gpu_shutdown(&self, device: &dyn khora_core::renderer::GraphicsDevice) {
1052        // Destroy ring buffers first (they own buffers + bind groups)
1053        if let Some(ring) = self.camera_ring.lock().unwrap().take() {
1054            ring.destroy(device);
1055        }
1056        if let Some(ring) = self.lighting_ring.lock().unwrap().take() {
1057            ring.destroy(device);
1058        }
1059
1060        let mut pipeline_lock = self.pipeline.lock().unwrap();
1061        if let Some(id) = pipeline_lock.take() {
1062            let _ = device.destroy_render_pipeline(id);
1063        }
1064        if let Some(id) = self.camera_layout.lock().unwrap().take() {
1065            let _ = device.destroy_bind_group_layout(id);
1066        }
1067        if let Some(id) = self.model_layout.lock().unwrap().take() {
1068            let _ = device.destroy_bind_group_layout(id);
1069        }
1070        if let Some(id) = self.material_layout.lock().unwrap().take() {
1071            let _ = device.destroy_bind_group_layout(id);
1072        }
1073        if let Some(id) = self.light_layout.lock().unwrap().take() {
1074            let _ = device.destroy_bind_group_layout(id);
1075        }
1076        if let Some(id) = self.lighting_buffer_layout.lock().unwrap().take() {
1077            let _ = device.destroy_bind_group_layout(id);
1078        }
1079    }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085    use crate::render_lane::world::ExtractedMesh;
1086    use khora_core::lane::Lane;
1087    use khora_core::{
1088        asset::{AssetHandle, AssetUUID},
1089        math::{affine_transform::AffineTransform, Mat4},
1090        renderer::{
1091            api::{pipeline::enums::PrimitiveTopology, resource::BufferId, util::IndexFormat},
1092            light::DirectionalLight,
1093        },
1094    };
1095    use std::sync::Arc;
1096
1097    fn create_test_gpu_mesh(index_count: u32) -> GpuMesh {
1098        GpuMesh {
1099            vertex_buffer: BufferId(0),
1100            index_buffer: BufferId(1),
1101            index_count,
1102            index_format: IndexFormat::Uint32,
1103            primitive_topology: PrimitiveTopology::TriangleList,
1104        }
1105    }
1106
1107    #[test]
1108    fn test_lit_forward_lane_creation() {
1109        let lane = LitForwardLane::new();
1110        assert_eq!(lane.strategy_name(), "LitForward");
1111        assert_eq!(lane.shader_complexity, ShaderComplexity::SimpleLit);
1112    }
1113
1114    #[test]
1115    fn test_lit_forward_lane_with_complexity() {
1116        let lane = LitForwardLane::with_complexity(ShaderComplexity::FullPBR);
1117        assert_eq!(lane.shader_complexity, ShaderComplexity::FullPBR);
1118    }
1119
1120    #[test]
1121    fn test_shader_complexity_ordering() {
1122        assert!(ShaderComplexity::Unlit < ShaderComplexity::SimpleLit);
1123        assert!(ShaderComplexity::SimpleLit < ShaderComplexity::FullPBR);
1124    }
1125
1126    #[test]
1127    fn test_shader_complexity_cost_multipliers() {
1128        assert_eq!(ShaderComplexity::Unlit.cost_multiplier(), 1.0);
1129        assert_eq!(ShaderComplexity::SimpleLit.cost_multiplier(), 1.5);
1130        assert_eq!(ShaderComplexity::FullPBR.cost_multiplier(), 2.5);
1131    }
1132
1133    #[test]
1134    fn test_cost_estimation_empty_world() {
1135        let lane = LitForwardLane::new();
1136        let render_world = RenderWorld::default();
1137        let gpu_meshes = Arc::new(RwLock::new(Assets::<GpuMesh>::new()));
1138
1139        let cost = lane.estimate_render_cost(&render_world, &gpu_meshes);
1140        assert_eq!(cost, 0.0, "Empty world should have zero cost");
1141    }
1142
1143    #[test]
1144    fn test_cost_estimation_with_meshes() {
1145        let lane = LitForwardLane::new();
1146
1147        // Create a GPU mesh with 300 indices (100 triangles)
1148        let mesh_uuid = AssetUUID::new();
1149        let gpu_mesh = create_test_gpu_mesh(300);
1150
1151        let mut gpu_meshes = Assets::<GpuMesh>::new();
1152        gpu_meshes.insert(mesh_uuid, AssetHandle::new(gpu_mesh));
1153
1154        let mut render_world = RenderWorld::default();
1155        render_world.meshes.push(ExtractedMesh {
1156            transform: AffineTransform::default(),
1157            cpu_mesh_uuid: mesh_uuid,
1158            gpu_mesh: AssetHandle::new(create_test_gpu_mesh(300)),
1159            material: None,
1160        });
1161
1162        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
1163        let cost = lane.estimate_render_cost(&render_world, &gpu_meshes_lock);
1164
1165        // Base cost without lights: 100 * 0.001 + 1 * 0.1 = 0.2
1166        // With SimpleLit multiplier (1.5) and no lights (factor 1.0):
1167        // 0.2 * 1.5 * 1.0 = 0.3
1168        assert!(
1169            (cost - 0.3).abs() < 0.0001,
1170            "Cost should be 0.3 for 100 triangles with SimpleLit complexity, got {}",
1171            cost
1172        );
1173    }
1174
1175    #[test]
1176    fn test_cost_estimation_with_lights() {
1177        use crate::render_lane::world::ExtractedLight;
1178        use khora_core::{
1179            math::{Mat4, Vec3},
1180            renderer::light::LightType,
1181        };
1182
1183        let lane = LitForwardLane::new();
1184
1185        // Create a GPU mesh
1186        let mesh_uuid = AssetUUID::new();
1187        let gpu_mesh = create_test_gpu_mesh(300);
1188
1189        let mut gpu_meshes = Assets::<GpuMesh>::new();
1190        gpu_meshes.insert(mesh_uuid, AssetHandle::new(gpu_mesh));
1191
1192        let mut render_world = RenderWorld::default();
1193        render_world.meshes.push(ExtractedMesh {
1194            transform: AffineTransform::default(),
1195            cpu_mesh_uuid: mesh_uuid,
1196            gpu_mesh: AssetHandle::new(create_test_gpu_mesh(300)),
1197            material: None,
1198        });
1199
1200        // Add 4 directional lights
1201        for _ in 0..4 {
1202            render_world.lights.push(ExtractedLight {
1203                light_type: LightType::Directional(DirectionalLight::default()),
1204                position: Vec3::ZERO,
1205                direction: Vec3::new(0.0, -1.0, 0.0),
1206                shadow_view_proj: Mat4::IDENTITY,
1207                shadow_atlas_index: None,
1208            });
1209        }
1210
1211        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
1212        let cost = lane.estimate_render_cost(&render_world, &gpu_meshes_lock);
1213
1214        // Base cost: 0.2
1215        // Shader multiplier (SimpleLit): 1.5
1216        // Light factor: 1.0 + (4 * 0.05) = 1.2
1217        // Total: 0.2 * 1.5 * 1.2 = 0.36
1218        assert!(
1219            (cost - 0.36).abs() < 0.0001,
1220            "Cost should be 0.36 with 4 lights, got {}",
1221            cost
1222        );
1223    }
1224
1225    #[test]
1226    fn test_cost_increases_with_complexity() {
1227        let mesh_uuid = AssetUUID::new();
1228        let gpu_mesh = create_test_gpu_mesh(300);
1229
1230        let mut gpu_meshes = Assets::<GpuMesh>::new();
1231        gpu_meshes.insert(mesh_uuid, AssetHandle::new(gpu_mesh));
1232
1233        let mut render_world = RenderWorld::default();
1234        render_world.meshes.push(ExtractedMesh {
1235            transform: AffineTransform::default(),
1236            cpu_mesh_uuid: mesh_uuid,
1237            gpu_mesh: AssetHandle::new(create_test_gpu_mesh(300)),
1238            material: None,
1239        });
1240
1241        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
1242
1243        let unlit_lane = LitForwardLane::with_complexity(ShaderComplexity::Unlit);
1244        let simple_lane = LitForwardLane::with_complexity(ShaderComplexity::SimpleLit);
1245        let pbr_lane = LitForwardLane::with_complexity(ShaderComplexity::FullPBR);
1246
1247        let unlit_cost = unlit_lane.estimate_render_cost(&render_world, &gpu_meshes_lock);
1248        let simple_cost = simple_lane.estimate_render_cost(&render_world, &gpu_meshes_lock);
1249        let pbr_cost = pbr_lane.estimate_render_cost(&render_world, &gpu_meshes_lock);
1250
1251        assert!(
1252            unlit_cost < simple_cost,
1253            "Unlit should be cheaper than SimpleLit"
1254        );
1255        assert!(
1256            simple_cost < pbr_cost,
1257            "SimpleLit should be cheaper than PBR"
1258        );
1259    }
1260
1261    #[test]
1262    fn test_effective_light_counts() {
1263        use crate::render_lane::world::ExtractedLight;
1264        use khora_core::{
1265            math::Vec3,
1266            renderer::light::{LightType, PointLight},
1267        };
1268
1269        let lane = LitForwardLane {
1270            max_directional_lights: 2,
1271            max_point_lights: 4,
1272            max_spot_lights: 2,
1273            ..Default::default()
1274        };
1275
1276        let mut render_world = RenderWorld::default();
1277
1278        // Add 5 directional lights (max is 2)
1279        for _ in 0..5 {
1280            render_world.lights.push(ExtractedLight {
1281                light_type: LightType::Directional(DirectionalLight::default()),
1282                position: Vec3::ZERO,
1283                direction: Vec3::new(0.0, -1.0, 0.0),
1284                shadow_view_proj: Mat4::IDENTITY,
1285                shadow_atlas_index: None,
1286            });
1287        }
1288
1289        // Add 3 point lights (max is 4)
1290        for _ in 0..3 {
1291            render_world.lights.push(ExtractedLight {
1292                light_type: LightType::Point(PointLight::default()),
1293                position: Vec3::ZERO,
1294                direction: Vec3::ZERO,
1295                shadow_view_proj: Mat4::IDENTITY,
1296                shadow_atlas_index: None,
1297            });
1298        }
1299
1300        let (dir, point, spot) = lane.effective_light_counts(&render_world);
1301        assert_eq!(dir, 2, "Should be clamped to max 2 directional lights");
1302        assert_eq!(point, 3, "Should use all 3 point lights (under max)");
1303        assert_eq!(spot, 0, "Should have 0 spot lights");
1304    }
1305
1306    #[test]
1307    fn test_get_pipeline_for_material() {
1308        let lane = LitForwardLane::new();
1309
1310        // No GPU init → pipeline not yet created → fallback to RenderPipelineId(0)
1311        let pipeline = lane.get_pipeline_for_material(None);
1312        assert_eq!(pipeline, RenderPipelineId(0));
1313
1314        // Same for repeated calls
1315        let pipeline = lane.get_pipeline_for_material(None);
1316        assert_eq!(pipeline, RenderPipelineId(0));
1317    }
1318}