gpui: Add dithering to linear gradient shader (#51211)

iam-liam created

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

<img width="1886" height="1003" alt="gradient_dithering_before"
src="https://github.com/user-attachments/assets/f75ae93b-b142-4d0e-9b61-e08f30fe1758"
/>

_Before_

<img width="1902" height="1052" alt="gradient_dithering_after"
src="https://github.com/user-attachments/assets/7aee9a36-f578-4e08-a846-44d092bcf043"
/>

_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

Change summary

crates/gpui_macos/src/shaders.metal  | 14 ++++++++++++++
crates/gpui_windows/src/shaders.hlsl | 14 ++++++++++++++
2 files changed, 28 insertions(+)

Detailed changes

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

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