Skip to main content

bevy_react/filters/builtin/
blur.rs

1//! The `blur` built-in: a two-pass separable Gaussian.
2
3use std::sync::Arc;
4
5use bevy::asset::load_embedded_asset;
6use bevy::prelude::*;
7use bevy::shader::Shader;
8use serde::Deserialize;
9use serde_json::Value;
10
11use crate::animations::ValueKind;
12use crate::filters::params::{ParamSlot, length_logical_px, static_layout};
13use crate::filters::registry::{ReactFilter, ResolvedFilterPass};
14use crate::protocol::units::Length;
15
16/// `blur`: separable Gaussian blur. `radius` defaults to `0` — CSS `blur()`
17/// with the value omitted means `0` (identity).
18///
19/// Resolves to **two** passes, horizontal then vertical, each packed as
20/// `params[0] = (radius_logical_px, dir.x, dir.y, 0)` with dir `(1,0)` then
21/// `(0,1)`. Only the radius is a named (layout-exposed) param — the direction
22/// components are pass-internal.
23#[derive(Debug, Clone, Copy, PartialEq, Default, Deserialize, ts_rs::TS)]
24#[serde(deny_unknown_fields)]
25pub struct BlurParams {
26    // Mirrors `#[react_filter]`'s override for a `Length` field.
27    #[serde(default)]
28    #[ts(type = "number | string")]
29    pub radius: Length,
30}
31
32fn blur_layout() -> Arc<[ParamSlot]> {
33    static_layout![ParamSlot {
34        name: "radius",
35        kind: ValueKind::Length,
36        vec: 0,
37        comp: 0,
38        len: 1,
39    }]
40}
41
42impl ReactFilter for BlurParams {
43    const NAME: &'static str = "blur";
44
45    fn shader(assets: &AssetServer) -> Handle<Shader> {
46        load_embedded_asset!(assets, "blur.wgsl")
47    }
48
49    fn identity_params() -> Option<Value> {
50        Some(serde_json::json!({ "radius": 0.0 }))
51    }
52
53    /// 3σ-style reach: a Gaussian of radius `r` is visually gone past `3r`.
54    fn outset(&self) -> Result<f32, String> {
55        Ok(3.0 * length_logical_px(Self::NAME, "radius", self.radius)?)
56    }
57
58    fn pack(&self) -> (Vec<Vec4>, Arc<[ParamSlot]>) {
59        // The horizontal pass's packing; `resolve` builds both directions.
60        // `pack` is infallible, so a non-px radius falls back to 0 here — but
61        // it can never reach the shader: `resolve`/`outset` reject it first.
62        (
63            vec![Vec4::new(
64                length_logical_px(Self::NAME, "radius", self.radius).unwrap_or(0.0),
65                1.0,
66                0.0,
67                0.0,
68            )],
69            blur_layout(),
70        )
71    }
72
73    fn resolve(&self, assets: &AssetServer) -> Result<Vec<ResolvedFilterPass>, String> {
74        let radius = length_logical_px(Self::NAME, "radius", self.radius)?;
75        let shader = Self::shader(assets);
76        let pass = |dir: (f32, f32)| ResolvedFilterPass {
77            shader: shader.clone(),
78            params: vec![Vec4::new(radius, dir.0, dir.1, 0.0)],
79            layout: blur_layout(),
80            wire_index: 0,
81        };
82        Ok(vec![pass((1.0, 0.0)), pass((0.0, 1.0))])
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use serde_json::json;
89
90    use super::*;
91    use crate::filters::registry::FilterRegistry;
92    use crate::filters::test_util::{asset_app, params};
93
94    /// Blur expands into exactly two separable passes — horizontal `(1,0)`
95    /// then vertical `(0,1)` — radius (logical px) in `params[0].x`, both
96    /// tagged `wire_index: 0` for the chain resolver to rewrite.
97    #[test]
98    fn blur_resolves_to_two_directional_passes() {
99        let app = asset_app();
100        let assets = app.world().resource::<AssetServer>();
101        let passes = params::<BlurParams>(json!({ "radius": 4 }))
102            .resolve(assets)
103            .expect("blur resolves");
104        assert_eq!(passes.len(), 2);
105        assert_eq!(passes[0].params, vec![Vec4::new(4.0, 1.0, 0.0, 0.0)]);
106        assert_eq!(passes[1].params, vec![Vec4::new(4.0, 0.0, 1.0, 0.0)]);
107        assert!(passes.iter().all(|p| p.wire_index == 0));
108        assert_eq!(passes[0].layout[0].kind, ValueKind::Length);
109    }
110
111    /// Blur bleeds 3x its radius (Gaussian reach) in logical px.
112    #[test]
113    fn blur_outset_is_three_radii() {
114        assert_eq!(
115            params::<BlurParams>(json!({ "radius": 4 })).outset(),
116            Ok(12.0)
117        );
118        assert_eq!(params::<BlurParams>(json!({})).outset(), Ok(0.0));
119    }
120
121    /// A non-px blur radius (`"50%"`, `"1vw"`, ...) rejects from both baked
122    /// registry fns — resolve and outset — naming the offending unit, instead
123    /// of silently packing `0.0`.
124    #[test]
125    fn non_px_blur_radius_rejects_from_registry() {
126        let app = asset_app();
127        let assets = app.world().resource::<AssetServer>();
128        let mut registry = FilterRegistry::default();
129        registry.register::<BlurParams>();
130        let blur = &registry.entries["blur"];
131
132        let value = json!({ "radius": "50%" });
133        let err = (blur.resolve)(&value, assets).expect_err("percent radius must reject resolve");
134        assert!(
135            err.contains("px") && err.contains("%"),
136            "names the unit: {err}"
137        );
138        let err = (blur.outset)(&value).expect_err("percent radius must reject outset");
139        assert!(
140            err.contains("px") && err.contains("%"),
141            "names the unit: {err}"
142        );
143
144        let err = (blur.outset)(&json!({ "radius": "1vw" })).expect_err("vw radius must reject");
145        assert!(err.contains("vw"), "names the unit: {err}");
146
147        // Bare numbers and explicit px stay accepted.
148        assert!((blur.resolve)(&json!({ "radius": 4 }), assets).is_ok());
149        assert_eq!((blur.outset)(&json!({ "radius": "4px" })), Ok(12.0));
150    }
151}