Skip to main content

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}