Skip to content

Commit f3fac8b

Browse files
author
Ericky Dos Santos
committed
feat(widget): add directional backdrop blur fades
1 parent f91a2a2 commit f3fac8b

6 files changed

Lines changed: 348 additions & 49 deletions

File tree

core/src/renderer.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,36 @@ pub trait Renderer {
165165
) {
166166
}
167167

168+
/// Draws a backdrop blur with a directional gradient fade.
169+
///
170+
/// Direction and stop semantics match [`with_gradient_fade`](Self::with_gradient_fade).
171+
/// Renderers that do not implement directional backdrop fades fall back to
172+
/// the original bottom-edge fade when possible, or an unfaded blur.
173+
fn draw_backdrop_blur_with_fade(
174+
&mut self,
175+
bounds: Rectangle,
176+
radius: f32,
177+
border_radius: [f32; 4],
178+
fade_direction: u8,
179+
fade_start: f32,
180+
fade_end: f32,
181+
saturation: f32,
182+
) {
183+
let compatible_fade_start = if fade_direction == 0 && fade_end >= 1.0 {
184+
fade_start
185+
} else {
186+
1.0
187+
};
188+
189+
self.draw_backdrop_blur(
190+
bounds,
191+
radius,
192+
border_radius,
193+
compatible_fade_start,
194+
saturation,
195+
);
196+
}
197+
168198
/// Begins recording content that should be rendered AFTER backdrop blur effects.
169199
///
170200
/// Content drawn between `start_post_blur_layer` and `end_post_blur_layer` will be

renderer/src/fallback.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,31 @@ where
146146
);
147147
}
148148

149+
fn draw_backdrop_blur_with_fade(
150+
&mut self,
151+
bounds: Rectangle,
152+
radius: f32,
153+
border_radius: [f32; 4],
154+
fade_direction: u8,
155+
fade_start: f32,
156+
fade_end: f32,
157+
saturation: f32,
158+
) {
159+
delegate!(
160+
self,
161+
renderer,
162+
renderer.draw_backdrop_blur_with_fade(
163+
bounds,
164+
radius,
165+
border_radius,
166+
fade_direction,
167+
fade_start,
168+
fade_end,
169+
saturation
170+
)
171+
);
172+
}
173+
149174
fn has_post_blur_content(&self) -> bool {
150175
delegate!(self, renderer, renderer.has_post_blur_content())
151176
}

wgpu/src/blur.rs

Lines changed: 87 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ pub struct BackdropBlur {
2020
pub radius: f32,
2121
/// Border radius [top_left, top_right, bottom_right, bottom_left] in logical pixels
2222
pub border_radius: [f32; 4],
23-
/// Vertical fade start as fraction (0.0–1.0) of bounds height.
24-
/// Full blur above this point, linearly fading to 0 at the bottom.
25-
/// 0.0 = fade across entire height, 1.0 = no fade (default).
23+
/// Fade direction, matching the gradient-fade renderer (0–5).
24+
pub fade_direction: u8,
25+
/// Fade stops as fractions (0.0–1.0) of the relevant dimension.
26+
/// Equal or reversed stops disable the fade.
2627
pub fade_start: f32,
28+
pub fade_end: f32,
2729
/// CSS `saturate()` amount applied to the blurred backdrop.
2830
/// 1.0 = unchanged (default), 0.0 = greyscale, above 1.0 = more saturated.
2931
/// Negative amounts are not legal in CSS and are clamped to 0.
@@ -52,7 +54,9 @@ impl BackdropBlur {
5254
bounds,
5355
radius: radius.max(0.0),
5456
border_radius: [0.0; 4],
57+
fade_direction: 0,
5558
fade_start: 1.0,
59+
fade_end: 1.0,
5660
saturation: 1.0,
5761
alpha: 1.0,
5862
}
@@ -65,12 +69,35 @@ impl BackdropBlur {
6569
border_radius: [f32; 4],
6670
fade_start: f32,
6771
saturation: f32,
72+
) -> Self {
73+
Self::with_border_radius_and_fade(
74+
bounds,
75+
radius,
76+
border_radius,
77+
0,
78+
fade_start,
79+
1.0,
80+
saturation,
81+
)
82+
}
83+
84+
/// Creates a backdrop blur with border radii and a directional fade.
85+
pub fn with_border_radius_and_fade(
86+
bounds: Rectangle,
87+
radius: f32,
88+
border_radius: [f32; 4],
89+
fade_direction: u8,
90+
fade_start: f32,
91+
fade_end: f32,
92+
saturation: f32,
6893
) -> Self {
6994
Self {
7095
bounds,
7196
radius: radius.max(0.0),
7297
border_radius,
98+
fade_direction: fade_direction.min(5),
7399
fade_start: fade_start.clamp(0.0, 1.0),
100+
fade_end: fade_end.clamp(0.0, 1.0),
74101
saturation: saturation.max(0.0),
75102
alpha: 1.0,
76103
}
@@ -97,16 +124,17 @@ struct BlurUniforms {
97124
params: [f32; 4],
98125
/// Border radius [top_left, top_right, bottom_right, bottom_left] in pixels
99126
border_radius: [f32; 4],
100-
/// fade_params.x = fade_start (0.0–1.0 fraction of bounds height)
127+
/// fade_params.x = fade_start (0.0–1.0 fraction of the relevant dimension)
101128
/// fade_params.y = 1.0 on the restore pass (emit the complementary weight)
102129
/// fade_params.z = region alpha (1.0 = full strength). Must be 1.0 on any
103130
/// pass that is not part of the erase/restore/blur crossfade,
104131
/// or that pass renders nothing.
105-
/// fade_params.w = reserved
132+
/// fade_params.w = fade_end
106133
fade_params: [f32; 4],
107134
/// filter_params.x = CSS `saturate()` amount (1.0 = identity)
108135
/// filter_params.y = 1.0 when the render target format is `*Srgb`
109-
/// filter_params.z/w = reserved
136+
/// filter_params.z = fade direction, matching the gradient-fade renderer (0–5)
137+
/// filter_params.w = reserved
110138
///
111139
/// A whole `vec4` rather than a bare `f32`: WGSL rounds a uniform struct up
112140
/// to a 16-byte multiple, so a lone trailing scalar would make the Rust size
@@ -135,10 +163,10 @@ struct PassGeometry {
135163
///
136164
/// The erase pass always clears the whole region, and only the final pass puts
137165
/// anything back. Any time that final pass writes at less than full weight —
138-
/// a vertical fade, a partial alpha, or both — the difference has to be made up
166+
/// a directional fade, a partial alpha, or both — the difference has to be made up
139167
/// by additively restoring the original scene, or the region is a hole.
140-
fn restores(fade: f32, alpha: f32) -> bool {
141-
fade < 1.0 || alpha < 1.0
168+
fn restores(fade_start: f32, fade_end: f32, alpha: f32) -> bool {
169+
fade_start < fade_end || alpha < 1.0
142170
}
143171

144172
/// Builds every uniform block for one blur region, in execution order.
@@ -150,7 +178,9 @@ fn restores(fade: f32, alpha: f32) -> bool {
150178
/// compound to its sixth power, and that is a silent, plausible-looking bug.
151179
fn build_uniforms(
152180
geo: &PassGeometry,
153-
fade: f32,
181+
fade_direction: u8,
182+
fade_start: f32,
183+
fade_end: f32,
154184
alpha: f32,
155185
saturation: f32,
156186
srgb: f32,
@@ -166,7 +196,7 @@ fn build_uniforms(
166196

167197
// Saturation rides on the final pass alone, so identity is what every other
168198
// block carries.
169-
let identity = [1.0, srgb, 0.0, 0.0];
199+
let identity = [1.0, srgb, f32::from(fade_direction), 0.0];
170200

171201
let mut all = Vec::with_capacity(8);
172202

@@ -181,7 +211,7 @@ fn build_uniforms(
181211
});
182212

183213
// Pass 1: Restore.
184-
if restores(fade, alpha) {
214+
if restores(fade_start, fade_end, alpha) {
185215
all.push(BlurUniforms {
186216
quad_bounds: clip_bounds,
187217
clip_bounds,
@@ -190,7 +220,7 @@ fn build_uniforms(
190220
// Carries the unfiltered original, so it stays at identity
191221
// saturation: saturating it would put saturated-but-unblurred
192222
// pixels in the fade region and seam against the scene outside.
193-
fade_params: [fade, 1.0, alpha, 0.0], // invert_fade
223+
fade_params: [fade_start, 1.0, alpha, fade_end], // invert_fade
194224
filter_params: identity,
195225
});
196226
}
@@ -215,8 +245,8 @@ fn build_uniforms(
215245
clip_bounds,
216246
params: [total_radius, 1.0, tex_width, tex_height],
217247
border_radius,
218-
fade_params: [fade, 0.0, alpha, 0.0],
219-
filter_params: [saturation, srgb, 0.0, 0.0],
248+
fade_params: [fade_start, 0.0, alpha, fade_end],
249+
filter_params: [saturation, srgb, f32::from(fade_direction), 0.0],
220250
});
221251

222252
all
@@ -654,12 +684,14 @@ impl Pipeline {
654684
// 2-6. Blur passes: ping-pong source ↔ intermediate
655685
// 7. Final V blur intermediate → target additively with weight*sdf
656686
//
657-
// `weight` is the vertical fade scaled by the region's alpha, so the
658-
// same crossfade that handles `fade_start` also handles a region inside
687+
// `weight` is the directional fade scaled by the region's alpha, so
688+
// the same crossfade that handles the stops also handles a region inside
659689
// a `with_opacity` group: at alpha 0 the restore pass puts back exactly
660690
// what the erase took away and the region is a pixel-exact no-op.
661691

662-
let fade = blur.fade_start;
692+
let fade_direction = blur.fade_direction;
693+
let fade_start = blur.fade_start;
694+
let fade_end = blur.fade_end;
663695
let alpha = blur.alpha.clamp(0.0, 1.0);
664696
let saturation = blur.saturation.max(0.0);
665697

@@ -676,12 +708,14 @@ impl Pipeline {
676708
tex_height,
677709
total_radius,
678710
},
679-
fade,
711+
fade_direction,
712+
fade_start,
713+
fade_end,
680714
alpha,
681715
saturation,
682716
self.srgb_flag(),
683717
);
684-
let has_restore = restores(fade, alpha);
718+
let has_restore = restores(fade_start, fade_end, alpha);
685719

686720
let (constant_bg, stride) = self.create_packed_uniforms(device, &all_uniforms);
687721

@@ -1196,7 +1230,7 @@ mod tests {
11961230
// The property the whole design rests on. The five ping-pong passes
11971231
// feed each other, so an amount leaking into them compounds: 1.8 would
11981232
// arrive as 1.8^6 ≈ 34. Every block but the last must be exactly 1.0.
1199-
let uniforms = build_uniforms(&geometry(), 1.0, 1.0, 1.8, 0.0);
1233+
let uniforms = build_uniforms(&geometry(), 0, 1.0, 1.0, 1.0, 1.8, 0.0);
12001234

12011235
let (last, rest) = uniforms.split_last().expect("at least one pass");
12021236

@@ -1216,7 +1250,7 @@ mod tests {
12161250
fn an_untouched_saturation_leaves_every_pass_at_identity() {
12171251
// The compatibility guarantee: with saturation defaulted, the uniform
12181252
// stream is what it always was, so the frame is unchanged.
1219-
let uniforms = build_uniforms(&geometry(), 1.0, 1.0, 1.0, 0.0);
1253+
let uniforms = build_uniforms(&geometry(), 0, 1.0, 1.0, 1.0, 1.0, 0.0);
12201254

12211255
assert!(
12221256
uniforms
@@ -1230,7 +1264,7 @@ mod tests {
12301264
fn the_srgb_flag_reaches_every_pass() {
12311265
// Whichever pass ends up filtering, it has to know which space it is
12321266
// working in — so the flag is not something only the last block gets.
1233-
let uniforms = build_uniforms(&geometry(), 1.0, 1.0, 1.0, 1.0);
1267+
let uniforms = build_uniforms(&geometry(), 0, 1.0, 1.0, 1.0, 1.0, 1.0);
12341268

12351269
assert!(
12361270
uniforms
@@ -1242,26 +1276,32 @@ mod tests {
12421276
#[test]
12431277
fn a_fully_faded_region_still_restores_what_the_erase_took() {
12441278
// Without the alpha term in `restores`, a region at partial opacity
1245-
// with no vertical fade would erase itself and put nothing back — a
1279+
// with no directional fade would erase itself and put nothing back — a
12461280
// transparent hole punched in the window, which is much worse than the
12471281
// popping this fixes.
1248-
assert!(restores(1.0, 0.5), "a partial alpha needs the restore pass");
1249-
assert!(restores(0.5, 1.0), "a vertical fade needs the restore pass");
12501282
assert!(
1251-
!restores(1.0, 1.0),
1283+
restores(1.0, 1.0, 0.5),
1284+
"a partial alpha needs the restore pass"
1285+
);
1286+
assert!(
1287+
restores(0.5, 1.0, 1.0),
1288+
"a directional fade needs the restore pass"
1289+
);
1290+
assert!(
1291+
!restores(1.0, 1.0, 1.0),
12521292
"a full-strength region covers its own erase"
12531293
);
12541294

1255-
let faded = build_uniforms(&geometry(), 1.0, 0.5, 1.0, 0.0);
1256-
let full = build_uniforms(&geometry(), 1.0, 1.0, 1.0, 0.0);
1295+
let faded = build_uniforms(&geometry(), 0, 1.0, 1.0, 0.5, 1.0, 0.0);
1296+
let full = build_uniforms(&geometry(), 0, 1.0, 1.0, 1.0, 1.0, 0.0);
12571297
assert_eq!(faded.len(), full.len() + 1);
12581298
}
12591299

12601300
#[test]
12611301
fn the_region_alpha_rides_on_the_crossfade_pair_only() {
12621302
// The erase and the ping-pong passes are not part of the crossfade;
12631303
// they must stay at full weight or they render nothing at all.
1264-
let uniforms = build_uniforms(&geometry(), 1.0, 0.25, 1.0, 0.0);
1304+
let uniforms = build_uniforms(&geometry(), 0, 1.0, 1.0, 0.25, 1.0, 0.0);
12651305

12661306
let restore = &uniforms[1];
12671307
let final_pass = uniforms.last().expect("at least one pass");
@@ -1282,10 +1322,26 @@ mod tests {
12821322
}
12831323
}
12841324

1325+
#[test]
1326+
fn directional_fade_parameters_reach_the_crossfade_pair() {
1327+
let uniforms = build_uniforms(&geometry(), 1, 0.0, 0.68, 1.0, 1.0, 0.0);
1328+
let restore = &uniforms[1];
1329+
let final_pass = uniforms.last().expect("at least one pass");
1330+
1331+
for pass in [restore, final_pass] {
1332+
assert!((pass.fade_params[0] - 0.0).abs() < f32::EPSILON);
1333+
assert!((pass.fade_params[3] - 0.68).abs() < f32::EPSILON);
1334+
assert!((pass.filter_params[2] - 1.0).abs() < f32::EPSILON);
1335+
}
1336+
}
1337+
12851338
#[test]
12861339
fn out_of_range_inputs_are_brought_into_range() {
1287-
let blur = BackdropBlur::with_border_radius(bounds(), 10.0, [0.0; 4], 2.0, -1.0);
1340+
let blur =
1341+
BackdropBlur::with_border_radius_and_fade(bounds(), 10.0, [0.0; 4], 9, 2.0, -1.0, -1.0);
1342+
assert_eq!(blur.fade_direction, 5);
12881343
assert!((blur.fade_start - 1.0).abs() < f32::EPSILON);
1344+
assert!(blur.fade_end.abs() < f32::EPSILON);
12891345
assert!(
12901346
blur.saturation.abs() < f32::EPSILON,
12911347
"CSS has no negative saturation"

0 commit comments

Comments
 (0)