Skip to main content

khora_data/ecs/systems/
capture_previous_transform.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//! Snapshots each simulated body's world-space transform into the engine's
16//! [`TransformInterpolation`] store so the render path can interpolate between
17//! the prior and current [`GlobalTransform`] for smooth motion at any frame
18//! rate.
19//!
20//! Runs in [`TickPhase::PostSimulation`] **before** `transform_propagation`
21//! (lower `order_hint`): it captures the still-current `GlobalTransform` — the
22//! value produced by the previous frame's propagation — *before* propagation
23//! overwrites it with this frame's simulated pose. Render then blends
24//! `previous → current` by the `Time::interpolation_alpha`.
25//!
26//! The snapshots live in [`Runtime::resources`](khora_core::Runtime), NOT in
27//! the ECS: interpolation is a render-only representation ("adapt the HOW,
28//! never the WHAT"), so it carries no game meaning and must not appear in the
29//! editor inspector or scene files. Only simulated entities (those carrying a
30//! [`RigidBody`]) are snapshotted; everything else renders at its current
31//! transform.
32
33use std::collections::HashSet;
34
35use khora_core::ecs::entity::EntityId;
36use khora_core::interpolation::{SharedTransformInterpolation, TransformInterpolation};
37
38use crate::ecs::{DataSystemRegistration, GlobalTransform, RigidBody, TickPhase, World};
39
40fn capture_previous_transform(world: &World, store: &mut TransformInterpolation) {
41    // Record the current world-space transform of every simulated body and
42    // track which ids are still live so despawned/no-longer-simulated entries
43    // can be pruned in the same pass (bounds memory, avoids stale generations).
44    let mut live: HashSet<EntityId> = HashSet::new();
45    for (id, gt) in world.query::<(EntityId, &GlobalTransform)>() {
46        if world.get::<RigidBody>(id).is_some() {
47            store.record(id, gt.0);
48            live.insert(id);
49        }
50    }
51    store.retain_live(&live);
52}
53
54/// Wrapper matching the `DataSystemRegistration::run` signature. Resolves the
55/// shared interpolation store from the runtime; a no-op when it isn't
56/// registered (e.g. headless data-layer tests that don't render).
57fn capture_previous_transform_entry(
58    world: &mut World,
59    runtime: &khora_core::Runtime,
60    _deck: &mut khora_core::lane::OutputDeck,
61) {
62    let Some(shared) = runtime.resources.get::<SharedTransformInterpolation>() else {
63        return;
64    };
65    let mut store = match shared.write() {
66        Ok(s) => s,
67        Err(_) => {
68            log::error!("capture_previous_transform: interpolation store lock poisoned");
69            return;
70        }
71    };
72    capture_previous_transform(world, &mut store);
73}
74
75inventory::submit! {
76    DataSystemRegistration {
77        name: "capture_previous_transform",
78        phase: TickPhase::PostSimulation,
79        run: capture_previous_transform_entry,
80        // Must run before transform_propagation (order_hint 0) so it reads the
81        // GlobalTransform from the *previous* frame's propagation.
82        order_hint: -10,
83        runs_after: &[],
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::ecs::{GlobalTransform, RigidBody, SemanticDomain, Transform, World};
91    use khora_core::math::Vec3;
92
93    fn register(world: &mut World) {
94        world.register_component::<Transform>(SemanticDomain::Spatial);
95        world.register_component::<GlobalTransform>(SemanticDomain::Spatial);
96        world.register_component::<RigidBody>(SemanticDomain::Physics);
97    }
98
99    #[test]
100    fn records_previous_for_rigid_body() {
101        let mut world = World::default();
102        register(&mut world);
103        let mut store = TransformInterpolation::new();
104
105        let body = world.spawn((
106            GlobalTransform::at_position(Vec3::new(1.0, 2.0, 3.0)),
107            RigidBody::default(),
108        ));
109
110        assert!(store.previous(body).is_none());
111        capture_previous_transform(&world, &mut store);
112
113        assert_eq!(
114            store.previous(body).map(|t| t.translation()),
115            Some(Vec3::new(1.0, 2.0, 3.0)),
116            "a simulated body's transform should be snapshotted"
117        );
118    }
119
120    #[test]
121    fn updates_previous_to_current_each_pass() {
122        let mut world = World::default();
123        register(&mut world);
124        let mut store = TransformInterpolation::new();
125
126        let body = world.spawn((
127            GlobalTransform::at_position(Vec3::new(0.0, 0.0, 0.0)),
128            RigidBody::default(),
129        ));
130        capture_previous_transform(&world, &mut store);
131
132        // Move the body, then capture again: the snapshot mirrors the (now
133        // current) GlobalTransform that propagation will overwrite next.
134        if let Some(gt) = world.get_mut::<GlobalTransform>(body) {
135            *gt = GlobalTransform::at_position(Vec3::new(5.0, 0.0, 0.0));
136        }
137        capture_previous_transform(&world, &mut store);
138
139        assert_eq!(
140            store.previous(body).map(|t| t.translation()),
141            Some(Vec3::new(5.0, 0.0, 0.0))
142        );
143    }
144
145    #[test]
146    fn static_entity_without_rigid_body_is_not_snapshotted() {
147        let mut world = World::default();
148        register(&mut world);
149        let mut store = TransformInterpolation::new();
150
151        let stat = world.spawn(GlobalTransform::at_position(Vec3::new(1.0, 1.0, 1.0)));
152        capture_previous_transform(&world, &mut store);
153
154        assert!(
155            store.previous(stat).is_none(),
156            "a static entity must not be snapshotted"
157        );
158    }
159
160    #[test]
161    fn despawned_body_is_pruned_next_pass() {
162        let mut world = World::default();
163        register(&mut world);
164        let mut store = TransformInterpolation::new();
165
166        let body = world.spawn((
167            GlobalTransform::at_position(Vec3::new(2.0, 0.0, 0.0)),
168            RigidBody::default(),
169        ));
170        capture_previous_transform(&world, &mut store);
171        assert!(store.previous(body).is_some());
172
173        world.despawn(body);
174        capture_previous_transform(&world, &mut store);
175        assert!(
176            store.previous(body).is_none(),
177            "a despawned body's snapshot must be pruned"
178        );
179        assert!(store.is_empty());
180    }
181}