From d97702ac792be181fe45298dda97307de61fe65e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuriko=E2=99=AA?= Date: Thu, 2 Apr 2026 00:21:09 +0800 Subject: [PATCH 1/2] feat(animation): impl `Animatable` for `Rot2` Enable animation support for 2D rotations. Since `UiTransform` and other 2D components now use `Rot2` for rotation, implementing `Animatable` is required to drive these fields via the `AnimationGraph`. - Implemented `Animatable` for `Rot2` in `bevy_animation`. - Used `slerp` for shortest-path interpolation, mirroring `Quat` behavior. - Implemented `blend` using cumulative SLERP to support multi-track blending and additive layers. - Added documentation comments consistent with existing `Quat` implementation. - Verified `interpolate` correctly handles shortest-path rotation. --- crates/bevy_animation/src/animatable.rs | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/bevy_animation/src/animatable.rs b/crates/bevy_animation/src/animatable.rs index a345c5fce4f0a..1cd38c27df836 100644 --- a/crates/bevy_animation/src/animatable.rs +++ b/crates/bevy_animation/src/animatable.rs @@ -197,6 +197,34 @@ impl Animatable for Quat { } } +impl Animatable for Rot2 { + /// Performs a slerp to smoothly interpolate between 2D rotations. + #[inline] + fn interpolate(a: &Self, b: &Self, t: f32) -> Self { + // We want to smoothly interpolate between the two rotations by default, + // mirroring the behavior of `Quat` to ensure shortest-path consistency. + a.slerp(*b, t) + } + + #[inline] + fn blend(inputs: impl Iterator>) -> Self { + let mut value = Self::IDENTITY; + for BlendInput { + weight, + value: incoming_value, + additive + } in inputs + { + if additive { + value = Self::slerp(Self::IDENTITY, incoming_value, weight) * value; + } else { + value = Self::interpolate(&value, &incoming_value, weight); + } + } + value + } +} + /// Evaluates a cubic Bézier curve at a value `t`, given two endpoints and the /// derivatives at those endpoints. /// From eb1d46aece23328444f0ad6b42a3ad4b878e050d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuriko=E2=99=AA?= Date: Thu, 2 Apr 2026 11:02:33 +0800 Subject: [PATCH 2/2] test: add unit tests for Rot2 animation and update animated_ui example - Added comprehensive unit tests verifying shortest-path interpolation and Bevy's cumulative blending behavior for `Rot2`. - Updated the `animated_ui` example to showcase 2D rotation curves using `Rot2`. - Fixed minor formatting issues in `animatable.rs` to satisfy CI checks. --- crates/bevy_animation/src/animatable.rs | 117 +++++++++++++++++++++++- examples/animation/animated_ui.rs | 20 ++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/crates/bevy_animation/src/animatable.rs b/crates/bevy_animation/src/animatable.rs index 1cd38c27df836..c08ffa1e57eaa 100644 --- a/crates/bevy_animation/src/animatable.rs +++ b/crates/bevy_animation/src/animatable.rs @@ -205,14 +205,14 @@ impl Animatable for Rot2 { // mirroring the behavior of `Quat` to ensure shortest-path consistency. a.slerp(*b, t) } - + #[inline] fn blend(inputs: impl Iterator>) -> Self { let mut value = Self::IDENTITY; for BlendInput { weight, value: incoming_value, - additive + additive, } in inputs { if additive { @@ -298,3 +298,116 @@ where let p1p2p3 = T::interpolate(&p1p2, &p2p3, t); T::interpolate(&p0p1p2, &p1p2p3, t) } + +#[cfg(test)] +mod tests { + use super::*; + use std::f32::consts::{FRAC_PI_2, PI}; + + const EPSILON: f32 = 1e-5; + + #[test] + fn test_rot2_shortest_path() { + // Interpolate from 89.99° to -89.99°. + // The shortest path must pass through 0° rather than 180°. + let a = Rot2::radians(FRAC_PI_2 - EPSILON); + let b = Rot2::radians(EPSILON - FRAC_PI_2); + + let mid = Animatable::interpolate(&a, &b, 0.5); + + assert!( + mid.as_radians().abs() < EPSILON, + "Expected shortest path through 0°, but got {}°", + mid.as_radians().to_degrees() + ); + } + + #[test] + fn test_rot2_blend_two_equal() { + // Two equal weights (0.5 each) for 0° and 90°. + // Result should be exactly 45°. + let inputs = [ + BlendInput { + weight: 0.5, + value: Rot2::IDENTITY, + additive: false, + }, + BlendInput { + weight: 0.5, + value: Rot2::radians(FRAC_PI_2), + additive: false, + }, + ]; + + let blended = Animatable::blend(inputs.into_iter()); + + assert!( + (blended.as_radians() - FRAC_PI_2 / 2.0).abs() < EPSILON, + "Expected 45°, got {}°", + blended.as_radians().to_degrees() + ); + } + + #[test] + fn test_rot2_blend_three_equal() { + // Three equal weights (1/3 each) for 0°, 90°, and 180°. + // Bevy's cumulative blending: + // 1. interpolate(IDENTITY, 0°, 0.33) = 0° + // 2. interpolate(0°, 90°, 0.33) = 30° + // 3. interpolate(30°, 180°, 0.33) = 80° + // This confirms the implementation matches Bevy's standard blending behavior. + + let inputs = [ + BlendInput { + weight: 1.0 / 3.0, + value: Rot2::IDENTITY, + additive: false, + }, + BlendInput { + weight: 1.0 / 3.0, + value: Rot2::radians(FRAC_PI_2), + additive: false, + }, + BlendInput { + weight: 1.0 / 3.0, + value: Rot2::radians(PI), + additive: false, + }, + ]; + + let blended = Animatable::blend(inputs.into_iter()); + let result_deg = blended.as_radians().to_degrees(); + + // We expect approximately 80° due to the cumulative nature of the blend logic. + assert!( + (result_deg - 80.0).abs() < 5.0, + "Three-way blend result should be approximately 80° (got {}°), matching Bevy's cumulative logic", + result_deg + ); + } + + #[test] + fn test_rot2_blend_additive() { + // Base 45° + Additive 90° (weight 1.0) = 135°. + let inputs = [ + BlendInput { + weight: 1.0, + value: Rot2::radians(PI / 4.0), + additive: false, + }, + BlendInput { + weight: 1.0, + value: Rot2::radians(FRAC_PI_2), + additive: true, + }, + ]; + + let blended = Animatable::blend(inputs.into_iter()); + + assert!( + (blended.as_radians() - 3.0 * PI / 4.0).abs() < EPSILON, + "Expected 135° (3PI/4), but got {}°", + blended.as_radians().to_degrees() + ); + } +} diff --git a/examples/animation/animated_ui.rs b/examples/animation/animated_ui.rs index 87e48263e91d8..6eeffe7e5276a 100644 --- a/examples/animation/animated_ui.rs +++ b/examples/animation/animated_ui.rs @@ -81,6 +81,26 @@ impl AnimationInfo { ), ); + // Create a curve that animates `UiTransform::rotation`. + // + // This animates the 2D rotation of the UI element using `Rot2`. + // Like other `Animatable` types, it uses shortest-path interpolation (slerp) + // to ensure smooth movement between keyframes. + use std::f32::consts::TAU; + + animation_clip.add_curve_to_target( + animation_target_id, + AnimatableCurve::new( + animated_field!(UiTransform::rotation), + AnimatableKeyframeCurve::new( + [0.0, 1.0, 2.0, 3.0] + .into_iter() + .zip([0., TAU / 3., TAU / 1.5, TAU].map(Rot2::radians)), + ) + .expect("should be able to build rotation curve because we pass in valid samples"), + ), + ); + // Save our animation clip as an asset. let animation_clip_handle = animation_clips.add(animation_clip);