Skip to main content

bevy_react/layer/
render.rs

1//! Render-world half of layer compositing — a custom pass over stock
2//! `bevy_ui_render`, public API only (no fork). Mechanism per frame:
3//!
4//! 1. [`extract_ui_layers`] (`ExtractSchedule`, after
5//!    `extract_ui_camera_view`): per promoted layer, spawn a **synthetic view**
6//!    whose `clip_from_view` is an orthographic projection over the layer's
7//!    capture rect — the same physical screen space stock UI vertices live in —
8//!    and register an empty `TransparentUi` phase for it. Stock extraction /
9//!    queue never know it exists.
10//! 2. [`redistribute_ui_layers`] (`PhaseSort`, before the stock sort): move
11//!    the already-queued phase items whose `main_entity` lies in a promoted
12//!    subtree, **verbatim**, from the camera's UI phase into their layer's
13//!    synthetic phase — stock `prepare_uinodes` (and sibling prepares) iterate
14//!    *all* phases, so the moved items are batched by stock code against the
15//!    synthetic view's `ViewUniformOffset`. Then inject one composite-quad
16//!    item per layer at the position of its first stolen item.
17//! 3. [`ui_layer_capture_pass`] (`Core2d`/`Core3d`, before `ui_pass`): render
18//!    each synthetic phase into the layer's offscreen texture (cleared
19//!    transparent). Straight-alpha blending onto transparent black accumulates
20//!    **premultiplied** color, so…
21//! 4. …a layer with a `filter` chain then replays its staged filter run
22//!    (same graph node, right after that layer's capture): fullscreen passes
23//!    capture → ping-pong textures ([`LayerFilterMeta::runs`], staged by
24//!    [`prepare_layer_filters`]), all of them or none — an uncompiled pass
25//!    pipeline aborts the whole run and [`FilterSlot::output_valid`] stays
26//!    false, so the layer restages and retries next frame. And…
27//! 5. …a layer with the `TRANSFORM3D` promotion reason replays its staged
28//!    mip-downsample chain last in the iteration ([`mips`]) — its sampled
29//!    texture (capture, or filter output) carries a full mip chain, rebuilt
30//!    only when level 0 was rewritten. Finally…
31//! 6. …the composite quad ([`DrawLayerComposite`], drawn inside the stock
32//!    `ui_pass` at the subtree's stacking position) samples the capture — or,
33//!    for a filtered layer, the final filter pass's output — with
34//!    premultiplied blending (`One`/`OneMinusSrcAlpha`) and multiplies rgb
35//!    *and* alpha by the group alpha. 3D-transformed quads sample trilinear +
36//!    anisotropic over the mip chain (minification shimmer) and feather ~1px
37//!    of coverage at their silhouette (`composite.wgsl`'s edge AA — diagonal
38//!    edges rasterize without MSAA).
39//!
40//! Re-verify on Bevy upgrades (spike checklist): `TransparentUi` field set,
41//! `SortedRenderPhase::{items, transient_items}` visibility, `prepare_uinodes`
42//! iterating all phases, `ViewSortedRenderPhases::prepare_for_new_frame`
43//! draining transients, straight `ALPHA_BLENDING` in `UiPipeline`, the
44//! `Queue → PhaseSort → PrepareBindGroups` schedule shape, naga_oil NOT
45//! re-exporting an import's entry points (the split-stage filter pipelines
46//! rely on pass shaders having no vertex entry of their own), naga's namer
47//! renaming digit-suffixed identifiers (the `pad_a`/`pad_b` constraint in
48//! composable WGSL modules), and wgpu accepting per-stage shader modules in
49//! `RenderPipelineDescriptor` (filter vertex stage = prelude module, fragment
50//! stage = pass module).
51
52pub mod backdrop;
53pub mod clip;
54pub mod mips;
55pub mod store;
56pub mod transform3d;
57
58pub use store::*;
59
60use std::ops::Range;
61
62use bevy::asset::{AssetServer, Handle};
63use bevy::camera::{Camera, Camera2d, Camera3d};
64use bevy::ecs::system::SystemParamItem;
65use bevy::ecs::system::lifetimeless::SRes;
66use bevy::math::{FloatOrd, Mat4, UVec4};
67use bevy::mesh::VertexBufferLayout;
68use bevy::platform::collections::HashMap;
69use bevy::prelude::*;
70use bevy::render::Extract;
71use bevy::render::camera::CameraMainPassTextureFormats;
72use bevy::render::render_phase::{
73    DrawFunctions, PhaseItem, PhaseItemExtraIndex, RenderCommand, RenderCommandResult,
74    SetItemPipeline, TrackedRenderPass, ViewSortedRenderPhases,
75};
76use bevy::render::render_resource::binding_types::{sampler, texture_2d, uniform_buffer};
77use bevy::render::render_resource::*;
78use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue, ViewQuery};
79use bevy::render::sync_world::{MainEntity, RenderEntity, TemporaryRenderEntity};
80use bevy::render::view::{ExtractedView, RetainedViewEntity, ViewUniform};
81use bevy::shader::Shader;
82use bevy::shader::ShaderCacheError;
83use bevy::ui::{ComputedNode, ComputedUiTargetCamera};
84use bevy::ui_render::{SetUiViewBindGroup, TransparentUi, stack_z_offsets};
85
86use super::{LayerCaptureRect, LayerGroupAlpha, LayerMembership, PromotedLayer};
87use crate::filters::{MAX_FILTER_PARAM_VECS, ResolvedFilterChain};
88
89/// Matches the private `bevy_ui_render::UI_CAMERA_FAR` (the stock UI ortho
90/// far plane / view z) so synthetic views project identically to the stock
91/// UI view.
92const UI_CAMERA_FAR: f32 = 1000.0;
93/// Matches the private `bevy_ui_render::UI_CAMERA_TRANSFORM_OFFSET`.
94const UI_CAMERA_TRANSFORM_OFFSET: f32 = -0.1;
95/// Stock UI views use subview 1 on the *camera's* main entity; layer capture
96/// views key off the *layer root's* main entity, so any constant would be
97/// collision-free — a distinct one keeps `RetainedViewEntity` debugging sane.
98const UI_LAYER_CAPTURE_SUBVIEW: u32 = 2;
99/// Cycle/depth guard for enclosing-chain walks ([`walk_enclosing`] and the
100/// capture-order depth computation): `enclosing` is acyclic by construction,
101/// so a chain longer than this is a bug, not a real hierarchy — walks stop
102/// rather than spin.
103const MAX_LAYER_DEPTH: usize = 64;
104/// Consecutive gated frames ([`FilterSlot::gated_frames`]) before the stuck
105/// composite gate warns about a pipeline that is *still compiling*. A shader
106/// that outright FAILED warns immediately (the gate inspects
107/// [`CachedPipelineState`] each gated frame), so this threshold only covers
108/// the never-completes case; it is deliberately generous because frame count
109/// is FPS-relative — at an uncapped 300 fps, startup compiles legitimately
110/// take hundreds of gated frames (~2 s here; ~10 s at 60 fps).
111const STUCK_GATE_HANG_FRAMES: u32 = 600;
112
113/// One filter pass of an extracted chain: the pass shader plus its packed
114/// uniform params.
115pub struct ExtractedFilterPass {
116    /// The pass's fragment shader (the vertex stage is always the prelude's —
117    /// see [`LayerFilterPipeline`]).
118    pub shader: Handle<Shader>,
119    /// The packed params, zero-padded to the full uniform array. A fixed
120    /// array rather than the main world's `Vec`: `FilterUniforms.params` is
121    /// fixed-size anyway, so padding at extract time makes uniform staging a
122    /// plain copy (unused slots are never read by the pass shader).
123    pub params: [Vec4; MAX_FILTER_PARAM_VECS],
124}
125
126/// A layer's filter chain, extracted from [`ResolvedFilterChain`]. Only the
127/// render-side fields cross: `wire_index`/`layout`/`outset_px`/`scale` are
128/// main-world concerns (animation metadata, capture sizing) and stay there.
129pub struct ExtractedChain {
130    pub passes: Vec<ExtractedFilterPass>,
131    /// Mirrors [`ResolvedFilterChain::version`] — compared against
132    /// [`FilterSlot::params_version`] to detect param changes.
133    pub version: u32,
134    /// Mirrors [`ResolvedFilterChain::always_dirty`] (time-driven filters
135    /// re-run every frame).
136    pub always_dirty: bool,
137}
138
139/// Map a main-world resolved chain into its render-side [`ExtractedChain`].
140/// The resolver never attaches an empty chain, but guard anyway — an empty
141/// chain must read as "no filter machinery" downstream.
142fn extract_chain(chain: Option<&ResolvedFilterChain>) -> Option<ExtractedChain> {
143    chain
144        .filter(|chain| !chain.passes.is_empty())
145        .map(|chain| ExtractedChain {
146            passes: chain
147                .passes
148                .iter()
149                .map(|pass| {
150                    // The registry rejects over-cap packs at resolve; a
151                    // custom `resolve` override that bypassed it would
152                    // otherwise be silently truncated here.
153                    debug_assert!(
154                        pass.params.len() <= MAX_FILTER_PARAM_VECS,
155                        "filter pass packs {} vec4s, over MAX_FILTER_PARAM_VECS",
156                        pass.params.len()
157                    );
158                    let mut params = [Vec4::ZERO; MAX_FILTER_PARAM_VECS];
159                    for (slot, value) in params.iter_mut().zip(&pass.params) {
160                        *slot = *value;
161                    }
162                    ExtractedFilterPass {
163                        shader: pass.shader.clone(),
164                        params,
165                    }
166                })
167                .collect(),
168            version: chain.version,
169            always_dirty: chain.always_dirty,
170        })
171}
172
173/// One promoted layer, as seen by the render world this frame.
174pub struct ExtractedLayer {
175    /// The layer root's main-world entity (subtree identity).
176    pub main_entity: MainEntity,
177    /// The synthetic capture view (render-world entity, lives one frame).
178    pub view_entity: Entity,
179    /// The synthetic view's phase key.
180    pub retained: RetainedViewEntity,
181    /// Render-world entity of the composite quad (carries
182    /// [`LayerCompositeBatch`] after prepare).
183    pub quad_entity: Entity,
184    /// Capture anchor: fractional physical px, stock UI view space (top-left
185    /// of the node's border box — translation moves it without re-capturing).
186    pub min: Vec2,
187    /// Capture texture size in whole texels.
188    pub size: UVec2,
189    /// The screen-space rect the composite quad clamps to (the layer root's
190    /// ancestor clipping, applied at composite time instead of capture time —
191    /// see [`clip`]). `None` = unclipped.
192    pub quad_clip: Option<bevy::math::Rect>,
193    /// Composite-time group alpha.
194    pub alpha: f32,
195    /// Color format of the camera target — capture textures must match, or
196    /// the stolen items' pipelines (specialized against the camera's format)
197    /// would be invalid for the capture pass.
198    pub target_format: TextureFormat,
199    /// Whether this layer's capture must re-render this frame. `false` = the
200    /// persistent texture in [`LayerTextureStore`] already holds the correct
201    /// pixels: the capture pass skips it, and its stolen phase items are
202    /// dropped instead of re-drawn. Decided at extract time (main-world dirt ∪
203    /// missing/mismatched slot), then propagated up the enclosing chain — a
204    /// re-capturing layer's quad re-draws inside every enclosing capture.
205    pub needs_capture: bool,
206    /// The layer root's resolved filter chain, if any (always non-empty when
207    /// present). Drives [`prepare_layer_filters`]; `None` clears the slot's
208    /// filter state (see [`FilterSlot`]).
209    pub chain: Option<ExtractedChain>,
210    /// The layer root's resolved `backdropFilter` chain, if any (always
211    /// non-empty and `always_dirty` when present — the source frame is
212    /// live). Drives the backdrop snapshot + filter staging
213    /// ([`backdrop::prepare_layer_backdrops`]); `None` clears the slot's
214    /// backdrop state.
215    pub backdrop_chain: Option<ExtractedChain>,
216    /// Render-world entity of the backdrop composite quad (the frosted
217    /// underlay drawn one epsilon below the content quad). Spawned only when
218    /// [`Self::backdrop_chain`] is present.
219    pub backdrop_quad_entity: Option<Entity>,
220    /// The quantized outset margin baked into `min`/`size`
221    /// ([`LayerCaptureRect::outset`]). The backdrop quad shrinks by this to
222    /// the un-inflated border box — frost must not paint in the outset ring.
223    pub outset: u32,
224    /// The node's layout-resolved corner radii, `[top_left, top_right,
225    /// bottom_right, bottom_left]` physical px (from
226    /// `ComputedNode.border_radius` — already clamped per corner to
227    /// `0.5 * min(w, h)`, Bevy's rule; matching what bevy_ui paints is the
228    /// point). Consumed only by the backdrop quad's uniform push: the frost
229    /// is masked to the rounded border box. All-zero = square.
230    pub corner_radius: [f32; 4],
231    /// The layer's composite-time 3D model matrix (screen-space homography,
232    /// from `LayerTransform3dMatrix`). `None` = untransformed (absent style
233    /// or identity params) — the quad takes the CPU clip path unchanged.
234    pub transform3d: Option<Mat4>,
235    /// Whether the layer carries the `TRANSFORM3D` promotion reason — its
236    /// sampled texture allocates a mip chain (see [`mips`]). Keyed on the
237    /// *reason*, not the matrix value: identity↔non-identity changes must
238    /// never realloc/re-capture, and the chain stays warm for the first
239    /// animated frame. Trilinear sampling itself engages only when
240    /// [`Self::transform3d`] is `Some` AND the chain is valid.
241    pub wants_mips: bool,
242}
243
244/// Per-frame extraction output. `layers` is index-aligned with
245/// [`LayerAtlases::textures`] and [`LayerCompositeMeta::atlas_bind_groups`].
246#[derive(Resource, Default)]
247pub struct ExtractedUiLayers {
248    pub layers: Vec<ExtractedLayer>,
249    /// node main entity → index into `layers` (steal routing).
250    pub membership: HashMap<MainEntity, usize>,
251    /// layer index → index of its enclosing layer (quad routing); `None` =
252    /// composite into the stock camera phase.
253    pub enclosing: Vec<Option<usize>>,
254    /// The stock UI view's phase key for the target camera.
255    pub stock_view: Option<RetainedViewEntity>,
256    /// The camera's render-world entity ([`ui_layer_capture_pass`] gates on
257    /// the current view being this camera).
258    pub camera_render_entity: Option<Entity>,
259    /// Layer indices in capture order: deepest (innermost) first, so an outer
260    /// capture's pass samples already-rendered inner captures.
261    pub capture_order: Vec<usize>,
262}
263
264/// Extracts promoted layers into the render world and spawns their synthetic
265/// capture views. Must run after `extract_ui_camera_view`: that system ends
266/// with a `retain` that would drop any phase it didn't create.
267#[allow(clippy::type_complexity, clippy::too_many_arguments)]
268pub fn extract_ui_layers(
269    mut commands: Commands,
270    mut phases: ResMut<ViewSortedRenderPhases<TransparentUi>>,
271    mut extracted: ResMut<ExtractedUiLayers>,
272    layers: Extract<
273        Query<(
274            Entity,
275            &LayerCaptureRect,
276            &LayerGroupAlpha,
277            &ComputedUiTargetCamera,
278            Option<&ResolvedFilterChain>,
279            Option<&crate::filters::ResolvedBackdropChain>,
280            Option<&crate::layer::transform3d::LayerTransform3dMatrix>,
281            &PromotedLayer,
282            Option<&ComputedNode>,
283        )>,
284    >,
285    membership: Extract<Res<LayerMembership>>,
286    repaints: Extract<Res<super::LayerRepaintState>>,
287    clips: Extract<Res<crate::layer::clip::LayerClips>>,
288    cameras: Extract<Query<(RenderEntity, &Camera), Or<(With<Camera2d>, With<Camera3d>)>>>,
289    main_pass_formats: Res<CameraMainPassTextureFormats>,
290    store: Res<LayerTextureStore>,
291) {
292    extracted.layers.clear();
293    extracted.membership.clear();
294    extracted.enclosing.clear();
295    extracted.capture_order.clear();
296    extracted.stock_view = None;
297    extracted.camera_render_entity = None;
298
299    if layers.is_empty() {
300        return;
301    }
302
303    // v1: all layers composite on one camera — the first layer root's UI
304    // target camera. (Multi-camera roots are a documented non-goal for now.)
305    let mut layer_index: HashMap<Entity, usize> = HashMap::default();
306    for (
307        root,
308        rect,
309        alpha,
310        target_camera,
311        filter_chain,
312        backdrop,
313        transform3d,
314        promoted,
315        computed,
316    ) in layers.iter()
317    {
318        let Some(camera_main) = target_camera.get() else {
319            continue;
320        };
321        let Ok((camera_render, camera)) = cameras.get(camera_main) else {
322            continue;
323        };
324        if !camera.is_active {
325            continue;
326        }
327        let Some(target_format) = main_pass_formats.get(&camera_render).copied() else {
328            continue;
329        };
330        if extracted.stock_view.is_none() {
331            extracted.stock_view = Some(RetainedViewEntity::new(
332                camera_main.into(),
333                None,
334                // Stock `UI_CAMERA_SUBVIEW`.
335                1,
336            ));
337            extracted.camera_render_entity = Some(camera_render);
338        }
339
340        let (min, size) = (rect.min, rect.size);
341        // Ortho over the capture rect in stock UI view space: vertices keep
342        // their physical screen coordinates; the projection alone remaps the
343        // rect to the capture target's clip space. Top-left origin like stock.
344        // The bounds are fractional — the window tracks the node exactly, so
345        // capture content is translation-invariant even subpixel.
346        let projection = Mat4::orthographic_rh(
347            min.x,
348            min.x + size.x as f32,
349            min.y + size.y as f32,
350            min.y,
351            0.0,
352            UI_CAMERA_FAR,
353        );
354        let retained =
355            RetainedViewEntity::new(MainEntity::from(root), None, UI_LAYER_CAPTURE_SUBVIEW);
356        let view_entity = commands
357            .spawn((
358                ExtractedView {
359                    retained_view_entity: retained,
360                    clip_from_view: projection,
361                    world_from_view: GlobalTransform::from_xyz(
362                        0.0,
363                        0.0,
364                        UI_CAMERA_FAR + UI_CAMERA_TRANSFORM_OFFSET,
365                    ),
366                    clip_from_world: None,
367                    target_format,
368                    viewport: UVec4::new(0, 0, size.x, size.y),
369                    color_grading: Default::default(),
370                    invert_culling: false,
371                },
372                TemporaryRenderEntity,
373            ))
374            .id();
375        let quad_entity = commands.spawn(TemporaryRenderEntity).id();
376        phases.prepare_for_new_frame(retained);
377
378        let wants_mips = promoted.reasons.0 & crate::layer::PromotionReasons::TRANSFORM3D != 0;
379        // Cache decision: re-capture on main-world dirt, or when the persistent
380        // slot can't serve (first frame, resize realloc, format flip, or a
381        // mip-state flip — the fresh mipped/unmipped texture needs content).
382        let cached_ok = store
383            .slots
384            .get(&MainEntity::from(root))
385            .is_some_and(|slot| {
386                slot.content_valid
387                    && slot.size == size
388                    && slot.format == target_format
389                    && slot.mips.is_some() == wants_mips
390            });
391        let needs_capture = !cached_ok || repaints.dirty.contains(&root);
392
393        let chain = extract_chain(filter_chain);
394        let backdrop_chain = extract_chain(backdrop.map(|b| &b.0));
395        let backdrop_quad_entity =
396            (backdrop_chain.is_some()).then(|| commands.spawn(TemporaryRenderEntity).id());
397
398        layer_index.insert(root, extracted.layers.len());
399        extracted.layers.push(ExtractedLayer {
400            main_entity: MainEntity::from(root),
401            view_entity,
402            retained,
403            quad_entity,
404            min,
405            size,
406            quad_clip: clips.quads.get(&root).copied().flatten(),
407            alpha: alpha.0.clamp(0.0, 1.0),
408            target_format,
409            needs_capture,
410            chain,
411            backdrop_chain,
412            backdrop_quad_entity,
413            outset: rect.outset,
414            corner_radius: computed.map_or([0.0; 4], |c| c.border_radius.into()),
415            // Identity matrices stay `None`: the quad renders exactly like an
416            // untransformed layer (CPU clip path), and picking stays inert.
417            transform3d: transform3d.filter(|m| !m.identity).map(|m| m.model),
418            wants_mips,
419        });
420    }
421
422    // Prune phases of layers that died since last frame: stock `retain` only
423    // keeps its own views alive, and ours re-register just above, so any
424    // subview-2 phase without a live layer this frame is stale.
425    let live: Vec<RetainedViewEntity> = extracted.layers.iter().map(|l| l.retained).collect();
426    phases.retain(|retained, _| {
427        retained.subview_index != UI_LAYER_CAPTURE_SUBVIEW || live.contains(retained)
428    });
429
430    for (node, layer_root) in membership.node_to_layer.iter() {
431        if let Some(&idx) = layer_index.get(layer_root) {
432            extracted.membership.insert(MainEntity::from(*node), idx);
433        }
434    }
435    extracted.enclosing = extracted
436        .layers
437        .iter()
438        .map(|layer| {
439            membership
440                .enclosing
441                .get(&layer.main_entity.id())
442                .copied()
443                .flatten()
444                .and_then(|e| layer_index.get(&e).copied())
445        })
446        .collect();
447    // Propagate `needs_capture` outward: a re-capturing inner layer's quad
448    // re-draws inside its enclosing captures, so those must re-capture too.
449    // (The main-world resolver already propagates its dirt the same way; this
450    // pass additionally covers render-side reasons — a missing/realloc'd
451    // slot — so redistribute can rely on "outer cached ⇒ inner cached".)
452    let extracted = &mut *extracted;
453    for i in 0..extracted.layers.len() {
454        if extracted.layers[i].needs_capture {
455            let layers = &mut extracted.layers;
456            walk_enclosing(i, &extracted.enclosing, |outer| {
457                if layers[outer].needs_capture {
458                    return false; // its own chain is already propagated
459                }
460                layers[outer].needs_capture = true;
461                true
462            });
463        }
464    }
465    // A nested backdrop layer's quad holds LIVE screen pixels (the snapshot
466    // re-blits every frame), so every enclosing capture containing that quad
467    // can never serve from cache — force the chain dirty unconditionally,
468    // each frame. The backdrop layer's OWN content capture still caches
469    // normally (the frost is a separate quad, not part of its capture).
470    // Documented cost: nesting a backdrop defeats ancestor capture caching.
471    for i in 0..extracted.layers.len() {
472        if extracted.layers[i].backdrop_chain.is_some() {
473            let layers = &mut extracted.layers;
474            walk_enclosing(i, &extracted.enclosing, |outer| {
475                if layers[outer].needs_capture {
476                    return false; // already dirty ⇒ its chain already is too
477                }
478                layers[outer].needs_capture = true;
479                true
480            });
481        }
482    }
483    // Capture order: innermost first (an outer capture samples its inner
484    // quads). depth = length of the enclosing chain.
485    let enclosing = extracted.enclosing.clone();
486    let depth_of = |mut idx: usize| {
487        let mut depth = 0usize;
488        while let Some(outer) = enclosing[idx] {
489            depth += 1;
490            idx = outer;
491            if depth > MAX_LAYER_DEPTH {
492                break; // cycle guard (impossible by construction)
493            }
494        }
495        depth
496    };
497    let mut order: Vec<usize> = (0..extracted.layers.len()).collect();
498    order.sort_by_key(|&i| std::cmp::Reverse(depth_of(i)));
499    extracted.capture_order = order;
500}
501
502/// Moves promoted subtrees' phase items from the camera's UI phase into their
503/// layer's synthetic phase, then injects one composite quad per layer. Runs
504/// after queueing, before the stock sort (which then sorts every phase,
505/// stolen items keeping their global stack-index sort keys).
506pub fn redistribute_ui_layers(
507    extracted: Res<ExtractedUiLayers>,
508    mut phases: ResMut<ViewSortedRenderPhases<TransparentUi>>,
509    draw_functions: Res<DrawFunctions<TransparentUi>>,
510    composite_pipeline: Option<Res<LayerCompositePipeline>>,
511    mut specialized: ResMut<SpecializedRenderPipelines<LayerCompositePipeline>>,
512    pipeline_cache: Res<PipelineCache>,
513) {
514    if extracted.layers.is_empty() {
515        return;
516    }
517    let Some(stock_view) = extracted.stock_view else {
518        return;
519    };
520    let Some(composite_pipeline) = composite_pipeline else {
521        return;
522    };
523
524    // Steal: drain matching items out of the stock phase in one pass…
525    let mut stolen: Vec<(usize, (Entity, MainEntity), TransparentUi)> = Vec::new();
526    // …tracking each layer's first (lowest-sort-key) stolen item: the
527    // composite quad draws exactly where the subtree would have started.
528    let mut quad_sort_keys: Vec<Option<FloatOrd>> = vec![None; extracted.layers.len()];
529    {
530        let Some(stock_phase) = phases.get_mut(&stock_view) else {
531            return;
532        };
533        // One O(n) partition pass (order-preserving): a `shift_remove` per
534        // stolen key shifts the IndexMap tail each time — O(n²), ~14ms/frame
535        // at 500 stress layers with most of the phase promoted.
536        let taken = std::mem::take(&mut stock_phase.items);
537        for (key, item) in taken {
538            let Some(&idx) = extracted.membership.get(&item.main_entity()) else {
539                stock_phase.items.insert(key, item);
540                continue;
541            };
542            let best = &mut quad_sort_keys[idx];
543            if best.is_none() || item.sort_key < best.unwrap() {
544                *best = Some(item.sort_key);
545            }
546            stolen.push((idx, key, item));
547        }
548    }
549    for (idx, _key, item) in stolen {
550        // A cached layer's items are simply dropped: the persistent texture
551        // already holds their pixels, so nothing re-draws them (and stock
552        // `prepare_uinodes` builds no vertices for them either). The steal
553        // itself is still load-bearing — it keeps the items out of the stock
554        // phase AND recorded each layer's quad sort key above.
555        if !extracted.layers[idx].needs_capture {
556            continue;
557        }
558        if let Some(phase) = phases.get_mut(&extracted.layers[idx].retained) {
559            phase.add_transient(item);
560        }
561    }
562
563    // SPIKE diagnostics: `BEVY_REACT_LAYER_SPIKE_MODE=steal` skips quad
564    // injection to isolate steal-side from composite-side effects.
565    if std::env::var("BEVY_REACT_LAYER_SPIKE_MODE").as_deref() == Ok("steal") {
566        return;
567    }
568    // Inject composite quads — inner layers' quads land in their enclosing
569    // layer's phase (they are content of the outer capture); top-level quads
570    // land in the camera phase at the subtree's stacking position.
571    let draw_function = draw_functions.read().id::<DrawLayerComposite>();
572    for (idx, layer) in extracted.layers.iter().enumerate() {
573        let Some(sort_key) = quad_sort_keys[idx] else {
574            // Nothing of this subtree was queued (hidden/empty): no quad.
575            continue;
576        };
577        let pipeline = specialized.specialize(
578            &pipeline_cache,
579            &composite_pipeline,
580            LayerCompositePipelineKey {
581                target_format: layer.target_format,
582            },
583        );
584        let target = match extracted.enclosing[idx] {
585            Some(outer) => {
586                if !extracted.layers[outer].needs_capture {
587                    // The enclosing capture is cached and already contains this
588                    // quad's pixels — nothing to draw it into. Propagation
589                    // guarantees a re-capturing inner never meets a cached
590                    // outer.
591                    debug_assert!(
592                        !layer.needs_capture,
593                        "inner layer re-captures but its enclosing layer is cached"
594                    );
595                    continue;
596                }
597                extracted.layers[outer].retained
598            }
599            None => stock_view,
600        };
601        if let Some(phase) = phases.get_mut(&target) {
602            // The frosted backdrop draws one epsilon UNDER the whole subtree
603            // (`BACKGROUND_COLOR` is 0.0 — the content quad sits exactly at
604            // the first stolen key, so "under" needs an explicit offset).
605            if let Some(backdrop_quad_entity) = layer.backdrop_quad_entity {
606                phase.add_transient(TransparentUi {
607                    sort_key: FloatOrd(sort_key.0 - backdrop::BACKDROP_UNDERLAY_EPSILON),
608                    entity: (backdrop_quad_entity, layer.main_entity),
609                    pipeline,
610                    draw_function,
611                    batch_range: 0..0,
612                    extra_index: PhaseItemExtraIndex::None,
613                    index: idx,
614                    indexed: false,
615                });
616            }
617            phase.add_transient(TransparentUi {
618                sort_key: FloatOrd(sort_key.0 + stack_z_offsets::BACKGROUND_COLOR),
619                entity: (layer.quad_entity, layer.main_entity),
620                pipeline,
621                draw_function,
622                batch_range: 0..0,
623                extra_index: PhaseItemExtraIndex::None,
624                index: idx,
625                indexed: false,
626            });
627        }
628    }
629}
630
631/// One composite-quad vertex: physical screen position (the stock UI view
632/// projects it), capture UV, and the group alpha. Future composite params
633/// (per-rule) extend this struct — the pass stays rule-agnostic.
634#[repr(C)]
635#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
636pub struct LayerCompositeVertex {
637    pub position: [f32; 3],
638    pub uv: [f32; 2],
639    pub alpha: f32,
640}
641
642/// Vertex buffer + per-layer capture bind groups for the composite draws.
643#[derive(Resource)]
644pub struct LayerCompositeMeta {
645    pub vertices: RawBufferVec<LayerCompositeVertex>,
646    pub atlas_bind_groups: Vec<BindGroup>,
647}
648
649impl Default for LayerCompositeMeta {
650    fn default() -> Self {
651        Self {
652            vertices: RawBufferVec::new(BufferUsages::VERTEX),
653            atlas_bind_groups: Vec::new(),
654        }
655    }
656}
657
658/// The composite quad's draw data on its render entity (mirrors `UiBatch`).
659#[derive(Component)]
660pub struct LayerCompositeBatch {
661    pub range: Range<u32>,
662    /// Index into [`LayerCompositeMeta::atlas_bind_groups`].
663    pub atlas: usize,
664    /// Dynamic offset of this quad's [`transform3d::CompositeUniforms`] entry.
665    pub uniform_offset: u32,
666}
667
668/// Edge-AA inflation for 3D-transformed composite quads, in pre-transform
669/// local px: the quad grows this much on every side (UVs extended
670/// proportionally past `[0, 1]`, clamped by the sampler) so the fragment
671/// stage can center a feather of the same width on the true rect edge — the
672/// outside half lands on the inflated ring, the inside half on real content.
673const EDGE_AA_INFLATE_PX: f32 = 1.0;
674
675/// The transformed quad's geometry, inflated by `inset` local px on every
676/// side with UVs extended proportionally — `uv ∈ [0, 1]` still maps exactly
677/// the true rect, which is what the shader's coverage term measures against.
678fn inflated_transform_quad(min: Vec2, size: UVec2, inset: f32) -> clip::ClippedQuad {
679    let size = size.as_vec2().max(Vec2::ONE);
680    let uv_inset = inset / size;
681    clip::ClippedQuad {
682        pos_min: min - inset,
683        pos_max: min + size + inset,
684        uv_min: -uv_inset,
685        uv_max: Vec2::ONE + uv_inset,
686    }
687}
688
689/// Builds composite-quad vertices + bind groups and stamps
690/// [`LayerCompositeBatch`] onto the quad entities, writing each quad's vertex
691/// range back into its phase item.
692#[allow(clippy::too_many_arguments)]
693pub fn prepare_layer_composites(
694    mut commands: Commands,
695    extracted: Res<ExtractedUiLayers>,
696    mut store: ResMut<LayerTextureStore>,
697    pipeline: Option<Res<LayerCompositePipeline>>,
698    pipeline_cache: Res<PipelineCache>,
699    render_device: Res<RenderDevice>,
700    render_queue: Res<RenderQueue>,
701    mut meta: ResMut<LayerCompositeMeta>,
702    mut uniforms_meta: ResMut<transform3d::CompositeUniformsMeta>,
703    filter_meta: Res<LayerFilterMeta>,
704    backdrop_meta: Res<backdrop::BackdropMeta>,
705    mut phases: ResMut<ViewSortedRenderPhases<TransparentUi>>,
706) {
707    meta.vertices.clear();
708    meta.atlas_bind_groups.clear();
709    uniforms_meta.uniforms.clear();
710    uniforms_meta.bind_group = None;
711    let Some(pipeline) = pipeline else {
712        return;
713    };
714    if extracted.layers.is_empty() {
715        return;
716    }
717
718    // The quads were injected with `index = layer index`; find each again in
719    // its (post-sort) phase to write the batch range.
720    let mut ranges: Vec<Option<Range<u32>>> = vec![None; extracted.layers.len()];
721    // Filtered layers whose output isn't ready this frame: their quads stay
722    // batch-less, so any enclosing capture rendered without them must not be
723    // served from cache — see the invalidation loop after this one.
724    let mut gated: Vec<usize> = Vec::new();
725    for (idx, layer) in extracted.layers.iter().enumerate() {
726        let Some(slot) = store.slots.get_mut(&layer.main_entity) else {
727            continue;
728        };
729        // Pick the quad's source: the raw capture, or — for a filtered
730        // layer — the final filter pass's ping-pong output.
731        let bind_group = if layer.chain.is_some() {
732            let Some(filter) = slot.filter.as_mut() else {
733                // Allocated by `prepare_layer_textures` whenever a chain is
734                // present; a miss means nothing to sample — gate the quad.
735                gated.push(idx);
736                continue;
737            };
738            // Readiness gate: chain present but no complete filtered output
739            // yet (startup compile, realloc). Skip the batch — the injected
740            // item keeps `batch_range 0..0` and draws nothing. Never fall
741            // back to the raw capture: a frame of unfiltered content is
742            // exactly the flash this gate exists to prevent.
743            if !filter.output_valid {
744                filter.gated_frames = filter.gated_frames.saturating_add(1);
745                // Once per stuck episode: an errored pass pipeline (user WGSL
746                // that failed to compile) warns immediately with the error;
747                // a still-compiling one is normal startup latency and only
748                // warns after the FPS-generous hang threshold.
749                if !filter.gate_warned {
750                    let compile_error = filter_meta
751                        .runs
752                        .get(idx)
753                        .and_then(|run| run.as_ref())
754                        .and_then(|run| {
755                            run.passes.iter().find_map(|pass| {
756                                // Only PERMANENT failures warn immediately.
757                                // `ShaderNotLoaded` / `ShaderImportNotYetAvailable`
758                                // are transient (the cache re-queues them while
759                                // an asset-path shader streams in at startup)
760                                // and fall through to the hang threshold.
761                                match pipeline_cache.get_render_pipeline_state(pass.pipeline) {
762                                    CachedPipelineState::Err(
763                                        e @ (ShaderCacheError::ProcessShaderError(_)
764                                        | ShaderCacheError::CreateShaderModule(_)),
765                                    ) => Some(e.to_string()),
766                                    _ => None,
767                                }
768                            })
769                        });
770                    if let Some(err) = compile_error {
771                        warn!(
772                            "UI layer {:?}: a filter pass shader failed to compile — the \
773                             layer's subtree is invisible (the composite gate never falls \
774                             back to unfiltered content) and its filter run restages every \
775                             frame. Error: {err}",
776                            layer.main_entity,
777                        );
778                        filter.gate_warned = true;
779                    } else if filter.gated_frames == STUCK_GATE_HANG_FRAMES {
780                        warn!(
781                            "UI layer {:?}: composite quad withheld for {} consecutive \
782                             frames and its filter pipeline is still not ready (no compile \
783                             error reported — a hung/queued compile?). Until it resolves, \
784                             the layer's subtree is invisible and its filter run restages \
785                             every frame.",
786                            layer.main_entity, STUCK_GATE_HANG_FRAMES,
787                        );
788                        filter.gate_warned = true;
789                    }
790                }
791                gated.push(idx);
792                continue;
793            }
794            // Cached until realloc; invalidated when `output_index` flips
795            // (pass-count parity change).
796            let output = filter.output_index;
797            // Trilinear only for a non-identity quad over a valid mip chain;
798            // otherwise the bilinear level-0 view (correct, just unmipped —
799            // never a gate, never a stale mip).
800            if layer.transform3d.is_some() && filter.mips_valid {
801                let Some(chain) = &filter.mips[output] else {
802                    unreachable!("mips_valid implies a staged chain");
803                };
804                if !matches!(&filter.composite_bind_group_mips, Some((built, _)) if *built == output)
805                {
806                    filter.composite_bind_group_mips = Some((
807                        output,
808                        render_device.create_bind_group(
809                            "ui_layer_composite_filtered_mips",
810                            &pipeline_cache.get_bind_group_layout(&pipeline.atlas_layout),
811                            &BindGroupEntries::sequential((
812                                &chain.full_view,
813                                &pipeline.sampler_mips,
814                            )),
815                        ),
816                    ));
817                }
818                let (_, bind_group) = filter.composite_bind_group_mips.as_ref().expect("just set");
819                bind_group.clone()
820            } else {
821                if !matches!(&filter.composite_bind_group, Some((built, _)) if *built == output) {
822                    filter.composite_bind_group = Some((
823                        output,
824                        render_device.create_bind_group(
825                            "ui_layer_composite_filtered",
826                            &pipeline_cache.get_bind_group_layout(&pipeline.atlas_layout),
827                            &BindGroupEntries::sequential((
828                                &filter.textures[output].default_view,
829                                &pipeline.sampler,
830                            )),
831                        ),
832                    ));
833                }
834                let (_, bind_group) = filter.composite_bind_group.as_ref().expect("just set");
835                bind_group.clone()
836            }
837        } else if layer.transform3d.is_some()
838            && slot.mips_valid
839            && let Some(chain) = &slot.mips
840        {
841            // Trilinear variant over the capture's full-mip view (same layout
842            // slot — any Filtering sampler fits). Lazy like `bind_group`.
843            if slot.bind_group_mips.is_none() {
844                slot.bind_group_mips = Some(render_device.create_bind_group(
845                    "ui_layer_composite_atlas_mips",
846                    &pipeline_cache.get_bind_group_layout(&pipeline.atlas_layout),
847                    &BindGroupEntries::sequential((&chain.full_view, &pipeline.sampler_mips)),
848                ));
849            }
850            slot.bind_group_mips.clone().expect("just set")
851        } else {
852            // Reuse the slot's bind group across frames; it dies on realloc.
853            if slot.bind_group.is_none() {
854                slot.bind_group = Some(render_device.create_bind_group(
855                    "ui_layer_composite_atlas",
856                    &pipeline_cache.get_bind_group_layout(&pipeline.atlas_layout),
857                    &BindGroupEntries::sequential((&slot.texture.default_view, &pipeline.sampler)),
858                ));
859            }
860            slot.bind_group.clone().expect("just set")
861        };
862        let start = meta.vertices.len() as u32;
863        // Fractional quad position (bilinear sampling smooths subpixel motion
864        // of a cached capture — the browser tradeoff), clamped to the layer's
865        // ancestor clip: the CAPTURE is clip-independent (interior clips
866        // only — see `clip::swap_interior_clips_in`), so the quad is where
867        // scroll/viewport clipping applies, with UVs shifted proportionally
868        // on clamped sides. A fully clipped-away layer draws no quad at all
869        // (`ranges[idx]` stays `None`, the item's batch_range stays `0..0`).
870        //
871        // A 3D-transformed quad can't be CPU-clamped (the clip rect is
872        // axis-aligned in screen space; the transformed quad isn't): it keeps
873        // its full geometry/UVs — inflated for the edge-AA feather — and the
874        // ancestor clip moves into the fragment stage via the per-quad
875        // uniform. A "fully clipped away" verdict is likewise unknowable
876        // pre-transform, so the transformed path always draws. Untransformed
877        // quads keep the CPU path, an open clip sentinel, and a zero feather —
878        // the shader stays single-path and pixel-identical for them.
879        let (q, model, clip_rect, feather) = match layer.transform3d {
880            Some(model) => (
881                inflated_transform_quad(layer.min, layer.size, EDGE_AA_INFLATE_PX),
882                model,
883                layer.quad_clip,
884                EDGE_AA_INFLATE_PX,
885            ),
886            None => {
887                let Some(q) = clip::clip_quad(layer.min, layer.size, layer.quad_clip) else {
888                    continue;
889                };
890                (q, Mat4::IDENTITY, None, 0.0)
891            }
892        };
893        let (min, max) = (q.pos_min, q.pos_max);
894        let (uv_min, uv_max) = (q.uv_min, q.uv_max);
895        // UVs are quad-relative (spike: texture == rect; slot-relative UVs
896        // arrive with the shared atlas).
897        let corners = [
898            ([min.x, min.y, 0.0], [uv_min.x, uv_min.y]),
899            ([max.x, min.y, 0.0], [uv_max.x, uv_min.y]),
900            ([max.x, max.y, 0.0], [uv_max.x, uv_max.y]),
901            ([min.x, min.y, 0.0], [uv_min.x, uv_min.y]),
902            ([max.x, max.y, 0.0], [uv_max.x, uv_max.y]),
903            ([min.x, max.y, 0.0], [uv_min.x, uv_max.y]),
904        ];
905        for (position, uv) in corners {
906            meta.vertices.push(LayerCompositeVertex {
907                position,
908                uv,
909                alpha: layer.alpha,
910            });
911        }
912        ranges[idx] = Some(start..start + 6);
913        let atlas_index = meta.atlas_bind_groups.len();
914        meta.atlas_bind_groups.push(bind_group);
915        let (open_min, open_max) = transform3d::open_clip();
916        let uniform_offset = uniforms_meta
917            .uniforms
918            .push(&transform3d::CompositeUniforms {
919                model,
920                clip_min: clip_rect.map_or(open_min, |r| r.min),
921                clip_max: clip_rect.map_or(open_max, |r| r.max),
922                edge_feather: feather,
923                pad_a: 0.0,
924                pad_b: Vec2::ZERO,
925                // Content quads never round: the capture already holds the
926                // node's own rounded paint. Zero radii disable the mask.
927                radius: Vec4::ZERO,
928                box_center: Vec2::ZERO,
929                box_size: Vec2::ZERO,
930            });
931        commands
932            .entity(layer.quad_entity)
933            .insert(LayerCompositeBatch {
934                range: ranges[idx].clone().unwrap(),
935                atlas: atlas_index,
936                uniform_offset,
937            });
938    }
939    // Backdrop quads: the frosted underlay, staged after the content quads so
940    // both share the vertex buffer + bind-group list. Geometry is the
941    // UN-inflated border box (never the outset ring), UVs into the inflated
942    // chain output, alpha = group alpha (a fading panel fades its frost),
943    // identity model + zero feather + CPU clip clamp (the untransformed path
944    // — a backdrop under a 3D-transformed layer stays axis-aligned, the
945    // documented v1 limit). A gated backdrop stays batch-less and draws
946    // nothing — the region shows the real frame, graceful by construction,
947    // and no enclosing invalidation is needed (extraction already forces
948    // enclosing re-capture every frame for backdrop layers).
949    let mut backdrop_ranges: Vec<Option<Range<u32>>> = vec![None; extracted.layers.len()];
950    for (idx, layer) in extracted.layers.iter().enumerate() {
951        let Some(backdrop_quad_entity) = layer.backdrop_quad_entity else {
952            continue;
953        };
954        let Some(slot) = store.slots.get_mut(&layer.main_entity) else {
955            continue;
956        };
957        let Some(backdrop_slot) = slot.backdrop.as_mut() else {
958            continue;
959        };
960        let Some(bind_group) = backdrop::backdrop_gate(
961            idx,
962            layer.main_entity,
963            backdrop_slot,
964            &backdrop_meta,
965            &pipeline_cache,
966            &render_device,
967            &pipeline.atlas_layout,
968            &pipeline.sampler,
969        ) else {
970            continue;
971        };
972        let Some(q) = backdrop::backdrop_quad(layer.min, layer.size, layer.outset, layer.quad_clip)
973        else {
974            continue;
975        };
976        let start = meta.vertices.len() as u32;
977        let (min, max) = (q.pos_min, q.pos_max);
978        let (uv_min, uv_max) = (q.uv_min, q.uv_max);
979        let corners = [
980            ([min.x, min.y, 0.0], [uv_min.x, uv_min.y]),
981            ([max.x, min.y, 0.0], [uv_max.x, uv_min.y]),
982            ([max.x, max.y, 0.0], [uv_max.x, uv_max.y]),
983            ([min.x, min.y, 0.0], [uv_min.x, uv_min.y]),
984            ([max.x, max.y, 0.0], [uv_max.x, uv_max.y]),
985            ([min.x, max.y, 0.0], [uv_min.x, uv_max.y]),
986        ];
987        for (position, uv) in corners {
988            meta.vertices.push(LayerCompositeVertex {
989                position,
990                uv,
991                alpha: layer.alpha,
992            });
993        }
994        backdrop_ranges[idx] = Some(start..start + 6);
995        let atlas_index = meta.atlas_bind_groups.len();
996        meta.atlas_bind_groups.push(bind_group);
997        // The UNCLIPPED border box (the same shrink `backdrop_quad` applies)
998        // for the rounded-corner mask: the CPU clip may have clamped the
999        // quad's geometry above, but the SDF must measure the true box.
1000        let box_min = layer.min + Vec2::splat(layer.outset as f32);
1001        let box_max = layer.min + layer.size.as_vec2() - Vec2::splat(layer.outset as f32);
1002        let (open_min, open_max) = transform3d::open_clip();
1003        let uniform_offset = uniforms_meta
1004            .uniforms
1005            .push(&transform3d::CompositeUniforms {
1006                model: Mat4::IDENTITY,
1007                clip_min: open_min,
1008                clip_max: open_max,
1009                edge_feather: 0.0,
1010                pad_a: 0.0,
1011                pad_b: Vec2::ZERO,
1012                // Frost is masked to the node's rounded border box; the radii
1013                // are the layout-resolved ones bevy_ui paints with, so the
1014                // frost edge coincides with the panel's own rounded edge.
1015                radius: Vec4::from(layer.corner_radius),
1016                box_center: (box_min + box_max) * 0.5,
1017                box_size: box_max - box_min,
1018            });
1019        commands
1020            .entity(backdrop_quad_entity)
1021            .insert(LayerCompositeBatch {
1022                range: backdrop_ranges[idx].clone().unwrap(),
1023                atlas: atlas_index,
1024                uniform_offset,
1025            });
1026    }
1027    // A gated quad drew nothing into its enclosing captures this frame, yet
1028    // those captures' `content_valid` was predicted from pipeline readiness
1029    // alone — an outer capture with a hole where the filtered subtree belongs
1030    // could otherwise be frozen as "valid". Force the enclosing chain to
1031    // re-capture until the filtered output exists.
1032    for idx in gated {
1033        walk_enclosing(idx, &extracted.enclosing, |outer| {
1034            if let Some(slot) = store.slots.get_mut(&extracted.layers[outer].main_entity) {
1035                slot.content_valid = false;
1036            }
1037            true
1038        });
1039    }
1040    meta.vertices.write_buffer(&render_device, &render_queue);
1041    // Composite uniforms: write, then bind the (possibly fresh) buffer — one
1042    // whole-buffer bind group, per-quad entries selected by dynamic offset.
1043    uniforms_meta
1044        .uniforms
1045        .write_buffer(&render_device, &render_queue);
1046    uniforms_meta.bind_group = uniforms_meta.uniforms.binding().map(|binding| {
1047        render_device.create_bind_group(
1048            "ui_layer_composite_uniforms",
1049            &pipeline_cache.get_bind_group_layout(&pipeline.uniform_layout),
1050            &BindGroupEntries::single(binding),
1051        )
1052    });
1053
1054    // Mark the injected quads drawable (post-sort, pre-draw). A phase item's
1055    // `batch_range` is an *item-skip count* — `SortedRenderPhase::render`
1056    // advances by `len()` and skips empty ranges entirely — so a standalone
1057    // quad is exactly `0..1`; its vertex range rides `LayerCompositeBatch`.
1058    for phase in phases.values_mut() {
1059        for item in phase.items.values_mut() {
1060            let drawable = extracted
1061                .layers
1062                .iter()
1063                .position(|l| l.quad_entity == item.entity())
1064                .is_some_and(|idx| ranges[idx].is_some())
1065                || extracted
1066                    .layers
1067                    .iter()
1068                    .position(|l| l.backdrop_quad_entity == Some(item.entity()))
1069                    .is_some_and(|idx| backdrop_ranges[idx].is_some());
1070            if drawable {
1071                item.batch_range = 0..1;
1072            }
1073        }
1074    }
1075}
1076
1077/// The composite pipeline: group 0 is the stock UI view uniform (so
1078/// [`SetUiViewBindGroup`] is reused verbatim), group 1 the capture texture.
1079/// Blending is **premultiplied** (`One`/`OneMinusSrcAlpha`): capture content
1080/// is premultiplied by construction (straight-alpha blending onto transparent
1081/// black), and the shader multiplies rgb *and* alpha by the group alpha.
1082#[derive(Resource)]
1083pub struct LayerCompositePipeline {
1084    pub view_layout: BindGroupLayoutDescriptor,
1085    pub atlas_layout: BindGroupLayoutDescriptor,
1086    /// Group 2: the per-quad [`transform3d::CompositeUniforms`] (dynamic
1087    /// offset) — 3D model matrix + fragment clip rect.
1088    pub uniform_layout: BindGroupLayoutDescriptor,
1089    pub sampler: Sampler,
1090    /// Trilinear + anisotropic sampler for non-identity 3D-transformed quads
1091    /// over a valid mip chain (see [`mips`]) — tilting minifies the capture,
1092    /// where bilinear-over-level-0 shimmers. Same layout slot as
1093    /// [`Self::sampler`] (any Filtering sampler fits), selected per quad via
1094    /// the variant bind groups.
1095    pub sampler_mips: Sampler,
1096    pub shader: Handle<Shader>,
1097}
1098
1099pub fn init_layer_composite_pipeline(
1100    mut commands: Commands,
1101    render_device: Res<RenderDevice>,
1102    asset_server: Res<AssetServer>,
1103) {
1104    let view_layout = BindGroupLayoutDescriptor::new(
1105        "ui_layer_composite_view_layout",
1106        &BindGroupLayoutEntries::single(
1107            ShaderStages::VERTEX_FRAGMENT,
1108            uniform_buffer::<ViewUniform>(true),
1109        ),
1110    );
1111    let atlas_layout = BindGroupLayoutDescriptor::new(
1112        "ui_layer_composite_atlas_layout",
1113        &BindGroupLayoutEntries::sequential(
1114            ShaderStages::FRAGMENT,
1115            (
1116                texture_2d(TextureSampleType::Float { filterable: true }),
1117                sampler(SamplerBindingType::Filtering),
1118            ),
1119        ),
1120    );
1121    let uniform_layout = BindGroupLayoutDescriptor::new(
1122        "ui_layer_composite_uniform_layout",
1123        &BindGroupLayoutEntries::single(
1124            ShaderStages::VERTEX_FRAGMENT,
1125            uniform_buffer::<transform3d::CompositeUniforms>(true),
1126        ),
1127    );
1128    commands.insert_resource(LayerCompositePipeline {
1129        view_layout,
1130        atlas_layout,
1131        uniform_layout,
1132        sampler: render_device.create_sampler(&SamplerDescriptor {
1133            label: Some("ui_layer_composite_sampler"),
1134            mag_filter: FilterMode::Linear,
1135            min_filter: FilterMode::Linear,
1136            ..Default::default()
1137        }),
1138        // Anisotropy needs no wgpu feature; it requires all three filters
1139        // Linear (which trilinear wants anyway) and a texture that actually
1140        // has a mip chain — the bind-group selection guarantees that.
1141        sampler_mips: render_device.create_sampler(&SamplerDescriptor {
1142            label: Some("ui_layer_composite_sampler_mips"),
1143            mag_filter: FilterMode::Linear,
1144            min_filter: FilterMode::Linear,
1145            mipmap_filter: bevy::render::render_resource::MipmapFilterMode::Linear,
1146            anisotropy_clamp: 8,
1147            ..Default::default()
1148        }),
1149        shader: bevy::asset::load_embedded_asset!(asset_server.as_ref(), "composite.wgsl"),
1150    });
1151}
1152
1153#[derive(Clone, Copy, Hash, PartialEq, Eq)]
1154pub struct LayerCompositePipelineKey {
1155    pub target_format: TextureFormat,
1156}
1157
1158impl SpecializedRenderPipeline for LayerCompositePipeline {
1159    type Key = LayerCompositePipelineKey;
1160
1161    fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor {
1162        let vertex_layout = VertexBufferLayout::from_vertex_formats(
1163            VertexStepMode::Vertex,
1164            vec![
1165                // position
1166                VertexFormat::Float32x3,
1167                // uv
1168                VertexFormat::Float32x2,
1169                // alpha
1170                VertexFormat::Float32,
1171            ],
1172        );
1173        RenderPipelineDescriptor {
1174            vertex: VertexState {
1175                shader: self.shader.clone(),
1176                buffers: vec![vertex_layout],
1177                ..Default::default()
1178            },
1179            fragment: Some(FragmentState {
1180                shader: self.shader.clone(),
1181                targets: vec![Some(ColorTargetState {
1182                    format: key.target_format,
1183                    blend: Some(BlendState {
1184                        color: BlendComponent {
1185                            src_factor: BlendFactor::One,
1186                            dst_factor: BlendFactor::OneMinusSrcAlpha,
1187                            operation: BlendOperation::Add,
1188                        },
1189                        alpha: BlendComponent {
1190                            src_factor: BlendFactor::One,
1191                            dst_factor: BlendFactor::OneMinusSrcAlpha,
1192                            operation: BlendOperation::Add,
1193                        },
1194                    }),
1195                    write_mask: ColorWrites::ALL,
1196                })],
1197                ..Default::default()
1198            }),
1199            layout: vec![
1200                self.view_layout.clone(),
1201                self.atlas_layout.clone(),
1202                self.uniform_layout.clone(),
1203            ],
1204            label: Some("ui_layer_composite_pipeline".into()),
1205            ..Default::default()
1206        }
1207    }
1208}
1209
1210pub struct SetLayerAtlasBindGroup<const I: usize>;
1211impl<P: PhaseItem, const I: usize> RenderCommand<P> for SetLayerAtlasBindGroup<I> {
1212    type Param = SRes<LayerCompositeMeta>;
1213    type ViewQuery = ();
1214    type ItemQuery = bevy::ecs::system::lifetimeless::Read<LayerCompositeBatch>;
1215
1216    #[inline]
1217    fn render<'w>(
1218        _item: &P,
1219        _view: (),
1220        batch: Option<&'w LayerCompositeBatch>,
1221        meta: SystemParamItem<'w, '_, Self::Param>,
1222        pass: &mut TrackedRenderPass<'w>,
1223    ) -> RenderCommandResult {
1224        let Some(batch) = batch else {
1225            return RenderCommandResult::Skip;
1226        };
1227        let Some(bind_group) = meta.into_inner().atlas_bind_groups.get(batch.atlas) else {
1228            return RenderCommandResult::Failure("layer atlas bind group missing");
1229        };
1230        pass.set_bind_group(I, bind_group, &[]);
1231        RenderCommandResult::Success
1232    }
1233}
1234
1235pub struct DrawLayerQuad;
1236impl<P: PhaseItem> RenderCommand<P> for DrawLayerQuad {
1237    type Param = SRes<LayerCompositeMeta>;
1238    type ViewQuery = ();
1239    type ItemQuery = bevy::ecs::system::lifetimeless::Read<LayerCompositeBatch>;
1240
1241    #[inline]
1242    fn render<'w>(
1243        _item: &P,
1244        _view: (),
1245        batch: Option<&'w LayerCompositeBatch>,
1246        meta: SystemParamItem<'w, '_, Self::Param>,
1247        pass: &mut TrackedRenderPass<'w>,
1248    ) -> RenderCommandResult {
1249        let Some(batch) = batch else {
1250            return RenderCommandResult::Skip;
1251        };
1252        let Some(vertices) = meta.into_inner().vertices.buffer() else {
1253            return RenderCommandResult::Failure("layer composite vertices missing");
1254        };
1255        pass.set_vertex_buffer(0, vertices.slice(..));
1256        pass.draw(batch.range.clone(), 0..1);
1257        RenderCommandResult::Success
1258    }
1259}
1260
1261/// The composite quad's draw stack — view uniform reuse means the quad rides
1262/// whatever view its phase belongs to (screen, or an outer layer's capture).
1263pub type DrawLayerComposite = (
1264    SetItemPipeline,
1265    SetUiViewBindGroup<0>,
1266    SetLayerAtlasBindGroup<1>,
1267    transform3d::SetCompositeUniforms<2>,
1268    DrawLayerQuad,
1269);
1270
1271/// The Rust mirror of the prelude's `FilterUniforms`
1272/// (`layer/filter_prelude.wgsl`) — one entry per staged filter pass in
1273/// [`LayerFilterMeta::uniforms`]. The explicit `pad` fields reproduce the
1274/// WGSL uniform-address-space layout byte for byte (160 bytes total; asserted
1275/// by `filter_uniforms_match_the_documented_wgsl_layout`). The digit-free
1276/// `pad_a`/`pad_b` names are load-bearing on the WGSL side: naga's namer
1277/// appends `_` to identifiers ending in a digit, which naga_oil rejects in
1278/// composable modules — and the mirror matches field for field.
1279#[derive(Clone, Copy, ShaderType)]
1280pub struct FilterUniforms {
1281    /// Seconds since startup (render-world `Time`), for `USES_TIME` filters.
1282    pub time: f32,
1283    pub pad_a: f32,
1284    /// The pass target's size in physical px.
1285    pub resolution: Vec2,
1286    /// `1.0 / resolution`: one texel step in UV.
1287    pub texel_size: Vec2,
1288    pub pad_b: Vec2,
1289    /// The packed filter params ([`ExtractedFilterPass::params`]).
1290    pub params: [Vec4; MAX_FILTER_PARAM_VECS],
1291}
1292
1293/// The filter-pass pipeline: ONE bind group layout for every filter — group 0
1294/// is the source texture (the capture, or the previous pass's ping-pong
1295/// output), a linear clamp-to-edge sampler, one dynamically-offset
1296/// [`FilterUniforms`], and the layer's original capture (always bound, so any
1297/// pass can sample the unfiltered input) — plus the prelude shader, which is
1298/// the **vertex stage of every filter pipeline**.
1299///
1300/// Split-stage design: the vertex entry (`vertex`, a fullscreen triangle)
1301/// lives in the prelude module, the fragment entry (`fragment`) in each pass
1302/// shader that `#import`s the prelude for bindings/helpers. naga_oil does not
1303/// re-export an import's entry points into the composed module, so the pass
1304/// shaders genuinely have no vertex entry — the pipeline descriptor names two
1305/// different shader handles, which wgpu supports (per-stage modules; the
1306/// cross-stage interface is the prelude's `FullscreenVertexOutput`).
1307/// Validated at runtime by the executing filter passes (module-doc spike
1308/// checklist); the documented fallback if a Bevy upgrade breaks it is a tiny
1309/// per-shader `@vertex` delegating to a prelude helper.
1310#[derive(Resource)]
1311pub struct LayerFilterPipeline {
1312    pub layout: BindGroupLayoutDescriptor,
1313    pub sampler: Sampler,
1314    /// `layer/filter_prelude.wgsl` — registered with `load_shader_library!`,
1315    /// which also embeds it as a loadable asset, so a plain handle to it
1316    /// works as a pipeline stage.
1317    pub prelude: Handle<Shader>,
1318}
1319
1320pub fn init_layer_filter_pipeline(
1321    mut commands: Commands,
1322    render_device: Res<RenderDevice>,
1323    asset_server: Res<AssetServer>,
1324) {
1325    let layout = BindGroupLayoutDescriptor::new(
1326        "ui_layer_filter_layout",
1327        &BindGroupLayoutEntries::sequential(
1328            ShaderStages::FRAGMENT,
1329            (
1330                texture_2d(TextureSampleType::Float { filterable: true }),
1331                sampler(SamplerBindingType::Filtering),
1332                // `uniform_buffer::<T>` sets `min_binding_size` from
1333                // `T::min_size()` — the 160-byte contract.
1334                uniform_buffer::<FilterUniforms>(true),
1335                // The layer's original capture (prelude `capture_texture`).
1336                texture_2d(TextureSampleType::Float { filterable: true }),
1337            ),
1338        ),
1339    );
1340    commands.insert_resource(LayerFilterPipeline {
1341        layout,
1342        sampler: render_device.create_sampler(&SamplerDescriptor {
1343            label: Some("ui_layer_filter_sampler"),
1344            address_mode_u: AddressMode::ClampToEdge,
1345            address_mode_v: AddressMode::ClampToEdge,
1346            mag_filter: FilterMode::Linear,
1347            min_filter: FilterMode::Linear,
1348            ..Default::default()
1349        }),
1350        prelude: bevy::asset::load_embedded_asset!(asset_server.as_ref(), "filter_prelude.wgsl"),
1351    });
1352}
1353
1354/// Specialization key: the pass's fragment shader plus the target format
1355/// (filter targets ride the capture's format). `Handle<Shader>` hashes by
1356/// asset id, so it works as a key directly.
1357#[derive(Clone, Hash, PartialEq, Eq)]
1358pub struct LayerFilterPipelineKey {
1359    pub shader: Handle<Shader>,
1360    pub target_format: TextureFormat,
1361}
1362
1363impl SpecializedRenderPipeline for LayerFilterPipeline {
1364    type Key = LayerFilterPipelineKey;
1365
1366    fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor {
1367        RenderPipelineDescriptor {
1368            // No vertex buffers: the prelude's fullscreen triangle is
1369            // generated from `vertex_index` alone.
1370            vertex: VertexState {
1371                shader: self.prelude.clone(),
1372                entry_point: Some("vertex".into()),
1373                ..Default::default()
1374            },
1375            fragment: Some(FragmentState {
1376                shader: key.shader,
1377                // `filter` is a WGSL reserved word — the prelude's contract
1378                // names the entry `fragment`.
1379                entry_point: Some("fragment".into()),
1380                targets: vec![Some(ColorTargetState {
1381                    format: key.target_format,
1382                    // Replace-write, no blending: the prelude documents that
1383                    // previous target contents are irrelevant and the
1384                    // fragment's (premultiplied) output lands verbatim.
1385                    blend: None,
1386                    write_mask: ColorWrites::ALL,
1387                })],
1388                ..Default::default()
1389            }),
1390            layout: vec![self.layout.clone()],
1391            label: Some("ui_layer_filter_pipeline".into()),
1392            ..Default::default()
1393        }
1394    }
1395}
1396
1397/// Whether a layer's filter passes must (re-)run this frame: fresh capture
1398/// content, changed params, a time-driven chain, or an output that was never
1399/// completed (startup, realloc, or a run whose execution was skipped).
1400pub const fn needs_filter_run(
1401    needs_capture: bool,
1402    chain_version: u32,
1403    stored_version: u32,
1404    always_dirty: bool,
1405    output_valid: bool,
1406) -> bool {
1407    needs_capture || chain_version != stored_version || always_dirty || !output_valid
1408}
1409
1410/// Walks the enclosing-layer chain upward from `start` (exclusive), calling
1411/// `visit` with each enclosing ancestor's index. Stops when the chain ends
1412/// (`enclosing[cur]` is `None`), when `visit` returns `false`, or after
1413/// [`MAX_LAYER_DEPTH`] ancestors — the shared bounded guard for every
1414/// enclosing-chain traversal (`enclosing` is acyclic by construction, so the
1415/// cap only matters for impossible cycles).
1416fn walk_enclosing(start: usize, enclosing: &[Option<usize>], mut visit: impl FnMut(usize) -> bool) {
1417    let mut cur = start;
1418    for _ in 0..MAX_LAYER_DEPTH {
1419        let Some(outer) = enclosing[cur] else {
1420            break;
1421        };
1422        if !visit(outer) {
1423            break;
1424        }
1425        cur = outer;
1426    }
1427}
1428
1429/// Ping-pong source for pass `i`: `None` = the layer's capture texture
1430/// (pass 0), otherwise the index of the previous pass's target.
1431pub const fn filter_source_index(pass: usize) -> Option<usize> {
1432    if pass == 0 {
1433        None
1434    } else {
1435        Some((pass - 1) % 2)
1436    }
1437}
1438
1439/// Ping-pong target for pass `i`.
1440pub const fn filter_target_index(pass: usize) -> usize {
1441    pass % 2
1442}
1443
1444/// Which ping-pong texture holds the final output of a `len`-pass chain
1445/// (the last pass's target; `len` is at least 1 for any staged run).
1446pub const fn filter_output_index(len: usize) -> usize {
1447    (len.saturating_sub(1)) % 2
1448}
1449
1450/// One staged filter pass, replayed by [`ui_layer_capture_pass`]: set the
1451/// pipeline, bind group 0 at the dynamic offset, render 3 vertices into
1452/// `target`.
1453pub struct LayerFilterPass {
1454    pub pipeline: CachedRenderPipelineId,
1455    pub bind_group: BindGroup,
1456    pub uniform_offset: u32,
1457    pub target: TextureView,
1458}
1459
1460/// A layer's staged filter run this frame.
1461pub struct LayerFilterRun {
1462    pub passes: Vec<LayerFilterPass>,
1463}
1464
1465/// Per-frame filter staging: the uniform buffer (one entry per staged pass)
1466/// and the replay list, index-aligned with [`ExtractedUiLayers::layers`].
1467/// `runs[idx] = None` means "no filter work this frame" — either the layer
1468/// has no chain, or its cached output is still valid (the composite samples
1469/// `FilterSlot.textures[output_index]` either way).
1470#[derive(Resource)]
1471pub struct LayerFilterMeta {
1472    pub uniforms: DynamicUniformBuffer<FilterUniforms>,
1473    pub runs: Vec<Option<LayerFilterRun>>,
1474}
1475
1476impl Default for LayerFilterMeta {
1477    fn default() -> Self {
1478        let mut uniforms = DynamicUniformBuffer::default();
1479        uniforms.set_label(Some("ui_layer_filter_uniforms"));
1480        Self {
1481            uniforms,
1482            runs: Vec::new(),
1483        }
1484    }
1485}
1486
1487/// Stages every resource a layer's filter passes need this frame: pipeline
1488/// specialization, one uniform entry per pass, and per-pass bind groups over
1489/// the capture/ping-pong textures. Execution happens in
1490/// [`ui_layer_capture_pass`], which replays [`LayerFilterMeta::runs`] right
1491/// after each layer's capture; this system also *predicts* that execution
1492/// (phase 3) and writes [`FilterSlot::output_valid`] accordingly, so the
1493/// downstream [`prepare_layer_composites`] gate is same-frame accurate.
1494#[allow(clippy::too_many_arguments)]
1495pub fn prepare_layer_filters(
1496    extracted: Res<ExtractedUiLayers>,
1497    mut store: ResMut<LayerTextureStore>,
1498    pipeline: Option<Res<LayerFilterPipeline>>,
1499    mut specialized: ResMut<SpecializedRenderPipelines<LayerFilterPipeline>>,
1500    pipeline_cache: Res<PipelineCache>,
1501    render_device: Res<RenderDevice>,
1502    render_queue: Res<RenderQueue>,
1503    time: Res<Time>,
1504    mut meta: ResMut<LayerFilterMeta>,
1505) {
1506    let LayerFilterMeta { uniforms, runs } = &mut *meta;
1507    uniforms.clear();
1508    runs.clear();
1509    runs.resize_with(extracted.layers.len(), || None);
1510    let Some(pipeline) = pipeline else {
1511        return;
1512    };
1513
1514    // Phase 1: decide, specialize, and stage uniforms. Bind groups wait for
1515    // phase 2 — they must reference the uniform buffer *after* `write_buffer`
1516    // (which may reallocate it).
1517    struct StagedPass {
1518        pipeline: CachedRenderPipelineId,
1519        uniform_offset: u32,
1520    }
1521    let mut staged: Vec<(usize, Vec<StagedPass>)> = Vec::new();
1522    for (idx, layer) in extracted.layers.iter().enumerate() {
1523        let Some(chain) = &layer.chain else {
1524            continue;
1525        };
1526        let Some(slot) = store.slots.get_mut(&layer.main_entity) else {
1527            continue;
1528        };
1529        // Uniforms describe the pass targets, which share the capture's
1530        // (clamped) size.
1531        let size = slot.size;
1532        let Some(filter) = slot.filter.as_mut() else {
1533            continue;
1534        };
1535        if !needs_filter_run(
1536            layer.needs_capture,
1537            chain.version,
1538            filter.params_version,
1539            chain.always_dirty,
1540            filter.output_valid,
1541        ) {
1542            continue;
1543        }
1544        // The staged run supersedes whatever the output textures hold; phase 3
1545        // below marks the output valid again iff the passes will execute.
1546        // A CHAIN CHANGE (vs a plain retry) also re-arms the stuck-gate warn:
1547        // the edit may swap in different shaders, and their failure deserves
1548        // its own once-per-episode report.
1549        if filter.params_version != chain.version {
1550            filter.gated_frames = 0;
1551            filter.gate_warned = false;
1552        }
1553        filter.params_version = chain.version;
1554        filter.output_valid = false;
1555        // The run rewrites the output's level 0 — its mip chain goes stale
1556        // until `prepare_layer_mips` (ordered after this system) restages it.
1557        filter.mips_valid = false;
1558        filter.output_index = filter_output_index(chain.passes.len());
1559
1560        let resolution = size.as_vec2();
1561        let texel_size = Vec2::ONE / resolution;
1562        let mut passes = Vec::with_capacity(chain.passes.len());
1563        for pass in &chain.passes {
1564            let id = specialized.specialize(
1565                &pipeline_cache,
1566                &pipeline,
1567                LayerFilterPipelineKey {
1568                    shader: pass.shader.clone(),
1569                    target_format: layer.target_format,
1570                },
1571            );
1572            let uniform_offset = uniforms.push(&FilterUniforms {
1573                time: time.elapsed_secs(),
1574                pad_a: 0.0,
1575                resolution,
1576                texel_size,
1577                pad_b: Vec2::ZERO,
1578                params: pass.params,
1579            });
1580            passes.push(StagedPass {
1581                pipeline: id,
1582                uniform_offset,
1583            });
1584        }
1585        staged.push((idx, passes));
1586    }
1587    if staged.is_empty() {
1588        return;
1589    }
1590
1591    // Phase 2: write the uniforms, then build the per-pass bind groups
1592    // against the (possibly fresh) buffer.
1593    uniforms.write_buffer(&render_device, &render_queue);
1594    let Some(uniform_binding) = uniforms.binding() else {
1595        return;
1596    };
1597    let layout = pipeline_cache.get_bind_group_layout(&pipeline.layout);
1598    for (idx, staged_passes) in staged {
1599        let layer = &extracted.layers[idx];
1600        let Some(slot) = store.slots.get(&layer.main_entity) else {
1601            continue;
1602        };
1603        let Some(filter) = slot.filter.as_ref() else {
1604            continue;
1605        };
1606        let passes = staged_passes
1607            .into_iter()
1608            .enumerate()
1609            .map(|(i, pass)| {
1610                let source = match filter_source_index(i) {
1611                    None => &slot.texture.default_view,
1612                    Some(ping) => &filter.textures[ping].default_view,
1613                };
1614                let bind_group = render_device.create_bind_group(
1615                    "ui_layer_filter",
1616                    &layout,
1617                    &BindGroupEntries::sequential((
1618                        source,
1619                        &pipeline.sampler,
1620                        uniform_binding.clone(),
1621                        &slot.texture.default_view,
1622                    )),
1623                );
1624                LayerFilterPass {
1625                    pipeline: pass.pipeline,
1626                    bind_group,
1627                    uniform_offset: pass.uniform_offset,
1628                    target: filter.textures[filter_target_index(i)].default_view.clone(),
1629                }
1630            })
1631            .collect();
1632        runs[idx] = Some(LayerFilterRun { passes });
1633    }
1634
1635    // Phase 3: predict execution and mark outputs valid. Mirrors the
1636    // `content_valid` discipline in `prepare_layer_textures`: a pipeline that
1637    // `get_render_pipeline` resolves *now* is guaranteed to resolve in the
1638    // graph node too (compiled pipelines never regress within a frame), so
1639    // marking valid here is safe — and a still-compiling pipeline (prediction
1640    // false) leaves `output_valid` false, which both gates the composite quad
1641    // (no partial/unfiltered flash) and forces a restage + retry next frame.
1642    // The source capture must be valid too ([`LayerSlot::content_valid`]):
1643    // filtering a blank/partial capture would freeze garbage on screen.
1644    for (idx, run) in runs.iter().enumerate() {
1645        let Some(run) = run else {
1646            continue;
1647        };
1648        let Some(slot) = store.slots.get_mut(&extracted.layers[idx].main_entity) else {
1649            continue;
1650        };
1651        let ready = run
1652            .passes
1653            .iter()
1654            .all(|pass| pipeline_cache.get_render_pipeline(pass.pipeline).is_some());
1655        if ready
1656            && slot.content_valid
1657            && let Some(filter) = slot.filter.as_mut()
1658        {
1659            filter.output_valid = true;
1660            filter.gated_frames = 0;
1661            filter.gate_warned = false;
1662        }
1663    }
1664}
1665
1666/// Renders each layer's synthetic phase into its capture texture, then
1667/// replays the layer's staged filter run (if any) capture → ping-pong
1668/// textures. Runs in the camera's schedule right before the stock `ui_pass`
1669/// consumes the composite quads.
1670#[allow(clippy::too_many_arguments)]
1671pub fn ui_layer_capture_pass(
1672    world: &World,
1673    view: ViewQuery<Entity>,
1674    extracted: Res<ExtractedUiLayers>,
1675    atlases: Res<LayerAtlases>,
1676    phases: Res<ViewSortedRenderPhases<TransparentUi>>,
1677    filter_meta: Res<LayerFilterMeta>,
1678    mip_meta: Res<mips::LayerMipMeta>,
1679    backdrop_meta: Res<backdrop::BackdropMeta>,
1680    blit_pipeline: Option<Res<backdrop::BackdropBlitPipeline>>,
1681    pipeline_cache: Res<PipelineCache>,
1682    mut ctx: RenderContext,
1683) {
1684    if extracted.camera_render_entity != Some(view.into_inner()) {
1685        return;
1686    }
1687    // The camera's CURRENT main texture — post-PostProcess, pre-`ui_pass`:
1688    // the tonemapped 3D frame with no UI on it, the v1 backdrop source.
1689    // Fetched here (not prepare) because the a/b buffer selection flips
1690    // during PostProcess.
1691    let main_texture = backdrop::camera_main_texture(world, extracted.camera_render_entity);
1692    // Innermost first ([`ExtractedUiLayers::capture_order`]): a quad sampling
1693    // layer B's capture (or B's filtered output) must draw — inside some
1694    // outer capture or the screen — only after B's capture *and filter*
1695    // passes ran; passes execute in encoder order, and B's filter replay sits
1696    // in B's loop iteration, before any enclosing layer's capture.
1697    for &idx in &extracted.capture_order {
1698        let layer = &extracted.layers[idx];
1699        // Backdrop first: blit the frame region into the snapshot, then run
1700        // the backdrop chain. Independent of the content capture below (the
1701        // source is the pre-UI frame, static across this whole loop in v1).
1702        if let Some(main_texture) = &main_texture {
1703            backdrop::run_backdrop_passes(
1704                idx,
1705                &backdrop_meta,
1706                blit_pipeline.as_deref(),
1707                main_texture,
1708                &pipeline_cache,
1709                &mut ctx,
1710            );
1711        }
1712        // Capture. Skipped when cached (`!needs_capture`): the persistent
1713        // texture already holds the pixels — and skipping keeps the
1714        // `LoadOp::Clear` from wiping them.
1715        if layer.needs_capture
1716            && let Some(texture) = atlases.textures.get(idx)
1717            && let Some(phase) = phases.get(&layer.retained)
1718            && !phase.items.is_empty()
1719        {
1720            let mut pass = ctx.begin_tracked_render_pass(RenderPassDescriptor {
1721                label: Some("ui_layer_capture"),
1722                color_attachments: &[Some(RenderPassColorAttachment {
1723                    view: &texture.default_view,
1724                    depth_slice: None,
1725                    resolve_target: None,
1726                    ops: Operations {
1727                        load: LoadOp::Clear(LinearRgba::NONE.into()),
1728                        store: StoreOp::Store,
1729                    },
1730                })],
1731                depth_stencil_attachment: None,
1732                timestamp_writes: None,
1733                occlusion_query_set: None,
1734                multiview_mask: None,
1735            });
1736            if let Err(err) = phase.render(&mut pass, world, layer.view_entity) {
1737                bevy::log::error!("layer capture pass failed: {err:?}");
1738            }
1739        }
1740
1741        // Filter replay — also when the capture above was skipped as cached:
1742        // a staged run over a clean capture is a params-only change (slider
1743        // move, time tick) re-filtering last frame's pixels.
1744        if let Some(run) = filter_meta.runs.get(idx).and_then(Option::as_ref) {
1745            // Resolve every pass pipeline up front: a `None` is a
1746            // still-compiling pipeline — abort the whole run, never execute a
1747            // partial chain. `output_valid` was only set by
1748            // `prepare_layer_filters` if all of these resolved back in
1749            // prepare (compiled pipelines don't regress), so an abort here
1750            // means it stayed false: the quad is gated this frame and the
1751            // layer restages + retries next frame.
1752            let pipelines: Option<Vec<_>> = run
1753                .passes
1754                .iter()
1755                .map(|pass| pipeline_cache.get_render_pipeline(pass.pipeline))
1756                .collect();
1757            if let Some(pipelines) = pipelines {
1758                for (pass_data, pipeline) in run.passes.iter().zip(pipelines) {
1759                    let mut pass = ctx.begin_tracked_render_pass(RenderPassDescriptor {
1760                        label: Some("ui_layer_filter"),
1761                        color_attachments: &[Some(RenderPassColorAttachment {
1762                            view: &pass_data.target,
1763                            depth_slice: None,
1764                            resolve_target: None,
1765                            ops: Operations {
1766                                // The fullscreen triangle replace-writes every
1767                                // texel, so `Clear` vs `Load` is
1768                                // content-equivalent; `Clear` skips loading
1769                                // stale contents on tiled GPUs.
1770                                load: LoadOp::Clear(LinearRgba::NONE.into()),
1771                                store: StoreOp::Store,
1772                            },
1773                        })],
1774                        depth_stencil_attachment: None,
1775                        timestamp_writes: None,
1776                        occlusion_query_set: None,
1777                        multiview_mask: None,
1778                    });
1779                    pass.set_render_pipeline(pipeline);
1780                    pass.set_bind_group(0, &pass_data.bind_group, &[pass_data.uniform_offset]);
1781                    pass.draw(0..3, 0..1);
1782                }
1783            }
1784        }
1785
1786        // Mip downsample replay — after capture AND filter, so the chain
1787        // reads this frame's level 0 (of whichever texture the composite
1788        // samples). Staged only when stale (`mips_valid` — a cached capture
1789        // keeps last frame's mips and stages nothing); the pipeline was
1790        // verified compiled at staging, so a `None` here is unreachable-in-
1791        // practice and simply skips.
1792        if let Some(run) = mip_meta.runs.get(idx).and_then(Option::as_ref)
1793            && let Some(pipeline) = pipeline_cache.get_render_pipeline(run.pipeline)
1794        {
1795            for level in &run.levels {
1796                let mut pass = ctx.begin_tracked_render_pass(RenderPassDescriptor {
1797                    label: Some("ui_layer_mip_blit"),
1798                    color_attachments: &[Some(RenderPassColorAttachment {
1799                        view: &level.target,
1800                        depth_slice: None,
1801                        resolve_target: None,
1802                        ops: Operations {
1803                            load: LoadOp::Clear(LinearRgba::NONE.into()),
1804                            store: StoreOp::Store,
1805                        },
1806                    })],
1807                    depth_stencil_attachment: None,
1808                    timestamp_writes: None,
1809                    occlusion_query_set: None,
1810                    multiview_mask: None,
1811                });
1812                pass.set_render_pipeline(pipeline);
1813                pass.set_bind_group(0, &level.bind_group, &[]);
1814                pass.draw(0..3, 0..1);
1815            }
1816        }
1817    }
1818}
1819
1820#[cfg(test)]
1821mod tests {
1822    use super::*;
1823    use bevy::render::render_resource::encase::UniformBuffer;
1824
1825    fn f32_at(bytes: &[u8], offset: usize) -> f32 {
1826        f32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap())
1827    }
1828
1829    /// The Rust mirror must reproduce the prelude's documented 160-byte
1830    /// uniform layout exactly (`layer/filter_prelude.wgsl`): time@0,
1831    /// resolution@8, texel_size@16, params@32 (stride 16), total 160.
1832    #[test]
1833    fn filter_uniforms_match_the_documented_wgsl_layout() {
1834        assert_eq!(FilterUniforms::min_size().get(), 160);
1835
1836        let mut params = [Vec4::ZERO; MAX_FILTER_PARAM_VECS];
1837        params[0] = Vec4::new(1.0, 2.0, 3.0, 4.0);
1838        params[7] = Vec4::new(5.0, 6.0, 7.0, 8.0);
1839        let value = FilterUniforms {
1840            time: 1.5,
1841            pad_a: 0.0,
1842            resolution: Vec2::new(320.0, 240.0),
1843            texel_size: Vec2::new(0.5, 0.25),
1844            pad_b: Vec2::ZERO,
1845            params,
1846        };
1847        let mut buffer = UniformBuffer::new(Vec::<u8>::new());
1848        buffer.write(&value).expect("uniform write");
1849        let bytes = buffer.into_inner();
1850        assert_eq!(bytes.len(), 160);
1851        // Per-field offsets, per the prelude's comment block.
1852        assert_eq!(f32_at(&bytes, 0), 1.5); // time
1853        assert_eq!(f32_at(&bytes, 8), 320.0); // resolution.x
1854        assert_eq!(f32_at(&bytes, 12), 240.0); // resolution.y
1855        assert_eq!(f32_at(&bytes, 16), 0.5); // texel_size.x
1856        assert_eq!(f32_at(&bytes, 20), 0.25); // texel_size.y
1857        assert_eq!(f32_at(&bytes, 32), 1.0); // params[0].x
1858        assert_eq!(f32_at(&bytes, 44), 4.0); // params[0].w
1859        assert_eq!(f32_at(&bytes, 32 + 7 * 16), 5.0); // params[7].x
1860        assert_eq!(f32_at(&bytes, 32 + 7 * 16 + 12), 8.0); // params[7].w
1861    }
1862
1863    /// The re-run decision, exhaustively: any of "capture re-rendered",
1864    /// "params changed", "time-driven", or "output never completed" forces a
1865    /// run; only a fully clean layer skips.
1866    #[test]
1867    fn needs_filter_run_decision_table() {
1868        // (needs_capture, chain_version, stored_version, always_dirty,
1869        //  output_valid) -> expected
1870        let cases = [
1871            // Fully clean: same version, valid output, static chain.
1872            (false, 3, 3, false, true, false),
1873            // Fresh capture content must re-filter.
1874            (true, 3, 3, false, true, true),
1875            // Param change (version bump).
1876            (false, 4, 3, false, true, true),
1877            // Version restart collision guard: a *lower* version differs too.
1878            (false, 1, 3, false, true, true),
1879            // Time-driven chains never settle.
1880            (false, 3, 3, true, true, true),
1881            // Output never completed (startup, realloc, skipped execution).
1882            (false, 3, 3, false, false, true),
1883            // Never staged (params_version 0 vs first real version 1).
1884            (false, 1, 0, false, false, true),
1885        ];
1886        for (capture, chain_v, stored_v, dirty, valid, expected) in cases {
1887            assert_eq!(
1888                needs_filter_run(capture, chain_v, stored_v, dirty, valid),
1889                expected,
1890                "needs_capture={capture} chain={chain_v} stored={stored_v} \
1891                 always_dirty={dirty} output_valid={valid}"
1892            );
1893        }
1894    }
1895
1896    /// Ping-pong plumbing: pass 0 reads the capture and writes texture 0;
1897    /// each later pass reads the previous target and writes the other
1898    /// texture; the final output is the last pass's target.
1899    #[test]
1900    fn filter_ping_pong_indices() {
1901        assert_eq!(filter_source_index(0), None);
1902        assert_eq!(filter_target_index(0), 0);
1903        assert_eq!(filter_source_index(1), Some(0));
1904        assert_eq!(filter_target_index(1), 1);
1905        assert_eq!(filter_source_index(2), Some(1));
1906        assert_eq!(filter_target_index(2), 0);
1907        assert_eq!(filter_source_index(3), Some(0));
1908        assert_eq!(filter_target_index(3), 1);
1909        // Every pass reads what the previous one wrote…
1910        for pass in 1..8 {
1911            assert_eq!(
1912                filter_source_index(pass),
1913                Some(filter_target_index(pass - 1)),
1914                "pass {pass} must read pass {}'s target",
1915                pass - 1
1916            );
1917            // …and never its own target.
1918            assert_ne!(filter_source_index(pass), Some(filter_target_index(pass)));
1919        }
1920        // The chain's output is the last pass's target.
1921        for len in 1..8 {
1922            assert_eq!(filter_output_index(len), filter_target_index(len - 1));
1923        }
1924        assert_eq!(filter_output_index(1), 0);
1925        assert_eq!(filter_output_index(2), 1);
1926        assert_eq!(filter_output_index(3), 0);
1927    }
1928
1929    /// The shared enclosing-chain walk: visits ancestors bottom-up
1930    /// (exclusive of the start), stops at the chain end or the `visit`
1931    /// veto, and never exceeds [`MAX_LAYER_DEPTH`] steps even on a
1932    /// (construction-impossible) cycle.
1933    #[test]
1934    fn walk_enclosing_table() {
1935        let visited = |start: usize, enclosing: &[Option<usize>]| {
1936            let mut seen = Vec::new();
1937            walk_enclosing(start, enclosing, |outer| {
1938                seen.push(outer);
1939                true
1940            });
1941            seen
1942        };
1943
1944        // Simple chain: 2 → 1 → 0 → (root).
1945        let chain = [None, Some(0), Some(1)];
1946        assert_eq!(visited(2, &chain), vec![1, 0]);
1947        assert_eq!(visited(1, &chain), vec![0]);
1948
1949        // `None` stops immediately: a root layer visits nothing.
1950        assert_eq!(visited(0, &chain), Vec::<usize>::new());
1951
1952        // A chain longer than MAX_LAYER_DEPTH truncates at the cap.
1953        let long: Vec<Option<usize>> = (0..MAX_LAYER_DEPTH + 10)
1954            .map(|i| i.checked_sub(1))
1955            .collect();
1956        let seen = visited(long.len() - 1, &long);
1957        assert_eq!(seen.len(), MAX_LAYER_DEPTH);
1958        assert_eq!(seen[0], long.len() - 2);
1959        assert_eq!(seen[MAX_LAYER_DEPTH - 1], long.len() - 1 - MAX_LAYER_DEPTH);
1960
1961        // A self-cycle terminates (bounded), visiting the cycle node
1962        // MAX_LAYER_DEPTH times.
1963        let cycle = [Some(0)];
1964        assert_eq!(visited(0, &cycle), vec![0; MAX_LAYER_DEPTH]);
1965
1966        // A two-node cycle terminates too.
1967        let cycle2 = [Some(1), Some(0)];
1968        assert_eq!(visited(0, &cycle2).len(), MAX_LAYER_DEPTH);
1969
1970        // `visit` returning false stops the walk (the needs_capture
1971        // propagation's "already propagated" early-out).
1972        let mut seen = Vec::new();
1973        walk_enclosing(2, &chain, |outer| {
1974            seen.push(outer);
1975            false
1976        });
1977        assert_eq!(seen, vec![1]);
1978    }
1979
1980    /// The edge-AA inflation grows the quad symmetrically and extends UVs so
1981    /// `uv ∈ [0, 1]` still maps exactly the true rect; a degenerate size
1982    /// doesn't divide by zero.
1983    #[test]
1984    fn inflated_transform_quad_extends_uvs_proportionally() {
1985        let q = inflated_transform_quad(Vec2::new(100.0, 50.0), UVec2::new(200, 100), 1.0);
1986        assert_eq!(q.pos_min, Vec2::new(99.0, 49.0));
1987        assert_eq!(q.pos_max, Vec2::new(301.0, 151.0));
1988        assert_eq!(q.uv_min, Vec2::new(-1.0 / 200.0, -1.0 / 100.0));
1989        assert_eq!(q.uv_max, Vec2::new(1.0 + 1.0 / 200.0, 1.0 + 1.0 / 100.0));
1990        // uv=0 must still land on the true rect min: interpolating position
1991        // by the uv fraction of the true edge recovers `min`.
1992        let span = q.pos_max - q.pos_min;
1993        let uv_span = q.uv_max - q.uv_min;
1994        let at_uv_zero = q.pos_min + span * (Vec2::ZERO - q.uv_min) / uv_span;
1995        assert!(at_uv_zero.abs_diff_eq(Vec2::new(100.0, 50.0), 1e-4));
1996
1997        let degenerate = inflated_transform_quad(Vec2::ZERO, UVec2::ZERO, 1.0);
1998        assert!(degenerate.uv_min.is_finite());
1999    }
2000}