Skip to main content

khora_lanes/render_lane/
forward_plus_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//! Forward+ (Tiled Forward) rendering lane implementation.
16//!
17//! This module implements a Forward+ rendering strategy that uses a compute shader
18//! to perform per-tile light culling before the main render pass. This approach
19//! significantly reduces the number of lights processed per fragment, making it
20//! ideal for scenes with many lights (>20).
21//!
22//! # Architecture
23//!
24//! The Forward+ pipeline works in two stages:
25//!
26//! 1. **Light Culling (Compute Pass)**: The screen is divided into tiles (16x16 pixels).
27//!    For each tile, the compute shader determines which lights intersect the tile's
28//!    frustum and builds a list of affecting light indices.
29//!
30//! 2. **Rendering (Render Pass)**: Each fragment looks up its tile's light list and
31//!    only evaluates lighting for those specific lights, rather than all lights in the scene.
32//!
33//! # Performance Characteristics
34//!
35//! - **O(tiles × lights)** for light culling (compute pass)
36//! - **O(fragments × lights_per_tile)** for shading (render pass)
37//! - **Suitable for**: Scenes with many lights (>20)
38//! - **Break-even point**: ~20 lights (vs standard forward rendering)
39//!
40//! # SAA Compliance (Symbiotic Adaptive Architecture)
41//!
42//! This lane integrates with GORNA through:
43//! - `estimate_cost()`: Provides accurate cost estimation including compute overhead
44//! - Configurable tile size and max lights per tile
45//! - Runtime-adjustable configuration via `ForwardPlusTileConfig`
46
47use crate::render_lane::ShaderComplexity;
48use khora_data::render::RenderWorld;
49
50use khora_core::renderer::api::{
51    command::BindGroupLayoutId,
52    util::{
53        dynamic_uniform_buffer::DynamicUniformRingBuffer, uniform_ring_buffer::UniformRingBuffer,
54    },
55};
56use khora_core::{
57    asset::Material,
58    renderer::{
59        api::{
60            command::{
61                BindGroupId, ComputePassDescriptor, ComputePipelineId, LoadOp, Operations,
62                RenderPassColorAttachment, RenderPassDepthStencilAttachment, RenderPassDescriptor,
63                StoreOp,
64            },
65            core::RenderContext,
66            pipeline::enums::PrimitiveTopology,
67            pipeline::{
68                ComputePipelineSpec, LayoutKey, LayoutSpec, PipelineSpec, RenderPipelineId,
69                ShaderVariantKey,
70            },
71            resource::{BufferId, CameraUniformData},
72            scene::GpuMesh,
73        },
74        traits::CommandEncoder,
75        ForwardPlusTileConfig,
76    },
77};
78use khora_data::assets::Assets;
79use std::sync::RwLock;
80
81// --- Cost Estimation Constants ---
82
83/// Base cost per triangle rendered.
84const TRIANGLE_COST: f32 = 0.001;
85
86/// Cost per draw call issued.
87const DRAW_CALL_COST: f32 = 0.1;
88
89/// Fixed overhead for the compute pass (tile frustum + dispatch).
90const COMPUTE_PASS_OVERHEAD: f32 = 0.5;
91
92/// Cost factor per tile in the light culling pass.
93const PER_TILE_COST: f32 = 0.0001;
94
95/// Cost factor per light-tile intersection test.
96const LIGHT_TILE_TEST_COST: f32 = 0.00001;
97
98/// Binding indices inside the group-3 *lighting* bind group, mirroring
99/// `forward_plus.wgsl`. Bindings 1/2/3 belong to the shared shadow
100/// contract (`khora_data::render::shadow_bindings::binding`) — Forward+
101/// owns 0, 4, 5, 6, 7 around them. See the canonical render bind-group
102/// convention in `.agent/conventions.md`.
103mod g3 {
104    /// `lights` storage buffer.
105    pub const LIGHTS: u32 = 0;
106    /// `light_indices` storage buffer (per-tile light lists).
107    pub const LIGHT_INDICES: u32 = 4;
108    /// `light_grid` storage buffer (per-tile offset/count pairs).
109    pub const LIGHT_GRID: u32 = 5;
110    /// `tile_info` uniform buffer.
111    pub const TILE_INFO: u32 = 6;
112    /// `light_shadow_view_projs` storage buffer.
113    pub const SHADOW_VIEW_PROJS: u32 = 7;
114}
115
116// --- ForwardPlusLane ---
117
118/// GPU resource handles for the Forward+ compute pass.
119///
120/// These are created during lane initialization and used each frame
121/// for light culling and rendering.
122#[derive(Debug, Default)]
123pub struct ForwardPlusGpuResources {
124    /// Buffer containing all GpuLight instances.
125    pub light_buffer: Option<BufferId>,
126    /// Buffer containing per-tile light index lists.
127    pub light_index_buffer: Option<BufferId>,
128    /// Buffer containing (offset, count) pairs per tile.
129    pub light_grid_buffer: Option<BufferId>,
130    /// Buffer containing tile info for the fragment shader.
131    pub tile_info_buffer: Option<BufferId>,
132    /// Uniform buffer for culling parameters.
133    pub culling_uniforms_buffer: Option<BufferId>,
134    /// Storage buffer with per-light 2D shadow view-projection matrices,
135    /// indexed identically to the `light_buffer`.
136    pub shadow_view_projs_buffer: Option<BufferId>,
137
138    /// Bind group layout for Group 0 (Camera).
139    pub camera_layout: Option<BindGroupLayoutId>,
140    /// Bind group layout for Group 1 (Model).
141    pub model_layout: Option<BindGroupLayoutId>,
142    /// Bind group layout for Group 2 (Material).
143    pub material_layout: Option<BindGroupLayoutId>,
144    /// Bind group layout for Group 3 — the lighting domain: light list,
145    /// shadow atlases, per-tile culling results + shadow view-projs.
146    pub lighting_layout: Option<BindGroupLayoutId>,
147    /// Bind group layout for Culling compute pass.
148    pub culling_layout: Option<BindGroupLayoutId>,
149
150    /// Ring buffer for camera uniforms.
151    pub camera_ring: Option<UniformRingBuffer>,
152    /// Ring buffer for model uniforms.
153    pub model_ring: Option<DynamicUniformRingBuffer>,
154
155    /// Bind group for the culling compute shader.
156    pub culling_bind_group: Option<BindGroupId>,
157    /// Compute pipeline for light culling.
158    pub culling_pipeline: Option<ComputePipelineId>,
159    /// Render pipeline for the Forward+ pass.
160    pub render_pipeline: Option<RenderPipelineId>,
161}
162
163impl ForwardPlusGpuResources {
164    /// Returns true if all required resources are initialized.
165    pub fn is_initialized(&self) -> bool {
166        self.light_buffer.is_some()
167            && self.light_index_buffer.is_some()
168            && self.light_grid_buffer.is_some()
169            && self.culling_uniforms_buffer.is_some()
170            && self.culling_bind_group.is_some()
171            && self.culling_pipeline.is_some()
172    }
173}
174
175/// A rendering lane that implements Forward+ (Tiled Forward) rendering.
176///
177/// Forward+ divides the screen into tiles and uses a compute shader to determine
178/// which lights affect each tile before the main render pass. This significantly
179/// reduces per-fragment lighting cost for scenes with many lights.
180///
181/// # Configuration
182///
183/// The lane is configured via `ForwardPlusTileConfig`, which controls:
184/// - **Tile size**: 16x16 or 32x32 pixels (trade-off between culling granularity and overhead)
185/// - **Max lights per tile**: Memory budget for per-tile light lists
186/// - **Depth pre-pass**: Optional optimization for depth-bounded light culling
187pub struct ForwardPlusLane {
188    /// Tile configuration for light culling.
189    pub tile_config: ForwardPlusTileConfig,
190
191    /// Shader complexity for cost estimation.
192    pub shader_complexity: ShaderComplexity,
193
194    /// Current screen dimensions (for tile count calculation).
195    screen_size: (u32, u32),
196
197    /// GPU resources for compute and render passes.
198    pub gpu_resources: std::sync::Mutex<ForwardPlusGpuResources>,
199
200    /// Backend pipeline system, retained so the render path can resolve the
201    /// per-material-variant pipeline (cheap cache hit). Set in `on_gpu_init`.
202    pipeline_system:
203        std::sync::OnceLock<std::sync::Arc<dyn khora_core::renderer::traits::PipelineSystem>>,
204}
205
206impl Default for ForwardPlusLane {
207    fn default() -> Self {
208        Self {
209            tile_config: ForwardPlusTileConfig::default(),
210            shader_complexity: ShaderComplexity::SimpleLit,
211            screen_size: (1920, 1080),
212            gpu_resources: std::sync::Mutex::new(ForwardPlusGpuResources::default()),
213            pipeline_system: std::sync::OnceLock::new(),
214        }
215    }
216}
217
218impl ForwardPlusLane {
219    /// Creates a new `ForwardPlusLane` with default settings.
220    ///
221    /// Default configuration:
222    /// - Tile size: 16x16 pixels
223    /// - Max lights per tile: 128
224    /// - No depth pre-pass
225    pub fn new() -> Self {
226        Self::default()
227    }
228
229    /// Creates a new `ForwardPlusLane` with the specified configuration.
230    ///
231    /// # Arguments
232    ///
233    /// * `config` - The tile configuration for light culling
234    pub fn with_config(config: ForwardPlusTileConfig) -> Self {
235        Self {
236            tile_config: config,
237            ..Default::default()
238        }
239    }
240
241    /// Creates a new `ForwardPlusLane` with the specified shader complexity.
242    pub fn with_complexity(complexity: ShaderComplexity) -> Self {
243        Self {
244            shader_complexity: complexity,
245            ..Default::default()
246        }
247    }
248
249    /// Updates the screen size used for tile calculations.
250    ///
251    /// This should be called when the window is resized to recalculate
252    /// tile counts and buffer sizes.
253    pub fn set_screen_size(&mut self, width: u32, height: u32) {
254        self.screen_size = (width, height);
255    }
256
257    /// Calculates the number of tiles in each dimension.
258    pub fn tile_count(&self) -> (u32, u32) {
259        self.tile_config
260            .tile_dimensions(self.screen_size.0, self.screen_size.1)
261    }
262
263    /// Calculates the total number of tiles on screen.
264    pub fn total_tiles(&self) -> u32 {
265        let (tiles_x, tiles_y) = self.tile_count();
266        tiles_x * tiles_y
267    }
268
269    /// Returns the effective number of lights in the scene.
270    ///
271    /// This counts all light types (directional, point, spot) that will be
272    /// processed by the light culling pass.
273    pub fn effective_light_count(&self, render_world: &RenderWorld) -> usize {
274        render_world.directional_light_count()
275            + render_world.point_light_count()
276            + render_world.spot_light_count()
277    }
278
279    /// Estimates the cost of the compute pass (light culling).
280    fn compute_pass_cost(&self, render_world: &RenderWorld) -> f32 {
281        let total_tiles = self.total_tiles() as f32;
282        let light_count = self.effective_light_count(render_world) as f32;
283
284        // Compute pass cost = overhead + per-tile cost + light-tile tests
285        COMPUTE_PASS_OVERHEAD
286            + (total_tiles * PER_TILE_COST)
287            + (total_tiles * light_count * LIGHT_TILE_TEST_COST)
288    }
289
290    /// Calculates the per-fragment light cost factor.
291    ///
292    /// For Forward+, this uses sqrt(total_lights) instead of linear scaling
293    /// because lights are culled per-tile, so each fragment only processes
294    /// a subset of lights.
295    fn fragment_light_factor(&self, render_world: &RenderWorld) -> f32 {
296        let total_lights = self.effective_light_count(render_world) as f32;
297
298        if total_lights == 0.0 {
299            return 1.0;
300        }
301
302        // Sublinear scaling: sqrt(lights) because of tile culling
303        // Clamped to max_lights_per_tile
304        let effective_lights = total_lights
305            .sqrt()
306            .min(self.tile_config.max_lights_per_tile as f32);
307
308        1.0 + (effective_lights * 0.02)
309    }
310}
311
312impl khora_core::lane::Lane for ForwardPlusLane {
313    fn strategy_name(&self) -> &'static str {
314        "ForwardPlus"
315    }
316
317    fn lane_kind(&self) -> khora_core::lane::LaneKind {
318        khora_core::lane::LaneKind::Render
319    }
320
321    fn estimate_cost(&self, ctx: &khora_core::lane::LaneContext) -> f32 {
322        let render_world = match ctx.get::<khora_core::lane::Ref<khora_data::render::RenderWorld>>()
323        {
324            Some(slot) => slot.get(),
325            None => return 1.0,
326        };
327        let gpu_meshes = match ctx.get::<std::sync::Arc<
328            std::sync::RwLock<
329                khora_data::assets::Assets<khora_core::renderer::api::scene::GpuMesh>,
330            >,
331        >>() {
332            Some(arc) => arc,
333            None => return 1.0,
334        };
335        self.estimate_render_cost(render_world, gpu_meshes)
336    }
337
338    fn on_initialize(
339        &self,
340        ctx: &mut khora_core::lane::LaneContext,
341    ) -> Result<(), khora_core::lane::LaneError> {
342        let device = ctx
343            .get::<std::sync::Arc<dyn khora_core::renderer::GraphicsDevice>>()
344            .ok_or(khora_core::lane::LaneError::missing(
345                "Arc<dyn GraphicsDevice>",
346            ))?
347            .clone();
348        let pipeline_system = ctx
349            .get::<std::sync::Arc<dyn khora_core::renderer::traits::PipelineSystem>>()
350            .ok_or(khora_core::lane::LaneError::missing(
351                "Arc<dyn PipelineSystem>",
352            ))?
353            .clone();
354        // Retain the system so the render hot path can resolve a pipeline per
355        // material variant (a cheap cache hit after first compile).
356        let _ = self.pipeline_system.set(pipeline_system.clone());
357        self.on_gpu_init(device.as_ref(), pipeline_system.as_ref())
358            .map_err(|e| khora_core::lane::LaneError::InitializationFailed(Box::new(e)))
359    }
360
361    fn execute(
362        &self,
363        ctx: &mut khora_core::lane::LaneContext,
364    ) -> Result<(), khora_core::lane::LaneError> {
365        use khora_core::lane::{LaneError, Ref, Slot};
366        let device = ctx
367            .get::<std::sync::Arc<dyn khora_core::renderer::GraphicsDevice>>()
368            .ok_or(LaneError::missing("Arc<dyn GraphicsDevice>"))?
369            .clone();
370        let gpu_meshes = ctx
371            .get::<std::sync::Arc<
372                std::sync::RwLock<
373                    khora_data::assets::Assets<khora_core::renderer::api::scene::GpuMesh>,
374                >,
375            >>()
376            .ok_or(LaneError::missing("Arc<RwLock<Assets<GpuMesh>>>"))?
377            .clone();
378        let encoder = ctx
379            .get::<Slot<dyn khora_core::renderer::traits::CommandEncoder>>()
380            .ok_or(LaneError::missing("Slot<dyn CommandEncoder>"))?
381            .get();
382        let render_world = ctx
383            .get::<Ref<khora_data::render::RenderWorld>>()
384            .ok_or(LaneError::missing("Ref<RenderWorld>"))?
385            .get();
386        let color_target = ctx
387            .get::<khora_core::lane::ColorTarget>()
388            .ok_or(LaneError::missing("ColorTarget"))?
389            .0;
390        let depth_target = ctx
391            .get::<khora_core::lane::DepthTarget>()
392            .ok_or(LaneError::missing("DepthTarget"))?
393            .0;
394        let clear_color = ctx
395            .get::<khora_core::lane::ClearColor>()
396            .ok_or(LaneError::missing("ClearColor"))?
397            .0;
398
399        let render_ctx = khora_core::renderer::api::core::RenderContext::new(
400            &color_target,
401            Some(&depth_target),
402            clear_color,
403        );
404
405        // Read the per-frame `ShadowFrame` published by whichever shadow
406        // strategy ran. Mirror of LitForwardLane's pattern — single
407        // cross-lane channel.
408        let (shadow_entries, shadow_bindings) = ctx
409            .get::<Slot<khora_core::lane::OutputDeck>>()
410            .map(|s| {
411                let frame = s
412                    .get()
413                    .slot::<khora_core::renderer::api::shadow::ShadowFrame>();
414                (frame.entries.clone(), frame.bindings)
415            })
416            .unwrap_or_default();
417
418        let ibl_bindings = ctx
419            .get::<khora_core::renderer::api::ibl::IblGpuBindings>()
420            .copied();
421
422        self.render(
423            render_world,
424            &shadow_entries,
425            shadow_bindings,
426            ibl_bindings,
427            device.as_ref(),
428            encoder,
429            &render_ctx,
430            &gpu_meshes,
431        );
432        Ok(())
433    }
434
435    fn on_shutdown(&self, ctx: &mut khora_core::lane::LaneContext) {
436        if let Some(device) = ctx.get::<std::sync::Arc<dyn khora_core::renderer::GraphicsDevice>>()
437        {
438            self.on_gpu_shutdown(device.as_ref());
439        }
440    }
441
442    fn as_any(&self) -> &dyn std::any::Any {
443        self
444    }
445
446    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
447        self
448    }
449}
450
451impl ForwardPlusLane {
452    /// Returns the render pipeline for the given material (or default).
453    pub fn get_pipeline_for_material(
454        &self,
455        _material: Option<&khora_core::asset::AssetHandle<Box<dyn Material>>>,
456    ) -> RenderPipelineId {
457        // Return the stored pipeline. Fallback to pipeline 0 if on_gpu_init
458        // hasn't run yet OR if the mutex is poisoned (degraded rendering
459        // rather than crashing the frame loop — R7).
460        let resources = crate::lock_or_log!(
461            self.gpu_resources.lock(),
462            "ForwardPlusLane::get_pipeline_for_material",
463            RenderPipelineId(0)
464        );
465        resources.render_pipeline.unwrap_or(RenderPipelineId(0))
466    }
467
468    #[allow(clippy::too_many_arguments)]
469    fn render(
470        &self,
471        render_world: &RenderWorld,
472        shadow_entries: &khora_data::render::ShadowEntries,
473        shadow_bindings: Option<khora_data::render::ShadowGpuBindings>,
474        ibl_bindings: Option<khora_core::renderer::api::ibl::IblGpuBindings>,
475        device: &dyn khora_core::renderer::GraphicsDevice,
476        encoder: &mut dyn CommandEncoder,
477        render_ctx: &RenderContext,
478        gpu_meshes: &RwLock<Assets<GpuMesh>>,
479    ) {
480        let mut resources =
481            crate::lock_or_log!(self.gpu_resources.lock(), "ForwardPlusLane::render");
482
483        // 1. Get Active Camera View.
484        //
485        // When no camera is present (e.g. editor viewport before any in-scene
486        // camera is added), a clear render pass must still be submitted
487        let Some(view) = render_world.views.first() else {
488            let color_attachment = khora_core::renderer::api::command::RenderPassColorAttachment {
489                view: render_ctx.color_target,
490                resolve_target: None,
491                ops: khora_core::renderer::api::command::Operations {
492                    load: khora_core::renderer::api::command::LoadOp::Clear(render_ctx.clear_color),
493                    store: khora_core::renderer::api::command::StoreOp::Store,
494                },
495                base_array_layer: 0,
496                base_mip_level: 0,
497            };
498            let clear_desc = khora_core::renderer::api::command::RenderPassDescriptor {
499                label: Some("ForwardPlus Clear-Only Pass"),
500                color_attachments: &[color_attachment],
501                depth_stencil_attachment: render_ctx.depth_target.map(|depth_view| {
502                    khora_core::renderer::api::command::RenderPassDepthStencilAttachment {
503                        view: depth_view,
504                        depth_ops: Some(khora_core::renderer::api::command::Operations {
505                            load: khora_core::renderer::api::command::LoadOp::Clear(1.0),
506                            store: khora_core::renderer::api::command::StoreOp::Store,
507                        }),
508                        stencil_ops: None,
509                        base_array_layer: 0,
510                    }
511                }),
512            };
513            let _ = encoder.begin_render_pass(&clear_desc);
514            return;
515        };
516
517        // 2. Prepare Camera Uniforms (Group 0)
518        let camera_uniforms = CameraUniformData {
519            view_projection: view.view_proj.to_cols_array_2d(),
520            camera_position: [view.position.x, view.position.y, view.position.z, 1.0],
521        };
522
523        let camera_bind_group = if let Some(ref mut ring) = resources.camera_ring {
524            ring.advance();
525            if let Err(e) = ring.write(device, bytemuck::bytes_of(&camera_uniforms)) {
526                log::error!("Failed to write camera ring buffer: {:?}", e);
527                return;
528            }
529            *ring.current_bind_group()
530        } else {
531            return;
532        };
533
534        // 3. Prepare Tile Info (sent to Group 3)
535        if let Some(tile_buffer) = resources.tile_info_buffer {
536            let config = self.tile_config;
537            let (width, height) = self.screen_size;
538            let num_tiles_x = width.div_ceil(config.tile_size.pixels());
539            let num_tiles_y = height.div_ceil(config.tile_size.pixels());
540            let tile_info = [
541                num_tiles_x,
542                num_tiles_y,
543                config.tile_size.pixels(),
544                config.max_lights_per_tile,
545            ];
546            if let Err(e) = device.write_buffer(tile_buffer, 0, bytemuck::cast_slice(&tile_info)) {
547                log::error!("ForwardPlusLane::render: tile info buffer write failed: {e:?}");
548            }
549        }
550
551        // 4. Update Light Data — fold per-light shadow metadata from the
552        // ShadowFrame into each GpuLight, and accumulate a parallel
553        // `shadow_view_projs` buffer indexed identically to `lights`.
554        //
555        // Directional / spot: `shadow_map_index` = 2D atlas layer,
556        // `shadow_view_projs[i]` carries the per-light VP matrix.
557        // Point: `shadow_map_index` = cube atlas index,
558        // `shadow_far_plane` = perspective far plane used by the shadow
559        // pass (matches `ShadowEntry::Cube.far_plane`).
560        use khora_data::render::ShadowEntry;
561        let lights: Vec<_> = render_world
562            .lights
563            .iter()
564            .enumerate()
565            .map(|(i, l)| {
566                let mut gl = khora_core::renderer::GpuLight::from_parts(
567                    [l.position.x, l.position.y, l.position.z],
568                    [l.direction.x, l.direction.y, l.direction.z],
569                    &l.light_type,
570                );
571                match shadow_entries.get(i) {
572                    Some(ShadowEntry::Atlas2D { atlas_index, .. }) => {
573                        gl.shadow_map_index = *atlas_index;
574                    }
575                    Some(ShadowEntry::Cube {
576                        cube_array_index,
577                        far_plane,
578                        ..
579                    }) => {
580                        gl.shadow_map_index = *cube_array_index;
581                        gl.shadow_far_plane = *far_plane;
582                    }
583                    None => {}
584                }
585                gl
586            })
587            .collect();
588
589        if let Some(light_buffer) = resources.light_buffer {
590            if let Err(e) = device.write_buffer(light_buffer, 0, bytemuck::cast_slice(&lights)) {
591                log::error!("ForwardPlusLane::render: light data buffer write failed: {e:?}");
592            }
593        }
594
595        // Parallel shadow view-projection matrices — directional / spot
596        // pull from the entry's `view_proj`, everything else gets the
597        // identity (bypassed via `shadow_map_index < 0` early-out in
598        // `sample_shadow_pcf`).
599        let shadow_view_projs: Vec<[[f32; 4]; 4]> = render_world
600            .lights
601            .iter()
602            .enumerate()
603            .map(|(i, _)| match shadow_entries.get(i) {
604                Some(ShadowEntry::Atlas2D { view_proj, .. }) => view_proj.to_cols_array_2d(),
605                _ => khora_core::math::Mat4::IDENTITY.to_cols_array_2d(),
606            })
607            .collect();
608
609        if let Some(svp_buffer) = resources.shadow_view_projs_buffer {
610            if !shadow_view_projs.is_empty() {
611                if let Err(e) =
612                    device.write_buffer(svp_buffer, 0, bytemuck::cast_slice(&shadow_view_projs))
613                {
614                    log::error!(
615                        "ForwardPlusLane::render: shadow view-proj buffer write failed: {e:?}"
616                    );
617                }
618            }
619        }
620
621        // Prepare and write Culling Uniforms
622        if let Some(culling_buffer) = resources.culling_uniforms_buffer {
623            let config = self.tile_config;
624            let (width, height) = self.screen_size;
625            let num_tiles_x = width.div_ceil(config.tile_size.pixels());
626            let num_tiles_y = height.div_ceil(config.tile_size.pixels());
627
628            let inv_vp = view.view_proj.inverse().unwrap_or_default();
629
630            let culling_data = khora_core::renderer::api::scene::CullingUniformsData {
631                view_projection: view.view_proj.to_cols_array_2d(),
632                inverse_projection: inv_vp.to_cols_array_2d(),
633                screen_dimensions: [width as f32, height as f32],
634                tile_count: [num_tiles_x, num_tiles_y],
635                num_lights: lights.len() as u32,
636                tile_size: config.tile_size.pixels(),
637                _padding: [0.0; 2],
638            };
639
640            if let Err(e) =
641                device.write_buffer(culling_buffer, 0, bytemuck::bytes_of(&culling_data))
642            {
643                log::error!("ForwardPlusLane::render: culling uniforms buffer write failed: {e:?}");
644            }
645        }
646
647        // Run Culling Compute Pass
648        if let (Some(culling_pipeline), Some(culling_bg)) =
649            (resources.culling_pipeline, resources.culling_bind_group)
650        {
651            let mut compute_pass = encoder.begin_compute_pass(&ComputePassDescriptor {
652                label: Some("Forward+ Light Culling Pass"),
653                timestamp_writes: None,
654            });
655            compute_pass.set_pipeline(&culling_pipeline);
656            compute_pass.set_bind_group(0, &culling_bg, &[]);
657
658            let config = self.tile_config;
659            let (width, height) = self.screen_size;
660            let num_tiles_x = width.div_ceil(config.tile_size.pixels());
661            let num_tiles_y = height.div_ceil(config.tile_size.pixels());
662            compute_pass.dispatch_workgroups(num_tiles_x, num_tiles_y, 1);
663        }
664
665        // 5. Prepare Per-Mesh Data (Dynamic Uniforms). Opaque draws batch by
666        // pipeline; blended draws are deferred to a back-to-front sorted batch.
667        let mut draw_commands = Vec::new();
668        let mut transparent_draws: Vec<(f32, khora_core::renderer::api::command::DrawCommand)> =
669            Vec::new();
670
671        if let Some(ref mut ring) = resources.model_ring {
672            ring.advance();
673        }
674
675        // Fallback pipeline (empty variant) used only if the per-variant
676        // resolve fails.
677        let fallback_pipeline = resources.render_pipeline.unwrap_or(RenderPipelineId(0));
678
679        let gpu_mesh_assets = crate::lock_or_log!(gpu_meshes.read(), "ForwardPlusLane::render");
680        for extracted_mesh in &render_world.meshes {
681            if let Some(gpu_mesh_handle) = gpu_mesh_assets.get(&extracted_mesh.cpu_mesh_uuid) {
682                // The material projection tags every rendered entity with a
683                // `GpuMaterial` before RenderFlow runs.
684                let Some(gpu_material) = &extracted_mesh.gpu_material else {
685                    continue;
686                };
687
688                // Resolve the pipeline for this material's texture variant
689                // (cheap cache hit after first compile; never retained).
690                let pipeline_id = self
691                    .pipeline_system
692                    .get()
693                    .map(|ps| {
694                        ps.pipeline(
695                            device,
696                            &render_pipeline_spec(
697                                device,
698                                gpu_material.variant.clone(),
699                                gpu_material.double_sided,
700                                gpu_material.blend,
701                            ),
702                        )
703                    })
704                    .transpose()
705                    .unwrap_or_else(|e| {
706                        log::error!("ForwardPlusLane: pipeline resolve failed: {:?}", e);
707                        None
708                    })
709                    .unwrap_or(fallback_pipeline);
710
711                // Compute Matrices
712                let model_mat = extracted_mesh.transform.to_matrix();
713                let normal_mat = model_mat.inverse().unwrap_or_default().transpose();
714
715                let model_uniforms = khora_core::renderer::api::scene::ModelUniforms {
716                    model_matrix: model_mat.to_cols_array_2d(),
717                    normal_matrix: normal_mat.to_cols_array_2d(),
718                };
719
720                // Model transform → dynamic ring; material → cached GpuMaterial.
721                let (model_bg, model_offset) = if let Some(ref mut ring) = resources.model_ring {
722                    let offset = match ring.push(device, bytemuck::bytes_of(&model_uniforms)) {
723                        Ok(off) => off,
724                        Err(_) => continue,
725                    };
726                    (*ring.current_bind_group(), offset)
727                } else {
728                    continue;
729                };
730
731                let command = khora_core::renderer::api::command::DrawCommand {
732                    pipeline: pipeline_id,
733                    vertex_buffer: gpu_mesh_handle.vertex_buffer,
734                    index_buffer: gpu_mesh_handle.index_buffer,
735                    index_count: gpu_mesh_handle.index_count,
736                    index_format: gpu_mesh_handle.index_format,
737                    model_bind_group: Some(model_bg),
738                    model_offset,
739                    material_bind_group: Some(gpu_material.bind_group),
740                    material_offset: 0,
741                };
742                if gpu_material.blend {
743                    transparent_draws.push((
744                        crate::render_lane::camera_distance_sq(&model_mat, view.position),
745                        command,
746                    ));
747                } else {
748                    draw_commands.push(command);
749                }
750            }
751        }
752
753        // Batch by pipeline (variant): one `set_pipeline` per variant.
754        draw_commands.sort_by_key(|cmd| cmd.pipeline.0);
755        // Transparent draws sort farthest-first instead: correct compositing
756        // outranks pipeline batching.
757        transparent_draws
758            .sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
759
760        // 6. Render Pass
761        let color_attachment = RenderPassColorAttachment {
762            view: render_ctx.color_target,
763            resolve_target: None,
764            ops: Operations {
765                load: LoadOp::Clear(render_ctx.clear_color),
766                store: StoreOp::Store,
767            },
768            base_array_layer: 0,
769            base_mip_level: 0,
770        };
771
772        let render_pass_desc = RenderPassDescriptor {
773            label: Some("ForwardPlus Render Pass"),
774            color_attachments: &[color_attachment],
775            depth_stencil_attachment: render_ctx.depth_target.map(|depth_view| {
776                RenderPassDepthStencilAttachment {
777                    view: depth_view,
778                    depth_ops: Some(Operations {
779                        load: LoadOp::Clear(1.0),
780                        store: StoreOp::Store,
781                    }),
782                    stencil_ops: None,
783                    base_array_layer: 0,
784                }
785            }),
786        };
787
788        // Build the per-frame group-3 (lighting) bind group. It packs
789        // the lighting domain into one group per the canonical 4-group
790        // render convention: the light list (0), the shadow atlases
791        // (1/2/3, from the shared `khora::shadow::bindings` contract),
792        // the per-tile culling results (4/5/6) and the shadow
793        // view-projections (7). It mixes persistent light buffers with
794        // the per-frame shadow atlas views, so it cannot be cached.
795        //
796        // If no shadow strategy ran this frame, skip — wgpu requires
797        // every pipeline-declared group to be bound.
798        let Some(shadow_bindings) = shadow_bindings else {
799            log::warn!(
800                "ForwardPlusLane: ShadowGpuBindings not available (shadow agent inactive?), skipping render"
801            );
802            return;
803        };
804        let (
805            Some(lighting_layout),
806            Some(light_buffer),
807            Some(light_index_buffer),
808            Some(light_grid_buffer),
809            Some(tile_info_buffer),
810            Some(shadow_view_projs_buffer),
811        ) = (
812            resources.lighting_layout,
813            resources.light_buffer,
814            resources.light_index_buffer,
815            resources.light_grid_buffer,
816            resources.tile_info_buffer,
817            resources.shadow_view_projs_buffer,
818        )
819        else {
820            log::warn!("ForwardPlusLane: lighting GPU resources not initialized, skipping render");
821            return;
822        };
823
824        use khora_core::renderer::api::command::{BindGroupDescriptor, BindGroupEntry};
825        let mut lighting_entries: Vec<BindGroupEntry> = Vec::with_capacity(8);
826        lighting_entries.push(BindGroupEntry::buffer(g3::LIGHTS, light_buffer, 0, None));
827        // Bindings 1/2/3 — shadow atlas 2D + sampler + cube atlas.
828        khora_data::render::shadow_bindings::fill_shadow_bind_group_entries(
829            &shadow_bindings,
830            &mut lighting_entries,
831        );
832        lighting_entries.push(BindGroupEntry::buffer(
833            g3::LIGHT_INDICES,
834            light_index_buffer,
835            0,
836            None,
837        ));
838        lighting_entries.push(BindGroupEntry::buffer(
839            g3::LIGHT_GRID,
840            light_grid_buffer,
841            0,
842            None,
843        ));
844        lighting_entries.push(BindGroupEntry::buffer(
845            g3::TILE_INFO,
846            tile_info_buffer,
847            0,
848            None,
849        ));
850        lighting_entries.push(BindGroupEntry::buffer(
851            g3::SHADOW_VIEW_PROJS,
852            shadow_view_projs_buffer,
853            0,
854            None,
855        ));
856        // Bindings 8..12 — image-based lighting (Forward+ packs its culling
857        // buffers at 4..8, so IBL follows at 8).
858        let Some(ibl) = ibl_bindings else {
859            log::warn!("ForwardPlusLane: IBL bindings not available, skipping render");
860            return;
861        };
862        khora_core::renderer::api::ibl::fill_ibl_bind_group_entries(&ibl, 8, &mut lighting_entries);
863        let lighting_bg = match device.create_bind_group(&BindGroupDescriptor {
864            label: Some("forward_plus_lighting_bind_group"),
865            layout: lighting_layout,
866            entries: &lighting_entries,
867        }) {
868            Ok(bg) => bg,
869            Err(e) => {
870                log::error!(
871                    "ForwardPlusLane: failed to create lighting bind group: {:?}",
872                    e
873                );
874                return;
875            }
876        };
877
878        let mut render_pass = encoder.begin_render_pass(&render_pass_desc);
879
880        // Bind Group 0: Camera
881        render_pass.set_bind_group(0, &camera_bind_group, &[]);
882
883        // Bind Group 3: Lighting (lights + shadows + tile culling).
884        render_pass.set_bind_group(3, &lighting_bg, &[]);
885
886        // Draw Cached Commands — set the pipeline once per variant (commands
887        // are pre-sorted by pipeline above). Opaque first, so it fills the depth
888        // buffer the transparent pass tests against, then blended draws
889        // farthest-first.
890        let mut current_pipeline: Option<RenderPipelineId> = None;
891        for cmd in draw_commands
892            .iter()
893            .chain(transparent_draws.iter().map(|(_, cmd)| cmd))
894        {
895            if current_pipeline != Some(cmd.pipeline) {
896                render_pass.set_pipeline(&cmd.pipeline);
897                current_pipeline = Some(cmd.pipeline);
898            }
899            if let Some(ref bg) = cmd.model_bind_group {
900                render_pass.set_bind_group(1, bg, &[cmd.model_offset]);
901            }
902            if let Some(ref bg) = cmd.material_bind_group {
903                render_pass.set_bind_group(2, bg, &[]);
904            }
905
906            render_pass.set_vertex_buffer(0, &cmd.vertex_buffer, 0);
907            render_pass.set_index_buffer(&cmd.index_buffer, 0, cmd.index_format);
908            render_pass.draw_indexed(0..cmd.index_count, 0, 0..1);
909        }
910
911        drop(render_pass);
912        let _ = device.destroy_bind_group(lighting_bg);
913    }
914
915    fn estimate_render_cost(
916        &self,
917        render_world: &RenderWorld,
918        gpu_meshes: &RwLock<Assets<GpuMesh>>,
919    ) -> f32 {
920        let gpu_mesh_assets = crate::lock_or_log!(
921            gpu_meshes.read(),
922            "ForwardPlusLane::estimate_render_cost",
923            0.0
924        );
925
926        let mut total_triangles = 0u32;
927        let mut draw_call_count = 0u32;
928
929        for extracted_mesh in &render_world.meshes {
930            if let Some(gpu_mesh) = gpu_mesh_assets.get(&extracted_mesh.cpu_mesh_uuid) {
931                let triangle_count = match gpu_mesh.primitive_topology {
932                    PrimitiveTopology::TriangleList => gpu_mesh.index_count / 3,
933                    PrimitiveTopology::TriangleStrip => {
934                        if gpu_mesh.index_count >= 3 {
935                            gpu_mesh.index_count - 2
936                        } else {
937                            0
938                        }
939                    }
940                    PrimitiveTopology::LineList
941                    | PrimitiveTopology::LineStrip
942                    | PrimitiveTopology::PointList => 0,
943                };
944
945                total_triangles += triangle_count;
946                draw_call_count += 1;
947            }
948        }
949
950        // Base geometry cost
951        let geometry_cost =
952            (total_triangles as f32 * TRIANGLE_COST) + (draw_call_count as f32 * DRAW_CALL_COST);
953
954        // Shader complexity multiplier
955        let shader_multiplier = self.shader_complexity.cost_multiplier();
956
957        // Compute pass overhead
958        let compute_cost = self.compute_pass_cost(render_world);
959
960        // Per-fragment light factor (sublinear for Forward+)
961        let light_factor = self.fragment_light_factor(render_world);
962
963        // Total cost
964        compute_cost + (geometry_cost * shader_multiplier * light_factor)
965    }
966
967    fn on_gpu_init(
968        &self,
969        device: &dyn khora_core::renderer::GraphicsDevice,
970        pipeline_system: &dyn khora_core::renderer::traits::PipelineSystem,
971    ) -> Result<(), khora_core::renderer::error::RenderError> {
972        use khora_core::renderer::api::{
973            command::{BindGroupDescriptor, BindGroupEntry},
974            resource::CameraUniformData,
975            scene::ModelUniforms,
976        };
977        use std::borrow::Cow;
978
979        log::info!("ForwardPlusLane: Initializing GPU resources...");
980
981        // Layouts — canonical Camera (group 0) + Material (group 2) come from
982        // the backend's cache (shared with the lit lanes); the per-draw Model
983        // (group 1), Forward+ lighting (group 3) and the compute culling layout
984        // are bespoke inline layouts shared with the lane's pipelines + buffers.
985        let variant = ShaderVariantKey::empty();
986        let camera_layout = pipeline_system.layout(device, LayoutKey::Camera, &variant)?;
987        let material_layout = pipeline_system.layout(device, LayoutKey::Material, &variant)?;
988        let model_layout = pipeline_system.inline_layout(
989            device,
990            FP_MODEL_LAYOUT_LABEL,
991            &fp_model_layout_entries(),
992        )?;
993        let lighting_layout = pipeline_system.inline_layout(
994            device,
995            FP_LIGHTING_LAYOUT_LABEL,
996            &fp_lighting_layout_entries(),
997        )?;
998        let culling_layout = pipeline_system.inline_layout(
999            device,
1000            FP_CULLING_LAYOUT_LABEL,
1001            &fp_culling_layout_entries(),
1002        )?;
1003
1004        // Pipelines (compiled + cached by the backend). Warm the empty-variant
1005        // render pipeline (untextured materials); textured variants compile
1006        // lazily in the render path keyed by `GpuMaterial::variant`.
1007        let pipeline_id = pipeline_system.pipeline(
1008            device,
1009            &render_pipeline_spec(device, ShaderVariantKey::empty(), false, false),
1010        )?;
1011        let culling_pipeline =
1012            pipeline_system.compute_pipeline(device, &culling_pipeline_spec())?;
1013
1014        // 3. Create Buffers and Rings
1015
1016        // Light Data Buffer
1017        let light_buffer = device
1018            .create_buffer(&khora_core::renderer::api::resource::BufferDescriptor {
1019                label: Some(Cow::Borrowed("Forward+ Light Buffer")),
1020                size: 64 * 1024,
1021                usage: khora_core::renderer::api::resource::BufferUsage::STORAGE
1022                    | khora_core::renderer::api::resource::BufferUsage::COPY_DST,
1023                mapped_at_creation: false,
1024            })
1025            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
1026
1027        // Light Index List
1028        let light_index_buffer = device
1029            .create_buffer(&khora_core::renderer::api::resource::BufferDescriptor {
1030                label: Some(Cow::Borrowed("Forward+ Light Index Buffer")),
1031                size: 120 * 68 * 256 * 4,
1032                usage: khora_core::renderer::api::resource::BufferUsage::STORAGE
1033                    | khora_core::renderer::api::resource::BufferUsage::COPY_DST,
1034                mapped_at_creation: false,
1035            })
1036            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
1037
1038        // Light Grid
1039        let light_grid_buffer = device
1040            .create_buffer(&khora_core::renderer::api::resource::BufferDescriptor {
1041                label: Some(Cow::Borrowed("Forward+ Light Grid Buffer")),
1042                size: 120 * 68 * 2 * 4,
1043                usage: khora_core::renderer::api::resource::BufferUsage::STORAGE
1044                    | khora_core::renderer::api::resource::BufferUsage::COPY_DST,
1045                mapped_at_creation: false,
1046            })
1047            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
1048
1049        // Tile Info Buffer
1050        let tile_info_buffer = device
1051            .create_buffer(&khora_core::renderer::api::resource::BufferDescriptor {
1052                label: Some(Cow::Borrowed("Forward+ Tile Info")),
1053                size: 256,
1054                usage: khora_core::renderer::api::resource::BufferUsage::UNIFORM
1055                    | khora_core::renderer::api::resource::BufferUsage::COPY_DST,
1056                mapped_at_creation: false,
1057            })
1058            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
1059
1060        // Shadow view-projection matrices (one mat4 per light, indexed
1061        // identically to `light_buffer`). 64 KB matches the light buffer
1062        // sizing — ~1024 matrices, well above any realistic scene.
1063        let shadow_view_projs_buffer = device
1064            .create_buffer(&khora_core::renderer::api::resource::BufferDescriptor {
1065                label: Some(Cow::Borrowed("Forward+ Shadow ViewProj Buffer")),
1066                size: 64 * 1024,
1067                usage: khora_core::renderer::api::resource::BufferUsage::STORAGE
1068                    | khora_core::renderer::api::resource::BufferUsage::COPY_DST,
1069                mapped_at_creation: false,
1070            })
1071            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
1072
1073        // Culling Uniforms
1074        let culling_uniforms_buffer = device
1075            .create_buffer(&khora_core::renderer::api::resource::BufferDescriptor {
1076                label: Some(Cow::Borrowed("Forward+ Culling Uniforms")),
1077                size: 256,
1078                usage: khora_core::renderer::api::resource::BufferUsage::UNIFORM
1079                    | khora_core::renderer::api::resource::BufferUsage::COPY_DST,
1080                mapped_at_creation: false,
1081            })
1082            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
1083
1084        // Ring Buffers
1085        let camera_ring = UniformRingBuffer::new(
1086            device,
1087            camera_layout,
1088            0,
1089            std::mem::size_of::<CameraUniformData>() as u64,
1090            "Forward+ Camera Ring",
1091        )
1092        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
1093
1094        let model_ring = DynamicUniformRingBuffer::new(
1095            device,
1096            model_layout,
1097            0,
1098            std::mem::size_of::<ModelUniforms>() as u32,
1099            khora_core::renderer::api::util::dynamic_uniform_buffer::DEFAULT_MAX_ELEMENTS,
1100            khora_core::renderer::api::util::dynamic_uniform_buffer::MIN_UNIFORM_ALIGNMENT,
1101            "Forward+ Model Ring",
1102        )
1103        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
1104
1105        // Materials are no longer per-frame ring uniforms: each material is a
1106        // cached `GpuMaterial` (uniforms + textures + group-2 bind group)
1107        // produced by the data-layer projection. The lane just binds it.
1108
1109        // 4. Bind Groups
1110
1111        let culling_bg = device
1112            .create_bind_group(&BindGroupDescriptor {
1113                label: Some("Forward+ Culling Bind Group"),
1114                layout: culling_layout,
1115                entries: &[
1116                    BindGroupEntry::buffer(0, culling_uniforms_buffer, 0, None),
1117                    BindGroupEntry::buffer(1, light_buffer, 0, None),
1118                    BindGroupEntry::buffer(2, light_index_buffer, 0, None),
1119                    BindGroupEntry::buffer(3, light_grid_buffer, 0, None),
1120                ],
1121            })
1122            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
1123
1124        // The group-3 (lighting) bind group is rebuilt every frame in
1125        // `render` because it combines the persistent light buffers with
1126        // the per-frame shadow atlas views — it cannot be cached here.
1127
1128        // 5. Store all resources
1129        let mut res = self.gpu_resources.lock().map_err(|_| {
1130            khora_core::renderer::error::RenderError::ResourceError(
1131                khora_core::renderer::error::ResourceError::BackendError(
1132                    "ForwardPlusLane gpu_resources mutex poisoned".into(),
1133                ),
1134            )
1135        })?;
1136        res.light_buffer = Some(light_buffer);
1137        res.light_index_buffer = Some(light_index_buffer);
1138        res.light_grid_buffer = Some(light_grid_buffer);
1139        res.tile_info_buffer = Some(tile_info_buffer);
1140        res.culling_uniforms_buffer = Some(culling_uniforms_buffer);
1141        res.shadow_view_projs_buffer = Some(shadow_view_projs_buffer);
1142        res.camera_layout = Some(camera_layout);
1143        res.model_layout = Some(model_layout);
1144        res.material_layout = Some(material_layout);
1145        res.lighting_layout = Some(lighting_layout);
1146        res.culling_layout = Some(culling_layout);
1147        res.camera_ring = Some(camera_ring);
1148        res.model_ring = Some(model_ring);
1149        res.culling_bind_group = Some(culling_bg);
1150        res.culling_pipeline = Some(culling_pipeline);
1151        res.render_pipeline = Some(pipeline_id);
1152
1153        Ok(())
1154    }
1155
1156    fn on_gpu_shutdown(&self, device: &dyn khora_core::renderer::GraphicsDevice) {
1157        let mut resources = crate::lock_or_log!(
1158            self.gpu_resources.lock(),
1159            "ForwardPlusLane::on_gpu_shutdown"
1160        );
1161
1162        if let Some(ring) = resources.camera_ring.take() {
1163            ring.destroy(device);
1164        }
1165        if let Some(ring) = resources.model_ring.take() {
1166            ring.destroy(device);
1167        }
1168
1169        if let Some(id) = resources.light_buffer.take() {
1170            device.destroy_buffer(id).ok();
1171        }
1172        if let Some(id) = resources.light_index_buffer.take() {
1173            device.destroy_buffer(id).ok();
1174        }
1175        if let Some(id) = resources.light_grid_buffer.take() {
1176            let _ = device.destroy_buffer(id);
1177        }
1178        if let Some(id) = resources.culling_uniforms_buffer.take() {
1179            let _ = device.destroy_buffer(id);
1180        }
1181        if let Some(id) = resources.shadow_view_projs_buffer.take() {
1182            let _ = device.destroy_buffer(id);
1183        }
1184    }
1185}
1186
1187// ─── Free functions (CLAD: declarative specs + bespoke layouts) ───
1188
1189/// Stable cache label for the Forward+ per-draw model layout.
1190const FP_MODEL_LAYOUT_LABEL: &str = "forward_plus_model_layout";
1191/// Stable cache label for the Forward+ group-3 lighting layout.
1192const FP_LIGHTING_LAYOUT_LABEL: &str = "forward_plus_lighting_layout";
1193/// Stable cache label for the Forward+ compute culling layout.
1194const FP_CULLING_LAYOUT_LABEL: &str = "forward_plus_culling_layout";
1195
1196/// Bespoke group-1 (model) layout: a single dynamic-offset uniform buffer.
1197fn fp_model_layout_entries() -> Vec<khora_core::renderer::api::command::BindGroupLayoutEntry> {
1198    use khora_core::renderer::api::command::{
1199        BindGroupLayoutEntry, BindingType, BufferBindingType,
1200    };
1201    use khora_core::renderer::api::scene::ModelUniforms;
1202    use khora_core::renderer::api::util::ShaderStageFlags;
1203    vec![BindGroupLayoutEntry {
1204        binding: 0,
1205        visibility: ShaderStageFlags::VERTEX,
1206        ty: BindingType::Buffer {
1207            ty: BufferBindingType::Uniform,
1208            has_dynamic_offset: true,
1209            min_binding_size: std::num::NonZeroU64::new(std::mem::size_of::<ModelUniforms>() as u64),
1210        },
1211    }]
1212}
1213
1214/// Bespoke group-3 (lighting) layout — the canonical 4-group render
1215/// convention's lighting domain. One bind group holds every lighting input:
1216/// the light list (0), the shadow atlases (1/2/3, shared
1217/// `khora::shadow::bindings` contract), the per-tile culling results (4/5/6)
1218/// and the per-light shadow view-projections (7).
1219fn fp_lighting_layout_entries() -> Vec<khora_core::renderer::api::command::BindGroupLayoutEntry> {
1220    use khora_core::renderer::api::command::{BindGroupLayoutEntry, BufferBindingType};
1221    use khora_core::renderer::api::util::ShaderStageFlags;
1222    let mut entries: Vec<BindGroupLayoutEntry> = vec![BindGroupLayoutEntry::buffer(
1223        g3::LIGHTS,
1224        ShaderStageFlags::FRAGMENT,
1225        BufferBindingType::Storage { read_only: true },
1226        false,
1227        None,
1228    )];
1229    entries.extend(khora_data::render::shadow_bindings::shadow_bind_group_layout_entries());
1230    entries.extend([
1231        BindGroupLayoutEntry::buffer(
1232            g3::LIGHT_INDICES,
1233            ShaderStageFlags::FRAGMENT,
1234            BufferBindingType::Storage { read_only: true },
1235            false,
1236            None,
1237        ),
1238        BindGroupLayoutEntry::buffer(
1239            g3::LIGHT_GRID,
1240            ShaderStageFlags::FRAGMENT,
1241            BufferBindingType::Storage { read_only: true },
1242            false,
1243            None,
1244        ),
1245        BindGroupLayoutEntry::buffer(
1246            g3::TILE_INFO,
1247            ShaderStageFlags::FRAGMENT,
1248            BufferBindingType::Uniform,
1249            false,
1250            None,
1251        ),
1252        BindGroupLayoutEntry::buffer(
1253            g3::SHADOW_VIEW_PROJS,
1254            ShaderStageFlags::FRAGMENT,
1255            BufferBindingType::Storage { read_only: true },
1256            false,
1257            None,
1258        ),
1259    ]);
1260    // IBL at 8..12 (irradiance cube, prefiltered cube, BRDF LUT, sampler).
1261    entries.extend(khora_core::renderer::api::ibl::ibl_bind_group_layout_entries(8));
1262    entries
1263}
1264
1265/// Bespoke compute culling layout (compute pass side).
1266fn fp_culling_layout_entries() -> Vec<khora_core::renderer::api::command::BindGroupLayoutEntry> {
1267    use khora_core::renderer::api::command::{BindGroupLayoutEntry, BufferBindingType};
1268    use khora_core::renderer::api::util::ShaderStageFlags;
1269    vec![
1270        BindGroupLayoutEntry::buffer(
1271            0,
1272            ShaderStageFlags::COMPUTE,
1273            BufferBindingType::Uniform,
1274            false,
1275            None,
1276        ),
1277        BindGroupLayoutEntry::buffer(
1278            1,
1279            ShaderStageFlags::COMPUTE,
1280            BufferBindingType::Storage { read_only: true },
1281            false,
1282            None,
1283        ),
1284        BindGroupLayoutEntry::buffer(
1285            2,
1286            ShaderStageFlags::COMPUTE,
1287            BufferBindingType::Storage { read_only: false },
1288            false,
1289            None,
1290        ),
1291        BindGroupLayoutEntry::buffer(
1292            3,
1293            ShaderStageFlags::COMPUTE,
1294            BufferBindingType::Storage { read_only: false },
1295            false,
1296            None,
1297        ),
1298    ]
1299}
1300
1301/// The declarative render pipeline spec for Forward+ under a given material
1302/// variant — built each call, deduped by the `PipelineSystem`. Canonical
1303/// Camera/Material; bespoke Model + Lighting. The group-2 (Material) layout
1304/// resolves to the variant's texture-binding set.
1305fn render_pipeline_spec(
1306    device: &dyn khora_core::renderer::GraphicsDevice,
1307    variant: ShaderVariantKey,
1308    double_sided: bool,
1309    blend: bool,
1310) -> PipelineSpec {
1311    use khora_core::renderer::api::pipeline::enums::{
1312        CompareFunction, CullMode, VertexFormat, VertexStepMode,
1313    };
1314    use khora_core::renderer::api::pipeline::state::{
1315        BlendStateDescriptor, ColorWrites, DepthBiasState, StencilFaceState,
1316    };
1317    use khora_core::renderer::api::pipeline::{
1318        ColorTargetStateDescriptor, DepthStencilStateDescriptor, MultisampleStateDescriptor,
1319        PrimitiveStateDescriptor, VertexAttributeDescriptor, VertexBufferLayoutDescriptor,
1320    };
1321    use khora_core::renderer::api::util::{SampleCount, TextureFormat};
1322    use std::borrow::Cow;
1323
1324    PipelineSpec {
1325        label: "ForwardPlus Pipeline",
1326        shader: "khora::pipelines::forward_plus",
1327        variant,
1328        bind_group_layouts: vec![
1329            LayoutSpec::Named(LayoutKey::Camera),
1330            LayoutSpec::Inline {
1331                label: FP_MODEL_LAYOUT_LABEL,
1332                entries: Cow::Owned(fp_model_layout_entries()),
1333            },
1334            LayoutSpec::Named(LayoutKey::Material),
1335            LayoutSpec::Inline {
1336                label: FP_LIGHTING_LAYOUT_LABEL,
1337                entries: Cow::Owned(fp_lighting_layout_entries()),
1338            },
1339        ],
1340        vertex_buffers: vec![VertexBufferLayoutDescriptor {
1341            array_stride: 32,
1342            step_mode: VertexStepMode::Vertex,
1343            attributes: Cow::Owned(vec![
1344                VertexAttributeDescriptor {
1345                    format: VertexFormat::Float32x3,
1346                    offset: 0,
1347                    shader_location: 0,
1348                },
1349                VertexAttributeDescriptor {
1350                    format: VertexFormat::Float32x3,
1351                    offset: 12,
1352                    shader_location: 1,
1353                },
1354                VertexAttributeDescriptor {
1355                    format: VertexFormat::Float32x2,
1356                    offset: 24,
1357                    shader_location: 2,
1358                },
1359            ]),
1360        }],
1361        vs_entry: "vs_main",
1362        fs_entry: Some("fs_main"),
1363        primitive: PrimitiveStateDescriptor {
1364            topology: PrimitiveTopology::TriangleList,
1365            // Single-sided materials cull back faces; double-sided disable
1366            // culling. The cull mode is part of the pipeline cache key.
1367            cull_mode: if double_sided {
1368                None
1369            } else {
1370                Some(CullMode::Back)
1371            },
1372            ..Default::default()
1373        },
1374        depth_stencil: Some(DepthStencilStateDescriptor {
1375            format: TextureFormat::Depth32Float,
1376            // Transparent surfaces depth-test but never depth-write; see
1377            // `StandardPbrLane::pipeline_spec` for the rationale.
1378            depth_write_enabled: !blend,
1379            depth_compare: CompareFunction::Less,
1380            stencil_front: StencilFaceState::default(),
1381            stencil_back: StencilFaceState::default(),
1382            stencil_read_mask: 0,
1383            stencil_write_mask: 0,
1384            bias: DepthBiasState::default(),
1385        }),
1386        color_targets: vec![ColorTargetStateDescriptor {
1387            format: device
1388                .get_surface_format()
1389                .unwrap_or(TextureFormat::Rgba8UnormSrgb),
1390            blend: blend.then(BlendStateDescriptor::alpha_blending),
1391            write_mask: ColorWrites::ALL,
1392        }],
1393        multisample: MultisampleStateDescriptor {
1394            count: SampleCount::X1,
1395            mask: !0,
1396            alpha_to_coverage_enabled: false,
1397        },
1398    }
1399}
1400
1401/// The declarative compute pipeline spec for Forward+ light culling.
1402fn culling_pipeline_spec() -> ComputePipelineSpec {
1403    use std::borrow::Cow;
1404    ComputePipelineSpec {
1405        label: "Forward+ Culling Pipeline",
1406        shader: "khora::pipelines::light_culling",
1407        variant: ShaderVariantKey::empty(),
1408        bind_group_layouts: vec![LayoutSpec::Inline {
1409            label: FP_CULLING_LAYOUT_LABEL,
1410            entries: Cow::Owned(fp_culling_layout_entries()),
1411        }],
1412        entry_point: "cs_main",
1413    }
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418    use super::*;
1419    use khora_core::lane::Lane;
1420    use khora_core::renderer::TileSize;
1421
1422    #[test]
1423    fn test_forward_plus_lane_creation() {
1424        let lane = ForwardPlusLane::new();
1425        assert_eq!(lane.tile_config.tile_size, TileSize::X16);
1426        assert_eq!(lane.tile_config.max_lights_per_tile, 128);
1427        assert_eq!(lane.shader_complexity, ShaderComplexity::SimpleLit);
1428    }
1429
1430    #[test]
1431    fn test_forward_plus_lane_with_config() {
1432        let config = ForwardPlusTileConfig {
1433            tile_size: TileSize::X32,
1434            max_lights_per_tile: 256,
1435            use_depth_prepass: true,
1436        };
1437        let lane = ForwardPlusLane::with_config(config);
1438
1439        assert_eq!(lane.tile_config.tile_size, TileSize::X32);
1440        assert_eq!(lane.tile_config.max_lights_per_tile, 256);
1441        assert!(lane.tile_config.use_depth_prepass);
1442    }
1443
1444    #[test]
1445    fn test_tile_count_calculation() {
1446        let mut lane = ForwardPlusLane::new();
1447        lane.set_screen_size(1920, 1080);
1448
1449        let (tiles_x, tiles_y) = lane.tile_count();
1450        assert_eq!(tiles_x, 120); // 1920 / 16
1451        assert_eq!(tiles_y, 68); // ceil(1080 / 16)
1452    }
1453
1454    #[test]
1455    fn test_strategy_name() {
1456        let lane = ForwardPlusLane::new();
1457        assert_eq!(lane.strategy_name(), "ForwardPlus");
1458    }
1459
1460    #[test]
1461    fn test_pipeline_id() {
1462        let lane = ForwardPlusLane::new();
1463        // No GPU init → pipeline not yet created → fallback to RenderPipelineId(0)
1464        let pipeline = lane.get_pipeline_for_material(None);
1465        assert_eq!(pipeline, RenderPipelineId(0));
1466    }
1467}