Skip to main content

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}