From b223bd9fe32abb859aa54a086d56ba1b1efbcc87 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:52:12 +0000 Subject: [PATCH 01/14] test(desktop-gpui): capture Apple's glass over fixed backdrops as a reference --- .github/workflows/glass-reference.yml | 58 +++++++ scripts/glass-reference/reference.swift | 213 ++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 .github/workflows/glass-reference.yml create mode 100644 scripts/glass-reference/reference.swift diff --git a/.github/workflows/glass-reference.yml b/.github/workflows/glass-reference.yml new file mode 100644 index 0000000000..9ccec38e42 --- /dev/null +++ b/.github/workflows/glass-reference.yml @@ -0,0 +1,58 @@ +name: Glass reference (Apple) +# Captures Apple's NSGlassEffectView over the fixed backdrops in +# scripts/glass-reference so Cap's glass renderer can be compared against it. + +on: + pull_request: + paths: + - "scripts/glass-reference/**" + - ".github/workflows/glass-reference.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: glass-reference-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + capture: + name: Apple glass reference (${{ matrix.runner }}) + strategy: + fail-fast: false + matrix: + runner: [macos-26, blacksmith-6vcpu-macos-26] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Screen capture permission + run: | + set -euo pipefail + sudo pmset -a displaysleep 0 sleep 0 || true + caffeinate -u -t 2 + swiftc -O scripts/glass-reference/reference.swift -o "$RUNNER_TEMP/reference" + db="/Library/Application Support/com.apple.TCC/TCC.db" + now=$(date +%s) + for client in "$RUNNER_TEMP/reference" /usr/sbin/screencapture /bin/bash /Users/runner/bin/Runner.Worker /Users/runner/bin/Runner.Listener; do + sudo sqlite3 "$db" "INSERT OR REPLACE INTO access (service,client,client_type,auth_value,auth_reason,auth_version,indirect_object_identifier,flags,last_modified) VALUES ('kTCCServiceScreenCapture','$client',1,2,4,1,'UNUSED',0,$now);" || true + done + sudo killall tccd || true + sleep 2 + sw_vers + system_profiler SPDisplaysDataType | grep -E "Resolution|Chipset|Metal" || true + + - name: Capture + run: | + "$RUNNER_TEMP/reference" "$RUNNER_TEMP/glass-reference" + ls "$RUNNER_TEMP/glass-reference/apple" | head + + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: glass-reference-${{ matrix.runner }} + path: ${{ runner.temp }}/glass-reference + retention-days: 30 diff --git a/scripts/glass-reference/reference.swift b/scripts/glass-reference/reference.swift new file mode 100644 index 0000000000..72f4eb9d12 --- /dev/null +++ b/scripts/glass-reference/reference.swift @@ -0,0 +1,213 @@ +// Renders Apple's own glass (NSGlassEffectView, macOS 26+) over a fixed set +// of backdrops and captures each window, so Cap's glass renderer can be +// compared against it pixel for pixel. Writes /scenes/.png (the +// bare backdrop), /apple/.png and /manifest.json. +// +// swiftc -O reference.swift -o reference && ./reference +// +// Needs Screen Recording for the calling process (screencapture -l). +import AppKit + +let canvas = NSSize(width: 960, height: 600) + +struct Shape { + let name: String + let rect: NSRect + let radius: CGFloat +} + +// Rects are in points with a top-left origin, matching the manifest. +let shapes: [Shape] = [ + Shape(name: "capsule", rect: NSRect(x: 60, y: 60, width: 320, height: 120), radius: 60), + Shape(name: "panel", rect: NSRect(x: 460, y: 60, width: 420, height: 240), radius: 24), + Shape(name: "toolbar", rect: NSRect(x: 60, y: 260, width: 300, height: 48), radius: 24), + Shape(name: "circle", rect: NSRect(x: 120, y: 380, width: 96, height: 96), radius: 48), + Shape(name: "card", rect: NSRect(x: 460, y: 380, width: 360, height: 160), radius: 16), +] + +func drawScene(_ name: String, in ctx: CGContext, size: NSSize) { + let rect = CGRect(origin: .zero, size: size) + func fill(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat) { + ctx.setFillColor(CGColor(srgbRed: r, green: g, blue: b, alpha: 1)) + ctx.fill(rect) + } + func text(_ color: NSColor) { + let style = NSMutableParagraphStyle() + style.lineSpacing = 4 + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 15, weight: .regular), + .foregroundColor: color, + .paragraphStyle: style, + ] + let line = "The quick brown fox jumps over the lazy dog 0123456789 — Cap records, edits and shares. " + let body = String(repeating: line, count: 40) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: false) + (body as NSString).draw(in: rect.insetBy(dx: 24, dy: 24), withAttributes: attrs) + NSGraphicsContext.restoreGraphicsState() + } + switch name { + case "white": fill(1, 1, 1) + case "black": fill(0, 0, 0) + case "gray": fill(0.5, 0.5, 0.5) + case "text": + fill(1, 1, 1) + text(.black) + case "darktext": + fill(0.08, 0.08, 0.09) + text(.white) + case "stripes": + fill(1, 1, 1) + ctx.setFillColor(CGColor(srgbRed: 0, green: 0, blue: 0, alpha: 1)) + var x: CGFloat = 0 + while x < size.width { + ctx.fill(CGRect(x: x, y: 0, width: 8, height: size.height)) + x += 16 + } + case "grid": + fill(0.96, 0.96, 0.97) + ctx.setStrokeColor(CGColor(srgbRed: 0.1, green: 0.3, blue: 0.9, alpha: 1)) + ctx.setLineWidth(2) + var v: CGFloat = 0 + while v <= max(size.width, size.height) { + ctx.move(to: CGPoint(x: v, y: 0)); ctx.addLine(to: CGPoint(x: v, y: size.height)) + ctx.move(to: CGPoint(x: 0, y: v)); ctx.addLine(to: CGPoint(x: size.width, y: v)) + v += 24 + } + ctx.strokePath() + case "checker": + let cell: CGFloat = 32 + for row in 0.. CGColor in + NSColor(calibratedHue: CGFloat(i) / 6, saturation: 0.85, brightness: 0.95, alpha: 1).cgColor + } + let gradient = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), colors: colors as CFArray, locations: nil)! + ctx.drawLinearGradient(gradient, start: .zero, end: CGPoint(x: size.width, y: 0), options: []) + case "blobs": + fill(0.98, 0.94, 0.88) + let blobs: [(CGFloat, CGFloat, CGFloat, CGColor)] = [ + (0.2, 0.3, 260, CGColor(srgbRed: 1.0, green: 0.35, blue: 0.45, alpha: 1)), + (0.7, 0.25, 300, CGColor(srgbRed: 0.25, green: 0.55, blue: 1.0, alpha: 1)), + (0.5, 0.8, 280, CGColor(srgbRed: 0.2, green: 0.85, blue: 0.55, alpha: 1)), + (0.9, 0.8, 220, CGColor(srgbRed: 1.0, green: 0.8, blue: 0.2, alpha: 1)), + ] + for (fx, fy, radius, color) in blobs { + let center = CGPoint(x: fx * size.width, y: fy * size.height) + let clear = color.copy(alpha: 0)! + let gradient = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), colors: [color, clear] as CFArray, locations: [0, 1])! + ctx.drawRadialGradient(gradient, startCenter: center, startRadius: 0, endCenter: center, endRadius: radius, options: []) + } + default: fill(1, 0, 1) + } +} + +let scenes = ["white", "black", "gray", "text", "darktext", "stripes", "grid", "checker", "hues", "blobs"] +let styles: [(String, NSGlassEffectView.Style)] = [("regular", .regular), ("clear", .clear)] +let appearances: [(String, NSAppearance.Name)] = [("light", .aqua), ("dark", .darkAqua)] +let tints: [(String, NSColor?)] = [("none", nil), ("blue", NSColor.systemBlue.withAlphaComponent(0.6))] + +final class FlippedView: NSView { + override var isFlipped: Bool { true } +} + +func renderScene(_ name: String, scale: CGFloat) -> CGImage { + let width = Int(canvas.width * scale), height = Int(canvas.height * scale) + let ctx = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + ctx.scaleBy(x: scale, y: scale) + drawScene(name, in: ctx, size: canvas) + return ctx.makeImage()! +} + +func writePNG(_ image: CGImage, to url: URL) { + let rep = NSBitmapImageRep(cgImage: image) + try! rep.representation(using: .png, properties: [:])!.write(to: url) +} + +func pump(_ seconds: TimeInterval) { + RunLoop.current.run(until: Date().addingTimeInterval(seconds)) +} + +let out = URL(fileURLWithPath: CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "glass-reference") +try FileManager.default.createDirectory(at: out.appendingPathComponent("scenes"), withIntermediateDirectories: true) +try FileManager.default.createDirectory(at: out.appendingPathComponent("apple"), withIntermediateDirectories: true) + +let app = NSApplication.shared +app.setActivationPolicy(.regular) +app.finishLaunching() + +let window = NSWindow(contentRect: NSRect(origin: NSPoint(x: 80, y: 80), size: canvas), + styleMask: [.borderless], backing: .buffered, defer: false) +window.isOpaque = true +window.hasShadow = false +window.level = .floating +let root = FlippedView(frame: NSRect(origin: .zero, size: canvas)) +window.contentView = root +window.makeKeyAndOrderFront(nil) +app.activate(ignoringOtherApps: true) +pump(1.0) + +let scale = window.backingScaleFactor +var cases: [[String: Any]] = [] +let backdrop = NSImageView(frame: root.bounds) +backdrop.imageScaling = .scaleAxesIndependently +root.addSubview(backdrop) + +for scene in scenes { + let image = renderScene(scene, scale: scale) + writePNG(image, to: out.appendingPathComponent("scenes/\(scene).png")) + backdrop.image = NSImage(cgImage: image, size: canvas) + for (styleName, style) in styles { + for (appearanceName, appearance) in appearances { + for (tintName, tint) in tints { + if tintName != "none" && !(scene == "white" || scene == "blobs" || scene == "black") { + continue + } + window.appearance = NSAppearance(named: appearance) + var views: [NSView] = [] + for shape in shapes { + let glass = NSGlassEffectView(frame: shape.rect) + glass.style = style + glass.cornerRadius = shape.radius + glass.tintColor = tint + root.addSubview(glass) + views.append(glass) + } + pump(0.6) + let name = "\(scene)-\(styleName)-\(appearanceName)-\(tintName)" + let path = out.appendingPathComponent("apple/\(name).png").path + let capture = Process() + capture.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") + capture.arguments = ["-x", "-o", "-l\(window.windowNumber)", path] + try capture.run() + capture.waitUntilExit() + cases.append([ + "name": name, "scene": scene, "style": styleName, + "appearance": appearanceName, "tint": tintName, + ]) + views.forEach { $0.removeFromSuperview() } + } + } + } +} + +let manifest: [String: Any] = [ + "canvas": [canvas.width, canvas.height], + "scale": scale, + "os": ProcessInfo.processInfo.operatingSystemVersionString, + "shapes": shapes.map { ["name": $0.name, "rect": [$0.rect.minX, $0.rect.minY, $0.rect.width, $0.rect.height], "radius": $0.radius] }, + "tints": ["blue": [0.0, 0.533, 1.0, 0.6]], + "cases": cases, +] +let data = try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys]) +try data.write(to: out.appendingPathComponent("manifest.json")) +print("wrote \(cases.count) cases to \(out.path)") From 79b6f2c94f6204982d39e31447f33606b955cb28 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:55:54 +0000 Subject: [PATCH 02/14] test(desktop-gpui): record the reference runner's accessibility and GPU state --- .github/workflows/glass-reference.yml | 6 ++++++ scripts/glass-reference/reference.swift | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/glass-reference.yml b/.github/workflows/glass-reference.yml index 9ccec38e42..aff28f02cc 100644 --- a/.github/workflows/glass-reference.yml +++ b/.github/workflows/glass-reference.yml @@ -41,6 +41,12 @@ jobs: sudo sqlite3 "$db" "INSERT OR REPLACE INTO access (service,client,client_type,auth_value,auth_reason,auth_version,indirect_object_identifier,flags,last_modified) VALUES ('kTCCServiceScreenCapture','$client',1,2,4,1,'UNUSED',0,$now);" || true done sudo killall tccd || true + defaults read com.apple.universalaccess 2>/dev/null || true + for key in reduceTransparency increaseContrast reduceMotion; do + defaults write com.apple.universalaccess "$key" -bool false || true + sudo defaults write com.apple.universalaccess "$key" -bool false || true + done + killall cfprefsd || true sleep 2 sw_vers system_profiler SPDisplaysDataType | grep -E "Resolution|Chipset|Metal" || true diff --git a/scripts/glass-reference/reference.swift b/scripts/glass-reference/reference.swift index 72f4eb9d12..05e1a145ad 100644 --- a/scripts/glass-reference/reference.swift +++ b/scripts/glass-reference/reference.swift @@ -7,6 +7,7 @@ // // Needs Screen Recording for the calling process (screencapture -l). import AppKit +import Metal let canvas = NSSize(width: 960, height: 600) @@ -204,6 +205,9 @@ let manifest: [String: Any] = [ "canvas": [canvas.width, canvas.height], "scale": scale, "os": ProcessInfo.processInfo.operatingSystemVersionString, + "reduceTransparency": NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency, + "increaseContrast": NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast, + "gpu": MTLCreateSystemDefaultDevice()?.name ?? "none", "shapes": shapes.map { ["name": $0.name, "rect": [$0.rect.minX, $0.rect.minY, $0.rect.width, $0.rect.height], "radius": $0.radius] }, "tints": ["blue": [0.0, 0.533, 1.0, 0.6]], "cases": cases, From a25aafd5bd1281c8d6454dafca58c763387c50bf Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:34:21 +0000 Subject: [PATCH 03/14] feat(desktop-gpui): Cap Glass renderer, model and real-app probe --- .github/workflows/ci.yml | 1 + .github/workflows/glass-probe.yml | 147 ++ .github/workflows/glass-reference.yml | 64 - apps/desktop-gpui/Cargo.lock | 10 + apps/desktop-gpui/Cargo.toml | 3 +- apps/desktop-gpui/glass/Cargo.toml | 11 + apps/desktop-gpui/glass/presets.json | 214 ++ apps/desktop-gpui/glass/src/bin/glass-fit.rs | 520 +++++ apps/desktop-gpui/glass/src/lib.rs | 147 ++ apps/desktop-gpui/glass/src/metrics.rs | 192 ++ apps/desktop-gpui/glass/src/model.rs | 265 +++ apps/desktop-gpui/glass/src/scenes.rs | 117 + apps/desktop-gpui/patches/zed-glass.patch | 2020 ++++++++++++++++++ apps/desktop-gpui/src/glass_probe.rs | 256 +++ apps/desktop-gpui/src/main.rs | 4 + apps/desktop-gpui/src/ui/glass.rs | 107 + apps/desktop-gpui/src/ui/mod.rs | 1 + scripts/glass-probe/run.sh | 51 + scripts/glass-reference/reference.swift | 116 +- scripts/prepare-gpui-dependency.sh | 1 + scripts/prepare-gpui-dependency.test.mjs | 2 + 21 files changed, 4177 insertions(+), 72 deletions(-) create mode 100644 .github/workflows/glass-probe.yml delete mode 100644 .github/workflows/glass-reference.yml create mode 100644 apps/desktop-gpui/glass/Cargo.toml create mode 100644 apps/desktop-gpui/glass/presets.json create mode 100644 apps/desktop-gpui/glass/src/bin/glass-fit.rs create mode 100644 apps/desktop-gpui/glass/src/lib.rs create mode 100644 apps/desktop-gpui/glass/src/metrics.rs create mode 100644 apps/desktop-gpui/glass/src/model.rs create mode 100644 apps/desktop-gpui/glass/src/scenes.rs create mode 100644 apps/desktop-gpui/patches/zed-glass.patch create mode 100644 apps/desktop-gpui/src/glass_probe.rs create mode 100644 apps/desktop-gpui/src/ui/glass.rs create mode 100755 scripts/glass-probe/run.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70bb14815b..4527fba587 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -421,6 +421,7 @@ jobs: cargo +1.95.0 test --manifest-path apps/desktop-gpui/Cargo.toml --locked --target ${{ matrix.settings.target }} --bin cap-gpui fi cargo +1.95.0 test --manifest-path apps/desktop-gpui/Cargo.toml --locked --target ${{ matrix.settings.target }} -p cap-gpui-kernels + cargo +1.95.0 test --manifest-path apps/desktop-gpui/Cargo.toml --locked --target ${{ matrix.settings.target }} -p cap-glass cargo +1.95.0 test --manifest-path apps/desktop-gpui/Cargo.toml --locked --target ${{ matrix.settings.target }} -p cap-i18n-build # Cap only builds gpui's crates as dependencies, so their own tests never run diff --git a/.github/workflows/glass-probe.yml b/.github/workflows/glass-probe.yml new file mode 100644 index 0000000000..0ccced4119 --- /dev/null +++ b/.github/workflows/glass-probe.yml @@ -0,0 +1,147 @@ +name: Glass probe (real app) +# Draws Cap Glass in the real GPUI app on macOS (Metal), Windows (Direct3D 11 +# on WARP) and Linux (wgpu on lavapipe), captures the window and scores every +# scene and preset against the CPU model in apps/desktop-gpui/glass, which is +# fitted to Apple's own glass. + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - "apps/desktop-gpui/glass/**" + - "apps/desktop-gpui/patches/zed-glass.patch" + - "apps/desktop-gpui/src/ui/glass.rs" + - "apps/desktop-gpui/src/glass_probe.rs" + - "scripts/glass-probe/**" + - ".github/workflows/glass-probe.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: glass-probe-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + probe: + name: Glass probe (${{ matrix.settings.name }}) + timeout-minutes: 75 + strategy: + fail-fast: false + matrix: + settings: + - name: macOS + slug: macos + target: aarch64-apple-darwin + runner: blacksmith-6vcpu-macos-15 + - name: Windows + slug: windows + target: x86_64-pc-windows-msvc + runner: windows-2022 + - name: Linux X11 + slug: linux-x11 + target: x86_64-unknown-linux-gnu + runner: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ matrix.settings.runner }} + env: + CARGO_PROFILE_DEV_DEBUG: "0" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Linux desktop dependencies + if: ${{ runner.os == 'Linux' }} + uses: ./.github/actions/install-desktop-deps + + - name: Install a window manager and software Vulkan + if: ${{ runner.os == 'Linux' }} + run: sudo apt-get install -y openbox mesa-vulkan-drivers libvulkan1 + + - name: Rust setup + uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: ${{ matrix.settings.target }} + + - uses: ./.github/actions/setup-rust-cache + with: + target: ${{ matrix.settings.target }} + prefix-key: v1-rust-gpui + save-cache: "false" + workspaces: apps/desktop-gpui -> target + + - name: Run setup + shell: bash + run: node scripts/setup.js + env: + RUST_TARGET_TRIPLE: ${{ matrix.settings.target }} + + - name: Prepare GPUI dependency + shell: bash + run: | + node scripts/sync-desktop-versions.mjs + ./scripts/prepare-gpui-dependency.sh + + - name: Test the glass model + shell: bash + run: cargo +1.95.0 test --manifest-path apps/desktop-gpui/Cargo.toml --locked -p cap-glass + + - name: Build the app + shell: bash + run: | + cargo +1.95.0 build --manifest-path apps/desktop-gpui/Cargo.toml --locked \ + --target ${{ matrix.settings.target }} --bin cap-gpui + if [[ "$RUNNER_OS" == "macOS" ]]; then + for frameworks in "$GITHUB_WORKSPACE/apps/desktop-gpui/target/${{ matrix.settings.target }}/debug/Frameworks" \ + "$GITHUB_WORKSPACE/apps/desktop-gpui/target/${{ matrix.settings.target }}/Frameworks"; do + mkdir -p "$frameworks" + ln -sfn "$GITHUB_WORKSPACE/target/Frameworks/Spacedrive.framework" "$frameworks/Spacedrive.framework" + done + fi + + - name: Screen capture permission + if: ${{ runner.os == 'macOS' }} + run: | + set -euo pipefail + sudo pmset -a displaysleep 0 sleep 0 || true + caffeinate -u -t 2 + app="$GITHUB_WORKSPACE/apps/desktop-gpui/target/${{ matrix.settings.target }}/debug/cap-gpui" + db="/Library/Application Support/com.apple.TCC/TCC.db" + now=$(date +%s) + for client in "$app" /bin/bash /Users/runner/bin/Runner.Worker /Users/runner/bin/Runner.Listener; do + sudo sqlite3 "$db" "INSERT OR REPLACE INTO access (service,client,client_type,auth_value,auth_reason,auth_version,indirect_object_identifier,flags,last_modified) VALUES ('kTCCServiceScreenCapture','$client',1,2,4,1,'UNUSED',0,$now);" + done + sudo killall tccd || true + sleep 2 + + - name: Glass probe + shell: bash + run: | + app="$GITHUB_WORKSPACE/apps/desktop-gpui/target/${{ matrix.settings.target }}/debug/cap-gpui" + out="$RUNNER_TEMP/glass-probe" + case "$RUNNER_OS" in + macOS) + scripts/glass-probe/run.sh "$app" "$out" + ;; + Windows) + export PATH="$(cygpath "$GITHUB_WORKSPACE/target/debug"):$PATH" + scripts/glass-probe/run.sh "$app.exe" "$out" + ;; + Linux) + export LD_LIBRARY_PATH="$GITHUB_WORKSPACE/target/native-deps/lib:$GITHUB_WORKSPACE/target/debug${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + Xvfb :99 -screen 0 1920x1080x24 -noreset & + export DISPLAY=:99 + sleep 1 + openbox & + sleep 1 + scripts/glass-probe/run.sh "$app" "$out" + ;; + esac + + - name: Upload captures + if: always() + uses: actions/upload-artifact@v4 + with: + name: glass-probe-${{ matrix.settings.slug }} + path: ${{ runner.temp }}/glass-probe + retention-days: 7 diff --git a/.github/workflows/glass-reference.yml b/.github/workflows/glass-reference.yml deleted file mode 100644 index aff28f02cc..0000000000 --- a/.github/workflows/glass-reference.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Glass reference (Apple) -# Captures Apple's NSGlassEffectView over the fixed backdrops in -# scripts/glass-reference so Cap's glass renderer can be compared against it. - -on: - pull_request: - paths: - - "scripts/glass-reference/**" - - ".github/workflows/glass-reference.yml" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: glass-reference-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - capture: - name: Apple glass reference (${{ matrix.runner }}) - strategy: - fail-fast: false - matrix: - runner: [macos-26, blacksmith-6vcpu-macos-26] - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Screen capture permission - run: | - set -euo pipefail - sudo pmset -a displaysleep 0 sleep 0 || true - caffeinate -u -t 2 - swiftc -O scripts/glass-reference/reference.swift -o "$RUNNER_TEMP/reference" - db="/Library/Application Support/com.apple.TCC/TCC.db" - now=$(date +%s) - for client in "$RUNNER_TEMP/reference" /usr/sbin/screencapture /bin/bash /Users/runner/bin/Runner.Worker /Users/runner/bin/Runner.Listener; do - sudo sqlite3 "$db" "INSERT OR REPLACE INTO access (service,client,client_type,auth_value,auth_reason,auth_version,indirect_object_identifier,flags,last_modified) VALUES ('kTCCServiceScreenCapture','$client',1,2,4,1,'UNUSED',0,$now);" || true - done - sudo killall tccd || true - defaults read com.apple.universalaccess 2>/dev/null || true - for key in reduceTransparency increaseContrast reduceMotion; do - defaults write com.apple.universalaccess "$key" -bool false || true - sudo defaults write com.apple.universalaccess "$key" -bool false || true - done - killall cfprefsd || true - sleep 2 - sw_vers - system_profiler SPDisplaysDataType | grep -E "Resolution|Chipset|Metal" || true - - - name: Capture - run: | - "$RUNNER_TEMP/reference" "$RUNNER_TEMP/glass-reference" - ls "$RUNNER_TEMP/glass-reference/apple" | head - - - name: Upload - uses: actions/upload-artifact@v4 - with: - name: glass-reference-${{ matrix.runner }} - path: ${{ runner.temp }}/glass-reference - retention-days: 30 diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index 900791a5cb..2516cffb20 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -1551,6 +1551,7 @@ dependencies = [ "cap-editor", "cap-enc-ffmpeg", "cap-export", + "cap-glass", "cap-gpui-kernels", "cap-i18n-build", "cap-media-info", @@ -1773,6 +1774,15 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "cap-glass" +version = "0.1.0" +dependencies = [ + "image 0.25.10", + "serde", + "serde_json", +] + [[package]] name = "cap-gpui-kernels" version = "0.1.0" diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index 8b9c003a1b..4bcf0a2bca 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -5,7 +5,7 @@ # wgpu-hal, tao) that only exist for the Tauri app. [workspace] resolver = "2" -members = [".", "kernels", "i18n-build"] +members = [".", "kernels", "i18n-build", "glass"] [package] name = "cap-desktop-gpui" @@ -27,6 +27,7 @@ path = "../../crates/recording/examples/studio-finalization-benchmark.rs" cap-audio = { path = "../../crates/audio" } cap-cli-install = { path = "../../crates/cli-install" } cap-gpui-kernels = { path = "kernels" } +cap-glass = { path = "glass" } # gpui — the wingleeio/zed fork Comet ships. The pinned rev carries f596cde # ("destination alpha on transparent windows: Porter-Duff OVER, not additive"), # which our transparent rounded main-window shell depends on. diff --git a/apps/desktop-gpui/glass/Cargo.toml b/apps/desktop-gpui/glass/Cargo.toml new file mode 100644 index 0000000000..2eeea4765d --- /dev/null +++ b/apps/desktop-gpui/glass/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "cap-glass" +version = "0.1.0" +edition = "2024" +license = "AGPL-3.0" +publish = false + +[dependencies] +image = { version = "0.25.1", default-features = false, features = ["png"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/apps/desktop-gpui/glass/presets.json b/apps/desktop-gpui/glass/presets.json new file mode 100644 index 0000000000..9953d4dfe3 --- /dev/null +++ b/apps/desktop-gpui/glass/presets.json @@ -0,0 +1,214 @@ +{ + "regular-light": { + "blur_k": 0.19, + "blur_max": 21, + "rim_k": 0.24, + "rim_max": 24, + "bend": -2, + "lens_curve": 0.4, + "poly": [ + 1, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "opacity": 0.6, + "line": 0, + "line_lit": 0, + "line_width": 1, + "light_angle": -2.3, + "glow": 0, + "glow_width": 4, + "hairline": [ + 0.86, + -0.12, + 0.92, + -0.08 + ] + }, + "regular-dark": { + "blur_k": 0.19, + "blur_max": 21, + "rim_k": 0.24, + "rim_max": 24, + "bend": -2, + "lens_curve": 0.4, + "poly": [ + 1, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "opacity": 0.6, + "line": 0, + "line_lit": 0, + "line_width": 1, + "light_angle": -2.3, + "glow": 0, + "glow_width": 4, + "hairline": [ + 0.86, + -0.12, + 0.92, + -0.08 + ] + }, + "clear-light": { + "blur_k": 0.19, + "blur_max": 21, + "rim_k": 0.24, + "rim_max": 24, + "bend": -2, + "lens_curve": 0.4, + "poly": [ + 1, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "opacity": 0.6, + "line": 0, + "line_lit": 0, + "line_width": 1, + "light_angle": -2.3, + "glow": 0, + "glow_width": 4, + "hairline": [ + 0.86, + -0.12, + 0.92, + -0.08 + ] + }, + "clear-dark": { + "blur_k": 0.19, + "blur_max": 21, + "rim_k": 0.24, + "rim_max": 24, + "bend": -2, + "lens_curve": 0.4, + "poly": [ + 1, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "opacity": 0.6, + "line": 0, + "line_lit": 0, + "line_width": 1, + "light_angle": -2.3, + "glow": 0, + "glow_width": 4, + "hairline": [ + 0.86, + -0.12, + 0.92, + -0.08 + ] + } +} \ No newline at end of file diff --git a/apps/desktop-gpui/glass/src/bin/glass-fit.rs b/apps/desktop-gpui/glass/src/bin/glass-fit.rs new file mode 100644 index 0000000000..e9ca249f11 --- /dev/null +++ b/apps/desktop-gpui/glass/src/bin/glass-fit.rs @@ -0,0 +1,520 @@ +//! Fits a `GlassMaterial` to Apple's reference captures. +//! +//! glass-fit [style-appearance ...] +//! +//! Reads `/manifest.json`, `scenes/*.png` and `apple/*.png` +//! (from scripts/glass-reference) and writes `/params.json`, +//! `/report.json` and side-by-side renders. The colour transform is +//! linear in its coefficients, so every evaluation solves it exactly by +//! least squares; Nelder-Mead only searches blur, lensing and lighting. +//! `GLASS_PARAMS=` skips the search and renders those. + +use std::path::{Path, PathBuf}; + +use cap_glass::{ + Rgba, Shape, metrics, + model::{self, GlassMaterial, POLY_TERMS}, +}; + +#[derive(serde::Deserialize)] +struct Manifest { + shapes: Vec, + cases: Vec, +} + +#[derive(serde::Deserialize)] +struct ManifestShape { + rect: [f32; 4], + radius: f32, +} + +#[derive(serde::Deserialize, Clone)] +struct Case { + name: String, + scene: String, + style: String, + appearance: String, + tint: String, +} + +struct Sample { + name: String, + backdrop: Rgba, + apple: Rgba, + /// One blurred crop per shape: (origin x, origin y, crop). + crops: Vec<(usize, usize, Rgba)>, +} + +const PAD: f32 = 110.; + +fn main() { + let args: Vec = std::env::args().collect(); + let reference = PathBuf::from(&args[1]); + let out = PathBuf::from(&args[2]); + std::fs::create_dir_all(out.join("renders")).unwrap(); + let manifest: Manifest = + serde_json::from_slice(&std::fs::read(reference.join("manifest.json")).unwrap()).unwrap(); + let shapes: Vec = manifest + .shapes + .iter() + .map(|shape| Shape { + x: shape.rect[0], + y: shape.rect[1], + width: shape.rect[2], + height: shape.rect[3], + radius: shape.radius, + }) + .collect(); + let presets: Vec = if args.len() > 3 { + args[3..].to_vec() + } else { + ["regular-light", "regular-dark", "clear-light", "clear-dark"] + .map(String::from) + .to_vec() + }; + let fixed: Option = std::env::var("GLASS_PARAMS") + .ok() + .and_then(|path| serde_json::from_slice(&std::fs::read(path).ok()?).ok()); + + let mut fitted = serde_json::Map::new(); + let mut report = serde_json::Map::new(); + for preset in presets { + let mut samples: Vec = manifest + .cases + .iter() + .filter(|case| { + format!("{}-{}", case.style, case.appearance) == preset + && case.tint == "none" + && case.scene != "uvfine" + }) + .map(|case| Sample { + name: case.name.clone(), + backdrop: Rgba::load(&reference.join(format!("scenes/{}.png", case.scene))).unwrap(), + apple: Rgba::load(&reference.join(format!("apple/{}.png", case.name))).unwrap(), + crops: Vec::new(), + }) + .collect(); + + let material = match fixed + .as_ref() + .and_then(|all| serde_json::from_value::(all.get(&preset)?.clone()).ok()) + { + Some(material) => material, + None => fit(&preset, &mut samples, &shapes), + }; + eprintln!("{preset}:\n{material:#?}"); + + prepare(&mut samples, &shapes, &material); + let mask = region_mask(&shapes, &samples[0].backdrop); + let mut scores = serde_json::Map::new(); + for sample in &samples { + let rendered = render_sample(&material, &shapes, sample); + let ssim = metrics::ssim(&rendered, &sample.apple, &mask); + let (mean, p95) = metrics::delta_e(&rendered, &sample.apple, &mask); + scores.insert( + sample.name.clone(), + serde_json::json!({ "ssim": ssim, "deltaE": mean, "deltaE95": p95 }), + ); + side_by_side(&rendered, &sample.apple, &out.join(format!("renders/{}.png", sample.name))); + } + fitted.insert(preset.clone(), serde_json::to_value(material).unwrap()); + report.insert(preset, serde_json::Value::Object(scores)); + std::fs::write(out.join("params.json"), serde_json::to_string_pretty(&fitted).unwrap()).unwrap(); + std::fs::write(out.join("report.json"), serde_json::to_string_pretty(&report).unwrap()).unwrap(); + } +} + +fn fit(preset: &str, samples: &mut [Sample], shapes: &[Shape]) -> GlassMaterial { + let start = GlassMaterial { + blur_k: 0.19, + blur_max: 21., + rim_k: 0.24, + rim_max: 24., + bend: -2., + lens_curve: 0.4, + poly: [0.; 3 * POLY_TERMS], + opacity: 0.6, + line: 0., + line_lit: 0., + line_width: 1., + light_angle: -2.3, + glow: 0., + glow_width: 4., + hairline: [0.85, -0.12, 0.9, -0.08], + }; + let mut best: Option<(f64, GlassMaterial)> = None; + let mut try_law = |blur_k: f32, blur_max: f32, best: &mut Option<(f64, GlassMaterial)>| { + let seed = GlassMaterial { + blur_k, + blur_max, + ..best.map_or(start, |(_, material)| material) + }; + let (loss, material) = fit_at_law(samples, shapes, seed); + eprintln!("{preset}: blur {blur_k:.3} x side, max {blur_max:.1} -> loss {loss:.2}"); + if best.is_none_or(|(current, _)| loss < current) { + *best = Some((loss, material)); + } + }; + for blur_k in [0.06, 0.1, 0.14, 0.19, 0.25] { + try_law(blur_k, 60., &mut best); + } + let k = best.unwrap().1.blur_k; + for blur_max in [8., 12., 16., 20., 26., 34.] { + try_law(k, blur_max, &mut best); + } + for _ in 0..2 { + let law = best.unwrap().1; + for (dk, dm) in [(-0.02, 0.), (0.02, 0.), (0., -2.), (0., 2.)] { + try_law((law.blur_k + dk).max(0.01), (law.blur_max + dm).max(1.), &mut best); + } + } + best.unwrap().1 +} + +fn crop(image: &Rgba, x0: usize, y0: usize, x1: usize, y1: usize) -> Rgba { + let mut out = Rgba::solid(x1 - x0, y1 - y0, [0.; 4]); + for y in y0..y1 { + for x in x0..x1 { + out.set(x - x0, y - y0, image.get(x, y)); + } + } + out +} + +fn prepare(samples: &mut [Sample], shapes: &[Shape], material: &GlassMaterial) { + std::thread::scope(|scope| { + for sample in samples.iter_mut() { + scope.spawn(move || { + sample.crops = shapes + .iter() + .map(|shape| { + let blur = material.resolve(shape.width, shape.height).blur; + let pad = PAD + blur * 3.; + let x0 = (shape.x - pad).max(0.) as usize; + let y0 = (shape.y - pad).max(0.) as usize; + let x1 = ((shape.x + shape.width + pad) as usize).min(sample.backdrop.width); + let y1 = ((shape.y + shape.height + pad) as usize).min(sample.backdrop.height); + (x0, y0, crop(&sample.backdrop, x0, y0, x1, y1).blurred(blur)) + }) + .collect(); + }); + } + }); +} + +/// Visit glass pixels with their global position and weight. With `sparse`, +/// pixels deeper than 12 px inside are visited on every other row and +/// column at four times the weight: they carry far less than the rim does. +fn for_each_glass_pixel( + material: &GlassMaterial, + shapes: &[Shape], + sample: &Sample, + sparse: bool, + mut visit: impl FnMut(&model::GlassSample, &model::PaneStyle, usize, usize, f64), +) { + for (shape, (ox, oy, blurred)) in shapes.iter().zip(&sample.crops) { + let style = material.resolve(shape.width, shape.height); + let local = Shape { + x: shape.x - *ox as f32, + y: shape.y - *oy as f32, + ..*shape + }; + let x0 = (local.x - 3.).floor().max(0.) as usize; + let y0 = (local.y - 3.).floor().max(0.) as usize; + let x1 = ((local.x + local.width + 3.).ceil() as usize).min(blurred.width); + let y1 = ((local.y + local.height + 3.).ceil() as usize).min(blurred.height); + for y in y0..y1 { + for x in x0..x1 { + let (px, py) = (x as f32 + 0.5, y as f32 + 0.5); + let weight = if sparse && local.sdf(px, py) < -12. { + if x % 2 != 0 || y % 2 != 0 { + continue; + } + 4. + } else { + 1. + }; + let glass = model::sample(&style, &local, blurred, px, py); + if glass.coverage > 0. || glass.ring > 0. { + visit(&glass, &style, x + ox, y + oy, weight); + } + } + } + } +} + +fn render_sample(material: &GlassMaterial, shapes: &[Shape], sample: &Sample) -> Rgba { + let mut out = sample.backdrop.clone(); + for_each_glass_pixel(material, shapes, sample, false, |glass, style, x, y, _| { + let under = sample.backdrop.get(x, y); + let under = + model::under_with_hairline(glass, [under[0] / 255., under[1] / 255., under[2] / 255.]); + let color = if glass.coverage > 0. { model::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.]); + }); + out +} + +/// Normal equations of the colour transform over every covered pixel: +/// `coverage * scale * features . poly = target - (1 - coverage) * under - +/// coverage * glow`, per channel, plus what the residual needs. +struct Normal { + ata: [[f64; POLY_TERMS]; POLY_TERMS], + atb: [[f64; 3]; POLY_TERMS], + btb: [f64; 3], + count: usize, + /// Squared error of hairline-only pixels, which the transform cannot + /// change. + fixed_sse: f64, + fixed_count: usize, +} + +impl Normal { + fn new() -> Self { + Self { + ata: [[0.; POLY_TERMS]; POLY_TERMS], + atb: [[0.; 3]; POLY_TERMS], + btb: [0.; 3], + count: 0, + fixed_sse: 0., + fixed_count: 0, + } + } + + fn add(&mut self, other: &Self) { + for i in 0..POLY_TERMS { + for j in 0..POLY_TERMS { + self.ata[i][j] += other.ata[i][j]; + } + for c in 0..3 { + self.atb[i][c] += other.atb[i][c]; + } + } + for c in 0..3 { + self.btb[c] += other.btb[c]; + } + self.count += other.count; + self.fixed_sse += other.fixed_sse; + self.fixed_count += other.fixed_count; + } + + /// Least-squares coefficients and the mean squared error per channel + /// value on the 0-255 scale. + fn solve(&self) -> ([f32; 3 * POLY_TERMS], f64) { + let mut a = self.ata; + for (i, row) in a.iter_mut().enumerate() { + row[i] += 1e-6; + } + let mut poly = [0f32; 3 * POLY_TERMS]; + let mut sse = 0.; + for c in 0..3 { + let b: [f64; POLY_TERMS] = std::array::from_fn(|i| self.atb[i][c]); + let x = solve_linear(a, b); + let mut xax = 0.; + let mut xb = 0.; + for i in 0..POLY_TERMS { + poly[c * POLY_TERMS + i] = x[i] as f32; + xb += x[i] * b[i]; + for j in 0..POLY_TERMS { + xax += x[i] * self.ata[i][j] * x[j]; + } + } + sse += self.btb[c] - 2. * xb + xax; + } + let total = sse.max(0.) + self.fixed_sse; + let values = 3 * (self.count + self.fixed_count).max(1); + (poly, total / values as f64 * 255. * 255.) + } +} + +fn solve_linear( + mut m: [[f64; POLY_TERMS]; POLY_TERMS], + mut v: [f64; POLY_TERMS], +) -> [f64; POLY_TERMS] { + for col in 0..POLY_TERMS { + let pivot = (col..POLY_TERMS) + .max_by(|a, b| m[*a][col].abs().total_cmp(&m[*b][col].abs())) + .unwrap(); + m.swap(col, pivot); + v.swap(col, pivot); + for row in 0..POLY_TERMS { + if row != col && m[col][col] != 0. { + let factor = m[row][col] / m[col][col]; + for k in 0..POLY_TERMS { + m[row][k] -= factor * m[col][k]; + } + v[row] -= factor * v[col]; + } + } + } + std::array::from_fn(|i| if m[i][i] == 0. { 0. } else { v[i] / m[i][i] }) +} + +fn normal_equations(material: &GlassMaterial, shapes: &[Shape], samples: &[Sample]) -> Normal { + std::thread::scope(|scope| { + let handles: Vec<_> = samples + .iter() + .map(|sample| { + scope.spawn(move || { + let mut normal = Normal::new(); + for_each_glass_pixel(material, shapes, sample, true, |glass, _, x, y, weight| { + let under = sample.backdrop.get(x, y); + let under = model::under_with_hairline( + glass, + [under[0] / 255., under[1] / 255., under[2] / 255.], + ); + let target = sample.apple.get(x, y); + if glass.coverage <= 0. { + for c in 0..3 { + let error = under[c] as f64 - target[c] as f64 / 255.; + normal.fixed_sse += weight * error * error; + } + normal.fixed_count += weight as usize; + return; + } + let features = model::poly_features(glass.source); + let weight = (glass.coverage * glass.scale) as f64; + let row: [f64; POLY_TERMS] = + std::array::from_fn(|i| features[i] as f64 * weight); + for i in 0..POLY_TERMS { + for j in i..POLY_TERMS { + normal.ata[i][j] += weight * row[i] * row[j]; + } + } + let coverage = glass.coverage as f64; + for c in 0..3 { + let b = target[c] as f64 / 255. + - (1. - coverage) * under[c] as f64 + - coverage * glass.glow as f64; + for i in 0..POLY_TERMS { + normal.atb[i][c] += weight * row[i] * b; + } + normal.btb[c] += weight * b * b; + } + normal.count += weight as usize; + }); + for i in 0..POLY_TERMS { + for j in 0..i { + normal.ata[i][j] = normal.ata[j][i]; + } + } + normal + }) + }) + .collect(); + let mut total = Normal::new(); + for handle in handles { + total.add(&handle.join().unwrap()); + } + total + }) +} + +fn evaluate(material: &GlassMaterial, shapes: &[Shape], samples: &[Sample]) -> (f64, GlassMaterial) { + let (poly, loss) = normal_equations(material, shapes, samples).solve(); + (loss, GlassMaterial { poly, ..*material }) +} + +/// Fit everything but the blur law, which stays fixed so each backdrop is +/// blurred once. +fn fit_at_law( + samples: &mut [Sample], + shapes: &[Shape], + start: GlassMaterial, +) -> (f64, GlassMaterial) { + prepare(samples, shapes, &start); + let samples: &[Sample] = samples; + let objective = |values: &[f32]| evaluate(&start.with_shape(values), shapes, samples).0; + let scales = [ + 0., 0., 0.05, 6., 0.6, 0.2, 0.04, 0.04, 0.5, 0.8, 0.04, 3., 0.08, 0.05, 0.08, 0.05, + ]; + let mut point = start.shape_vec().to_vec(); + let mut value = objective(&point); + for _ in 0..4 { + let (next_value, next) = nelder_mead(&objective, &point, &scales, 160); + if next_value >= value - 1e-3 { + break; + } + value = next_value; + point = next; + } + evaluate(&start.with_shape(&point), shapes, samples) +} + +fn region_mask(shapes: &[Shape], image: &Rgba) -> Vec { + (0..image.width * image.height) + .map(|index| { + let (x, y) = ((index % image.width) as f32 + 0.5, (index / image.width) as f32 + 0.5); + shapes.iter().any(|shape| shape.sdf(x, y) < 1.5) + }) + .collect() +} + +fn nelder_mead( + objective: &impl Fn(&[f32]) -> f64, + start: &[f32], + scales: &[f32], + iterations: usize, +) -> (f64, Vec) { + let dims: Vec = (0..start.len()).filter(|i| scales[*i] != 0.).collect(); + let mut simplex: Vec<(f64, Vec)> = vec![(objective(start), start.to_vec())]; + for &dim in &dims { + let mut vertex = start.to_vec(); + vertex[dim] += scales[dim]; + simplex.push((objective(&vertex), vertex)); + } + for _ in 0..iterations { + simplex.sort_by(|a, b| a.0.total_cmp(&b.0)); + let worst = simplex.len() - 1; + let centroid: Vec = (0..start.len()) + .map(|i| simplex[..worst].iter().map(|(_, v)| v[i]).sum::() / worst as f32) + .collect(); + let along = |t: f32| -> Vec { + centroid + .iter() + .zip(&simplex[worst].1) + .map(|(c, w)| c + t * (c - w)) + .collect() + }; + let reflected = along(1.); + let reflected_value = objective(&reflected); + if reflected_value < simplex[0].0 { + let expanded = along(2.); + let expanded_value = objective(&expanded); + simplex[worst] = if expanded_value < reflected_value { + (expanded_value, expanded) + } else { + (reflected_value, reflected) + }; + } else if reflected_value < simplex[worst - 1].0 { + simplex[worst] = (reflected_value, reflected); + } else { + let contracted = along(-0.5); + let contracted_value = objective(&contracted); + if contracted_value < simplex[worst].0 { + simplex[worst] = (contracted_value, contracted); + } else { + let best = simplex[0].1.clone(); + for vertex in simplex.iter_mut().skip(1) { + let shrunk: Vec = + best.iter().zip(&vertex.1).map(|(b, v)| b + 0.5 * (v - b)).collect(); + *vertex = (objective(&shrunk), shrunk); + } + } + } + } + simplex.sort_by(|a, b| a.0.total_cmp(&b.0)); + simplex.swap_remove(0) +} + +fn side_by_side(ours: &Rgba, apple: &Rgba, path: &Path) { + let mut sheet = Rgba::solid(ours.width * 2, ours.height, [255.; 4]); + for y in 0..ours.height { + for x in 0..ours.width { + sheet.set(x, y, apple.get(x, y)); + sheet.set(x + ours.width, y, ours.get(x, y)); + } + } + sheet.save(path).unwrap(); +} diff --git a/apps/desktop-gpui/glass/src/lib.rs b/apps/desktop-gpui/glass/src/lib.rs new file mode 100644 index 0000000000..b8e5edd8eb --- /dev/null +++ b/apps/desktop-gpui/glass/src/lib.rs @@ -0,0 +1,147 @@ +//! Cap Glass: the glass material's math, shared by the GPU shaders (Metal, +//! HLSL, WGSL in the gpui patch) and the CPU reference renderer that the +//! comparison harness fits against Apple's own glass. + +pub mod metrics; +pub mod model; +pub mod scenes; + +use std::path::Path; + +/// The fitted materials, keyed `regular-light`, `regular-dark`, +/// `clear-light` and `clear-dark` (see `glass-fit`). +pub const PRESETS_JSON: &str = include_str!("../presets.json"); + +/// An RGBA image with 0-255 float channels, non-premultiplied. +#[derive(Clone)] +pub struct Rgba { + pub width: usize, + pub height: usize, + pub pixels: Vec, +} + +impl Rgba { + pub fn solid(width: usize, height: usize, color: [f32; 4]) -> Self { + Self { + width, + height, + pixels: color.repeat(width * height), + } + } + + pub fn load(path: &Path) -> image::ImageResult { + let image = image::open(path)?.to_rgba8(); + Ok(Self { + width: image.width() as usize, + height: image.height() as usize, + pixels: image.as_raw().iter().map(|value| *value as f32).collect(), + }) + } + + pub fn save(&self, path: &Path) -> image::ImageResult<()> { + let bytes: Vec = self + .pixels + .iter() + .map(|value| value.round().clamp(0., 255.) as u8) + .collect(); + image::RgbaImage::from_raw(self.width as u32, self.height as u32, bytes) + .expect("pixel buffer matches its size") + .save(path) + } + + pub fn get(&self, x: usize, y: usize) -> [f32; 4] { + let index = (y * self.width + x) * 4; + [ + self.pixels[index], + self.pixels[index + 1], + self.pixels[index + 2], + self.pixels[index + 3], + ] + } + + pub fn set(&mut self, x: usize, y: usize, color: [f32; 4]) { + let index = (y * self.width + x) * 4; + self.pixels[index..index + 4].copy_from_slice(&color); + } + + /// Bilinear sample with clamped edges, in pixel coordinates (pixel + /// centres at +0.5). + pub fn sample(&self, x: f32, y: f32) -> [f32; 4] { + let fx = (x - 0.5).clamp(0., (self.width - 1) as f32); + let fy = (y - 0.5).clamp(0., (self.height - 1) as f32); + let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(self.width - 1), (y0 + 1).min(self.height - 1)); + let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); + let (a, b, c, d) = (self.get(x0, y0), self.get(x1, y0), self.get(x0, y1), self.get(x1, y1)); + std::array::from_fn(|i| { + let top = a[i] + (b[i] - a[i]) * tx; + let bottom = c[i] + (d[i] - c[i]) * tx; + top + (bottom - top) * ty + }) + } + + /// Gaussian blur of every channel with clamped edges. + pub fn blurred(&self, sigma: f32) -> Self { + if sigma < 0.5 { + return self.clone(); + } + let radius = (sigma * 3.).ceil() as usize; + let kernel = metrics::gaussian_kernel(sigma as f64, radius); + let mut out = self.clone(); + for channel in 0..4 { + let values: Vec = self + .pixels + .iter() + .skip(channel) + .step_by(4) + .map(|value| *value as f64) + .collect(); + let blurred = metrics::separable_blur(&values, self.width, self.height, &kernel); + for (index, value) in blurred.into_iter().enumerate() { + out.pixels[index * 4 + channel] = value as f32; + } + } + out + } +} + +/// A rounded rectangle in pixels, top-left origin. +#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)] +pub struct Shape { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, + pub radius: f32, +} + +impl Shape { + /// Signed distance to the rounded rectangle's edge, negative inside. + pub fn sdf(&self, px: f32, py: f32) -> f32 { + let radius = self.radius.min(self.width / 2.).min(self.height / 2.); + let cx = px - (self.x + self.width / 2.); + let cy = py - (self.y + self.height / 2.); + let qx = cx.abs() - (self.width / 2. - radius); + let qy = cy.abs() - (self.height / 2. - radius); + let outside = (qx.max(0.).powi(2) + qy.max(0.).powi(2)).sqrt(); + outside + qx.max(qy).min(0.) - radius + } + + /// Unit outward normal of the distance field. + pub fn normal(&self, px: f32, py: f32) -> (f32, f32) { + let e = 0.5; + let dx = self.sdf(px + e, py) - self.sdf(px - e, py); + let dy = self.sdf(px, py + e) - self.sdf(px, py - e); + let length = (dx * dx + dy * dy).sqrt().max(1e-6); + (dx / length, dy / length) + } + + pub fn mask(&self, width: usize, height: usize, inset: f32) -> Vec { + (0..width * height) + .map(|index| { + let (x, y) = ((index % width) as f32 + 0.5, (index / width) as f32 + 0.5); + self.sdf(x, y) < -inset + }) + .collect() + } +} diff --git a/apps/desktop-gpui/glass/src/metrics.rs b/apps/desktop-gpui/glass/src/metrics.rs new file mode 100644 index 0000000000..45cb04b4d2 --- /dev/null +++ b/apps/desktop-gpui/glass/src/metrics.rs @@ -0,0 +1,192 @@ +use crate::Rgba; + +/// Mean SSIM over the masked pixels, computed on Rec. 709 luma with the usual +/// 11x11, sigma 1.5 gaussian window. +pub fn ssim(a: &Rgba, b: &Rgba, mask: &[bool]) -> f64 { + assert_eq!((a.width, a.height), (b.width, b.height)); + let (width, height) = (a.width, a.height); + let luma = |image: &Rgba| -> Vec { + image + .pixels + .chunks_exact(4) + .map(|px| 0.2126 * px[0] as f64 + 0.7152 * px[1] as f64 + 0.0722 * px[2] as f64) + .collect() + }; + let (x, y) = (luma(a), luma(b)); + let kernel = gaussian_kernel(1.5, 5); + let blur = |values: &[f64]| separable_blur(values, width, height, &kernel); + let xy: Vec = x.iter().zip(&y).map(|(x, y)| x * y).collect(); + let xx: Vec = x.iter().map(|x| x * x).collect(); + let yy: Vec = y.iter().map(|y| y * y).collect(); + let (mu_x, mu_y) = (blur(&x), blur(&y)); + let (e_xx, e_yy, e_xy) = (blur(&xx), blur(&yy), blur(&xy)); + let (c1, c2) = ((0.01f64 * 255.).powi(2), (0.03f64 * 255.).powi(2)); + let mut total = 0.; + let mut count = 0usize; + for index in 0..width * height { + if !mask[index] { + continue; + } + let (mx, my) = (mu_x[index], mu_y[index]); + let vx = e_xx[index] - mx * mx; + let vy = e_yy[index] - my * my; + let cov = e_xy[index] - mx * my; + total += ((2. * mx * my + c1) * (2. * cov + c2)) / ((mx * mx + my * my + c1) * (vx + vy + c2)); + count += 1; + } + if count == 0 { 1. } else { total / count as f64 } +} + +/// CIEDE2000 colour difference per masked pixel, returned as (mean, p95). +pub fn delta_e(a: &Rgba, b: &Rgba, mask: &[bool]) -> (f64, f64) { + let mut values: Vec = a + .pixels + .chunks_exact(4) + .zip(b.pixels.chunks_exact(4)) + .zip(mask) + .filter(|(_, keep)| **keep) + .map(|((p, q), _)| ciede2000(srgb_to_lab(p), srgb_to_lab(q))) + .collect(); + if values.is_empty() { + return (0., 0.); + } + values.sort_by(f64::total_cmp); + let mean = values.iter().sum::() / values.len() as f64; + let p95 = values[((values.len() - 1) as f64 * 0.95).round() as usize]; + (mean, p95) +} + +pub fn gaussian_kernel(sigma: f64, radius: usize) -> Vec { + let mut kernel: Vec = (0..=2 * radius) + .map(|i| { + let x = i as f64 - radius as f64; + (-(x * x) / (2. * sigma * sigma)).exp() + }) + .collect(); + let sum: f64 = kernel.iter().sum(); + kernel.iter_mut().for_each(|value| *value /= sum); + kernel +} + +pub fn separable_blur(values: &[f64], width: usize, height: usize, kernel: &[f64]) -> Vec { + let radius = kernel.len() / 2; + let mut horizontal = vec![0.; values.len()]; + for row in 0..height { + for col in 0..width { + let mut sum = 0.; + for (k, weight) in kernel.iter().enumerate() { + let x = (col + k).saturating_sub(radius).min(width - 1); + sum += weight * values[row * width + x]; + } + horizontal[row * width + col] = sum; + } + } + let mut out = vec![0.; values.len()]; + for row in 0..height { + for col in 0..width { + let mut sum = 0.; + for (k, weight) in kernel.iter().enumerate() { + let y = (row + k).saturating_sub(radius).min(height - 1); + sum += weight * horizontal[y * width + col]; + } + out[row * width + col] = sum; + } + } + out +} + +fn srgb_to_lab(px: &[f32]) -> [f64; 3] { + let linear = |c: f32| { + let c = (c as f64 / 255.).clamp(0., 1.); + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } + }; + let (r, g, b) = (linear(px[0]), linear(px[1]), linear(px[2])); + let x = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047; + let y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b; + let z = (0.0193339 * r + 0.1191920 * g + 0.9503041 * b) / 1.08883; + let f = |t: f64| { + if t > 216. / 24389. { t.cbrt() } else { (24389. / 27. * t + 16.) / 116. } + }; + let (fx, fy, fz) = (f(x), f(y), f(z)); + [116. * fy - 16., 500. * (fx - fy), 200. * (fy - fz)] +} + +fn ciede2000(lab1: [f64; 3], lab2: [f64; 3]) -> f64 { + let [l1, a1, b1] = lab1; + let [l2, a2, b2] = lab2; + let c1 = (a1 * a1 + b1 * b1).sqrt(); + let c2 = (a2 * a2 + b2 * b2).sqrt(); + let c_bar = (c1 + c2) / 2.; + let g = 0.5 * (1. - (c_bar.powi(7) / (c_bar.powi(7) + 25f64.powi(7))).sqrt()); + let (a1p, a2p) = ((1. + g) * a1, (1. + g) * a2); + let (c1p, c2p) = ((a1p * a1p + b1 * b1).sqrt(), (a2p * a2p + b2 * b2).sqrt()); + let hue = |b: f64, a: f64| { + if a == 0. && b == 0. { + 0. + } else { + let h = b.atan2(a).to_degrees(); + if h < 0. { h + 360. } else { h } + } + }; + let (h1p, h2p) = (hue(b1, a1p), hue(b2, a2p)); + let dl = l2 - l1; + let dc = c2p - c1p; + let dh_angle = if c1p * c2p == 0. { + 0. + } else if (h2p - h1p).abs() <= 180. { + h2p - h1p + } else if h2p - h1p > 180. { + h2p - h1p - 360. + } else { + h2p - h1p + 360. + }; + let dh = 2. * (c1p * c2p).sqrt() * (dh_angle.to_radians() / 2.).sin(); + let l_bar = (l1 + l2) / 2.; + let cp_bar = (c1p + c2p) / 2.; + let hp_bar = if c1p * c2p == 0. { + h1p + h2p + } else if (h1p - h2p).abs() <= 180. { + (h1p + h2p) / 2. + } else if h1p + h2p < 360. { + (h1p + h2p + 360.) / 2. + } else { + (h1p + h2p - 360.) / 2. + }; + let t = 1. - 0.17 * (hp_bar - 30.).to_radians().cos() + + 0.24 * (2. * hp_bar).to_radians().cos() + + 0.32 * (3. * hp_bar + 6.).to_radians().cos() + - 0.20 * (4. * hp_bar - 63.).to_radians().cos(); + let d_theta = 30. * (-((hp_bar - 275.) / 25.).powi(2)).exp(); + let rc = 2. * (cp_bar.powi(7) / (cp_bar.powi(7) + 25f64.powi(7))).sqrt(); + let sl = 1. + (0.015 * (l_bar - 50.).powi(2)) / (20. + (l_bar - 50.).powi(2)).sqrt(); + let sc = 1. + 0.045 * cp_bar; + let sh = 1. + 0.015 * cp_bar * t; + let rt = -(2. * d_theta).to_radians().sin() * rc; + ((dl / sl).powi(2) + (dc / sc).powi(2) + (dh / sh).powi(2) + rt * (dc / sc) * (dh / sh)).sqrt() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ciede2000_matches_published_pairs() { + let pairs = [ + ([50.0, 2.6772, -79.7751], [50.0, 0.0, -82.7485], 2.0425), + ([50.0, 3.1571, -77.2803], [50.0, 0.0, -82.7485], 2.8615), + ([50.0, 2.5, 0.0], [73.0, 25.0, -18.0], 27.1492), + ([60.2574, -34.0099, 36.2677], [60.4626, -34.1751, 39.4387], 1.2644), + ]; + for (a, b, expected) in pairs { + assert!((ciede2000(a, b) - expected).abs() < 1e-3, "{a:?} {b:?}"); + } + } + + #[test] + fn identical_images_score_perfectly() { + let image = Rgba::solid(16, 16, [120., 40., 200., 255.]); + let mask = vec![true; 256]; + assert!((ssim(&image, &image, &mask) - 1.).abs() < 1e-9); + assert_eq!(delta_e(&image, &image, &mask), (0., 0.)); + } +} diff --git a/apps/desktop-gpui/glass/src/model.rs b/apps/desktop-gpui/glass/src/model.rs new file mode 100644 index 0000000000..9f1f7fabce --- /dev/null +++ b/apps/desktop-gpui/glass/src/model.rs @@ -0,0 +1,265 @@ +use crate::{Rgba, Shape}; + +/// Terms of the colour transform, per output channel: +/// `r, g, b, r², g², b², rg, gb, rb, 1` of the blurred backdrop (0-1 sRGB). +pub const POLY_TERMS: usize = 10; + +pub fn poly_features(c: [f32; 3]) -> [f32; POLY_TERMS] { + let [r, g, b] = c; + [r, g, b, r * r, g * g, b * b, r * g, g * b, r * b, 1.] +} + +/// A glass material, fitted against Apple's glass. Blur and lensing scale +/// with the pane's shorter side, the way Apple's do; [`GlassMaterial::resolve`] +/// turns them into the absolute [`PaneStyle`] the renderers draw with. +#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct GlassMaterial { + /// Blur sigma: `min(blur_max, blur_k * shorter side)`. + pub blur_k: f32, + pub blur_max: f32, + /// Width of the lensing rim: `min(rim_max, rim_k * shorter side)`. + pub rim_k: f32, + pub rim_max: f32, + /// How far the rim bends light along the normal, in rim widths + /// (negative pulls from inside the pane). + pub bend: f32, + /// Exponent of the bend's falloff across the rim. + pub lens_curve: f32, + /// Second-order colour transform of the blurred backdrop: for each output + /// channel, coefficients of [`poly_features`]. It carries the frost, the + /// saturation, the hue shift and the brightness compression Apple's + /// materials apply. + #[serde(with = "serde_poly")] + pub poly: [f32; 3 * POLY_TERMS], + /// How opaque the glass is over a transparent backdrop. + pub opacity: f32, + /// Edge line: scales the colour by `1 + line + line_lit * facing` at the + /// edge, decaying inwards over `line_width`; `facing` is how much the + /// edge faces `light_angle` (radians, y down). + pub line: f32, + pub line_lit: f32, + pub line_width: f32, + pub light_angle: f32, + /// A soft inner glow from the edge. + pub glow: f32, + pub glow_width: f32, + /// The hairline Apple draws in the pixel just outside the edge: + /// `clamp(min(under, a * under + b), 0, 1)`, with `a` and `b` blended + /// between vertical edges (`[a, b]` first) and horizontal ones by the + /// edge normal. + pub hairline: [f32; 4], +} + +mod serde_poly { + use super::POLY_TERMS; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(poly: &[f32; 3 * POLY_TERMS], s: S) -> Result { + s.collect_seq(poly.iter()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[f32; 3 * POLY_TERMS], D::Error> { + let values = Vec::::deserialize(d)?; + values + .try_into() + .map_err(|_| serde::de::Error::custom("poly needs 30 coefficients")) + } +} + +/// One pane's resolved style, in logical pixels. Mirrored field for field by +/// `GlassStyle` in the gpui patch's shaders (which take device pixels). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PaneStyle { + pub blur: f32, + pub lens_rim: f32, + pub lens_curve: f32, + pub rim_bend: f32, + pub poly: [f32; 3 * POLY_TERMS], + pub opacity: f32, + pub line: f32, + pub line_lit: f32, + pub line_width: f32, + pub light: [f32; 2], + pub glow: f32, + pub glow_width: f32, + pub hairline: [f32; 4], +} + +impl PaneStyle { + /// The same style for a window drawing `scale` device pixels per + /// logical pixel. + pub fn scaled(&self, scale: f32) -> Self { + Self { + blur: self.blur * scale, + lens_rim: self.lens_rim * scale, + rim_bend: self.rim_bend * scale, + line_width: self.line_width * scale, + glow_width: self.glow_width * scale, + ..*self + } + } +} + +impl GlassMaterial { + /// Parameters the optimiser searches; the colour transform is solved. + pub const SHAPE_FIELDS: usize = 16; + + pub fn resolve(&self, width: f32, height: f32) -> PaneStyle { + let shorter = width.min(height).max(1.); + let lens_rim = (self.rim_k * shorter).min(self.rim_max).max(0.5); + PaneStyle { + blur: (self.blur_k * shorter).min(self.blur_max).max(0.), + lens_rim, + lens_curve: self.lens_curve, + rim_bend: self.bend * lens_rim, + poly: self.poly, + opacity: self.opacity, + line: self.line, + line_lit: self.line_lit, + line_width: self.line_width, + light: [self.light_angle.cos(), self.light_angle.sin()], + glow: self.glow, + glow_width: self.glow_width, + hairline: self.hairline, + } + } + + pub fn shape_vec(&self) -> [f32; Self::SHAPE_FIELDS] { + [ + self.blur_k, + self.blur_max, + self.rim_k, + self.rim_max, + self.bend, + self.lens_curve, + self.line, + self.line_lit, + self.line_width, + self.light_angle, + self.glow, + self.glow_width, + self.hairline[0], + self.hairline[1], + self.hairline[2], + self.hairline[3], + ] + } + + pub fn with_shape(&self, values: &[f32]) -> Self { + Self { + blur_k: values[0].max(0.), + blur_max: values[1].max(0.), + rim_k: values[2].max(0.), + rim_max: values[3].max(0.5), + bend: values[4], + lens_curve: values[5].clamp(0.2, 6.), + line: values[6], + line_lit: values[7], + line_width: values[8].max(0.3), + light_angle: values[9], + glow: values[10], + glow_width: values[11].max(0.5), + hairline: [values[12], values[13], values[14], values[15]], + ..*self + } + } +} + +/// The blurred backdrop colour a glass pixel samples (0-1), before the +/// colour transform, plus everything applied around it. +pub struct GlassSample { + pub source: [f32; 3], + /// How much of the pixel the glass covers. + pub coverage: f32, + /// How much of the pixel the hairline ring outside the edge covers. + pub ring: f32, + /// The hairline's `a` and `b` at this point of the edge. + pub hairline: [f32; 2], + /// `1 + line term`. + pub scale: f32, + pub glow: f32, +} + +pub fn sample(style: &PaneStyle, shape: &Shape, blurred: &Rgba, px: f32, py: f32) -> GlassSample { + let d = shape.sdf(px, py); + let coverage = (0.5 - d).clamp(0., 1.); + let ring = (1.5 - d).clamp(0., 1.) - coverage; + let (nx, ny) = shape.normal(px, py); + let vertical = nx * nx; + let hairline = [ + style.hairline[0] * vertical + style.hairline[2] * (1. - vertical), + style.hairline[1] * vertical + style.hairline[3] * (1. - vertical), + ]; + if coverage <= 0. { + return GlassSample { + source: [0.; 3], + coverage: 0., + ring, + hairline, + scale: 1., + glow: 0., + }; + } + let edge = (-d).max(0.); + let rim = 1. - (edge / style.lens_rim).clamp(0., 1.).powf(style.lens_curve); + let value = blurred.sample(px + nx * style.rim_bend * rim, py + ny * style.rim_bend * rim); + let facing = nx * style.light[0] + ny * style.light[1]; + GlassSample { + source: [value[0] / 255., value[1] / 255., value[2] / 255.], + coverage, + ring, + hairline, + scale: 1. + (-edge / style.line_width).exp() * (style.line + style.line_lit * facing), + glow: (-edge / style.glow_width).exp() * style.glow, + } +} + +/// The backdrop under a glass pixel once the hairline is drawn on it (0-1). +pub fn under_with_hairline(sample: &GlassSample, under: [f32; 3]) -> [f32; 3] { + std::array::from_fn(|i| { + let line = under[i].min(sample.hairline[0] * under[i] + sample.hairline[1]).clamp(0., 1.); + under[i] + (line - under[i]) * sample.ring + }) +} + +/// Apply the colour transform and lighting: 0-1 sRGB out, unclamped. +pub fn finish(style: &PaneStyle, sample: &GlassSample) -> [f32; 3] { + let features = poly_features(sample.source); + std::array::from_fn(|channel| { + let coefficients = &style.poly[channel * POLY_TERMS..(channel + 1) * POLY_TERMS]; + let value: f32 = coefficients.iter().zip(features).map(|(c, f)| c * f).sum(); + value * sample.scale + sample.glow + }) +} + +/// Composite one pane over `out`, sampling `blurred` (the backdrop blurred +/// by the pane's own blur). +pub fn composite(style: &PaneStyle, shape: &Shape, blurred: &Rgba, out: &mut Rgba) { + let x0 = (shape.x - 3.).floor().max(0.) as usize; + let y0 = (shape.y - 3.).floor().max(0.) as usize; + let x1 = ((shape.x + shape.width + 3.).ceil() as usize).min(out.width); + let y1 = ((shape.y + shape.height + 3.).ceil() as usize).min(out.height); + for y in y0..y1 { + for x in x0..x1 { + let glass = sample(style, shape, blurred, x as f32 + 0.5, y as f32 + 0.5); + if glass.coverage <= 0. && glass.ring <= 0. { + continue; + } + let under = out.get(x, y); + 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.]); + } + } +} + +/// Composite every pane of `material` over `backdrop`. +pub fn render(material: &GlassMaterial, shapes: &[Shape], backdrop: &Rgba) -> Rgba { + 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); + } + out +} diff --git a/apps/desktop-gpui/glass/src/scenes.rs b/apps/desktop-gpui/glass/src/scenes.rs new file mode 100644 index 0000000000..d88a2ef725 --- /dev/null +++ b/apps/desktop-gpui/glass/src/scenes.rs @@ -0,0 +1,117 @@ +use crate::{Rgba, Shape}; + +/// Logical size of every scene; the probe window and the reference capture +/// share it. +pub const CANVAS: (f32, f32) = (960., 600.); + +/// The panes the probe and the reference capture lay over each scene, in +/// logical pixels with a top-left origin. +pub const SHAPES: [Shape; 5] = [ + Shape { + x: 60., + y: 60., + width: 320., + height: 120., + radius: 60., + }, + Shape { + x: 460., + y: 60., + width: 420., + height: 240., + radius: 24., + }, + Shape { + x: 60., + y: 260., + width: 300., + height: 48., + radius: 24., + }, + Shape { + x: 120., + y: 380., + width: 96., + height: 96., + radius: 48., + }, + Shape { + x: 460., + y: 380., + width: 360., + height: 160., + radius: 16., + }, +]; + +/// Backdrops that render identically everywhere (no text, so no fonts). +pub const NAMES: [&str; 9] = [ + "white", "black", "gray", "stripes", "grid", "checker", "hues", "blobs", "split", +]; + +/// Render `name` at `scale` device pixels per logical pixel. +pub fn render(name: &str, scale: f32) -> Option { + let (width, height) = ((CANVAS.0 * scale).round() as usize, (CANVAS.1 * scale).round() as usize); + let mut image = Rgba::solid(width, height, [255.; 4]); + for y in 0..height { + for x in 0..width { + let (lx, ly) = ((x as f32 + 0.5) / scale, (y as f32 + 0.5) / scale); + image.set(x, y, pixel(name, lx, ly)?); + } + } + Some(image) +} + +fn pixel(name: &str, x: f32, y: f32) -> Option<[f32; 4]> { + let gray = |value: f32| [value, value, value, 255.]; + Some(match name { + "white" => gray(255.), + "black" => gray(0.), + "gray" => gray(128.), + "stripes" => gray(if (x / 8.).floor() as i64 % 2 == 0 { 0. } else { 255. }), + "grid" => { + let on_line = |v: f32| (v + 1.).rem_euclid(24.) < 2.; + if on_line(x) || on_line(y) { + [26., 77., 230., 255.] + } else { + [245., 245., 247., 255.] + } + } + "checker" => { + if ((x / 32.).floor() as i64 + (y / 32.).floor() as i64) % 2 == 0 { + [242., 82., 51., 255.] + } else { + [31., 140., 242., 255.] + } + } + "hues" => { + let hue = (x / CANVAS.0).clamp(0., 1.) * 6.; + let f = |n: f32| { + let k = (n + hue) % 6.; + 242. * (1. - 0.85 * (k.min(4. - k).clamp(0., 1.))) + }; + [f(5.), f(3.), f(1.), 255.] + } + "blobs" => { + let mut color = [250., 240., 224.]; + for (fx, fy, radius, blob) in [ + (0.2, 0.7, 260., [255., 89., 115.]), + (0.7, 0.75, 300., [64., 140., 255.]), + (0.5, 0.2, 280., [51., 217., 140.]), + (0.9, 0.2, 220., [255., 204., 51.]), + ] { + let d = ((x - fx * CANVAS.0).powi(2) + (y - fy * CANVAS.1).powi(2)).sqrt(); + let t = (1. - d / radius).clamp(0., 1.); + for i in 0..3 { + color[i] += (blob[i] - color[i]) * t; + } + } + [color[0], color[1], color[2], 255.] + } + "split" => { + let white = y < CANVAS.1 / 2. || x < 170. || x >= 670.; + gray(if white { 255. } else { 0. }) + } + _ => return None, + }) +} diff --git a/apps/desktop-gpui/patches/zed-glass.patch b/apps/desktop-gpui/patches/zed-glass.patch new file mode 100644 index 0000000000..12d721a633 --- /dev/null +++ b/apps/desktop-gpui/patches/zed-glass.patch @@ -0,0 +1,2020 @@ +diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs +index 6cc3562..6ea0576 100644 +--- a/crates/gpui/src/scene.rs ++++ b/crates/gpui/src/scene.rs +@@ -52,7 +52,7 @@ pub struct Scene { + pub surfaces: Vec, + /// Backdrop-blur regions — deliberately OUTSIDE the primitive batch + /// stream: the renderer breaks its render pass at each blur's order to +- /// snapshot the framebuffer (macOS Metal; other renderers ignore them). ++ /// snapshot the framebuffer. + pub backdrop_blurs: Vec, + } + +@@ -613,8 +613,8 @@ impl From for Primitive { + + /// A within-window backdrop blur region: the renderer snapshots everything + /// painted below this order and paints it back gaussian-blurred inside the +-/// rounded bounds (frosted-glass popovers). macOS Metal only — see +-/// [`crate::Window::paint_backdrop_blur`]. ++/// rounded bounds, optionally shaded as glass. See ++/// [`crate::Window::paint_backdrop_blur`] and [`crate::Window::paint_glass`]. + #[derive(Debug, Copy, Clone)] + #[repr(C)] + #[expect(missing_docs)] +@@ -624,6 +624,53 @@ pub struct BackdropBlur { + pub bounds: Bounds, + pub content_mask: ContentMask, + pub corner_radii: Corners, ++ pub glass: GlassStyle, ++} ++ ++/// The glass material a backdrop blur is shaded with, resolved for one ++/// pane. Lengths are device pixels; colours are 0-1 in the framebuffer's ++/// (gamma-encoded) space. All zeros (`enabled == 0`) is a plain blur. ++/// ++/// Per pixel: the blurred backdrop is sampled along the edge normal by ++/// `rim_bend` within the `lens_rim`-wide rim (falling off with ++/// `lens_curve`) and transformed by a second-order polynomial: each output ++/// channel `c` is `poly[c * 10..]` dotted with `r, g, b, r², g², b², rg, gb, ++/// rb, 1`. An edge line then scales the colour by `1 + line + line_lit` ++/// times how much the edge faces `light_x`/`light_y` (decaying over ++/// `line_width`), and an inner glow adds `glow` (decaying over ++/// `glow_width`). Over a transparent backdrop the glass is `opacity` opaque. ++/// The pixel ring just outside the edge gets a hairline, ++/// `clamp(min(under, a * under + b), 0, 1)`, with `a` and `b` blended from ++/// `hairline[0..2]` on vertical edges to `hairline[2..4]` on horizontal ones. ++#[derive(Debug, Default, Copy, Clone, PartialEq)] ++#[repr(C)] ++#[expect(missing_docs)] ++pub struct GlassStyle { ++ pub enabled: u32, ++ pub lens_rim: f32, ++ pub lens_curve: f32, ++ pub rim_bend: f32, ++ pub poly: [f32; 30], ++ pub opacity: f32, ++ pub line: f32, ++ pub line_lit: f32, ++ pub line_width: f32, ++ pub light_x: f32, ++ pub light_y: f32, ++ pub glow: f32, ++ pub glow_width: f32, ++ pub hairline: [f32; 4], ++} ++ ++impl GlassStyle { ++ /// How far the shader may sample outside the pane's bounds. ++ pub fn reach(&self) -> f32 { ++ if self.enabled == 0 { ++ 0. ++ } else { ++ self.rim_bend.abs() + 2. ++ } ++ } + } + + #[derive(Debug, Copy, Clone)] +diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs +index 4299e15..91bee9f 100644 +--- a/crates/gpui/src/window.rs ++++ b/crates/gpui/src/window.rs +@@ -13,7 +13,7 @@ use crate::{ + PolychromeSprite, + Priority, PromptButton, PromptLevel, Quad, Render, RenderGlyphParams, RenderImage, + RenderImageParams, RenderSvgParams, Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR, +- SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, BackdropBlur, ScaledPixels, Scene, Shadow, SharedString, Size, ++ SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, BackdropBlur, GlassStyle, ScaledPixels, Scene, Shadow, SharedString, Size, + StrikethroughStyle, Style, SubpixelSprite, SubscriberSet, Subscription, SystemWindowTab, + SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextRenderingMode, TextStyle, + TextStyleRefinement, ThermalState, TransformationMatrix, Underline, UnderlineStyle, +@@ -4186,6 +4186,19 @@ impl Window { + bounds: Bounds, + corner_radii: Corners, + blur_radius: Pixels, ++ ) { ++ self.paint_glass(bounds, corner_radii, blur_radius, GlassStyle::default()); ++ } ++ ++ /// Paint a glass pane: [`Self::paint_backdrop_blur`] shaded with ++ /// `glass`, whose lengths are logical pixels here and scaled to device ++ /// pixels for the renderer. ++ pub fn paint_glass( ++ &mut self, ++ bounds: Bounds, ++ corner_radii: Corners, ++ blur_radius: Pixels, ++ glass: GlassStyle, + ) { + self.invalidator.debug_assert_paint(); + let scale_factor = self.scale_factor(); +@@ -4210,6 +4223,13 @@ impl Window { + bounds: bounds.scale(scale_factor), + content_mask, + corner_radii: corner_radii.scale(scale_factor), ++ glass: GlassStyle { ++ lens_rim: glass.lens_rim * scale_factor, ++ rim_bend: glass.rim_bend * scale_factor, ++ line_width: glass.line_width * scale_factor, ++ glow_width: glass.glow_width * scale_factor, ++ ..glass ++ }, + }); + } + +diff --git a/crates/gpui_macos/build.rs b/crates/gpui_macos/build.rs +index 70e9a0b..79415df 100644 +--- a/crates/gpui_macos/build.rs ++++ b/crates/gpui_macos/build.rs +@@ -52,6 +52,7 @@ mod macos_build { + "Shadow".into(), + "BackdropBlurInputIndex".into(), + "BackdropBlur".into(), ++ "GlassStyle".into(), + "QuadInputIndex".into(), + "Underline".into(), + "UnderlineInputIndex".into(), +diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs +index 4be15aa..f4c608d 100644 +--- a/crates/gpui_macos/src/metal_renderer.rs ++++ b/crates/gpui_macos/src/metal_renderer.rs +@@ -979,7 +979,7 @@ impl MetalRenderer { + // so snapshot just that padded region instead of the whole + // drawable (popovers are small; this is a 10-20x reduction). + let sigma = blur.blur_radius.0.max(1.0); +- let padding = (sigma * 3.0).ceil() + 2.0; ++ let padding = (sigma * 3.0 + blur.glass.reach()).ceil() + 2.0; + let visible = blur.bounds.intersect(&blur.content_mask.bounds); + let drawable_width = texture.width() as i64; + let drawable_height = texture.height() as i64; +@@ -1064,6 +1064,7 @@ impl MetalRenderer { + &mut instance_offset, + viewport_size, + &blurred, ++ &scratch, + command_encoder, + ) { + command_encoder.end_encoding(); +@@ -1425,6 +1426,7 @@ impl MetalRenderer { + instance_offset: &mut usize, + viewport_size: Size, + source_texture: &metal::TextureRef, ++ sharp_texture: &metal::TextureRef, + command_encoder: &metal::RenderCommandEncoderRef, + ) -> bool { + if blurs.is_empty() { +@@ -1462,6 +1464,10 @@ impl MetalRenderer { + BackdropBlurInputIndex::SourceTexture as u64, + Some(source_texture), + ); ++ command_encoder.set_fragment_texture( ++ BackdropBlurInputIndex::SharpTexture as u64, ++ Some(sharp_texture), ++ ); + + let blur_bytes_len = mem::size_of_val(blurs); + let buffer_contents = +@@ -2397,6 +2403,8 @@ enum BackdropBlurInputIndex { + /// Drawable rect the snapshot covers (x, y, w, h in device px) — the + /// fragment shader maps frag position → snapshot UV through it. + SourceRect = 4, ++ /// The unblurred snapshot glass antialiases its edge against. ++ SharpTexture = 5, + } + + #[repr(C)] +@@ -2568,7 +2576,7 @@ mod renderer_hardening_tests { + #[cfg(test)] + mod backdrop_blur_tests { + use super::*; +- use gpui::{Corners, Edges, Hsla, solid_background, transparent_black}; ++ use gpui::{Corners, Edges, GlassStyle, Hsla, solid_background, transparent_black}; + use image::RgbaImage; + + const VIEW_W: i32 = 640; +@@ -2645,6 +2653,7 @@ mod backdrop_blur_tests { + bounds: blur_bounds, + content_mask: ContentMask { bounds: viewport() }, + corner_radii: Corners::default(), ++ glass: Default::default(), + }); + scene.pop_layer(); + } +@@ -2741,6 +2750,197 @@ mod backdrop_blur_tests { + ); + } + ++ fn push_stripes(scene: &mut Scene) { ++ let mut x = 0.; ++ let mut dark = true; ++ while x < VIEW_W as f32 { ++ push_quad(scene, bounds(x, 0., 8., VIEW_H as f32), if dark { 0.05 } else { 0.95 }); ++ x += 8.; ++ dark = !dark; ++ } ++ } ++ ++ fn test_glass() -> GlassStyle { ++ let mut poly = [0.; 30]; ++ poly[..10].copy_from_slice(&[0.6, 0.15, 0.05, -0.2, 0.1, 0.0, 0.05, 0.0, 0.02, 0.22]); ++ poly[10..20].copy_from_slice(&[0.05, 0.5, 0.1, 0.0, -0.15, 0.05, 0.0, 0.04, 0.0, 0.2]); ++ poly[20..].copy_from_slice(&[0.12, 0.05, 0.55, 0.0, 0.0, -0.1, 0.02, 0.0, 0.03, 0.18]); ++ GlassStyle { ++ enabled: 1, ++ lens_rim: 28., ++ lens_curve: 0.4, ++ rim_bend: -40., ++ poly, ++ opacity: 0.6, ++ line: -0.1, ++ line_lit: 0.05, ++ line_width: 1., ++ light_x: -0.7, ++ light_y: -0.7, ++ glow: 0.04, ++ glow_width: 6., ++ hairline: [0.86, -0.12, 0.92, -0.08], ++ } ++ } ++ ++ fn gaussian_blur(image: &RgbaImage, sigma: f32) -> Vec<[f32; 4]> { ++ let (w, h) = (image.width() as i32, image.height() as i32); ++ let radius = (sigma * 3.).ceil() as i32; ++ let weights: Vec = (-radius..=radius) ++ .map(|i| (-(i * i) as f32 / (2. * sigma * sigma)).exp()) ++ .collect(); ++ let total: f32 = weights.iter().sum(); ++ let pass = |src: &dyn Fn(i32, i32) -> [f32; 4], horizontal: bool| -> Vec<[f32; 4]> { ++ let mut out = vec![[0.; 4]; (w * h) as usize]; ++ for y in 0..h { ++ for x in 0..w { ++ let mut acc = [0.; 4]; ++ for (k, weight) in weights.iter().enumerate() { ++ let offset = k as i32 - radius; ++ let (sx, sy) = if horizontal { ++ ((x + offset).clamp(0, w - 1), y) ++ } else { ++ (x, (y + offset).clamp(0, h - 1)) ++ }; ++ let px = src(sx, sy); ++ for c in 0..4 { ++ acc[c] += px[c] * weight / total; ++ } ++ } ++ out[(y * w + x) as usize] = acc; ++ } ++ } ++ out ++ }; ++ let first = pass( ++ &|x, y| { ++ let p = image.get_pixel(x as u32, y as u32); ++ [p[0] as f32, p[1] as f32, p[2] as f32, p[3] as f32] ++ }, ++ true, ++ ); ++ pass(&|x, y| first[(y * w + x) as usize], false) ++ } ++ ++ /// The glass shader against the same math on the CPU: the snapshot is ++ /// gaussian-blurred, sampled along the edge normal in the rim, then ++ /// colour-transformed, compressed and lit. ++ #[test] ++ fn glass_matches_the_cpu_model() { ++ if metal::Device::system_default().is_none() { ++ return; ++ } ++ let pane = bounds(200., 140., 240., 160.); ++ let radius = 30.; ++ let sigma = 12.; ++ let glass = test_glass(); ++ ++ let mut base = Scene::default(); ++ push_stripes(&mut base); ++ let backdrop = render(&mut base); ++ ++ let mut scene = Scene::default(); ++ push_stripes(&mut scene); ++ scene.push_layer(pane); ++ scene.insert_primitive(Shadow { ++ order: 0, ++ blur_radius: ScaledPixels(0.), ++ bounds: pane, ++ corner_radii: Corners::all(ScaledPixels(radius)), ++ content_mask: ContentMask { bounds: viewport() }, ++ color: transparent_black(), ++ element_bounds: pane, ++ element_corner_radii: Corners::all(ScaledPixels(radius)), ++ inset: 0, ++ pad: 0, ++ }); ++ scene.insert_backdrop_blur(BackdropBlur { ++ order: 0, ++ blur_radius: ScaledPixels(sigma), ++ bounds: pane, ++ content_mask: ContentMask { bounds: viewport() }, ++ corner_radii: Corners::all(ScaledPixels(radius)), ++ glass, ++ }); ++ scene.pop_layer(); ++ let rendered = render(&mut scene); ++ ++ let blurred = gaussian_blur(&backdrop, sigma); ++ let width = VIEW_W; ++ let sample = |x: f32, y: f32| -> [f32; 4] { ++ let fx = (x - 0.5).clamp(0., (VIEW_W - 1) as f32); ++ let fy = (y - 0.5).clamp(0., (VIEW_H - 1) as f32); ++ let (x0, y0) = (fx.floor() as i32, fy.floor() as i32); ++ let (x1, y1) = ((x0 + 1).min(VIEW_W - 1), (y0 + 1).min(VIEW_H - 1)); ++ let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); ++ let at = |x: i32, y: i32| blurred[(y * width + x) as usize]; ++ let (a, b, c, d) = (at(x0, y0), at(x1, y0), at(x0, y1), at(x1, y1)); ++ std::array::from_fn(|i| { ++ let top = a[i] + (b[i] - a[i]) * tx; ++ top + (c[i] + (d[i] - c[i]) * tx - top) * ty ++ }) ++ }; ++ let sdf = |x: f32, y: f32| -> f32 { ++ let half = (pane.size.width.0 / 2., pane.size.height.0 / 2.); ++ let qx = (x - (pane.origin.x.0 + half.0)).abs() - (half.0 - radius); ++ let qy = (y - (pane.origin.y.0 + half.1)).abs() - (half.1 - radius); ++ (qx.max(0.).powi(2) + qy.max(0.).powi(2)).sqrt() + qx.max(qy).min(0.) - radius ++ }; ++ ++ let mut errors = Vec::new(); ++ for y in (pane.origin.y.0 as i32 - 2)..(pane.origin.y.0 + pane.size.height.0) as i32 + 2 { ++ for x in (pane.origin.x.0 as i32 - 2)..(pane.origin.x.0 + pane.size.width.0) as i32 + 2 { ++ let (px, py) = (x as f32 + 0.5, y as f32 + 0.5); ++ let d = sdf(px, py); ++ let coverage = (0.5 - d).clamp(0., 1.); ++ let ring = (1.5 - d).clamp(0., 1.) - coverage; ++ let (nx, ny) = { ++ let dx = sdf(px + 0.5, py) - sdf(px - 0.5, py); ++ let dy = sdf(px, py + 0.5) - sdf(px, py - 0.5); ++ let len = (dx * dx + dy * dy).sqrt().max(1e-6); ++ (dx / len, dy / len) ++ }; ++ let vertical = nx * nx; ++ let hair_a = glass.hairline[2] + (glass.hairline[0] - glass.hairline[2]) * vertical; ++ let hair_b = glass.hairline[3] + (glass.hairline[1] - glass.hairline[3]) * vertical; ++ let raw = backdrop.get_pixel(x as u32, y as u32); ++ let under: [f32; 3] = std::array::from_fn(|i| { ++ let value = raw[i] as f32 / 255.; ++ let line = value.min(hair_a * value + hair_b).clamp(0., 1.); ++ (value + (line - value) * ring) * 255. ++ }); ++ let expected: [f32; 3] = if coverage <= 0. { ++ under ++ } else { ++ let edge = (-d).max(0.); ++ let rim = 1. - (edge / glass.lens_rim).clamp(0., 1.).powf(glass.lens_curve); ++ let s = sample(px + nx * glass.rim_bend * rim, py + ny * glass.rim_bend * rim); ++ let c = [s[0] / 255., s[1] / 255., s[2] / 255.]; ++ let features = [ ++ c[0], c[1], c[2], c[0] * c[0], c[1] * c[1], c[2] * c[2], c[0] * c[1], ++ c[1] * c[2], c[0] * c[2], 1., ++ ]; ++ let facing = nx * glass.light_x + ny * glass.light_y; ++ let line = (-edge / glass.line_width).exp() * (glass.line + glass.line_lit * facing); ++ let glow = (-edge / glass.glow_width).exp() * glass.glow; ++ std::array::from_fn(|i| { ++ let value: f32 = (0..10).map(|t| glass.poly[i * 10 + t] * features[t]).sum(); ++ let value = (value * (1. + line) + glow).clamp(0., 1.) * 255.; ++ under[i] + (value - under[i]) * coverage ++ }) ++ }; ++ let got = rendered.get_pixel(x as u32, y as u32); ++ for c in 0..3 { ++ errors.push((got[c] as f32 - expected[c]).abs()); ++ } ++ } ++ } ++ errors.sort_by(f32::total_cmp); ++ let mean = errors.iter().sum::() / errors.len() as f32; ++ let p99 = errors[errors.len() * 99 / 100]; ++ assert!(mean < 1.5 && p99 < 8., "glass vs CPU model: mean {mean:.2}, p99 {p99:.2}"); ++ } ++ + #[test] + fn two_blurs_of_different_sizes_in_one_frame() { + if metal::Device::system_default().is_none() { +diff --git a/crates/gpui_macos/src/shaders.metal b/crates/gpui_macos/src/shaders.metal +index e087c0d..40c50bd 100644 +--- a/crates/gpui_macos/src/shaders.metal ++++ b/crates/gpui_macos/src/shaders.metal +@@ -1354,9 +1354,17 @@ vertex BackdropBlurVertexOutput backdrop_blur_vertex( + [[buffer(BackdropBlurInputIndex_ViewportSize)]]) { + float2 unit_vertex = unit_vertices[unit_vertex_id]; + BackdropBlur blur = blurs[blur_id]; ++ // Glass draws a hairline in the pixel ring just outside its edge. ++ Bounds_ScaledPixels bounds = blur.bounds; ++ if (blur.glass.enabled != 0) { ++ bounds.origin.x -= 2.; ++ bounds.origin.y -= 2.; ++ bounds.size.width += 4.; ++ bounds.size.height += 4.; ++ } + float4 device_position = +- to_device_position(unit_vertex, blur.bounds, viewport_size); +- float4 clip_distance = distance_from_clip_rect(unit_vertex, blur.bounds, ++ to_device_position(unit_vertex, bounds, viewport_size); ++ float4 clip_distance = distance_from_clip_rect(unit_vertex, bounds, + blur.content_mask.bounds); + return BackdropBlurVertexOutput{ + device_position, +@@ -1364,26 +1372,77 @@ vertex BackdropBlurVertexOutput backdrop_blur_vertex( + {clip_distance.x, clip_distance.y, clip_distance.z, clip_distance.w}}; + } + ++float3 glass_shade(GlassStyle glass, float3 c, float edge, float2 normal) { ++ float features[10] = {c.r, c.g, c.b, c.r * c.r, c.g * c.g, c.b * c.b, ++ c.r * c.g, c.g * c.b, c.r * c.b, 1.}; ++ float3 color = float3(0.); ++ for (int term = 0; term < 10; term++) { ++ color += features[term] * ++ float3(glass.poly[term], glass.poly[10 + term], glass.poly[20 + term]); ++ } ++ float facing = dot(normal, float2(glass.light_x, glass.light_y)); ++ float line = exp(-edge / glass.line_width) * (glass.line + glass.line_lit * facing); ++ float glow = exp(-edge / glass.glow_width) * glass.glow; ++ return color * (1. + line) + glow; ++} ++ + fragment float4 backdrop_blur_fragment( + BackdropBlurFragmentInput input [[stage_in]], + constant BackdropBlur *blurs [[buffer(BackdropBlurInputIndex_Blurs)]], + constant float4 &source_rect [[buffer(BackdropBlurInputIndex_SourceRect)]], + texture2d source_texture +- [[texture(BackdropBlurInputIndex_SourceTexture)]]) { ++ [[texture(BackdropBlurInputIndex_SourceTexture)]], ++ texture2d sharp_texture ++ [[texture(BackdropBlurInputIndex_SharpTexture)]]) { + constexpr sampler source_sampler(coord::normalized, address::clamp_to_edge, + filter::linear); + BackdropBlur blur = blurs[input.blur_id]; ++ float2 position = input.position.xy; ++ float distance = quad_sdf(position, blur.bounds, blur.corner_radii); + +- // Rounded-rect clip: blending is disabled on this pipeline (the blur +- // REPLACES the region), so fragments outside must discard, not return 0. +- float distance = quad_sdf(input.position.xy, blur.bounds, blur.corner_radii); +- if (distance > 0.) { +- discard_fragment(); ++ // The snapshot was gaussian-blurred on the GPU (MPSImageGaussianBlur) ++ // before this pass. It covers only source_rect (x, y, w, h) of the ++ // drawable, not the whole viewport. ++ if (blur.glass.enabled == 0) { ++ // Blending is disabled on this pipeline (the blur REPLACES the region), ++ // so fragments outside must discard, not return 0. ++ if (distance > 0.) { ++ discard_fragment(); ++ } ++ float2 uv = (position - source_rect.xy) / source_rect.zw; ++ return source_texture.sample(source_sampler, uv); + } + +- // The snapshot was gaussian-blurred on the GPU (MPSImageGaussianBlur) +- // before this pass — one clean sample. The snapshot covers only +- // source_rect (x, y, w, h) of the drawable, not the whole viewport. +- float2 uv = (input.position.xy - source_rect.xy) / source_rect.zw; +- return source_texture.sample(source_sampler, uv); ++ // Glass antialiases its own edge against the unblurred snapshot, and ++ // draws its hairline in the ring just outside the edge. ++ float coverage = saturate(0.5 - distance); ++ float ring = saturate(1.5 - distance) - coverage; ++ if (coverage <= 0. && ring <= 0.) { ++ discard_fragment(); ++ } ++ float2 normal = float2( ++ quad_sdf(position + float2(0.5, 0.), blur.bounds, blur.corner_radii) - ++ quad_sdf(position - float2(0.5, 0.), blur.bounds, blur.corner_radii), ++ quad_sdf(position + float2(0., 0.5), blur.bounds, blur.corner_radii) - ++ quad_sdf(position - float2(0., 0.5), blur.bounds, blur.corner_radii)); ++ normal /= max(length(normal), 1e-6); ++ float vertical = normal.x * normal.x; ++ float hair_a = mix(blur.glass.hairline[2], blur.glass.hairline[0], vertical); ++ float hair_b = mix(blur.glass.hairline[3], blur.glass.hairline[1], vertical); ++ float4 under = sharp_texture.sample( ++ source_sampler, (position - source_rect.xy) / source_rect.zw); ++ float3 hairline = saturate(min(under.rgb, hair_a * under.rgb + hair_b)); ++ under.rgb = mix(under.rgb, hairline, ring); ++ if (coverage <= 0.) { ++ return under; ++ } ++ float edge = max(-distance, 0.); ++ float rim = 1. - pow(saturate(edge / blur.glass.lens_rim), blur.glass.lens_curve); ++ float2 sample_at = position + normal * blur.glass.rim_bend * rim; ++ float4 backdrop = source_texture.sample( ++ source_sampler, (sample_at - source_rect.xy) / source_rect.zw); ++ float3 straight = backdrop.a > 0. ? backdrop.rgb / backdrop.a : float3(0.); ++ float alpha = mix(backdrop.a, 1., blur.glass.opacity); ++ float3 glass = saturate(glass_shade(blur.glass, straight, edge, normal)); ++ return mix(under, float4(glass * alpha, alpha), coverage); + } +diff --git a/crates/gpui_wgpu/src/shaders.wgsl b/crates/gpui_wgpu/src/shaders.wgsl +index 011be71..47a16cd 100644 +--- a/crates/gpui_wgpu/src/shaders.wgsl ++++ b/crates/gpui_wgpu/src/shaders.wgsl +@@ -1399,3 +1399,194 @@ fn fs_surface(input: SurfaceVarying) -> @location(0) vec4 { + + return ycbcr_to_RGB * y_cb_cr; + } ++ ++// --- backdrop blur and glass --- // ++ ++struct GlassStyle { ++ enabled: u32, ++ lens_rim: f32, ++ lens_curve: f32, ++ rim_bend: f32, ++ poly: array, ++ opacity: f32, ++ line_strength: f32, ++ line_lit: f32, ++ line_width: f32, ++ light_x: f32, ++ light_y: f32, ++ glow: f32, ++ glow_width: f32, ++ hairline: array, ++} ++ ++struct BackdropBlur { ++ order: u32, ++ blur_radius: f32, ++ bounds: Bounds, ++ content_mask: Bounds, ++ corner_radii: Corners, ++ glass: GlassStyle, ++} ++ ++// For the downsample and blur passes `source_rect` is the texel rect of the ++// source texture being read and `params` is (direction x, direction y, ++// sigma in source texels or the downsample factor, unused). For the glass ++// pass `source_rect` is the drawable rect that was blurred, `params.x` the ++// factor it was blurred at (the blurred texture holds it from texel 0, ++// scaled down by that factor), and `sharp_texture_size` the size of the ++// drawable-sized unblurred copy. ++struct BackdropParams { ++ source_rect: vec4, ++ params: vec4, ++ source_texture_size: vec2, ++ sharp_texture_size: vec2, ++} ++ ++@group(1) @binding(0) var backdrop_params: BackdropParams; ++@group(1) @binding(1) var t_backdrop: texture_2d; ++@group(1) @binding(2) var s_backdrop: sampler; ++@group(1) @binding(3) var b_backdrop_blurs: array; ++@group(1) @binding(4) var t_backdrop_sharp: texture_2d; ++ ++struct BackdropPassVarying { ++ @builtin(position) position: vec4, ++ @location(0) uv: vec2, ++} ++ ++@vertex ++fn vs_backdrop_pass(@builtin(vertex_index) vertex_id: u32) -> BackdropPassVarying { ++ let uv = vec2(f32((vertex_id << 1u) & 2u), f32(vertex_id & 2u)); ++ var out = BackdropPassVarying(); ++ out.position = vec4(uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0), 0.0, 1.0); ++ out.uv = uv; ++ return out; ++} ++ ++fn backdrop_source_uv(uv: vec2) -> vec2 { ++ let rect = backdrop_params.source_rect; ++ return (rect.xy + uv * rect.zw) / backdrop_params.source_texture_size; ++} ++ ++@fragment ++fn fs_backdrop_downsample(input: BackdropPassVarying) -> @location(0) vec4 { ++ let rect = backdrop_params.source_rect; ++ let texel = 1.0 / backdrop_params.source_texture_size; ++ let center = backdrop_source_uv(input.uv); ++ let quarter = texel * backdrop_params.params.z * 0.25; ++ let lo = (rect.xy + 0.5) * texel; ++ let hi = (rect.xy + rect.zw - 0.5) * texel; ++ return 0.25 * ( ++ textureSampleLevel(t_backdrop, s_backdrop, clamp(center + vec2(-quarter.x, -quarter.y), lo, hi), 0.0) + ++ textureSampleLevel(t_backdrop, s_backdrop, clamp(center + vec2(quarter.x, -quarter.y), lo, hi), 0.0) + ++ textureSampleLevel(t_backdrop, s_backdrop, clamp(center + vec2(-quarter.x, quarter.y), lo, hi), 0.0) + ++ textureSampleLevel(t_backdrop, s_backdrop, clamp(center + vec2(quarter.x, quarter.y), lo, hi), 0.0)); ++} ++ ++@fragment ++fn fs_backdrop_blur_pass(input: BackdropPassVarying) -> @location(0) vec4 { ++ let rect = backdrop_params.source_rect; ++ let sigma = max(backdrop_params.params.z, 0.5); ++ let radius = i32(ceil(sigma * 3.0)); ++ let step = backdrop_params.params.xy / backdrop_params.source_texture_size; ++ let uv = backdrop_source_uv(input.uv); ++ let lo = (rect.xy + 0.5) / backdrop_params.source_texture_size; ++ let hi = (rect.xy + rect.zw - 0.5) / backdrop_params.source_texture_size; ++ var sum = vec4(0.0); ++ var total = 0.0; ++ for (var i = -radius; i <= radius; i++) { ++ let weight = exp(-f32(i * i) / (2.0 * sigma * sigma)); ++ sum += weight * textureSampleLevel(t_backdrop, s_backdrop, clamp(uv + step * f32(i), lo, hi), 0.0); ++ total += weight; ++ } ++ return sum / total; ++} ++ ++struct BackdropGlassVarying { ++ @builtin(position) position: vec4, ++ @location(0) @interpolate(flat) blur_id: u32, ++ @location(1) clip_distances: vec4, ++} ++ ++@vertex ++fn vs_backdrop_glass(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) instance_id: u32) -> BackdropGlassVarying { ++ let unit_vertex = vec2(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); ++ let blur = b_backdrop_blurs[instance_id]; ++ // Glass draws a hairline in the pixel ring just outside its edge. ++ var bounds = blur.bounds; ++ if (blur.glass.enabled != 0u) { ++ bounds.origin -= vec2(2.0); ++ bounds.size += vec2(4.0); ++ } ++ var out = BackdropGlassVarying(); ++ out.position = to_device_position(unit_vertex, bounds); ++ out.blur_id = instance_id; ++ out.clip_distances = distance_from_clip_rect(unit_vertex, bounds, blur.content_mask); ++ return out; ++} ++ ++// Clamped to the blurred region: texels beyond it hold an older pass. ++fn backdrop_blurred_uv(position: vec2) -> vec2 { ++ let rect = backdrop_params.source_rect; ++ let factor = backdrop_params.params.x; ++ let texel = clamp((position - rect.xy) / factor, vec2(0.5), rect.zw / factor - 0.5); ++ return texel / backdrop_params.source_texture_size; ++} ++ ++@fragment ++fn fs_backdrop_glass(input: BackdropGlassVarying) -> @location(0) vec4 { ++ if (any(input.clip_distances < vec4(0.0))) { ++ discard; ++ } ++ let blur = b_backdrop_blurs[input.blur_id]; ++ let position = input.position.xy; ++ let distance = quad_sdf(position, blur.bounds, blur.corner_radii); ++ ++ if (blur.glass.enabled == 0u) { ++ if (distance > 0.0) { ++ discard; ++ } ++ return textureSampleLevel(t_backdrop, s_backdrop, backdrop_blurred_uv(position), 0.0); ++ } ++ ++ let coverage = saturate(0.5 - distance); ++ let ring = saturate(1.5 - distance) - coverage; ++ if (coverage <= 0.0 && ring <= 0.0) { ++ discard; ++ } ++ let glass = blur.glass; ++ let edge = max(-distance, 0.0); ++ var normal = vec2( ++ quad_sdf(position + vec2(0.5, 0.0), blur.bounds, blur.corner_radii) - ++ quad_sdf(position - vec2(0.5, 0.0), blur.bounds, blur.corner_radii), ++ quad_sdf(position + vec2(0.0, 0.5), blur.bounds, blur.corner_radii) - ++ quad_sdf(position - vec2(0.0, 0.5), blur.bounds, blur.corner_radii)); ++ normal = normal / max(length(normal), 1e-6); ++ let vertical = normal.x * normal.x; ++ let hair_a = mix(glass.hairline[2], glass.hairline[0], vertical); ++ let hair_b = mix(glass.hairline[3], glass.hairline[1], vertical); ++ var under = textureSampleLevel(t_backdrop_sharp, s_backdrop, position / backdrop_params.sharp_texture_size, 0.0); ++ let hair_color = saturate(min(under.rgb, hair_a * under.rgb + vec3(hair_b))); ++ under = vec4(mix(under.rgb, hair_color, ring), under.a); ++ if (coverage <= 0.0) { ++ return under; ++ } ++ let rim = 1.0 - pow(saturate(edge / glass.lens_rim), glass.lens_curve); ++ let sample_at = position + normal * glass.rim_bend * rim; ++ let backdrop = textureSampleLevel(t_backdrop, s_backdrop, backdrop_blurred_uv(sample_at), 0.0); ++ var color = select(vec3(0.0), backdrop.rgb / backdrop.a, backdrop.a > 0.0); ++ let alpha = mix(backdrop.a, 1.0, glass.opacity); ++ ++ let c = color; ++ let features = array(c.r, c.g, c.b, c.r * c.r, c.g * c.g, c.b * c.b, ++ c.r * c.g, c.g * c.b, c.r * c.b, 1.0); ++ color = vec3(0.0); ++ for (var term = 0; term < 10; term++) { ++ color += features[term] * vec3(glass.poly[term], glass.poly[10 + term], glass.poly[20 + term]); ++ } ++ let facing = dot(normal, vec2(glass.light_x, glass.light_y)); ++ let line_term = exp(-edge / glass.line_width) * (glass.line_strength + glass.line_lit * facing); ++ let glow = exp(-edge / glass.glow_width) * glass.glow; ++ color = saturate(color * (1.0 + line_term) + vec3(glow)); ++ ++ return mix(under, vec4(color * alpha, alpha), coverage); ++} +diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs +index 2bfbc4e..f60b1af 100644 +--- a/crates/gpui_wgpu/src/wgpu_renderer.rs ++++ b/crates/gpui_wgpu/src/wgpu_renderer.rs +@@ -2,7 +2,8 @@ use crate::surface_frame::SurfaceFrame; + use crate::{CompositorGpuHint, WgpuAtlas, WgpuContext}; + use bytemuck::{Pod, Zeroable}; + use gpui::{ +- AtlasTextureId, AtlasTextureKind, Background, Bounds, DevicePixels, GpuSpecs, MonochromeSprite, ++ AtlasTextureId, AtlasTextureKind, BackdropBlur, Background, Bounds, DevicePixels, DrawOrder, ++ GpuSpecs, MonochromeSprite, + Path, Point, PolychromeSprite, PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, + SubpixelSprite, Underline, get_gamma_correction_ratios, + }; +@@ -93,6 +94,9 @@ struct WgpuPipelines { + poly_sprites: wgpu::RenderPipeline, + #[allow(dead_code)] + surfaces: wgpu::RenderPipeline, ++ backdrop_downsample: wgpu::RenderPipeline, ++ backdrop_blur_pass: wgpu::RenderPipeline, ++ backdrop_glass: wgpu::RenderPipeline, + } + + struct WgpuBindGroupLayouts { +@@ -100,8 +104,34 @@ struct WgpuBindGroupLayouts { + instances: wgpu::BindGroupLayout, + instances_with_texture: wgpu::BindGroupLayout, + surfaces: wgpu::BindGroupLayout, ++ backdrop_pass: wgpu::BindGroupLayout, ++ backdrop_glass: wgpu::BindGroupLayout, + } + ++/// Surface-sized textures for backdrop blurs: `sharp` holds the snapshot at ++/// the region's own position; `ping` and `pong` hold it downsampled and ++/// blurred from texel 0. ++struct BackdropTargets { ++ width: u32, ++ height: u32, ++ sharp: wgpu::Texture, ++ sharp_view: wgpu::TextureView, ++ ping_view: wgpu::TextureView, ++ pong_view: wgpu::TextureView, ++} ++ ++#[repr(C)] ++#[derive(Clone, Copy, Pod, Zeroable)] ++struct BackdropParams { ++ source_rect: [f32; 4], ++ params: [f32; 4], ++ source_texture_size: [f32; 2], ++ sharp_texture_size: [f32; 2], ++} ++ ++/// Frames without a backdrop blur before its textures are dropped. ++const BACKDROP_RELEASE_AFTER_FRAMES: u32 = 120; ++ + /// Shared GPU context reference, used to coordinate device recovery across multiple windows. + pub type GpuContext = Rc>>; + +@@ -121,6 +151,8 @@ struct WgpuResources { + path_intermediate_view: Option, + path_msaa_texture: Option, + path_msaa_view: Option, ++ backdrop_sampler: wgpu::Sampler, ++ backdrop_targets: Option, + } + + impl WgpuResources { +@@ -129,6 +161,7 @@ impl WgpuResources { + self.path_intermediate_view = None; + self.path_msaa_texture = None; + self.path_msaa_view = None; ++ self.backdrop_targets = None; + } + } + +@@ -159,6 +192,9 @@ pub struct WgpuRenderer { + device_lost: std::sync::Arc, + surface_configured: bool, + needs_redraw: bool, ++ /// Whether the surface can be copied from, which backdrop blurs need. ++ backdrop_supported: bool, ++ blur_free_frames: u32, + } + + impl WgpuRenderer { +@@ -331,8 +367,13 @@ impl WgpuRenderer { + ); + } + ++ let backdrop_supported = surface_caps.usages.contains(wgpu::TextureUsages::COPY_SRC); + let surface_config = wgpu::SurfaceConfiguration { +- usage: wgpu::TextureUsages::RENDER_ATTACHMENT, ++ usage: if backdrop_supported { ++ wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC ++ } else { ++ wgpu::TextureUsages::RENDER_ATTACHMENT ++ }, + format: surface_format, + width: clamped_width.max(1), + height: clamped_height.max(1), +@@ -368,6 +409,12 @@ impl WgpuRenderer { + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); ++ let backdrop_sampler = device.create_sampler(&wgpu::SamplerDescriptor { ++ label: Some("backdrop_sampler"), ++ mag_filter: wgpu::FilterMode::Linear, ++ min_filter: wgpu::FilterMode::Linear, ++ ..Default::default() ++ }); + + let uniform_alignment = device.limits().min_uniform_buffer_offset_alignment as u64; + let globals_size = std::mem::size_of::() as u64; +@@ -464,6 +511,8 @@ impl WgpuRenderer { + path_intermediate_view: None, + path_msaa_texture: None, + path_msaa_view: None, ++ backdrop_sampler, ++ backdrop_targets: None, + }; + + Ok(Self { +@@ -489,6 +538,8 @@ impl WgpuRenderer { + device_lost: context.device_lost_flag(), + surface_configured: true, + needs_redraw: false, ++ backdrop_supported, ++ blur_free_frames: 0, + }) + } + +@@ -608,11 +659,44 @@ impl WgpuRenderer { + ], + }); + ++ let texture_entry = |binding: u32| wgpu::BindGroupLayoutEntry { ++ binding, ++ visibility: wgpu::ShaderStages::FRAGMENT, ++ ty: wgpu::BindingType::Texture { ++ sample_type: wgpu::TextureSampleType::Float { filterable: true }, ++ view_dimension: wgpu::TextureViewDimension::D2, ++ multisampled: false, ++ }, ++ count: None, ++ }; ++ let sampler_entry = wgpu::BindGroupLayoutEntry { ++ binding: 2, ++ visibility: wgpu::ShaderStages::FRAGMENT, ++ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), ++ count: None, ++ }; ++ let backdrop_pass = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { ++ label: Some("backdrop_pass_layout"), ++ entries: &[storage_buffer_entry(0), texture_entry(1), sampler_entry], ++ }); ++ let backdrop_glass = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { ++ label: Some("backdrop_glass_layout"), ++ entries: &[ ++ storage_buffer_entry(0), ++ texture_entry(1), ++ sampler_entry, ++ storage_buffer_entry(3), ++ texture_entry(4), ++ ], ++ }); ++ + WgpuBindGroupLayouts { + globals, + instances, + instances_with_texture, + surfaces, ++ backdrop_pass, ++ backdrop_glass, + } + } + +@@ -878,6 +962,46 @@ impl WgpuRenderer { + &shader_module, + ); + ++ // The backdrop passes write their final colour, coverage included. ++ let replace_target = [Some(wgpu::ColorTargetState { ++ format: surface_format, ++ blend: None, ++ write_mask: wgpu::ColorWrites::ALL, ++ })]; ++ let backdrop_downsample = create_pipeline( ++ "backdrop_downsample", ++ "vs_backdrop_pass", ++ "fs_backdrop_downsample", ++ &layouts.globals, ++ &layouts.backdrop_pass, ++ wgpu::PrimitiveTopology::TriangleList, ++ &replace_target, ++ 1, ++ &shader_module, ++ ); ++ let backdrop_blur_pass = create_pipeline( ++ "backdrop_blur_pass", ++ "vs_backdrop_pass", ++ "fs_backdrop_blur_pass", ++ &layouts.globals, ++ &layouts.backdrop_pass, ++ wgpu::PrimitiveTopology::TriangleList, ++ &replace_target, ++ 1, ++ &shader_module, ++ ); ++ let backdrop_glass = create_pipeline( ++ "backdrop_glass", ++ "vs_backdrop_glass", ++ "fs_backdrop_glass", ++ &layouts.globals, ++ &layouts.backdrop_glass, ++ wgpu::PrimitiveTopology::TriangleStrip, ++ &replace_target, ++ 1, ++ &shader_module, ++ ); ++ + WgpuPipelines { + quads, + shadows, +@@ -888,6 +1012,9 @@ impl WgpuRenderer { + subpixel_sprites, + poly_sprites, + surfaces, ++ backdrop_downsample, ++ backdrop_blur_pass, ++ backdrop_glass, + } + } + +@@ -1148,6 +1275,17 @@ impl WgpuRenderer { + + // Now that we know the surface is healthy, ensure intermediate textures exist + self.ensure_intermediate_textures(); ++ if scene.backdrop_blurs.is_empty() { ++ self.blur_free_frames = self.blur_free_frames.saturating_add(1); ++ if self.blur_free_frames >= BACKDROP_RELEASE_AFTER_FRAMES { ++ self.resources_mut().backdrop_targets = None; ++ } ++ } else { ++ self.blur_free_frames = 0; ++ if self.backdrop_supported { ++ self.ensure_backdrop_targets(); ++ } ++ } + + let frame_view = frame + .texture() +@@ -1227,7 +1365,28 @@ impl WgpuRenderer { + ..Default::default() + }); + +- for batch in scene.batches() { ++ let mut pending_blurs = scene.backdrop_blurs.iter().peekable(); ++ 'batches: for batch in scene.batches() { ++ // Backdrop blurs interleave by draw order outside the ++ // batch stream: everything painted so far is copied, ++ // blurred and painted back before the batch above it. ++ while let Some(blur) = pending_blurs ++ .next_if(|blur| blur.order <= batch_first_order(scene, &batch)) ++ { ++ drop(pass); ++ let ok = self.draw_backdrop_blur( ++ &mut encoder, ++ frame.texture(), ++ &frame_view, ++ blur, ++ &mut instance_offset, ++ ); ++ pass = Self::continue_main_pass(&mut encoder, &frame_view); ++ if !ok { ++ overflow = true; ++ break 'batches; ++ } ++ } + let ok = match batch { + PrimitiveBatch::Quads(range) => { + self.draw_quads(&scene.quads[range], &mut instance_offset, &mut pass) +@@ -1313,6 +1472,21 @@ impl WgpuRenderer { + break; + } + } ++ if !overflow { ++ drop(pass); ++ for blur in pending_blurs { ++ if !self.draw_backdrop_blur( ++ &mut encoder, ++ frame.texture(), ++ &frame_view, ++ blur, ++ &mut instance_offset, ++ ) { ++ overflow = true; ++ break; ++ } ++ } ++ } + } + + if overflow { +@@ -1365,6 +1539,276 @@ impl WgpuRenderer { + ) + } + ++ fn continue_main_pass<'a>( ++ encoder: &'a mut wgpu::CommandEncoder, ++ frame_view: &'a wgpu::TextureView, ++ ) -> wgpu::RenderPass<'a> { ++ encoder.begin_render_pass(&wgpu::RenderPassDescriptor { ++ label: Some("main_pass_continued"), ++ color_attachments: &[Some(wgpu::RenderPassColorAttachment { ++ view: frame_view, ++ resolve_target: None, ++ ops: wgpu::Operations { ++ load: wgpu::LoadOp::Load, ++ store: wgpu::StoreOp::Store, ++ }, ++ depth_slice: None, ++ })], ++ depth_stencil_attachment: None, ++ ..Default::default() ++ }) ++ } ++ ++ fn ensure_backdrop_targets(&mut self) { ++ let (width, height) = (self.surface_config.width, self.surface_config.height); ++ let format = self.surface_config.format; ++ let resources = self.resources_mut(); ++ if resources ++ .backdrop_targets ++ .as_ref() ++ .is_some_and(|targets| targets.width == width && targets.height == height) ++ { ++ return; ++ } ++ let texture = |label: &str, usage: wgpu::TextureUsages| { ++ resources.device.create_texture(&wgpu::TextureDescriptor { ++ label: Some(label), ++ size: wgpu::Extent3d { ++ width, ++ height, ++ depth_or_array_layers: 1, ++ }, ++ mip_level_count: 1, ++ sample_count: 1, ++ dimension: wgpu::TextureDimension::D2, ++ format, ++ usage, ++ view_formats: &[], ++ }) ++ }; ++ let target_usage = ++ wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING; ++ let sharp = texture( ++ "backdrop_sharp", ++ wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING, ++ ); ++ let view = |texture: &wgpu::Texture| texture.create_view(&Default::default()); ++ let targets = BackdropTargets { ++ width, ++ height, ++ sharp_view: view(&sharp), ++ ping_view: view(&texture("backdrop_ping", target_usage)), ++ pong_view: view(&texture("backdrop_pong", target_usage)), ++ sharp, ++ }; ++ resources.backdrop_targets = Some(targets); ++ } ++ ++ /// Copy the region under `blur` (plus room for the gaussian tail and any ++ /// glass lensing), downsample and blur it, then paint it back. Returns ++ /// false only when the instance buffer is full. Downsampling keeps every ++ /// blur under ~13 taps per side: a gaussian that wide is ++ /// indistinguishable from one taken at full resolution. ++ fn draw_backdrop_blur( ++ &self, ++ encoder: &mut wgpu::CommandEncoder, ++ frame_texture: &wgpu::Texture, ++ frame_view: &wgpu::TextureView, ++ blur: &BackdropBlur, ++ instance_offset: &mut u64, ++ ) -> bool { ++ let resources = self.resources(); ++ let Some(targets) = resources.backdrop_targets.as_ref() else { ++ return true; ++ }; ++ let sigma = blur.blur_radius.0.max(1.0); ++ let padding = (sigma * 3.0 + blur.glass.reach()).ceil() + 2.0; ++ let visible = blur.bounds.intersect(&blur.content_mask.bounds); ++ let (width, height) = (targets.width as i64, targets.height as i64); ++ let x0 = ((visible.origin.x.0 - padding).floor() as i64).max(0); ++ let y0 = ((visible.origin.y.0 - padding).floor() as i64).max(0); ++ let x1 = ((visible.origin.x.0 + visible.size.width.0 + padding).ceil() as i64).min(width); ++ let y1 = ((visible.origin.y.0 + visible.size.height.0 + padding).ceil() as i64).min(height); ++ if x1 <= x0 || y1 <= y0 { ++ return true; ++ } ++ let factor = (sigma / 4.).floor().clamp(1., 8.); ++ let region = [x0 as f32, y0 as f32, (x1 - x0) as f32, (y1 - y0) as f32]; ++ let reduced = [(region[2] / factor).ceil(), (region[3] / factor).ceil()]; ++ let full = [targets.width as f32, targets.height as f32]; ++ ++ encoder.copy_texture_to_texture( ++ wgpu::TexelCopyTextureInfo { ++ texture: frame_texture, ++ mip_level: 0, ++ origin: wgpu::Origin3d { ++ x: x0 as u32, ++ y: y0 as u32, ++ z: 0, ++ }, ++ aspect: wgpu::TextureAspect::All, ++ }, ++ wgpu::TexelCopyTextureInfo { ++ texture: &targets.sharp, ++ mip_level: 0, ++ origin: wgpu::Origin3d { ++ x: x0 as u32, ++ y: y0 as u32, ++ z: 0, ++ }, ++ aspect: wgpu::TextureAspect::All, ++ }, ++ wgpu::Extent3d { ++ width: (x1 - x0) as u32, ++ height: (y1 - y0) as u32, ++ depth_or_array_layers: 1, ++ }, ++ ); ++ ++ let mut run_pass = |pipeline: &wgpu::RenderPipeline, ++ source: &wgpu::TextureView, ++ target: &wgpu::TextureView, ++ params: BackdropParams| ++ -> bool { ++ let Some((offset, size)) = ++ self.write_to_instance_buffer(instance_offset, bytemuck::bytes_of(¶ms)) ++ else { ++ return false; ++ }; ++ let bind_group = resources.device.create_bind_group(&wgpu::BindGroupDescriptor { ++ label: None, ++ layout: &resources.bind_group_layouts.backdrop_pass, ++ entries: &[ ++ wgpu::BindGroupEntry { ++ binding: 0, ++ resource: self.instance_binding(offset, size), ++ }, ++ wgpu::BindGroupEntry { ++ binding: 1, ++ resource: wgpu::BindingResource::TextureView(source), ++ }, ++ wgpu::BindGroupEntry { ++ binding: 2, ++ resource: wgpu::BindingResource::Sampler(&resources.backdrop_sampler), ++ }, ++ ], ++ }); ++ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { ++ label: Some("backdrop_pass"), ++ color_attachments: &[Some(wgpu::RenderPassColorAttachment { ++ view: target, ++ resolve_target: None, ++ ops: wgpu::Operations { ++ load: wgpu::LoadOp::Load, ++ store: wgpu::StoreOp::Store, ++ }, ++ depth_slice: None, ++ })], ++ depth_stencil_attachment: None, ++ ..Default::default() ++ }); ++ pass.set_viewport(0., 0., reduced[0], reduced[1], 0., 1.); ++ pass.set_pipeline(pipeline); ++ pass.set_bind_group(0, &resources.globals_bind_group, &[]); ++ pass.set_bind_group(1, &bind_group, &[]); ++ pass.draw(0..3, 0..1); ++ true ++ }; ++ ++ let pipelines = &resources.pipelines; ++ let reduced_rect = [0., 0., reduced[0], reduced[1]]; ++ let (blur_source, blur_source_rect) = if factor > 1. { ++ if !run_pass( ++ &pipelines.backdrop_downsample, ++ &targets.sharp_view, ++ &targets.pong_view, ++ BackdropParams { ++ source_rect: region, ++ params: [0., 0., factor, 0.], ++ source_texture_size: full, ++ sharp_texture_size: full, ++ }, ++ ) { ++ return false; ++ } ++ (&targets.pong_view, reduced_rect) ++ } else { ++ (&targets.sharp_view, region) ++ }; ++ if !run_pass( ++ &pipelines.backdrop_blur_pass, ++ blur_source, ++ &targets.ping_view, ++ BackdropParams { ++ source_rect: blur_source_rect, ++ params: [1., 0., sigma / factor, 0.], ++ source_texture_size: full, ++ sharp_texture_size: full, ++ }, ++ ) || !run_pass( ++ &pipelines.backdrop_blur_pass, ++ &targets.ping_view, ++ &targets.pong_view, ++ BackdropParams { ++ source_rect: reduced_rect, ++ params: [0., 1., sigma / factor, 0.], ++ source_texture_size: full, ++ sharp_texture_size: full, ++ }, ++ ) { ++ return false; ++ } ++ ++ let params = BackdropParams { ++ source_rect: region, ++ params: [factor, 0., 0., 0.], ++ source_texture_size: full, ++ sharp_texture_size: full, ++ }; ++ let Some((params_offset, params_size)) = ++ self.write_to_instance_buffer(instance_offset, bytemuck::bytes_of(¶ms)) ++ else { ++ return false; ++ }; ++ let blur_bytes = unsafe { Self::instance_bytes(std::slice::from_ref(blur)) }; ++ let Some((blur_offset, blur_size)) = self.write_to_instance_buffer(instance_offset, blur_bytes) ++ else { ++ return false; ++ }; ++ let bind_group = resources.device.create_bind_group(&wgpu::BindGroupDescriptor { ++ label: None, ++ layout: &resources.bind_group_layouts.backdrop_glass, ++ entries: &[ ++ wgpu::BindGroupEntry { ++ binding: 0, ++ resource: self.instance_binding(params_offset, params_size), ++ }, ++ wgpu::BindGroupEntry { ++ binding: 1, ++ resource: wgpu::BindingResource::TextureView(&targets.pong_view), ++ }, ++ wgpu::BindGroupEntry { ++ binding: 2, ++ resource: wgpu::BindingResource::Sampler(&resources.backdrop_sampler), ++ }, ++ wgpu::BindGroupEntry { ++ binding: 3, ++ resource: self.instance_binding(blur_offset, blur_size), ++ }, ++ wgpu::BindGroupEntry { ++ binding: 4, ++ resource: wgpu::BindingResource::TextureView(&targets.sharp_view), ++ }, ++ ], ++ }); ++ let mut pass = Self::continue_main_pass(encoder, frame_view); ++ pass.set_pipeline(&pipelines.backdrop_glass); ++ pass.set_bind_group(0, &resources.globals_bind_group, &[]); ++ pass.set_bind_group(1, &bind_group, &[]); ++ pass.draw(0..4, 0..1); ++ true ++ } ++ + fn draw_shadows( + &self, + shadows: &[Shadow], +@@ -1937,3 +2381,22 @@ impl RenderingParameters { + } + } + } ++ ++/// The draw order of a batch's first primitive -- where a backdrop blur ++/// whose order is not above it must be painted first. ++fn batch_first_order(scene: &Scene, batch: &PrimitiveBatch) -> DrawOrder { ++ match batch { ++ PrimitiveBatch::Shadows(range) => scene.shadows[range.start].order, ++ PrimitiveBatch::Quads(range) => scene.quads[range.start].order, ++ PrimitiveBatch::Paths(range) => scene.paths[range.start].order, ++ PrimitiveBatch::Underlines(range) => scene.underlines[range.start].order, ++ PrimitiveBatch::MonochromeSprites { range, .. } => { ++ scene.monochrome_sprites[range.start].order ++ } ++ PrimitiveBatch::SubpixelSprites { range, .. } => scene.subpixel_sprites[range.start].order, ++ PrimitiveBatch::PolychromeSprites { range, .. } => { ++ scene.polychrome_sprites[range.start].order ++ } ++ PrimitiveBatch::Surfaces(range) => scene.surfaces[range.start].order, ++ } ++} +diff --git a/crates/gpui_windows/build.rs b/crates/gpui_windows/build.rs +index 1db9571..0a6b4a0 100644 +--- a/crates/gpui_windows/build.rs ++++ b/crates/gpui_windows/build.rs +@@ -38,6 +38,9 @@ mod shader_compilation { + "monochrome_sprite", + "subpixel_sprite", + "polychrome_sprite", ++ "backdrop_downsample", ++ "backdrop_blur_pass", ++ "backdrop_glass", + ]; + + let rust_binding_path = format!("{}/shaders_bytes.rs", out_dir); +diff --git a/crates/gpui_windows/src/directx_renderer.rs b/crates/gpui_windows/src/directx_renderer.rs +index e4b605b..1a88981 100644 +--- a/crates/gpui_windows/src/directx_renderer.rs ++++ b/crates/gpui_windows/src/directx_renderer.rs +@@ -53,6 +53,34 @@ pub(crate) struct DirectXRenderer { + /// In that case we want to discard the first frame that we draw as we got reset in the middle of a frame + /// meaning we lost all the allocated gpu textures and scene resources. + skip_draws: bool, ++ ++ /// Snapshot and blur targets for backdrop blurs, dropped again after ++ /// [`BACKDROP_RELEASE_AFTER_FRAMES`] frames without one. ++ backdrop_textures: Option, ++ blur_free_frames: u32, ++} ++ ++const BACKDROP_RELEASE_AFTER_FRAMES: u32 = 120; ++ ++/// Drawable-sized textures for backdrop blurs: `sharp` holds the snapshot ++/// at the region's own position; `ping` and `pong` hold it downsampled and ++/// blurred from texel 0. ++struct BackdropTextures { ++ width: u32, ++ height: u32, ++ sharp: ID3D11Texture2D, ++ sharp_view: Option, ++ ping: (Option, Option), ++ pong: (Option, Option), ++} ++ ++#[derive(Debug, Default, Clone, Copy)] ++#[repr(C)] ++struct BackdropParams { ++ source_rect: [f32; 4], ++ params: [f32; 4], ++ source_texture_size: [f32; 2], ++ sharp_texture_size: [f32; 2], + } + + /// Direct3D objects +@@ -91,11 +119,16 @@ struct DirectXRenderPipelines { + mono_sprites: PipelineState, + subpixel_sprites: PipelineState, + poly_sprites: PipelineState, ++ backdrop_glass: PipelineState, ++ backdrop_downsample: (ID3D11VertexShader, ID3D11PixelShader), ++ backdrop_blur_pass: (ID3D11VertexShader, ID3D11PixelShader), + } + + struct DirectXGlobalElements { + global_params_buffer: Option, + sampler: Option, ++ backdrop_params_buffer: Option, ++ backdrop_sampler: Option, + } + + struct Annotation<'a>(&'a ID3DUserDefinedAnnotation); +@@ -192,6 +225,8 @@ impl DirectXRenderer { + width: 1, + height: 1, + skip_draws: false, ++ backdrop_textures: None, ++ blur_free_frames: 0, + }) + } + +@@ -314,6 +349,7 @@ impl DirectXRenderer { + self.resources = Some(resources); + self.globals = globals; + self.pipelines = pipelines; ++ self.backdrop_textures = None; + self.direct_composition = direct_composition; + self.skip_draws = true; + Ok(()) +@@ -336,12 +372,30 @@ impl DirectXRenderer { + + self.upload_scene_buffers(scene)?; + ++ if scene.backdrop_blurs.is_empty() { ++ self.blur_free_frames = self.blur_free_frames.saturating_add(1); ++ if self.blur_free_frames >= BACKDROP_RELEASE_AFTER_FRAMES { ++ self.backdrop_textures = None; ++ } ++ } else { ++ self.blur_free_frames = 0; ++ } ++ + let annotation = self + .devices + .as_ref() + .and_then(|devices| devices.annotation.clone()) + .filter(|annotation| unsafe { annotation.GetStatus().as_bool() }); ++ let mut pending_blurs = scene.backdrop_blurs.iter().peekable(); + for batch in scene.batches() { ++ // Backdrop blurs interleave by draw order outside the batch ++ // stream: everything painted so far is snapshotted, blurred and ++ // painted back before the batch drawn above it. ++ while let Some(blur) = ++ pending_blurs.next_if(|blur| blur.order <= batch_first_order(scene, &batch)) ++ { ++ self.draw_backdrop_blur(blur)?; ++ } + let _annotation = annotation + .as_ref() + .map(|annotation| Annotation::new(annotation, HSTRING::from(batch.label()))); +@@ -380,9 +434,222 @@ impl DirectXRenderer { + ) + })?; + } ++ for blur in pending_blurs { ++ self.draw_backdrop_blur(blur)?; ++ } + self.present() + } + ++ fn ensure_backdrop_textures(&mut self) -> Result<()> { ++ let (width, height) = (self.width, self.height); ++ if self ++ .backdrop_textures ++ .as_ref() ++ .is_some_and(|textures| textures.width == width && textures.height == height) ++ { ++ return Ok(()); ++ } ++ let device = &self.devices.as_ref().context("devices missing")?.device; ++ let texture = |bind: u32| -> Result { ++ let desc = D3D11_TEXTURE2D_DESC { ++ Width: width, ++ Height: height, ++ MipLevels: 1, ++ ArraySize: 1, ++ Format: RENDER_TARGET_FORMAT, ++ SampleDesc: DXGI_SAMPLE_DESC { ++ Count: 1, ++ Quality: 0, ++ }, ++ Usage: D3D11_USAGE_DEFAULT, ++ BindFlags: bind, ++ CPUAccessFlags: 0, ++ MiscFlags: 0, ++ }; ++ let mut output = None; ++ unsafe { device.CreateTexture2D(&desc, None, Some(&mut output))? }; ++ Ok(output.unwrap()) ++ }; ++ let target = || -> Result<(Option, Option)> { ++ let texture = texture((D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32)?; ++ let mut target_view = None; ++ let mut shader_view = None; ++ unsafe { ++ device.CreateRenderTargetView(&texture, None, Some(&mut target_view))?; ++ device.CreateShaderResourceView(&texture, None, Some(&mut shader_view))?; ++ } ++ Ok((target_view, shader_view)) ++ }; ++ let sharp = texture(D3D11_BIND_SHADER_RESOURCE.0 as u32)?; ++ let mut sharp_view = None; ++ unsafe { device.CreateShaderResourceView(&sharp, None, Some(&mut sharp_view))? }; ++ self.backdrop_textures = Some(BackdropTextures { ++ width, ++ height, ++ sharp, ++ sharp_view, ++ ping: target()?, ++ pong: target()?, ++ }); ++ Ok(()) ++ } ++ ++ /// Snapshot the region under `blur` (plus room for the gaussian tail and ++ /// any glass lensing), downsample and blur it, then paint it back. ++ /// Downsampling keeps every blur under ~13 taps per side: a gaussian ++ /// that wide is indistinguishable from one taken at full resolution. ++ fn draw_backdrop_blur(&mut self, blur: &BackdropBlur) -> Result<()> { ++ let sigma = blur.blur_radius.0.max(1.0); ++ let padding = (sigma * 3.0 + blur.glass.reach()).ceil() + 2.0; ++ let visible = blur.bounds.intersect(&blur.content_mask.bounds); ++ let (width, height) = (self.width as i64, self.height as i64); ++ let x0 = ((visible.origin.x.0 - padding).floor() as i64).max(0); ++ let y0 = ((visible.origin.y.0 - padding).floor() as i64).max(0); ++ let x1 = ((visible.origin.x.0 + visible.size.width.0 + padding).ceil() as i64).min(width); ++ let y1 = ((visible.origin.y.0 + visible.size.height.0 + padding).ceil() as i64).min(height); ++ if x1 <= x0 || y1 <= y0 { ++ return Ok(()); ++ } ++ self.ensure_backdrop_textures()?; ++ let factor = (sigma / 4.).floor().clamp(1., 8.); ++ let region = [x0 as f32, y0 as f32, (x1 - x0) as f32, (y1 - y0) as f32]; ++ let reduced = [(region[2] / factor).ceil(), (region[3] / factor).ceil()]; ++ let full = [self.width as f32, self.height as f32]; ++ ++ let devices = self.devices.as_ref().context("devices missing")?; ++ let context = &devices.device_context; ++ let resources = self.resources.as_ref().context("resources missing")?; ++ let render_target = resources.render_target.as_ref().context("render target missing")?; ++ let textures = self.backdrop_textures.as_ref().context("backdrop textures missing")?; ++ let params_buffer = &self.globals.backdrop_params_buffer; ++ let sampler = slice::from_ref(&self.globals.backdrop_sampler); ++ let no_views: [Option; 3] = [None, None, None]; ++ ++ let set_params = |params: BackdropParams| -> Result<()> { ++ if let Some(buffer) = params_buffer { ++ update_buffer(context, buffer, slice::from_ref(¶ms))?; ++ } ++ Ok(()) ++ }; ++ let reduced_viewport = D3D11_VIEWPORT { ++ TopLeftX: 0., ++ TopLeftY: 0., ++ Width: reduced[0], ++ Height: reduced[1], ++ MinDepth: 0., ++ MaxDepth: 1., ++ }; ++ let run_pass = |shaders: &(ID3D11VertexShader, ID3D11PixelShader), ++ source: &Option, ++ target: &Option, ++ params: BackdropParams| ++ -> Result<()> { ++ set_params(params)?; ++ unsafe { ++ context.PSSetShaderResources(1, Some(&no_views)); ++ context.OMSetRenderTargets(Some(slice::from_ref(target)), None); ++ context.RSSetViewports(Some(slice::from_ref(&reduced_viewport))); ++ context.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST); ++ context.VSSetShader(&shaders.0, None); ++ context.PSSetShader(&shaders.1, None); ++ context.PSSetConstantBuffers(1, Some(slice::from_ref(params_buffer))); ++ context.PSSetSamplers(1, Some(sampler)); ++ context.PSSetShaderResources(2, Some(slice::from_ref(source))); ++ context.OMSetBlendState(&self.pipelines.backdrop_glass.blend_state, None, 0xFFFFFFFF); ++ context.Draw(3, 0); ++ context.PSSetShaderResources(2, Some(&[None])); ++ } ++ Ok(()) ++ }; ++ ++ unsafe { ++ context.CopySubresourceRegion( ++ &textures.sharp, ++ 0, ++ x0 as u32, ++ y0 as u32, ++ 0, ++ render_target, ++ 0, ++ Some(&D3D11_BOX { ++ left: x0 as u32, ++ top: y0 as u32, ++ front: 0, ++ right: x1 as u32, ++ bottom: y1 as u32, ++ back: 1, ++ }), ++ ); ++ } ++ let reduced_rect = [0., 0., reduced[0], reduced[1]]; ++ let blur_source = if factor > 1. { ++ run_pass( ++ &self.pipelines.backdrop_downsample, ++ &textures.sharp_view, ++ &textures.pong.0, ++ BackdropParams { ++ source_rect: region, ++ params: [0., 0., factor, 0.], ++ source_texture_size: full, ++ sharp_texture_size: full, ++ }, ++ )?; ++ (&textures.pong.1, reduced_rect) ++ } else { ++ (&textures.sharp_view, region) ++ }; ++ run_pass( ++ &self.pipelines.backdrop_blur_pass, ++ blur_source.0, ++ &textures.ping.0, ++ BackdropParams { ++ source_rect: blur_source.1, ++ params: [1., 0., sigma / factor, 0.], ++ source_texture_size: full, ++ sharp_texture_size: full, ++ }, ++ )?; ++ run_pass( ++ &self.pipelines.backdrop_blur_pass, ++ &textures.ping.1, ++ &textures.pong.0, ++ BackdropParams { ++ source_rect: reduced_rect, ++ params: [0., 1., sigma / factor, 0.], ++ source_texture_size: full, ++ sharp_texture_size: full, ++ }, ++ )?; ++ ++ set_params(BackdropParams { ++ source_rect: region, ++ params: [factor, 0., 0., 0.], ++ source_texture_size: full, ++ sharp_texture_size: full, ++ })?; ++ self.pipelines ++ .backdrop_glass ++ .update_buffer(&devices.device, context, slice::from_ref(blur))?; ++ unsafe { ++ context.OMSetRenderTargets(Some(slice::from_ref(&resources.render_target_view)), None); ++ context.PSSetConstantBuffers(1, Some(slice::from_ref(params_buffer))); ++ context.PSSetSamplers(1, Some(sampler)); ++ context.PSSetShaderResources(2, Some(&[textures.pong.1.clone(), textures.sharp_view.clone()])); ++ } ++ self.pipelines.backdrop_glass.draw( ++ context, ++ slice::from_ref(&resources.viewport), ++ slice::from_ref(&self.globals.global_params_buffer), ++ D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, ++ 4, ++ 1, ++ )?; ++ unsafe { ++ context.PSSetShaderResources(2, Some(&[None, None])); ++ } ++ Ok(()) ++ } ++ + pub(crate) fn resize(&mut self, new_size: Size) -> Result<()> { + let width = new_size.width.0.max(1) as u32; + let height = new_size.height.0.max(1) as u32; +@@ -917,6 +1184,22 @@ impl DirectXRenderPipelines { + create_blend_state(device)?, + )?; + ++ let backdrop_glass = PipelineState::new( ++ device, ++ "backdrop_glass_pipeline", ++ ShaderModule::BackdropGlass, ++ 4, ++ create_replace_blend_state(device)?, ++ )?; ++ let pass_shaders = |module: ShaderModule| -> Result<(ID3D11VertexShader, ID3D11PixelShader)> { ++ let vertex = RawShaderBytes::new(module, ShaderTarget::Vertex)?; ++ let fragment = RawShaderBytes::new(module, ShaderTarget::Fragment)?; ++ Ok(( ++ create_vertex_shader(device, vertex.as_bytes())?, ++ create_fragment_shader(device, fragment.as_bytes())?, ++ )) ++ }; ++ + Ok(Self { + shadow_pipeline, + quad_pipeline, +@@ -926,6 +1209,9 @@ impl DirectXRenderPipelines { + mono_sprites, + subpixel_sprites, + poly_sprites, ++ backdrop_glass, ++ backdrop_downsample: pass_shaders(ShaderModule::BackdropDownsample)?, ++ backdrop_blur_pass: pass_shaders(ShaderModule::BackdropBlurPass)?, + }) + } + } +@@ -986,9 +1272,41 @@ impl DirectXGlobalElements { + output + }; + ++ let backdrop_params_buffer = unsafe { ++ let desc = D3D11_BUFFER_DESC { ++ ByteWidth: std::mem::size_of::() as u32, ++ Usage: D3D11_USAGE_DYNAMIC, ++ BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32, ++ CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, ++ ..Default::default() ++ }; ++ let mut buffer = None; ++ device.CreateBuffer(&desc, None, Some(&mut buffer))?; ++ buffer ++ }; ++ let backdrop_sampler = unsafe { ++ let desc = D3D11_SAMPLER_DESC { ++ Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR, ++ AddressU: D3D11_TEXTURE_ADDRESS_CLAMP, ++ AddressV: D3D11_TEXTURE_ADDRESS_CLAMP, ++ AddressW: D3D11_TEXTURE_ADDRESS_CLAMP, ++ MipLODBias: 0.0, ++ MaxAnisotropy: 1, ++ ComparisonFunc: D3D11_COMPARISON_ALWAYS, ++ BorderColor: [0.0; 4], ++ MinLOD: 0.0, ++ MaxLOD: D3D11_FLOAT32_MAX, ++ }; ++ let mut output = None; ++ device.CreateSamplerState(&desc, Some(&mut output))?; ++ output ++ }; ++ + Ok(Self { + global_params_buffer, + sampler, ++ backdrop_params_buffer, ++ backdrop_sampler, + }) + } + } +@@ -1432,6 +1750,18 @@ fn create_blend_state(device: &ID3D11Device) -> Result { + } + } + ++/// The backdrop passes write their final colour, coverage already applied. ++fn create_replace_blend_state(device: &ID3D11Device) -> Result { ++ let mut desc = D3D11_BLEND_DESC::default(); ++ desc.RenderTarget[0].BlendEnable = false.into(); ++ desc.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL.0 as u8; ++ unsafe { ++ let mut state = None; ++ device.CreateBlendState(&desc, Some(&mut state))?; ++ Ok(state.unwrap()) ++ } ++} ++ + #[inline] + fn create_blend_state_for_subpixel_rendering(device: &ID3D11Device) -> Result { + let mut desc = D3D11_BLEND_DESC::default(); +@@ -1614,6 +1944,25 @@ fn report_live_objects(device: &ID3D11Device) -> Result<()> { + Ok(()) + } + ++/// The draw order of a batch's first primitive -- where a backdrop blur ++/// whose order is not above it must be painted first. ++fn batch_first_order(scene: &Scene, batch: &PrimitiveBatch) -> DrawOrder { ++ match batch { ++ PrimitiveBatch::Shadows(range) => scene.shadows[range.start].order, ++ PrimitiveBatch::Quads(range) => scene.quads[range.start].order, ++ PrimitiveBatch::Paths(range) => scene.paths[range.start].order, ++ PrimitiveBatch::Underlines(range) => scene.underlines[range.start].order, ++ PrimitiveBatch::MonochromeSprites { range, .. } => { ++ scene.monochrome_sprites[range.start].order ++ } ++ PrimitiveBatch::SubpixelSprites { range, .. } => scene.subpixel_sprites[range.start].order, ++ PrimitiveBatch::PolychromeSprites { range, .. } => { ++ scene.polychrome_sprites[range.start].order ++ } ++ PrimitiveBatch::Surfaces(range) => scene.surfaces[range.start].order, ++ } ++} ++ + const BUFFER_COUNT: usize = 3; + + pub(crate) mod shader_resources { +@@ -1639,6 +1988,9 @@ pub(crate) mod shader_resources { + SubpixelSprite, + PolychromeSprite, + EmojiRasterization, ++ BackdropDownsample, ++ BackdropBlurPass, ++ BackdropGlass, + } + + #[derive(Copy, Clone, Debug, Eq, PartialEq)] +@@ -1716,6 +2068,18 @@ pub(crate) mod shader_resources { + ShaderTarget::Vertex => EMOJI_RASTERIZATION_VERTEX_BYTES, + ShaderTarget::Fragment => EMOJI_RASTERIZATION_FRAGMENT_BYTES, + }, ++ ShaderModule::BackdropDownsample => match target { ++ ShaderTarget::Vertex => BACKDROP_DOWNSAMPLE_VERTEX_BYTES, ++ ShaderTarget::Fragment => BACKDROP_DOWNSAMPLE_FRAGMENT_BYTES, ++ }, ++ ShaderModule::BackdropBlurPass => match target { ++ ShaderTarget::Vertex => BACKDROP_BLUR_PASS_VERTEX_BYTES, ++ ShaderTarget::Fragment => BACKDROP_BLUR_PASS_FRAGMENT_BYTES, ++ }, ++ ShaderModule::BackdropGlass => match target { ++ ShaderTarget::Vertex => BACKDROP_GLASS_VERTEX_BYTES, ++ ShaderTarget::Fragment => BACKDROP_GLASS_FRAGMENT_BYTES, ++ }, + }; + Self { inner: bytes } + } +@@ -1803,6 +2167,9 @@ pub(crate) mod shader_resources { + ShaderModule::SubpixelSprite => "subpixel_sprite", + ShaderModule::PolychromeSprite => "polychrome_sprite", + ShaderModule::EmojiRasterization => "emoji_rasterization", ++ ShaderModule::BackdropDownsample => "backdrop_downsample", ++ ShaderModule::BackdropBlurPass => "backdrop_blur_pass", ++ ShaderModule::BackdropGlass => "backdrop_glass", + } + } + } +diff --git a/crates/gpui_windows/src/shaders.hlsl b/crates/gpui_windows/src/shaders.hlsl +index 78b1ada..24bd265 100644 +--- a/crates/gpui_windows/src/shaders.hlsl ++++ b/crates/gpui_windows/src/shaders.hlsl +@@ -1289,3 +1289,198 @@ float4 polychrome_sprite_fragment(PolychromeSpriteFragmentInput input): SV_Targe + color.a *= sprite.opacity * saturate(0.5 - distance) * edge_fade_alpha(input.position.xy, sprite.fade); + return color; + } ++ ++/* ++** ++** Backdrop blur and glass ++** ++*/ ++ ++// Mirrors gpui's `GlassStyle`; `line` is reserved in HLSL, hence ++// `line_strength` for the same field. ++struct GlassStyle { ++ uint enabled; ++ float lens_rim; ++ float lens_curve; ++ float rim_bend; ++ float poly[30]; ++ float opacity; ++ float line_strength; ++ float line_lit; ++ float line_width; ++ float light_x; ++ float light_y; ++ float glow; ++ float glow_width; ++ float hairline[4]; ++}; ++ ++struct BackdropBlur { ++ uint order; ++ float blur_radius; ++ Bounds bounds; ++ Bounds content_mask; ++ Corners corner_radii; ++ GlassStyle glass; ++}; ++ ++// One constant buffer serves every backdrop pass. For the downsample and ++// blur passes `source_rect` is the texel rect of the source texture being ++// read and `params` is (direction x, direction y, sigma in source texels or ++// the downsample factor, unused). For the glass pass `source_rect` is the ++// drawable rect that was blurred, `params.x` the factor it was blurred at ++// (the blurred texture holds it from texel 0, scaled down by that factor), ++// and `sharp_texture_size` the size of the drawable-sized unblurred copy. ++cbuffer BackdropParams: register(b1) { ++ float4 source_rect; ++ float4 params; ++ float2 source_texture_size; ++ float2 sharp_texture_size; ++}; ++ ++Texture2D t_backdrop: register(t2); ++Texture2D t_backdrop_sharp: register(t3); ++SamplerState s_backdrop: register(s1); ++ ++struct BackdropPassOutput { ++ float4 position: SV_Position; ++ float2 uv: TEXCOORD0; ++}; ++ ++BackdropPassOutput backdrop_pass_vertex(uint vertex_id: SV_VertexID) { ++ float2 uv = float2((vertex_id << 1) & 2, vertex_id & 2); ++ BackdropPassOutput output; ++ output.position = float4(uv * float2(2., -2.) + float2(-1., 1.), 0., 1.); ++ output.uv = uv; ++ return output; ++} ++ ++BackdropPassOutput backdrop_downsample_vertex(uint vertex_id: SV_VertexID) { ++ return backdrop_pass_vertex(vertex_id); ++} ++ ++BackdropPassOutput backdrop_blur_pass_vertex(uint vertex_id: SV_VertexID) { ++ return backdrop_pass_vertex(vertex_id); ++} ++ ++float2 backdrop_source_uv(float2 uv) { ++ return (source_rect.xy + uv * source_rect.zw) / source_texture_size; ++} ++ ++float4 backdrop_downsample_fragment(BackdropPassOutput input): SV_Target { ++ float2 texel = 1. / source_texture_size; ++ float2 center = backdrop_source_uv(input.uv); ++ float2 quarter = texel * params.z * 0.25; ++ float2 lo = (source_rect.xy + 0.5) * texel; ++ float2 hi = (source_rect.xy + source_rect.zw - 0.5) * texel; ++ float4 sum = t_backdrop.SampleLevel(s_backdrop, clamp(center + float2(-quarter.x, -quarter.y), lo, hi), 0); ++ sum += t_backdrop.SampleLevel(s_backdrop, clamp(center + float2(quarter.x, -quarter.y), lo, hi), 0); ++ sum += t_backdrop.SampleLevel(s_backdrop, clamp(center + float2(-quarter.x, quarter.y), lo, hi), 0); ++ sum += t_backdrop.SampleLevel(s_backdrop, clamp(center + float2(quarter.x, quarter.y), lo, hi), 0); ++ return 0.25 * sum; ++} ++ ++float4 backdrop_blur_pass_fragment(BackdropPassOutput input): SV_Target { ++ float sigma = max(params.z, 0.5); ++ int radius = (int)ceil(sigma * 3.); ++ float2 step = params.xy / source_texture_size; ++ float2 uv = backdrop_source_uv(input.uv); ++ float2 lo = (source_rect.xy + 0.5) / source_texture_size; ++ float2 hi = (source_rect.xy + source_rect.zw - 0.5) / source_texture_size; ++ float4 sum = 0.; ++ float total = 0.; ++ [loop] ++ for (int i = -radius; i <= radius; i++) { ++ float weight = exp(-(float)(i * i) / (2. * sigma * sigma)); ++ sum += weight * t_backdrop.SampleLevel(s_backdrop, clamp(uv + step * (float)i, lo, hi), 0); ++ total += weight; ++ } ++ return sum / total; ++} ++ ++StructuredBuffer backdrop_blurs: register(t1); ++ ++struct BackdropGlassVertexOutput { ++ float4 position: SV_Position; ++ nointerpolation uint blur_id: TEXCOORD0; ++ float4 clip_distance: SV_ClipDistance; ++}; ++ ++BackdropGlassVertexOutput backdrop_glass_vertex(uint vertex_id: SV_VertexID, uint blur_id: SV_InstanceID) { ++ float2 unit_vertex = float2(float(vertex_id & 1u), 0.5 * float(vertex_id & 2u)); ++ BackdropBlur blur = backdrop_blurs[blur_id]; ++ // Glass draws a hairline in the pixel ring just outside its edge. ++ Bounds bounds = blur.bounds; ++ if (blur.glass.enabled != 0u) { ++ bounds.origin -= 2.; ++ bounds.size += 4.; ++ } ++ BackdropGlassVertexOutput output; ++ output.position = to_device_position(unit_vertex, bounds); ++ output.blur_id = blur_id; ++ output.clip_distance = distance_from_clip_rect(unit_vertex, bounds, blur.content_mask); ++ return output; ++} ++ ++// Clamped to the blurred region: texels beyond it hold an older pass. ++float2 backdrop_blurred_uv(float2 position) { ++ float2 texel = (position - source_rect.xy) / params.x; ++ texel = clamp(texel, 0.5, source_rect.zw / params.x - 0.5); ++ return texel / source_texture_size; ++} ++ ++float4 backdrop_glass_fragment(BackdropGlassVertexOutput input): SV_Target { ++ BackdropBlur blur = backdrop_blurs[input.blur_id]; ++ float2 position = input.position.xy; ++ float distance = quad_sdf(position, blur.bounds, blur.corner_radii); ++ ++ if (blur.glass.enabled == 0u) { ++ if (distance > 0.) { ++ discard; ++ } ++ return t_backdrop.SampleLevel(s_backdrop, backdrop_blurred_uv(position), 0); ++ } ++ ++ float coverage = saturate(0.5 - distance); ++ float ring = saturate(1.5 - distance) - coverage; ++ if (coverage <= 0. && ring <= 0.) { ++ discard; ++ } ++ GlassStyle glass = blur.glass; ++ float edge = max(-distance, 0.); ++ float2 normal = float2( ++ quad_sdf(position + float2(0.5, 0.), blur.bounds, blur.corner_radii) - ++ quad_sdf(position - float2(0.5, 0.), blur.bounds, blur.corner_radii), ++ quad_sdf(position + float2(0., 0.5), blur.bounds, blur.corner_radii) - ++ quad_sdf(position - float2(0., 0.5), blur.bounds, blur.corner_radii)); ++ normal /= max(length(normal), 1e-6); ++ float vertical = normal.x * normal.x; ++ float hair_a = lerp(glass.hairline[2], glass.hairline[0], vertical); ++ float hair_b = lerp(glass.hairline[3], glass.hairline[1], vertical); ++ float4 under = t_backdrop_sharp.SampleLevel(s_backdrop, position / sharp_texture_size, 0); ++ float3 hair_color = saturate(min(under.rgb, hair_a * under.rgb + hair_b)); ++ under.rgb = lerp(under.rgb, hair_color, ring); ++ if (coverage <= 0.) { ++ return under; ++ } ++ float rim = 1. - pow(saturate(edge / glass.lens_rim), glass.lens_curve); ++ float2 sample_at = position + normal * glass.rim_bend * rim; ++ float4 backdrop = t_backdrop.SampleLevel(s_backdrop, backdrop_blurred_uv(sample_at), 0); ++ float3 color = backdrop.a > 0. ? backdrop.rgb / backdrop.a : float3(0., 0., 0.); ++ float alpha = lerp(backdrop.a, 1., glass.opacity); ++ ++ float features[10] = {color.r, color.g, color.b, color.r * color.r, color.g * color.g, ++ color.b * color.b, color.r * color.g, color.g * color.b, ++ color.r * color.b, 1.}; ++ color = float3(0., 0., 0.); ++ [unroll] ++ for (int term = 0; term < 10; term++) { ++ color += features[term] * float3(glass.poly[term], glass.poly[10 + term], glass.poly[20 + term]); ++ } ++ float facing = dot(normal, float2(glass.light_x, glass.light_y)); ++ float line_term = exp(-edge / glass.line_width) * (glass.line_strength + glass.line_lit * facing); ++ float glow = exp(-edge / glass.glow_width) * glass.glow; ++ color = saturate(color * (1. + line_term) + glow); ++ ++ return lerp(under, float4(color * alpha, alpha), coverage); ++} diff --git a/apps/desktop-gpui/src/glass_probe.rs b/apps/desktop-gpui/src/glass_probe.rs new file mode 100644 index 0000000000..40760fee57 --- /dev/null +++ b/apps/desktop-gpui/src/glass_probe.rs @@ -0,0 +1,256 @@ +use std::{path::PathBuf, sync::Arc, time::Duration}; + +use cap_glass::{Rgba, Shape, metrics, model, scenes}; +use cap_recording::sources::screen_capture::ScreenCaptureTarget; +use gpui::{ + App, AppContext as _, AsyncApp, Context, IntoElement, ParentElement, Render, RenderImage, + Styled, Window, WindowBackgroundAppearance, WindowBounds, WindowHandle, WindowKind, + WindowOptions, div, img, point, px, size, +}; + +use crate::ui::glass::{self, GlassKind}; + +const PRESETS: [(&str, GlassKind, bool); 4] = [ + ("regular-light", GlassKind::Regular, false), + ("regular-dark", GlassKind::Regular, true), + ("clear-light", GlassKind::Clear, false), + ("clear-dark", GlassKind::Clear, true), +]; + +/// `CAP_GLASS_PROBE_OUTPUT=`: draw every scene in `cap_glass::scenes` +/// with glass panes in each preset, capture the window, and score it against +/// the CPU model of the same material. Writes the captures, the model's +/// renders and `report.json`, whose `passed` is the CI verdict. +pub fn run(cx: &mut App) { + let Some(output) = std::env::var_os("CAP_GLASS_PROBE_OUTPUT").map(PathBuf::from) else { + return; + }; + if let Err(error) = std::fs::create_dir_all(&output) { + tracing::error!(%error, "glass probe: cannot create its output directory"); + return; + } + // An X11 window capture reads whatever is on screen over the window. + crate::app_windows::hide_main_window(cx); + let handle = cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(gpui::Bounds { + origin: point(px(120.), px(120.)), + size: size(px(scenes::CANVAS.0), px(scenes::CANVAS.1)), + })), + titlebar: None, + kind: WindowKind::Normal, + focus: false, + show: true, + is_resizable: false, + window_background: WindowBackgroundAppearance::Opaque, + ..Default::default() + }, + |_, cx| cx.new(|_| ProbeView::default()), + ); + let handle = match handle { + Ok(handle) => handle, + Err(error) => { + tracing::error!("glass probe: window failed to open: {error:#}"); + return; + } + }; + cx.spawn(async move |cx| { + let report = probe(handle, &output, cx).await; + if let Err(error) = std::fs::write(output.join("report.json"), report.to_string()) { + tracing::error!(%error, "glass probe: could not write its report"); + } + cx.update(|cx| cx.quit()); + }) + .detach(); +} + +#[derive(Default)] +struct ProbeView { + scene: Option>, + preset: Option<(GlassKind, bool)>, +} + +impl Render for ProbeView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let mut root = div() + .size_full() + .relative() + .bg(gpui::white()) + .children(self.scene.clone().map(|scene| img(scene).absolute().size_full())); + if let Some((kind, dark)) = self.preset { + for shape in scenes::SHAPES { + root = root.child( + glass::pane(kind, dark, shape.radius) + .absolute() + .left(px(shape.x)) + .top(px(shape.y)) + .w(px(shape.width)) + .h(px(shape.height)), + ); + } + } + root + } +} + +async fn sleep(cx: &mut AsyncApp, millis: u64) { + cx.background_executor() + .timer(Duration::from_millis(millis)) + .await; +} + +async fn probe( + handle: WindowHandle, + output: &std::path::Path, + cx: &mut AsyncApp, +) -> serde_json::Value { + sleep(cx, 1500).await; + let scale = handle + .update(cx, |_, window, _| window.scale_factor()) + .unwrap_or(1.); + let Some(target) = handle + .update(cx, |_, window, _| window_target(window)) + .ok() + .flatten() + else { + return serde_json::json!({ "passed": false, "error": "no window id to capture" }); + }; + + let mut cases = Vec::new(); + for scene_name in scenes::NAMES { + let Some(scene) = scenes::render(scene_name, scale) else { + continue; + }; + let image = to_rgba_image(&scene); + let render_image = crate::library::rgba_to_render_image(image); + for (preset_name, kind, dark) in PRESETS { + handle + .update(cx, |view, window, cx| { + view.scene = Some(render_image.clone()); + view.preset = Some((kind, dark)); + cx.notify(); + window.refresh(); + }) + .ok(); + sleep(cx, 400).await; + let name = format!("{scene_name}-{preset_name}"); + let captured = { + let target = target.clone(); + gpui_tokio::Tokio::spawn(cx, async move { + cap_recording::screenshot::capture_screenshot(target).await + }) + .await + }; + let captured = match captured { + Ok(Ok(image)) => image.to_rgba8(), + Ok(Err(error)) => { + cases.push(serde_json::json!({ "name": name, "error": format!("{error:#}") })); + continue; + } + Err(error) => { + cases.push(serde_json::json!({ "name": name, "error": error.to_string() })); + continue; + } + }; + let captured = crop_to(&captured, scene.width as u32, scene.height as u32); + let captured = Rgba { + width: captured.width() as usize, + height: captured.height() as usize, + pixels: captured.as_raw().iter().map(|value| *value as f32).collect(), + }; + let expected = expected_render(&scene, glass::material(kind, dark), scale); + let mask = glass_mask(scale, captured.width, captured.height); + let ssim = metrics::ssim(&captured, &expected, &mask); + let (delta_e, delta_e95) = metrics::delta_e(&captured, &expected, &mask); + let _ = captured.save(&output.join(format!("{name}.png"))); + let _ = expected.save(&output.join(format!("{name}-model.png"))); + cases.push(serde_json::json!({ + "name": name, + "ssim": ssim, + "deltaE": delta_e, + "deltaE95": delta_e95, + "passed": ssim >= 0.97 && delta_e <= 2.0, + })); + } + } + let passed = !cases.is_empty() + && cases + .iter() + .all(|case| case["passed"].as_bool() == Some(true)); + serde_json::json!({ "passed": passed, "scale": scale, "cases": cases }) +} + +fn to_rgba_image(image: &Rgba) -> image::RgbaImage { + let bytes = image + .pixels + .iter() + .map(|value| value.round().clamp(0., 255.) as u8) + .collect(); + image::RgbaImage::from_raw(image.width as u32, image.height as u32, bytes) + .expect("scene buffer matches its size") +} + +/// A window capture can carry a frame or shadow around the content; the +/// content is the centred `width` x `height`. +fn crop_to(image: &image::RgbaImage, width: u32, height: u32) -> image::RgbaImage { + if image.width() == width && image.height() == height { + return image.clone(); + } + let x = image.width().saturating_sub(width) / 2; + let y = image.height().saturating_sub(height) / 2; + image::imageops::crop_imm(image, x, y, width.min(image.width()), height.min(image.height())) + .to_image() +} + +fn scaled(shape: &Shape, scale: f32) -> Shape { + Shape { + x: shape.x * scale, + y: shape.y * scale, + width: shape.width * scale, + height: shape.height * scale, + radius: shape.radius * scale, + } +} + +fn expected_render(scene: &Rgba, material: &model::GlassMaterial, scale: f32) -> Rgba { + let mut out = scene.clone(); + for shape in scenes::SHAPES { + let style = material.resolve(shape.width, shape.height).scaled(scale); + let device = scaled(&shape, scale); + model::composite(&style, &device, &scene.blurred(style.blur), &mut out); + } + out +} + +fn glass_mask(scale: f32, width: usize, height: usize) -> Vec { + (0..width * height) + .map(|index| { + let (x, y) = ((index % width) as f32 + 0.5, (index / width) as f32 + 0.5); + scenes::SHAPES + .iter() + .any(|shape| scaled(shape, scale).sdf(x, y) < 1.5) + }) + .collect() +} + +#[cfg(not(target_os = "linux"))] +fn window_target(window: &Window) -> Option { + let number = crate::platform::window_number(window)?; + Some(ScreenCaptureTarget::Window { + id: number.to_string().parse().ok()?, + }) +} + +#[cfg(target_os = "linux")] +fn window_target(window: &Window) -> Option { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + let number = match HasWindowHandle::window_handle(window).ok()?.as_raw() { + RawWindowHandle::Xlib(handle) => handle.window as u64, + RawWindowHandle::Xcb(handle) => u64::from(handle.window.get()), + _ => return None, + }; + Some(ScreenCaptureTarget::Window { + id: number.to_string().parse().ok()?, + }) +} diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index db32a0fa61..bed80670ab 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -69,6 +69,8 @@ mod permissions_ui; #[cfg(test)] mod picker_benchmark; #[cfg(debug_assertions)] +mod glass_probe; +#[cfg(debug_assertions)] mod picker_ui_benchmark; mod platform; mod presets; @@ -806,6 +808,8 @@ fn main() { // synthetic clicks are dropped. #[cfg(debug_assertions)] picker_ui_benchmark::run(window_handle, cx); + #[cfg(debug_assertions)] + glass_probe::run(cx); if let Ok(auto) = std::env::var("CAP_GPUI_AUTO_RECORD") && let Some((mode, secs)) = parse_auto_record(&auto) { diff --git a/apps/desktop-gpui/src/ui/glass.rs b/apps/desktop-gpui/src/ui/glass.rs new file mode 100644 index 0000000000..4b37e28e13 --- /dev/null +++ b/apps/desktop-gpui/src/ui/glass.rs @@ -0,0 +1,107 @@ +//! Cap Glass: panes that blur, lens and tint whatever is painted beneath +//! them, with materials fitted against Apple's own glass (`glass/`, and the +//! `glass-reference` capture that feeds it). +//! +//! A pane paints itself first and its children over it, so content sits on +//! the glass, never under it. The glass samples what is already in the +//! window: on a transparent window, paint the backdrop it should show (the +//! picker paints the captured desktop) before the pane. + +use std::sync::OnceLock; + +use cap_glass::model::{GlassMaterial, PaneStyle}; +use gpui::{Bounds, Corners, Div, ParentElement, Pixels, Styled, Window, canvas, div, px}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GlassKind { + /// Frosted: reads as a surface, for panels and toolbars. + Regular, + /// Barely frosted: for controls floating over busy content. + Clear, +} + +struct Presets { + regular_light: GlassMaterial, + regular_dark: GlassMaterial, + clear_light: GlassMaterial, + clear_dark: GlassMaterial, +} + +fn presets() -> &'static Presets { + static PRESETS: OnceLock = OnceLock::new(); + PRESETS.get_or_init(|| { + let all: serde_json::Value = serde_json::from_str(cap_glass::PRESETS_JSON) + .expect("glass presets are valid JSON"); + let get = |name: &str| -> GlassMaterial { + serde_json::from_value(all[name].clone()).expect("glass preset is complete") + }; + Presets { + regular_light: get("regular-light"), + regular_dark: get("regular-dark"), + clear_light: get("clear-light"), + clear_dark: get("clear-dark"), + } + }) +} + +pub fn material(kind: GlassKind, dark: bool) -> &'static GlassMaterial { + let presets = presets(); + match (kind, dark) { + (GlassKind::Regular, false) => &presets.regular_light, + (GlassKind::Regular, true) => &presets.regular_dark, + (GlassKind::Clear, false) => &presets.clear_light, + (GlassKind::Clear, true) => &presets.clear_dark, + } +} + +pub fn gpui_style(style: &PaneStyle) -> gpui::GlassStyle { + gpui::GlassStyle { + enabled: 1, + lens_rim: style.lens_rim, + lens_curve: style.lens_curve, + rim_bend: style.rim_bend, + poly: style.poly, + opacity: style.opacity, + line: style.line, + line_lit: style.line_lit, + line_width: style.line_width, + light_x: style.light[0], + light_y: style.light[1], + glow: style.glow, + glow_width: style.glow_width, + hairline: style.hairline, + } +} + +/// Paint one glass pane over whatever the window already holds at `bounds`. +pub fn paint(window: &mut Window, bounds: Bounds, radius: f32, material: &GlassMaterial) { + let (width, height) = (f32::from(bounds.size.width), f32::from(bounds.size.height)); + if width <= 0. || height <= 0. { + return; + } + let style = material.resolve(width, height); + let radius = radius.min(width / 2.).min(height / 2.); + window.paint_glass( + bounds, + Corners::all(px(radius)), + px(style.blur), + gpui_style(&style), + ); +} + +/// A rounded glass pane: a `Div` whose first child is the glass layer, so +/// everything added after it paints on the glass. `radius` is clamped to the +/// pane like CSS clamps `border-radius`. +pub fn pane(kind: GlassKind, dark: bool, radius: f32) -> Div { + let material = *material(kind, dark); + div().relative().rounded(px(radius)).child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| paint(window, bounds, radius, &material), + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) +} diff --git a/apps/desktop-gpui/src/ui/mod.rs b/apps/desktop-gpui/src/ui/mod.rs index 4d3b32d946..8473ee64ae 100644 --- a/apps/desktop-gpui/src/ui/mod.rs +++ b/apps/desktop-gpui/src/ui/mod.rs @@ -45,6 +45,7 @@ mod collapsible; mod color_picker; mod editor_button; mod field; +pub mod glass; mod kbd; pub mod keyboard_nav; mod menu; diff --git a/scripts/glass-probe/run.sh b/scripts/glass-probe/run.sh new file mode 100755 index 0000000000..82e55fff8f --- /dev/null +++ b/scripts/glass-probe/run.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Runs cap-gpui's glass probe (src/glass_probe.rs) in a throwaway profile: +# every scene in cap_glass::scenes with glass panes in each preset, captured +# from the real window and scored against the CPU model. Fails unless every +# case passes. +# +# scripts/glass-probe/run.sh +set -euo pipefail + +APP=$1 +OUT=$2 +mkdir -p "$OUT/profile/recordings" "$OUT/profile/logs" "$OUT/captures" +cat > "$OUT/profile/store" <<'JSON' +{"general_settings":{"hasCompletedOnboarding":true,"hasCompletedStartup":true,"enableNotifications":false,"enableTelemetry":false,"recordingCountdown":0},"recording_start_safety":{"confirmBeforeRecordingWithoutMicrophone":false}} +JSON +report="$OUT/captures/report.json" +rm -f "$report" + +env CAP_GPUI_APP_DATA_DIR="$OUT/profile" \ + CAP_GPUI_RECORDINGS_DIR="$OUT/profile/recordings" \ + CAP_GPUI_LOG_DIR="$OUT/profile/logs" \ + CAP_GPUI_AUTO_NO_MIC=1 \ + CAP_GLASS_PROBE_OUTPUT="$OUT/captures" \ + RUST_LOG="${RUST_LOG:-cap_gpui=info}" \ + "$APP" > "$OUT/app.log" 2>&1 & +pid=$! + +for _ in $(seq 1 600); do + [ -s "$report" ] && break + kill -0 "$pid" 2>/dev/null || break + sleep 0.5 +done +kill "$pid" 2>/dev/null || true + +if [ ! -s "$report" ]; then + echo "glass probe wrote no report" >&2 + tail -n 80 "$OUT/app.log" >&2 || true + exit 1 +fi +python3 - "$report" <<'PY' +import json, sys +report = json.load(open(sys.argv[1])) +print(f"scale {report.get('scale')} passed {report['passed']}") +for case in report.get("cases", []): + if "error" in case: + print(f" {case['name']:28s} error: {case['error']}") + else: + mark = "ok " if case["passed"] else "BAD" + print(f" {mark} {case['name']:28s} ssim {case['ssim']:.4f} dE {case['deltaE']:.2f} dE95 {case['deltaE95']:.2f}") +sys.exit(0 if report["passed"] else 1) +PY diff --git a/scripts/glass-reference/reference.swift b/scripts/glass-reference/reference.swift index 05e1a145ad..eb492fceb7 100644 --- a/scripts/glass-reference/reference.swift +++ b/scripts/glass-reference/reference.swift @@ -8,6 +8,7 @@ // Needs Screen Recording for the calling process (screencapture -l). import AppKit import Metal +import SwiftUI let canvas = NSSize(width: 960, height: 600) @@ -106,11 +107,48 @@ func drawScene(_ name: String, in ctx: CGContext, size: NSSize) { let gradient = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), colors: [color, clear] as CFArray, locations: [0, 1])! ctx.drawRadialGradient(gradient, startCenter: center, startRadius: 0, endCenter: center, endRadius: radius, options: []) } + case "uv": + // R and G encode the sampled point, so a capture reads back where + // each glass pixel's light came from. + let width = Int(size.width), height = Int(size.height) + for row in 0.. 1 ? CommandLine.arguments[1] : "glass-reference") try FileManager.default.createDirectory(at: out.appendingPathComponent("scenes"), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: out.appendingPathComponent("apple"), withIntermediateDirectories: true) +try FileManager.default.createDirectory(at: out.appendingPathComponent("bare"), withIntermediateDirectories: true) +try FileManager.default.createDirectory(at: out.appendingPathComponent("controls"), withIntermediateDirectories: true) let app = NSApplication.shared app.setActivationPolicy(.regular) @@ -149,6 +235,7 @@ app.finishLaunching() let window = NSWindow(contentRect: NSRect(origin: NSPoint(x: 80, y: 80), size: canvas), styleMask: [.borderless], backing: .buffered, defer: false) window.isOpaque = true +window.colorSpace = .sRGB window.hasShadow = false window.level = .floating let root = FlippedView(frame: NSRect(origin: .zero, size: canvas)) @@ -167,6 +254,9 @@ for scene in scenes { let image = renderScene(scene, scale: scale) writePNG(image, to: out.appendingPathComponent("scenes/\(scene).png")) backdrop.image = NSImage(cgImage: image, size: canvas) + window.appearance = NSAppearance(named: .aqua) + pump(0.4) + capture(window, to: out.appendingPathComponent("bare/\(scene).png")) for (styleName, style) in styles { for (appearanceName, appearance) in appearances { for (tintName, tint) in tints { @@ -185,12 +275,7 @@ for scene in scenes { } pump(0.6) let name = "\(scene)-\(styleName)-\(appearanceName)-\(tintName)" - let path = out.appendingPathComponent("apple/\(name).png").path - let capture = Process() - capture.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") - capture.arguments = ["-x", "-o", "-l\(window.windowNumber)", path] - try capture.run() - capture.waitUntilExit() + capture(window, to: out.appendingPathComponent("apple/\(name).png")) cases.append([ "name": name, "scene": scene, "style": styleName, "appearance": appearanceName, "tint": tintName, @@ -201,7 +286,24 @@ for scene in scenes { } } +var controlCases: [String] = [] +for scene in ["blobs", "white", "darktext"] { + backdrop.image = NSImage(cgImage: renderScene(scene, scale: scale), size: canvas) + for (appearanceName, appearance) in appearances { + window.appearance = NSAppearance(named: appearance) + let host = NSHostingView(rootView: ControlsPanel()) + host.frame = root.bounds + root.addSubview(host) + pump(0.8) + let name = "\(scene)-\(appearanceName)" + capture(window, to: out.appendingPathComponent("controls/\(name).png")) + controlCases.append(name) + host.removeFromSuperview() + } +} + let manifest: [String: Any] = [ + "controls": controlCases, "canvas": [canvas.width, canvas.height], "scale": scale, "os": ProcessInfo.processInfo.operatingSystemVersionString, diff --git a/scripts/prepare-gpui-dependency.sh b/scripts/prepare-gpui-dependency.sh index bd26ac9bf2..cce93449c1 100755 --- a/scripts/prepare-gpui-dependency.sh +++ b/scripts/prepare-gpui-dependency.sh @@ -12,6 +12,7 @@ patch_paths=( "apps/desktop-gpui/patches/zed-wayland-visibility.patch" "apps/desktop-gpui/patches/zed-wayland-activation.patch" "apps/desktop-gpui/patches/zed-closed-window-events.patch" + "apps/desktop-gpui/patches/zed-glass.patch" ) patch_files=("${patch_paths[@]/#/$repo_root/}") base_revision="5d1f83d9f27a19bec1fb241dc33b42238af9cf8d" diff --git a/scripts/prepare-gpui-dependency.test.mjs b/scripts/prepare-gpui-dependency.test.mjs index 45a5b1eee0..8b92595aaa 100644 --- a/scripts/prepare-gpui-dependency.test.mjs +++ b/scripts/prepare-gpui-dependency.test.mjs @@ -152,6 +152,8 @@ test("dependency verification handles overlapping patches without changing the c patch("zed-wayland-activation.patch"); writeFileSync(path.join(upstream, "closed.rs"), "quiet closed windows\n"); patch("zed-closed-window-events.patch"); + writeFileSync(path.join(upstream, "glass.rs"), "glass panes\n"); + patch("zed-glass.patch"); const script = path.join(cap, "scripts/prepare-gpui-dependency.sh"); writeFileSync( script, From feb6bff53aaccce639b019a2da341042fef96ab5 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:45:48 +0000 Subject: [PATCH 04/14] fix(desktop-gpui): draw glass panes painted last, capture the probe window by area on X11 --- apps/desktop-gpui/Cargo.toml | 4 + apps/desktop-gpui/glass/presets.json | 294 ++++++---------------- apps/desktop-gpui/patches/zed-glass.patch | 18 +- apps/desktop-gpui/src/glass_probe.rs | 26 +- scripts/glass-probe/run.sh | 2 +- 5 files changed, 119 insertions(+), 225 deletions(-) diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index 4bcf0a2bca..17820a1fdf 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -333,6 +333,10 @@ debug = "line-tables-only" [profile.dev.package.cap-gpui-kernels] opt-level = 3 +# The glass probe renders the CPU model of every scene it captures. +[profile.dev.package.cap-glass] +opt-level = 3 + # Incremental halves a one-line rebuild for about 2 GB of cache, and line tables # cut its codegen and link time; breakpoints and backtraces keep their lines. [profile.dev.package.cap-desktop-gpui] diff --git a/apps/desktop-gpui/glass/presets.json b/apps/desktop-gpui/glass/presets.json index 9953d4dfe3..71c9adcb29 100644 --- a/apps/desktop-gpui/glass/presets.json +++ b/apps/desktop-gpui/glass/presets.json @@ -1,214 +1,82 @@ { - "regular-light": { - "blur_k": 0.19, - "blur_max": 21, - "rim_k": 0.24, - "rim_max": 24, - "bend": -2, - "lens_curve": 0.4, - "poly": [ - 1, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0 - ], - "opacity": 0.6, - "line": 0, - "line_lit": 0, - "line_width": 1, - "light_angle": -2.3, - "glow": 0, - "glow_width": 4, - "hairline": [ - 0.86, - -0.12, - 0.92, - -0.08 - ] - }, - "regular-dark": { - "blur_k": 0.19, - "blur_max": 21, - "rim_k": 0.24, - "rim_max": 24, - "bend": -2, - "lens_curve": 0.4, - "poly": [ - 1, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0 - ], - "opacity": 0.6, - "line": 0, - "line_lit": 0, - "line_width": 1, - "light_angle": -2.3, - "glow": 0, - "glow_width": 4, - "hairline": [ - 0.86, - -0.12, - 0.92, - -0.08 - ] - }, - "clear-light": { - "blur_k": 0.19, - "blur_max": 21, - "rim_k": 0.24, - "rim_max": 24, - "bend": -2, - "lens_curve": 0.4, - "poly": [ - 1, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0 - ], - "opacity": 0.6, - "line": 0, - "line_lit": 0, - "line_width": 1, - "light_angle": -2.3, - "glow": 0, - "glow_width": 4, - "hairline": [ - 0.86, - -0.12, - 0.92, - -0.08 - ] - }, - "clear-dark": { - "blur_k": 0.19, - "blur_max": 21, - "rim_k": 0.24, - "rim_max": 24, - "bend": -2, - "lens_curve": 0.4, - "poly": [ - 1, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0 - ], - "opacity": 0.6, - "line": 0, - "line_lit": 0, - "line_width": 1, - "light_angle": -2.3, - "glow": 0, - "glow_width": 4, - "hairline": [ - 0.86, - -0.12, - 0.92, - -0.08 - ] - } -} \ No newline at end of file + "regular-light": { + "blur_k": 0.19, + "blur_max": 21, + "rim_k": 0.24, + "rim_max": 24, + "bend": -2, + "lens_curve": 0.4, + "poly": [ + 1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + ], + "opacity": 0.6, + "line": 0, + "line_lit": 0, + "line_width": 1, + "light_angle": -2.3, + "glow": 0, + "glow_width": 4, + "hairline": [0.86, -0.12, 0.92, -0.08] + }, + "regular-dark": { + "blur_k": 0.19, + "blur_max": 21, + "rim_k": 0.24, + "rim_max": 24, + "bend": -2, + "lens_curve": 0.4, + "poly": [ + 1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + ], + "opacity": 0.6, + "line": 0, + "line_lit": 0, + "line_width": 1, + "light_angle": -2.3, + "glow": 0, + "glow_width": 4, + "hairline": [0.86, -0.12, 0.92, -0.08] + }, + "clear-light": { + "blur_k": 0.19, + "blur_max": 21, + "rim_k": 0.24, + "rim_max": 24, + "bend": -2, + "lens_curve": 0.4, + "poly": [ + 1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + ], + "opacity": 0.6, + "line": 0, + "line_lit": 0, + "line_width": 1, + "light_angle": -2.3, + "glow": 0, + "glow_width": 4, + "hairline": [0.86, -0.12, 0.92, -0.08] + }, + "clear-dark": { + "blur_k": 0.19, + "blur_max": 21, + "rim_k": 0.24, + "rim_max": 24, + "bend": -2, + "lens_curve": 0.4, + "poly": [ + 1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + ], + "opacity": 0.6, + "line": 0, + "line_lit": 0, + "line_width": 1, + "light_angle": -2.3, + "glow": 0, + "glow_width": 4, + "hairline": [0.86, -0.12, 0.92, -0.08] + } +} diff --git a/apps/desktop-gpui/patches/zed-glass.patch b/apps/desktop-gpui/patches/zed-glass.patch index 12d721a633..a42b32020a 100644 --- a/apps/desktop-gpui/patches/zed-glass.patch +++ b/apps/desktop-gpui/patches/zed-glass.patch @@ -77,7 +77,7 @@ index 6cc3562..6ea0576 100644 #[derive(Debug, Copy, Clone)] diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs -index 4299e15..91bee9f 100644 +index 4299e15..82cdc92 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -13,7 +13,7 @@ use crate::{ @@ -89,7 +89,7 @@ index 4299e15..91bee9f 100644 StrikethroughStyle, Style, SubpixelSprite, SubscriberSet, Subscription, SystemWindowTab, SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextRenderingMode, TextStyle, TextStyleRefinement, ThermalState, TransformationMatrix, Underline, UnderlineStyle, -@@ -4186,6 +4186,19 @@ impl Window { +@@ -4186,10 +4186,29 @@ impl Window { bounds: Bounds, corner_radii: Corners, blur_radius: Pixels, @@ -109,7 +109,17 @@ index 4299e15..91bee9f 100644 ) { self.invalidator.debug_assert_paint(); let scale_factor = self.scale_factor(); -@@ -4210,6 +4223,13 @@ impl Window { + let content_mask = self.content_mask().scale(scale_factor); ++ // One layer gives the splitter and the blur the same order, so the ++ // renderer paints the blur before the splitter's batch even when ++ // 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); + // Invisible splitter primitive: forces a batch boundary at this order + // so the renderer can break its render pass exactly here. + self.next_frame.scene.insert_primitive(Shadow { +@@ -4210,7 +4229,15 @@ impl Window { bounds: bounds.scale(scale_factor), content_mask, corner_radii: corner_radii.scale(scale_factor), @@ -121,8 +131,10 @@ index 4299e15..91bee9f 100644 + ..glass + }, }); ++ self.next_frame.scene.pop_layer(); } + /// Paint one or more quads into the scene for the next frame at the current stacking context. diff --git a/crates/gpui_macos/build.rs b/crates/gpui_macos/build.rs index 70e9a0b..79415df 100644 --- a/crates/gpui_macos/build.rs diff --git a/apps/desktop-gpui/src/glass_probe.rs b/apps/desktop-gpui/src/glass_probe.rs index 40760fee57..2abead04dd 100644 --- a/apps/desktop-gpui/src/glass_probe.rs +++ b/apps/desktop-gpui/src/glass_probe.rs @@ -241,16 +241,26 @@ fn window_target(window: &Window) -> Option { }) } +/// Cap's own windows are not in its X11 window list, so the probe captures +/// the window's area of the display; it hides every other Cap window first. #[cfg(target_os = "linux")] fn window_target(window: &Window) -> Option { - use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use scap_targets::bounds::{LogicalBounds, LogicalPosition, LogicalSize}; - let number = match HasWindowHandle::window_handle(window).ok()?.as_raw() { - RawWindowHandle::Xlib(handle) => handle.window as u64, - RawWindowHandle::Xcb(handle) => u64::from(handle.window.get()), - _ => return None, - }; - Some(ScreenCaptureTarget::Window { - id: number.to_string().parse().ok()?, + let display = scap_targets::Display::primary(); + let bounds = window.bounds(); + let origin = display.raw_handle().logical_bounds()?.position(); + Some(ScreenCaptureTarget::Area { + screen: display.id(), + bounds: LogicalBounds::new( + 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)), + ), + ), }) } diff --git a/scripts/glass-probe/run.sh b/scripts/glass-probe/run.sh index 82e55fff8f..dabac71e91 100755 --- a/scripts/glass-probe/run.sh +++ b/scripts/glass-probe/run.sh @@ -25,7 +25,7 @@ env CAP_GPUI_APP_DATA_DIR="$OUT/profile" \ "$APP" > "$OUT/app.log" 2>&1 & pid=$! -for _ in $(seq 1 600); do +for _ in $(seq 1 1200); do [ -s "$report" ] && break kill -0 "$pid" 2>/dev/null || break sleep 0.5 From 16afb47d072195088c7a02d3fcbd4098a0df78c7 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:58:18 +0000 Subject: [PATCH 05/14] feat(desktop-gpui): fit glass to Apple with a cubic colour transform and its edge hairline --- apps/desktop-gpui/glass/presets.json | 184 ++++++++++++++-------- apps/desktop-gpui/glass/src/model.rs | 24 ++- apps/desktop-gpui/glass/tests/presets.rs | 114 ++++++++++++++ apps/desktop-gpui/patches/zed-glass.patch | 119 +++++++++----- apps/desktop-gpui/src/ui/glass.rs | 1 + 5 files changed, 334 insertions(+), 108 deletions(-) create mode 100644 apps/desktop-gpui/glass/tests/presets.rs diff --git a/apps/desktop-gpui/glass/presets.json b/apps/desktop-gpui/glass/presets.json index 71c9adcb29..40789a0ce0 100644 --- a/apps/desktop-gpui/glass/presets.json +++ b/apps/desktop-gpui/glass/presets.json @@ -1,82 +1,130 @@ { - "regular-light": { - "blur_k": 0.19, - "blur_max": 21, - "rim_k": 0.24, - "rim_max": 24, - "bend": -2, - "lens_curve": 0.4, + "clear-dark": { + "bend": -2.366746425628662, + "blur_k": 0.17999997735023499, + "blur_max": 20.0, + "glow": -0.00915201473981142, + "glow_width": 0.5, + "hairline": [ + 0.5046544075012207, -0.002066596643999219, 1.037916898727417, + -0.004036169033497572 + ], + "lens_curve": 0.3376707434654236, + "light_angle": 10.696141242980957, + "line": 0.009427699260413647, + "line_lit": -0.00020453354227356613, + "line_width": 0.9371033906936646, + "opacity": 0.6000000238418579, "poly": [ - 1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + 0.9841277003288269, -0.28581157326698303, 0.06853790581226349, + -0.10551261156797409, -0.019449958577752113, -0.06275196373462677, + 0.12861721217632294, 0.0336744524538517, -0.02680288627743721, 0.0, 0.0, + 0.0, 0.09590495377779007, -0.025067875161767006, 0.7507156133651733, + -0.008180275559425354, -0.03581831976771355, -0.010394089855253696, + -0.01718660071492195, 0.04081161320209503, 0.02539644204080105, + -0.005516655743122101, 0.0, 0.0, 0.0, 0.09734207391738892, + -0.07906761765480042, -0.22363290190696716, 1.016892433166504, + -0.00731066195294261, -0.06583347171545029, -0.11654830724000931, + 0.04832758754491806, 0.12703412771224976, 0.01597178541123867, 0.0, 0.0, + 0.0, 0.09707652777433395 ], - "opacity": 0.6, - "line": 0, - "line_lit": 0, - "line_width": 1, - "light_angle": -2.3, - "glow": 0, - "glow_width": 4, - "hairline": [0.86, -0.12, 0.92, -0.08] + "rim_k": 0.3009837865829468, + "rim_max": 26.59674072265625 }, - "regular-dark": { - "blur_k": 0.19, - "blur_max": 21, - "rim_k": 0.24, - "rim_max": 24, - "bend": -2, - "lens_curve": 0.4, + "clear-light": { + "bend": -2.364307403564453, + "blur_k": 0.17999984323978424, + "blur_max": 20.0, + "glow": -0.040041275322437286, + "glow_width": 0.5, + "hairline": [ + 0.6467424035072327, -0.017890620976686478, 1.0234696865081787, + 0.008217902854084969 + ], + "lens_curve": 0.33955836296081543, + "light_angle": 8.900734901428223, + "line": 0.013842251151800156, + "line_lit": 0.00113571563269943, + "line_width": 1.1170105934143066, + "opacity": 0.6000000238418579, "poly": [ - 1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + 0.8068393468856812, -0.19063682854175568, 0.12205540388822556, + 0.03810872882604599, 0.05950373411178589, -0.08793330937623978, + -0.04968729987740517, 0.011197183281183243, -0.03260619565844536, 0.0, + 0.0, 0.0, 0.27716732025146484, -0.009006494656205177, 0.6790370345115662, + 0.013659832999110222, -0.02463696524500847, 0.033544085919857025, + -0.016603535041213036, 0.00851691234856844, 0.0007132220198400319, + -0.0068999179638922215, 0.0, 0.0, 0.0, 0.278425395488739, + 0.05969604477286339, -0.09342411160469055, 0.722533643245697, + -0.04663229361176491, -0.05806506425142288, 0.033827684819698334, + -0.052240658551454544, 0.10449537634849548, 0.007342542987316847, 0.0, + 0.0, 0.0, 0.2787947356700897 ], - "opacity": 0.6, - "line": 0, - "line_lit": 0, - "line_width": 1, - "light_angle": -2.3, - "glow": 0, - "glow_width": 4, - "hairline": [0.86, -0.12, 0.92, -0.08] + "rim_k": 0.2935454249382019, + "rim_max": 27.180828094482422 }, - "clear-light": { - "blur_k": 0.19, - "blur_max": 21, - "rim_k": 0.24, - "rim_max": 24, - "bend": -2, - "lens_curve": 0.4, + "regular-dark": { + "bend": -1.0383737087249756, + "blur_k": 0.1399989128112793, + "blur_max": 12.0, + "glow": 0.023018531501293182, + "glow_width": 47.50996780395508, + "hairline": [ + 0.265051931142807, 0.09913472086191177, 0.5498596429824829, + 0.042205967009067535 + ], + "lens_curve": 0.6115574836730957, + "light_angle": 3.5436832904815674, + "line": 0.013988529331982136, + "line_lit": -5.383496682043187e-5, + "line_width": 0.516719400882721, + "opacity": 0.6000000238418579, "poly": [ - 1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + 1.1429176330566406, -0.10875950753688812, 0.033222246915102005, + -0.08781613409519196, -0.13997182250022888, -0.032387249171733856, + -0.3350599706172943, 0.02284449338912964, -0.0969463512301445, 0.0, 0.0, + 0.0, 0.06109626591205597, -0.14081989228725433, 1.3113867044448853, + -0.13325585424900055, 0.012389129027724266, -0.6814408302307129, + 0.0769672617316246, 0.005131110083311796, -0.11113206297159195, + 0.055657077580690384, 0.0, 0.0, 0.0, 0.06288916617631912, + -0.14669793844223022, -0.0684649795293808, 1.239702582359314, + 0.011958428658545017, -0.1758630871772766, -0.09301606565713882, + 0.12923042476177216, -0.4333122670650482, -0.0655086413025856, 0.0, 0.0, + 0.0, 0.06273098289966583 ], - "opacity": 0.6, - "line": 0, - "line_lit": 0, - "line_width": 1, - "light_angle": -2.3, - "glow": 0, - "glow_width": 4, - "hairline": [0.86, -0.12, 0.92, -0.08] + "rim_k": 0.0955091044306755, + "rim_max": 43.95122528076172 }, - "clear-dark": { - "blur_k": 0.19, - "blur_max": 21, - "rim_k": 0.24, - "rim_max": 24, - "bend": -2, - "lens_curve": 0.4, + "regular-light": { + "bend": -1.6921217441558838, + "blur_k": 0.14000000059604645, + "blur_max": 12.0, + "glow": -0.06775760650634766, + "glow_width": 0.5, + "hairline": [ + 0.6670563817024231, 0.017260314896702766, 0.7883668541908264, + 0.007549159694463015 + ], + "lens_curve": 0.576151967048645, + "light_angle": 2.86551833152771, + "line": 0.012727930210530758, + "line_lit": 0.0012256903573870659, + "line_width": 1.5420403480529785, + "opacity": 0.6000000238418579, "poly": [ - 1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + 0.7772780656814575, -0.45734652876853943, 0.2029036283493042, + -0.1445373296737671, 0.028123168274760246, -0.15287674963474274, + 0.20918069779872894, 0.03389207646250725, -0.0789894387125969, 0.0, 0.0, + 0.0, 0.5196360945701599, -0.05892333388328552, 0.5539849996566772, + -0.04516340047121048, -0.038535263389348984, -0.03162488341331482, + 0.007563223596662283, 0.03239148110151291, -0.00026807302492670715, + 0.0019051238195970654, 0.0, 0.0, 0.0, 0.5209547877311707, + 0.16917257010936737, -0.3096109628677368, 0.619947612285614, + -0.12055128067731857, -0.14295552670955658, -0.1775696575641632, + -0.13636651635169983, 0.48980438709259033, 0.027854571118950844, 0.0, 0.0, + 0.0, 0.5214924812316895 ], - "opacity": 0.6, - "line": 0, - "line_lit": 0, - "line_width": 1, - "light_angle": -2.3, - "glow": 0, - "glow_width": 4, - "hairline": [0.86, -0.12, 0.92, -0.08] + "rim_k": 0.21131862699985504, + "rim_max": 16.063199996948242 } } diff --git a/apps/desktop-gpui/glass/src/model.rs b/apps/desktop-gpui/glass/src/model.rs index 9f1f7fabce..32d9ec1384 100644 --- a/apps/desktop-gpui/glass/src/model.rs +++ b/apps/desktop-gpui/glass/src/model.rs @@ -1,12 +1,28 @@ use crate::{Rgba, Shape}; /// Terms of the colour transform, per output channel: -/// `r, g, b, r², g², b², rg, gb, rb, 1` of the blurred backdrop (0-1 sRGB). -pub const POLY_TERMS: usize = 10; +/// `r, g, b, r², g², b², rg, gb, rb, r³, g³, b³, 1` of the blurred backdrop +/// (0-1 sRGB). The cubes let it follow Apple's tone curve along the grey +/// axis, which a quadratic misses by several levels at mid grey. +pub const POLY_TERMS: usize = 13; pub fn poly_features(c: [f32; 3]) -> [f32; POLY_TERMS] { let [r, g, b] = c; - [r, g, b, r * r, g * g, b * b, r * g, g * b, r * b, 1.] + [ + r, + g, + b, + r * r, + g * g, + b * b, + r * g, + g * b, + r * b, + r * r * r, + g * g * g, + b * b * b, + 1., + ] } /// A glass material, fitted against Apple's glass. Blur and lensing scale @@ -62,7 +78,7 @@ mod serde_poly { let values = Vec::::deserialize(d)?; values .try_into() - .map_err(|_| serde::de::Error::custom("poly needs 30 coefficients")) + .map_err(|_| serde::de::Error::custom("poly needs 39 coefficients")) } } diff --git a/apps/desktop-gpui/glass/tests/presets.rs b/apps/desktop-gpui/glass/tests/presets.rs new file mode 100644 index 0000000000..ac2e1d93ed --- /dev/null +++ b/apps/desktop-gpui/glass/tests/presets.rs @@ -0,0 +1,114 @@ +use cap_glass::{ + PRESETS_JSON, Rgba, Shape, + model::{self, GlassMaterial}, + scenes, +}; + +fn presets() -> serde_json::Value { + serde_json::from_str(PRESETS_JSON).unwrap() +} + +fn material(name: &str) -> GlassMaterial { + serde_json::from_value(presets()[name].clone()).unwrap() +} + +fn centre_over(material: &GlassMaterial, gray: f32) -> f32 { + let backdrop = Rgba::solid(960, 600, [gray, gray, gray, 255.]); + let shape = scenes::SHAPES[1]; + let out = model::render(material, &[shape], &backdrop); + out.get( + (shape.x + shape.width / 2.) as usize, + (shape.y + shape.height / 2.) as usize, + )[0] +} + +/// What Apple's glass turns a flat black, mid-grey and white backdrop into, +/// read off the reference captures (macOS 27, Apple M4 Max). +#[test] +fn presets_match_apple_over_flat_backdrops() { + for (name, black, gray, white) in [ + ("regular-light", 133., 188., 240.), + ("regular-dark", 20., 110., 114.), + ("clear-light", 71., 158., 244.), + ("clear-dark", 25., 116., 207.), + ] { + let material = material(name); + for (backdrop, expected) in [(0., black), (128., gray), (255., white)] { + let got = centre_over(&material, backdrop); + eprintln!("{name} {backdrop} -> {got:.1} (apple {expected})"); + assert!( + (got - expected).abs() <= 3., + "{name} over {backdrop}: {got} vs Apple's {expected}" + ); + } + } +} + +#[test] +fn every_preset_is_complete() { + let all = presets(); + let names: Vec<&str> = all.as_object().unwrap().keys().map(String::as_str).collect(); + assert_eq!( + names, + ["clear-dark", "clear-light", "regular-dark", "regular-light"] + ); + for name in names { + let material = material(name); + let style = material.resolve(420., 240.); + assert!(style.blur > 0. && style.lens_rim > 0., "{name}"); + } +} + +#[test] +fn blur_and_lensing_scale_with_the_shorter_side_up_to_their_caps() { + let material = material("clear-light"); + let small = material.resolve(300., 48.); + let large = material.resolve(900., 600.); + assert!((small.blur - material.blur_k * 48.).abs() < 1e-4); + assert_eq!(large.blur, material.blur_max); + assert!(small.lens_rim < large.lens_rim); + let doubled = small.scaled(2.); + assert_eq!(doubled.blur, small.blur * 2.); + assert_eq!(doubled.lens_rim, small.lens_rim * 2.); + assert_eq!(doubled.rim_bend, small.rim_bend * 2.); +} + +#[test] +fn the_rounded_rect_distance_field_is_signed_and_clamps_its_radius() { + let shape = Shape { + x: 0., + y: 0., + width: 100., + height: 40., + radius: 80., + }; + assert!((shape.sdf(50., 20.) + 20.).abs() < 1e-4); + assert!((shape.sdf(50., -5.) - 5.).abs() < 1e-4); + assert!((shape.sdf(0., 20.)).abs() < 1e-4); + assert!(shape.sdf(1., 1.) > 0., "a corner beyond the clamped radius is outside"); + let (nx, ny) = shape.normal(50., 1.); + assert!(nx.abs() < 1e-3 && (ny + 1.).abs() < 1e-3); +} + +#[test] +fn scenes_render_deterministically_at_any_scale() { + for name in scenes::NAMES { + let one = scenes::render(name, 1.).unwrap(); + assert_eq!((one.width, one.height), (960, 600)); + let two = scenes::render(name, 2.).unwrap(); + assert_eq!((two.width, two.height), (1920, 1200)); + assert_eq!(one.pixels, scenes::render(name, 1.).unwrap().pixels); + } + assert!(scenes::render("nope", 1.).is_none()); +} + +#[test] +fn glass_leaves_pixels_beyond_its_hairline_alone() { + let material = material("regular-light"); + let backdrop = scenes::render("checker", 1.).unwrap(); + let shape = scenes::SHAPES[1]; + let out = model::render(&material, &[shape], &backdrop); + for (x, y) in [(455usize, 180usize), (885, 180), (670, 55), (670, 305)] { + assert_eq!(out.get(x, y), backdrop.get(x, y), "({x}, {y})"); + } +} diff --git a/apps/desktop-gpui/patches/zed-glass.patch b/apps/desktop-gpui/patches/zed-glass.patch index a42b32020a..959e646d6e 100644 --- a/apps/desktop-gpui/patches/zed-glass.patch +++ b/apps/desktop-gpui/patches/zed-glass.patch @@ -1,5 +1,5 @@ diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs -index 6cc3562..6ea0576 100644 +index 6cc3562..c49648d 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -52,7 +52,7 @@ pub struct Scene { @@ -22,7 +22,7 @@ index 6cc3562..6ea0576 100644 #[derive(Debug, Copy, Clone)] #[repr(C)] #[expect(missing_docs)] -@@ -624,6 +624,53 @@ pub struct BackdropBlur { +@@ -624,6 +624,78 @@ pub struct BackdropBlur { pub bounds: Bounds, pub content_mask: ContentMask, pub corner_radii: Corners, @@ -35,16 +35,16 @@ index 6cc3562..6ea0576 100644 +/// +/// Per pixel: the blurred backdrop is sampled along the edge normal by +/// `rim_bend` within the `lens_rim`-wide rim (falling off with -+/// `lens_curve`) and transformed by a second-order polynomial: each output -+/// channel `c` is `poly[c * 10..]` dotted with `r, g, b, r², g², b², rg, gb, -+/// rb, 1`. An edge line then scales the colour by `1 + line + line_lit` ++/// `lens_curve`) and transformed by a cubic polynomial: each output channel ++/// `c` is `poly[c * 13..]` dotted with `r, g, b, r², g², b², rg, gb, rb, r³, ++/// g³, b³, 1`. An edge line then scales the colour by `1 + line + line_lit` +/// times how much the edge faces `light_x`/`light_y` (decaying over +/// `line_width`), and an inner glow adds `glow` (decaying over +/// `glow_width`). Over a transparent backdrop the glass is `opacity` opaque. +/// The pixel ring just outside the edge gets a hairline, +/// `clamp(min(under, a * under + b), 0, 1)`, with `a` and `b` blended from +/// `hairline[0..2]` on vertical edges to `hairline[2..4]` on horizontal ones. -+#[derive(Debug, Default, Copy, Clone, PartialEq)] ++#[derive(Debug, Copy, Clone, PartialEq)] +#[repr(C)] +#[expect(missing_docs)] +pub struct GlassStyle { @@ -52,7 +52,7 @@ index 6cc3562..6ea0576 100644 + pub lens_rim: f32, + pub lens_curve: f32, + pub rim_bend: f32, -+ pub poly: [f32; 30], ++ pub poly: [f32; 39], + pub opacity: f32, + pub line: f32, + pub line_lit: f32, @@ -62,6 +62,31 @@ index 6cc3562..6ea0576 100644 + pub glow: f32, + pub glow_width: f32, + pub hairline: [f32; 4], ++ /// Keeps the struct a multiple of 8 bytes, which WGSL's storage layout ++ /// requires of `BackdropBlur`. ++ pub pad: f32, ++} ++ ++impl Default for GlassStyle { ++ fn default() -> Self { ++ Self { ++ enabled: 0, ++ lens_rim: 0., ++ lens_curve: 0., ++ rim_bend: 0., ++ poly: [0.; 39], ++ opacity: 0., ++ line: 0., ++ line_lit: 0., ++ line_width: 0., ++ light_x: 0., ++ light_y: 0., ++ glow: 0., ++ glow_width: 0., ++ hairline: [0.; 4], ++ pad: 0., ++ } ++ } +} + +impl GlassStyle { @@ -148,7 +173,7 @@ index 70e9a0b..79415df 100644 "Underline".into(), "UnderlineInputIndex".into(), diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs -index 4be15aa..f4c608d 100644 +index 4be15aa..56248f3 100644 --- a/crates/gpui_macos/src/metal_renderer.rs +++ b/crates/gpui_macos/src/metal_renderer.rs @@ -979,7 +979,7 @@ impl MetalRenderer { @@ -213,7 +238,7 @@ index 4be15aa..f4c608d 100644 }); scene.pop_layer(); } -@@ -2741,6 +2750,197 @@ mod backdrop_blur_tests { +@@ -2741,6 +2750,215 @@ mod backdrop_blur_tests { ); } @@ -228,10 +253,16 @@ index 4be15aa..f4c608d 100644 + } + + fn test_glass() -> GlassStyle { -+ let mut poly = [0.; 30]; -+ poly[..10].copy_from_slice(&[0.6, 0.15, 0.05, -0.2, 0.1, 0.0, 0.05, 0.0, 0.02, 0.22]); -+ poly[10..20].copy_from_slice(&[0.05, 0.5, 0.1, 0.0, -0.15, 0.05, 0.0, 0.04, 0.0, 0.2]); -+ poly[20..].copy_from_slice(&[0.12, 0.05, 0.55, 0.0, 0.0, -0.1, 0.02, 0.0, 0.03, 0.18]); ++ let mut poly = [0.; 39]; ++ poly[..13].copy_from_slice(&[ ++ 0.6, 0.15, 0.05, -0.2, 0.1, 0.0, 0.05, 0.0, 0.02, 0.1, 0.0, -0.02, 0.22, ++ ]); ++ poly[13..26].copy_from_slice(&[ ++ 0.05, 0.5, 0.1, 0.0, -0.15, 0.05, 0.0, 0.04, 0.0, 0.0, 0.08, 0.0, 0.2, ++ ]); ++ poly[26..].copy_from_slice(&[ ++ 0.12, 0.05, 0.55, 0.0, 0.0, -0.1, 0.02, 0.0, 0.03, -0.03, 0.0, 0.06, 0.18, ++ ]); + GlassStyle { + enabled: 1, + lens_rim: 28., @@ -247,6 +278,7 @@ index 4be15aa..f4c608d 100644 + glow: 0.04, + glow_width: 6., + hairline: [0.86, -0.12, 0.92, -0.08], ++ pad: 0., + } + } + @@ -384,14 +416,25 @@ index 4be15aa..f4c608d 100644 + let s = sample(px + nx * glass.rim_bend * rim, py + ny * glass.rim_bend * rim); + let c = [s[0] / 255., s[1] / 255., s[2] / 255.]; + let features = [ -+ c[0], c[1], c[2], c[0] * c[0], c[1] * c[1], c[2] * c[2], c[0] * c[1], -+ c[1] * c[2], c[0] * c[2], 1., ++ c[0], ++ c[1], ++ c[2], ++ c[0] * c[0], ++ c[1] * c[1], ++ c[2] * c[2], ++ c[0] * c[1], ++ c[1] * c[2], ++ c[0] * c[2], ++ c[0] * c[0] * c[0], ++ c[1] * c[1] * c[1], ++ c[2] * c[2] * c[2], ++ 1., + ]; + let facing = nx * glass.light_x + ny * glass.light_y; + let line = (-edge / glass.line_width).exp() * (glass.line + glass.line_lit * facing); + let glow = (-edge / glass.glow_width).exp() * glass.glow; + std::array::from_fn(|i| { -+ let value: f32 = (0..10).map(|t| glass.poly[i * 10 + t] * features[t]).sum(); ++ let value: f32 = (0..13).map(|t| glass.poly[i * 13 + t] * features[t]).sum(); + let value = (value * (1. + line) + glow).clamp(0., 1.) * 255.; + under[i] + (value - under[i]) * coverage + }) @@ -412,7 +455,7 @@ index 4be15aa..f4c608d 100644 fn two_blurs_of_different_sizes_in_one_frame() { if metal::Device::system_default().is_none() { diff --git a/crates/gpui_macos/src/shaders.metal b/crates/gpui_macos/src/shaders.metal -index e087c0d..40c50bd 100644 +index e087c0d..de1333f 100644 --- a/crates/gpui_macos/src/shaders.metal +++ b/crates/gpui_macos/src/shaders.metal @@ -1354,9 +1354,17 @@ vertex BackdropBlurVertexOutput backdrop_blur_vertex( @@ -435,17 +478,18 @@ index e087c0d..40c50bd 100644 blur.content_mask.bounds); return BackdropBlurVertexOutput{ device_position, -@@ -1364,26 +1372,77 @@ vertex BackdropBlurVertexOutput backdrop_blur_vertex( +@@ -1364,26 +1372,78 @@ vertex BackdropBlurVertexOutput backdrop_blur_vertex( {clip_distance.x, clip_distance.y, clip_distance.z, clip_distance.w}}; } +float3 glass_shade(GlassStyle glass, float3 c, float edge, float2 normal) { -+ float features[10] = {c.r, c.g, c.b, c.r * c.r, c.g * c.g, c.b * c.b, -+ c.r * c.g, c.g * c.b, c.r * c.b, 1.}; ++ float features[13] = {c.r, c.g, c.b, c.r * c.r, c.g * c.g, c.b * c.b, ++ c.r * c.g, c.g * c.b, c.r * c.b, c.r * c.r * c.r, ++ c.g * c.g * c.g, c.b * c.b * c.b, 1.}; + float3 color = float3(0.); -+ for (int term = 0; term < 10; term++) { ++ for (int term = 0; term < 13; term++) { + color += features[term] * -+ float3(glass.poly[term], glass.poly[10 + term], glass.poly[20 + term]); ++ float3(glass.poly[term], glass.poly[13 + term], glass.poly[26 + term]); + } + float facing = dot(normal, float2(glass.light_x, glass.light_y)); + float line = exp(-edge / glass.line_width) * (glass.line + glass.line_lit * facing); @@ -525,10 +569,10 @@ index e087c0d..40c50bd 100644 + return mix(under, float4(glass * alpha, alpha), coverage); } diff --git a/crates/gpui_wgpu/src/shaders.wgsl b/crates/gpui_wgpu/src/shaders.wgsl -index 011be71..47a16cd 100644 +index 011be71..bf2175c 100644 --- a/crates/gpui_wgpu/src/shaders.wgsl +++ b/crates/gpui_wgpu/src/shaders.wgsl -@@ -1399,3 +1399,194 @@ fn fs_surface(input: SurfaceVarying) -> @location(0) vec4 { +@@ -1399,3 +1399,195 @@ fn fs_surface(input: SurfaceVarying) -> @location(0) vec4 { return ycbcr_to_RGB * y_cb_cr; } @@ -540,7 +584,7 @@ index 011be71..47a16cd 100644 + lens_rim: f32, + lens_curve: f32, + rim_bend: f32, -+ poly: array, ++ poly: array, + opacity: f32, + line_strength: f32, + line_lit: f32, @@ -550,6 +594,7 @@ index 011be71..47a16cd 100644 + glow: f32, + glow_width: f32, + hairline: array, ++ pad: f32, +} + +struct BackdropBlur { @@ -710,11 +755,11 @@ index 011be71..47a16cd 100644 + let alpha = mix(backdrop.a, 1.0, glass.opacity); + + let c = color; -+ let features = array(c.r, c.g, c.b, c.r * c.r, c.g * c.g, c.b * c.b, -+ c.r * c.g, c.g * c.b, c.r * c.b, 1.0); ++ let features = array(c.r, c.g, c.b, c.r * c.r, c.g * c.g, c.b * c.b, ++ c.r * c.g, c.g * c.b, c.r * c.b, c.r * c.r * c.r, c.g * c.g * c.g, c.b * c.b * c.b, 1.0); + color = vec3(0.0); -+ for (var term = 0; term < 10; term++) { -+ color += features[term] * vec3(glass.poly[term], glass.poly[10 + term], glass.poly[20 + term]); ++ for (var term = 0; term < 13; term++) { ++ color += features[term] * vec3(glass.poly[term], glass.poly[13 + term], glass.poly[26 + term]); + } + let facing = dot(normal, vec2(glass.light_x, glass.light_y)); + let line_term = exp(-edge / glass.line_width) * (glass.line_strength + glass.line_lit * facing); @@ -1828,10 +1873,10 @@ index e4b605b..1a88981 100644 } } diff --git a/crates/gpui_windows/src/shaders.hlsl b/crates/gpui_windows/src/shaders.hlsl -index 78b1ada..24bd265 100644 +index 78b1ada..11465d8 100644 --- a/crates/gpui_windows/src/shaders.hlsl +++ b/crates/gpui_windows/src/shaders.hlsl -@@ -1289,3 +1289,198 @@ float4 polychrome_sprite_fragment(PolychromeSpriteFragmentInput input): SV_Targe +@@ -1289,3 +1289,200 @@ float4 polychrome_sprite_fragment(PolychromeSpriteFragmentInput input): SV_Targe color.a *= sprite.opacity * saturate(0.5 - distance) * edge_fade_alpha(input.position.xy, sprite.fade); return color; } @@ -1849,7 +1894,7 @@ index 78b1ada..24bd265 100644 + float lens_rim; + float lens_curve; + float rim_bend; -+ float poly[30]; ++ float poly[39]; + float opacity; + float line_strength; + float line_lit; @@ -1859,6 +1904,7 @@ index 78b1ada..24bd265 100644 + float glow; + float glow_width; + float hairline[4]; ++ float pad; +}; + +struct BackdropBlur { @@ -2015,13 +2061,14 @@ index 78b1ada..24bd265 100644 + float3 color = backdrop.a > 0. ? backdrop.rgb / backdrop.a : float3(0., 0., 0.); + float alpha = lerp(backdrop.a, 1., glass.opacity); + -+ float features[10] = {color.r, color.g, color.b, color.r * color.r, color.g * color.g, ++ float features[13] = {color.r, color.g, color.b, color.r * color.r, color.g * color.g, + color.b * color.b, color.r * color.g, color.g * color.b, -+ color.r * color.b, 1.}; ++ color.r * color.b, color.r * color.r * color.r, ++ color.g * color.g * color.g, color.b * color.b * color.b, 1.}; + color = float3(0., 0., 0.); + [unroll] -+ for (int term = 0; term < 10; term++) { -+ color += features[term] * float3(glass.poly[term], glass.poly[10 + term], glass.poly[20 + term]); ++ for (int term = 0; term < 13; term++) { ++ color += features[term] * float3(glass.poly[term], glass.poly[13 + term], glass.poly[26 + term]); + } + float facing = dot(normal, float2(glass.light_x, glass.light_y)); + float line_term = exp(-edge / glass.line_width) * (glass.line_strength + glass.line_lit * facing); diff --git a/apps/desktop-gpui/src/ui/glass.rs b/apps/desktop-gpui/src/ui/glass.rs index 4b37e28e13..937d65fff0 100644 --- a/apps/desktop-gpui/src/ui/glass.rs +++ b/apps/desktop-gpui/src/ui/glass.rs @@ -70,6 +70,7 @@ pub fn gpui_style(style: &PaneStyle) -> gpui::GlassStyle { glow: style.glow, glow_width: style.glow_width, hairline: style.hairline, + pad: 0., } } From 9c73afa305a25c901d751821281d4968993110a9 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:15:45 +0000 Subject: [PATCH 06/14] fix(desktop-gpui): weight the glass colour fit as intended and pin the grey axis to Apple's --- apps/desktop-gpui/glass/presets.json | 194 ++++++++++--------- apps/desktop-gpui/glass/src/bin/glass-fit.rs | 23 ++- 2 files changed, 122 insertions(+), 95 deletions(-) diff --git a/apps/desktop-gpui/glass/presets.json b/apps/desktop-gpui/glass/presets.json index 40789a0ce0..b5b872eb09 100644 --- a/apps/desktop-gpui/glass/presets.json +++ b/apps/desktop-gpui/glass/presets.json @@ -1,130 +1,138 @@ { "clear-dark": { - "bend": -2.366746425628662, - "blur_k": 0.17999997735023499, + "bend": -2.412919521331787, + "blur_k": 0.18000054359436035, "blur_max": 20.0, - "glow": -0.00915201473981142, - "glow_width": 0.5, + "glow": -9.978866728488356e-6, + "glow_width": 39.30601119995117, "hairline": [ - 0.5046544075012207, -0.002066596643999219, 1.037916898727417, - -0.004036169033497572 + 0.5050199031829834, -0.002001264365389943, 1.0410776138305664, + -0.00509136077016592 ], - "lens_curve": 0.3376707434654236, - "light_angle": 10.696141242980957, - "line": 0.009427699260413647, - "line_lit": -0.00020453354227356613, - "line_width": 0.9371033906936646, + "lens_curve": 0.3599303960800171, + "light_angle": 2.3112025260925293, + "line": 0.0018150180112570524, + "line_lit": 0.0017182374140247703, + "line_width": 0.994491457939148, "opacity": 0.6000000238418579, "poly": [ - 0.9841277003288269, -0.28581157326698303, 0.06853790581226349, - -0.10551261156797409, -0.019449958577752113, -0.06275196373462677, - 0.12861721217632294, 0.0336744524538517, -0.02680288627743721, 0.0, 0.0, - 0.0, 0.09590495377779007, -0.025067875161767006, 0.7507156133651733, - -0.008180275559425354, -0.03581831976771355, -0.010394089855253696, - -0.01718660071492195, 0.04081161320209503, 0.02539644204080105, - -0.005516655743122101, 0.0, 0.0, 0.0, 0.09734207391738892, - -0.07906761765480042, -0.22363290190696716, 1.016892433166504, - -0.00731066195294261, -0.06583347171545029, -0.11654830724000931, - 0.04832758754491806, 0.12703412771224976, 0.01597178541123867, 0.0, 0.0, - 0.0, 0.09707652777433395 + 0.6313937902450562, 0.7283032536506653, -0.7202409505844116, + 0.7613860964775085, -2.0895676612854004, 1.455474615097046, + 0.11192644387483597, 0.08696344494819641, -0.08549253642559052, + -0.5280044078826904, 1.2200205326080322, -0.8582108616828918, + 0.09805495291948318, -0.07880198210477829, 0.8358877301216125, + -0.0669502466917038, 0.0785026028752327, -0.18421262502670288, + 0.11133701354265213, 0.045185938477516174, 0.023230290040373802, + -0.010092880576848984, -0.06803300976753235, 0.10296168178319931, + -0.0751410573720932, 0.09795161336660385, -0.17431367933750153, + -0.31713956594467163, 1.1713937520980835, 0.1505192369222641, + 0.10798941552639008, -0.3602425456047058, 0.06854856759309769, + 0.10419972240924835, 0.021173598244786263, -0.08950604498386383, + -0.09568458050489426, 0.12689803540706635, 0.09791429340839386 ], - "rim_k": 0.3009837865829468, - "rim_max": 26.59674072265625 + "rim_k": 0.2883424758911133, + "rim_max": 24.872886657714844 }, "clear-light": { - "bend": -2.364307403564453, - "blur_k": 0.17999984323978424, + "bend": -2.4594221115112305, + "blur_k": 0.17999997735023499, "blur_max": 20.0, - "glow": -0.040041275322437286, + "glow": -0.0408473014831543, "glow_width": 0.5, "hairline": [ - 0.6467424035072327, -0.017890620976686478, 1.0234696865081787, - 0.008217902854084969 + 0.6450185179710388, -0.016545917838811874, 1.0230517387390137, + 0.008764644153416157 ], - "lens_curve": 0.33955836296081543, - "light_angle": 8.900734901428223, - "line": 0.013842251151800156, - "line_lit": 0.00113571563269943, - "line_width": 1.1170105934143066, + "lens_curve": 0.318873792886734, + "light_angle": 2.6443076133728027, + "line": 0.01456276886165142, + "line_lit": 0.0015534033300355077, + "line_width": 1.097947359085083, "opacity": 0.6000000238418579, "poly": [ - 0.8068393468856812, -0.19063682854175568, 0.12205540388822556, - 0.03810872882604599, 0.05950373411178589, -0.08793330937623978, - -0.04968729987740517, 0.011197183281183243, -0.03260619565844536, 0.0, - 0.0, 0.0, 0.27716732025146484, -0.009006494656205177, 0.6790370345115662, - 0.013659832999110222, -0.02463696524500847, 0.033544085919857025, - -0.016603535041213036, 0.00851691234856844, 0.0007132220198400319, - -0.0068999179638922215, 0.0, 0.0, 0.0, 0.278425395488739, - 0.05969604477286339, -0.09342411160469055, 0.722533643245697, - -0.04663229361176491, -0.05806506425142288, 0.033827684819698334, - -0.052240658551454544, 0.10449537634849548, 0.007342542987316847, 0.0, - 0.0, 0.0, 0.2787947356700897 + 0.2497211992740631, 0.7583296895027161, -0.36811646819114685, + 1.273596167564392, -1.9042826890945435, 0.8599693775177002, + -0.0514889694750309, 0.052962444722652435, -0.08220545202493668, + -0.7352914810180664, 1.1641223430633545, -0.5389814972877502, + 0.2785186767578125, -0.03988504037261009, 0.7219523787498474, + -0.007673143874853849, 0.03376581892371178, -0.050814464688301086, + 0.03104168362915516, 0.0132481399923563, -0.0016589678125455976, + -0.007338891737163067, -0.033974405378103256, 0.04808909818530083, + -0.028364654630422592, 0.27844366431236267, -0.0301153976470232, + 0.1788342148065567, 0.5316532850265503, 0.18545162677764893, + -0.6017318367958069, 0.36461755633354187, -0.07064586132764816, + 0.13311389088630676, -0.007584464270621538, -0.13774992525577545, + 0.3131157159805298, -0.1805034577846527, 0.2784975469112396 ], - "rim_k": 0.2935454249382019, - "rim_max": 27.180828094482422 + "rim_k": 0.2952793836593628, + "rim_max": 26.819894790649414 }, "regular-dark": { - "bend": -1.0383737087249756, - "blur_k": 0.1399989128112793, + "bend": -1.6669726371765137, + "blur_k": 0.14000000059604645, "blur_max": 12.0, - "glow": 0.023018531501293182, - "glow_width": 47.50996780395508, + "glow": 0.024336621165275574, + "glow_width": 51.54517364501953, "hairline": [ - 0.265051931142807, 0.09913472086191177, 0.5498596429824829, - 0.042205967009067535 + 0.2600359320640564, 0.103030264377594, 0.5471664667129517, + 0.04482661187648773 ], - "lens_curve": 0.6115574836730957, - "light_angle": 3.5436832904815674, - "line": 0.013988529331982136, - "line_lit": -5.383496682043187e-5, - "line_width": 0.516719400882721, + "lens_curve": 0.5919271111488342, + "light_angle": -2.782675266265869, + "line": 0.011403294280171394, + "line_lit": 0.011242559179663658, + "line_width": 0.3977275788784027, "opacity": 0.6000000238418579, "poly": [ - 1.1429176330566406, -0.10875950753688812, 0.033222246915102005, - -0.08781613409519196, -0.13997182250022888, -0.032387249171733856, - -0.3350599706172943, 0.02284449338912964, -0.0969463512301445, 0.0, 0.0, - 0.0, 0.06109626591205597, -0.14081989228725433, 1.3113867044448853, - -0.13325585424900055, 0.012389129027724266, -0.6814408302307129, - 0.0769672617316246, 0.005131110083311796, -0.11113206297159195, - 0.055657077580690384, 0.0, 0.0, 0.0, 0.06288916617631912, - -0.14669793844223022, -0.0684649795293808, 1.239702582359314, - 0.011958428658545017, -0.1758630871772766, -0.09301606565713882, - 0.12923042476177216, -0.4333122670650482, -0.0655086413025856, 0.0, 0.0, - 0.0, 0.06273098289966583 + 1.1208113431930542, 0.769611120223999, -0.9219794273376465, + 0.1965949982404709, -1.9167686700820923, 1.746425747871399, + -0.3972877562046051, 0.10475105792284012, -0.1348458230495453, + -0.213811457157135, 1.0382335186004639, -1.0049047470092773, + 0.06521148979663849, -0.11392313241958618, 1.4205176830291748, + -0.28513824939727783, 0.0018862647702917457, -0.9691377878189087, + 0.4069260358810425, 0.0062097590416669846, -0.0784282386302948, + 0.034528374671936035, -0.00854511745274067, 0.1753605753183365, + -0.20346999168395996, 0.06513476371765137, -0.15799254179000854, + -0.39501243829727173, 1.5855051279067993, -0.05833205580711365, + 0.4637044072151184, -0.6609259247779846, 0.17491891980171204, + -0.48810815811157227, -0.048988454043865204, 0.038651589304208755, + -0.36601346731185913, 0.29942595958709717, 0.06502226740121841 ], - "rim_k": 0.0955091044306755, - "rim_max": 43.95122528076172 + "rim_k": 0.21779286861419678, + "rim_max": 17.012699127197266 }, "regular-light": { - "bend": -1.6921217441558838, + "bend": -1.6906808614730835, "blur_k": 0.14000000059604645, "blur_max": 12.0, - "glow": -0.06775760650634766, + "glow": -0.06975674629211426, "glow_width": 0.5, "hairline": [ - 0.6670563817024231, 0.017260314896702766, 0.7883668541908264, - 0.007549159694463015 + 0.6683441400527954, 0.015820689499378204, 0.7885841131210327, + 0.007480344735085964 ], - "lens_curve": 0.576151967048645, - "light_angle": 2.86551833152771, - "line": 0.012727930210530758, - "line_lit": 0.0012256903573870659, - "line_width": 1.5420403480529785, + "lens_curve": 0.5981463193893433, + "light_angle": 5.205510139465332, + "line": 0.01358448900282383, + "line_lit": -0.0012929972726851702, + "line_width": 1.5013341903686523, "opacity": 0.6000000238418579, "poly": [ - 0.7772780656814575, -0.45734652876853943, 0.2029036283493042, - -0.1445373296737671, 0.028123168274760246, -0.15287674963474274, - 0.20918069779872894, 0.03389207646250725, -0.0789894387125969, 0.0, 0.0, - 0.0, 0.5196360945701599, -0.05892333388328552, 0.5539849996566772, - -0.04516340047121048, -0.038535263389348984, -0.03162488341331482, - 0.007563223596662283, 0.03239148110151291, -0.00026807302492670715, - 0.0019051238195970654, 0.0, 0.0, 0.0, 0.5209547877311707, - 0.16917257010936737, -0.3096109628677368, 0.619947612285614, - -0.12055128067731857, -0.14295552670955658, -0.1775696575641632, - -0.13636651635169983, 0.48980438709259033, 0.027854571118950844, 0.0, 0.0, - 0.0, 0.5214924812316895 + -0.138549342751503, 0.7256880402565002, -0.1833912432193756, + 1.8144844770431519, -2.4605472087860107, 0.6123811602592468, + 0.23255813121795654, 0.05579162761569023, -0.12572336196899414, + -1.1628233194351196, 1.4883190393447876, -0.43835973739624023, + 0.5214229226112366, -0.0343957282602787, 0.4276241362094879, + 0.04451335221529007, -0.1150304526090622, 0.21809197962284088, + -0.13724924623966217, 0.03976645693182945, -0.017057476565241814, + 0.0032571046613156796, 0.050204869359731674, -0.13996750116348267, + 0.0802760049700737, 0.5213379263877869, 0.10205833613872528, + 0.3425259590148926, -0.017469502985477448, 0.12757959961891174, + -1.446061372756958, 0.9996414184570312, -0.17898792028427124, + 0.5396572947502136, -0.002463854616507888, -0.1542862057685852, + 0.762222409248352, -0.6541531682014465, 0.5215706825256348 ], - "rim_k": 0.21131862699985504, - "rim_max": 16.063199996948242 + "rim_k": 0.2092672884464264, + "rim_max": 15.961286544799805 } } diff --git a/apps/desktop-gpui/glass/src/bin/glass-fit.rs b/apps/desktop-gpui/glass/src/bin/glass-fit.rs index e9ca249f11..0ea2d0b9a4 100644 --- a/apps/desktop-gpui/glass/src/bin/glass-fit.rs +++ b/apps/desktop-gpui/glass/src/bin/glass-fit.rs @@ -47,6 +47,9 @@ struct Sample { const PAD: f32 = 110.; +/// How much more the black, grey and white scenes count in the colour fit. +const NEUTRAL_WEIGHT: f64 = 12.; + fn main() { let args: Vec = std::env::args().collect(); let reference = PathBuf::from(&args[1]); @@ -99,6 +102,14 @@ fn main() { .as_ref() .and_then(|all| serde_json::from_value::(all.get(&preset)?.clone()).ok()) { + // `GLASS_RESOLVE=1`: keep the given blur, lensing and lighting but + // solve the colour transform again. + Some(material) if std::env::var_os("GLASS_RESOLVE").is_some() => { + prepare(&mut samples, &shapes, &material); + let (loss, material) = evaluate(&material, &shapes, &samples); + eprintln!("{preset}: re-solved colour, loss {loss:.2}"); + material + } Some(material) => material, None => fit(&preset, &mut samples, &shapes), }; @@ -358,7 +369,15 @@ fn normal_equations(material: &GlassMaterial, shapes: &[Shape], samples: &[Sampl .map(|sample| { scope.spawn(move || { let mut normal = Normal::new(); + // Flat backdrops pin the grey axis, where any error shows + // most; a handful of colourful scenes would otherwise + // outvote them. + let neutral = ["white-", "black-", "gray-"] + .iter() + .any(|prefix| sample.name.starts_with(prefix)); + let scene_weight = if neutral { NEUTRAL_WEIGHT } else { 1. }; for_each_glass_pixel(material, shapes, sample, true, |glass, _, x, y, weight| { + let weight = weight * scene_weight; let under = sample.backdrop.get(x, y); let under = model::under_with_hairline( glass, @@ -374,9 +393,9 @@ fn normal_equations(material: &GlassMaterial, shapes: &[Shape], samples: &[Sampl return; } let features = model::poly_features(glass.source); - let weight = (glass.coverage * glass.scale) as f64; + let scale = (glass.coverage * glass.scale) as f64; let row: [f64; POLY_TERMS] = - std::array::from_fn(|i| features[i] as f64 * weight); + std::array::from_fn(|i| features[i] as f64 * scale); for i in 0..POLY_TERMS { for j in i..POLY_TERMS { normal.ata[i][j] += weight * row[i] * row[j]; From 90777daa45bff83d9cbfbc2be76fb732843b7460 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:08:09 +0000 Subject: [PATCH 07/14] fix(desktop-gpui): glass-fit reads Retina references at their capture scale --- apps/desktop-gpui/glass/src/bin/glass-fit.rs | 36 +++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/desktop-gpui/glass/src/bin/glass-fit.rs b/apps/desktop-gpui/glass/src/bin/glass-fit.rs index 0ea2d0b9a4..2b628ba9c0 100644 --- a/apps/desktop-gpui/glass/src/bin/glass-fit.rs +++ b/apps/desktop-gpui/glass/src/bin/glass-fit.rs @@ -20,6 +20,24 @@ use cap_glass::{ struct Manifest { shapes: Vec, cases: Vec, + /// Image pixels per point: the captures are at the display's backing + /// scale, the shapes in points. + #[serde(default = "one")] + scale: f32, +} + +fn one() -> f32 { + 1. +} + +static SCALE: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// A shape's material in image pixels: the material's laws are in points. +fn style_for(material: &GlassMaterial, shape: &Shape) -> model::PaneStyle { + let scale = *SCALE.get().unwrap_or(&1.); + material + .resolve(shape.width / scale, shape.height / scale) + .scaled(scale) } #[derive(serde::Deserialize)] @@ -57,15 +75,17 @@ fn main() { std::fs::create_dir_all(out.join("renders")).unwrap(); let manifest: Manifest = serde_json::from_slice(&std::fs::read(reference.join("manifest.json")).unwrap()).unwrap(); + let scale = manifest.scale; + SCALE.set(scale).unwrap(); let shapes: Vec = manifest .shapes .iter() .map(|shape| Shape { - x: shape.rect[0], - y: shape.rect[1], - width: shape.rect[2], - height: shape.rect[3], - radius: shape.radius, + x: shape.rect[0] * scale, + y: shape.rect[1] * scale, + width: shape.rect[2] * scale, + height: shape.rect[3] * scale, + radius: shape.radius * scale, }) .collect(); let presets: Vec = if args.len() > 3 { @@ -199,8 +219,8 @@ fn prepare(samples: &mut [Sample], shapes: &[Shape], material: &GlassMaterial) { sample.crops = shapes .iter() .map(|shape| { - let blur = material.resolve(shape.width, shape.height).blur; - let pad = PAD + blur * 3.; + let blur = style_for(material, shape).blur; + let pad = PAD * SCALE.get().unwrap_or(&1.) + blur * 3.; let x0 = (shape.x - pad).max(0.) as usize; let y0 = (shape.y - pad).max(0.) as usize; let x1 = ((shape.x + shape.width + pad) as usize).min(sample.backdrop.width); @@ -224,7 +244,7 @@ fn for_each_glass_pixel( mut visit: impl FnMut(&model::GlassSample, &model::PaneStyle, usize, usize, f64), ) { for (shape, (ox, oy, blurred)) in shapes.iter().zip(&sample.crops) { - let style = material.resolve(shape.width, shape.height); + let style = style_for(material, shape); let local = Shape { x: shape.x - *ox as f32, y: shape.y - *oy as f32, From 7cdd62aece1c34a4ff6c56b26de54f58ff6c0e5d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:10:53 +0000 Subject: [PATCH 08/14] ci(desktop-gpui): run the glass probe's summary with whichever Python the runner has --- scripts/glass-probe/run.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/glass-probe/run.sh b/scripts/glass-probe/run.sh index dabac71e91..efa057d56f 100755 --- a/scripts/glass-probe/run.sh +++ b/scripts/glass-probe/run.sh @@ -37,7 +37,9 @@ if [ ! -s "$report" ]; then tail -n 80 "$OUT/app.log" >&2 || true exit 1 fi -python3 - "$report" <<'PY' +# Windows images ship `python`, not `python3`. +PYTHON=$(command -v python3 || command -v python) +"$PYTHON" - "$report" <<'PY' import json, sys report = json.load(open(sys.argv[1])) print(f"scale {report.get('scale')} passed {report['passed']}") From 6f7ea4254f549e3379c8a35aad4a5d40c1f21315 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:16:18 +0000 Subject: [PATCH 09/14] ci(desktop-gpui): the glass probe captures its window's area on Windows, where hosted runners lack window capture --- apps/desktop-gpui/src/glass_probe.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/desktop-gpui/src/glass_probe.rs b/apps/desktop-gpui/src/glass_probe.rs index 2abead04dd..aa8ea888c6 100644 --- a/apps/desktop-gpui/src/glass_probe.rs +++ b/apps/desktop-gpui/src/glass_probe.rs @@ -29,7 +29,7 @@ pub fn run(cx: &mut App) { tracing::error!(%error, "glass probe: cannot create its output directory"); return; } - // An X11 window capture reads whatever is on screen over the window. + // An area capture reads whatever is on screen over the window. crate::app_windows::hide_main_window(cx); let handle = cx.open_window( WindowOptions { @@ -233,7 +233,7 @@ fn glass_mask(scale: f32, width: usize, height: usize) -> Vec { .collect() } -#[cfg(not(target_os = "linux"))] +#[cfg(target_os = "macos")] fn window_target(window: &Window) -> Option { let number = crate::platform::window_number(window)?; Some(ScreenCaptureTarget::Window { @@ -241,9 +241,10 @@ fn window_target(window: &Window) -> Option { }) } -/// Cap's own windows are not in its X11 window list, so the probe captures -/// the window's area of the display; it hides every other Cap window first. -#[cfg(target_os = "linux")] +/// Cap's own windows are not in its X11 window list, and hosted Windows +/// runners have no window capture, so the probe captures the window's area +/// of the display; it hides every other Cap window first. +#[cfg(not(target_os = "macos"))] fn window_target(window: &Window) -> Option { use scap_targets::bounds::{LogicalBounds, LogicalPosition, LogicalSize}; From 3b487ef0981fb8cf6d61a9a07dc448d5f709b1f8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:18:49 +0000 Subject: [PATCH 10/14] fix(desktop-gpui): map the reduced backdrop back by its real scale on DirectX and wgpu, and tidy the glass crate --- apps/desktop-gpui/glass/src/lib.rs | 2 - apps/desktop-gpui/glass/src/model.rs | 4 +- apps/desktop-gpui/patches/zed-glass.patch | 70 ++++++++++++----------- apps/desktop-gpui/src/ui/glass.rs | 1 - 4 files changed, 40 insertions(+), 37 deletions(-) diff --git a/apps/desktop-gpui/glass/src/lib.rs b/apps/desktop-gpui/glass/src/lib.rs index b8e5edd8eb..bb20913b7e 100644 --- a/apps/desktop-gpui/glass/src/lib.rs +++ b/apps/desktop-gpui/glass/src/lib.rs @@ -80,7 +80,6 @@ impl Rgba { }) } - /// Gaussian blur of every channel with clamped edges. pub fn blurred(&self, sigma: f32) -> Self { if sigma < 0.5 { return self.clone(); @@ -127,7 +126,6 @@ impl Shape { outside + qx.max(qy).min(0.) - radius } - /// Unit outward normal of the distance field. pub fn normal(&self, px: f32, py: f32) -> (f32, f32) { let e = 0.5; let dx = self.sdf(px + e, py) - self.sdf(px - e, py); diff --git a/apps/desktop-gpui/glass/src/model.rs b/apps/desktop-gpui/glass/src/model.rs index 32d9ec1384..c767ea06ab 100644 --- a/apps/desktop-gpui/glass/src/model.rs +++ b/apps/desktop-gpui/glass/src/model.rs @@ -122,9 +122,9 @@ impl GlassMaterial { pub fn resolve(&self, width: f32, height: f32) -> PaneStyle { let shorter = width.min(height).max(1.); - let lens_rim = (self.rim_k * shorter).min(self.rim_max).max(0.5); + let lens_rim = (self.rim_k * shorter).clamp(0.5, self.rim_max.max(0.5)); PaneStyle { - blur: (self.blur_k * shorter).min(self.blur_max).max(0.), + blur: (self.blur_k * shorter).clamp(0., self.blur_max.max(0.)), lens_rim, lens_curve: self.lens_curve, rim_bend: self.bend * lens_rim, diff --git a/apps/desktop-gpui/patches/zed-glass.patch b/apps/desktop-gpui/patches/zed-glass.patch index 959e646d6e..033ae8ce5d 100644 --- a/apps/desktop-gpui/patches/zed-glass.patch +++ b/apps/desktop-gpui/patches/zed-glass.patch @@ -569,7 +569,7 @@ index e087c0d..de1333f 100644 + return mix(under, float4(glass * alpha, alpha), coverage); } diff --git a/crates/gpui_wgpu/src/shaders.wgsl b/crates/gpui_wgpu/src/shaders.wgsl -index 011be71..bf2175c 100644 +index 011be71..5c7194d 100644 --- a/crates/gpui_wgpu/src/shaders.wgsl +++ b/crates/gpui_wgpu/src/shaders.wgsl @@ -1399,3 +1399,195 @@ fn fs_surface(input: SurfaceVarying) -> @location(0) vec4 { @@ -609,10 +609,10 @@ index 011be71..bf2175c 100644 +// For the downsample and blur passes `source_rect` is the texel rect of the +// source texture being read and `params` is (direction x, direction y, +// sigma in source texels or the downsample factor, unused). For the glass -+// pass `source_rect` is the drawable rect that was blurred, `params.x` the -+// factor it was blurred at (the blurred texture holds it from texel 0, -+// scaled down by that factor), and `sharp_texture_size` the size of the -+// drawable-sized unblurred copy. ++// pass `source_rect` is the drawable rect that was blurred, `params.xy` how ++// far it was scaled down on each axis (the blurred texture holds it from ++// texel 0), and `sharp_texture_size` the size of the drawable-sized ++// unblurred copy. +struct BackdropParams { + source_rect: vec4, + params: vec4, @@ -705,8 +705,8 @@ index 011be71..bf2175c 100644 +// Clamped to the blurred region: texels beyond it hold an older pass. +fn backdrop_blurred_uv(position: vec2) -> vec2 { + let rect = backdrop_params.source_rect; -+ let factor = backdrop_params.params.x; -+ let texel = clamp((position - rect.xy) / factor, vec2(0.5), rect.zw / factor - 0.5); ++ let scale = backdrop_params.params.xy; ++ let texel = clamp((position - rect.xy) / scale, vec2(0.5), rect.zw / scale - 0.5); + return texel / backdrop_params.source_texture_size; +} + @@ -769,7 +769,7 @@ index 011be71..bf2175c 100644 + return mix(under, vec4(color * alpha, alpha), coverage); +} diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs -index 2bfbc4e..f60b1af 100644 +index 2bfbc4e..72ce028 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -2,7 +2,8 @@ use crate::surface_frame::SurfaceFrame; @@ -1072,7 +1072,7 @@ index 2bfbc4e..f60b1af 100644 } if overflow { -@@ -1365,6 +1539,276 @@ impl WgpuRenderer { +@@ -1365,6 +1539,279 @@ impl WgpuRenderer { ) } @@ -1172,6 +1172,9 @@ index 2bfbc4e..f60b1af 100644 + let factor = (sigma / 4.).floor().clamp(1., 8.); + let region = [x0 as f32, y0 as f32, (x1 - x0) as f32, (y1 - y0) as f32]; + let reduced = [(region[2] / factor).ceil(), (region[3] / factor).ceil()]; ++ // The downsample stretches the region across `reduced` texels, so a ++ // side that `factor` does not divide shrinks by a little less. ++ let scale = [region[2] / reduced[0], region[3] / reduced[1]]; + let full = [targets.width as f32, targets.height as f32]; + + encoder.copy_texture_to_texture( @@ -1278,7 +1281,7 @@ index 2bfbc4e..f60b1af 100644 + &targets.ping_view, + BackdropParams { + source_rect: blur_source_rect, -+ params: [1., 0., sigma / factor, 0.], ++ params: [1., 0., sigma / scale[0], 0.], + source_texture_size: full, + sharp_texture_size: full, + }, @@ -1288,7 +1291,7 @@ index 2bfbc4e..f60b1af 100644 + &targets.pong_view, + BackdropParams { + source_rect: reduced_rect, -+ params: [0., 1., sigma / factor, 0.], ++ params: [0., 1., sigma / scale[1], 0.], + source_texture_size: full, + sharp_texture_size: full, + }, @@ -1298,7 +1301,7 @@ index 2bfbc4e..f60b1af 100644 + + let params = BackdropParams { + source_rect: region, -+ params: [factor, 0., 0., 0.], ++ params: [scale[0], scale[1], 0., 0.], + source_texture_size: full, + sharp_texture_size: full, + }; @@ -1349,7 +1352,7 @@ index 2bfbc4e..f60b1af 100644 fn draw_shadows( &self, shadows: &[Shadow], -@@ -1937,3 +2381,22 @@ impl RenderingParameters { +@@ -1937,3 +2384,22 @@ impl RenderingParameters { } } } @@ -1387,7 +1390,7 @@ index 1db9571..0a6b4a0 100644 let rust_binding_path = format!("{}/shaders_bytes.rs", out_dir); diff --git a/crates/gpui_windows/src/directx_renderer.rs b/crates/gpui_windows/src/directx_renderer.rs -index e4b605b..1a88981 100644 +index e4b605b..89f3cbb 100644 --- a/crates/gpui_windows/src/directx_renderer.rs +++ b/crates/gpui_windows/src/directx_renderer.rs @@ -53,6 +53,34 @@ pub(crate) struct DirectXRenderer { @@ -1490,7 +1493,7 @@ index e4b605b..1a88981 100644 let _annotation = annotation .as_ref() .map(|annotation| Annotation::new(annotation, HSTRING::from(batch.label()))); -@@ -380,9 +434,222 @@ impl DirectXRenderer { +@@ -380,9 +434,225 @@ impl DirectXRenderer { ) })?; } @@ -1574,6 +1577,9 @@ index e4b605b..1a88981 100644 + let factor = (sigma / 4.).floor().clamp(1., 8.); + let region = [x0 as f32, y0 as f32, (x1 - x0) as f32, (y1 - y0) as f32]; + let reduced = [(region[2] / factor).ceil(), (region[3] / factor).ceil()]; ++ // The downsample stretches the region across `reduced` texels, so a ++ // side that `factor` does not divide shrinks by a little less. ++ let scale = [region[2] / reduced[0], region[3] / reduced[1]]; + let full = [self.width as f32, self.height as f32]; + + let devices = self.devices.as_ref().context("devices missing")?; @@ -1664,7 +1670,7 @@ index e4b605b..1a88981 100644 + &textures.ping.0, + BackdropParams { + source_rect: blur_source.1, -+ params: [1., 0., sigma / factor, 0.], ++ params: [1., 0., sigma / scale[0], 0.], + source_texture_size: full, + sharp_texture_size: full, + }, @@ -1675,7 +1681,7 @@ index e4b605b..1a88981 100644 + &textures.pong.0, + BackdropParams { + source_rect: reduced_rect, -+ params: [0., 1., sigma / factor, 0.], ++ params: [0., 1., sigma / scale[1], 0.], + source_texture_size: full, + sharp_texture_size: full, + }, @@ -1683,7 +1689,7 @@ index e4b605b..1a88981 100644 + + set_params(BackdropParams { + source_rect: region, -+ params: [factor, 0., 0., 0.], ++ params: [scale[0], scale[1], 0., 0.], + source_texture_size: full, + sharp_texture_size: full, + })?; @@ -1713,7 +1719,7 @@ index e4b605b..1a88981 100644 pub(crate) fn resize(&mut self, new_size: Size) -> Result<()> { let width = new_size.width.0.max(1) as u32; let height = new_size.height.0.max(1) as u32; -@@ -917,6 +1184,22 @@ impl DirectXRenderPipelines { +@@ -917,6 +1187,22 @@ impl DirectXRenderPipelines { create_blend_state(device)?, )?; @@ -1736,7 +1742,7 @@ index e4b605b..1a88981 100644 Ok(Self { shadow_pipeline, quad_pipeline, -@@ -926,6 +1209,9 @@ impl DirectXRenderPipelines { +@@ -926,6 +1212,9 @@ impl DirectXRenderPipelines { mono_sprites, subpixel_sprites, poly_sprites, @@ -1746,7 +1752,7 @@ index e4b605b..1a88981 100644 }) } } -@@ -986,9 +1272,41 @@ impl DirectXGlobalElements { +@@ -986,9 +1275,41 @@ impl DirectXGlobalElements { output }; @@ -1788,7 +1794,7 @@ index e4b605b..1a88981 100644 }) } } -@@ -1432,6 +1750,18 @@ fn create_blend_state(device: &ID3D11Device) -> Result { +@@ -1432,6 +1753,18 @@ fn create_blend_state(device: &ID3D11Device) -> Result { } } @@ -1807,7 +1813,7 @@ index e4b605b..1a88981 100644 #[inline] fn create_blend_state_for_subpixel_rendering(device: &ID3D11Device) -> Result { let mut desc = D3D11_BLEND_DESC::default(); -@@ -1614,6 +1944,25 @@ fn report_live_objects(device: &ID3D11Device) -> Result<()> { +@@ -1614,6 +1947,25 @@ fn report_live_objects(device: &ID3D11Device) -> Result<()> { Ok(()) } @@ -1833,7 +1839,7 @@ index e4b605b..1a88981 100644 const BUFFER_COUNT: usize = 3; pub(crate) mod shader_resources { -@@ -1639,6 +1988,9 @@ pub(crate) mod shader_resources { +@@ -1639,6 +1991,9 @@ pub(crate) mod shader_resources { SubpixelSprite, PolychromeSprite, EmojiRasterization, @@ -1843,7 +1849,7 @@ index e4b605b..1a88981 100644 } #[derive(Copy, Clone, Debug, Eq, PartialEq)] -@@ -1716,6 +2068,18 @@ pub(crate) mod shader_resources { +@@ -1716,6 +2071,18 @@ pub(crate) mod shader_resources { ShaderTarget::Vertex => EMOJI_RASTERIZATION_VERTEX_BYTES, ShaderTarget::Fragment => EMOJI_RASTERIZATION_FRAGMENT_BYTES, }, @@ -1862,7 +1868,7 @@ index e4b605b..1a88981 100644 }; Self { inner: bytes } } -@@ -1803,6 +2167,9 @@ pub(crate) mod shader_resources { +@@ -1803,6 +2170,9 @@ pub(crate) mod shader_resources { ShaderModule::SubpixelSprite => "subpixel_sprite", ShaderModule::PolychromeSprite => "polychrome_sprite", ShaderModule::EmojiRasterization => "emoji_rasterization", @@ -1873,7 +1879,7 @@ index e4b605b..1a88981 100644 } } diff --git a/crates/gpui_windows/src/shaders.hlsl b/crates/gpui_windows/src/shaders.hlsl -index 78b1ada..11465d8 100644 +index 78b1ada..5ae5ab5 100644 --- a/crates/gpui_windows/src/shaders.hlsl +++ b/crates/gpui_windows/src/shaders.hlsl @@ -1289,3 +1289,200 @@ float4 polychrome_sprite_fragment(PolychromeSpriteFragmentInput input): SV_Targe @@ -1920,9 +1926,9 @@ index 78b1ada..11465d8 100644 +// blur passes `source_rect` is the texel rect of the source texture being +// read and `params` is (direction x, direction y, sigma in source texels or +// the downsample factor, unused). For the glass pass `source_rect` is the -+// drawable rect that was blurred, `params.x` the factor it was blurred at -+// (the blurred texture holds it from texel 0, scaled down by that factor), -+// and `sharp_texture_size` the size of the drawable-sized unblurred copy. ++// drawable rect that was blurred, `params.xy` how far it was scaled down on ++// each axis (the blurred texture holds it from texel 0), and ++// `sharp_texture_size` the size of the drawable-sized unblurred copy. +cbuffer BackdropParams: register(b1) { + float4 source_rect; + float4 params; @@ -2016,8 +2022,8 @@ index 78b1ada..11465d8 100644 + +// Clamped to the blurred region: texels beyond it hold an older pass. +float2 backdrop_blurred_uv(float2 position) { -+ float2 texel = (position - source_rect.xy) / params.x; -+ texel = clamp(texel, 0.5, source_rect.zw / params.x - 0.5); ++ float2 texel = (position - source_rect.xy) / params.xy; ++ texel = clamp(texel, 0.5, source_rect.zw / params.xy - 0.5); + return texel / source_texture_size; +} + diff --git a/apps/desktop-gpui/src/ui/glass.rs b/apps/desktop-gpui/src/ui/glass.rs index 937d65fff0..b444f67cb1 100644 --- a/apps/desktop-gpui/src/ui/glass.rs +++ b/apps/desktop-gpui/src/ui/glass.rs @@ -74,7 +74,6 @@ pub fn gpui_style(style: &PaneStyle) -> gpui::GlassStyle { } } -/// Paint one glass pane over whatever the window already holds at `bounds`. pub fn paint(window: &mut Window, bounds: Bounds, radius: f32, material: &GlassMaterial) { let (width, height) = (f32::from(bounds.size.width), f32::from(bounds.size.height)); if width <= 0. || height <= 0. { From e40da5c1fc9f3f60149417f78af6ffb6397f12c1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:33:19 +0000 Subject: [PATCH 11/14] feat(desktop-gpui): match Apple's glass size adaptation, lens falloff, colour and tint --- apps/desktop-gpui/glass/presets.json | 840 ++++++++++++++++--- apps/desktop-gpui/glass/src/bin/glass-fit.rs | 553 ++++++++---- apps/desktop-gpui/glass/src/model.rs | 277 +++++- apps/desktop-gpui/glass/tests/presets.rs | 109 ++- apps/desktop-gpui/patches/zed-glass.patch | 165 +++- apps/desktop-gpui/src/glass_probe.rs | 275 +++++- apps/desktop-gpui/src/ui/glass.rs | 35 +- scripts/glass-reference/sweep.swift | 267 ++++++ 8 files changed, 2145 insertions(+), 376 deletions(-) create mode 100644 scripts/glass-reference/sweep.swift diff --git a/apps/desktop-gpui/glass/presets.json b/apps/desktop-gpui/glass/presets.json index b5b872eb09..d6b3e17c01 100644 --- a/apps/desktop-gpui/glass/presets.json +++ b/apps/desktop-gpui/glass/presets.json @@ -1,138 +1,772 @@ { "clear-dark": { - "bend": -2.412919521331787, - "blur_k": 0.18000054359436035, + "bend_base": 0.0, + "bend_k": 0.5220050811767578, + "bend_max": 44.700069427490234, + "blur_k": 0.2080000936985016, "blur_max": 20.0, - "glow": -9.978866728488356e-6, - "glow_width": 39.30601119995117, + "glow": -0.023217225447297096, + "glow_width": 0.5, "hairline": [ - 0.5050199031829834, -0.002001264365389943, 1.0410776138305664, - -0.00509136077016592 + 0.593367338180542, + -0.05059341341257095, + 1.0214955806732178, + 0.0020442637614905834 ], - "lens_curve": 0.3599303960800171, - "light_angle": 2.3112025260925293, - "line": 0.0018150180112570524, - "line_lit": 0.0017182374140247703, - "line_width": 0.994491457939148, + "lens_curve": 1.0, + "light_angle": -0.21393775939941406, + "line": 0.022001737728714943, + "line_lit": 4.008400719612837e-05, + "line_width": 0.977599024772644, "opacity": 0.6000000238418579, "poly": [ - 0.6313937902450562, 0.7283032536506653, -0.7202409505844116, - 0.7613860964775085, -2.0895676612854004, 1.455474615097046, - 0.11192644387483597, 0.08696344494819641, -0.08549253642559052, - -0.5280044078826904, 1.2200205326080322, -0.8582108616828918, - 0.09805495291948318, -0.07880198210477829, 0.8358877301216125, - -0.0669502466917038, 0.0785026028752327, -0.18421262502670288, - 0.11133701354265213, 0.045185938477516174, 0.023230290040373802, - -0.010092880576848984, -0.06803300976753235, 0.10296168178319931, - -0.0751410573720932, 0.09795161336660385, -0.17431367933750153, - -0.31713956594467163, 1.1713937520980835, 0.1505192369222641, - 0.10798941552639008, -0.3602425456047058, 0.06854856759309769, - 0.10419972240924835, 0.021173598244786263, -0.08950604498386383, - -0.09568458050489426, 0.12689803540706635, 0.09791429340839386 + 0.9304253458976746, + -0.12481314688920975, + -0.10088877379894257, + -0.004045877140015364, + -0.07585885375738144, + 0.14717964828014374, + -0.07393107563257217, + 0.06024130806326866, + -0.03879432752728462, + -0.000815805746242404, + 0.03807489201426506, + -0.07791057974100113, + 0.016926679760217667, + 0.0058880094438791275, + 0.03510991483926773, + -0.025886960327625275, + 0.016199838370084763, + -0.03267030790448189, + 0.01904219388961792, + 0.09808459132909775, + -0.00837408285588026, + 0.7795435786247253, + -0.07242636382579803, + -0.04168546944856644, + 0.04919590428471565, + 0.11554957926273346, + -0.059605952352285385, + -0.02326720952987671, + -0.007376297377049923, + -0.0005821718950755894, + -0.04080246761441231, + -0.05716409534215927, + 0.043717119842767715, + 0.023880789056420326, + 0.012365260161459446, + 0.026850342750549316, + -0.011099698953330517, + -0.011699579656124115, + -0.0034550407435745, + 0.0981355756521225, + -0.040654897689819336, + -0.15084543824195862, + 0.8951388597488403, + -0.00773319648578763, + -0.06471528857946396, + 0.08432544767856598, + 0.0036153215914964676, + 0.015856880694627762, + -0.013019285164773464, + -0.008954270742833614, + 0.056587692350149155, + -0.06041482836008072, + 0.033091913908720016, + 0.0052201892249286175, + -0.029888352379202843, + -0.02021406590938568, + 0.01316642202436924, + 0.017514236271381378, + -0.01460269745439291, + 0.0980868712067604 ], - "rim_k": 0.2883424758911133, - "rim_max": 24.872886657714844 + "rim_base": 3.5, + "rim_k": 0.039999820291996, + "rim_max": 7.899994850158691, + "tint": { + "high": [ + [ + -0.068, + -0.068, + -0.068, + -0.068, + -0.068 + ], + [ + -0.1654, + 0.043, + 0.043, + 0.1021, + 0.1161 + ], + [ + -0.1202, + 0.1449, + 0.1839, + 0.211, + 0.2499 + ], + [ + -0.0202, + 0.2129, + 0.2659, + 0.3087, + 0.3616 + ], + [ + 0.0908, + 0.2828, + 0.3405, + 0.4175, + 0.4843 + ], + [ + 0.1908, + 0.3508, + 0.4318, + 0.5262, + 0.6071 + ], + [ + 0.2908, + 0.4314, + 0.5405, + 0.6505, + 0.7616 + ], + [ + 0.4018, + 0.5174, + 0.6456, + 0.7749, + 0.9161 + ], + [ + 0.4908, + 0.5777, + 0.7453, + 0.9103, + 1.0044 + ], + [ + 0.6675, + 0.6379, + 0.8026, + 0.9573, + 1.0044 + ], + [ + 0.8162, + 0.5092, + 0.8019, + 0.9504, + 1.0044 + ], + [ + 0.9759, + 0.9759, + 0.9759, + 0.9759, + 0.9759 + ] + ], + "kind": "layer", + "low": [ + [ + -0.068, + -0.068, + -0.068, + -0.068, + -0.068 + ], + [ + -0.1654, + 0.043, + 0.043, + -0.0162, + -0.0302 + ], + [ + -0.1202, + 0.0719, + 0.0329, + 0.0092, + -0.0297 + ], + [ + -0.0202, + 0.1013, + 0.0482, + 0.0018, + -0.0511 + ], + [ + 0.0908, + 0.1434, + 0.0782, + 0.0054, + -0.0615 + ], + [ + 0.1908, + 0.1728, + 0.0917, + -0.0131, + -0.0939 + ], + [ + 0.2908, + 0.2046, + 0.1028, + -0.0032, + -0.1142 + ], + [ + 0.4018, + 0.2309, + 0.1102, + -0.0153, + -0.1565 + ], + [ + 0.4908, + 0.2536, + 0.1011, + -0.0822, + -0.1764 + ], + [ + 0.6675, + 0.3201, + 0.1707, + 0.0269, + -0.0202 + ], + [ + 0.8162, + 0.5092, + 0.2201, + 0.0576, + 0.014 + ], + [ + 0.9759, + 0.9759, + 0.9759, + 0.9759, + 0.9759 + ] + ] + } }, "clear-light": { - "bend": -2.4594221115112305, - "blur_k": 0.17999997735023499, + "bend_base": 0.0, + "bend_k": 0.5219979286193848, + "bend_max": 44.700042724609375, + "blur_k": 0.20799961686134338, "blur_max": 20.0, - "glow": -0.0408473014831543, + "glow": -0.07008297741413116, "glow_width": 0.5, "hairline": [ - 0.6450185179710388, -0.016545917838811874, 1.0230517387390137, - 0.008764644153416157 + 0.8242620229721069, + -0.1320314109325409, + 0.9752746820449829, + 0.04035332053899765 ], - "lens_curve": 0.318873792886734, - "light_angle": 2.6443076133728027, - "line": 0.01456276886165142, - "line_lit": 0.0015534033300355077, - "line_width": 1.097947359085083, + "lens_curve": 1.0, + "light_angle": -2.847440719604492, + "line": 0.041799671947956085, + "line_lit": -0.0001845387159846723, + "line_width": 0.8613333702087402, "opacity": 0.6000000238418579, "poly": [ - 0.2497211992740631, 0.7583296895027161, -0.36811646819114685, - 1.273596167564392, -1.9042826890945435, 0.8599693775177002, - -0.0514889694750309, 0.052962444722652435, -0.08220545202493668, - -0.7352914810180664, 1.1641223430633545, -0.5389814972877502, - 0.2785186767578125, -0.03988504037261009, 0.7219523787498474, - -0.007673143874853849, 0.03376581892371178, -0.050814464688301086, - 0.03104168362915516, 0.0132481399923563, -0.0016589678125455976, - -0.007338891737163067, -0.033974405378103256, 0.04808909818530083, - -0.028364654630422592, 0.27844366431236267, -0.0301153976470232, - 0.1788342148065567, 0.5316532850265503, 0.18545162677764893, - -0.6017318367958069, 0.36461755633354187, -0.07064586132764816, - 0.13311389088630676, -0.007584464270621538, -0.13774992525577545, - 0.3131157159805298, -0.1805034577846527, 0.2784975469112396 + 1.1434636116027832, + -0.27574145793914795, + -0.17895224690437317, + -0.0836833268404007, + 0.1225225031375885, + 0.1864597499370575, + -0.37334734201431274, + 0.4921723008155823, + -0.3727247416973114, + -0.12790314853191376, + -0.02433956041932106, + -0.05253470316529274, + 0.29910096526145935, + 0.11252079159021378, + 0.02607177011668682, + -0.18502143025398254, + 0.15787704288959503, + -0.2634184658527374, + 0.075639508664608, + 0.27857351303100586, + -0.03583918884396553, + 0.7502926588058472, + -0.02740371786057949, + -0.01758371666073799, + 0.02899535372853279, + 0.023246919736266136, + -0.019552607089281082, + -0.0588289238512516, + 0.0193873830139637, + -0.005965698044747114, + -0.034728698432445526, + -0.0037371099460870028, + 0.019717223942279816, + 0.017275473102927208, + 0.005123075097799301, + 0.04545024037361145, + -0.0219881571829319, + 0.006534123793244362, + -0.012194417417049408, + 0.27857986092567444, + -0.07504594326019287, + -0.19895681738853455, + 0.9592817425727844, + -0.06376186013221741, + 0.014718386344611645, + -0.15277686715126038, + 0.07323915511369705, + 0.006354332435876131, + 0.10439865291118622, + 0.04123719036579132, + 0.035259801894426346, + -0.11292031407356262, + 0.010606243275105953, + 0.0005314881564117968, + -9.800001862458885e-05, + -0.18178176879882812, + 0.060928262770175934, + 0.37380093336105347, + -0.21683937311172485, + 0.27857956290245056 ], - "rim_k": 0.2952793836593628, - "rim_max": 26.819894790649414 + "rim_base": 3.5, + "rim_k": 0.04000019654631615, + "rim_max": 7.90000057220459, + "tint": { + "high": [ + [ + -0.068, + -0.068, + -0.068, + -0.068, + -0.068 + ], + [ + -0.1654, + 0.043, + 0.043, + 0.1021, + 0.1161 + ], + [ + -0.1202, + 0.1449, + 0.1839, + 0.211, + 0.2499 + ], + [ + -0.0202, + 0.2129, + 0.2659, + 0.3087, + 0.3616 + ], + [ + 0.0908, + 0.2828, + 0.3405, + 0.4175, + 0.4843 + ], + [ + 0.1908, + 0.3508, + 0.4318, + 0.5262, + 0.6071 + ], + [ + 0.2908, + 0.4314, + 0.5405, + 0.6505, + 0.7616 + ], + [ + 0.4018, + 0.5174, + 0.6456, + 0.7749, + 0.9161 + ], + [ + 0.4908, + 0.5777, + 0.7453, + 0.9103, + 1.0044 + ], + [ + 0.6675, + 0.6379, + 0.8026, + 0.9573, + 1.0044 + ], + [ + 0.8162, + 0.5092, + 0.8019, + 0.9504, + 1.0044 + ], + [ + 0.9759, + 0.9759, + 0.9759, + 0.9759, + 0.9759 + ] + ], + "kind": "layer", + "low": [ + [ + -0.068, + -0.068, + -0.068, + -0.068, + -0.068 + ], + [ + -0.1654, + 0.043, + 0.043, + -0.0162, + -0.0302 + ], + [ + -0.1202, + 0.0719, + 0.0329, + 0.0092, + -0.0297 + ], + [ + -0.0202, + 0.1013, + 0.0482, + 0.0018, + -0.0511 + ], + [ + 0.0908, + 0.1434, + 0.0782, + 0.0054, + -0.0615 + ], + [ + 0.1908, + 0.1728, + 0.0917, + -0.0131, + -0.0939 + ], + [ + 0.2908, + 0.2046, + 0.1028, + -0.0032, + -0.1142 + ], + [ + 0.4018, + 0.2309, + 0.1102, + -0.0153, + -0.1565 + ], + [ + 0.4908, + 0.2536, + 0.1011, + -0.0822, + -0.1764 + ], + [ + 0.6675, + 0.3201, + 0.1707, + 0.0269, + -0.0202 + ], + [ + 0.8162, + 0.5092, + 0.2201, + 0.0576, + 0.014 + ], + [ + 0.9759, + 0.9759, + 0.9759, + 0.9759, + 0.9759 + ] + ] + } }, "regular-dark": { - "bend": -1.6669726371765137, + "adapt": { + "large": 104.5999984741211, + "poly": [ + -2.121854066848755, + 4.518572807312012, + -1.3435695171356201, + 0.776678204536438, + -9.252964973449707, + 0.28634369373321533, + 4.4394073486328125, + -1.3082177639007568, + 4.613157272338867, + 0.1885642260313034, + 4.356483459472656, + 2.1629786491394043, + -1.000849723815918, + -1.141960620880127, + -1.716369390487671, + 4.243387222290039, + -2.109083414077759, + -3.6303977966308594, + -1.3335038423538208, + 0.07930468022823334, + -0.06321439892053604, + 1.6568504571914673, + -0.5502462983131409, + 0.47407275438308716, + -0.5093795657157898, + 0.18556971848011017, + -1.3741369247436523, + 0.4993451237678528, + 0.30742496252059937, + -0.13183802366256714, + -0.43906792998313904, + 0.5691431164741516, + -0.260586142539978, + -0.2801496684551239, + 1.1938122510910034, + 0.6415687203407288, + 0.2897544205188751, + -1.767791986465454, + 0.18552877008914948, + 0.07926828414201736, + -0.7065837979316711, + -1.389380693435669, + 3.1453685760498047, + 1.1397459506988525, + 1.6847290992736816, + -3.872493028640747, + -0.043841056525707245, + 0.8904445767402649, + -0.2337447702884674, + 0.0994030013680458, + -0.5763757824897766, + 1.5949156284332275, + -1.1367660760879517, + -1.1690083742141724, + 0.5194790959358215, + -1.9919798374176025, + 0.5003051161766052, + 0.38825687766075134, + 1.7843526601791382, + 0.07925731688737869 + ], + "small": 64.0 + }, + "bend_base": 10.7000732421875, + "bend_k": 0.0820002481341362, + "bend_max": 26.200000762939453, "blur_k": 0.14000000059604645, "blur_max": 12.0, - "glow": 0.024336621165275574, - "glow_width": 51.54517364501953, + "glow": -0.0030187764205038548, + "glow_width": 0.5, "hairline": [ - 0.2600359320640564, 0.103030264377594, 0.5471664667129517, - 0.04482661187648773 + 0.1722264587879181, + 0.17626123130321503, + 0.48439785838127136, + 0.10737959295511246 ], - "lens_curve": 0.5919271111488342, - "light_angle": -2.782675266265869, - "line": 0.011403294280171394, - "line_lit": 0.011242559179663658, - "line_width": 0.3977275788784027, + "lens_curve": 1.0, + "light_angle": 3.786133289337158, + "line": -0.061254773288965225, + "line_lit": -0.001504245214164257, + "line_width": 0.30000001192092896, "opacity": 0.6000000238418579, "poly": [ - 1.1208113431930542, 0.769611120223999, -0.9219794273376465, - 0.1965949982404709, -1.9167686700820923, 1.746425747871399, - -0.3972877562046051, 0.10475105792284012, -0.1348458230495453, - -0.213811457157135, 1.0382335186004639, -1.0049047470092773, - 0.06521148979663849, -0.11392313241958618, 1.4205176830291748, - -0.28513824939727783, 0.0018862647702917457, -0.9691377878189087, - 0.4069260358810425, 0.0062097590416669846, -0.0784282386302948, - 0.034528374671936035, -0.00854511745274067, 0.1753605753183365, - -0.20346999168395996, 0.06513476371765137, -0.15799254179000854, - -0.39501243829727173, 1.5855051279067993, -0.05833205580711365, - 0.4637044072151184, -0.6609259247779846, 0.17491891980171204, - -0.48810815811157227, -0.048988454043865204, 0.038651589304208755, - -0.36601346731185913, 0.29942595958709717, 0.06502226740121841 + 1.1187022924423218, + 0.03367934003472328, + -0.10441520810127258, + -0.13338716328144073, + 0.29741689562797546, + 0.19946272671222687, + -0.5520410537719727, + -0.9366043210029602, + 0.44745180010795593, + 0.0030512954108417034, + -0.45639705657958984, + -0.0059659117832779884, + -0.07397174090147018, + 0.006216675043106079, + 0.13561110198497772, + 0.7081236839294434, + -0.3326506018638611, + 0.11330801993608475, + -0.0989147275686264, + 0.07836921513080597, + -0.060900237411260605, + 1.11102294921875, + -0.017686933279037476, + 0.029460275545716286, + -0.4791528880596161, + 0.013812395744025707, + -0.1606086790561676, + -0.02373814396560192, + -0.028927847743034363, + -0.017582418397068977, + -0.002322347601875663, + -0.009515616111457348, + 0.012796386145055294, + 0.0010435275034978986, + 0.0005133572849445045, + -0.019619032740592957, + 0.012490831315517426, + -0.012097016908228397, + 0.01967502385377884, + 0.0784754678606987, + -0.06453773379325867, + -0.00016743599553592503, + 1.1111270189285278, + 0.025668099522590637, + 0.23321135342121124, + 0.05709727853536606, + -0.4604215919971466, + -0.7881916761398315, + 0.26137059926986694, + -0.050472091883420944, + -0.392466276884079, + 0.03832476586103439, + 0.047914065420627594, + 0.004019365645945072, + 0.4532164931297302, + 0.2794245183467865, + -0.20846489071846008, + 0.008654672652482986, + -0.18662935495376587, + 0.07838035374879837 ], - "rim_k": 0.21779286861419678, - "rim_max": 17.012699127197266 + "rim_base": 4.0, + "rim_k": 0.07199979573488235, + "rim_max": 8.5, + "tint": { + "desaturate": 0.32, + "kind": "grey", + "shift": 0.101 + } }, "regular-light": { - "bend": -1.6906808614730835, - "blur_k": 0.14000000059604645, - "blur_max": 12.0, - "glow": -0.06975674629211426, - "glow_width": 0.5, + "bend_base": 10.70003604888916, + "bend_k": 0.08199961483478546, + "bend_max": 26.200000762939453, + "blur_k": 0.25999999046325684, + "blur_max": 13.5, + "glow": 0.0010122086387127638, + "glow_width": 2.685718297958374, "hairline": [ - 0.6683441400527954, 0.015820689499378204, 0.7885841131210327, - 0.007480344735085964 + 0.7884511947631836, + -0.04988262057304382, + 0.8663737773895264, + -0.03755432367324829 ], - "lens_curve": 0.5981463193893433, - "light_angle": 5.205510139465332, - "line": 0.01358448900282383, - "line_lit": -0.0012929972726851702, - "line_width": 1.5013341903686523, + "lens_curve": 1.0, + "light_angle": 0.20863288640975952, + "line": -0.15016056597232819, + "line_lit": 0.00040745738078840077, + "line_width": 0.30000001192092896, "opacity": 0.6000000238418579, "poly": [ - -0.138549342751503, 0.7256880402565002, -0.1833912432193756, - 1.8144844770431519, -2.4605472087860107, 0.6123811602592468, - 0.23255813121795654, 0.05579162761569023, -0.12572336196899414, - -1.1628233194351196, 1.4883190393447876, -0.43835973739624023, - 0.5214229226112366, -0.0343957282602787, 0.4276241362094879, - 0.04451335221529007, -0.1150304526090622, 0.21809197962284088, - -0.13724924623966217, 0.03976645693182945, -0.017057476565241814, - 0.0032571046613156796, 0.050204869359731674, -0.13996750116348267, - 0.0802760049700737, 0.5213379263877869, 0.10205833613872528, - 0.3425259590148926, -0.017469502985477448, 0.12757959961891174, - -1.446061372756958, 0.9996414184570312, -0.17898792028427124, - 0.5396572947502136, -0.002463854616507888, -0.1542862057685852, - 0.762222409248352, -0.6541531682014465, 0.5215706825256348 + 1.2788854837417603, + -0.8453500866889954, + 0.003082971554249525, + 0.2738867998123169, + -0.27242588996887207, + -0.500204861164093, + -0.4753827750682831, + 1.9009149074554443, + -0.9198514223098755, + -0.7914177775382996, + 0.7524295449256897, + 0.33018216490745544, + 1.1248490810394287, + 0.14570458233356476, + -0.5863457918167114, + -1.1137068271636963, + 0.4047931730747223, + -0.5792659521102905, + 0.2896379828453064, + 0.5211592316627502, + -0.036302655935287476, + 0.41237375140190125, + 0.05753712356090546, + -0.13990472257137299, + 0.19397875666618347, + -0.044933948665857315, + 0.13752412796020508, + 0.055802471935749054, + -0.1938532590866089, + 0.07925422489643097, + -0.11504264175891876, + -0.030976055189967155, + -0.05894238501787186, + 0.06816978752613068, + -0.0008735256269574165, + -0.02404271997511387, + 0.12015138566493988, + 0.011716355569660664, + -0.07116328924894333, + 0.5211941599845886, + -0.599159300327301, + -0.7419008612632751, + 1.7605870962142944, + 0.01254228688776493, + 0.24147452414035797, + -0.6960784792900085, + 1.0482441186904907, + -0.8146082162857056, + 0.2614218294620514, + 0.20659644901752472, + 0.24794399738311768, + -0.708295464515686, + -0.10671452432870865, + -0.4012254476547241, + -0.5572263598442078, + -0.5958108305931091, + 0.4865833520889282, + 1.8127862215042114, + -0.43663233518600464, + 0.5212134122848511 ], - "rim_k": 0.2092672884464264, - "rim_max": 15.961286544799805 + "rim_base": 4.0, + "rim_k": 0.07199995219707489, + "rim_max": 8.5, + "tint": { + "desaturate": 0.32, + "kind": "grey", + "shift": -0.103 + } } } diff --git a/apps/desktop-gpui/glass/src/bin/glass-fit.rs b/apps/desktop-gpui/glass/src/bin/glass-fit.rs index 2b628ba9c0..445cd565a2 100644 --- a/apps/desktop-gpui/glass/src/bin/glass-fit.rs +++ b/apps/desktop-gpui/glass/src/bin/glass-fit.rs @@ -1,9 +1,10 @@ //! Fits a `GlassMaterial` to Apple's reference captures. //! -//! glass-fit [style-appearance ...] +//! glass-fit [,...] [style-appearance ...] //! -//! Reads `/manifest.json`, `scenes/*.png` and `apple/*.png` -//! (from scripts/glass-reference) and writes `/params.json`, +//! Reads each `/manifest.json`, `scenes/*.png` and +//! `apple/*.png` (from scripts/glass-reference: `reference` and `sweep`) +//! and writes `/params.json`, //! `/report.json` and side-by-side renders. The colour transform is //! linear in its coefficients, so every evaluation solves it exactly by //! least squares; Nelder-Mead only searches blur, lensing and lighting. @@ -13,9 +14,54 @@ use std::path::{Path, PathBuf}; use cap_glass::{ Rgba, Shape, metrics, - model::{self, GlassMaterial, POLY_TERMS}, + model::{self, Adapt, GlassMaterial, POLY_TERMS}, }; +/// Where Regular dark glass switches between its small-pane and large-pane +/// colour transforms, in points on the shorter side: measured from the +/// `sweep` captures, where every pane up to 64 pt matches and every pane +/// from 128 pt matches, with 80 pt and 96 pt on one straight line between. +const ADAPT: [(&str, f32, f32); 1] = [("regular-dark", 64., 104.6)]; + +/// Apple's blur sigma, `min(max, k * shorter side)`, measured from the +/// edge in the `split` scene under the 48, 120 and 240 pt panes: a Gaussian +/// within 0.004 of Apple's edge profile for Clear. Regular's profile has a +/// heavier dark-side tail than any Gaussian; this is its best single fit. +const BLUR: [(&str, f32, f32); 2] = [("clear", 0.208, 20.), ("regular", 0.26, 13.5)]; + +/// Apple's refraction, measured from the `sweep lens` captures (UV ramp +/// under panes from 20 to 270 pt): decay length `[base, k, max]` and edge +/// bend `[base, k, max]`, in points of the shorter side. Light and dark +/// match. The fit keeps these fixed. +const LENS: [(&str, [f32; 3], [f32; 3]); 2] = [ + ("clear", [3.5, 0.04, 7.9], [0., 0.522, 44.7]), + ("regular", [4., 0.072, 8.5], [10.7, 0.082, 26.2]), +]; + +fn with_lens(preset: &str, material: GlassMaterial) -> GlassMaterial { + let (_, rim, bend) = LENS + .iter() + .find(|(style, ..)| preset.starts_with(style)) + .expect("every preset has a measured lens"); + GlassMaterial { + rim_base: rim[0], + rim_k: rim[1], + rim_max: rim[2], + bend_base: bend[0], + bend_k: bend[1], + bend_max: bend[2], + lens_curve: 1., + ..material + } +} + +fn adapt_for(preset: &str, poly: [f32; 3 * POLY_TERMS]) -> Option { + ADAPT + .iter() + .find(|(name, ..)| *name == preset) + .map(|&(_, small, large)| Adapt { small, large, poly }) +} + #[derive(serde::Deserialize)] struct Manifest { shapes: Vec, @@ -53,12 +99,16 @@ struct Case { style: String, appearance: String, tint: String, + /// The sweep's falling pass repeats its rising one. + #[serde(default)] + pass: String, } struct Sample { name: String, backdrop: Rgba, apple: Rgba, + shapes: Vec, /// One blurred crop per shape: (origin x, origin y, crop). crops: Vec<(usize, usize, Rgba)>, } @@ -70,24 +120,25 @@ const NEUTRAL_WEIGHT: f64 = 12.; fn main() { let args: Vec = std::env::args().collect(); - let reference = PathBuf::from(&args[1]); + let references: Vec<(PathBuf, Manifest)> = args[1] + .split(',') + .map(|dir| { + let dir = PathBuf::from(dir); + let manifest = + serde_json::from_slice(&std::fs::read(dir.join("manifest.json")).unwrap()).unwrap(); + (dir, manifest) + }) + .collect(); let out = PathBuf::from(&args[2]); std::fs::create_dir_all(out.join("renders")).unwrap(); - let manifest: Manifest = - serde_json::from_slice(&std::fs::read(reference.join("manifest.json")).unwrap()).unwrap(); - let scale = manifest.scale; + let scale = references[0].1.scale; + assert!( + references + .iter() + .all(|(_, manifest)| manifest.scale == scale), + "references must share a backing scale" + ); SCALE.set(scale).unwrap(); - let shapes: Vec = manifest - .shapes - .iter() - .map(|shape| Shape { - x: shape.rect[0] * scale, - y: shape.rect[1] * scale, - width: shape.rect[2] * scale, - height: shape.rect[3] * scale, - radius: shape.radius * scale, - }) - .collect(); let presets: Vec = if args.len() > 3 { args[3..].to_vec() } else { @@ -95,6 +146,9 @@ fn main() { .map(String::from) .to_vec() }; + // `GLASS_ALL_GREYS=1` scores every level of the sweep; the search uses + // every other one, which pins the tone curve as well at half the cost. + let all_greys = std::env::var_os("GLASS_ALL_GREYS").is_some(); let fixed: Option = std::env::var("GLASS_PARAMS") .ok() .and_then(|path| serde_json::from_slice(&std::fs::read(path).ok()?).ok()); @@ -102,77 +156,171 @@ fn main() { let mut fitted = serde_json::Map::new(); let mut report = serde_json::Map::new(); for preset in presets { - let mut samples: Vec = manifest - .cases + let mut samples: Vec = references .iter() - .filter(|case| { - format!("{}-{}", case.style, case.appearance) == preset - && case.tint == "none" - && case.scene != "uvfine" - }) - .map(|case| Sample { - name: case.name.clone(), - backdrop: Rgba::load(&reference.join(format!("scenes/{}.png", case.scene))).unwrap(), - apple: Rgba::load(&reference.join(format!("apple/{}.png", case.name))).unwrap(), - crops: Vec::new(), + .flat_map(|(reference, manifest)| { + let shapes: Vec = manifest + .shapes + .iter() + .map(|shape| Shape { + x: shape.rect[0] * scale, + y: shape.rect[1] * scale, + width: shape.rect[2] * scale, + height: shape.rect[3] * scale, + radius: shape.radius * scale, + }) + .collect(); + manifest + .cases + .iter() + .filter(|case| { + format!("{}-{}", case.style, case.appearance) == preset + && case.tint == "none" + && case.scene != "uvfine" + && case.pass != "down" + && (all_greys || sweep_level_kept(&case.scene)) + }) + .map(move |case| Sample { + name: case.name.clone(), + backdrop: Rgba::load(&reference.join(format!("scenes/{}.png", case.scene))) + .unwrap(), + apple: Rgba::load(&reference.join(format!("apple/{}.png", case.name))) + .unwrap(), + shapes: shapes.clone(), + crops: Vec::new(), + }) + .collect::>() }) .collect(); let material = match fixed .as_ref() .and_then(|all| serde_json::from_value::(all.get(&preset)?.clone()).ok()) - { + .map(|material| GlassMaterial { + adapt: material.adapt.or_else(|| adapt_for(&preset, material.poly)), + ..material + }) { // `GLASS_RESOLVE=1`: keep the given blur, lensing and lighting but // solve the colour transform again. Some(material) if std::env::var_os("GLASS_RESOLVE").is_some() => { - prepare(&mut samples, &shapes, &material); - let (loss, material) = evaluate(&material, &shapes, &samples); + prepare(&mut samples, &material); + let (loss, material) = evaluate(&material, &samples); eprintln!("{preset}: re-solved colour, loss {loss:.2}"); material } Some(material) => material, - None => fit(&preset, &mut samples, &shapes), + None => fit(&preset, &mut samples), }; eprintln!("{preset}:\n{material:#?}"); - prepare(&mut samples, &shapes, &material); - let mask = region_mask(&shapes, &samples[0].backdrop); + prepare(&mut samples, &material); let mut scores = serde_json::Map::new(); for sample in &samples { - let rendered = render_sample(&material, &shapes, sample); + let mask = region_mask(&sample.shapes, &sample.backdrop); + let rendered = render_sample(&material, sample); let ssim = metrics::ssim(&rendered, &sample.apple, &mask); let (mean, p95) = metrics::delta_e(&rendered, &sample.apple, &mask); scores.insert( sample.name.clone(), serde_json::json!({ "ssim": ssim, "deltaE": mean, "deltaE95": p95 }), ); - side_by_side(&rendered, &sample.apple, &out.join(format!("renders/{}.png", sample.name))); + side_by_side( + &rendered, + &sample.apple, + &out.join(format!("renders/{}.png", sample.name)), + ); } fitted.insert(preset.clone(), serde_json::to_value(material).unwrap()); report.insert(preset, serde_json::Value::Object(scores)); - std::fs::write(out.join("params.json"), serde_json::to_string_pretty(&fitted).unwrap()).unwrap(); - std::fs::write(out.join("report.json"), serde_json::to_string_pretty(&report).unwrap()).unwrap(); + std::fs::write( + out.join("params.json"), + serde_json::to_string_pretty(&fitted).unwrap(), + ) + .unwrap(); + std::fs::write( + out.join("report.json"), + serde_json::to_string_pretty(&report).unwrap(), + ) + .unwrap(); } } -fn fit(preset: &str, samples: &mut [Sample], shapes: &[Shape]) -> GlassMaterial { - let start = GlassMaterial { - blur_k: 0.19, - blur_max: 21., - rim_k: 0.24, - rim_max: 24., - bend: -2., - lens_curve: 0.4, - poly: [0.; 3 * POLY_TERMS], - opacity: 0.6, - line: 0., - line_lit: 0., - line_width: 1., - light_angle: -2.3, - glow: 0., - glow_width: 4., - hairline: [0.85, -0.12, 0.9, -0.08], - }; +/// `GLASS_NEUTRAL_WEIGHT` and `GLASS_SWEEP_WEIGHT` override how much the +/// flat scenes and the sweep's greys count in the colour fit. +fn neutral_weight() -> f64 { + std::env::var("GLASS_NEUTRAL_WEIGHT") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(NEUTRAL_WEIGHT) +} + +fn sweep_weight() -> f64 { + std::env::var("GLASS_SWEEP_WEIGHT") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(1.) +} + +/// The sweep's greys the search uses: every 32 levels, plus white. +fn sweep_level_kept(scene: &str) -> bool { + scene + .strip_prefix("grey") + .and_then(|level| level.parse::().ok()) + .is_none_or(|level| level % 32 == 0 || level == 255) +} + +fn fit(preset: &str, samples: &mut [Sample]) -> GlassMaterial { + let start = with_lens( + preset, + GlassMaterial { + blur_k: 0.19, + blur_max: 21., + rim_base: 0., + rim_k: 0., + rim_max: 0., + bend_base: 0., + bend_k: 0., + bend_max: 0., + lens_curve: 1., + poly: [0.; 3 * POLY_TERMS], + opacity: 0.6, + line: 0., + line_lit: 0., + line_width: 1., + light_angle: -2.3, + glow: 0., + glow_width: 4., + hairline: [0.85, -0.12, 0.9, -0.08], + adapt: adapt_for(preset, [0.; 3 * POLY_TERMS]), + tint: None, + }, + ); + if std::env::var_os("GLASS_SEARCH_BLUR").is_none() { + // `GLASS_BLUR=,` overrides the measured law. + let &(_, measured_k, measured_max) = BLUR + .iter() + .find(|(style, ..)| preset.starts_with(style)) + .expect("every preset has a measured blur"); + let (blur_k, blur_max) = std::env::var("GLASS_BLUR") + .ok() + .and_then(|law| { + let (k, max) = law.split_once(',')?; + Some((k.parse().ok()?, max.parse().ok()?)) + }) + .unwrap_or((measured_k, measured_max)); + let (loss, material) = fit_at_law( + samples, + GlassMaterial { + blur_k, + blur_max, + ..start + }, + ); + eprintln!( + "{preset}: measured blur {blur_k:.3} x side, max {blur_max:.1} -> loss {loss:.2}" + ); + return material; + } let mut best: Option<(f64, GlassMaterial)> = None; let mut try_law = |blur_k: f32, blur_max: f32, best: &mut Option<(f64, GlassMaterial)>| { let seed = GlassMaterial { @@ -180,7 +328,7 @@ fn fit(preset: &str, samples: &mut [Sample], shapes: &[Shape]) -> GlassMaterial blur_max, ..best.map_or(start, |(_, material)| material) }; - let (loss, material) = fit_at_law(samples, shapes, seed); + let (loss, material) = fit_at_law(samples, seed); eprintln!("{preset}: blur {blur_k:.3} x side, max {blur_max:.1} -> loss {loss:.2}"); if best.is_none_or(|(current, _)| loss < current) { *best = Some((loss, material)); @@ -196,7 +344,11 @@ fn fit(preset: &str, samples: &mut [Sample], shapes: &[Shape]) -> GlassMaterial for _ in 0..2 { let law = best.unwrap().1; for (dk, dm) in [(-0.02, 0.), (0.02, 0.), (0., -2.), (0., 2.)] { - try_law((law.blur_k + dk).max(0.01), (law.blur_max + dm).max(1.), &mut best); + try_law( + (law.blur_k + dk).max(0.01), + (law.blur_max + dm).max(1.), + &mut best, + ); } } best.unwrap().1 @@ -212,19 +364,22 @@ fn crop(image: &Rgba, x0: usize, y0: usize, x1: usize, y1: usize) -> Rgba { out } -fn prepare(samples: &mut [Sample], shapes: &[Shape], material: &GlassMaterial) { +fn prepare(samples: &mut [Sample], material: &GlassMaterial) { std::thread::scope(|scope| { for sample in samples.iter_mut() { scope.spawn(move || { - sample.crops = shapes + sample.crops = sample + .shapes .iter() .map(|shape| { let blur = style_for(material, shape).blur; let pad = PAD * SCALE.get().unwrap_or(&1.) + blur * 3.; let x0 = (shape.x - pad).max(0.) as usize; let y0 = (shape.y - pad).max(0.) as usize; - let x1 = ((shape.x + shape.width + pad) as usize).min(sample.backdrop.width); - let y1 = ((shape.y + shape.height + pad) as usize).min(sample.backdrop.height); + let x1 = + ((shape.x + shape.width + pad) as usize).min(sample.backdrop.width); + let y1 = + ((shape.y + shape.height + pad) as usize).min(sample.backdrop.height); (x0, y0, crop(&sample.backdrop, x0, y0, x1, y1).blurred(blur)) }) .collect(); @@ -233,18 +388,22 @@ fn prepare(samples: &mut [Sample], shapes: &[Shape], material: &GlassMaterial) { }); } -/// Visit glass pixels with their global position and weight. With `sparse`, -/// pixels deeper than 12 px inside are visited on every other row and -/// column at four times the weight: they carry far less than the rim does. +/// Visit glass pixels with their global position, weight and how much of +/// the small-pane colour transform their pane uses. With `sparse`, pixels +/// deeper than 12 px inside are visited on every other row and column at +/// four times the weight: they carry far less than the rim does. fn for_each_glass_pixel( material: &GlassMaterial, - shapes: &[Shape], sample: &Sample, sparse: bool, - mut visit: impl FnMut(&model::GlassSample, &model::PaneStyle, usize, usize, f64), + mut visit: impl FnMut(&model::GlassSample, &model::PaneStyle, usize, usize, f64, f32), ) { - for (shape, (ox, oy, blurred)) in shapes.iter().zip(&sample.crops) { + let scale = *SCALE.get().unwrap_or(&1.); + for (shape, (ox, oy, blurred)) in sample.shapes.iter().zip(&sample.crops) { let style = style_for(material, shape); + let small = material.adapt.map_or(0., |adapt| { + adapt.weight(shape.width.min(shape.height) / scale) + }); let local = Shape { x: shape.x - *ox as f32, y: shape.y - *oy as f32, @@ -267,21 +426,26 @@ fn for_each_glass_pixel( }; let glass = model::sample(&style, &local, blurred, px, py); if glass.coverage > 0. || glass.ring > 0. { - visit(&glass, &style, x + ox, y + oy, weight); + visit(&glass, &style, x + ox, y + oy, weight, small); } } } } } -fn render_sample(material: &GlassMaterial, shapes: &[Shape], sample: &Sample) -> Rgba { +fn render_sample(material: &GlassMaterial, sample: &Sample) -> Rgba { let mut out = sample.backdrop.clone(); - for_each_glass_pixel(material, shapes, sample, false, |glass, style, x, y, _| { + for_each_glass_pixel(material, sample, false, |glass, style, x, y, _, _| { let under = sample.backdrop.get(x, y); let under = model::under_with_hairline(glass, [under[0] / 255., under[1] / 255., under[2] / 255.]); - let color = if glass.coverage > 0. { model::finish(style, glass) } else { under }; - let mix = |i: usize| (under[i] + (color[i].clamp(0., 1.) - under[i]) * glass.coverage) * 255.; + let color = if glass.coverage > 0. { + model::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.]); }); out @@ -289,10 +453,14 @@ fn render_sample(material: &GlassMaterial, shapes: &[Shape], sample: &Sample) -> /// Normal equations of the colour transform over every covered pixel: /// `coverage * scale * features . poly = target - (1 - coverage) * under - -/// coverage * glow`, per channel, plus what the residual needs. +/// coverage * glow`, per channel, plus what the residual needs. With size +/// adaptation the features come twice, weighted by how much of the large- +/// and the small-pane transform the pixel's pane uses, so both are solved +/// together. struct Normal { - ata: [[f64; POLY_TERMS]; POLY_TERMS], - atb: [[f64; 3]; POLY_TERMS], + terms: usize, + ata: Vec, + atb: Vec<[f64; 3]>, btb: [f64; 3], count: usize, /// Squared error of hairline-only pixels, which the transform cannot @@ -302,10 +470,11 @@ struct Normal { } impl Normal { - fn new() -> Self { + fn new(terms: usize) -> Self { Self { - ata: [[0.; POLY_TERMS]; POLY_TERMS], - atb: [[0.; 3]; POLY_TERMS], + terms, + ata: vec![0.; terms * terms], + atb: vec![[0.; 3]; terms], btb: [0.; 3], count: 0, fixed_sse: 0., @@ -314,12 +483,12 @@ impl Normal { } fn add(&mut self, other: &Self) { - for i in 0..POLY_TERMS { - for j in 0..POLY_TERMS { - self.ata[i][j] += other.ata[i][j]; - } + for (a, b) in self.ata.iter_mut().zip(&other.ata) { + *a += b; + } + for (a, b) in self.atb.iter_mut().zip(&other.atb) { for c in 0..3 { - self.atb[i][c] += other.atb[i][c]; + a[c] += b[c]; } } for c in 0..3 { @@ -330,119 +499,150 @@ impl Normal { self.fixed_count += other.fixed_count; } - /// Least-squares coefficients and the mean squared error per channel - /// value on the 0-255 scale. - fn solve(&self) -> ([f32; 3 * POLY_TERMS], f64) { - let mut a = self.ata; - for (i, row) in a.iter_mut().enumerate() { - row[i] += 1e-6; + /// Least-squares coefficients, `terms` per channel, and the mean + /// squared error per channel value on the 0-255 scale. + fn solve(&self) -> (Vec<[f32; 3]>, f64) { + let n = self.terms; + let mut a = self.ata.clone(); + for i in 0..n { + a[i * n + i] += 1e-6; } - let mut poly = [0f32; 3 * POLY_TERMS]; + let mut coefficients = vec![[0f32; 3]; n]; let mut sse = 0.; for c in 0..3 { - let b: [f64; POLY_TERMS] = std::array::from_fn(|i| self.atb[i][c]); - let x = solve_linear(a, b); + let b: Vec = self.atb.iter().map(|row| row[c]).collect(); + let x = solve_linear(a.clone(), b.clone(), n); let mut xax = 0.; let mut xb = 0.; - for i in 0..POLY_TERMS { - poly[c * POLY_TERMS + i] = x[i] as f32; + for i in 0..n { + coefficients[i][c] = x[i] as f32; xb += x[i] * b[i]; - for j in 0..POLY_TERMS { - xax += x[i] * self.ata[i][j] * x[j]; + for j in 0..n { + xax += x[i] * self.ata[i * n + j] * x[j]; } } sse += self.btb[c] - 2. * xb + xax; } let total = sse.max(0.) + self.fixed_sse; let values = 3 * (self.count + self.fixed_count).max(1); - (poly, total / values as f64 * 255. * 255.) + (coefficients, total / values as f64 * 255. * 255.) } } -fn solve_linear( - mut m: [[f64; POLY_TERMS]; POLY_TERMS], - mut v: [f64; POLY_TERMS], -) -> [f64; POLY_TERMS] { - for col in 0..POLY_TERMS { - let pivot = (col..POLY_TERMS) - .max_by(|a, b| m[*a][col].abs().total_cmp(&m[*b][col].abs())) +fn solve_linear(mut m: Vec, mut v: Vec, n: usize) -> Vec { + for col in 0..n { + let pivot = (col..n) + .max_by(|a, b| m[*a * n + col].abs().total_cmp(&m[*b * n + col].abs())) .unwrap(); - m.swap(col, pivot); + for k in 0..n { + m.swap(col * n + k, pivot * n + k); + } v.swap(col, pivot); - for row in 0..POLY_TERMS { - if row != col && m[col][col] != 0. { - let factor = m[row][col] / m[col][col]; - for k in 0..POLY_TERMS { - m[row][k] -= factor * m[col][k]; + for row in 0..n { + if row != col && m[col * n + col] != 0. { + let factor = m[row * n + col] / m[col * n + col]; + for k in 0..n { + m[row * n + k] -= factor * m[col * n + k]; } v[row] -= factor * v[col]; } } } - std::array::from_fn(|i| if m[i][i] == 0. { 0. } else { v[i] / m[i][i] }) + (0..n) + .map(|i| { + if m[i * n + i] == 0. { + 0. + } else { + v[i] / m[i * n + i] + } + }) + .collect() } -fn normal_equations(material: &GlassMaterial, shapes: &[Shape], samples: &[Sample]) -> Normal { +fn normal_equations(material: &GlassMaterial, samples: &[Sample]) -> Normal { + let blocks = if material.adapt.is_some() { 2 } else { 1 }; + let terms = POLY_TERMS * blocks; std::thread::scope(|scope| { let handles: Vec<_> = samples .iter() .map(|sample| { scope.spawn(move || { - let mut normal = Normal::new(); + let mut normal = Normal::new(terms); // Flat backdrops pin the grey axis, where any error shows // most; a handful of colourful scenes would otherwise // outvote them. let neutral = ["white-", "black-", "gray-"] .iter() .any(|prefix| sample.name.starts_with(prefix)); - let scene_weight = if neutral { NEUTRAL_WEIGHT } else { 1. }; - for_each_glass_pixel(material, shapes, sample, true, |glass, _, x, y, weight| { - let weight = weight * scene_weight; - let under = sample.backdrop.get(x, y); - let under = model::under_with_hairline( - glass, - [under[0] / 255., under[1] / 255., under[2] / 255.], - ); - let target = sample.apple.get(x, y); - if glass.coverage <= 0. { - for c in 0..3 { - let error = under[c] as f64 - target[c] as f64 / 255.; - normal.fixed_sse += weight * error * error; - } - normal.fixed_count += weight as usize; - return; - } - let features = model::poly_features(glass.source); - let scale = (glass.coverage * glass.scale) as f64; - let row: [f64; POLY_TERMS] = - std::array::from_fn(|i| features[i] as f64 * scale); - for i in 0..POLY_TERMS { - for j in i..POLY_TERMS { - normal.ata[i][j] += weight * row[i] * row[j]; + let scene_weight = if neutral { neutral_weight() } else { 1. } + * if sample.name.starts_with("grey") { + sweep_weight() + } else { + 1. + }; + let mut row = vec![0f64; terms]; + for_each_glass_pixel( + material, + sample, + true, + |glass, _, x, y, weight, small| { + let weight = weight * scene_weight; + let under = sample.backdrop.get(x, y); + let under = model::under_with_hairline( + glass, + [under[0] / 255., under[1] / 255., under[2] / 255.], + ); + let target = sample.apple.get(x, y); + if glass.coverage <= 0. { + for c in 0..3 { + let error = under[c] as f64 - target[c] as f64 / 255.; + normal.fixed_sse += weight * error * error; + } + normal.fixed_count += weight as usize; + return; } - } - let coverage = glass.coverage as f64; - for c in 0..3 { - let b = target[c] as f64 / 255. - - (1. - coverage) * under[c] as f64 - - coverage * glass.glow as f64; + let features = model::poly_features(glass.source); + let scale = (glass.coverage * glass.scale) as f64; for i in 0..POLY_TERMS { - normal.atb[i][c] += weight * row[i] * b; + let feature = features[i] as f64 * scale; + if blocks == 1 { + row[i] = feature; + } else { + row[i] = feature * (1. - small as f64); + row[POLY_TERMS + i] = feature * small as f64; + } } - normal.btb[c] += weight * b * b; - } - normal.count += weight as usize; - }); - for i in 0..POLY_TERMS { + for i in 0..terms { + if row[i] == 0. { + continue; + } + for j in i..terms { + normal.ata[i * terms + j] += weight * row[i] * row[j]; + } + } + let coverage = glass.coverage as f64; + for c in 0..3 { + let b = target[c] as f64 / 255. + - (1. - coverage) * under[c] as f64 + - coverage * glass.glow as f64; + for i in 0..terms { + normal.atb[i][c] += weight * row[i] * b; + } + normal.btb[c] += weight * b * b; + } + normal.count += weight as usize; + }, + ); + for i in 0..terms { for j in 0..i { - normal.ata[i][j] = normal.ata[j][i]; + normal.ata[i * terms + j] = normal.ata[j * terms + i]; } } normal }) }) .collect(); - let mut total = Normal::new(); + let mut total = Normal::new(terms); for handle in handles { total.add(&handle.join().unwrap()); } @@ -450,23 +650,30 @@ fn normal_equations(material: &GlassMaterial, shapes: &[Shape], samples: &[Sampl }) } -fn evaluate(material: &GlassMaterial, shapes: &[Shape], samples: &[Sample]) -> (f64, GlassMaterial) { - let (poly, loss) = normal_equations(material, shapes, samples).solve(); - (loss, GlassMaterial { poly, ..*material }) +fn evaluate(material: &GlassMaterial, samples: &[Sample]) -> (f64, GlassMaterial) { + let (coefficients, loss) = normal_equations(material, samples).solve(); + let mut material = *material; + for c in 0..3 { + for i in 0..POLY_TERMS { + material.poly[c * POLY_TERMS + i] = coefficients[i][c]; + if let Some(adapt) = &mut material.adapt { + adapt.poly[c * POLY_TERMS + i] = coefficients[POLY_TERMS + i][c]; + } + } + } + (loss, material) } /// Fit everything but the blur law, which stays fixed so each backdrop is /// blurred once. -fn fit_at_law( - samples: &mut [Sample], - shapes: &[Shape], - start: GlassMaterial, -) -> (f64, GlassMaterial) { - prepare(samples, shapes, &start); +fn fit_at_law(samples: &mut [Sample], start: GlassMaterial) -> (f64, GlassMaterial) { + prepare(samples, &start); let samples: &[Sample] = samples; - let objective = |values: &[f32]| evaluate(&start.with_shape(values), shapes, samples).0; + let objective = |values: &[f32]| evaluate(&start.with_shape(values), samples).0; + // Blur is searched outside, the lens is measured: only lighting and the + // edge line move here. let scales = [ - 0., 0., 0.05, 6., 0.6, 0.2, 0.04, 0.04, 0.5, 0.8, 0.04, 3., 0.08, 0.05, 0.08, 0.05, + 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.04, 0.04, 0.5, 0.8, 0.04, 3., 0.08, 0.05, 0.08, 0.05, ]; let mut point = start.shape_vec().to_vec(); let mut value = objective(&point); @@ -478,13 +685,16 @@ fn fit_at_law( value = next_value; point = next; } - evaluate(&start.with_shape(&point), shapes, samples) + evaluate(&start.with_shape(&point), samples) } fn region_mask(shapes: &[Shape], image: &Rgba) -> Vec { (0..image.width * image.height) .map(|index| { - let (x, y) = ((index % image.width) as f32 + 0.5, (index / image.width) as f32 + 0.5); + let (x, y) = ( + (index % image.width) as f32 + 0.5, + (index / image.width) as f32 + 0.5, + ); shapes.iter().any(|shape| shape.sdf(x, y) < 1.5) }) .collect() @@ -536,8 +746,11 @@ fn nelder_mead( } else { let best = simplex[0].1.clone(); for vertex in simplex.iter_mut().skip(1) { - let shrunk: Vec = - best.iter().zip(&vertex.1).map(|(b, v)| b + 0.5 * (v - b)).collect(); + let shrunk: Vec = best + .iter() + .zip(&vertex.1) + .map(|(b, v)| b + 0.5 * (v - b)) + .collect(); *vertex = (objective(&shrunk), shrunk); } } diff --git a/apps/desktop-gpui/glass/src/model.rs b/apps/desktop-gpui/glass/src/model.rs index c767ea06ab..4af498f963 100644 --- a/apps/desktop-gpui/glass/src/model.rs +++ b/apps/desktop-gpui/glass/src/model.rs @@ -1,10 +1,12 @@ use crate::{Rgba, Shape}; -/// Terms of the colour transform, per output channel: -/// `r, g, b, r², g², b², rg, gb, rb, r³, g³, b³, 1` of the blurred backdrop -/// (0-1 sRGB). The cubes let it follow Apple's tone curve along the grey -/// axis, which a quadratic misses by several levels at mid grey. -pub const POLY_TERMS: usize = 13; +/// Terms of the colour transform, per output channel: the full cubic in +/// `r, g, b` of the blurred backdrop (0-1 sRGB), constant last. The cubes +/// follow Apple's tone curve along the grey axis; the mixed cubic terms let +/// that curve act on brightness without bending hue, which Apple's Regular +/// glass needs over saturated content (it lifts a light blue's blue channel +/// past what white comes out as). +pub const POLY_TERMS: usize = 20; pub fn poly_features(c: [f32; 3]) -> [f32; POLY_TERMS] { let [r, g, b] = c; @@ -21,6 +23,13 @@ pub fn poly_features(c: [f32; 3]) -> [f32; POLY_TERMS] { r * r * r, g * g * g, b * b * b, + r * r * g, + r * r * b, + g * g * r, + g * g * b, + b * b * r, + b * b * g, + r * g * b, 1., ] } @@ -33,13 +42,20 @@ pub struct GlassMaterial { /// Blur sigma: `min(blur_max, blur_k * shorter side)`. pub blur_k: f32, pub blur_max: f32, - /// Width of the lensing rim: `min(rim_max, rim_k * shorter side)`. + /// Decay length of the lensing rim: + /// `min(rim_max, rim_base + rim_k * shorter side)`. + pub rim_base: f32, pub rim_k: f32, pub rim_max: f32, - /// How far the rim bends light along the normal, in rim widths - /// (negative pulls from inside the pane). - pub bend: f32, - /// Exponent of the bend's falloff across the rim. + /// How far the edge pulls light in from inside the pane: + /// `min(bend_max, bend_base + bend_k * shorter side)`. Apple's grows with + /// the pane up to a cap, independently of the decay length. + pub bend_base: f32, + pub bend_k: f32, + pub bend_max: f32, + /// Shape of the falloff: the bend at depth `d` is + /// `exp(-(d / decay)^lens_curve)` of the edge's, so 1 is the + /// exponential falloff Apple's captures follow. pub lens_curve: f32, /// Second-order colour transform of the blurred backdrop: for each output /// channel, coefficients of [`poly_features`]. It carries the frost, the @@ -64,6 +80,153 @@ pub struct GlassMaterial { /// between vertical edges (`[a, b]` first) and horizontal ones by the /// edge normal. pub hairline: [f32; 4], + /// Size adaptation: Regular glass in dark mode lifts small panes over + /// bright content. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapt: Option, + /// How a tint colours this material. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tint: Option, +} + +/// Rec. 709 luma of a gamma-encoded colour: the weights Apple's tint mixes +/// with, which fit its captures better than Rec. 601 or linear light. +pub const LUMA: [f32; 3] = [0.2126, 0.7152, 0.0722]; + +/// How a tint (an sRGB colour and an alpha) changes the finished glass +/// colour `u`, measured from Apple's tinted glass (`sweep tints` and +/// `sweep levels`). Either way it comes down to a colour matrix, +/// `u * scale + luma(u) * luma + offset` per channel: see [`TintMatrix`]. +#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum TintLaw { + /// Regular glass ignores the tint's colour: it mixes `desaturate * + /// alpha` of the way towards the luma and shifts by `shift * alpha`. + Grey { desaturate: f32, shift: f32 }, + /// Clear glass alpha-blends a tint layer in sRGB. The layer is the tint + /// itself over white and a darker, more saturated colour of the same hue + /// over black, following the luma in between. Over black, the tint's + /// brightest and darkest channels land on `high` and `low`, looked up by + /// the tint's HSL lightness (rows, [`TINT_LIGHTNESS`]) and saturation + /// (columns, [`TINT_SATURATION`]); the other channel keeps its place + /// between them. Values below 0 are kept: they clamp per channel. + Layer { + high: [[f32; 5]; 12], + low: [[f32; 5]; 12], + }, +} + +/// The HSL grid Clear's tint table is measured on (`sweep hsl`). +pub const TINT_LIGHTNESS: [f32; 12] = [0., 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 1.]; +pub const TINT_SATURATION: [f32; 5] = [0., 0.25, 0.5, 0.75, 1.]; + +/// Bilinear lookup in a table over [`TINT_LIGHTNESS`] x [`TINT_SATURATION`]. +fn tint_lookup(table: &[[f32; 5]; 12], lightness: f32, saturation: f32) -> f32 { + fn cell(axis: &[f32], value: f32) -> (usize, f32) { + let value = value.clamp(axis[0], axis[axis.len() - 1]); + let index = axis + .windows(2) + .position(|pair| value <= pair[1]) + .unwrap_or(axis.len() - 2); + ( + index, + (value - axis[index]) / (axis[index + 1] - axis[index]), + ) + } + let (row, along_l) = cell(&TINT_LIGHTNESS, lightness); + let (column, along_s) = cell(&TINT_SATURATION, saturation); + let mix = |a: f32, b: f32, t: f32| a + (b - a) * t; + mix( + mix(table[row][column], table[row][column + 1], along_s), + mix(table[row + 1][column], table[row + 1][column + 1], along_s), + along_l, + ) +} + +/// A tint resolved to what the renderers apply to the finished colour. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TintMatrix { + pub scale: [f32; 3], + pub luma: [f32; 3], + pub offset: [f32; 3], +} + +impl TintMatrix { + pub const NONE: Self = Self { + scale: [1.; 3], + luma: [0.; 3], + offset: [0.; 3], + }; + + pub fn apply(&self, u: [f32; 3]) -> [f32; 3] { + let y = u[0] * LUMA[0] + u[1] * LUMA[1] + u[2] * LUMA[2]; + std::array::from_fn(|c| u[c] * self.scale[c] + y * self.luma[c] + self.offset[c]) + } +} + +impl TintLaw { + /// `color` is sRGB 0-1, `alpha` 0-1. + pub fn matrix(&self, color: [f32; 3], alpha: f32) -> TintMatrix { + let alpha = alpha.clamp(0., 1.); + match *self { + TintLaw::Grey { desaturate, shift } => { + let mix = desaturate * alpha; + TintMatrix { + scale: [1. - mix; 3], + luma: [mix; 3], + offset: [shift * alpha; 3], + } + } + TintLaw::Layer { high, low } => { + let t = color.map(|c| c.clamp(0., 1.)); + let (brightest, darkest) = (t[0].max(t[1]).max(t[2]), t[0].min(t[1]).min(t[2])); + let lightness = (brightest + darkest) / 2.; + let spread = 1. - (2. * lightness - 1.).abs(); + let saturation = if spread > 1e-6 { + (brightest - darkest) / spread + } else { + 0. + }; + let (to_high, to_low) = ( + tint_lookup(&high, lightness, saturation), + tint_lookup(&low, lightness, saturation), + ); + let over_black = t.map(|c| { + let place = if brightest - darkest > 1e-6 { + (c - darkest) / (brightest - darkest) + } else { + 0.5 + }; + (to_low + (to_high - to_low) * place).clamp(0., 1.) + }); + TintMatrix { + scale: [1. - alpha; 3], + luma: std::array::from_fn(|c| alpha * (t[c] - over_black[c])), + offset: over_black.map(|n| alpha * n), + } + } + } + } +} + +/// A second colour transform for small panes. Below `small` points on the +/// shorter side a pane uses `poly`, above `large` the material's own, and +/// in between a linear blend of the two. Apple's captures show no +/// dependence on the backdrop beyond the transform itself, and no +/// hysteresis, so the blend is fixed per pane size. +#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct Adapt { + pub small: f32, + pub large: f32, + #[serde(with = "serde_poly")] + pub poly: [f32; 3 * POLY_TERMS], +} + +impl Adapt { + /// How much of the small-pane transform a pane uses. + pub fn weight(&self, shorter: f32) -> f32 { + ((self.large - shorter) / (self.large - self.small).max(1e-3)).clamp(0., 1.) + } } mod serde_poly { @@ -78,7 +241,7 @@ mod serde_poly { let values = Vec::::deserialize(d)?; values .try_into() - .map_err(|_| serde::de::Error::custom("poly needs 39 coefficients")) + .map_err(|_| serde::de::Error::custom("poly needs 3 x POLY_TERMS coefficients")) } } @@ -99,6 +262,8 @@ pub struct PaneStyle { pub glow: f32, pub glow_width: f32, pub hairline: [f32; 4], + /// Tint on the finished colour; [`TintMatrix::NONE`] when untinted. + pub tint: TintMatrix, } impl PaneStyle { @@ -118,17 +283,29 @@ impl PaneStyle { impl GlassMaterial { /// Parameters the optimiser searches; the colour transform is solved. - pub const SHAPE_FIELDS: usize = 16; + pub const SHAPE_FIELDS: usize = 19; + + /// The colour transform a pane of this shorter side uses. + pub fn poly_for(&self, shorter: f32) -> [f32; 3 * POLY_TERMS] { + match &self.adapt { + Some(adapt) => { + let weight = adapt.weight(shorter); + std::array::from_fn(|i| self.poly[i] + (adapt.poly[i] - self.poly[i]) * weight) + } + None => self.poly, + } + } pub fn resolve(&self, width: f32, height: f32) -> PaneStyle { let shorter = width.min(height).max(1.); - let lens_rim = (self.rim_k * shorter).clamp(0.5, self.rim_max.max(0.5)); + let lens_rim = (self.rim_base + self.rim_k * shorter).clamp(0.5, self.rim_max.max(0.5)); + let bend = (self.bend_base + self.bend_k * shorter).clamp(0., self.bend_max.max(0.)); PaneStyle { blur: (self.blur_k * shorter).clamp(0., self.blur_max.max(0.)), lens_rim, lens_curve: self.lens_curve, - rim_bend: self.bend * lens_rim, - poly: self.poly, + rim_bend: -bend, + poly: self.poly_for(shorter), opacity: self.opacity, line: self.line, line_lit: self.line_lit, @@ -137,16 +314,27 @@ impl GlassMaterial { glow: self.glow, glow_width: self.glow_width, hairline: self.hairline, + tint: TintMatrix::NONE, } } + /// The tint `color` (sRGB 0-1) at `alpha` on this material, or no tint + /// for a material without a measured tint law. + pub fn tint_matrix(&self, color: [f32; 3], alpha: f32) -> TintMatrix { + self.tint + .map_or(TintMatrix::NONE, |law| law.matrix(color, alpha)) + } + pub fn shape_vec(&self) -> [f32; Self::SHAPE_FIELDS] { [ self.blur_k, self.blur_max, + self.rim_base, self.rim_k, self.rim_max, - self.bend, + self.bend_base, + self.bend_k, + self.bend_max, self.lens_curve, self.line, self.line_lit, @@ -165,17 +353,20 @@ impl GlassMaterial { Self { blur_k: values[0].max(0.), blur_max: values[1].max(0.), - rim_k: values[2].max(0.), - rim_max: values[3].max(0.5), - bend: values[4], - lens_curve: values[5].clamp(0.2, 6.), - line: values[6], - line_lit: values[7], - line_width: values[8].max(0.3), - light_angle: values[9], - glow: values[10], - glow_width: values[11].max(0.5), - hairline: [values[12], values[13], values[14], values[15]], + rim_base: values[2].max(0.), + rim_k: values[3].max(0.), + rim_max: values[4].max(0.5), + bend_base: values[5], + bend_k: values[6], + bend_max: values[7].max(0.), + lens_curve: values[8].clamp(0.2, 6.), + line: values[9], + line_lit: values[10], + line_width: values[11].max(0.3), + light_angle: values[12], + glow: values[13], + glow_width: values[14].max(0.5), + hairline: [values[15], values[16], values[17], values[18]], ..*self } } @@ -217,8 +408,11 @@ pub fn sample(style: &PaneStyle, shape: &Shape, blurred: &Rgba, px: f32, py: f32 }; } let edge = (-d).max(0.); - let rim = 1. - (edge / style.lens_rim).clamp(0., 1.).powf(style.lens_curve); - let value = blurred.sample(px + nx * style.rim_bend * rim, py + ny * style.rim_bend * rim); + let rim = (-(edge / style.lens_rim).powf(style.lens_curve)).exp(); + let value = blurred.sample( + px + nx * style.rim_bend * rim, + py + ny * style.rim_bend * rim, + ); let facing = nx * style.light[0] + ny * style.light[1]; GlassSample { source: [value[0] / 255., value[1] / 255., value[2] / 255.], @@ -233,19 +427,22 @@ pub fn sample(style: &PaneStyle, shape: &Shape, blurred: &Rgba, px: f32, py: f32 /// The backdrop under a glass pixel once the hairline is drawn on it (0-1). pub fn under_with_hairline(sample: &GlassSample, under: [f32; 3]) -> [f32; 3] { std::array::from_fn(|i| { - let line = under[i].min(sample.hairline[0] * under[i] + sample.hairline[1]).clamp(0., 1.); + let line = under[i] + .min(sample.hairline[0] * under[i] + sample.hairline[1]) + .clamp(0., 1.); under[i] + (line - under[i]) * sample.ring }) } -/// Apply the colour transform and lighting: 0-1 sRGB out, unclamped. +/// Apply the colour transform, lighting and tint: 0-1 sRGB out, unclamped. pub fn finish(style: &PaneStyle, sample: &GlassSample) -> [f32; 3] { let features = poly_features(sample.source); - std::array::from_fn(|channel| { + let untinted: [f32; 3] = std::array::from_fn(|channel| { let coefficients = &style.poly[channel * POLY_TERMS..(channel + 1) * POLY_TERMS]; let value: f32 = coefficients.iter().zip(features).map(|(c, f)| c * f).sum(); - value * sample.scale + sample.glow - }) + (value * sample.scale + sample.glow).clamp(0., 1.) + }); + style.tint.apply(untinted) } /// Composite one pane over `out`, sampling `blurred` (the backdrop blurred @@ -262,9 +459,15 @@ pub fn composite(style: &PaneStyle, shape: &Shape, blurred: &Rgba, out: &mut Rgb continue; } let under = out.get(x, y); - 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.; + 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.]); } } diff --git a/apps/desktop-gpui/glass/tests/presets.rs b/apps/desktop-gpui/glass/tests/presets.rs index ac2e1d93ed..c761851ad8 100644 --- a/apps/desktop-gpui/glass/tests/presets.rs +++ b/apps/desktop-gpui/glass/tests/presets.rs @@ -47,7 +47,12 @@ fn presets_match_apple_over_flat_backdrops() { #[test] fn every_preset_is_complete() { let all = presets(); - let names: Vec<&str> = all.as_object().unwrap().keys().map(String::as_str).collect(); + let names: Vec<&str> = all + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); assert_eq!( names, ["clear-dark", "clear-light", "regular-dark", "regular-light"] @@ -85,7 +90,10 @@ fn the_rounded_rect_distance_field_is_signed_and_clamps_its_radius() { assert!((shape.sdf(50., 20.) + 20.).abs() < 1e-4); assert!((shape.sdf(50., -5.) - 5.).abs() < 1e-4); assert!((shape.sdf(0., 20.)).abs() < 1e-4); - assert!(shape.sdf(1., 1.) > 0., "a corner beyond the clamped radius is outside"); + assert!( + shape.sdf(1., 1.) > 0., + "a corner beyond the clamped radius is outside" + ); let (nx, ny) = shape.normal(50., 1.); assert!(nx.abs() < 1e-3 && (ny + 1.).abs() < 1e-3); } @@ -112,3 +120,100 @@ fn glass_leaves_pixels_beyond_its_hairline_alone() { assert_eq!(out.get(x, y), backdrop.get(x, y), "({x}, {y})"); } } + +fn centre_of( + material: &GlassMaterial, + gray: f32, + shape: Shape, + tint: Option<([f32; 3], f32)>, +) -> [f32; 3] { + let backdrop = Rgba::solid(960, 600, [gray, gray, gray, 255.]); + let mut style = material.resolve(shape.width, shape.height); + if let Some((color, alpha)) = tint { + style.tint = material.tint_matrix(color, alpha); + } + let mut out = backdrop.clone(); + model::composite(&style, &shape, &backdrop.blurred(style.blur), &mut out); + let pixel = out.get( + (shape.x + shape.width / 2.) as usize, + (shape.y + shape.height / 2.) as usize, + ); + [pixel[0], pixel[1], pixel[2]] +} + +fn pane(side: f32) -> Shape { + Shape { + x: 100., + y: 100., + width: 400., + height: side, + radius: side / 2., + } +} + +/// Regular dark glass lifts small panes over bright content: over white, +/// Apple's reads 180 up to 64 pt tall, 154 at 80 pt, 127 at 96 pt and 114 +/// from 128 pt (`sweep`, macOS 27.0.1). +#[test] +fn regular_dark_adapts_to_pane_size() { + let material = material("regular-dark"); + for (side, apple) in [ + (44., 180.), + (64., 180.), + (80., 154.), + (96., 127.), + (128., 114.), + (240., 114.), + ] { + let got = centre_of(&material, 255., pane(side), None)[0]; + eprintln!("regular-dark {side} pt over white -> {got:.1} (apple {apple})"); + assert!( + (got - apple).abs() <= 3., + "{side} pt: {got} vs Apple's {apple}" + ); + } +} + +/// Apple's tints over mid grey, read off the `sweep` captures: Regular +/// ignores the hue and shifts grey, Clear blends the colour in. +#[test] +fn tints_match_apple_over_mid_grey() { + let blue = ([0., 0.533, 1.], 0.6); + let red = ([1., 0.22, 0.235], 0.6); + for (name, tint, apple) in [ + ("regular-light", blue, [172., 172., 172.]), + ("regular-dark", blue, [126., 125., 126.]), + ("regular-light", red, [172., 172., 172.]), + ("clear-light", blue, [63., 130., 193.]), + ("clear-dark", blue, [46., 107., 166.]), + ] { + let got = centre_of(&material(name), 128., pane(240.), Some(tint)); + eprintln!("{name} {tint:?} over 128 -> {got:?} (apple {apple:?})"); + for c in 0..3 { + assert!( + (got[c] - apple[c]).abs() <= 3., + "{name} channel {c}: {got:?} vs Apple's {apple:?}" + ); + } + } +} + +/// Apple's refraction at the edge grows with the pane up to a cap: about +/// 0.52 px per point for Clear up to 45 px, 11 + 0.08 px per point for +/// Regular up to 26 px (`sweep lens`). +#[test] +fn lens_amplitude_follows_apple() { + for (name, side, apple) in [ + ("clear-light", 20., 10.9), + ("clear-light", 44., 23.5), + ("clear-light", 270., 44.1), + ("regular-light", 44., 14.3), + ("regular-light", 270., 25.6), + ] { + let got = -material(name).resolve(400., side).rim_bend; + assert!( + (got - apple).abs() <= 2., + "{name} {side} pt: {got} vs Apple's {apple}" + ); + } +} diff --git a/apps/desktop-gpui/patches/zed-glass.patch b/apps/desktop-gpui/patches/zed-glass.patch index 033ae8ce5d..340be7e9d2 100644 --- a/apps/desktop-gpui/patches/zed-glass.patch +++ b/apps/desktop-gpui/patches/zed-glass.patch @@ -1,5 +1,5 @@ diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs -index 6cc3562..c49648d 100644 +index 6cc3562..4c1f558 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -52,7 +52,7 @@ pub struct Scene { @@ -22,7 +22,7 @@ index 6cc3562..c49648d 100644 #[derive(Debug, Copy, Clone)] #[repr(C)] #[expect(missing_docs)] -@@ -624,6 +624,78 @@ pub struct BackdropBlur { +@@ -624,6 +624,87 @@ pub struct BackdropBlur { pub bounds: Bounds, pub content_mask: ContentMask, pub corner_radii: Corners, @@ -52,7 +52,7 @@ index 6cc3562..c49648d 100644 + pub lens_rim: f32, + pub lens_curve: f32, + pub rim_bend: f32, -+ pub poly: [f32; 39], ++ pub poly: [f32; 60], + pub opacity: f32, + pub line: f32, + pub line_lit: f32, @@ -62,8 +62,14 @@ index 6cc3562..c49648d 100644 + pub glow: f32, + pub glow_width: f32, + pub hairline: [f32; 4], -+ /// Keeps the struct a multiple of 8 bytes, which WGSL's storage layout -+ /// requires of `BackdropBlur`. ++ /// Tint, a colour matrix on the finished glass colour: `color * ++ /// tint_scale + luma(color) * tint_luma + tint_offset` per channel, with ++ /// Rec. 709 luma of the gamma-encoded colour. Untinted glass has a scale ++ /// of 1 and nothing else. The struct stays a multiple of 8 bytes, which ++ /// WGSL's storage layout requires of `BackdropBlur`. ++ pub tint_scale: [f32; 3], ++ pub tint_luma: [f32; 3], ++ pub tint_offset: [f32; 3], + pub pad: f32, +} + @@ -74,7 +80,7 @@ index 6cc3562..c49648d 100644 + lens_rim: 0., + lens_curve: 0., + rim_bend: 0., -+ poly: [0.; 39], ++ poly: [0.; 60], + opacity: 0., + line: 0., + line_lit: 0., @@ -84,6 +90,9 @@ index 6cc3562..c49648d 100644 + glow: 0., + glow_width: 0., + hairline: [0.; 4], ++ tint_scale: [1.; 3], ++ tint_luma: [0.; 3], ++ tint_offset: [0.; 3], + pad: 0., + } + } @@ -173,7 +182,7 @@ index 70e9a0b..79415df 100644 "Underline".into(), "UnderlineInputIndex".into(), diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs -index 4be15aa..56248f3 100644 +index 4be15aa..a4f34d0 100644 --- a/crates/gpui_macos/src/metal_renderer.rs +++ b/crates/gpui_macos/src/metal_renderer.rs @@ -979,7 +979,7 @@ impl MetalRenderer { @@ -238,7 +247,7 @@ index 4be15aa..56248f3 100644 }); scene.pop_layer(); } -@@ -2741,6 +2750,215 @@ mod backdrop_blur_tests { +@@ -2741,6 +2750,249 @@ mod backdrop_blur_tests { ); } @@ -246,22 +255,29 @@ index 4be15aa..56248f3 100644 + let mut x = 0.; + let mut dark = true; + while x < VIEW_W as f32 { -+ push_quad(scene, bounds(x, 0., 8., VIEW_H as f32), if dark { 0.05 } else { 0.95 }); ++ push_quad( ++ scene, ++ bounds(x, 0., 8., VIEW_H as f32), ++ if dark { 0.05 } else { 0.95 }, ++ ); + x += 8.; + dark = !dark; + } + } + + fn test_glass() -> GlassStyle { -+ let mut poly = [0.; 39]; -+ poly[..13].copy_from_slice(&[ -+ 0.6, 0.15, 0.05, -0.2, 0.1, 0.0, 0.05, 0.0, 0.02, 0.1, 0.0, -0.02, 0.22, ++ let mut poly = [0.; 60]; ++ poly[..20].copy_from_slice(&[ ++ 0.6, 0.15, 0.05, -0.2, 0.1, 0.0, 0.05, 0.0, 0.02, 0.1, 0.0, -0.02, 0.04, -0.03, ++ 0.0, 0.02, 0.0, 0.0, 0.05, 0.22, + ]); -+ poly[13..26].copy_from_slice(&[ -+ 0.05, 0.5, 0.1, 0.0, -0.15, 0.05, 0.0, 0.04, 0.0, 0.0, 0.08, 0.0, 0.2, ++ poly[20..40].copy_from_slice(&[ ++ 0.05, 0.5, 0.1, 0.0, -0.15, 0.05, 0.0, 0.04, 0.0, 0.0, 0.08, 0.0, 0.0, 0.03, -0.04, ++ 0.0, 0.02, 0.0, -0.03, 0.2, + ]); -+ poly[26..].copy_from_slice(&[ -+ 0.12, 0.05, 0.55, 0.0, 0.0, -0.1, 0.02, 0.0, 0.03, -0.03, 0.0, 0.06, 0.18, ++ poly[40..].copy_from_slice(&[ ++ 0.12, 0.05, 0.55, 0.0, 0.0, -0.1, 0.02, 0.0, 0.03, -0.03, 0.0, 0.06, 0.0, 0.0, ++ 0.03, 0.0, -0.05, 0.04, 0.02, 0.18, + ]); + GlassStyle { + enabled: 1, @@ -278,6 +294,9 @@ index 4be15aa..56248f3 100644 + glow: 0.04, + glow_width: 6., + hairline: [0.86, -0.12, 0.92, -0.08], ++ tint_scale: [0.4, 0.4, 0.4], ++ tint_luma: [0.05, 0.12, 0.25], ++ tint_offset: [0.02, 0.15, 0.3], + pad: 0., + } + } @@ -388,7 +407,8 @@ index 4be15aa..56248f3 100644 + + let mut errors = Vec::new(); + for y in (pane.origin.y.0 as i32 - 2)..(pane.origin.y.0 + pane.size.height.0) as i32 + 2 { -+ for x in (pane.origin.x.0 as i32 - 2)..(pane.origin.x.0 + pane.size.width.0) as i32 + 2 { ++ for x in (pane.origin.x.0 as i32 - 2)..(pane.origin.x.0 + pane.size.width.0) as i32 + 2 ++ { + let (px, py) = (x as f32 + 0.5, y as f32 + 0.5); + let d = sdf(px, py); + let coverage = (0.5 - d).clamp(0., 1.); @@ -412,8 +432,11 @@ index 4be15aa..56248f3 100644 + under + } else { + let edge = (-d).max(0.); -+ let rim = 1. - (edge / glass.lens_rim).clamp(0., 1.).powf(glass.lens_curve); -+ let s = sample(px + nx * glass.rim_bend * rim, py + ny * glass.rim_bend * rim); ++ let rim = (-(edge / glass.lens_rim).powf(glass.lens_curve)).exp(); ++ let s = sample( ++ px + nx * glass.rim_bend * rim, ++ py + ny * glass.rim_bend * rim, ++ ); + let c = [s[0] / 255., s[1] / 255., s[2] / 255.]; + let features = [ + c[0], @@ -428,14 +451,31 @@ index 4be15aa..56248f3 100644 + c[0] * c[0] * c[0], + c[1] * c[1] * c[1], + c[2] * c[2] * c[2], ++ c[0] * c[0] * c[1], ++ c[0] * c[0] * c[2], ++ c[1] * c[1] * c[0], ++ c[1] * c[1] * c[2], ++ c[2] * c[2] * c[0], ++ c[2] * c[2] * c[1], ++ c[0] * c[1] * c[2], + 1., + ]; + let facing = nx * glass.light_x + ny * glass.light_y; -+ let line = (-edge / glass.line_width).exp() * (glass.line + glass.line_lit * facing); ++ let line = ++ (-edge / glass.line_width).exp() * (glass.line + glass.line_lit * facing); + let glow = (-edge / glass.glow_width).exp() * glass.glow; ++ let shaded: [f32; 3] = std::array::from_fn(|i| { ++ let value: f32 = ++ (0..20).map(|t| glass.poly[i * 20 + t] * features[t]).sum(); ++ (value * (1. + line) + glow).clamp(0., 1.) ++ }); ++ let luma = shaded[0] * 0.2126 + shaded[1] * 0.7152 + shaded[2] * 0.0722; + std::array::from_fn(|i| { -+ let value: f32 = (0..13).map(|t| glass.poly[i * 13 + t] * features[t]).sum(); -+ let value = (value * (1. + line) + glow).clamp(0., 1.) * 255.; ++ let value = (shaded[i] * glass.tint_scale[i] ++ + luma * glass.tint_luma[i] ++ + glass.tint_offset[i]) ++ .clamp(0., 1.) ++ * 255.; + under[i] + (value - under[i]) * coverage + }) + }; @@ -448,14 +488,17 @@ index 4be15aa..56248f3 100644 + errors.sort_by(f32::total_cmp); + let mean = errors.iter().sum::() / errors.len() as f32; + let p99 = errors[errors.len() * 99 / 100]; -+ assert!(mean < 1.5 && p99 < 8., "glass vs CPU model: mean {mean:.2}, p99 {p99:.2}"); ++ assert!( ++ mean < 1.5 && p99 < 8., ++ "glass vs CPU model: mean {mean:.2}, p99 {p99:.2}" ++ ); + } + #[test] fn two_blurs_of_different_sizes_in_one_frame() { if metal::Device::system_default().is_none() { diff --git a/crates/gpui_macos/src/shaders.metal b/crates/gpui_macos/src/shaders.metal -index e087c0d..de1333f 100644 +index e087c0d..1a5848c 100644 --- a/crates/gpui_macos/src/shaders.metal +++ b/crates/gpui_macos/src/shaders.metal @@ -1354,9 +1354,17 @@ vertex BackdropBlurVertexOutput backdrop_blur_vertex( @@ -478,18 +521,21 @@ index e087c0d..de1333f 100644 blur.content_mask.bounds); return BackdropBlurVertexOutput{ device_position, -@@ -1364,26 +1372,78 @@ vertex BackdropBlurVertexOutput backdrop_blur_vertex( +@@ -1364,26 +1372,88 @@ vertex BackdropBlurVertexOutput backdrop_blur_vertex( {clip_distance.x, clip_distance.y, clip_distance.z, clip_distance.w}}; } +float3 glass_shade(GlassStyle glass, float3 c, float edge, float2 normal) { -+ float features[13] = {c.r, c.g, c.b, c.r * c.r, c.g * c.g, c.b * c.b, ++ // The full cubic in r, g and b; `cap_glass::model::poly_features`. ++ float features[20] = {c.r, c.g, c.b, c.r * c.r, c.g * c.g, c.b * c.b, + c.r * c.g, c.g * c.b, c.r * c.b, c.r * c.r * c.r, -+ c.g * c.g * c.g, c.b * c.b * c.b, 1.}; ++ c.g * c.g * c.g, c.b * c.b * c.b, c.r * c.r * c.g, ++ c.r * c.r * c.b, c.g * c.g * c.r, c.g * c.g * c.b, ++ c.b * c.b * c.r, c.b * c.b * c.g, c.r * c.g * c.b, 1.}; + float3 color = float3(0.); -+ for (int term = 0; term < 13; term++) { ++ for (int term = 0; term < 20; term++) { + color += features[term] * -+ float3(glass.poly[term], glass.poly[13 + term], glass.poly[26 + term]); ++ float3(glass.poly[term], glass.poly[20 + term], glass.poly[40 + term]); + } + float facing = dot(normal, float2(glass.light_x, glass.light_y)); + float line = exp(-edge / glass.line_width) * (glass.line + glass.line_lit * facing); @@ -559,20 +605,27 @@ index e087c0d..de1333f 100644 + return under; + } + float edge = max(-distance, 0.); -+ float rim = 1. - pow(saturate(edge / blur.glass.lens_rim), blur.glass.lens_curve); ++ float rim = exp(-pow(edge / blur.glass.lens_rim, blur.glass.lens_curve)); + float2 sample_at = position + normal * blur.glass.rim_bend * rim; + float4 backdrop = source_texture.sample( + source_sampler, (sample_at - source_rect.xy) / source_rect.zw); + float3 straight = backdrop.a > 0. ? backdrop.rgb / backdrop.a : float3(0.); + float alpha = mix(backdrop.a, 1., blur.glass.opacity); + float3 glass = saturate(glass_shade(blur.glass, straight, edge, normal)); ++ float luma = dot(glass, float3(0.2126, 0.7152, 0.0722)); ++ glass = saturate(glass * float3(blur.glass.tint_scale[0], blur.glass.tint_scale[1], ++ blur.glass.tint_scale[2]) + ++ luma * float3(blur.glass.tint_luma[0], blur.glass.tint_luma[1], ++ blur.glass.tint_luma[2]) + ++ float3(blur.glass.tint_offset[0], blur.glass.tint_offset[1], ++ blur.glass.tint_offset[2])); + return mix(under, float4(glass * alpha, alpha), coverage); } diff --git a/crates/gpui_wgpu/src/shaders.wgsl b/crates/gpui_wgpu/src/shaders.wgsl -index 011be71..5c7194d 100644 +index 011be71..2b82b2b 100644 --- a/crates/gpui_wgpu/src/shaders.wgsl +++ b/crates/gpui_wgpu/src/shaders.wgsl -@@ -1399,3 +1399,195 @@ fn fs_surface(input: SurfaceVarying) -> @location(0) vec4 { +@@ -1399,3 +1399,204 @@ fn fs_surface(input: SurfaceVarying) -> @location(0) vec4 { return ycbcr_to_RGB * y_cb_cr; } @@ -584,7 +637,7 @@ index 011be71..5c7194d 100644 + lens_rim: f32, + lens_curve: f32, + rim_bend: f32, -+ poly: array, ++ poly: array, + opacity: f32, + line_strength: f32, + line_lit: f32, @@ -594,6 +647,9 @@ index 011be71..5c7194d 100644 + glow: f32, + glow_width: f32, + hairline: array, ++ tint_scale: array, ++ tint_luma: array, ++ tint_offset: array, + pad: f32, +} + @@ -748,23 +804,29 @@ index 011be71..5c7194d 100644 + if (coverage <= 0.0) { + return under; + } -+ let rim = 1.0 - pow(saturate(edge / glass.lens_rim), glass.lens_curve); ++ let rim = exp(-pow(edge / glass.lens_rim, glass.lens_curve)); + let sample_at = position + normal * glass.rim_bend * rim; + let backdrop = textureSampleLevel(t_backdrop, s_backdrop, backdrop_blurred_uv(sample_at), 0.0); + var color = select(vec3(0.0), backdrop.rgb / backdrop.a, backdrop.a > 0.0); + let alpha = mix(backdrop.a, 1.0, glass.opacity); + + let c = color; -+ let features = array(c.r, c.g, c.b, c.r * c.r, c.g * c.g, c.b * c.b, -+ c.r * c.g, c.g * c.b, c.r * c.b, c.r * c.r * c.r, c.g * c.g * c.g, c.b * c.b * c.b, 1.0); ++ let features = array(c.r, c.g, c.b, c.r * c.r, c.g * c.g, c.b * c.b, ++ c.r * c.g, c.g * c.b, c.r * c.b, c.r * c.r * c.r, c.g * c.g * c.g, c.b * c.b * c.b, ++ c.r * c.r * c.g, c.r * c.r * c.b, c.g * c.g * c.r, c.g * c.g * c.b, ++ c.b * c.b * c.r, c.b * c.b * c.g, c.r * c.g * c.b, 1.0); + color = vec3(0.0); -+ for (var term = 0; term < 13; term++) { -+ color += features[term] * vec3(glass.poly[term], glass.poly[13 + term], glass.poly[26 + term]); ++ for (var term = 0; term < 20; term++) { ++ color += features[term] * vec3(glass.poly[term], glass.poly[20 + term], glass.poly[40 + term]); + } + let facing = dot(normal, vec2(glass.light_x, glass.light_y)); + let line_term = exp(-edge / glass.line_width) * (glass.line_strength + glass.line_lit * facing); + let glow = exp(-edge / glass.glow_width) * glass.glow; + color = saturate(color * (1.0 + line_term) + vec3(glow)); ++ let luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); ++ color = saturate(color * vec3(glass.tint_scale[0], glass.tint_scale[1], glass.tint_scale[2]) ++ + luma * vec3(glass.tint_luma[0], glass.tint_luma[1], glass.tint_luma[2]) ++ + vec3(glass.tint_offset[0], glass.tint_offset[1], glass.tint_offset[2])); + + return mix(under, vec4(color * alpha, alpha), coverage); +} @@ -1879,10 +1941,10 @@ index e4b605b..89f3cbb 100644 } } diff --git a/crates/gpui_windows/src/shaders.hlsl b/crates/gpui_windows/src/shaders.hlsl -index 78b1ada..5ae5ab5 100644 +index 78b1ada..fa802f7 100644 --- a/crates/gpui_windows/src/shaders.hlsl +++ b/crates/gpui_windows/src/shaders.hlsl -@@ -1289,3 +1289,200 @@ float4 polychrome_sprite_fragment(PolychromeSpriteFragmentInput input): SV_Targe +@@ -1289,3 +1289,211 @@ float4 polychrome_sprite_fragment(PolychromeSpriteFragmentInput input): SV_Targe color.a *= sprite.opacity * saturate(0.5 - distance) * edge_fade_alpha(input.position.xy, sprite.fade); return color; } @@ -1900,7 +1962,7 @@ index 78b1ada..5ae5ab5 100644 + float lens_rim; + float lens_curve; + float rim_bend; -+ float poly[39]; ++ float poly[60]; + float opacity; + float line_strength; + float line_lit; @@ -1910,6 +1972,9 @@ index 78b1ada..5ae5ab5 100644 + float glow; + float glow_width; + float hairline[4]; ++ float tint_scale[3]; ++ float tint_luma[3]; ++ float tint_offset[3]; + float pad; +}; + @@ -2061,25 +2126,33 @@ index 78b1ada..5ae5ab5 100644 + if (coverage <= 0.) { + return under; + } -+ float rim = 1. - pow(saturate(edge / glass.lens_rim), glass.lens_curve); ++ float rim = exp(-pow(edge / glass.lens_rim, glass.lens_curve)); + float2 sample_at = position + normal * glass.rim_bend * rim; + float4 backdrop = t_backdrop.SampleLevel(s_backdrop, backdrop_blurred_uv(sample_at), 0); + float3 color = backdrop.a > 0. ? backdrop.rgb / backdrop.a : float3(0., 0., 0.); + float alpha = lerp(backdrop.a, 1., glass.opacity); + -+ float features[13] = {color.r, color.g, color.b, color.r * color.r, color.g * color.g, ++ float features[20] = {color.r, color.g, color.b, color.r * color.r, color.g * color.g, + color.b * color.b, color.r * color.g, color.g * color.b, + color.r * color.b, color.r * color.r * color.r, -+ color.g * color.g * color.g, color.b * color.b * color.b, 1.}; ++ color.g * color.g * color.g, color.b * color.b * color.b, ++ color.r * color.r * color.g, color.r * color.r * color.b, ++ color.g * color.g * color.r, color.g * color.g * color.b, ++ color.b * color.b * color.r, color.b * color.b * color.g, ++ color.r * color.g * color.b, 1.}; + color = float3(0., 0., 0.); + [unroll] -+ for (int term = 0; term < 13; term++) { -+ color += features[term] * float3(glass.poly[term], glass.poly[13 + term], glass.poly[26 + term]); ++ for (int term = 0; term < 20; term++) { ++ color += features[term] * float3(glass.poly[term], glass.poly[20 + term], glass.poly[40 + term]); + } + float facing = dot(normal, float2(glass.light_x, glass.light_y)); + float line_term = exp(-edge / glass.line_width) * (glass.line_strength + glass.line_lit * facing); + float glow = exp(-edge / glass.glow_width) * glass.glow; + color = saturate(color * (1. + line_term) + glow); ++ float luma = dot(color, float3(0.2126, 0.7152, 0.0722)); ++ color = saturate(color * float3(glass.tint_scale[0], glass.tint_scale[1], glass.tint_scale[2]) ++ + luma * float3(glass.tint_luma[0], glass.tint_luma[1], glass.tint_luma[2]) ++ + float3(glass.tint_offset[0], glass.tint_offset[1], glass.tint_offset[2])); + + return lerp(under, float4(color * alpha, alpha), coverage); +} diff --git a/apps/desktop-gpui/src/glass_probe.rs b/apps/desktop-gpui/src/glass_probe.rs index aa8ea888c6..3678958857 100644 --- a/apps/desktop-gpui/src/glass_probe.rs +++ b/apps/desktop-gpui/src/glass_probe.rs @@ -21,10 +21,27 @@ const PRESETS: [(&str, GlassKind, bool); 4] = [ /// with glass panes in each preset, capture the window, and score it against /// the CPU model of the same material. Writes the captures, the model's /// renders and `report.json`, whose `passed` is the CI verdict. +/// +/// With `CAP_GLASS_PROBE_REFERENCE=` it replays a corpus from +/// `scripts/glass-reference` instead: every case's backdrop, panes and tint, +/// scored against Apple's capture of the same case, so captures from each +/// OS line up with Apple's and with each other. `CAP_GLASS_PROBE_CASES` +/// limits it to cases whose names start with one of its comma-separated +/// prefixes. pub fn run(cx: &mut App) { let Some(output) = std::env::var_os("CAP_GLASS_PROBE_OUTPUT").map(PathBuf::from) else { return; }; + let reference = match std::env::var_os("CAP_GLASS_PROBE_REFERENCE").map(PathBuf::from) { + Some(dir) => match Reference::load(&dir) { + Ok(reference) => Some(Arc::new(reference)), + Err(error) => { + tracing::error!(%error, "glass probe: cannot read the reference corpus"); + return; + } + }, + None => None, + }; if let Err(error) = std::fs::create_dir_all(&output) { tracing::error!(%error, "glass probe: cannot create its output directory"); return; @@ -55,7 +72,10 @@ pub fn run(cx: &mut App) { } }; cx.spawn(async move |cx| { - let report = probe(handle, &output, cx).await; + let report = match reference { + Some(reference) => probe_reference(handle, &reference, &output, cx).await, + None => probe(handle, &output, cx).await, + }; if let Err(error) = std::fs::write(output.join("report.json"), report.to_string()) { tracing::error!(%error, "glass probe: could not write its report"); } @@ -64,24 +84,39 @@ pub fn run(cx: &mut App) { .detach(); } -#[derive(Default)] struct ProbeView { scene: Option>, preset: Option<(GlassKind, bool)>, + shapes: Vec, + tint: Option, +} + +impl Default for ProbeView { + fn default() -> Self { + Self { + scene: None, + preset: None, + shapes: scenes::SHAPES.to_vec(), + tint: None, + } + } } impl Render for ProbeView { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - let mut root = div() - .size_full() - .relative() - .bg(gpui::white()) - .children(self.scene.clone().map(|scene| img(scene).absolute().size_full())); + let mut root = div().size_full().relative().bg(gpui::white()).children( + self.scene + .clone() + .map(|scene| img(scene).absolute().size_full()), + ); if let Some((kind, dark)) = self.preset { - for shape in scenes::SHAPES { + for shape in &self.shapes { + let pane = match self.tint { + Some(tint) => glass::pane_tinted(kind, dark, shape.radius, tint), + None => glass::pane(kind, dark, shape.radius), + }; root = root.child( - glass::pane(kind, dark, shape.radius) - .absolute() + pane.absolute() .left(px(shape.x)) .top(px(shape.y)) .w(px(shape.width)) @@ -156,7 +191,11 @@ async fn probe( let captured = Rgba { width: captured.width() as usize, height: captured.height() as usize, - pixels: captured.as_raw().iter().map(|value| *value as f32).collect(), + pixels: captured + .as_raw() + .iter() + .map(|value| *value as f32) + .collect(), }; let expected = expected_render(&scene, glass::material(kind, dark), scale); let mask = glass_mask(scale, captured.width, captured.height); @@ -180,6 +219,210 @@ async fn probe( serde_json::json!({ "passed": passed, "scale": scale, "cases": cases }) } +/// A corpus written by `scripts/glass-reference` (`reference` or `sweep`). +struct Reference { + dir: PathBuf, + shapes: Vec, + cases: Vec, + tints: std::collections::HashMap, +} + +#[derive(serde::Deserialize)] +struct ReferenceManifest { + shapes: Vec, + cases: Vec, + #[serde(default)] + tints: std::collections::HashMap, +} + +#[derive(serde::Deserialize)] +struct ReferenceShape { + rect: [f32; 4], + radius: f32, +} + +#[derive(serde::Deserialize, Clone)] +struct ReferenceCase { + name: String, + scene: String, + style: String, + appearance: String, + tint: String, + #[serde(default)] + pass: String, +} + +impl Reference { + fn load(dir: &std::path::Path) -> anyhow::Result { + let manifest: ReferenceManifest = + serde_json::from_slice(&std::fs::read(dir.join("manifest.json"))?)?; + let prefixes: Vec = std::env::var("CAP_GLASS_PROBE_CASES") + .map(|list| list.split(',').map(str::to_owned).collect()) + .unwrap_or_default(); + Ok(Self { + dir: dir.to_owned(), + shapes: manifest + .shapes + .iter() + .map(|shape| Shape { + x: shape.rect[0], + y: shape.rect[1], + width: shape.rect[2], + height: shape.rect[3], + radius: shape.radius, + }) + .collect(), + cases: manifest + .cases + .into_iter() + // The sweep's falling pass repeats its rising one. + .filter(|case| case.pass != "down") + .filter(|case| { + prefixes.is_empty() + || prefixes.iter().any(|prefix| case.name.starts_with(prefix)) + }) + .collect(), + tints: manifest.tints, + }) + } + + /// `name` from the corpus at `width` x `height` device pixels. + fn image(&self, folder: &str, name: &str, width: u32, height: u32) -> Option { + let image = image::open(self.dir.join(folder).join(format!("{name}.png"))) + .ok()? + .to_rgba8(); + Some(if image.width() == width && image.height() == height { + image + } else { + image::imageops::resize(&image, width, height, image::imageops::FilterType::Triangle) + }) + } +} + +async fn probe_reference( + handle: WindowHandle, + reference: &Reference, + output: &std::path::Path, + cx: &mut AsyncApp, +) -> serde_json::Value { + sleep(cx, 1500).await; + let scale = handle + .update(cx, |_, window, _| window.scale_factor()) + .unwrap_or(1.); + let Some(target) = handle + .update(cx, |_, window, _| window_target(window)) + .ok() + .flatten() + else { + return serde_json::json!({ "passed": false, "error": "no window id to capture" }); + }; + let shapes = reference.shapes.clone(); + handle + .update(cx, |view, _, _| view.shapes = shapes.clone()) + .ok(); + let (width, height) = ( + (scenes::CANVAS.0 * scale).round() as u32, + (scenes::CANVAS.1 * scale).round() as u32, + ); + let mask: Vec = (0..(width * height) as usize) + .map(|index| { + let x = (index % width as usize) as f32 + 0.5; + let y = (index / width as usize) as f32 + 0.5; + shapes + .iter() + .any(|shape| scaled(shape, scale).sdf(x, y) < 1.5) + }) + .collect(); + let mut scene_cache: std::collections::HashMap> = Default::default(); + let mut cases = Vec::new(); + for case in &reference.cases { + let kind = match case.style.as_str() { + "clear" => GlassKind::Clear, + _ => GlassKind::Regular, + }; + let dark = case.appearance == "dark"; + let tint = reference + .tints + .get(&case.tint) + .map(|[r, g, b, a]| gpui::Rgba { + r: *r, + g: *g, + b: *b, + a: *a, + }); + let scene = match scene_cache.get(&case.scene) { + Some(scene) => scene.clone(), + None => { + let Some(image) = reference.image("scenes", &case.scene, width, height) else { + cases.push(serde_json::json!({ "name": case.name, "error": "missing scene" })); + continue; + }; + let scene = crate::library::rgba_to_render_image(image); + scene_cache.insert(case.scene.clone(), scene.clone()); + scene + } + }; + handle + .update(cx, |view, window, cx| { + view.scene = Some(scene); + view.preset = Some((kind, dark)); + view.tint = tint; + cx.notify(); + window.refresh(); + }) + .ok(); + sleep(cx, 400).await; + let captured = { + let target = target.clone(); + gpui_tokio::Tokio::spawn(cx, async move { + cap_recording::screenshot::capture_screenshot(target).await + }) + .await + }; + let captured = match captured { + Ok(Ok(image)) => crop_to(&image.to_rgba8(), width, height), + Ok(Err(error)) => { + cases.push(serde_json::json!({ "name": case.name, "error": format!("{error:#}") })); + continue; + } + Err(error) => { + cases.push(serde_json::json!({ "name": case.name, "error": error.to_string() })); + continue; + } + }; + let _ = captured.save(output.join(format!("{}.png", case.name))); + let Some(apple) = reference.image("apple", &case.name, width, height) else { + cases.push(serde_json::json!({ "name": case.name, "error": "missing Apple capture" })); + continue; + }; + let to_rgba = |image: &image::RgbaImage| Rgba { + width: image.width() as usize, + height: image.height() as usize, + pixels: image.as_raw().iter().map(|value| *value as f32).collect(), + }; + let (ours, apple) = (to_rgba(&captured), to_rgba(&apple)); + let ssim = metrics::ssim(&ours, &apple, &mask); + let (delta_e, delta_e95) = metrics::delta_e(&ours, &apple, &mask); + cases.push(serde_json::json!({ + "name": case.name, + "ssim": ssim, + "deltaE": delta_e, + "deltaE95": delta_e95, + "passed": ssim >= 0.98 && delta_e <= 1.0 && delta_e95 <= 3.0, + })); + } + let passed = !cases.is_empty() + && cases + .iter() + .all(|case| case["passed"].as_bool() == Some(true)); + serde_json::json!({ + "passed": passed, + "scale": scale, + "os": std::env::consts::OS, + "cases": cases, + }) +} + fn to_rgba_image(image: &Rgba) -> image::RgbaImage { let bytes = image .pixels @@ -198,8 +441,14 @@ fn crop_to(image: &image::RgbaImage, width: u32, height: u32) -> image::RgbaImag } let x = image.width().saturating_sub(width) / 2; let y = image.height().saturating_sub(height) / 2; - image::imageops::crop_imm(image, x, y, width.min(image.width()), height.min(image.height())) - .to_image() + image::imageops::crop_imm( + image, + x, + y, + width.min(image.width()), + height.min(image.height()), + ) + .to_image() } fn scaled(shape: &Shape, scale: f32) -> Shape { diff --git a/apps/desktop-gpui/src/ui/glass.rs b/apps/desktop-gpui/src/ui/glass.rs index b444f67cb1..6194a92b0a 100644 --- a/apps/desktop-gpui/src/ui/glass.rs +++ b/apps/desktop-gpui/src/ui/glass.rs @@ -30,8 +30,8 @@ struct Presets { fn presets() -> &'static Presets { static PRESETS: OnceLock = OnceLock::new(); PRESETS.get_or_init(|| { - let all: serde_json::Value = serde_json::from_str(cap_glass::PRESETS_JSON) - .expect("glass presets are valid JSON"); + let all: serde_json::Value = + serde_json::from_str(cap_glass::PRESETS_JSON).expect("glass presets are valid JSON"); let get = |name: &str| -> GlassMaterial { serde_json::from_value(all[name].clone()).expect("glass preset is complete") }; @@ -70,16 +70,32 @@ pub fn gpui_style(style: &PaneStyle) -> gpui::GlassStyle { glow: style.glow, glow_width: style.glow_width, hairline: style.hairline, + tint_scale: style.tint.scale, + tint_luma: style.tint.luma, + tint_offset: style.tint.offset, pad: 0., } } -pub fn paint(window: &mut Window, bounds: Bounds, radius: f32, material: &GlassMaterial) { +/// A tint for a pane, sRGB with alpha, as SwiftUI's `.tint` takes it: +/// Regular glass reads only its alpha, Clear glass its colour too. +pub type Tint = gpui::Rgba; + +pub fn paint( + window: &mut Window, + bounds: Bounds, + radius: f32, + material: &GlassMaterial, + tint: Option, +) { let (width, height) = (f32::from(bounds.size.width), f32::from(bounds.size.height)); if width <= 0. || height <= 0. { return; } - let style = material.resolve(width, height); + let mut style = material.resolve(width, height); + if let Some(tint) = tint { + style.tint = material.tint_matrix([tint.r, tint.g, tint.b], tint.a); + } let radius = radius.min(width / 2.).min(height / 2.); window.paint_glass( bounds, @@ -93,11 +109,20 @@ pub fn paint(window: &mut Window, bounds: Bounds, radius: f32, material: /// everything added after it paints on the glass. `radius` is clamped to the /// pane like CSS clamps `border-radius`. pub fn pane(kind: GlassKind, dark: bool, radius: f32) -> Div { + pane_with(kind, dark, radius, None) +} + +/// [`pane`] with a tint. +pub fn pane_tinted(kind: GlassKind, dark: bool, radius: f32, tint: Tint) -> Div { + pane_with(kind, dark, radius, Some(tint)) +} + +fn pane_with(kind: GlassKind, dark: bool, radius: f32, tint: Option) -> Div { let material = *material(kind, dark); div().relative().rounded(px(radius)).child( canvas( |_, _, _| {}, - move |bounds, _, window, _| paint(window, bounds, radius, &material), + move |bounds, _, window, _| paint(window, bounds, radius, &material, tint), ) .absolute() .top_0() diff --git a/scripts/glass-reference/sweep.swift b/scripts/glass-reference/sweep.swift new file mode 100644 index 0000000000..15cea806a1 --- /dev/null +++ b/scripts/glass-reference/sweep.swift @@ -0,0 +1,267 @@ +// Sweeps Apple's glass (NSGlassEffectView) across backdrop brightness and +// pane size, so Cap's glass can match how Apple's adapts: small Regular +// panes lighten over bright content in dark mode, and tints are tone mapped +// against what is behind them. Writes /scenes/.png, +// /apple/.png and /manifest.json in the same format as +// reference.swift. +// +// swiftc -O sweep.swift -o sweep && ./sweep [lens | tints] +// +// `lens` swaps the greys for the UV ramp (red and green encode the sampled +// point), so the same shapes show how Apple's refraction scales with size. +// `tints` runs a grid of tint colours and alphas over three greys, to fit +// how a tint's colour and alpha set the tinted glass. `levels` runs Clear +// glass only, under tints spanning their brightest and darkest channel, +// which is what sets how a tint darkens over dark content. `hsl` runs Clear +// glass under a regular grid of HSL lightness and saturation at one hue: +// the tint keeps its hue, so this grid is the whole law. +// +// Each style and appearance keeps the same views for a rising and then a +// falling pass over the greys, so any hysteresis in the adaptation shows up +// as a difference between the `up` and `down` captures of a level. +import AppKit +import Metal + +let canvas = NSSize(width: 960, height: 600) + +struct Shape { + let name: String + let rect: NSRect + let radius: CGFloat +} + +var shapes: [Shape] = [] +do { + // Capsules of one width and rising height. + var y: CGFloat = 20 + for height in [20, 28, 36, 44, 52, 64, 80, 96] as [CGFloat] { + shapes.append(Shape(name: "capsule\(Int(height))", rect: NSRect(x: 30, y: y, width: 200, height: height), radius: height / 2)) + y += height + 18 + } + // Squares with a fixed corner radius. + y = 20 + for side in [24, 32, 48, 64, 96, 128] as [CGFloat] { + shapes.append(Shape(name: "square\(Int(side))", rect: NSRect(x: 270, y: y, width: side, height: side), radius: min(12, side / 2))) + y += side + 18 + } + // Bars of one height and rising width, then a large panel. + y = 20 + for width in [60, 120, 240, 420] as [CGFloat] { + shapes.append(Shape(name: "bar\(Int(width))", rect: NSRect(x: 460, y: y, width: width, height: 44), radius: 22)) + y += 44 + 18 + } + shapes.append(Shape(name: "panel", rect: NSRect(x: 460, y: 300, width: 460, height: 270), radius: 24)) +} + +let greys: [Int] = [0, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 255] +let styles: [(String, NSGlassEffectView.Style)] = [("regular", .regular), ("clear", .clear)] +let appearances: [(String, NSAppearance.Name)] = [("light", .aqua), ("dark", .darkAqua)] +let tints: [(String, NSColor?, [Double])] = [ + ("none", nil, []), + ("blue", NSColor.systemBlue.withAlphaComponent(0.6), [0.0, 0.533, 1.0, 0.6]), + ("red", NSColor.systemRed.withAlphaComponent(0.6), [1.0, 0.22, 0.235, 0.6]), + ("green", NSColor.systemGreen.withAlphaComponent(0.6), [0.204, 0.78, 0.349, 0.6]), + ("blue30", NSColor.systemBlue.withAlphaComponent(0.3), [0.0, 0.533, 1.0, 0.3]), + ("blue100", NSColor.systemBlue, [0.0, 0.533, 1.0, 1.0]), +] + +final class FlippedView: NSView { + override var isFlipped: Bool { true } +} + +func greyImage(_ level: Int, scale: CGFloat) -> CGImage { + let width = Int(canvas.width * scale), height = Int(canvas.height * scale) + let ctx = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + let v = CGFloat(level) / 255 + ctx.setFillColor(CGColor(srgbRed: v, green: v, blue: v, alpha: 1)) + ctx.fill(CGRect(x: 0, y: 0, width: width, height: height)) + return ctx.makeImage()! +} + +func uvImage(scale: CGFloat) -> CGImage { + let width = Int(canvas.width * scale), height = Int(canvas.height * scale) + let ctx = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + for row in 0.. 1 ? CommandLine.arguments[1] : "glass-sweep") +let lensMode = CommandLine.arguments.count > 2 && CommandLine.arguments[2] == "lens" +let hslMode = CommandLine.arguments.count > 2 && CommandLine.arguments[2] == "hsl" +let levelsMode = hslMode || (CommandLine.arguments.count > 2 && CommandLine.arguments[2] == "levels") +let tintMode = levelsMode || (CommandLine.arguments.count > 2 && CommandLine.arguments[2] == "tints") +let tintGrid: [(String, NSColor?, [Double])] = { + var grid: [(String, NSColor?, [Double])] = [("none", nil, [])] + func add(_ name: String, _ r: Double, _ g: Double, _ b: Double, _ a: Double) { + grid.append((name, NSColor(srgbRed: r, green: g, blue: b, alpha: a), [r, g, b, a])) + } + for i in 0..<12 { + let color = NSColor(calibratedHue: CGFloat(i) / 12, saturation: 1, brightness: 1, alpha: 1).usingColorSpace(.sRGB)! + add("hue\(i * 30)", Double(color.redComponent), Double(color.greenComponent), Double(color.blueComponent), 0.6) + } + add("white", 1, 1, 1, 0.6) + add("grey50", 0.5, 0.5, 0.5, 0.6) + add("black", 0, 0, 0, 0.6) + add("navy", 0.1, 0.15, 0.45, 0.6) + add("brown", 0.55, 0.35, 0.2, 0.6) + add("pastel", 0.95, 0.75, 0.85, 0.6) + for a in [0.1, 0.2, 0.45, 0.8, 1.0] { + add("orange\(Int(a * 100))", 1, 0.58, 0.0, a) + } + if levelsMode { + grid = [("none", nil, [])] + for high in [0.2, 0.4, 0.6, 0.8, 1.0] { + for fraction in [0.0, 0.25, 0.5, 0.75] { + let low = high * fraction + add("warm\(Int(high * 100))-\(Int(fraction * 100))", high, low + (high - low) * 0.3, low, 0.6) + add("cool\(Int(high * 100))-\(Int(fraction * 100))", low, low + (high - low) * 0.6, high, 0.6) + } + add("grey\(Int(high * 100))", high, high, high, 0.6) + } + } + if hslMode { + grid = [("none", nil, [])] + // HSL to sRGB at hue 210 degrees: red is the darkest channel, blue + // the brightest, green halfway between. + for lightness in [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 1.0] { + for saturation in [0.0, 0.25, 0.5, 0.75, 1.0] { + let chroma = (1 - abs(2 * lightness - 1)) * saturation + let low = lightness - chroma / 2 + let high = lightness + chroma / 2 + add("l\(Int(lightness * 100))-s\(Int(saturation * 100))", low, (low + high) / 2, high, 0.6) + } + } + } + return grid +}() +try FileManager.default.createDirectory(at: out.appendingPathComponent("scenes"), withIntermediateDirectories: true) +try FileManager.default.createDirectory(at: out.appendingPathComponent("apple"), withIntermediateDirectories: true) + +let app = NSApplication.shared +app.setActivationPolicy(.regular) +app.finishLaunching() + +let window = NSWindow(contentRect: NSRect(origin: NSPoint(x: 80, y: 80), size: canvas), + styleMask: [.borderless], backing: .buffered, defer: false) +window.isOpaque = true +window.colorSpace = .sRGB +window.hasShadow = false +window.level = .floating +let root = FlippedView(frame: NSRect(origin: .zero, size: canvas)) +window.contentView = root +window.makeKeyAndOrderFront(nil) +app.activate(ignoringOtherApps: true) +pump(1.0) + +let scale = window.backingScaleFactor +let backdrop = NSImageView(frame: root.bounds) +backdrop.imageScaling = .scaleAxesIndependently +root.addSubview(backdrop) + +// Scene names by level; the lens mode has one scene, the UV ramp. +var images: [Int: NSImage] = [:] +var sceneNames: [Int: String] = [:] +let levels: [Int] = lensMode ? [-1] : tintMode ? [16, 128, 240] : greys +for level in levels { + let image = level < 0 ? uvImage(scale: scale) : greyImage(level, scale: scale) + let scene = level < 0 ? "uv" : String(format: "grey%03d", level) + writePNG(image, to: out.appendingPathComponent("scenes/\(scene).png")) + images[level] = NSImage(cgImage: image, size: canvas) + sceneNames[level] = scene +} + +var cases: [[String: Any]] = [] +let runTints = lensMode ? Array(tints.prefix(1)) : tintMode ? tintGrid : tints +for (tintName, tint, _) in runTints { + for (styleName, style) in styles { + for (appearanceName, appearance) in appearances { + // Clear's tint does not depend on the appearance, so the grid + // only needs Clear in light mode. + if tintMode && styleName == "clear" && appearanceName == "dark" { + continue + } + if levelsMode && styleName == "regular" { + continue + } + window.appearance = NSAppearance(named: appearance) + backdrop.image = images[levels[0]] + var views: [NSView] = [] + for shape in shapes { + let glass = NSGlassEffectView(frame: shape.rect) + glass.style = style + glass.cornerRadius = shape.radius + glass.tintColor = tint + root.addSubview(glass) + views.append(glass) + } + pump(1.0) + // Untinted runs go up and back down to expose hysteresis; tinted + // ones only go up. + let passes: [(String, [Int])] = tintName == "none" && !lensMode && !tintMode + ? [("up", levels), ("down", levels.reversed())] + : [("up", levels)] + for (pass, levels) in passes { + for level in levels { + backdrop.image = images[level] + pump(1.0) + let scene = sceneNames[level]! + let name = "\(scene)-\(styleName)-\(appearanceName)-\(tintName)-\(pass)" + capture(window, to: out.appendingPathComponent("apple/\(name).png")) + cases.append([ + "name": name, "scene": scene, "style": styleName, + "appearance": appearanceName, "tint": tintName, "pass": pass, + ]) + } + } + views.forEach { $0.removeFromSuperview() } + } + } +} + +let manifest: [String: Any] = [ + "canvas": [canvas.width, canvas.height], + "scale": scale, + "os": ProcessInfo.processInfo.operatingSystemVersionString, + "reduceTransparency": NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency, + "increaseContrast": NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast, + "gpu": MTLCreateSystemDefaultDevice()?.name ?? "none", + "shapes": shapes.map { ["name": $0.name, "rect": [$0.rect.minX, $0.rect.minY, $0.rect.width, $0.rect.height], "radius": $0.radius] }, + "tints": Dictionary(uniqueKeysWithValues: runTints.filter { !$0.2.isEmpty }.map { ($0.0, $0.2) }), + "cases": cases, +] +let data = try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys]) +try data.write(to: out.appendingPathComponent("manifest.json")) +print("wrote \(cases.count) cases to \(out.path)") From 87b8924621e6960467d3cf1818ef635b7cae7477 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:39:43 +0000 Subject: [PATCH 12/14] style(desktop-gpui): format glass presets --- apps/desktop-gpui/glass/presets.json | 802 +++++---------------------- 1 file changed, 153 insertions(+), 649 deletions(-) diff --git a/apps/desktop-gpui/glass/presets.json b/apps/desktop-gpui/glass/presets.json index d6b3e17c01..fb713621e2 100644 --- a/apps/desktop-gpui/glass/presets.json +++ b/apps/desktop-gpui/glass/presets.json @@ -8,255 +8,69 @@ "glow": -0.023217225447297096, "glow_width": 0.5, "hairline": [ - 0.593367338180542, - -0.05059341341257095, - 1.0214955806732178, + 0.593367338180542, -0.05059341341257095, 1.0214955806732178, 0.0020442637614905834 ], "lens_curve": 1.0, "light_angle": -0.21393775939941406, "line": 0.022001737728714943, - "line_lit": 4.008400719612837e-05, + "line_lit": 4.008400719612837e-5, "line_width": 0.977599024772644, "opacity": 0.6000000238418579, "poly": [ - 0.9304253458976746, - -0.12481314688920975, - -0.10088877379894257, - -0.004045877140015364, - -0.07585885375738144, - 0.14717964828014374, - -0.07393107563257217, - 0.06024130806326866, - -0.03879432752728462, - -0.000815805746242404, - 0.03807489201426506, - -0.07791057974100113, - 0.016926679760217667, - 0.0058880094438791275, - 0.03510991483926773, - -0.025886960327625275, - 0.016199838370084763, - -0.03267030790448189, - 0.01904219388961792, - 0.09808459132909775, - -0.00837408285588026, - 0.7795435786247253, - -0.07242636382579803, - -0.04168546944856644, - 0.04919590428471565, - 0.11554957926273346, - -0.059605952352285385, - -0.02326720952987671, - -0.007376297377049923, - -0.0005821718950755894, - -0.04080246761441231, - -0.05716409534215927, - 0.043717119842767715, - 0.023880789056420326, - 0.012365260161459446, - 0.026850342750549316, - -0.011099698953330517, - -0.011699579656124115, - -0.0034550407435745, - 0.0981355756521225, - -0.040654897689819336, - -0.15084543824195862, - 0.8951388597488403, - -0.00773319648578763, - -0.06471528857946396, - 0.08432544767856598, - 0.0036153215914964676, - 0.015856880694627762, - -0.013019285164773464, - -0.008954270742833614, - 0.056587692350149155, - -0.06041482836008072, - 0.033091913908720016, - 0.0052201892249286175, - -0.029888352379202843, - -0.02021406590938568, - 0.01316642202436924, - 0.017514236271381378, - -0.01460269745439291, - 0.0980868712067604 + 0.9304253458976746, -0.12481314688920975, -0.10088877379894257, + -0.004045877140015364, -0.07585885375738144, 0.14717964828014374, + -0.07393107563257217, 0.06024130806326866, -0.03879432752728462, + -0.000815805746242404, 0.03807489201426506, -0.07791057974100113, + 0.016926679760217667, 0.0058880094438791275, 0.03510991483926773, + -0.025886960327625275, 0.016199838370084763, -0.03267030790448189, + 0.01904219388961792, 0.09808459132909775, -0.00837408285588026, + 0.7795435786247253, -0.07242636382579803, -0.04168546944856644, + 0.04919590428471565, 0.11554957926273346, -0.059605952352285385, + -0.02326720952987671, -0.007376297377049923, -0.0005821718950755894, + -0.04080246761441231, -0.05716409534215927, 0.043717119842767715, + 0.023880789056420326, 0.012365260161459446, 0.026850342750549316, + -0.011099698953330517, -0.011699579656124115, -0.0034550407435745, + 0.0981355756521225, -0.040654897689819336, -0.15084543824195862, + 0.8951388597488403, -0.00773319648578763, -0.06471528857946396, + 0.08432544767856598, 0.0036153215914964676, 0.015856880694627762, + -0.013019285164773464, -0.008954270742833614, 0.056587692350149155, + -0.06041482836008072, 0.033091913908720016, 0.0052201892249286175, + -0.029888352379202843, -0.02021406590938568, 0.01316642202436924, + 0.017514236271381378, -0.01460269745439291, 0.0980868712067604 ], "rim_base": 3.5, "rim_k": 0.039999820291996, "rim_max": 7.899994850158691, "tint": { "high": [ - [ - -0.068, - -0.068, - -0.068, - -0.068, - -0.068 - ], - [ - -0.1654, - 0.043, - 0.043, - 0.1021, - 0.1161 - ], - [ - -0.1202, - 0.1449, - 0.1839, - 0.211, - 0.2499 - ], - [ - -0.0202, - 0.2129, - 0.2659, - 0.3087, - 0.3616 - ], - [ - 0.0908, - 0.2828, - 0.3405, - 0.4175, - 0.4843 - ], - [ - 0.1908, - 0.3508, - 0.4318, - 0.5262, - 0.6071 - ], - [ - 0.2908, - 0.4314, - 0.5405, - 0.6505, - 0.7616 - ], - [ - 0.4018, - 0.5174, - 0.6456, - 0.7749, - 0.9161 - ], - [ - 0.4908, - 0.5777, - 0.7453, - 0.9103, - 1.0044 - ], - [ - 0.6675, - 0.6379, - 0.8026, - 0.9573, - 1.0044 - ], - [ - 0.8162, - 0.5092, - 0.8019, - 0.9504, - 1.0044 - ], - [ - 0.9759, - 0.9759, - 0.9759, - 0.9759, - 0.9759 - ] + [-0.068, -0.068, -0.068, -0.068, -0.068], + [-0.1654, 0.043, 0.043, 0.1021, 0.1161], + [-0.1202, 0.1449, 0.1839, 0.211, 0.2499], + [-0.0202, 0.2129, 0.2659, 0.3087, 0.3616], + [0.0908, 0.2828, 0.3405, 0.4175, 0.4843], + [0.1908, 0.3508, 0.4318, 0.5262, 0.6071], + [0.2908, 0.4314, 0.5405, 0.6505, 0.7616], + [0.4018, 0.5174, 0.6456, 0.7749, 0.9161], + [0.4908, 0.5777, 0.7453, 0.9103, 1.0044], + [0.6675, 0.6379, 0.8026, 0.9573, 1.0044], + [0.8162, 0.5092, 0.8019, 0.9504, 1.0044], + [0.9759, 0.9759, 0.9759, 0.9759, 0.9759] ], "kind": "layer", "low": [ - [ - -0.068, - -0.068, - -0.068, - -0.068, - -0.068 - ], - [ - -0.1654, - 0.043, - 0.043, - -0.0162, - -0.0302 - ], - [ - -0.1202, - 0.0719, - 0.0329, - 0.0092, - -0.0297 - ], - [ - -0.0202, - 0.1013, - 0.0482, - 0.0018, - -0.0511 - ], - [ - 0.0908, - 0.1434, - 0.0782, - 0.0054, - -0.0615 - ], - [ - 0.1908, - 0.1728, - 0.0917, - -0.0131, - -0.0939 - ], - [ - 0.2908, - 0.2046, - 0.1028, - -0.0032, - -0.1142 - ], - [ - 0.4018, - 0.2309, - 0.1102, - -0.0153, - -0.1565 - ], - [ - 0.4908, - 0.2536, - 0.1011, - -0.0822, - -0.1764 - ], - [ - 0.6675, - 0.3201, - 0.1707, - 0.0269, - -0.0202 - ], - [ - 0.8162, - 0.5092, - 0.2201, - 0.0576, - 0.014 - ], - [ - 0.9759, - 0.9759, - 0.9759, - 0.9759, - 0.9759 - ] + [-0.068, -0.068, -0.068, -0.068, -0.068], + [-0.1654, 0.043, 0.043, -0.0162, -0.0302], + [-0.1202, 0.0719, 0.0329, 0.0092, -0.0297], + [-0.0202, 0.1013, 0.0482, 0.0018, -0.0511], + [0.0908, 0.1434, 0.0782, 0.0054, -0.0615], + [0.1908, 0.1728, 0.0917, -0.0131, -0.0939], + [0.2908, 0.2046, 0.1028, -0.0032, -0.1142], + [0.4018, 0.2309, 0.1102, -0.0153, -0.1565], + [0.4908, 0.2536, 0.1011, -0.0822, -0.1764], + [0.6675, 0.3201, 0.1707, 0.0269, -0.0202], + [0.8162, 0.5092, 0.2201, 0.0576, 0.014], + [0.9759, 0.9759, 0.9759, 0.9759, 0.9759] ] } }, @@ -269,9 +83,7 @@ "glow": -0.07008297741413116, "glow_width": 0.5, "hairline": [ - 0.8242620229721069, - -0.1320314109325409, - 0.9752746820449829, + 0.8242620229721069, -0.1320314109325409, 0.9752746820449829, 0.04035332053899765 ], "lens_curve": 1.0, @@ -281,243 +93,59 @@ "line_width": 0.8613333702087402, "opacity": 0.6000000238418579, "poly": [ - 1.1434636116027832, - -0.27574145793914795, - -0.17895224690437317, - -0.0836833268404007, - 0.1225225031375885, - 0.1864597499370575, - -0.37334734201431274, - 0.4921723008155823, - -0.3727247416973114, - -0.12790314853191376, - -0.02433956041932106, - -0.05253470316529274, - 0.29910096526145935, - 0.11252079159021378, - 0.02607177011668682, - -0.18502143025398254, - 0.15787704288959503, - -0.2634184658527374, - 0.075639508664608, - 0.27857351303100586, - -0.03583918884396553, - 0.7502926588058472, - -0.02740371786057949, - -0.01758371666073799, - 0.02899535372853279, - 0.023246919736266136, - -0.019552607089281082, - -0.0588289238512516, - 0.0193873830139637, - -0.005965698044747114, - -0.034728698432445526, - -0.0037371099460870028, - 0.019717223942279816, - 0.017275473102927208, - 0.005123075097799301, - 0.04545024037361145, - -0.0219881571829319, - 0.006534123793244362, - -0.012194417417049408, - 0.27857986092567444, - -0.07504594326019287, - -0.19895681738853455, - 0.9592817425727844, - -0.06376186013221741, - 0.014718386344611645, - -0.15277686715126038, - 0.07323915511369705, - 0.006354332435876131, - 0.10439865291118622, - 0.04123719036579132, - 0.035259801894426346, - -0.11292031407356262, - 0.010606243275105953, - 0.0005314881564117968, - -9.800001862458885e-05, - -0.18178176879882812, - 0.060928262770175934, - 0.37380093336105347, - -0.21683937311172485, - 0.27857956290245056 + 1.1434636116027832, -0.27574145793914795, -0.17895224690437317, + -0.0836833268404007, 0.1225225031375885, 0.1864597499370575, + -0.37334734201431274, 0.4921723008155823, -0.3727247416973114, + -0.12790314853191376, -0.02433956041932106, -0.05253470316529274, + 0.29910096526145935, 0.11252079159021378, 0.02607177011668682, + -0.18502143025398254, 0.15787704288959503, -0.2634184658527374, + 0.075639508664608, 0.27857351303100586, -0.03583918884396553, + 0.7502926588058472, -0.02740371786057949, -0.01758371666073799, + 0.02899535372853279, 0.023246919736266136, -0.019552607089281082, + -0.0588289238512516, 0.0193873830139637, -0.005965698044747114, + -0.034728698432445526, -0.0037371099460870028, 0.019717223942279816, + 0.017275473102927208, 0.005123075097799301, 0.04545024037361145, + -0.0219881571829319, 0.006534123793244362, -0.012194417417049408, + 0.27857986092567444, -0.07504594326019287, -0.19895681738853455, + 0.9592817425727844, -0.06376186013221741, 0.014718386344611645, + -0.15277686715126038, 0.07323915511369705, 0.006354332435876131, + 0.10439865291118622, 0.04123719036579132, 0.035259801894426346, + -0.11292031407356262, 0.010606243275105953, 0.0005314881564117968, + -9.800001862458885e-5, -0.18178176879882812, 0.060928262770175934, + 0.37380093336105347, -0.21683937311172485, 0.27857956290245056 ], "rim_base": 3.5, "rim_k": 0.04000019654631615, "rim_max": 7.90000057220459, "tint": { "high": [ - [ - -0.068, - -0.068, - -0.068, - -0.068, - -0.068 - ], - [ - -0.1654, - 0.043, - 0.043, - 0.1021, - 0.1161 - ], - [ - -0.1202, - 0.1449, - 0.1839, - 0.211, - 0.2499 - ], - [ - -0.0202, - 0.2129, - 0.2659, - 0.3087, - 0.3616 - ], - [ - 0.0908, - 0.2828, - 0.3405, - 0.4175, - 0.4843 - ], - [ - 0.1908, - 0.3508, - 0.4318, - 0.5262, - 0.6071 - ], - [ - 0.2908, - 0.4314, - 0.5405, - 0.6505, - 0.7616 - ], - [ - 0.4018, - 0.5174, - 0.6456, - 0.7749, - 0.9161 - ], - [ - 0.4908, - 0.5777, - 0.7453, - 0.9103, - 1.0044 - ], - [ - 0.6675, - 0.6379, - 0.8026, - 0.9573, - 1.0044 - ], - [ - 0.8162, - 0.5092, - 0.8019, - 0.9504, - 1.0044 - ], - [ - 0.9759, - 0.9759, - 0.9759, - 0.9759, - 0.9759 - ] + [-0.068, -0.068, -0.068, -0.068, -0.068], + [-0.1654, 0.043, 0.043, 0.1021, 0.1161], + [-0.1202, 0.1449, 0.1839, 0.211, 0.2499], + [-0.0202, 0.2129, 0.2659, 0.3087, 0.3616], + [0.0908, 0.2828, 0.3405, 0.4175, 0.4843], + [0.1908, 0.3508, 0.4318, 0.5262, 0.6071], + [0.2908, 0.4314, 0.5405, 0.6505, 0.7616], + [0.4018, 0.5174, 0.6456, 0.7749, 0.9161], + [0.4908, 0.5777, 0.7453, 0.9103, 1.0044], + [0.6675, 0.6379, 0.8026, 0.9573, 1.0044], + [0.8162, 0.5092, 0.8019, 0.9504, 1.0044], + [0.9759, 0.9759, 0.9759, 0.9759, 0.9759] ], "kind": "layer", "low": [ - [ - -0.068, - -0.068, - -0.068, - -0.068, - -0.068 - ], - [ - -0.1654, - 0.043, - 0.043, - -0.0162, - -0.0302 - ], - [ - -0.1202, - 0.0719, - 0.0329, - 0.0092, - -0.0297 - ], - [ - -0.0202, - 0.1013, - 0.0482, - 0.0018, - -0.0511 - ], - [ - 0.0908, - 0.1434, - 0.0782, - 0.0054, - -0.0615 - ], - [ - 0.1908, - 0.1728, - 0.0917, - -0.0131, - -0.0939 - ], - [ - 0.2908, - 0.2046, - 0.1028, - -0.0032, - -0.1142 - ], - [ - 0.4018, - 0.2309, - 0.1102, - -0.0153, - -0.1565 - ], - [ - 0.4908, - 0.2536, - 0.1011, - -0.0822, - -0.1764 - ], - [ - 0.6675, - 0.3201, - 0.1707, - 0.0269, - -0.0202 - ], - [ - 0.8162, - 0.5092, - 0.2201, - 0.0576, - 0.014 - ], - [ - 0.9759, - 0.9759, - 0.9759, - 0.9759, - 0.9759 - ] + [-0.068, -0.068, -0.068, -0.068, -0.068], + [-0.1654, 0.043, 0.043, -0.0162, -0.0302], + [-0.1202, 0.0719, 0.0329, 0.0092, -0.0297], + [-0.0202, 0.1013, 0.0482, 0.0018, -0.0511], + [0.0908, 0.1434, 0.0782, 0.0054, -0.0615], + [0.1908, 0.1728, 0.0917, -0.0131, -0.0939], + [0.2908, 0.2046, 0.1028, -0.0032, -0.1142], + [0.4018, 0.2309, 0.1102, -0.0153, -0.1565], + [0.4908, 0.2536, 0.1011, -0.0822, -0.1764], + [0.6675, 0.3201, 0.1707, 0.0269, -0.0202], + [0.8162, 0.5092, 0.2201, 0.0576, 0.014], + [0.9759, 0.9759, 0.9759, 0.9759, 0.9759] ] } }, @@ -525,66 +153,26 @@ "adapt": { "large": 104.5999984741211, "poly": [ - -2.121854066848755, - 4.518572807312012, - -1.3435695171356201, - 0.776678204536438, - -9.252964973449707, - 0.28634369373321533, - 4.4394073486328125, - -1.3082177639007568, - 4.613157272338867, - 0.1885642260313034, - 4.356483459472656, - 2.1629786491394043, - -1.000849723815918, - -1.141960620880127, - -1.716369390487671, - 4.243387222290039, - -2.109083414077759, - -3.6303977966308594, - -1.3335038423538208, - 0.07930468022823334, - -0.06321439892053604, - 1.6568504571914673, - -0.5502462983131409, - 0.47407275438308716, - -0.5093795657157898, - 0.18556971848011017, - -1.3741369247436523, - 0.4993451237678528, - 0.30742496252059937, - -0.13183802366256714, - -0.43906792998313904, - 0.5691431164741516, - -0.260586142539978, - -0.2801496684551239, - 1.1938122510910034, - 0.6415687203407288, - 0.2897544205188751, - -1.767791986465454, - 0.18552877008914948, - 0.07926828414201736, - -0.7065837979316711, - -1.389380693435669, - 3.1453685760498047, - 1.1397459506988525, - 1.6847290992736816, - -3.872493028640747, - -0.043841056525707245, - 0.8904445767402649, - -0.2337447702884674, - 0.0994030013680458, - -0.5763757824897766, - 1.5949156284332275, - -1.1367660760879517, - -1.1690083742141724, - 0.5194790959358215, - -1.9919798374176025, - 0.5003051161766052, - 0.38825687766075134, - 1.7843526601791382, - 0.07925731688737869 + -2.121854066848755, 4.518572807312012, -1.3435695171356201, + 0.776678204536438, -9.252964973449707, 0.28634369373321533, + 4.4394073486328125, -1.3082177639007568, 4.613157272338867, + 0.1885642260313034, 4.356483459472656, 2.1629786491394043, + -1.000849723815918, -1.141960620880127, -1.716369390487671, + 4.243387222290039, -2.109083414077759, -3.6303977966308594, + -1.3335038423538208, 0.07930468022823334, -0.06321439892053604, + 1.6568504571914673, -0.5502462983131409, 0.47407275438308716, + -0.5093795657157898, 0.18556971848011017, -1.3741369247436523, + 0.4993451237678528, 0.30742496252059937, -0.13183802366256714, + -0.43906792998313904, 0.5691431164741516, -0.260586142539978, + -0.2801496684551239, 1.1938122510910034, 0.6415687203407288, + 0.2897544205188751, -1.767791986465454, 0.18552877008914948, + 0.07926828414201736, -0.7065837979316711, -1.389380693435669, + 3.1453685760498047, 1.1397459506988525, 1.6847290992736816, + -3.872493028640747, -0.043841056525707245, 0.8904445767402649, + -0.2337447702884674, 0.0994030013680458, -0.5763757824897766, + 1.5949156284332275, -1.1367660760879517, -1.1690083742141724, + 0.5194790959358215, -1.9919798374176025, 0.5003051161766052, + 0.38825687766075134, 1.7843526601791382, 0.07925731688737869 ], "small": 64.0 }, @@ -596,9 +184,7 @@ "glow": -0.0030187764205038548, "glow_width": 0.5, "hairline": [ - 0.1722264587879181, - 0.17626123130321503, - 0.48439785838127136, + 0.1722264587879181, 0.17626123130321503, 0.48439785838127136, 0.10737959295511246 ], "lens_curve": 1.0, @@ -608,66 +194,26 @@ "line_width": 0.30000001192092896, "opacity": 0.6000000238418579, "poly": [ - 1.1187022924423218, - 0.03367934003472328, - -0.10441520810127258, - -0.13338716328144073, - 0.29741689562797546, - 0.19946272671222687, - -0.5520410537719727, - -0.9366043210029602, - 0.44745180010795593, - 0.0030512954108417034, - -0.45639705657958984, - -0.0059659117832779884, - -0.07397174090147018, - 0.006216675043106079, - 0.13561110198497772, - 0.7081236839294434, - -0.3326506018638611, - 0.11330801993608475, - -0.0989147275686264, - 0.07836921513080597, - -0.060900237411260605, - 1.11102294921875, - -0.017686933279037476, - 0.029460275545716286, - -0.4791528880596161, - 0.013812395744025707, - -0.1606086790561676, - -0.02373814396560192, - -0.028927847743034363, - -0.017582418397068977, - -0.002322347601875663, - -0.009515616111457348, - 0.012796386145055294, - 0.0010435275034978986, - 0.0005133572849445045, - -0.019619032740592957, - 0.012490831315517426, - -0.012097016908228397, - 0.01967502385377884, - 0.0784754678606987, - -0.06453773379325867, - -0.00016743599553592503, - 1.1111270189285278, - 0.025668099522590637, - 0.23321135342121124, - 0.05709727853536606, - -0.4604215919971466, - -0.7881916761398315, - 0.26137059926986694, - -0.050472091883420944, - -0.392466276884079, - 0.03832476586103439, - 0.047914065420627594, - 0.004019365645945072, - 0.4532164931297302, - 0.2794245183467865, - -0.20846489071846008, - 0.008654672652482986, - -0.18662935495376587, - 0.07838035374879837 + 1.1187022924423218, 0.03367934003472328, -0.10441520810127258, + -0.13338716328144073, 0.29741689562797546, 0.19946272671222687, + -0.5520410537719727, -0.9366043210029602, 0.44745180010795593, + 0.0030512954108417034, -0.45639705657958984, -0.0059659117832779884, + -0.07397174090147018, 0.006216675043106079, 0.13561110198497772, + 0.7081236839294434, -0.3326506018638611, 0.11330801993608475, + -0.0989147275686264, 0.07836921513080597, -0.060900237411260605, + 1.11102294921875, -0.017686933279037476, 0.029460275545716286, + -0.4791528880596161, 0.013812395744025707, -0.1606086790561676, + -0.02373814396560192, -0.028927847743034363, -0.017582418397068977, + -0.002322347601875663, -0.009515616111457348, 0.012796386145055294, + 0.0010435275034978986, 0.0005133572849445045, -0.019619032740592957, + 0.012490831315517426, -0.012097016908228397, 0.01967502385377884, + 0.0784754678606987, -0.06453773379325867, -0.00016743599553592503, + 1.1111270189285278, 0.025668099522590637, 0.23321135342121124, + 0.05709727853536606, -0.4604215919971466, -0.7881916761398315, + 0.26137059926986694, -0.050472091883420944, -0.392466276884079, + 0.03832476586103439, 0.047914065420627594, 0.004019365645945072, + 0.4532164931297302, 0.2794245183467865, -0.20846489071846008, + 0.008654672652482986, -0.18662935495376587, 0.07838035374879837 ], "rim_base": 4.0, "rim_k": 0.07199979573488235, @@ -687,9 +233,7 @@ "glow": 0.0010122086387127638, "glow_width": 2.685718297958374, "hairline": [ - 0.7884511947631836, - -0.04988262057304382, - 0.8663737773895264, + 0.7884511947631836, -0.04988262057304382, 0.8663737773895264, -0.03755432367324829 ], "lens_curve": 1.0, @@ -699,66 +243,26 @@ "line_width": 0.30000001192092896, "opacity": 0.6000000238418579, "poly": [ - 1.2788854837417603, - -0.8453500866889954, - 0.003082971554249525, - 0.2738867998123169, - -0.27242588996887207, - -0.500204861164093, - -0.4753827750682831, - 1.9009149074554443, - -0.9198514223098755, - -0.7914177775382996, - 0.7524295449256897, - 0.33018216490745544, - 1.1248490810394287, - 0.14570458233356476, - -0.5863457918167114, - -1.1137068271636963, - 0.4047931730747223, - -0.5792659521102905, - 0.2896379828453064, - 0.5211592316627502, - -0.036302655935287476, - 0.41237375140190125, - 0.05753712356090546, - -0.13990472257137299, - 0.19397875666618347, - -0.044933948665857315, - 0.13752412796020508, - 0.055802471935749054, - -0.1938532590866089, - 0.07925422489643097, - -0.11504264175891876, - -0.030976055189967155, - -0.05894238501787186, - 0.06816978752613068, - -0.0008735256269574165, - -0.02404271997511387, - 0.12015138566493988, - 0.011716355569660664, - -0.07116328924894333, - 0.5211941599845886, - -0.599159300327301, - -0.7419008612632751, - 1.7605870962142944, - 0.01254228688776493, - 0.24147452414035797, - -0.6960784792900085, - 1.0482441186904907, - -0.8146082162857056, - 0.2614218294620514, - 0.20659644901752472, - 0.24794399738311768, - -0.708295464515686, - -0.10671452432870865, - -0.4012254476547241, - -0.5572263598442078, - -0.5958108305931091, - 0.4865833520889282, - 1.8127862215042114, - -0.43663233518600464, - 0.5212134122848511 + 1.2788854837417603, -0.8453500866889954, 0.003082971554249525, + 0.2738867998123169, -0.27242588996887207, -0.500204861164093, + -0.4753827750682831, 1.9009149074554443, -0.9198514223098755, + -0.7914177775382996, 0.7524295449256897, 0.33018216490745544, + 1.1248490810394287, 0.14570458233356476, -0.5863457918167114, + -1.1137068271636963, 0.4047931730747223, -0.5792659521102905, + 0.2896379828453064, 0.5211592316627502, -0.036302655935287476, + 0.41237375140190125, 0.05753712356090546, -0.13990472257137299, + 0.19397875666618347, -0.044933948665857315, 0.13752412796020508, + 0.055802471935749054, -0.1938532590866089, 0.07925422489643097, + -0.11504264175891876, -0.030976055189967155, -0.05894238501787186, + 0.06816978752613068, -0.0008735256269574165, -0.02404271997511387, + 0.12015138566493988, 0.011716355569660664, -0.07116328924894333, + 0.5211941599845886, -0.599159300327301, -0.7419008612632751, + 1.7605870962142944, 0.01254228688776493, 0.24147452414035797, + -0.6960784792900085, 1.0482441186904907, -0.8146082162857056, + 0.2614218294620514, 0.20659644901752472, 0.24794399738311768, + -0.708295464515686, -0.10671452432870865, -0.4012254476547241, + -0.5572263598442078, -0.5958108305931091, 0.4865833520889282, + 1.8127862215042114, -0.43663233518600464, 0.5212134122848511 ], "rim_base": 4.0, "rim_k": 0.07199995219707489, From ec8327854dcfdb76efa21118125a168fcda2e2a9 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:45:14 +0000 Subject: [PATCH 13/14] ci(glass-probe): skip the matrix for fork pull requests that change no glass code --- .github/workflows/glass-probe.yml | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/glass-probe.yml b/.github/workflows/glass-probe.yml index 0ccced4119..83f9e315e7 100644 --- a/.github/workflows/glass-probe.yml +++ b/.github/workflows/glass-probe.yml @@ -18,14 +18,48 @@ on: permissions: contents: read + pull-requests: read concurrency: group: glass-probe-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: + # A pull request from a fork that changes only this workflow or the probe's + # scripts does not queue the three-runner matrix; one that also changes the + # glass itself does, and so does any change from this repository. + scope: + name: Scope + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 5 + outputs: + run: ${{ steps.scope.outputs.run }} + steps: + - name: Scope + id: scope + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + PR: ${{ github.event.pull_request.number }} + SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + run: | + set -euo pipefail + if [[ "$EVENT" != pull_request || "$SAME_REPO" == true ]]; then + echo "run=true" >> "$GITHUB_OUTPUT"; exit 0 + fi + gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/files" --paginate --jq '.[].filename' > "$RUNNER_TEMP/changed.txt" + glass='^apps/desktop-gpui/(glass/.*|patches/zed-glass\.patch|src/ui/glass\.rs|src/glass_probe\.rs)$' + if grep -qE "$glass" "$RUNNER_TEMP/changed.txt"; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + echo "A fork pull request that changes no glass code does not run the glass probe." | tee -a "$GITHUB_STEP_SUMMARY" + fi + probe: name: Glass probe (${{ matrix.settings.name }}) + needs: scope + if: needs.scope.outputs.run == 'true' timeout-minutes: 75 strategy: fail-fast: false From 0cb9fbe4ce76cbd1e379efcfcba3b6800fbe569f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:00:10 +0000 Subject: [PATCH 14/14] ci(glass-probe): upload the probed Windows and Linux builds for GPU machines --- .github/workflows/glass-probe.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/glass-probe.yml b/.github/workflows/glass-probe.yml index 83f9e315e7..57f960e09b 100644 --- a/.github/workflows/glass-probe.yml +++ b/.github/workflows/glass-probe.yml @@ -179,3 +179,29 @@ jobs: name: glass-probe-${{ matrix.settings.slug }} path: ${{ runner.temp }}/glass-probe retention-days: 7 + + # The probed build itself, so the same binary can run on machines with + # real GPUs (the hosted runners draw on WARP and lavapipe). + - name: Stage the build + if: ${{ success() && runner.os != 'macOS' }} + shell: bash + run: | + out="$RUNNER_TEMP/cap-gpui-build" + mkdir -p "$out" + bin="$GITHUB_WORKSPACE/apps/desktop-gpui/target/${{ matrix.settings.target }}/debug/cap-gpui" + if [[ "$RUNNER_OS" == Windows ]]; then + cp "$bin.exe" "$out/" + cp "$GITHUB_WORKSPACE"/target/debug/*.dll "$out/" 2>/dev/null || true + else + cp "$bin" "$out/" + cp -r "$GITHUB_WORKSPACE/target/native-deps/lib" "$out/lib" 2>/dev/null || true + fi + + - name: Upload the build + if: ${{ success() && runner.os != 'macOS' }} + uses: actions/upload-artifact@v4 + with: + name: cap-gpui-debug-${{ matrix.settings.slug }} + path: ${{ runner.temp }}/cap-gpui-build + if-no-files-found: ignore + retention-days: 3