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 /// Morph state (the frozen snapshot + the blend target), present iff a
76 /// morph is in flight. Unlike [`Self::filter`]/[`Self::backdrop`] it is
77 /// PRESERVED across slot reallocs — the frozen pixels must survive the
78 /// union-rect resize of the freeze frame (see
79 /// [`super::morph::freeze_morph_snapshot`]); cleared when the extracted
80 /// morph disappears.
81 pub morph: Option<super::morph::MorphSlot>,
82 pub last_seen: u64,
83}
84
85/// A layer's persistent filter-pass resources: two same-size ping-pong
86/// textures (pass 0 samples the capture and writes `textures[0]`, pass 1
87/// samples `textures[0]` and writes `textures[1]`, and so on) plus the
88/// bookkeeping that lets a clean chain skip re-running its passes. Allocated
89/// at the capture's size + format; dies with the [`LayerSlot`] on realloc.
90pub struct FilterSlot {
91 /// The ping-pong targets (`RENDER_ATTACHMENT | TEXTURE_BINDING`).
92 pub textures: [CachedTexture; 2],
93 /// The [`ExtractedChain::version`](super::ExtractedChain::version) the
94 /// last staged run used; `0` = never staged (versions start at 1).
95 pub params_version: u32,
96 /// Whether `textures[output_index]` holds a *complete* filter output.
97 /// Staging a run resets it; [`prepare_layer_filters`](super::prepare_layer_filters)
98 /// sets it back only when the whole staged chain is certain to execute
99 /// this frame (every pass pipeline already compiled AND the source
100 /// capture valid — the same conservative discipline as
101 /// [`LayerSlot::content_valid`]). While false,
102 /// [`prepare_layer_composites`](super::prepare_layer_composites) withholds
103 /// the quad's batch (draws nothing — never a flash of unfiltered content)
104 /// and the layer restages every frame until the run goes through.
105 pub output_valid: bool,
106 /// Consecutive frames the composite gate has withheld this layer's quad
107 /// (no complete filtered output to sample); reset to 0 when
108 /// [`Self::output_valid`] flips true. Drives the stuck-gate warning (see
109 /// [`Self::gate_warned`]) — a pipeline that never compiles (user WGSL
110 /// error) would otherwise leave the subtree invisible forever with no
111 /// log from this module.
112 pub gated_frames: u32,
113 /// Whether this stuck episode already warned (once per episode; reset
114 /// with [`Self::gated_frames`]). An errored pass pipeline warns
115 /// immediately with the compile error; a still-compiling one only after
116 /// [`STUCK_GATE_HANG_FRAMES`](super::STUCK_GATE_HANG_FRAMES).
117 pub gate_warned: bool,
118 /// Which ping-pong texture the final pass writes: `(len - 1) % 2`.
119 pub output_index: usize,
120 /// Composite bind group sampling `textures[.0]` — built by
121 /// `prepare_layer_composites`' filter retarget, kept until realloc like
122 /// [`LayerSlot::bind_group`]; the stored index invalidates it when
123 /// `output_index` flips (pass-count parity change).
124 pub composite_bind_group: Option<(usize, BindGroup)>,
125 /// Mip-chain views per ping-pong (either can be the output on pass-count
126 /// parity flips), present iff the layer wants mips. The composite samples
127 /// the *filter output*, so for a filtered layer the mips live here, not
128 /// on the capture.
129 pub mips: [Option<mips::MipChain>; 2],
130 /// Mirrors [`LayerSlot::mips_valid`] for the current output texture;
131 /// reset whenever a filter run is staged.
132 pub mips_valid: bool,
133 /// Trilinear composite bind group over `textures[.0]`'s full-mip view,
134 /// with the same `output_index` invalidation as
135 /// [`Self::composite_bind_group`].
136 pub composite_bind_group_mips: Option<(usize, BindGroup)>,
137}
138
139/// Persistent (cross-frame) capture textures, keyed by layer root — the
140/// resource that makes capture caching possible. Slots are allocated /
141/// reallocated by [`prepare_layer_textures`] and evicted a few frames after
142/// their layer disappears (demote, despawn).
143#[derive(Resource, Default)]
144pub struct LayerTextureStore {
145 pub slots: HashMap<MainEntity, LayerSlot>,
146 pub frame: u64,
147}
148
149/// Maintains the persistent per-layer capture textures (camera target format —
150/// stolen pipelines were specialized against it; sample count 1 — `ui_pass`
151/// renders unsampled): get-or-(re)allocate each live layer's
152/// [`LayerTextureStore`] slot, mirror it into the index-aligned
153/// [`LayerAtlases`], and evict slots whose layer is gone. Also owns the
154/// [`FilterSlot`] lifecycle: ping-pong textures allocated while the layer has
155/// a chain, cleared (with their version bookkeeping — load-bearing, see the
156/// in-body comment) when it doesn't. Deliberately not Bevy's `TextureCache` —
157/// capture caching needs each layer to keep *its own* texture (and its
158/// pixels) across frames.
159pub fn prepare_layer_textures(
160 extracted: Res<ExtractedUiLayers>,
161 render_device: Res<RenderDevice>,
162 pipeline_cache: Res<PipelineCache>,
163 phases: Res<ViewSortedRenderPhases<TransparentUi>>,
164 mut store: ResMut<LayerTextureStore>,
165 mut atlases: ResMut<LayerAtlases>,
166) {
167 atlases.textures.clear();
168 let store = &mut *store;
169 store.frame += 1;
170 let frame = store.frame;
171 for layer in &extracted.layers {
172 let wanted = layer.size.max(UVec2::ONE);
173 let slot = store.slots.entry(layer.main_entity).or_insert_with(|| {
174 alloc_layer_slot(
175 &render_device,
176 wanted,
177 layer.target_format,
178 layer.wants_mips,
179 )
180 });
181 // Morph freeze first: a new `freeze_seq` steals the pixels currently
182 // on screen (the capture, or an interrupted morph's blend) BEFORE
183 // the realloc below would drop them.
184 super::morph::freeze_morph_snapshot(slot, layer, wanted, &render_device);
185 if slot.size != wanted
186 || slot.format != layer.target_format
187 || slot.mips.is_some() != layer.wants_mips
188 {
189 // Resize / format / mip-state flip: fresh texture, and the stale
190 // bind group dies with the slot — as does the filter state
191 // (`filter: None`), which re-allocates at the new size just
192 // below. Extraction already flagged `needs_capture` (its
193 // `cached_ok` mirrors this key). The morph state is the one
194 // survivor: its frozen snapshot must outlive the union-rect
195 // resize (the blend re-allocates below).
196 let morph = slot.morph.take();
197 *slot = alloc_layer_slot(
198 &render_device,
199 wanted,
200 layer.target_format,
201 layer.wants_mips,
202 );
203 slot.morph = morph;
204 }
205 // Post-realloc morph maintenance: clear an ended morph, track the
206 // capture size with the blend target.
207 super::morph::maintain_morph_blend(slot, layer, wanted, &render_device);
208 if layer.chain.is_some() {
209 // Ping-pong textures ride the capture's size + format; a realloc
210 // above reset `filter` to `None`, so this re-allocates them too
211 // (with `output_valid: false` / `params_version: 0` — the staged
212 // run restarts from scratch).
213 if slot.filter.is_none() {
214 slot.filter = Some(alloc_filter_slot(
215 &render_device,
216 wanted,
217 layer.target_format,
218 layer.wants_mips,
219 ));
220 }
221 } else {
222 // No chain this frame: drop the filter state entirely.
223 // Load-bearing, not just cleanup — `ResolvedFilterChain.version`
224 // restarts at 1 per chain lifetime (demote/re-promote, filter
225 // unset/re-set), so a surviving `params_version` could collide
226 // with a restarted version and skip a needed re-run with stale
227 // params.
228 slot.filter = None;
229 }
230 // Backdrop slot: same lifecycle as the filter slot (allocated while
231 // a chain exists, cleared — with its version bookkeeping — when it
232 // doesn't; a realloc above dropped it implicitly).
233 if layer.backdrop_chain.is_some() {
234 if slot.backdrop.is_none() {
235 slot.backdrop = Some(super::backdrop::alloc_backdrop_slot(
236 &render_device,
237 wanted,
238 layer.target_format,
239 ));
240 }
241 } else {
242 slot.backdrop = None;
243 }
244 if layer.needs_capture {
245 // This frame's capture is only servable from cache later if every
246 // item actually renders — a still-compiling pipeline makes
247 // `phase.render` skip its item silently, and freezing that
248 // blank/partial capture would blank the layer on screen for good
249 // (the exact failure mode of capturing during app startup).
250 // Conservative by construction: a pipeline that compiles between
251 // here and the capture pass costs one redundant re-capture.
252 slot.content_valid = phases.get(&layer.retained).is_some_and(|phase| {
253 !phase.items.is_empty()
254 && phase
255 .items
256 .values()
257 .all(|i| pipeline_cache.get_render_pipeline(i.pipeline).is_some())
258 });
259 // The capture rewrites level 0 this frame — its mip chain (if
260 // any) goes stale until `prepare_layer_mips` restages it.
261 slot.mips_valid = false;
262 }
263 slot.last_seen = frame;
264 atlases.textures.push(slot.texture.clone());
265 }
266 // Demoted/despawned layers: keep the slot for a short grace (cheap
267 // re-promotion churn), then free the texture memory.
268 store.slots.retain(|_, slot| slot.last_seen + 3 >= frame);
269}
270
271/// Create a capture-format texture, optionally with a full mip chain. When
272/// mipped, the returned `default_view` is **base-mip-only** (see
273/// [`LayerSlot::texture`] for why that keeps every consumer valid) and the
274/// per-level + full views come back as a [`mips::MipChain`].
275pub(super) fn alloc_capture_texture(
276 render_device: &RenderDevice,
277 label: &'static str,
278 size: UVec2,
279 format: TextureFormat,
280 mipped: bool,
281) -> (CachedTexture, Option<mips::MipChain>) {
282 let levels = if mipped {
283 mips::mip_level_count(size)
284 } else {
285 1
286 };
287 let texture = render_device.create_texture(&TextureDescriptor {
288 label: Some(label),
289 size: Extent3d {
290 width: size.x,
291 height: size.y,
292 depth_or_array_layers: 1,
293 },
294 mip_level_count: levels,
295 sample_count: 1,
296 dimension: TextureDimension::D2,
297 format,
298 usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
299 view_formats: &[],
300 });
301 let default_view = texture.create_view(&TextureViewDescriptor {
302 mip_level_count: Some(1),
303 ..Default::default()
304 });
305 let chain = mipped.then(|| mips::build_mip_chain(&texture, levels));
306 (
307 CachedTexture {
308 texture,
309 default_view,
310 },
311 chain,
312 )
313}
314
315fn alloc_layer_slot(
316 render_device: &RenderDevice,
317 size: UVec2,
318 format: TextureFormat,
319 mipped: bool,
320) -> LayerSlot {
321 let (texture, mips) =
322 alloc_capture_texture(render_device, "ui_layer_capture", size, format, mipped);
323 LayerSlot {
324 texture,
325 size,
326 format,
327 bind_group: None,
328 mips,
329 mips_valid: false,
330 bind_group_mips: None,
331 content_valid: false,
332 filter: None,
333 backdrop: None,
334 morph: None,
335 last_seen: 0,
336 }
337}
338
339/// Allocate a layer's two filter ping-pong textures at the capture's size and
340/// format (same-size passes — the prelude documents `uv` as a 1:1 lookup; the
341/// capture format keeps every pass target compatible with the composite).
342fn alloc_filter_slot(
343 render_device: &RenderDevice,
344 size: UVec2,
345 format: TextureFormat,
346 mipped: bool,
347) -> FilterSlot {
348 // Both ping-pongs get the chain when mipped: either can be the final
349 // output on a pass-count parity flip, and that output is what the
350 // transformed composite samples trilinearly.
351 let alloc =
352 |label: &'static str| alloc_capture_texture(render_device, label, size, format, mipped);
353 let (ping, ping_mips) = alloc("ui_layer_filter_ping");
354 let (pong, pong_mips) = alloc("ui_layer_filter_pong");
355 FilterSlot {
356 textures: [ping, pong],
357 params_version: 0,
358 output_valid: false,
359 gated_frames: 0,
360 gate_warned: false,
361 output_index: 0,
362 composite_bind_group: None,
363 mips: [ping_mips, pong_mips],
364 mips_valid: false,
365 composite_bind_group_mips: None,
366 }
367}