Skip to main content

concinnity_core/gfx/
transform_propagation.rs

1//! Resolving each entity's world matrix from its Transform and its Parent
2//! chain, and writing the result back to its GlobalTransform. A host with a
3//! renderer runs this every frame before it builds a draw list.
4//!
5//! The cache holds a slot table rather than entity-keyed maps: every entity that
6//! owns a Transform gets a dense slot, and parents, depths, children, locals and
7//! world matrices are parallel arrays indexed by it. A full resolve orders the
8//! slots shallowest-first (a counting sort on depth) so one pass composes the
9//! whole hierarchy.
10//!
11//! Between full resolves the pass is incremental. The Transform column stamps a
12//! change tick per row, so the entities written since the last resolve are
13//! recoverable without scanning their values; each one re-walks only its own
14//! subtree. A whole-column write, a row added or removed, any Parent edit, or a
15//! dirty set large enough that the subtree walks would overlap all fall back to
16//! the full resolve.
17
18use alloc::collections::BTreeMap;
19use alloc::vec::Vec;
20
21use crate::components::{GlobalTransform, Parent, Transform};
22use crate::ecs::{ColumnTicks, Entity, MAX_CHANGE_AGE, PipelineContext, Tick};
23use crate::gfx::transform::{IDENTITY, mat4_mul};
24
25/// A resolved world matrix, column-major like every other matrix here.
26pub type WorldMatrix = [[f32; 4]; 4];
27
28// Slot sentinel for "no parent" and for an entity the slot table does not hold.
29const NO_SLOT: u32 = u32::MAX;
30
31// Depth sentinels. CYCLIC marks an entity whose ancestor chain loops, or that
32// descends from one: it resolves to its own local matrix. VISITING marks a slot
33// the depth walk is currently descending through, which is how it detects the
34// loop; UNVISITED is the initial state.
35const CYCLIC: u32 = u32::MAX;
36const VISITING: u32 = u32::MAX - 1;
37const UNVISITED: u32 = u32::MAX - 2;
38
39// A moved entity re-walks its own subtree, so past a column-size fraction of
40// this divisor the walks overlap enough that one ordered full resolve is the
41// cheaper pass. A single moved entity always stays incremental.
42const DIRTY_BUDGET_DIVISOR: usize = 8;
43
44// The source-column tick stamps observed at the last resolve. `transform`
45// carries all four so the next frame can tell a targeted write (which the
46// per-row stamps describe) from a whole-column write or a row add/remove
47// (which they do not).
48#[derive(Clone, Copy)]
49struct SourceTicks {
50    transform: ColumnTicks,
51    parent: Tick,
52}
53
54/// Reused scratch plus change-tracking for the per-frame transform propagation.
55///
56/// A caller owns one and passes it to [`propagate_transforms_cached`] each
57/// frame: every buffer is refilled in place (no per-frame allocation once they
58/// reach steady-state capacity), propagation is skipped entirely on frames where
59/// neither the Transform nor the Parent column changed since the last recompute,
60/// and on frames where only some Transforms were written only those entities'
61/// subtrees are recomposed.
62#[derive(Default)]
63pub struct TransformCache {
64    // Slot tables, indexed by slot and all the same length: one slot per entity
65    // owning a Transform, in Transform-column order.
66    entity: Vec<Entity>,
67    local: Vec<WorldMatrix>,
68    world: Vec<WorldMatrix>,
69    parent: Vec<u32>,
70    depth: Vec<u32>,
71    // Slots shallowest-first, so one forward pass composes every chain.
72    order: Vec<u32>,
73    // Children in compressed-row form: `child_list[child_start[s]..child_start[s + 1]]`
74    // are slot `s`'s children. `child_start` has one entry per slot plus a tail.
75    child_start: Vec<u32>,
76    child_list: Vec<u32>,
77    // Entity index -> slot, NO_SLOT where the entity owns no Transform.
78    slot_of: Vec<u32>,
79    // Last pass that recomputed each slot, against `pass`, so a subtree already
80    // covered by a shallower dirty ancestor is not walked twice.
81    visited: Vec<u32>,
82    pass: u32,
83    // Per-pass scratch: the depth walk's ancestor path, the dirty slots, the
84    // subtree walk's stack, and a u32 run shared by the counting sort and the
85    // child-index fill (each clears it before use).
86    path: Vec<u32>,
87    dirty: Vec<u32>,
88    stack: Vec<u32>,
89    offsets: Vec<u32>,
90    last: Option<SourceTicks>,
91}
92
93impl TransformCache {
94    // The slot holding `entity`'s transform, or None when it owns none. The
95    // generation check rejects a handle whose index was recycled.
96    fn slot_of(&self, entity: Entity) -> Option<u32> {
97        let slot = *self.slot_of.get(entity.index() as usize)?;
98        if slot == NO_SLOT {
99            return None;
100        }
101        (self.entity[slot as usize] == entity).then_some(slot)
102    }
103
104    // Rebuild the whole slot table from the live Transform + Parent columns:
105    // one slot per Transform, each slot's parent slot, its depth, its children,
106    // and the shallowest-first order that composes them.
107    fn resolve(&mut self, ctx: &PipelineContext) {
108        self.entity.clear();
109        self.local.clear();
110        for (entity, transform) in ctx.query_with_entity::<Transform>() {
111            self.entity.push(entity);
112            self.local.push(transform.model_matrix());
113        }
114        let slots = self.entity.len();
115
116        let widest = self
117            .entity
118            .iter()
119            .map(|e| e.index() as usize)
120            .max()
121            .map_or(0, |i| i + 1);
122        self.slot_of.clear();
123        self.slot_of.resize(widest, NO_SLOT);
124        for (slot, entity) in self.entity.iter().enumerate() {
125            self.slot_of[entity.index() as usize] = slot as u32;
126        }
127
128        // A Parent naming an entity with no Transform composes against nothing,
129        // so its child resolves as a root.
130        self.parent.clear();
131        self.parent.resize(slots, NO_SLOT);
132        for (entity, parent) in ctx.query_with_entity::<Parent>() {
133            if let Some(slot) = self.slot_of(entity) {
134                self.parent[slot as usize] = self.slot_of(parent.0).unwrap_or(NO_SLOT);
135            }
136        }
137
138        self.compute_depths();
139        self.order_by_depth();
140        self.build_children();
141
142        self.world.clear();
143        self.world.resize(slots, IDENTITY);
144        for i in 0..self.order.len() {
145            let slot = self.order[i] as usize;
146            self.world[slot] = self.compose(slot);
147        }
148
149        self.visited.clear();
150        self.visited.resize(slots, 0);
151        self.pass = 0;
152    }
153
154    // One slot's world matrix from its parent's, which the shallowest-first
155    // order guarantees is already resolved. A root, and anything caught in (or
156    // hanging off) a parent cycle, keeps its local matrix.
157    fn compose(&self, slot: usize) -> WorldMatrix {
158        match (self.depth[slot], self.parent[slot]) {
159            (CYCLIC, _) | (_, NO_SLOT) => self.local[slot],
160            (_, parent) => mat4_mul(self.world[parent as usize], self.local[slot]),
161        }
162    }
163
164    // Depth of every slot: 0 for a root, parent depth + 1 otherwise, CYCLIC for
165    // a slot whose ancestor chain loops. Iterative over an explicit ancestor
166    // path, so a deep hierarchy cannot overflow the stack, and memoized, so
167    // each slot is resolved once no matter how many descendants ask for it.
168    fn compute_depths(&mut self) {
169        self.depth.clear();
170        self.depth.resize(self.local.len(), UNVISITED);
171        for start in 0..self.depth.len() {
172            if self.depth[start] != UNVISITED {
173                continue;
174            }
175            self.path.clear();
176            let mut current = start;
177            // The depth to assign the last entry pushed onto the path.
178            let base = loop {
179                match self.depth[current] {
180                    UNVISITED => {}
181                    // Already on this path, or known to hang off a loop.
182                    VISITING | CYCLIC => break CYCLIC,
183                    known => break known + 1,
184                }
185                self.depth[current] = VISITING;
186                self.path.push(current as u32);
187                match self.parent[current] {
188                    NO_SLOT => break 0,
189                    parent => current = parent as usize,
190                }
191            };
192            let mut depth = base;
193            for &slot in self.path.iter().rev() {
194                self.depth[slot as usize] = depth;
195                if depth != CYCLIC {
196                    depth += 1;
197                }
198            }
199        }
200    }
201
202    // Order the slots shallowest-first by counting sort, with the cyclic slots
203    // in a bucket of their own past the deepest real level.
204    fn order_by_depth(&mut self) {
205        let deepest = self
206            .depth
207            .iter()
208            .copied()
209            .filter(|&d| d != CYCLIC)
210            .max()
211            .unwrap_or(0) as usize;
212        let cyclic_bucket = deepest + 1;
213        self.offsets.clear();
214        self.offsets.resize(cyclic_bucket + 1, 0);
215        for &depth in &self.depth {
216            let bucket = if depth == CYCLIC {
217                cyclic_bucket
218            } else {
219                depth as usize
220            };
221            self.offsets[bucket] += 1;
222        }
223        let mut offset = 0;
224        for count in &mut self.offsets {
225            let bucket = *count;
226            *count = offset;
227            offset += bucket;
228        }
229        self.order.clear();
230        self.order.resize(self.depth.len(), 0);
231        for slot in 0..self.depth.len() {
232            let bucket = if self.depth[slot] == CYCLIC {
233                cyclic_bucket
234            } else {
235                self.depth[slot] as usize
236            };
237            self.order[self.offsets[bucket] as usize] = slot as u32;
238            self.offsets[bucket] += 1;
239        }
240    }
241
242    // Invert `parent` into the compressed-row child index the subtree walk
243    // descends. Counts per parent, prefix-sums them into row starts, then
244    // places each slot under its parent.
245    fn build_children(&mut self) {
246        let slots = self.parent.len();
247        self.child_start.clear();
248        self.child_start.resize(slots + 1, 0);
249        for &parent in &self.parent {
250            if parent != NO_SLOT {
251                self.child_start[parent as usize + 1] += 1;
252            }
253        }
254        for i in 0..slots {
255            self.child_start[i + 1] += self.child_start[i];
256        }
257        self.child_list.clear();
258        self.child_list.resize(self.child_start[slots] as usize, 0);
259        // Walk the starts forward as rows fill, then restore them.
260        self.offsets.clear();
261        self.offsets.extend_from_slice(&self.child_start[..slots]);
262        for slot in 0..slots {
263            let parent = self.parent[slot];
264            if parent == NO_SLOT {
265                continue;
266            }
267            let at = self.offsets[parent as usize] as usize;
268            self.offsets[parent as usize] += 1;
269            self.child_list[at] = slot as u32;
270        }
271    }
272
273    // Write every slot's world matrix into its GlobalTransform. An entity that
274    // owns a Transform but no GlobalTransform is simply skipped.
275    fn write_all(&self, ctx: &mut PipelineContext) {
276        for (slot, &entity) in self.entity.iter().enumerate() {
277            if let Some(global) = ctx.get_mut::<GlobalTransform>(entity) {
278                global.0 = self.world[slot];
279            }
280        }
281    }
282
283    // Recompose only the subtrees under the Transforms written since `since`.
284    // Returns false when the per-row stamps cannot carry the pass -- an unknown
285    // entity, or a dirty set large enough that a full resolve is cheaper -- and
286    // the caller falls back.
287    fn resolve_incremental(&mut self, ctx: &mut PipelineContext, since: Tick) -> bool {
288        let budget = (self.entity.len() / DIRTY_BUDGET_DIVISOR).max(1);
289        self.dirty.clear();
290        for (entity, transform) in ctx.changed_rows::<Transform>(since) {
291            if self.dirty.len() >= budget {
292                return false;
293            }
294            let Some(slot) = self.slot_of(entity) else {
295                return false;
296            };
297            self.local[slot as usize] = transform.model_matrix();
298            self.dirty.push(slot);
299        }
300
301        // Shallowest first, so an ancestor's walk subsumes any dirty descendant
302        // rather than recomputing it against a stale parent and again after.
303        self.dirty
304            .sort_unstable_by_key(|&slot| self.depth[slot as usize]);
305        self.pass = self.pass.wrapping_add(1);
306        if self.pass == 0 {
307            self.visited.fill(0);
308            self.pass = 1;
309        }
310        for i in 0..self.dirty.len() {
311            self.walk_subtree(ctx, self.dirty[i]);
312        }
313        true
314    }
315
316    // Recompose `seed` and everything under it, writing each GlobalTransform as
317    // it goes. Slots already recomposed this pass are skipped, so seeds that
318    // share an ancestor cost one walk between them.
319    fn walk_subtree(&mut self, ctx: &mut PipelineContext, seed: u32) {
320        self.stack.clear();
321        self.stack.push(seed);
322        while let Some(slot) = self.stack.pop() {
323            let slot = slot as usize;
324            if self.visited[slot] == self.pass {
325                continue;
326            }
327            self.visited[slot] = self.pass;
328            self.world[slot] = self.compose(slot);
329            if let Some(global) = ctx.get_mut::<GlobalTransform>(self.entity[slot]) {
330                global.0 = self.world[slot];
331            }
332            let (start, end) = (
333                self.child_start[slot] as usize,
334                self.child_start[slot + 1] as usize,
335            );
336            self.stack.extend_from_slice(&self.child_list[start..end]);
337        }
338    }
339
340    // The resolved matrices keyed by entity, for the one-shot callers that want
341    // a lookup table rather than the slot arrays.
342    fn world_map(&self) -> BTreeMap<Entity, WorldMatrix> {
343        self.entity
344            .iter()
345            .copied()
346            .zip(self.world.iter().copied())
347            .collect()
348    }
349}
350
351/// Resolve each entity's world matrix from its Transform and Parent chain.
352///
353/// Returns an entity -> world matrix map, built through a throwaway
354/// [`TransformCache`]; the per-frame path owns a cache and calls
355/// [`propagate_transforms_cached`] instead so the buffers survive across frames.
356pub fn resolve_world_matrices(ctx: &PipelineContext) -> BTreeMap<Entity, WorldMatrix> {
357    let mut cache = TransformCache::default();
358    cache.resolve(ctx);
359    cache.world_map()
360}
361
362/// Resolve and write every entity's GlobalTransform in one shot, with no cache
363/// carried across calls. The reparent recompose and the load-time pass use this;
364/// the per-frame path uses [`propagate_transforms_cached`].
365pub fn propagate_transforms(ctx: &mut PipelineContext) {
366    let mut cache = TransformCache::default();
367    cache.resolve(ctx);
368    cache.write_all(ctx);
369}
370
371/// Per-frame transform propagation against the reused scratch in `cache`.
372///
373/// Writes each entity's GlobalTransform from its Transform + Parent chain,
374/// exactly as [`propagate_transforms`], but does the least work the change ticks
375/// allow: nothing at all when neither source column moved, a walk of just the
376/// moved entities' subtrees when only targeted Transform writes landed, and a
377/// full ordered resolve otherwise.
378pub fn propagate_transforms_cached(ctx: &mut PipelineContext, cache: &mut TransformCache) {
379    let transform = ctx.column_ticks::<Transform>();
380    let parent = ctx.changed_tick::<Parent>();
381
382    if let Some(last) = cache.last {
383        if last.transform.changed == transform.changed && last.parent == parent {
384            return;
385        }
386        // Per-row stamps describe the change only while no whole-column write,
387        // row add/remove, or Parent edit has happened since the last resolve,
388        // and only while the two ticks are still within the wrap-relative
389        // window a row comparison is valid over.
390        let targeted_only = last.parent == parent
391            && last.transform.bulk == transform.bulk
392            && last.transform.structural == transform.structural
393            && transform
394                .changed
395                .get()
396                .wrapping_sub(last.transform.changed.get())
397                <= MAX_CHANGE_AGE;
398        if targeted_only && cache.resolve_incremental(ctx, last.transform.changed) {
399            cache.last = Some(SourceTicks { transform, parent });
400            return;
401        }
402    }
403
404    cache.resolve(ctx);
405    cache.write_all(ctx);
406    cache.last = Some(SourceTicks { transform, parent });
407}
408
409/// Re-parent an entity at runtime: detach it from its current parent (if any),
410/// attach it under `new_parent` (or leave it a root when `None`), keep both
411/// parents' Children lists in sync, and recompose world matrices so the new
412/// chain shows up immediately. Entity-keyed throughout, so it is invariant to
413/// component-column order.
414pub fn reparent(ctx: &mut PipelineContext, child: Entity, new_parent: Option<Entity>) {
415    use crate::components::Children;
416
417    // Drop the old parent edge and unlist the child from that parent.
418    if let Some(old) = ctx.remove::<Parent>(child)
419        && let Some(siblings) = ctx.get_mut::<Children>(old.0)
420    {
421        siblings.0.retain(|&e| e != child);
422    }
423
424    // Attach under the new parent (None leaves it a root). The Parent column is
425    // free of `child` here (just removed), so the insert never duplicates.
426    if let Some(parent) = new_parent {
427        ctx.insert(child, Parent(parent));
428        match ctx.get_mut::<Children>(parent) {
429            Some(kids) => {
430                if !kids.0.contains(&child) {
431                    kids.0.push(child);
432                }
433            }
434            None => ctx.insert(parent, Children(concinnity_memory::InlineVec::one(child))),
435        }
436    }
437
438    propagate_transforms(ctx);
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use alloc::vec;
445
446    use crate::components::Children;
447    use crate::ecs::{Arena, ComponentStorage, FrameContext, NoPayloads, Resources};
448    use crate::gfx::profile::FrameProfile;
449
450    const IDENTITY4: WorldMatrix = IDENTITY;
451
452    // The pieces a PipelineContext borrows, owned so a test can build one
453    // context and hold it for the whole body.
454    struct TestWorld {
455        components: ComponentStorage,
456        blob: NoPayloads,
457        profile: FrameProfile,
458        resources: Resources,
459        scratch: Arena,
460    }
461
462    impl TestWorld {
463        fn new() -> TestWorld {
464            TestWorld {
465                components: ComponentStorage::default(),
466                blob: NoPayloads,
467                profile: FrameProfile::default(),
468                resources: Resources::new(),
469                scratch: Arena::with_capacity(64 * 1024),
470            }
471        }
472
473        fn ctx(&mut self) -> PipelineContext<'_> {
474            PipelineContext {
475                components: &mut self.components,
476                blob: &mut self.blob,
477                profile: &mut self.profile,
478                resources: &mut self.resources,
479                frame: FrameContext::new(&self.scratch),
480            }
481        }
482    }
483
484    fn translate(x: f32) -> Transform {
485        Transform {
486            position: [x, 0.0, 0.0],
487            rotation_deg: [0.0; 3],
488            scale: [1.0; 3],
489        }
490    }
491
492    // An entity carrying the Transform propagation reads and the
493    // GlobalTransform it writes, optionally parented.
494    fn spawn(ctx: &mut PipelineContext, t: Transform, parent: Option<Entity>) -> Entity {
495        let entity = ctx.components.spawn();
496        ctx.insert(entity, t);
497        ctx.insert(entity, GlobalTransform::default());
498        if let Some(p) = parent {
499            ctx.insert(entity, Parent(p));
500        }
501        entity
502    }
503
504    fn global(ctx: &PipelineContext, entity: Entity) -> WorldMatrix {
505        ctx.get::<GlobalTransform>(entity).unwrap().0
506    }
507
508    // A chain of `depth` entities each parented to the one above, translated a
509    // unit apart, returned root-first.
510    fn chain(ctx: &mut PipelineContext, depth: usize) -> Vec<Entity> {
511        let mut chain = Vec::with_capacity(depth);
512        let mut parent = None;
513        for i in 0..depth {
514            let entity = spawn(ctx, translate(i as f32 + 1.0), parent);
515            chain.push(entity);
516            parent = Some(entity);
517        }
518        chain
519    }
520
521    // propagate_transforms composes each entity's GlobalTransform from its parent
522    // chain: a root's world matrix is its local, a child's is parent_world * local.
523    #[test]
524    fn propagate_transforms_composes_parent_then_child() {
525        let parent_t = Transform {
526            position: [1.0, 2.0, 3.0],
527            rotation_deg: [0.0, 30.0, 0.0],
528            scale: [1.0, 1.0, 1.0],
529        };
530        let child_t = Transform {
531            position: [0.0, 0.0, 1.0],
532            rotation_deg: [10.0, 0.0, 5.0],
533            scale: [2.0, 2.0, 2.0],
534        };
535
536        let mut world = TestWorld::new();
537        let mut ctx = world.ctx();
538        let parent_e = spawn(&mut ctx, parent_t, None);
539        let child_e = spawn(&mut ctx, child_t, Some(parent_e));
540
541        propagate_transforms(&mut ctx);
542
543        assert_eq!(
544            global(&ctx, parent_e),
545            parent_t.model_matrix(),
546            "root world = local"
547        );
548        assert_eq!(
549            global(&ctx, child_e),
550            mat4_mul(parent_t.model_matrix(), child_t.model_matrix()),
551            "child world = parent_world * local"
552        );
553    }
554
555    // The cached per-frame path resolves the same parent-then-child composition
556    // as the uncached `propagate_transforms`.
557    #[test]
558    fn cached_propagation_matches_the_uncached_path() {
559        let parent_t = Transform {
560            position: [1.0, 2.0, 3.0],
561            rotation_deg: [0.0, 30.0, 0.0],
562            scale: [1.0, 1.0, 1.0],
563        };
564        let child_t = Transform {
565            position: [0.0, 0.0, 1.0],
566            rotation_deg: [10.0, 0.0, 5.0],
567            scale: [2.0, 2.0, 2.0],
568        };
569
570        let mut world = TestWorld::new();
571        let mut ctx = world.ctx();
572        let parent_e = spawn(&mut ctx, parent_t, None);
573        let child_e = spawn(&mut ctx, child_t, Some(parent_e));
574
575        let mut cache = TransformCache::default();
576        propagate_transforms_cached(&mut ctx, &mut cache);
577
578        assert_eq!(global(&ctx, parent_e), parent_t.model_matrix());
579        assert_eq!(
580            global(&ctx, child_e),
581            mat4_mul(parent_t.model_matrix(), child_t.model_matrix())
582        );
583    }
584
585    // The cached path skips the resolve (and the GlobalTransform writes) on
586    // frames where no Transform / Parent changed, and recomputes once one does.
587    #[test]
588    fn cached_propagation_skips_until_a_transform_changes() {
589        let mut world = TestWorld::new();
590        let mut ctx = world.ctx();
591        let t0 = translate(1.0);
592        let e = spawn(&mut ctx, t0, None);
593
594        let mut cache = TransformCache::default();
595        propagate_transforms_cached(&mut ctx, &mut cache);
596        assert_eq!(global(&ctx, e), t0.model_matrix());
597
598        // A GlobalTransform write does not dirty the Transform column, so the
599        // next pass must skip and leave the (deliberately corrupted) value.
600        ctx.get_mut::<GlobalTransform>(e).unwrap().0 = IDENTITY4;
601        propagate_transforms_cached(&mut ctx, &mut cache);
602        assert_eq!(
603            global(&ctx, e),
604            IDENTITY4,
605            "unchanged Transform => propagation skipped"
606        );
607
608        // Mutating the Transform dirties its row; the next pass recomputes.
609        let t1 = Transform {
610            position: [0.0, 5.0, 0.0],
611            rotation_deg: [0.0; 3],
612            scale: [1.0; 3],
613        };
614        *ctx.get_mut::<Transform>(e).unwrap() = t1;
615        propagate_transforms_cached(&mut ctx, &mut cache);
616        assert_eq!(
617            global(&ctx, e),
618            t1.model_matrix(),
619            "changed Transform => propagation recomputed"
620        );
621    }
622
623    // A moved entity carries its whole subtree with it: every descendant's
624    // GlobalTransform recomposes against the new ancestor world matrix.
625    #[test]
626    fn moving_a_root_recomposes_its_whole_subtree() {
627        let mut world = TestWorld::new();
628        let mut ctx = world.ctx();
629        let links = chain(&mut ctx, 4);
630
631        let mut cache = TransformCache::default();
632        propagate_transforms_cached(&mut ctx, &mut cache);
633
634        let moved = translate(100.0);
635        *ctx.get_mut::<Transform>(links[0]).unwrap() = moved;
636        propagate_transforms_cached(&mut ctx, &mut cache);
637
638        // Each link's world matrix is its ancestors composed left to right.
639        let mut expected = moved.model_matrix();
640        assert_eq!(global(&ctx, links[0]), expected);
641        for (depth, &link) in links.iter().enumerate().skip(1) {
642            let local = translate(depth as f32 + 1.0).model_matrix();
643            expected = mat4_mul(expected, local);
644            assert_eq!(global(&ctx, link), expected, "link at depth {depth}");
645        }
646    }
647
648    // The pass is genuinely incremental: an entity outside the moved subtree is
649    // not rewritten, so a deliberately corrupted sibling stays corrupted.
650    #[test]
651    fn an_untouched_subtree_is_not_rewritten() {
652        let mut world = TestWorld::new();
653        let mut ctx = world.ctx();
654        let root_a = spawn(&mut ctx, translate(1.0), None);
655        let child_a = spawn(&mut ctx, translate(2.0), Some(root_a));
656        let root_b = spawn(&mut ctx, translate(3.0), None);
657        let child_b = spawn(&mut ctx, translate(4.0), Some(root_b));
658
659        let mut cache = TransformCache::default();
660        propagate_transforms_cached(&mut ctx, &mut cache);
661
662        ctx.get_mut::<GlobalTransform>(child_b).unwrap().0 = IDENTITY4;
663        *ctx.get_mut::<Transform>(root_a).unwrap() = translate(50.0);
664        propagate_transforms_cached(&mut ctx, &mut cache);
665
666        assert_eq!(
667            global(&ctx, child_a),
668            mat4_mul(
669                translate(50.0).model_matrix(),
670                translate(2.0).model_matrix()
671            ),
672            "the moved root's subtree recomposed"
673        );
674        assert_eq!(
675            global(&ctx, child_b),
676            IDENTITY4,
677            "the other tree was never walked"
678        );
679        assert_eq!(global(&ctx, root_b), translate(3.0).model_matrix());
680    }
681
682    // An ancestor and one of its descendants dirtied in the same frame: the
683    // descendant must end up composed against the ancestor's NEW world matrix,
684    // not the stale one it would read if the walks ran deepest-first.
685    #[test]
686    fn a_dirty_ancestor_and_descendant_resolve_against_the_new_parent() {
687        let mut world = TestWorld::new();
688        let mut ctx = world.ctx();
689        let links = chain(&mut ctx, 3);
690
691        let mut cache = TransformCache::default();
692        propagate_transforms_cached(&mut ctx, &mut cache);
693
694        // Dirty the deepest link first, so column order alone would resolve it
695        // before its ancestor.
696        *ctx.get_mut::<Transform>(links[2]).unwrap() = translate(7.0);
697        *ctx.get_mut::<Transform>(links[0]).unwrap() = translate(9.0);
698        propagate_transforms_cached(&mut ctx, &mut cache);
699
700        let root = translate(9.0).model_matrix();
701        let mid = mat4_mul(root, translate(2.0).model_matrix());
702        assert_eq!(global(&ctx, links[0]), root);
703        assert_eq!(global(&ctx, links[1]), mid);
704        assert_eq!(
705            global(&ctx, links[2]),
706            mat4_mul(mid, translate(7.0).model_matrix())
707        );
708    }
709
710    // Adding an entity moves the Transform column's structural tick, which the
711    // per-row stamps cannot describe, so the pass falls back to a full resolve
712    // and the new entity is picked up.
713    #[test]
714    fn a_spawned_entity_forces_a_full_resolve() {
715        let mut world = TestWorld::new();
716        let mut ctx = world.ctx();
717        let root = spawn(&mut ctx, translate(1.0), None);
718
719        let mut cache = TransformCache::default();
720        propagate_transforms_cached(&mut ctx, &mut cache);
721
722        let late = spawn(&mut ctx, translate(5.0), Some(root));
723        propagate_transforms_cached(&mut ctx, &mut cache);
724
725        assert_eq!(
726            global(&ctx, late),
727            mat4_mul(translate(1.0).model_matrix(), translate(5.0).model_matrix()),
728            "the entity added after the last resolve composed correctly"
729        );
730    }
731
732    // Despawning likewise moves the structural tick, and the swap-remove that
733    // frees the row must not leave the survivors pointing at the wrong slots.
734    #[test]
735    fn a_despawned_entity_leaves_the_survivors_correct() {
736        let mut world = TestWorld::new();
737        let mut ctx = world.ctx();
738        let root = spawn(&mut ctx, translate(1.0), None);
739        let doomed = spawn(&mut ctx, translate(2.0), Some(root));
740        let kept = spawn(&mut ctx, translate(3.0), Some(root));
741
742        let mut cache = TransformCache::default();
743        propagate_transforms_cached(&mut ctx, &mut cache);
744
745        ctx.despawn(doomed);
746        *ctx.get_mut::<Transform>(root).unwrap() = translate(20.0);
747        propagate_transforms_cached(&mut ctx, &mut cache);
748
749        assert_eq!(
750            global(&ctx, kept),
751            mat4_mul(
752                translate(20.0).model_matrix(),
753                translate(3.0).model_matrix()
754            )
755        );
756    }
757
758    // A whole-column write leaves no per-row stamps to read, so the pass must
759    // fall back and pick up every entity's new value.
760    #[test]
761    fn a_whole_column_write_falls_back_to_a_full_resolve() {
762        let mut world = TestWorld::new();
763        let mut ctx = world.ctx();
764        let root = spawn(&mut ctx, translate(1.0), None);
765        let child = spawn(&mut ctx, translate(2.0), Some(root));
766
767        let mut cache = TransformCache::default();
768        propagate_transforms_cached(&mut ctx, &mut cache);
769
770        for t in ctx.query_mut::<Transform>() {
771            t.position[1] += 3.0;
772        }
773        propagate_transforms_cached(&mut ctx, &mut cache);
774
775        let shifted = |x: f32| Transform {
776            position: [x, 3.0, 0.0],
777            rotation_deg: [0.0; 3],
778            scale: [1.0; 3],
779        };
780        assert_eq!(global(&ctx, root), shifted(1.0).model_matrix());
781        assert_eq!(
782            global(&ctx, child),
783            mat4_mul(shifted(1.0).model_matrix(), shifted(2.0).model_matrix())
784        );
785    }
786
787    // Enough entities dirtied to blow the budget: the pass gives up on the
788    // subtree walks and full-resolves, which must reach every one of them.
789    #[test]
790    fn a_dirty_set_past_the_budget_falls_back_and_still_resolves() {
791        let mut world = TestWorld::new();
792        let mut ctx = world.ctx();
793        let entities: Vec<Entity> = (0..16)
794            .map(|i| spawn(&mut ctx, translate(i as f32), None))
795            .collect();
796
797        let mut cache = TransformCache::default();
798        propagate_transforms_cached(&mut ctx, &mut cache);
799
800        // 16 / DIRTY_BUDGET_DIVISOR is 2, so moving half is well past it.
801        for (i, &e) in entities.iter().enumerate().take(8) {
802            *ctx.get_mut::<Transform>(e).unwrap() = translate(100.0 + i as f32);
803        }
804        propagate_transforms_cached(&mut ctx, &mut cache);
805
806        for (i, &e) in entities.iter().enumerate() {
807            let expected = if i < 8 {
808                translate(100.0 + i as f32)
809            } else {
810                translate(i as f32)
811            };
812            assert_eq!(global(&ctx, e), expected.model_matrix(), "entity {i}");
813        }
814    }
815
816    // A Parent naming an entity that owns no Transform has nothing to compose
817    // against, so the child resolves as a root.
818    #[test]
819    fn a_parent_without_a_transform_leaves_the_child_a_root() {
820        let mut world = TestWorld::new();
821        let mut ctx = world.ctx();
822        let bare = ctx.components.spawn();
823        ctx.insert(bare, Children(concinnity_memory::InlineVec::new()));
824        let child = spawn(&mut ctx, translate(4.0), Some(bare));
825
826        propagate_transforms(&mut ctx);
827        assert_eq!(global(&ctx, child), translate(4.0).model_matrix());
828    }
829
830    // resolve_world_matrices breaks a parent cycle: mutually-parented entities
831    // fall back to their own local matrix rather than looping forever.
832    #[test]
833    fn resolve_world_matrices_breaks_parent_cycle() {
834        let mut world = TestWorld::new();
835        let mut ctx = world.ctx();
836        let a_t = translate(1.0);
837        let b_t = Transform {
838            position: [0.0, 2.0, 0.0],
839            rotation_deg: [0.0; 3],
840            scale: [1.0; 3],
841        };
842
843        // a parents b and b parents a: a cycle with no root.
844        let a = ctx.components.spawn();
845        ctx.insert(a, a_t);
846        let b = ctx.components.spawn();
847        ctx.insert(b, b_t);
848        ctx.insert(a, Parent(b));
849        ctx.insert(b, Parent(a));
850
851        let resolved = resolve_world_matrices(&ctx);
852        assert_eq!(resolved.len(), 2);
853        // Neither resolved through the chain, so each keeps its own local matrix.
854        assert_eq!(resolved.get(&a).copied(), Some(a_t.model_matrix()));
855        assert_eq!(resolved.get(&b).copied(), Some(b_t.model_matrix()));
856    }
857
858    // An entity hanging off a cycle inherits the fallback: with no resolvable
859    // ancestor world matrix it keeps its own local rather than composing
860    // against a matrix that was never resolved.
861    #[test]
862    fn an_entity_below_a_cycle_falls_back_to_its_local() {
863        let mut world = TestWorld::new();
864        let mut ctx = world.ctx();
865        let a = spawn(&mut ctx, translate(1.0), None);
866        let b = spawn(&mut ctx, translate(2.0), Some(a));
867        ctx.insert(a, Parent(b));
868        let below = spawn(&mut ctx, translate(3.0), Some(b));
869
870        propagate_transforms(&mut ctx);
871
872        assert_eq!(global(&ctx, a), translate(1.0).model_matrix());
873        assert_eq!(global(&ctx, b), translate(2.0).model_matrix());
874        assert_eq!(global(&ctx, below), translate(3.0).model_matrix());
875    }
876
877    // A hierarchy deeper than any recursion budget: the depth walk and the
878    // resolve are both iterative, so this composes rather than overflowing.
879    // Each link steps a single unit, so every partial sum along the chain is an
880    // exact f32 and the deepest world matrix can be asserted outright.
881    #[test]
882    fn a_very_deep_chain_resolves_iteratively() {
883        const DEPTH: usize = 20_000;
884
885        let mut world = TestWorld::new();
886        let mut ctx = world.ctx();
887        let mut links = Vec::with_capacity(DEPTH);
888        let mut parent = None;
889        for _ in 0..DEPTH {
890            let entity = spawn(&mut ctx, translate(1.0), parent);
891            links.push(entity);
892            parent = Some(entity);
893        }
894
895        propagate_transforms(&mut ctx);
896
897        assert_eq!(global(&ctx, links[DEPTH - 1])[3][0], DEPTH as f32);
898    }
899
900    #[test]
901    fn reparent_recomposes_child_world_matrix_and_relists() {
902        let (a_t, b_t, child_t) = (translate(10.0), translate(-5.0), translate(1.0));
903
904        let mut world = TestWorld::new();
905        let mut ctx = world.ctx();
906        let a = spawn(&mut ctx, a_t, None);
907        let b = spawn(&mut ctx, b_t, None);
908        let child = spawn(&mut ctx, child_t, None);
909
910        // Attach under A: the child's world matrix composes A x local, and A
911        // lists it.
912        reparent(&mut ctx, child, Some(a));
913        let under_a = global(&ctx, child);
914        assert_eq!(
915            under_a,
916            mat4_mul(a_t.model_matrix(), child_t.model_matrix())
917        );
918        assert_eq!(ctx.get::<Children>(a).unwrap().0, vec![child]);
919
920        // Move under B: world matrix recomposes against B, A unlists it.
921        reparent(&mut ctx, child, Some(b));
922        let under_b = global(&ctx, child);
923        assert_eq!(
924            under_b,
925            mat4_mul(b_t.model_matrix(), child_t.model_matrix())
926        );
927        assert_ne!(under_a, under_b, "the child actually moved");
928        assert!(
929            ctx.get::<Children>(a).unwrap().0.is_empty(),
930            "A unlisted the child"
931        );
932        assert_eq!(ctx.get::<Children>(b).unwrap().0, vec![child]);
933        assert_eq!(ctx.get::<Parent>(child).unwrap().0, b);
934
935        // Detach to a root: no Parent, world matrix is its own local.
936        reparent(&mut ctx, child, None);
937        assert_eq!(global(&ctx, child), child_t.model_matrix());
938        assert!(ctx.get::<Parent>(child).is_none(), "child is now a root");
939        assert!(
940            ctx.get::<Children>(b).unwrap().0.is_empty(),
941            "B unlisted the child"
942        );
943    }
944}