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 }