Skip to main content

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