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}