Skip to main content

viewport_lib/camera/
track.rs

1//! Keyframe camera animation with Catmull-Rom interpolation.
2
3use crate::camera::camera::{Camera, CameraTarget};
4
5// ---------------------------------------------------------------------------
6// CameraTrack
7// ---------------------------------------------------------------------------
8
9/// A time-sorted sequence of camera keyframes for animation.
10///
11/// Build the track with [`push`](Self::push), then call
12/// [`interpolate_camera`] each frame to get a smoothly interpolated
13/// [`CameraTarget`] that you can apply to a [`Camera`].
14///
15/// Center and distance are interpolated with a Catmull-Rom spline.
16/// Orientation is interpolated with spherical linear interpolation (slerp)
17/// between adjacent keyframes.
18#[derive(Clone, Debug, Default)]
19pub struct CameraTrack {
20    /// Time-sorted keyframes: `(time_seconds, target)`.
21    keyframes: Vec<(f64, CameraTarget)>,
22}
23
24impl CameraTrack {
25    /// Create an empty track.
26    pub fn new() -> Self {
27        Self::default()
28    }
29
30    /// Create a track from a pre-built list of `(time, target)` pairs.
31    ///
32    /// The list is sorted by time; duplicate times are kept (the later one wins
33    /// during interpolation).
34    pub fn from_keyframes(mut keyframes: Vec<(f64, CameraTarget)>) -> Self {
35        keyframes.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
36        Self { keyframes }
37    }
38
39    /// Append a keyframe at `time` seconds, keeping the list sorted.
40    pub fn push(&mut self, time: f64, target: CameraTarget) {
41        let pos = self
42            .keyframes
43            .partition_point(|(t, _)| *t <= time);
44        self.keyframes.insert(pos, (time, target));
45    }
46
47    /// Return a slice of all keyframes in time order.
48    pub fn keyframes(&self) -> &[(f64, CameraTarget)] {
49        &self.keyframes
50    }
51
52    /// Duration from the first to the last keyframe, or `0.0` if empty.
53    pub fn duration(&self) -> f64 {
54        match (self.keyframes.first(), self.keyframes.last()) {
55            (Some(first), Some(last)) => (last.0 - first.0).max(0.0),
56            _ => 0.0,
57        }
58    }
59
60    /// Return `true` if the track has no keyframes.
61    pub fn is_empty(&self) -> bool {
62        self.keyframes.is_empty()
63    }
64
65    /// Number of keyframes.
66    pub fn len(&self) -> usize {
67        self.keyframes.len()
68    }
69}
70
71// ---------------------------------------------------------------------------
72// Interpolation
73// ---------------------------------------------------------------------------
74
75/// Interpolate a [`CameraTarget`] from a [`CameraTrack`] at time `t`.
76///
77/// - Center and distance use Catmull-Rom spline interpolation.
78/// - Orientation uses spherical linear interpolation between the two
79///   surrounding keyframes.
80///
81/// If the track is empty a default `CameraTarget` is returned. If `t` is
82/// before the first keyframe, the first keyframe is returned; if after the
83/// last, the last keyframe is returned.
84pub fn interpolate_camera(track: &CameraTrack, t: f64) -> CameraTarget {
85    let kfs = track.keyframes();
86
87    match kfs.len() {
88        0 => CameraTarget {
89            center: glam::Vec3::ZERO,
90            distance: 5.0,
91            orientation: glam::Quat::IDENTITY,
92        },
93        1 => kfs[0].1,
94        _ => {
95            // Clamp to track range.
96            if t <= kfs[0].0 {
97                return kfs[0].1;
98            }
99            if t >= kfs[kfs.len() - 1].0 {
100                return kfs[kfs.len() - 1].1;
101            }
102
103            // Find segment index i such that kfs[i].time <= t < kfs[i+1].time.
104            let i = kfs.partition_point(|(kt, _)| *kt <= t).saturating_sub(1);
105            let i = i.min(kfs.len() - 2);
106
107            let t0 = kfs[i].0;
108            let t1 = kfs[i + 1].0;
109            let s = if (t1 - t0).abs() < 1e-12 {
110                0.0_f32
111            } else {
112                ((t - t0) / (t1 - t0)) as f32
113            };
114
115            // Gather 4 control points (with phantom endpoints at boundaries).
116            let p1 = kfs[i].1;
117            let p2 = kfs[i + 1].1;
118            let p0 = if i > 0 {
119                kfs[i - 1].1
120            } else {
121                // Phantom: reflect p2 through p1.
122                CameraTarget {
123                    center: p1.center * 2.0 - p2.center,
124                    distance: p1.distance * 2.0 - p2.distance,
125                    orientation: p1.orientation, // kept simple for boundary
126                }
127            };
128            let p3 = if i + 2 < kfs.len() {
129                kfs[i + 2].1
130            } else {
131                // Phantom: reflect p1 through p2.
132                CameraTarget {
133                    center: p2.center * 2.0 - p1.center,
134                    distance: p2.distance * 2.0 - p1.distance,
135                    orientation: p2.orientation,
136                }
137            };
138
139            CameraTarget {
140                center: catmull_rom_vec3(p0.center, p1.center, p2.center, p3.center, s),
141                distance: catmull_rom_f32(p0.distance, p1.distance, p2.distance, p3.distance, s)
142                    .max(Camera::MIN_DISTANCE),
143                orientation: p1.orientation.slerp(p2.orientation, s).normalize(),
144            }
145        }
146    }
147}
148
149// ---------------------------------------------------------------------------
150// Helpers
151// ---------------------------------------------------------------------------
152
153fn catmull_rom_f32(p0: f32, p1: f32, p2: f32, p3: f32, s: f32) -> f32 {
154    let s2 = s * s;
155    let s3 = s2 * s;
156    0.5 * (2.0 * p1
157        + (-p0 + p2) * s
158        + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * s2
159        + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * s3)
160}
161
162fn catmull_rom_vec3(
163    p0: glam::Vec3,
164    p1: glam::Vec3,
165    p2: glam::Vec3,
166    p3: glam::Vec3,
167    s: f32,
168) -> glam::Vec3 {
169    glam::Vec3::new(
170        catmull_rom_f32(p0.x, p1.x, p2.x, p3.x, s),
171        catmull_rom_f32(p0.y, p1.y, p2.y, p3.y, s),
172        catmull_rom_f32(p0.z, p1.z, p2.z, p3.z, s),
173    )
174}
175
176// ---------------------------------------------------------------------------
177// Tests
178// ---------------------------------------------------------------------------
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    fn target(x: f32, d: f32) -> CameraTarget {
185        CameraTarget {
186            center: glam::Vec3::new(x, 0.0, 0.0),
187            distance: d,
188            orientation: glam::Quat::IDENTITY,
189        }
190    }
191
192    #[test]
193    fn test_empty_track_returns_default() {
194        let track = CameraTrack::new();
195        let t = interpolate_camera(&track, 0.0);
196        assert_eq!(t.distance, 5.0);
197    }
198
199    #[test]
200    fn test_single_keyframe() {
201        let mut track = CameraTrack::new();
202        track.push(0.0, target(3.0, 7.0));
203        let t = interpolate_camera(&track, 5.0);
204        assert!((t.center.x - 3.0).abs() < 1e-5);
205        assert!((t.distance - 7.0).abs() < 1e-5);
206    }
207
208    #[test]
209    fn test_clamp_before_start() {
210        let mut track = CameraTrack::new();
211        track.push(1.0, target(1.0, 1.0));
212        track.push(2.0, target(2.0, 2.0));
213        let t = interpolate_camera(&track, 0.0);
214        assert!((t.center.x - 1.0).abs() < 1e-5);
215    }
216
217    #[test]
218    fn test_clamp_after_end() {
219        let mut track = CameraTrack::new();
220        track.push(1.0, target(1.0, 1.0));
221        track.push(2.0, target(2.0, 2.0));
222        let t = interpolate_camera(&track, 5.0);
223        assert!((t.center.x - 2.0).abs() < 1e-5);
224    }
225
226    #[test]
227    fn test_midpoint_two_keyframes() {
228        let mut track = CameraTrack::new();
229        track.push(0.0, target(0.0, 4.0));
230        track.push(1.0, target(2.0, 8.0));
231        // At exactly t=0.5, Catmull-Rom with phantom endpoints on a linear
232        // sequence should give the midpoint.
233        let t = interpolate_camera(&track, 0.5);
234        assert!((t.center.x - 1.0).abs() < 0.05, "center.x={}", t.center.x);
235        assert!((t.distance - 6.0).abs() < 0.1, "distance={}", t.distance);
236    }
237
238    #[test]
239    fn test_keyframe_hit_exact() {
240        let mut track = CameraTrack::new();
241        track.push(0.0, target(0.0, 1.0));
242        track.push(1.0, target(5.0, 3.0));
243        track.push(2.0, target(10.0, 5.0));
244        // At t=1.0 exactly we should be at the second keyframe.
245        let t = interpolate_camera(&track, 1.0);
246        assert!((t.center.x - 5.0).abs() < 1e-4, "center.x={}", t.center.x);
247        assert!((t.distance - 3.0).abs() < 1e-4, "distance={}", t.distance);
248    }
249
250    #[test]
251    fn test_from_keyframes_sorts() {
252        let kfs = vec![
253            (2.0_f64, target(2.0, 2.0)),
254            (0.0_f64, target(0.0, 0.0)),
255            (1.0_f64, target(1.0, 1.0)),
256        ];
257        let track = CameraTrack::from_keyframes(kfs);
258        assert_eq!(track.keyframes()[0].0, 0.0);
259        assert_eq!(track.keyframes()[1].0, 1.0);
260        assert_eq!(track.keyframes()[2].0, 2.0);
261    }
262
263    #[test]
264    fn test_duration() {
265        let mut track = CameraTrack::new();
266        assert_eq!(track.duration(), 0.0);
267        track.push(1.0, target(0.0, 1.0));
268        track.push(4.0, target(1.0, 2.0));
269        assert!((track.duration() - 3.0).abs() < 1e-10);
270    }
271
272    #[test]
273    fn test_push_keeps_sorted() {
274        let mut track = CameraTrack::new();
275        track.push(3.0, target(3.0, 3.0));
276        track.push(1.0, target(1.0, 1.0));
277        track.push(2.0, target(2.0, 2.0));
278        assert_eq!(track.keyframes()[0].0, 1.0);
279        assert_eq!(track.keyframes()[1].0, 2.0);
280        assert_eq!(track.keyframes()[2].0, 3.0);
281    }
282}