bevy_director 0.7.0-dev

Unreal-Sequencer-inspired cinematic camera and sequence system for Bevy
Documentation
//! Handheld: a seeded, deterministic wobble layered onto the evaluated
//! pose. A sum of golden-ratio-detuned sines per axis, so it never
//! visibly repeats and never pulls in a rand crate. Same seed, same
//! take, every run.

use bevy::prelude::*;

use crate::sequence::Shake;

const PHI: f32 = 1.618_034;

/// Layered sines in -1..1, detuned so the pattern does not loop.
fn wobble(t: f32, hz: f32, phase: f32) -> f32 {
    let x = t * hz * std::f32::consts::TAU + phase;
    (x.sin() + (x * PHI).sin() * 0.5 + (x * PHI * PHI).sin() * 0.25) / 1.75
}

/// Everything shake adds at one shot-local time. The pose channels are
/// composed by the evaluator; the lens deltas are raw offsets for it to
/// clamp in context.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct ShakeSample {
    /// Local-space position nudge.
    pub nudge: Vec3,
    /// Rotation composed onto the aim.
    pub wobble: Quat,
    /// Radians added to the fov: the zoom breathes.
    pub fov_delta: f32,
    /// Meters added to the focus distance: focus hunts.
    pub focus_delta: f32,
}

/// The additive offset at a shot-local time.
pub(crate) fn shake_offset(shake: &Shake, t: f32) -> ShakeSample {
    let seed = shake.seed as f32 * 12.9898;
    // An authored-but-empty ramp is nonsense, not silence: absent means
    // constant full strength, the same rule eval uses for lens tracks.
    let ramp = shake
        .ramp
        .as_ref()
        .filter(|r| !r.keys.is_empty())
        .map_or(1.0, |r| r.sample(t).max(0.0));
    let amp = shake.amplitude_deg.to_radians() * ramp;
    let hz = shake.frequency_hz.max(0.01);
    let yaw = wobble(t, hz, seed) * amp;
    let pitch = wobble(t, hz * 1.13, seed + 7.31) * amp;
    // Roll reads twice as strong as it is; keep it half.
    let roll = wobble(t, hz * 0.87, seed + 3.77) * amp * 0.5;
    let pos = Vec3::new(
        wobble(t, hz * 0.71, seed + 1.13),
        wobble(t, hz * 0.79, seed + 9.02),
        0.0,
    ) * (shake.pos_amplitude * ramp);
    // The lens channels run slower than the handheld jitter: a zoom
    // breathes and a focus hunts, neither buzzes.
    let fov_delta = shake.fov_amplitude_deg.map_or(0.0, |amplitude| {
        wobble(t, hz * 0.6, seed + 4.9) * amplitude.to_radians() * ramp
    });
    let focus_delta = shake.focus_amplitude.map_or(0.0, |amplitude| {
        wobble(t, hz * 0.35, seed + 6.17) * amplitude * ramp
    });
    ShakeSample {
        nudge: pos,
        wobble: Quat::from_euler(EulerRot::YXZ, yaw, pitch, roll),
        fov_delta,
        focus_delta,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sequence::ScalarTrack;

    fn shake(seed: u32) -> Shake {
        Shake {
            amplitude_deg: 1.5,
            frequency_hz: 9.0,
            seed,
            pos_amplitude: 0.02,
            ramp: None,
            fov_amplitude_deg: Some(0.8),
            focus_amplitude: Some(0.5),
        }
    }

    #[test]
    fn same_seed_same_take() {
        for t in [0.0, 0.37, 2.11, 8.4] {
            assert_eq!(shake_offset(&shake(7), t), shake_offset(&shake(7), t));
        }
    }

    #[test]
    fn different_seeds_diverge_and_stay_bounded() {
        let mut differs = false;
        for i in 0..100 {
            let t = i as f32 * 0.05;
            let a = shake_offset(&shake(1), t);
            let b = shake_offset(&shake(2), t);
            differs |= a.nudge.distance(b.nudge) > 1e-4 || a.wobble.angle_between(b.wobble) > 1e-4;
            assert!(a.nudge.length() <= 0.03, "position blew past its amplitude");
            assert!(a.wobble.to_axis_angle().1 <= 3.0_f32.to_radians());
            assert!(a.fov_delta.abs() <= 0.8_f32.to_radians());
            assert!(a.focus_delta.abs() <= 0.5);
        }
        assert!(differs);
    }

    #[test]
    fn ramp_zero_silences_every_channel() {
        let mut zeroed = shake(3);
        zeroed.ramp = Some(ScalarTrack::constant(0.0));
        for t in [0.0, 0.5, 3.3] {
            let sample = shake_offset(&zeroed, t);
            assert_eq!(sample.nudge, Vec3::ZERO);
            assert_eq!(sample.wobble, Quat::IDENTITY);
            assert_eq!(sample.fov_delta, 0.0);
            assert_eq!(sample.focus_delta, 0.0);
        }
    }

    #[test]
    fn ramp_scales_and_empty_ramp_reads_as_absent() {
        let full = shake(5);
        let mut half = shake(5);
        half.ramp = Some(ScalarTrack::constant(0.5));
        let mut empty = shake(5);
        empty.ramp = Some(ScalarTrack::default());
        let t = 1.23;
        let f = shake_offset(&full, t);
        let h = shake_offset(&half, t);
        let e = shake_offset(&empty, t);
        assert!((h.nudge - f.nudge * 0.5).length() < 1e-6);
        assert!((h.fov_delta - f.fov_delta * 0.5).abs() < 1e-6);
        assert!((h.focus_delta - f.focus_delta * 0.5).abs() < 1e-6);
        assert_eq!(e, f);
    }
}