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}