Skip to main content

backdrop_blur_egui/
own_loop.rs

1//! The own-loop adapter: drive `egui-winit` + `egui-wgpu` directly (not eframe), render the UI
2//! into an offscreen intermediate, then blur a region and composite the frosted surface into the
3//! target — in the one order that does not panic (DESIGN §6, M4-corrected).
4
5use crate::Surface;
6use backdrop_blur_core::{BackdropBlur, BlurError, BlurRequest, Region, RepaintPolicy, Scale};
7use backdrop_blur_wgpu::{SourceColorSpace, SourceView, WgpuBlur};
8
9/// Own-loop-only resolution of a [`Surface`]. This `impl` lives in the `own-loop`-gated module on
10/// purpose: it builds a **top-left** [`BlurRequest`] (the egui-wgpu sampling convention), which is
11/// wrong for the grab-pass path, so gating the module makes `request` *uncallable* from a
12/// grab-pass build — the relocated-flip bug is unrepresentable rather than merely discouraged.
13impl Surface {
14    /// Resolve to a physical-pixel [`BlurRequest`]. The egui rect (points) scales by
15    /// `pixels_per_point`; the backdrop behind the surface is the same screen area.
16    fn request(&self, pixels_per_point: f32) -> BlurRequest {
17        let origin = [
18            (self.rect.min.x * pixels_per_point).round().max(0.0) as u32,
19            (self.rect.min.y * pixels_per_point).round().max(0.0) as u32,
20        ];
21        let size = [
22            (self.rect.width() * pixels_per_point).round().max(0.0) as u32,
23            (self.rect.height() * pixels_per_point).round().max(0.0) as u32,
24        ];
25        let region = Region {
26            origin,
27            size,
28            scale: Scale::new(pixels_per_point),
29        };
30        BlurRequest {
31            source_region: region,
32            target_rect: region,
33            strength: self.strength,
34            tint: self.tint,
35            corner_radius: self.corner_radius,
36            opacity: self.opacity,
37        }
38    }
39}
40
41/// The strongest repaint obligation across a set of surfaces: `Live` wins, then the shortest
42/// `Bounded` interval, else `Static`. [`OwnLoopRenderer::render_frame`] applies this to the egui
43/// `Context` itself; this is exposed for hosts that want to inspect the obligation directly.
44pub fn strongest_repaint(surfaces: &[Surface]) -> RepaintPolicy {
45    surfaces
46        .iter()
47        .fold(RepaintPolicy::Static, |acc, s| match (acc, s.repaint) {
48            (RepaintPolicy::Live, _) | (_, RepaintPolicy::Live) => RepaintPolicy::Live,
49            (RepaintPolicy::Bounded(a), RepaintPolicy::Bounded(b)) => {
50                RepaintPolicy::Bounded(a.min(b))
51            }
52            (RepaintPolicy::Bounded(d), RepaintPolicy::Static)
53            | (RepaintPolicy::Static, RepaintPolicy::Bounded(d)) => RepaintPolicy::Bounded(d),
54            (RepaintPolicy::Static, RepaintPolicy::Static) => RepaintPolicy::Static,
55        })
56}
57
58/// The per-frame GPU handles a blur needs, bundled so the surface loop stays legible. Generic
59/// over the backend `B`, so a test can build one entirely from `()` and run headlessly.
60pub(crate) struct SeamContext<'a, B: BackdropBlur> {
61    pub device: &'a B::Device,
62    pub queue: &'a B::Queue,
63    pub encoder: &'a mut B::Encoder,
64    pub source: &'a B::SourceTexture,
65    pub target: &'a B::Target,
66    pub target_format: B::TargetFormat,
67}
68
69/// The backend-agnostic core of the adapter: for each surface, `prepare` the blur and `record` it
70/// when the region is non-empty. Generic over the backend so it is exercised headlessly by a
71/// recording fake in tests (the real egui rendering around it needs a GPU; this mapping does not).
72///
73/// Returns the number of surfaces that recorded a blur (a clipped-to-nothing surface prepares to
74/// `Ok(None)` and records nothing).
75pub(crate) fn composite_surfaces<B>(
76    blur: &mut B,
77    ctx: SeamContext<'_, B>,
78    surfaces: &[Surface],
79    pixels_per_point: f32,
80) -> Result<usize, BlurError>
81where
82    B: BackdropBlur,
83    B::TargetFormat: Copy,
84{
85    let mut recorded = 0;
86    for surface in surfaces {
87        let request = surface.request(pixels_per_point);
88        if let Some(prepared) = blur.prepare(
89            ctx.device,
90            ctx.queue,
91            ctx.source,
92            ctx.target_format,
93            &request,
94        )? {
95            blur.record(ctx.encoder, ctx.target, &prepared)?;
96            recorded += 1;
97        }
98    }
99    Ok(recorded)
100}
101
102/// The offscreen intermediate egui renders into (the blur source). Cached and resized to the
103/// screen; its format matches the target so one `egui-wgpu::Renderer` serves both.
104struct Intermediate {
105    texture: wgpu::Texture,
106    size: [u32; 2],
107}
108
109/// Whether the own-loop adapter supports compositing into `format`. The adapter renders egui's
110/// **gamma-encoded** output (egui#3168) into an intermediate of the *same* format and decodes it
111/// in the blur shader; that model is only correct for **non-sRGB `Unorm`** targets. An `*Srgb`
112/// target would make the sampler decode once and the shader decode again (washed-out frost), so it
113/// is rejected at construction rather than silently mis-rendered.
114pub fn is_supported_target(format: wgpu::TextureFormat) -> bool {
115    matches!(
116        format,
117        wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Bgra8Unorm
118    )
119}
120
121/// Drives one own-loop frame for an `egui-winit` + `egui-wgpu` host: it renders the egui UI into
122/// the intermediate (the blur source) and the target (the display), then blurs and composites the
123/// frosted surfaces over the target — all on one encoder with a single submit.
124pub struct OwnLoopRenderer {
125    renderer: egui_wgpu::Renderer,
126    target_format: wgpu::TextureFormat,
127    intermediate: Option<Intermediate>,
128}
129
130impl OwnLoopRenderer {
131    /// Build the adapter for a host whose target (swapchain) has `target_format`.
132    ///
133    /// Returns [`BlurError::UnsupportedTarget`] unless `target_format` is a non-sRGB `Unorm`
134    /// format ([`is_supported_target`]) — the adapter pins the decode-in-shader gamma model, which
135    /// only matches non-sRGB targets (egui#3168). This makes the documented format assumption a
136    /// checked contract rather than prose.
137    pub fn new(
138        device: &wgpu::Device,
139        target_format: wgpu::TextureFormat,
140    ) -> Result<Self, BlurError> {
141        if !is_supported_target(target_format) {
142            return Err(BlurError::UnsupportedTarget {
143                format: format!("{target_format:?} (own-loop needs a non-sRGB Unorm target)"),
144            });
145        }
146        let renderer =
147            egui_wgpu::Renderer::new(device, target_format, egui_wgpu::RendererOptions::default());
148        Ok(Self {
149            renderer,
150            target_format,
151            intermediate: None,
152        })
153    }
154
155    /// The intermediate sized to `size`, recreated only on a size change. Total — no panic path:
156    /// a stale intermediate is dropped, then `get_or_insert_with` constructs or returns the cached
157    /// one.
158    fn intermediate(&mut self, device: &wgpu::Device, size: [u32; 2]) -> &Intermediate {
159        if self.intermediate.as_ref().is_none_or(|i| i.size != size) {
160            self.intermediate = None;
161        }
162        let format = self.target_format;
163        self.intermediate.get_or_insert_with(|| {
164            let texture = device.create_texture(&wgpu::TextureDescriptor {
165                label: Some("backdrop-blur egui intermediate"),
166                size: wgpu::Extent3d {
167                    width: size[0].max(1),
168                    height: size[1].max(1),
169                    depth_or_array_layers: 1,
170                },
171                mip_level_count: 1,
172                sample_count: 1,
173                dimension: wgpu::TextureDimension::D2,
174                format,
175                usage: wgpu::TextureUsages::RENDER_ATTACHMENT
176                    | wgpu::TextureUsages::TEXTURE_BINDING,
177                view_formats: &[],
178            });
179            Intermediate { texture, size }
180        })
181    }
182
183    /// Render one frosted frame. `ctx` is the host's egui context: the adapter applies the
184    /// surfaces' [`RepaintPolicy`] to it (`request_repaint` for `Live`, `request_repaint_after`
185    /// for `Bounded`) so a stale backdrop cannot be silently forgotten (§4.6 — the adapter, not
186    /// the host, drives the repaint). `frame` carries the tessellated egui output + the target.
187    pub fn render_frame(
188        &mut self,
189        device: &wgpu::Device,
190        queue: &wgpu::Queue,
191        ctx: &egui::Context,
192        blur: &mut WgpuBlur,
193        frame: FrameInput<'_>,
194        surfaces: &[Surface],
195    ) -> Result<(), BlurError> {
196        // 1. Texture deltas first.
197        for (id, delta) in &frame.textures_delta.set {
198            self.renderer.update_texture(device, queue, *id, delta);
199        }
200
201        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
202            label: Some("backdrop-blur own-loop frame"),
203        });
204
205        // 2. Upload vertex/index/uniform buffers; keep the returned command buffers to submit
206        //    BEFORE the main encoder (egui-wgpu does not auto-submit them).
207        let egui_buffers = self.renderer.update_buffers(
208            device,
209            queue,
210            &mut encoder,
211            frame.paint_jobs,
212            &frame.screen,
213        );
214
215        // One owned view of the intermediate, used by reference for the egui→intermediate pass
216        // (the pass clones it via forget_lifetime) and then moved into the blur `SourceView`.
217        let size = frame.screen.size_in_pixels;
218        let intermediate_view = self
219            .intermediate(device, size)
220            .texture
221            .create_view(&wgpu::TextureViewDescriptor::default());
222
223        // 3 + 4. Render egui into the intermediate (blur source) and into the target (display).
224        //        Each render pass is scoped and dropped before the encoder is touched again — a
225        //        live `forget_lifetime` pass plus an encoder op is a runtime panic (M4).
226        {
227            let mut pass = begin_clear_pass(
228                &mut encoder,
229                &intermediate_view,
230                "backdrop-blur egui→intermediate",
231            );
232            self.renderer
233                .render(&mut pass, frame.paint_jobs, &frame.screen);
234        }
235        {
236            let mut pass =
237                begin_clear_pass(&mut encoder, frame.target, "backdrop-blur egui→target");
238            self.renderer
239                .render(&mut pass, frame.paint_jobs, &frame.screen);
240        }
241
242        // 5. Blur + composite each surface, sampling the intermediate, writing the target.
243        let source = SourceView {
244            view: intermediate_view,
245            size,
246            color_space: SourceColorSpace::GammaSrgb,
247        };
248        composite_surfaces(
249            blur,
250            SeamContext {
251                device,
252                queue,
253                encoder: &mut encoder,
254                source: &source,
255                target: frame.target,
256                target_format: self.target_format,
257            },
258            surfaces,
259            frame.screen.pixels_per_point,
260        )?;
261
262        // 6. One submit: egui's upload buffers, then the main encoder.
263        let main = encoder.finish();
264        queue.submit(egui_buffers.into_iter().chain(std::iter::once(main)));
265
266        // Free textures egui dropped this frame.
267        for id in &frame.textures_delta.free {
268            self.renderer.free_texture(id);
269        }
270
271        // The adapter drives liveness: keep the backdrop fresh for Live/Bounded surfaces.
272        match strongest_repaint(surfaces) {
273            RepaintPolicy::Live => ctx.request_repaint(),
274            RepaintPolicy::Bounded(after) => ctx.request_repaint_after(after),
275            RepaintPolicy::Static => {}
276        }
277        Ok(())
278    }
279}
280
281/// One frame's egui output plus where to draw it.
282pub struct FrameInput<'a> {
283    /// The display target (swapchain view); must have the adapter's `target_format`.
284    pub target: &'a wgpu::TextureView,
285    /// The tessellated egui primitives for this frame.
286    ///
287    /// **Backdrop-Root rule (host obligation):** v1 renders this *same* frame into both the blur
288    /// source and the display, and the blur samples the surface's own screen area. So the host
289    /// must **not** paint a frosted surface's own background/fill into these jobs — otherwise the
290    /// blur samples the panel's fill instead of the content behind it. The crate owns only the
291    /// background; the surface's foreground is the host's, painted in its own later pass.
292    pub paint_jobs: &'a [egui::ClippedPrimitive],
293    /// The textures egui created/freed this frame.
294    pub textures_delta: &'a egui::TexturesDelta,
295    /// Screen size (physical px) + pixels-per-point.
296    pub screen: egui_wgpu::ScreenDescriptor,
297}
298
299/// Begin a render pass that clears `view`, returning a `'static` pass (egui-wgpu's `render`
300/// requires `RenderPass<'static>`). The caller must drop it before reusing the encoder.
301fn begin_clear_pass(
302    encoder: &mut wgpu::CommandEncoder,
303    view: &wgpu::TextureView,
304    label: &str,
305) -> wgpu::RenderPass<'static> {
306    encoder
307        .begin_render_pass(&wgpu::RenderPassDescriptor {
308            label: Some(label),
309            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
310                view,
311                resolve_target: None,
312                depth_slice: None,
313                ops: wgpu::Operations {
314                    load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
315                    store: wgpu::StoreOp::Store,
316                },
317            })],
318            depth_stencil_attachment: None,
319            timestamp_writes: None,
320            occlusion_query_set: None,
321            multiview_mask: None,
322        })
323        .forget_lifetime()
324}
325
326#[cfg(test)]
327mod tests {
328    // Coverage boundary: these default-tier tests cover the backend-agnostic surface→prepare/record
329    // mapping (`composite_surfaces`), the repaint fold, and the format guard. `render_frame`'s
330    // frame ordering (update_buffers → scoped passes dropped before encoder reuse → single chained
331    // submit) needs real egui-wgpu + a GPU, so it is covered only by the gated `own_loop_render`
332    // test (`--features image-snapshots`, lavapipe), not the always-on `cargo test`.
333    use super::*;
334    use backdrop_blur_core::{BlurStrength, CornerRadius, Tint};
335    use std::cell::RefCell;
336
337    #[test]
338    fn is_supported_target_accepts_only_non_srgb_unorm() {
339        assert!(is_supported_target(wgpu::TextureFormat::Rgba8Unorm));
340        assert!(is_supported_target(wgpu::TextureFormat::Bgra8Unorm));
341        // sRGB targets would double-decode the gamma intermediate — rejected.
342        assert!(!is_supported_target(wgpu::TextureFormat::Rgba8UnormSrgb));
343        assert!(!is_supported_target(wgpu::TextureFormat::Bgra8UnormSrgb));
344        assert!(!is_supported_target(wgpu::TextureFormat::Rgba16Float));
345    }
346
347    /// A recording fake backend: all associated types are `()`, so the surface→prepare/record
348    /// wiring runs with no GPU. It returns `Ok(None)` for a zero-area region (the no-op), mirroring
349    /// the real backend's clip behavior, so the test can assert "prepare always, record only when
350    /// the region is non-empty".
351    #[derive(Default)]
352    struct RecordingBlur {
353        events: RefCell<Vec<&'static str>>,
354    }
355
356    impl BackdropBlur for RecordingBlur {
357        type Device = ();
358        type Queue = ();
359        type Encoder = ();
360        type SourceTexture = ();
361        type Target = ();
362        type TargetFormat = ();
363        type Prepared = ();
364
365        fn prepare(
366            &mut self,
367            _device: &(),
368            _queue: &(),
369            _source: &(),
370            _target_format: (),
371            request: &BlurRequest,
372        ) -> Result<Option<()>, BlurError> {
373            self.events.borrow_mut().push("prepare");
374            if request.source_region.size[0] == 0 || request.source_region.size[1] == 0 {
375                Ok(None)
376            } else {
377                Ok(Some(()))
378            }
379        }
380
381        fn record(&self, _encoder: &mut (), _target: &(), _prepared: &()) -> Result<(), BlurError> {
382            self.events.borrow_mut().push("record");
383            Ok(())
384        }
385    }
386
387    fn surface(rect: egui::Rect) -> Surface {
388        Surface {
389            rect,
390            strength: BlurStrength::new(8.0),
391            tint: Tint::new(backdrop_blur_core::LinearRgba::new(0.0, 0.0, 0.0, 0.1)),
392            corner_radius: CornerRadius::new(12.0),
393            opacity: backdrop_blur_core::Opacity::default(),
394            repaint: RepaintPolicy::Static,
395        }
396    }
397
398    #[test]
399    fn composite_surfaces_prepares_each_and_records_only_non_empty() {
400        let mut blur = RecordingBlur::default();
401        let surfaces = [
402            surface(egui::Rect::from_min_size(
403                egui::pos2(10.0, 10.0),
404                egui::vec2(100.0, 60.0),
405            )),
406            surface(egui::Rect::from_min_size(
407                egui::pos2(0.0, 0.0),
408                egui::vec2(0.0, 0.0),
409            )), // empty → no-op
410            surface(egui::Rect::from_min_size(
411                egui::pos2(50.0, 50.0),
412                egui::vec2(80.0, 40.0),
413            )),
414        ];
415        let recorded = composite_surfaces(
416            &mut blur,
417            SeamContext {
418                device: &(),
419                queue: &(),
420                encoder: &mut (),
421                source: &(),
422                target: &(),
423                target_format: (),
424            },
425            &surfaces,
426            1.0,
427        )
428        .expect("the fake backend never errors");
429
430        assert_eq!(recorded, 2);
431        let events = blur.events.into_inner();
432        assert_eq!(
433            events.iter().filter(|e| **e == "prepare").count(),
434            3,
435            "prepare runs for every surface"
436        );
437        assert_eq!(
438            events.iter().filter(|e| **e == "record").count(),
439            2,
440            "record skips the empty surface"
441        );
442        // Order proof: the empty surface prepares but does not record between the two real ones.
443        assert_eq!(
444            events,
445            vec!["prepare", "record", "prepare", "prepare", "record"]
446        );
447    }
448
449    #[test]
450    fn strongest_repaint_prefers_live_then_shortest_bounded() {
451        use std::time::Duration;
452        let live = surface(egui::Rect::ZERO);
453        let mut live = live;
454        live.repaint = RepaintPolicy::Live;
455
456        let mut bounded_long = surface(egui::Rect::ZERO);
457        bounded_long.repaint = RepaintPolicy::Bounded(Duration::from_millis(500));
458        let mut bounded_short = surface(egui::Rect::ZERO);
459        bounded_short.repaint = RepaintPolicy::Bounded(Duration::from_millis(100));
460
461        assert_eq!(strongest_repaint(&[]), RepaintPolicy::Static);
462        assert_eq!(
463            strongest_repaint(&[bounded_long, bounded_short]),
464            RepaintPolicy::Bounded(Duration::from_millis(100))
465        );
466        assert_eq!(
467            strongest_repaint(&[bounded_long, live]),
468            RepaintPolicy::Live
469        );
470    }
471}