Skip to main content

bevy_react/filters/builtin/
shadow.rs

1//! The `shadow` built-in: a blurred, offset drop shadow under the content.
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::builtin::BlurParams;
13use crate::filters::params::{FilterColor, ParamSlot, length_logical_px, static_layout};
14use crate::filters::registry::{ReactFilter, ResolvedFilterPass};
15use crate::protocol::units::Length;
16
17/// Silhouette-prep mode marker in `params[0].w` (see `shadow.wgsl`).
18const MODE_PREP: f32 = 0.0;
19/// Combine mode marker in `params[0].w`.
20const MODE_COMBINE: f32 = 1.0;
21
22fn default_offset_y() -> Length {
23    Length::Px(4.0)
24}
25
26fn default_spread() -> Length {
27    Length::Px(6.0)
28}
29
30fn default_shadow_color() -> FilterColor {
31    // Black at 60% alpha — a soft, visible shorthand default.
32    FilterColor([0.0, 0.0, 0.0, 0.6])
33}
34
35/// `shadow`: a CSS-`drop-shadow`-style shadow — the content's alpha
36/// silhouette, tinted `color`, shifted by `offsetX`/`offsetY` px (positive =
37/// right/down), Gaussian-blurred by `spread`, composited UNDER the content.
38/// `{ name: "shadow" }` with no params is a soft black shadow below
39/// (shorthand-default convention); the true identity is
40/// `color: "transparent"`.
41///
42/// Resolves to **four** passes, bloom's exact structure: silhouette-prep
43/// (`shadow.wgsl` — offset + tint), blur H + V (literally `blur.wgsl`,
44/// sharing the plain blur's pipeline — `spread` sits in blur's radius slot),
45/// and a combine (`shadow.wgsl`) layering the original capture over the
46/// blurred shadow through the prelude's always-bound `capture_texture`. Like
47/// bloom, the combine reads the ORIGINAL capture — chain `shadow` first (or
48/// alone) when combining with color ops. All named params ride every pass in
49/// the same slots (uniform length rewriting + animation bindings — all four
50/// are `{ animated }`-drivable); the mode switch is pass-internal in
51/// `params[0].w`, like blur's direction.
52#[derive(Debug, Clone, Copy, PartialEq, Deserialize, ts_rs::TS)]
53#[serde(deny_unknown_fields, rename_all = "camelCase")]
54pub struct ShadowParams {
55    #[serde(default = "default_shadow_color")]
56    pub color: FilterColor,
57    // Mirror `#[react_filter]`'s override for `Length` fields; offsets may be
58    // negative (left/up).
59    #[serde(default)]
60    #[ts(type = "number | string")]
61    pub offset_x: Length,
62    #[serde(default = "default_offset_y")]
63    #[ts(type = "number | string")]
64    pub offset_y: Length,
65    #[serde(default = "default_spread")]
66    #[ts(type = "number | string")]
67    pub spread: Length,
68}
69
70impl Default for ShadowParams {
71    fn default() -> Self {
72        Self {
73            color: default_shadow_color(),
74            offset_x: Length::Px(0.0),
75            offset_y: default_offset_y(),
76            spread: default_spread(),
77        }
78    }
79}
80
81fn shadow_layout() -> Arc<[ParamSlot]> {
82    static_layout![
83        ParamSlot {
84            name: "spread",
85            kind: ValueKind::Length,
86            vec: 0,
87            comp: 0,
88            len: 1,
89        },
90        ParamSlot {
91            name: "offsetX",
92            kind: ValueKind::Length,
93            vec: 1,
94            comp: 0,
95            len: 1,
96        },
97        ParamSlot {
98            name: "offsetY",
99            kind: ValueKind::Length,
100            vec: 1,
101            comp: 1,
102            len: 1,
103        },
104        ParamSlot {
105            name: "color",
106            kind: ValueKind::Color,
107            vec: 2,
108            comp: 0,
109            len: 4,
110        },
111    ]
112}
113
114impl ShadowParams {
115    fn lengths_px(&self) -> Result<(f32, f32, f32), String> {
116        Ok((
117            length_logical_px(Self::NAME, "offsetX", self.offset_x)?,
118            length_logical_px(Self::NAME, "offsetY", self.offset_y)?,
119            length_logical_px(Self::NAME, "spread", self.spread)?,
120        ))
121    }
122}
123
124impl ReactFilter for ShadowParams {
125    const NAME: &'static str = "shadow";
126
127    fn shader(assets: &AssetServer) -> Handle<Shader> {
128        load_embedded_asset!(assets, "shadow.wgsl")
129    }
130
131    fn identity_params() -> Option<Value> {
132        // Deserializes through the struct, so offsets/spread take their
133        // defaults; a fully transparent shadow makes the combine pass return
134        // exactly the original regardless of them.
135        Some(serde_json::json!({ "color": "transparent" }))
136    }
137
138    /// The shadow reaches its largest offset shift plus the blur bleed
139    /// (3σ-style, like `blur`) past the silhouette.
140    fn outset(&self) -> Result<f32, String> {
141        let (offset_x, offset_y, spread) = self.lengths_px()?;
142        Ok(offset_x.abs().max(offset_y.abs()) + 3.0 * spread)
143    }
144
145    fn pack(&self) -> (Vec<Vec4>, Arc<[ParamSlot]>) {
146        // The prep pass's packing; `resolve` builds all four passes. `pack`
147        // is infallible, so non-px lengths fall back to 0 here — but they can
148        // never reach the shader: `resolve`/`outset` reject them first.
149        let (offset_x, offset_y, spread) = self.lengths_px().unwrap_or((0.0, 0.0, 0.0));
150        (
151            vec![
152                Vec4::new(spread, 0.0, 0.0, MODE_PREP),
153                Vec4::new(offset_x, offset_y, 0.0, 0.0),
154                Vec4::from_array(self.color.0),
155            ],
156            shadow_layout(),
157        )
158    }
159
160    fn resolve(&self, assets: &AssetServer) -> Result<Vec<ResolvedFilterPass>, String> {
161        let (offset_x, offset_y, spread) = self.lengths_px()?;
162        let shadow_shader = Self::shader(assets);
163        let blur_shader = BlurParams::shader(assets);
164        let offsets = Vec4::new(offset_x, offset_y, 0.0, 0.0);
165        let color = Vec4::from_array(self.color.0);
166        let pass = |shader: &Handle<Shader>, internal: (f32, f32, f32)| ResolvedFilterPass {
167            shader: shader.clone(),
168            params: vec![
169                Vec4::new(spread, internal.0, internal.1, internal.2),
170                offsets,
171                color,
172            ],
173            layout: shadow_layout(),
174            wire_index: 0,
175        };
176        Ok(vec![
177            pass(&shadow_shader, (0.0, 0.0, MODE_PREP)),
178            pass(&blur_shader, (1.0, 0.0, 0.0)),
179            pass(&blur_shader, (0.0, 1.0, 0.0)),
180            pass(&shadow_shader, (0.0, 0.0, MODE_COMBINE)),
181        ])
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use serde_json::json;
188
189    use super::*;
190    use crate::filters::registry::FilterRegistry;
191    use crate::filters::test_util::{asset_app, params};
192
193    /// Shadow expands into prep → blur H → blur V → combine, all
194    /// `wire_index: 0`, with the named params (spread/offsets/color) in the
195    /// same slots of every pass — spread sits in blur's radius slot so the
196    /// middle passes literally run the blur shader — and the mode/direction
197    /// components pass-internal.
198    #[test]
199    fn shadow_resolves_to_four_passes() {
200        let app = asset_app();
201        let assets = app.world().resource::<AssetServer>();
202        let passes = params::<ShadowParams>(json!({
203            "offsetX": 3, "offsetY": -2, "spread": 5, "color": "#ff0000",
204        }))
205        .resolve(assets)
206        .expect("shadow resolves");
207        assert_eq!(passes.len(), 4);
208
209        let offsets = Vec4::new(3.0, -2.0, 0.0, 0.0);
210        let color = Vec4::new(1.0, 0.0, 0.0, 1.0);
211        assert_eq!(
212            passes[0].params,
213            vec![Vec4::new(5.0, 0.0, 0.0, 0.0), offsets, color]
214        );
215        assert_eq!(
216            passes[1].params,
217            vec![Vec4::new(5.0, 1.0, 0.0, 0.0), offsets, color]
218        );
219        assert_eq!(
220            passes[2].params,
221            vec![Vec4::new(5.0, 0.0, 1.0, 0.0), offsets, color]
222        );
223        assert_eq!(
224            passes[3].params,
225            vec![Vec4::new(5.0, 0.0, 0.0, 1.0), offsets, color]
226        );
227        assert!(passes.iter().all(|p| p.wire_index == 0));
228        assert_eq!(passes[0].layout[0].name, "spread");
229        assert_eq!(passes[0].layout[0].kind, ValueKind::Length);
230        assert_eq!(passes[0].layout[3].kind, ValueKind::Color);
231
232        // Prep/combine share shadow's shader; the middle passes are literally
233        // the blur shader (so they share its compiled pipeline).
234        assert_eq!(passes[0].shader, passes[3].shader);
235        assert_eq!(passes[1].shader, passes[2].shader);
236        assert_eq!(passes[1].shader, BlurParams::shader(assets));
237        assert_ne!(passes[0].shader, passes[1].shader);
238    }
239
240    /// The shadow bleeds its largest |offset| plus 3x the spread; the
241    /// defaults (offset 0/4, spread 6) give 22 logical px.
242    #[test]
243    fn shadow_outset_is_offset_plus_three_spreads() {
244        assert_eq!(
245            params::<ShadowParams>(json!({ "offsetX": -8, "offsetY": 2, "spread": 4 })).outset(),
246            Ok(20.0)
247        );
248        assert_eq!(params::<ShadowParams>(json!({})).outset(), Ok(22.0));
249    }
250
251    /// Empty params take the shorthand defaults: a soft black shadow below.
252    #[test]
253    fn shadow_empty_params_default_to_a_soft_shadow() {
254        let p = params::<ShadowParams>(json!({}));
255        assert_eq!(p, ShadowParams::default());
256        assert_eq!(p.offset_x, Length::Px(0.0));
257        assert_eq!(p.offset_y, Length::Px(4.0));
258        assert_eq!(p.spread, Length::Px(6.0));
259        assert_eq!(p.color.0, [0.0, 0.0, 0.0, 0.6]);
260    }
261
262    /// The true identity is a fully transparent shadow; it deserializes
263    /// cleanly and packs a zero-alpha color slot.
264    #[test]
265    fn identity_is_a_transparent_shadow() {
266        let identity = ShadowParams::identity_params().expect("has identity");
267        let p: ShadowParams = serde_json::from_value(identity).expect("identity decodes");
268        assert_eq!(p.color.0, [0.0, 0.0, 0.0, 0.0]);
269        assert_eq!(p.pack().0[2], Vec4::ZERO);
270    }
271
272    /// Non-px offsets/spread reject from both baked registry fns, naming the
273    /// unit — same contract as blur.
274    #[test]
275    fn non_px_lengths_reject_from_registry() {
276        let app = asset_app();
277        let assets = app.world().resource::<AssetServer>();
278        let mut registry = FilterRegistry::default();
279        registry.register::<ShadowParams>();
280        let entry = &registry.entries["shadow"];
281
282        for value in [
283            json!({ "offsetX": "50%" }),
284            json!({ "offsetY": "2vw" }),
285            json!({ "spread": "1vh" }),
286        ] {
287            let err = (entry.resolve)(&value, assets).expect_err("non-px must reject resolve");
288            assert!(err.contains("px"), "names the unit: {err}");
289            let err = (entry.outset)(&value).expect_err("non-px must reject outset");
290            assert!(err.contains("px"), "names the unit: {err}");
291        }
292        assert!((entry.resolve)(&json!({ "offsetX": -6, "spread": "3px" }), assets).is_ok());
293        assert_eq!(
294            (entry.outset)(&json!({ "offsetY": -10, "spread": 2 })),
295            Ok(16.0)
296        );
297    }
298
299    /// `deny_unknown_fields`: a typoed param rejects.
300    #[test]
301    fn unknown_shadow_param_rejects() {
302        assert!(serde_json::from_value::<ShadowParams>(json!({ "offsetx": 2 })).is_err());
303    }
304}