Skip to content

feat(desktop-gpui): Cap Glass, a cross-platform glass material - #3442

Open
richiemcilroy wants to merge 18 commits into
feat/gpui-window-arrange-focusfrom
feat/gpui-cap-glass
Open

richiemcilroy wants to merge 18 commits into
feat/gpui-window-arrange-focusfrom
feat/gpui-cap-glass

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Stacked on #3454. Part of the picker stack: #3441, #3450, #3452, #3454, this, #3456.

What this adds

Cap Glass is a glass material for the GPUI app. It renders the same on macOS, Windows and Linux, and was fitted against Apple's NSGlassEffectView.

Renderer (apps/desktop-gpui/patches/zed-glass.patch)

The gpui fork's BackdropBlur existed only in the Metal renderer. This patch extends it with a GlassStyle and implements it in all three renderers: Metal (shaders.metal), DirectX 11 (shaders.hlsl) and wgpu (shaders.wgsl).

Per pane, the renderer:

  1. snapshots the region the pane samples;
  2. blurs it (MPS on Metal; a downsample plus separable gaussian on DirectX and wgpu);
  3. runs one fragment pass with the same math on every backend:
    • a blur radius that scales with the pane's shorter side;
    • refraction along the rim's normal;
    • a per-channel cubic colour transform;
    • an edge line and glow;
    • the hairline just outside the edge.

Window::paint_glass wraps the pass in its own scene layer, so the glass draws even when nothing else is painted above it. prepare-gpui-dependency.sh applies the patch like the others.

Package (apps/desktop-gpui/glass, crate cap-glass)

  • model.rs is a CPU model of the same material. It is the reference the GPU output is checked against.
  • presets.json holds the four fitted materials: Regular and Clear, each in light and dark.
  • glass-fit fits a material to reference captures, at whatever backing scale they were taken. The colour transform is linear in its coefficients, so each evaluation solves it exactly by weighted least squares (neutral scenes weighted 12x). Nelder-Mead searches only blur, lensing and lighting.
  • src/ui/glass.rs is the app API: glass::pane(kind, dark, radius) gives a Div whose first child is the glass, so content paints on it.

Reference captures (scripts/glass-reference/reference.swift)

The script draws 13 scenes (flat greys, black, white, text, dense text, checker, grid, stripes, dots, saturated blobs, hues, a UV ramp, a split field). It puts NSGlassEffectView panes over them in each style and appearance, at several sizes and radii, and captures them.

The references were captured on a MacBook Pro (M4 Max, macOS 27). CI VMs render Liquid Glass in a reduced form, so references must come from real hardware. To regenerate them, run the script on a Mac with Screen Recording access, then glass-fit <dir> <out>.

Fit against Apple

Per preset, over the 13 scenes, inside the glass:

Preset Worst SSIM Mean ΔE2000 Scenes at SSIM ≥ 0.97 and ΔE < 2 Worst scene
Clear, light 0.991 0.86 12 / 13 checker, ΔE 3.6
Clear, dark 0.993 0.89 12 / 13 checker, ΔE 3.5
Regular, light 0.965 1.36 9 / 13 checker, ΔE 4.8
Regular, dark 0.967 1.94 6 / 13 checker 3.7, white 3.1, text 2.9

Notes on the table:

  • The worst SSIM in both Regular presets is the stripes scene. There, Apple's blur is not quite gaussian.
  • The saturated checker is the worst scene for colour in every preset. A cubic transform can't follow Apple's saturation curve exactly at the extremes.
  • Flat greys match Apple to within 3 levels per channel in every preset. tests/presets.rs pins this.
  • A further refit that searched a wider space did not improve on these numbers, so these presets ship.

GPU against the model, on every OS

The glass probe (CAP_GLASS_PROBE_OUTPUT, .github/workflows/glass-probe.yml) runs on all three OSes:

  1. it draws every scene with glass panes in each preset in the real app;
  2. it captures the window;
  3. it scores the capture against the CPU model.

On Windows and Linux it captures the window's screen area. Hosted Windows runners have no window capture, and Cap's own windows aren't in the X11 window list.

It passes at SSIM ≥ 0.97 and mean ΔE ≤ 2.

OS / backend Cases Worst SSIM Worst mean ΔE Worst ΔE p95
macOS / Metal 36 0.9992 0.41 0.66
Linux X11 / wgpu 36 0.9992 0.65 1.57
Windows / DirectX 11 36 0.9992 0.58 1.34

Cost

  • Glass costs nothing until a pane is on screen.
  • A pane copies and blurs only its own region plus the blur's reach.
  • Metal sizes its scratch textures to that region. DirectX and wgpu keep surface-sized ones.
  • Every backend frees its scratch textures after 120 frames with no glass, as the existing Metal backdrop blur already did.
  • cap-glass builds at opt-level = 3 in dev, so the probe and the fitter run at full speed.

Testing

  • cargo test -p cap-glass runs the preset calibration against Apple's flat values (wired into ci.yml).
  • The glass probe runs on macOS, Windows and Linux (table above).
  • node --test scripts/prepare-gpui-dependency.test.mjs covers the new patch's place in the patch list.

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge, with non-blocking gaps in the CPU reference and the scaled X11 probe.

Findings

  1. P2 Scaled X11 probe fails ▶
  2. P2 Transparent reference images turn opaque ▶
  3. P2 Overlapping panes miss earlier glass ▶
Fix with agent prompt
### Issue 1
apps/desktop-gpui/src/glass_probe.rs:257-263
On X11 with a backing scale above 1, `window_target` passes logical window coordinates to a capture path that reads physical pixels. At scale 2, it captures roughly 960×600 pixels while the expected image is 1920×1200. `crop_to` cannot enlarge the capture, so `metrics::ssim` panics before the probe writes its report.

Apply the X11 scale conversion already used by `capture_scale_factor` when building these capture bounds.

### Issue 2
apps/desktop-gpui/glass/src/model.rs:undefined-268
`model::composite` makes every touched pixel fully opaque and ignores `style.opacity`. The GPU shaders instead use the backdrop's alpha and the material's opacity, so the CPU reference gives different results for transparent images.

Match the GPU's behavior in the CPU model and add a transparent-backdrop test. The current opaque scenes cannot catch this difference.

### Issue 3
apps/desktop-gpui/glass/src/model.rs:undefined-278
`model::render` blurs the original `backdrop` for every pane. When panes overlap, a later pane ignores the glass already drawn beneath it. The GPU instead snapshots the window before each pane, so the CPU reference produces different colours and blur in the overlap.

Blur the current `out` before drawing each pane, and add an overlapping-pane test.

```suggestion
        composite(&style, shape, &out.blurred(style.blur), &mut out);
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds Cap Glass rendering for Metal, DirectX 11, and wgpu, plus fitted presets and a shared CPU model.

  • GPUI draws glass panes with blur, refraction, and edge lighting across three renderers.
  • The app can choose fitted Regular or Clear glass panes for light or dark appearances.
  • A glass probe checks rendered panes against the CPU model on three operating systems.

Reviews (6) · Last reviewed commit: "Merge feat/gpui-window-arrange-focus int..." · Reviewed by Greptile

@vercel

vercel Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cap-status Ready Ready Preview Oct 27, 2026 9:04am UTC

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

Comment thread apps/desktop-gpui/glass/src/bin/glass-fit.rs
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

Comment thread apps/desktop-gpui/patches/zed-glass.patch
Comment thread apps/desktop-gpui/glass/src/lib.rs Outdated
Comment thread apps/desktop-gpui/glass/src/model.rs Outdated
…ws, where hosted runners lack window capture
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

let under = under_with_hairline(&glass, [under[0] / 255., under[1] / 255., under[2] / 255.]);
let color = if glass.coverage > 0. { finish(style, &glass) } else { under };
let mix = |i: usize| (under[i] + (color[i].clamp(0., 1.) - under[i]) * glass.coverage) * 255.;
out.set(x, y, [mix(0), mix(1), mix(2), 255.]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Transparent reference images turn opaque

model::composite makes every touched pixel fully opaque and ignores style.opacity. The GPU shaders instead use the backdrop's alpha and the material's opacity, so the CPU reference gives different results for transparent images.

Match the GPU's behavior in the CPU model and add a transparent-backdrop test. The current opaque scenes cannot catch this difference.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop-gpui/glass/src/model.rs
Line: 268

Comment:
**Transparent reference images turn opaque**

`model::composite` makes every touched pixel fully opaque and ignores `style.opacity`. The GPU shaders instead use the backdrop's alpha and the material's opacity, so the CPU reference gives different results for transparent images.

Match the GPU's behavior in the CPU model and add a transparent-backdrop test. The current opaque scenes cannot catch this difference.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@superagent-security superagent-security 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.

Superagent found 1 security concern(s).

- "apps/desktop-gpui/src/ui/glass.rs"
- "apps/desktop-gpui/src/glass_probe.rs"
- "scripts/glass-probe/**"
- ".github/workflows/glass-probe.yml"

@superagent-security superagent-security Bot Oct 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Fork PRs can queue the full 3-OS, 75-minute glass probe by editing only the workflow file

Workflow lists its own file in pull_request paths and gates a 3-OS, 75-minute build/probe matrix, so a fork can queue it by editing only CI.

Add the same-repo scope job used by perf-budget.yml/instant-reliability.yml, or drop the workflow's own path from the trigger.

AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name=".github/workflows/glass-probe.yml">
<violation number="1" location=".github/workflows/glass-probe.yml:16">
<priority>P2</priority>
<title>Fork PRs can queue the full 3-OS, 75-minute glass probe by editing only the workflow file</title>
<evidence>The newly added workflow runs on `pull_request` (`opened, synchronize, reopened, ready_for_review`, line 8-9) and lists its own file among the path filters at line 16 (`- ".github/workflows/glass-probe.yml"`). The job it gates has `timeout-minutes: 75` (line 29) and a matrix of three runners (lines 32-45): `blacksmith-6vcpu-macos-15`, `windows-2022`, and `blacksmith-8vcpu-ubuntu-2404`. Each matrix leg checks out the PR, runs `node scripts/setup.js` and `./scripts/prepare-gpui-dependency.sh`, builds the complete `cap-gpui` binary, runs `cargo test -p cap-glass`, rewrites the macOS TCC database with sudo, and launches the real app to render/capture 9 scenes x 4 presets. A fork contributor can therefore trigger all of that work with a commit that touches only `.github/workflows/glass-probe.yml`, with no product change. The repository already fixed this exact self-trigger pattern with same-repo scope jobs in `perf-budget.yml` (scope job, base lines ~26-69, comment "a fork cannot queue the macOS job by editing these files") and `instant-reliability.yml` (scope job, base lines ~41-70); glass-probe.yml copies the older ungated picker-smoke shape instead.</evidence>
<recommendation>Add a lightweight `scope` job like `perf-budget.yml`/`instant-reliability.yml` that computes the changed files, and only set `run=true` for a workflow/script-only change when `github.event.pull_request.head.repo.full_name == github.repository` (keep `workflow_dispatch` and same-repo PRs unrestricted). Alternatively, remove `.github/workflows/glass-probe.yml` from the paths filter and validate CI-only changes with a bounded job. Keep the full 3-OS probe for changes to actual glass code.</recommendation>
</violation>
</file>

@capy-ai capy-ai 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.

Capy found 1 potential issue (1 medium).

View 1 other finding in Capy.

Open in Capy

+ // nothing else is painted above it. The extra 2px covers glass's
+ // hairline.
+ let layer_bounds = bounds.scale(scale_factor).dilate(ScaledPixels(2.));
+ self.next_frame.scene.push_layer(layer_bounds);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shadow batching can suppress a glass pane on Metal

Medium Severity

The existing Metal renderer processes pending blurs only before a batch whose first draw order reaches the blur, and does not flush remaining blurs afterward. A shadowed glass::pane with no painted children emits its real drop shadow before this layer, followed by the invisible splitter shadow inside it. Scene::BatchIterator merges those shadows into one batch starting below the glass order, so Metal never draws the pane. Giving the splitter and glass the same order does not guarantee a batch boundary. Split batches at backdrop orders explicitly; the new DirectX and wgpu scheduling also needs that boundary to avoid drawing later shadows before an intervening glass pass.

Open in Capy

let mut out = backdrop.clone();
for shape in shapes {
let style = material.resolve(shape.width, shape.height);
composite(&style, shape, &backdrop.blurred(style.blur), &mut out);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Overlapping panes miss earlier glass

model::render blurs the original backdrop for every pane. When panes overlap, a later pane ignores the glass already drawn beneath it. The GPU instead snapshots the window before each pane, so the CPU reference produces different colours and blur in the overlap.

Blur the current out before drawing each pane, and add an overlapping-pane test.

Suggested change
composite(&style, shape, &backdrop.blurred(style.blur), &mut out);
composite(&style, shape, &out.blurred(style.blur), &mut out);
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop-gpui/glass/src/model.rs
Line: 278

Comment:
**Overlapping panes miss earlier glass**

`model::render` blurs the original `backdrop` for every pane. When panes overlap, a later pane ignores the glass already drawn beneath it. The GPU instead snapshots the window before each pane, so the CPU reference produces different colours and blur in the overlap.

Blur the current `out` before drawing each pane, and add an overlapping-pane test.

```suggestion
        composite(&style, shape, &out.blurred(style.blur), &mut out);
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@capy-ai capy-ai 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.

Capy found no issues.

Open in Capy

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

Comment on lines +257 to +263
LogicalPosition::new(
f64::from(f32::from(bounds.origin.x)) - origin.x(),
f64::from(f32::from(bounds.origin.y)) - origin.y(),
),
LogicalSize::new(
f64::from(f32::from(bounds.size.width)),
f64::from(f32::from(bounds.size.height)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Scaled X11 probe fails

On X11 with a backing scale above 1, window_target passes logical window coordinates to a capture path that reads physical pixels. At scale 2, it captures roughly 960×600 pixels while the expected image is 1920×1200. crop_to cannot enlarge the capture, so metrics::ssim panics before the probe writes its report.

Apply the X11 scale conversion already used by capture_scale_factor when building these capture bounds.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop-gpui/src/glass_probe.rs
Line: 257-263

Comment:
**Scaled X11 probe fails**

On X11 with a backing scale above 1, `window_target` passes logical window coordinates to a capture path that reads physical pixels. At scale 2, it captures roughly 960×600 pixels while the expected image is 1920×1200. `crop_to` cannot enlarge the capture, so `metrics::ssim` panics before the probe writes its report.

Apply the X11 scale conversion already used by `capture_scale_factor` when building these capture bounds.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

This branch was successfully deployed

2 active deployments
Preview – cap-web — 88168f04 Deployed Oct 27, 2026 by vercel[bot]
Preview – cap-status — 88168f04 Deployed Oct 27, 2026 by vercel[bot]
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