Skip to main content

proof_engine/effects/
mod.rs

1//! Screen effects coordinator — combines all post-processing passes into
2//! a single, driven, event-triggered system.
3//!
4
5pub mod field_viz;
6
7// # Architecture
8//
9// `EffectsController` owns all postfx parameter structs and drives them
10// from high-level game events:
11//   - `EffectEvent::CameraShake(trauma)` → screen shake + chromatic
12//   - `EffectEvent::Explosion(pos, power)` → grain flash + bloom spike + distortion
13//   - `EffectEvent::BossEnter` → full cinematic effect sequence
14//   - `EffectEvent::PlayerDeath` → desaturation + darkening + vignette crush
15//   - `EffectEvent::LevelUp` → hue rainbow + bloom burst
16//   - `EffectEvent::ChaosRift(entropy)` → continuous chaos distortion
17//
18// The controller smoothly interpolates between effect states each frame.
19// All parameters are exposed as public fields for direct access if needed.
20
21use crate::render::postfx::{
22    bloom::BloomParams,
23    grain::GrainParams,
24    scanlines::ScanlineParams,
25    chromatic::ChromaticParams,
26    distortion::DistortionParams,
27    motion_blur::MotionBlurParams,
28    color_grade::ColorGradeParams,
29};
30use crate::math::springs::SpringDamper;
31
32// ── ColorGradeParams compat ───────────────────────────────────────────────────
33
34// ── EffectEvent ───────────────────────────────────────────────────────────────
35
36/// High-level game events that trigger post-processing effects.
37#[derive(Debug, Clone)]
38pub enum EffectEvent {
39    /// Camera trauma — scales chromatic aberration, grain, and motion blur.
40    CameraShake { trauma: f32 },
41    /// Explosion at world position — grain flash, bloom spike, distortion burst.
42    Explosion { power: f32, is_boss: bool },
43    /// Boss entity enters the scene — full cinematic effect.
44    BossEnter,
45    /// Player death — color drain, vignette crush, slow fade to black.
46    PlayerDeath,
47    /// Level-up or victory — hue rainbow + bloom burst.
48    LevelUp,
49    /// Continuous chaos rift at given entropy level (0–1). Call each frame.
50    ChaosRift { entropy: f32 },
51    /// Heal — green tint flash + bloom pulse.
52    Heal { amount_fraction: f32 },
53    /// Flash a specific color (hit flash, pickup, etc.).
54    ColorFlash { r: f32, g: f32, b: f32, intensity: f32, duration: f32 },
55    /// Portal activation — chromatic + distortion ripple.
56    Portal,
57    /// Time slow — everything desaturates and motion blur increases.
58    TimeSlow { factor: f32 },
59    /// Resume normal speed after time slow.
60    TimeResume,
61    /// Lightning strike — instant white flash + chromatic.
62    LightningStrike,
63    /// Trigger a scanline glitch burst.
64    DisplayGlitch { intensity: f32, duration: f32 },
65    /// Reset all effects to default.
66    Reset,
67}
68
69// ── EffectLayer ───────────────────────────────────────────────────────────────
70
71/// A single time-limited effect overlay, active for `duration` seconds.
72#[derive(Debug, Clone)]
73struct EffectLayer {
74    kind:     LayerKind,
75    age:      f32,
76    duration: f32,
77    strength: f32,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq)]
81enum LayerKind {
82    GrainFlash,
83    BloomSpike,
84    ChromaticBurst,
85    DistortionBurst,
86    ColorFlash { r: f32, g: f32, b: f32 },
87    VignetteCrush,
88    HueRainbow,
89    GlitchBurst,
90    WhiteFlash,
91}
92
93impl EffectLayer {
94    fn is_expired(&self) -> bool { self.age >= self.duration }
95    fn progress(&self) -> f32 { (self.age / self.duration).clamp(0.0, 1.0) }
96    fn intensity(&self) -> f32 {
97        let t = self.progress();
98        // Default: decay curve (quick flash, exponential falloff)
99        self.strength * (1.0 - t) * (1.0 - t)
100    }
101}
102
103// ── EffectsController ─────────────────────────────────────────────────────────
104
105/// The master effects coordinator.
106///
107/// Owns all postfx parameter structs, drives them from `EffectEvent`s,
108/// and provides smooth spring-based transitions between states.
109pub struct EffectsController {
110    // ── Postfx parameter blocks ───────────────────────────────────────────────
111    pub bloom:       BloomParams,
112    pub grain:       GrainParams,
113    pub scanlines:   ScanlineParams,
114    pub chromatic:   ChromaticParams,
115    pub distortion:  DistortionParams,
116    pub motion_blur: MotionBlurParams,
117    pub color_grade: ColorGradeParams,
118
119    // ── Derived scalar states (spring-smoothed) ───────────────────────────────
120    trauma:          f32,   // Current camera trauma [0, 1]
121    entropy:         f32,   // Current chaos entropy [0, 1]
122    time_slow:       f32,   // Time slow factor [0, 1] (1 = normal)
123
124    // ── Springs for smooth parameter transitions ──────────────────────────────
125    bloom_spring:    SpringDamper,
126    grain_spring:    SpringDamper,
127    chromatic_spring: SpringDamper,
128    vignette_spring: SpringDamper,
129    saturation_spring: SpringDamper,
130    brightness_spring: SpringDamper,
131
132    // ── Active timed layers ───────────────────────────────────────────────────
133    layers: Vec<EffectLayer>,
134
135    // ── State flags ───────────────────────────────────────────────────────────
136    pub is_dead:          bool,
137    pub boss_mode:        bool,
138    pub chaos_rift_active: bool,
139    pub time_slow_active:  bool,
140}
141
142impl EffectsController {
143    pub fn new() -> Self {
144        Self {
145            bloom:       BloomParams::default(),
146            grain:       GrainParams::default(),
147            scanlines:   ScanlineParams::default(),
148            chromatic:   ChromaticParams::none(),
149            distortion:  DistortionParams::none(),
150            motion_blur: MotionBlurParams::default(),
151            color_grade: ColorGradeParams::default(),
152
153            trauma:    0.0,
154            entropy:   0.0,
155            time_slow: 1.0,
156
157            bloom_spring:      SpringDamper::critical(1.0, 6.0),
158            grain_spring:      SpringDamper::critical(0.0, 8.0),
159            chromatic_spring:  SpringDamper::critical(0.0, 8.0),
160            vignette_spring:   SpringDamper::critical(0.15, 5.0),
161            saturation_spring: SpringDamper::critical(1.0, 4.0),
162            brightness_spring: SpringDamper::critical(0.0, 6.0),
163
164            layers: Vec::new(),
165            is_dead:           false,
166            boss_mode:         false,
167            chaos_rift_active: false,
168            time_slow_active:  false,
169        }
170    }
171
172    // ── Event handling ────────────────────────────────────────────────────────
173
174    /// Process a game event, triggering appropriate effects.
175    pub fn send(&mut self, event: EffectEvent) {
176        match event {
177            EffectEvent::CameraShake { trauma } => {
178                self.trauma = (self.trauma + trauma).clamp(0.0, 1.0);
179            }
180
181            EffectEvent::Explosion { power, is_boss } => {
182                let p = power.clamp(0.0, 1.0);
183                self.push_layer(EffectLayer {
184                    kind: LayerKind::GrainFlash, age: 0.0,
185                    duration: 0.3 + p * 0.4, strength: p * 0.8
186                });
187                self.push_layer(EffectLayer {
188                    kind: LayerKind::BloomSpike, age: 0.0,
189                    duration: 0.5 + p * 0.5, strength: p * 2.0
190                });
191                self.push_layer(EffectLayer {
192                    kind: LayerKind::DistortionBurst, age: 0.0,
193                    duration: 0.4 + p * 0.3, strength: p
194                });
195                if is_boss {
196                    self.push_layer(EffectLayer {
197                        kind: LayerKind::WhiteFlash, age: 0.0,
198                        duration: 0.15, strength: 1.0
199                    });
200                    self.push_layer(EffectLayer {
201                        kind: LayerKind::ChromaticBurst, age: 0.0,
202                        duration: 0.6, strength: 1.0
203                    });
204                }
205                self.trauma = (self.trauma + p * 0.5).min(1.0);
206            }
207
208            EffectEvent::BossEnter => {
209                self.boss_mode = true;
210                self.push_layer(EffectLayer {
211                    kind: LayerKind::VignetteCrush, age: 0.0,
212                    duration: 3.0, strength: 1.0
213                });
214                self.push_layer(EffectLayer {
215                    kind: LayerKind::ChromaticBurst, age: 0.0,
216                    duration: 1.5, strength: 0.8
217                });
218                self.saturation_spring.set_target(0.0);
219            }
220
221            EffectEvent::PlayerDeath => {
222                self.is_dead = true;
223                self.saturation_spring.set_target(0.0);
224                self.brightness_spring.set_target(-0.8);
225                self.vignette_spring.set_target(1.0);
226            }
227
228            EffectEvent::LevelUp => {
229                self.push_layer(EffectLayer {
230                    kind: LayerKind::HueRainbow, age: 0.0,
231                    duration: 2.0, strength: 1.0
232                });
233                self.push_layer(EffectLayer {
234                    kind: LayerKind::BloomSpike, age: 0.0,
235                    duration: 0.8, strength: 3.0
236                });
237                self.bloom_spring.set_target(3.0);
238            }
239
240            EffectEvent::ChaosRift { entropy } => {
241                self.entropy = entropy.clamp(0.0, 1.0);
242                self.chaos_rift_active = true;
243            }
244
245            EffectEvent::Heal { amount_fraction } => {
246                let g = amount_fraction.clamp(0.0, 1.0);
247                self.push_layer(EffectLayer {
248                    kind: LayerKind::ColorFlash { r: 0.2, g: 1.0, b: 0.3 },
249                    age: 0.0, duration: 0.5, strength: g * 0.6
250                });
251                self.push_layer(EffectLayer {
252                    kind: LayerKind::BloomSpike, age: 0.0,
253                    duration: 0.4, strength: g * 1.5
254                });
255            }
256
257            EffectEvent::ColorFlash { r, g, b, intensity, duration } => {
258                self.push_layer(EffectLayer {
259                    kind: LayerKind::ColorFlash { r, g, b },
260                    age: 0.0, duration, strength: intensity
261                });
262            }
263
264            EffectEvent::Portal => {
265                self.push_layer(EffectLayer {
266                    kind: LayerKind::DistortionBurst, age: 0.0,
267                    duration: 0.8, strength: 0.6
268                });
269                self.push_layer(EffectLayer {
270                    kind: LayerKind::ChromaticBurst, age: 0.0,
271                    duration: 0.5, strength: 0.5
272                });
273            }
274
275            EffectEvent::TimeSlow { factor } => {
276                self.time_slow_active = true;
277                self.time_slow = factor.clamp(0.05, 1.0);
278                self.saturation_spring.set_target(0.4);
279                self.motion_blur.scale = 0.6;
280                self.motion_blur.temporal = 0.4;
281            }
282
283            EffectEvent::TimeResume => {
284                self.time_slow_active = false;
285                self.time_slow = 1.0;
286                self.saturation_spring.set_target(1.0);
287                self.motion_blur = MotionBlurParams::default();
288            }
289
290            EffectEvent::LightningStrike => {
291                self.push_layer(EffectLayer {
292                    kind: LayerKind::WhiteFlash, age: 0.0,
293                    duration: 0.1, strength: 1.0
294                });
295                self.push_layer(EffectLayer {
296                    kind: LayerKind::ChromaticBurst, age: 0.0,
297                    duration: 0.3, strength: 1.0
298                });
299                self.trauma = (self.trauma + 0.4).min(1.0);
300            }
301
302            EffectEvent::DisplayGlitch { intensity, duration } => {
303                let i = intensity.clamp(0.0, 1.0);
304                self.push_layer(EffectLayer {
305                    kind: LayerKind::GlitchBurst, age: 0.0,
306                    duration, strength: i
307                });
308                self.push_layer(EffectLayer {
309                    kind: LayerKind::ChromaticBurst, age: 0.0,
310                    duration: duration * 0.7, strength: i * 0.8
311                });
312            }
313
314            EffectEvent::Reset => {
315                self.reset();
316            }
317        }
318    }
319
320    fn push_layer(&mut self, layer: EffectLayer) {
321        self.layers.push(layer);
322    }
323
324    // ── Tick ──────────────────────────────────────────────────────────────────
325
326    /// Advance all effects by `dt` seconds.
327    ///
328    /// Call this every frame before reading the parameter blocks.
329    pub fn tick(&mut self, dt: f32) {
330        // Decay trauma
331        self.trauma = (self.trauma - dt * 1.5).max(0.0);
332        let t2 = self.trauma * self.trauma;
333
334        // Decay chaos rift entropy if not being driven
335        if !self.chaos_rift_active {
336            self.entropy = (self.entropy - dt * 0.5).max(0.0);
337        }
338        self.chaos_rift_active = false; // reset; will be re-set next frame if still active
339
340        // ── Advance layers ────────────────────────────────────────────────────
341        let mut grain_add    = 0.0_f32;
342        let mut bloom_add    = 0.0_f32;
343        let mut chromatic_add = 0.0_f32;
344        let mut distortion_add = 0.0_f32;
345        let mut vignette_add = 0.0_f32;
346        let mut brightness_add = 0.0_f32;
347        let mut hue_shift = 0.0_f32;
348
349        for layer in &mut self.layers {
350            layer.age += dt;
351            let intensity = layer.intensity();
352            match layer.kind {
353                LayerKind::GrainFlash         => grain_add      += intensity * 0.8,
354                LayerKind::BloomSpike         => bloom_add      += intensity * 2.0,
355                LayerKind::ChromaticBurst     => chromatic_add  += intensity * 0.025,
356                LayerKind::DistortionBurst    => distortion_add += intensity * 0.05,
357                LayerKind::VignetteCrush      => vignette_add   += intensity * 0.7,
358                LayerKind::HueRainbow         => hue_shift       = layer.progress() * 360.0,
359                LayerKind::GlitchBurst        => {
360                    grain_add    += intensity * 0.4;
361                    chromatic_add += intensity * 0.02;
362                }
363                LayerKind::WhiteFlash         => brightness_add += intensity * 1.5,
364                LayerKind::ColorFlash { .. }  => {
365                    brightness_add += intensity * 0.3;
366                }
367            }
368        }
369        self.layers.retain(|l| !l.is_expired());
370
371        // ── Trauma contribution ───────────────────────────────────────────────
372        chromatic_add  += t2 * 0.015;
373        grain_add      += t2 * 0.4;
374        bloom_add      += t2 * 0.5;
375
376        // ── Chaos rift contribution ────────────────────────────────────────────
377        let e = self.entropy;
378        distortion_add += e * 0.08;
379        chromatic_add  += e * 0.02;
380        grain_add      += e * 0.3;
381
382        // ── Spring targets ────────────────────────────────────────────────────
383        self.bloom_spring.set_target(1.0 + bloom_add);
384        self.grain_spring.set_target(grain_add);
385        self.chromatic_spring.set_target(chromatic_add);
386        if !self.is_dead && !self.boss_mode {
387            self.saturation_spring.set_target(1.0 - e * 0.3);
388            self.vignette_spring.set_target(0.15 + vignette_add);
389            self.brightness_spring.set_target(brightness_add);
390        }
391
392        // ── Advance springs ───────────────────────────────────────────────────
393        self.bloom_spring.tick(dt);
394        self.grain_spring.tick(dt);
395        self.chromatic_spring.tick(dt);
396        self.vignette_spring.tick(dt);
397        self.saturation_spring.tick(dt);
398        self.brightness_spring.tick(dt);
399
400        // ── Write to parameter blocks ─────────────────────────────────────────
401        let bloom_target = self.bloom_spring.position.max(0.0);
402        self.bloom.threshold = (0.8 - (bloom_target - 1.0) * 0.2).clamp(0.0, 1.0);
403        self.bloom.intensity = bloom_target;
404
405        self.grain.intensity = self.grain_spring.position.max(0.0);
406        self.grain.enabled = self.grain.intensity > 0.001;
407
408        let chrom = self.chromatic_spring.position.max(0.0);
409        if chrom > 0.001 {
410            self.chromatic = ChromaticParams {
411                enabled: true,
412                red_offset: 0.002 + chrom,
413                blue_offset: 0.003 + chrom * 1.2,
414                green_offset: chrom * 0.1,
415                radial_scale: true,
416                tangential: t2 * 0.3 + e * 0.2,
417                spectrum_spread: (chrom * 10.0).min(0.8),
418                barrel_distortion: e * 0.04,
419            };
420        } else {
421            self.chromatic = ChromaticParams::none();
422        }
423
424        let dist = distortion_add;
425        if dist > 0.001 || e > 0.01 {
426            self.distortion.enabled = true;
427            self.distortion.scale = (dist + e * 0.5).min(3.0);
428            self.distortion.max_offset = (dist * 0.5 + e * 0.06).min(0.15);
429            self.distortion.chromatic_split = (dist * 2.0 + e * 0.4).min(1.0);
430        } else {
431            self.distortion.enabled = false;
432        }
433
434        self.color_grade.saturation = self.saturation_spring.position.clamp(0.0, 2.0);
435        self.color_grade.brightness = self.brightness_spring.position.clamp(-1.0, 2.0);
436        self.color_grade.vignette = self.vignette_spring.position.clamp(0.0, 1.0);
437        self.color_grade.hue_shift = hue_shift;
438
439        // Boss mode: boost contrast
440        if self.boss_mode {
441            self.color_grade.contrast = 1.3;
442        } else {
443            self.color_grade.contrast = 1.0;
444        }
445
446        // Time slow: slightly warm the color grade
447        if self.time_slow_active {
448            self.color_grade.saturation = self.color_grade.saturation * 0.5;
449        }
450    }
451
452    // ── Reset ──────────────────────────────────────────────────────────────────
453
454    /// Reset all effects to default (no trauma, no layers, default postfx).
455    pub fn reset(&mut self) {
456        self.trauma      = 0.0;
457        self.entropy     = 0.0;
458        self.time_slow   = 1.0;
459        self.is_dead     = false;
460        self.boss_mode   = false;
461        self.chaos_rift_active = false;
462        self.time_slow_active  = false;
463        self.layers.clear();
464
465        self.bloom       = BloomParams::default();
466        self.grain       = GrainParams::default();
467        self.scanlines   = ScanlineParams::default();
468        self.chromatic   = ChromaticParams::none();
469        self.distortion  = DistortionParams::none();
470        self.motion_blur = MotionBlurParams::default();
471        self.color_grade = ColorGradeParams::default();
472
473        self.bloom_spring.teleport(1.0);
474        self.grain_spring.teleport(0.0);
475        self.chromatic_spring.teleport(0.0);
476        self.vignette_spring.teleport(0.15);
477        self.saturation_spring.teleport(1.0);
478        self.brightness_spring.teleport(0.0);
479    }
480
481    // ── Accessors ──────────────────────────────────────────────────────────────
482
483    pub fn trauma(&self) -> f32 { self.trauma }
484    pub fn entropy(&self) -> f32 { self.entropy }
485    pub fn time_slow_factor(&self) -> f32 { self.time_slow }
486    pub fn active_layer_count(&self) -> usize { self.layers.len() }
487
488    /// True if any timed effects are currently running.
489    pub fn has_active_effects(&self) -> bool {
490        !self.layers.is_empty() || self.trauma > 0.01 || self.entropy > 0.01
491    }
492
493    /// Summary string for debug overlay.
494    pub fn debug_summary(&self) -> String {
495        format!(
496            "trauma={:.2} entropy={:.2} layers={} bloom={:.2} grain={:.2} chrom={:.3} dist={} sat={:.2}",
497            self.trauma, self.entropy, self.layers.len(),
498            self.bloom.intensity, self.grain.intensity,
499            self.chromatic.red_offset,
500            self.distortion.enabled,
501            self.color_grade.saturation,
502        )
503    }
504}
505
506impl Default for EffectsController {
507    fn default() -> Self { Self::new() }
508}
509
510// ── EffectPresets ─────────────────────────────────────────────────────────────
511
512/// Pre-baked effect sequences for common scenarios.
513pub struct EffectPresets;
514
515impl EffectPresets {
516    /// Generate a burst of events simulating a boss fight opening.
517    pub fn boss_opening() -> Vec<EffectEvent> {
518        vec![
519            EffectEvent::BossEnter,
520            EffectEvent::CameraShake { trauma: 0.7 },
521            EffectEvent::DisplayGlitch { intensity: 0.5, duration: 0.4 },
522        ]
523    }
524
525    /// Player takes a heavy hit.
526    pub fn heavy_hit(damage_fraction: f32) -> Vec<EffectEvent> {
527        vec![
528            EffectEvent::CameraShake { trauma: damage_fraction * 0.8 },
529            EffectEvent::Explosion { power: damage_fraction * 0.5, is_boss: false },
530            EffectEvent::ColorFlash {
531                r: 1.0, g: 0.1, b: 0.1,
532                intensity: damage_fraction * 0.6,
533                duration: 0.3,
534            },
535        ]
536    }
537
538    /// Area of Effect explosion.
539    pub fn aoe_explosion(power: f32) -> Vec<EffectEvent> {
540        vec![
541            EffectEvent::Explosion { power, is_boss: power > 0.8 },
542            EffectEvent::CameraShake { trauma: power * 0.6 },
543        ]
544    }
545
546    /// Dimensional rift opening — sustained chaos effects.
547    pub fn rift_opening(entropy: f32) -> Vec<EffectEvent> {
548        vec![
549            EffectEvent::ChaosRift { entropy },
550            EffectEvent::DisplayGlitch { intensity: entropy * 0.6, duration: 0.5 },
551            EffectEvent::Portal,
552        ]
553    }
554}
555
556// ── Tests ──────────────────────────────────────────────────────────────────────
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    #[test]
563    fn controller_smoke() {
564        let mut ctrl = EffectsController::new();
565        ctrl.send(EffectEvent::CameraShake { trauma: 0.5 });
566        ctrl.tick(0.016);
567        assert!(ctrl.trauma > 0.0, "trauma should be set");
568    }
569
570    #[test]
571    fn explosion_triggers_layers() {
572        let mut ctrl = EffectsController::new();
573        ctrl.send(EffectEvent::Explosion { power: 0.8, is_boss: false });
574        // Should have timed layers
575        assert!(ctrl.active_layer_count() > 0, "explosion should create layers");
576    }
577
578    #[test]
579    fn layers_expire() {
580        let mut ctrl = EffectsController::new();
581        ctrl.send(EffectEvent::Explosion { power: 0.5, is_boss: false });
582        // Advance past the longest layer duration (1.0 sec for 0.5 power)
583        for _ in 0..120 {
584            ctrl.tick(0.016);
585        }
586        assert_eq!(ctrl.active_layer_count(), 0, "all layers should expire");
587    }
588
589    #[test]
590    fn reset_clears_everything() {
591        let mut ctrl = EffectsController::new();
592        ctrl.send(EffectEvent::Explosion { power: 1.0, is_boss: true });
593        ctrl.send(EffectEvent::BossEnter);
594        ctrl.send(EffectEvent::PlayerDeath);
595        ctrl.tick(0.016);
596        ctrl.send(EffectEvent::Reset);
597        ctrl.tick(0.0);
598        assert!(!ctrl.is_dead);
599        assert!(!ctrl.boss_mode);
600        assert_eq!(ctrl.active_layer_count(), 0);
601    }
602
603    #[test]
604    fn chaos_rift_activates_distortion() {
605        let mut ctrl = EffectsController::new();
606        ctrl.chaos_rift_active = true;
607        ctrl.entropy = 0.8;
608        ctrl.tick(0.016);
609        assert!(ctrl.distortion.enabled, "high entropy should enable distortion");
610    }
611
612    #[test]
613    fn trauma_decays() {
614        let mut ctrl = EffectsController::new();
615        ctrl.send(EffectEvent::CameraShake { trauma: 1.0 });
616        for _ in 0..60 {
617            ctrl.tick(0.016);
618        }
619        assert!(ctrl.trauma < 0.5, "trauma should decay over time: {}", ctrl.trauma);
620    }
621
622    #[test]
623    fn preset_boss_opening_generates_events() {
624        let events = EffectPresets::boss_opening();
625        assert!(!events.is_empty(), "boss opening should produce events");
626        let mut ctrl = EffectsController::new();
627        for e in events { ctrl.send(e); }
628        ctrl.tick(0.016);
629        assert!(ctrl.boss_mode, "boss mode should be set");
630    }
631
632    #[test]
633    fn time_slow_desaturates() {
634        let mut ctrl = EffectsController::new();
635        ctrl.send(EffectEvent::TimeSlow { factor: 0.2 });
636        for _ in 0..30 {
637            ctrl.tick(0.016);
638        }
639        assert!(
640            ctrl.color_grade.saturation < 0.8,
641            "time slow should reduce saturation: {}", ctrl.color_grade.saturation
642        );
643    }
644}