Skip to main content

khora_core/interpolation/
mod.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//! Render-interpolation bookkeeping — the per-entity "previous pose" store.
16//!
17//! The fixed-timestep simulation advances in whole steps while rendering
18//! happens once per (variable-rate) frame. To stay smooth between steps, the
19//! render path blends each simulated entity's transform *as it was before the
20//! latest step* with its current [`GlobalTransform`] by the
21//! [`Time::interpolation_alpha`](crate::time::Time::interpolation_alpha).
22//!
23//! Those "previous" poses live HERE, not in the ECS, on purpose: interpolation
24//! is a render-only representation ("adapt the HOW, never the WHAT"). Keeping it
25//! out of the component space means it never shows up in the editor inspector
26//! and is never written into scene files — it carries no game meaning. The store
27//! lives in [`Runtime::resources`](crate::Runtime) behind an `Arc<RwLock<…>>`,
28//! mirroring the `CollisionPairs` broadphase scratch: engine-owned per-entity
29//! data, keyed by [`EntityId`], that no gameplay code authors.
30
31use std::collections::{HashMap, HashSet};
32use std::sync::{Arc, RwLock};
33
34use crate::ecs::entity::EntityId;
35use crate::math::affine_transform::AffineTransform;
36
37/// Per-entity snapshots of the world-space transform *before* the latest
38/// simulation step, consumed by the render path for motion interpolation.
39///
40/// Engine-internal: populated by the data layer's capture pass for simulated
41/// bodies and read by the render projection. Not an ECS component (see the
42/// module docs) — it is neither inspectable nor serialized.
43#[derive(Debug, Default)]
44pub struct TransformInterpolation {
45    previous: HashMap<EntityId, AffineTransform>,
46}
47
48impl TransformInterpolation {
49    /// Creates an empty store.
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// The pre-step world-space transform recorded for `entity`, if the entity
55    /// is currently simulated. `None` means "render at the current transform"
56    /// (static entity, or first frame before any snapshot).
57    pub fn previous(&self, entity: EntityId) -> Option<AffineTransform> {
58        self.previous.get(&entity).copied()
59    }
60
61    /// Records `entity`'s current world-space transform as the pose to
62    /// interpolate *from* on the next render.
63    pub fn record(&mut self, entity: EntityId, transform: AffineTransform) {
64        self.previous.insert(entity, transform);
65    }
66
67    /// Drops snapshots for entities absent from `live` — despawned bodies, or
68    /// entities the simulation no longer moves. Bounds memory and prevents a
69    /// recycled [`EntityId`] from reading a stale generation's pose.
70    pub fn retain_live(&mut self, live: &HashSet<EntityId>) {
71        self.previous.retain(|id, _| live.contains(id));
72    }
73
74    /// Number of entities with a recorded previous pose.
75    pub fn len(&self) -> usize {
76        self.previous.len()
77    }
78
79    /// Whether no previous pose is recorded.
80    pub fn is_empty(&self) -> bool {
81        self.previous.is_empty()
82    }
83}
84
85/// Shared, interior-mutable handle to the [`TransformInterpolation`] store as it
86/// lives in [`Runtime::resources`](crate::Runtime): the data layer's capture
87/// pass takes the write lock once per frame; the render projection takes a read
88/// lock while it projects.
89pub type SharedTransformInterpolation = Arc<RwLock<TransformInterpolation>>;
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::math::Vec3;
95
96    fn entity(index: u32) -> EntityId {
97        EntityId {
98            index,
99            generation: 0,
100        }
101    }
102
103    fn at(x: f32) -> AffineTransform {
104        AffineTransform(crate::math::Mat4::from_translation(Vec3::new(x, 0.0, 0.0)))
105    }
106
107    #[test]
108    fn record_then_read_round_trips() {
109        let mut store = TransformInterpolation::new();
110        assert!(store.previous(entity(0)).is_none());
111        store.record(entity(0), at(3.0));
112        assert_eq!(
113            store.previous(entity(0)).map(|t| t.translation()),
114            Some(Vec3::new(3.0, 0.0, 0.0))
115        );
116    }
117
118    #[test]
119    fn record_overwrites_previous_value() {
120        let mut store = TransformInterpolation::new();
121        store.record(entity(1), at(1.0));
122        store.record(entity(1), at(2.0));
123        assert_eq!(
124            store.previous(entity(1)).map(|t| t.translation()),
125            Some(Vec3::new(2.0, 0.0, 0.0))
126        );
127        assert_eq!(store.len(), 1);
128    }
129
130    #[test]
131    fn retain_live_drops_absent_entities() {
132        let mut store = TransformInterpolation::new();
133        store.record(entity(0), at(0.0));
134        store.record(entity(1), at(1.0));
135        store.record(entity(2), at(2.0));
136
137        let live: HashSet<EntityId> = [entity(0), entity(2)].into_iter().collect();
138        store.retain_live(&live);
139
140        assert!(store.previous(entity(0)).is_some());
141        assert!(store.previous(entity(1)).is_none(), "despawned id pruned");
142        assert!(store.previous(entity(2)).is_some());
143        assert_eq!(store.len(), 2);
144    }
145
146    #[test]
147    fn empty_live_set_clears_the_store() {
148        let mut store = TransformInterpolation::new();
149        store.record(entity(0), at(0.0));
150        store.retain_live(&HashSet::new());
151        assert!(store.is_empty());
152    }
153}