rustmotion_core/schema/shake.rs
1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::time::TimePoint;
5
6/// One beat-synced hit that kicks the camera. `at` resolves the same way
7/// as any other in-scene [`TimePoint`] — relative to the scene's own start
8/// unless it carries the `@` prefix (see the anchoring rule documented
9/// once on [`TimePoint`]) — so an impact can land exactly on a cut:
10/// `{"at": "4b", "amplitude": 24.0}`.
11#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
12#[serde(deny_unknown_fields)]
13pub struct ShakeImpact {
14 /// When this impact lands, on the scene's own timeline.
15 pub at: TimePoint,
16 /// Peak displacement at `t = at`, in pixels, before decay.
17 pub amplitude: f64,
18}
19
20/// Declarative camera shake: every impact in [`SceneShake::impacts`] is an
21/// independent damped harmonic oscillator, summed together. **Additive**
22/// over whatever [`super::scenario::Scene::camera`] already resolves to —
23/// `camera.keyframes` is a single value-over-time series per property,
24/// with no way to layer a second signal onto it without replacing the
25/// first, so a shake needs its own field to coexist with a pan instead of
26/// fighting over one keyframe track.
27///
28/// # Formula
29///
30/// For an impact of `amplitude` `A` landing at `t0`, its contribution at
31/// `t >= t0` (exactly zero before `t0` — an impact never affects time
32/// before it lands) is:
33///
34/// ```text
35/// Δt = t - t0
36/// envelope = A * exp(-decay * Δt)
37/// phase = 2π * frequency * Δt
38/// x = envelope * cos(phase)
39/// y = envelope * sin(phase)
40/// rotation = shake.rotation * x (degrees)
41/// ```
42///
43/// `x` and `y` are 90° out of phase, so the combined offset traces a
44/// decaying spiral rather than a straight line back and forth — this is
45/// what reads as a *shake* instead of a *bounce*. `decay` (1/seconds) is
46/// the exponential rate: the envelope reaches `1/e` (~37%) of its peak
47/// after `1/decay` seconds. `frequency` (Hz) is how many oscillations per
48/// second it makes while decaying. `rotation` is a degrees-per-pixel
49/// coefficient applied to the already-computed `x` offset — not an
50/// independent oscillator — so the twist always stays in phase with the
51/// translation instead of drifting against it; `0.0` (the default)
52/// disables rotational shake entirely.
53///
54/// Multiple impacts are summed — not replaced — at every instant, so a
55/// fast retrigger before the previous impact has decayed accumulates
56/// rather than resetting: the same physical intuition as striking a bell
57/// twice in quick succession.
58#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
59#[serde(deny_unknown_fields)]
60pub struct SceneShake {
61 /// Every hit contributing to this scene's shake. Impacts are summed,
62 /// not last-wins — see [`SceneShake`]'s formula.
63 pub impacts: Vec<ShakeImpact>,
64 /// Exponential decay rate, in 1/seconds. Higher decays faster: the
65 /// envelope reaches `1/e` of its peak after `1/decay` seconds.
66 /// Default: 10.0 (~0.1s to 1/e, fully read as settled well within half
67 /// a second).
68 #[serde(default = "default_shake_decay")]
69 pub decay: f64,
70 /// Oscillations per second while the envelope decays. Default: 20.0.
71 #[serde(default = "default_shake_frequency")]
72 pub frequency: f64,
73 /// Degrees of rotational shake per pixel of the (already-computed) `x`
74 /// offset — see [`SceneShake`]'s formula. `0.0` (default) disables
75 /// rotational shake.
76 #[serde(default)]
77 pub rotation: f64,
78}
79
80fn default_shake_decay() -> f64 {
81 10.0
82}
83
84fn default_shake_frequency() -> f64 {
85 20.0
86}
87
88#[cfg(test)]
89mod schema_tests {
90 use super::*;
91 use serde_json::json;
92
93 #[test]
94 fn deserializes_with_every_known_field() {
95 let json = json!({
96 "impacts": [
97 { "at": "4b", "amplitude": 24.0 },
98 { "at": 1.5, "amplitude": 12.0 }
99 ],
100 "decay": 8.0,
101 "frequency": 18.0,
102 "rotation": 0.5
103 });
104 let shake: SceneShake = serde_json::from_value(json).unwrap();
105 assert_eq!(shake.impacts.len(), 2);
106 assert_eq!(shake.impacts[0].at, TimePoint::Spec("4b".to_string()));
107 assert_eq!(shake.impacts[0].amplitude, 24.0);
108 assert_eq!(shake.impacts[1].at, TimePoint::Seconds(1.5));
109 assert_eq!(shake.decay, 8.0);
110 assert_eq!(shake.frequency, 18.0);
111 assert_eq!(shake.rotation, 0.5);
112 }
113
114 #[test]
115 fn decay_frequency_and_rotation_default() {
116 let json = json!({ "impacts": [{ "at": 0.0, "amplitude": 10.0 }] });
117 let shake: SceneShake = serde_json::from_value(json).unwrap();
118 assert_eq!(shake.decay, default_shake_decay());
119 assert_eq!(shake.frequency, default_shake_frequency());
120 assert_eq!(shake.rotation, 0.0);
121 }
122
123 #[test]
124 fn a_typo_on_scene_shake_is_rejected() {
125 let json = json!({ "impacts": [], "decy": 8.0 });
126 let err = serde_json::from_value::<SceneShake>(json)
127 .expect_err("a typo'd field on SceneShake must be rejected, not silently ignored");
128 assert!(err.to_string().contains("decy"), "got: {err}");
129 }
130
131 #[test]
132 fn a_typo_on_a_shake_impact_is_rejected() {
133 let json = json!({ "impacts": [{ "at": 0.0, "amplitud": 10.0 }] });
134 let err = serde_json::from_value::<SceneShake>(json)
135 .expect_err("a typo'd field on ShakeImpact must be rejected, not silently ignored");
136 assert!(err.to_string().contains("amplitud"), "got: {err}");
137 }
138
139 #[test]
140 fn amplitude_is_required_on_every_impact() {
141 let json = json!({ "impacts": [{ "at": 0.0 }] });
142 assert!(serde_json::from_value::<SceneShake>(json).is_err());
143 }
144
145 #[test]
146 fn six_impacts_round_trip_through_json() {
147 let impacts: Vec<_> = (0..6)
148 .map(|i| json!({ "at": format!("{}b", i * 4), "amplitude": 20.0 - i as f64 * 2.0 }))
149 .collect();
150 let json = json!({ "impacts": impacts });
151 let shake: SceneShake = serde_json::from_value(json).unwrap();
152 assert_eq!(shake.impacts.len(), 6);
153 let back = serde_json::to_value(&shake).unwrap();
154 let round_tripped: SceneShake = serde_json::from_value(back).unwrap();
155 assert_eq!(shake, round_tripped);
156 }
157}