1use 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
49pub 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 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 pub fn get_pipeline_for_material(
216 &self,
217 _material: Option<&khora_core::asset::AssetHandle<Box<dyn Material>>>,
218 ) -> RenderPipelineId {
219 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 let view = if let Some(first_view) = render_world.views.first() {
235 first_view
236 } else {
237 return; };
239
240 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 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 let gpu_mesh_assets = gpu_meshes.read().unwrap();
284
285 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 let pipeline = self.get_pipeline_for_material(extracted_mesh.material.as_ref());
292
293 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; };
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 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 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 let mut render_pass = encoder.begin_render_pass(&render_pass_desc);
375
376 render_pass.set_bind_group(0, &camera_bind_group, &[]);
378
379 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 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 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 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 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, 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 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 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, shader_location: 1,
548 },
549 VertexAttributeDescriptor {
550 format: VertexFormat::Float32x2,
551 offset: 24, shader_location: 2,
553 },
554 ];
555
556 let vertex_layout = VertexBufferLayoutDescriptor {
557 array_stride: 32, step_mode: VertexStepMode::Vertex,
559 attributes: Cow::Owned(vertex_attributes),
560 };
561
562 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 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, 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, 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 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 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 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 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 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 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 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, 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, 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, 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 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 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 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 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 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 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 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}