Skip to content

Commit ea53e5f

Browse files
committed
feat(animation): implement Animatable for Rot2
This adds animation support for 2D rotations by implementing the `Animatable` trait for `Rot2`. - Uses spherical linear interpolation (slerp) to ensure shortest-path consistency. - Implements correct cumulative and additive blending logic matching existing mathematical types like `Quat`. - Adds unit tests verifying shortest-path behavior and cumulative weighting. - Updates the `animated_ui` example to showcase `Rot2` rotation animation.
1 parent 45e454a commit ea53e5f

File tree

2 files changed

+161
-0
lines changed

2 files changed

+161
-0
lines changed

crates/bevy_animation/src/animatable.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,34 @@ impl Animatable for Quat {
197197
}
198198
}
199199

200+
impl Animatable for Rot2 {
201+
/// Performs a slerp to smoothly interpolate between 2D rotations.
202+
#[inline]
203+
fn interpolate(a: &Self, b: &Self, t: f32) -> Self {
204+
// We want to smoothly interpolate between the two rotations by default,
205+
// mirroring the behavior of `Quat` to ensure shortest-path consistency.
206+
a.slerp(*b, t)
207+
}
208+
209+
#[inline]
210+
fn blend(inputs: impl Iterator<Item = BlendInput<Self>>) -> Self {
211+
let mut value = Self::IDENTITY;
212+
for BlendInput {
213+
weight,
214+
value: incoming_value,
215+
additive,
216+
} in inputs
217+
{
218+
if additive {
219+
value = Self::slerp(Self::IDENTITY, incoming_value, weight) * value;
220+
} else {
221+
value = Self::interpolate(&value, &incoming_value, weight);
222+
}
223+
}
224+
value
225+
}
226+
}
227+
200228
/// Evaluates a cubic Bézier curve at a value `t`, given two endpoints and the
201229
/// derivatives at those endpoints.
202230
///
@@ -270,3 +298,116 @@ where
270298
let p1p2p3 = T::interpolate(&p1p2, &p2p3, t);
271299
T::interpolate(&p0p1p2, &p1p2p3, t)
272300
}
301+
302+
#[cfg(test)]
303+
mod tests {
304+
use super::*;
305+
use core::f32::consts::{FRAC_PI_2, PI};
306+
307+
const EPSILON: f32 = 1e-5;
308+
309+
#[test]
310+
fn test_rot2_shortest_path() {
311+
// Interpolate from 89.99° to -89.99°.
312+
// The shortest path must pass through 0° rather than 180°.
313+
let a = Rot2::radians(FRAC_PI_2 - EPSILON);
314+
let b = Rot2::radians(EPSILON - FRAC_PI_2);
315+
316+
let mid = Animatable::interpolate(&a, &b, 0.5);
317+
318+
assert!(
319+
mid.as_radians().abs() < EPSILON,
320+
"Expected shortest path through 0°, but got {}°",
321+
mid.as_radians().to_degrees()
322+
);
323+
}
324+
325+
#[test]
326+
fn test_rot2_blend_two_equal() {
327+
// Two equal weights (0.5 each) for 0° and 90°.
328+
// Result should be exactly 45°.
329+
let inputs = [
330+
BlendInput {
331+
weight: 0.5,
332+
value: Rot2::IDENTITY,
333+
additive: false,
334+
},
335+
BlendInput {
336+
weight: 0.5,
337+
value: Rot2::radians(FRAC_PI_2),
338+
additive: false,
339+
},
340+
];
341+
342+
let blended = Animatable::blend(inputs.into_iter());
343+
344+
assert!(
345+
(blended.as_radians() - FRAC_PI_2 / 2.0).abs() < EPSILON,
346+
"Expected 45°, got {}°",
347+
blended.as_radians().to_degrees()
348+
);
349+
}
350+
351+
#[test]
352+
fn test_rot2_blend_three_equal() {
353+
// Three equal weights (1/3 each) for 0°, 90°, and 180°.
354+
// Bevy's cumulative blending:
355+
// 1. interpolate(IDENTITY, 0°, 0.33) = 0°
356+
// 2. interpolate(0°, 90°, 0.33) = 30°
357+
// 3. interpolate(30°, 180°, 0.33) = 80°
358+
// This confirms the implementation matches Bevy's standard blending behavior.
359+
360+
let inputs = [
361+
BlendInput {
362+
weight: 1.0 / 3.0,
363+
value: Rot2::IDENTITY,
364+
additive: false,
365+
},
366+
BlendInput {
367+
weight: 1.0 / 3.0,
368+
value: Rot2::radians(FRAC_PI_2),
369+
additive: false,
370+
},
371+
BlendInput {
372+
weight: 1.0 / 3.0,
373+
value: Rot2::radians(PI),
374+
additive: false,
375+
},
376+
];
377+
378+
let blended = Animatable::blend(inputs.into_iter());
379+
let result_deg = blended.as_radians().to_degrees();
380+
381+
// We expect approximately 80° due to the cumulative nature of the blend logic.
382+
assert!(
383+
(result_deg - 80.0).abs() < 5.0,
384+
"Three-way blend result should be approximately 80° (got {}°), matching Bevy's cumulative logic",
385+
result_deg
386+
);
387+
}
388+
389+
#[test]
390+
fn test_rot2_blend_additive() {
391+
// Base 45° + Additive 90° (weight 1.0) = 135°.
392+
let inputs = [
393+
BlendInput {
394+
weight: 1.0,
395+
value: Rot2::radians(PI / 4.0),
396+
additive: false,
397+
},
398+
BlendInput {
399+
weight: 1.0,
400+
value: Rot2::radians(FRAC_PI_2),
401+
additive: true,
402+
},
403+
];
404+
405+
let blended = Animatable::blend(inputs.into_iter());
406+
407+
assert!(
408+
(blended.as_radians() - 3.0 * PI / 4.0).abs() < EPSILON,
409+
"Expected 135° (3PI/4), but got {}°",
410+
blended.as_radians().to_degrees()
411+
);
412+
}
413+
}

examples/animation/animated_ui.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,26 @@ impl AnimationInfo {
8181
),
8282
);
8383

84+
// Create a curve that animates `UiTransform::rotation`.
85+
//
86+
// This animates the 2D rotation of the UI element using `Rot2`.
87+
// Like other `Animatable` types, it uses shortest-path interpolation (slerp)
88+
// to ensure smooth movement between keyframes.
89+
use core::f32::consts::TAU;
90+
91+
animation_clip.add_curve_to_target(
92+
animation_target_id,
93+
AnimatableCurve::new(
94+
animated_field!(UiTransform::rotation),
95+
AnimatableKeyframeCurve::new(
96+
[0.0, 1.0, 2.0, 3.0]
97+
.into_iter()
98+
.zip([0., TAU / 3., TAU / 1.5, TAU].map(Rot2::radians)),
99+
)
100+
.expect("should be able to build rotation curve because we pass in valid samples"),
101+
),
102+
);
103+
84104
// Save our animation clip as an asset.
85105
let animation_clip_handle = animation_clips.add(animation_clip);
86106

0 commit comments

Comments
 (0)