bevy_director 0.7.0-dev

Unreal-Sequencer-inspired cinematic camera and sequence system for Bevy
Documentation
//! Sampling helpers over bevy_math's curves, and the piece bevy does not
//! ship: arc-length reparametrization, so a dolly on a spline moves at
//! constant speed instead of lurching between control points.

use bevy::{
    math::curve::{Curve, Interval},
    prelude::*,
};

use crate::sequence::Key;

/// Sample an eased segment between two keys at a shot-local time.
/// Positions lerp and rotations slerp under the SAME eased weight, so the
/// framing travels as one. Returns the rotation only when both keys have
/// one.
pub(crate) fn eased_segment(a: &Key, b: &Key, t: f32) -> (Vec3, Option<Quat>) {
    let w = eased_fraction(a, b, t);
    let pos = a.pos.lerp(b.pos, w);
    let rot = match (a.rot, b.rot) {
        (Some(qa), Some(qb)) => Some(qa.slerp(qb, w)),
        _ => None,
    };
    (pos, rot)
}

/// The eased 0..1 weight between two keys at a shot-local time.
pub(crate) fn eased_fraction(a: &Key, b: &Key, t: f32) -> f32 {
    let span = (b.time - a.time).max(f32::EPSILON);
    a.ease.sample_clamped(((t - a.time) / span).clamp(0.0, 1.0))
}

/// Cumulative chord lengths over a curve, sampled at uniform parameter
/// steps. Inverting it (length in, parameter out) is what makes constant
/// speed possible on splines that bevy samples in parameter space.
pub struct ArcLengthLut {
    /// lengths[i] = arc length from domain start to sample i.
    lengths: Vec<f32>,
    domain: Interval,
}

impl ArcLengthLut {
    pub fn new(curve: &impl Curve<Vec3>, samples: usize) -> Self {
        let samples = samples.max(2);
        let domain = curve.domain();
        let mut lengths = Vec::with_capacity(samples);
        lengths.push(0.0);
        let mut prev = curve.sample_clamped(domain.start());
        let mut total = 0.0;
        for i in 1..samples {
            let t = domain.start() + domain.length() * i as f32 / (samples - 1) as f32;
            let p = curve.sample_clamped(t);
            total += p.distance(prev);
            lengths.push(total);
            prev = p;
        }
        Self { lengths, domain }
    }

    pub fn total_length(&self) -> f32 {
        *self.lengths.last().unwrap_or(&0.0)
    }

    /// The curve parameter (in the curve's own domain) where the arc
    /// length from the start reaches `s`. Clamped at both ends.
    pub fn t_at_length(&self, s: f32) -> f32 {
        let total = self.total_length();
        if total <= 0.0 {
            return self.domain.start();
        }
        let s = s.clamp(0.0, total);
        // Binary search the cumulative table, then lerp inside the step.
        let i = self.lengths.partition_point(|&l| l < s);
        let steps = (self.lengths.len() - 1) as f32;
        let frac = if i == 0 {
            0.0
        } else {
            let (l0, l1) = (self.lengths[i - 1], self.lengths[i]);
            let inside = if l1 > l0 { (s - l0) / (l1 - l0) } else { 0.0 };
            (i as f32 - 1.0 + inside) / steps
        };
        self.domain.start() + self.domain.length() * frac
    }

    /// Map a 0..1 travel fraction to the curve parameter that lies that
    /// fraction of the LENGTH along, not that fraction of the parameter.
    pub fn t_at_fraction(&self, fraction: f32) -> f32 {
        self.t_at_length(self.total_length() * fraction.clamp(0.0, 1.0))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use bevy::math::curve::FunctionCurve;
    use std::f32::consts::TAU;

    #[test]
    fn eased_segment_matches_easefunction_samples() {
        let a = Key {
            time: 1.0,
            pos: Vec3::ZERO,
            rot: Some(Quat::IDENTITY),
            ease: EaseFunction::SmoothStep,
        };
        let b = Key {
            time: 3.0,
            pos: Vec3::new(10.0, 0.0, 0.0),
            rot: Some(Quat::from_rotation_y(1.0)),
            ease: EaseFunction::Linear,
        };
        // Halfway in time; SmoothStep(0.5) = 0.5, so midpoint.
        let (pos, rot) = eased_segment(&a, &b, 2.0);
        assert!((pos.x - 5.0).abs() < 1e-5);
        // A quarter in time: SmoothStep(0.25) = 3t^2 - 2t^3 = 0.15625.
        let (pos, _) = eased_segment(&a, &b, 1.5);
        assert!((pos.x - 1.5625).abs() < 1e-4);
        // Clamped outside the span.
        let (pos, _) = eased_segment(&a, &b, 0.0);
        assert!(pos.x.abs() < 1e-6);
        let (pos, rot_end) = eased_segment(&a, &b, 9.0);
        assert!((pos.x - 10.0).abs() < 1e-5);
        assert!(rot.is_some() && rot_end.is_some());
    }

    #[test]
    fn missing_rotation_on_either_side_yields_none() {
        let a = Key {
            time: 0.0,
            pos: Vec3::ZERO,
            rot: None,
            ease: EaseFunction::Linear,
        };
        let b = Key {
            time: 1.0,
            pos: Vec3::X,
            rot: Some(Quat::IDENTITY),
            ease: EaseFunction::Linear,
        };
        assert!(eased_segment(&a, &b, 0.5).1.is_none());
    }

    #[test]
    fn arclength_lut_matches_analytic_circle() {
        let radius = 3.0;
        let circle = FunctionCurve::new(Interval::UNIT, move |t: f32| {
            Vec3::new((t * TAU).cos(), (t * TAU).sin(), 0.0) * radius
        });
        let lut = ArcLengthLut::new(&circle, 512);
        let circumference = TAU * radius;
        assert!(
            (lut.total_length() - circumference).abs() / circumference < 1e-3,
            "lut total {} vs analytic {}",
            lut.total_length(),
            circumference
        );
        // Half the length around a circle is half the parameter.
        let t_half = lut.t_at_length(circumference / 2.0);
        assert!((t_half - 0.5).abs() < 1e-3);
    }

    #[test]
    fn constant_speed_fractions_are_equidistant_on_uneven_curves() {
        // Parameter crawls at the start (t^3), so uniform parameter
        // sampling would bunch points; uniform LENGTH must not.
        let line = FunctionCurve::new(Interval::UNIT, |t: f32| {
            Vec3::new(t * t * t * 10.0, 0.0, 0.0)
        });
        let lut = ArcLengthLut::new(&line, 512);
        let points: Vec<Vec3> = (0..=10)
            .map(|i| line.sample_clamped(lut.t_at_fraction(i as f32 / 10.0)))
            .collect();
        let gaps: Vec<f32> = points.windows(2).map(|p| p[0].distance(p[1])).collect();
        let mean = gaps.iter().sum::<f32>() / gaps.len() as f32;
        for gap in gaps {
            assert!(
                (gap - mean).abs() / mean < 0.02,
                "gap {gap} strays from mean {mean}"
            );
        }
    }
}