diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index 0a6e8ad..d16aa55 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,55 @@ mod tests { assert_eq!(prepared[0].source_height, 64.0); } + #[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, 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 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()); + + // 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] + 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..367fa42 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -3713,6 +3713,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 +3728,14 @@ 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 <= 0. { + return; + } + let scale_factor = self.scale_factor(); let content_mask = self.content_mask(); self.next_frame.scene.insert_primitive(BackdropBlur { @@ -3737,7 +3749,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 +6519,57 @@ 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_culled_only_at_zero_opacity(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + assert!(paint_backdrop_blur(cx, 0.0).is_empty()); + // 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 4cd87a7..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( @@ -2954,7 +2986,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/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)); 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); }