khora_lanes/scene_lane/transform_propagation.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
15use std::collections::{HashMap, VecDeque};
16
17use khora_core::{ecs::entity::EntityId, math::Mat4};
18use khora_data::ecs::{GlobalTransform, Parent, Transform, Without, World};
19
20/// A system that propagates local `Transform` changes through the scene hierarchy
21/// to compute the final `GlobalTransform` for each entity.
22///
23/// This system performs a Breadth-First Search (BFS) traversal of the scene hierarchy.
24/// It guarantees that parent transforms are computed before their children, ensuring
25/// correctness in a single pass.
26///
27/// It is designed to run once per frame, before the rendering and physics systems.
28pub fn transform_propagation_system(world: &mut World) {
29 // Stage 1: Initialize the work queue with all root entities.
30 // A root is an entity with a `Transform` and `GlobalTransform` but no `Parent`.
31 // Their `GlobalTransform` is calculated directly from their local `Transform`.
32 let mut queue: VecDeque<EntityId> = VecDeque::new();
33 for (id, transform, global_transform, _) in
34 world.query::<(EntityId, &Transform, &mut GlobalTransform, Without<Parent>)>()
35 {
36 global_transform.0 = transform.to_mat4().into();
37 queue.push_back(id);
38 }
39
40 // Stage 2: Build a map of parent -> children relationships for efficient traversal.
41 // This is done once at the beginning for performance.
42 let mut children_map: HashMap<EntityId, Vec<EntityId>> = HashMap::new();
43 for (child_id, parent) in world.query::<(EntityId, &Parent)>() {
44 children_map.entry(parent.0).or_default().push(child_id);
45 }
46
47 // Stage 3: Process the queue in a Breadth-First manner.
48 // We use a `head` index to iterate through the queue without complex borrowing issues,
49 // as we are both reading from and writing to the queue.
50 let mut head = 0;
51 while let Some(&parent_id) = queue.get(head) {
52 head += 1; // Move to the next item for the next iteration.
53
54 // If this parent has any children...
55 if let Some(children) = children_map.get(&parent_id) {
56 // Get the parent's global transform, which we know is now up-to-date.
57 // We can `unwrap` because we know the entity is valid and has this component.
58 let parent_matrix = world.get::<GlobalTransform>(parent_id).unwrap().0;
59
60 for &child_id in children {
61 // Read the child's local transform.
62 if let Some(local_transform) = world.get::<Transform>(child_id) {
63 let child_matrix = Mat4::from(parent_matrix) * local_transform.to_mat4();
64
65 // Update the child's `GlobalTransform` component directly.
66 if let Some(global_transform) = world.get_mut::<GlobalTransform>(child_id) {
67 global_transform.0 = child_matrix.into();
68 }
69
70 // Add the child to the end of the queue. Its own children will be processed
71 // in a future iteration, after all entities at the current depth are done.
72 queue.push_back(child_id);
73 }
74 }
75 }
76 }
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use khora_core::math::{Mat4, Vec3, EPSILON};
83 use khora_data::ecs::{Children, GlobalTransform, Parent, SemanticDomain, Transform, World};
84
85 /// A helper function to compare two `Mat4` matrices for approximate equality.
86 fn assert_matrix_approx_eq(a: Mat4, b: Mat4) {
87 for i in 0..4 {
88 for j in 0..4 {
89 let val_a = a.cols[i][j];
90 let val_b = b.cols[i][j];
91 assert!(
92 (val_a - val_b).abs() < EPSILON,
93 "Matrix mismatch at col {}, row {}: {} != {}",
94 i,
95 j,
96 val_a,
97 val_b
98 );
99 }
100 }
101 }
102
103 #[test]
104 fn test_transform_propagation_simple_hierarchy() {
105 // --- 1. ARRANGE ---
106 let mut world = World::default();
107
108 // Register all the components we will use.
109 world.register_component::<Parent>(SemanticDomain::Spatial);
110 world.register_component::<Children>(SemanticDomain::Spatial);
111 world.register_component::<Transform>(SemanticDomain::Spatial);
112 world.register_component::<GlobalTransform>(SemanticDomain::Spatial);
113
114 // Create a root entity (the parent).
115 // It's translated by 10 units on the X axis.
116 let parent_transform = Transform {
117 translation: Vec3::new(10.0, 0.0, 0.0),
118 ..Default::default()
119 };
120 let parent_id = world.spawn((parent_transform, GlobalTransform::identity()));
121
122 // Create a child entity.
123 // It's translated by 2 units on the Y axis *relative to its parent*.
124 let child_transform = Transform {
125 translation: Vec3::new(0.0, 2.0, 0.0),
126 ..Default::default()
127 };
128 let child_id = world.spawn((
129 child_transform,
130 GlobalTransform::identity(),
131 Parent(parent_id),
132 ));
133
134 // --- 2. ACT ---
135 // Run the system we want to test.
136 transform_propagation_system(&mut world);
137
138 // --- 3. ASSERT ---
139 // Get the final GlobalTransform of the child entity.
140 let child_global_transform = world
141 .get::<GlobalTransform>(child_id)
142 .expect("Child should have a GlobalTransform component");
143
144 // The expected result is the parent's translation combined with the child's.
145 let expected_matrix = Mat4::from_translation(Vec3::new(10.0, 2.0, 0.0));
146
147 assert_matrix_approx_eq(child_global_transform.0.into(), expected_matrix);
148 }
149}