Skip to main content

backdrop_blur_core/
material.rs

1//! The material — *what kind of glass*. The knobs a caller turns to describe a frosted
2//! surface: how much blur, what tint film, how round the corners. All logical (in points);
3//! the backend resolves them to physical pixels against a region's [`Scale`].
4
5use crate::geometry::Scale;
6
7/// Blur radius in **logical points**.
8///
9/// Core resolves this to a physical-pixel radius — the one algorithm-*agnostic* step. The
10/// **GPU application** of the resulting parameters (allocating the pyramid textures, binding the
11/// per-pass offset uniforms, the draws) lives in the backend; the GPU-free *policy* that
12/// produces them — the Gaussian sigma/taps and the dual-Kawase level/half-pixel math — lives in
13/// the crate's `algorithm` module (DESIGN §4.2, §15). **Reversal noted (glow IMPL §0c):** an earlier
14/// version of this paragraph said core "has no notion of levels — that is the wgpu crate's".
15/// That held while wgpu was the only backend; the glow backend needs the *same* level policy, so
16/// it was hoisted to core to keep the two backends from drifting. Core now owns the level math;
17/// only the GPU resources it keys stay backend-specific.
18///
19/// The resolution is exposed as [`BlurRequest::physical_blur_radius`], **not** a free
20/// `strength × scale` call: a [`BlurRequest`] carries two independent scales (source vs
21/// target), and the blur convolution happens in the *source* texture's pixel space, so the
22/// radius must resolve against `source_region.scale`. Pinning that scale inside the request
23/// makes the wrong one impossible to pass (the same guardrail [`ResolvedMask::from_target`]
24/// gives the corner radius).
25///
26/// Non-negative by construction: a negative radius is meaningless, so [`Self::new`] clamps to
27/// `0` (which the backend reads as "no blur"), keeping the type total.
28///
29/// [`BlurRequest::physical_blur_radius`]: crate::BlurRequest::physical_blur_radius
30/// [`BlurRequest`]: crate::BlurRequest
31/// [`ResolvedMask::from_target`]: crate::ResolvedMask::from_target
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct BlurStrength(f32);
34
35impl BlurStrength {
36    /// Construct from logical points. Non-finite input (`NaN`/`±∞`) and negatives clamp to `0`,
37    /// so a garbage strength becomes "no blur" rather than a degenerate kernel downstream.
38    pub fn new(points: f32) -> Self {
39        Self(if points.is_finite() {
40            points.max(0.0)
41        } else {
42            0.0
43        })
44    }
45
46    /// The blur radius in logical points (always `>= 0`).
47    pub fn points(self) -> f32 {
48        self.0
49    }
50
51    /// Logical points × a scale = physical-pixel blur radius. Crate-private on purpose: the
52    /// *correct* scale is always the source region's, so callers resolve through
53    /// [`BlurRequest::physical_blur_radius`](crate::BlurRequest::physical_blur_radius), which
54    /// pins it. Exposing a bare `(scale)` socket would let the wrong region's scale through
55    /// on a mismatched-DPI surface with no compile error.
56    pub(crate) fn to_physical_radius(self, scale: Scale) -> f32 {
57        self.0 * scale.factor()
58    }
59}
60
61/// A straight-alpha color in **linear light**. RGB are linear (already gamma-decoded) and may
62/// exceed `1.0` (HDR over-bright); alpha is coverage in `[0, 1]` (never gamma-encoded). The
63/// blur convolution runs in linear light, so a tint authored in sRGB must be decoded first —
64/// that is exactly what [`Self::from_srgb_unmultiplied`] does, so callers never hand the
65/// backend gamma-encoded tint values (DESIGN §4.2).
66///
67/// Fields are private so the "already linear" invariant is only ever established through a
68/// named constructor (matching the other newtypes), and both constructors are **total**:
69/// non-finite channels (`NaN`/`±∞`) are scrubbed to `0.0` so a malformed tint can never reach
70/// the GPU as undebuggable garbage.
71#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct LinearRgba {
73    r: f32,
74    g: f32,
75    b: f32,
76    a: f32,
77}
78
79impl LinearRgba {
80    /// Build from channels that are *already* linear. Non-finite channels are scrubbed to
81    /// `0.0` and alpha is clamped to `[0, 1]`; RGB keep their (possibly `> 1`, HDR) magnitude.
82    pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
83        Self {
84            r: finite_or_zero(r),
85            g: finite_or_zero(g),
86            b: finite_or_zero(b),
87            a: finite_or_zero(a).clamp(0.0, 1.0),
88        }
89    }
90
91    /// Decode straight-alpha sRGB bytes to linear light: RGB through the sRGB EOTF, alpha
92    /// linearly. This is the gamma decode the linear-space convolution requires. The result is
93    /// finite by construction (`u8 / 255` and the EOTF never produce `NaN`/`∞`).
94    pub fn from_srgb_unmultiplied(rgba: [u8; 4]) -> Self {
95        let [r, g, b, a] = rgba;
96        Self {
97            r: srgb_to_linear(f32::from(r) / 255.0),
98            g: srgb_to_linear(f32::from(g) / 255.0),
99            b: srgb_to_linear(f32::from(b) / 255.0),
100            a: f32::from(a) / 255.0,
101        }
102    }
103
104    /// Linear red.
105    pub fn r(self) -> f32 {
106        self.r
107    }
108
109    /// Linear green.
110    pub fn g(self) -> f32 {
111        self.g
112    }
113
114    /// Linear blue.
115    pub fn b(self) -> f32 {
116        self.b
117    }
118
119    /// Film opacity / coverage, in `[0, 1]`.
120    pub fn a(self) -> f32 {
121        self.a
122    }
123}
124
125/// The sRGB electro-optical transfer function (gamma → linear) for one normalized channel.
126fn srgb_to_linear(c: f32) -> f32 {
127    if c <= 0.040_45 {
128        c / 12.92
129    } else {
130        ((c + 0.055) / 1.055).powf(2.4)
131    }
132}
133
134/// Replace a non-finite value (`NaN`/`±∞`) with `0.0`, leaving finite values untouched.
135fn finite_or_zero(x: f32) -> f32 {
136    if x.is_finite() { x } else { 0.0 }
137}
138
139/// The glass film painted over the blurred backdrop. The wrapped color is linear-light; its
140/// alpha is the **film opacity** (how much of the tint shows over the blur).
141#[derive(Debug, Clone, Copy, PartialEq)]
142pub struct Tint(LinearRgba);
143
144impl Tint {
145    /// Wrap an already-linear color as the film.
146    pub fn new(color: LinearRgba) -> Self {
147        Self(color)
148    }
149
150    /// Convenience: a film authored as straight-alpha sRGB bytes, decoded to linear.
151    pub fn from_srgb_unmultiplied(rgba: [u8; 4]) -> Self {
152        Self(LinearRgba::from_srgb_unmultiplied(rgba))
153    }
154
155    /// The film color in linear light.
156    pub fn color(self) -> LinearRgba {
157        self.0
158    }
159}
160
161/// Corner radius in **logical points**. Resolves (× the target region's [`Scale`]) to a
162/// physical-pixel radius, clamped so it can never overshoot the surface (the clamp lives in
163/// [`crate::ResolvedMask::from_target`]). Non-negative by construction.
164#[derive(Debug, Clone, Copy, PartialEq)]
165pub struct CornerRadius(f32);
166
167impl CornerRadius {
168    /// Construct from logical points. Non-finite input (`NaN`/`±∞`) and negatives clamp to `0`
169    /// (square corners).
170    pub fn new(points: f32) -> Self {
171        Self(if points.is_finite() {
172            points.max(0.0)
173        } else {
174            0.0
175        })
176    }
177
178    /// The corner radius in logical points (always `>= 0`).
179    pub fn points(self) -> f32 {
180        self.0
181    }
182}
183
184/// Surface-global **fade coverage** in `[0, 1]` — how *present* the whole frosted surface is,
185/// distinct from [`Tint`]'s alpha (which is the film *mix*, blur vs tint color) and from
186/// [`BlurStrength`] (the radius). It scales the composite's final blend weight: `1.0` is the
187/// surface fully composited (the default — every existing caller and golden is unchanged), `0.0`
188/// leaves the destination untouched (the surface absent), and a fractional value blends the
189/// frosted result over the destination by that factor. A consumer animating a surface in/out
190/// (a modal scrim fading with its dialog) drives this per frame.
191///
192/// Two-sided clamp `[0, 1]` (unlike [`BlurStrength`]/[`CornerRadius`], which clamp only the
193/// lower bound) — the precedent is [`LinearRgba`]'s alpha. Non-finite input falls back to `1.0`
194/// (fully present, behavior-preserving), **not** `0.0`: a `NaN` propagates through `f32::clamp`,
195/// and a silently-invisible surface is a worse failure than a silently-opaque one.
196#[derive(Debug, Clone, Copy, PartialEq)]
197pub struct Opacity(f32);
198
199impl Opacity {
200    /// A fully-present surface — the default.
201    pub const FULL: Self = Self(1.0);
202
203    /// Construct from a `[0, 1]` factor. Out-of-range clamps; non-finite (`NaN`/`±∞`) falls back
204    /// to `1.0` (fully present).
205    pub fn new(factor: f32) -> Self {
206        Self(if factor.is_finite() {
207            factor.clamp(0.0, 1.0)
208        } else {
209            1.0
210        })
211    }
212
213    /// The fade factor in `[0, 1]`.
214    pub fn value(self) -> f32 {
215        self.0
216    }
217}
218
219impl Default for Opacity {
220    fn default() -> Self {
221        Self::FULL
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn close(a: f32, b: f32) -> bool {
230        (a - b).abs() < 1e-4
231    }
232
233    #[test]
234    fn blur_strength_new_clamps_negative_to_zero() {
235        assert_eq!(BlurStrength::new(-3.0).points(), 0.0);
236        assert_eq!(BlurStrength::new(8.0).points(), 8.0);
237    }
238
239    #[test]
240    fn blur_strength_new_scrubs_non_finite_to_zero() {
241        assert_eq!(BlurStrength::new(f32::NAN).points(), 0.0);
242        assert_eq!(BlurStrength::new(f32::INFINITY).points(), 0.0);
243        assert_eq!(BlurStrength::new(f32::NEG_INFINITY).points(), 0.0);
244    }
245
246    #[test]
247    fn to_physical_radius_multiplies_by_scale() {
248        let r = BlurStrength::new(8.0).to_physical_radius(Scale::new(2.0));
249        assert!(close(r, 16.0));
250    }
251
252    #[test]
253    fn to_physical_radius_of_zero_strength_is_zero() {
254        let r = BlurStrength::new(0.0).to_physical_radius(Scale::new(3.0));
255        assert!(close(r, 0.0));
256    }
257
258    #[test]
259    fn corner_radius_new_clamps_negative_to_zero() {
260        assert_eq!(CornerRadius::new(-1.0).points(), 0.0);
261        assert_eq!(CornerRadius::new(12.0).points(), 12.0);
262    }
263
264    #[test]
265    fn from_srgb_unmultiplied_maps_endpoints_exactly() {
266        let black = LinearRgba::from_srgb_unmultiplied([0, 0, 0, 255]);
267        assert!(close(black.r(), 0.0) && close(black.g(), 0.0) && close(black.b(), 0.0));
268        assert!(close(black.a(), 1.0));
269
270        let white = LinearRgba::from_srgb_unmultiplied([255, 255, 255, 255]);
271        assert!(close(white.r(), 1.0) && close(white.g(), 1.0) && close(white.b(), 1.0));
272    }
273
274    #[test]
275    fn from_srgb_unmultiplied_decodes_midtone_through_eotf() {
276        // sRGB 188/255 ≈ 0.737 gamma → ≈ 0.502 linear (the classic "perceptual half").
277        let mid = LinearRgba::from_srgb_unmultiplied([188, 188, 188, 128]);
278        assert!(close(mid.r(), 0.502_886_5));
279        // Alpha is linear, not gamma-decoded.
280        assert!(close(mid.a(), 128.0 / 255.0));
281    }
282
283    #[test]
284    fn from_srgb_unmultiplied_uses_linear_segment_near_black() {
285        // Below the 0.04045 knee the transfer is the linear c/12.92 segment.
286        let dark = LinearRgba::from_srgb_unmultiplied([2, 2, 2, 255]);
287        let expected = (2.0 / 255.0) / 12.92;
288        assert!(close(dark.r(), expected));
289    }
290
291    #[test]
292    fn new_scrubs_non_finite_channels_to_zero() {
293        let scrubbed = LinearRgba::new(f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::NAN);
294        assert_eq!(scrubbed.r(), 0.0);
295        assert_eq!(scrubbed.g(), 0.0);
296        assert_eq!(scrubbed.b(), 0.0);
297        assert_eq!(scrubbed.a(), 0.0);
298    }
299
300    #[test]
301    fn new_clamps_alpha_but_keeps_hdr_rgb() {
302        let color = LinearRgba::new(4.0, 0.0, 0.0, 1.5);
303        assert!(close(color.r(), 4.0)); // HDR over-bright preserved
304        assert!(close(color.a(), 1.0)); // alpha clamped into [0, 1]
305    }
306
307    #[test]
308    fn tint_from_srgb_decodes_its_wrapped_color() {
309        let tint = Tint::from_srgb_unmultiplied([255, 255, 255, 64]);
310        assert!(close(tint.color().r(), 1.0));
311        assert!(close(tint.color().a(), 64.0 / 255.0));
312    }
313
314    #[test]
315    fn opacity_new_clamps_into_unit_range() {
316        assert_eq!(Opacity::new(-1.0).value(), 0.0);
317        assert_eq!(Opacity::new(2.0).value(), 1.0);
318        assert!(close(Opacity::new(0.3).value(), 0.3));
319    }
320
321    #[test]
322    fn opacity_new_scrubs_non_finite_to_full() {
323        // Non-finite falls back to 1.0 (fully present), NOT 0.0 — a NaN propagates through
324        // f32::clamp, and an invisible surface is the worse silent failure.
325        assert_eq!(Opacity::new(f32::NAN).value(), 1.0);
326        assert_eq!(Opacity::new(f32::INFINITY).value(), 1.0);
327        assert_eq!(Opacity::new(f32::NEG_INFINITY).value(), 1.0);
328    }
329
330    #[test]
331    fn opacity_default_and_full_are_one() {
332        assert_eq!(Opacity::default().value(), 1.0);
333        assert_eq!(Opacity::FULL.value(), 1.0);
334    }
335}