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 crate::render_lane::RenderLane;
30
31use super::RenderWorld;
32use khora_core::{
33    asset::{AssetUUID, Material},
34    renderer::{
35        api::{
36            command::{
37                LoadOp, Operations, RenderPassColorAttachment, RenderPassDescriptor, StoreOp,
38            },
39            PrimitiveTopology,
40        },
41        traits::CommandEncoder,
42        GpuMesh, RenderContext, RenderPipelineId,
43    },
44};
45use khora_data::assets::Assets;
46use std::sync::RwLock;
47
48/// A lane that implements a simple, unlit forward rendering strategy.
49///
50/// This lane takes the extracted scene data from a `RenderWorld` and generates
51/// GPU commands to render all meshes with a basic, unlit appearance. It does not
52/// perform any lighting calculations, shadow mapping, or post-processing effects.
53///
54/// # Performance Characteristics
55/// - **Zero heap allocations** during the render pass encoding
56/// - **Linear iteration** over the extracted mesh list
57/// - **Minimal state changes** (one pipeline bind per material, ideally)
58/// - **Suitable for**: High frame rates, simple scenes, or as a debug/fallback renderer
59#[derive(Default)]
60pub struct SimpleUnlitLane;
61
62impl SimpleUnlitLane {
63    /// Creates a new `SimpleUnlitLane`.
64    ///
65    /// This lane is stateless, so construction is trivial.
66    pub fn new() -> Self {
67        Self
68    }
69}
70
71impl RenderLane for SimpleUnlitLane {
72    fn strategy_name(&self) -> &'static str {
73        "SimpleUnlit"
74    }
75
76    fn get_pipeline_for_material(
77        &self,
78        material_uuid: Option<AssetUUID>,
79        materials: &Assets<Box<dyn Material>>,
80    ) -> RenderPipelineId {
81        // If a material is specified, verify it exists in the cache
82        if let Some(uuid) = material_uuid {
83            if materials.get(&uuid).is_none() {
84                // Material not found, will use default pipeline
85                let _ = uuid; // Silence unused warning
86            }
87        }
88
89        // All unlit materials currently use the same pipeline
90        // Future enhancements could differentiate based on:
91        // - Texture presence (textured vs. untextured)
92        // - Alpha blend mode (opaque, masked, transparent)
93        // - Two-sided rendering
94        RenderPipelineId(0)
95    }
96
97    fn render(
98        &self,
99        render_world: &RenderWorld,
100        encoder: &mut dyn CommandEncoder,
101        render_ctx: &RenderContext,
102        gpu_meshes: &RwLock<Assets<GpuMesh>>,
103        materials: &RwLock<Assets<Box<dyn Material>>>,
104    ) {
105        // Acquire read locks on the caches
106        let gpu_mesh_assets = gpu_meshes.read().unwrap();
107        let material_assets = materials.read().unwrap();
108
109        // Pre-compute all pipelines for each mesh to ensure they live long enough
110        // for the render pass references
111        let pipelines: Vec<RenderPipelineId> = render_world
112            .meshes
113            .iter()
114            .map(|mesh| self.get_pipeline_for_material(mesh.material_uuid, &material_assets))
115            .collect();
116
117        // Configure the render pass to render into the provided color target
118        let color_attachment = RenderPassColorAttachment {
119            view: render_ctx.color_target,
120            resolve_target: None,
121            ops: Operations {
122                load: LoadOp::Clear(render_ctx.clear_color),
123                store: StoreOp::Store,
124            },
125        };
126
127        let render_pass_desc = RenderPassDescriptor {
128            label: Some("Simple Unlit Pass"),
129            color_attachments: &[color_attachment],
130        };
131
132        // Begin the render pass
133        let mut render_pass = encoder.begin_render_pass(&render_pass_desc);
134
135        // Track the last pipeline we bound to avoid redundant state changes
136        let mut current_pipeline: Option<RenderPipelineId> = None;
137
138        // Iterate over all extracted meshes and issue draw calls
139        for (i, extracted_mesh) in render_world.meshes.iter().enumerate() {
140            // Look up the corresponding GpuMesh in the cache
141            if let Some(gpu_mesh_handle) = gpu_mesh_assets.get(&extracted_mesh.gpu_mesh_uuid) {
142                // Get the pre-computed pipeline for this mesh
143                let pipeline = &pipelines[i];
144
145                // Only bind the pipeline if it's different from the current one
146                // This is a basic optimization to reduce GPU state changes
147                if current_pipeline != Some(*pipeline) {
148                    render_pass.set_pipeline(pipeline);
149                    current_pipeline = Some(*pipeline);
150                }
151
152                // Bind the vertex buffer
153                render_pass.set_vertex_buffer(0, &gpu_mesh_handle.vertex_buffer, 0);
154
155                // Bind the index buffer with the correct format from the mesh
156                render_pass.set_index_buffer(
157                    &gpu_mesh_handle.index_buffer,
158                    0,
159                    gpu_mesh_handle.index_format,
160                );
161
162                // Issue the indexed draw call
163                render_pass.draw_indexed(0..gpu_mesh_handle.index_count, 0, 0..1);
164            }
165        }
166    }
167
168    fn estimate_cost(
169        &self,
170        render_world: &RenderWorld,
171        gpu_meshes: &RwLock<Assets<GpuMesh>>,
172    ) -> f32 {
173        let gpu_mesh_assets = gpu_meshes.read().unwrap();
174
175        let mut total_triangles = 0u32;
176        let mut draw_call_count = 0u32;
177
178        for extracted_mesh in &render_world.meshes {
179            if let Some(gpu_mesh) = gpu_mesh_assets.get(&extracted_mesh.gpu_mesh_uuid) {
180                // Calculate triangle count based on primitive topology
181                let triangle_count = match gpu_mesh.primitive_topology {
182                    PrimitiveTopology::TriangleList => gpu_mesh.index_count / 3,
183                    PrimitiveTopology::TriangleStrip => {
184                        if gpu_mesh.index_count >= 3 {
185                            gpu_mesh.index_count - 2
186                        } else {
187                            0
188                        }
189                    }
190                    // Lines and points don't contribute to triangle count
191                    PrimitiveTopology::LineList
192                    | PrimitiveTopology::LineStrip
193                    | PrimitiveTopology::PointList => 0,
194                };
195
196                total_triangles += triangle_count;
197                draw_call_count += 1;
198            }
199        }
200
201        // Cost model: triangles have a small per-triangle cost,
202        // draw calls have a fixed overhead
203        const TRIANGLE_COST: f32 = 0.001;
204        const DRAW_CALL_COST: f32 = 0.1;
205
206        (total_triangles as f32 * TRIANGLE_COST) + (draw_call_count as f32 * DRAW_CALL_COST)
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use khora_core::{
214        asset::AssetHandle,
215        renderer::{api::PrimitiveTopology, BufferId, IndexFormat},
216    };
217    use std::sync::Arc;
218
219    #[test]
220    fn test_simple_unlit_lane_creation() {
221        let lane = SimpleUnlitLane::new();
222        assert_eq!(lane.strategy_name(), "SimpleUnlit");
223    }
224
225    #[test]
226    fn test_default_construction() {
227        let lane = SimpleUnlitLane;
228        assert_eq!(lane.strategy_name(), "SimpleUnlit");
229    }
230
231    #[test]
232    fn test_cost_estimation_empty_world() {
233        let lane = SimpleUnlitLane::new();
234        let render_world = RenderWorld::default();
235        let gpu_meshes = Arc::new(RwLock::new(Assets::<GpuMesh>::new()));
236
237        let cost = lane.estimate_cost(&render_world, &gpu_meshes);
238        assert_eq!(cost, 0.0, "Empty world should have zero cost");
239    }
240
241    #[test]
242    fn test_cost_estimation_triangle_list() {
243        use crate::render_lane::world::ExtractedMesh;
244        use khora_core::asset::AssetUUID;
245
246        let lane = SimpleUnlitLane::new();
247
248        // Create a GPU mesh with 300 indices (100 triangles) using TriangleList
249        let mesh_uuid = AssetUUID::new();
250        let gpu_mesh = GpuMesh {
251            vertex_buffer: BufferId(0),
252            index_buffer: BufferId(1),
253            index_count: 300,
254            index_format: IndexFormat::Uint32,
255            primitive_topology: PrimitiveTopology::TriangleList,
256        };
257
258        let mut gpu_meshes = Assets::<GpuMesh>::new();
259        gpu_meshes.insert(mesh_uuid, AssetHandle::new(gpu_mesh));
260
261        let mut render_world = RenderWorld::default();
262        render_world.meshes.push(ExtractedMesh {
263            transform: Default::default(),
264            gpu_mesh_uuid: mesh_uuid,
265            material_uuid: None,
266        });
267
268        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
269        let cost = lane.estimate_cost(&render_world, &gpu_meshes_lock);
270
271        // Expected: 100 triangles * 0.001 + 1 draw call * 0.1 = 0.1 + 0.1 = 0.2
272        assert_eq!(
273            cost, 0.2,
274            "Cost should be 0.2 for 100 triangles + 1 draw call"
275        );
276    }
277
278    #[test]
279    fn test_cost_estimation_triangle_strip() {
280        use crate::render_lane::world::ExtractedMesh;
281        use khora_core::asset::AssetUUID;
282
283        let lane = SimpleUnlitLane::new();
284
285        // Create a GPU mesh with 52 indices (50 triangles) using TriangleStrip
286        let mesh_uuid = AssetUUID::new();
287        let gpu_mesh = GpuMesh {
288            vertex_buffer: BufferId(0),
289            index_buffer: BufferId(1),
290            index_count: 52,
291            index_format: IndexFormat::Uint16,
292            primitive_topology: PrimitiveTopology::TriangleStrip,
293        };
294
295        let mut gpu_meshes = Assets::<GpuMesh>::new();
296        gpu_meshes.insert(mesh_uuid, AssetHandle::new(gpu_mesh));
297
298        let mut render_world = RenderWorld::default();
299        render_world.meshes.push(ExtractedMesh {
300            transform: Default::default(),
301            gpu_mesh_uuid: mesh_uuid,
302            material_uuid: None,
303        });
304
305        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
306        let cost = lane.estimate_cost(&render_world, &gpu_meshes_lock);
307
308        // Expected: 50 triangles * 0.001 + 1 draw call * 0.1 = 0.05 + 0.1 = 0.15
309        assert_eq!(
310            cost, 0.15,
311            "Cost should be 0.15 for 50 triangles + 1 draw call"
312        );
313    }
314
315    #[test]
316    fn test_cost_estimation_lines_and_points() {
317        use crate::render_lane::world::ExtractedMesh;
318        use khora_core::asset::AssetUUID;
319
320        let lane = SimpleUnlitLane::new();
321
322        // Create meshes with non-triangle topologies
323        let line_uuid = AssetUUID::new();
324        let point_uuid = AssetUUID::new();
325
326        let line_mesh = GpuMesh {
327            vertex_buffer: BufferId(0),
328            index_buffer: BufferId(1),
329            index_count: 100,
330            index_format: IndexFormat::Uint32,
331            primitive_topology: PrimitiveTopology::LineList,
332        };
333
334        let point_mesh = GpuMesh {
335            vertex_buffer: BufferId(2),
336            index_buffer: BufferId(3),
337            index_count: 50,
338            index_format: IndexFormat::Uint32,
339            primitive_topology: PrimitiveTopology::PointList,
340        };
341
342        let mut gpu_meshes = Assets::<GpuMesh>::new();
343        gpu_meshes.insert(line_uuid, AssetHandle::new(line_mesh));
344        gpu_meshes.insert(point_uuid, AssetHandle::new(point_mesh));
345
346        let mut render_world = RenderWorld::default();
347        render_world.meshes.push(ExtractedMesh {
348            transform: Default::default(),
349            gpu_mesh_uuid: line_uuid,
350            material_uuid: None,
351        });
352        render_world.meshes.push(ExtractedMesh {
353            transform: Default::default(),
354            gpu_mesh_uuid: point_uuid,
355            material_uuid: None,
356        });
357
358        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
359        let cost = lane.estimate_cost(&render_world, &gpu_meshes_lock);
360
361        // Expected: 0 triangles * 0.001 + 2 draw calls * 0.1 = 0.0 + 0.2 = 0.2
362        assert_eq!(
363            cost, 0.2,
364            "Cost should be 0.2 for 2 draw calls with no triangles"
365        );
366    }
367
368    #[test]
369    fn test_cost_estimation_multiple_meshes() {
370        use crate::render_lane::world::ExtractedMesh;
371        use khora_core::asset::AssetUUID;
372
373        let lane = SimpleUnlitLane::new();
374
375        // Create 3 different meshes
376        let mesh1_uuid = AssetUUID::new();
377        let mesh2_uuid = AssetUUID::new();
378        let mesh3_uuid = AssetUUID::new();
379
380        let mesh1 = GpuMesh {
381            vertex_buffer: BufferId(0),
382            index_buffer: BufferId(1),
383            index_count: 600, // 200 triangles
384            index_format: IndexFormat::Uint32,
385            primitive_topology: PrimitiveTopology::TriangleList,
386        };
387
388        let mesh2 = GpuMesh {
389            vertex_buffer: BufferId(2),
390            index_buffer: BufferId(3),
391            index_count: 102, // 100 triangles (strip)
392            index_format: IndexFormat::Uint16,
393            primitive_topology: PrimitiveTopology::TriangleStrip,
394        };
395
396        let mesh3 = GpuMesh {
397            vertex_buffer: BufferId(4),
398            index_buffer: BufferId(5),
399            index_count: 150, // 50 triangles
400            index_format: IndexFormat::Uint32,
401            primitive_topology: PrimitiveTopology::TriangleList,
402        };
403
404        let mut gpu_meshes = Assets::<GpuMesh>::new();
405        gpu_meshes.insert(mesh1_uuid, AssetHandle::new(mesh1));
406        gpu_meshes.insert(mesh2_uuid, AssetHandle::new(mesh2));
407        gpu_meshes.insert(mesh3_uuid, AssetHandle::new(mesh3));
408
409        let mut render_world = RenderWorld::default();
410        render_world.meshes.push(ExtractedMesh {
411            transform: Default::default(),
412            gpu_mesh_uuid: mesh1_uuid,
413            material_uuid: None,
414        });
415        render_world.meshes.push(ExtractedMesh {
416            transform: Default::default(),
417            gpu_mesh_uuid: mesh2_uuid,
418            material_uuid: None,
419        });
420        render_world.meshes.push(ExtractedMesh {
421            transform: Default::default(),
422            gpu_mesh_uuid: mesh3_uuid,
423            material_uuid: None,
424        });
425
426        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
427        let cost = lane.estimate_cost(&render_world, &gpu_meshes_lock);
428
429        // Expected: (200 + 100 + 50) triangles * 0.001 + 3 draw calls * 0.1
430        //         = 350 * 0.001 + 3 * 0.1 = 0.35 + 0.3 = 0.65
431        assert!(
432            (cost - 0.65).abs() < 0.0001,
433            "Cost should be approximately 0.65 for 350 triangles + 3 draw calls, got {}",
434            cost
435        );
436    }
437
438    #[test]
439    fn test_cost_estimation_missing_mesh() {
440        use crate::render_lane::world::ExtractedMesh;
441        use khora_core::asset::AssetUUID;
442
443        let lane = SimpleUnlitLane::new();
444        let gpu_meshes = Arc::new(RwLock::new(Assets::<GpuMesh>::new()));
445
446        // Reference a mesh that doesn't exist in the cache
447        let mut render_world = RenderWorld::default();
448        render_world.meshes.push(ExtractedMesh {
449            transform: Default::default(),
450            gpu_mesh_uuid: AssetUUID::new(),
451            material_uuid: None,
452        });
453
454        let cost = lane.estimate_cost(&render_world, &gpu_meshes);
455
456        // Expected: 0 cost since mesh is not found
457        assert_eq!(cost, 0.0, "Missing mesh should contribute zero cost");
458    }
459
460    #[test]
461    fn test_cost_estimation_degenerate_triangle_strip() {
462        use crate::render_lane::world::ExtractedMesh;
463        use khora_core::asset::AssetUUID;
464
465        let lane = SimpleUnlitLane::new();
466
467        // Create a triangle strip with only 2 indices (not enough for a triangle)
468        let mesh_uuid = AssetUUID::new();
469        let gpu_mesh = GpuMesh {
470            vertex_buffer: BufferId(0),
471            index_buffer: BufferId(1),
472            index_count: 2,
473            index_format: IndexFormat::Uint16,
474            primitive_topology: PrimitiveTopology::TriangleStrip,
475        };
476
477        let mut gpu_meshes = Assets::<GpuMesh>::new();
478        gpu_meshes.insert(mesh_uuid, AssetHandle::new(gpu_mesh));
479
480        let mut render_world = RenderWorld::default();
481        render_world.meshes.push(ExtractedMesh {
482            transform: Default::default(),
483            gpu_mesh_uuid: mesh_uuid,
484            material_uuid: None,
485        });
486
487        let gpu_meshes_lock = Arc::new(RwLock::new(gpu_meshes));
488        let cost = lane.estimate_cost(&render_world, &gpu_meshes_lock);
489
490        // Expected: 0 triangles + 1 draw call * 0.1 = 0.1
491        assert_eq!(
492            cost, 0.1,
493            "Degenerate triangle strip should only cost draw call overhead"
494        );
495    }
496
497    #[test]
498    fn test_get_pipeline_for_material_with_none() {
499        let lane = SimpleUnlitLane::new();
500        let materials = Assets::<Box<dyn Material>>::new();
501
502        let pipeline = lane.get_pipeline_for_material(None, &materials);
503        assert_eq!(
504            pipeline,
505            RenderPipelineId(0),
506            "None material should use default pipeline"
507        );
508    }
509
510    #[test]
511    fn test_get_pipeline_for_material_not_found() {
512        use khora_core::asset::AssetUUID;
513
514        let lane = SimpleUnlitLane::new();
515        let materials = Assets::<Box<dyn Material>>::new();
516
517        let pipeline = lane.get_pipeline_for_material(Some(AssetUUID::new()), &materials);
518        assert_eq!(
519            pipeline,
520            RenderPipelineId(0),
521            "Missing material should use default pipeline"
522        );
523    }
524}