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}