Skip to content

fix(scene): scale backdrop blur with element opacity - #9

Merged
BumpyClock merged 2 commits into
mainfrom
fix/backdrop-blur-opacity
Jul 28, 2026
Merged

fix(scene): scale backdrop blur with element opacity#9
BumpyClock merged 2 commits into
mainfrom
fix/backdrop-blur-opacity

Conversation

@BumpyClock

@BumpyClock BumpyClock commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Backdrop blur ignored element opacity. paint_backdrop_blur was the only paint method that never read element_opacity — its neighbours (paint_quad, paint_shadow, paint_text) all do — and BackdropBlur carried no opacity field to multiply by. A surface fading to zero kept painting a full-strength blurred backdrop until unmount, then dropped it in a single frame.

Downstream (gpui-component) measured this as a 1.5–3.2 luma single-frame step at the tail of otherwise-smooth 215ms exit animations, and it made wrapper-driven fades over blurred subtrees impossible — a faded-to-zero flyout still showed its blur.

Approach

Opacity becomes a per-instance parameter on BackdropBlur, applied only at final composite. It is deliberately invisible to blur planning, source capture, clustering and pyramid generation:

  • backdrop_blur_clusters groups on padded-bounds overlap
  • backdrop_blur_plan_groups groups on radius→plan equality
  • prepare_backdrop_blurs copies opacity through untouched

So two surfaces mid-fade at 0.9 and 0.8 still share one cluster, one plan group, one blur texture and one source snapshot — batching is unaffected during exactly the frames where cost matters.

Opacity folds into the surface alpha rather than lerping against a separately-bound sharp texture. The composite already blends src-over against the framebuffer, which still holds the unblurred backdrop at that draw order, so alpha *= opacity yields dst = blurred·w + sharp·(1−w) — identical result, no extra sample or bind.

Culling happens only at exactly zero opacity. A positive threshold would reintroduce the same single-frame pop at a different point in the fade.

Why not the alternatives

Measured on a real 215ms/13-frame exit through render_to_image on the Metal path:

mechanism max luma step unevenness detail unevenness
composite weight (this PR) 5.55 1.05 1.24
scale blur radius 12.74 3.17 3.21
threshold skip 18.17 4.56 35.5

Radius scaling is also structurally wrong: radius feeds backdrop_blur_plan_for_radius and cluster padding, so animating it re-plans and re-clusters every frame.

Incidental fix

The HLSL BackdropBlur mirror was missing pad2, giving a 108-byte shader-side stride against the 112-byte StructureByteStride from size_of::<BackdropBlur>(). Any Windows batch with two or more blurs read skewed data from index 1 onward. Both are now 112, and a size_of assertion guards the hand-maintained mirror — unlike the Metal struct, which is cbindgen-generated and cannot drift.

Verification

macOS: cargo test -p gpui -p gpui_macos --features gpui/test-support → 298 passed, 0 failed. Clippy byte-identical to baseline.

On-GPU measurements (scratch probe, not committed — the fork has no image-comparison test infrastructure): opaque interpolation max error 1/255 at o = 0.25/0.5/0.75 and exactly 0 at endpoints; zero-opacity frame byte-identical to the no-blur frame with unmount step 0.000; rounded-mask intersection shows 0 difference outside the mask and in rounded-off corners at every opacity; translucent-target luma at o=0.5 measured 52.07 against a linear midpoint of 52.02, excluding an opacity-squared implementation (which would give 44.9).

Unverified: DirectX/HLSL and wgpu have not been compiled — no local toolchain for those targets. CI is their first build.

Not covered: painter order across two overlapping blurred surfaces at differing opacities (batching is covered, ordering is not).

Note

Scale backdrop blur composite weight by element opacity

  • Adds an opacity: f32 field to the BackdropBlur struct (replacing the pad2 padding field), passed from the element's effective opacity in paint_backdrop_blur.
  • Updates Metal, WGSL, and HLSL shaders to multiply the blurred backdrop alpha by clamp(blur.opacity, 0.0, 1.0) during fragment shading.
  • Backdrop blurs with zero or non-finite opacity are culled at paint time rather than submitted to the GPU.
  • Behavioral Change: backdrop blurs now fade with element opacity instead of always rendering at full weight; elements with exactly zero opacity are skipped entirely.

Macroscope summarized d2b94c1.

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.
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::<BackdropBlur>(), which the hand-maintained shader mirror
had silently violated before this branch.
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Backdrop blur primitives now carry element opacity through scene planning, window painting, GPU storage layouts, and Metal/WGPU compositing shaders. Tests cover opacity propagation, culling, grouping, scratch sizing, cache behavior, and cross-backend layout compatibility.

Changes

Backdrop blur opacity

Layer / File(s) Summary
Scene contract and planning
crates/gpui/src/scene.rs
BackdropBlur replaces pad2 with documented opacity, while planning preserves opacity without changing clustering, grouping, or scratch texture sizing.
Window opacity propagation
crates/gpui/src/window.rs
paint_backdrop_blur culls non-finite and non-positive opacity, stores opacity clamped to 1.0, and tests propagation and positive-opacity rendering.
Backend shader and layout validation
crates/gpui_macos/src/{metal_renderer.rs,shaders.metal}, crates/gpui_wgpu/src/{shaders.wgsl,wgpu_renderer.rs}, crates/gpui_windows/src/directx_renderer.rs
Metal and WGPU fragment shaders scale blur alpha by per-primitive opacity; backend tests update layouts, cache fixtures, fast-path behavior, and HLSL stride validation.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Window as Window::paint_backdrop_blur
  participant Scene as BackdropBlur
  participant Shader as backdrop_blur_fragment
  Window->>Scene: Store clamped element opacity
  Scene->>Shader: Upload opacity in GPU buffer
  Shader->>Shader: Scale blur alpha by opacity
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: backdrop blur now scales with element opacity.
Description check ✅ Passed The description matches the changeset and explains the opacity-weighted backdrop blur update.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/backdrop-blur-opacity

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with 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.

Inline comments:
In `@crates/gpui/src/scene.rs`:
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 242241ec-ded9-4788-a49b-2bdafa33afa1

📥 Commits

Reviewing files that changed from the base of the PR and between 6646f57 and d2b94c1.

⛔ Files ignored due to path filters (1)
  • crates/gpui_windows/src/shaders.hlsl is excluded by !**/*.hlsl
📒 Files selected for processing (7)
  • crates/gpui/src/scene.rs
  • crates/gpui/src/window.rs
  • crates/gpui_macos/src/metal_renderer.rs
  • crates/gpui_macos/src/shaders.metal
  • crates/gpui_wgpu/src/shaders.wgsl
  • crates/gpui_wgpu/src/wgpu_renderer.rs
  • crates/gpui_windows/src/directx_renderer.rs

Comment thread crates/gpui/src/scene.rs
Comment on lines +1746 to +1794
#[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);
}

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.

@BumpyClock
BumpyClock merged commit 1eeb173 into main Jul 28, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant