Skip to main content

backdrop_blur_core/
algorithm.rs

1//! The GPU-free blur **policy** both backends share — hoisted here so the wgpu and glow paths
2//! resolve a radius to identical parameters (DESIGN §15). This is the ping-pong cache key, the
3//! physical-radius → Gaussian-kernel resolution, the dual-Kawase level/half-pixel math, the
4//! backdrop UV remap, and the [`TargetEncoding`] vocabulary. None of it names a GPU type, so it
5//! stays in `#![forbid(unsafe_code)]` core and is fully default-tier testable.
6//!
7//! **Reversal recorded honestly:** an earlier note in [`material`](crate::material) said "core
8//! has no notion of levels — that is the wgpu crate's". That was true when wgpu was the only
9//! backend; with glow needing the *same* level/half-pixel policy, duplicating it would risk the
10//! two backends drifting, so the GPU-free math moved up here. What stays backend-specific is the
11//! GPU *application* of these numbers — allocating the pyramid textures, binding the per-pass
12//! offset uniforms, the actual draws — not the policy that produces them.
13
14use crate::geometry::Region;
15
16/// Upper bound on the separable-Gaussian tap radius, so a huge `BlurStrength` cannot blow up the
17/// per-fragment loop. Tooltip/dialog blur sits far below this.
18pub const MAX_GAUSSIAN_RADIUS: i32 = 64;
19
20/// Keys a ping-pong scratch chain. `levels` is `1` for the separable Gaussian (no downsampling);
21/// the field exists so the dual-Kawase path keys its mip depth without a new type.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23pub struct PingPongKey {
24    /// The level-0 (full) size of the chain, in physical pixels.
25    pub size: [u32; 2],
26    /// The mip depth: `1` for the Gaussian, `N` for an `N`-level dual-Kawase pyramid.
27    pub levels: u32,
28}
29
30/// A resolved separable-Gaussian kernel: the standard deviation and the half-width (taps each
31/// side of center). `tap_radius == 0` is a single-tap pass-through (no blur).
32#[derive(Clone, Copy, Debug, PartialEq)]
33pub struct GaussianKernel {
34    /// The Gaussian standard deviation (always `> 0`, so the shader's `i / sigma` is finite).
35    pub sigma: f32,
36    /// Taps each side of center, clamped to [`MAX_GAUSSIAN_RADIUS`].
37    pub tap_radius: i32,
38}
39
40/// Resolve a physical-pixel blur radius to a Gaussian kernel. `sigma ≈ radius / 3` (three sigma
41/// spans the visual radius), floored at `0.5` so the shader's `i / sigma` is always finite even
42/// at `tap_radius == 0`; the tap radius is the rounded physical radius, clamped to the max.
43pub fn resolve_gaussian(physical_radius: f32) -> GaussianKernel {
44    let tap_radius = (physical_radius.round() as i32).clamp(0, MAX_GAUSSIAN_RADIUS);
45    let sigma = (physical_radius / 3.0).max(0.5);
46    GaussianKernel { sigma, tap_radius }
47}
48
49/// At/above this physical-pixel radius, dual-Kawase wins (downsampled, near-constant cost); below
50/// it the separable Gaussian is just as good and cheaper to set up (research: the dual-filter
51/// advantage needs a reasonably large radius, ≥ ~7px). The kept Gaussian path is the small-radius
52/// fallback; dual-Kawase is the production algorithm for large/animated blur.
53pub const KAWASE_THRESHOLD_PX: f32 = 16.0;
54
55/// Max dual-Kawase mip depth, so a huge radius cannot build an unbounded pyramid (1/64 downscale).
56pub const MAX_KAWASE_LEVELS: u32 = 6;
57
58/// Whether `physical_radius` should use dual-Kawase (vs the Gaussian fallback).
59pub fn use_dual_kawase(physical_radius: f32) -> bool {
60    physical_radius >= KAWASE_THRESHOLD_PX
61}
62
63/// Dual-Kawase mip depth `N` for a physical radius. Each down/up pass ~doubles the effective
64/// radius, so `N ≈ log2(radius)`, clamped to `[1, MAX_KAWASE_LEVELS]`. The pyramid then has
65/// `N + 1` levels (level 0 = full, level `i` = `base >> i`).
66///
67/// **Deliberate divergence from KWin:** the per-pass sampling spread is fixed at one half-texel
68/// (no per-pass `offset` scalar — see the shader headers), so radius granularity is **log2-
69/// quantized**: every radius in a band (e.g. 16–22 → `N=4`, 23–45 → `N=5`) blurs identically and
70/// the amount doubles at each band boundary. This suits per-component design-token radii; a
71/// continuous-slider host that wants smooth control would add a fractional-`log2` offset to the
72/// backend's Kawase params and scale the taps by it (KWin's continuous dial), which v1
73/// deliberately omits (YAGNI).
74pub fn resolve_kawase_levels(physical_radius: f32) -> u32 {
75    let levels = physical_radius.max(2.0).log2().round() as i32;
76    levels.clamp(1, MAX_KAWASE_LEVELS as i32) as u32
77}
78
79/// The size of mip level `level` for a pyramid whose level 0 is `base` (each level halves, floored
80/// at 1px so a tall/thin region never collapses to zero).
81pub fn kawase_level_size(base: [u32; 2], level: u32) -> [u32; 2] {
82    [(base[0] >> level).max(1), (base[1] >> level).max(1)]
83}
84
85/// The half-texel sampling offset for a dual-Kawase pass that **samples** a texture of `size`
86/// (KWin's `halfpixel` convention: `0.5 / size`).
87pub fn kawase_halfpixel(size: [u32; 2]) -> [f32; 2] {
88    [0.5 / size[0] as f32, 0.5 / size[1] as f32]
89}
90
91/// Whether the composite shader writes a **linear** target (hardware encodes, or a float target)
92/// or must **manually re-encode** linear→sRGB for a gamma `Unorm` target. The shared encode
93/// vocabulary: the wgpu backend derives it from a `wgpu::TextureFormat` allowlist, the glow
94/// backend from the live context's sRGB-framebuffer state — but both speak this type so the
95/// composite shader's "encode?" branch reads the same on either path.
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub enum TargetEncoding {
98    /// The target is linear (a `*Srgb` format that encodes in hardware, or a float target): the
99    /// composite writes linear light directly.
100    Linear,
101    /// The target is a gamma `Unorm` format (the egui case): the composite must manually apply
102    /// the sRGB OETF before writing.
103    Srgb,
104}
105
106/// Map target-rect uv `[0,1]` onto the blurred scratch, which holds the **clipped** source region.
107/// Returns `(offset, scale)` so that `scratch_uv = offset + target_uv * scale`. It is the identity
108/// when the source region was fully in-bounds (`clipped == source_region`), and an inset otherwise —
109/// the composite samples through this with `ClampToEdge`, so a source region clipped at a screen
110/// edge still registers 1:1 with the content behind the glass instead of being stretched.
111pub fn backdrop_uv_remap(source_region: &Region, clipped: &Region) -> ([f32; 2], [f32; 2]) {
112    let [sx, sy] = [
113        source_region.origin[0] as f32,
114        source_region.origin[1] as f32,
115    ];
116    let [sw, sh] = [source_region.size[0] as f32, source_region.size[1] as f32];
117    let [cx, cy] = [clipped.origin[0] as f32, clipped.origin[1] as f32];
118    let [cw, ch] = [clipped.size[0] as f32, clipped.size[1] as f32];
119    ([(sx - cx) / cw, (sy - cy) / ch], [sw / cw, sh / ch])
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::geometry::Scale;
126
127    fn close(a: f32, b: f32) -> bool {
128        (a - b).abs() < 1e-4
129    }
130
131    #[test]
132    fn resolve_gaussian_zero_radius_is_single_tap_passthrough() {
133        let k = resolve_gaussian(0.0);
134        assert_eq!(k.tap_radius, 0);
135        assert!(
136            k.sigma > 0.0,
137            "sigma must stay positive so i/sigma is finite"
138        );
139    }
140
141    #[test]
142    fn resolve_gaussian_sets_sigma_to_a_third_of_radius() {
143        let k = resolve_gaussian(15.0);
144        assert_eq!(k.tap_radius, 15);
145        assert!(close(k.sigma, 5.0));
146    }
147
148    #[test]
149    fn resolve_gaussian_clamps_tap_radius_to_max() {
150        let k = resolve_gaussian(1000.0);
151        assert_eq!(k.tap_radius, MAX_GAUSSIAN_RADIUS);
152    }
153
154    #[test]
155    fn use_dual_kawase_switches_at_the_threshold() {
156        assert!(!use_dual_kawase(KAWASE_THRESHOLD_PX - 0.1));
157        assert!(use_dual_kawase(KAWASE_THRESHOLD_PX));
158        assert!(use_dual_kawase(40.0));
159    }
160
161    #[test]
162    fn resolve_kawase_levels_grows_logarithmically_and_clamps() {
163        assert_eq!(resolve_kawase_levels(16.0), 4); // log2(16) = 4
164        assert_eq!(resolve_kawase_levels(32.0), 5);
165        assert_eq!(resolve_kawase_levels(10000.0), MAX_KAWASE_LEVELS);
166        assert_eq!(resolve_kawase_levels(2.0), 1); // clamped floor
167        // Pin the round() band boundary (log2 = 4.5 at radius ≈ 22.6), so the level-count policy
168        // is locked rather than incidental — swapping round/floor/ceil would change these.
169        assert_eq!(resolve_kawase_levels(22.0), 4);
170        assert_eq!(resolve_kawase_levels(23.0), 5);
171    }
172
173    #[test]
174    fn kawase_level_size_halves_and_floors_at_one() {
175        assert_eq!(kawase_level_size([200, 100], 0), [200, 100]);
176        assert_eq!(kawase_level_size([200, 100], 1), [100, 50]);
177        assert_eq!(kawase_level_size([200, 100], 2), [50, 25]);
178        // A thin axis floors at 1 instead of collapsing to 0.
179        assert_eq!(kawase_level_size([200, 1], 4), [12, 1]);
180    }
181
182    #[test]
183    fn kawase_halfpixel_is_half_a_texel() {
184        assert_eq!(kawase_halfpixel([100, 50]), [0.5 / 100.0, 0.5 / 50.0]);
185    }
186
187    fn region(origin: [u32; 2], size: [u32; 2]) -> Region {
188        Region {
189            origin,
190            size,
191            scale: Scale::new(1.0),
192        }
193    }
194
195    #[test]
196    fn backdrop_uv_remap_is_identity_when_unclipped() {
197        let r = region([50, 50], [100, 100]);
198        let (offset, scale) = backdrop_uv_remap(&r, &r);
199        assert!(close(offset[0], 0.0) && close(offset[1], 0.0));
200        assert!(close(scale[0], 1.0) && close(scale[1], 1.0));
201    }
202
203    #[test]
204    fn backdrop_uv_remap_insets_a_right_clipped_region() {
205        // source runs 40px off the right edge; clip_to keeps the origin and shrinks width 100→60.
206        let source = region([100, 50], [100, 80]);
207        let clipped = region([100, 50], [60, 80]);
208        let (offset, scale) = backdrop_uv_remap(&source, &clipped);
209        // Origin is preserved by clip_to, so the offset is zero; only the scale compensates, so
210        // target uv 1.0 maps past scratch uv 1.0 (the clipped-off part) and ClampToEdge holds it.
211        assert!(close(offset[0], 0.0) && close(offset[1], 0.0));
212        assert!(close(scale[0], 100.0 / 60.0) && close(scale[1], 1.0));
213    }
214
215    #[test]
216    fn ping_pong_key_distinguishes_size_and_levels() {
217        let a = PingPongKey {
218            size: [100, 80],
219            levels: 1,
220        };
221        let b = PingPongKey {
222            size: [100, 80],
223            levels: 2,
224        };
225        let c = PingPongKey {
226            size: [80, 100],
227            levels: 1,
228        };
229        assert_ne!(a, b);
230        assert_ne!(a, c);
231        assert_eq!(
232            a,
233            PingPongKey {
234                size: [100, 80],
235                levels: 1
236            }
237        );
238    }
239}