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, RenderWorld};
18use khora_core::{
19    math::Vec3,
20    renderer::{light::LightType, GpuMesh},
21};
22use khora_data::ecs::{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_uuid = world
56                .query::<&MaterialComponent>()
57                .nth(entity_id)
58                .map(|material_comp| material_comp.uuid);
59
60            let extracted_mesh = ExtractedMesh {
61                // Extract the affine transform directly from GlobalTransform
62                transform: transform.0,
63                // Extract the UUID of the GpuMesh asset.
64                gpu_mesh_uuid: gpu_mesh_handle_comp.uuid,
65                // Extract the UUID of the Material asset, if present.
66                material_uuid,
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
75    /// Extracts light components from the world into the render world.
76    fn extract_lights(&self, world: &World, render_world: &mut RenderWorld) {
77        // Query for entities that have both a Light component and a GlobalTransform.
78        let light_query = world.query::<(&Light, &GlobalTransform)>();
79
80        for (light_comp, global_transform) in light_query {
81            // Skip disabled lights
82            if !light_comp.enabled {
83                continue;
84            }
85
86            // Extract position from the global transform
87            let position = global_transform.0.translation();
88
89            // Extract direction based on light type
90            // For directional lights, use the direction from the light type
91            // For spot lights, transform the local direction by the global rotation
92            // For point lights, direction is not used but we set a default
93            let direction = match &light_comp.light_type {
94                LightType::Directional(dir_light) => dir_light.direction,
95                LightType::Spot(spot_light) => {
96                    // Transform the spot light's local direction by the entity's rotation
97                    let rotation = global_transform.0.rotation();
98                    rotation * spot_light.direction
99                }
100                LightType::Point(_) => Vec3::ZERO, // Not used for point lights
101            };
102
103            let extracted = ExtractedLight {
104                light_type: light_comp.light_type,
105                position,
106                direction,
107            };
108
109            render_world.lights.push(extracted);
110        }
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use khora_core::{
118        asset::{AssetHandle, AssetUUID},
119        math::{affine_transform::AffineTransform, Vec3},
120        renderer::GpuMesh,
121    };
122
123    // Helper function to create a dummy GpuMesh for testing
124    fn create_dummy_gpu_mesh() -> GpuMesh {
125        use khora_core::renderer::{api::PrimitiveTopology, BufferId, IndexFormat};
126        GpuMesh {
127            vertex_buffer: BufferId(0),
128            index_buffer: BufferId(0),
129            index_count: 0,
130            index_format: IndexFormat::Uint32,
131            primitive_topology: PrimitiveTopology::TriangleList,
132        }
133    }
134
135    // Dummy material for testing - implements both Material and Asset
136    #[derive(Clone)]
137    struct DummyMaterial;
138
139    impl khora_core::asset::Material for DummyMaterial {}
140    impl khora_core::asset::Asset for DummyMaterial {}
141
142    #[test]
143    fn test_extract_lane_creation() {
144        let lane = ExtractRenderablesLane::new();
145        // Just verify it can be created
146        let _ = lane;
147    }
148
149    #[test]
150    fn test_extract_lane_default() {
151        let _lane = ExtractRenderablesLane;
152    }
153
154    #[test]
155    fn test_extract_empty_world() {
156        let lane = ExtractRenderablesLane::new();
157        let world = World::new();
158        let mut render_world = RenderWorld::default();
159
160        lane.run(&world, &mut render_world);
161
162        assert_eq!(
163            render_world.meshes.len(),
164            0,
165            "Empty world should extract no meshes"
166        );
167    }
168
169    #[test]
170    fn test_extract_single_entity_without_material() {
171        let lane = ExtractRenderablesLane::new();
172        let mut world = World::new();
173        let mut render_world = RenderWorld::default();
174
175        // Create a simple transform
176        let transform =
177            GlobalTransform(AffineTransform::from_translation(Vec3::new(1.0, 2.0, 3.0)));
178
179        // Create a GPU mesh handle
180        let mesh_uuid = AssetUUID::new();
181        let gpu_mesh_handle = HandleComponent::<GpuMesh> {
182            handle: AssetHandle::new(create_dummy_gpu_mesh()),
183            uuid: mesh_uuid,
184        };
185
186        // Spawn an entity with transform and mesh
187        world.spawn((transform, gpu_mesh_handle));
188
189        lane.run(&world, &mut render_world);
190
191        assert_eq!(render_world.meshes.len(), 1, "Should extract 1 mesh");
192
193        let extracted = &render_world.meshes[0];
194        assert_eq!(extracted.gpu_mesh_uuid, mesh_uuid);
195        assert_eq!(extracted.material_uuid, None);
196        assert_eq!(extracted.transform.translation(), Vec3::new(1.0, 2.0, 3.0));
197    }
198
199    #[test]
200    fn test_extract_single_entity_with_material() {
201        let lane = ExtractRenderablesLane::new();
202        let mut world = World::new();
203        let mut render_world = RenderWorld::default();
204
205        // Create components
206        let transform = GlobalTransform(AffineTransform::from_translation(Vec3::new(
207            5.0, 10.0, 15.0,
208        )));
209        let mesh_uuid = AssetUUID::new();
210        let material_uuid = AssetUUID::new();
211
212        let gpu_mesh_handle = HandleComponent::<GpuMesh> {
213            handle: AssetHandle::new(create_dummy_gpu_mesh()),
214            uuid: mesh_uuid,
215        };
216
217        // Create a dummy material wrapped in Box<dyn Material>
218        let dummy_material: Box<dyn khora_core::asset::Material> = Box::new(DummyMaterial);
219        let material = MaterialComponent {
220            handle: AssetHandle::new(dummy_material),
221            uuid: material_uuid,
222        };
223
224        // Spawn an entity with all components
225        world.spawn((transform, gpu_mesh_handle, material));
226
227        lane.run(&world, &mut render_world);
228
229        assert_eq!(render_world.meshes.len(), 1, "Should extract 1 mesh");
230
231        let extracted = &render_world.meshes[0];
232        assert_eq!(extracted.gpu_mesh_uuid, mesh_uuid);
233        assert_eq!(extracted.material_uuid, Some(material_uuid));
234        assert_eq!(
235            extracted.transform.translation(),
236            Vec3::new(5.0, 10.0, 15.0)
237        );
238    }
239
240    #[test]
241    fn test_extract_multiple_entities() {
242        let lane = ExtractRenderablesLane::new();
243        let mut world = World::new();
244        let mut render_world = RenderWorld::default();
245
246        let mut with_material_count = 0;
247        let mut without_material_count = 0;
248
249        // Create 5 entities with different transforms
250        for i in 0..5 {
251            let transform = GlobalTransform(AffineTransform::from_translation(Vec3::new(
252                i as f32,
253                i as f32 * 2.0,
254                i as f32 * 3.0,
255            )));
256            let mesh_uuid = AssetUUID::new();
257            let gpu_mesh_handle = HandleComponent::<GpuMesh> {
258                handle: AssetHandle::new(create_dummy_gpu_mesh()),
259                uuid: mesh_uuid,
260            };
261
262            // Add material to some entities but not all
263            if i % 2 == 0 {
264                let dummy_material: Box<dyn khora_core::asset::Material> = Box::new(DummyMaterial);
265                let material = MaterialComponent {
266                    handle: AssetHandle::new(dummy_material),
267                    uuid: AssetUUID::new(),
268                };
269                world.spawn((transform, gpu_mesh_handle, material));
270                with_material_count += 1;
271            } else {
272                world.spawn((transform, gpu_mesh_handle));
273                without_material_count += 1;
274            }
275        }
276
277        lane.run(&world, &mut render_world);
278
279        assert_eq!(render_world.meshes.len(), 5, "Should extract 5 meshes");
280
281        // Count how many extracted meshes have materials (don't rely on order)
282        let extracted_with_material = render_world
283            .meshes
284            .iter()
285            .filter(|m| m.material_uuid.is_some())
286            .count();
287        let extracted_without_material = render_world
288            .meshes
289            .iter()
290            .filter(|m| m.material_uuid.is_none())
291            .count();
292
293        assert_eq!(
294            extracted_with_material, with_material_count,
295            "Should have {} entities with materials",
296            with_material_count
297        );
298        assert_eq!(
299            extracted_without_material, without_material_count,
300            "Should have {} entities without materials",
301            without_material_count
302        );
303    }
304
305    #[test]
306    fn test_extract_with_different_transforms() {
307        let lane = ExtractRenderablesLane::new();
308        let mut world = World::new();
309        let mut render_world = RenderWorld::default();
310
311        // Entity with translation
312        let transform1 = GlobalTransform(AffineTransform::from_translation(Vec3::new(
313            10.0, 20.0, 30.0,
314        )));
315        world.spawn((
316            transform1,
317            HandleComponent::<GpuMesh> {
318                handle: AssetHandle::new(create_dummy_gpu_mesh()),
319                uuid: AssetUUID::new(),
320            },
321        ));
322
323        // Entity with rotation around Y axis
324        use khora_core::math::Quaternion;
325        let transform2 = GlobalTransform(AffineTransform::from_quat(Quaternion::from_axis_angle(
326            Vec3::Y,
327            std::f32::consts::PI / 2.0,
328        )));
329        world.spawn((
330            transform2,
331            HandleComponent::<GpuMesh> {
332                handle: AssetHandle::new(create_dummy_gpu_mesh()),
333                uuid: AssetUUID::new(),
334            },
335        ));
336
337        // Entity with scale
338        let transform3 = GlobalTransform(AffineTransform::from_scale(Vec3::new(2.0, 3.0, 4.0)));
339        world.spawn((
340            transform3,
341            HandleComponent::<GpuMesh> {
342                handle: AssetHandle::new(create_dummy_gpu_mesh()),
343                uuid: AssetUUID::new(),
344            },
345        ));
346
347        lane.run(&world, &mut render_world);
348
349        assert_eq!(render_world.meshes.len(), 3, "Should extract 3 meshes");
350
351        // Verify first transform (translation)
352        assert_eq!(
353            render_world.meshes[0].transform.translation(),
354            Vec3::new(10.0, 20.0, 30.0)
355        );
356
357        // Verify transforms are different (comparing the matrices directly)
358        let mat0 = render_world.meshes[0].transform.to_matrix();
359        let mat1 = render_world.meshes[1].transform.to_matrix();
360        let mat2 = render_world.meshes[2].transform.to_matrix();
361        assert_ne!(mat0, mat1);
362        assert_ne!(mat1, mat2);
363    }
364
365    #[test]
366    fn test_extract_clears_previous_data() {
367        let lane = ExtractRenderablesLane::new();
368        let mut world = World::new();
369        let mut render_world = RenderWorld::default();
370
371        // First extraction with 3 entities
372        for _ in 0..3 {
373            let transform = GlobalTransform(AffineTransform::IDENTITY);
374            let gpu_mesh_handle = HandleComponent::<GpuMesh> {
375                handle: AssetHandle::new(create_dummy_gpu_mesh()),
376                uuid: AssetUUID::new(),
377            };
378            world.spawn((transform, gpu_mesh_handle));
379        }
380
381        lane.run(&world, &mut render_world);
382        assert_eq!(
383            render_world.meshes.len(),
384            3,
385            "First run should extract 3 meshes"
386        );
387
388        // Create a new world with only 1 entity
389        let mut world2 = World::new();
390        let transform = GlobalTransform(AffineTransform::IDENTITY);
391        let gpu_mesh_handle = HandleComponent::<GpuMesh> {
392            handle: AssetHandle::new(create_dummy_gpu_mesh()),
393            uuid: AssetUUID::new(),
394        };
395        world2.spawn((transform, gpu_mesh_handle));
396
397        // Second extraction should clear previous data
398        lane.run(&world2, &mut render_world);
399        assert_eq!(
400            render_world.meshes.len(),
401            1,
402            "Second run should extract only 1 mesh (cleared previous)"
403        );
404    }
405
406    #[test]
407    fn test_extract_entities_without_mesh_component() {
408        let lane = ExtractRenderablesLane::new();
409        let mut world = World::new();
410        let mut render_world = RenderWorld::default();
411
412        // Create entities with only transform (no GpuMesh handle)
413        // Note: World::spawn requires at least one component, so we add a Transform too
414        use khora_data::ecs::Transform;
415        for _ in 0..3 {
416            let transform = GlobalTransform(AffineTransform::IDENTITY);
417            // Add Transform as a second component to satisfy ComponentBundle
418            world.spawn((transform, Transform::default()));
419        }
420
421        // Create one entity with both GlobalTransform and GpuMesh handle
422        let transform = GlobalTransform(AffineTransform::IDENTITY);
423        let gpu_mesh_handle = HandleComponent::<GpuMesh> {
424            handle: AssetHandle::new(create_dummy_gpu_mesh()),
425            uuid: AssetUUID::new(),
426        };
427        world.spawn((transform, gpu_mesh_handle));
428
429        lane.run(&world, &mut render_world);
430
431        // Should only extract the entity that has both transform and mesh
432        assert_eq!(
433            render_world.meshes.len(),
434            1,
435            "Should only extract entities with both components"
436        );
437    }
438
439    #[test]
440    fn test_extract_preserves_mesh_uuids() {
441        let lane = ExtractRenderablesLane::new();
442        let mut world = World::new();
443        let mut render_world = RenderWorld::default();
444
445        // Create entities with specific UUIDs
446        let uuid1 = AssetUUID::new();
447        let uuid2 = AssetUUID::new();
448        let uuid3 = AssetUUID::new();
449
450        let transform = GlobalTransform(AffineTransform::IDENTITY);
451
452        world.spawn((
453            transform,
454            HandleComponent::<GpuMesh> {
455                handle: AssetHandle::new(create_dummy_gpu_mesh()),
456                uuid: uuid1,
457            },
458        ));
459        world.spawn((
460            transform,
461            HandleComponent::<GpuMesh> {
462                handle: AssetHandle::new(create_dummy_gpu_mesh()),
463                uuid: uuid2,
464            },
465        ));
466        world.spawn((
467            transform,
468            HandleComponent::<GpuMesh> {
469                handle: AssetHandle::new(create_dummy_gpu_mesh()),
470                uuid: uuid3,
471            },
472        ));
473
474        lane.run(&world, &mut render_world);
475
476        assert_eq!(render_world.meshes.len(), 3);
477
478        // Verify UUIDs are preserved (order might vary depending on ECS implementation)
479        let extracted_uuids: Vec<AssetUUID> = render_world
480            .meshes
481            .iter()
482            .map(|m| m.gpu_mesh_uuid)
483            .collect();
484
485        assert!(extracted_uuids.contains(&uuid1), "Should contain uuid1");
486        assert!(extracted_uuids.contains(&uuid2), "Should contain uuid2");
487        assert!(extracted_uuids.contains(&uuid3), "Should contain uuid3");
488    }
489
490    #[test]
491    fn test_extract_with_identity_transform() {
492        let lane = ExtractRenderablesLane::new();
493        let mut world = World::new();
494        let mut render_world = RenderWorld::default();
495
496        let transform = GlobalTransform(AffineTransform::IDENTITY);
497        let mesh_uuid = AssetUUID::new();
498        world.spawn((
499            transform,
500            HandleComponent::<GpuMesh> {
501                handle: AssetHandle::new(create_dummy_gpu_mesh()),
502                uuid: mesh_uuid,
503            },
504        ));
505
506        lane.run(&world, &mut render_world);
507
508        assert_eq!(render_world.meshes.len(), 1);
509
510        let extracted = &render_world.meshes[0];
511        use khora_core::math::Mat4;
512        assert_eq!(extracted.transform.to_matrix(), Mat4::IDENTITY);
513    }
514
515    #[test]
516    fn test_extract_multiple_runs() {
517        let lane = ExtractRenderablesLane::new();
518        let mut world = World::new();
519        let mut render_world = RenderWorld::default();
520
521        // Add initial entity
522        let transform = GlobalTransform(AffineTransform::IDENTITY);
523        world.spawn((
524            transform,
525            HandleComponent::<GpuMesh> {
526                handle: AssetHandle::new(create_dummy_gpu_mesh()),
527                uuid: AssetUUID::new(),
528            },
529        ));
530
531        // First run
532        lane.run(&world, &mut render_world);
533        assert_eq!(render_world.meshes.len(), 1);
534
535        // Add more entities
536        world.spawn((
537            transform,
538            HandleComponent::<GpuMesh> {
539                handle: AssetHandle::new(create_dummy_gpu_mesh()),
540                uuid: AssetUUID::new(),
541            },
542        ));
543        world.spawn((
544            transform,
545            HandleComponent::<GpuMesh> {
546                handle: AssetHandle::new(create_dummy_gpu_mesh()),
547                uuid: AssetUUID::new(),
548            },
549        ));
550
551        // Second run should see all entities
552        lane.run(&world, &mut render_world);
553        assert_eq!(
554            render_world.meshes.len(),
555            3,
556            "Should extract all 3 entities"
557        );
558
559        // Third run should still work correctly
560        lane.run(&world, &mut render_world);
561        assert_eq!(
562            render_world.meshes.len(),
563            3,
564            "Should consistently extract 3 entities"
565        );
566    }
567}