Implement perceptual gamma / contrast correction for Linux font rendering (#38862)

Kirill Bulatov and localcc created

Part of https://github.com/zed-industries/zed/issues/7992
Port of https://github.com/zed-industries/zed/pull/37167 to Linux

When using Blade rendering (Linux platforms and self-compiled builds
with the Blade renderer enabled), Zed reads `ZED_FONTS_GAMMA` and
`ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST` environment variables for the
values to use for font rendering.

`ZED_FONTS_GAMMA` corresponds to
[getgamma](https://learn.microsoft.com/en-us/windows/win32/api/dwrite/nf-dwrite-idwriterenderingparams-getgamma)
values.
Allowed range [1.0, 2.2], other values are clipped.
Default: 1.8

`ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST` corresponds to
[getgrayscaleenhancedcontrast](https://learn.microsoft.com/en-us/windows/win32/api/dwrite_1/nf-dwrite_1-idwriterenderingparams1-getgrayscaleenhancedcontrast)
values.
Allowed range: [0.0, ..), other values are clipped.
Default: 1.0

Screenshots (left is Nightly, right is the new code):

* Non-lodpi display

With the defaults:

<img width="2560" height="1600" alt="image"
src="https://github.com/user-attachments/assets/987168b4-3f5f-45a0-a740-9c0e49efbb9c"
/>


With `env ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST=7777`: 

<img width="2560" height="1600" alt="image"
src="https://github.com/user-attachments/assets/893bc2c7-9db4-4874-8ef6-3425d079db63"
/>


Lodpi, default settings:
<img width="3830" height="2160" alt="image"
src="https://github.com/user-attachments/assets/ec009e00-69b3-4c01-a18c-8286e2015e74"
/>

Lodpi, font size 7:
<img width="3830" height="2160" alt="image"
src="https://github.com/user-attachments/assets/f33e3df6-971b-4e18-b425-53d3404b19be"
/>


Release Notes:

- Implement perceptual gamma / contrast correction for Linux font
rendering

---------

Co-authored-by: localcc <work@localcc.cc>

Change summary

crates/gpui/src/platform/blade/blade_renderer.rs | 120 +++++++++++++++--
crates/gpui/src/platform/blade/shaders.wgsl      |  35 +++++
docs/src/linux.md                                |  12 +
3 files changed, 149 insertions(+), 18 deletions(-)

Detailed changes

crates/gpui/src/platform/blade/blade_renderer.rs 🔗

@@ -83,6 +83,8 @@ struct ShaderUnderlinesData {
 #[derive(blade_macros::ShaderData)]
 struct ShaderMonoSpritesData {
     globals: GlobalParams,
+    gamma_ratios: [f32; 4],
+    grayscale_enhanced_contrast: f32,
     t_sprite: gpu::TextureView,
     s_sprite: gpu::Sampler,
     b_mono_sprites: gpu::BufferPiece,
@@ -334,11 +336,11 @@ pub struct BladeRenderer {
     atlas_sampler: gpu::Sampler,
     #[cfg(target_os = "macos")]
     core_video_texture_cache: CVMetalTextureCache,
-    path_sample_count: u32,
     path_intermediate_texture: gpu::Texture,
     path_intermediate_texture_view: gpu::TextureView,
     path_intermediate_msaa_texture: Option<gpu::Texture>,
     path_intermediate_msaa_texture_view: Option<gpu::TextureView>,
+    rendering_parameters: RenderingParameters,
 }
 
 impl BladeRenderer {
@@ -364,17 +366,12 @@ impl BladeRenderer {
             name: "main",
             buffer_count: 2,
         });
-        // workaround for https://github.com/zed-industries/zed/issues/26143
-        let path_sample_count = std::env::var("ZED_PATH_SAMPLE_COUNT")
-            .ok()
-            .and_then(|v| v.parse().ok())
-            .or_else(|| {
-                [4, 2, 1]
-                    .into_iter()
-                    .find(|&n| (context.gpu.capabilities().sample_count_mask & n) != 0)
-            })
-            .unwrap_or(1);
-        let pipelines = BladePipelines::new(&context.gpu, surface.info(), path_sample_count);
+        let rendering_parameters = RenderingParameters::from_env(context);
+        let pipelines = BladePipelines::new(
+            &context.gpu,
+            surface.info(),
+            rendering_parameters.path_sample_count,
+        );
         let instance_belt = BufferBelt::new(BufferBeltDescriptor {
             memory: gpu::Memory::Shared,
             min_chunk_size: 0x1000,
@@ -401,7 +398,7 @@ impl BladeRenderer {
                 surface.info().format,
                 config.size.width,
                 config.size.height,
-                path_sample_count,
+                rendering_parameters.path_sample_count,
             )
             .unzip();
 
@@ -425,11 +422,11 @@ impl BladeRenderer {
             atlas_sampler,
             #[cfg(target_os = "macos")]
             core_video_texture_cache,
-            path_sample_count,
             path_intermediate_texture,
             path_intermediate_texture_view,
             path_intermediate_msaa_texture,
             path_intermediate_msaa_texture_view,
+            rendering_parameters,
         })
     }
 
@@ -506,7 +503,7 @@ impl BladeRenderer {
                     self.surface.info().format,
                     gpu_size.width,
                     gpu_size.height,
-                    self.path_sample_count,
+                    self.rendering_parameters.path_sample_count,
                 )
                 .unzip();
             self.path_intermediate_msaa_texture = path_intermediate_msaa_texture;
@@ -521,8 +518,11 @@ impl BladeRenderer {
             self.gpu
                 .reconfigure_surface(&mut self.surface, self.surface_config);
             self.pipelines.destroy(&self.gpu);
-            self.pipelines =
-                BladePipelines::new(&self.gpu, self.surface.info(), self.path_sample_count);
+            self.pipelines = BladePipelines::new(
+                &self.gpu,
+                self.surface.info(),
+                self.rendering_parameters.path_sample_count,
+            );
         }
     }
 
@@ -783,6 +783,10 @@ impl BladeRenderer {
                         0,
                         &ShaderMonoSpritesData {
                             globals,
+                            gamma_ratios: self.rendering_parameters.gamma_ratios,
+                            grayscale_enhanced_contrast: self
+                                .rendering_parameters
+                                .grayscale_enhanced_contrast,
                             t_sprite: tex_info.raw_view,
                             s_sprite: self.atlas_sampler,
                             b_mono_sprites: instance_buf,
@@ -984,3 +988,85 @@ fn create_msaa_texture_if_needed(
 
     Some((texture_msaa, texture_view_msaa))
 }
+
+/// A set of parameters that can be set using a corresponding environment variable.
+struct RenderingParameters {
+    // Env var: ZED_PATH_SAMPLE_COUNT
+    // workaround for https://github.com/zed-industries/zed/issues/26143
+    path_sample_count: u32,
+
+    // Env var: ZED_FONTS_GAMMA
+    // Allowed range [1.0, 2.2], other values are clipped
+    // Default: 1.8
+    gamma_ratios: [f32; 4],
+    // Env var: ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST
+    // Allowed range: [0.0, ..), other values are clipped
+    // Default: 1.0
+    grayscale_enhanced_contrast: f32,
+}
+
+impl RenderingParameters {
+    fn from_env(context: &BladeContext) -> Self {
+        use std::env;
+
+        let path_sample_count = env::var("ZED_PATH_SAMPLE_COUNT")
+            .ok()
+            .and_then(|v| v.parse().ok())
+            .or_else(|| {
+                [4, 2, 1]
+                    .into_iter()
+                    .find(|&n| (context.gpu.capabilities().sample_count_mask & n) != 0)
+            })
+            .unwrap_or(1);
+        let gamma = env::var("ZED_FONTS_GAMMA")
+            .ok()
+            .and_then(|v| v.parse().ok())
+            .unwrap_or(1.8_f32)
+            .clamp(1.0, 2.2);
+        let gamma_ratios = Self::get_gamma_ratios(gamma);
+        let grayscale_enhanced_contrast = env::var("ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST")
+            .ok()
+            .and_then(|v| v.parse().ok())
+            .unwrap_or(1.0_f32)
+            .max(0.0);
+
+        Self {
+            path_sample_count,
+            gamma_ratios,
+            grayscale_enhanced_contrast,
+        }
+    }
+
+    // Gamma ratios for brightening/darkening edges for better contrast
+    // https://github.com/microsoft/terminal/blob/1283c0f5b99a2961673249fa77c6b986efb5086c/src/renderer/atlas/dwrite.cpp#L50
+    fn get_gamma_ratios(gamma: f32) -> [f32; 4] {
+        const GAMMA_INCORRECT_TARGET_RATIOS: [[f32; 4]; 13] = [
+            [0.0000 / 4.0, 0.0000 / 4.0, 0.0000 / 4.0, 0.0000 / 4.0], // gamma = 1.0
+            [0.0166 / 4.0, -0.0807 / 4.0, 0.2227 / 4.0, -0.0751 / 4.0], // gamma = 1.1
+            [0.0350 / 4.0, -0.1760 / 4.0, 0.4325 / 4.0, -0.1370 / 4.0], // gamma = 1.2
+            [0.0543 / 4.0, -0.2821 / 4.0, 0.6302 / 4.0, -0.1876 / 4.0], // gamma = 1.3
+            [0.0739 / 4.0, -0.3963 / 4.0, 0.8167 / 4.0, -0.2287 / 4.0], // gamma = 1.4
+            [0.0933 / 4.0, -0.5161 / 4.0, 0.9926 / 4.0, -0.2616 / 4.0], // gamma = 1.5
+            [0.1121 / 4.0, -0.6395 / 4.0, 1.1588 / 4.0, -0.2877 / 4.0], // gamma = 1.6
+            [0.1300 / 4.0, -0.7649 / 4.0, 1.3159 / 4.0, -0.3080 / 4.0], // gamma = 1.7
+            [0.1469 / 4.0, -0.8911 / 4.0, 1.4644 / 4.0, -0.3234 / 4.0], // gamma = 1.8
+            [0.1627 / 4.0, -1.0170 / 4.0, 1.6051 / 4.0, -0.3347 / 4.0], // gamma = 1.9
+            [0.1773 / 4.0, -1.1420 / 4.0, 1.7385 / 4.0, -0.3426 / 4.0], // gamma = 2.0
+            [0.1908 / 4.0, -1.2652 / 4.0, 1.8650 / 4.0, -0.3476 / 4.0], // gamma = 2.1
+            [0.2031 / 4.0, -1.3864 / 4.0, 1.9851 / 4.0, -0.3501 / 4.0], // gamma = 2.2
+        ];
+
+        const NORM13: f32 = ((0x10000 as f64) / (255.0 * 255.0) * 4.0) as f32;
+        const NORM24: f32 = ((0x100 as f64) / (255.0) * 4.0) as f32;
+
+        let index = ((gamma * 10.0).round() as usize).clamp(10, 22) - 10;
+        let ratios = GAMMA_INCORRECT_TARGET_RATIOS[index];
+
+        [
+            ratios[0] * NORM13,
+            ratios[1] * NORM24,
+            ratios[2] * NORM13,
+            ratios[3] * NORM24,
+        ]
+    }
+}

crates/gpui/src/platform/blade/shaders.wgsl 🔗

@@ -28,6 +28,35 @@ fn heat_map_color(value: f32, minValue: f32, maxValue: f32, position: vec2<f32>)
 
 */
 
+fn color_brightness(color: vec3<f32>) -> f32 {
+    // REC. 601 luminance coefficients for perceived brightness
+    return dot(color, vec3<f32>(0.30, 0.59, 0.11));
+}
+
+fn light_on_dark_contrast(enhancedContrast: f32, color: vec3<f32>) -> f32 {
+    let brightness = color_brightness(color);
+    let multiplier = saturate(4.0 * (0.75 - brightness));
+    return enhancedContrast * multiplier;
+}
+
+fn enhance_contrast(alpha: f32, k: f32) -> f32 {
+    return alpha * (k + 1.0) / (alpha * k + 1.0);
+}
+
+fn apply_alpha_correction(a: f32, b: f32, g: vec4<f32>) -> f32 {
+    let brightness_adjustment = g.x * b + g.y;
+    let correction = brightness_adjustment * a + (g.z * b + g.w);
+    return a + a * (1.0 - a) * correction;
+}
+
+fn apply_contrast_and_gamma_correction(sample: f32, color: vec3<f32>, enhanced_contrast_factor: f32, gamma_ratios: vec4<f32>) -> f32 {
+    let enhanced_contrast = light_on_dark_contrast(enhanced_contrast_factor, color);
+    let brightness = color_brightness(color);
+
+    let contrasted = enhance_contrast(sample, enhanced_contrast);
+    return apply_alpha_correction(contrasted, brightness, gamma_ratios);
+}
+
 struct GlobalParams {
     viewport_size: vec2<f32>,
     premultiplied_alpha: u32,
@@ -35,6 +64,8 @@ struct GlobalParams {
 }
 
 var<uniform> globals: GlobalParams;
+var<uniform> gamma_ratios: vec4<f32>;
+var<uniform> grayscale_enhanced_contrast: f32;
 var t_sprite: texture_2d<f32>;
 var s_sprite: sampler;
 
@@ -1124,11 +1155,13 @@ fn vs_mono_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index
 @fragment
 fn fs_mono_sprite(input: MonoSpriteVarying) -> @location(0) vec4<f32> {
     let sample = textureSample(t_sprite, s_sprite, input.tile_position).r;
+    let alpha_corrected = apply_contrast_and_gamma_correction(sample, input.color.rgb, grayscale_enhanced_contrast, gamma_ratios);
+
     // Alpha clip after using the derivatives.
     if (any(input.clip_distances < vec4<f32>(0.0))) {
         return vec4<f32>(0.0);
     }
-    return blend_color(input.color, sample);
+    return blend_color(input.color, alpha_corrected);
 }
 
 // --- polychrome sprites --- //

docs/src/linux.md 🔗

@@ -368,3 +368,15 @@ xrandr --dpi 192
 ```
 
 Replace `192` with your desired DPI value. This affects the system globally and will be used by Zed's automatic RandR detection when `Xft.dpi` is not set.
+
+### Font rendering parameters
+
+When using Blade rendering (Linux platforms and self-compiled builds with the Blade renderer enabled), Zed reads `ZED_FONTS_GAMMA` and `ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST` environment variables for the values to use for font rendering.
+
+`ZED_FONTS_GAMMA` corresponds to [getgamma](https://learn.microsoft.com/en-us/windows/win32/api/dwrite/nf-dwrite-idwriterenderingparams-getgamma) values.
+Allowed range [1.0, 2.2], other values are clipped.
+Default: 1.8
+
+`ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST` corresponds to [getgrayscaleenhancedcontrast](https://learn.microsoft.com/en-us/windows/win32/api/dwrite_1/nf-dwrite_1-idwriterenderingparams1-getgrayscaleenhancedcontrast) values.
+Allowed range: [0.0, ..), other values are clipped.
+Default: 1.0