gpui: Add dithering to linear gradient shader (#51211)
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
This commit is contained in:
1 parent
1c18a6c497
commit
c227d76df4
2 files changed
+28
No files matched your search
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user