Skip to main content

wallr_common/
effect.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, PartialEq)]
4#[serde(rename_all = "snake_case")]
5pub enum Effect {
6    Fade(FadeParams),
7    Wipe(WipeParams),
8    Slide(SlideParams),
9    Wave(WaveParams),
10    Grow(GrowParams),
11    Outer(OuterParams),
12}
13
14impl<'de> Deserialize<'de> for Effect {
15    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
16    where
17        D: serde::Deserializer<'de>,
18    {
19        struct EffectVisitor;
20
21        impl<'de> serde::de::Visitor<'de> for EffectVisitor {
22            type Value = Effect;
23
24            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
25                formatter.write_str("a string or a map representing an effect")
26            }
27
28            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
29            where
30                E: serde::de::Error,
31            {
32                match value {
33                    "fade" => Ok(Effect::Fade(FadeParams::default())),
34                    "wipe" => Ok(Effect::Wipe(WipeParams::default())),
35                    "slide" => Ok(Effect::Slide(SlideParams::default())),
36                    "wave" => Ok(Effect::Wave(WaveParams::default())),
37                    "grow" => Ok(Effect::Grow(GrowParams::default())),
38                    "outer" => Ok(Effect::Outer(OuterParams::default())),
39                    _ => Err(E::custom(format!("unknown effect type: {}", value))),
40                }
41            }
42
43            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
44            where
45                A: serde::de::MapAccess<'de>,
46            {
47                let key: String = map
48                    .next_key()?
49                    .ok_or_else(|| serde::de::Error::custom("expected a key in Effect map"))?;
50
51                match key.as_str() {
52                    "fade" => Ok(Effect::Fade(map.next_value()?)),
53                    "wipe" => Ok(Effect::Wipe(map.next_value()?)),
54                    "slide" => Ok(Effect::Slide(map.next_value()?)),
55                    "wave" => Ok(Effect::Wave(map.next_value()?)),
56                    "grow" => Ok(Effect::Grow(map.next_value()?)),
57                    "outer" => Ok(Effect::Outer(map.next_value()?)),
58                    _ => Err(serde::de::Error::custom(format!(
59                        "unknown effect type: {}",
60                        key
61                    ))),
62                }
63            }
64        }
65
66        deserializer.deserialize_any(EffectVisitor)
67    }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
71pub struct FadeParams {
72    #[serde(default)]
73    pub from: f32,
74    #[serde(default = "default_one")]
75    pub to: f32,
76    #[serde(default = "default_easing")]
77    pub easing: Easing,
78}
79
80impl Default for FadeParams {
81    fn default() -> Self {
82        Self {
83            from: 0.0,
84            to: 1.0,
85            easing: Easing::Bezier,
86        }
87    }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
91pub struct WipeParams {
92    #[serde(default)]
93    pub direction: WipeDirection,
94    #[serde(default = "default_wipe_softness")]
95    pub softness: f32,
96    #[serde(default)]
97    pub angle: Option<f32>,
98    #[serde(default = "default_easing")]
99    pub easing: Easing,
100}
101
102impl Default for WipeParams {
103    fn default() -> Self {
104        Self {
105            direction: WipeDirection::Left,
106            softness: 0.006,
107            angle: None,
108            easing: Easing::Bezier,
109        }
110    }
111}
112
113#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
114#[serde(rename_all = "snake_case")]
115pub enum WipeDirection {
116    #[default]
117    Left,
118    Right,
119    Up,
120    Down,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
124pub struct SlideParams {
125    #[serde(default)]
126    pub direction: SlideDirection,
127    #[serde(default = "default_easing")]
128    pub easing: Easing,
129}
130
131impl Default for SlideParams {
132    fn default() -> Self {
133        Self {
134            direction: SlideDirection::Left,
135            easing: Easing::Bezier,
136        }
137    }
138}
139
140#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
141#[serde(rename_all = "snake_case")]
142pub enum SlideDirection {
143    #[default]
144    Left,
145    Right,
146    Up,
147    Down,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
151#[serde(rename_all = "snake_case")]
152pub enum Origin {
153    #[default]
154    Center,
155    Cursor,
156    Custom(f32, f32),
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
160pub struct WaveParams {
161    #[serde(default = "default_wave_frequency")]
162    pub frequency: f32,
163    #[serde(default = "default_wave_amplitude")]
164    pub amplitude: f32,
165    #[serde(default)]
166    pub angle: Option<f32>,
167    /// Wave center. An explicit `--origin` wins when no angle is given;
168    /// an angle derives the center from the sweep direction.
169    #[serde(default)]
170    pub origin: Origin,
171    #[serde(default = "default_easing")]
172    pub easing: Easing,
173}
174
175impl Default for WaveParams {
176    fn default() -> Self {
177        Self {
178            frequency: 3.0,
179            amplitude: 0.05,
180            angle: None,
181            origin: Origin::Center,
182            easing: Easing::Bezier,
183        }
184    }
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
188pub struct GrowParams {
189    #[serde(default)]
190    pub origin: Origin,
191    #[serde(default = "default_easing")]
192    pub easing: Easing,
193}
194
195impl Default for GrowParams {
196    fn default() -> Self {
197        Self {
198            origin: Origin::Center,
199            easing: Easing::Bezier,
200        }
201    }
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
205pub struct OuterParams {
206    #[serde(default)]
207    pub origin: Origin,
208    #[serde(default = "default_easing")]
209    pub easing: Easing,
210}
211
212impl Default for OuterParams {
213    fn default() -> Self {
214        Self {
215            origin: Origin::Center,
216            easing: Easing::Bezier,
217        }
218    }
219}
220
221#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, clap::ValueEnum)]
222#[serde(rename_all = "snake_case")]
223#[clap(rename_all = "snake_case")]
224pub enum Easing {
225    Linear,
226    #[serde(alias = "ease-in")]
227    EaseIn,
228    #[serde(alias = "ease-out")]
229    EaseOut,
230    #[serde(alias = "ease-in-out")]
231    #[default]
232    EaseInOut,
233    /// Cubic-bezier(.54, 0, .34, .99): awww's default transition curve.
234    /// Evaluated on the CPU once per frame and sent with linear passthrough,
235    /// since the shader only implements fixed curves.
236    Bezier,
237}
238
239fn default_one() -> f32 {
240    1.0
241}
242
243fn default_easing() -> Easing {
244    Easing::Bezier
245}
246
247fn default_wipe_softness() -> f32 {
248    0.006
249}
250fn default_wave_frequency() -> f32 {
251    3.0
252}
253fn default_wave_amplitude() -> f32 {
254    0.05
255}
256
257/// Cubic-bezier y(x) with awww's default control points, in the style of
258/// gre/bezier-easing (the same family awww ports): Newton-Raphson with a
259/// bisection fallback.
260fn cubic_bezier_y(x: f32) -> f32 {
261    const X1: f32 = 0.54;
262    const Y1: f32 = 0.0;
263    const X2: f32 = 0.34;
264    const Y2: f32 = 0.99;
265    fn sample_curve_x(t: f32) -> f32 {
266        ((1.0 - 3.0 * X2 + 3.0 * X1) * t + (3.0 * X2 - 6.0 * X1)) * t * t + 3.0 * X1 * t
267    }
268    fn sample_curve_y(t: f32) -> f32 {
269        ((1.0 - 3.0 * Y2 + 3.0 * Y1) * t + (3.0 * Y2 - 6.0 * Y1)) * t * t + 3.0 * Y1 * t
270    }
271    fn sample_slope_x(t: f32) -> f32 {
272        3.0 * (1.0 - 3.0 * X2 + 3.0 * X1) * t * t + 2.0 * (3.0 * X2 - 6.0 * X1) * t + 3.0 * X1
273    }
274    let mut t = x.clamp(0.0, 1.0);
275    for _ in 0..5 {
276        let slope = sample_slope_x(t);
277        if slope.abs() < 1e-4 {
278            break;
279        }
280        t -= (sample_curve_x(t) - x) / slope;
281    }
282    if !(0.0..=1.0).contains(&t) {
283        let (mut lo, mut hi) = (0.0f32, 1.0f32);
284        t = x;
285        while hi - lo > 1e-4 {
286            if sample_curve_x(t) < x {
287                lo = t;
288            } else {
289                hi = t;
290            }
291            t = (lo + hi) / 2.0;
292        }
293    }
294    sample_curve_y(t.clamp(0.0, 1.0))
295}
296
297#[repr(C)]
298#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
299pub struct EffectUniforms {
300    pub effect_type: u32,
301    pub progress: f32,
302    pub param_a: f32,
303    pub param_b: f32,
304    pub param_c: f32,
305    pub param_d: f32,
306    pub origin: [f32; 2],
307    pub direction: [f32; 2],
308    pub easing: u32,
309}
310
311pub fn compute_effect_uniforms(effect: &Effect, progress: f32) -> EffectUniforms {
312    let progress = progress.clamp(0.0, 1.0);
313    let easing_index = |e: &Easing| match e {
314        Easing::Linear | Easing::Bezier => 0,
315        Easing::EaseIn => 1,
316        Easing::EaseOut => 2,
317        Easing::EaseInOut => 3,
318    };
319    // Bezier is pre-evaluated here (once per frame) and sent with linear
320    // passthrough; the shader only implements fixed curves.
321    let eased = |easing: &Easing| match easing {
322        Easing::Bezier => cubic_bezier_y(progress),
323        _ => progress,
324    };
325    match effect {
326        Effect::Fade(params) => EffectUniforms {
327            effect_type: 0,
328            progress: eased(&params.easing),
329            param_a: params.from,
330            param_b: params.to,
331            param_c: 0.0,
332            param_d: 0.0,
333            origin: [0.5, 0.5],
334            direction: [0.0, 0.0],
335            easing: easing_index(&params.easing),
336        },
337        Effect::Wipe(params) => {
338            let (dir_vec, origin) = if let Some(angle_deg) = params.angle {
339                // awww angle convention: 0 = right-to-left, 90 = top-to-bottom,
340                // 270 = bottom-to-top. -sin keeps UV y (top-left origin) aligned.
341                let rad = angle_deg.to_radians();
342                (
343                    [rad.cos(), -rad.sin()],
344                    [0.5 - 0.5 * rad.cos(), 0.5 + 0.5 * rad.sin()],
345                )
346            } else {
347                match params.direction {
348                    WipeDirection::Left => ([-1.0, 0.0], [0.0, 0.5]),
349                    WipeDirection::Right => ([1.0, 0.0], [1.0, 0.5]),
350                    WipeDirection::Up => ([0.0, -1.0], [0.5, 0.0]),
351                    WipeDirection::Down => ([0.0, 1.0], [0.5, 1.0]),
352                }
353            };
354            EffectUniforms {
355                effect_type: 1,
356                progress: eased(&params.easing),
357                param_a: params.softness,
358                param_b: 0.0,
359                param_c: 0.0,
360                param_d: 0.0,
361                origin,
362                direction: dir_vec,
363                easing: easing_index(&params.easing),
364            }
365        }
366        Effect::Slide(params) => {
367            let (dir_vec, origin) = match params.direction {
368                SlideDirection::Left => ([-1.0, 0.0], [0.0, 0.5]),
369                SlideDirection::Right => ([1.0, 0.0], [1.0, 0.5]),
370                SlideDirection::Up => ([0.0, -1.0], [0.5, 0.0]),
371                SlideDirection::Down => ([0.0, 1.0], [0.5, 1.0]),
372            };
373            EffectUniforms {
374                effect_type: 2,
375                progress: eased(&params.easing),
376                param_a: 0.0,
377                param_b: 0.0,
378                param_c: 0.0,
379                param_d: 0.0,
380                origin,
381                direction: dir_vec,
382                easing: easing_index(&params.easing),
383            }
384        }
385        Effect::Wave(params) => {
386            let (dir_vec, origin) = if let Some(angle_deg) = params.angle {
387                let rad = angle_deg.to_radians();
388                (
389                    [rad.cos(), rad.sin()],
390                    [0.5 + 0.5 * rad.cos(), 0.5 - 0.5 * rad.sin()],
391                )
392            } else {
393                let origin = match params.origin {
394                    Origin::Center | Origin::Cursor => [0.5, 0.5],
395                    Origin::Custom(x, y) => [x, y],
396                };
397                ([0.0, 0.0], origin)
398            };
399            EffectUniforms {
400                effect_type: 3,
401                progress: eased(&params.easing),
402                param_a: params.frequency,
403                param_b: params.amplitude,
404                param_c: 0.0,
405                param_d: 0.0,
406                origin,
407                direction: dir_vec,
408                easing: easing_index(&params.easing),
409            }
410        }
411        Effect::Grow(params) => {
412            let orig = match params.origin {
413                Origin::Center | Origin::Cursor => [0.5, 0.5],
414                Origin::Custom(x, y) => [x, y],
415            };
416            EffectUniforms {
417                effect_type: 4,
418                progress: eased(&params.easing),
419                param_a: 0.0,
420                param_b: 0.0,
421                param_c: 0.0,
422                param_d: 0.0,
423                origin: orig,
424                direction: [0.0, 0.0],
425                easing: easing_index(&params.easing),
426            }
427        }
428        Effect::Outer(params) => {
429            let orig = match params.origin {
430                Origin::Center | Origin::Cursor => [0.5, 0.5],
431                Origin::Custom(x, y) => [x, y],
432            };
433            EffectUniforms {
434                effect_type: 5,
435                progress: eased(&params.easing),
436                param_a: 0.0,
437                param_b: 0.0,
438                param_c: 0.0,
439                param_d: 0.0,
440                origin: orig,
441                direction: [0.0, 0.0],
442                easing: easing_index(&params.easing),
443            }
444        }
445    }
446}
447
448/// Parse an effect name into its default `Effect`.
449/// Accepts Wallr names plus the directional aliases familiar from awww:
450/// `simple`, `left`, `right`, `top`, `bottom`, `center`, `any`, and `random`.
451pub fn effect_from_name(name: &str) -> Option<Effect> {
452    let seed = || {
453        std::time::SystemTime::now()
454            .duration_since(std::time::UNIX_EPOCH)
455            .unwrap_or_default()
456            .subsec_nanos()
457    };
458    Some(match name {
459        // `simple` is a plain linear crossfade, matching awww's constant
460        // byte-step feel; `fade` keeps the eased (bezier-like) polish.
461        "simple" => Effect::Fade(FadeParams {
462            easing: Easing::Linear,
463            ..FadeParams::default()
464        }),
465        "fade" => Effect::Fade(FadeParams::default()),
466        "wipe" => Effect::Wipe(WipeParams::default()),
467        "slide" => Effect::Slide(SlideParams::default()),
468        "left" => Effect::Slide(SlideParams {
469            direction: SlideDirection::Left,
470            ..SlideParams::default()
471        }),
472        "right" => Effect::Slide(SlideParams {
473            direction: SlideDirection::Right,
474            ..SlideParams::default()
475        }),
476        "top" => Effect::Slide(SlideParams {
477            direction: SlideDirection::Up,
478            ..SlideParams::default()
479        }),
480        "bottom" => Effect::Slide(SlideParams {
481            direction: SlideDirection::Down,
482            ..SlideParams::default()
483        }),
484        "wave" => Effect::Wave(WaveParams::default()),
485        "grow" => Effect::Grow(GrowParams::default()),
486        "center" => Effect::Grow(GrowParams::default()),
487        "outer" => Effect::Outer(OuterParams::default()),
488        "any" => {
489            let value = seed();
490            let origin = Origin::Custom(
491                (value % 1000) as f32 / 1000.0,
492                ((value / 1000) % 1000) as f32 / 1000.0,
493            );
494            if value % 2 == 0 {
495                Effect::Grow(GrowParams {
496                    origin,
497                    ..GrowParams::default()
498                })
499            } else {
500                Effect::Outer(OuterParams {
501                    origin,
502                    ..OuterParams::default()
503                })
504            }
505        }
506        "random" => match seed() % 5 {
507            0 => Effect::Fade(FadeParams::default()),
508            1 => Effect::Slide(SlideParams::default()),
509            2 => Effect::Wave(WaveParams::default()),
510            3 => Effect::Grow(GrowParams::default()),
511            _ => Effect::Outer(OuterParams::default()),
512        },
513        _ => return None,
514    })
515}
516
517/// Canonical effect names. Directional and convenience aliases (`simple`,
518/// `left`, `right`, `top`, `bottom`, `center`, `any`, `random`) still map
519/// through `effect_from_name`; this list drives CLI help and errors so
520/// users see the six real transitions rather than every alias.
521pub fn effect_names() -> &'static [&'static str] {
522    &["fade", "wipe", "slide", "wave", "grow", "outer"]
523}
524
525#[derive(Debug, Clone, Default)]
526pub struct EffectOverrides {
527    pub origin: Option<(f32, f32)>,
528    pub origin_preset: Option<String>,
529    pub direction: Option<[f32; 2]>,
530    pub angle: Option<f32>,
531    pub easing: Option<Easing>,
532    pub from: Option<f32>,
533    pub to: Option<f32>,
534    pub frequency: Option<f32>,
535    pub amplitude: Option<f32>,
536    pub softness: Option<f32>,
537}
538
539fn origin_from_preset(preset: &str) -> (f32, f32) {
540    match preset {
541        "top_left" => (0.0, 0.0),
542        "top" => (0.5, 0.0),
543        "top_right" => (1.0, 0.0),
544        "left" => (0.0, 0.5),
545        "center" => (0.5, 0.5),
546        "right" => (1.0, 0.5),
547        "bottom_left" => (0.0, 1.0),
548        "bottom" => (0.5, 1.0),
549        "bottom_right" => (1.0, 1.0),
550        _ => (0.5, 0.5),
551    }
552}
553
554/// Apply CLI-style overrides to an effect, mutating it in place.
555pub fn apply_effect_overrides(effect: &mut Effect, o: &EffectOverrides) {
556    let origin = o
557        .origin
558        .or_else(|| o.origin_preset.as_ref().map(|p| origin_from_preset(p)));
559
560    match effect {
561        Effect::Fade(p) => {
562            if let Some(v) = o.from {
563                p.from = v;
564            }
565            if let Some(v) = o.to {
566                p.to = v;
567            }
568            if let Some(e) = o.easing {
569                p.easing = e;
570            }
571        }
572        Effect::Wipe(p) => {
573            if let Some(s) = o.softness {
574                p.softness = s;
575            }
576            if let Some(a) = o.angle {
577                p.angle = Some(a);
578            }
579            if let Some(e) = o.easing {
580                p.easing = e;
581            }
582            if let Some(d) = o.direction {
583                p.direction = match d {
584                    [-1.0, 0.0] => WipeDirection::Left,
585                    [1.0, 0.0] => WipeDirection::Right,
586                    [0.0, 1.0] => WipeDirection::Up,
587                    [0.0, -1.0] => WipeDirection::Down,
588                    _ => p.direction,
589                };
590            }
591        }
592        Effect::Slide(p) => {
593            if let Some(e) = o.easing {
594                p.easing = e;
595            }
596            if let Some(d) = o.direction {
597                p.direction = match d {
598                    [-1.0, 0.0] => SlideDirection::Left,
599                    [1.0, 0.0] => SlideDirection::Right,
600                    [0.0, 1.0] => SlideDirection::Up,
601                    [0.0, -1.0] => SlideDirection::Down,
602                    _ => p.direction,
603                };
604            }
605        }
606        Effect::Wave(p) => {
607            if let Some(v) = o.frequency {
608                p.frequency = v;
609            }
610            if let Some(v) = o.amplitude {
611                p.amplitude = v;
612            }
613            if let Some(a) = o.angle {
614                p.angle = Some(a);
615            }
616            if let Some((x, y)) = origin {
617                p.origin = Origin::Custom(x, y);
618            }
619            if let Some(e) = o.easing {
620                p.easing = e;
621            }
622        }
623        Effect::Grow(p) => {
624            if let Some((x, y)) = origin {
625                p.origin = Origin::Custom(x, y);
626            }
627            if let Some(e) = o.easing {
628                p.easing = e;
629            }
630        }
631        Effect::Outer(p) => {
632            if let Some((x, y)) = origin {
633                p.origin = Origin::Custom(x, y);
634            }
635            if let Some(e) = o.easing {
636                p.easing = e;
637            }
638        }
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    #[test]
647    fn ranged_effects_keep_both_endpoints_for_the_shader() {
648        let fade = Effect::Fade(FadeParams {
649            from: 0.2,
650            to: 0.9,
651            easing: Easing::EaseOut,
652        });
653        let start = compute_effect_uniforms(&fade, 0.0);
654        let end = compute_effect_uniforms(&fade, 1.0);
655        assert_eq!((start.param_a, start.param_b), (0.2, 0.9));
656        assert_eq!((end.param_a, end.param_b), (0.2, 0.9));
657    }
658
659    #[test]
660    fn awww_direction_aliases_resolve_to_typed_effects() {
661        assert!(matches!(effect_from_name("simple"), Some(Effect::Fade(_))));
662        assert!(matches!(effect_from_name("left"), Some(Effect::Slide(_))));
663        assert!(matches!(effect_from_name("right"), Some(Effect::Slide(_))));
664        assert!(matches!(effect_from_name("center"), Some(Effect::Grow(_))));
665        assert!(matches!(
666            effect_from_name("any"),
667            Some(Effect::Grow(_)) | Some(Effect::Outer(_))
668        ));
669        assert!(effect_from_name("random").is_some());
670    }
671
672    #[test]
673    fn effect_types_match_shader_arms() {
674        // Must track effects.wgsl: 0 fade, 1 wipe, 2 slide, 3 wave, 4 grow, 5 outer.
675        let cases = [
676            (Effect::Fade(FadeParams::default()), 0),
677            (Effect::Wipe(WipeParams::default()), 1),
678            (Effect::Slide(SlideParams::default()), 2),
679            (Effect::Wave(WaveParams::default()), 3),
680            (Effect::Grow(GrowParams::default()), 4),
681            (Effect::Outer(OuterParams::default()), 5),
682        ];
683        for (effect, expected) in cases {
684            assert_eq!(compute_effect_uniforms(&effect, 0.5).effect_type, expected);
685        }
686    }
687
688    #[test]
689    fn removed_effects_no_longer_resolve() {
690        for name in ["blur", "zoom", "pixelate", "ripple", "dissolve", "shader"] {
691            assert_eq!(effect_from_name(name), None);
692        }
693    }
694
695    #[test]
696    fn bezier_easing_matches_awww_curve() {
697        assert_eq!(cubic_bezier_y(0.0), 0.0);
698        assert_eq!(cubic_bezier_y(1.0), 1.0);
699        let mut prev = 0.0;
700        let mut i = 1;
701        while i <= 20 {
702            let y = cubic_bezier_y(i as f32 / 20.0);
703            assert!(y >= prev, "bezier must not decrease");
704            prev = y;
705            i += 1;
706        }
707        // awww's (.54, 0, .34, .99): slow start, fast middle, soft landing.
708        let mid = cubic_bezier_y(0.5);
709        assert!((0.4..0.7).contains(&mid), "mid: {mid}");
710        assert!(cubic_bezier_y(0.25) < 0.25);
711    }
712
713    #[test]
714    fn wave_honors_explicit_origin_without_angle() {
715        let mut wave = Effect::Wave(WaveParams::default());
716        apply_effect_overrides(
717            &mut wave,
718            &EffectOverrides {
719                origin: Some((1.0, 1.0)),
720                ..EffectOverrides::default()
721            },
722        );
723        let uniforms = compute_effect_uniforms(&wave, 0.5);
724        assert_eq!(uniforms.origin, [1.0, 1.0]);
725
726        // An angle still derives the center from the sweep direction.
727        let mut angled = Effect::Wave(WaveParams::default());
728        apply_effect_overrides(
729            &mut angled,
730            &EffectOverrides {
731                origin: Some((1.0, 1.0)),
732                angle: Some(90.0),
733                ..EffectOverrides::default()
734            },
735        );
736        let uniforms = compute_effect_uniforms(&angled, 0.5);
737        assert_ne!(uniforms.origin, [1.0, 1.0]);
738    }
739}