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                    pad_b: Vec2::ZERO,
342                    params: pass.params,
343                });
344                (id, offset)
345            })
346            .collect();
347        staged.push((
348            idx,
349            StagedBackdrop {
350                blit_pipeline: blit_id,
351                blit_offset,
352                passes,
353            },
354        ));
355    }
356    if staged.is_empty() {
357        return;
358    }
359
360    // Phase 2: write the uniforms, then build the chain's per-pass bind
361    // groups against the (possibly fresh) buffer. The BLIT bind group is
362    // deliberately NOT built here — see the module doc.
363    blit_uniforms.write_buffer(&render_device, &render_queue);
364    filter_uniforms.write_buffer(&render_device, &render_queue);
365    let Some(uniform_binding) = filter_uniforms.binding() else {
366        return;
367    };
368    let layout = pipeline_cache.get_bind_group_layout(&filter_pipeline.layout);
369    for (idx, staged) in staged {
370        let layer = &extracted.layers[idx];
371        let Some(slot) = store.slots.get(&layer.main_entity) else {
372            continue;
373        };
374        let Some(backdrop) = slot.backdrop.as_ref() else {
375            continue;
376        };
377        let passes = staged
378            .passes
379            .iter()
380            .enumerate()
381            .map(|(i, &(pipeline, uniform_offset))| {
382                let source = match filter_source_index(i) {
383                    // Pass 0 samples the snapshot — the backdrop's "capture".
384                    None => &backdrop.snapshot.default_view,
385                    Some(ping) => &backdrop.textures[ping].default_view,
386                };
387                let bind_group = render_device.create_bind_group(
388                    "ui_layer_backdrop_filter",
389                    &layout,
390                    &BindGroupEntries::sequential((
391                        source,
392                        &filter_pipeline.sampler,
393                        uniform_binding.clone(),
394                        // Binding 3 (`capture_texture`) = the unfiltered
395                        // snapshot, so combine-style passes (bloom) composite
396                        // over the real backdrop.
397                        &backdrop.snapshot.default_view,
398                    )),
399                );
400                LayerFilterPass {
401                    pipeline,
402                    bind_group,
403                    uniform_offset,
404                    target: backdrop.textures[filter_target_index(i)]
405                        .default_view
406                        .clone(),
407                }
408            })
409            .collect();
410        blits[idx] = Some(BackdropBlit {
411            pipeline: staged.blit_pipeline,
412            uniform_offset: staged.blit_offset,
413            target: backdrop.snapshot.default_view.clone(),
414        });
415        runs[idx] = Some(LayerFilterRun { passes });
416    }
417
418    // Phase 3: predict execution. Valid iff the blit AND every chain pass
419    // resolve now (compiled pipelines never regress within a frame). No
420    // source-content requirement: the main texture always holds a complete
421    // frame at blit time.
422    for idx in 0..extracted.layers.len() {
423        let (Some(blit), Some(run)) = (
424            blits.get(idx).and_then(Option::as_ref),
425            runs.get(idx).and_then(Option::as_ref),
426        ) else {
427            continue;
428        };
429        let Some(slot) = store.slots.get_mut(&extracted.layers[idx].main_entity) else {
430            continue;
431        };
432        let ready = pipeline_cache.get_render_pipeline(blit.pipeline).is_some()
433            && run
434                .passes
435                .iter()
436                .all(|pass| pipeline_cache.get_render_pipeline(pass.pipeline).is_some());
437        if ready && let Some(backdrop) = slot.backdrop.as_mut() {
438            backdrop.output_valid = true;
439            backdrop.gated_frames = 0;
440            backdrop.gate_warned = false;
441        }
442    }
443}
444
445/// Execute one layer's staged backdrop work inside the capture-pass
446/// encoder: blit the frame region into the snapshot, then replay the chain
447/// snapshot → ping-pong. Called per layer from `ui_layer_capture_pass`,
448/// before stock `ui_pass` consumes the quads.
449pub fn run_backdrop_passes(
450    idx: usize,
451    meta: &BackdropMeta,
452    blit_pipeline: Option<&BackdropBlitPipeline>,
453    main_texture: &TextureView,
454    pipeline_cache: &PipelineCache,
455    ctx: &mut RenderContext,
456) {
457    let (Some(blit), Some(run)) = (
458        meta.blits.get(idx).and_then(Option::as_ref),
459        meta.runs.get(idx).and_then(Option::as_ref),
460    ) else {
461        return;
462    };
463    let Some(blit_pipeline_res) = blit_pipeline else {
464        return;
465    };
466    // All-or-nothing across blit + chain: a partial run would leave the
467    // outputs inconsistent, and `output_valid` was only set if everything
468    // resolved at prepare.
469    let Some(blit_compiled) = pipeline_cache.get_render_pipeline(blit.pipeline) else {
470        return;
471    };
472    let chain_compiled: Option<Vec<_>> = run
473        .passes
474        .iter()
475        .map(|pass| pipeline_cache.get_render_pipeline(pass.pipeline))
476        .collect();
477    let Some(chain_compiled) = chain_compiled else {
478        return;
479    };
480    let Some(blit_binding) = meta.blit_uniforms.binding() else {
481        return;
482    };
483    // The blit bind group is created here, against THIS moment's main
484    // texture view (see the module doc's a/b-flip rationale). One group per
485    // layer is fine: backdrop layers are rare relative to UI nodes, and the
486    // view can differ between invocations.
487    let blit_bind_group = ctx.render_device().create_bind_group(
488        "ui_layer_backdrop_blit",
489        &pipeline_cache.get_bind_group_layout(&blit_pipeline_res.layout),
490        &BindGroupEntries::sequential((main_texture, &blit_pipeline_res.sampler, blit_binding)),
491    );
492    {
493        let mut pass = ctx.begin_tracked_render_pass(RenderPassDescriptor {
494            label: Some("ui_layer_backdrop_blit"),
495            color_attachments: &[Some(RenderPassColorAttachment {
496                view: &blit.target,
497                depth_slice: None,
498                resolve_target: None,
499                ops: Operations {
500                    // Replace-write of every texel; Clear skips loading stale
501                    // contents on tiled GPUs.
502                    load: LoadOp::Clear(LinearRgba::NONE.into()),
503                    store: StoreOp::Store,
504                },
505            })],
506            depth_stencil_attachment: None,
507            timestamp_writes: None,
508            occlusion_query_set: None,
509            multiview_mask: None,
510        });
511        pass.set_render_pipeline(blit_compiled);
512        pass.set_bind_group(0, &blit_bind_group, &[blit.uniform_offset]);
513        pass.draw(0..3, 0..1);
514    }
515    for (pass_data, pipeline) in run.passes.iter().zip(chain_compiled) {
516        let mut pass = ctx.begin_tracked_render_pass(RenderPassDescriptor {
517            label: Some("ui_layer_backdrop_filter"),
518            color_attachments: &[Some(RenderPassColorAttachment {
519                view: &pass_data.target,
520                depth_slice: None,
521                resolve_target: None,
522                ops: Operations {
523                    load: LoadOp::Clear(LinearRgba::NONE.into()),
524                    store: StoreOp::Store,
525                },
526            })],
527            depth_stencil_attachment: None,
528            timestamp_writes: None,
529            occlusion_query_set: None,
530            multiview_mask: None,
531        });
532        pass.set_render_pipeline(pipeline);
533        pass.set_bind_group(0, &pass_data.bind_group, &[pass_data.uniform_offset]);
534        pass.draw(0..3, 0..1);
535    }
536}
537
538/// Fetch the camera's current main-texture view for [`run_backdrop_passes`].
539/// `None` (no `ViewTarget` on the camera) skips every backdrop this frame.
540pub fn camera_main_texture(world: &World, camera: Option<Entity>) -> Option<TextureView> {
541    let target = world.get::<ViewTarget>(camera?)?;
542    Some(target.main_texture_view().clone())
543}
544
545/// The backdrop quad's clipped geometry: the UN-inflated border box
546/// (`min + outset .. min + size − outset`) with UVs relative to the
547/// INFLATED snapshot — frost never paints in the outset ring, but its blur
548/// was computed with real neighborhood. `None` = fully clipped away (or a
549/// degenerate box), draw nothing.
550pub fn backdrop_quad(
551    min: Vec2,
552    size: UVec2,
553    outset: u32,
554    clip: Option<Rect>,
555) -> Option<ClippedQuad> {
556    let size = size.as_vec2();
557    let inset = Vec2::splat(outset as f32);
558    let box_min = min + inset;
559    let box_max = min + size - inset;
560    let (pos_min, pos_max) = match clip {
561        None => (box_min, box_max),
562        Some(c) => (box_min.max(c.min), box_max.min(c.max)),
563    };
564    if pos_min.x >= pos_max.x || pos_min.y >= pos_max.y {
565        return None;
566    }
567    Some(ClippedQuad {
568        pos_min,
569        pos_max,
570        uv_min: (pos_min - min) / size,
571        uv_max: (pos_max - min) / size,
572    })
573}
574
575/// Whether this layer's backdrop composite may draw this frame, maintaining
576/// the gate-warn bookkeeping. Returns the bind group to sample when ready.
577/// Mirrors the content-filter gate in `prepare_layer_composites`, with a
578/// milder warning: a withheld BACKDROP quad shows the unfiltered frame (the
579/// pixels are already in the target), never an invisible subtree.
580#[allow(clippy::too_many_arguments)]
581pub fn backdrop_gate(
582    idx: usize,
583    main_entity: bevy::render::sync_world::MainEntity,
584    backdrop: &mut BackdropSlot,
585    meta: &BackdropMeta,
586    pipeline_cache: &PipelineCache,
587    render_device: &RenderDevice,
588    atlas_layout: &BindGroupLayoutDescriptor,
589    sampler: &Sampler,
590) -> Option<BindGroup> {
591    if !backdrop.output_valid {
592        backdrop.gated_frames = backdrop.gated_frames.saturating_add(1);
593        if !backdrop.gate_warned {
594            let compile_error = meta
595                .runs
596                .get(idx)
597                .and_then(|run| run.as_ref())
598                .into_iter()
599                .flat_map(|run| run.passes.iter().map(|p| p.pipeline))
600                .chain(
601                    meta.blits
602                        .get(idx)
603                        .and_then(|b| b.as_ref())
604                        .map(|b| b.pipeline),
605                )
606                .find_map(
607                    |pipeline| match pipeline_cache.get_render_pipeline_state(pipeline) {
608                        CachedPipelineState::Err(
609                            e @ (bevy::shader::ShaderCacheError::ProcessShaderError(_)
610                            | bevy::shader::ShaderCacheError::CreateShaderModule(_)),
611                        ) => Some(e.to_string()),
612                        _ => None,
613                    },
614                );
615            if let Some(err) = compile_error {
616                warn!(
617                    "UI layer {main_entity:?}: a backdropFilter pass shader failed to \
618                     compile — the region shows the UNFILTERED frame until fixed (the \
619                     backdrop gate is graceful; the node's own content still draws). \
620                     Error: {err}",
621                );
622                backdrop.gate_warned = true;
623            } else if backdrop.gated_frames == STUCK_GATE_HANG_FRAMES {
624                warn!(
625                    "UI layer {main_entity:?}: backdrop quad withheld for {} consecutive \
626                     frames and its pipeline is still not ready (no compile error \
627                     reported). The region shows the unfiltered frame until it resolves.",
628                    STUCK_GATE_HANG_FRAMES,
629                );
630                backdrop.gate_warned = true;
631            }
632        }
633        return None;
634    }
635    let output = backdrop.output_index;
636    if !matches!(&backdrop.composite_bind_group, Some((built, _)) if *built == output) {
637        backdrop.composite_bind_group = Some((
638            output,
639            render_device.create_bind_group(
640                "ui_layer_composite_backdrop",
641                &pipeline_cache.get_bind_group_layout(atlas_layout),
642                &BindGroupEntries::sequential((&backdrop.textures[output].default_view, sampler)),
643            ),
644        ));
645    }
646    backdrop
647        .composite_bind_group
648        .as_ref()
649        .map(|(_, bind_group)| bind_group.clone())
650}
651
652/// The backdrop quad's stacking offset below its layer's first stolen item.
653/// `stack_z_offsets::BACKGROUND_COLOR` is `0.0`, so an explicit negative
654/// epsilon is required to sort strictly under the content composite quad
655/// (which sits AT the first stolen key). Per-node z offsets span −0.1..0.08
656/// and adjacent stack indices differ by 1.0, so 0.005 can never sink the
657/// quad under a preceding sibling.
658pub const BACKDROP_UNDERLAY_EPSILON: f32 = 0.005;
659
660/// Sanity math for [`ExtractedUiLayers`]-driven vertex staging lives with
661/// the pure helpers; see the tests below.
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    /// `backdrop_quad` shrink+UV table: the quad covers the un-inflated
667    /// border box with UVs mapping the box's position inside the inflated
668    /// snapshot; clips clamp position and UVs together; degenerate boxes
669    /// (outset ≥ half the rect) draw nothing.
670    #[test]
671    fn backdrop_quad_shrinks_to_border_box_with_inflated_uvs() {
672        let min = Vec2::new(100.0, 200.0);
673        let size = UVec2::new(132, 96); // border box 100×64 + outset 16
674        let q = backdrop_quad(min, size, 16, None).expect("quad");
675        assert_eq!(q.pos_min, Vec2::new(116.0, 216.0));
676        assert_eq!(q.pos_max, Vec2::new(216.0, 280.0));
677        assert_eq!(q.uv_min, Vec2::new(16.0 / 132.0, 16.0 / 96.0));
678        assert_eq!(q.uv_max, Vec2::new(116.0 / 132.0, 80.0 / 96.0));
679
680        // Zero outset: the border box IS the rect, full UV window.
681        let q = backdrop_quad(min, size, 0, None).expect("quad");
682        assert_eq!(q.pos_min, min);
683        assert_eq!(q.uv_min, Vec2::ZERO);
684        assert_eq!(q.uv_max, Vec2::ONE);
685
686        // An ancestor clip clamps position and UVs proportionally.
687        let clip = Rect::new(150.0, 216.0, 400.0, 400.0);
688        let q = backdrop_quad(min, size, 16, Some(clip)).expect("quad");
689        assert_eq!(q.pos_min, Vec2::new(150.0, 216.0));
690        assert_eq!(q.pos_max, Vec2::new(216.0, 280.0));
691        assert_eq!(q.uv_min, Vec2::new(50.0 / 132.0, 16.0 / 96.0));
692
693        // Fully clipped away → None.
694        let far = Rect::new(1000.0, 1000.0, 2000.0, 2000.0);
695        assert!(backdrop_quad(min, size, 16, Some(far)).is_none());
696
697        // Degenerate: outset eats the whole box.
698        assert!(backdrop_quad(min, UVec2::new(20, 20), 16, None).is_none());
699    }
700
701    /// The underlay epsilon sorts strictly under the content quad for
702    /// representative first-stolen keys, including a negative
703    /// (box-shadow-like) offset, without crossing a whole stack index.
704    #[test]
705    fn underlay_epsilon_sorts_under_content_quad() {
706        for first_key in [0.0f32, -0.1, 0.08, 41.0, -3.05] {
707            let content = first_key; // + stack_z_offsets::BACKGROUND_COLOR == 0.0
708            let backdrop = first_key - BACKDROP_UNDERLAY_EPSILON;
709            assert!(backdrop < content, "key {first_key}");
710            assert!(content - backdrop < 1.0, "must stay within the stack slot");
711        }
712    }
713}