1use bevy::math::{Rect, UVec2, Vec2};
32use bevy::prelude::*;
33use bevy::render::camera::ExtractedCamera;
34use bevy::render::render_resource::binding_types::{sampler, texture_2d, uniform_buffer};
35use bevy::render::render_resource::*;
36use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue};
37use bevy::render::texture::CachedTexture;
38use bevy::render::view::ViewTarget;
39use bevy::shader::Shader;
40
41use super::clip::ClippedQuad;
42use super::store::alloc_capture_texture;
43use super::{
44 ExtractedUiLayers, FilterUniforms, LayerFilterPass, LayerFilterPipeline,
45 LayerFilterPipelineKey, LayerFilterRun, LayerTextureStore, STUCK_GATE_HANG_FRAMES,
46 filter_output_index, filter_source_index, filter_target_index,
47};
48
49pub struct BackdropSlot {
57 pub snapshot: CachedTexture,
61 pub textures: [CachedTexture; 2],
64 pub params_version: u32,
68 pub output_valid: bool,
73 pub gated_frames: u32,
75 pub gate_warned: bool,
77 pub output_index: usize,
79 pub composite_bind_group: Option<(usize, BindGroup)>,
82}
83
84pub fn alloc_backdrop_slot(
86 render_device: &RenderDevice,
87 size: UVec2,
88 format: TextureFormat,
89) -> BackdropSlot {
90 let alloc =
91 |label: &'static str| alloc_capture_texture(render_device, label, size, format, false).0;
92 BackdropSlot {
93 snapshot: alloc("ui_layer_backdrop_snapshot"),
94 textures: [
95 alloc("ui_layer_backdrop_ping"),
96 alloc("ui_layer_backdrop_pong"),
97 ],
98 params_version: 0,
99 output_valid: false,
100 gated_frames: 0,
101 gate_warned: false,
102 output_index: 0,
103 composite_bind_group: None,
104 }
105}
106
107#[derive(Clone, Copy, ShaderType)]
110pub struct BackdropBlitUniforms {
111 pub src_uv_min: Vec2,
112 pub src_uv_scale: Vec2,
113}
114
115#[derive(Resource)]
120pub struct BackdropBlitPipeline {
121 pub layout: BindGroupLayoutDescriptor,
122 pub sampler: Sampler,
123 pub shader: Handle<Shader>,
124}
125
126pub fn init_backdrop_blit_pipeline(
127 mut commands: Commands,
128 render_device: Res<RenderDevice>,
129 asset_server: Res<AssetServer>,
130) {
131 let layout = BindGroupLayoutDescriptor::new(
132 "ui_layer_backdrop_blit_layout",
133 &BindGroupLayoutEntries::sequential(
134 ShaderStages::FRAGMENT,
135 (
136 texture_2d(TextureSampleType::Float { filterable: true }),
137 sampler(SamplerBindingType::Filtering),
138 uniform_buffer::<BackdropBlitUniforms>(true),
139 ),
140 ),
141 );
142 commands.insert_resource(BackdropBlitPipeline {
143 layout,
144 sampler: render_device.create_sampler(&SamplerDescriptor {
145 label: Some("ui_layer_backdrop_blit_sampler"),
146 address_mode_u: AddressMode::ClampToEdge,
147 address_mode_v: AddressMode::ClampToEdge,
148 mag_filter: FilterMode::Linear,
149 min_filter: FilterMode::Linear,
150 ..Default::default()
151 }),
152 shader: bevy::asset::load_embedded_asset!(asset_server.as_ref(), "backdrop_blit.wgsl"),
153 });
154}
155
156#[derive(Clone, Copy, Hash, PartialEq, Eq)]
157pub struct BackdropBlitPipelineKey {
158 pub target_format: TextureFormat,
159}
160
161impl SpecializedRenderPipeline for BackdropBlitPipeline {
162 type Key = BackdropBlitPipelineKey;
163
164 fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor {
165 RenderPipelineDescriptor {
166 vertex: VertexState {
167 shader: self.shader.clone(),
168 entry_point: Some("vertex".into()),
169 ..Default::default()
170 },
171 fragment: Some(FragmentState {
172 shader: self.shader.clone(),
173 entry_point: Some("fragment".into()),
174 targets: vec![Some(ColorTargetState {
175 format: key.target_format,
176 blend: None,
178 write_mask: ColorWrites::ALL,
179 })],
180 ..Default::default()
181 }),
182 layout: vec![self.layout.clone()],
183 label: Some("ui_layer_backdrop_blit_pipeline".into()),
184 ..Default::default()
185 }
186 }
187}
188
189pub struct BackdropBlit {
193 pub pipeline: CachedRenderPipelineId,
194 pub uniform_offset: u32,
195 pub target: TextureView,
196}
197
198#[derive(Resource)]
204pub struct BackdropMeta {
205 pub blit_uniforms: DynamicUniformBuffer<BackdropBlitUniforms>,
206 pub filter_uniforms: DynamicUniformBuffer<FilterUniforms>,
207 pub blits: Vec<Option<BackdropBlit>>,
208 pub runs: Vec<Option<LayerFilterRun>>,
209}
210
211impl Default for BackdropMeta {
212 fn default() -> Self {
213 let mut blit_uniforms = DynamicUniformBuffer::default();
214 blit_uniforms.set_label(Some("ui_layer_backdrop_blit_uniforms"));
215 let mut filter_uniforms = DynamicUniformBuffer::default();
216 filter_uniforms.set_label(Some("ui_layer_backdrop_filter_uniforms"));
217 Self {
218 blit_uniforms,
219 filter_uniforms,
220 blits: Vec::new(),
221 runs: Vec::new(),
222 }
223 }
224}
225
226#[allow(clippy::too_many_arguments)]
233pub fn prepare_layer_backdrops(
234 extracted: Res<ExtractedUiLayers>,
235 mut store: ResMut<LayerTextureStore>,
236 filter_pipeline: Option<Res<LayerFilterPipeline>>,
237 blit_pipeline: Option<Res<BackdropBlitPipeline>>,
238 mut specialized_filters: ResMut<SpecializedRenderPipelines<LayerFilterPipeline>>,
239 mut specialized_blits: ResMut<SpecializedRenderPipelines<BackdropBlitPipeline>>,
240 pipeline_cache: Res<PipelineCache>,
241 render_device: Res<RenderDevice>,
242 render_queue: Res<RenderQueue>,
243 cameras: Query<&ExtractedCamera>,
244 time: Res<Time>,
245 mut meta: ResMut<BackdropMeta>,
246) {
247 let BackdropMeta {
248 blit_uniforms,
249 filter_uniforms,
250 blits,
251 runs,
252 } = &mut *meta;
253 blit_uniforms.clear();
254 filter_uniforms.clear();
255 blits.clear();
256 runs.clear();
257 blits.resize_with(extracted.layers.len(), || None);
258 runs.resize_with(extracted.layers.len(), || None);
259 let (Some(filter_pipeline), Some(blit_pipeline)) = (filter_pipeline, blit_pipeline) else {
260 return;
261 };
262 let Some(camera) = extracted
266 .camera_render_entity
267 .and_then(|e| cameras.get(e).ok())
268 else {
269 return;
270 };
271 let Some(target_size) = camera.physical_target_size else {
272 return;
273 };
274 let viewport_offset = camera
275 .viewport
276 .as_ref()
277 .map_or(Vec2::ZERO, |v| v.physical_position.as_vec2());
278 let target_size = target_size.as_vec2().max(Vec2::ONE);
279
280 struct StagedBackdrop {
282 blit_pipeline: CachedRenderPipelineId,
283 blit_offset: u32,
284 passes: Vec<(CachedRenderPipelineId, u32)>,
285 }
286 let mut staged: Vec<(usize, StagedBackdrop)> = Vec::new();
287 for (idx, layer) in extracted.layers.iter().enumerate() {
288 let Some(chain) = &layer.backdrop_chain 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(backdrop) = slot.backdrop.as_mut() else {
296 continue;
297 };
298 if backdrop.params_version != chain.version {
301 backdrop.gated_frames = 0;
302 backdrop.gate_warned = false;
303 }
304 backdrop.params_version = chain.version;
305 backdrop.output_valid = false;
308 backdrop.output_index = filter_output_index(chain.passes.len());
309
310 let blit_id = specialized_blits.specialize(
311 &pipeline_cache,
312 &blit_pipeline,
313 BackdropBlitPipelineKey {
314 target_format: layer.target_format,
315 },
316 );
317 let blit_offset = blit_uniforms.push(&BackdropBlitUniforms {
318 src_uv_min: (viewport_offset + layer.min) / target_size,
319 src_uv_scale: size.as_vec2() / target_size,
320 });
321
322 let resolution = size.as_vec2();
323 let texel_size = Vec2::ONE / resolution;
324 let passes = chain
325 .passes
326 .iter()
327 .map(|pass| {
328 let id = specialized_filters.specialize(
329 &pipeline_cache,
330 &filter_pipeline,
331 LayerFilterPipelineKey {
332 shader: pass.shader.clone(),
333 target_format: layer.target_format,
334 },
335 );
336 let offset = filter_uniforms.push(&FilterUniforms {
337 time: time.elapsed_secs(),
338 pad_a: 0.0,
339 resolution,
340 texel_size,
341 content_inset: Vec2::splat(layer.outset as f32),
345 params: pass.params,
346 });
347 (id, offset)
348 })
349 .collect();
350 staged.push((
351 idx,
352 StagedBackdrop {
353 blit_pipeline: blit_id,
354 blit_offset,
355 passes,
356 },
357 ));
358 }
359 if staged.is_empty() {
360 return;
361 }
362
363 blit_uniforms.write_buffer(&render_device, &render_queue);
367 filter_uniforms.write_buffer(&render_device, &render_queue);
368 let Some(uniform_binding) = filter_uniforms.binding() else {
369 return;
370 };
371 let layout = pipeline_cache.get_bind_group_layout(&filter_pipeline.layout);
372 for (idx, staged) in staged {
373 let layer = &extracted.layers[idx];
374 let Some(slot) = store.slots.get(&layer.main_entity) else {
375 continue;
376 };
377 let Some(backdrop) = slot.backdrop.as_ref() else {
378 continue;
379 };
380 let passes = staged
381 .passes
382 .iter()
383 .enumerate()
384 .map(|(i, &(pipeline, uniform_offset))| {
385 let source = match filter_source_index(i) {
386 None => &backdrop.snapshot.default_view,
388 Some(ping) => &backdrop.textures[ping].default_view,
389 };
390 let bind_group = render_device.create_bind_group(
391 "ui_layer_backdrop_filter",
392 &layout,
393 &BindGroupEntries::sequential((
394 source,
395 &filter_pipeline.sampler,
396 uniform_binding.clone(),
397 &backdrop.snapshot.default_view,
401 )),
402 );
403 LayerFilterPass {
404 pipeline,
405 bind_group,
406 uniform_offset,
407 target: backdrop.textures[filter_target_index(i)]
408 .default_view
409 .clone(),
410 }
411 })
412 .collect();
413 blits[idx] = Some(BackdropBlit {
414 pipeline: staged.blit_pipeline,
415 uniform_offset: staged.blit_offset,
416 target: backdrop.snapshot.default_view.clone(),
417 });
418 runs[idx] = Some(LayerFilterRun { passes });
419 }
420
421 for idx in 0..extracted.layers.len() {
426 let (Some(blit), Some(run)) = (
427 blits.get(idx).and_then(Option::as_ref),
428 runs.get(idx).and_then(Option::as_ref),
429 ) else {
430 continue;
431 };
432 let Some(slot) = store.slots.get_mut(&extracted.layers[idx].main_entity) else {
433 continue;
434 };
435 let ready = pipeline_cache.get_render_pipeline(blit.pipeline).is_some()
436 && run
437 .passes
438 .iter()
439 .all(|pass| pipeline_cache.get_render_pipeline(pass.pipeline).is_some());
440 if ready && let Some(backdrop) = slot.backdrop.as_mut() {
441 backdrop.output_valid = true;
442 backdrop.gated_frames = 0;
443 backdrop.gate_warned = false;
444 }
445 }
446}
447
448pub fn run_backdrop_passes(
453 idx: usize,
454 meta: &BackdropMeta,
455 blit_pipeline: Option<&BackdropBlitPipeline>,
456 main_texture: &TextureView,
457 pipeline_cache: &PipelineCache,
458 ctx: &mut RenderContext,
459) {
460 let (Some(blit), Some(run)) = (
461 meta.blits.get(idx).and_then(Option::as_ref),
462 meta.runs.get(idx).and_then(Option::as_ref),
463 ) else {
464 return;
465 };
466 let Some(blit_pipeline_res) = blit_pipeline else {
467 return;
468 };
469 let Some(blit_compiled) = pipeline_cache.get_render_pipeline(blit.pipeline) else {
473 return;
474 };
475 let chain_compiled: Option<Vec<_>> = run
476 .passes
477 .iter()
478 .map(|pass| pipeline_cache.get_render_pipeline(pass.pipeline))
479 .collect();
480 let Some(chain_compiled) = chain_compiled else {
481 return;
482 };
483 let Some(blit_binding) = meta.blit_uniforms.binding() else {
484 return;
485 };
486 let blit_bind_group = ctx.render_device().create_bind_group(
491 "ui_layer_backdrop_blit",
492 &pipeline_cache.get_bind_group_layout(&blit_pipeline_res.layout),
493 &BindGroupEntries::sequential((main_texture, &blit_pipeline_res.sampler, blit_binding)),
494 );
495 {
496 let mut pass = ctx.begin_tracked_render_pass(RenderPassDescriptor {
497 label: Some("ui_layer_backdrop_blit"),
498 color_attachments: &[Some(RenderPassColorAttachment {
499 view: &blit.target,
500 depth_slice: None,
501 resolve_target: None,
502 ops: Operations {
503 load: LoadOp::Clear(LinearRgba::NONE.into()),
506 store: StoreOp::Store,
507 },
508 })],
509 depth_stencil_attachment: None,
510 timestamp_writes: None,
511 occlusion_query_set: None,
512 multiview_mask: None,
513 });
514 pass.set_render_pipeline(blit_compiled);
515 pass.set_bind_group(0, &blit_bind_group, &[blit.uniform_offset]);
516 pass.draw(0..3, 0..1);
517 }
518 for (pass_data, pipeline) in run.passes.iter().zip(chain_compiled) {
519 let mut pass = ctx.begin_tracked_render_pass(RenderPassDescriptor {
520 label: Some("ui_layer_backdrop_filter"),
521 color_attachments: &[Some(RenderPassColorAttachment {
522 view: &pass_data.target,
523 depth_slice: None,
524 resolve_target: None,
525 ops: Operations {
526 load: LoadOp::Clear(LinearRgba::NONE.into()),
527 store: StoreOp::Store,
528 },
529 })],
530 depth_stencil_attachment: None,
531 timestamp_writes: None,
532 occlusion_query_set: None,
533 multiview_mask: None,
534 });
535 pass.set_render_pipeline(pipeline);
536 pass.set_bind_group(0, &pass_data.bind_group, &[pass_data.uniform_offset]);
537 pass.draw(0..3, 0..1);
538 }
539}
540
541pub fn camera_main_texture(world: &World, camera: Option<Entity>) -> Option<TextureView> {
544 let target = world.get::<ViewTarget>(camera?)?;
545 Some(target.main_texture_view().clone())
546}
547
548pub fn backdrop_quad(
554 min: Vec2,
555 size: UVec2,
556 outset: u32,
557 clip: Option<Rect>,
558) -> Option<ClippedQuad> {
559 let size = size.as_vec2();
560 let inset = Vec2::splat(outset as f32);
561 let box_min = min + inset;
562 let box_max = min + size - inset;
563 let (pos_min, pos_max) = match clip {
564 None => (box_min, box_max),
565 Some(c) => (box_min.max(c.min), box_max.min(c.max)),
566 };
567 if pos_min.x >= pos_max.x || pos_min.y >= pos_max.y {
568 return None;
569 }
570 Some(ClippedQuad {
571 pos_min,
572 pos_max,
573 uv_min: (pos_min - min) / size,
574 uv_max: (pos_max - min) / size,
575 })
576}
577
578#[allow(clippy::too_many_arguments)]
584pub fn backdrop_gate(
585 idx: usize,
586 main_entity: bevy::render::sync_world::MainEntity,
587 backdrop: &mut BackdropSlot,
588 meta: &BackdropMeta,
589 pipeline_cache: &PipelineCache,
590 render_device: &RenderDevice,
591 atlas_layout: &BindGroupLayoutDescriptor,
592 sampler: &Sampler,
593) -> Option<BindGroup> {
594 if !backdrop.output_valid {
595 backdrop.gated_frames = backdrop.gated_frames.saturating_add(1);
596 if !backdrop.gate_warned {
597 let compile_error = meta
598 .runs
599 .get(idx)
600 .and_then(|run| run.as_ref())
601 .into_iter()
602 .flat_map(|run| run.passes.iter().map(|p| p.pipeline))
603 .chain(
604 meta.blits
605 .get(idx)
606 .and_then(|b| b.as_ref())
607 .map(|b| b.pipeline),
608 )
609 .find_map(
610 |pipeline| match pipeline_cache.get_render_pipeline_state(pipeline) {
611 CachedPipelineState::Err(
612 e @ (bevy::shader::ShaderCacheError::ProcessShaderError(_)
613 | bevy::shader::ShaderCacheError::CreateShaderModule(_)),
614 ) => Some(e.to_string()),
615 _ => None,
616 },
617 );
618 if let Some(err) = compile_error {
619 warn!(
620 "UI layer {main_entity:?}: a backdropFilter pass shader failed to \
621 compile — the region shows the UNFILTERED frame until fixed (the \
622 backdrop gate is graceful; the node's own content still draws). \
623 Error: {err}",
624 );
625 backdrop.gate_warned = true;
626 } else if backdrop.gated_frames == STUCK_GATE_HANG_FRAMES {
627 warn!(
628 "UI layer {main_entity:?}: backdrop quad withheld for {} consecutive \
629 frames and its pipeline is still not ready (no compile error \
630 reported). The region shows the unfiltered frame until it resolves.",
631 STUCK_GATE_HANG_FRAMES,
632 );
633 backdrop.gate_warned = true;
634 }
635 }
636 return None;
637 }
638 let output = backdrop.output_index;
639 if !matches!(&backdrop.composite_bind_group, Some((built, _)) if *built == output) {
640 backdrop.composite_bind_group = Some((
641 output,
642 render_device.create_bind_group(
643 "ui_layer_composite_backdrop",
644 &pipeline_cache.get_bind_group_layout(atlas_layout),
645 &BindGroupEntries::sequential((&backdrop.textures[output].default_view, sampler)),
646 ),
647 ));
648 }
649 backdrop
650 .composite_bind_group
651 .as_ref()
652 .map(|(_, bind_group)| bind_group.clone())
653}
654
655pub const BACKDROP_UNDERLAY_EPSILON: f32 = 0.005;
662
663#[cfg(test)]
666mod tests {
667 use super::*;
668
669 #[test]
674 fn backdrop_quad_shrinks_to_border_box_with_inflated_uvs() {
675 let min = Vec2::new(100.0, 200.0);
676 let size = UVec2::new(132, 96); let q = backdrop_quad(min, size, 16, None).expect("quad");
678 assert_eq!(q.pos_min, Vec2::new(116.0, 216.0));
679 assert_eq!(q.pos_max, Vec2::new(216.0, 280.0));
680 assert_eq!(q.uv_min, Vec2::new(16.0 / 132.0, 16.0 / 96.0));
681 assert_eq!(q.uv_max, Vec2::new(116.0 / 132.0, 80.0 / 96.0));
682
683 let q = backdrop_quad(min, size, 0, None).expect("quad");
685 assert_eq!(q.pos_min, min);
686 assert_eq!(q.uv_min, Vec2::ZERO);
687 assert_eq!(q.uv_max, Vec2::ONE);
688
689 let clip = Rect::new(150.0, 216.0, 400.0, 400.0);
691 let q = backdrop_quad(min, size, 16, Some(clip)).expect("quad");
692 assert_eq!(q.pos_min, Vec2::new(150.0, 216.0));
693 assert_eq!(q.pos_max, Vec2::new(216.0, 280.0));
694 assert_eq!(q.uv_min, Vec2::new(50.0 / 132.0, 16.0 / 96.0));
695
696 let far = Rect::new(1000.0, 1000.0, 2000.0, 2000.0);
698 assert!(backdrop_quad(min, size, 16, Some(far)).is_none());
699
700 assert!(backdrop_quad(min, UVec2::new(20, 20), 16, None).is_none());
702 }
703
704 #[test]
708 fn underlay_epsilon_sorts_under_content_quad() {
709 for first_key in [0.0f32, -0.1, 0.08, 41.0, -3.05] {
710 let content = first_key; let backdrop = first_key - BACKDROP_UNDERLAY_EPSILON;
712 assert!(backdrop < content, "key {first_key}");
713 assert!(content - backdrop < 1.0, "must stay within the stack slot");
714 }
715 }
716}