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