Skip to main content

bevy_react/filters/
backdrop.rs

1//! The `backdropFilter` chain components — the backdrop instance of
2//! [`resolve_chains`](super::resolve_chains).
3//!
4//! `backdropFilter` shares everything with the content `filter` style except
5//! its source: the chain filters what is rendered *behind* the node (v1: the
6//! camera's post-processed 3D frame), and the result composites as an opaque
7//! quad under the node's own content. Wire format, registry validation,
8//! param packing, and interpolation are all the shared `filters` machinery;
9//! this module only names the second channel's component pair and its
10//! resolver instance parameters.
11//!
12//! One deliberate semantic difference: resolved backdrop chains are ALWAYS
13//! `always_dirty` ([`ChainInput::FORCE_ALWAYS_DIRTY`]). The backdrop source
14//! is the live frame — the snapshot re-blits and the chain re-runs every
15//! frame, so caching a backdrop filter output is never valid.
16
17use bevy::prelude::*;
18
19use super::resolve::{ChainInput, ResolvedChain, ResolvedFilterChain};
20use super::wire::FilterChain;
21
22/// The wire `backdropFilter` chain of a node, mirrored off the applied style
23/// by `crate::ui_map::apply_style_masked`'s BACKDROP arm — present iff the
24/// style carries a non-empty chain. Same contract as
25/// [`FilterInput`](super::FilterInput): the style apply owns writes (the
26/// applied style may be hover/press/focus-merged), the resolver only reads.
27#[derive(Component, Debug, Clone, Default, PartialEq)]
28pub struct BackdropInput(pub FilterChain);
29
30impl ChainInput for BackdropInput {
31    const KIND_UNKNOWN: &'static str = "backdropFilterUnknown";
32    const KIND_PARAMS: &'static str = "backdropFilterParams";
33    const FORCE_ALWAYS_DIRTY: bool = true;
34    fn chain(&self) -> &FilterChain {
35        &self.0
36    }
37    fn validate_entry(
38        name: &str,
39        is_morph: bool,
40        _passes: &[super::ResolvedFilterPass],
41    ) -> Result<(), String> {
42        if is_morph {
43            return Err(format!(
44                "morph filter {name:?} cannot be used in a `backdropFilter` chain — it is a \
45                 `morphFilter` name"
46            ));
47        }
48        Ok(())
49    }
50}
51
52/// A node's fully resolved `backdropFilter` chain — a newtype over
53/// [`ResolvedFilterChain`] so extract, transitions, animations, and devtools
54/// reuse the inner type via `.0`. Attached to promoted roots by the backdrop
55/// [`resolve_chains`](super::resolve_chains) instance; absent when the chain
56/// has no valid entries. Removed on demotion by
57/// `evaluate_layer_promotions`'s cleanup (like the content chain).
58#[derive(Component, Debug, Clone, Default)]
59pub struct ResolvedBackdropChain(pub ResolvedFilterChain);
60
61impl ResolvedChain for ResolvedBackdropChain {
62    fn from_inner(inner: ResolvedFilterChain) -> Self {
63        Self(inner)
64    }
65    fn inner(&self) -> &ResolvedFilterChain {
66        &self.0
67    }
68    fn inner_mut(&mut self) -> &mut ResolvedFilterChain {
69        &mut self.0
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use serde_json::json;
76
77    use super::super::test_util::{
78        anim_app, create, drain_dirt, ease_app, entity_of, resolve_app, tick, update,
79    };
80    use super::*;
81    use crate::layer::{LayerContentDirt, PromotedLayer};
82
83    /// The family split: a MORPH filter named in a `backdropFilter` chain
84    /// warns (`backdropFilterParams`) and is skipped — with no other entry,
85    /// no chain attaches (the node stays promoted).
86    #[cfg(all(feature = "devtools", debug_assertions))]
87    #[test]
88    fn morph_filter_in_backdrop_chain_warns_and_skips() {
89        let _lock = crate::diag::test_lock();
90        crate::diag::arm_runtime();
91        let _ = crate::diag::take_runtime_warnings();
92
93        let (mut app, ops_tx) = resolve_app();
94        ops_tx
95            .send(vec![create(
96                11,
97                json!({ "style": { "backdropFilter": { "name": "crossfade" } } }),
98            )])
99            .unwrap();
100        app.update();
101        let e = entity_of(&app, 11);
102        assert!(app.world().get::<PromotedLayer>(e).is_some(), "promoted");
103        assert!(
104            app.world().get::<ResolvedBackdropChain>(e).is_none(),
105            "morph entry skipped, no chain"
106        );
107
108        let warns: Vec<_> = crate::diag::take_runtime_warnings()
109            .into_iter()
110            .filter(|w| w.node == Some(11))
111            .collect();
112        assert_eq!(warns.len(), 1, "{warns:?}");
113        assert_eq!(warns[0].kind, "backdropFilterParams");
114        assert!(
115            warns[0].message.contains("`morphFilter` name"),
116            "{}",
117            warns[0].message
118        );
119    }
120
121    /// A `backdropFilter` create resolves into a [`ResolvedBackdropChain`] on
122    /// the promoted root — and the chain is `always_dirty` even for a filter
123    /// with no `USES_TIME` (the forced backdrop semantics: the source frame
124    /// is live). The content-chain component stays absent — independent
125    /// channels.
126    #[test]
127    fn backdrop_create_attaches_forced_always_dirty_chain() {
128        let (mut app, ops_tx) = resolve_app();
129        ops_tx
130            .send(vec![create(
131                1,
132                json!({ "style": { "backdropFilter": { "name": "grayscale" } } }),
133            )])
134            .unwrap();
135        app.update();
136        let e = entity_of(&app, 1);
137        assert!(app.world().get::<PromotedLayer>(e).is_some(), "promoted");
138        assert!(
139            app.world().get::<BackdropInput>(e).is_some(),
140            "input stamped"
141        );
142        let chain = app
143            .world()
144            .get::<ResolvedBackdropChain>(e)
145            .expect("backdrop chain resolved");
146        assert_eq!(chain.0.version, 1);
147        assert_eq!(chain.0.passes.len(), 1);
148        assert_eq!(chain.0.passes[0].params[0].w, 1.0, "full grayscale");
149        assert!(chain.0.always_dirty, "backdrop chains are forced live");
150        assert!(
151            app.world()
152                .get::<super::super::ResolvedFilterChain>(e)
153                .is_none(),
154            "content channel untouched"
155        );
156    }
157
158    /// A backdrop param delta bumps the version and lands in
159    /// `composite_only` — never `nodes`: a backdrop delta must not dirty the
160    /// subtree capture (it only touches pixels behind the node).
161    #[test]
162    fn backdrop_param_update_is_composite_only() {
163        let (mut app, ops_tx) = resolve_app();
164        ops_tx
165            .send(vec![create(
166                1,
167                json!({ "style": { "backdropFilter": { "name": "grayscale" } } }),
168            )])
169            .unwrap();
170        app.update();
171        let e = entity_of(&app, 1);
172        drain_dirt(&mut app);
173
174        ops_tx
175            .send(vec![update(
176                1,
177                json!({ "style": { "backdropFilter": {
178                    "name": "grayscale", "params": { "amount": 0.5 }
179                } } }),
180                &[],
181            )])
182            .unwrap();
183        app.update();
184        let chain = app.world().get::<ResolvedBackdropChain>(e).expect("chain");
185        assert_eq!(chain.0.version, 2);
186        assert_eq!(chain.0.passes[0].params[0].w, 0.5);
187        let dirt = app.world().resource::<LayerContentDirt>();
188        assert!(dirt.composite_only.contains(&e), "{dirt:?}");
189        assert!(!dirt.nodes.contains(&e), "{dirt:?}");
190    }
191
192    /// Backdrop validation failures warn under the backdrop-specific kinds
193    /// (`backdropFilterUnknown`/`backdropFilterParams`), attributed to the
194    /// node — devtools routes them at the `backdropFilter` style row.
195    #[cfg(all(feature = "devtools", debug_assertions))]
196    #[test]
197    fn backdrop_validation_warns_with_backdrop_kinds() {
198        let _lock = crate::diag::test_lock();
199        crate::diag::arm_runtime();
200        let _ = crate::diag::take_runtime_warnings();
201
202        let (mut app, ops_tx) = resolve_app();
203        ops_tx
204            .send(vec![create(
205                7,
206                json!({ "style": { "backdropFilter": [
207                    { "name": "nope" },
208                    { "name": "blur", "params": { "radius": "50%" } },
209                    { "name": "sepia" }
210                ] } }),
211            )])
212            .unwrap();
213        app.update();
214        let e = entity_of(&app, 7);
215        let chain = app.world().get::<ResolvedBackdropChain>(e).expect("chain");
216        assert_eq!(chain.0.passes.len(), 1, "only sepia survives");
217
218        let mut kinds: Vec<_> = crate::diag::take_runtime_warnings()
219            .into_iter()
220            .filter(|w| w.node == Some(7))
221            .map(|w| w.kind)
222            .collect();
223        kinds.sort();
224        assert_eq!(kinds, ["backdropFilterParams", "backdropFilterUnknown"]);
225    }
226
227    /// Unsetting `backdropFilter` on a node held promoted by another reason
228    /// removes the resolved chain (the `RemovedComponents` cleanup path);
229    /// full demotion removes it too (the evaluator's cleanup).
230    #[test]
231    fn unset_removes_chain_while_promoted_and_on_demote() {
232        let (mut app, ops_tx) = resolve_app();
233        ops_tx
234            .send(vec![create(
235                1,
236                json!({ "style": {
237                    "cache": "always",
238                    "backdropFilter": { "name": "grayscale" }
239                } }),
240            )])
241            .unwrap();
242        app.update();
243        let e = entity_of(&app, 1);
244        assert!(app.world().get::<ResolvedBackdropChain>(e).is_some());
245
246        ops_tx
247            .send(vec![update(1, json!({}), &["backdropFilter"])])
248            .unwrap();
249        app.update();
250        assert!(
251            app.world().get::<PromotedLayer>(e).is_some(),
252            "cache: always still holds the layer"
253        );
254        assert!(app.world().get::<BackdropInput>(e).is_none(), "input gone");
255        assert!(
256            app.world().get::<ResolvedBackdropChain>(e).is_none(),
257            "chain cleaned up while still promoted"
258        );
259
260        ops_tx
261            .send(vec![update(
262                1,
263                json!({ "style": { "backdropFilter": { "name": "sepia" } } }),
264                &["cache"],
265            )])
266            .unwrap();
267        app.update();
268        assert!(app.world().get::<ResolvedBackdropChain>(e).is_some());
269        ops_tx
270            .send(vec![update(1, json!({}), &["backdropFilter"])])
271            .unwrap();
272        app.update();
273        assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
274        assert!(
275            app.world().get::<ResolvedBackdropChain>(e).is_none(),
276            "demote removes the resolved chain"
277        );
278    }
279
280    /// `transition: { backdropFilter }` eases the backdrop chain's packed
281    /// params (composite-only dirt each easing frame) and settles bit-exact
282    /// on the resolver's own output — the second channel runs the same
283    /// whole-value machinery as `filter`.
284    #[test]
285    fn backdrop_transition_eases_and_settles_exactly() {
286        let (mut app, ops_tx) = ease_app();
287        ops_tx
288            .send(vec![create(
289                1,
290                json!({ "style": {
291                    "backdropFilter": { "name": "grayscale", "params": { "amount": 0.0 } },
292                    "transition": { "backdropFilter": { "duration": 1000, "easing": "linear" } },
293                } }),
294            )])
295            .unwrap();
296        app.update();
297        let e = entity_of(&app, 1);
298        assert_eq!(
299            app.world()
300                .get::<ResolvedBackdropChain>(e)
301                .unwrap()
302                .0
303                .passes[0]
304                .params[0]
305                .w,
306            0.0,
307            "mount snaps, no fade-in"
308        );
309        drain_dirt(&mut app);
310
311        ops_tx
312            .send(vec![update(
313                1,
314                json!({ "style": { "backdropFilter": {
315                    "name": "grayscale", "params": { "amount": 1.0 }
316                } } }),
317                &[],
318            )])
319            .unwrap();
320        tick(&mut app, 0.5);
321        {
322            let chain = &app.world().get::<ResolvedBackdropChain>(e).unwrap().0;
323            let w = chain.passes[0].params[0].w;
324            assert!(w > 0.0 && w < 1.0, "mid-ease: {w}");
325            assert!(chain.always_dirty, "forced-live survives the ease write");
326            let dirt = app.world().resource::<LayerContentDirt>();
327            assert!(dirt.composite_only.contains(&e), "{dirt:?}");
328            assert!(!dirt.nodes.contains(&e), "{dirt:?}");
329        }
330
331        tick(&mut app, 0.6);
332        let settled_version = {
333            let world = app.world();
334            let chain = &world.get::<ResolvedBackdropChain>(e).unwrap().0;
335            let expected =
336                (world.resource::<crate::filters::FilterRegistry>().entries["grayscale"].resolve)(
337                    &json!({ "amount": 1.0 }),
338                    world.resource::<bevy::asset::AssetServer>(),
339                )
340                .unwrap();
341            assert_eq!(chain.passes, expected, "settles on the resolver's output");
342            chain.version
343        };
344        drain_dirt(&mut app);
345        tick(&mut app, 0.25);
346        assert_eq!(
347            app.world()
348                .get::<ResolvedBackdropChain>(e)
349                .unwrap()
350                .0
351                .version,
352            settled_version,
353            "settled: no more churn"
354        );
355    }
356
357    /// The two chains are independent transition channels: easing one leaves
358    /// the other's params and version untouched.
359    #[test]
360    fn filter_and_backdrop_channels_ease_independently() {
361        let (mut app, ops_tx) = ease_app();
362        ops_tx
363            .send(vec![create(
364                1,
365                json!({ "style": {
366                    "filter": { "name": "grayscale", "params": { "amount": 0.0 } },
367                    "backdropFilter": { "name": "sepia", "params": { "amount": 0.0 } },
368                    "transition": { "all": { "duration": 1000, "easing": "linear" } },
369                } }),
370            )])
371            .unwrap();
372        app.update();
373        let e = entity_of(&app, 1);
374        drain_dirt(&mut app);
375
376        // Retarget ONLY the backdrop chain.
377        ops_tx
378            .send(vec![update(
379                1,
380                json!({ "style": { "backdropFilter": {
381                    "name": "sepia", "params": { "amount": 1.0 }
382                } } }),
383                &[],
384            )])
385            .unwrap();
386        tick(&mut app, 0.5);
387        let backdrop = &app.world().get::<ResolvedBackdropChain>(e).unwrap().0;
388        let w = backdrop.passes[0].params[1].x;
389        assert!(w > 0.0 && w < 1.0, "backdrop mid-ease: {w}");
390        let content = app
391            .world()
392            .get::<crate::filters::ResolvedFilterChain>(e)
393            .unwrap();
394        assert_eq!(content.passes[0].params[0].w, 0.0, "content untouched");
395        assert_eq!(content.version, 1, "content version quiet");
396    }
397
398    /// Easing TOWARD an unset `backdropFilter` snaps (the documented
399    /// empty-chain rule): unsetting demotes and removes the resolved chain
400    /// the same frame — no lingering eased writes on later frames.
401    #[test]
402    fn backdrop_transition_to_unset_snaps() {
403        let (mut app, ops_tx) = ease_app();
404        ops_tx
405            .send(vec![create(
406                1,
407                json!({ "style": {
408                    "backdropFilter": { "name": "grayscale" },
409                    "transition": { "backdropFilter": { "duration": 1000 } },
410                } }),
411            )])
412            .unwrap();
413        app.update();
414        let e = entity_of(&app, 1);
415        ops_tx
416            .send(vec![update(1, json!({}), &["backdropFilter"])])
417            .unwrap();
418        tick(&mut app, 0.1);
419        assert!(
420            app.world().get::<ResolvedBackdropChain>(e).is_none(),
421            "unset demotes + removes — snap, no ease"
422        );
423        tick(&mut app, 0.1);
424        assert!(app.world().get::<ResolvedBackdropChain>(e).is_none());
425    }
426
427    /// Inline `{ animated }` backdrop-param bindings drive the
428    /// backdrop chain through the whole ops → resolve → apply pipeline
429    /// (blur's H+V passes both driven, physical-px rewrite, composite-only
430    /// dirt), while the CONTENT chain — same node, same param name — stays
431    /// untouched: the two binding namespaces are independent.
432    #[test]
433    fn backdrop_param_binding_follows_shared_value_through_pipeline() {
434        let (mut app, ops_tx, anim_tx) = anim_app();
435        anim_tx
436            .send(crate::animations::AnimationCommand::Set { id: 1, value: 4.0 })
437            .unwrap();
438        ops_tx
439            .send(vec![create(
440                1,
441                json!({
442                    "style": {
443                        "filter": { "name": "blur", "params": { "radius": 10 } },
444                        "backdropFilter": { "name": "blur",
445                            "params": { "radius": { "animated": { "id": 1 } } } },
446                    },
447                }),
448            )])
449            .unwrap();
450        app.update();
451        let e = entity_of(&app, 1);
452        {
453            let chain = &app.world().get::<ResolvedBackdropChain>(e).unwrap().0;
454            assert_eq!(chain.passes.len(), 2, "blur expands to H+V");
455            assert_eq!(chain.passes[0].params[0].x, 4.0, "H radius driven");
456            assert_eq!(chain.passes[1].params[0].x, 4.0, "V radius driven");
457            let content = app
458                .world()
459                .get::<crate::filters::ResolvedFilterChain>(e)
460                .unwrap();
461            assert_eq!(
462                content.passes[0].params[0].x, 10.0,
463                "content chain keeps its static radius"
464            );
465        }
466
467        drain_dirt(&mut app);
468        anim_tx
469            .send(crate::animations::AnimationCommand::Set { id: 1, value: 6.0 })
470            .unwrap();
471        tick(&mut app, 0.016);
472        {
473            let chain = &app.world().get::<ResolvedBackdropChain>(e).unwrap().0;
474            assert_eq!(chain.passes[0].params[0].x, 6.0);
475            assert_eq!(chain.passes[1].params[0].x, 6.0);
476        }
477        let dirt = app.world().resource::<LayerContentDirt>();
478        assert!(dirt.composite_only.contains(&e), "{dirt:?}");
479        assert!(!dirt.nodes.contains(&e), "never capture dirt: {dirt:?}");
480    }
481}