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}