Skip to content

Unified interpolation #18674

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

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

Conversation

NthTensor
Copy link
Contributor

@NthTensor NthTensor commented Apr 2, 2025

Objective

Bevy currently has lots of different ways to perform interpolation: Animatable, Mix, StableInterpolate, Ease and VectorSpace to name a few. These traits are implemented on types in a confusing ad-hoc case-by-case basis that often leaves useful functionality on the table.

This PR seeks to unify all these methods under a single Interpolate super-trait, with a single "sane default" interpolation method called interp(). With this trait, a lot of obscure technical debt in bevy_math, bevy_color, and bevy_animation can be cleared away.

Solution

We introduce a new Interpolate trait with fairly strong requirements, and then reconcile the existing interpolation methods with it:

  • bevy_color::Mix is superseded and has been removed.
  • StableInterpolate adds a uniform velocity invariant, it has been turned into a sub-trait and renamed InterpolateStable.
  • VectorSpace supports linear interpolation, it has become a sub-trait with a blanket impl.
  • Ease is just a curve version of interpolation, it has become a sub-trait and been renamed InterpolateCurve.
  • Animatable is technical-debt and was being heavily abused by the animation system, it has been replaced by the Blend and Blendable sub-traits (which encapsulate Lie-Group-like additive blending behavior).

As I was working on this, I found myself getting very confused by the long and complex type names in the animation_curves.rs -- and since many of them referenced the now defunct Animatable trait, I thought I would take the opportunity to give them shorter and more clearly distinct names. I'm clearly not the only person who has been having trouble with them -- in three places the docs seem to be linking to AnimatableCurve when they meant to link to AnimationCurve.

Testing

I ran all the animation and color examples, and every other example which I directly modified.

Migration Guide

Math

  • VectorSpace::lerp was replaced by Interpolate::interp. The interp method is always linear for vector-spaces.
  • StableInterpolate is now InterpolateStable. The interpolate_stable method was replaced by Interpolate::interp.
  • Ease is now InterpolateCurve and interpolate_curve_unbounded is now interp_curve.

Color

  • The bevy_color::Mix trait became the bevy_math::Interpolate trait, and mix is now interp.
  • Color now interpolates in Oklab by default.
  • Laba, LinearRgba, Oklaba, Srgba and Xyza no longer implement VectorSpace. Use scaled_by to scale colors, or to_vec4 and from_vec4 for general math.
  • Every color space now implements Interpolate, InterpolateStable, and InterpolateCurve.

Animation

  • A new Blend trait has been introduced to extend Interpolate with additive blending.
  • The Animatable trait has been replaced by Blendable, which has a blanket implementation for Blend types.
  • AnimatableProperty can now be defined for properties that do not implement Blendable (or previously, Animatable).
  • BasicAnimationCurveEvaluator is now BlendStackEvaluator.
  • BasicAnimationCurveEvaluatorStackElement is now BlendStackElement.
  • AnimatableCurve is now PropertyCurve.
  • AnimatableCurveEvaluator is now PropertyCurveEvaluator.
  • AnimatableKeyframeCurve is now KeyframeCurve.

Questions For Reviewers

  • This PR removes Animatable entirely, but it doesn't need to. We can keep it around (but not use it internally) if we know there are downstream consumers. Do you think people will miss it?
  • What is up with the special-cased implementation of InterpolateCurve for Quat? Why is slerp not good enough here?

@NthTensor NthTensor added C-Code-Quality A section of code that is hard to understand or change A-Animation Make things move and change over time A-Math Fundamental domain-agnostic mathematical operations M-Needs-Migration-Guide A breaking change to Bevy's public API that needs to be noted in a migration guide X-Contentious There are nontrivial implications that should be thought through S-Needs-Review Needs reviewer attention (from anyone!) to move forward A-Color Color spaces and color math labels Apr 2, 2025
/// Other sub-traits may add stronger properties to this method:
/// - For types that implement `VectorSpace`, this is linear interpolation.
/// - For types that implement `InterpolateStable`, the interpolation is stable under resampling.
fn interp(&self, other: &Self, param: f32) -> Self;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super minor insignificant question: why not interpolate? Is it just to keep it concise?

Copy link
Contributor

@bushrat011899 bushrat011899 Apr 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd personally prefer we use the full interpolate, and then offer a trivial blanket-impl trait to provide a shortcut name like lerp (which is more standard nomenclature anyway):

pub trait Lerp: Interpolate {
    fn lerp(&self, other: &Self, value: f32) -> Self;
}

impl<T: Interpolate> Lerp for T {
    fn lerp(&self, other: &Self, value: f32) -> Self {
        self.interpolate(other, value)
    }
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know enough about the animation system to do a full review, but IMO, renaming to interpolate and the Lerp trait are the only things missing from this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if a blanket impl of Lerp for all T: Interpolate would necessarily make sense. For example, Quat::interp uses Quat::slerp, not Quat::lerp, so it would be misleading.

+1 for interpolate over interp though.

Copy link
Contributor Author

@NthTensor NthTensor Apr 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VectorSpace is effectively lerp right now. I don't think a Lerp trait is necessary: slerp, lerp and nlerp should be reserved for methods implemented directly on types, not traits. If you need generalized linear interpolation use T: VectorSpace with T::interpolate.

Copy link
Contributor

@bushrat011899 bushrat011899 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this in principle; in maths there are clear and well known definitions for things like interpolation, so the more things we can tie back to pure-maths concepts the better IMO. I do have some questions and comments, but I largely approve of this PR as-is. Just commenting now since it's still in draft.

@@ -3,7 +3,7 @@
#[path = "../helpers/camera_controller.rs"]
mod camera_controller;

use bevy::{color::palettes::css::*, prelude::*};
use bevy::{color::palettes::css::*, math::Interpolate, prelude::*};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth putting Interpolate in bevy_math::prelude, since it's likely to be commonly used.

Comment on lines -119 to +120
let color = mixed.0[start_i as usize].mix(&mixed.0[start_i as usize + 1], local_t);
let color = mixed.0[start_i as usize].interp(&mixed.0[start_i as usize + 1], local_t);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Future PR: Lines like this make me want a RangeInterpolateExt trait which would let you do something like (a..b).at(t). Likewise for slices (which would assume equal weighting to account for more than 2 items in a slice).

pub trait RangeInterpolateExt<T> {
    fn at(&self, value: f32) -> T;
}

impl<T: Interpolate> RangeInterpolateExt<T> for Range<T> {
    fn at(&self, value: f32) -> T {
        self.start.interp(self.end, value)
    }
}

assert_eq!((0.0..5.0).at(0.5), 2.5);

Slice support in particular would be really nice, since you could do:

let index = start_i as usize;
let color = mixed.0[index..=index+1].at(local_t);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually bevy_color already has something quite like this. we just need to generalize it.

AnimatableKeyframeCurve::new([0.0, 1.0, 2.0, 3.0].into_iter().zip([
KeyframeCurve::new([0.0, 1.0, 2.0, 3.0].into_iter().zip([
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, a KeyframeCurve implies it must support animation, since it is a curve of animation keyframes.

Comment on lines +652 to +660
impl Interpolate for Transform {
fn interp(&self, other: &Self, param: f32) -> Self {
Transform {
translation: self.translation.interp(&other.translation, param),
rotation: self.rotation.interp(&other.rotation, param),
scale: self.scale.interp(&other.scale, param),
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Future PR: This is begging for a derive macro.

Copy link
Contributor Author

@NthTensor NthTensor Apr 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, so is Blend... actually all these traits are.

impl Interpolate for Color {
#[inline]
fn interp(&self, other: &Self, param: f32) -> Self {
Oklaba::interp(&(*self).into(), &(*other).into(), param).into()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: This will increase the number of maths operations involved in the-function-formerly-known-as-mix, since we now always convert to Oklaba for both colours, instead of only ever converting the second colour into the first's space. I think that's acceptable, but others may not agree.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would say this is probably good behaviour, because Oklab should be the goto "colour interpolating" space based on how it's constructed, and users of the Color enum aren't supposed to be picky about performance (if they are, they should use specific colour types and watch for where they convert.)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change should be documented though, as it is an observable change in behaviour.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't agree with this change mainly because is not in sync with the rest of the Color impls, like Hue, Luminance , Saturation, etc.

How all traits are implemented is by keeping the color space if possible, and in case is not, the color is converted to ChosenColorSpace which is Oklcha not Oklaba.

So even if this change stays as it is, at least use ChosenColorSpace.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interpolating in OkLch is not going to be as perceptually linear as OkLab. Personally I think the Color enum should use the space that makes the most sense here -- OkLcha obviously makes the most sense for Luminance and Hue (because it's a luma-chroma-hue space) but OkLab makes the most sense for Interpolate and EuclideanDistance (because it's a perceptually uniform linear space).

Tbh, looking at them now I have an issue with how these other traits are implemented as well (it's concerning that EuclideanDistance is not symmetric). But I can leave those changes for another PR.

///
/// Some of you will have noticed that these rules encodes the axioms for a Monoid; In fact this trait
/// represents something similar to a Lie Group.
pub trait Blend: Interpolate {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason this trait isn't moved to bevy_math instead? While it's only used on bevy_animation, it conceptually feels more bevy_math than bevy_animation to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wondering about this too. Open to bike shedding from reviewers.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My vote: rename to BlendAdditive and move to bevy_math. That leaves room for multiplicative blending in the future.

/// represents something similar to a Lie Group.
pub trait Blend: Interpolate {
/// The default value of the blend, which has no effect when blended with other values.
const IDENTITY: Self;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like we're rapidly reinventing the information captured in num-traits. Is there a reason bevy_math doesn't use it? Especially considering it's already in our dependency graph thanks to numerous other crates (approx, guillotiere, image, wgpu, rand_distr, etc.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had the same thought while writing this, but I don't like introducing new dependencies when they are incidental. As future work we should look at utilizing num, possibly along side a change to add f64 scalar support throughout bevy math.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lack of f64 support is pretty annoying, and also f16 (and other non-standard float types) would be useful for e.g. GPU integration.

@@ -111,6 +114,12 @@ use downcast_rs::{impl_downcast, Downcast};
/// as long as it can be obtained by mutable reference. This makes it more
/// flexible than [`animated_field`].
///
/// Promperties that [`Blend`] are easiest to use, because the default
/// implementation of [`AnimationCurve`] for [`PropertyCurve`] is blending-based.
/// To animating a non-blendable property (like a boolean or enum), you must add
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// To animating a non-blendable property (like a boolean or enum), you must add
/// To animate a non-blendable property (like a boolean or enum), you must add

use bevy_math::*;
use bevy_transform::components::Transform;

/// A type with a natural method of addative blending.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// A type with a natural method of addative blending.
/// A type with a natural method of additive blending.

/// A type with a natural method of addative blending.
///
/// Any type with a well-behaved method of composition which also implements the [`Interpolate`] trait
/// can support addative blending. Specifically, the type must:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// can support addative blending. Specifically, the type must:
/// can support additive blending. Specifically, the type must:

/// 3. The value of `T::blend(a, b, w)` should be equivalent to `comp(a, interp(IDENTITY, b, w))`. This implies
/// that `T::blend(a, b, 0)` is equivalent to `a` and `T::blend(a, b, 1)` is equivalent to `comp(a, b)`.
///
/// Some of you will have noticed that these rules encodes the axioms for a Monoid; In fact this trait
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: My hunch is that this sort of plural readership is not fit for writing documentation.
Maybe something like this would be better?

Suggested change
/// Some of you will have noticed that these rules encodes the axioms for a Monoid; In fact this trait
/// Those more familiar with abstract algebra may notice that these rules encodes the axioms for a Monoid; In fact this trait

const IDENTITY: Self;

/// Addatively blends another value on top of this one.
fn blend(self, other: Self, blend_weight: f32) -> Self;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type docstring talks about the properties the blended object should have, but what properties should this blend function itself have and how do they relate?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See point (3). We require blend(a, b, w) == comp(a, interp(IDENTITY, b, w)). Eg, it should be an additive blend.

where
T: Blend,
{
/// Addatively blends the value into the blender.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Addatively blends the value into the blender.
/// Additively blends the value into the blender.

pub fn blend_interp(self, value: T, weight: f32) -> Self {
let cumulative_weight = self.weight + weight;
Blender {
value: T::interp(&self.value, &value, weight / cumulative_weight),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible division by zero if the user happens to put in a negative weight (unlikely) or when starting with a 0 weight blend on a 0 weight blender. (I can see this happening in procedural scenarios, blending with a 0 input should probably always blend with 0)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch

/// This is an extension trait to `Blend` with methods to easily blend and interpolate
/// between multiple values.
pub trait Blendable: Blend {
/// Addatively blends the value into a new blender.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Addatively blends the value into a new blender.
/// Additively blends the value into a new blender.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you believe I put this through two different levels of spell-check and these still got through somehow?

}
}

/// This is an extension trait to `Blend` with methods to easily blend and interpolate
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need an extension trait?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At one point I had more logic in Blendable but I guess I refactored that into Blender<T> at some point. I could probably remove Blendable now I guess, in favor of Blend and Blender<T>.


#[inline]
fn blend(self, other: Self, blend_weight: f32) -> Self {
$ty::from_vec4(self.to_vec4() + other.to_vec4() * blend_weight)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this supposed to be a convex (or at least an affine) combination?

Suggested change
$ty::from_vec4(self.to_vec4() + other.to_vec4() * blend_weight)
$ty::from_vec4(self.to_vec4() * (1.0 - blend_weight) + other.to_vec4() * blend_weight)

Also I wonder if we want to blend alpha like that.

Copy link
Contributor Author

@NthTensor NthTensor Apr 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nooo Blend is for additive blending, which is just a linear combination. if we did it this way, blend would be equivalent to interpolate. take a closer look at Blender<T>::blend_additive, Blender<T>::blend_interp and impl_blendable_group_action!.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a pretty direct translation of the math as it was done previously (just with the extra complexity of Animatable cut out).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but it's an interesting idea to alpha-blend here. additive blending is all about layering effects on top of each other, and alpha blending does sort of capture that for colors. @mweatherley @viridia should additive blending for animated colors involve alpha blending?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there's an obvious right answer here. If I add A to B, does the alpha in A affect the blend factor, or does it affect the result alpha? My first question would be, "what does photoshop / gimp / inkscape / CSS do?"

I would check out what linebender does here in their new color crate (which was partly based on bevy_color) - they are far more pedantic/strict about color spaces than we are: https://docs.rs/color/0.2.3/src/color/dynamic.rs.html#412-430

Just FYI, it's interesting that linebender's color crate defines premultiplied-alpha colors as distinct types (using a generic parameter). This was an idea that we discussed early on, and initially I was against it (I thought it would make the color classes too complicated) but now I am not so sure that was the right choice.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems as though the current implementation just does a linear combination including alpha, without considering premultiplication or alpha blending. This PR at least consistent with the current behavior.

Let's mark color blend-mode support and possible typed alpha state as problems for a future PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another util file down! 🎉

@@ -17,7 +18,7 @@ use derive_more::derive::From;
///
/// # Operations
///
/// [`Color`] supports all the standard color operations, such as [mixing](Mix),
/// [`Color`] supports all the standard color operations, such as [interpolating](bevy_math::Interpolate),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do find it a little funny that colour mixing is now a bevy_math thing.
I wonder what bevy_color people think about this change.
Also I think it would be nice to add in an alias here for discoverability.

Suggested change
/// [`Color`] supports all the standard color operations, such as [interpolating](bevy_math::Interpolate),
/// [`Color`] supports all the standard color operations, such as [mixing/interpolating](bevy_math::Interpolate),

Copy link
Contributor Author

@NthTensor NthTensor Apr 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be fine to add a mix and mix_assign alias to Interpolate to ease migrations. the trouble with mixing is that no one really gets to own it, it has to be shared by what ever crate the type comes from, and math and animation.

@@ -143,7 +144,7 @@ pub struct SampleAutoCurve<T> {

impl<T> Curve<T> for SampleAutoCurve<T>
where
T: StableInterpolate,
T: InterpolateStable + Clone,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By using Interpolate here would make SampleAutoCurve usable in more scenarios.

Like you could get rid of ColorCurve.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InterpolateStable gives us something like uniform velocity, which I think is important to have here by default.

I do want to merge this and ColorCurve (or get rid of ColorCurve) and rename the AutoCurve stuff to be more friendly. But I was thinking about doing it as follow up. I don't think this needs to happen in this PR.

pub trait VectorSpace:
Mul<f32, Output = Self>
Interpolate
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of the given blank implementation down below, you don't need to make the VectorSpace to inherit from Interpolate.

What I mean is that types that depend on T: VectorSpace can use .interp() automatically as long as they import the Interpolate trait.

Copy link
Contributor Author

@NthTensor NthTensor Apr 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But you can't use interpolate in generics of type T: VectorSpace unless there's a trait bound, can you?

impl Interpolate for Color {
#[inline]
fn interp(&self, other: &Self, param: f32) -> Self {
Oklaba::interp(&(*self).into(), &(*other).into(), param).into()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't agree with this change mainly because is not in sync with the rest of the Color impls, like Hue, Luminance , Saturation, etc.

How all traits are implemented is by keeping the color space if possible, and in case is not, the color is converted to ChosenColorSpace which is Oklcha not Oklaba.

So even if this change stays as it is, at least use ChosenColorSpace.

@greeble-dev
Copy link
Contributor

greeble-dev commented Apr 3, 2025

I have a few concerns as someone who's interested in skeletal animation. TLDR is that Interpolate is fine, but I'm not sure about the additive parts of Blend.

  • Blend::blend naming.
    • "Blend" is an overloaded term. Sometimes it encompasses additive blends, sometimes it's specific to interpolation.
    • I've not seen it used to refer specifically to additive blends like Blend does.
    • add or weighted_add might be clearer?
  • Blender seems quite specific to animation graph evaluation.
    • It allows mixing add blends and interpolate blends using one weight accumulator.
    • I'm not sure where else that's useful, but maybe fine if it's not intended to be for general use?

The remaining concerns were true of the previous Animatable so they're arguably out of scope, but I'm raising them just in case.

  • Quat blend uses the order weighted_additive * base.
    • The reverse is valid, and might be preferable in some cases.
    • In skeletal animation terms this is "parentspace add" versus "localspace add".
  • Transform blend keeps the components separate.
    • The Blend documentation talks about composition.
    • Keeping the components separate means that composing transforms is not multiplication as defined by Transform::mul_transform.
    • Doing Transform::interp(Transform::IDENTITY, other, weight) * self seems valid and would mean composition = multiplication.
    • Unclear if one is better than the other.
    • Also the multiply order is debatable, same as quats.
  • Blend defines add but not subtract.
    • In skeletal animation, additives typically mix three poses with: add(a, sub(b, c), weight).
    • The subtract part might be baked into the animation clip or done as a runtime blend op.
    • Maybe leaving it undefined for now is fine and it gets added later. But could be safer to work through the semantics earlier, which leads onto...
  • Transform blend adds the scales.
    • That can work, but I'd argue it's unintuitive.
    • Adding Transform::IDENTITY to itself gives a scale of two.
    • Maybe that's fine if there's a Transform::ADDITIVE_IDENTITY where scale is zero.
    • Some alternatives I've seen are below.
    • I prefer alternative two as it's simple and doesn't need a special identity.
    • Base = zero is trouble for alternative two and three.
// 1: Current Bevy? Additive identity = 0.
fn sub(additive, base) { additive - base }
fn weighted_add(base, additive, weight) { base + (additive * weight) }

// 2: Additive identity = 1.
fn sub(additive, base) { additive / base }
fn weighted_add(base, additive, weight) { lerp(base, base * additive, weight) }

// 3: Unreal. Additive identity = 0.
fn sub(additive, base) { (additive / base) - 1.0 }
fn weighted_add(base, additive, weight) { base * (1.0 + (additive * weight)) }

@NthTensor
Copy link
Contributor Author

NthTensor commented Apr 3, 2025

@greeble-dev thanks so much for this feedback. Blend is def the part of this that I feel the least confident about. I don't like Animatanle but I've been really hesitant to remove it.

Re:

  • Blend name bike-shed: Totally fair, what about additive_blend? That's what I had originally.
  • Blender is weird: Yep, it exists to serve the animation graph and nothing else. I'm thinking about removing it.
  • Parent space vs local space: Interesting! Can you say more about this? When/why would you use local space? How might we expose it through the animation curves api?
  • Transform::blend: Good catch! I'm just following the existing functionality here, but now that I think about it it does seem wrong. I'd prefer to look at this in a separate issue.
  • Blend is not subtractive: Again, I'm replicating the existing functionality, but this is an interesting concern. Currently the semantics don't exactly capture a Lie-Group because they use a Monoid (no additive inverse) instead of a Group. That's all just in the formal semantics of the trait though, doesn't affect the implementation (which I think does effectively support negative interpolation in all cases). This should probably be strengthened.
  • Transform scale. I really really really hate scale. Almost as much as cones. I will have to think more about this. I prefer (2) as well.

@greeble-dev
Copy link
Contributor

  • Blend name bike-shed: additive_blend seems fine.
  • Parent space vs local space:
    • I'm afraid I don't have anything more concrete than "sometimes one looks nicer than the other".
    • For now I think it's fine to use parent space everywhere - that's what Unreal does.
    • If there's an option to choose, I'm not sure where it would go in the animation API.
      • Maybe it's a curve property that flows down the graph and gets interpreted by the add node.
      • Bevy's anim graph isn't like any other anim system I've used so I can't speak with experience.
  • Blend is not subtractive:
    • I was a bit confused by this, but then realised that sub(a, b) is equivalent to weighted_add(a, b, -1). I think that's what you meant?
    • But with scale the odd one out as always.
    • Changing scale to be the logarithm might mean it's no longer the odd one out.
    • But I don't know of any anim system that does that, and negative scale gets funky.

@NthTensor
Copy link
Contributor Author

Blend is not subtractive:
I was a bit confused by this, but then realised that sub(a, b) is equivalent to weighted_add(a, b, -1). I think that's what you meant? But with scale the odd one out as always.

Yep, sub(a, b) is in practice equivalent to weighted_add(a, b, -1.0) == comp(a, interp(identity, b, -1.0)) currently, because all of the interpolations also happen to support negative extrapolation. We don't actually require that to be the case, and we probably should.

And yes sigh looks like that does mess with scale again. Scale will have to be interp(a, a * b, abs(w)).

@NthTensor NthTensor requested a review from alice-i-cecile May 6, 2025 01:27
@NthTensor
Copy link
Contributor Author

Looking forward to seeing that design (although nth is like me, and bites off more than he can chew :P).

Here it is (also, yes).

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 A-Color Color spaces and color math A-Math Fundamental domain-agnostic mathematical operations C-Code-Quality A section of code that is hard to understand or change M-Needs-Migration-Guide A breaking change to Bevy's public API that needs to be noted in a migration guide S-Needs-Review Needs reviewer attention (from anyone!) to move forward X-Contentious There are nontrivial implications that should be thought through
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants