From 202df5ce0a3d06c3f55bc82c73dd7add37456800 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Wed, 2 Jul 2025 13:53:51 +0200 Subject: [PATCH 1/4] Add an enum to control the font coverage to color conversion --- crates/egui-wgpu/src/renderer.rs | 6 ++- crates/egui_glow/src/painter.rs | 2 +- crates/epaint/src/image.rs | 82 ++++++++++++++++++++++++++------ crates/epaint/src/lib.rs | 2 +- 4 files changed, 74 insertions(+), 18 deletions(-) diff --git a/crates/egui-wgpu/src/renderer.rs b/crates/egui-wgpu/src/renderer.rs index 73df09e43cc..473c730285e 100644 --- a/crates/egui-wgpu/src/renderer.rs +++ b/crates/egui-wgpu/src/renderer.rs @@ -571,7 +571,11 @@ impl Renderer { "Mismatch between texture size and texel count" ); profiling::scope!("font -> sRGBA"); - Cow::Owned(image.srgba_pixels(None).collect::>()) + Cow::Owned( + image + .srgba_pixels(Default::default()) + .collect::>(), + ) } }; let data_bytes: &[u8] = bytemuck::cast_slice(data_color32.as_slice()); diff --git a/crates/egui_glow/src/painter.rs b/crates/egui_glow/src/painter.rs index a5e7b3c1f46..a574cbb7191 100644 --- a/crates/egui_glow/src/painter.rs +++ b/crates/egui_glow/src/painter.rs @@ -544,7 +544,7 @@ impl Painter { let data: Vec = { profiling::scope!("font -> sRGBA"); image - .srgba_pixels(None) + .srgba_pixels(Default::default()) .flat_map(|a| a.to_array()) .collect() }; diff --git a/crates/epaint/src/image.rs b/crates/epaint/src/image.rs index 8fcef2df7eb..1c5a1466266 100644 --- a/crates/epaint/src/image.rs +++ b/crates/epaint/src/image.rs @@ -318,6 +318,59 @@ impl std::fmt::Debug for ColorImage { // ---------------------------------------------------------------------------- +/// How to convert font coverage values into alpha and color values. +// +// This whole thing is less than rigorous. +// Ideally we should do this in a shader instead, and use different computations +// for different text colors. +// See https://hikogui.org/2022/10/24/the-trouble-with-anti-aliasing.html for an in-depth analysis. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum AlphaFromCoverage { + /// `alpha = coverage`. + /// + /// Looks good for black-on-white text, i.e. light mode. + /// + /// Same as [`Self::Gamma`]`(1.0)`, but more efficient. + Linear, + + /// `alpha = coverage^gamma`. + Gamma(f32), + + /// `alpha = 2 * coverage - coverage^2` + /// + /// This looks good for white-on-black text, i.e. dark mode. + /// + /// Very similar to a gamma of 0.5, but produces sharper text. + /// See for a comparison to gamma=0.5. + #[default] + TwoCoverageMinusCoverageSq, +} + +impl AlphaFromCoverage { + /// A good-looking default for light mode (black-on-white text). + pub const LIGHT_MODE_DEFAULT: Self = Self::Linear; + + /// A good-looking default for dark mode (white-on-black text). + pub const DARK_MODE_DEFAULT: Self = Self::TwoCoverageMinusCoverageSq; + + /// Convert coverage to alpha. + #[inline(always)] + pub fn alpha_from_coverage(&self, coverage: f32) -> f32 { + match self { + Self::Linear => coverage, + Self::Gamma(gamma) => coverage.powf(*gamma), + Self::TwoCoverageMinusCoverageSq => 2.0 * coverage - coverage * coverage, + } + } + + #[inline(always)] + pub fn color_from_coverage(&self, coverage: f32) -> Color32 { + let alpha = self.alpha_from_coverage(coverage); + Color32::from_white_alpha(ecolor::linear_u8_from_linear_f32(alpha)) + } +} + /// A single-channel image designed for the font texture. /// /// Each value represents "coverage", i.e. how much a texel is covered by a character. @@ -359,25 +412,24 @@ impl FontImage { /// /// If you are having problems with text looking skinny and pixelated, try using a low gamma, e.g. `0.4`. #[inline] - pub fn srgba_pixels(&self, gamma: Option) -> impl ExactSizeIterator + '_ { + pub fn srgba_pixels( + &self, + alpha_from_coverage: AlphaFromCoverage, + ) -> impl ExactSizeIterator + '_ { // This whole function is less than rigorous. // Ideally we should do this in a shader instead, and use different computations // for different text colors. // See https://hikogui.org/2022/10/24/the-trouble-with-anti-aliasing.html for an in-depth analysis. - self.pixels.iter().map(move |coverage| { - let alpha = if let Some(gamma) = gamma { - coverage.powf(gamma) - } else { - // alpha = coverage * coverage; // recommended by the article for WHITE text (using linear blending) - - // The following is recommended by the article for BLACK text (using linear blending). - // Very similar to a gamma of 0.5, but produces sharper text. - // In practice it works well for all text colors (better than a gamma of 0.5, for instance). - // See https://www.desmos.com/calculator/w0ndf5blmn for a visual comparison. - 2.0 * coverage - coverage * coverage - }; - Color32::from_white_alpha(ecolor::linear_u8_from_linear_f32(alpha)) - }) + self.pixels + .iter() + .map(move |&coverage| alpha_from_coverage.color_from_coverage(coverage)) + } + + /// For explanation of `gamma`, see [`Self::srgba_pixels`]. + pub fn to_color_image(&self, alpha_from_coverage: AlphaFromCoverage) -> ColorImage { + profiling::function_scope!(); + let pixels = self.srgba_pixels(alpha_from_coverage).collect(); + ColorImage::new(self.size, pixels) } /// Clone a sub-region as a new image. diff --git a/crates/epaint/src/lib.rs b/crates/epaint/src/lib.rs index 264966809be..7afc7b1461a 100644 --- a/crates/epaint/src/lib.rs +++ b/crates/epaint/src/lib.rs @@ -50,7 +50,7 @@ pub use self::{ color::ColorMode, corner_radius::CornerRadius, corner_radius_f32::CornerRadiusF32, - image::{ColorImage, FontImage, ImageData, ImageDelta}, + image::{AlphaFromCoverage, ColorImage, FontImage, ImageData, ImageDelta}, margin::Margin, margin_f32::*, mesh::{Mesh, Mesh16, Vertex}, From 369e28ca999b55ad5a31a2450a13e994cc53ed67 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Wed, 2 Jul 2025 13:54:10 +0200 Subject: [PATCH 2/4] Add per-theme settings for font-coverage-to-alpha --- crates/egui/src/context.rs | 56 +++++++++++++++++-- crates/egui/src/memory/mod.rs | 6 +- crates/egui/src/style.rs | 45 ++++++++++++++- .../snapshots/widget_gallery_light_x1.png | 4 +- .../snapshots/widget_gallery_light_x2.png | 4 +- 5 files changed, 103 insertions(+), 12 deletions(-) diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 54ae490366f..80bcf570ff7 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -1876,6 +1876,16 @@ impl Context { } } + pub(crate) fn reset_font_atlas(&self) { + let pixels_per_point = self.pixels_per_point(); + let fonts = self.read(|ctx| { + ctx.fonts + .get(&pixels_per_point.into()) + .map(|current_fonts| current_fonts.lock().fonts.definitions().clone()) + }); + self.memory_mut(|mem| mem.new_font_definitions = fonts); + } + /// Tell `egui` which fonts to use. /// /// The default `egui` fonts only support latin and cyrillic alphabets, @@ -2011,10 +2021,19 @@ impl Context { /// You can use [`Ui::style_mut`] to change the style of a single [`Ui`]. pub fn set_style_of(&self, theme: Theme, style: impl Into>) { let style = style.into(); - self.options_mut(|opt| match theme { - Theme::Dark => opt.dark_style = style, - Theme::Light => opt.light_style = style, + let mut recreate_font_atlas = false; + self.options_mut(|opt| { + let dest = match theme { + Theme::Dark => &mut opt.dark_style, + Theme::Light => &mut opt.light_style, + }; + recreate_font_atlas = + dest.visuals.text_alpha_from_coverage != style.visuals.text_alpha_from_coverage; + *dest = style; }); + if recreate_font_atlas { + self.reset_font_atlas(); + } } /// The [`crate::Visuals`] used by all subsequent windows, panels etc. @@ -2411,7 +2430,28 @@ impl ContextImpl { } // Inform the backend of all textures that have been updated (including font atlas). - let textures_delta = self.tex_manager.0.write().take_delta(); + let textures_delta = { + // HACK to get much nicer looking text in light mode. + // This assumes all text is black-on-white in light mode, + // and white-on-black in dark mode, which is not necessarily true, + // but often close enough. + // Of course this fails for cases when there is black-on-white text in dark mode, + // and white-on-black text in light mode. + + let text_alpha_from_coverage = + self.memory.options.style().visuals.text_alpha_from_coverage; + + let mut textures_delta = self.tex_manager.0.write().take_delta(); + + for (_, delta) in &mut textures_delta.set { + if let ImageData::Font(font) = &mut delta.image { + delta.image = + ImageData::Color(font.to_color_image(text_alpha_from_coverage).into()); + } + } + + textures_delta + }; let mut platform_output: PlatformOutput = std::mem::take(&mut viewport.output); @@ -3009,9 +3049,17 @@ impl Context { options.ui(ui); + let text_alpha_from_coverage_changed = + prev_options.style().visuals.text_alpha_from_coverage + != options.style().visuals.text_alpha_from_coverage; + if options != prev_options { self.options_mut(move |o| *o = options); } + + if text_alpha_from_coverage_changed { + ui.ctx().reset_font_atlas(); + } } fn fonts_tweak_ui(&self, ui: &mut Ui) { diff --git a/crates/egui/src/memory/mod.rs b/crates/egui/src/memory/mod.rs index 53d9172b0a1..8f98de305ef 100644 --- a/crates/egui/src/memory/mod.rs +++ b/crates/egui/src/memory/mod.rs @@ -408,11 +408,11 @@ impl Options { .show(ui, |ui| { theme_preference.radio_buttons(ui); - std::sync::Arc::make_mut(match theme { + let style = std::sync::Arc::make_mut(match theme { Theme::Dark => dark_style, Theme::Light => light_style, - }) - .ui(ui); + }); + style.ui(ui); }); CollapsingHeader::new("✒ Painting") diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index 205aa5786cc..a2983114c2a 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -3,7 +3,7 @@ #![allow(clippy::if_same_then_else)] use emath::Align; -use epaint::{CornerRadius, Shadow, Stroke, text::FontTweak}; +use epaint::{AlphaFromCoverage, CornerRadius, Shadow, Stroke, text::FontTweak}; use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc}; use crate::{ @@ -921,6 +921,9 @@ pub struct Visuals { /// this is more to provide a convenient summary of the rest of the settings. pub dark_mode: bool, + /// ADVANCED: Controls how we render text. + pub text_alpha_from_coverage: AlphaFromCoverage, + /// Override default text color for all text. /// /// This is great for setting the color of text for any widget. @@ -1374,6 +1377,7 @@ impl Visuals { pub fn dark() -> Self { Self { dark_mode: true, + text_alpha_from_coverage: AlphaFromCoverage::DARK_MODE_DEFAULT, override_text_color: None, weak_text_alpha: 0.6, weak_text_color: None, @@ -1436,6 +1440,7 @@ impl Visuals { pub fn light() -> Self { Self { dark_mode: false, + text_alpha_from_coverage: AlphaFromCoverage::LIGHT_MODE_DEFAULT, widgets: Widgets::light(), selection: Selection::light(), hyperlink_color: Color32::from_rgb(0, 155, 255), @@ -2068,6 +2073,7 @@ impl Visuals { pub fn ui(&mut self, ui: &mut crate::Ui) { let Self { dark_mode, + text_alpha_from_coverage, override_text_color: _, weak_text_alpha, weak_text_color, @@ -2216,6 +2222,10 @@ impl Visuals { "Weak text color", ); }); + + ui.add_space(4.0); + + text_alpha_from_coverage_ui(ui, text_alpha_from_coverage); }); ui.collapsing("Text cursor", |ui| { @@ -2326,6 +2336,39 @@ impl Visuals { } } +fn text_alpha_from_coverage_ui(ui: &mut Ui, text_alpha_from_coverage: &mut AlphaFromCoverage) { + ui.label("Text rendering"); + + let mut dark_mode_special = + *text_alpha_from_coverage == AlphaFromCoverage::TwoCoverageMinusCoverageSq; + + ui.horizontal(|ui| { + ui.checkbox(&mut dark_mode_special, "Dark mode special"); + if dark_mode_special { + *text_alpha_from_coverage = AlphaFromCoverage::TwoCoverageMinusCoverageSq; + } else { + let mut gamma = match text_alpha_from_coverage { + AlphaFromCoverage::Linear => 1.0, + AlphaFromCoverage::Gamma(gamma) => *gamma, + AlphaFromCoverage::TwoCoverageMinusCoverageSq => 0.5, // approximately the same + }; + + ui.add( + DragValue::new(&mut gamma) + .speed(0.01) + .range(0.01..=3.0) + .prefix("Gamma:"), + ); + + if gamma == 1.0 { + *text_alpha_from_coverage = AlphaFromCoverage::Linear; + } else { + *text_alpha_from_coverage = AlphaFromCoverage::Gamma(gamma); + } + } + }); +} + impl TextCursorStyle { fn ui(&mut self, ui: &mut Ui) { let Self { diff --git a/crates/egui_demo_lib/tests/snapshots/widget_gallery_light_x1.png b/crates/egui_demo_lib/tests/snapshots/widget_gallery_light_x1.png index 02e80127213..948766c97f4 100644 --- a/crates/egui_demo_lib/tests/snapshots/widget_gallery_light_x1.png +++ b/crates/egui_demo_lib/tests/snapshots/widget_gallery_light_x1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2198a523fb986e90fa3a42f047499f5b1c791075e7c3822b45509d9880073966 -size 60272 +oid sha256:34d85b6015112ea2733f7246f8daabfb9d983523e187339e4d26bfc1f3a3bba3 +size 59460 diff --git a/crates/egui_demo_lib/tests/snapshots/widget_gallery_light_x2.png b/crates/egui_demo_lib/tests/snapshots/widget_gallery_light_x2.png index 40518afe0b0..150365d5fcf 100644 --- a/crates/egui_demo_lib/tests/snapshots/widget_gallery_light_x2.png +++ b/crates/egui_demo_lib/tests/snapshots/widget_gallery_light_x2.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7bb371a477f58c90ac72aed45a081f3177ea968f090e3739bdb5044ade29f4be -size 144295 +oid sha256:4f51d75010cd1213daa6a1282d352655e64b69da7bca478011ea055a2e5349bc +size 146500 From 45ce610caf720e029875c6505a92900c8e579c59 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Wed, 2 Jul 2025 14:15:44 +0200 Subject: [PATCH 3/4] Tweaks --- crates/egui/src/style.rs | 11 ++++++----- crates/epaint/src/image.rs | 6 +----- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index a2983114c2a..364b3fffc95 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -2337,13 +2337,14 @@ impl Visuals { } fn text_alpha_from_coverage_ui(ui: &mut Ui, text_alpha_from_coverage: &mut AlphaFromCoverage) { - ui.label("Text rendering"); - let mut dark_mode_special = *text_alpha_from_coverage == AlphaFromCoverage::TwoCoverageMinusCoverageSq; ui.horizontal(|ui| { - ui.checkbox(&mut dark_mode_special, "Dark mode special"); + ui.label("Text rendering:"); + + ui.checkbox(&mut dark_mode_special, "Dark-mode special"); + if dark_mode_special { *text_alpha_from_coverage = AlphaFromCoverage::TwoCoverageMinusCoverageSq; } else { @@ -2356,8 +2357,8 @@ fn text_alpha_from_coverage_ui(ui: &mut Ui, text_alpha_from_coverage: &mut Alpha ui.add( DragValue::new(&mut gamma) .speed(0.01) - .range(0.01..=3.0) - .prefix("Gamma:"), + .range(0.1..=4.0) + .prefix("Gamma: "), ); if gamma == 1.0 { diff --git a/crates/epaint/src/image.rs b/crates/epaint/src/image.rs index 1c5a1466266..2c9ccc018ad 100644 --- a/crates/epaint/src/image.rs +++ b/crates/epaint/src/image.rs @@ -407,10 +407,6 @@ impl FontImage { } /// Returns the textures as `sRGBA` premultiplied pixels, row by row, top to bottom. - /// - /// `gamma` should normally be set to `None`. - /// - /// If you are having problems with text looking skinny and pixelated, try using a low gamma, e.g. `0.4`. #[inline] pub fn srgba_pixels( &self, @@ -425,7 +421,7 @@ impl FontImage { .map(move |&coverage| alpha_from_coverage.color_from_coverage(coverage)) } - /// For explanation of `gamma`, see [`Self::srgba_pixels`]. + /// Convert this coverage image to a [`ColorImage`]. pub fn to_color_image(&self, alpha_from_coverage: AlphaFromCoverage) -> ColorImage { profiling::function_scope!(); let pixels = self.srgba_pixels(alpha_from_coverage).collect(); From 967256552febbec3c3fc5293ce3a113d33ba6152 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Wed, 2 Jul 2025 14:56:46 +0200 Subject: [PATCH 4/4] Remove dead comment --- crates/epaint/src/image.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/epaint/src/image.rs b/crates/epaint/src/image.rs index 2c9ccc018ad..6b40714cd13 100644 --- a/crates/epaint/src/image.rs +++ b/crates/epaint/src/image.rs @@ -412,10 +412,6 @@ impl FontImage { &self, alpha_from_coverage: AlphaFromCoverage, ) -> impl ExactSizeIterator + '_ { - // This whole function is less than rigorous. - // Ideally we should do this in a shader instead, and use different computations - // for different text colors. - // See https://hikogui.org/2022/10/24/the-trouble-with-anti-aliasing.html for an in-depth analysis. self.pixels .iter() .map(move |&coverage| alpha_from_coverage.color_from_coverage(coverage))