khora_lanes/render_lane/
extract_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//! Defines the lane responsible for extracting renderable data from the main ECS world.
16
17use super::{ExtractedLight, ExtractedMesh, ExtractedView, RenderWorld};
18use khora_core::{
19    math::Vec3,
20    renderer::{api::scene::GpuMesh, light::LightType},
21};
22use khora_data::ecs::{Camera, GlobalTransform, HandleComponent, Light, MaterialComponent, World};
23
24/// A lane that performs the "extraction" phase of the rendering pipeline.
25///
26/// It queries the main `World` for entities with renderable components and populates
27/// the `RenderWorld` with a simplified, flat representation of the scene suitable
28/// for rendering.
29#[derive(Default)]
30pub struct ExtractRenderablesLane;
31
32impl ExtractRenderablesLane {
33    /// Creates a new `ExtractRenderablesLane`.
34    pub fn new() -> Self {
35        Self
36    }
37
38    /// Executes the extraction process for one frame.
39    ///
40    /// # Arguments
41    /// * `world`: A reference to the main ECS `World` containing simulation data.
42    /// * `render_world`: A mutable reference to the `RenderWorld` to be populated.
43    pub fn run(&self, world: &World, render_world: &mut RenderWorld) {
44        // 1. Clear the render world from the previous frame's data.
45        render_world.clear();
46
47        // 2. Execute the transversal query to find all renderable meshes.
48        // We query for entities that have both a GlobalTransform and a GpuMesh handle.
49        // The MaterialComponent is optional, so we'll handle it separately.
50        let query = world.query::<(&GlobalTransform, &HandleComponent<GpuMesh>)>();
51
52        // 3. Iterate directly over the query and populate the RenderWorld.
53        for (entity_id, (transform, gpu_mesh_handle_comp)) in query.enumerate() {
54            // Try to get the material component if it exists
55            let material = world
56                .query::<&MaterialComponent>()
57                .nth(entity_id)
58                .map(|material_comp| material_comp.handle.clone());
59
60            let extracted_mesh = ExtractedMesh {
61                // Extract the affine transform directly from GlobalTransform
62                transform: transform.0,
63                cpu_mesh_uuid: gpu_mesh_handle_comp.uuid,
64                // Pass the asset handle directly
65                gpu_mesh: gpu_mesh_handle_comp.handle.clone(),
66                material,
67            };
68            render_world.meshes.push(extracted_mesh);
69        }
70
71        // 4. Extract all active lights from the world.
72        self.extract_lights(world, render_world);
73
74        // 5. Extract all active cameras (views) from the world.
75        self.extract_views(world, render_world);
76    }
77
78    /// Extracts active camera components from the world into the render world.
79    fn extract_views(&self, world: &World, render_world: &mut RenderWorld) {
80        // Query for entities that have both a Camera component and a GlobalTransform.
81        let camera_query = world.query::<(&Camera, &GlobalTransform)>();
82        let cameras: Vec<_> = camera_query.collect();
83        log::debug!("ExtractViews: Found {} cameras", cameras.len());
84
85        for (camera, global_transform) in cameras {
86            log::trace!(
87                "ExtractViews: Checking camera is_active={}",
88                camera.is_active
89            );
90            if !camera.is_active {
91                continue;
92            }
93
94            // Calculate View Matrix properly
95            // View matrix = inverse of camera transform
96            // For a camera: view = R(-rotation) * T(-position)
97            // We extract rotation and position separately for correct view matrix construction
98            let position = global_transform.0.translation();
99            let rotation = global_transform.0.rotation();
100
101            // Create view matrix: first rotate (inverse rotation), then translate (inverse position)
102            let rotation_matrix = khora_core::math::Mat4::from_quat(rotation.inverse());
103            let translation_matrix = khora_core::math::Mat4::from_translation(-position);
104            let view_matrix = rotation_matrix * translation_matrix;
105
106            // Get Projection Matrix
107            let proj_matrix = camera.projection_matrix();
108
109            // View-Projection Matrix
110            let view_proj = proj_matrix * view_matrix;
111
112            let forward = global_transform.0.forward();
113            log::trace!(
114                "ExtractViews: Camera at pos={:?} forward={:?}",
115                position,
116                forward
117            );
118
119            let extracted_view = ExtractedView {
120                view_proj,
121                position,
122            };
123
124            render_world.views.push(extracted_view);
125        }
126    }
127
128    /// Extracts light components from the world into the render world.
129    fn extract_lights(&self, world: &World, render_world: &mut RenderWorld) {
130        // Query for entities that have both a Light component and a GlobalTransform.
131        let light_query = world.query::<(&Light, &GlobalTransform)>();
132
133        for (light_comp, global_transform) in light_query {
134            // Skip disabled lights
135            if !light_comp.enabled {
136                continue;
137            }
138
139            // Extract position from the global transform
140            let position = global_transform.0.translation();
141
142            // Extract direction based on light type
143            // For directional lights, use the direction from the light type
144            // For spot lights, transform the local direction by the global rotation
145            // For point lights, direction is not used but we set a default
146            let direction = match &light_comp.light_type {
147                LightType::Directional(dir_light) => {
148                    let rotation = global_transform.0.rotation();
149                    rotation * dir_light.direction
150                }
151                LightType::Spot(spot_light) => {
152                    // Transform the spot light's local direction by the entity's rotation
153                    let rotation = global_transform.0.rotation();
154                    rotation * spot_light.direction
155                }
156                LightType::Point(_) => Vec3::ZERO, // Not used for point lights
157            };
158
159            let extracted = ExtractedLight {
160                light_type: light_comp.light_type,
161                position,
162                direction,
163                shadow_view_proj: khora_core::math::Mat4::IDENTITY,
164                shadow_atlas_index: None,
165            };
166
167            render_world.lights.push(extracted);
168        }
169    }
170}
171
172impl khora_core::lane::Lane for ExtractRenderablesLane {
173    fn strategy_name(&self) -> &'static str {
174        "ExtractRenderables"
175    }
176
177    fn lane_kind(&self) -> khora_core::lane::LaneKind {
178        khora_core::lane::LaneKind::Render
179    }
180
181    fn execute(
182        &self,
183        ctx: &mut khora_core::lane::LaneContext,
184    ) -> Result<(), khora_core::lane::LaneError> {
185        use khora_core::lane::{LaneError, Slot};
186
187        let world = ctx
188            .get::<Slot<World>>()
189            .ok_or(LaneError::missing("Slot<World>"))?
190            .get_ref();
191        let render_world = ctx
192            .get::<Slot<super::RenderWorld>>()
193            .ok_or(LaneError::missing("Slot<RenderWorld>"))?
194            .get();
195
196        self.run(world, render_world);
197        Ok(())
198    }
199
200    fn as_any(&self) -> &dyn std::any::Any {
201        self
202    }
203
204    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
205        self
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use khora_core::{
213        asset::{AssetHandle, AssetUUID},
214        math::{affine_transform::AffineTransform, Vec3},
215        renderer::api::scene::GpuMesh,
216    };
217
218    // Helper function to create a dummy GpuMesh for testing
219    fn create_dummy_gpu_mesh() -> GpuMesh {
220        use khora_core::renderer::api::{
221            pipeline::enums::PrimitiveTopology, resource::BufferId, util::IndexFormat,
222        };
223        GpuMesh {
224            vertex_buffer: BufferId(0),
225            index_buffer: BufferId(0),
226            index_count: 0,
227            index_format: IndexFormat::Uint32,
228            primitive_topology: PrimitiveTopology::TriangleList,
229        }
230    }
231
232    // Dummy material for testing - implements both Material and Asset
233    #[derive(Clone)]
234    struct DummyMaterial;
235
236    impl khora_core::asset::Material for DummyMaterial {}
237    impl khora_core::asset::Asset for DummyMaterial {}
238
239    #[test]
240    fn test_extract_lane_creation() {
241        let lane = ExtractRenderablesLane::new();
242        // Just verify it can be created
243        let _ = lane;
244    }
245
246    #[test]
247    fn test_extract_lane_default() {
248        let _lane = ExtractRenderablesLane;
249    }
250
251    #[test]
252    fn test_extract_empty_world() {
253        let lane = ExtractRenderablesLane::new();
254        let world = World::new();
255        let mut render_world = RenderWorld::default();
256
257        lane.run(&world, &mut render_world);
258
259        assert_eq!(
260            render_world.meshes.len(),
261            0,
262            "Empty world should extract no meshes"
263        );
264    }
265
266    #[test]
267    fn test_extract_single_entity_without_material() {
268        let lane = ExtractRenderablesLane::new();
269        let mut world = World::new();
270        let mut render_world = RenderWorld::default();
271
272        // Create a simple transform
273        let transform =
274            GlobalTransform(AffineTransform::from_translation(Vec3::new(1.0, 2.0, 3.0)));
275
276        // Create a GPU mesh handle
277        let mesh_uuid = AssetUUID::new();
278        let gpu_mesh_handle = HandleComponent::<GpuMesh> {
279            handle: AssetHandle::new(create_dummy_gpu_mesh()),
280            uuid: mesh_uuid,
281        };
282
283        // Spawn an entity with transform and mesh
284        world.spawn((transform, gpu_mesh_handle));
285
286        lane.run(&world, &mut render_world);
287
288        assert_eq!(render_world.meshes.len(), 1, "Should extract 1 mesh");
289
290        let extracted = &render_world.meshes[0];
291        assert_eq!(extracted.cpu_mesh_uuid, mesh_uuid);
292        assert!(extracted.material.is_none());
293        assert_eq!(extracted.transform.translation(), Vec3::new(1.0, 2.0, 3.0));
294    }
295
296    #[test]
297    fn test_extract_single_entity_with_material() {
298        let lane = ExtractRenderablesLane::new();
299        let mut world = World::new();
300        let mut render_world = RenderWorld::default();
301
302        // Create components
303        let transform = GlobalTransform(AffineTransform::from_translation(Vec3::new(
304            5.0, 10.0, 15.0,
305        )));
306        let mesh_uuid = AssetUUID::new();
307        let material_uuid = AssetUUID::new();
308
309        let gpu_mesh_handle = HandleComponent::<GpuMesh> {
310            handle: AssetHandle::new(create_dummy_gpu_mesh()),
311            uuid: mesh_uuid,
312        };
313
314        // Create a dummy material wrapped in Box<dyn Material>
315        let dummy_material: Box<dyn khora_core::asset::Material> = Box::new(DummyMaterial);
316        let material = MaterialComponent {
317            handle: AssetHandle::new(dummy_material),
318            uuid: material_uuid,
319        };
320
321        // Spawn an entity with all components
322        world.spawn((transform, gpu_mesh_handle, material));
323
324        lane.run(&world, &mut render_world);
325
326        assert_eq!(render_world.meshes.len(), 1, "Should extract 1 mesh");
327
328        let extracted = &render_world.meshes[0];
329        assert_eq!(extracted.cpu_mesh_uuid, mesh_uuid);
330        assert!(extracted.material.is_some());
331        assert_eq!(
332            extracted.transform.translation(),
333            Vec3::new(5.0, 10.0, 15.0)
334        );
335    }
336
337    #[test]
338    fn test_extract_multiple_entities() {
339        let lane = ExtractRenderablesLane::new();
340        let mut world = World::new();
341        let mut render_world = RenderWorld::default();
342
343        let mut with_material_count = 0;
344        let mut without_material_count = 0;
345
346        // Create 5 entities with different transforms
347        for i in 0..5 {
348            let transform = GlobalTransform(AffineTransform::from_translation(Vec3::new(
349                i as f32,
350                i as f32 * 2.0,
351                i as f32 * 3.0,
352            )));
353            let mesh_uuid = AssetUUID::new();
354            let gpu_mesh_handle = HandleComponent::<GpuMesh> {
355                handle: AssetHandle::new(create_dummy_gpu_mesh()),
356                uuid: mesh_uuid,
357            };
358
359            // Add material to some entities but not all
360            if i % 2 == 0 {
361                let dummy_material: Box<dyn khora_core::asset::Material> = Box::new(DummyMaterial);
362                let material = MaterialComponent {
363                    handle: AssetHandle::new(dummy_material),
364                    uuid: AssetUUID::new(),
365                };
366                world.spawn((transform, gpu_mesh_handle, material));
367                with_material_count += 1;
368            } else {
369                world.spawn((transform, gpu_mesh_handle));
370                without_material_count += 1;
371            }
372        }
373
374        lane.run(&world, &mut render_world);
375
376        assert_eq!(render_world.meshes.len(), 5, "Should extract 5 meshes");
377
378        // Count how many extracted meshes have materials (don't rely on order)
379        let extracted_with_material = render_world
380            .meshes
381            .iter()
382            .filter(|m| m.material.is_some())
383            .count();
384        let extracted_without_material = render_world
385            .meshes
386            .iter()
387            .filter(|m| m.material.is_none())
388            .count();
389
390        assert_eq!(
391            extracted_with_material, with_material_count,
392            "Should have {} entities with materials",
393            with_material_count
394        );
395        assert_eq!(
396            extracted_without_material, without_material_count,
397            "Should have {} entities without materials",
398            without_material_count
399        );
400    }
401
402    #[test]
403    fn test_extract_with_different_transforms() {
404        let lane = ExtractRenderablesLane::new();
405        let mut world = World::new();
406        let mut render_world = RenderWorld::default();
407
408        // Entity with translation
409        let transform1 = GlobalTransform(AffineTransform::from_translation(Vec3::new(
410            10.0, 20.0, 30.0,
411        )));
412        world.spawn((
413            transform1,
414            HandleComponent::<GpuMesh> {
415                handle: AssetHandle::new(create_dummy_gpu_mesh()),
416                uuid: AssetUUID::new(),
417            },
418        ));
419
420        // Entity with rotation around Y axis
421        use khora_core::math::Quaternion;
422        let transform2 = GlobalTransform(AffineTransform::from_quat(Quaternion::from_axis_angle(
423            Vec3::Y,
424            std::f32::consts::PI / 2.0,
425        )));
426        world.spawn((
427            transform2,
428            HandleComponent::<GpuMesh> {
429                handle: AssetHandle::new(create_dummy_gpu_mesh()),
430                uuid: AssetUUID::new(),
431            },
432        ));
433
434        // Entity with scale
435        let transform3 = GlobalTransform(AffineTransform::from_scale(Vec3::new(2.0, 3.0, 4.0)));
436        world.spawn((
437            transform3,
438            HandleComponent::<GpuMesh> {
439                handle: AssetHandle::new(create_dummy_gpu_mesh()),
440                uuid: AssetUUID::new(),
441            },
442        ));
443
444        lane.run(&world, &mut render_world);
445
446        assert_eq!(render_world.meshes.len(), 3, "Should extract 3 meshes");
447
448        // Verify first transform (translation)
449        assert_eq!(
450            render_world.meshes[0].transform.translation(),
451            Vec3::new(10.0, 20.0, 30.0)
452        );
453
454        // Verify transforms are different (comparing the matrices directly)
455        let mat0 = render_world.meshes[0].transform.to_matrix();
456        let mat1 = render_world.meshes[1].transform.to_matrix();
457        let mat2 = render_world.meshes[2].transform.to_matrix();
458        assert_ne!(mat0, mat1);
459        assert_ne!(mat1, mat2);
460    }
461
462    #[test]
463    fn test_extract_clears_previous_data() {
464        let lane = ExtractRenderablesLane::new();
465        let mut world = World::new();
466        let mut render_world = RenderWorld::default();
467
468        // First extraction with 3 entities
469        for _ in 0..3 {
470            let transform = GlobalTransform(AffineTransform::IDENTITY);
471            let gpu_mesh_handle = HandleComponent::<GpuMesh> {
472                handle: AssetHandle::new(create_dummy_gpu_mesh()),
473                uuid: AssetUUID::new(),
474            };
475            world.spawn((transform, gpu_mesh_handle));
476        }
477
478        lane.run(&world, &mut render_world);
479        assert_eq!(
480            render_world.meshes.len(),
481            3,
482            "First run should extract 3 meshes"
483        );
484
485        // Create a new world with only 1 entity
486        let mut world2 = World::new();
487        let transform = GlobalTransform(AffineTransform::IDENTITY);
488        let gpu_mesh_handle = HandleComponent::<GpuMesh> {
489            handle: AssetHandle::new(create_dummy_gpu_mesh()),
490            uuid: AssetUUID::new(),
491        };
492        world2.spawn((transform, gpu_mesh_handle));
493
494        // Second extraction should clear previous data
495        lane.run(&world2, &mut render_world);
496        assert_eq!(
497            render_world.meshes.len(),
498            1,
499            "Second run should extract only 1 mesh (cleared previous)"
500        );
501    }
502
503    #[test]
504    fn test_extract_entities_without_mesh_component() {
505        let lane = ExtractRenderablesLane::new();
506        let mut world = World::new();
507        let mut render_world = RenderWorld::default();
508
509        // Create entities with only transform (no GpuMesh handle)
510        // Note: World::spawn requires at least one component, so we add a Transform too
511        use khora_data::ecs::Transform;
512        for _ in 0..3 {
513            let transform = GlobalTransform(AffineTransform::IDENTITY);
514            // Add Transform as a second component to satisfy ComponentBundle
515            world.spawn((transform, Transform::default()));
516        }
517
518        // Create one entity with both GlobalTransform and GpuMesh handle
519        let transform = GlobalTransform(AffineTransform::IDENTITY);
520        let gpu_mesh_handle = HandleComponent::<GpuMesh> {
521            handle: AssetHandle::new(create_dummy_gpu_mesh()),
522            uuid: AssetUUID::new(),
523        };
524        world.spawn((transform, gpu_mesh_handle));
525
526        lane.run(&world, &mut render_world);
527
528        // Should only extract the entity that has both transform and mesh
529        assert_eq!(
530            render_world.meshes.len(),
531            1,
532            "Should only extract entities with both components"
533        );
534    }
535
536    #[test]
537    fn test_extract_preserves_mesh_uuids() {
538        let lane = ExtractRenderablesLane::new();
539        let mut world = World::new();
540        let mut render_world = RenderWorld::default();
541
542        // Create entities with specific UUIDs
543        let uuid1 = AssetUUID::new();
544        let uuid2 = AssetUUID::new();
545        let uuid3 = AssetUUID::new();
546
547        let transform = GlobalTransform(AffineTransform::IDENTITY);
548
549        world.spawn((
550            transform,
551            HandleComponent::<GpuMesh> {
552                handle: AssetHandle::new(create_dummy_gpu_mesh()),
553                uuid: uuid1,
554            },
555        ));
556        world.spawn((
557            transform,
558            HandleComponent::<GpuMesh> {
559                handle: AssetHandle::new(create_dummy_gpu_mesh()),
560                uuid: uuid2,
561            },
562        ));
563        world.spawn((
564            transform,
565            HandleComponent::<GpuMesh> {
566                handle: AssetHandle::new(create_dummy_gpu_mesh()),
567                uuid: uuid3,
568            },
569        ));
570
571        lane.run(&world, &mut render_world);
572
573        assert_eq!(render_world.meshes.len(), 3);
574
575        // Verify UUIDs are preserved (order might vary depending on ECS implementation)
576        let extracted_uuids: Vec<AssetUUID> = render_world
577            .meshes
578            .iter()
579            .map(|m| m.cpu_mesh_uuid)
580            .collect();
581
582        assert!(extracted_uuids.contains(&uuid1), "Should contain uuid1");
583        assert!(extracted_uuids.contains(&uuid2), "Should contain uuid2");
584        assert!(extracted_uuids.contains(&uuid3), "Should contain uuid3");
585    }
586
587    #[test]
588    fn test_extract_with_identity_transform() {
589        let lane = ExtractRenderablesLane::new();
590        let mut world = World::new();
591        let mut render_world = RenderWorld::default();
592
593        let transform = GlobalTransform(AffineTransform::IDENTITY);
594        let mesh_uuid = AssetUUID::new();
595        world.spawn((
596            transform,
597            HandleComponent::<GpuMesh> {
598                handle: AssetHandle::new(create_dummy_gpu_mesh()),
599                uuid: mesh_uuid,
600            },
601        ));
602
603        lane.run(&world, &mut render_world);
604
605        assert_eq!(render_world.meshes.len(), 1);
606
607        let extracted = &render_world.meshes[0];
608        use khora_core::math::Mat4;
609        assert_eq!(extracted.transform.to_matrix(), Mat4::IDENTITY);
610    }
611
612    #[test]
613    fn test_extract_multiple_runs() {
614        let lane = ExtractRenderablesLane::new();
615        let mut world = World::new();
616        let mut render_world = RenderWorld::default();
617
618        // Add initial entity
619        let transform = GlobalTransform(AffineTransform::IDENTITY);
620        world.spawn((
621            transform,
622            HandleComponent::<GpuMesh> {
623                handle: AssetHandle::new(create_dummy_gpu_mesh()),
624                uuid: AssetUUID::new(),
625            },
626        ));
627
628        // First run
629        lane.run(&world, &mut render_world);
630        assert_eq!(render_world.meshes.len(), 1);
631
632        // Add more entities
633        world.spawn((
634            transform,
635            HandleComponent::<GpuMesh> {
636                handle: AssetHandle::new(create_dummy_gpu_mesh()),
637                uuid: AssetUUID::new(),
638            },
639        ));
640        world.spawn((
641            transform,
642            HandleComponent::<GpuMesh> {
643                handle: AssetHandle::new(create_dummy_gpu_mesh()),
644                uuid: AssetUUID::new(),
645            },
646        ));
647
648        // Second run should see all entities
649        lane.run(&world, &mut render_world);
650        assert_eq!(
651            render_world.meshes.len(),
652            3,
653            "Should extract all 3 entities"
654        );
655
656        // Third run should still work correctly
657        lane.run(&world, &mut render_world);
658        assert_eq!(
659            render_world.meshes.len(),
660            3,
661            "Should consistently extract 3 entities"
662        );
663    }
664}