Skip to main content

rustmotion_core/engine/
shake.rs

1use crate::schema::shake::SceneShake;
2use crate::schema::time::{TimeCtx, TimeError};
3
4#[derive(Debug, Clone, Copy, PartialEq, Default)]
5pub struct ShakeOffset {
6    pub x: f64,
7    pub y: f64,
8    pub rotation: f64,
9}
10
11impl ShakeOffset {
12    fn plus(self, other: ShakeOffset) -> ShakeOffset {
13        ShakeOffset {
14            x: self.x + other.x,
15            y: self.y + other.y,
16            rotation: self.rotation + other.rotation,
17        }
18    }
19}
20
21pub fn shake_offset(
22    shake: &SceneShake,
23    ctx: &TimeCtx,
24    time: f64,
25) -> Result<ShakeOffset, TimeError> {
26    let mut total = ShakeOffset::default();
27    for impact in &shake.impacts {
28        let at = impact.at.resolve_relative(ctx)?;
29        let dt = time - at;
30        if dt < 0.0 {
31            continue;
32        }
33        let envelope = impact.amplitude * (-shake.decay * dt).exp();
34        let phase = std::f64::consts::TAU * shake.frequency * dt;
35        let x = envelope * phase.cos();
36        let y = envelope * phase.sin();
37        total = total.plus(ShakeOffset {
38            x,
39            y,
40            rotation: shake.rotation * x,
41        });
42    }
43    Ok(total)
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49    use crate::schema::shake::ShakeImpact;
50    use crate::schema::time::TimePoint;
51
52    fn ctx() -> TimeCtx {
53        TimeCtx {
54            bpm: Some(120.0),
55            beat_offset: 0.0,
56            scene_start: 0.0,
57        }
58    }
59
60    fn single_impact(
61        at: f64,
62        amplitude: f64,
63        decay: f64,
64        frequency: f64,
65        rotation: f64,
66    ) -> SceneShake {
67        SceneShake {
68            impacts: vec![ShakeImpact {
69                at: TimePoint::Seconds(at),
70                amplitude,
71            }],
72            decay,
73            frequency,
74            rotation,
75        }
76    }
77
78    #[test]
79    fn an_impact_contributes_nothing_before_it_lands() {
80        let shake = single_impact(1.0, 20.0, 10.0, 20.0, 0.0);
81        for t in [0.0, 0.5, 0.999] {
82            let offset = shake_offset(&shake, &ctx(), t).unwrap();
83            assert_eq!(offset, ShakeOffset::default(), "t={t} is before at=1.0");
84        }
85    }
86
87    #[test]
88    fn an_impact_peaks_at_its_own_at_along_x_with_zero_y() {
89        let shake = single_impact(1.0, 20.0, 10.0, 20.0, 0.0);
90        let offset = shake_offset(&shake, &ctx(), 1.0).unwrap();
91        assert!((offset.x - 20.0).abs() < 1e-9, "x={}", offset.x);
92        assert!(offset.y.abs() < 1e-9, "y={}", offset.y);
93    }
94
95    #[test]
96    fn the_envelope_decays_to_roughly_one_over_e_after_one_over_decay_seconds() {
97        let decay = 10.0;
98        let shake = single_impact(0.0, 20.0, decay, 0.0, 0.0);
99        let offset = shake_offset(&shake, &ctx(), 1.0 / decay).unwrap();
100        let expected = 20.0 / std::f64::consts::E;
101        assert!(
102            (offset.x - expected).abs() < 1e-6,
103            "x={}, expected {expected}",
104            offset.x
105        );
106    }
107
108    #[test]
109    fn multiple_impacts_sum_linearly() {
110        let a = single_impact(0.0, 10.0, 8.0, 15.0, 0.0);
111        let b = single_impact(0.3, 6.0, 8.0, 15.0, 0.0);
112        let combined = SceneShake {
113            impacts: [a.impacts.clone(), b.impacts.clone()].concat(),
114            decay: 8.0,
115            frequency: 15.0,
116            rotation: 0.0,
117        };
118        for t in [0.0, 0.15, 0.3, 0.5, 1.0] {
119            let oa = shake_offset(&a, &ctx(), t).unwrap();
120            let ob = shake_offset(&b, &ctx(), t).unwrap();
121            let oc = shake_offset(&combined, &ctx(), t).unwrap();
122            assert!((oc.x - (oa.x + ob.x)).abs() < 1e-9, "t={t}: x mismatch");
123            assert!((oc.y - (oa.y + ob.y)).abs() < 1e-9, "t={t}: y mismatch");
124        }
125    }
126
127    #[test]
128    fn rotation_is_proportional_to_x_via_the_documented_coefficient() {
129        let coeff = 0.5;
130        let shake = single_impact(0.0, 20.0, 8.0, 15.0, coeff);
131        for t in [0.0, 0.05, 0.2, 0.5] {
132            let offset = shake_offset(&shake, &ctx(), t).unwrap();
133            assert!(
134                (offset.rotation - coeff * offset.x).abs() < 1e-9,
135                "t={t}: rotation={}, x={}",
136                offset.rotation,
137                offset.x
138            );
139        }
140    }
141
142    #[test]
143    fn beat_grid_impacts_resolve_through_bpm_and_beat_offset() {
144        let shake = SceneShake {
145            impacts: vec![ShakeImpact {
146                at: TimePoint::Spec("2b".to_string()),
147                amplitude: 15.0,
148            }],
149            decay: 10.0,
150            frequency: 20.0,
151            rotation: 0.0,
152        };
153        let before = shake_offset(&shake, &ctx(), 0.99).unwrap();
154        assert_eq!(before, ShakeOffset::default());
155        let at_impact = shake_offset(&shake, &ctx(), 1.0).unwrap();
156        assert!((at_impact.x - 15.0).abs() < 1e-6);
157    }
158
159    #[test]
160    fn a_beat_unit_impact_with_no_bpm_errors_instead_of_silently_resolving() {
161        let shake = SceneShake {
162            impacts: vec![ShakeImpact {
163                at: TimePoint::Spec("2b".to_string()),
164                amplitude: 15.0,
165            }],
166            decay: 10.0,
167            frequency: 20.0,
168            rotation: 0.0,
169        };
170        let no_bpm_ctx = TimeCtx {
171            bpm: None,
172            beat_offset: 0.0,
173            scene_start: 0.0,
174        };
175        assert!(shake_offset(&shake, &no_bpm_ctx, 1.0).is_err());
176    }
177
178    #[test]
179    fn six_impacts_reproduce_a_hand_sampled_155_keyframe_track() {
180        let decay = 12.0;
181        let frequency = 18.0;
182        let rotation = 0.4;
183        let impacts_at_amp = [
184            (0.0, 24.0),
185            (0.5, 20.0),
186            (1.0, 20.0),
187            (1.5, 16.0),
188            (2.0, 16.0),
189            (2.5, 12.0),
190        ];
191        let shake = SceneShake {
192            impacts: impacts_at_amp
193                .iter()
194                .map(|(at, amp)| ShakeImpact {
195                    at: TimePoint::Seconds(*at),
196                    amplitude: *amp,
197                })
198                .collect(),
199            decay,
200            frequency,
201            rotation,
202        };
203
204        let hand_sampled = |t: f64| -> (f64, f64, f64) {
205            let mut x = 0.0;
206            let mut y = 0.0;
207            for (at, amp) in impacts_at_amp {
208                let dt = t - at;
209                if dt < 0.0 {
210                    continue;
211                }
212                let envelope = amp * (-decay * dt).exp();
213                let phase = std::f64::consts::TAU * frequency * dt;
214                x += envelope * phase.cos();
215                y += envelope * phase.sin();
216            }
217            (x, y, rotation * x)
218        };
219
220        let span_end = 3.0;
221        let samples = 155;
222        for i in 0..samples {
223            let t = span_end * i as f64 / (samples - 1) as f64;
224            let offset = shake_offset(&shake, &ctx(), t).unwrap();
225            let (ex, ey, erot) = hand_sampled(t);
226            assert!(
227                (offset.x - ex).abs() < 1e-9,
228                "t={t}: x={}, expected {ex}",
229                offset.x
230            );
231            assert!(
232                (offset.y - ey).abs() < 1e-9,
233                "t={t}: y={}, expected {ey}",
234                offset.y
235            );
236            assert!(
237                (offset.rotation - erot).abs() < 1e-9,
238                "t={t}: rotation={}, expected {erot}",
239                offset.rotation
240            );
241        }
242    }
243}