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

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>
This commit is contained in:
Kirill Bulatov 2025-09-25 16:02:27 +03:00 committed by GitHub
parent f25ace6be0
commit e72021a26b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 149 additions and 18 deletions

View file

@ -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,
]
}
}

View file

@ -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 --- //

View file

@ -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