Skip to main content

backdrop_blur_wgpu/
lib.rs

1//! `backdrop-blur-wgpu` — the wgpu backend for [`backdrop_blur_core`]'s frosted-glass seam.
2//!
3//! It implements [`BackdropBlur`] with a safe, WGSL pipeline: a **separable Gaussian** blur for
4//! small radii and **dual-Kawase** (down/up-sample, the production-compositor algorithm) for large
5//! radii, selected by a radius threshold, followed by a tinted, rounded-rect-masked composite. The
6//! crate is `#![forbid(unsafe_code)]`; the only place that *could* want `unsafe` — the GPU-uniform
7//! `Pod` impls — uses bytemuck derives.
8//!
9//! # What the host provides
10//!
11//! The own-loop host renders its UI into an offscreen intermediate and hands it to [`prepare`]
12//! as a [`SourceView`] — the texture view **plus its size and color space**, because a
13//! `wgpu::TextureView` exposes neither and the backend needs both (the size to clip/scale, the
14//! color space to know whether to sRGB-decode on sample — egui renders gamma-encoded, egui#3168).
15//! The host owns the final [`Target`](BackdropBlur::Target); the backend owns only the internal
16//! ping-pong scratch.
17//!
18//! # On error handling
19//!
20//! wgpu resource creation (textures, buffers, pipelines) does **not** return `Result` — OOM and
21//! validation faults surface through the device's error handler, not synchronously — so this
22//! backend cannot map them to [`BlurError::ResourceCreation`] without an async error-scope pass
23//! (a candidate refinement). The error it *does* return synchronously is
24//! [`BlurError::UnsupportedTarget`], checked against an allowlist before any GPU call.
25//!
26//! [`prepare`]: BackdropBlur::prepare
27//! [`backdrop_blur_core`]: backdrop_blur_core
28#![forbid(unsafe_code)]
29
30use std::collections::HashMap;
31
32use backdrop_blur_core::{BackdropBlur, BlurError, BlurRequest, BlurStage, ResolvedMask};
33use wgpu::util::DeviceExt as _;
34
35mod cache;
36mod uniforms;
37
38use cache::{
39    PingPongKey, RETENTION_FRAMES, SCRATCH_FORMAT, TargetEncoding, backdrop_uv_remap,
40    composite_encode_srgb, evict_decision, kawase_halfpixel, kawase_level_size, resolve_gaussian,
41    resolve_kawase_levels, use_dual_kawase,
42};
43use uniforms::{CompositeParams, GaussianParams, KawaseParams};
44
45/// The color space of the backdrop the host hands in. egui renders **gamma-encoded** regardless
46/// of texture format (egui#3168), so its intermediate is [`GammaSrgb`](Self::GammaSrgb) and must
47/// be decoded before the linear-light convolution. A host that renders linear uses [`Linear`].
48///
49/// [`Linear`]: Self::Linear
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum SourceColorSpace {
52    /// sRGB gamma-encoded values (egui). Decoded to linear on sample.
53    GammaSrgb,
54    /// Already linear-light values. Sampled as-is.
55    Linear,
56}
57
58/// The backdrop source: a sampleable view plus the two things a `wgpu::TextureView` cannot tell
59/// the backend on its own — the texture's pixel size and its color space. The host constructs
60/// one per frame from its offscreen intermediate; it owns the view for the call's duration.
61pub struct SourceView {
62    /// A sampleable view of the host's intermediate texture.
63    pub view: wgpu::TextureView,
64    /// The intermediate's `[width, height]` in physical pixels.
65    pub size: [u32; 2],
66    /// Whether the intermediate holds gamma-encoded or linear values.
67    pub color_space: SourceColorSpace,
68}
69
70/// The owned, per-call handle from `prepare` to `record`: the resolved blur (which algorithm +
71/// its bind groups), the composite bind group, and `generation` (so `record` can debug-assert the
72/// serial prepare→record contract — a stale handle would alias clobbered scratch, K1). The bind
73/// groups already hold their textures/uniforms via wgpu's internal refcounting.
74pub struct WgpuPrepared {
75    target_format: wgpu::TextureFormat,
76    generation: u64,
77    blur: PreparedBlur,
78    composite_bind: wgpu::BindGroup,
79}
80
81/// The resolved blur for one surface: either the separable Gaussian (small radius) or dual-Kawase
82/// (large radius). Each variant carries the bind groups its `record` passes replay; the keyed
83/// scratch they target lives in [`WgpuBlur`].
84enum PreparedBlur {
85    /// Horizontal then vertical Gaussian into the 2-texture ping-pong; composite samples B.
86    Gaussian {
87        key: PingPongKey,
88        horizontal_bind: wgpu::BindGroup,
89        vertical_bind: wgpu::BindGroup,
90    },
91    /// Prefilter (decode+remap into mip 0) → `N` downsamples → `N` upsamples back to mip 0;
92    /// composite samples mip 0.
93    DualKawase {
94        key: PingPongKey,
95        prefilter_bind: wgpu::BindGroup,
96        down_binds: Vec<wgpu::BindGroup>,
97        up_binds: Vec<wgpu::BindGroup>,
98    },
99}
100
101/// The two same-size `Rgba16Float` ping-pong views for the Gaussian path: the horizontal pass
102/// writes A (`views[0]`), the vertical pass writes B (`views[1]`), the composite samples B. Only
103/// the views are stored — a `wgpu::TextureView` keeps its parent texture alive by refcount.
104struct ScratchChain {
105    views: [wgpu::TextureView; 2],
106    /// The frame this chain was last touched by `ensure_scratch`; drives last-frame-used eviction.
107    last_used_frame: u64,
108}
109
110/// A dual-Kawase mip pyramid plus the frame it was last used: `N + 1` decreasing-size views (level 0
111/// = full clipped size). The `last_used_frame` drives last-frame-used eviction, exactly as
112/// [`ScratchChain`] does for the Gaussian path.
113struct PyramidChain {
114    views: Vec<wgpu::TextureView>,
115    /// The frame this chain was last touched by `ensure_pyramid`; drives last-frame-used eviction.
116    last_used_frame: u64,
117}
118
119/// The wgpu implementation of [`BackdropBlur`]. Holds the fixed pipeline machinery (bind-group
120/// layout, sampler, Gaussian/downsample/upsample pipelines) and the per-`(size)` scratch
121/// (Gaussian ping-pong + dual-Kawase pyramid) + per-target-format composite caches, so repeated
122/// frosted surfaces reuse them.
123pub struct WgpuBlur {
124    pipeline_layout: wgpu::PipelineLayout,
125    bind_group_layout: wgpu::BindGroupLayout,
126    sampler: wgpu::Sampler,
127    gaussian_pipeline: wgpu::RenderPipeline,
128    downsample_pipeline: wgpu::RenderPipeline,
129    upsample_pipeline: wgpu::RenderPipeline,
130    composite_shader: wgpu::ShaderModule,
131    composite_pipelines: HashMap<wgpu::TextureFormat, wgpu::RenderPipeline>,
132    scratch: HashMap<PingPongKey, ScratchChain>,
133    /// Dual-Kawase mip pyramids: `N + 1` decreasing-size views, level 0 = full clipped size.
134    pyramids: HashMap<PingPongKey, PyramidChain>,
135    /// Advanced once per `prepare` ([`Self::begin_frame`]); the "now" last-frame-used eviction
136    /// compares each chain's `last_used_frame` against, so a resized/moved surface's old-size chains
137    /// are dropped instead of accumulating one per distinct size forever.
138    frame: u64,
139    /// Bumped each `prepare`; stamped into [`WgpuPrepared`] so `record` can detect a stale handle.
140    generation: u64,
141}
142
143// --- Constructors ---
144
145impl WgpuBlur {
146    /// Build the fixed pipeline machinery. The Gaussian pipeline (always writing the internal
147    /// scratch format) is built now; composite pipelines are built lazily per target format.
148    pub fn new(device: &wgpu::Device) -> Self {
149        let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
150            label: Some("backdrop-blur bind group layout"),
151            entries: &[
152                wgpu::BindGroupLayoutEntry {
153                    binding: 0,
154                    visibility: wgpu::ShaderStages::FRAGMENT,
155                    ty: wgpu::BindingType::Texture {
156                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
157                        view_dimension: wgpu::TextureViewDimension::D2,
158                        multisampled: false,
159                    },
160                    count: None,
161                },
162                wgpu::BindGroupLayoutEntry {
163                    binding: 1,
164                    visibility: wgpu::ShaderStages::FRAGMENT,
165                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
166                    count: None,
167                },
168                wgpu::BindGroupLayoutEntry {
169                    binding: 2,
170                    visibility: wgpu::ShaderStages::FRAGMENT,
171                    ty: wgpu::BindingType::Buffer {
172                        ty: wgpu::BufferBindingType::Uniform,
173                        has_dynamic_offset: false,
174                        min_binding_size: None,
175                    },
176                    count: None,
177                },
178            ],
179        });
180
181        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
182            label: Some("backdrop-blur pipeline layout"),
183            bind_group_layouts: &[Some(&bind_group_layout)],
184            immediate_size: 0,
185        });
186
187        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
188            label: Some("backdrop-blur sampler"),
189            address_mode_u: wgpu::AddressMode::ClampToEdge,
190            address_mode_v: wgpu::AddressMode::ClampToEdge,
191            address_mode_w: wgpu::AddressMode::ClampToEdge,
192            mag_filter: wgpu::FilterMode::Linear,
193            min_filter: wgpu::FilterMode::Linear,
194            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
195            ..Default::default()
196        });
197
198        let gaussian_shader =
199            device.create_shader_module(wgpu::include_wgsl!("shaders/gaussian.wgsl"));
200        let downsample_shader =
201            device.create_shader_module(wgpu::include_wgsl!("shaders/downsample.wgsl"));
202        let upsample_shader =
203            device.create_shader_module(wgpu::include_wgsl!("shaders/upsample.wgsl"));
204        let composite_shader =
205            device.create_shader_module(wgpu::include_wgsl!("shaders/composite.wgsl"));
206
207        // All blur passes write the internal scratch format with no blend; only the composite
208        // matches the caller's format and blends.
209        let gaussian_pipeline = build_pipeline(
210            device,
211            &pipeline_layout,
212            &gaussian_shader,
213            SCRATCH_FORMAT,
214            None,
215        );
216        let downsample_pipeline = build_pipeline(
217            device,
218            &pipeline_layout,
219            &downsample_shader,
220            SCRATCH_FORMAT,
221            None,
222        );
223        let upsample_pipeline = build_pipeline(
224            device,
225            &pipeline_layout,
226            &upsample_shader,
227            SCRATCH_FORMAT,
228            None,
229        );
230
231        Self {
232            pipeline_layout,
233            bind_group_layout,
234            sampler,
235            gaussian_pipeline,
236            downsample_pipeline,
237            upsample_pipeline,
238            composite_shader,
239            composite_pipelines: HashMap::new(),
240            scratch: HashMap::new(),
241            pyramids: HashMap::new(),
242            frame: 0,
243            generation: 0,
244        }
245    }
246}
247
248// --- Internal resource management ---
249
250impl WgpuBlur {
251    /// Advance to the next frame and evict every scratch/pyramid chain untouched for
252    /// [`RETENTION_FRAMES`]. Called once at the top of [`prepare`](BackdropBlur::prepare), before any
253    /// `ensure_*`, so the chain a surface is about to use this frame is never evicted. Dropping a
254    /// `HashMap` entry drops its `wgpu::TextureView`s, releasing the underlying textures by refcount
255    /// — no explicit GPU free. The stale-set decision is the pure, core-shared [`evict_decision`].
256    fn begin_frame(&mut self) {
257        self.frame = self.frame.wrapping_add(1);
258        let stale = evict_decision(
259            self.scratch.iter().map(|(k, c)| (*k, c.last_used_frame)),
260            self.frame,
261            RETENTION_FRAMES,
262        );
263        for key in stale {
264            self.scratch.remove(&key);
265        }
266        let stale = evict_decision(
267            self.pyramids.iter().map(|(k, c)| (*k, c.last_used_frame)),
268            self.frame,
269            RETENTION_FRAMES,
270        );
271        for key in stale {
272            self.pyramids.remove(&key);
273        }
274    }
275
276    /// Create the two Gaussian ping-pong textures for `key` if not already cached, and mark the chain
277    /// used this frame so eviction keeps it.
278    fn ensure_scratch(&mut self, device: &wgpu::Device, key: PingPongKey) {
279        if let Some(chain) = self.scratch.get_mut(&key) {
280            chain.last_used_frame = self.frame;
281            return;
282        }
283        let view_a = scratch_view(device, key.size, "backdrop-blur scratch A");
284        let view_b = scratch_view(device, key.size, "backdrop-blur scratch B");
285        self.scratch.insert(
286            key,
287            ScratchChain {
288                views: [view_a, view_b],
289                last_used_frame: self.frame,
290            },
291        );
292    }
293
294    /// Create the dual-Kawase mip pyramid for `key` (`key.levels` = `N` → `N + 1` views, level 0
295    /// at the full clipped size, level `i` halved) if not already cached.
296    fn ensure_pyramid(&mut self, device: &wgpu::Device, key: PingPongKey) {
297        if let Some(chain) = self.pyramids.get_mut(&key) {
298            chain.last_used_frame = self.frame;
299            return;
300        }
301        let views = (0..=key.levels)
302            .map(|level| {
303                let size = kawase_level_size(key.size, level);
304                scratch_view(device, size, "backdrop-blur kawase mip")
305            })
306            .collect();
307        self.pyramids.insert(
308            key,
309            PyramidChain {
310                views,
311                last_used_frame: self.frame,
312            },
313        );
314    }
315
316    /// Build and cache the composite pipeline for `format` if not already present.
317    fn ensure_composite_pipeline(&mut self, device: &wgpu::Device, format: wgpu::TextureFormat) {
318        if self.composite_pipelines.contains_key(&format) {
319            return;
320        }
321        let pipeline = build_pipeline(
322            device,
323            &self.pipeline_layout,
324            &self.composite_shader,
325            format,
326            Some(over_blend()),
327        );
328        self.composite_pipelines.insert(format, pipeline);
329    }
330
331    /// One bind group: a sampled texture view + the shared sampler + a uniform buffer.
332    fn bind(
333        &self,
334        device: &wgpu::Device,
335        view: &wgpu::TextureView,
336        uniform: &wgpu::Buffer,
337        label: &str,
338    ) -> wgpu::BindGroup {
339        device.create_bind_group(&wgpu::BindGroupDescriptor {
340            label: Some(label),
341            layout: &self.bind_group_layout,
342            entries: &[
343                wgpu::BindGroupEntry {
344                    binding: 0,
345                    resource: wgpu::BindingResource::TextureView(view),
346                },
347                wgpu::BindGroupEntry {
348                    binding: 1,
349                    resource: wgpu::BindingResource::Sampler(&self.sampler),
350                },
351                wgpu::BindGroupEntry {
352                    binding: 2,
353                    resource: uniform.as_entire_binding(),
354                },
355            ],
356        })
357    }
358}
359
360// --- Test-support (gated) ---
361
362#[cfg(feature = "image-snapshots")]
363impl WgpuBlur {
364    /// The number of cached scratch + pyramid chains. Exposed only under the `image-snapshots` test
365    /// feature so the gated GPU tier can assert eviction actually bounds the cache as a surface is
366    /// dragged/resized (the leak guard). Not part of the public API.
367    pub fn cached_chain_count(&self) -> usize {
368        self.scratch.len() + self.pyramids.len()
369    }
370}
371
372// --- The seam ---
373
374impl BackdropBlur for WgpuBlur {
375    type Device = wgpu::Device;
376    type Queue = wgpu::Queue;
377    type Encoder = wgpu::CommandEncoder;
378    type SourceTexture = SourceView;
379    type Target = wgpu::TextureView;
380    type TargetFormat = wgpu::TextureFormat;
381    type Prepared = WgpuPrepared;
382
383    fn prepare(
384        &mut self,
385        device: &Self::Device,
386        _queue: &Self::Queue,
387        source: &Self::SourceTexture,
388        target_format: Self::TargetFormat,
389        request: &BlurRequest,
390    ) -> Result<Option<Self::Prepared>, BlurError> {
391        let Some(clipped) = request.source_region.clip_to(source.size) else {
392            return Ok(None); // zero-area or fully-offscreen region → no-op
393        };
394
395        // Advance the eviction clock and drop scratch chains untouched for RETENTION_FRAMES, before
396        // ensuring this frame's chain — so a resized/moved surface's old-size chains are freed rather
397        // than accumulating. Placed *after* the clip guard, mirroring the glow backend (blur.rs:99):
398        // the counter counts frosted frames, so a surface that clips to nothing does not age out a
399        // chain it is about to reuse when it returns on-screen.
400        self.begin_frame();
401
402        let encode_srgb = matches!(
403            composite_encode_srgb(target_format).ok_or_else(|| BlurError::UnsupportedTarget {
404                format: format!("{target_format:?}"),
405            })?,
406            TargetEncoding::Srgb
407        );
408        let decode_srgb = matches!(source.color_space, SourceColorSpace::GammaSrgb);
409        self.ensure_composite_pipeline(device, target_format);
410
411        let radius = request.physical_blur_radius();
412        let [source_w, source_h] = [source.size[0] as f32, source.size[1] as f32];
413        let [clip_x, clip_y] = [clipped.origin[0] as f32, clipped.origin[1] as f32];
414        let [clip_w, clip_h] = [clipped.size[0] as f32, clipped.size[1] as f32];
415        // Maps a full-scratch [0,1] onto the gamma source sub-rect (shared by the Gaussian
416        // horizontal pass and the dual-Kawase prefilter, both of which sample the source).
417        let remap_offset = [clip_x / source_w, clip_y / source_h];
418        let remap_scale = [clip_w / source_w, clip_h / source_h];
419
420        // The composite is identical for both algorithms; only the texture it samples differs
421        // (Gaussian scratch B vs Kawase mip 0). `backdrop_uv_*` keeps a clipped source registered
422        // 1:1 with the content behind the glass.
423        let (backdrop_uv_offset, backdrop_uv_scale) =
424            backdrop_uv_remap(&request.source_region, &clipped);
425        let mask = ResolvedMask::from_target(&request.target_rect, request.corner_radius);
426        let tint = request.tint.color();
427        let composite = CompositeParams::new(
428            [
429                request.target_rect.origin[0] as f32,
430                request.target_rect.origin[1] as f32,
431            ],
432            [
433                request.target_rect.size[0] as f32,
434                request.target_rect.size[1] as f32,
435            ],
436            [tint.r(), tint.g(), tint.b(), tint.a()],
437            backdrop_uv_offset,
438            backdrop_uv_scale,
439            mask.corner_radius_px,
440            encode_srgb,
441            request.opacity.value(),
442        );
443        let composite_buf = uniform_buffer(device, &composite, "backdrop-blur composite");
444
445        let (blur, composite_bind) = if use_dual_kawase(radius) {
446            let levels = resolve_kawase_levels(radius);
447            let key = PingPongKey {
448                size: clipped.size,
449                levels,
450            };
451            self.ensure_pyramid(device, key);
452            let n = levels as usize;
453
454            // Prefilter: source (gamma, sub-rect) → mip 0 (linear), via the Gaussian pipeline at
455            // radius 0 — a pure decode + remap, no blur.
456            let prefilter = GaussianParams::new(
457                remap_offset,
458                remap_scale,
459                [1.0 / source_w, 1.0 / source_h],
460                [1.0, 0.0],
461                0.5,
462                0,
463                decode_srgb,
464            );
465            let prefilter_buf =
466                uniform_buffer(device, &prefilter, "backdrop-blur kawase-prefilter");
467            // Per-pass half-pixel offsets: each pass samples a known mip level.
468            let down_bufs: Vec<wgpu::Buffer> = (0..n)
469                .map(|i| {
470                    let hp = kawase_halfpixel(kawase_level_size(clipped.size, i as u32));
471                    uniform_buffer(device, &KawaseParams::new(hp), "backdrop-blur kawase-down")
472                })
473                .collect();
474            let up_bufs: Vec<wgpu::Buffer> = (0..n)
475                .map(|j| {
476                    let hp = kawase_halfpixel(kawase_level_size(clipped.size, (n - j) as u32));
477                    uniform_buffer(device, &KawaseParams::new(hp), "backdrop-blur kawase-up")
478                })
479                .collect();
480
481            let pyramid = self
482                .pyramids
483                .get(&key)
484                .ok_or_else(|| BlurError::ResourceCreation {
485                    stage: BlurStage::PingPongTexture,
486                    source: "kawase pyramid missing immediately after ensure_pyramid".into(),
487                })?;
488            let pyramid = &pyramid.views;
489            let prefilter_bind = self.bind(
490                device,
491                &source.view,
492                &prefilter_buf,
493                "backdrop-blur prefilter-bind",
494            );
495            let down_binds = down_bufs
496                .iter()
497                .enumerate()
498                .map(|(i, buf)| self.bind(device, &pyramid[i], buf, "backdrop-blur down-bind"))
499                .collect();
500            let up_binds = up_bufs
501                .iter()
502                .enumerate()
503                .map(|(j, buf)| self.bind(device, &pyramid[n - j], buf, "backdrop-blur up-bind"))
504                .collect();
505            let composite_bind = self.bind(
506                device,
507                &pyramid[0],
508                &composite_buf,
509                "backdrop-blur composite-bind",
510            );
511
512            (
513                PreparedBlur::DualKawase {
514                    key,
515                    prefilter_bind,
516                    down_binds,
517                    up_binds,
518                },
519                composite_bind,
520            )
521        } else {
522            let kernel = resolve_gaussian(radius);
523            let key = PingPongKey {
524                size: clipped.size,
525                levels: 1,
526            };
527            self.ensure_scratch(device, key);
528
529            // Pass 1 maps the scratch onto the source sub-rect and decodes; pass 2 samples the
530            // full (linear) scratch A.
531            let horizontal = GaussianParams::new(
532                remap_offset,
533                remap_scale,
534                [1.0 / source_w, 1.0 / source_h],
535                [1.0, 0.0],
536                kernel.sigma,
537                kernel.tap_radius,
538                decode_srgb,
539            );
540            let vertical = GaussianParams::new(
541                [0.0, 0.0],
542                [1.0, 1.0],
543                [1.0 / clip_w, 1.0 / clip_h],
544                [0.0, 1.0],
545                kernel.sigma,
546                kernel.tap_radius,
547                false,
548            );
549            let horizontal_buf = uniform_buffer(device, &horizontal, "backdrop-blur gaussian-h");
550            let vertical_buf = uniform_buffer(device, &vertical, "backdrop-blur gaussian-v");
551
552            let chain = self
553                .scratch
554                .get(&key)
555                .ok_or_else(|| BlurError::ResourceCreation {
556                    stage: BlurStage::PingPongTexture,
557                    source: "scratch chain missing immediately after ensure_scratch".into(),
558                })?;
559            let horizontal_bind = self.bind(
560                device,
561                &source.view,
562                &horizontal_buf,
563                "backdrop-blur h-bind",
564            );
565            let vertical_bind = self.bind(
566                device,
567                &chain.views[0],
568                &vertical_buf,
569                "backdrop-blur v-bind",
570            );
571            let composite_bind = self.bind(
572                device,
573                &chain.views[1],
574                &composite_buf,
575                "backdrop-blur composite-bind",
576            );
577
578            (
579                PreparedBlur::Gaussian {
580                    key,
581                    horizontal_bind,
582                    vertical_bind,
583                },
584                composite_bind,
585            )
586        };
587
588        self.generation += 1;
589        Ok(Some(WgpuPrepared {
590            target_format,
591            generation: self.generation,
592            blur,
593            composite_bind,
594        }))
595    }
596
597    fn record(
598        &self,
599        encoder: &mut Self::Encoder,
600        target: &Self::Target,
601        prepared: &Self::Prepared,
602    ) -> Result<(), BlurError> {
603        // v1 is serial prepare→record per surface: this must be the most recent prepare, or its
604        // shared scratch has already been clobbered by a newer one (K1). Debug-only — release
605        // builds trust the contract.
606        debug_assert_eq!(
607            prepared.generation, self.generation,
608            "record called with a stale Prepared (a newer prepare clobbered the shared scratch); \
609             v1 requires serial prepare→record per surface (K1)"
610        );
611        let composite_pipeline = self
612            .composite_pipelines
613            .get(&prepared.target_format)
614            .ok_or_else(|| BlurError::ResourceCreation {
615                stage: BlurStage::CompositePipeline,
616                source: "composite pipeline missing at record".into(),
617            })?;
618
619        // Blur into the scratch (Gaussian ping-pong, or the dual-Kawase pyramid).
620        self.record_blur(encoder, &prepared.blur)?;
621
622        // Composite: the final blurred texture → target, over the WHOLE attachment (default
623        // viewport). The
624        // rounded-rect coverage forms every edge, so straight sides are anti-aliased and an
625        // off-target rect cannot trip scissor validation; coverage 0 outside the panel keeps
626        // LoadOp::Load content untouched. (A scissor to the panel + AA margin is a future perf
627        // optimization once the host threads the target size in.)
628        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
629            label: Some("backdrop-blur composite-pass"),
630            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
631                view: target,
632                resolve_target: None,
633                depth_slice: None,
634                ops: wgpu::Operations {
635                    load: wgpu::LoadOp::Load,
636                    store: wgpu::StoreOp::Store,
637                },
638            })],
639            depth_stencil_attachment: None,
640            timestamp_writes: None,
641            occlusion_query_set: None,
642            multiview_mask: None,
643        });
644        pass.set_pipeline(composite_pipeline);
645        pass.set_bind_group(0, &prepared.composite_bind, &[]);
646        pass.draw(0..3, 0..1);
647        Ok(())
648    }
649}
650
651// --- Pass + buffer helpers ---
652
653/// Create a UNIFORM buffer initialized with `value`'s bytes.
654fn uniform_buffer<T: bytemuck::Pod>(device: &wgpu::Device, value: &T, label: &str) -> wgpu::Buffer {
655    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
656        label: Some(label),
657        contents: bytemuck::bytes_of(value),
658        usage: wgpu::BufferUsages::UNIFORM,
659    })
660}
661
662/// A `[width, height]` `SCRATCH_FORMAT` texture usable as both a sampled source and a render
663/// target, returned as a view (the view keeps the texture alive by refcount).
664fn scratch_view(device: &wgpu::Device, size: [u32; 2], label: &str) -> wgpu::TextureView {
665    let texture = device.create_texture(&wgpu::TextureDescriptor {
666        label: Some(label),
667        size: wgpu::Extent3d {
668            width: size[0],
669            height: size[1],
670            depth_or_array_layers: 1,
671        },
672        mip_level_count: 1,
673        sample_count: 1,
674        dimension: wgpu::TextureDimension::D2,
675        format: SCRATCH_FORMAT,
676        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
677        view_formats: &[],
678    });
679    texture.create_view(&wgpu::TextureViewDescriptor::default())
680}
681
682impl WgpuBlur {
683    /// Replay a prepared blur's passes into its scratch: the Gaussian horizontal/vertical, or the
684    /// dual-Kawase prefilter → downsamples → upsamples back to mip 0.
685    fn record_blur(
686        &self,
687        encoder: &mut wgpu::CommandEncoder,
688        blur: &PreparedBlur,
689    ) -> Result<(), BlurError> {
690        let missing = || BlurError::ResourceCreation {
691            stage: BlurStage::PingPongTexture,
692            source: "scratch missing at record (prepare not called, or evicted)".into(),
693        };
694        match blur {
695            PreparedBlur::Gaussian {
696                key,
697                horizontal_bind,
698                vertical_bind,
699            } => {
700                let chain = self.scratch.get(key).ok_or_else(missing)?;
701                self.blur_pass(
702                    encoder,
703                    &chain.views[0],
704                    horizontal_bind,
705                    &self.gaussian_pipeline,
706                    "backdrop-blur h-pass",
707                );
708                self.blur_pass(
709                    encoder,
710                    &chain.views[1],
711                    vertical_bind,
712                    &self.gaussian_pipeline,
713                    "backdrop-blur v-pass",
714                );
715            }
716            PreparedBlur::DualKawase {
717                key,
718                prefilter_bind,
719                down_binds,
720                up_binds,
721            } => {
722                let pyramid = self.pyramids.get(key).ok_or_else(missing)?;
723                let pyramid = &pyramid.views;
724                let n = key.levels as usize;
725                // Prefilter: source (gamma, sub-rect) → mip 0 (linear), via the Gaussian pipeline
726                // at radius 0 (a pure decode + remap).
727                self.blur_pass(
728                    encoder,
729                    &pyramid[0],
730                    prefilter_bind,
731                    &self.gaussian_pipeline,
732                    "backdrop-blur kawase-prefilter",
733                );
734                // Downsample i: mip[i] → mip[i+1].
735                for (i, bind) in down_binds.iter().enumerate() {
736                    self.blur_pass(
737                        encoder,
738                        &pyramid[i + 1],
739                        bind,
740                        &self.downsample_pipeline,
741                        "backdrop-blur kawase-down",
742                    );
743                }
744                // Upsample j: mip[n-j] → mip[n-1-j], ending at mip 0.
745                for (j, bind) in up_binds.iter().enumerate() {
746                    self.blur_pass(
747                        encoder,
748                        &pyramid[n - 1 - j],
749                        bind,
750                        &self.upsample_pipeline,
751                        "backdrop-blur kawase-up",
752                    );
753                }
754            }
755        }
756        Ok(())
757    }
758
759    /// A full-attachment blur pass (replace, no blend): clears then draws the oversized triangle
760    /// into `attachment` using `bind` and `pipeline`.
761    fn blur_pass(
762        &self,
763        encoder: &mut wgpu::CommandEncoder,
764        attachment: &wgpu::TextureView,
765        bind: &wgpu::BindGroup,
766        pipeline: &wgpu::RenderPipeline,
767        label: &str,
768    ) {
769        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
770            label: Some(label),
771            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
772                view: attachment,
773                resolve_target: None,
774                depth_slice: None,
775                ops: wgpu::Operations {
776                    load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
777                    store: wgpu::StoreOp::Store,
778                },
779            })],
780            depth_stencil_attachment: None,
781            timestamp_writes: None,
782            occlusion_query_set: None,
783            multiview_mask: None,
784        });
785        pass.set_pipeline(pipeline);
786        pass.set_bind_group(0, bind, &[]);
787        pass.draw(0..3, 0..1);
788    }
789}
790
791/// Standard non-premultiplied "over" blend for the composite.
792fn over_blend() -> wgpu::BlendState {
793    wgpu::BlendState {
794        color: wgpu::BlendComponent {
795            src_factor: wgpu::BlendFactor::SrcAlpha,
796            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
797            operation: wgpu::BlendOperation::Add,
798        },
799        alpha: wgpu::BlendComponent {
800            src_factor: wgpu::BlendFactor::One,
801            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
802            operation: wgpu::BlendOperation::Add,
803        },
804    }
805}
806
807/// Build a render pipeline from a shader module (a `vs_main`/`fs_main` pair) writing `format`,
808/// with optional blend. Used for both the Gaussian and the composite pipelines.
809fn build_pipeline(
810    device: &wgpu::Device,
811    layout: &wgpu::PipelineLayout,
812    shader: &wgpu::ShaderModule,
813    format: wgpu::TextureFormat,
814    blend: Option<wgpu::BlendState>,
815) -> wgpu::RenderPipeline {
816    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
817        label: Some("backdrop-blur pipeline"),
818        layout: Some(layout),
819        vertex: wgpu::VertexState {
820            module: shader,
821            entry_point: Some("vs_main"),
822            buffers: &[],
823            compilation_options: wgpu::PipelineCompilationOptions::default(),
824        },
825        fragment: Some(wgpu::FragmentState {
826            module: shader,
827            entry_point: Some("fs_main"),
828            targets: &[Some(wgpu::ColorTargetState {
829                format,
830                blend,
831                write_mask: wgpu::ColorWrites::ALL,
832            })],
833            compilation_options: wgpu::PipelineCompilationOptions::default(),
834        }),
835        primitive: wgpu::PrimitiveState {
836            topology: wgpu::PrimitiveTopology::TriangleList,
837            ..Default::default()
838        },
839        depth_stencil: None,
840        multisample: wgpu::MultisampleState::default(),
841        multiview_mask: None,
842        cache: None,
843    })
844}