Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make VariableCurve into curves #13105

Draft
wants to merge 44 commits into
base: main
Choose a base branch
from

Conversation

mweatherley
Copy link
Contributor

@mweatherley mweatherley commented Apr 25, 2024

Note: This PR is on top of #12932. About 2000 lines of code in this branch are just from that PR.

Objective

Presently, the main notion of curve in bevy_animation is VariableCurve, which is essentially a way of organizing an imported glTF animation. This RFC demonstrates a reorganization of this data so that each Transform component and MorphWeights curve is actually a Curve, with its glTF interpolation modes reified by curve structs that implement them on the underlying data buffers.

This has several advantages:

  • It makes Bevy's representations of glTF animations more portable and reusable.
  • It cleans up the code internally surrounding animation modes by dispatching them based on separate types.
  • It lays the foundation for more general curve-based animation by making the current animation data conform more closely with those notions.

Solution

VariableCurve, along with the code that loads it and uses it, has been completely overhauled.

VariableCurve is still an enum, but it is split up in a different way:

/// A curve for animating either a the component of a [`Transform`] (translation, rotation, scale)
/// or the [`MorphWeights`] of morph targets for a mesh.
///
/// Each variant yields a [`Curve`] over the data that it parametrizes.
pub enum VariableCurve {
    /// A [`TranslationCurve`] for animating the `translation` component of a [`Transform`].
    Translation(TranslationCurve),

    /// A [`RotationCurve`] for animating the `rotation` component of a [`Transform`].
    Rotation(RotationCurve),

    /// A [`ScaleCurve`] for animating the `scale` component of a [`Transform`].
    Scale(ScaleCurve),

    /// A [`WeightsCurve`] for animating [`MorphWeights`] of a mesh.
    Weights(WeightsCurve),
}

Each of the Transform component curve types here is actually a Curve, but they are still enums, broken down by mode of interpolation; for example, here is RotationCurve:

/// A curve specifying the scale component of a [`Transform`] in animation. The variants are
/// broken down by interpolation mode (with the exception of `Constant`, which never interpolates).
///
/// This type is, itself, a `Curve<Quat>`, and it internally uses the provided sampling modes; each
/// variant "knows" its own interpolation mode.
#[derive(Clone, Debug, Reflect)]
pub enum RotationCurve {
    /// A curve which takes a constant value over its domain. Notably, this is how animations with
    /// only a single keyframe are interpreted.
    Constant(ConstantCurve<Quat>),

    /// A curve which uses spherical linear interpolation between keyframes.
    SphericalLinear(UnevenSampleAutoCurve<Quat>),

    /// A curve which interpolates between keyframes in steps.
    Step(SteppedKeyframeCurve<Quat>),

    /// A curve which interpolates between keyframes by using auxiliary tangent data to join
    /// adjacent keyframes with a cubic Hermite spline. For quaternions, this means interpolating
    /// the underlying 4-vectors, sampling, and normalizing the result.
    CubicSpline(CubicKeyframeCurve<Vec4>),
}

Some of the curve representations that appear here are new, such as SteppedKeyframeCurve and CubicKeyframeCurve — these belong to bevy_animation, and are built on the data structures from bevy::math::curve::core, which handles the data storage and access patterns. Others, like UnevenSampleAutoCurve and ConstantCurve, are taken "off-the-shelf" from the Curve API.

Its implementation of Curve<Quat> essentially just matches over the variants. (The last one requires special handling to do quaternion normalization, but that's about it.)

Allocation-free Curve<Vec<T>>

Since the definitions implicit in the Curve trait would require that anything that looks like a Curve<Vec<T>> allocates to produce owned output, this PR introduces an offshoot trait which mitigates this problem — IterableCurve:

/// A curve which provides samples in the form of [`Iterator`]s.
///
/// This is an abstraction that provides an interface for curves which look like `Curve<Vec<T>>`
/// but side-stepping issues with allocation on sampling. This happens when the size of an output
/// array cannot be known statically.
pub trait IterableCurve<T> {
    /// The interval over which this curve is parametrized.
    fn domain(&self) -> Interval;

    /// Sample this curve at a specified time `t`, producing an iterator over sampled values.
    fn sample_iter<'a>(&self, t: f32) -> impl Iterator<Item = T>
    where
        Self: 'a;
}

This is used in concert with the core data structures from the Curve API to sample from keyframes valued in morph weights without ever allocating, all backed by a contiguous buffer of output data.

Performance

This is probably one of the biggest concerns about substantial changes to VariableCurve, so let me be proactive in addressing this.

First of all, VariableCurve has not changed in size (still 64 bytes); this is because the backing data for every curve is at most a pair of vectors — 48 bytes in total, plus 16 bytes for enum discriminants. This is important for caching reasons.

Secondly, proactive measures have been taken to ensure that the curves are designed with good cache locality properties, internally using a Vec<f32> for keyframe times paired with a contiguous buffer of sample data Vec<T> which is sliced up to actually perform sampling. One nice thing is that bevy_animation is not doing any very fancy gymnastics here; it's mostly just using the bevy_math::curve::core APIs as someone would in user-space. The IterableCurve abstraction mentioned above allows these niceties to extend to the case of morph weights without allocation concerns.

Finally, preliminary performance data from tracing looks pretty good (huge grain of salt — just my machine, on this one example, etc.). Here is a fairly typical example from many_foxes running single-threaded on my M1 Macbook Air:
Screenshot 2024-06-12 at 2 57 51 PM

(animate_targets — The yellow histogram is this branch, while the red branch is main. )

Under these circumstances, I see a consistent, modest performance improvement in the ballpark of ~10%.

One thing that I really want is a broader base of examples to pull from for performance benchmarking our animation systems, to get a better idea of the potential impact under realistic circumstances.

@mweatherley mweatherley changed the title IntegrateCurve with bevy_animation Integrate Curve with bevy_animation Apr 25, 2024
@alice-i-cecile alice-i-cecile added A-Animation Make things move and change over time C-Enhancement A new feature X-Controversial There is active debate or serious implications around merging this PR labels Apr 25, 2024
@nzhao95
Copy link
Contributor

nzhao95 commented May 2, 2024

Hey cool stuff. However I think it would be important to keep a baked animation data at all times. With curves it's really conveniant to work and modify animations which I would love but to actually play them having to compute curve values every frame would be loss of performence compared to baked animation.
Once the curve animation is all set it should be converted to baked animation before being sent to the animation graph that would only read a value and update on engine ticks. Here it feels like you are having variablecurves that only hold CubicSpline for instance

@mweatherley
Copy link
Contributor Author

Hey cool stuff. However I think it would be important to keep a baked animation data at all times. With curves it's really conveniant to work and modify animations which I would love but to actually play them having to compute curve values every frame would be loss of performence compared to baked animation. Once the curve animation is all set it should be converted to baked animation before being sent to the animation graph that would only read a value and update on engine ticks. Here it feels like you are having variablecurves that only hold CubicSpline for instance

This is pretty much a direct drop-in replacement for how animation currently works in Bevy (which is to say, there is no baking). It also doesn't really preclude things like baking AnimationGraph output in the future; it's more-or-less just making the parts of VariableCurve able to stand on their own as data.

@nzhao95
Copy link
Contributor

nzhao95 commented May 2, 2024

This is pretty much a direct drop-in replacement for how animation currently works in Bevy (which is to say, there is no baking). It also doesn't really preclude things like baking AnimationGraph output in the future; it's more-or-less just making the parts of VariableCurve able to stand on their own as data.

I believe right now the transform values are lerped between previous and next key which will always be the case because of variable frame durations. The lerp function comes from the glam crate which is using SIMd making this operation very fast.
So I think it would be a nice addition in the future to cache the baked animation and avoid as many operations as possible. I didn't quite look at your code yet but I'll try to do that to see if I can make any useful suggestions ^^

@mweatherley
Copy link
Contributor Author

I believe right now the transform values are lerped between previous and next key which will always be the case because of variable frame durations. The lerp function comes from the glam crate which is using SIMd making this operation very fast. So I think it would be a nice addition in the future to cache the baked animation and avoid as many operations as possible. I didn't quite look at your code yet but I'll try to do that to see if I can make any useful suggestions ^^

Actually, I think that bevy_animation doesn't use glam's Vec3A for interpolation, but maybe we should switch at some point. In any case, the keyframe interpolation in this implementation works in pretty much exactly the same way, just with the interpolation modes reified to trait implementations of types (vector types just use lerp for this). It may be the case that in doing performance optimizations, however, that we will want to collapse some of these abstractions (in favor, for example, of using SoA); in any case, it would remain true that, for example, TranslationCurve would remain a unified Curve<Vec3>.

One advantage of this is that if we do ever actually want to bake anything, the Curve interface makes this quite standard, since we can just call resample on anything that is a curve and then extract the result. For example, if we get to the point where the animation graph output is a Curve, then we can also bake the result pretty easily. Of course, this is probably overlooking a number of organizational and technical challenges that would intervene along the way 😄

@nzhao95
Copy link
Contributor

nzhao95 commented May 2, 2024

I believe right now the transform values are lerped between previous and next key which will always be the case because of variable frame durations. The lerp function comes from the glam crate which is using SIMd making this operation very fast. So I think it would be a nice addition in the future to cache the baked animation and avoid as many operations as possible. I didn't quite look at your code yet but I'll try to do that to see if I can make any useful suggestions ^^

Actually, I think that bevy_animation doesn't use glam's Vec3A for interpolation, but maybe we should switch at some point. In any case, the keyframe interpolation in this implementation works in pretty much exactly the same way, just with the interpolation modes reified to trait implementations of types (vector types just use lerp for this). It may be the case that in doing performance optimizations, however, that we will want to collapse some of these abstractions (in favor, for example, of using SoA); in any case, it would remain true that, for example, TranslationCurve would remain a unified Curve<Vec3>.

One advantage of this is that if we do ever actually want to bake anything, the Curve interface makes this quite standard, since we can just call resample on anything that is a curve and then extract the result. For example, if we get to the point where the animation graph output is a Curve, then we can also bake the result pretty easily. Of course, this is probably overlooking a number of organizational and technical challenges that would intervene along the way 😄

I was looking at lib.rs in bevy_animation : fn apply_single_keyframe() I believe it's the function used for apply animations but I'm not sure I just guess it by the name :D But this function is indeed using the Quat::slerp and Vec3::lerp which are coming from glam.
And yeah I think it's awesome to be able to store anims in curves but I was just saying that for the actual display it would be nice to cache it and make the frame iterations lower.
Would be easier to tell with some use cases. Where is the interpolation done at the end to be played by the AnimationPlayer ?

@mweatherley
Copy link
Contributor Author

I was looking at lib.rs in bevy_animation : fn apply_single_keyframe() I believe it's the function used for apply animations but I'm not sure I just guess it by the name :D But this function is indeed using the Quat::slerp and Vec3::lerp which are coming from glam. And yeah I think it's awesome to be able to store anims in curves but I was just saying that for the actual display it would be nice to cache it and make the frame iterations lower. Would be easier to tell with some use cases. Where is the interpolation done at the end to be played by the AnimationPlayer ?

Ah; apply_single_keyframe is only used in the case that the loaded glTF animation only consists of a single keyframe, and the lerp and slerp there is better described as "blending" rather than interpolation (it comes from weights in the animation graph); the actual interpolation logic is in apply_tweened_keyframe. In this proposal, both of these would be largely replaced just by calling sample on the associated curves (the constant curve variants playing the role of apply_single_keyframe).

@nzhao95
Copy link
Contributor

nzhao95 commented May 3, 2024

Ok I see, we should probably look into code optimization on this topic later. Even the tweened keyframe function is doing a few unecessary copies :

                let tangent_out_start = keyframes[step_start * 3 + 2];
                let tangent_in_end = keyframes[(step_start + 1) * 3];
                let value_end = keyframes[(step_start + 1) * 3 + 1];
                let result = cubic_spline_interpolation(
                    value_start,
                    tangent_out_start,
                    tangent_in_end,
                    value_end,
                    lerp,
                    duration,
                );

The keyframe update functions should be very fast compared to data management functions which can take there time. But yeah lots of things to do ^^ Having curves is already a huge plus. Would be interesting to reimplement all the actual curve evaluations in SIMd as well

@mweatherley mweatherley changed the title Integrate Curve with bevy_animation Make VariableCurve into curves Jun 12, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-Animation Make things move and change over time C-Enhancement A new feature X-Controversial There is active debate or serious implications around merging this PR
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants