| // Copyright 2026 the Vello Authors | |
| // SPDX-License-Identifier: Apache-2.0 OR MIT | |
| //! Cache key for glyph bitmaps stored in the atlas. | |
| //! | |
| //! [`GlyphCacheKey`] captures every parameter that affects the visual appearance | |
| //! of a rasterized glyph — font identity, size, hinting, subpixel position, | |
| //! COLR context color, and variable-font coordinates. Two keys that compare | |
| //! equal produce identical bitmaps and can safely share a single atlas entry. | |
| use crate::color::{AlphaColor, Srgb}; | |
| use crate::glyph::FontEmbolden; | |
| use crate::kurbo::Join; | |
| use core::hash::{Hash, Hasher}; | |
| #[cfg(not(feature = "std"))] | |
| use core_maths::CoreFloat as _; | |
| use skrifa::instance::NormalizedCoord; | |
| use smallvec::SmallVec; | |
| /// Number of horizontal subpixel quantization buckets (valid range: 1–253). | |
| /// | |
| /// Higher values improve rendering quality at the cost of more atlas entries | |
| /// per glyph. Common values: 1 (disabled), 2, 4 (default), 8. | |
| pub(crate) const SUBPIXEL_BUCKETS: u8 = 4; | |
| /// Sentinel `subpixel_x` for COLR glyph cache entries. | |
| /// | |
| /// `quantize_subpixel` returns values in `0..SUBPIXEL_BUCKETS`, so values | |
| /// above that range can never appear in an outline key. Using distinct | |
| /// sentinels for COLR and bitmap entries prevents cache collisions between | |
| /// glyph types that would otherwise produce identical keys (same font, glyph | |
| /// id, size, and color). | |
| pub(crate) const SUBPIXEL_COLR: u8 = SUBPIXEL_BUCKETS; | |
| /// Sentinel `subpixel_x` for bitmap glyph cache entries. See [`SUBPIXEL_COLR`]. | |
| pub(crate) const SUBPIXEL_BITMAP: u8 = SUBPIXEL_BUCKETS + 1; | |
| /// Unique identifier for a cached glyph bitmap. | |
| /// | |
| /// Two glyphs with the same key are visually identical and can share | |
| /// the same cached bitmap. The key includes all parameters that affect | |
| /// the glyph's appearance. | |
| /// | |
| /// `var_coords` is deliberately excluded from `Hash`/`Eq` because the | |
| /// [`GlyphAtlas`](crate::atlas::cache::GlyphAtlas) uses a two-level map | |
| /// structure that already partitions entries by variation coordinates. | |
| /// Callers that use a flat map must ensure equivalent `var_coords` | |
| /// externally. | |
| #[derive(Clone, Debug)] | |
| pub struct GlyphCacheKey { | |
| /// Unique identifier for the font blob. | |
| pub font_id: u64, | |
| /// Index within font collection (for TTC files). | |
| pub font_index: u32, | |
| /// Glyph index within the font. | |
| pub glyph_id: u32, | |
| /// Font size as f32 bits (exact match, no quantization). | |
| pub size_bits: u32, | |
| /// Whether hinting was applied. | |
| pub hinted: bool, | |
| /// Horizontal subpixel position (0 to SUBPIXEL_BUCKETS-1 for outlines), | |
| /// or a sentinel (`SUBPIXEL_COLR` / `SUBPIXEL_BITMAP`) for non-outline glyphs. | |
| pub subpixel_x: u8, | |
| /// Context color for COLR glyphs. Only used for rendering, not for Hash/Eq. | |
| pub context_color: AlphaColor<Srgb>, | |
| /// Pre-packed context color (premultiplied RGBA8 as u32) used in Hash/Eq. | |
| pub context_color_packed: u32, | |
| /// Synthetic embolden amount. Only non-zero for outline glyphs. | |
| pub embolden_x_bits: u32, | |
| /// Synthetic embolden amount. Only non-zero for outline glyphs. | |
| pub embolden_y_bits: u32, | |
| /// Join style for synthetic embolden. Only meaningful for outline glyphs. | |
| pub embolden_join_bits: u8, | |
| /// Miter limit for synthetic embolden. Only meaningful for outline glyphs. | |
| pub embolden_miter_limit_bits: u32, | |
| /// Tolerance for synthetic embolden. Only meaningful for outline glyphs. | |
| pub embolden_tolerance_bits: u32, | |
| /// Variation coordinates for variable fonts. | |
| pub var_coords: SmallVec<[NormalizedCoord; 4]>, | |
| } | |
| impl GlyphCacheKey { | |
| /// Creates a new cache key. | |
| /// | |
| /// `fractional_x` (the fractional pixel offset) is quantized into | |
| /// `SUBPIXEL_BUCKETS` buckets, so nearby positions share the same entry. | |
| #[inline] | |
| pub fn new( | |
| font_id: u64, | |
| font_index: u32, | |
| glyph_id: u32, | |
| size: f32, | |
| hinted: bool, | |
| fractional_x: f32, | |
| context_color: AlphaColor<Srgb>, | |
| context_color_packed: u32, | |
| embolden: FontEmbolden, | |
| var_coords: &[NormalizedCoord], | |
| ) -> Self { | |
| Self { | |
| font_id, | |
| font_index, | |
| glyph_id, | |
| size_bits: size.to_bits(), | |
| hinted, | |
| subpixel_x: quantize_subpixel(fractional_x), | |
| context_color, | |
| context_color_packed, | |
| embolden_x_bits: f32_bits(embolden.amount.xx), | |
| embolden_y_bits: f32_bits(embolden.amount.yy), | |
| embolden_join_bits: join_bits(embolden.join), | |
| embolden_miter_limit_bits: f32_bits(embolden.miter_limit), | |
| embolden_tolerance_bits: f32_bits(embolden.tolerance), | |
| var_coords: SmallVec::from_slice(var_coords), | |
| } | |
| } | |
| } | |
| /// Manual `Hash` and `PartialEq` use the pre-packed `context_color_packed` field | |
| /// (a premultiplied RGBA8 `u32`) instead of `AlphaColor<Srgb>`, which doesn't | |
| /// implement `Hash`/`Eq`. Packing once at construction avoids repeated work | |
| /// during lookups. `glyph_id` is compared first for early short-circuit. | |
| impl Hash for GlyphCacheKey { | |
| #[inline] | |
| fn hash<H: Hasher>(&self, state: &mut H) { | |
| self.font_id.hash(state); | |
| self.font_index.hash(state); | |
| self.glyph_id.hash(state); | |
| self.size_bits.hash(state); | |
| self.hinted.hash(state); | |
| self.subpixel_x.hash(state); | |
| self.context_color_packed.hash(state); | |
| self.embolden_x_bits.hash(state); | |
| self.embolden_y_bits.hash(state); | |
| self.embolden_join_bits.hash(state); | |
| self.embolden_miter_limit_bits.hash(state); | |
| self.embolden_tolerance_bits.hash(state); | |
| } | |
| } | |
| impl PartialEq for GlyphCacheKey { | |
| #[inline] | |
| fn eq(&self, other: &Self) -> bool { | |
| self.glyph_id == other.glyph_id | |
| && self.subpixel_x == other.subpixel_x | |
| && self.font_id == other.font_id | |
| && self.font_index == other.font_index | |
| && self.size_bits == other.size_bits | |
| && self.hinted == other.hinted | |
| && self.context_color_packed == other.context_color_packed | |
| && self.embolden_x_bits == other.embolden_x_bits | |
| && self.embolden_y_bits == other.embolden_y_bits | |
| && self.embolden_join_bits == other.embolden_join_bits | |
| && self.embolden_miter_limit_bits == other.embolden_miter_limit_bits | |
| && self.embolden_tolerance_bits == other.embolden_tolerance_bits | |
| } | |
| } | |
| impl Eq for GlyphCacheKey {} | |
| #[inline(always)] | |
| fn join_bits(join: Join) -> u8 { | |
| match join { | |
| Join::Bevel => 0, | |
| Join::Miter => 1, | |
| Join::Round => 2, | |
| } | |
| } | |
| #[expect( | |
| clippy::cast_possible_truncation, | |
| reason = "Cache keys intentionally store embolden parameters at f32 precision." | |
| )] | |
| #[inline(always)] | |
| fn f32_bits(value: f64) -> u32 { | |
| (value as f32).to_bits() | |
| } | |
| /// Premultiply and pack an RGBA color into a `u32` for bitwise hashing/comparison. | |
| #[inline] | |
| pub(crate) fn pack_color(color: AlphaColor<Srgb>) -> u32 { | |
| color.premultiply().to_rgba8().to_u32() | |
| } | |
| /// Quantize a fractional pixel offset into one of [`SUBPIXEL_BUCKETS`] buckets. | |
| /// | |
| /// Values near 1.0 (>= 0.875 with 4 buckets) are clamped to the last bucket | |
| /// rather than wrapping to 0. Wrapping to bucket 0 without also incrementing the | |
| /// integer pixel coordinate would shift the glyph by ~0.75px in the wrong | |
| /// direction. Clamping keeps the worst-case error to 0.125px. | |
| #[expect( | |
| clippy::cast_possible_truncation, | |
| reason = "result is clamped to SUBPIXEL_BUCKETS-1 which fits in u8" | |
| )] | |
| #[inline] | |
| fn quantize_subpixel(frac: f32) -> u8 { | |
| let normalized = frac.fract(); | |
| let normalized = if normalized < 0.0 { | |
| normalized + 1.0 | |
| } else { | |
| normalized | |
| }; | |
| ((normalized * SUBPIXEL_BUCKETS as f32).round() as u8).min(SUBPIXEL_BUCKETS - 1) | |
| } | |
| /// Convert a quantized bucket index back to the fractional pixel offset it represents. | |
| #[inline] | |
| pub fn subpixel_offset(quantized: u8) -> f32 { | |
| quantized as f32 / SUBPIXEL_BUCKETS as f32 | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use crate::color::palette::css::BLACK; | |
| use super::*; | |
| #[test] | |
| fn test_quantize_subpixel() { | |
| // Test bucket boundaries | |
| assert_eq!(quantize_subpixel(0.0), 0); | |
| assert_eq!(quantize_subpixel(0.1), 0); | |
| assert_eq!(quantize_subpixel(0.2), 1); | |
| assert_eq!(quantize_subpixel(0.25), 1); | |
| assert_eq!(quantize_subpixel(0.4), 2); | |
| assert_eq!(quantize_subpixel(0.5), 2); | |
| assert_eq!(quantize_subpixel(0.6), 2); | |
| assert_eq!(quantize_subpixel(0.7), 3); | |
| assert_eq!(quantize_subpixel(0.75), 3); | |
| assert_eq!(quantize_subpixel(0.9), 3); | |
| assert_eq!(quantize_subpixel(1.0), 0); | |
| } | |
| #[test] | |
| fn test_subpixel_offset() { | |
| assert_eq!(subpixel_offset(0), 0.0); | |
| assert_eq!(subpixel_offset(1), 0.25); | |
| assert_eq!(subpixel_offset(2), 0.5); | |
| assert_eq!(subpixel_offset(3), 0.75); | |
| } | |
| #[test] | |
| fn test_key_equality() { | |
| let packed = pack_color(BLACK); | |
| let key1 = GlyphCacheKey::new( | |
| 1, | |
| 0, | |
| 42, | |
| 16.0, | |
| true, | |
| 0.3, | |
| BLACK, | |
| packed, | |
| FontEmbolden::default(), | |
| &[], | |
| ); | |
| let key2 = GlyphCacheKey::new( | |
| 1, | |
| 0, | |
| 42, | |
| 16.0, | |
| true, | |
| 0.3, | |
| BLACK, | |
| packed, | |
| FontEmbolden::default(), | |
| &[], | |
| ); | |
| assert_eq!(key1, key2); | |
| } | |
| #[test] | |
| fn test_outline_colr_bitmap_keys_never_collide() { | |
| let packed = pack_color(BLACK); | |
| let outline_key = GlyphCacheKey::new( | |
| 1, | |
| 0, | |
| 42, | |
| 16.0, | |
| false, | |
| 0.0, | |
| BLACK, | |
| packed, | |
| FontEmbolden::default(), | |
| &[], | |
| ); | |
| let colr_key = GlyphCacheKey { | |
| font_id: 1, | |
| font_index: 0, | |
| glyph_id: 42, | |
| size_bits: 16.0_f32.to_bits(), | |
| hinted: false, | |
| subpixel_x: SUBPIXEL_COLR, | |
| context_color: BLACK, | |
| context_color_packed: packed, | |
| embolden_x_bits: 0, | |
| embolden_y_bits: 0, | |
| embolden_join_bits: join_bits(Join::Miter), | |
| embolden_miter_limit_bits: 4.0_f32.to_bits(), | |
| embolden_tolerance_bits: 0.1_f32.to_bits(), | |
| var_coords: SmallVec::new(), | |
| }; | |
| let bitmap_key = GlyphCacheKey { | |
| font_id: 1, | |
| font_index: 0, | |
| glyph_id: 42, | |
| size_bits: 16.0_f32.to_bits(), | |
| hinted: false, | |
| subpixel_x: SUBPIXEL_BITMAP, | |
| context_color: BLACK, | |
| context_color_packed: packed, | |
| embolden_x_bits: 0, | |
| embolden_y_bits: 0, | |
| embolden_join_bits: join_bits(Join::Miter), | |
| embolden_miter_limit_bits: 4.0_f32.to_bits(), | |
| embolden_tolerance_bits: 0.1_f32.to_bits(), | |
| var_coords: SmallVec::new(), | |
| }; | |
| assert_ne!(outline_key, colr_key); | |
| assert_ne!(outline_key, bitmap_key); | |
| assert_ne!(colr_key, bitmap_key); | |
| } | |
| #[test] | |
| fn test_sentinels_unreachable_by_quantize() { | |
| for i in 0..=255_u8 { | |
| let frac = i as f32 / 255.0; | |
| let bucket = quantize_subpixel(frac); | |
| assert!(bucket < SUBPIXEL_BUCKETS, "bucket {bucket} for frac {frac}"); | |
| } | |
| } | |
| #[test] | |
| fn test_var_coords_excluded_from_equality() { | |
| let packed = pack_color(BLACK); | |
| // var_coords is excluded from Hash/Eq (two-level map handles it), | |
| // so keys differing only in var_coords are considered equal. | |
| let key1 = GlyphCacheKey::new( | |
| 1, | |
| 0, | |
| 42, | |
| 16.0, | |
| true, | |
| 0.3, | |
| BLACK, | |
| packed, | |
| FontEmbolden::default(), | |
| &[], | |
| ); | |
| let key2 = GlyphCacheKey::new( | |
| 1, | |
| 0, | |
| 42, | |
| 16.0, | |
| true, | |
| 0.3, | |
| BLACK, | |
| packed, | |
| FontEmbolden::default(), | |
| &[NormalizedCoord::from_bits(100)], | |
| ); | |
| assert_eq!(key1, key2); | |
| } | |
| } |