Skip to main content

khora_lanes/render_lane/
standard_pbr_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//! Standard PBR rendering lane — Cook-Torrance BRDF, multi-light, with
16//! shadow sampling. Alternative strategy to `LitForwardLane` /
17//! `ForwardPlusLane`, registered under
18//! [`RenderAgent`](khora_agents::render_agent).
19//!
20//! The pipeline is composed from `khora::pipelines::standard_pbr`,
21//! which shares the same group(3) uniform / shadow layout as
22//! `LitForward` — the only difference is the BRDF in the fragment
23//! shader. Consequently this lane reuses the lit lane's render path
24//! one-for-one, plumbed through a shared free function so the two
25//! lanes never drift apart structurally.
26
27use khora_core::math::Mat4;
28use khora_core::renderer::api::command::BindGroupLayoutId;
29use khora_core::renderer::api::pipeline::{
30    LayoutKey, LayoutSpec, PipelineSpec, RenderPipelineId, ShaderVariantKey,
31};
32use khora_core::renderer::api::util::dynamic_uniform_buffer::DynamicUniformRingBuffer;
33use khora_core::renderer::api::util::uniform_ring_buffer::UniformRingBuffer;
34use khora_core::renderer::{
35    api::{
36        command::{
37            LoadOp, Operations, RenderPassColorAttachment, RenderPassDepthStencilAttachment,
38            RenderPassDescriptor, StoreOp,
39        },
40        core::RenderContext,
41        pipeline::enums::PrimitiveTopology,
42        scene::{
43            DirectionalLightUniform, GpuMesh, LightingUniforms, ModelUniforms, PointLightUniform,
44            SpotLightUniform, MAX_DIRECTIONAL_LIGHTS, MAX_POINT_LIGHTS, MAX_SPOT_LIGHTS,
45        },
46    },
47    traits::CommandEncoder,
48};
49use khora_data::assets::Assets;
50use khora_data::render::RenderWorld;
51use std::sync::{Mutex, OnceLock, RwLock};
52
53/// Full PBR (Cook-Torrance) lit lane.
54///
55/// Per CLAD this struct holds only the lane's persistent state — the
56/// init / render bodies are private free functions in this module, the
57/// trait impl below dispatches to them. No inherent methods other than
58/// what the `Lane` and `Default` traits require.
59#[derive(Default)]
60pub struct StandardPbrLane {
61    pipeline: OnceLock<RenderPipelineId>,
62    /// Backend pipeline system, retained so the render path can resolve the
63    /// per-material-variant pipeline (cheap cache hit). Set in init.
64    pipeline_system: OnceLock<std::sync::Arc<dyn khora_core::renderer::traits::PipelineSystem>>,
65    camera_layout: OnceLock<BindGroupLayoutId>,
66    model_layout: OnceLock<BindGroupLayoutId>,
67    material_layout: OnceLock<BindGroupLayoutId>,
68    /// Full lighting + shadows bind group layout (group 3).
69    light_layout: OnceLock<BindGroupLayoutId>,
70    /// Single-binding layout used by the lighting ring buffer.
71    lighting_buffer_layout: OnceLock<BindGroupLayoutId>,
72    camera_ring: Mutex<Option<UniformRingBuffer>>,
73    lighting_ring: Mutex<Option<UniformRingBuffer>>,
74    /// Per-frame model (transform) uniforms — written once, bound at a
75    /// dynamic offset per draw (no per-draw buffer/bind-group churn).
76    model_ring: Mutex<Option<DynamicUniformRingBuffer>>,
77}
78
79// ─── Free functions (CLAD: no inherent methods on the lane struct) ───
80
81/// The declarative pipeline spec for StandardPbr under a given material
82/// variant — built each call, deduped by the `PipelineSystem`. Layouts are
83/// the canonical 4-group budget; the backend owns + caches them per variant
84/// (shared across lit lanes). The group-2 (Material) layout resolves to the
85/// variant's texture-binding set.
86fn pipeline_spec(
87    device: &dyn khora_core::renderer::GraphicsDevice,
88    variant: ShaderVariantKey,
89    double_sided: bool,
90    blend: bool,
91) -> PipelineSpec {
92    use khora_core::renderer::api::pipeline::enums::{
93        CompareFunction, CullMode, VertexFormat, VertexStepMode,
94    };
95    use khora_core::renderer::api::pipeline::state::{
96        BlendStateDescriptor, ColorWrites, DepthBiasState, StencilFaceState,
97    };
98    use khora_core::renderer::api::pipeline::{
99        ColorTargetStateDescriptor, DepthStencilStateDescriptor, MultisampleStateDescriptor,
100        PrimitiveStateDescriptor, VertexAttributeDescriptor, VertexBufferLayoutDescriptor,
101    };
102    use khora_core::renderer::api::util::{SampleCount, TextureFormat};
103    use std::borrow::Cow;
104
105    PipelineSpec {
106        label: "StandardPbr Pipeline",
107        shader: "khora::pipelines::standard_pbr",
108        variant,
109        bind_group_layouts: vec![
110            LayoutSpec::Named(LayoutKey::Camera),
111            LayoutSpec::Named(LayoutKey::Model),
112            LayoutSpec::Named(LayoutKey::Material),
113            LayoutSpec::Named(LayoutKey::Lighting),
114        ],
115        vertex_buffers: vec![VertexBufferLayoutDescriptor {
116            array_stride: 32,
117            step_mode: VertexStepMode::Vertex,
118            attributes: Cow::Owned(vec![
119                VertexAttributeDescriptor {
120                    format: VertexFormat::Float32x3,
121                    offset: 0,
122                    shader_location: 0,
123                },
124                VertexAttributeDescriptor {
125                    format: VertexFormat::Float32x3,
126                    offset: 12,
127                    shader_location: 1,
128                },
129                VertexAttributeDescriptor {
130                    format: VertexFormat::Float32x2,
131                    offset: 24,
132                    shader_location: 2,
133                },
134            ]),
135        }],
136        vs_entry: "vs_main",
137        fs_entry: Some("fs_main"),
138        primitive: PrimitiveStateDescriptor {
139            topology: PrimitiveTopology::TriangleList,
140            // Single-sided materials cull back faces; double-sided disable
141            // culling. The cull mode is part of the pipeline cache key.
142            cull_mode: if double_sided {
143                None
144            } else {
145                Some(CullMode::Back)
146            },
147            ..Default::default()
148        },
149        depth_stencil: Some(DepthStencilStateDescriptor {
150            format: TextureFormat::Depth32Float,
151            // Transparent surfaces still depth-*test* against the opaque scene
152            // but must not depth-*write*: writing would let a nearer
153            // transparent fragment reject a farther one that should still show
154            // through it.
155            depth_write_enabled: !blend,
156            depth_compare: CompareFunction::Less,
157            stencil_front: StencilFaceState::default(),
158            stencil_back: StencilFaceState::default(),
159            stencil_read_mask: 0,
160            stencil_write_mask: 0,
161            bias: DepthBiasState::default(),
162        }),
163        color_targets: vec![ColorTargetStateDescriptor {
164            format: device
165                .get_surface_format()
166                .unwrap_or(TextureFormat::Rgba8UnormSrgb),
167            blend: blend.then(BlendStateDescriptor::alpha_blending),
168            write_mask: ColorWrites::ALL,
169        }],
170        multisample: MultisampleStateDescriptor {
171            count: SampleCount::X1,
172            mask: !0,
173            alpha_to_coverage_enabled: false,
174        },
175    }
176}
177
178fn init_gpu_resources(
179    lane: &StandardPbrLane,
180    device: &dyn khora_core::renderer::GraphicsDevice,
181    pipeline_system: &dyn khora_core::renderer::traits::PipelineSystem,
182) -> Result<(), khora_core::renderer::error::RenderError> {
183    use khora_core::renderer::api::resource::CameraUniformData;
184
185    let variant = ShaderVariantKey::empty();
186    // Canonical layouts come from the backend's LayoutCache (deduped across
187    // lit lanes + shared with the material projection's group-2 bind group).
188    let camera_layout = pipeline_system.layout(device, LayoutKey::Camera, &variant)?;
189    let model_layout = pipeline_system.layout(device, LayoutKey::Model, &variant)?;
190    let material_layout = pipeline_system.layout(device, LayoutKey::Material, &variant)?;
191    let light_layout = pipeline_system.layout(device, LayoutKey::Lighting, &variant)?;
192    let lighting_buffer_layout =
193        pipeline_system.layout(device, LayoutKey::LightingBuffer, &variant)?;
194
195    // Warm the empty-variant pipeline (untextured materials). Textured
196    // variants are compiled lazily in the render path on first use, keyed by
197    // `GpuMaterial::variant`.
198    let pipeline_id = pipeline_system.pipeline(
199        device,
200        &pipeline_spec(device, ShaderVariantKey::empty(), false, false),
201    )?;
202
203    let _ = lane.camera_layout.set(camera_layout);
204    let _ = lane.model_layout.set(model_layout);
205    let _ = lane.material_layout.set(material_layout);
206    let _ = lane.light_layout.set(light_layout);
207    let _ = lane.pipeline.set(pipeline_id);
208
209    // Ring buffers (per-lane GPU buffers) built against the shared layouts.
210    let camera_ring = UniformRingBuffer::new(
211        device,
212        camera_layout,
213        0,
214        std::mem::size_of::<CameraUniformData>() as u64,
215        "StandardPbr Camera Ring",
216    )
217    .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
218
219    let _ = lane.lighting_buffer_layout.set(lighting_buffer_layout);
220
221    let lighting_ring = UniformRingBuffer::new(
222        device,
223        lighting_buffer_layout,
224        0,
225        std::mem::size_of::<LightingUniforms>() as u64,
226        "StandardPbr Lighting Ring",
227    )
228    .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
229
230    let model_ring = DynamicUniformRingBuffer::new(
231        device,
232        model_layout,
233        0,
234        std::mem::size_of::<ModelUniforms>() as u32,
235        khora_core::renderer::api::util::dynamic_uniform_buffer::DEFAULT_MAX_ELEMENTS,
236        khora_core::renderer::api::util::dynamic_uniform_buffer::MIN_UNIFORM_ALIGNMENT,
237        "StandardPbr Model Ring",
238    )
239    .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
240
241    use crate::render_lane::util::lock::mutex_lock_render;
242    *mutex_lock_render(&lane.camera_ring, "StandardPbr init.camera_ring")? = Some(camera_ring);
243    *mutex_lock_render(&lane.lighting_ring, "StandardPbr init.lighting_ring")? =
244        Some(lighting_ring);
245    *mutex_lock_render(&lane.model_ring, "StandardPbr init.model_ring")? = Some(model_ring);
246    Ok(())
247}
248
249#[allow(clippy::too_many_arguments)]
250fn render_pbr(
251    lane: &StandardPbrLane,
252    render_world: &RenderWorld,
253    shadow_entries: &khora_data::render::ShadowEntries,
254    shadow_bindings: Option<khora_data::render::ShadowGpuBindings>,
255    ibl_bindings: Option<khora_core::renderer::api::ibl::IblGpuBindings>,
256    device: &dyn khora_core::renderer::GraphicsDevice,
257    encoder: &mut dyn CommandEncoder,
258    render_ctx: &RenderContext,
259    gpu_meshes: &RwLock<Assets<GpuMesh>>,
260) {
261    use khora_core::renderer::api::{
262        command::{BindGroupDescriptor, BindGroupEntry, BindingResource, BufferBinding},
263        resource::CameraUniformData,
264    };
265
266    let Some(view) = render_world.views.first() else {
267        // No camera — still emit a clear pass so downstream agents see
268        // a deterministic depth buffer.
269        let color_attachment = RenderPassColorAttachment {
270            view: render_ctx.color_target,
271            resolve_target: None,
272            ops: Operations {
273                load: LoadOp::Clear(render_ctx.clear_color),
274                store: StoreOp::Store,
275            },
276            base_array_layer: 0,
277            base_mip_level: 0,
278        };
279        let clear_desc = RenderPassDescriptor {
280            label: Some("StandardPbr Clear-Only Pass"),
281            color_attachments: &[color_attachment],
282            depth_stencil_attachment: render_ctx.depth_target.map(|d| {
283                RenderPassDepthStencilAttachment {
284                    view: d,
285                    depth_ops: Some(Operations {
286                        load: LoadOp::Clear(1.0),
287                        store: StoreOp::Store,
288                    }),
289                    stencil_ops: None,
290                    base_array_layer: 0,
291                }
292            }),
293        };
294        let _ = encoder.begin_render_pass(&clear_desc);
295        return;
296    };
297
298    // Camera ring write.
299    let camera_uniforms = CameraUniformData {
300        view_projection: view.view_proj.to_cols_array_2d(),
301        camera_position: [view.position.x, view.position.y, view.position.z, 1.0],
302    };
303    let camera_bind_group = {
304        let mut lock = crate::lock_or_log!(
305            lane.camera_ring.lock(),
306            "StandardPbrLane::render camera_ring"
307        );
308        let ring = match lock.as_mut() {
309            Some(r) => r,
310            None => {
311                log::warn!("StandardPbrLane: camera ring buffer not initialized");
312                return;
313            }
314        };
315        ring.advance();
316        if let Err(e) = ring.write(device, bytemuck::bytes_of(&camera_uniforms)) {
317            log::error!("Failed to write camera ring buffer: {:?}", e);
318            return;
319        }
320        *ring.current_bind_group()
321    };
322
323    // Lighting struct (same layout as LitForward).
324    let mut lighting_uniforms = LightingUniforms {
325        directional_lights: [DirectionalLightUniform {
326            direction: [0.0; 4],
327            color: khora_core::math::LinearRgba::BLACK,
328            shadow_view_proj: [[0.0; 4]; 4],
329            shadow_params: [0.0; 4],
330        }; MAX_DIRECTIONAL_LIGHTS],
331        point_lights: [PointLightUniform {
332            position: [0.0; 4],
333            color: khora_core::math::LinearRgba::BLACK,
334            shadow_params: [0.0; 4],
335        }; MAX_POINT_LIGHTS],
336        spot_lights: [SpotLightUniform {
337            position: [0.0; 4],
338            direction: [0.0; 4],
339            color: khora_core::math::LinearRgba::BLACK,
340            params: [0.0; 4],
341            shadow_view_proj: [[0.0; 4]; 4],
342            shadow_params: [0.0; 4],
343        }; MAX_SPOT_LIGHTS],
344        num_directional_lights: 0,
345        num_point_lights: 0,
346        num_spot_lights: 0,
347        _padding: 0,
348    };
349
350    for (light_index, light) in render_world.lights.iter().enumerate() {
351        let shadow = shadow_entries.get(light_index);
352        let (atlas2d_view_proj, atlas2d_index) = match shadow {
353            Some(khora_data::render::ShadowEntry::Atlas2D {
354                view_proj,
355                atlas_index,
356            }) => (*view_proj, *atlas_index as f32),
357            _ => (Mat4::IDENTITY, -1.0),
358        };
359        let (cube_layer, cube_far_plane) = match shadow {
360            Some(khora_data::render::ShadowEntry::Cube {
361                cube_array_index,
362                far_plane,
363                ..
364            }) => (*cube_array_index as f32, *far_plane),
365            _ => (-1.0, 0.0),
366        };
367
368        match light.light_type {
369            khora_core::renderer::light::LightType::Directional(ref d) => {
370                if (lighting_uniforms.num_directional_lights as usize) < MAX_DIRECTIONAL_LIGHTS {
371                    let idx = lighting_uniforms.num_directional_lights as usize;
372                    lighting_uniforms.directional_lights[idx] = DirectionalLightUniform {
373                        direction: [light.direction.x, light.direction.y, light.direction.z, 0.0],
374                        color: d.color.with_alpha(d.intensity),
375                        shadow_view_proj: atlas2d_view_proj.to_cols_array_2d(),
376                        shadow_params: [atlas2d_index, d.shadow_bias, d.shadow_normal_bias, 0.0],
377                    };
378                    lighting_uniforms.num_directional_lights += 1;
379                }
380            }
381            khora_core::renderer::light::LightType::Point(ref p) => {
382                if (lighting_uniforms.num_point_lights as usize) < MAX_POINT_LIGHTS {
383                    let idx = lighting_uniforms.num_point_lights as usize;
384                    lighting_uniforms.point_lights[idx] = PointLightUniform {
385                        position: [
386                            light.position.x,
387                            light.position.y,
388                            light.position.z,
389                            p.range,
390                        ],
391                        color: p.color.with_alpha(p.intensity),
392                        shadow_params: [
393                            cube_layer,
394                            p.shadow_bias,
395                            p.shadow_normal_bias,
396                            cube_far_plane,
397                        ],
398                    };
399                    lighting_uniforms.num_point_lights += 1;
400                }
401            }
402            khora_core::renderer::light::LightType::Spot(ref s) => {
403                if (lighting_uniforms.num_spot_lights as usize) < MAX_SPOT_LIGHTS {
404                    let idx = lighting_uniforms.num_spot_lights as usize;
405                    lighting_uniforms.spot_lights[idx] = SpotLightUniform {
406                        position: [
407                            light.position.x,
408                            light.position.y,
409                            light.position.z,
410                            s.range,
411                        ],
412                        direction: [
413                            light.direction.x,
414                            light.direction.y,
415                            light.direction.z,
416                            s.inner_cone_angle.cos(),
417                        ],
418                        color: s.color.with_alpha(s.intensity),
419                        params: [s.outer_cone_angle.cos(), 0.0, 0.0, 0.0],
420                        shadow_view_proj: atlas2d_view_proj.to_cols_array_2d(),
421                        shadow_params: [atlas2d_index, s.shadow_bias, s.shadow_normal_bias, 0.0],
422                    };
423                    lighting_uniforms.num_spot_lights += 1;
424                }
425            }
426        }
427    }
428
429    let (_lighting_bind_group, lighting_ring_buffer_id) = {
430        let mut lock = crate::lock_or_log!(
431            lane.lighting_ring.lock(),
432            "StandardPbrLane::render lighting_ring"
433        );
434        let ring = match lock.as_mut() {
435            Some(r) => r,
436            None => {
437                log::warn!("StandardPbrLane: lighting ring buffer not initialized");
438                return;
439            }
440        };
441        ring.advance();
442        if let Err(e) = ring.write(device, bytemuck::bytes_of(&lighting_uniforms)) {
443            log::error!("Failed to write lighting ring buffer: {:?}", e);
444            return;
445        }
446        (*ring.current_bind_group(), ring.current_buffer())
447    };
448
449    let gpu_mesh_assets =
450        crate::lock_or_log!(gpu_meshes.read(), "StandardPbrLane::render gpu_meshes");
451    // Fallback pipeline (empty variant) used only if the per-variant resolve
452    // fails.
453    let fallback_pipeline = lane
454        .pipeline
455        .get()
456        .copied()
457        .unwrap_or(khora_core::renderer::api::pipeline::RenderPipelineId(0));
458
459    // Opaque draws batch by pipeline; blended draws are deferred to a second
460    // batch sorted back-to-front (blending is order-dependent), each carrying
461    // its squared distance to the camera as the sort key.
462    let mut draw_commands = Vec::with_capacity(render_world.meshes.len());
463    let mut transparent_draws: Vec<(f32, khora_core::renderer::api::command::DrawCommand)> =
464        Vec::new();
465    let mut temp_bind_groups = Vec::new();
466
467    // Model transforms go through the per-frame dynamic ring: one buffer,
468    // one bind group, a per-draw offset — no per-draw allocation.
469    let mut model_ring_lock =
470        crate::lock_or_log!(lane.model_ring.lock(), "StandardPbrLane::render model_ring");
471    let model_ring = match model_ring_lock.as_mut() {
472        Some(r) => r,
473        None => {
474            log::warn!("StandardPbrLane: model ring buffer not initialized");
475            return;
476        }
477    };
478    model_ring.advance();
479
480    for extracted_mesh in &render_world.meshes {
481        let Some(gpu_mesh_handle) = gpu_mesh_assets.get(&extracted_mesh.cpu_mesh_uuid) else {
482            continue;
483        };
484        // The material projection tags every rendered entity with a
485        // `GpuMaterial` before `RenderFlow` runs; skip a draw until its
486        // material has been uploaded.
487        let Some(gpu_material) = &extracted_mesh.gpu_material else {
488            continue;
489        };
490        // Resolve the pipeline for this material's texture variant (cheap
491        // cache hit after first compile; the lane never retains a per-variant
492        // `RenderPipelineId`).
493        let pipeline_id = lane
494            .pipeline_system
495            .get()
496            .map(|ps| {
497                ps.pipeline(
498                    device,
499                    &pipeline_spec(
500                        device,
501                        gpu_material.variant.clone(),
502                        gpu_material.double_sided,
503                        gpu_material.blend,
504                    ),
505                )
506            })
507            .transpose()
508            .unwrap_or_else(|e| {
509                log::error!("StandardPbrLane: pipeline resolve failed: {:?}", e);
510                None
511            })
512            .unwrap_or(fallback_pipeline);
513        let model_mat = extracted_mesh.transform.to_matrix();
514        let normal_mat = match model_mat.inverse() {
515            Some(inv) => inv.transpose(),
516            None => continue,
517        };
518        let model_uniforms = ModelUniforms {
519            model_matrix: model_mat.to_cols_array_2d(),
520            normal_matrix: normal_mat.to_cols_array_2d(),
521        };
522        let model_offset = match model_ring.push(device, bytemuck::bytes_of(&model_uniforms)) {
523            Ok(offset) => offset,
524            Err(e) => {
525                log::error!("StandardPbrLane: failed to push model uniforms: {:?}", e);
526                continue;
527            }
528        };
529        let model_bg = *model_ring.current_bind_group();
530
531        let command = khora_core::renderer::api::command::DrawCommand {
532            pipeline: pipeline_id,
533            vertex_buffer: gpu_mesh_handle.vertex_buffer,
534            index_buffer: gpu_mesh_handle.index_buffer,
535            index_format: gpu_mesh_handle.index_format,
536            index_count: gpu_mesh_handle.index_count,
537            model_bind_group: Some(model_bg),
538            model_offset,
539            material_bind_group: Some(gpu_material.bind_group),
540            material_offset: 0,
541        };
542        if gpu_material.blend {
543            transparent_draws.push((
544                crate::render_lane::camera_distance_sq(&model_mat, view.position),
545                command,
546            ));
547        } else {
548            draw_commands.push(command);
549        }
550    }
551
552    // Batch by pipeline (variant) so each pipeline is set once across the
553    // pass — avoids per-draw pipeline thrash when materials mix variants.
554    draw_commands.sort_by_key(|cmd| cmd.pipeline.0);
555    // Transparent draws sort farthest-first instead: correct compositing
556    // outranks pipeline batching, since each blended fragment must be applied
557    // over everything behind it.
558    transparent_draws.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
559
560    let color_attachment = RenderPassColorAttachment {
561        view: render_ctx.color_target,
562        resolve_target: None,
563        ops: Operations {
564            load: LoadOp::Clear(render_ctx.clear_color),
565            store: StoreOp::Store,
566        },
567        base_array_layer: 0,
568        base_mip_level: 0,
569    };
570    let render_pass_desc = RenderPassDescriptor {
571        label: Some("Standard PBR Pass"),
572        color_attachments: &[color_attachment],
573        depth_stencil_attachment: render_ctx.depth_target.map(|d| {
574            RenderPassDepthStencilAttachment {
575                view: d,
576                depth_ops: Some(Operations {
577                    load: LoadOp::Clear(1.0),
578                    store: StoreOp::Store,
579                }),
580                stencil_ops: None,
581                base_array_layer: 0,
582            }
583        }),
584    };
585
586    // Build the per-frame lighting bind group (group 3).
587    let final_lighting_bind_group = if let Some(layout) = lane.light_layout.get().copied() {
588        let Some(shadow_bindings) = shadow_bindings else {
589            log::warn!("StandardPbrLane: ShadowGpuBindings not available, skipping render");
590            return;
591        };
592        let mut entries = vec![BindGroupEntry {
593            binding: khora_data::render::shadow_bindings::binding::LIGHTING_UNIFORMS,
594            resource: BindingResource::Buffer(BufferBinding {
595                buffer: lighting_ring_buffer_id,
596                offset: 0,
597                size: None,
598            }),
599            _phantom: std::marker::PhantomData,
600        }];
601        khora_data::render::shadow_bindings::fill_shadow_bind_group_entries(
602            &shadow_bindings,
603            &mut entries,
604        );
605        // IBL occupies group-3 bindings 4..8 for StandardPbr (after shadow).
606        let Some(ibl) = ibl_bindings else {
607            log::warn!("StandardPbrLane: IBL bindings not available, skipping render");
608            return;
609        };
610        khora_core::renderer::api::ibl::fill_ibl_bind_group_entries(&ibl, 4, &mut entries);
611        match device.create_bind_group(&BindGroupDescriptor {
612            label: Some("standard_pbr_lighting_bind_group_dynamic"),
613            layout,
614            entries: &entries,
615        }) {
616            Ok(bg) => {
617                temp_bind_groups.push(bg);
618                bg
619            }
620            Err(e) => {
621                log::error!(
622                    "StandardPbrLane: failed to create lighting bind group: {:?}",
623                    e
624                );
625                return;
626            }
627        }
628    } else {
629        log::warn!("StandardPbrLane: light layout not initialized");
630        return;
631    };
632
633    let mut render_pass = encoder.begin_render_pass(&render_pass_desc);
634    render_pass.set_bind_group(0, &camera_bind_group, &[]);
635    render_pass.set_bind_group(3, &final_lighting_bind_group, &[]);
636
637    // Opaque first — it fills the depth buffer the transparent pass tests
638    // against — then the blended draws, farthest first.
639    let mut current_pipeline = None;
640    for cmd in draw_commands
641        .iter()
642        .chain(transparent_draws.iter().map(|(_, cmd)| cmd))
643    {
644        if current_pipeline != Some(cmd.pipeline) {
645            render_pass.set_pipeline(&cmd.pipeline);
646            current_pipeline = Some(cmd.pipeline);
647        }
648        if let Some(bg) = &cmd.model_bind_group {
649            render_pass.set_bind_group(1, bg, &[cmd.model_offset]);
650        }
651        if let Some(bg) = &cmd.material_bind_group {
652            render_pass.set_bind_group(2, bg, &[]);
653        }
654        render_pass.set_vertex_buffer(0, &cmd.vertex_buffer, 0);
655        render_pass.set_index_buffer(&cmd.index_buffer, 0, cmd.index_format);
656        render_pass.draw_indexed(0..cmd.index_count, 0, 0..1);
657    }
658
659    drop(render_pass);
660    // Only the per-frame group-3 lighting bind group is transient now;
661    // model uniforms live in the ring, materials in the GpuMaterial cache.
662    for bg in temp_bind_groups {
663        let _ = device.destroy_bind_group(bg);
664    }
665}
666
667impl khora_core::lane::Lane for StandardPbrLane {
668    fn strategy_name(&self) -> &'static str {
669        "StandardPbr"
670    }
671
672    fn lane_kind(&self) -> khora_core::lane::LaneKind {
673        khora_core::lane::LaneKind::Render
674    }
675
676    fn on_initialize(
677        &self,
678        ctx: &mut khora_core::lane::LaneContext,
679    ) -> Result<(), khora_core::lane::LaneError> {
680        let device = ctx
681            .get::<std::sync::Arc<dyn khora_core::renderer::GraphicsDevice>>()
682            .ok_or(khora_core::lane::LaneError::missing(
683                "Arc<dyn GraphicsDevice>",
684            ))?
685            .clone();
686        let pipeline_system = ctx
687            .get::<std::sync::Arc<dyn khora_core::renderer::traits::PipelineSystem>>()
688            .ok_or(khora_core::lane::LaneError::missing(
689                "Arc<dyn PipelineSystem>",
690            ))?
691            .clone();
692        // Retain the system so the render hot path can resolve a pipeline per
693        // material variant (a cheap cache hit after first compile).
694        let _ = self.pipeline_system.set(pipeline_system.clone());
695        init_gpu_resources(self, device.as_ref(), pipeline_system.as_ref())
696            .map_err(|e| khora_core::lane::LaneError::InitializationFailed(Box::new(e)))
697    }
698
699    fn execute(
700        &self,
701        ctx: &mut khora_core::lane::LaneContext,
702    ) -> Result<(), khora_core::lane::LaneError> {
703        use khora_core::lane::{LaneError, Ref, Slot};
704        let device = ctx
705            .get::<std::sync::Arc<dyn khora_core::renderer::GraphicsDevice>>()
706            .ok_or(LaneError::missing("Arc<dyn GraphicsDevice>"))?
707            .clone();
708        let gpu_meshes = ctx
709            .get::<std::sync::Arc<
710                std::sync::RwLock<
711                    khora_data::assets::Assets<khora_core::renderer::api::scene::GpuMesh>,
712                >,
713            >>()
714            .ok_or(LaneError::missing("Arc<RwLock<Assets<GpuMesh>>>"))?
715            .clone();
716        let encoder = ctx
717            .get::<Slot<dyn khora_core::renderer::traits::CommandEncoder>>()
718            .ok_or(LaneError::missing("Slot<dyn CommandEncoder>"))?
719            .get();
720        let render_world = ctx
721            .get::<Ref<khora_data::render::RenderWorld>>()
722            .ok_or(LaneError::missing("Ref<RenderWorld>"))?
723            .get();
724        let color_target = ctx
725            .get::<khora_core::lane::ColorTarget>()
726            .ok_or(LaneError::missing("ColorTarget"))?
727            .0;
728        let depth_target = ctx
729            .get::<khora_core::lane::DepthTarget>()
730            .ok_or(LaneError::missing("DepthTarget"))?
731            .0;
732        let clear_color = ctx
733            .get::<khora_core::lane::ClearColor>()
734            .ok_or(LaneError::missing("ClearColor"))?
735            .0;
736        let render_ctx = khora_core::renderer::api::core::RenderContext::new(
737            &color_target,
738            Some(&depth_target),
739            clear_color,
740        );
741
742        let (shadow_entries, shadow_bindings) = ctx
743            .get::<Slot<khora_core::lane::OutputDeck>>()
744            .map(|s| {
745                let frame = s
746                    .get()
747                    .slot::<khora_core::renderer::api::shadow::ShadowFrame>();
748                (frame.entries.clone(), frame.bindings)
749            })
750            .unwrap_or_default();
751
752        // Image-based lighting bindings — baked once at startup, forwarded into
753        // the lane ctx by the render agent (static after the bake).
754        let ibl_bindings = ctx
755            .get::<khora_core::renderer::api::ibl::IblGpuBindings>()
756            .copied();
757
758        render_pbr(
759            self,
760            render_world,
761            &shadow_entries,
762            shadow_bindings,
763            ibl_bindings,
764            device.as_ref(),
765            encoder,
766            &render_ctx,
767            &gpu_meshes,
768        );
769        Ok(())
770    }
771
772    fn as_any(&self) -> &dyn std::any::Any {
773        self
774    }
775
776    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
777        self
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784    use khora_core::lane::Lane;
785
786    #[test]
787    fn standard_pbr_lane_strategy_name() {
788        let lane = StandardPbrLane::default();
789        assert_eq!(lane.strategy_name(), "StandardPbr");
790        assert_eq!(lane.lane_kind(), khora_core::lane::LaneKind::Render);
791    }
792}