From a46858ac21ec43b1af94d8b2b9a5a8fde4ac4f01 Mon Sep 17 00:00:00 2001 From: iam-liam <117163129+iam-liam@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:23:07 +0100 Subject: [PATCH] gpui: Add dithering to linear gradient shader (#51211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linear gradients in dark color ranges (5-15% lightness) show visible banding due to 8-bit quantization — only ~7 distinct values exist in that range, producing hard steps instead of smooth transitions. This affects every dark theme in Zed. ## What this does Adds triangular-distributed dithering after gradient interpolation in both the Metal and HLSL fragment shaders. The noise breaks up quantization steps at the sub-pixel level, producing perceptually smooth gradients. ## How it works Two hash-based pseudo-random values (seeded from fragment position x golden ratio) are summed to produce a triangular probability distribution. This is added to the RGB channels at +/-1/255 amplitude. - **Triangular PDF** — mean-zero, so no brightness shift across the gradient - **+/-1/255 amplitude** — below perceptual threshold, invisible on bright gradients where 8-bit precision is already sufficient - **Deterministic per-pixel** — seeded from position, no temporal flickering - **Zero-cost** — a couple of `fract`/`sin` per fragment, negligible vs. the existing gradient math ### Channel-specific amplitudes | Channel | Amplitude | Rationale | |---------|-----------|----------------------------------------------------------------------------------------------------| | RGB | ±2/255 | Breaks dark-on-dark banding where adjacent 8-bit values are perceptually close | | Alpha | ±3/255 | Alpha gradients over dark backgrounds need stronger noise — α × dark color = tiny composited steps | The higher alpha amplitude is necessary because when a semi-transparent gradient (e.g., 0.4 → 0.0 alpha) composites over a dark background, the effective visible difference per quantization step is smaller than the RGB case. ±3/255 is still well below the perceptual threshold on bright/opaque elements. ## Scope Two files changed, purely additive: | File | Change | |------|--------| | `crates/gpui_macos/src/shaders.metal` | 13 lines after `mix()` in `fill_color()` | | `crates/gpui_windows/src/shaders.hlsl` | 13 lines after `lerp()` in `gradient_color()` | No changes to Rust code, no API changes, no new dependencies. ## Screenshots gradient_dithering_before _Before_ gradient_dithering_after _After_ ## Test plan This is a shader-level fix; no Rust test harness exists for visual output. Manual testing is appropriate here. Visual regression tests cover UI layout, not sub-pixel rendering quality. **Manual (macOS):** - [x] Dark gradients (5-13% lightness range) — banding eliminated - [x] Bright gradients — no visible difference (dither amplitude below precision threshold) - [x] Oklab and sRGB color spaces — both paths dithered - [x] Solid colours, pattern fills, checkerboard — unaffected (dither only applies to LinearGradient case) - [x] Alpha gradients (semi-transparent over dark bg) — banding eliminated with alpha dithering - [x] Path gradients (paint_path) — same fill_colour() function, dithering applies **Windows:** HLSL change is identical logic with HLSL built-ins (`frac`/`lerp` vs `fract`/`mix`) — not tested locally. Release Notes: - Improved linear gradient rendering by adding dithering to eliminate visible banding in dark color ranges Liam --- crates/gpui_macos/src/shaders.metal | 14 ++++++++++++++ crates/gpui_windows/src/shaders.hlsl | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/crates/gpui_macos/src/shaders.metal b/crates/gpui_macos/src/shaders.metal index 3c6adac3359ac41ee0cc265480dae6e63a2c2136..4dc2d334e1e0929cc2ee7369ecce2f074b919366 100644 --- a/crates/gpui_macos/src/shaders.metal +++ b/crates/gpui_macos/src/shaders.metal @@ -1215,6 +1215,20 @@ float4 fill_color(Background background, break; } } + + // Dither to reduce banding in gradients (especially dark/alpha). + // Triangular-distributed noise breaks up 8-bit quantization steps. + // ±2/255 for RGB (enough for dark-on-dark compositing), + // ±3/255 for alpha (needs more because alpha × dark color = tiny steps). + { + float2 seed = position * 0.6180339887; // golden ratio spread + float r1 = fract(sin(dot(seed, float2(12.9898, 78.233))) * 43758.5453); + float r2 = fract(sin(dot(seed, float2(39.3460, 11.135))) * 24634.6345); + float tri = r1 + r2 - 1.0; // triangular PDF, range [-1, +1] + color.rgb += tri * 2.0 / 255.0; + color.a += tri * 3.0 / 255.0; + } + break; } case 2: { diff --git a/crates/gpui_windows/src/shaders.hlsl b/crates/gpui_windows/src/shaders.hlsl index f508387daf9c98ffcce521209d2c981cf04db983..646cfd61cc37c31fade09d427c6d7c8f87519fa6 100644 --- a/crates/gpui_windows/src/shaders.hlsl +++ b/crates/gpui_windows/src/shaders.hlsl @@ -384,6 +384,20 @@ float4 gradient_color(Background background, break; } } + + // Dither to reduce banding in gradients (especially dark/alpha). + // Triangular-distributed noise breaks up 8-bit quantization steps. + // ±2/255 for RGB (enough for dark-on-dark compositing), + // ±3/255 for alpha (needs more because alpha × dark color = tiny steps). + { + float2 seed = position * 0.6180339887; // golden ratio spread + float r1 = frac(sin(dot(seed, float2(12.9898, 78.233))) * 43758.5453); + float r2 = frac(sin(dot(seed, float2(39.3460, 11.135))) * 24634.6345); + float tri = r1 + r2 - 1.0; // triangular PDF, range [-1, +1] + color.rgb += tri * 2.0 / 255.0; + color.a += tri * 3.0 / 255.0; + } + break; } case 2: {