Skip to main content

glifo/atlas/
key.rs

1// Copyright 2026 the Vello Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Cache key for glyph bitmaps stored in the atlas.
5//!
6//! [`GlyphCacheKey`] captures every parameter that affects the visual appearance
7//! of a rasterized glyph — font identity, size, hinting, subpixel position,
8//! COLR context color, and variable-font coordinates. Two keys that compare
9//! equal produce identical bitmaps and can safely share a single atlas entry.
10
11use crate::color::{AlphaColor, Srgb};
12use crate::glyph::FontEmbolden;
13use crate::kurbo::Join;
14use core::hash::{Hash, Hasher};
15#[cfg(not(feature = "std"))]
16use core_maths::CoreFloat as _;
17use skrifa::instance::NormalizedCoord;
18use smallvec::SmallVec;
19
20/// Number of horizontal subpixel quantization buckets (valid range: 1–253).
21///
22/// Higher values improve rendering quality at the cost of more atlas entries
23/// per glyph. Common values: 1 (disabled), 2, 4 (default), 8.
24pub(crate) const SUBPIXEL_BUCKETS: u8 = 4;
25
26/// Sentinel `subpixel_x` for COLR glyph cache entries.
27///
28/// `quantize_subpixel` returns values in `0..SUBPIXEL_BUCKETS`, so values
29/// above that range can never appear in an outline key. Using distinct
30/// sentinels for COLR and bitmap entries prevents cache collisions between
31/// glyph types that would otherwise produce identical keys (same font, glyph
32/// id, size, and color).
33pub(crate) const SUBPIXEL_COLR: u8 = SUBPIXEL_BUCKETS;
34
35/// Sentinel `subpixel_x` for bitmap glyph cache entries. See [`SUBPIXEL_COLR`].
36pub(crate) const SUBPIXEL_BITMAP: u8 = SUBPIXEL_BUCKETS + 1;
37
38/// Unique identifier for a cached glyph bitmap.
39///
40/// Two glyphs with the same key are visually identical and can share
41/// the same cached bitmap. The key includes all parameters that affect
42/// the glyph's appearance.
43///
44/// `var_coords` is deliberately excluded from `Hash`/`Eq` because the
45/// [`GlyphAtlas`](crate::atlas::cache::GlyphAtlas) uses a two-level map
46/// structure that already partitions entries by variation coordinates.
47/// Callers that use a flat map must ensure equivalent `var_coords`
48/// externally.
49#[derive(Clone, Debug)]
50pub struct GlyphCacheKey {
51    /// Unique identifier for the font blob.
52    pub font_id: u64,
53    /// Index within font collection (for TTC files).
54    pub font_index: u32,
55    /// Glyph index within the font.
56    pub glyph_id: u32,
57    /// Font size as f32 bits (exact match, no quantization).
58    pub size_bits: u32,
59    /// Whether hinting was applied.
60    pub hinted: bool,
61    /// Horizontal subpixel position (0 to SUBPIXEL_BUCKETS-1 for outlines),
62    /// or a sentinel (`SUBPIXEL_COLR` / `SUBPIXEL_BITMAP`) for non-outline glyphs.
63    pub subpixel_x: u8,
64    /// Context color for COLR glyphs. Only used for rendering, not for Hash/Eq.
65    pub context_color: AlphaColor<Srgb>,
66    /// Pre-packed context color (premultiplied RGBA8 as u32) used in Hash/Eq.
67    pub context_color_packed: u32,
68    /// Synthetic embolden amount. Only non-zero for outline glyphs.
69    pub embolden_x_bits: u32,
70    /// Synthetic embolden amount. Only non-zero for outline glyphs.
71    pub embolden_y_bits: u32,
72    /// Join style for synthetic embolden. Only meaningful for outline glyphs.
73    pub embolden_join_bits: u8,
74    /// Miter limit for synthetic embolden. Only meaningful for outline glyphs.
75    pub embolden_miter_limit_bits: u32,
76    /// Tolerance for synthetic embolden. Only meaningful for outline glyphs.
77    pub embolden_tolerance_bits: u32,
78    /// Variation coordinates for variable fonts.
79    pub var_coords: SmallVec<[NormalizedCoord; 4]>,
80}
81
82impl GlyphCacheKey {
83    /// Creates a new cache key.
84    ///
85    /// `fractional_x` (the fractional pixel offset) is quantized into
86    /// `SUBPIXEL_BUCKETS` buckets, so nearby positions share the same entry.
87    #[inline]
88    pub fn new(
89        font_id: u64,
90        font_index: u32,
91        glyph_id: u32,
92        size: f32,
93        hinted: bool,
94        fractional_x: f32,
95        context_color: AlphaColor<Srgb>,
96        context_color_packed: u32,
97        embolden: FontEmbolden,
98        var_coords: &[NormalizedCoord],
99    ) -> Self {
100        Self {
101            font_id,
102            font_index,
103            glyph_id,
104            size_bits: size.to_bits(),
105            hinted,
106            subpixel_x: quantize_subpixel(fractional_x),
107            context_color,
108            context_color_packed,
109            embolden_x_bits: f32_bits(embolden.amount.xx),
110            embolden_y_bits: f32_bits(embolden.amount.yy),
111            embolden_join_bits: join_bits(embolden.join),
112            embolden_miter_limit_bits: f32_bits(embolden.miter_limit),
113            embolden_tolerance_bits: f32_bits(embolden.tolerance),
114            var_coords: SmallVec::from_slice(var_coords),
115        }
116    }
117}
118
119/// Manual `Hash` and `PartialEq` use the pre-packed `context_color_packed` field
120/// (a premultiplied RGBA8 `u32`) instead of `AlphaColor<Srgb>`, which doesn't
121/// implement `Hash`/`Eq`. Packing once at construction avoids repeated work
122/// during lookups. `glyph_id` is compared first for early short-circuit.
123impl Hash for GlyphCacheKey {
124    #[inline]
125    fn hash<H: Hasher>(&self, state: &mut H) {
126        self.font_id.hash(state);
127        self.font_index.hash(state);
128        self.glyph_id.hash(state);
129        self.size_bits.hash(state);
130        self.hinted.hash(state);
131        self.subpixel_x.hash(state);
132        self.context_color_packed.hash(state);
133        self.embolden_x_bits.hash(state);
134        self.embolden_y_bits.hash(state);
135        self.embolden_join_bits.hash(state);
136        self.embolden_miter_limit_bits.hash(state);
137        self.embolden_tolerance_bits.hash(state);
138    }
139}
140
141impl PartialEq for GlyphCacheKey {
142    #[inline]
143    fn eq(&self, other: &Self) -> bool {
144        self.glyph_id == other.glyph_id
145            && self.subpixel_x == other.subpixel_x
146            && self.font_id == other.font_id
147            && self.font_index == other.font_index
148            && self.size_bits == other.size_bits
149            && self.hinted == other.hinted
150            && self.context_color_packed == other.context_color_packed
151            && self.embolden_x_bits == other.embolden_x_bits
152            && self.embolden_y_bits == other.embolden_y_bits
153            && self.embolden_join_bits == other.embolden_join_bits
154            && self.embolden_miter_limit_bits == other.embolden_miter_limit_bits
155            && self.embolden_tolerance_bits == other.embolden_tolerance_bits
156    }
157}
158
159impl Eq for GlyphCacheKey {}
160
161#[inline(always)]
162fn join_bits(join: Join) -> u8 {
163    match join {
164        Join::Bevel => 0,
165        Join::Miter => 1,
166        Join::Round => 2,
167    }
168}
169
170#[expect(
171    clippy::cast_possible_truncation,
172    reason = "Cache keys intentionally store embolden parameters at f32 precision."
173)]
174#[inline(always)]
175fn f32_bits(value: f64) -> u32 {
176    (value as f32).to_bits()
177}
178
179/// Premultiply and pack an RGBA color into a `u32` for bitwise hashing/comparison.
180#[inline]
181pub(crate) fn pack_color(color: AlphaColor<Srgb>) -> u32 {
182    color.premultiply().to_rgba8().to_u32()
183}
184
185/// Quantize a fractional pixel offset into one of [`SUBPIXEL_BUCKETS`] buckets.
186///
187/// Values near 1.0 (>= 0.875 with 4 buckets) are clamped to the last bucket
188/// rather than wrapping to 0. Wrapping to bucket 0 without also incrementing the
189/// integer pixel coordinate would shift the glyph by ~0.75px in the wrong
190/// direction. Clamping keeps the worst-case error to 0.125px.
191#[expect(
192    clippy::cast_possible_truncation,
193    reason = "result is clamped to SUBPIXEL_BUCKETS-1 which fits in u8"
194)]
195#[inline]
196fn quantize_subpixel(frac: f32) -> u8 {
197    let normalized = frac.fract();
198    let normalized = if normalized < 0.0 {
199        normalized + 1.0
200    } else {
201        normalized
202    };
203    ((normalized * SUBPIXEL_BUCKETS as f32).round() as u8).min(SUBPIXEL_BUCKETS - 1)
204}
205
206/// Convert a quantized bucket index back to the fractional pixel offset it represents.
207#[inline]
208pub fn subpixel_offset(quantized: u8) -> f32 {
209    quantized as f32 / SUBPIXEL_BUCKETS as f32
210}
211
212#[cfg(test)]
213mod tests {
214    use crate::color::palette::css::BLACK;
215
216    use super::*;
217
218    #[test]
219    fn test_quantize_subpixel() {
220        // Test bucket boundaries
221        assert_eq!(quantize_subpixel(0.0), 0);
222        assert_eq!(quantize_subpixel(0.1), 0);
223        assert_eq!(quantize_subpixel(0.2), 1);
224        assert_eq!(quantize_subpixel(0.25), 1);
225        assert_eq!(quantize_subpixel(0.4), 2);
226        assert_eq!(quantize_subpixel(0.5), 2);
227        assert_eq!(quantize_subpixel(0.6), 2);
228        assert_eq!(quantize_subpixel(0.7), 3);
229        assert_eq!(quantize_subpixel(0.75), 3);
230        assert_eq!(quantize_subpixel(0.9), 3);
231        assert_eq!(quantize_subpixel(1.0), 0);
232    }
233
234    #[test]
235    fn test_subpixel_offset() {
236        assert_eq!(subpixel_offset(0), 0.0);
237        assert_eq!(subpixel_offset(1), 0.25);
238        assert_eq!(subpixel_offset(2), 0.5);
239        assert_eq!(subpixel_offset(3), 0.75);
240    }
241
242    #[test]
243    fn test_key_equality() {
244        let packed = pack_color(BLACK);
245        let key1 = GlyphCacheKey::new(
246            1,
247            0,
248            42,
249            16.0,
250            true,
251            0.3,
252            BLACK,
253            packed,
254            FontEmbolden::default(),
255            &[],
256        );
257        let key2 = GlyphCacheKey::new(
258            1,
259            0,
260            42,
261            16.0,
262            true,
263            0.3,
264            BLACK,
265            packed,
266            FontEmbolden::default(),
267            &[],
268        );
269        assert_eq!(key1, key2);
270    }
271
272    #[test]
273    fn test_outline_colr_bitmap_keys_never_collide() {
274        let packed = pack_color(BLACK);
275        let outline_key = GlyphCacheKey::new(
276            1,
277            0,
278            42,
279            16.0,
280            false,
281            0.0,
282            BLACK,
283            packed,
284            FontEmbolden::default(),
285            &[],
286        );
287        let colr_key = GlyphCacheKey {
288            font_id: 1,
289            font_index: 0,
290            glyph_id: 42,
291            size_bits: 16.0_f32.to_bits(),
292            hinted: false,
293            subpixel_x: SUBPIXEL_COLR,
294            context_color: BLACK,
295            context_color_packed: packed,
296            embolden_x_bits: 0,
297            embolden_y_bits: 0,
298            embolden_join_bits: join_bits(Join::Miter),
299            embolden_miter_limit_bits: 4.0_f32.to_bits(),
300            embolden_tolerance_bits: 0.1_f32.to_bits(),
301            var_coords: SmallVec::new(),
302        };
303        let bitmap_key = GlyphCacheKey {
304            font_id: 1,
305            font_index: 0,
306            glyph_id: 42,
307            size_bits: 16.0_f32.to_bits(),
308            hinted: false,
309            subpixel_x: SUBPIXEL_BITMAP,
310            context_color: BLACK,
311            context_color_packed: packed,
312            embolden_x_bits: 0,
313            embolden_y_bits: 0,
314            embolden_join_bits: join_bits(Join::Miter),
315            embolden_miter_limit_bits: 4.0_f32.to_bits(),
316            embolden_tolerance_bits: 0.1_f32.to_bits(),
317            var_coords: SmallVec::new(),
318        };
319        assert_ne!(outline_key, colr_key);
320        assert_ne!(outline_key, bitmap_key);
321        assert_ne!(colr_key, bitmap_key);
322    }
323
324    #[test]
325    fn test_sentinels_unreachable_by_quantize() {
326        for i in 0..=255_u8 {
327            let frac = i as f32 / 255.0;
328            let bucket = quantize_subpixel(frac);
329            assert!(bucket < SUBPIXEL_BUCKETS, "bucket {bucket} for frac {frac}");
330        }
331    }
332
333    #[test]
334    fn test_var_coords_excluded_from_equality() {
335        let packed = pack_color(BLACK);
336        // var_coords is excluded from Hash/Eq (two-level map handles it),
337        // so keys differing only in var_coords are considered equal.
338        let key1 = GlyphCacheKey::new(
339            1,
340            0,
341            42,
342            16.0,
343            true,
344            0.3,
345            BLACK,
346            packed,
347            FontEmbolden::default(),
348            &[],
349        );
350        let key2 = GlyphCacheKey::new(
351            1,
352            0,
353            42,
354            16.0,
355            true,
356            0.3,
357            BLACK,
358            packed,
359            FontEmbolden::default(),
360            &[NormalizedCoord::from_bits(100)],
361        );
362        assert_eq!(key1, key2);
363    }
364}