Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions crates/gpui/src/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<BackdropBlur> for Primitive {
Expand Down Expand Up @@ -1542,7 +1544,7 @@ mod tests {
source_origin_y: 0.0,
source_width: 1.0,
source_height: 1.0,
pad2: 0,
opacity: 1.0,
}
}

Expand All @@ -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]
Expand Down Expand Up @@ -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);
}

Comment on lines +1746 to +1794

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | 🏗️ Heavy lift

Add a render-level regression for overlapping opacities.

This test verifies planning, grouping, and scratch sizing only; it does not exercise fragment compositing. Add a GPU/visual case with overlapping blurs at different opacities to validate the expected blend behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gpui/src/scene.rs` around lines 1746 - 1794, Add a GPU/visual render
regression alongside backdrop blur tests using overlapping blur surfaces with
different opacities. Exercise the fragment compositing path and validate the
rendered blend output, while retaining the existing planning/grouping/scratch
assertions in backdrop_blur_clusters, backdrop_blur_plan_groups, and
prepare_backdrop_blurs tests.

#[test]
fn backdrop_blur_clusters_preserve_interleaved_source_order() {
let blurs = [
Expand Down
68 changes: 67 additions & 1 deletion crates/gpui/src/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pixels>,
Expand All @@ -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 {
Expand All @@ -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),
});
}

Expand Down Expand Up @@ -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<f32> {
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]);
}
}
34 changes: 33 additions & 1 deletion crates/gpui_macos/src/metal_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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());
Expand Down
3 changes: 3 additions & 0 deletions crates/gpui_macos/src/shaders.metal
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion crates/gpui_wgpu/src/shaders.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,7 @@ struct BackdropBlur {
source_origin_y: f32,
source_width: f32,
source_height: f32,
opacity: f32,
}
@group(1) @binding(0) var<storage, read> b_backdrop_blurs: array<BackdropBlur>;

Expand Down Expand Up @@ -1250,7 +1251,9 @@ fn fs_backdrop_blur(input: BackdropBlurVarying) -> @location(0) vec4<f32> {
}

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<f32>(straight_rgb, color.a), surface_alpha),
input.position.xy,
Expand Down
2 changes: 1 addition & 1 deletion crates/gpui_wgpu/src/wgpu_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 11 additions & 1 deletion crates/gpui_windows/src/directx_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -3238,6 +3238,16 @@ mod tests {
assert_eq!(std::mem::size_of::<BackdropBlurParams>(), 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::<BackdropBlur>(), 112);
}

#[test]
fn rounded_backdrop_rebuild_uses_retained_configuration() {
assert!(!rounded_backdrop_rebuild_requested(None));
Expand Down
4 changes: 4 additions & 0 deletions crates/gpui_windows/src/shaders.hlsl
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,7 @@ struct BackdropBlur {
float source_origin_y;
float source_width;
float source_height;
float opacity;
};

struct BackdropBlurParams {
Expand Down Expand Up @@ -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);
}
Expand Down
Loading