@@ -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.
151179fn 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