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}