curve fetching optimization for animation system

- gives 2x-3x performance boost on average
- essentially turns binary search of a span into simple indexing using stored index value from the previous frame (so called "fetch hint")
This commit is contained in:
Dmitry Stepanov
2026-07-13 21:46:52 +03:00
parent e8f3067e17
commit b81f3a8469
5 changed files with 134 additions and 59 deletions
+53 -28
View File
@@ -21,6 +21,7 @@
//! Track data container is a flexible source of data for numeric parameters, it is built using a set
//! of parametric curves. See [`TrackDataContainer`] docs for more info.
use crate::track::HintContainer;
use crate::{
core::{
algebra::{Vector2, Vector3, Vector4},
@@ -159,7 +160,7 @@ impl TrackDataContainer {
}
#[inline(always)]
fn fetch_vector2(&self, time: f32) -> Option<TrackValue> {
fn fetch_vector2(&self, time: f32, fetch_hints: &mut HintContainer) -> Option<TrackValue> {
if self.curves.len() < 2 {
return None;
}
@@ -167,29 +168,39 @@ impl TrackDataContainer {
// SAFETY: The indices are guaranteed to be correct by the above check.
unsafe {
Some(TrackValue::Vector2(Vector2::new(
self.curves.get_unchecked(0).value_at(time),
self.curves.get_unchecked(1).value_at(time),
self.curves
.get_unchecked(0)
.value_at(time, &mut fetch_hints[0]),
self.curves
.get_unchecked(1)
.value_at(time, &mut fetch_hints[1]),
)))
}
}
#[inline(always)]
fn fetch_vector3(&self, time: f32) -> Option<TrackValue> {
fn fetch_vector3(&self, time: f32, fetch_hints: &mut HintContainer) -> Option<TrackValue> {
if self.curves.len() < 3 {
return None;
}
unsafe {
Some(TrackValue::Vector3(Vector3::new(
self.curves.get_unchecked(0).value_at(time),
self.curves.get_unchecked(1).value_at(time),
self.curves.get_unchecked(2).value_at(time),
self.curves
.get_unchecked(0)
.value_at(time, &mut fetch_hints[0]),
self.curves
.get_unchecked(1)
.value_at(time, &mut fetch_hints[1]),
self.curves
.get_unchecked(2)
.value_at(time, &mut fetch_hints[2]),
)))
}
}
#[inline(always)]
fn fetch_vector4(&self, time: f32) -> Option<TrackValue> {
fn fetch_vector4(&self, time: f32, fetch_hints: &mut HintContainer) -> Option<TrackValue> {
if self.curves.len() < 4 {
return None;
}
@@ -197,16 +208,28 @@ impl TrackDataContainer {
// SAFETY: The indices are guaranteed to be correct by the above check.
unsafe {
Some(TrackValue::Vector4(Vector4::new(
self.curves.get_unchecked(0).value_at(time),
self.curves.get_unchecked(1).value_at(time),
self.curves.get_unchecked(2).value_at(time),
self.curves.get_unchecked(3).value_at(time),
self.curves
.get_unchecked(0)
.value_at(time, &mut fetch_hints[0]),
self.curves
.get_unchecked(1)
.value_at(time, &mut fetch_hints[1]),
self.curves
.get_unchecked(2)
.value_at(time, &mut fetch_hints[2]),
self.curves
.get_unchecked(3)
.value_at(time, &mut fetch_hints[3]),
)))
}
}
#[inline(always)]
fn fetch_quaternion_euler(&self, time: f32) -> Option<TrackValue> {
fn fetch_quaternion_euler(
&self,
time: f32,
fetch_hints: &mut HintContainer,
) -> Option<TrackValue> {
if self.curves.len() < 3 {
return None;
}
@@ -219,9 +242,9 @@ impl TrackDataContainer {
// Convert Euler angles to quaternion
let (x, y, z) = (
x_curve.value_at(time),
y_curve.value_at(time),
z_curve.value_at(time),
x_curve.value_at(time, &mut fetch_hints[0]),
y_curve.value_at(time, &mut fetch_hints[1]),
z_curve.value_at(time, &mut fetch_hints[1]),
);
Some(TrackValue::UnitQuaternion(quat_from_euler(
@@ -232,7 +255,7 @@ impl TrackDataContainer {
}
#[inline(always)]
fn fetch_quaternion(&self, time: f32) -> Option<TrackValue> {
fn fetch_quaternion(&self, time: f32, fetch_hints: &mut HintContainer) -> Option<TrackValue> {
if self.curves.len() < 4 {
return None;
}
@@ -246,10 +269,10 @@ impl TrackDataContainer {
// Convert Euler angles to quaternion
let (x, y, z, w) = (
x_curve.value_at(time),
y_curve.value_at(time),
z_curve.value_at(time),
w_curve.value_at(time),
x_curve.value_at(time, &mut fetch_hints[0]),
y_curve.value_at(time, &mut fetch_hints[1]),
z_curve.value_at(time, &mut fetch_hints[2]),
w_curve.value_at(time, &mut fetch_hints[3]),
);
Some(TrackValue::UnitQuaternion(UnitQuaternion::from_quaternion(
@@ -261,14 +284,16 @@ impl TrackDataContainer {
/// Tries to get a value at a given time. The method could fail if the internal set of curves is malformed
/// and cannot produce a desired value (for example, [`Vector3`] can be fetched only if the amount of curves
/// is 3).
pub fn fetch(&self, time: f32) -> Option<TrackValue> {
pub fn fetch(&self, time: f32, fetch_hints: &mut HintContainer) -> Option<TrackValue> {
match self.kind {
TrackValueKind::Real => Some(TrackValue::Real(self.curves.first()?.value_at(time))),
TrackValueKind::Vector2 => self.fetch_vector2(time),
TrackValueKind::Vector3 => self.fetch_vector3(time),
TrackValueKind::Vector4 => self.fetch_vector4(time),
TrackValueKind::UnitQuaternionEuler => self.fetch_quaternion_euler(time),
TrackValueKind::UnitQuaternion => self.fetch_quaternion(time),
TrackValueKind::Real => Some(TrackValue::Real(
self.curves.first()?.value_at(time, &mut fetch_hints[0]),
)),
TrackValueKind::Vector2 => self.fetch_vector2(time, fetch_hints),
TrackValueKind::Vector3 => self.fetch_vector3(time, fetch_hints),
TrackValueKind::Vector4 => self.fetch_vector4(time, fetch_hints),
TrackValueKind::UnitQuaternionEuler => self.fetch_quaternion_euler(time, fetch_hints),
TrackValueKind::UnitQuaternion => self.fetch_quaternion(time, fetch_hints),
}
}
+6 -5
View File
@@ -25,7 +25,7 @@
#![allow(clippy::doc_lazy_continuation)]
#![allow(mismatched_lifetime_syntaxes)]
use crate::track::TrackBinding;
use crate::track::{HintContainer, TrackBinding};
use crate::{
core::{
algebra::{UnitQuaternion, Vector3},
@@ -504,7 +504,7 @@ impl<T: EntityId> Animation<T> {
tracks
.iter()
.find(|track| track.value_binding() == &ValueBinding::Position)
.and_then(|track| track.fetch(time))
.and_then(|track| track.fetch(time, &mut HintContainer::default()))
.and_then(|value| {
if let TrackValue::Vector3(position) = value.value {
Some(position)
@@ -519,7 +519,7 @@ impl<T: EntityId> Animation<T> {
tracks
.iter()
.find(|track| track.value_binding() == &ValueBinding::Rotation)
.and_then(|track| track.fetch(time))
.and_then(|track| track.fetch(time, &mut HintContainer::default()))
.and_then(|value| {
if let TrackValue::UnitQuaternion(rotation) = value.value {
Some(rotation)
@@ -898,12 +898,13 @@ impl<T: EntityId> Animation<T> {
self.pose.reset();
for track in tracks_data.tracks.iter() {
let Some(binding) = self.track_bindings.get(&track.id()) else {
let Some(binding) = self.track_bindings.get_mut(&track.id()) else {
continue;
};
if binding.is_enabled() {
if let Some(bound_value) = track.fetch(self.time_position) {
if let Some(bound_value) = track.fetch(self.time_position, &mut binding.fetch_hints)
{
self.pose.add_to_node_pose(binding.target(), bound_value);
}
}
+12 -2
View File
@@ -28,6 +28,11 @@ use crate::{
};
use std::fmt::Debug;
/// A set of indices of keys, where each index points on the right side of a curve span. Each index
/// corresponds in the array to a curve in the curve set. It is used to remember last curve fetching
/// position to eliminate searching of a span for most cases.
pub type HintContainer = [usize; 4];
/// Track binding contains a handle to a target object that will be animated by an animation track.
/// Additionally, the binding could be disabled to temporarily prevent animation from affecting the
/// target.
@@ -39,6 +44,9 @@ pub struct TrackBinding<T: EntityId> {
/// A target bound to a track. The actual track id is stored as a key in hash map of bindings in
/// the animation.
pub target: T,
#[visit(skip)]
#[reflect(hidden)]
pub(super) fetch_hints: HintContainer,
}
impl<T: EntityId> Default for TrackBinding<T> {
@@ -46,6 +54,7 @@ impl<T: EntityId> Default for TrackBinding<T> {
Self {
enabled: true,
target: Default::default(),
fetch_hints: Default::default(),
}
}
}
@@ -56,6 +65,7 @@ impl<T: EntityId> TrackBinding<T> {
Self {
enabled: true,
target,
fetch_hints: Default::default(),
}
}
@@ -171,8 +181,8 @@ impl Track {
}
/// Tries to get a new property value at a given time position.
pub fn fetch(&self, time: f32) -> Option<BoundValue> {
self.frames.fetch(time).map(|v| BoundValue {
pub fn fetch(&self, time: f32, fetch_hints: &mut HintContainer) -> Option<BoundValue> {
self.frames.fetch(time, fetch_hints).map(|v| BoundValue {
binding: self.binding.clone(),
value: v,
})
+57 -22
View File
@@ -251,22 +251,57 @@ impl Curve {
}
#[inline]
fn fetch_at<I>(&self, location: f32, interpolator: I) -> f32
fn fetch_at<I>(&self, location: f32, hint: &mut usize, interpolator: I) -> f32
where
I: FnOnce(&CurveKey, &CurveKey, f32) -> f32,
{
#[inline(always)]
fn interpolate_keys<I>(
left: &CurveKey,
right: &CurveKey,
location: f32,
interpolator: I,
) -> f32
where
I: FnOnce(&CurveKey, &CurveKey, f32) -> f32,
{
let t = (location - left.location) / (right.location - left.location);
interpolator(left, right, t)
}
if let (Some(first), Some(last)) = (self.keys.first(), self.keys.last()) {
if location <= first.location {
*hint = 0;
first.value
} else if location >= last.location {
*hint = self.keys().len().saturating_sub(1);
last.value
} else {
// Try to predict the span using the given hint.
if let (Some(predicted_left), Some(predicted_right)) = (
self.keys.get((*hint).saturating_sub(1)),
self.keys.get(*hint),
) {
if location >= predicted_left.location && location < predicted_right.location {
return interpolate_keys(
predicted_left,
predicted_right,
location,
interpolator,
);
} else {
// TODO: Usually curve fetching is continuous, so we should also check the
// next or previous span here, this will completely eliminate binary search
// below on case when playback time step is lower than mid-key time distance.
}
}
// Use binary search for multiple spans.
let pos = self.keys.partition_point(|k| k.location < location);
let left = self.keys.get(pos.saturating_sub(1)).unwrap();
let right = self.keys.get(pos).unwrap();
let t = (location - left.location) / (right.location - left.location);
interpolator(left, right, t)
*hint = self.keys.partition_point(|k| k.location < location);
let span_end = *hint;
let left = self.keys.get(span_end.saturating_sub(1)).unwrap();
let right = self.keys.get(span_end).unwrap();
interpolate_keys(left, right, location, interpolator)
}
} else {
0.0
@@ -274,13 +309,13 @@ impl Curve {
}
#[inline]
pub fn value_at(&self, location: f32) -> f32 {
self.fetch_at(location, |a, b, t| a.interpolate(b, t))
pub fn value_at(&self, location: f32, hint: &mut usize) -> f32 {
self.fetch_at(location, hint, |a, b, t| a.interpolate(b, t))
}
#[inline]
pub fn angle_at(&self, location: f32) -> f32 {
self.fetch_at(location, |a, b, t| a.interpolate_angles(b, t))
pub fn angle_at(&self, location: f32, hint: &mut usize) -> f32 {
self.fetch_at(location, hint, |a, b, t| a.interpolate_angles(b, t))
}
pub fn bounds(&self) -> Rect<f32> {
@@ -396,21 +431,21 @@ mod test {
let mut curve = Curve::default();
// Test fetching from empty curve.
assert_eq!(curve.value_at(0.0), 0.0);
assert_eq!(curve.value_at(0.0, &mut 0), 0.0);
curve.add_key(CurveKey::new(0.0, 1.0, CurveKeyKind::Linear));
// One-key curves must always return its single key value.
assert_eq!(curve.value_at(-1.0), 1.0);
assert_eq!(curve.value_at(1.0), 1.0);
assert_eq!(curve.value_at(0.0), 1.0);
assert_eq!(curve.value_at(-1.0, &mut 0), 1.0);
assert_eq!(curve.value_at(1.0, &mut 0), 1.0);
assert_eq!(curve.value_at(0.0, &mut 0), 1.0);
curve.add_key(CurveKey::new(1.0, 0.0, CurveKeyKind::Linear));
// Two-key curves must always use interpolation.
assert_eq!(curve.value_at(-1.0), 1.0);
assert_eq!(curve.value_at(2.0), 0.0);
assert_eq!(curve.value_at(0.5), 0.5);
assert_eq!(curve.value_at(-1.0, &mut 0), 1.0);
assert_eq!(curve.value_at(2.0, &mut 0), 0.0);
assert_eq!(curve.value_at(0.5, &mut 0), 0.5);
// Add one more key and do more checks.
curve.add_key(CurveKey::new(2.0, 1.0, CurveKeyKind::Linear));
@@ -420,15 +455,15 @@ mod test {
assert!(curve.keys[1].location <= curve.keys[2].location);
// Check generic out-of-bounds fetching.
assert_eq!(curve.value_at(-1.0), 1.0); // Left side oob
assert_eq!(curve.value_at(3.0), 1.0); // Right side oob.
assert_eq!(curve.value_at(-1.0, &mut 0), 1.0); // Left side oob
assert_eq!(curve.value_at(3.0, &mut 0), 1.0); // Right side oob.
// Check edge cases.
assert_eq!(curve.value_at(0.0), 1.0); // Left edge.
assert_eq!(curve.value_at(2.0), 1.0); // Right edge.
assert_eq!(curve.value_at(0.0, &mut 0), 1.0); // Left edge.
assert_eq!(curve.value_at(2.0, &mut 0), 1.0); // Right edge.
// Check interpolation.
assert_eq!(curve.value_at(0.5), 0.5);
assert_eq!(curve.value_at(0.5, &mut 0), 0.5);
// Check id.
let id = Uuid::new_v4();
+6 -2
View File
@@ -260,7 +260,9 @@ impl ScriptTrait for FlyingCameraController {
.min(1.0);
*self.target_velocity = new_normalized_velocity.scale(
*self.speed
* self.acceleration_curve.value_at(self.acceleration_coeff)
* self
.acceleration_curve
.value_at(self.acceleration_coeff, &mut 0)
* context.dt,
);
} else {
@@ -270,7 +272,9 @@ impl ScriptTrait for FlyingCameraController {
if let Some(normalized_velocity) = self.target_velocity.try_normalize(f32::EPSILON) {
*self.target_velocity = normalized_velocity.scale(
*self.speed
* self.deceleration_curve.value_at(self.acceleration_coeff)
* self
.deceleration_curve
.value_at(self.acceleration_coeff, &mut 0)
* context.dt,
);
} else {