bevy/crates/bevy_animation/src/gltf_curves.rs
JoshValjosh ddee5cca85
Improve Bevy's double-precision story for third-party crates (#19194)
# Objective

Certain classes of games, usually those with enormous worlds, require
some amount of support for double-precision. Libraries like `big_space`
exist to allow for large worlds while integrating cleanly with Bevy's
primarily single-precision ecosystem, but even then, games will often
still work directly in double-precision throughout the part of the
pipeline that feeds into the Bevy interface.

Currently, working with double-precision types in Bevy is a pain. `glam`
provides types like `DVec3`, but Bevy doesn't provide double-precision
analogs for `glam` wrappers like `Dir3`. This is mostly because doing so
involves one of:

- code duplication
- generics
- templates (like `glam` uses)
- macros

Each of these has issues that are enough to be deal-breakers as far as
maintainability, usability or readability. To work around this, I'm
putting together `bevy_dmath`, a crate that duplicates `bevy_math` types
and functionality to allow downstream users to enjoy the ergonomics and
power of `bevy_math` in double-precision. For the most part, it's a
smooth process, but in order to fully integrate, there are some
necessary changes that can only be made in `bevy_math`.

## Solution

This PR addresses the first and easiest issue with downstream
double-precision math support: `VectorSpace` currently can only
represent vector spaces over `f32`. This automatically closes the door
to double-precision curves, among other things. This restriction can be
easily lifted by allowing vector spaces to specify the underlying scalar
field. This PR adds a new trait `ScalarField` that satisfies the
properties of a scalar field (the ones that can be upheld statically)
and adds a new associated type `type Scalar: ScalarField` to
`VectorSpace`. It's mostly an unintrusive change. The biggest annoyances
are:

- it touches a lot of curve code
- `bevy_math::ops` doesn't support `f64`, so there are some annoying
workarounds

As far as curves code, I wanted to make this change unintrusive and
bite-sized, so I'm trying to touch as little code as possible. To prove
to myself it can be done, I went ahead and (*not* in this PR) migrated
most of the curves API to support different `ScalarField`s and it went
really smoothly! The ugliest thing was adding `P::Scalar: From<usize>`
in several places. There's an argument to be made here that we should be
using `num-traits`, but that's not immediately relevant. The point is
that for now, the smallest change I could make was to go into every
curve impl and make them generic over `VectorSpace<Scalar = f32>`.
Curves work exactly like before and don't change the user API at all.

# Follow-up

- **Extend `bevy_math::ops` to work with `f64`.** `bevy_math::ops` is
used all over, and if curves are ever going to support different
`ScalarField` types, we'll need to be able to use the correct `std` or
`libm` ops for `f64` types as well. Adding an `ops64` mod turned out to
be really ugly, but I'll point out the maintenance burden is low because
we're not going to be adding new floating-point ops anytime soon.
Another solution is to build a floating-point trait that calls the right
op variant and impl it for `f32` and `f64`. This reduces maintenance
burden because on the off chance we ever *do* want to go modify it, it's
all tied together: you can't change the interface on one without
changing the trait, which forces you to update the other. A third option
is to use `num-traits`, which is basically option 2 but someone else did
the work for us. They already support `no_std` using `libm`, so it would
be more or less a drop-in replacement. They're missing a couple
floating-point ops like `floor` and `ceil`, but we could make our own
floating-point traits for those (there's even the potential for
upstreaming them into `num-traits`).
- **Tweak curves to accept vector spaces over any `ScalarField`.**
Curves are ready to support custom scalar types as soon as the bullet
above is addressed. I will admit that the code is not as fun to look at:
`P::Scalar` instead of `f32` everywhere. We could consider an alternate
design where we use `f32` even to interpolate something like a `DVec3`,
but personally I think that's a worse solution than parameterizing
curves over the vector space's scalar type. At the end of the day, it's
not really bad to deal with in my opinion... `ScalarType` supports
enough operations that working with them is almost like working with raw
float types, and it unlocks a whole ecosystem for games that want to use
double-precision.
2025-06-08 02:02:47 +00:00

436 lines
14 KiB
Rust

//! Concrete curve structures used to load glTF curves into the animation system.
use bevy_math::{
curve::{cores::*, iterable::IterableCurve, *},
vec4, Quat, Vec4, VectorSpace,
};
use bevy_reflect::Reflect;
use either::Either;
use thiserror::Error;
/// A keyframe-defined curve that "interpolates" by stepping at `t = 1.0` to the next keyframe.
#[derive(Debug, Clone, Reflect)]
pub struct SteppedKeyframeCurve<T> {
core: UnevenCore<T>,
}
impl<T> Curve<T> for SteppedKeyframeCurve<T>
where
T: Clone,
{
#[inline]
fn domain(&self) -> Interval {
self.core.domain()
}
#[inline]
fn sample_clamped(&self, t: f32) -> T {
self.core
.sample_with(t, |x, y, t| if t >= 1.0 { y.clone() } else { x.clone() })
}
#[inline]
fn sample_unchecked(&self, t: f32) -> T {
self.sample_clamped(t)
}
}
impl<T> SteppedKeyframeCurve<T> {
/// Create a new [`SteppedKeyframeCurve`]. If the curve could not be constructed from the
/// given data, an error is returned.
#[inline]
pub fn new(timed_samples: impl IntoIterator<Item = (f32, T)>) -> Result<Self, UnevenCoreError> {
Ok(Self {
core: UnevenCore::new(timed_samples)?,
})
}
}
/// A keyframe-defined curve that uses cubic spline interpolation, backed by a contiguous buffer.
#[derive(Debug, Clone, Reflect)]
pub struct CubicKeyframeCurve<T> {
// Note: the sample width here should be 3.
core: ChunkedUnevenCore<T>,
}
impl<V> Curve<V> for CubicKeyframeCurve<V>
where
V: VectorSpace<Scalar = f32>,
{
#[inline]
fn domain(&self) -> Interval {
self.core.domain()
}
#[inline]
fn sample_clamped(&self, t: f32) -> V {
match self.core.sample_interp_timed(t) {
// In all the cases where only one frame matters, defer to the position within it.
InterpolationDatum::Exact((_, v))
| InterpolationDatum::LeftTail((_, v))
| InterpolationDatum::RightTail((_, v)) => v[1],
InterpolationDatum::Between((t0, u), (t1, v), s) => {
cubic_spline_interpolation(u[1], u[2], v[0], v[1], s, t1 - t0)
}
}
}
#[inline]
fn sample_unchecked(&self, t: f32) -> V {
self.sample_clamped(t)
}
}
impl<T> CubicKeyframeCurve<T> {
/// Create a new [`CubicKeyframeCurve`] from keyframe `times` and their associated `values`.
/// Because 3 values are needed to perform cubic interpolation, `values` must have triple the
/// length of `times` — each consecutive triple `a_k, v_k, b_k` associated to time `t_k`
/// consists of:
/// - The in-tangent `a_k` for the sample at time `t_k`
/// - The actual value `v_k` for the sample at time `t_k`
/// - The out-tangent `b_k` for the sample at time `t_k`
///
/// For example, for a curve built from two keyframes, the inputs would have the following form:
/// - `times`: `[t_0, t_1]`
/// - `values`: `[a_0, v_0, b_0, a_1, v_1, b_1]`
#[inline]
pub fn new(
times: impl IntoIterator<Item = f32>,
values: impl IntoIterator<Item = T>,
) -> Result<Self, ChunkedUnevenCoreError> {
Ok(Self {
core: ChunkedUnevenCore::new(times, values, 3)?,
})
}
}
// NOTE: We can probably delete `CubicRotationCurve` once we improve the `Reflect` implementations
// for the `Curve` API adaptors; this is basically a `CubicKeyframeCurve` composed with `map`.
/// A keyframe-defined curve that uses cubic spline interpolation, special-cased for quaternions
/// since it uses `Vec4` internally.
#[derive(Debug, Clone, Reflect)]
#[reflect(Clone)]
pub struct CubicRotationCurve {
// Note: The sample width here should be 3.
core: ChunkedUnevenCore<Vec4>,
}
impl Curve<Quat> for CubicRotationCurve {
#[inline]
fn domain(&self) -> Interval {
self.core.domain()
}
#[inline]
fn sample_clamped(&self, t: f32) -> Quat {
let vec = match self.core.sample_interp_timed(t) {
// In all the cases where only one frame matters, defer to the position within it.
InterpolationDatum::Exact((_, v))
| InterpolationDatum::LeftTail((_, v))
| InterpolationDatum::RightTail((_, v)) => v[1],
InterpolationDatum::Between((t0, u), (t1, v), s) => {
cubic_spline_interpolation(u[1], u[2], v[0], v[1], s, t1 - t0)
}
};
Quat::from_vec4(vec.normalize())
}
#[inline]
fn sample_unchecked(&self, t: f32) -> Quat {
self.sample_clamped(t)
}
}
impl CubicRotationCurve {
/// Create a new [`CubicRotationCurve`] from keyframe `times` and their associated `values`.
/// Because 3 values are needed to perform cubic interpolation, `values` must have triple the
/// length of `times` — each consecutive triple `a_k, v_k, b_k` associated to time `t_k`
/// consists of:
/// - The in-tangent `a_k` for the sample at time `t_k`
/// - The actual value `v_k` for the sample at time `t_k`
/// - The out-tangent `b_k` for the sample at time `t_k`
///
/// For example, for a curve built from two keyframes, the inputs would have the following form:
/// - `times`: `[t_0, t_1]`
/// - `values`: `[a_0, v_0, b_0, a_1, v_1, b_1]`
///
/// To sample quaternions from this curve, the resulting interpolated `Vec4` output is normalized
/// and interpreted as a quaternion.
pub fn new(
times: impl IntoIterator<Item = f32>,
values: impl IntoIterator<Item = Vec4>,
) -> Result<Self, ChunkedUnevenCoreError> {
Ok(Self {
core: ChunkedUnevenCore::new(times, values, 3)?,
})
}
}
/// A keyframe-defined curve that uses linear interpolation over many samples at once, backed
/// by a contiguous buffer.
#[derive(Debug, Clone, Reflect)]
pub struct WideLinearKeyframeCurve<T> {
// Here the sample width is the number of things to simultaneously interpolate.
core: ChunkedUnevenCore<T>,
}
impl<T> IterableCurve<T> for WideLinearKeyframeCurve<T>
where
T: VectorSpace<Scalar = f32>,
{
#[inline]
fn domain(&self) -> Interval {
self.core.domain()
}
#[inline]
fn sample_iter_clamped(&self, t: f32) -> impl Iterator<Item = T> {
match self.core.sample_interp(t) {
InterpolationDatum::Exact(v)
| InterpolationDatum::LeftTail(v)
| InterpolationDatum::RightTail(v) => Either::Left(v.iter().copied()),
InterpolationDatum::Between(u, v, s) => {
let interpolated = u.iter().zip(v.iter()).map(move |(x, y)| x.lerp(*y, s));
Either::Right(interpolated)
}
}
}
#[inline]
fn sample_iter_unchecked(&self, t: f32) -> impl Iterator<Item = T> {
self.sample_iter_clamped(t)
}
}
impl<T> WideLinearKeyframeCurve<T> {
/// Create a new [`WideLinearKeyframeCurve`]. An error will be returned if:
/// - `values` has length zero.
/// - `times` has less than `2` unique valid entries.
/// - The length of `values` is not divisible by that of `times` (once sorted, filtered,
/// and deduplicated).
#[inline]
pub fn new(
times: impl IntoIterator<Item = f32>,
values: impl IntoIterator<Item = T>,
) -> Result<Self, WideKeyframeCurveError> {
Ok(Self {
core: ChunkedUnevenCore::new_width_inferred(times, values)?,
})
}
}
/// A keyframe-defined curve that uses stepped "interpolation" over many samples at once, backed
/// by a contiguous buffer.
#[derive(Debug, Clone, Reflect)]
pub struct WideSteppedKeyframeCurve<T> {
// Here the sample width is the number of things to simultaneously interpolate.
core: ChunkedUnevenCore<T>,
}
impl<T> IterableCurve<T> for WideSteppedKeyframeCurve<T>
where
T: Clone,
{
#[inline]
fn domain(&self) -> Interval {
self.core.domain()
}
#[inline]
fn sample_iter_clamped(&self, t: f32) -> impl Iterator<Item = T> {
match self.core.sample_interp(t) {
InterpolationDatum::Exact(v)
| InterpolationDatum::LeftTail(v)
| InterpolationDatum::RightTail(v) => Either::Left(v.iter().cloned()),
InterpolationDatum::Between(u, v, s) => {
let interpolated =
u.iter()
.zip(v.iter())
.map(move |(x, y)| if s >= 1.0 { y.clone() } else { x.clone() });
Either::Right(interpolated)
}
}
}
#[inline]
fn sample_iter_unchecked(&self, t: f32) -> impl Iterator<Item = T> {
self.sample_iter_clamped(t)
}
}
impl<T> WideSteppedKeyframeCurve<T> {
/// Create a new [`WideSteppedKeyframeCurve`]. An error will be returned if:
/// - `values` has length zero.
/// - `times` has less than `2` unique valid entries.
/// - The length of `values` is not divisible by that of `times` (once sorted, filtered,
/// and deduplicated).
#[inline]
pub fn new(
times: impl IntoIterator<Item = f32>,
values: impl IntoIterator<Item = T>,
) -> Result<Self, WideKeyframeCurveError> {
Ok(Self {
core: ChunkedUnevenCore::new_width_inferred(times, values)?,
})
}
}
/// A keyframe-defined curve that uses cubic interpolation over many samples at once, backed by a
/// contiguous buffer.
#[derive(Debug, Clone, Reflect)]
pub struct WideCubicKeyframeCurve<T> {
core: ChunkedUnevenCore<T>,
}
impl<T> IterableCurve<T> for WideCubicKeyframeCurve<T>
where
T: VectorSpace<Scalar = f32>,
{
#[inline]
fn domain(&self) -> Interval {
self.core.domain()
}
fn sample_iter_clamped(&self, t: f32) -> impl Iterator<Item = T> {
match self.core.sample_interp_timed(t) {
InterpolationDatum::Exact((_, v))
| InterpolationDatum::LeftTail((_, v))
| InterpolationDatum::RightTail((_, v)) => {
// Pick out the part of this that actually represents the position (instead of tangents),
// which is the middle third.
let width = self.core.width();
Either::Left(v[width..(width * 2)].iter().copied())
}
InterpolationDatum::Between((t0, u), (t1, v), s) => Either::Right(
cubic_spline_interpolate_slices(self.core.width() / 3, u, v, s, t1 - t0),
),
}
}
#[inline]
fn sample_iter_unchecked(&self, t: f32) -> impl Iterator<Item = T> {
self.sample_iter_clamped(t)
}
}
/// An error indicating that a multisampling keyframe curve could not be constructed.
#[derive(Debug, Error)]
#[error("unable to construct a curve using this data")]
pub enum WideKeyframeCurveError {
/// The number of given values was not divisible by a multiple of the number of keyframes.
#[error("number of values ({values_given}) is not divisible by {divisor}")]
LengthMismatch {
/// The number of values given.
values_given: usize,
/// The number that `values_given` was supposed to be divisible by.
divisor: usize,
},
/// An error was returned by the internal core constructor.
#[error(transparent)]
CoreError(#[from] ChunkedUnevenCoreError),
}
impl<T> WideCubicKeyframeCurve<T> {
/// Create a new [`WideCubicKeyframeCurve`].
///
/// An error will be returned if:
/// - `values` has length zero.
/// - `times` has less than `2` unique valid entries.
/// - The length of `values` is not divisible by three times that of `times` (once sorted,
/// filtered, and deduplicated).
#[inline]
pub fn new(
times: impl IntoIterator<Item = f32>,
values: impl IntoIterator<Item = T>,
) -> Result<Self, WideKeyframeCurveError> {
let times: Vec<f32> = times.into_iter().collect();
let values: Vec<T> = values.into_iter().collect();
let divisor = times.len() * 3;
if values.len() % divisor != 0 {
return Err(WideKeyframeCurveError::LengthMismatch {
values_given: values.len(),
divisor,
});
}
Ok(Self {
core: ChunkedUnevenCore::new_width_inferred(times, values)?,
})
}
}
/// A curve specifying the [`MorphWeights`] for a mesh in animation. The variants are broken
/// down by interpolation mode (with the exception of `Constant`, which never interpolates).
///
/// This type is, itself, a `Curve<Vec<f32>>`; however, in order to avoid allocation, it is
/// recommended to use its implementation of the [`IterableCurve`] trait, which allows iterating
/// directly over information derived from the curve without allocating.
///
/// [`MorphWeights`]: bevy_mesh::morph::MorphWeights
#[derive(Debug, Clone, Reflect)]
#[reflect(Clone)]
pub enum WeightsCurve {
/// A curve which takes a constant value over its domain. Notably, this is how animations with
/// only a single keyframe are interpreted.
Constant(ConstantCurve<Vec<f32>>),
/// A curve which interpolates weights linearly between keyframes.
Linear(WideLinearKeyframeCurve<f32>),
/// A curve which interpolates weights between keyframes in steps.
Step(WideSteppedKeyframeCurve<f32>),
/// A curve which interpolates between keyframes by using auxiliary tangent data to join
/// adjacent keyframes with a cubic Hermite spline, which is then sampled.
CubicSpline(WideCubicKeyframeCurve<f32>),
}
//---------//
// HELPERS //
//---------//
/// Helper function for cubic spline interpolation.
fn cubic_spline_interpolation<T>(
value_start: T,
tangent_out_start: T,
tangent_in_end: T,
value_end: T,
lerp: f32,
step_duration: f32,
) -> T
where
T: VectorSpace<Scalar = f32>,
{
let coeffs = (vec4(2.0, 1.0, -2.0, 1.0) * lerp + vec4(-3.0, -2.0, 3.0, -1.0)) * lerp;
value_start * (coeffs.x * lerp + 1.0)
+ tangent_out_start * step_duration * lerp * (coeffs.y + 1.0)
+ value_end * lerp * coeffs.z
+ tangent_in_end * step_duration * lerp * coeffs.w
}
fn cubic_spline_interpolate_slices<'a, T: VectorSpace<Scalar = f32>>(
width: usize,
first: &'a [T],
second: &'a [T],
s: f32,
step_between: f32,
) -> impl Iterator<Item = T> + 'a {
(0..width).map(move |idx| {
cubic_spline_interpolation(
first[idx + width],
first[idx + (width * 2)],
second[idx + width],
second[idx],
s,
step_between,
)
})
}