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}