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    /// 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}