khora_lanes/render_lane/
simple_unlit_lane.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Implements a simple, unlit rendering strategy.
16//!
17//! The `SimpleUnlitLane` is the most basic rendering pipeline in Khora. It renders
18//! meshes without any lighting calculations, making it the fastest and most straightforward
19//! rendering strategy. This lane is ideal for:
20//! - Debug visualization and prototyping
21//! - Rendering UI elements or 2D sprites
22//! - Performance-critical scenarios where lighting is not needed
23//! - Serving as a fallback when more complex rendering strategies cannot meet their budget
24//!
25//! As a "Lane" in the CLAD architecture, this implementation is optimized for raw speed
26//! and deterministic execution. It contains minimal branching logic and is designed to
27//! be driven by a higher-level `RenderAgent`.
28
29use super::RenderWorld;
30use khora_core::{
31    asset::Material,
32    renderer::{
33        api::{
34            command::{
35                LoadOp, Operations, RenderPassColorAttachment, RenderPassDepthStencilAttachment,
36                RenderPassDescriptor, StoreOp,
37            },
38            core::RenderContext,
39            pipeline::enums::PrimitiveTopology,
40            pipeline::RenderPipelineId,
41            scene::GpuMesh,
42        },
43        traits::CommandEncoder,
44    },
45};
46use khora_data::assets::Assets;
47use std::sync::RwLock;
48
49/// A lane that implements a simple, unlit forward rendering strategy.
50///
51/// This lane takes the extracted scene data from a `RenderWorld` and generates
52/// GPU commands to render all meshes with a basic, unlit appearance. It does not
53/// perform any lighting calculations, shadow mapping, or post-processing effects.
54///
55/// # Performance Characteristics
56/// - **Zero heap allocations** during the render pass encoding
57/// - **Linear iteration** over the extracted mesh list
58/// - **Minimal state changes** (one pipeline bind per material, ideally)
59/// - **Suitable for**: High frame rates, simple scenes, or as a debug/fallback renderer
60pub struct SimpleUnlitLane {
61    pipeline: std::sync::Mutex<Option<RenderPipelineId>>,
62    camera_layout: std::sync::Mutex<Option<khora_core::renderer::api::command::BindGroupLayoutId>>,
63    model_layout: std::sync::Mutex<Option<khora_core::renderer::api::command::BindGroupLayoutId>>,
64    camera_ring: std::sync::Mutex<
65        Option<khora_core::renderer::api::util::uniform_ring_buffer::UniformRingBuffer>,
66    >,
67    model_ring: std::sync::Mutex<
68        Option<khora_core::renderer::api::util::dynamic_uniform_buffer::DynamicUniformRingBuffer>,
69    >,
70    material_layout:
71        std::sync::Mutex<Option<khora_core::renderer::api::command::BindGroupLayoutId>>,
72    material_ring: std::sync::Mutex<
73        Option<khora_core::renderer::api::util::dynamic_uniform_buffer::DynamicUniformRingBuffer>,
74    >,
75}
76
77impl Default for SimpleUnlitLane {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83impl SimpleUnlitLane {
84    /// Creates a new `SimpleUnlitLane`.
85    pub fn new() -> Self {
86        Self {
87            pipeline: std::sync::Mutex::new(None),
88            camera_layout: std::sync::Mutex::new(None),
89            model_layout: std::sync::Mutex::new(None),
90            camera_ring: std::sync::Mutex::new(None),
91            model_ring: std::sync::Mutex::new(None),
92            material_layout: std::sync::Mutex::new(None),
93            material_ring: std::sync::Mutex::new(None),
94        }
95    }
96}
97
98impl khora_core::lane::Lane for SimpleUnlitLane {
99    fn strategy_name(&self) -> &'static str {
100        "SimpleUnlit"
101    }
102
103    fn lane_kind(&self) -> khora_core::lane::LaneKind {
104        khora_core::lane::LaneKind::Render
105    }
106
107    fn estimate_cost(&self, ctx: &khora_core::lane::LaneContext) -> f32 {
108        let render_world =
109            match ctx.get::<khora_core::lane::Slot<crate::render_lane::RenderWorld>>() {
110                Some(slot) => slot.get_ref(),
111                None => return 1.0,
112            };
113        let gpu_meshes = match ctx.get::<std::sync::Arc<
114            std::sync::RwLock<
115                khora_data::assets::Assets<khora_core::renderer::api::scene::GpuMesh>,
116            >,
117        >>() {
118            Some(arc) => arc,
119            None => return 1.0,
120        };
121        self.estimate_render_cost(render_world, gpu_meshes)
122    }
123
124    fn on_initialize(
125        &self,
126        ctx: &mut khora_core::lane::LaneContext,
127    ) -> Result<(), khora_core::lane::LaneError> {
128        let device = ctx
129            .get::<std::sync::Arc<dyn khora_core::renderer::GraphicsDevice>>()
130            .ok_or(khora_core::lane::LaneError::missing(
131                "Arc<dyn GraphicsDevice>",
132            ))?;
133        self.on_gpu_init(device.as_ref())
134            .map_err(|e| khora_core::lane::LaneError::InitializationFailed(Box::new(e)))
135    }
136
137    fn execute(
138        &self,
139        ctx: &mut khora_core::lane::LaneContext,
140    ) -> Result<(), khora_core::lane::LaneError> {
141        use khora_core::lane::{LaneError, Slot};
142        let device = ctx
143            .get::<std::sync::Arc<dyn khora_core::renderer::GraphicsDevice>>()
144            .ok_or(LaneError::missing("Arc<dyn GraphicsDevice>"))?
145            .clone();
146        let gpu_meshes = ctx
147            .get::<std::sync::Arc<
148                std::sync::RwLock<
149                    khora_data::assets::Assets<khora_core::renderer::api::scene::GpuMesh>,
150                >,
151            >>()
152            .ok_or(LaneError::missing("Arc<RwLock<Assets<GpuMesh>>>"))?
153            .clone();
154        let encoder = ctx
155            .get::<Slot<dyn khora_core::renderer::traits::CommandEncoder>>()
156            .ok_or(LaneError::missing("Slot<dyn CommandEncoder>"))?
157            .get();
158        let render_world = ctx
159            .get::<Slot<crate::render_lane::RenderWorld>>()
160            .ok_or(LaneError::missing("Slot<RenderWorld>"))?
161            .get_ref();
162        let color_target = ctx
163            .get::<khora_core::lane::ColorTarget>()
164            .ok_or(LaneError::missing("ColorTarget"))?
165            .0;
166        let depth_target = ctx
167            .get::<khora_core::lane::DepthTarget>()
168            .ok_or(LaneError::missing("DepthTarget"))?
169            .0;
170        let clear_color = ctx
171            .get::<khora_core::lane::ClearColor>()
172            .ok_or(LaneError::missing("ClearColor"))?
173            .0;
174        let shadow_atlas = ctx.get::<khora_core::lane::ShadowAtlasView>().map(|v| v.0);
175        let shadow_sampler = ctx
176            .get::<khora_core::lane::ShadowComparisonSampler>()
177            .map(|v| v.0);
178
179        let mut render_ctx = khora_core::renderer::api::core::RenderContext::new(
180            &color_target,
181            Some(&depth_target),
182            clear_color,
183        );
184        render_ctx.shadow_atlas = shadow_atlas.as_ref();
185        render_ctx.shadow_sampler = shadow_sampler.as_ref();
186
187        self.render(
188            render_world,
189            device.as_ref(),
190            encoder,
191            &render_ctx,
192            &gpu_meshes,
193        );
194        Ok(())
195    }
196
197    fn on_shutdown(&self, ctx: &mut khora_core::lane::LaneContext) {
198        if let Some(device) = ctx.get::<std::sync::Arc<dyn khora_core::renderer::GraphicsDevice>>()
199        {
200            self.on_gpu_shutdown(device.as_ref());
201        }
202    }
203
204    fn as_any(&self) -> &dyn std::any::Any {
205        self
206    }
207
208    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
209        self
210    }
211}
212
213impl SimpleUnlitLane {
214    /// Returns the render pipeline for the given material (or default).
215    pub fn get_pipeline_for_material(
216        &self,
217        _material: Option<&khora_core::asset::AssetHandle<Box<dyn Material>>>,
218    ) -> RenderPipelineId {
219        // Return the stored pipeline, or 0 if not initialized.
220        self.pipeline.lock().unwrap().unwrap_or(RenderPipelineId(0))
221    }
222
223    fn render(
224        &self,
225        render_world: &RenderWorld,
226        device: &dyn khora_core::renderer::GraphicsDevice,
227        encoder: &mut dyn CommandEncoder,
228        render_ctx: &RenderContext,
229        gpu_meshes: &RwLock<Assets<GpuMesh>>,
230    ) {
231        use khora_core::renderer::api::{resource::CameraUniformData, scene::ModelUniforms};
232
233        // 1. Get Active Camera View
234        let view = if let Some(first_view) = render_world.views.first() {
235            first_view
236        } else {
237            return; // No camera, nothing to render
238        };
239
240        // 2. Prepare Camera Uniforms via Persistent Ring Buffer
241        let camera_uniforms = CameraUniformData {
242            view_projection: view.view_proj.to_cols_array_2d(),
243            camera_position: [view.position.x, view.position.y, view.position.z, 1.0],
244        };
245
246        let camera_bind_group = {
247            let mut lock = self.camera_ring.lock().unwrap();
248            let ring = match lock.as_mut() {
249                Some(r) => r,
250                None => {
251                    log::warn!("SimpleUnlitLane: camera ring buffer not initialized");
252                    return;
253                }
254            };
255            ring.advance();
256            if let Err(e) = ring.write(device, bytemuck::bytes_of(&camera_uniforms)) {
257                log::error!("Failed to write camera ring buffer: {:?}", e);
258                return;
259            }
260            *ring.current_bind_group()
261        };
262
263        // Lock the model ring buffer and advance it for this frame
264        let mut model_ring_lock = self.model_ring.lock().unwrap();
265        let model_ring = match model_ring_lock.as_mut() {
266            Some(mr) => {
267                mr.advance();
268                mr
269            }
270            None => return,
271        };
272
273        let mut material_ring_lock = self.material_ring.lock().unwrap();
274        let material_ring = match material_ring_lock.as_mut() {
275            Some(mr) => {
276                mr.advance();
277                mr
278            }
279            None => return,
280        };
281
282        // Acquire read locks on the caches
283        let gpu_mesh_assets = gpu_meshes.read().unwrap();
284
285        // 3. Prepare Draw Commands
286        let mut draw_commands = Vec::with_capacity(render_world.meshes.len());
287
288        for extracted_mesh in &render_world.meshes {
289            if let Some(gpu_mesh_handle) = gpu_mesh_assets.get(&extracted_mesh.cpu_mesh_uuid) {
290                // Get the pre-computed pipeline for this mesh
291                let pipeline = self.get_pipeline_for_material(extracted_mesh.material.as_ref());
292
293                // Create Per-Mesh Uniforms
294                let model_mat = extracted_mesh.transform.to_matrix();
295
296                let normal_mat = if let Some(inverse) = model_mat.inverse() {
297                    inverse.transpose()
298                } else {
299                    continue; // Skip if degenerate transform
300                };
301
302                let mut base_color = khora_core::math::LinearRgba::WHITE;
303                if let Some(mat_handle) = &extracted_mesh.material {
304                    base_color = mat_handle.base_color();
305                }
306
307                let model_uniforms = ModelUniforms {
308                    model_matrix: model_mat.to_cols_array_2d(),
309                    normal_matrix: normal_mat.to_cols_array_2d(),
310                };
311
312                let offset = match model_ring.push(device, bytemuck::bytes_of(&model_uniforms)) {
313                    Ok(off) => off,
314                    Err(_) => continue,
315                };
316                let model_bg = *model_ring.current_bind_group();
317
318                // Build MaterialUniforms
319                let material_uniforms = khora_core::renderer::api::scene::MaterialUniforms {
320                    base_color,
321                    emissive: khora_core::math::LinearRgba::BLACK,
322                    ambient: khora_core::math::LinearRgba::BLACK,
323                };
324
325                let mat_offset =
326                    match material_ring.push(device, bytemuck::bytes_of(&material_uniforms)) {
327                        Ok(off) => off,
328                        Err(_) => continue,
329                    };
330                let material_bg = *material_ring.current_bind_group();
331
332                draw_commands.push(khora_core::renderer::api::command::DrawCommand {
333                    pipeline,
334                    vertex_buffer: gpu_mesh_handle.vertex_buffer,
335                    index_buffer: gpu_mesh_handle.index_buffer,
336                    index_format: gpu_mesh_handle.index_format,
337                    index_count: gpu_mesh_handle.index_count,
338                    model_bind_group: Some(model_bg),
339                    model_offset: offset,
340                    material_bind_group: Some(material_bg),
341                    material_offset: mat_offset,
342                });
343            }
344        }
345
346        // Configure the render pass to render into the provided color target
347        let color_attachment = RenderPassColorAttachment {
348            view: render_ctx.color_target,
349            resolve_target: None,
350            ops: Operations {
351                load: LoadOp::Clear(render_ctx.clear_color),
352                store: StoreOp::Store,
353            },
354            base_array_layer: 0,
355        };
356
357        let render_pass_desc = RenderPassDescriptor {
358            label: Some("Simple Unlit Pass"),
359            color_attachments: &[color_attachment],
360            depth_stencil_attachment: render_ctx.depth_target.map(|depth_view| {
361                RenderPassDepthStencilAttachment {
362                    view: depth_view,
363                    depth_ops: Some(Operations {
364                        load: LoadOp::Clear(1.0),
365                        store: StoreOp::Store,
366                    }),
367                    stencil_ops: None,
368                    base_array_layer: 0,
369                }
370            }),
371        };
372
373        // Begin the render pass
374        let mut render_pass = encoder.begin_render_pass(&render_pass_desc);
375
376        // Bind global camera
377        render_pass.set_bind_group(0, &camera_bind_group, &[]);
378
379        // Track the last pipeline we bound to avoid redundant state changes
380        let mut current_pipeline: Option<RenderPipelineId> = None;
381
382        for cmd in &draw_commands {
383            if current_pipeline != Some(cmd.pipeline) {
384                render_pass.set_pipeline(&cmd.pipeline);
385                current_pipeline = Some(cmd.pipeline);
386            }
387
388            if let Some(ref bg) = cmd.model_bind_group {
389                render_pass.set_bind_group(1, bg, &[cmd.model_offset]);
390            }
391
392            if let Some(ref bg) = cmd.material_bind_group {
393                render_pass.set_bind_group(2, bg, &[cmd.material_offset]);
394            }
395
396            render_pass.set_vertex_buffer(0, &cmd.vertex_buffer, 0);
397            render_pass.set_index_buffer(&cmd.index_buffer, 0, cmd.index_format);
398            render_pass.draw_indexed(0..cmd.index_count, 0, 0..1);
399        }
400    }
401
402    fn estimate_render_cost(
403        &self,
404        render_world: &RenderWorld,
405        gpu_meshes: &RwLock<Assets<GpuMesh>>,
406    ) -> f32 {
407        let gpu_mesh_assets = gpu_meshes.read().unwrap();
408
409        let mut total_triangles = 0u32;
410        let mut draw_call_count = 0u32;
411
412        for extracted_mesh in &render_world.meshes {
413            if let Some(gpu_mesh) = gpu_mesh_assets.get(&extracted_mesh.cpu_mesh_uuid) {
414                // Calculate triangle count based on primitive topology
415                let triangle_count = match gpu_mesh.primitive_topology {
416                    PrimitiveTopology::TriangleList => gpu_mesh.index_count / 3,
417                    PrimitiveTopology::TriangleStrip => {
418                        if gpu_mesh.index_count >= 3 {
419                            gpu_mesh.index_count - 2
420                        } else {
421                            0
422                        }
423                    }
424                    // Lines and points don't contribute to triangle count
425                    PrimitiveTopology::LineList
426                    | PrimitiveTopology::LineStrip
427                    | PrimitiveTopology::PointList => 0,
428                };
429
430                total_triangles += triangle_count;
431                draw_call_count += 1;
432            }
433        }
434
435        // Cost model: triangles have a small per-triangle cost,
436        // draw calls have a fixed overhead
437        const TRIANGLE_COST: f32 = 0.001;
438        const DRAW_CALL_COST: f32 = 0.1;
439
440        (total_triangles as f32 * TRIANGLE_COST) + (draw_call_count as f32 * DRAW_CALL_COST)
441    }
442
443    fn on_gpu_init(
444        &self,
445        device: &dyn khora_core::renderer::GraphicsDevice,
446    ) -> Result<(), khora_core::renderer::error::RenderError> {
447        use crate::render_lane::shaders::UNLIT_WGSL;
448        use khora_core::renderer::api::{
449            command::{
450                BindGroupLayoutDescriptor, BindGroupLayoutEntry, BindingType, BufferBindingType,
451            },
452            core::{ShaderModuleDescriptor, ShaderSourceData},
453            pipeline::enums::{CompareFunction, VertexFormat, VertexStepMode},
454            pipeline::state::{ColorWrites, DepthBiasState, StencilFaceState},
455            pipeline::{
456                ColorTargetStateDescriptor, DepthStencilStateDescriptor,
457                MultisampleStateDescriptor, PrimitiveStateDescriptor, RenderPipelineDescriptor,
458                VertexAttributeDescriptor, VertexBufferLayoutDescriptor,
459            },
460            resource::CameraUniformData,
461            scene::ModelUniforms,
462            util::uniform_ring_buffer::UniformRingBuffer,
463            util::{SampleCount, ShaderStageFlags},
464        };
465        use std::borrow::Cow;
466
467        log::info!("SimpleUnlitLane: Initializing GPU resources...");
468
469        // 1. Create Bind Group Layouts
470
471        let camera_layout = device
472            .create_bind_group_layout(&BindGroupLayoutDescriptor {
473                label: Some("simple_unlit_camera_layout"),
474                entries: &[BindGroupLayoutEntry {
475                    binding: 0,
476                    visibility: ShaderStageFlags::VERTEX | ShaderStageFlags::FRAGMENT,
477                    ty: BindingType::Buffer {
478                        ty: BufferBindingType::Uniform,
479                        has_dynamic_offset: false,
480                        min_binding_size: None,
481                    },
482                }],
483            })
484            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
485
486        let model_layout = device
487            .create_bind_group_layout(&BindGroupLayoutDescriptor {
488                label: Some("simple_unlit_model_layout"),
489                entries: &[BindGroupLayoutEntry {
490                    binding: 0,
491                    visibility: ShaderStageFlags::VERTEX,
492                    ty: BindingType::Buffer {
493                        ty: BufferBindingType::Uniform,
494                        has_dynamic_offset: true,
495                        min_binding_size: std::num::NonZeroU64::new(
496                            std::mem::size_of::<ModelUniforms>() as u64,
497                        ),
498                    },
499                }],
500            })
501            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
502
503        let material_layout = device
504            .create_bind_group_layout(&BindGroupLayoutDescriptor {
505                label: Some("simple_unlit_material_layout"),
506                entries: &[BindGroupLayoutEntry {
507                    binding: 0,
508                    visibility: ShaderStageFlags::FRAGMENT, // Material uniforms primarily in FS
509                    ty: BindingType::Buffer {
510                        ty: BufferBindingType::Uniform,
511                        has_dynamic_offset: true,
512                        min_binding_size: std::num::NonZeroU64::new(std::mem::size_of::<
513                            khora_core::renderer::api::scene::MaterialUniforms,
514                        >()
515                            as u64),
516                    },
517                }],
518            })
519            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
520
521        *self.camera_layout.lock().unwrap() = Some(camera_layout);
522        *self.model_layout.lock().unwrap() = Some(model_layout);
523        *self.material_layout.lock().unwrap() = Some(material_layout);
524
525        // 2. Create Shader Module
526        let shader_src = UNLIT_WGSL.to_string();
527        let shader_module = device
528            .create_shader_module(&ShaderModuleDescriptor {
529                label: Some("simple_unlit_shader"),
530                source: ShaderSourceData::Wgsl(Cow::Owned(shader_src)),
531            })
532            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
533
534        // 3. Define Vertex Layout (matching our standard vertex buffer)
535        // Attribute 0: Position (vec3<f32>)
536        // Attribute 1: Normal (vec3<f32>)
537        // Attribute 2: UV (vec2<f32>)
538        let vertex_attributes = vec![
539            VertexAttributeDescriptor {
540                format: VertexFormat::Float32x3,
541                offset: 0,
542                shader_location: 0,
543            },
544            VertexAttributeDescriptor {
545                format: VertexFormat::Float32x3,
546                offset: 12, // 3 * size_of<f32>
547                shader_location: 1,
548            },
549            VertexAttributeDescriptor {
550                format: VertexFormat::Float32x2,
551                offset: 24, // 6 * size_of<f32>
552                shader_location: 2,
553            },
554        ];
555
556        let vertex_layout = VertexBufferLayoutDescriptor {
557            array_stride: 32, // 3*4 + 3*4 + 2*4
558            step_mode: VertexStepMode::Vertex,
559            attributes: Cow::Owned(vertex_attributes),
560        };
561
562        // 4. Create Pipeline Layout
563        let pipeline_layout_ids = vec![camera_layout, model_layout, material_layout];
564        let pipeline_layout_desc = khora_core::renderer::api::pipeline::PipelineLayoutDescriptor {
565            label: Some(Cow::Borrowed("SimpleUnlit Pipeline Layout")),
566            bind_group_layouts: &pipeline_layout_ids,
567        };
568
569        let pipeline_layout_id = device
570            .create_pipeline_layout(&pipeline_layout_desc)
571            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
572
573        // 5. Create Render Pipeline
574        let pipeline_desc = RenderPipelineDescriptor {
575            label: Some(Cow::Borrowed("SimpleUnlit Pipeline")),
576            vertex_shader_module: shader_module,
577            vertex_entry_point: Cow::Borrowed("vs_main"),
578            fragment_shader_module: Some(shader_module),
579            fragment_entry_point: Some(Cow::Borrowed("fs_main")),
580            vertex_buffers_layout: Cow::Owned(vec![vertex_layout]),
581            layout: Some(pipeline_layout_id),
582            primitive_state: PrimitiveStateDescriptor {
583                topology: PrimitiveTopology::TriangleList,
584                ..Default::default()
585            },
586            depth_stencil_state: Some(DepthStencilStateDescriptor {
587                format: khora_core::renderer::api::util::TextureFormat::Depth32Float,
588                depth_write_enabled: true,
589                depth_compare: CompareFunction::Less,
590                stencil_front: StencilFaceState::default(),
591                stencil_back: StencilFaceState::default(),
592                stencil_read_mask: 0,
593                stencil_write_mask: 0,
594                bias: DepthBiasState::default(),
595            }),
596            color_target_states: Cow::Owned(vec![ColorTargetStateDescriptor {
597                format: device
598                    .get_surface_format()
599                    .unwrap_or(khora_core::renderer::api::util::TextureFormat::Rgba8UnormSrgb),
600                blend: None, // REPLACE
601                write_mask: ColorWrites::ALL,
602            }]),
603            multisample_state: MultisampleStateDescriptor {
604                count: SampleCount::X1,
605                mask: !0,
606                alpha_to_coverage_enabled: false,
607            },
608        };
609
610        let pipeline_id = device
611            .create_render_pipeline(&pipeline_desc)
612            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
613
614        let mut pipeline_lock = self.pipeline.lock().unwrap();
615        *pipeline_lock = Some(pipeline_id);
616
617        let camera_ring = UniformRingBuffer::new(
618            device,
619            camera_layout,
620            0,
621            std::mem::size_of::<CameraUniformData>() as u64,
622            "Camera Uniform Ring Runlit",
623        )
624        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
625
626        *self.camera_ring.lock().unwrap() = Some(camera_ring);
627
628        let model_ring =
629            khora_core::renderer::api::util::dynamic_uniform_buffer::DynamicUniformRingBuffer::new(
630                device,
631                model_layout,
632                0,
633                std::mem::size_of::<ModelUniforms>() as u32,
634                khora_core::renderer::api::util::dynamic_uniform_buffer::DEFAULT_MAX_ELEMENTS,
635                khora_core::renderer::api::util::dynamic_uniform_buffer::MIN_UNIFORM_ALIGNMENT,
636                "Model Dynamic Ring Runlit",
637            )
638            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
639
640        *self.model_ring.lock().unwrap() = Some(model_ring);
641
642        let material_ring =
643            khora_core::renderer::api::util::dynamic_uniform_buffer::DynamicUniformRingBuffer::new(
644                device,
645                material_layout,
646                0, // Binding size
647                std::mem::size_of::<khora_core::renderer::api::scene::MaterialUniforms>() as u32,
648                khora_core::renderer::api::util::dynamic_uniform_buffer::DEFAULT_MAX_ELEMENTS,
649                khora_core::renderer::api::util::dynamic_uniform_buffer::MIN_UNIFORM_ALIGNMENT,
650                "Material Dynamic Ring Runlit",
651            )
652            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
653
654        *self.material_ring.lock().unwrap() = Some(material_ring);
655
656        Ok(())
657    }
658
659    fn on_gpu_shutdown(&self, device: &dyn khora_core::renderer::GraphicsDevice) {
660        if let Some(ring) = self.camera_ring.lock().unwrap().take() {
661            ring.destroy(device);
662        }
663        if let Some(ring) = self.model_ring.lock().unwrap().take() {
664            ring.destroy(device);
665        }
666        if let Some(ring) = self.material_ring.lock().unwrap().take() {
667            ring.destroy(device);
668        }
669        let mut pipeline_lock = self.pipeline.lock().unwrap();
670        if let Some(id) = pipeline_lock.take() {
671            let _ = device.destroy_render_pipeline(id);
672        }
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679    use khora_core::lane::Lane;
680    use khora_core::{
681        asset::AssetHandle,
682        renderer::api::{
683            pipeline::enums::PrimitiveTopology, resource::BufferId, util::IndexFormat,
684        },
685    };
686    use std::sync::Arc;
687
688    #[test]
689    fn test_simple_unlit_lane_creation() {
690        let lane = SimpleUnlitLane::new();
691        assert_eq!(lane.strategy_name(), "SimpleUnlit");
692    }
693
694    #[test]
695    fn test_default_construction() {
696        let lane = SimpleUnlitLane::new();
697        assert_eq!(lane.strategy_name(), "SimpleUnlit");
698    }
699
700    #[test]
701    fn test_cost_estimation_empty_world() {
702        let lane = SimpleUnlitLane::new();
703        let render_world = RenderWorld::default();
704        let gpu_meshes = Arc::new(RwLock::new(Assets::<GpuMesh>::new()));
705
706        let cost = lane.estimate_render_cost(&render_world, &gpu_meshes);
707        assert_eq!(cost, 0.0, "Empty world should have zero cost");
708    }
709
710    #[test]
711    fn test_cost_estimation_triangle_list() {
712        use crate::render_lane::world::ExtractedMesh;
713        use khora_core::asset::AssetUUID;
714
715        let lane = SimpleUnlitLane::new();
716
717        // Create a GPU mesh with 300 indices (100 triangles) using TriangleList
718        let mesh_uuid = AssetUUID::new();
719        let gpu_mesh = GpuMesh {
720            vertex_buffer: BufferId(0),
721            index_buffer: BufferId(1),
722            index_count: 300,
723            index_format: IndexFormat::Uint32,
724            primitive_topology: PrimitiveTopology::TriangleList,
725        };
726        let gpu_mesh_handle = AssetHandle::new(gpu_mesh);
727        let mut gpu_meshes = Assets::<GpuMesh>::new();
728        gpu_meshes.insert(mesh_uuid, gpu_mesh_handle.clone());
729
730        let mut render_world = RenderWorld::default();
731        render_world.meshes.push(ExtractedMesh {
732            transform: Default::default(),
733            cpu_mesh_uuid: mesh_uuid,
734            gpu_mesh: gpu_mesh_handle,
735            material: None,
736        });
737
738        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
739        let cost = lane.estimate_render_cost(&render_world, &gpu_meshes_lock);
740
741        // Expected: 100 triangles * 0.001 + 1 draw call * 0.1 = 0.1 + 0.1 = 0.2
742        assert_eq!(
743            cost, 0.2,
744            "Cost should be 0.2 for 100 triangles + 1 draw call"
745        );
746    }
747
748    #[test]
749    fn test_cost_estimation_triangle_strip() {
750        use crate::render_lane::world::ExtractedMesh;
751        use khora_core::asset::AssetUUID;
752
753        let lane = SimpleUnlitLane::new();
754
755        // Create a GPU mesh with 52 indices (50 triangles) using TriangleStrip
756        let mesh_uuid = AssetUUID::new();
757        let gpu_mesh = GpuMesh {
758            vertex_buffer: BufferId(0),
759            index_buffer: BufferId(1),
760            index_count: 52,
761            index_format: IndexFormat::Uint16,
762            primitive_topology: PrimitiveTopology::TriangleStrip,
763        };
764        let gpu_mesh_handle = AssetHandle::new(gpu_mesh);
765        let mut gpu_meshes = Assets::<GpuMesh>::new();
766        gpu_meshes.insert(mesh_uuid, gpu_mesh_handle.clone());
767
768        let mut render_world = RenderWorld::default();
769        render_world.meshes.push(ExtractedMesh {
770            transform: Default::default(),
771            cpu_mesh_uuid: mesh_uuid,
772            gpu_mesh: gpu_mesh_handle,
773            material: None,
774        });
775
776        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
777        let cost = lane.estimate_render_cost(&render_world, &gpu_meshes_lock);
778
779        // Expected: 50 triangles * 0.001 + 1 draw call * 0.1 = 0.05 + 0.1 = 0.15
780        assert_eq!(
781            cost, 0.15,
782            "Cost should be 0.15 for 50 triangles + 1 draw call"
783        );
784    }
785
786    #[test]
787    fn test_cost_estimation_lines_and_points() {
788        use crate::render_lane::world::ExtractedMesh;
789        use khora_core::asset::AssetUUID;
790
791        let lane = SimpleUnlitLane::new();
792
793        // Create meshes with non-triangle topologies
794        let line_uuid = AssetUUID::new();
795        let point_uuid = AssetUUID::new();
796
797        let line_mesh = GpuMesh {
798            vertex_buffer: BufferId(0),
799            index_buffer: BufferId(1),
800            index_count: 100,
801            index_format: IndexFormat::Uint32,
802            primitive_topology: PrimitiveTopology::LineList,
803        };
804
805        let point_mesh = GpuMesh {
806            vertex_buffer: BufferId(2),
807            index_buffer: BufferId(3),
808            index_count: 50,
809            index_format: IndexFormat::Uint32,
810            primitive_topology: PrimitiveTopology::PointList,
811        };
812        let line_mesh_handle = AssetHandle::new(line_mesh);
813        let point_mesh_handle = AssetHandle::new(point_mesh);
814
815        let mut gpu_meshes = Assets::<GpuMesh>::new();
816        gpu_meshes.insert(line_uuid, line_mesh_handle.clone());
817        gpu_meshes.insert(point_uuid, point_mesh_handle.clone());
818
819        let mut render_world = RenderWorld::default();
820        render_world.meshes.push(ExtractedMesh {
821            transform: Default::default(),
822            cpu_mesh_uuid: line_uuid,
823            gpu_mesh: line_mesh_handle,
824            material: None,
825        });
826        render_world.meshes.push(ExtractedMesh {
827            transform: Default::default(),
828            cpu_mesh_uuid: point_uuid,
829            gpu_mesh: point_mesh_handle,
830            material: None,
831        });
832
833        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
834        let cost = lane.estimate_render_cost(&render_world, &gpu_meshes_lock);
835
836        // Expected: 0 triangles * 0.001 + 2 draw calls * 0.1 = 0.0 + 0.2 = 0.2
837        assert_eq!(
838            cost, 0.2,
839            "Cost should be 0.2 for 2 draw calls with no triangles"
840        );
841    }
842
843    #[test]
844    fn test_cost_estimation_multiple_meshes() {
845        use crate::render_lane::world::ExtractedMesh;
846        use khora_core::asset::AssetUUID;
847
848        let lane = SimpleUnlitLane::new();
849
850        // Create 3 different meshes
851        let mesh1_uuid = AssetUUID::new();
852        let mesh2_uuid = AssetUUID::new();
853        let mesh3_uuid = AssetUUID::new();
854
855        let mesh1 = GpuMesh {
856            vertex_buffer: BufferId(0),
857            index_buffer: BufferId(1),
858            index_count: 600, // 200 triangles
859            index_format: IndexFormat::Uint32,
860            primitive_topology: PrimitiveTopology::TriangleList,
861        };
862
863        let mesh2 = GpuMesh {
864            vertex_buffer: BufferId(2),
865            index_buffer: BufferId(3),
866            index_count: 102, // 100 triangles (strip)
867            index_format: IndexFormat::Uint16,
868            primitive_topology: PrimitiveTopology::TriangleStrip,
869        };
870
871        let mesh3 = GpuMesh {
872            vertex_buffer: BufferId(4),
873            index_buffer: BufferId(5),
874            index_count: 150, // 50 triangles
875            index_format: IndexFormat::Uint32,
876            primitive_topology: PrimitiveTopology::TriangleList,
877        };
878
879        let mut gpu_meshes = Assets::<GpuMesh>::new();
880        gpu_meshes.insert(mesh1_uuid, AssetHandle::new(mesh1));
881        gpu_meshes.insert(mesh2_uuid, AssetHandle::new(mesh2));
882        gpu_meshes.insert(mesh3_uuid, AssetHandle::new(mesh3));
883
884        let mut render_world = RenderWorld::default();
885        render_world.meshes.push(ExtractedMesh {
886            transform: Default::default(),
887            cpu_mesh_uuid: mesh1_uuid,
888            gpu_mesh: AssetHandle::new(create_test_mesh(600)),
889            material: None,
890        });
891        render_world.meshes.push(ExtractedMesh {
892            transform: Default::default(),
893            cpu_mesh_uuid: mesh2_uuid,
894            gpu_mesh: AssetHandle::new(create_test_mesh(102)),
895            material: None,
896        });
897        render_world.meshes.push(ExtractedMesh {
898            transform: Default::default(),
899            cpu_mesh_uuid: mesh3_uuid,
900            gpu_mesh: AssetHandle::new(create_test_mesh(150)),
901            material: None,
902        });
903
904        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
905        let cost = lane.estimate_render_cost(&render_world, &gpu_meshes_lock);
906
907        // Expected: (200 + 100 + 50) triangles * 0.001 + 3 draw calls * 0.1
908        //         = 350 * 0.001 + 3 * 0.1 = 0.35 + 0.3 = 0.65
909        assert!(
910            (cost - 0.65).abs() < 0.0001,
911            "Cost should be approximately 0.65 for 350 triangles + 3 draw calls, got {}",
912            cost
913        );
914    }
915
916    // Helper to create test mesh
917    fn create_test_mesh(index_count: u32) -> GpuMesh {
918        GpuMesh {
919            vertex_buffer: BufferId(0),
920            index_buffer: BufferId(1),
921            index_count,
922            index_format: IndexFormat::Uint32,
923            primitive_topology: PrimitiveTopology::TriangleList,
924        }
925    }
926
927    #[test]
928    fn test_cost_estimation_missing_mesh() {
929        use crate::render_lane::world::ExtractedMesh;
930        use khora_core::asset::AssetUUID;
931
932        let lane = SimpleUnlitLane::new();
933        let gpu_meshes = Arc::new(RwLock::new(Assets::<GpuMesh>::new()));
934
935        // Reference a mesh that doesn't exist in the cache
936        let mut render_world = RenderWorld::default();
937        render_world.meshes.push(ExtractedMesh {
938            transform: Default::default(),
939            cpu_mesh_uuid: AssetUUID::new(),
940            gpu_mesh: AssetHandle::new(create_test_mesh(300)),
941            material: None,
942        });
943
944        let cost = lane.estimate_render_cost(&render_world, &gpu_meshes);
945
946        // Expected: 0 cost since mesh is not found
947        assert_eq!(cost, 0.0, "Missing mesh should contribute zero cost");
948    }
949
950    #[test]
951    fn test_cost_estimation_degenerate_triangle_strip() {
952        use crate::render_lane::world::ExtractedMesh;
953        use khora_core::asset::AssetUUID;
954
955        let lane = SimpleUnlitLane::new();
956
957        // Create a triangle strip with only 2 indices (not enough for a triangle)
958        let mesh_uuid = AssetUUID::new();
959        let gpu_mesh = GpuMesh {
960            vertex_buffer: BufferId(0),
961            index_buffer: BufferId(1),
962            index_count: 2,
963            index_format: IndexFormat::Uint16,
964            primitive_topology: PrimitiveTopology::TriangleStrip,
965        };
966
967        let handle = AssetHandle::new(gpu_mesh);
968        let mut gpu_meshes = Assets::<GpuMesh>::new();
969        gpu_meshes.insert(mesh_uuid, handle.clone());
970
971        let mut render_world = RenderWorld::default();
972        render_world.meshes.push(ExtractedMesh {
973            transform: Default::default(),
974            cpu_mesh_uuid: mesh_uuid,
975            gpu_mesh: handle,
976            material: None,
977        });
978
979        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
980        let cost = lane.estimate_render_cost(&render_world, &gpu_meshes_lock);
981
982        // Expected: 0 triangles + 1 draw call * 0.1 = 0.1
983        assert_eq!(
984            cost, 0.1,
985            "Degenerate triangle strip should only cost draw call overhead"
986        );
987    }
988
989    #[test]
990    fn test_get_pipeline_for_material_with_none() {
991        let lane = SimpleUnlitLane::new();
992
993        let pipeline = lane.get_pipeline_for_material(None);
994        assert_eq!(
995            pipeline,
996            RenderPipelineId(0),
997            "None material should use default pipeline"
998        );
999    }
1000
1001    #[test]
1002    fn test_get_pipeline_for_material_not_found() {
1003        let lane = SimpleUnlitLane::new();
1004
1005        // Since there is no registry anymore, we just test with None or a dummy handle.
1006        // The old test "missing material" is now redundant with "None material".
1007        let pipeline = lane.get_pipeline_for_material(None);
1008        assert_eq!(
1009            pipeline,
1010            RenderPipelineId(0),
1011            "Missing material should use default pipeline"
1012        );
1013    }
1014}