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