From d659df665586a7f2092c640e9162827974de0358 Mon Sep 17 00:00:00 2001 From: valadaptive Date: Mon, 8 Sep 2025 05:22:29 -0400 Subject: [PATCH] Subpixel glyph positioning --- crates/epaint/src/text/font.rs | 99 +++++++++++++++++++++++---- crates/epaint/src/text/text_layout.rs | 68 +++++++++++------- 2 files changed, 130 insertions(+), 37 deletions(-) diff --git a/crates/epaint/src/text/font.rs b/crates/epaint/src/text/font.rs index e6991269d..172e37a5d 100644 --- a/crates/epaint/src/text/font.rs +++ b/crates/epaint/src/text/font.rs @@ -57,6 +57,67 @@ impl GlyphInfo { }; } +// Subpixel binning, taken from cosmic-text: +// https://github.com/pop-os/cosmic-text/blob/974ddaed96b334f560b606ebe5d2ca2d2f9f23ef/src/glyph_cache.rs + +/// Bin for subpixel positioning of glyphs. +/// +/// For accurate glyph positioning, we want to render each glyph at a subpixel coordinate. However, we also want to +/// cache each glyph's bitmap. As a compromise, we bin each subpixel offset into one of four fractional values. This +/// means one glyph can have up to four subpixel-positioned bitmaps in the cache. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub(super) enum SubpixelBin { + #[default] + Zero, + One, + Two, + Three, +} + +impl SubpixelBin { + /// Bin the given position and return the new integral coordinate. + fn new(pos: f32) -> (i32, Self) { + let trunc = pos as i32; + let fract = pos - trunc as f32; + + #[expect(clippy::collapsible_else_if)] + if pos.is_sign_negative() { + if fract > -0.125 { + (trunc, Self::Zero) + } else if fract > -0.375 { + (trunc - 1, Self::Three) + } else if fract > -0.625 { + (trunc - 1, Self::Two) + } else if fract > -0.875 { + (trunc - 1, Self::One) + } else { + (trunc - 1, Self::Zero) + } + } else { + if fract < 0.125 { + (trunc, Self::Zero) + } else if fract < 0.375 { + (trunc, Self::One) + } else if fract < 0.625 { + (trunc, Self::Two) + } else if fract < 0.875 { + (trunc, Self::Three) + } else { + (trunc + 1, Self::Zero) + } + } + } + + pub fn as_float(&self) -> f32 { + match self { + Self::Zero => 0.0, + Self::One => 0.25, + Self::Two => 0.5, + Self::Three => 0.75, + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Default)] pub struct GlyphAllocation { /// Used for pair-kerning. @@ -65,7 +126,7 @@ pub struct GlyphAllocation { /// Use `ab_glyph::GlyphId(0)` if you just want to have an id, and don't care. pub(crate) id: ab_glyph::GlyphId, - /// Unit: points. + /// Unit: screen pixels. pub advance_width: f32, /// UV rectangle for drawing. @@ -81,7 +142,7 @@ pub struct FontImpl { ab_glyph_font: ab_glyph::FontArc, tweak: FontTweak, glyph_info_cache: ahash::HashMap, - glyph_alloc_cache: ahash::HashMap<(ab_glyph::GlyphId, u64), GlyphAllocation>, + glyph_alloc_cache: ahash::HashMap<(ab_glyph::GlyphId, SubpixelBin, u64), GlyphAllocation>, } trait FontExt { @@ -203,14 +264,23 @@ impl FontImpl { } #[inline] - pub fn pair_kerning( + pub(super) fn pair_kerning_screen_space( &self, metrics: &ScaledMetrics, last_glyph_id: ab_glyph::GlyphId, glyph_id: ab_glyph::GlyphId, ) -> f32 { self.ab_glyph_font.kern_unscaled(last_glyph_id, glyph_id) * metrics.px_scale_factor - / metrics.pixels_per_point + } + + #[inline] + pub fn pair_kerning( + &self, + metrics: &ScaledMetrics, + last_glyph_id: ab_glyph::GlyphId, + glyph_id: ab_glyph::GlyphId, + ) -> f32 { + self.pair_kerning_screen_space(metrics, last_glyph_id, glyph_id) / metrics.pixels_per_point } #[inline(always)] @@ -243,18 +313,21 @@ impl FontImpl { atlas: &mut TextureAtlas, metrics: &ScaledMetrics, glyph_info: GlyphInfo, - ) -> GlyphAllocation { + h_pos: f32, + ) -> (GlyphAllocation, i32) { let Some(glyph_id) = glyph_info.id else { // Invisible. - return GlyphAllocation::default(); + return (GlyphAllocation::default(), h_pos as i32); }; + let (h_pos_round, bin) = SubpixelBin::new(h_pos); + let entry = match self .glyph_alloc_cache - .entry((glyph_id, metrics.glyph_cache_key())) + .entry((glyph_id, bin, metrics.glyph_cache_key())) { std::collections::hash_map::Entry::Occupied(glyph_alloc) => { - return *glyph_alloc.get(); + return (*glyph_alloc.get(), h_pos_round); } std::collections::hash_map::Entry::Vacant(entry) => entry, }; @@ -268,7 +341,10 @@ impl FontImpl { // (https://github.com/alexheretic/ab-glyph/issues/15), and this field is never accessed when // rasterizing. We can just put anything here. scale: PxScale::from(0.0), - position: ab_glyph::Point::default(), + position: ab_glyph::Point { + x: bin.as_float(), + y: 0.0, + }, }; let outlined = OutlinedGlyph::new( glyph, @@ -315,12 +391,11 @@ impl FontImpl { let allocation = GlyphAllocation { id: glyph_id, - advance_width: (glyph_info.advance_width_unscaled.0 * metrics.px_scale_factor) - / metrics.pixels_per_point, + advance_width: glyph_info.advance_width_unscaled.0 * metrics.px_scale_factor, uv_rect, }; entry.insert(allocation); - allocation + (allocation, h_pos_round) } } diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index 8e211c28e..1deee327a 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -45,7 +45,7 @@ impl PointScale { /// Temporary storage before line-wrapping. #[derive(Clone)] struct Paragraph { - /// Start of the next glyph to be added. + /// Start of the next glyph to be added. In screen-space / physical pixels. pub cursor_x: f32, /// This is included in case there are no glyphs @@ -169,7 +169,7 @@ fn layout_section( paragraph.empty_paragraph_height = line_height; // TODO(emilk): replace this hack with actually including `\n` in the glyphs? } - paragraph.cursor_x += leading_space; + paragraph.cursor_x += leading_space * pixels_per_point; let mut last_glyph_id = None; @@ -195,23 +195,32 @@ fn layout_section( (font_impl, scaled_metrics.unwrap_or_default()) } }; - let glyph_alloc = match font_impl.as_mut() { - Some(font_impl) => { - font_impl.allocate_glyph(font.atlas, &font_impl_metrics, glyph_info) - } + + if let (Some(font_impl), Some(last_glyph_id), Some(glyph_id)) = + (&font_impl, last_glyph_id, glyph_info.id) + { + paragraph.cursor_x += font_impl.pair_kerning_screen_space( + &font_impl_metrics, + last_glyph_id, + glyph_id, + ); + paragraph.cursor_x += extra_letter_spacing * pixels_per_point; + } + + let (glyph_alloc, physical_x) = match font_impl.as_mut() { + Some(font_impl) => font_impl.allocate_glyph( + font.atlas, + &font_impl_metrics, + glyph_info, + paragraph.cursor_x, + ), None => Default::default(), }; - if let (Some(font_impl), Some(last_glyph_id)) = (&font_impl, last_glyph_id) { - paragraph.cursor_x += - font_impl.pair_kerning(&font_impl_metrics, last_glyph_id, glyph_alloc.id); - paragraph.cursor_x += extra_letter_spacing; - } - paragraph.glyphs.push(Glyph { chr, - pos: pos2(paragraph.cursor_x, f32::NAN), - advance_width: glyph_alloc.advance_width, + pos: pos2(physical_x as f32 / pixels_per_point, f32::NAN), + advance_width: glyph_alloc.advance_width / pixels_per_point, line_height, font_impl_height: font_impl_metrics.row_height, font_impl_ascent: font_impl_metrics.ascent, @@ -222,7 +231,6 @@ fn layout_section( }); paragraph.cursor_x += glyph_alloc.advance_width; - paragraph.cursor_x = paragraph.cursor_x.round_to_pixels(pixels_per_point); last_glyph_id = Some(glyph_alloc.id); } } @@ -454,14 +462,6 @@ fn replace_last_glyph_with_overflow_character( let (font_id, glyph_info) = font.glyph_info(overflow_character); let mut font_impl = font.fonts_by_id.get_mut(&font_id); - let font_impl_metrics = font_impl - .as_ref() - .map(|f| f.scaled_metrics(pixels_per_point, font_size)) - .unwrap_or_default(); - let replacement_glyph_alloc = font_impl - .as_mut() - .map(|f| f.allocate_glyph(font.atlas, &font_impl_metrics, glyph_info)) - .unwrap_or_default(); let font_impl_metrics = font_impl .as_mut() .map(|f| f.scaled_metrics(pixels_per_point, font_size)) @@ -488,12 +488,30 @@ fn replace_last_glyph_with_overflow_character( 0.0 // TODO(emilk): heed paragraph leading_space 😬 }; + let replacement_glyph_width = font_impl + .as_mut() + .and_then(|f| f.glyph_info(overflow_character)) + .map(|i| i.advance_width_unscaled.0 * font_impl_metrics.px_scale_factor) + .unwrap_or_default(); + // Check if we're within width budget: - if overflow_glyph_x + replacement_glyph_alloc.advance_width <= job.effective_wrap_width() + if overflow_glyph_x + replacement_glyph_width <= job.effective_wrap_width() || row.glyphs.is_empty() { // we are done + let (replacement_glyph_alloc, physical_x) = font_impl + .as_mut() + .map(|f| { + f.allocate_glyph( + font.atlas, + &font_impl_metrics, + glyph_info, + overflow_glyph_x * pixels_per_point, + ) + }) + .unwrap_or_default(); + let font_metrics = font.scaled_metrics(pixels_per_point, font_size); let line_height = section .format @@ -502,7 +520,7 @@ fn replace_last_glyph_with_overflow_character( row.glyphs.push(Glyph { chr: overflow_character, - pos: pos2(overflow_glyph_x, f32::NAN), + pos: pos2(physical_x as f32 / pixels_per_point, f32::NAN), advance_width: replacement_glyph_alloc.advance_width, line_height, font_impl_height: font_impl_metrics.row_height,