Skip to main content

bevy_react/layer/render/
backdrop.rs

1//! Render-world half of `backdropFilter`: the snapshot blit, the backdrop
2//! filter run, and the backdrop composite quad.
3//!
4//! Per frame, for each layer with an extracted backdrop chain:
5//!
6//! 1. [`prepare_layer_backdrops`] (PrepareBindGroups, after
7//!    `prepare_layer_textures`) stages a snapshot **blit** (crop-sample the
8//!    camera main texture over the layer's capture rect —
9//!    `backdrop_blit.wgsl`) and a **filter run** over the snapshot, reusing
10//!    the content-filter pipeline wholesale: the snapshot is bound as the
11//!    pass-0 source AND as the binding-3 `capture_texture`, so every filter
12//!    shader works unchanged (the snapshot is opaque, which satisfies the
13//!    premultiplied contract trivially). Backdrop chains are forced
14//!    `always_dirty` at resolve, so this stages every frame.
15//! 2. [`run_backdrop_passes`] (called from `ui_layer_capture_pass`, before
16//!    stock `ui_pass`) executes blit → chain. The blit bind group is created
17//!    HERE, in the pass system: `ViewTarget`'s a/b main texture flips during
18//!    PostProcess, so a prepare-time binding would race (Bevy's tonemapping
19//!    node is the precedent). At this point in the camera schedule the main
20//!    texture holds the tonemapped 3D frame with no UI — exactly the v1
21//!    backdrop.
22//! 3. [`stage_backdrop_composite`] (called from `prepare_layer_composites`)
23//!    stamps a [`LayerCompositeBatch`] onto the layer's backdrop quad
24//!    (injected by `redistribute_ui_layers` one epsilon *below* the content
25//!    quad): the un-inflated border box (frost never paints in the outset
26//!    ring) sampling the chain's output, multiplied by the group alpha.
27//!    Gating is graceful by construction — a withheld backdrop quad draws
28//!    nothing and the region shows the real (unfiltered) frame already in
29//!    the target, never an invisible subtree.
30
31use bevy::math::{Rect, UVec2, Vec2};
32use bevy::prelude::*;
33use bevy::render::camera::ExtractedCamera;
34use bevy::render::render_resource::binding_types::{sampler, texture_2d, uniform_buffer};
35use bevy::render::render_resource::*;
36use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue};
37use bevy::render::texture::CachedTexture;
38use bevy::render::view::ViewTarget;
39use bevy::shader::Shader;
40
41use super::clip::ClippedQuad;
42use super::store::alloc_capture_texture;
43use super::{
44    ExtractedUiLayers, FilterUniforms, LayerFilterPass, LayerFilterPipeline,
45    LayerFilterPipelineKey, LayerFilterRun, LayerTextureStore, STUCK_GATE_HANG_FRAMES,
46    filter_output_index, filter_source_index, filter_target_index,
47};
48
49/// A layer's persistent backdrop resources: the snapshot texture (blit
50/// target + chain source) and the chain's ping-pong pair, with the same
51/// `output_valid` discipline as
52/// [`FilterSlot`](super::FilterSlot). Allocated at the capture's size +
53/// format whenever the layer has a backdrop chain; dies with the
54/// [`LayerSlot`](super::LayerSlot) on realloc. No mip state: the backdrop
55/// quad is never 3D-transformed in v1.
56pub struct BackdropSlot {
57    /// The snapshot (RENDER_ATTACHMENT | TEXTURE_BINDING): the frame region
58    /// under the layer's (outset-inflated) capture rect, re-blitted every
59    /// frame.
60    pub snapshot: CachedTexture,
61    /// The chain's ping-pong targets (pass 0 samples the snapshot and writes
62    /// `textures[0]`, and so on).
63    pub textures: [CachedTexture; 2],
64    /// The staged chain version (`0` = never staged; versions start at 1).
65    /// Backdrop runs restage every frame regardless — this only re-arms the
66    /// stuck-gate warn on chain edits.
67    pub params_version: u32,
68    /// Whether `textures[output_index]` holds a complete filtered backdrop.
69    /// Predicted at prepare (blit pipeline AND every chain pipeline
70    /// compiled); while false the backdrop quad is withheld — the region
71    /// shows the unfiltered frame.
72    pub output_valid: bool,
73    /// Consecutive withheld frames; drives the stuck-gate warn.
74    pub gated_frames: u32,
75    /// Once-per-episode warn latch (see [`super::FilterSlot::gate_warned`]).
76    pub gate_warned: bool,
77    /// Which ping-pong texture the final pass writes.
78    pub output_index: usize,
79    /// Composite bind group over `textures[.0]`, index-invalidated on
80    /// `output_index` parity flips.
81    pub composite_bind_group: Option<(usize, BindGroup)>,
82}
83
84/// Allocate a layer's backdrop slot at the capture's size and format.
85pub fn alloc_backdrop_slot(
86    render_device: &RenderDevice,
87    size: UVec2,
88    format: TextureFormat,
89) -> BackdropSlot {
90    let alloc =
91        |label: &'static str| alloc_capture_texture(render_device, label, size, format, false).0;
92    BackdropSlot {
93        snapshot: alloc("ui_layer_backdrop_snapshot"),
94        textures: [
95            alloc("ui_layer_backdrop_ping"),
96            alloc("ui_layer_backdrop_pong"),
97        ],
98        params_version: 0,
99        output_valid: false,
100        gated_frames: 0,
101        gate_warned: false,
102        output_index: 0,
103        composite_bind_group: None,
104    }
105}
106
107/// The blit's per-layer uniforms: the snapshot rect mapped into the main
108/// texture's UV space. Mirrors `BlitUniforms` in `backdrop_blit.wgsl`.
109#[derive(Clone, Copy, ShaderType)]
110pub struct BackdropBlitUniforms {
111    pub src_uv_min: Vec2,
112    pub src_uv_scale: Vec2,
113}
114
115/// The snapshot-blit pipeline: main texture + clamp-to-edge sampler + one
116/// dynamically-offset [`BackdropBlitUniforms`]. Deliberately not the filter
117/// layout (that mandates the 160-byte `FilterUniforms` via
118/// `min_binding_size`), same reasoning as the mip blit.
119#[derive(Resource)]
120pub struct BackdropBlitPipeline {
121    pub layout: BindGroupLayoutDescriptor,
122    pub sampler: Sampler,
123    pub shader: Handle<Shader>,
124}
125
126pub fn init_backdrop_blit_pipeline(
127    mut commands: Commands,
128    render_device: Res<RenderDevice>,
129    asset_server: Res<AssetServer>,
130) {
131    let layout = BindGroupLayoutDescriptor::new(
132        "ui_layer_backdrop_blit_layout",
133        &BindGroupLayoutEntries::sequential(
134            ShaderStages::FRAGMENT,
135            (
136                texture_2d(TextureSampleType::Float { filterable: true }),
137                sampler(SamplerBindingType::Filtering),
138                uniform_buffer::<BackdropBlitUniforms>(true),
139            ),
140        ),
141    );
142    commands.insert_resource(BackdropBlitPipeline {
143        layout,
144        sampler: render_device.create_sampler(&SamplerDescriptor {
145            label: Some("ui_layer_backdrop_blit_sampler"),
146            address_mode_u: AddressMode::ClampToEdge,
147            address_mode_v: AddressMode::ClampToEdge,
148            mag_filter: FilterMode::Linear,
149            min_filter: FilterMode::Linear,
150            ..Default::default()
151        }),
152        shader: bevy::asset::load_embedded_asset!(asset_server.as_ref(), "backdrop_blit.wgsl"),
153    });
154}
155
156#[derive(Clone, Copy, Hash, PartialEq, Eq)]
157pub struct BackdropBlitPipelineKey {
158    pub target_format: TextureFormat,
159}
160
161impl SpecializedRenderPipeline for BackdropBlitPipeline {
162    type Key = BackdropBlitPipelineKey;
163
164    fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor {
165        RenderPipelineDescriptor {
166            vertex: VertexState {
167                shader: self.shader.clone(),
168                entry_point: Some("vertex".into()),
169                ..Default::default()
170            },
171            fragment: Some(FragmentState {
172                shader: self.shader.clone(),
173                entry_point: Some("fragment".into()),
174                targets: vec![Some(ColorTargetState {
175                    format: key.target_format,
176                    // Replace-write: the triangle covers every texel.
177                    blend: None,
178                    write_mask: ColorWrites::ALL,
179                })],
180                ..Default::default()
181            }),
182            layout: vec![self.layout.clone()],
183            label: Some("ui_layer_backdrop_blit_pipeline".into()),
184            ..Default::default()
185        }
186    }
187}
188
189/// One staged snapshot blit: pipeline + this layer's uniform entry. The bind
190/// group is created in the pass ([`run_backdrop_passes`]) — the main-texture
191/// view is not stable at prepare time.
192pub struct BackdropBlit {
193    pub pipeline: CachedRenderPipelineId,
194    pub uniform_offset: u32,
195    pub target: TextureView,
196}
197
198/// Per-frame backdrop staging, index-aligned with
199/// [`ExtractedUiLayers::layers`]. Owns its own uniform buffers — the content
200/// filter's [`LayerFilterMeta`](super::LayerFilterMeta) buffer is written by
201/// its own system, and a shared buffer across systems would order-couple the
202/// writes.
203#[derive(Resource)]
204pub struct BackdropMeta {
205    pub blit_uniforms: DynamicUniformBuffer<BackdropBlitUniforms>,
206    pub filter_uniforms: DynamicUniformBuffer<FilterUniforms>,
207    pub blits: Vec<Option<BackdropBlit>>,
208    pub runs: Vec<Option<LayerFilterRun>>,
209}
210
211impl Default for BackdropMeta {
212    fn default() -> Self {
213        let mut blit_uniforms = DynamicUniformBuffer::default();
214        blit_uniforms.set_label(Some("ui_layer_backdrop_blit_uniforms"));
215        let mut filter_uniforms = DynamicUniformBuffer::default();
216        filter_uniforms.set_label(Some("ui_layer_backdrop_filter_uniforms"));
217        Self {
218            blit_uniforms,
219            filter_uniforms,
220            blits: Vec::new(),
221            runs: Vec::new(),
222        }
223    }
224}
225
226/// Stage every backdrop's blit + filter run for this frame. Mirrors
227/// `prepare_layer_filters`' three phases (stage → write buffers + bind
228/// groups → predict validity), with two deliberate differences: staging is
229/// unconditional per frame (the source frame is live), and the validity
230/// prediction requires the BLIT pipeline compiled too — the chain would
231/// otherwise filter a garbage snapshot.
232#[allow(clippy::too_many_arguments)]
233pub fn prepare_layer_backdrops(
234    extracted: Res<ExtractedUiLayers>,
235    mut store: ResMut<LayerTextureStore>,
236    filter_pipeline: Option<Res<LayerFilterPipeline>>,
237    blit_pipeline: Option<Res<BackdropBlitPipeline>>,
238    mut specialized_filters: ResMut<SpecializedRenderPipelines<LayerFilterPipeline>>,
239    mut specialized_blits: ResMut<SpecializedRenderPipelines<BackdropBlitPipeline>>,
240    pipeline_cache: Res<PipelineCache>,
241    render_device: Res<RenderDevice>,
242    render_queue: Res<RenderQueue>,
243    cameras: Query<&ExtractedCamera>,
244    time: Res<Time>,
245    mut meta: ResMut<BackdropMeta>,
246) {
247    let BackdropMeta {
248        blit_uniforms,
249        filter_uniforms,
250        blits,
251        runs,
252    } = &mut *meta;
253    blit_uniforms.clear();
254    filter_uniforms.clear();
255    blits.clear();
256    runs.clear();
257    blits.resize_with(extracted.layers.len(), || None);
258    runs.resize_with(extracted.layers.len(), || None);
259    let (Some(filter_pipeline), Some(blit_pipeline)) = (filter_pipeline, blit_pipeline) else {
260        return;
261    };
262    // The snapshot rect maps into the main texture through the camera's
263    // physical viewport: layer coordinates are viewport-relative, the main
264    // texture covers the whole target.
265    let Some(camera) = extracted
266        .camera_render_entity
267        .and_then(|e| cameras.get(e).ok())
268    else {
269        return;
270    };
271    let Some(target_size) = camera.physical_target_size else {
272        return;
273    };
274    let viewport_offset = camera
275        .viewport
276        .as_ref()
277        .map_or(Vec2::ZERO, |v| v.physical_position.as_vec2());
278    let target_size = target_size.as_vec2().max(Vec2::ONE);
279
280    // Phase 1: stage uniforms + specialize pipelines.
281    struct StagedBackdrop {
282        blit_pipeline: CachedRenderPipelineId,
283        blit_offset: u32,
284        passes: Vec<(CachedRenderPipelineId, u32)>,
285    }
286    let mut staged: Vec<(usize, StagedBackdrop)> = Vec::new();
287    for (idx, layer) in extracted.layers.iter().enumerate() {
288        let Some(chain) = &layer.backdrop_chain else {
289            continue;
290        };
291        let Some(slot) = store.slots.get_mut(&layer.main_entity) else {
292            continue;
293        };
294        let size = slot.size;
295        let Some(backdrop) = slot.backdrop.as_mut() else {
296            continue;
297        };
298        // A chain edit may swap in different shaders — re-arm the warn so a
299        // new failure gets its own report (same rule as the content filter).
300        if backdrop.params_version != chain.version {
301            backdrop.gated_frames = 0;
302            backdrop.gate_warned = false;
303        }
304        backdrop.params_version = chain.version;
305        // The run supersedes whatever the outputs hold; phase 3 re-marks
306        // valid iff blit + passes will all execute.
307        backdrop.output_valid = false;
308        backdrop.output_index = filter_output_index(chain.passes.len());
309
310        let blit_id = specialized_blits.specialize(
311            &pipeline_cache,
312            &blit_pipeline,
313            BackdropBlitPipelineKey {
314                target_format: layer.target_format,
315            },
316        );
317        let blit_offset = blit_uniforms.push(&BackdropBlitUniforms {
318            src_uv_min: (viewport_offset + layer.min) / target_size,
319            src_uv_scale: size.as_vec2() / target_size,
320        });
321
322        let resolution = size.as_vec2();
323        let texel_size = Vec2::ONE / resolution;
324        let passes = chain
325            .passes
326            .iter()
327            .map(|pass| {
328                let id = specialized_filters.specialize(
329                    &pipeline_cache,
330                    &filter_pipeline,
331                    LayerFilterPipelineKey {
332                        shader: pass.shader.clone(),
333                        target_format: layer.target_format,
334                    },
335                );
336                let offset = filter_uniforms.push(&FilterUniforms {
337                    time: time.elapsed_secs(),
338                    pad_a: 0.0,
339                    resolution,
340                    texel_size,
341                    // The backdrop snapshot covers the layer's inflated
342                    // capture rect, so the node rect sits `outset` px in —
343                    // same as the content chain.
344                    content_inset: Vec2::splat(layer.outset as f32),
345                    params: pass.params,
346                });
347                (id, offset)
348            })
349            .collect();
350        staged.push((
351            idx,
352            StagedBackdrop {
353                blit_pipeline: blit_id,
354                blit_offset,
355                passes,
356            },
357        ));
358    }
359    if staged.is_empty() {
360        return;
361    }
362
363    // Phase 2: write the uniforms, then build the chain's per-pass bind
364    // groups against the (possibly fresh) buffer. The BLIT bind group is
365    // deliberately NOT built here — see the module doc.
366    blit_uniforms.write_buffer(&render_device, &render_queue);
367    filter_uniforms.write_buffer(&render_device, &render_queue);
368    let Some(uniform_binding) = filter_uniforms.binding() else {
369        return;
370    };
371    let layout = pipeline_cache.get_bind_group_layout(&filter_pipeline.layout);
372    for (idx, staged) in staged {
373        let layer = &extracted.layers[idx];
374        let Some(slot) = store.slots.get(&layer.main_entity) else {
375            continue;
376        };
377        let Some(backdrop) = slot.backdrop.as_ref() else {
378            continue;
379        };
380        let passes = staged
381            .passes
382            .iter()
383            .enumerate()
384            .map(|(i, &(pipeline, uniform_offset))| {
385                let source = match filter_source_index(i) {
386                    // Pass 0 samples the snapshot — the backdrop's "capture".
387                    None => &backdrop.snapshot.default_view,
388                    Some(ping) => &backdrop.textures[ping].default_view,
389                };
390                let bind_group = render_device.create_bind_group(
391                    "ui_layer_backdrop_filter",
392                    &layout,
393                    &BindGroupEntries::sequential((
394                        source,
395                        &filter_pipeline.sampler,
396                        uniform_binding.clone(),
397                        // Binding 3 (`capture_texture`) = the unfiltered
398                        // snapshot, so combine-style passes (bloom) composite
399                        // over the real backdrop.
400                        &backdrop.snapshot.default_view,
401                    )),
402                );
403                LayerFilterPass {
404                    pipeline,
405                    bind_group,
406                    uniform_offset,
407                    target: backdrop.textures[filter_target_index(i)]
408                        .default_view
409                        .clone(),
410                }
411            })
412            .collect();
413        blits[idx] = Some(BackdropBlit {
414            pipeline: staged.blit_pipeline,
415            uniform_offset: staged.blit_offset,
416            target: backdrop.snapshot.default_view.clone(),
417        });
418        runs[idx] = Some(LayerFilterRun { passes });
419    }
420
421    // Phase 3: predict execution. Valid iff the blit AND every chain pass
422    // resolve now (compiled pipelines never regress within a frame). No
423    // source-content requirement: the main texture always holds a complete
424    // frame at blit time.
425    for idx in 0..extracted.layers.len() {
426        let (Some(blit), Some(run)) = (
427            blits.get(idx).and_then(Option::as_ref),
428            runs.get(idx).and_then(Option::as_ref),
429        ) else {
430            continue;
431        };
432        let Some(slot) = store.slots.get_mut(&extracted.layers[idx].main_entity) else {
433            continue;
434        };
435        let ready = pipeline_cache.get_render_pipeline(blit.pipeline).is_some()
436            && run
437                .passes
438                .iter()
439                .all(|pass| pipeline_cache.get_render_pipeline(pass.pipeline).is_some());
440        if ready && let Some(backdrop) = slot.backdrop.as_mut() {
441            backdrop.output_valid = true;
442            backdrop.gated_frames = 0;
443            backdrop.gate_warned = false;
444        }
445    }
446}
447
448/// Execute one layer's staged backdrop work inside the capture-pass
449/// encoder: blit the frame region into the snapshot, then replay the chain
450/// snapshot → ping-pong. Called per layer from `ui_layer_capture_pass`,
451/// before stock `ui_pass` consumes the quads.
452pub fn run_backdrop_passes(
453    idx: usize,
454    meta: &BackdropMeta,
455    blit_pipeline: Option<&BackdropBlitPipeline>,
456    main_texture: &TextureView,
457    pipeline_cache: &PipelineCache,
458    ctx: &mut RenderContext,
459) {
460    let (Some(blit), Some(run)) = (
461        meta.blits.get(idx).and_then(Option::as_ref),
462        meta.runs.get(idx).and_then(Option::as_ref),
463    ) else {
464        return;
465    };
466    let Some(blit_pipeline_res) = blit_pipeline else {
467        return;
468    };
469    // All-or-nothing across blit + chain: a partial run would leave the
470    // outputs inconsistent, and `output_valid` was only set if everything
471    // resolved at prepare.
472    let Some(blit_compiled) = pipeline_cache.get_render_pipeline(blit.pipeline) else {
473        return;
474    };
475    let chain_compiled: Option<Vec<_>> = run
476        .passes
477        .iter()
478        .map(|pass| pipeline_cache.get_render_pipeline(pass.pipeline))
479        .collect();
480    let Some(chain_compiled) = chain_compiled else {
481        return;
482    };
483    let Some(blit_binding) = meta.blit_uniforms.binding() else {
484        return;
485    };
486    // The blit bind group is created here, against THIS moment's main
487    // texture view (see the module doc's a/b-flip rationale). One group per
488    // layer is fine: backdrop layers are rare relative to UI nodes, and the
489    // view can differ between invocations.
490    let blit_bind_group = ctx.render_device().create_bind_group(
491        "ui_layer_backdrop_blit",
492        &pipeline_cache.get_bind_group_layout(&blit_pipeline_res.layout),
493        &BindGroupEntries::sequential((main_texture, &blit_pipeline_res.sampler, blit_binding)),
494    );
495    {
496        let mut pass = ctx.begin_tracked_render_pass(RenderPassDescriptor {
497            label: Some("ui_layer_backdrop_blit"),
498            color_attachments: &[Some(RenderPassColorAttachment {
499                view: &blit.target,
500                depth_slice: None,
501                resolve_target: None,
502                ops: Operations {
503                    // Replace-write of every texel; Clear skips loading stale
504                    // contents on tiled GPUs.
505                    load: LoadOp::Clear(LinearRgba::NONE.into()),
506                    store: StoreOp::Store,
507                },
508            })],
509            depth_stencil_attachment: None,
510            timestamp_writes: None,
511            occlusion_query_set: None,
512            multiview_mask: None,
513        });
514        pass.set_render_pipeline(blit_compiled);
515        pass.set_bind_group(0, &blit_bind_group, &[blit.uniform_offset]);
516        pass.draw(0..3, 0..1);
517    }
518    for (pass_data, pipeline) in run.passes.iter().zip(chain_compiled) {
519        let mut pass = ctx.begin_tracked_render_pass(RenderPassDescriptor {
520            label: Some("ui_layer_backdrop_filter"),
521            color_attachments: &[Some(RenderPassColorAttachment {
522                view: &pass_data.target,
523                depth_slice: None,
524                resolve_target: None,
525                ops: Operations {
526                    load: LoadOp::Clear(LinearRgba::NONE.into()),
527                    store: StoreOp::Store,
528                },
529            })],
530            depth_stencil_attachment: None,
531            timestamp_writes: None,
532            occlusion_query_set: None,
533            multiview_mask: None,
534        });
535        pass.set_render_pipeline(pipeline);
536        pass.set_bind_group(0, &pass_data.bind_group, &[pass_data.uniform_offset]);
537        pass.draw(0..3, 0..1);
538    }
539}
540
541/// Fetch the camera's current main-texture view for [`run_backdrop_passes`].
542/// `None` (no `ViewTarget` on the camera) skips every backdrop this frame.
543pub fn camera_main_texture(world: &World, camera: Option<Entity>) -> Option<TextureView> {
544    let target = world.get::<ViewTarget>(camera?)?;
545    Some(target.main_texture_view().clone())
546}
547
548/// The backdrop quad's clipped geometry: the UN-inflated border box
549/// (`min + outset .. min + size − outset`) with UVs relative to the
550/// INFLATED snapshot — frost never paints in the outset ring, but its blur
551/// was computed with real neighborhood. `None` = fully clipped away (or a
552/// degenerate box), draw nothing.
553pub fn backdrop_quad(
554    min: Vec2,
555    size: UVec2,
556    outset: u32,
557    clip: Option<Rect>,
558) -> Option<ClippedQuad> {
559    let size = size.as_vec2();
560    let inset = Vec2::splat(outset as f32);
561    let box_min = min + inset;
562    let box_max = min + size - inset;
563    let (pos_min, pos_max) = match clip {
564        None => (box_min, box_max),
565        Some(c) => (box_min.max(c.min), box_max.min(c.max)),
566    };
567    if pos_min.x >= pos_max.x || pos_min.y >= pos_max.y {
568        return None;
569    }
570    Some(ClippedQuad {
571        pos_min,
572        pos_max,
573        uv_min: (pos_min - min) / size,
574        uv_max: (pos_max - min) / size,
575    })
576}
577
578/// Whether this layer's backdrop composite may draw this frame, maintaining
579/// the gate-warn bookkeeping. Returns the bind group to sample when ready.
580/// Mirrors the content-filter gate in `prepare_layer_composites`, with a
581/// milder warning: a withheld BACKDROP quad shows the unfiltered frame (the
582/// pixels are already in the target), never an invisible subtree.
583#[allow(clippy::too_many_arguments)]
584pub fn backdrop_gate(
585    idx: usize,
586    main_entity: bevy::render::sync_world::MainEntity,
587    backdrop: &mut BackdropSlot,
588    meta: &BackdropMeta,
589    pipeline_cache: &PipelineCache,
590    render_device: &RenderDevice,
591    atlas_layout: &BindGroupLayoutDescriptor,
592    sampler: &Sampler,
593) -> Option<BindGroup> {
594    if !backdrop.output_valid {
595        backdrop.gated_frames = backdrop.gated_frames.saturating_add(1);
596        if !backdrop.gate_warned {
597            let compile_error = meta
598                .runs
599                .get(idx)
600                .and_then(|run| run.as_ref())
601                .into_iter()
602                .flat_map(|run| run.passes.iter().map(|p| p.pipeline))
603                .chain(
604                    meta.blits
605                        .get(idx)
606                        .and_then(|b| b.as_ref())
607                        .map(|b| b.pipeline),
608                )
609                .find_map(
610                    |pipeline| match pipeline_cache.get_render_pipeline_state(pipeline) {
611                        CachedPipelineState::Err(
612                            e @ (bevy::shader::ShaderCacheError::ProcessShaderError(_)
613                            | bevy::shader::ShaderCacheError::CreateShaderModule(_)),
614                        ) => Some(e.to_string()),
615                        _ => None,
616                    },
617                );
618            if let Some(err) = compile_error {
619                warn!(
620                    "UI layer {main_entity:?}: a backdropFilter pass shader failed to \
621                     compile — the region shows the UNFILTERED frame until fixed (the \
622                     backdrop gate is graceful; the node's own content still draws). \
623                     Error: {err}",
624                );
625                backdrop.gate_warned = true;
626            } else if backdrop.gated_frames == STUCK_GATE_HANG_FRAMES {
627                warn!(
628                    "UI layer {main_entity:?}: backdrop quad withheld for {} consecutive \
629                     frames and its pipeline is still not ready (no compile error \
630                     reported). The region shows the unfiltered frame until it resolves.",
631                    STUCK_GATE_HANG_FRAMES,
632                );
633                backdrop.gate_warned = true;
634            }
635        }
636        return None;
637    }
638    let output = backdrop.output_index;
639    if !matches!(&backdrop.composite_bind_group, Some((built, _)) if *built == output) {
640        backdrop.composite_bind_group = Some((
641            output,
642            render_device.create_bind_group(
643                "ui_layer_composite_backdrop",
644                &pipeline_cache.get_bind_group_layout(atlas_layout),
645                &BindGroupEntries::sequential((&backdrop.textures[output].default_view, sampler)),
646            ),
647        ));
648    }
649    backdrop
650        .composite_bind_group
651        .as_ref()
652        .map(|(_, bind_group)| bind_group.clone())
653}
654
655/// The backdrop quad's stacking offset below its layer's first stolen item.
656/// `stack_z_offsets::BACKGROUND_COLOR` is `0.0`, so an explicit negative
657/// epsilon is required to sort strictly under the content composite quad
658/// (which sits AT the first stolen key). Per-node z offsets span −0.1..0.08
659/// and adjacent stack indices differ by 1.0, so 0.005 can never sink the
660/// quad under a preceding sibling.
661pub const BACKDROP_UNDERLAY_EPSILON: f32 = 0.005;
662
663/// Sanity math for [`ExtractedUiLayers`]-driven vertex staging lives with
664/// the pure helpers; see the tests below.
665#[cfg(test)]
666mod tests {
667    use super::*;
668
669    /// `backdrop_quad` shrink+UV table: the quad covers the un-inflated
670    /// border box with UVs mapping the box's position inside the inflated
671    /// snapshot; clips clamp position and UVs together; degenerate boxes
672    /// (outset ≥ half the rect) draw nothing.
673    #[test]
674    fn backdrop_quad_shrinks_to_border_box_with_inflated_uvs() {
675        let min = Vec2::new(100.0, 200.0);
676        let size = UVec2::new(132, 96); // border box 100×64 + outset 16
677        let q = backdrop_quad(min, size, 16, None).expect("quad");
678        assert_eq!(q.pos_min, Vec2::new(116.0, 216.0));
679        assert_eq!(q.pos_max, Vec2::new(216.0, 280.0));
680        assert_eq!(q.uv_min, Vec2::new(16.0 / 132.0, 16.0 / 96.0));
681        assert_eq!(q.uv_max, Vec2::new(116.0 / 132.0, 80.0 / 96.0));
682
683        // Zero outset: the border box IS the rect, full UV window.
684        let q = backdrop_quad(min, size, 0, None).expect("quad");
685        assert_eq!(q.pos_min, min);
686        assert_eq!(q.uv_min, Vec2::ZERO);
687        assert_eq!(q.uv_max, Vec2::ONE);
688
689        // An ancestor clip clamps position and UVs proportionally.
690        let clip = Rect::new(150.0, 216.0, 400.0, 400.0);
691        let q = backdrop_quad(min, size, 16, Some(clip)).expect("quad");
692        assert_eq!(q.pos_min, Vec2::new(150.0, 216.0));
693        assert_eq!(q.pos_max, Vec2::new(216.0, 280.0));
694        assert_eq!(q.uv_min, Vec2::new(50.0 / 132.0, 16.0 / 96.0));
695
696        // Fully clipped away → None.
697        let far = Rect::new(1000.0, 1000.0, 2000.0, 2000.0);
698        assert!(backdrop_quad(min, size, 16, Some(far)).is_none());
699
700        // Degenerate: outset eats the whole box.
701        assert!(backdrop_quad(min, UVec2::new(20, 20), 16, None).is_none());
702    }
703
704    /// The underlay epsilon sorts strictly under the content quad for
705    /// representative first-stolen keys, including a negative
706    /// (box-shadow-like) offset, without crossing a whole stack index.
707    #[test]
708    fn underlay_epsilon_sorts_under_content_quad() {
709        for first_key in [0.0f32, -0.1, 0.08, 41.0, -3.05] {
710            let content = first_key; // + stack_z_offsets::BACKGROUND_COLOR == 0.0
711            let backdrop = first_key - BACKDROP_UNDERLAY_EPSILON;
712            assert!(backdrop < content, "key {first_key}");
713            assert!(content - backdrop < 1.0, "must stay within the stack slot");
714        }
715    }
716}