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