Skip to main content

viewport_lib/camera/
animator.rs

1//! Smooth camera motion with exponential damping and fly-to animations.
2//!
3//! `CameraAnimator` does NOT own the [`Camera`]. It takes `&mut Camera` in
4//! [`update()`](crate::camera::animator::CameraAnimator::update) so the application decides whether to
5//! use animation at all.
6
7use crate::camera::camera::{Camera, Projection};
8
9/// Easing function for fly-to animations.
10#[derive(Clone, Copy, Debug, PartialEq)]
11#[non_exhaustive]
12pub enum Easing {
13    /// Constant velocity (t).
14    Linear,
15    /// Decelerates near the end — feels natural for camera snapping.
16    EaseOutCubic,
17    /// Accelerates then decelerates — smooth for longer transitions.
18    EaseInOutCubic,
19}
20
21impl Easing {
22    /// Evaluate the easing curve at `t` (clamped to 0..1).
23    pub fn eval(self, t: f32) -> f32 {
24        let t = t.clamp(0.0, 1.0);
25        match self {
26            Self::Linear => t,
27            Self::EaseOutCubic => {
28                let inv = 1.0 - t;
29                1.0 - inv * inv * inv
30            }
31            Self::EaseInOutCubic => {
32                if t < 0.5 {
33                    4.0 * t * t * t
34                } else {
35                    let p = -2.0 * t + 2.0;
36                    1.0 - p * p * p / 2.0
37                }
38            }
39        }
40    }
41}
42
43/// Damping coefficients for camera motion channels.
44///
45/// Each value is in the range `0.0..1.0` where 0 = no damping (instant) and
46/// 0.85 = smooth deceleration. Values are frame-rate independent via
47/// `damping.powf(dt * 60.0)`.
48#[derive(Clone, Debug)]
49pub struct CameraDamping {
50    /// Orbit (rotation) damping factor.
51    pub orbit: f32,
52    /// Pan (translation) damping factor.
53    pub pan: f32,
54    /// Zoom (distance) damping factor.
55    pub zoom: f32,
56    /// Velocity magnitude below which motion stops.
57    pub epsilon: f32,
58}
59
60impl Default for CameraDamping {
61    fn default() -> Self {
62        Self {
63            orbit: 0.85,
64            pan: 0.85,
65            zoom: 0.85,
66            epsilon: 0.0001,
67        }
68    }
69}
70
71/// An in-progress fly-to animation.
72#[derive(Clone, Debug)]
73struct CameraFlight {
74    start_center: glam::Vec3,
75    start_distance: f32,
76    start_orientation: glam::Quat,
77    #[allow(dead_code)]
78    start_projection: Projection,
79    target_center: glam::Vec3,
80    target_distance: f32,
81    target_orientation: glam::Quat,
82    target_projection: Option<Projection>,
83    duration: f32,
84    elapsed: f32,
85    easing: Easing,
86}
87
88/// Smooth camera controller with exponential damping and animated transitions.
89///
90/// Feed raw input deltas via [`apply_orbit`](Self::apply_orbit),
91/// [`apply_pan`](Self::apply_pan), [`apply_zoom`](Self::apply_zoom). Then call
92/// [`update`](Self::update) once per frame to apply decayed velocities to the
93/// camera.
94///
95/// For animated transitions (view presets, zoom-to-fit), use
96/// [`fly_to`](Self::fly_to). Any raw input during a flight cancels it.
97pub struct CameraAnimator {
98    damping: CameraDamping,
99    /// (yaw_rate, pitch_rate) in radians.
100    orbit_velocity: glam::Vec2,
101    /// (right, up) in world units.
102    pan_velocity: glam::Vec2,
103    /// Distance change rate.
104    zoom_velocity: f32,
105    /// Active fly-to animation, if any.
106    flight: Option<CameraFlight>,
107}
108
109impl CameraAnimator {
110    /// Create an animator with custom damping.
111    pub fn new(damping: CameraDamping) -> Self {
112        Self {
113            damping,
114            orbit_velocity: glam::Vec2::ZERO,
115            pan_velocity: glam::Vec2::ZERO,
116            zoom_velocity: 0.0,
117            flight: None,
118        }
119    }
120
121    /// Create an animator with sensible default damping values.
122    pub fn with_default_damping() -> Self {
123        Self::new(CameraDamping::default())
124    }
125
126    /// Feed a raw orbit delta (yaw, pitch) in radians.
127    pub fn apply_orbit(&mut self, yaw_delta: f32, pitch_delta: f32) {
128        if self.flight.is_some() {
129            self.flight = None;
130        }
131        self.orbit_velocity += glam::Vec2::new(yaw_delta, pitch_delta);
132    }
133
134    /// Feed a raw pan delta (right, up) in world units.
135    pub fn apply_pan(&mut self, right_delta: f32, up_delta: f32) {
136        if self.flight.is_some() {
137            self.flight = None;
138        }
139        self.pan_velocity += glam::Vec2::new(right_delta, up_delta);
140    }
141
142    /// Feed a raw zoom delta (distance change).
143    pub fn apply_zoom(&mut self, delta: f32) {
144        if self.flight.is_some() {
145            self.flight = None;
146        }
147        self.zoom_velocity += delta;
148    }
149
150    /// Start an animated fly-to transition with default easing.
151    pub fn fly_to(
152        &mut self,
153        camera: &Camera,
154        target_center: glam::Vec3,
155        target_distance: f32,
156        target_orientation: glam::Quat,
157        duration: f32,
158    ) {
159        self.fly_to_full(
160            camera,
161            target_center,
162            target_distance,
163            target_orientation,
164            None,
165            duration,
166            Easing::EaseOutCubic,
167        );
168    }
169
170    /// Start an animated fly-to transition with custom easing and optional projection change.
171    pub fn fly_to_with_easing(
172        &mut self,
173        camera: &Camera,
174        target_center: glam::Vec3,
175        target_distance: f32,
176        target_orientation: glam::Quat,
177        duration: f32,
178        easing: Easing,
179    ) {
180        self.fly_to_full(
181            camera,
182            target_center,
183            target_distance,
184            target_orientation,
185            None,
186            duration,
187            easing,
188        );
189    }
190
191    /// Start a fly-to transition with all options.
192    #[allow(clippy::too_many_arguments)]
193    pub fn fly_to_full(
194        &mut self,
195        camera: &Camera,
196        target_center: glam::Vec3,
197        target_distance: f32,
198        target_orientation: glam::Quat,
199        target_projection: Option<Projection>,
200        duration: f32,
201        easing: Easing,
202    ) {
203        // Zero out velocities — the flight takes over.
204        self.orbit_velocity = glam::Vec2::ZERO;
205        self.pan_velocity = glam::Vec2::ZERO;
206        self.zoom_velocity = 0.0;
207
208        self.flight = Some(CameraFlight {
209            start_center: camera.center(),
210            start_distance: camera.distance(),
211            start_orientation: camera.orientation(),
212            start_projection: camera.projection,
213            target_center,
214            target_distance,
215            target_orientation,
216            target_projection,
217            duration: duration.max(0.001),
218            elapsed: 0.0,
219            easing,
220        });
221    }
222
223    /// Cancel any active fly-to animation.
224    pub fn cancel_flight(&mut self) {
225        self.flight = None;
226    }
227
228    /// Returns `true` if the animator has residual velocity or an active flight.
229    pub fn is_animating(&self) -> bool {
230        if self.flight.is_some() {
231            return true;
232        }
233        let eps = self.damping.epsilon;
234        self.orbit_velocity.length() > eps
235            || self.pan_velocity.length() > eps
236            || self.zoom_velocity.abs() > eps
237    }
238
239    /// Advance the animator by `dt` seconds and apply motion to `camera`.
240    ///
241    /// Returns `true` if the camera was modified (useful for triggering redraws).
242    pub fn update(&mut self, dt: f32, camera: &mut Camera) -> bool {
243        // Handle active flight.
244        if let Some(ref mut flight) = self.flight {
245            flight.elapsed += dt;
246            let raw_t = (flight.elapsed / flight.duration).min(1.0);
247            let t = flight.easing.eval(raw_t);
248
249            camera.set_center(flight.start_center.lerp(flight.target_center, t));
250            camera.set_distance(
251                flight.start_distance + (flight.target_distance - flight.start_distance) * t,
252            );
253            camera.set_orientation(flight.start_orientation.slerp(flight.target_orientation, t));
254
255            // Switch projection at the end of the animation.
256            if raw_t >= 1.0 {
257                if let Some(proj) = flight.target_projection {
258                    camera.projection = proj;
259                }
260                self.flight = None;
261            }
262            return true;
263        }
264
265        let eps = self.damping.epsilon;
266        let mut changed = false;
267
268        // Apply orbit velocity.
269        if self.orbit_velocity.length() > eps {
270            camera.orbit(self.orbit_velocity.x, self.orbit_velocity.y);
271            self.orbit_velocity *= self.damping.orbit.powf(dt * 60.0);
272            if self.orbit_velocity.length() <= eps {
273                self.orbit_velocity = glam::Vec2::ZERO;
274            }
275            changed = true;
276        }
277
278        // Apply pan velocity.
279        // Note: the animator's pan_velocity.y convention is the opposite sign from
280        // pan_world's up_delta (animator subtracts up, pan_world adds up), so negate y.
281        if self.pan_velocity.length() > eps {
282            camera.pan_world(self.pan_velocity.x, -self.pan_velocity.y);
283            self.pan_velocity *= self.damping.pan.powf(dt * 60.0);
284            if self.pan_velocity.length() <= eps {
285                self.pan_velocity = glam::Vec2::ZERO;
286            }
287            changed = true;
288        }
289
290        // Apply zoom velocity.
291        if self.zoom_velocity.abs() > eps {
292            camera.zoom_by_delta(self.zoom_velocity);
293            self.zoom_velocity *= self.damping.zoom.powf(dt * 60.0);
294            if self.zoom_velocity.abs() <= eps {
295                self.zoom_velocity = 0.0;
296            }
297            changed = true;
298        }
299
300        changed
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    fn default_camera() -> Camera {
309        Camera::default()
310    }
311
312    #[test]
313    fn test_damping_decays_velocity() {
314        let mut anim = CameraAnimator::with_default_damping();
315        let mut cam = default_camera();
316        anim.apply_orbit(0.5, 0.3);
317        assert!(anim.is_animating());
318        // Run many frames — velocity should decay to zero.
319        for _ in 0..300 {
320            anim.update(1.0 / 60.0, &mut cam);
321        }
322        assert!(!anim.is_animating(), "should have settled after 300 frames");
323    }
324
325    #[test]
326    fn test_zero_damping_passes_through() {
327        let damping = CameraDamping {
328            orbit: 0.0,
329            pan: 0.0,
330            zoom: 0.0,
331            epsilon: 0.0001,
332        };
333        let mut anim = CameraAnimator::new(damping);
334        let mut cam = default_camera();
335        let orig_orientation = cam.orientation;
336        anim.apply_orbit(0.1, 0.0);
337        anim.update(1.0 / 60.0, &mut cam);
338        // With zero damping, velocity decays instantly (0^anything=0).
339        assert!(
340            !anim.is_animating(),
341            "zero damping should settle in one frame"
342        );
343        // Camera should have moved.
344        assert!(
345            (cam.orientation.x - orig_orientation.x).abs() > 1e-6
346                || (cam.orientation.y - orig_orientation.y).abs() > 1e-6,
347            "camera orientation should have changed"
348        );
349    }
350
351    #[test]
352    fn test_fly_to_reaches_target() {
353        let mut anim = CameraAnimator::with_default_damping();
354        let mut cam = default_camera();
355        let target_center = glam::Vec3::new(10.0, 20.0, 30.0);
356        let target_dist = 15.0;
357        let target_orient = glam::Quat::from_rotation_y(std::f32::consts::PI);
358        anim.fly_to(&cam, target_center, target_dist, target_orient, 0.5);
359
360        // Advance well past the duration.
361        for _ in 0..120 {
362            anim.update(1.0 / 60.0, &mut cam);
363        }
364        assert!(
365            (cam.center - target_center).length() < 1e-4,
366            "center should match target: {:?}",
367            cam.center
368        );
369        assert!(
370            (cam.distance - target_dist).abs() < 1e-4,
371            "distance should match target: {}",
372            cam.distance
373        );
374    }
375
376    #[test]
377    fn test_fly_to_cancelled_by_input() {
378        let mut anim = CameraAnimator::with_default_damping();
379        let mut cam = default_camera();
380        let target = glam::Vec3::new(100.0, 0.0, 0.0);
381        anim.fly_to(&cam, target, 50.0, glam::Quat::IDENTITY, 1.0);
382
383        // Advance a few frames.
384        for _ in 0..5 {
385            anim.update(1.0 / 60.0, &mut cam);
386        }
387        // Apply user input — should cancel the flight.
388        anim.apply_orbit(0.1, 0.0);
389        // The flight should be gone.
390        anim.update(1.0 / 60.0, &mut cam);
391        // Camera should NOT have reached the target.
392        assert!(
393            (cam.center - target).length() > 1.0,
394            "flight should have been cancelled"
395        );
396    }
397
398    #[test]
399    fn test_is_animating_reflects_state() {
400        let mut anim = CameraAnimator::with_default_damping();
401        let mut cam = default_camera();
402        assert!(!anim.is_animating());
403
404        anim.apply_zoom(1.0);
405        assert!(anim.is_animating());
406
407        for _ in 0..300 {
408            anim.update(1.0 / 60.0, &mut cam);
409        }
410        assert!(!anim.is_animating());
411    }
412
413    #[test]
414    fn test_easing_boundaries() {
415        for easing in [Easing::Linear, Easing::EaseOutCubic, Easing::EaseInOutCubic] {
416            let v0 = easing.eval(0.0);
417            let v1 = easing.eval(1.0);
418            assert!(v0.abs() < 1e-6, "{easing:?}: eval(0) = {v0}, expected 0");
419            assert!(
420                (v1 - 1.0).abs() < 1e-6,
421                "{easing:?}: eval(1) = {v1}, expected 1"
422            );
423        }
424    }
425}