Skip to main content

bevy_react/layer/render/
morph.rs

1//! Render-world half of `morphFilter`: the freeze (stealing the layer's
2//! on-screen pixels as the "from" snapshot), the per-frame blend pass, and
3//! the morph composite gate.
4//!
5//! Per frame, for each layer with an extracted morph
6//! ([`ExtractedMorph`] — an active [`crate::filters::MorphState`] plus a
7//! resolved single-pass chain):
8//!
9//! 1. [`freeze_morph_snapshot`] + [`maintain_morph_blend`] (called from
10//!    `prepare_layer_textures`, bracketing its realloc) maintain the
11//!    [`MorphSlot`]. A new `freeze_seq` **steals** the pixels currently on
12//!    screen — the previous blend (an interrupted morph's in-flight mix) or
13//!    the capture texture itself — as `snapshot`, *before* the realloc that
14//!    a same-frame content/size change would trigger drops them. No
15//!    blit/copy: taking over the texture is free and survives reallocs by
16//!    construction. Nothing valid to freeze (startup) degrades to no
17//!    snapshot — the blend becomes a self-blend of the live capture (a
18//!    visual snap while progress runs, never garbage).
19//! 2. [`prepare_layer_morphs`] (PrepareBindGroups, after
20//!    `prepare_layer_textures`, before `prepare_layer_filters`) stages the
21//!    single blend pass every frame while the morph is in flight (progress
22//!    moves — the uniforms change even when nothing else does), reusing the
23//!    content-filter pipeline wholesale: binding 0 = the live capture (the
24//!    "to"), binding 3 = the snapshot (the "from" — layout-anchored, plain
25//!    0..1 UV stretches it onto the current capture rect), target = the
26//!    dedicated `blend` texture. The engine writes the reserved
27//!    `params[7].x` = progress (see the MORPH CONTRACT in
28//!    `filter_prelude.wgsl`).
29//! 3. [`run_morph_passes`] (called from `ui_layer_capture_pass`, after the
30//!    layer's capture, before its regular filter run) executes the pass.
31//! 4. The regular `filter` chain (if any) sources the blend instead of the
32//!    capture — morph first, then filters; a morph-ONLY layer's composite
33//!    quad samples `blend` directly, gated by [`morph_gate`] with the same
34//!    never-show-partial-content discipline as the content-filter gate.
35//!
36//! The blend target is deliberately NOT a prepended ping-pong pass: with a
37//! ≥2-pass regular chain, pass 2 would overwrite the blend's ping-pong slot,
38//! and the interrupt freeze (which steals the *blended* output) would freeze
39//! a re-filtered image instead. A dedicated texture keeps the regular
40//! chain's indices untouched and the freeze source well-defined.
41
42use bevy::math::{UVec2, Vec2, Vec4};
43use bevy::prelude::*;
44use bevy::render::render_resource::*;
45use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue};
46use bevy::render::texture::CachedTexture;
47use bevy::shader::ShaderCacheError;
48
49use super::store::alloc_capture_texture;
50use super::{
51    ExtractedFilterPass, ExtractedLayer, ExtractedUiLayers, FilterUniforms, LayerFilterPass,
52    LayerFilterPipeline, LayerFilterPipelineKey, LayerFilterRun, LayerSlot, LayerTextureStore,
53    STUCK_GATE_HANG_FRAMES,
54};
55
56/// A layer's morph, as seen by the render world this frame. Present iff the
57/// main-world [`crate::filters::MorphState`] is active AND the resolved
58/// morph chain has exactly one pass (the resolver's cap).
59pub struct ExtractedMorph {
60    /// Mirrors [`crate::filters::MorphState::freeze_seq`]; a value the slot
61    /// hasn't consumed yet triggers the steal in [`freeze_morph_snapshot`].
62    pub freeze_seq: u64,
63    /// The eased blend progress, `0..=1`.
64    pub progress: f32,
65    /// Mirrors the resolved chain's version — re-arms the stuck-gate warn on
66    /// param/shader edits (the run itself restages every frame regardless).
67    pub version: u32,
68    /// The single blend pass (shader + packed user params; the engine
69    /// overwrites the reserved tail at staging).
70    pub pass: ExtractedFilterPass,
71}
72
73/// A layer's persistent morph resources: the frozen `snapshot` (the "from"
74/// texture, stolen at freeze) and the dedicated `blend` target the morph
75/// pass writes (what the regular chain — or the composite — samples).
76/// Preserved across [`LayerSlot`] reallocs (the snapshot must survive a
77/// same-frame capture resize on the freeze frame); cleared when the
78/// extracted morph disappears (settle, unset, demote).
79pub struct MorphSlot {
80    /// The frozen "from" pixels, layout-anchored: the blend stretches them
81    /// onto the current capture rect (0..1 UV over both textures). `None` =
82    /// nothing valid to freeze existed (startup) — the pass degrades to a
83    /// self-blend of the live capture.
84    pub snapshot: Option<CachedTexture>,
85    /// The blend target (`RENDER_ATTACHMENT | TEXTURE_BINDING`), allocated
86    /// at the capture's size + format and re-allocated when they change
87    /// (its content is rewritten every in-flight frame anyway).
88    pub blend: CachedTexture,
89    /// The blend's allocation key.
90    pub blend_size: UVec2,
91    /// The last consumed [`ExtractedMorph::freeze_seq`].
92    pub seen_seq: u64,
93    /// The staged chain version (warn re-arm only; see
94    /// [`ExtractedMorph::version`]).
95    pub params_version: u32,
96    /// Whether `blend` holds a complete morph output. Predicted at prepare
97    /// (pipeline compiled AND the live capture valid); while false a
98    /// morph-only composite is withheld ([`morph_gate`]) and a downstream
99    /// regular chain keeps its own output invalid.
100    pub output_valid: bool,
101    /// Consecutive withheld frames; drives the stuck-gate warn.
102    pub gated_frames: u32,
103    /// Once-per-episode warn latch (see `FilterSlot::gate_warned`).
104    pub gate_warned: bool,
105    /// Composite bind group over `blend` (morph-only layers); dies with the
106    /// blend realloc.
107    pub composite_bind_group: Option<BindGroup>,
108}
109
110/// Consume a new `freeze_seq`: steal the pixels currently on screen as the
111/// snapshot and reset the slot's morph state around it. MUST run before the
112/// caller's realloc branch — the steal reads the pre-realloc textures — and
113/// the caller must preserve `slot.morph` across that realloc.
114pub fn freeze_morph_snapshot(
115    slot: &mut LayerSlot,
116    layer: &ExtractedLayer,
117    wanted: UVec2,
118    render_device: &RenderDevice,
119) {
120    let Some(morph) = &layer.morph else {
121        return;
122    };
123    if slot
124        .morph
125        .as_ref()
126        .is_some_and(|m| m.seen_seq == morph.freeze_seq)
127    {
128        return;
129    }
130    let snapshot = match slot.morph.take() {
131        // Interrupt: the previous blend holds the in-flight mix that is on
132        // screen right now — exactly what the restarted morph eases FROM.
133        Some(prev) if prev.output_valid => Some(prev.blend),
134        _ => {
135            if slot.content_valid {
136                // Plain freeze: the capture holds last frame's appearance.
137                // Steal it and give the slot a fresh texture — the freeze
138                // frame re-captures anyway (the channel pushed capture dirt),
139                // and every capture-derived state resets with it.
140                let (fresh, fresh_mips) = alloc_capture_texture(
141                    render_device,
142                    "ui_layer_capture",
143                    slot.size,
144                    slot.format,
145                    slot.mips.is_some(),
146                );
147                let stolen = std::mem::replace(&mut slot.texture, fresh);
148                slot.mips = fresh_mips;
149                slot.mips_valid = false;
150                slot.bind_group = None;
151                slot.bind_group_mips = None;
152                slot.content_valid = false;
153                Some(stolen)
154            } else {
155                // Nothing valid on screen (startup): degrade to a self-blend.
156                None
157            }
158        }
159    };
160    let (blend, _) = alloc_capture_texture(
161        render_device,
162        "ui_layer_morph_blend",
163        wanted,
164        layer.target_format,
165        false,
166    );
167    slot.morph = Some(MorphSlot {
168        snapshot,
169        blend,
170        blend_size: wanted,
171        seen_seq: morph.freeze_seq,
172        params_version: 0,
173        output_valid: false,
174        gated_frames: 0,
175        gate_warned: false,
176        composite_bind_group: None,
177    });
178}
179
180/// Post-realloc maintenance: clear the slot when the morph ended, and track
181/// the capture size with the blend target (a mid-flight resize re-allocates
182/// the blend only — the snapshot survives untouched).
183pub fn maintain_morph_blend(
184    slot: &mut LayerSlot,
185    layer: &ExtractedLayer,
186    wanted: UVec2,
187    render_device: &RenderDevice,
188) {
189    if layer.morph.is_none() {
190        slot.morph = None;
191        return;
192    }
193    if let Some(morph) = slot.morph.as_mut()
194        && morph.blend_size != wanted
195    {
196        let (blend, _) = alloc_capture_texture(
197            render_device,
198            "ui_layer_morph_blend",
199            wanted,
200            layer.target_format,
201            false,
202        );
203        morph.blend = blend;
204        morph.blend_size = wanted;
205        morph.output_valid = false;
206        morph.composite_bind_group = None;
207    }
208}
209
210/// Per-frame morph staging, index-aligned with
211/// [`ExtractedUiLayers::layers`]. Owns its own uniform buffer (the shared-
212/// buffer order-coupling rule — see `BackdropMeta`).
213#[derive(Resource)]
214pub struct MorphMeta {
215    pub uniforms: DynamicUniformBuffer<FilterUniforms>,
216    pub runs: Vec<Option<LayerFilterRun>>,
217}
218
219impl Default for MorphMeta {
220    fn default() -> Self {
221        let mut uniforms = DynamicUniformBuffer::default();
222        uniforms.set_label(Some("ui_layer_morph_uniforms"));
223        Self {
224            uniforms,
225            runs: Vec::new(),
226        }
227    }
228}
229
230/// The engine-reserved param tail of a morph pass (see the MORPH CONTRACT in
231/// `filter_prelude.wgsl`): `params[7].x` = progress (`params[6]` is a
232/// reserved-unused spare). The snapshot needs no transform: it is
233/// layout-anchored, stretched onto the current capture rect by the plain
234/// 0..1 UV lookup. Returns the padded params with the tail written.
235fn morph_engine_params(
236    user: &[Vec4; crate::filters::MAX_FILTER_PARAM_VECS],
237    progress: f32,
238) -> [Vec4; crate::filters::MAX_FILTER_PARAM_VECS] {
239    let mut params = *user;
240    params[7].x = progress.clamp(0.0, 1.0);
241    params
242}
243
244/// Stage every morphing layer's blend pass for this frame. Mirrors
245/// `prepare_layer_filters`' three phases; staging is unconditional per
246/// in-flight frame (progress moves every frame). Ordered after
247/// `prepare_layer_textures` (the slot/blend exist) and before
248/// `prepare_layer_filters` (the regular chain's validity prediction reads
249/// [`MorphSlot::output_valid`]).
250#[allow(clippy::too_many_arguments)]
251pub fn prepare_layer_morphs(
252    extracted: Res<ExtractedUiLayers>,
253    mut store: ResMut<LayerTextureStore>,
254    pipeline: Option<Res<LayerFilterPipeline>>,
255    mut specialized: ResMut<SpecializedRenderPipelines<LayerFilterPipeline>>,
256    pipeline_cache: Res<PipelineCache>,
257    render_device: Res<RenderDevice>,
258    render_queue: Res<RenderQueue>,
259    time: Res<Time>,
260    mut meta: ResMut<MorphMeta>,
261) {
262    let MorphMeta { uniforms, runs } = &mut *meta;
263    uniforms.clear();
264    runs.clear();
265    runs.resize_with(extracted.layers.len(), || None);
266    let Some(pipeline) = pipeline else {
267        return;
268    };
269
270    // Phase 1: stage uniforms + specialize.
271    let mut staged: Vec<(usize, CachedRenderPipelineId, u32)> = Vec::new();
272    for (idx, layer) in extracted.layers.iter().enumerate() {
273        // Idle morph: pre-specialize the blend pipeline so the async compile
274        // runs while nothing is on screen — the first key change then finds
275        // it cached instead of gating the composite (a visible blink of the
276        // subtree). Specialization is memoized per (shader, format), so the
277        // steady-state cost is a hash lookup.
278        if let Some(shader) = &layer.morph_warm {
279            specialized.specialize(
280                &pipeline_cache,
281                &pipeline,
282                LayerFilterPipelineKey {
283                    shader: shader.clone(),
284                    target_format: layer.target_format,
285                },
286            );
287        }
288        let Some(extracted_morph) = &layer.morph else {
289            continue;
290        };
291        let Some(slot) = store.slots.get_mut(&layer.main_entity) else {
292            continue;
293        };
294        let size = slot.size;
295        let Some(morph) = slot.morph.as_mut() else {
296            continue;
297        };
298        // A param/shader edit gets its own once-per-episode gate warn.
299        if morph.params_version != extracted_morph.version {
300            morph.gated_frames = 0;
301            morph.gate_warned = false;
302        }
303        morph.params_version = extracted_morph.version;
304        // The run supersedes whatever the blend holds; phase 3 re-marks
305        // valid iff the pass will execute over a valid capture.
306        morph.output_valid = false;
307
308        let id = specialized.specialize(
309            &pipeline_cache,
310            &pipeline,
311            LayerFilterPipelineKey {
312                shader: extracted_morph.pass.shader.clone(),
313                target_format: layer.target_format,
314            },
315        );
316        let resolution = size.as_vec2();
317        let offset = uniforms.push(&FilterUniforms {
318            time: time.elapsed_secs(),
319            pad_a: 0.0,
320            resolution,
321            texel_size: Vec2::ONE / resolution,
322            // The blend target is capture-sized, so it carries the same
323            // inflation as the content chain.
324            content_inset: Vec2::splat(layer.outset as f32),
325            params: morph_engine_params(&extracted_morph.pass.params, extracted_morph.progress),
326        });
327        staged.push((idx, id, offset));
328    }
329    if staged.is_empty() {
330        return;
331    }
332
333    // Phase 2: write the uniforms, then build the bind groups against the
334    // (possibly fresh) buffer.
335    uniforms.write_buffer(&render_device, &render_queue);
336    let Some(uniform_binding) = uniforms.binding() else {
337        return;
338    };
339    let layout = pipeline_cache.get_bind_group_layout(&pipeline.layout);
340    for (idx, pipeline_id, uniform_offset) in staged {
341        let layer = &extracted.layers[idx];
342        let Some(slot) = store.slots.get(&layer.main_entity) else {
343            continue;
344        };
345        let Some(morph) = slot.morph.as_ref() else {
346            continue;
347        };
348        // Binding 0 = the live capture (the "to"); binding 3 = the frozen
349        // snapshot (the "from" — or the live capture again on degrade, the
350        // self-blend). Target = the dedicated blend texture.
351        let from_view = morph
352            .snapshot
353            .as_ref()
354            .map_or(&slot.texture.default_view, |s| &s.default_view);
355        let bind_group = render_device.create_bind_group(
356            "ui_layer_morph",
357            &layout,
358            &BindGroupEntries::sequential((
359                &slot.texture.default_view,
360                &pipeline.sampler,
361                uniform_binding.clone(),
362                from_view,
363            )),
364        );
365        runs[idx] = Some(LayerFilterRun {
366            passes: vec![LayerFilterPass {
367                pipeline: pipeline_id,
368                bind_group,
369                uniform_offset,
370                target: morph.blend.default_view.clone(),
371            }],
372        });
373    }
374
375    // Phase 3: predict execution (compiled pipelines never regress within a
376    // frame). The live capture must be valid too — blending a blank/partial
377    // capture would put garbage on screen.
378    for (idx, run) in runs.iter().enumerate() {
379        let Some(run) = run else {
380            continue;
381        };
382        let Some(slot) = store.slots.get_mut(&extracted.layers[idx].main_entity) else {
383            continue;
384        };
385        let ready = run
386            .passes
387            .iter()
388            .all(|pass| pipeline_cache.get_render_pipeline(pass.pipeline).is_some());
389        if ready
390            && slot.content_valid
391            && let Some(morph) = slot.morph.as_mut()
392        {
393            morph.output_valid = true;
394            morph.gated_frames = 0;
395            morph.gate_warned = false;
396        }
397    }
398}
399
400/// Execute one layer's staged morph pass inside the capture-pass encoder:
401/// capture (already rendered) + snapshot → blend. Called per layer from
402/// `ui_layer_capture_pass`, after the capture, before the regular filter
403/// replay (which sources the blend).
404pub fn run_morph_passes(
405    idx: usize,
406    meta: &MorphMeta,
407    pipeline_cache: &PipelineCache,
408    ctx: &mut RenderContext,
409) {
410    let Some(run) = meta.runs.get(idx).and_then(Option::as_ref) else {
411        return;
412    };
413    for pass_data in &run.passes {
414        let Some(pipeline) = pipeline_cache.get_render_pipeline(pass_data.pipeline) else {
415            // Still compiling: `output_valid` stayed false at prepare, the
416            // composite is gated, and the layer restages next frame.
417            return;
418        };
419        let mut pass = ctx.begin_tracked_render_pass(RenderPassDescriptor {
420            label: Some("ui_layer_morph"),
421            color_attachments: &[Some(RenderPassColorAttachment {
422                view: &pass_data.target,
423                depth_slice: None,
424                resolve_target: None,
425                ops: Operations {
426                    load: LoadOp::Clear(LinearRgba::NONE.into()),
427                    store: StoreOp::Store,
428                },
429            })],
430            depth_stencil_attachment: None,
431            timestamp_writes: None,
432            occlusion_query_set: None,
433            multiview_mask: None,
434        });
435        pass.set_render_pipeline(pipeline);
436        pass.set_bind_group(0, &pass_data.bind_group, &[pass_data.uniform_offset]);
437        pass.draw(0..3, 0..1);
438    }
439}
440
441/// Whether a morph-ONLY layer's composite may draw this frame, maintaining
442/// the gate-warn bookkeeping. Returns the bind group over `blend` when
443/// ready. Same discipline as the content-filter gate: never fall back to
444/// the raw capture (the mid-blend flash this gate exists to prevent).
445#[allow(clippy::too_many_arguments)]
446pub fn morph_gate(
447    idx: usize,
448    main_entity: bevy::render::sync_world::MainEntity,
449    morph: &mut MorphSlot,
450    meta: &MorphMeta,
451    pipeline_cache: &PipelineCache,
452    render_device: &RenderDevice,
453    atlas_layout: &BindGroupLayoutDescriptor,
454    sampler: &Sampler,
455) -> Option<BindGroup> {
456    if !morph.output_valid {
457        morph.gated_frames = morph.gated_frames.saturating_add(1);
458        if !morph.gate_warned {
459            let compile_error = meta
460                .runs
461                .get(idx)
462                .and_then(|run| run.as_ref())
463                .and_then(|run| {
464                    run.passes.iter().find_map(|pass| {
465                        match pipeline_cache.get_render_pipeline_state(pass.pipeline) {
466                            CachedPipelineState::Err(
467                                e @ (ShaderCacheError::ProcessShaderError(_)
468                                | ShaderCacheError::CreateShaderModule(_)),
469                            ) => Some(e.to_string()),
470                            _ => None,
471                        }
472                    })
473                });
474            if let Some(err) = compile_error {
475                warn!(
476                    "UI layer {main_entity:?}: the morphFilter pass shader failed to \
477                     compile — the layer's subtree is invisible while the morph is in \
478                     flight (the composite gate never falls back to unblended content). \
479                     Error: {err}",
480                );
481                morph.gate_warned = true;
482            } else if morph.gated_frames == STUCK_GATE_HANG_FRAMES {
483                warn!(
484                    "UI layer {main_entity:?}: morph composite withheld for {} consecutive \
485                     frames and its pipeline is still not ready (no compile error \
486                     reported). The layer's subtree is invisible until it resolves.",
487                    STUCK_GATE_HANG_FRAMES,
488                );
489                morph.gate_warned = true;
490            }
491        }
492        return None;
493    }
494    if morph.composite_bind_group.is_none() {
495        morph.composite_bind_group = Some(render_device.create_bind_group(
496            "ui_layer_composite_morph",
497            &pipeline_cache.get_bind_group_layout(atlas_layout),
498            &BindGroupEntries::sequential((&morph.blend.default_view, sampler)),
499        ));
500    }
501    morph.composite_bind_group.clone()
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use crate::filters::MAX_FILTER_PARAM_VECS;
508
509    /// The reserved tail: progress lands in `params[7].x` (clamped), user
510    /// params — including the now-spare `params[6]` slot — survive
511    /// untouched.
512    #[test]
513    fn morph_engine_params_writes_clamped_progress() {
514        let mut user = [Vec4::ZERO; MAX_FILTER_PARAM_VECS];
515        user[0] = Vec4::new(1.0, 2.0, 3.0, 4.0);
516
517        let p = morph_engine_params(&user, 0.25);
518        assert_eq!(p[0], user[0], "user params untouched");
519        assert_eq!(p[6], Vec4::ZERO, "spare slot untouched");
520        assert_eq!(p[7].x, 0.25);
521
522        let p = morph_engine_params(&user, 2.0);
523        assert_eq!(p[7].x, 1.0, "progress clamps");
524    }
525}