diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index 111433470..a054d96d5 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -2,7 +2,7 @@ use emath::Align; use epaint::{ - AlphaFromCoverage, CornerRadius, Shadow, Stroke, TextOptions, + CornerRadius, FontColorTransferFunction, Shadow, Stroke, TextOptions, mutex::Mutex, text::{FontTweak, Tag}, }; @@ -1461,7 +1461,7 @@ impl Visuals { Self { dark_mode: true, text_options: TextOptions { - alpha_from_coverage: AlphaFromCoverage::DARK_MODE_DEFAULT, + color_transfer_function: FontColorTransferFunction::DARK_MODE_DEFAULT, ..Default::default() }, override_text_color: None, @@ -1527,7 +1527,7 @@ impl Visuals { Self { dark_mode: false, text_options: TextOptions { - alpha_from_coverage: AlphaFromCoverage::LIGHT_MODE_DEFAULT, + color_transfer_function: FontColorTransferFunction::LIGHT_MODE_DEFAULT, ..Default::default() }, widgets: Widgets::light(), @@ -2318,12 +2318,12 @@ impl Visuals { let TextOptions { max_texture_side: _, - alpha_from_coverage, + color_transfer_function, font_hinting, subpixel_binning, } = text_options; - text_alpha_from_coverage_ui(ui, alpha_from_coverage); + color_transfer_function_ui(ui, color_transfer_function); ui.checkbox(font_hinting, "Font hinting (sharper text)"); ui.checkbox(subpixel_binning, "Sub-pixel binning (more even kerning)"); @@ -2437,23 +2437,29 @@ impl Visuals { } } -fn text_alpha_from_coverage_ui(ui: &mut Ui, alpha_from_coverage: &mut AlphaFromCoverage) { - let mut dark_mode_special = - *alpha_from_coverage == AlphaFromCoverage::TwoCoverageMinusCoverageSq; - +fn color_transfer_function_ui( + ui: &mut Ui, + color_transfer_function: &mut FontColorTransferFunction, +) { ui.horizontal(|ui| { - ui.label("Text rendering:"); + ui.label("Opacity tweaking:"); - ui.checkbox(&mut dark_mode_special, "Dark-mode special"); + ui.radio_value( + color_transfer_function, + FontColorTransferFunction::Off, + "Off", + ); + ui.radio_value( + color_transfer_function, + FontColorTransferFunction::DARK_MODE_DEFAULT, + "Dark-mode special", + ); - if dark_mode_special { - *alpha_from_coverage = AlphaFromCoverage::DARK_MODE_DEFAULT; - } else { - let mut gamma = match alpha_from_coverage { - AlphaFromCoverage::Linear => 1.0, - AlphaFromCoverage::Gamma(gamma) => *gamma, - AlphaFromCoverage::TwoCoverageMinusCoverageSq => 0.5, // approximately the same - }; + let mut use_gamma = matches!(color_transfer_function, FontColorTransferFunction::Gamma(_)); + ui.radio_value(&mut use_gamma, true, "Gamma function"); + + if use_gamma { + let mut gamma = color_transfer_function.to_gamma(); ui.add( DragValue::new(&mut gamma) @@ -2462,11 +2468,7 @@ fn text_alpha_from_coverage_ui(ui: &mut Ui, alpha_from_coverage: &mut AlphaFromC .prefix("Gamma: "), ); - if gamma == 1.0 { - *alpha_from_coverage = AlphaFromCoverage::Linear; - } else { - *alpha_from_coverage = AlphaFromCoverage::Gamma(gamma); - } + *color_transfer_function = FontColorTransferFunction::Gamma(gamma); } }); } diff --git a/crates/epaint/src/image.rs b/crates/epaint/src/image.rs index 059d9d769..6fbd2b38f 100644 --- a/crates/epaint/src/image.rs +++ b/crates/epaint/src/image.rs @@ -1,3 +1,4 @@ +use ecolor::linear_f32_from_linear_u8; use emath::Vec2; use crate::{Color32, textures::TextureOptions}; @@ -346,22 +347,37 @@ 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. +/// +/// epaint stores all glyphs in the font atlas as white (with varying opacity), +/// so that egui can reuse the same glyph for different text colors +/// (with a simple color multiplication in the shader). +/// +/// Because of this simplification, we need to apply a non-linear +/// ramp to the glyph colors before writing them into the font atlas, +/// as a way to compensate. +/// +/// This whole thing is less than rigorous. +/// +/// It would be better to either render all text colors into the font atlas +/// (which would require more atlas space, but would allow for more accurate rendering of colored text and emojis), +/// or do the color compensation in the shader, based on the active text color. +/// +/// When experimenting, use to compare to a ground truth. +/// +/// See for related analysis. #[derive(Clone, Copy, Debug, Default, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub enum AlphaFromCoverage { - /// `alpha = coverage`. +pub enum FontColorTransferFunction { + /// Use the raw RGBA values from the font rasterizer, without any conversion. /// - /// Looks good for black-on-white text, i.e. light mode. + /// This is the required mode for colored emojis etc. /// - /// Same as [`Self::Gamma`]`(1.0)`, but more efficient. - Linear, + /// This mode looks good for black-on-white text, i.e. light mode. + Off, /// `alpha = coverage^gamma`. + /// + /// Gamma=1 looks good for black-on-white text, i.e. light mode. Gamma(f32), /// `alpha = 2 * coverage - coverage^2` @@ -374,29 +390,59 @@ pub enum AlphaFromCoverage { TwoCoverageMinusCoverageSq, } -impl AlphaFromCoverage { +impl FontColorTransferFunction { /// A good-looking default for light mode (black-on-white text). - pub const LIGHT_MODE_DEFAULT: Self = Self::Linear; + pub const LIGHT_MODE_DEFAULT: Self = Self::Off; /// A good-looking default for dark mode (white-on-black text). pub const DARK_MODE_DEFAULT: Self = Self::TwoCoverageMinusCoverageSq; + /// How to convert a white color written by the font rasterizer + /// into a color to be written into the font atlas. + #[inline(always)] + pub fn to_atlas_color(self, input_color: Color32) -> Color32 { + match self { + Self::Off | Self::Gamma(1.0) => input_color, + + Self::Gamma(gamma) => { + let coverage = linear_f32_from_linear_u8(input_color.a()); + let alpha = coverage.powf(gamma); + Color32::from_white_alpha(ecolor::linear_u8_from_linear_f32(alpha)) + } + + Self::TwoCoverageMinusCoverageSq => { + let coverage = linear_f32_from_linear_u8(input_color.a()); + let alpha = 2.0 * coverage - coverage * coverage; + Color32::from_white_alpha(ecolor::linear_u8_from_linear_f32(alpha)) + } + } + } + /// Convert coverage to alpha. #[inline(always)] - pub fn alpha_from_coverage(&self, coverage: f32) -> f32 { + pub fn alpha_from_coverage(self, coverage: f32) -> f32 { let coverage = coverage.clamp(0.0, 1.0); match self { - Self::Linear => coverage, - Self::Gamma(gamma) => coverage.powf(*gamma), + Self::Off | Self::Gamma(1.0) => 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 { + 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)) } + + /// Convert this into the closest gamma exponent + pub fn to_gamma(self) -> f32 { + match self { + Self::Off => 1.0, + Self::Gamma(gamma) => gamma, + Self::TwoCoverageMinusCoverageSq => 0.5, // approximately the same + } + } } // ---------------------------------------------------------------------------- diff --git a/crates/epaint/src/lib.rs b/crates/epaint/src/lib.rs index 427dad181..bff5c79a3 100644 --- a/crates/epaint/src/lib.rs +++ b/crates/epaint/src/lib.rs @@ -52,7 +52,7 @@ pub use self::{ corner_radius::CornerRadius, corner_radius_f32::CornerRadiusF32, direction::Direction, - image::{AlphaFromCoverage, ColorImage, ImageData, ImageDelta}, + image::{ColorImage, FontColorTransferFunction, ImageData, ImageDelta}, margin::Margin, margin_f32::*, mesh::{Mesh, Mesh16, Vertex}, diff --git a/crates/epaint/src/text/font.rs b/crates/epaint/src/text/font.rs index 53bc73737..987f271e0 100644 --- a/crates/epaint/src/text/font.rs +++ b/crates/epaint/src/text/font.rs @@ -1,5 +1,6 @@ #![expect(clippy::mem_forget)] +use ecolor::Color32; use emath::{GuiRounding as _, OrderedFloat, Vec2, vec2}; use self_cell::self_cell; use skrifa::{GlyphId, MetadataProvider as _}; @@ -274,26 +275,31 @@ impl FontCell { let width = bounds.width() as u16; let height = bounds.height() as u16; - let mut ctx = vello_cpu::RenderContext::new(width, height); - ctx.set_transform(kurbo::Affine::translate((-bounds.x0, -bounds.y0))); - ctx.set_paint(color::OpaqueColor::::WHITE); - ctx.fill_path(&path); - let mut dest = vello_cpu::Pixmap::new(width, height); - let mut resources = vello_cpu::Resources::new(); - ctx.render_to_pixmap(&mut resources, &mut dest); let uv_rect = if width == 0 || height == 0 { UvRect::default() } else { + let mut ctx = vello_cpu::RenderContext::new(width, height); + ctx.set_transform(kurbo::Affine::translate((-bounds.x0, -bounds.y0))); + ctx.set_paint(color::OpaqueColor::::WHITE); + ctx.fill_path(&path); + let mut dest = vello_cpu::Pixmap::new(width, height); + let mut resources = vello_cpu::Resources::new(); + ctx.render_to_pixmap(&mut resources, &mut dest); + let glyph_pos = { - let alpha_from_coverage = atlas.options().alpha_from_coverage; + let color_transfer_function = atlas.options().color_transfer_function; let (glyph_pos, image) = atlas.allocate((width as usize, height as usize)); let pixels = dest.data_as_u8_slice(); for y in 0..height as usize { for x in 0..width as usize { - image[(x + glyph_pos.0, y + glyph_pos.1)] = alpha_from_coverage - .color_from_coverage( - pixels[((y * width as usize) + x) * 4 + 3] as f32 / 255.0, - ); + let pixel_offset = 4 * ((y * width as usize) + x); + image[(x + glyph_pos.0, y + glyph_pos.1)] = color_transfer_function + .to_atlas_color(Color32::from_rgba_premultiplied( + pixels[pixel_offset], + pixels[pixel_offset + 1], + pixels[pixel_offset + 2], + pixels[pixel_offset + 3], + )); } } glyph_pos diff --git a/crates/epaint/src/text/mod.rs b/crates/epaint/src/text/mod.rs index 6d2d783c2..d62092d12 100644 --- a/crates/epaint/src/text/mod.rs +++ b/crates/epaint/src/text/mod.rs @@ -25,8 +25,8 @@ pub struct TextOptions { /// Maximum size of the font texture. pub max_texture_side: usize, - /// Controls how to convert glyph coverage to alpha. - pub alpha_from_coverage: crate::AlphaFromCoverage, + /// Controls how to convert glyph colors when writing to the font atlas. + pub color_transfer_function: crate::FontColorTransferFunction, /// Whether to enable font hinting /// @@ -54,7 +54,7 @@ impl Default for TextOptions { fn default() -> Self { Self { max_texture_side: 2048, // Small but portable - alpha_from_coverage: crate::AlphaFromCoverage::default(), + color_transfer_function: crate::FontColorTransferFunction::default(), font_hinting: true, subpixel_binning: true, } diff --git a/crates/epaint/src/texture_atlas.rs b/crates/epaint/src/texture_atlas.rs index 9a77c142a..4f8548817 100644 --- a/crates/epaint/src/texture_atlas.rs +++ b/crates/epaint/src/texture_atlas.rs @@ -120,8 +120,9 @@ impl TextureAtlas { let distance_to_center = ((dx * dx + dy * dy) as f32).sqrt(); let coverage = remap_clamp(distance_to_center, (r - 0.5)..=(r + 0.5), 1.0..=0.0); - image[((x as i32 + hw + dx) as usize, (y as i32 + hw + dy) as usize)] = - options.alpha_from_coverage.color_from_coverage(coverage); + image[((x as i32 + hw + dx) as usize, (y as i32 + hw + dy) as usize)] = options + .color_transfer_function + .color_from_coverage(coverage); } } atlas.discs.push(PrerasterizedDisc {