bevy_react/layer/render/store.rs
1//! The persistent capture-texture store — the resource that makes layer
2//! capture caching possible. Split from `render.rs` (which stays the pass /
3//! composite half): slot structs, allocation, and the per-frame
4//! [`prepare_layer_textures`] maintenance. Everything is re-exported through
5//! `super` so consumers keep their `render::…` paths.
6
7use bevy::platform::collections::HashMap;
8use bevy::prelude::*;
9use bevy::render::render_phase::ViewSortedRenderPhases;
10use bevy::render::render_resource::{
11 BindGroup, Extent3d, PipelineCache, TextureDescriptor, TextureDimension, TextureFormat,
12 TextureUsages, TextureViewDescriptor,
13};
14use bevy::render::renderer::RenderDevice;
15use bevy::render::sync_world::MainEntity;
16use bevy::render::texture::CachedTexture;
17use bevy::ui_render::TransparentUi;
18
19use super::{ExtractedUiLayers, mips};
20
21/// The per-layer offscreen capture textures (spike: one texture per layer;
22/// the planned per-depth shared atlas swaps in behind the same indices).
23/// Index-aligned with [`ExtractedUiLayers::layers`]; entries are clones of the
24/// persistent [`LayerTextureStore`] slots.
25#[derive(Resource, Default)]
26pub struct LayerAtlases {
27 pub textures: Vec<CachedTexture>,
28}
29
30/// One layer's persistent capture texture. Unlike Bevy's `TextureCache`
31/// (descriptor-keyed pool — same-size layers can swap textures between frames,
32/// and nothing pins content), a slot is keyed by the layer root's `MainEntity`,
33/// so a clean layer's texture reliably still holds last frame's capture.
34pub struct LayerSlot {
35 /// The capture texture. For a mipped slot ([`Self::mips`] present) the
36 /// `default_view` is a **base-mip-only** view, so every pre-mips consumer
37 /// stays valid unchanged: the capture attachment (single-mip rule), the
38 /// filter-pass sources (level-0, 1:1 contract), and the bilinear
39 /// composite bind group (can never accidentally sample a stale mip).
40 pub texture: CachedTexture,
41 pub size: UVec2,
42 pub format: TextureFormat,
43 /// Composite bind group, built lazily and kept until realloc (per-frame
44 /// bind-group creation is real cost at hundreds of layers).
45 pub bind_group: Option<BindGroup>,
46 /// Mip-chain views, present iff the layer wants mips (`TRANSFORM3D`
47 /// promotion reason — see [`mips`]); presence joins the realloc key.
48 pub mips: Option<mips::MipChain>,
49 /// Whether the mip chain matches the texture's current level 0. Reset
50 /// whenever a capture is staged; set by [`mips::prepare_layer_mips`] when
51 /// the downsample run is certain to execute. While false the composite
52 /// samples bilinear level 0 (correct, just unmipped).
53 pub mips_valid: bool,
54 /// Trilinear composite bind group (full-mip view + `sampler_mips`), for
55 /// non-identity transformed quads; lazy like [`Self::bind_group`].
56 pub bind_group_mips: Option<BindGroup>,
57 /// Whether the texture holds a *complete* capture. A capture that runs
58 /// while any of its items' pipelines are still compiling renders those
59 /// items as nothing (`phase.render` skips them silently) — serving that
60 /// from cache would freeze a blank/partial layer on screen. Only a
61 /// capture whose pipelines were all ready marks the content valid;
62 /// until then extraction keeps re-capturing.
63 pub content_valid: bool,
64 /// Filter-pass state, present iff the layer had a chain last frame.
65 /// **Cleared whenever the chain disappears** ([`prepare_layer_textures`]):
66 /// [`ResolvedFilterChain::version`](crate::filters::ResolvedFilterChain)
67 /// restarts at 1 per chain lifetime (demote/re-promote), so a stale
68 /// `params_version` surviving the chain's absence could collide with a
69 /// restarted version and skip a needed re-run with old params.
70 pub filter: Option<FilterSlot>,
71 /// Backdrop state (snapshot + its ping-pong pair), present iff the layer
72 /// had a `backdropFilter` chain last frame. Same clear-on-absence rule
73 /// as [`Self::filter`], for the same version-restart reason.
74 pub backdrop: Option<super::backdrop::BackdropSlot>,
75 pub last_seen: u64,
76}
77
78/// A layer's persistent filter-pass resources: two same-size ping-pong
79/// textures (pass 0 samples the capture and writes `textures[0]`, pass 1
80/// samples `textures[0]` and writes `textures[1]`, and so on) plus the
81/// bookkeeping that lets a clean chain skip re-running its passes. Allocated
82/// at the capture's size + format; dies with the [`LayerSlot`] on realloc.
83pub struct FilterSlot {
84 /// The ping-pong targets (`RENDER_ATTACHMENT | TEXTURE_BINDING`).
85 pub textures: [CachedTexture; 2],
86 /// The [`ExtractedChain::version`](super::ExtractedChain::version) the
87 /// last staged run used; `0` = never staged (versions start at 1).
88 pub params_version: u32,
89 /// Whether `textures[output_index]` holds a *complete* filter output.
90 /// Staging a run resets it; [`prepare_layer_filters`](super::prepare_layer_filters)
91 /// sets it back only when the whole staged chain is certain to execute
92 /// this frame (every pass pipeline already compiled AND the source
93 /// capture valid — the same conservative discipline as
94 /// [`LayerSlot::content_valid`]). While false,
95 /// [`prepare_layer_composites`](super::prepare_layer_composites) withholds
96 /// the quad's batch (draws nothing — never a flash of unfiltered content)
97 /// and the layer restages every frame until the run goes through.
98 pub output_valid: bool,
99 /// Consecutive frames the composite gate has withheld this layer's quad
100 /// (no complete filtered output to sample); reset to 0 when
101 /// [`Self::output_valid`] flips true. Drives the stuck-gate warning (see
102 /// [`Self::gate_warned`]) — a pipeline that never compiles (user WGSL
103 /// error) would otherwise leave the subtree invisible forever with no
104 /// log from this module.
105 pub gated_frames: u32,
106 /// Whether this stuck episode already warned (once per episode; reset
107 /// with [`Self::gated_frames`]). An errored pass pipeline warns
108 /// immediately with the compile error; a still-compiling one only after
109 /// [`STUCK_GATE_HANG_FRAMES`](super::STUCK_GATE_HANG_FRAMES).
110 pub gate_warned: bool,
111 /// Which ping-pong texture the final pass writes: `(len - 1) % 2`.
112 pub output_index: usize,
113 /// Composite bind group sampling `textures[.0]` — built by
114 /// `prepare_layer_composites`' filter retarget, kept until realloc like
115 /// [`LayerSlot::bind_group`]; the stored index invalidates it when
116 /// `output_index` flips (pass-count parity change).
117 pub composite_bind_group: Option<(usize, BindGroup)>,
118 /// Mip-chain views per ping-pong (either can be the output on pass-count
119 /// parity flips), present iff the layer wants mips. The composite samples
120 /// the *filter output*, so for a filtered layer the mips live here, not
121 /// on the capture.
122 pub mips: [Option<mips::MipChain>; 2],
123 /// Mirrors [`LayerSlot::mips_valid`] for the current output texture;
124 /// reset whenever a filter run is staged.
125 pub mips_valid: bool,
126 /// Trilinear composite bind group over `textures[.0]`'s full-mip view,
127 /// with the same `output_index` invalidation as
128 /// [`Self::composite_bind_group`].
129 pub composite_bind_group_mips: Option<(usize, BindGroup)>,
130}
131
132/// Persistent (cross-frame) capture textures, keyed by layer root — the
133/// resource that makes capture caching possible. Slots are allocated /
134/// reallocated by [`prepare_layer_textures`] and evicted a few frames after
135/// their layer disappears (demote, despawn).
136#[derive(Resource, Default)]
137pub struct LayerTextureStore {
138 pub slots: HashMap<MainEntity, LayerSlot>,
139 pub frame: u64,
140}
141
142/// Maintains the persistent per-layer capture textures (camera target format —
143/// stolen pipelines were specialized against it; sample count 1 — `ui_pass`
144/// renders unsampled): get-or-(re)allocate each live layer's
145/// [`LayerTextureStore`] slot, mirror it into the index-aligned
146/// [`LayerAtlases`], and evict slots whose layer is gone. Also owns the
147/// [`FilterSlot`] lifecycle: ping-pong textures allocated while the layer has
148/// a chain, cleared (with their version bookkeeping — load-bearing, see the
149/// in-body comment) when it doesn't. Deliberately not Bevy's `TextureCache` —
150/// capture caching needs each layer to keep *its own* texture (and its
151/// pixels) across frames.
152pub fn prepare_layer_textures(
153 extracted: Res<ExtractedUiLayers>,
154 render_device: Res<RenderDevice>,
155 pipeline_cache: Res<PipelineCache>,
156 phases: Res<ViewSortedRenderPhases<TransparentUi>>,
157 mut store: ResMut<LayerTextureStore>,
158 mut atlases: ResMut<LayerAtlases>,
159) {
160 atlases.textures.clear();
161 let store = &mut *store;
162 store.frame += 1;
163 let frame = store.frame;
164 for layer in &extracted.layers {
165 let wanted = layer.size.max(UVec2::ONE);
166 let slot = store.slots.entry(layer.main_entity).or_insert_with(|| {
167 alloc_layer_slot(
168 &render_device,
169 wanted,
170 layer.target_format,
171 layer.wants_mips,
172 )
173 });
174 if slot.size != wanted
175 || slot.format != layer.target_format
176 || slot.mips.is_some() != layer.wants_mips
177 {
178 // Resize / format / mip-state flip: fresh texture, and the stale
179 // bind group dies with the slot — as does the filter state
180 // (`filter: None`), which re-allocates at the new size just
181 // below. Extraction already flagged `needs_capture` (its
182 // `cached_ok` mirrors this key).
183 *slot = alloc_layer_slot(
184 &render_device,
185 wanted,
186 layer.target_format,
187 layer.wants_mips,
188 );
189 }
190 if layer.chain.is_some() {
191 // Ping-pong textures ride the capture's size + format; a realloc
192 // above reset `filter` to `None`, so this re-allocates them too
193 // (with `output_valid: false` / `params_version: 0` — the staged
194 // run restarts from scratch).
195 if slot.filter.is_none() {
196 slot.filter = Some(alloc_filter_slot(
197 &render_device,
198 wanted,
199 layer.target_format,
200 layer.wants_mips,
201 ));
202 }
203 } else {
204 // No chain this frame: drop the filter state entirely.
205 // Load-bearing, not just cleanup — `ResolvedFilterChain.version`
206 // restarts at 1 per chain lifetime (demote/re-promote, filter
207 // unset/re-set), so a surviving `params_version` could collide
208 // with a restarted version and skip a needed re-run with stale
209 // params.
210 slot.filter = None;
211 }
212 // Backdrop slot: same lifecycle as the filter slot (allocated while
213 // a chain exists, cleared — with its version bookkeeping — when it
214 // doesn't; a realloc above dropped it implicitly).
215 if layer.backdrop_chain.is_some() {
216 if slot.backdrop.is_none() {
217 slot.backdrop = Some(super::backdrop::alloc_backdrop_slot(
218 &render_device,
219 wanted,
220 layer.target_format,
221 ));
222 }
223 } else {
224 slot.backdrop = None;
225 }
226 if layer.needs_capture {
227 // This frame's capture is only servable from cache later if every
228 // item actually renders — a still-compiling pipeline makes
229 // `phase.render` skip its item silently, and freezing that
230 // blank/partial capture would blank the layer on screen for good
231 // (the exact failure mode of capturing during app startup).
232 // Conservative by construction: a pipeline that compiles between
233 // here and the capture pass costs one redundant re-capture.
234 slot.content_valid = phases.get(&layer.retained).is_some_and(|phase| {
235 !phase.items.is_empty()
236 && phase
237 .items
238 .values()
239 .all(|i| pipeline_cache.get_render_pipeline(i.pipeline).is_some())
240 });
241 // The capture rewrites level 0 this frame — its mip chain (if
242 // any) goes stale until `prepare_layer_mips` restages it.
243 slot.mips_valid = false;
244 }
245 slot.last_seen = frame;
246 atlases.textures.push(slot.texture.clone());
247 }
248 // Demoted/despawned layers: keep the slot for a short grace (cheap
249 // re-promotion churn), then free the texture memory.
250 store.slots.retain(|_, slot| slot.last_seen + 3 >= frame);
251}
252
253/// Create a capture-format texture, optionally with a full mip chain. When
254/// mipped, the returned `default_view` is **base-mip-only** (see
255/// [`LayerSlot::texture`] for why that keeps every consumer valid) and the
256/// per-level + full views come back as a [`mips::MipChain`].
257pub(super) fn alloc_capture_texture(
258 render_device: &RenderDevice,
259 label: &'static str,
260 size: UVec2,
261 format: TextureFormat,
262 mipped: bool,
263) -> (CachedTexture, Option<mips::MipChain>) {
264 let levels = if mipped {
265 mips::mip_level_count(size)
266 } else {
267 1
268 };
269 let texture = render_device.create_texture(&TextureDescriptor {
270 label: Some(label),
271 size: Extent3d {
272 width: size.x,
273 height: size.y,
274 depth_or_array_layers: 1,
275 },
276 mip_level_count: levels,
277 sample_count: 1,
278 dimension: TextureDimension::D2,
279 format,
280 usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
281 view_formats: &[],
282 });
283 let default_view = texture.create_view(&TextureViewDescriptor {
284 mip_level_count: Some(1),
285 ..Default::default()
286 });
287 let chain = mipped.then(|| mips::build_mip_chain(&texture, levels));
288 (
289 CachedTexture {
290 texture,
291 default_view,
292 },
293 chain,
294 )
295}
296
297fn alloc_layer_slot(
298 render_device: &RenderDevice,
299 size: UVec2,
300 format: TextureFormat,
301 mipped: bool,
302) -> LayerSlot {
303 let (texture, mips) =
304 alloc_capture_texture(render_device, "ui_layer_capture", size, format, mipped);
305 LayerSlot {
306 texture,
307 size,
308 format,
309 bind_group: None,
310 mips,
311 mips_valid: false,
312 bind_group_mips: None,
313 content_valid: false,
314 filter: None,
315 backdrop: None,
316 last_seen: 0,
317 }
318}
319
320/// Allocate a layer's two filter ping-pong textures at the capture's size and
321/// format (same-size passes — the prelude documents `uv` as a 1:1 lookup; the
322/// capture format keeps every pass target compatible with the composite).
323fn alloc_filter_slot(
324 render_device: &RenderDevice,
325 size: UVec2,
326 format: TextureFormat,
327 mipped: bool,
328) -> FilterSlot {
329 // Both ping-pongs get the chain when mipped: either can be the final
330 // output on a pass-count parity flip, and that output is what the
331 // transformed composite samples trilinearly.
332 let alloc =
333 |label: &'static str| alloc_capture_texture(render_device, label, size, format, mipped);
334 let (ping, ping_mips) = alloc("ui_layer_filter_ping");
335 let (pong, pong_mips) = alloc("ui_layer_filter_pong");
336 FilterSlot {
337 textures: [ping, pong],
338 params_version: 0,
339 output_valid: false,
340 gated_frames: 0,
341 gate_warned: false,
342 output_index: 0,
343 composite_bind_group: None,
344 mips: [ping_mips, pong_mips],
345 mips_valid: false,
346 composite_bind_group_mips: None,
347 }
348}