From 33266d725aa8e04b0d29caebf8755b95ce0c14b1 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 11:19:11 -0700 Subject: [PATCH 1/2] fix(scene): scale backdrop blur with element opacity A backdrop blur painted full strength until its element unmounted, so a flyout fading to zero kept its blurred backdrop through the entire exit and dropped it in one step at unmount. Measured on a black/white stripe backdrop the step was 41 luma; on lower-contrast content it reads as the 1-3 luma pop consumers see at the end of an otherwise clean fade. Carry the element's effective opacity on the primitive and use it as the composite weight of the blurred layer. Because the composite blends over the target that already holds the unblurred backdrop, weighting its alpha crossfades blurred to sharp, which measures linear across the fade with no discontinuity. Blur radius is untouched, so the pyramid plan and the cluster identity stay opacity-independent and batching is unchanged. The primitive is skipped below half of one 8-bit step of opacity, which drops the source snapshot and blur passes for surfaces that can no longer change a pixel. The reused `pad2` field keeps the GPU layout at 112 bytes. --- crates/gpui/src/scene.rs | 48 ++++++++++++++++-- crates/gpui/src/window.rs | 66 ++++++++++++++++++++++++- crates/gpui_macos/src/metal_renderer.rs | 2 +- crates/gpui_macos/src/shaders.metal | 3 ++ crates/gpui_wgpu/src/shaders.wgsl | 5 +- crates/gpui_wgpu/src/wgpu_renderer.rs | 2 +- crates/gpui_windows/src/shaders.hlsl | 4 ++ 7 files changed, 123 insertions(+), 7 deletions(-) diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index 0a6e8ad..6b87140 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -1092,7 +1092,9 @@ pub struct BackdropBlur { pub source_origin_y: f32, pub source_width: f32, pub source_height: f32, - pub pad2: u32, // align storage buffer stride to WGSL layout + /// Effective element opacity, used as the composite weight of the blurred + /// backdrop. Also aligns the storage buffer stride to the WGSL layout. + pub opacity: f32, } impl From for Primitive { @@ -1542,7 +1544,7 @@ mod tests { source_origin_y: 0.0, source_width: 1.0, source_height: 1.0, - pad2: 0, + opacity: 1.0, } } @@ -1568,7 +1570,7 @@ mod tests { assert_eq!(offset_of!(BackdropBlur, source_origin_y), 96); assert_eq!(offset_of!(BackdropBlur, source_width), 100); assert_eq!(offset_of!(BackdropBlur, source_height), 104); - assert_eq!(offset_of!(BackdropBlur, pad2), 108); + assert_eq!(offset_of!(BackdropBlur, opacity), 108); } #[test] @@ -1741,6 +1743,46 @@ mod tests { assert_eq!(prepared[0].source_height, 64.0); } + #[test] + fn backdrop_blur_opacity_does_not_split_clusters_or_plan_groups() { + let mut blurs = [ + test_backdrop_blur_with_bounds(1, 0.0, 10.0, 12.0), + test_backdrop_blur_with_bounds(2, 15.0, 10.0, 12.0), + test_backdrop_blur_with_bounds(3, 30.0, 10.0, 12.0), + ]; + let viewport_size = size(DevicePixels(200), DevicePixels(100)); + let opaque_clusters = backdrop_blur_clusters(&blurs, viewport_size); + let opaque_groups = backdrop_blur_plan_groups(&blurs, BackdropBlurPlan::MAX_PASSES); + + // Mid-fade each blur carries a different opacity; batching must not + // notice, or a fading stack of surfaces would fragment into one + // snapshot and blur pyramid per surface. + blurs[0].opacity = 0.25; + blurs[1].opacity = 0.5; + blurs[2].opacity = 0.75; + + let faded_clusters = backdrop_blur_clusters(&blurs, viewport_size); + assert_eq!(faded_clusters.len(), 1); + assert_eq!(faded_clusters.len(), opaque_clusters.len()); + assert_eq!(faded_clusters[0].len(), opaque_clusters[0].len()); + assert_eq!( + backdrop_blur_plan_groups(&blurs, BackdropBlurPlan::MAX_PASSES), + opaque_groups + ); + } + + #[test] + fn prepare_backdrop_blurs_preserves_opacity() { + let mut blurs = [test_backdrop_blur_with_bounds(1, 0.0, 10.0, 1.0)]; + blurs[0].opacity = 0.4; + let viewport_size = size(DevicePixels(100), DevicePixels(100)); + let scratch_bounds = backdrop_scratch_bounds(&blurs, viewport_size).unwrap(); + + let prepared = prepare_backdrop_blurs(&blurs, scratch_bounds); + + assert_eq!(prepared[0].opacity, 0.4); + } + #[test] fn backdrop_blur_clusters_preserve_interleaved_source_order() { let blurs = [ diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 1aef6fd..aafe4be 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -75,6 +75,12 @@ pub const DEFAULT_ADDITIONAL_WINDOW_SIZE: Size = Size { height: Pixels(750.), }; +/// Backdrop blur is skipped below this effective element opacity. Half of one +/// 8-bit step is the most such a composite can move any channel, so dropping +/// the primitive here saves the source snapshot and blur pyramid without a +/// visible step at the end of a fade. +const MINIMUM_BACKDROP_BLUR_OPACITY: f32 = 0.5 / 255.; + /// Represents the two different phases when dispatching events. #[derive(Default, Copy, Clone, Debug, Eq, PartialEq)] pub enum DispatchPhase { @@ -3713,6 +3719,10 @@ impl Window { /// `blur_radius` approximates the filter's three-sigma support. At render /// scale, positive radii below the pyramid's roughly 3.7-device-pixel /// minimum use that minimum. + /// + /// The blurred backdrop is composited over the unblurred one with the + /// element's effective opacity as its weight, so an element fading out + /// stops contributing blur at the same rate as the rest of its painting. pub fn paint_backdrop_blur( &mut self, bounds: Bounds, @@ -3724,6 +3734,11 @@ impl Window { return; } + let opacity = self.element_opacity(); + if !opacity.is_finite() || opacity < MINIMUM_BACKDROP_BLUR_OPACITY { + return; + } + let scale_factor = self.scale_factor(); let content_mask = self.content_mask(); self.next_frame.scene.insert_primitive(BackdropBlur { @@ -3737,7 +3752,7 @@ impl Window { source_origin_y: 0.0, source_width: 1.0, source_height: 1.0, - pad2: 0, + opacity: opacity.min(1.0), }); } @@ -6507,3 +6522,52 @@ pub fn outline( border_style, } } + +#[cfg(test)] +mod backdrop_blur_tests { + use crate::{ + DrawPhase, Drawable, TestAppContext, VisualTestContext, div, prelude::*, px, size, + }; + + fn paint_backdrop_blur(cx: &mut VisualTestContext, opacity: f32) -> Vec { + cx.update(|window, cx| { + window.next_frame.clear(); + window.invalidator.set_phase(DrawPhase::Prepaint); + let mut element = + Drawable::new(div().size_full().backdrop_blur(px(12.)).opacity(opacity)); + element.layout_as_root(size(px(100.), px(100.)).into(), window, cx); + window.with_absolute_element_offset(Default::default(), |window| { + element.prepaint(window, cx) + }); + + window.invalidator.set_phase(DrawPhase::Paint); + element.paint(window, cx); + window.invalidator.set_phase(DrawPhase::None); + + window + .next_frame + .scene + .backdrop_blurs + .iter() + .map(|blur| blur.opacity) + .collect() + }) + } + + #[gpui::test] + fn backdrop_blur_carries_element_opacity(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + assert_eq!(paint_backdrop_blur(cx, 1.0), vec![1.0]); + assert_eq!(paint_backdrop_blur(cx, 0.4), vec![0.4]); + } + + #[gpui::test] + fn backdrop_blur_is_skipped_once_invisible(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + assert!(paint_backdrop_blur(cx, 0.0).is_empty()); + assert!(paint_backdrop_blur(cx, 0.001).is_empty()); + assert_eq!(paint_backdrop_blur(cx, 0.01), vec![0.01]); + } +} diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs index 4cd87a7..d246b46 100644 --- a/crates/gpui_macos/src/metal_renderer.rs +++ b/crates/gpui_macos/src/metal_renderer.rs @@ -2954,7 +2954,7 @@ mod tests { source_origin_y: 0.0, source_width: 1.0, source_height: 1.0, - pad2: 0, + opacity: 1.0, }); assert!(MetalRenderer::retained_layers_for_scene(&scene).is_empty()); diff --git a/crates/gpui_macos/src/shaders.metal b/crates/gpui_macos/src/shaders.metal index a32fa32..e426452 100644 --- a/crates/gpui_macos/src/shaders.metal +++ b/crates/gpui_macos/src/shaders.metal @@ -288,6 +288,9 @@ fragment float4 backdrop_blur_fragment( float alpha = clamp(0.5 - distance, 0.0, 1.0); alpha *= content_mask_alpha(input.position.xy, blur.content_mask.rounded_bounds, blur.content_mask.corner_radii); + // Weighting the composite by the element's opacity crossfades the blurred + // backdrop back to the unblurred one already in the target. + alpha *= clamp(blur.opacity, 0.0, 1.0); float3 straight_rgb = color.a > 0.0 ? color.rgb / color.a : float3(0.0); return float4(straight_rgb, color.a * alpha); diff --git a/crates/gpui_wgpu/src/shaders.wgsl b/crates/gpui_wgpu/src/shaders.wgsl index 38895c2..d58c35c 100644 --- a/crates/gpui_wgpu/src/shaders.wgsl +++ b/crates/gpui_wgpu/src/shaders.wgsl @@ -1202,6 +1202,7 @@ struct BackdropBlur { source_origin_y: f32, source_width: f32, source_height: f32, + opacity: f32, } @group(1) @binding(0) var b_backdrop_blurs: array; @@ -1250,7 +1251,9 @@ fn fs_backdrop_blur(input: BackdropBlurVarying) -> @location(0) vec4 { } let distance = quad_sdf(input.position.xy, blur.bounds, blur.corner_radii); - let surface_alpha = saturate(0.5 - distance); + // Weighting the composite by the element's opacity crossfades the blurred + // backdrop back to the unblurred one already in the target. + let surface_alpha = saturate(0.5 - distance) * saturate(blur.opacity); return apply_content_mask( blend_color(vec4(straight_rgb, color.a), surface_alpha), input.position.xy, diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index 6c93a4d..3541a11 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -3280,7 +3280,7 @@ mod tests { source_origin_y: 0.0, source_width: 1.0, source_height: 1.0, - pad2: 0, + opacity: 1.0, }); let retained_scene = scene.clone_paint_range(layer.paint_range); diff --git a/crates/gpui_windows/src/shaders.hlsl b/crates/gpui_windows/src/shaders.hlsl index a2399cd..934130e 100644 --- a/crates/gpui_windows/src/shaders.hlsl +++ b/crates/gpui_windows/src/shaders.hlsl @@ -560,6 +560,7 @@ struct BackdropBlur { float source_origin_y; float source_width; float source_height; + float opacity; }; struct BackdropBlurParams { @@ -719,6 +720,9 @@ float4 backdrop_blur_fragment(BackdropBlurFragmentInput input) : SV_Target { float distance = quad_sdf(input.position.xy, blur.bounds, blur.corner_radii); float alpha = saturate(0.5 - distance); alpha *= content_mask_alpha(input.position.xy, blur.content_mask); + // Weighting the composite by the element's opacity crossfades the blurred + // backdrop back to the unblurred one already in the target. + alpha *= saturate(blur.opacity); float3 rgb = color.a > 0.0 ? color.rgb / color.a : float3(0.0, 0.0, 0.0); return float4(rgb, color.a * alpha); } From d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 12:17:03 -0700 Subject: [PATCH 2/2] test(scene): pin backdrop blur opacity behavior Cull only at exactly zero opacity rather than at a small positive threshold. A threshold reintroduces the single-frame pop the opacity work removes, just at a different point in the fade, so anything above zero now keeps painting. Add the tests that pin the contract: opacity stays out of cluster identity and plan grouping, so two surfaces mid-fade at different opacities still share one cluster, one plan group and one source snapshot; preparation copies opacity through untouched; and the HLSL structured-buffer stride matches size_of::(), which the hand-maintained shader mirror had silently violated before this branch. --- crates/gpui/src/scene.rs | 33 +++++++++++++-------- crates/gpui/src/window.rs | 22 +++++++------- crates/gpui_macos/src/metal_renderer.rs | 32 ++++++++++++++++++++ crates/gpui_windows/src/directx_renderer.rs | 12 +++++++- 4 files changed, 76 insertions(+), 23 deletions(-) diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index 6b87140..d16aa55 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -1745,30 +1745,39 @@ mod tests { #[test] fn backdrop_blur_opacity_does_not_split_clusters_or_plan_groups() { + // Same bounds and radius, so everything but opacity is shared. let mut blurs = [ test_backdrop_blur_with_bounds(1, 0.0, 10.0, 12.0), - test_backdrop_blur_with_bounds(2, 15.0, 10.0, 12.0), - test_backdrop_blur_with_bounds(3, 30.0, 10.0, 12.0), + test_backdrop_blur_with_bounds(2, 5.0, 10.0, 12.0), ]; let viewport_size = size(DevicePixels(200), DevicePixels(100)); let opaque_clusters = backdrop_blur_clusters(&blurs, viewport_size); let opaque_groups = backdrop_blur_plan_groups(&blurs, BackdropBlurPlan::MAX_PASSES); + let opaque_scratch = backdrop_scratch_bounds(&blurs, viewport_size).unwrap(); - // Mid-fade each blur carries a different opacity; batching must not - // notice, or a fading stack of surfaces would fragment into one - // snapshot and blur pyramid per surface. - blurs[0].opacity = 0.25; - blurs[1].opacity = 0.5; - blurs[2].opacity = 0.75; + // Mid-fade the two surfaces sit at different opacities. Batching must + // not notice: opacity is a composite parameter, and letting it reach + // planning would give each surface its own cluster, plan group, blur + // texture and source snapshot. + blurs[0].opacity = 0.9; + blurs[1].opacity = 0.8; let faded_clusters = backdrop_blur_clusters(&blurs, viewport_size); + let faded_groups = backdrop_blur_plan_groups(&blurs, BackdropBlurPlan::MAX_PASSES); + let faded_scratch = backdrop_scratch_bounds(&blurs, viewport_size).unwrap(); + assert_eq!(faded_clusters.len(), 1); + assert_eq!(faded_clusters[0].len(), 2); assert_eq!(faded_clusters.len(), opaque_clusters.len()); assert_eq!(faded_clusters[0].len(), opaque_clusters[0].len()); - assert_eq!( - backdrop_blur_plan_groups(&blurs, BackdropBlurPlan::MAX_PASSES), - opaque_groups - ); + + // One plan group is one generated blur texture. + assert_eq!(faded_groups.len(), 1); + assert_eq!(faded_groups, opaque_groups); + + // One source snapshot, over the same region. + assert_eq!(faded_scratch.bounds, opaque_scratch.bounds); + assert_eq!(faded_scratch.texture_size, opaque_scratch.texture_size); } #[test] diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index aafe4be..367fa42 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -75,12 +75,6 @@ pub const DEFAULT_ADDITIONAL_WINDOW_SIZE: Size = Size { height: Pixels(750.), }; -/// Backdrop blur is skipped below this effective element opacity. Half of one -/// 8-bit step is the most such a composite can move any channel, so dropping -/// the primitive here saves the source snapshot and blur pyramid without a -/// visible step at the end of a fade. -const MINIMUM_BACKDROP_BLUR_OPACITY: f32 = 0.5 / 255.; - /// Represents the two different phases when dispatching events. #[derive(Default, Copy, Clone, Debug, Eq, PartialEq)] pub enum DispatchPhase { @@ -3734,8 +3728,11 @@ impl Window { return; } + // Only a fully transparent element is culled. Any positive opacity + // still contributes, because dropping the primitive at a threshold + // reintroduces the one-frame step this weighting exists to remove. let opacity = self.element_opacity(); - if !opacity.is_finite() || opacity < MINIMUM_BACKDROP_BLUR_OPACITY { + if !opacity.is_finite() || opacity <= 0. { return; } @@ -6563,11 +6560,16 @@ mod backdrop_blur_tests { } #[gpui::test] - fn backdrop_blur_is_skipped_once_invisible(cx: &mut TestAppContext) { + fn backdrop_blur_is_culled_only_at_zero_opacity(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); assert!(paint_backdrop_blur(cx, 0.0).is_empty()); - assert!(paint_backdrop_blur(cx, 0.001).is_empty()); - assert_eq!(paint_backdrop_blur(cx, 0.01), vec![0.01]); + // Anything above zero still paints; a threshold here would be the + // one-frame pop the opacity weighting exists to remove. + assert_eq!( + paint_backdrop_blur(cx, f32::MIN_POSITIVE), + vec![f32::MIN_POSITIVE] + ); + assert_eq!(paint_backdrop_blur(cx, 0.001), vec![0.001]); } } diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs index d246b46..53d4902 100644 --- a/crates/gpui_macos/src/metal_renderer.rs +++ b/crates/gpui_macos/src/metal_renderer.rs @@ -2937,6 +2937,38 @@ mod tests { ])); } + #[test] + fn fading_backdrop_blurs_keep_the_snapshot_fast_path() { + let bounds = Bounds::new( + Point::new(ScaledPixels(0.0), ScaledPixels(0.0)), + Size::new(ScaledPixels(100.0), ScaledPixels(100.0)), + ); + let blur = |order, opacity| BackdropBlur { + order, + pad: 0, + bounds, + content_mask: ContentMask::new(bounds), + corner_radii: Corners::all(ScaledPixels(8.0)), + blur_radius: ScaledPixels(16.0), + source_origin_x: 0.0, + source_origin_y: 0.0, + source_width: 1.0, + source_height: 1.0, + opacity, + }; + + // Two surfaces mid-fade at different opacities still share one plan + // group, so the renderer keeps blurring in place instead of taking a + // source snapshot. + let plan_groups = + backdrop_blur_plan_groups(&[blur(0, 0.9), blur(1, 0.8)], BackdropBlurPlan::MAX_PASSES); + + assert_eq!(plan_groups.len(), 1); + assert!(!MetalRenderer::backdrop_blur_needs_source_snapshot( + &plan_groups + )); + } + #[test] fn retained_layer_with_backdrop_blur_is_excluded_from_metal_cache() { let bounds = Bounds::new( diff --git a/crates/gpui_windows/src/directx_renderer.rs b/crates/gpui_windows/src/directx_renderer.rs index f9621dd..ba07741 100644 --- a/crates/gpui_windows/src/directx_renderer.rs +++ b/crates/gpui_windows/src/directx_renderer.rs @@ -3221,7 +3221,7 @@ mod amd { #[cfg(test)] mod tests { - use gpui::{Bounds, ContentMask, Corners, ScaledPixels, point, size}; + use gpui::{BackdropBlur, Bounds, ContentMask, Corners, ScaledPixels, point, size}; use super::{ BackdropBlurParams, GlobalParams, RetainedLayerClip, retained_layer_clip, @@ -3238,6 +3238,16 @@ mod tests { assert_eq!(std::mem::size_of::(), 16); } + /// `struct BackdropBlur` in `shaders.hlsl` is hand-written, unlike the + /// Metal one that `gpui_macos/build.rs` generates from `scene.rs`, so + /// nothing else catches it drifting from the Rust type. The structured + /// buffer's `StructureByteStride` comes from this size, and a shader-side + /// struct of a different size skews every instance after the first. + #[test] + fn backdrop_blur_preserves_hlsl_structured_buffer_stride() { + assert_eq!(std::mem::size_of::(), 112); + } + #[test] fn rounded_backdrop_rebuild_uses_retained_configuration() { assert!(!rounded_backdrop_rebuild_requested(None));