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
_Before_
_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: {