Skip to main content

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 khora_core::{
30    asset::Material,
31    renderer::{
32        api::{
33            command::{
34                LoadOp, Operations, RenderPassColorAttachment, RenderPassDepthStencilAttachment,
35                RenderPassDescriptor, StoreOp,
36            },
37            core::RenderContext,
38            pipeline::enums::PrimitiveTopology,
39            pipeline::{LayoutKey, LayoutSpec, PipelineSpec, RenderPipelineId, ShaderVariantKey},
40            scene::GpuMesh,
41        },
42        traits::CommandEncoder,
43    },
44};
45use khora_data::assets::Assets;
46use khora_data::render::RenderWorld;
47use std::sync::{OnceLock, 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    // Init-once handles (set in `on_initialize`, read every frame).
62    // `OnceLock` gives lock-free reads after the one-time write — no
63    // per-frame Mutex contention on the hot path.
64    pipeline: OnceLock<RenderPipelineId>,
65    camera_layout: OnceLock<khora_core::renderer::api::command::BindGroupLayoutId>,
66    model_layout: OnceLock<khora_core::renderer::api::command::BindGroupLayoutId>,
67    material_layout: OnceLock<khora_core::renderer::api::command::BindGroupLayoutId>,
68
69    // Per-frame mutated ring buffers — `Mutex` is required for exclusive
70    // access during `advance` / `write` / `push`.
71    camera_ring: std::sync::Mutex<
72        Option<khora_core::renderer::api::util::uniform_ring_buffer::UniformRingBuffer>,
73    >,
74    model_ring: std::sync::Mutex<
75        Option<khora_core::renderer::api::util::dynamic_uniform_buffer::DynamicUniformRingBuffer>,
76    >,
77    material_ring: std::sync::Mutex<
78        Option<khora_core::renderer::api::util::dynamic_uniform_buffer::DynamicUniformRingBuffer>,
79    >,
80}
81
82impl Default for SimpleUnlitLane {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88impl SimpleUnlitLane {
89    /// Creates a new `SimpleUnlitLane`.
90    pub fn new() -> Self {
91        Self {
92            pipeline: OnceLock::new(),
93            camera_layout: OnceLock::new(),
94            model_layout: OnceLock::new(),
95            material_layout: OnceLock::new(),
96            camera_ring: std::sync::Mutex::new(None),
97            model_ring: std::sync::Mutex::new(None),
98            material_ring: std::sync::Mutex::new(None),
99        }
100    }
101}
102
103impl khora_core::lane::Lane for SimpleUnlitLane {
104    fn strategy_name(&self) -> &'static str {
105        "SimpleUnlit"
106    }
107
108    fn lane_kind(&self) -> khora_core::lane::LaneKind {
109        khora_core::lane::LaneKind::Render
110    }
111
112    fn estimate_cost(&self, ctx: &khora_core::lane::LaneContext) -> f32 {
113        let render_world = match ctx.get::<khora_core::lane::Ref<khora_data::render::RenderWorld>>()
114        {
115            Some(slot) => slot.get(),
116            None => return 1.0,
117        };
118        let gpu_meshes = match ctx.get::<std::sync::Arc<
119            std::sync::RwLock<
120                khora_data::assets::Assets<khora_core::renderer::api::scene::GpuMesh>,
121            >,
122        >>() {
123            Some(arc) => arc,
124            None => return 1.0,
125        };
126        self.estimate_render_cost(render_world, gpu_meshes)
127    }
128
129    fn on_initialize(
130        &self,
131        ctx: &mut khora_core::lane::LaneContext,
132    ) -> Result<(), khora_core::lane::LaneError> {
133        let device = ctx
134            .get::<std::sync::Arc<dyn khora_core::renderer::GraphicsDevice>>()
135            .ok_or(khora_core::lane::LaneError::missing(
136                "Arc<dyn GraphicsDevice>",
137            ))?
138            .clone();
139        let pipeline_system = ctx
140            .get::<std::sync::Arc<dyn khora_core::renderer::traits::PipelineSystem>>()
141            .ok_or(khora_core::lane::LaneError::missing(
142                "Arc<dyn PipelineSystem>",
143            ))?
144            .clone();
145        self.on_gpu_init(device.as_ref(), pipeline_system.as_ref())
146            .map_err(|e| khora_core::lane::LaneError::InitializationFailed(Box::new(e)))
147    }
148
149    fn execute(
150        &self,
151        ctx: &mut khora_core::lane::LaneContext,
152    ) -> Result<(), khora_core::lane::LaneError> {
153        use khora_core::lane::{LaneError, Ref, Slot};
154        let device = ctx
155            .get::<std::sync::Arc<dyn khora_core::renderer::GraphicsDevice>>()
156            .ok_or(LaneError::missing("Arc<dyn GraphicsDevice>"))?
157            .clone();
158        let gpu_meshes = ctx
159            .get::<std::sync::Arc<
160                std::sync::RwLock<
161                    khora_data::assets::Assets<khora_core::renderer::api::scene::GpuMesh>,
162                >,
163            >>()
164            .ok_or(LaneError::missing("Arc<RwLock<Assets<GpuMesh>>>"))?
165            .clone();
166        let encoder = ctx
167            .get::<Slot<dyn khora_core::renderer::traits::CommandEncoder>>()
168            .ok_or(LaneError::missing("Slot<dyn CommandEncoder>"))?
169            .get();
170        let render_world = ctx
171            .get::<Ref<khora_data::render::RenderWorld>>()
172            .ok_or(LaneError::missing("Ref<RenderWorld>"))?
173            .get();
174        let color_target = ctx
175            .get::<khora_core::lane::ColorTarget>()
176            .ok_or(LaneError::missing("ColorTarget"))?
177            .0;
178        let depth_target = ctx
179            .get::<khora_core::lane::DepthTarget>()
180            .ok_or(LaneError::missing("DepthTarget"))?
181            .0;
182        let clear_color = ctx
183            .get::<khora_core::lane::ClearColor>()
184            .ok_or(LaneError::missing("ClearColor"))?
185            .0;
186        let shadow_atlas = ctx.get::<khora_core::lane::ShadowAtlasView>().map(|v| v.0);
187        let shadow_sampler = ctx
188            .get::<khora_core::lane::ShadowComparisonSampler>()
189            .map(|v| v.0);
190
191        let mut render_ctx = khora_core::renderer::api::core::RenderContext::new(
192            &color_target,
193            Some(&depth_target),
194            clear_color,
195        );
196        render_ctx.shadow_atlas = shadow_atlas.as_ref();
197        render_ctx.shadow_sampler = shadow_sampler.as_ref();
198
199        self.render(
200            render_world,
201            device.as_ref(),
202            encoder,
203            &render_ctx,
204            &gpu_meshes,
205        );
206        Ok(())
207    }
208
209    fn on_shutdown(&self, ctx: &mut khora_core::lane::LaneContext) {
210        if let Some(device) = ctx.get::<std::sync::Arc<dyn khora_core::renderer::GraphicsDevice>>()
211        {
212            self.on_gpu_shutdown(device.as_ref());
213        }
214    }
215
216    fn as_any(&self) -> &dyn std::any::Any {
217        self
218    }
219
220    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
221        self
222    }
223}
224
225impl SimpleUnlitLane {
226    /// Returns the render pipeline for the given material (or default).
227    pub fn get_pipeline_for_material(
228        &self,
229        _material: Option<&khora_core::asset::AssetHandle<Box<dyn Material>>>,
230    ) -> RenderPipelineId {
231        // Lock-free read — `OnceLock::get()` returns a borrow without
232        // any synchronization once the cell has been initialized.
233        self.pipeline.get().copied().unwrap_or(RenderPipelineId(0))
234    }
235
236    fn render(
237        &self,
238        render_world: &RenderWorld,
239        device: &dyn khora_core::renderer::GraphicsDevice,
240        encoder: &mut dyn CommandEncoder,
241        render_ctx: &RenderContext,
242        gpu_meshes: &RwLock<Assets<GpuMesh>>,
243    ) {
244        use khora_core::renderer::api::{resource::CameraUniformData, scene::ModelUniforms};
245
246        // 1. Get Active Camera View.
247        //
248        // When no camera is present (e.g. editor viewport before any in-scene
249        // camera is added), a clear render pass must still be submitted
250        let Some(view) = render_world.views.first() else {
251            let color_attachment = RenderPassColorAttachment {
252                view: render_ctx.color_target,
253                resolve_target: None,
254                ops: Operations {
255                    load: LoadOp::Clear(render_ctx.clear_color),
256                    store: StoreOp::Store,
257                },
258                base_array_layer: 0,
259                base_mip_level: 0,
260            };
261            let clear_desc = RenderPassDescriptor {
262                label: Some("Simple Unlit Clear-Only Pass"),
263                color_attachments: &[color_attachment],
264                depth_stencil_attachment: render_ctx.depth_target.map(|depth_view| {
265                    RenderPassDepthStencilAttachment {
266                        view: depth_view,
267                        depth_ops: Some(Operations {
268                            load: LoadOp::Clear(1.0),
269                            store: StoreOp::Store,
270                        }),
271                        stencil_ops: None,
272                        base_array_layer: 0,
273                    }
274                }),
275            };
276            // Dropping the render pass immediately ends it — the clear is
277            // recorded into the encoder without any draw calls.
278            let _ = encoder.begin_render_pass(&clear_desc);
279            return;
280        };
281
282        // 2. Prepare Camera Uniforms via Persistent Ring Buffer
283        let camera_uniforms = CameraUniformData {
284            view_projection: view.view_proj.to_cols_array_2d(),
285            camera_position: [view.position.x, view.position.y, view.position.z, 1.0],
286        };
287
288        let camera_bind_group = {
289            let mut lock = crate::lock_or_log!(
290                self.camera_ring.lock(),
291                "SimpleUnlitLane::render camera_ring"
292            );
293            let ring = match lock.as_mut() {
294                Some(r) => r,
295                None => {
296                    log::warn!("SimpleUnlitLane: camera ring buffer not initialized");
297                    return;
298                }
299            };
300            ring.advance();
301            if let Err(e) = ring.write(device, bytemuck::bytes_of(&camera_uniforms)) {
302                log::error!("Failed to write camera ring buffer: {:?}", e);
303                return;
304            }
305            *ring.current_bind_group()
306        };
307
308        // Lock the model ring buffer and advance it for this frame
309        let mut model_ring_lock =
310            crate::lock_or_log!(self.model_ring.lock(), "SimpleUnlitLane::render model_ring");
311        let model_ring = match model_ring_lock.as_mut() {
312            Some(mr) => {
313                mr.advance();
314                mr
315            }
316            None => return,
317        };
318
319        let mut material_ring_lock = crate::lock_or_log!(
320            self.material_ring.lock(),
321            "SimpleUnlitLane::render material_ring"
322        );
323        let material_ring = match material_ring_lock.as_mut() {
324            Some(mr) => {
325                mr.advance();
326                mr
327            }
328            None => return,
329        };
330
331        // Acquire read locks on the caches
332        let gpu_mesh_assets =
333            crate::lock_or_log!(gpu_meshes.read(), "SimpleUnlitLane::render gpu_meshes");
334
335        // 3. Prepare Draw Commands
336        let mut draw_commands = Vec::with_capacity(render_world.meshes.len());
337
338        for extracted_mesh in &render_world.meshes {
339            if let Some(gpu_mesh_handle) = gpu_mesh_assets.get(&extracted_mesh.cpu_mesh_uuid) {
340                // Get the pre-computed pipeline for this mesh
341                let pipeline = self.get_pipeline_for_material(extracted_mesh.material.as_ref());
342
343                // Create Per-Mesh Uniforms
344                let model_mat = extracted_mesh.transform.to_matrix();
345
346                let normal_mat = if let Some(inverse) = model_mat.inverse() {
347                    inverse.transpose()
348                } else {
349                    continue; // Skip if degenerate transform
350                };
351
352                let mut base_color = khora_core::math::LinearRgba::WHITE;
353                if let Some(mat_handle) = &extracted_mesh.material {
354                    base_color = mat_handle.base_color();
355                }
356
357                let model_uniforms = ModelUniforms {
358                    model_matrix: model_mat.to_cols_array_2d(),
359                    normal_matrix: normal_mat.to_cols_array_2d(),
360                };
361
362                let offset = match model_ring.push(device, bytemuck::bytes_of(&model_uniforms)) {
363                    Ok(off) => off,
364                    Err(_) => continue,
365                };
366                let model_bg = *model_ring.current_bind_group();
367
368                // Build MaterialUniforms
369                let material_uniforms = khora_core::renderer::api::scene::MaterialUniforms {
370                    base_color,
371                    emissive: khora_core::math::LinearRgba::BLACK,
372                    ambient: khora_core::math::LinearRgba::BLACK,
373                    pbr_factors: [0.0, 1.0, 0.5, 0.0],
374                };
375
376                let mat_offset =
377                    match material_ring.push(device, bytemuck::bytes_of(&material_uniforms)) {
378                        Ok(off) => off,
379                        Err(_) => continue,
380                    };
381                let material_bg = *material_ring.current_bind_group();
382
383                draw_commands.push(khora_core::renderer::api::command::DrawCommand {
384                    pipeline,
385                    vertex_buffer: gpu_mesh_handle.vertex_buffer,
386                    index_buffer: gpu_mesh_handle.index_buffer,
387                    index_format: gpu_mesh_handle.index_format,
388                    index_count: gpu_mesh_handle.index_count,
389                    model_bind_group: Some(model_bg),
390                    model_offset: offset,
391                    material_bind_group: Some(material_bg),
392                    material_offset: mat_offset,
393                });
394            }
395        }
396
397        // Configure the render pass to render into the provided color target
398        let color_attachment = RenderPassColorAttachment {
399            view: render_ctx.color_target,
400            resolve_target: None,
401            ops: Operations {
402                load: LoadOp::Clear(render_ctx.clear_color),
403                store: StoreOp::Store,
404            },
405            base_array_layer: 0,
406            base_mip_level: 0,
407        };
408
409        let render_pass_desc = RenderPassDescriptor {
410            label: Some("Simple Unlit Pass"),
411            color_attachments: &[color_attachment],
412            depth_stencil_attachment: render_ctx.depth_target.map(|depth_view| {
413                RenderPassDepthStencilAttachment {
414                    view: depth_view,
415                    depth_ops: Some(Operations {
416                        load: LoadOp::Clear(1.0),
417                        store: StoreOp::Store,
418                    }),
419                    stencil_ops: None,
420                    base_array_layer: 0,
421                }
422            }),
423        };
424
425        // Begin the render pass
426        let mut render_pass = encoder.begin_render_pass(&render_pass_desc);
427
428        // Bind global camera
429        render_pass.set_bind_group(0, &camera_bind_group, &[]);
430
431        // Track the last pipeline we bound to avoid redundant state changes
432        let mut current_pipeline: Option<RenderPipelineId> = None;
433
434        for cmd in &draw_commands {
435            if current_pipeline != Some(cmd.pipeline) {
436                render_pass.set_pipeline(&cmd.pipeline);
437                current_pipeline = Some(cmd.pipeline);
438            }
439
440            if let Some(ref bg) = cmd.model_bind_group {
441                render_pass.set_bind_group(1, bg, &[cmd.model_offset]);
442            }
443
444            if let Some(ref bg) = cmd.material_bind_group {
445                render_pass.set_bind_group(2, bg, &[cmd.material_offset]);
446            }
447
448            render_pass.set_vertex_buffer(0, &cmd.vertex_buffer, 0);
449            render_pass.set_index_buffer(&cmd.index_buffer, 0, cmd.index_format);
450            render_pass.draw_indexed(0..cmd.index_count, 0, 0..1);
451        }
452    }
453
454    fn estimate_render_cost(
455        &self,
456        render_world: &RenderWorld,
457        gpu_meshes: &RwLock<Assets<GpuMesh>>,
458    ) -> f32 {
459        let gpu_mesh_assets = crate::lock_or_log!(
460            gpu_meshes.read(),
461            "SimpleUnlitLane::estimate_render_cost",
462            0.0
463        );
464
465        let mut total_triangles = 0u32;
466        let mut draw_call_count = 0u32;
467
468        for extracted_mesh in &render_world.meshes {
469            if let Some(gpu_mesh) = gpu_mesh_assets.get(&extracted_mesh.cpu_mesh_uuid) {
470                // Calculate triangle count based on primitive topology
471                let triangle_count = match gpu_mesh.primitive_topology {
472                    PrimitiveTopology::TriangleList => gpu_mesh.index_count / 3,
473                    PrimitiveTopology::TriangleStrip => {
474                        if gpu_mesh.index_count >= 3 {
475                            gpu_mesh.index_count - 2
476                        } else {
477                            0
478                        }
479                    }
480                    // Lines and points don't contribute to triangle count
481                    PrimitiveTopology::LineList
482                    | PrimitiveTopology::LineStrip
483                    | PrimitiveTopology::PointList => 0,
484                };
485
486                total_triangles += triangle_count;
487                draw_call_count += 1;
488            }
489        }
490
491        // Cost model: triangles have a small per-triangle cost,
492        // draw calls have a fixed overhead
493        const TRIANGLE_COST: f32 = 0.001;
494        const DRAW_CALL_COST: f32 = 0.1;
495
496        (total_triangles as f32 * TRIANGLE_COST) + (draw_call_count as f32 * DRAW_CALL_COST)
497    }
498
499    fn on_gpu_init(
500        &self,
501        device: &dyn khora_core::renderer::GraphicsDevice,
502        pipeline_system: &dyn khora_core::renderer::traits::PipelineSystem,
503    ) -> Result<(), khora_core::renderer::error::RenderError> {
504        use khora_core::renderer::api::{
505            resource::CameraUniformData, scene::ModelUniforms,
506            util::uniform_ring_buffer::UniformRingBuffer,
507        };
508
509        log::info!("SimpleUnlitLane: Initializing GPU resources...");
510
511        // The camera layout is the canonical engine layout (shared with the
512        // lit lanes); the model + material layouts are bespoke to this unlit
513        // strategy (single dynamic-offset uniforms, no PBR textures), resolved
514        // as inline layouts so the ring buffers + pipeline share one id.
515        let variant = ShaderVariantKey::empty();
516        let camera_layout = pipeline_system.layout(device, LayoutKey::Camera, &variant)?;
517        let model_layout = pipeline_system.inline_layout(
518            device,
519            UNLIT_MODEL_LAYOUT_LABEL,
520            &unlit_model_layout_entries(),
521        )?;
522        let material_layout = pipeline_system.inline_layout(
523            device,
524            UNLIT_MATERIAL_LAYOUT_LABEL,
525            &unlit_material_layout_entries(),
526        )?;
527
528        // Pipeline (compiled + cached by the backend).
529        let pipeline_id = pipeline_system.pipeline(device, &pipeline_spec(device))?;
530
531        // Init-once writes — `set` returns Err if already initialized,
532        // which we ignore: a second `on_initialize` is a logic bug
533        // upstream, not a runtime failure.
534        let _ = self.camera_layout.set(camera_layout);
535        let _ = self.model_layout.set(model_layout);
536        let _ = self.material_layout.set(material_layout);
537        let _ = self.pipeline.set(pipeline_id);
538
539        let camera_ring = UniformRingBuffer::new(
540            device,
541            camera_layout,
542            0,
543            std::mem::size_of::<CameraUniformData>() as u64,
544            "Camera Uniform Ring Runlit",
545        )
546        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
547
548        *crate::render_lane::util::lock::mutex_lock_render(
549            &self.camera_ring,
550            "SimpleUnlit init.camera_ring",
551        )? = Some(camera_ring);
552
553        let model_ring =
554            khora_core::renderer::api::util::dynamic_uniform_buffer::DynamicUniformRingBuffer::new(
555                device,
556                model_layout,
557                0,
558                std::mem::size_of::<ModelUniforms>() as u32,
559                khora_core::renderer::api::util::dynamic_uniform_buffer::DEFAULT_MAX_ELEMENTS,
560                khora_core::renderer::api::util::dynamic_uniform_buffer::MIN_UNIFORM_ALIGNMENT,
561                "Model Dynamic Ring Runlit",
562            )
563            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
564
565        *crate::render_lane::util::lock::mutex_lock_render(
566            &self.model_ring,
567            "SimpleUnlit init.model_ring",
568        )? = Some(model_ring);
569
570        let material_ring =
571            khora_core::renderer::api::util::dynamic_uniform_buffer::DynamicUniformRingBuffer::new(
572                device,
573                material_layout,
574                0, // Binding size
575                std::mem::size_of::<khora_core::renderer::api::scene::MaterialUniforms>() as u32,
576                khora_core::renderer::api::util::dynamic_uniform_buffer::DEFAULT_MAX_ELEMENTS,
577                khora_core::renderer::api::util::dynamic_uniform_buffer::MIN_UNIFORM_ALIGNMENT,
578                "Material Dynamic Ring Runlit",
579            )
580            .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
581
582        *crate::render_lane::util::lock::mutex_lock_render(
583            &self.material_ring,
584            "SimpleUnlit init.material_ring",
585        )? = Some(material_ring);
586
587        Ok(())
588    }
589
590    fn on_gpu_shutdown(&self, device: &dyn khora_core::renderer::GraphicsDevice) {
591        // Ring buffers own their GPU buffers + bind groups; the pipeline and
592        // bind-group layouts are owned + cached by the `PipelineSystem`
593        // backend, so the lane must not destroy them here.
594        if let Some(ring) = self.camera_ring.lock().ok().and_then(|mut g| g.take()) {
595            ring.destroy(device);
596        }
597        if let Some(ring) = self.model_ring.lock().ok().and_then(|mut g| g.take()) {
598            ring.destroy(device);
599        }
600        if let Some(ring) = self.material_ring.lock().ok().and_then(|mut g| g.take()) {
601            ring.destroy(device);
602        }
603    }
604}
605
606// ─── Free functions (CLAD: declarative pipeline spec + bespoke layouts) ───
607
608/// Stable cache label for the unlit per-draw model layout.
609const UNLIT_MODEL_LAYOUT_LABEL: &str = "simple_unlit_model_layout";
610/// Stable cache label for the unlit per-draw material layout.
611const UNLIT_MATERIAL_LAYOUT_LABEL: &str = "simple_unlit_material_layout";
612
613/// Bespoke group-1 (model) layout: a single dynamic-offset uniform buffer.
614fn unlit_model_layout_entries() -> Vec<khora_core::renderer::api::command::BindGroupLayoutEntry> {
615    use khora_core::renderer::api::command::{
616        BindGroupLayoutEntry, BindingType, BufferBindingType,
617    };
618    use khora_core::renderer::api::scene::ModelUniforms;
619    use khora_core::renderer::api::util::ShaderStageFlags;
620    vec![BindGroupLayoutEntry {
621        binding: 0,
622        visibility: ShaderStageFlags::VERTEX,
623        ty: BindingType::Buffer {
624            ty: BufferBindingType::Uniform,
625            has_dynamic_offset: true,
626            min_binding_size: std::num::NonZeroU64::new(std::mem::size_of::<ModelUniforms>() as u64),
627        },
628    }]
629}
630
631/// Bespoke group-2 (material) layout: a single dynamic-offset uniform buffer
632/// (unlit has no PBR textures, so it does not use the canonical material
633/// layout).
634fn unlit_material_layout_entries() -> Vec<khora_core::renderer::api::command::BindGroupLayoutEntry>
635{
636    use khora_core::renderer::api::command::{
637        BindGroupLayoutEntry, BindingType, BufferBindingType,
638    };
639    use khora_core::renderer::api::scene::MaterialUniforms;
640    use khora_core::renderer::api::util::ShaderStageFlags;
641    vec![BindGroupLayoutEntry {
642        binding: 0,
643        visibility: ShaderStageFlags::FRAGMENT,
644        ty: BindingType::Buffer {
645            ty: BufferBindingType::Uniform,
646            has_dynamic_offset: true,
647            min_binding_size: std::num::NonZeroU64::new(
648                std::mem::size_of::<MaterialUniforms>() as u64
649            ),
650        },
651    }]
652}
653
654/// The declarative pipeline spec for SimpleUnlit — built each call, deduped by
655/// the `PipelineSystem`. Camera is the canonical layout; model + material are
656/// bespoke inline layouts shared with the lane's ring buffers.
657fn pipeline_spec(device: &dyn khora_core::renderer::GraphicsDevice) -> PipelineSpec {
658    use khora_core::renderer::api::pipeline::enums::{
659        CompareFunction, VertexFormat, VertexStepMode,
660    };
661    use khora_core::renderer::api::pipeline::state::{
662        ColorWrites, DepthBiasState, StencilFaceState,
663    };
664    use khora_core::renderer::api::pipeline::{
665        ColorTargetStateDescriptor, DepthStencilStateDescriptor, MultisampleStateDescriptor,
666        PrimitiveStateDescriptor, VertexAttributeDescriptor, VertexBufferLayoutDescriptor,
667    };
668    use khora_core::renderer::api::util::{SampleCount, TextureFormat};
669    use std::borrow::Cow;
670
671    PipelineSpec {
672        label: "SimpleUnlit Pipeline",
673        shader: "khora::pipelines::unlit",
674        variant: ShaderVariantKey::empty(),
675        bind_group_layouts: vec![
676            LayoutSpec::Named(LayoutKey::Camera),
677            LayoutSpec::Inline {
678                label: UNLIT_MODEL_LAYOUT_LABEL,
679                entries: Cow::Owned(unlit_model_layout_entries()),
680            },
681            LayoutSpec::Inline {
682                label: UNLIT_MATERIAL_LAYOUT_LABEL,
683                entries: Cow::Owned(unlit_material_layout_entries()),
684            },
685        ],
686        vertex_buffers: vec![VertexBufferLayoutDescriptor {
687            array_stride: 32,
688            step_mode: VertexStepMode::Vertex,
689            attributes: Cow::Owned(vec![
690                VertexAttributeDescriptor {
691                    format: VertexFormat::Float32x3,
692                    offset: 0,
693                    shader_location: 0,
694                },
695                VertexAttributeDescriptor {
696                    format: VertexFormat::Float32x3,
697                    offset: 12,
698                    shader_location: 1,
699                },
700                VertexAttributeDescriptor {
701                    format: VertexFormat::Float32x2,
702                    offset: 24,
703                    shader_location: 2,
704                },
705            ]),
706        }],
707        vs_entry: "vs_main",
708        fs_entry: Some("fs_main"),
709        primitive: PrimitiveStateDescriptor {
710            topology: PrimitiveTopology::TriangleList,
711            ..Default::default()
712        },
713        depth_stencil: Some(DepthStencilStateDescriptor {
714            format: TextureFormat::Depth32Float,
715            depth_write_enabled: true,
716            depth_compare: CompareFunction::Less,
717            stencil_front: StencilFaceState::default(),
718            stencil_back: StencilFaceState::default(),
719            stencil_read_mask: 0,
720            stencil_write_mask: 0,
721            bias: DepthBiasState::default(),
722        }),
723        color_targets: vec![ColorTargetStateDescriptor {
724            format: device
725                .get_surface_format()
726                .unwrap_or(TextureFormat::Rgba8UnormSrgb),
727            blend: None,
728            write_mask: ColorWrites::ALL,
729        }],
730        multisample: MultisampleStateDescriptor {
731            count: SampleCount::X1,
732            mask: !0,
733            alpha_to_coverage_enabled: false,
734        },
735    }
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use khora_core::lane::Lane;
742    use khora_core::{
743        asset::AssetHandle,
744        renderer::api::{
745            pipeline::enums::PrimitiveTopology, resource::BufferId, util::IndexFormat,
746        },
747    };
748    use khora_data::render::ExtractedMesh;
749    use std::sync::Arc;
750
751    #[test]
752    fn test_simple_unlit_lane_creation() {
753        let lane = SimpleUnlitLane::new();
754        assert_eq!(lane.strategy_name(), "SimpleUnlit");
755    }
756
757    #[test]
758    fn test_default_construction() {
759        let lane = SimpleUnlitLane::new();
760        assert_eq!(lane.strategy_name(), "SimpleUnlit");
761    }
762
763    #[test]
764    fn test_cost_estimation_empty_world() {
765        let lane = SimpleUnlitLane::new();
766        let render_world = RenderWorld::default();
767        let gpu_meshes = Arc::new(RwLock::new(Assets::<GpuMesh>::new()));
768
769        let cost = lane.estimate_render_cost(&render_world, &gpu_meshes);
770        assert_eq!(cost, 0.0, "Empty world should have zero cost");
771    }
772
773    #[test]
774    fn test_cost_estimation_triangle_list() {
775        use khora_core::asset::AssetUUID;
776
777        let lane = SimpleUnlitLane::new();
778
779        // Create a GPU mesh with 300 indices (100 triangles) using TriangleList
780        let mesh_uuid = AssetUUID::new();
781        let gpu_mesh = GpuMesh {
782            vertex_buffer: BufferId(0),
783            index_buffer: BufferId(1),
784            index_count: 300,
785            index_format: IndexFormat::Uint32,
786            primitive_topology: PrimitiveTopology::TriangleList,
787        };
788        let gpu_mesh_handle = AssetHandle::new(gpu_mesh);
789        let mut gpu_meshes = Assets::<GpuMesh>::new();
790        gpu_meshes.insert(mesh_uuid, gpu_mesh_handle.clone());
791
792        let mut render_world = RenderWorld::default();
793        render_world.meshes.push(ExtractedMesh {
794            transform: Default::default(),
795            cpu_mesh_uuid: mesh_uuid,
796            gpu_mesh: gpu_mesh_handle,
797            material: None,
798            gpu_material: None,
799        });
800
801        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
802        let cost = lane.estimate_render_cost(&render_world, &gpu_meshes_lock);
803
804        // Expected: 100 triangles * 0.001 + 1 draw call * 0.1 = 0.1 + 0.1 = 0.2
805        assert_eq!(
806            cost, 0.2,
807            "Cost should be 0.2 for 100 triangles + 1 draw call"
808        );
809    }
810
811    #[test]
812    fn test_cost_estimation_triangle_strip() {
813        use khora_core::asset::AssetUUID;
814
815        let lane = SimpleUnlitLane::new();
816
817        // Create a GPU mesh with 52 indices (50 triangles) using TriangleStrip
818        let mesh_uuid = AssetUUID::new();
819        let gpu_mesh = GpuMesh {
820            vertex_buffer: BufferId(0),
821            index_buffer: BufferId(1),
822            index_count: 52,
823            index_format: IndexFormat::Uint16,
824            primitive_topology: PrimitiveTopology::TriangleStrip,
825        };
826        let gpu_mesh_handle = AssetHandle::new(gpu_mesh);
827        let mut gpu_meshes = Assets::<GpuMesh>::new();
828        gpu_meshes.insert(mesh_uuid, gpu_mesh_handle.clone());
829
830        let mut render_world = RenderWorld::default();
831        render_world.meshes.push(ExtractedMesh {
832            transform: Default::default(),
833            cpu_mesh_uuid: mesh_uuid,
834            gpu_mesh: gpu_mesh_handle,
835            material: None,
836            gpu_material: None,
837        });
838
839        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
840        let cost = lane.estimate_render_cost(&render_world, &gpu_meshes_lock);
841
842        // Expected: 50 triangles * 0.001 + 1 draw call * 0.1 = 0.05 + 0.1 = 0.15
843        assert_eq!(
844            cost, 0.15,
845            "Cost should be 0.15 for 50 triangles + 1 draw call"
846        );
847    }
848
849    #[test]
850    fn test_cost_estimation_lines_and_points() {
851        use khora_core::asset::AssetUUID;
852
853        let lane = SimpleUnlitLane::new();
854
855        // Create meshes with non-triangle topologies
856        let line_uuid = AssetUUID::new();
857        let point_uuid = AssetUUID::new();
858
859        let line_mesh = GpuMesh {
860            vertex_buffer: BufferId(0),
861            index_buffer: BufferId(1),
862            index_count: 100,
863            index_format: IndexFormat::Uint32,
864            primitive_topology: PrimitiveTopology::LineList,
865        };
866
867        let point_mesh = GpuMesh {
868            vertex_buffer: BufferId(2),
869            index_buffer: BufferId(3),
870            index_count: 50,
871            index_format: IndexFormat::Uint32,
872            primitive_topology: PrimitiveTopology::PointList,
873        };
874        let line_mesh_handle = AssetHandle::new(line_mesh);
875        let point_mesh_handle = AssetHandle::new(point_mesh);
876
877        let mut gpu_meshes = Assets::<GpuMesh>::new();
878        gpu_meshes.insert(line_uuid, line_mesh_handle.clone());
879        gpu_meshes.insert(point_uuid, point_mesh_handle.clone());
880
881        let mut render_world = RenderWorld::default();
882        render_world.meshes.push(ExtractedMesh {
883            transform: Default::default(),
884            cpu_mesh_uuid: line_uuid,
885            gpu_mesh: line_mesh_handle,
886            material: None,
887            gpu_material: None,
888        });
889        render_world.meshes.push(ExtractedMesh {
890            transform: Default::default(),
891            cpu_mesh_uuid: point_uuid,
892            gpu_mesh: point_mesh_handle,
893            material: None,
894            gpu_material: None,
895        });
896
897        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
898        let cost = lane.estimate_render_cost(&render_world, &gpu_meshes_lock);
899
900        // Expected: 0 triangles * 0.001 + 2 draw calls * 0.1 = 0.0 + 0.2 = 0.2
901        assert_eq!(
902            cost, 0.2,
903            "Cost should be 0.2 for 2 draw calls with no triangles"
904        );
905    }
906
907    #[test]
908    fn test_cost_estimation_multiple_meshes() {
909        use khora_core::asset::AssetUUID;
910
911        let lane = SimpleUnlitLane::new();
912
913        // Create 3 different meshes
914        let mesh1_uuid = AssetUUID::new();
915        let mesh2_uuid = AssetUUID::new();
916        let mesh3_uuid = AssetUUID::new();
917
918        let mesh1 = GpuMesh {
919            vertex_buffer: BufferId(0),
920            index_buffer: BufferId(1),
921            index_count: 600, // 200 triangles
922            index_format: IndexFormat::Uint32,
923            primitive_topology: PrimitiveTopology::TriangleList,
924        };
925
926        let mesh2 = GpuMesh {
927            vertex_buffer: BufferId(2),
928            index_buffer: BufferId(3),
929            index_count: 102, // 100 triangles (strip)
930            index_format: IndexFormat::Uint16,
931            primitive_topology: PrimitiveTopology::TriangleStrip,
932        };
933
934        let mesh3 = GpuMesh {
935            vertex_buffer: BufferId(4),
936            index_buffer: BufferId(5),
937            index_count: 150, // 50 triangles
938            index_format: IndexFormat::Uint32,
939            primitive_topology: PrimitiveTopology::TriangleList,
940        };
941
942        let mut gpu_meshes = Assets::<GpuMesh>::new();
943        gpu_meshes.insert(mesh1_uuid, AssetHandle::new(mesh1));
944        gpu_meshes.insert(mesh2_uuid, AssetHandle::new(mesh2));
945        gpu_meshes.insert(mesh3_uuid, AssetHandle::new(mesh3));
946
947        let mut render_world = RenderWorld::default();
948        render_world.meshes.push(ExtractedMesh {
949            transform: Default::default(),
950            cpu_mesh_uuid: mesh1_uuid,
951            gpu_mesh: AssetHandle::new(create_test_mesh(600)),
952            material: None,
953            gpu_material: None,
954        });
955        render_world.meshes.push(ExtractedMesh {
956            transform: Default::default(),
957            cpu_mesh_uuid: mesh2_uuid,
958            gpu_mesh: AssetHandle::new(create_test_mesh(102)),
959            material: None,
960            gpu_material: None,
961        });
962        render_world.meshes.push(ExtractedMesh {
963            transform: Default::default(),
964            cpu_mesh_uuid: mesh3_uuid,
965            gpu_mesh: AssetHandle::new(create_test_mesh(150)),
966            material: None,
967            gpu_material: None,
968        });
969
970        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
971        let cost = lane.estimate_render_cost(&render_world, &gpu_meshes_lock);
972
973        // Expected: (200 + 100 + 50) triangles * 0.001 + 3 draw calls * 0.1
974        //         = 350 * 0.001 + 3 * 0.1 = 0.35 + 0.3 = 0.65
975        assert!(
976            (cost - 0.65).abs() < 0.0001,
977            "Cost should be approximately 0.65 for 350 triangles + 3 draw calls, got {}",
978            cost
979        );
980    }
981
982    // Helper to create test mesh
983    fn create_test_mesh(index_count: u32) -> GpuMesh {
984        GpuMesh {
985            vertex_buffer: BufferId(0),
986            index_buffer: BufferId(1),
987            index_count,
988            index_format: IndexFormat::Uint32,
989            primitive_topology: PrimitiveTopology::TriangleList,
990        }
991    }
992
993    #[test]
994    fn test_cost_estimation_missing_mesh() {
995        use khora_core::asset::AssetUUID;
996
997        let lane = SimpleUnlitLane::new();
998        let gpu_meshes = Arc::new(RwLock::new(Assets::<GpuMesh>::new()));
999
1000        // Reference a mesh that doesn't exist in the cache
1001        let mut render_world = RenderWorld::default();
1002        render_world.meshes.push(ExtractedMesh {
1003            transform: Default::default(),
1004            cpu_mesh_uuid: AssetUUID::new(),
1005            gpu_mesh: AssetHandle::new(create_test_mesh(300)),
1006            material: None,
1007            gpu_material: None,
1008        });
1009
1010        let cost = lane.estimate_render_cost(&render_world, &gpu_meshes);
1011
1012        // Expected: 0 cost since mesh is not found
1013        assert_eq!(cost, 0.0, "Missing mesh should contribute zero cost");
1014    }
1015
1016    #[test]
1017    fn test_cost_estimation_degenerate_triangle_strip() {
1018        use khora_core::asset::AssetUUID;
1019
1020        let lane = SimpleUnlitLane::new();
1021
1022        // Create a triangle strip with only 2 indices (not enough for a triangle)
1023        let mesh_uuid = AssetUUID::new();
1024        let gpu_mesh = GpuMesh {
1025            vertex_buffer: BufferId(0),
1026            index_buffer: BufferId(1),
1027            index_count: 2,
1028            index_format: IndexFormat::Uint16,
1029            primitive_topology: PrimitiveTopology::TriangleStrip,
1030        };
1031
1032        let handle = AssetHandle::new(gpu_mesh);
1033        let mut gpu_meshes = Assets::<GpuMesh>::new();
1034        gpu_meshes.insert(mesh_uuid, handle.clone());
1035
1036        let mut render_world = RenderWorld::default();
1037        render_world.meshes.push(ExtractedMesh {
1038            transform: Default::default(),
1039            cpu_mesh_uuid: mesh_uuid,
1040            gpu_mesh: handle,
1041            material: None,
1042            gpu_material: None,
1043        });
1044
1045        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
1046        let cost = lane.estimate_render_cost(&render_world, &gpu_meshes_lock);
1047
1048        // Expected: 0 triangles + 1 draw call * 0.1 = 0.1
1049        assert_eq!(
1050            cost, 0.1,
1051            "Degenerate triangle strip should only cost draw call overhead"
1052        );
1053    }
1054
1055    #[test]
1056    fn test_get_pipeline_for_material_with_none() {
1057        let lane = SimpleUnlitLane::new();
1058
1059        let pipeline = lane.get_pipeline_for_material(None);
1060        assert_eq!(
1061            pipeline,
1062            RenderPipelineId(0),
1063            "None material should use default pipeline"
1064        );
1065    }
1066
1067    #[test]
1068    fn test_get_pipeline_for_material_not_found() {
1069        let lane = SimpleUnlitLane::new();
1070
1071        // Since there is no registry anymore, we just test with None or a dummy handle.
1072        // The old test "missing material" is now redundant with "None material".
1073        let pipeline = lane.get_pipeline_for_material(None);
1074        assert_eq!(
1075            pipeline,
1076            RenderPipelineId(0),
1077            "Missing material should use default pipeline"
1078        );
1079    }
1080}