git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/scene/anim.rs (10.9K)

  1 //! Animation primitive — Phase 4 of the core rebuild.
  2 //!
  3 //! The legacy toolkit has almost no animation: a single hand-rolled `hover_animation` helper (with
  4 //! a dead duplicate), and everything else is an instant boolean flip (`hovered`, `pressed`,
  5 //! `network_opacity` as a static multiplier). This module provides the missing spine — a small
  6 //! [`Animated<T>`] value that eases or springs toward a target over time — so hover/press/opacity
  7 //! and transitions become interpolated instead of instantaneous.
  8 //!
  9 //! It is pure time-based math over an [`Animatable`] value, fully unit-testable without a clock,
 10 //! GPU, or event loop: drive it with [`Animated::tick`] and read [`Animated::value`]. Wiring it
 11 //! into widgets and having the frame loop keep ticking while anything is live is the (runtime-gated)
 12 //! follow-up; the loop already returns "still animating" from `tick`, which this feeds.
 13 
 14 /// A value that can be interpolated and integrated for animation (scalars, colors, points).
 15 pub trait Animatable: Copy {
 16     fn lerp(self, other: Self, t: f32) -> Self;
 17     fn add(self, other: Self) -> Self;
 18     fn sub(self, other: Self) -> Self;
 19     fn scale(self, s: f32) -> Self;
 20     fn zero() -> Self;
 21     /// Rough magnitude used for settle detection (need not be a true norm).
 22     fn magnitude(self) -> f32;
 23 }
 24 
 25 impl Animatable for f32 {
 26     fn lerp(self, other: Self, t: f32) -> Self {
 27         self + (other - self) * t
 28     }
 29     fn add(self, other: Self) -> Self {
 30         self + other
 31     }
 32     fn sub(self, other: Self) -> Self {
 33         self - other
 34     }
 35     fn scale(self, s: f32) -> Self {
 36         self * s
 37     }
 38     fn zero() -> Self {
 39         0.0
 40     }
 41     fn magnitude(self) -> f32 {
 42         self.abs()
 43     }
 44 }
 45 
 46 impl Animatable for [f32; 4] {
 47     fn lerp(self, other: Self, t: f32) -> Self {
 48         [
 49             self[0] + (other[0] - self[0]) * t,
 50             self[1] + (other[1] - self[1]) * t,
 51             self[2] + (other[2] - self[2]) * t,
 52             self[3] + (other[3] - self[3]) * t,
 53         ]
 54     }
 55     fn add(self, o: Self) -> Self {
 56         [self[0] + o[0], self[1] + o[1], self[2] + o[2], self[3] + o[3]]
 57     }
 58     fn sub(self, o: Self) -> Self {
 59         [self[0] - o[0], self[1] - o[1], self[2] - o[2], self[3] - o[3]]
 60     }
 61     fn scale(self, s: f32) -> Self {
 62         [self[0] * s, self[1] * s, self[2] * s, self[3] * s]
 63     }
 64     fn zero() -> Self {
 65         [0.0; 4]
 66     }
 67     fn magnitude(self) -> f32 {
 68         self[0].abs().max(self[1].abs()).max(self[2].abs()).max(self[3].abs())
 69     }
 70 }
 71 
 72 /// Easing curve applied to a tween's normalized time `t in [0,1]`.
 73 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 74 pub enum Easing {
 75     Linear,
 76     EaseInQuad,
 77     EaseOutQuad,
 78     EaseInOutCubic,
 79 }
 80 
 81 impl Easing {
 82     pub fn apply(self, t: f32) -> f32 {
 83         let t = t.clamp(0.0, 1.0);
 84         match self {
 85             Easing::Linear => t,
 86             Easing::EaseInQuad => t * t,
 87             Easing::EaseOutQuad => t * (2.0 - t),
 88             Easing::EaseInOutCubic => {
 89                 if t < 0.5 {
 90                     4.0 * t * t * t
 91                 } else {
 92                     let f = -2.0 * t + 2.0;
 93                     1.0 - f * f * f / 2.0
 94                 }
 95             }
 96         }
 97     }
 98 }
 99 
100 /// How an [`Animated`] value approaches its target.
101 #[derive(Clone, Copy, Debug, PartialEq)]
102 pub enum Motion {
103     /// Ease from the value-at-retarget to the target over `duration` seconds.
104     Tween { duration: f32, easing: Easing },
105     /// Physical spring: `stiffness` pulls toward the target, `damping` bleeds velocity.
106     Spring { stiffness: f32, damping: f32 },
107 }
108 
109 /// Below this (in `Animatable::magnitude`) a spring is considered settled.
110 const SETTLE_EPS: f32 = 0.001;
111 
112 /// A value that animates toward a target. Retarget with [`set_target`](Animated::set_target); each
113 /// frame call [`tick`](Animated::tick) with the elapsed seconds and read [`value`](Animated::value).
114 #[derive(Clone, Copy, Debug)]
115 pub struct Animated<T: Animatable> {
116     current: T,
117     start: T,
118     target: T,
119     velocity: T,
120     elapsed: f32,
121     motion: Motion,
122     animating: bool,
123 }
124 
125 impl<T: Animatable> Animated<T> {
126     pub fn new(value: T, motion: Motion) -> Self {
127         Animated {
128             current: value,
129             start: value,
130             target: value,
131             velocity: T::zero(),
132             elapsed: 0.0,
133             motion,
134             animating: false,
135         }
136     }
137 
138     pub fn tween(value: T, duration: f32, easing: Easing) -> Self {
139         Self::new(value, Motion::Tween { duration, easing })
140     }
141 
142     pub fn spring(value: T, stiffness: f32, damping: f32) -> Self {
143         Self::new(value, Motion::Spring { stiffness, damping })
144     }
145 
146     pub fn value(&self) -> T {
147         self.current
148     }
149 
150     pub fn target(&self) -> T {
151         self.target
152     }
153 
154     pub fn is_animating(&self) -> bool {
155         self.animating
156     }
157 
158     /// Aim at a new target and start animating (unless already there). A tween restarts from the
159     /// current value; a spring keeps its velocity for continuous motion.
160     pub fn set_target(&mut self, target: T) {
161         if target.sub(self.current).magnitude() < SETTLE_EPS
162             && self.velocity.magnitude() < SETTLE_EPS
163         {
164             self.jump_to(target);
165             return;
166         }
167         self.target = target;
168         self.start = self.current;
169         self.elapsed = 0.0;
170         self.animating = true;
171     }
172 
173     /// Snap instantly to `value`, cancelling any in-flight animation.
174     pub fn jump_to(&mut self, value: T) {
175         self.current = value;
176         self.start = value;
177         self.target = value;
178         self.velocity = T::zero();
179         self.elapsed = 0.0;
180         self.animating = false;
181     }
182 
183     /// Advance by `dt` seconds. Returns whether the value is still animating (the signal the frame
184     /// loop uses to keep requesting frames).
185     pub fn tick(&mut self, dt: f32) -> bool {
186         if !self.animating {
187             return false;
188         }
189         match self.motion {
190             Motion::Tween { duration, easing } => {
191                 self.elapsed += dt;
192                 let t = if duration > 0.0 { (self.elapsed / duration).clamp(0.0, 1.0) } else { 1.0 };
193                 self.current = self.start.lerp(self.target, easing.apply(t));
194                 if t >= 1.0 {
195                     self.current = self.target;
196                     self.animating = false;
197                 }
198             }
199             Motion::Spring { stiffness, damping } => {
200                 // Semi-implicit Euler.
201                 let disp = self.target.sub(self.current);
202                 let force = disp.scale(stiffness).sub(self.velocity.scale(damping));
203                 self.velocity = self.velocity.add(force.scale(dt));
204                 self.current = self.current.add(self.velocity.scale(dt));
205                 if disp.magnitude() < SETTLE_EPS && self.velocity.magnitude() < SETTLE_EPS {
206                     self.current = self.target;
207                     self.velocity = T::zero();
208                     self.animating = false;
209                 }
210             }
211         }
212         self.animating
213     }
214 }
215 
216 #[cfg(test)]
217 mod tests {
218     use super::*;
219 
220     fn run_to_settle<T: Animatable>(a: &mut Animated<T>, dt: f32, max_steps: usize) -> usize {
221         let mut n = 0;
222         while a.tick(dt) && n < max_steps {
223             n += 1;
224         }
225         n
226     }
227 
228     #[test]
229     fn new_is_idle_at_value() {
230         let a = Animated::tween(5.0f32, 0.3, Easing::Linear);
231         assert_eq!(a.value(), 5.0);
232         assert!(!a.is_animating());
233     }
234 
235     #[test]
236     fn linear_tween_reaches_target_and_stops() {
237         let mut a = Animated::tween(0.0f32, 1.0, Easing::Linear);
238         a.set_target(10.0);
239         assert!(a.is_animating());
240         // Halfway: linear => 5.0.
241         a.tick(0.5);
242         assert!((a.value() - 5.0).abs() < 1e-4);
243         // Finish.
244         let still = a.tick(0.5);
245         assert!(!still);
246         assert_eq!(a.value(), 10.0);
247         assert!(!a.is_animating());
248     }
249 
250     #[test]
251     fn tween_overshoot_dt_clamps_to_target() {
252         let mut a = Animated::tween(0.0f32, 0.2, Easing::Linear);
253         a.set_target(1.0);
254         assert!(!a.tick(10.0)); // dt far exceeds duration
255         assert_eq!(a.value(), 1.0);
256     }
257 
258     #[test]
259     fn retarget_restarts_tween_from_current() {
260         let mut a = Animated::tween(0.0f32, 1.0, Easing::Linear);
261         a.set_target(10.0);
262         a.tick(0.5); // now at 5.0
263         a.set_target(0.0); // reverse
264         assert!((a.value() - 5.0).abs() < 1e-4, "keeps current value at retarget");
265         a.tick(0.5); // halfway back from 5 -> 0
266         assert!((a.value() - 2.5).abs() < 1e-4);
267     }
268 
269     #[test]
270     fn easing_endpoints_and_shape() {
271         for e in [Easing::Linear, Easing::EaseInQuad, Easing::EaseOutQuad, Easing::EaseInOutCubic] {
272             assert!((e.apply(0.0) - 0.0).abs() < 1e-6, "{e:?} at 0");
273             assert!((e.apply(1.0) - 1.0).abs() < 1e-6, "{e:?} at 1");
274         }
275         // EaseInQuad starts slow: at t=0.5 it's below linear (0.25 < 0.5).
276         assert!(Easing::EaseInQuad.apply(0.5) < 0.5);
277         // EaseOutQuad starts fast: above linear at t=0.5.
278         assert!(Easing::EaseOutQuad.apply(0.5) > 0.5);
279     }
280 
281     #[test]
282     fn spring_converges_and_settles() {
283         let mut a = Animated::spring(0.0f32, 120.0, 20.0);
284         a.set_target(1.0);
285         let steps = run_to_settle(&mut a, 1.0 / 60.0, 100_000);
286         assert!(!a.is_animating(), "spring settled within {steps} steps");
287         assert!((a.value() - 1.0).abs() < 0.01, "converged to target, got {}", a.value());
288         assert!(steps > 1, "took a few frames, not instant");
289     }
290 
291     #[test]
292     fn spring_keeps_velocity_across_retarget() {
293         let mut a = Animated::spring(0.0f32, 100.0, 15.0);
294         a.set_target(1.0);
295         for _ in 0..5 {
296             a.tick(1.0 / 60.0);
297         }
298         let moving = a.value();
299         a.set_target(2.0);
300         // Still animating and continues past the intermediate value toward the new target.
301         assert!(a.is_animating());
302         let steps = run_to_settle(&mut a, 1.0 / 60.0, 100_000);
303         assert!((a.value() - 2.0).abs() < 0.01, "reached new target in {steps} steps from {moving}");
304     }
305 
306     #[test]
307     fn jump_to_is_instant_and_idle() {
308         let mut a = Animated::tween(0.0f32, 1.0, Easing::Linear);
309         a.set_target(10.0);
310         a.tick(0.3);
311         a.jump_to(7.0);
312         assert_eq!(a.value(), 7.0);
313         assert!(!a.is_animating());
314         assert!(!a.tick(1.0), "no motion after jump");
315     }
316 
317     #[test]
318     fn set_target_equal_to_current_does_not_animate() {
319         let mut a = Animated::tween(3.0f32, 1.0, Easing::Linear);
320         a.set_target(3.0);
321         assert!(!a.is_animating());
322     }
323 
324     #[test]
325     fn animates_a_color_via_tween() {
326         let mut a = Animated::tween([0.0, 0.0, 0.0, 1.0], 1.0, Easing::Linear);
327         a.set_target([1.0, 0.5, 0.0, 1.0]);
328         a.tick(0.5);
329         let v = a.value();
330         assert!((v[0] - 0.5).abs() < 1e-4 && (v[1] - 0.25).abs() < 1e-4 && (v[2] - 0.0).abs() < 1e-4);
331         assert!(!a.tick(0.5));
332         assert_eq!(a.value(), [1.0, 0.5, 0.0, 1.0]);
333     }
334 }