Skip to main content

bevy_react/filters/
params.rs

1//! Param packing and interpolation: where a filter's named params land in the
2//! packed `Vec4` uniform array ([`ParamSlot`]), the caps guarding the packing
3//! and the chain outset, the param value types with wire-level decode rules
4//! ([`FilterColor`], [`length_logical_px`]), and the layout-aware
5//! interpolation primitives ([`lerp_packed_params`], [`lerp_angle`]) the
6//! easing paths blend packed arrays with.
7
8use bevy::math::Vec4;
9use serde::{Deserialize, Deserializer};
10
11use crate::animations::ValueKind;
12use crate::protocol::units::Length;
13
14/// Cap on the packed `Vec4` array per pass — the fixed-size uniform array the
15/// filter shaders declare.
16pub const MAX_FILTER_PARAM_VECS: usize = 8;
17
18/// Defensive cap on a chain's summed outset, physical px per side. Far beyond
19/// any sane blur (quality is bounded well before this — see `blur.wgsl`'s
20/// MAX_HALF note), it bounds the inflation math: `2 * outset` adds at most
21/// 2048 texels to the capture, leaving real headroom under wgpu's default
22/// 8192 `max_texture_dimension_2d` for the content itself. (Texture-limit
23/// safety proper is the allocator's concern, not this cap's.)
24pub const MAX_FILTER_OUTSET_PX: u32 = 1024;
25
26/// Where one named parameter lands in a pass's packed `Vec4` array: `vec` is
27/// the `Vec4` index, `comp` the starting component within it, `len` how many
28/// consecutive components the param spans.
29///
30/// Multi-component params reuse [`ValueKind::Scalar`] with `len > 1` (one
31/// slot spanning `len` components) — there is no dedicated vector kind, since
32/// no param needs per-component semantics and interpolation is component-wise
33/// anyway.
34///
35/// **No-straddle rule:** a slot never crosses a `Vec4` boundary —
36/// `comp + len <= 4`, always. Packers (the built-ins' hand layouts and
37/// `#[react_filter]`'s generated contiguous fill) pad a multi-component param
38/// that would straddle up to component 0 of the *next* `Vec4`, leaving the
39/// skipped components zero. The chain resolver's physical-px rewrite relies
40/// on this when it clamps a slot's component range at 4.
41///
42/// **Length contract:** a slot with `kind == ValueKind::Length` holds the
43/// param's *logical*-px value as produced by
44/// [`ReactFilter::pack`](crate::filters::ReactFilter::pack); the chain
45/// resolve system ([`resolve_chains`](crate::filters::resolve_chains))
46/// rewrites those components to physical px using this layout metadata before
47/// upload. Every other kind packs in its final unit (scalars as-is, angles in
48/// radians).
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct ParamSlot {
51    pub name: &'static str,
52    pub kind: ValueKind,
53    pub vec: usize,
54    pub comp: usize,
55    pub len: usize,
56}
57
58/// The shared, lazily-built `Arc<[ParamSlot]>` layout of a hand-written
59/// built-in's `pack`: one static per call site, cloned on use (the
60/// `#[react_filter]` macro generates the equivalent for custom filters).
61macro_rules! static_layout {
62    ($($slot:expr),+ $(,)?) => {{
63        static LAYOUT: ::std::sync::LazyLock<
64            ::std::sync::Arc<[crate::filters::ParamSlot]>,
65        > = ::std::sync::LazyLock::new(|| ::std::sync::Arc::from(vec![$($slot),+]));
66        ::std::sync::Arc::clone(&LAYOUT)
67    }};
68}
69pub(crate) use static_layout;
70
71pub(super) fn check_param_cap(name: &str, vecs: usize) -> Result<(), String> {
72    if vecs > MAX_FILTER_PARAM_VECS {
73        return Err(format!(
74            "filter {name:?} packs {vecs} param vec4s, over the cap of {MAX_FILTER_PARAM_VECS}"
75        ));
76    }
77    Ok(())
78}
79
80/// A [`Length`] filter param's logical-px value. Only `Px` has a fixed
81/// logical size here — percent/viewport units have no basis for a filter
82/// param — so any other unit is rejected with a message naming it, instead of
83/// silently resolving to `0.0`. Bare wire numbers decode as `Px` and stay
84/// accepted.
85///
86/// `pub` because `#[react_filter]`-generated `pack`/`outset`/`resolve` code
87/// calls it from consumer crates (blur shares it in-crate).
88pub fn length_logical_px(filter: &str, param: &str, len: Length) -> Result<f32, String> {
89    let unit = match len {
90        Length::Px(px) => return Ok(px),
91        Length::Auto => "auto",
92        Length::Percent(_) => "%",
93        Length::Vw(_) => "vw",
94        Length::Vh(_) => "vh",
95        Length::VMin(_) => "vmin",
96        Length::VMax(_) => "vmax",
97    };
98    Err(format!(
99        "filter {filter:?} {param} must be in px (a bare number or \"px\"), got a {unit:?} length"
100    ))
101}
102
103/// A color filter parameter: **linear** (shader-ready) straight-alpha RGBA.
104///
105/// Deserializes from a CSS color string — any form
106/// [`crate::canvas::parse_css_color`] accepts (hex, named colors,
107/// `rgb()`/`hsl()`/`oklch()`/…) — converted sRGB → linear on decode, so
108/// [`ReactFilter::pack`](crate::filters::ReactFilter::pack) copies the four
109/// components into a `Vec4` slot untouched. `Default` is transparent black
110/// (`[0.0; 4]`).
111///
112/// Unlike the style layer's warn-and-magenta fallback (a whole `Style` must
113/// never fail to decode), filter params are strict — `deny_unknown_fields`
114/// already hard-errors — so an unparsable color is a hard `Err`: the
115/// registry's resolve path skips the filter entry with a `filterParams`
116/// warning, exactly like blur's non-px radius.
117#[derive(Debug, Clone, Copy, PartialEq, Default)]
118pub struct FilterColor(pub [f32; 4]);
119
120impl<'de> Deserialize<'de> for FilterColor {
121    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
122        let s = String::deserialize(d)?;
123        let srgba = crate::canvas::parse_css_color(&s)
124            .ok_or_else(|| serde::de::Error::custom(format!("invalid color {s:?}")))?;
125        let lin = bevy::color::LinearRgba::from(srgba);
126        Ok(Self([lin.red, lin.green, lin.blue, lin.alpha]))
127    }
128}
129
130/// ts-rs surface: a `FilterColor` is a CSS color **string** on the wire
131/// (mirroring the primitive impls — it inlines, it does not declare).
132impl ::ts_rs::TS for FilterColor {
133    type WithoutGenerics = Self;
134    fn name() -> String {
135        "string".to_owned()
136    }
137    fn inline() -> String {
138        Self::name()
139    }
140    fn inline_flattened() -> String {
141        panic!("FilterColor cannot be flattened")
142    }
143    fn decl() -> String {
144        panic!("FilterColor cannot be declared")
145    }
146    fn decl_concrete() -> String {
147        panic!("FilterColor cannot be declared")
148    }
149}
150
151/// Shortest-arc interpolation between two angles in **radians**, for packed
152/// [`ValueKind::Angle`] filter params.
153///
154/// Filter-owned on purpose (not a [`crate::animations::Lerp`] impl): the
155/// style `rotate` transition deliberately animates its angle as a bare scalar
156/// (720° → 0° unwinds through two full turns), so shortest-arc must not leak
157/// into the general lerp primitives. Packed angle params feed periodic shader
158/// math (hue rotation), where only the angle mod 2π matters and the short way
159/// around the circle is the right path.
160///
161/// `t == 0.0` returns `a` and `t == 1.0` returns `b` **bit-exactly** (the
162/// `t == 1.0` branch is load-bearing: the wrapped formula would land on `b`'s
163/// angle only mod 2π). In between the result is `a + wrap(b - a) * t`, with
164/// `wrap` folding the difference into `(-π, π]` — so for `t ∈ (0, 1)` the
165/// result lands within π of `a`, is NOT normalized to any canonical range,
166/// and is consumed directly as radians. Exactly-opposite angles take the
167/// positive (counter-clockwise) arc. `t` outside `0..=1` extrapolates along
168/// the same arc.
169pub fn lerp_angle(a: f32, b: f32, t: f32) -> f32 {
170    use std::f32::consts::{PI, TAU};
171    if t == 0.0 {
172        return a;
173    }
174    if t == 1.0 {
175        return b;
176    }
177    // `rem_euclid` puts the difference in `[0, TAU)`; folding the upper half
178    // down yields the signed shortest arc in `(-PI, PI]`.
179    let mut delta = (b - a).rem_euclid(TAU);
180    if delta > PI {
181        delta -= TAU;
182    }
183    a + delta * t
184}
185
186/// Interpolate two packed param arrays of the **same layout**, slot-by-slot:
187/// the layout (not the raw components) decides how each param blends.
188///
189/// - [`ValueKind::Scalar`] / [`ValueKind::Length`] / [`ValueKind::Color`]
190///   slots lerp component-wise in the packed space. For colors that is
191///   **linear-space** interpolation — filter color params are packed linear
192///   RGBA (see [`FilterColor`]), shader-ready; converting to sRGB to
193///   interpolate would both diverge from what the shader sees and hand the
194///   GPU values it doesn't consume. (The style layer's `[f32; 4]`
195///   [`crate::animations::Lerp`] doc says sRGB component-wise — that applies
196///   to *style* colors, which live in sRGB; do not conflate the two spaces.)
197/// - [`ValueKind::Angle`] slots take the shortest arc via [`lerp_angle`].
198///
199/// Components no slot covers (no-straddle padding — zero by the packing
200/// contract) copy from `b`, so padding is stable rather than blended; the
201/// choice is unobservable for contract-abiding packers. `t == 0.0` / `t == 1.0`
202/// return `a` / `b` bit-exactly (whole array, padding included); other `t`
203/// use the plain `a + (b - a) * t` form, so out-of-range `t` extrapolates.
204///
205/// The caller guarantees `a`/`b` came from the same layout; a length mismatch
206/// is a bug upstream (`debug_assert`ed) and defensively returns `b.to_vec()`
207/// in release. Slot indices out of the arrays' bounds are skipped, like the
208/// resolver's physical-px rewrite.
209pub fn lerp_packed_params(a: &[Vec4], b: &[Vec4], t: f32, layout: &[ParamSlot]) -> Vec<Vec4> {
210    debug_assert_eq!(
211        a.len(),
212        b.len(),
213        "lerp_packed_params: a/b length mismatch (the caller guarantees one shared layout)"
214    );
215    if a.len() != b.len() {
216        return b.to_vec();
217    }
218    if t == 0.0 {
219        return a.to_vec();
220    }
221    if t == 1.0 {
222        return b.to_vec();
223    }
224    // Padding policy: start from `b`, overwrite the slot-covered components.
225    let mut out = b.to_vec();
226    for slot in layout {
227        let Some((av, bv)) = a.get(slot.vec).zip(b.get(slot.vec)) else {
228            continue;
229        };
230        for comp in slot.comp..(slot.comp + slot.len).min(4) {
231            out[slot.vec][comp] = match slot.kind {
232                ValueKind::Angle => lerp_angle(av[comp], bv[comp], t),
233                _ => av[comp] + (bv[comp] - av[comp]) * t,
234            };
235        }
236    }
237    out
238}
239
240#[cfg(test)]
241mod tests {
242    use std::f32::consts::PI;
243
244    use serde_json::json;
245
246    use super::*;
247
248    /// When the endpoints are within half a circle of each other, the
249    /// shortest arc IS the straight line — plain lerp, ascending or
250    /// descending.
251    #[test]
252    fn lerp_angle_within_half_circle_matches_plain_lerp() {
253        let (a, b) = (0.2f32, 1.7f32); // 1.5 rad apart, well under PI
254        for t in [0.25f32, 0.5, 0.75] {
255            let plain = a + (b - a) * t;
256            assert!(
257                (lerp_angle(a, b, t) - plain).abs() < 1e-6,
258                "t={t}: {} vs {plain}",
259                lerp_angle(a, b, t)
260            );
261        }
262        // Descending across zero (not the seam): still plain.
263        assert!((lerp_angle(1.0, -1.0, 0.5)).abs() < 1e-6);
264    }
265
266    /// 170° to -170° is 20° of travel through the ±π seam — the midpoint is
267    /// ±180°, NOT 0° (the long way's midpoint).
268    #[test]
269    fn lerp_angle_crosses_seam_the_short_way() {
270        let a = 170f32.to_radians();
271        let b = (-170f32).to_radians();
272        let mid = lerp_angle(a, b, 0.5);
273        assert!((mid.abs() - PI).abs() < 1e-5, "mid = {mid}");
274        // A quarter of the way is 175°, still on `a`'s side of the seam.
275        assert!((lerp_angle(a, b, 0.25) - 175f32.to_radians()).abs() < 1e-5);
276        // And the seam-free direction check: it never goes near 0.
277        assert!(mid.abs() > 3.0);
278    }
279
280    /// `t == 0` / `t == 1` return the endpoints bit-exactly, including
281    /// across the seam where the wrapped formula alone would only land on
282    /// `b`'s angle mod 2π.
283    #[test]
284    fn lerp_angle_endpoints_exact() {
285        let a = 0.1f32 + 0.7f32; // awkward: not exactly 0.8
286        let b = -3.041_7f32;
287        assert_eq!(lerp_angle(a, b, 0.0), a);
288        assert_eq!(lerp_angle(a, b, 1.0), b);
289        let (sa, sb) = (170f32.to_radians(), (-170f32).to_radians());
290        assert_eq!(lerp_angle(sa, sb, 0.0), sa);
291        assert_eq!(lerp_angle(sa, sb, 1.0), sb);
292    }
293
294    /// Reversing the endpoints mirrors the path: `lerp_angle(a, b, t)` and
295    /// `lerp_angle(b, a, 1 - t)` are the same angle mod 2π (the raw values
296    /// may differ by a turn — the seam midpoint lands on +π one way and -π
297    /// the other).
298    #[test]
299    fn lerp_angle_symmetric_in_its_arguments() {
300        use std::f32::consts::TAU;
301        let cases = [
302            (0.4f32, 2.9f32),
303            (170f32.to_radians(), (-170f32).to_radians()),
304            (-0.3f32, 0.9f32),
305        ];
306        for (a, b) in cases {
307            for t in [0.25f32, 0.5, 0.75] {
308                let fwd = lerp_angle(a, b, t);
309                let rev = lerp_angle(b, a, 1.0 - t);
310                let diff = (fwd - rev).rem_euclid(TAU);
311                let dist = diff.min(TAU - diff);
312                assert!(dist < 1e-5, "a={a} b={b} t={t}: {fwd} vs {rev}");
313            }
314        }
315    }
316
317    /// A two-slot layout (scalar + angle in one vec4) blends each slot per
318    /// its kind: the scalar takes the straight line, the angle the shortest
319    /// arc through the seam.
320    #[test]
321    fn lerp_packed_params_lerps_each_slot_by_kind() {
322        let layout = [
323            ParamSlot {
324                name: "amount",
325                kind: ValueKind::Scalar,
326                vec: 0,
327                comp: 0,
328                len: 1,
329            },
330            ParamSlot {
331                name: "angle",
332                kind: ValueKind::Angle,
333                vec: 0,
334                comp: 1,
335                len: 1,
336            },
337        ];
338        let a = [Vec4::new(0.0, 170f32.to_radians(), 0.0, 0.0)];
339        let b = [Vec4::new(10.0, (-170f32).to_radians(), 0.0, 0.0)];
340        let out = lerp_packed_params(&a, &b, 0.5, &layout);
341        assert_eq!(out.len(), 1);
342        assert_eq!(out[0].x, 5.0);
343        // Through the seam (±π), not through 0 as a raw component lerp
344        // would give.
345        assert!((out[0].y.abs() - PI).abs() < 1e-5, "angle = {}", out[0].y);
346    }
347
348    /// A color slot interpolates in the packed — LINEAR — space: the
349    /// midpoint of linear 0.0 and linear 1.0 is 0.5. An sRGB-space
350    /// interpolation converted back to linear would give ≈0.214; asserting
351    /// 0.5 pins the linear-space contract (packed colors are shader-ready
352    /// linear RGBA, per `FilterColor`).
353    #[test]
354    fn lerp_packed_params_color_slot_interpolates_linearly() {
355        let layout = [ParamSlot {
356            name: "color",
357            kind: ValueKind::Color,
358            vec: 0,
359            comp: 0,
360            len: 4,
361        }];
362        let a = [Vec4::new(0.0, 0.0, 0.0, 1.0)];
363        let b = [Vec4::new(1.0, 1.0, 1.0, 1.0)];
364        let mid = lerp_packed_params(&a, &b, 0.5, &layout)[0];
365        assert_eq!(mid, Vec4::new(0.5, 0.5, 0.5, 1.0));
366        assert!((mid.x - 0.214).abs() > 0.2, "must not be the sRGB midpoint");
367    }
368
369    /// A `Length` slot lerps its (logical-px) component like a scalar.
370    #[test]
371    fn lerp_packed_params_length_slot_lerps() {
372        let layout = [ParamSlot {
373            name: "radius",
374            kind: ValueKind::Length,
375            vec: 0,
376            comp: 0,
377            len: 1,
378        }];
379        // Blur-shaped packing: radius in comp 0, direction in comps 1-2.
380        let a = [Vec4::new(4.0, 1.0, 0.0, 0.0)];
381        let b = [Vec4::new(8.0, 1.0, 0.0, 0.0)];
382        assert_eq!(lerp_packed_params(&a, &b, 0.25, &layout)[0].x, 5.0);
383    }
384
385    /// Components no slot covers copy from `b` untouched — stable, never
386    /// blended (they are zero by the packing contract; the test plants
387    /// nonzero values to observe the copy).
388    #[test]
389    fn lerp_packed_params_padding_copies_from_b() {
390        let layout = [ParamSlot {
391            name: "amount",
392            kind: ValueKind::Scalar,
393            vec: 0,
394            comp: 0,
395            len: 1,
396        }];
397        let a = [Vec4::new(0.0, 111.0, 0.0, 0.0), Vec4::splat(5.0)];
398        let b = [Vec4::new(2.0, 222.0, 0.0, 0.0), Vec4::splat(7.0)];
399        let out = lerp_packed_params(&a, &b, 0.5, &layout);
400        assert_eq!(out[0], Vec4::new(1.0, 222.0, 0.0, 0.0));
401        assert_eq!(out[1], Vec4::splat(7.0));
402    }
403
404    /// `t == 0` / `t == 1` return `a` / `b` bit-exactly — awkward values
405    /// where `a + (b - a) * t` would NOT reproduce `b` at `t = 1`.
406    #[test]
407    fn lerp_packed_params_endpoints_exact() {
408        let layout = [ParamSlot {
409            name: "stuff",
410            kind: ValueKind::Scalar,
411            vec: 0,
412            comp: 0,
413            len: 4,
414        }];
415        let a = [Vec4::new(0.1f32 + 0.7f32, 1e-7, -3.333_333_3, 0.3)];
416        let b = [Vec4::new(0.2f32 + 0.1f32, 123_456.79, 2.718_281_7, -0.1)];
417        assert_eq!(lerp_packed_params(&a, &b, 0.0, &layout), a.to_vec());
418        assert_eq!(lerp_packed_params(&a, &b, 1.0, &layout), b.to_vec());
419    }
420
421    /// Debug builds assert the shared-layout contract on mismatched lengths.
422    #[cfg(debug_assertions)]
423    #[test]
424    #[should_panic(expected = "length mismatch")]
425    fn lerp_packed_params_mismatched_lengths_asserts_in_debug() {
426        let _ = lerp_packed_params(&[Vec4::ZERO], &[], 0.5, &[]);
427    }
428
429    /// Release builds take the defensive path: mismatched lengths return
430    /// `b` wholesale.
431    #[cfg(not(debug_assertions))]
432    #[test]
433    fn lerp_packed_params_mismatched_lengths_returns_b() {
434        let b = [Vec4::splat(3.0)];
435        assert_eq!(lerp_packed_params(&[], &b, 0.5, &[]), b.to_vec());
436    }
437
438    fn from<T: serde::de::DeserializeOwned>(value: serde_json::Value) -> T {
439        serde_json::from_value(value).expect("params decode")
440    }
441
442    /// `FilterColor` reuses the style layer's CSS color parser and stores
443    /// linear (shader-ready) RGBA.
444    #[test]
445    fn filter_color_parses_css_strings_to_linear_rgba() {
446        let c: FilterColor = from(json!("#ff0000"));
447        assert_eq!(c.0, [1.0, 0.0, 0.0, 1.0]);
448        let c: FilterColor = from(json!("rgb(255 0 0)"));
449        assert_eq!(c.0, [1.0, 0.0, 0.0, 1.0]);
450        // Linear, not sRGB: mid-gray #808080 is ~0.216 linear, not 0.502 —
451        // the sRGB → linear conversion happens at decode.
452        let c: FilterColor = from(json!("#808080"));
453        assert!((c.0[0] - 0.2158).abs() < 1e-3, "linear gray: {:?}", c.0);
454        assert_eq!(c.0[3], 1.0);
455    }
456
457    /// Garbage is a hard `Err` — unlike style colors' warn-and-magenta (a
458    /// whole `Style` must never fail to decode), a typed filter param is
459    /// strict, so the registry path skips the entry with a `filterParams`
460    /// warning, like blur's non-px radius.
461    #[test]
462    fn filter_color_garbage_is_a_hard_error() {
463        assert!(serde_json::from_value::<FilterColor>(json!("notacolor")).is_err());
464        assert!(serde_json::from_value::<FilterColor>(json!(42)).is_err());
465    }
466}