Skip to main content

glifo/atlas/
cache.rs

1// Copyright 2026 the Vello Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Glyph atlas cache with LRU eviction.
5
6use super::commands::AtlasCommandRecorder;
7use super::key::GlyphCacheKey;
8#[cfg(all(debug_assertions, feature = "std"))]
9use super::key::SUBPIXEL_BUCKETS;
10use super::region::{AtlasSlot, RasterMetrics};
11use crate::Pixmap;
12use alloc::sync::Arc;
13use alloc::vec::Vec;
14use core::fmt::{Debug, Formatter};
15use foldhash::fast::FixedState;
16use hashbrown::HashMap;
17use hashbrown::hash_map::RawEntryMut;
18use smallvec::SmallVec;
19pub use vello_common::image_cache::ImageCache;
20pub use vello_common::multi_atlas::AtlasConfig;
21use vello_common::paint::ImageId;
22
23/// Deterministic hash map type alias.
24///
25/// Uses `foldhash::fast::FixedState` instead of the default random-seeded hasher
26/// so that iteration order is identical across processes. This ensures that LRU
27/// eviction deallocates atlas regions in a deterministic order, producing
28/// reproducible atlas packing regardless of which binary (CPU / hybrid) runs.
29type FixedHashMap<K, V> = HashMap<K, V, FixedState>;
30
31/// Fixed seed for deterministic hashing across all glyph cache maps.
32const HASH_SEED: u64 = 0;
33/// Compile-time empty map for static (non-variable) glyph entries.
34const EMPTY_GLYPH_MAP: FixedHashMap<GlyphCacheKey, GlyphCacheEntry> =
35    FixedHashMap::with_hasher(FixedState::with_seed(HASH_SEED));
36/// Compile-time empty map for variable-font glyph entries, keyed by variation coordinates.
37const EMPTY_VAR_MAP: FixedHashMap<VarKey, FixedHashMap<GlyphCacheKey, GlyphCacheEntry>> =
38    FixedHashMap::with_hasher(FixedState::with_seed(HASH_SEED));
39
40/// Padding in pixels added to each side of a glyph to prevent texture bleeding.
41///
42/// The hybrid (GPU) renderer samples atlas sub-images via `Extend::Pad`, which
43/// clamps out-of-bounds coordinates to the edge texel. Without at least 1px of
44/// transparent padding, strip-rasteriser overshoot at glyph boundaries would
45/// either duplicate the edge row/column or bleed in content from a neighbouring
46/// glyph allocation. 1px is sufficient: the overshoot is sub-pixel, and the
47/// transparent padding absorbs it. This padding also enables a future switch to
48/// native bilinear sampling in the hybrid renderer.
49pub const GLYPH_PADDING: u16 = 1;
50
51/// Configuration for glyph cache behavior.
52#[derive(Clone, Debug)]
53pub struct GlyphCacheConfig {
54    /// Maximum age (in frames) before an unused entry is evicted.
55    pub max_entry_age: u64,
56    /// How often (in frames) to run the eviction pass.
57    pub eviction_frequency: u64,
58    /// Maximum font size (in ppem) that will be cached. Glyphs rendered at
59    /// sizes above this threshold are drawn directly each frame, since very
60    /// large glyphs consume disproportionate atlas space.
61    pub max_cached_font_size: f32,
62}
63
64impl Default for GlyphCacheConfig {
65    fn default() -> Self {
66        Self {
67            max_entry_age: 64,
68            eviction_frequency: 64,
69            max_cached_font_size: 128.0,
70        }
71    }
72}
73
74/// A bitmap glyph pixmap awaiting GPU upload.
75///
76/// Accumulated during glyph encoding when a bitmap glyph is inserted into the
77/// atlas cache. The application must drain these via
78/// [`GlyphAtlas::drain_pending_uploads`] and upload each pixmap to the
79/// GPU atlas at the position indicated by `image_id` (look up via
80/// `ImageCache::get` to obtain atlas layer and offset).
81#[derive(Debug)]
82pub struct PendingBitmapUpload {
83    /// The image ID allocated in the shared `ImageCache`.
84    /// Use `image_cache.get(image_id)` to obtain `atlas_id` and `offset`.
85    pub image_id: ImageId,
86    /// The bitmap pixel data to upload.
87    pub pixmap: Arc<Pixmap>,
88    /// The atlas slot information for this glyph (includes dimensions).
89    pub atlas_slot: AtlasSlot,
90}
91
92/// An atlas region that must be cleared to transparent.
93///
94/// Accumulated during eviction ([`GlyphAtlas::maintain`]) for every evicted
95/// glyph. The application must drain these via
96/// [`GlyphAtlas::drain_pending_clear_rects`] **after** calling `maintain` so
97/// that freed atlas regions are zeroed before the slot is reused on a
98/// subsequent frame. This prevents stale pixel data from bleeding through
99/// when the renderer composites (`SrcOver`) onto the atlas.
100#[derive(Clone, Copy, Debug)]
101pub struct PendingClearRect {
102    /// Which atlas page contains this region.
103    pub page_index: u32,
104    /// X position of the padded region in the atlas (pixels).
105    pub x: u16,
106    /// Y position of the padded region in the atlas (pixels).
107    pub y: u16,
108    /// Width of the padded region (pixels).
109    pub width: u16,
110    /// Height of the padded region (pixels).
111    pub height: u16,
112}
113
114/// Core glyph atlas cache data shared by all renderer backends.
115///
116/// Contains the cache entries, LRU tracking, pending uploads, and statistics.
117/// Does **not** own any pixel storage — that responsibility belongs to the
118/// concrete wrapper types provided by the integrating renderer crate.
119pub struct GlyphAtlas {
120    /// Eviction configuration.
121    eviction_config: GlyphCacheConfig,
122    /// Entries for non-variable fonts.
123    static_entries: FixedHashMap<GlyphCacheKey, GlyphCacheEntry>,
124    /// Entries for variable fonts, keyed by variation coordinates.
125    variable_entries: FixedHashMap<VarKey, FixedHashMap<GlyphCacheKey, GlyphCacheEntry>>,
126    /// Current frame serial for LRU tracking.
127    serial: u64,
128    /// Serial of last eviction pass.
129    last_eviction_serial: u64,
130    /// Total cached glyph count (across all maps).
131    entry_count: usize,
132    /// Bitmap glyphs awaiting GPU upload.
133    pending_uploads: Vec<PendingBitmapUpload>,
134    /// Atlas regions that must be cleared to transparent before compositing.
135    pending_clear_rects: Vec<PendingClearRect>,
136    /// Outline and COLR glyph commands awaiting replay, indexed by atlas page.
137    /// Uses `SmallVec` with inline capacity of 1 because most applications use
138    /// a single atlas page; the common case avoids heap allocation entirely.
139    pending_atlas_commands: SmallVec<[Option<AtlasCommandRecorder>; 1]>,
140    /// Number of cache hits since last `clear_stats()`.
141    cache_hits: u64,
142    /// Number of cache misses since last `clear_stats()`.
143    cache_misses: u64,
144}
145
146impl GlyphAtlas {
147    /// Creates a new empty core cache with default eviction settings.
148    pub fn new() -> Self {
149        Self::with_config(GlyphCacheConfig::default())
150    }
151
152    /// Creates a new empty core cache with custom eviction settings.
153    pub fn with_config(eviction_config: GlyphCacheConfig) -> Self {
154        Self {
155            eviction_config,
156            static_entries: EMPTY_GLYPH_MAP,
157            variable_entries: EMPTY_VAR_MAP,
158            serial: 0,
159            last_eviction_serial: 0,
160            entry_count: 0,
161            pending_uploads: Vec::new(),
162            pending_clear_rects: Vec::new(),
163            pending_atlas_commands: SmallVec::new(),
164            cache_hits: 0,
165            cache_misses: 0,
166        }
167    }
168
169    /// Returns a reference to the cache configuration.
170    pub fn config(&self) -> &GlyphCacheConfig {
171        &self.eviction_config
172    }
173
174    /// Look up a cached glyph.
175    pub fn get(&mut self, key: &GlyphCacheKey) -> Option<AtlasSlot> {
176        let serial = self.serial;
177        let entries = if key.var_coords.is_empty() {
178            &mut self.static_entries
179        } else {
180            match self
181                .variable_entries
182                .raw_entry_mut()
183                .from_key(&VarLookupKey(&key.var_coords))
184            {
185                RawEntryMut::Occupied(e) => e.into_mut(),
186                RawEntryMut::Vacant(_) => {
187                    self.cache_misses += 1;
188                    return None;
189                }
190            }
191        };
192
193        match entries.get_mut(key) {
194            Some(entry) => {
195                entry.serial = serial;
196                self.cache_hits += 1;
197                Some(entry.atlas_slot)
198            }
199            None => {
200                self.cache_misses += 1;
201                None
202            }
203        }
204    }
205
206    /// Allocate atlas space and insert a cache entry.
207    ///
208    /// Returns the allocated [`AtlasSlot`] on success.
209    #[expect(
210        clippy::cast_possible_truncation,
211        reason = "atlas offsets fit in u16 at reasonable atlas sizes"
212    )]
213    pub fn insert_entry(
214        &mut self,
215        image_cache: &mut ImageCache,
216        key: GlyphCacheKey,
217        raster_metrics: RasterMetrics,
218    ) -> Option<AtlasSlot> {
219        let padded_w = u32::from(raster_metrics.width) + u32::from(GLYPH_PADDING) * 2;
220        let padded_h = u32::from(raster_metrics.height) + u32::from(GLYPH_PADDING) * 2;
221
222        let image_id = image_cache.allocate(padded_w, padded_h, 0).ok()?;
223        let resource = image_cache.get(image_id)?;
224        let page_index = resource.atlas_id.as_u32() as usize;
225
226        let x = resource.offset[0] + GLYPH_PADDING;
227        let y = resource.offset[1] + GLYPH_PADDING;
228
229        let atlas_slot = AtlasSlot {
230            image_id,
231            page_index: page_index as u32,
232            x,
233            y,
234            width: raster_metrics.width,
235            height: raster_metrics.height,
236            bearing_x: raster_metrics.bearing_x,
237            bearing_y: raster_metrics.bearing_y,
238        };
239
240        let entry = GlyphCacheEntry {
241            atlas_slot,
242            serial: self.serial,
243        };
244
245        let entries = if key.var_coords.is_empty() {
246            &mut self.static_entries
247        } else {
248            match self
249                .variable_entries
250                .raw_entry_mut()
251                .from_key(&VarLookupKey(&key.var_coords))
252            {
253                RawEntryMut::Occupied(e) => e.into_mut(),
254                RawEntryMut::Vacant(e) => e.insert(key.var_coords.clone(), EMPTY_GLYPH_MAP).1,
255            }
256        };
257
258        entries.insert(key, entry);
259        self.entry_count += 1;
260
261        Some(atlas_slot)
262    }
263
264    /// Allocate atlas space, insert a cache entry, and return the page recorder.
265    #[expect(
266        clippy::cast_possible_truncation,
267        reason = "atlas dimensions are configured to fit in u16"
268    )]
269    pub fn insert(
270        &mut self,
271        image_cache: &mut ImageCache,
272        key: GlyphCacheKey,
273        raster_metrics: RasterMetrics,
274    ) -> Option<(AtlasSlot, &mut AtlasCommandRecorder)> {
275        let atlas_slot = self.insert_entry(image_cache, key, raster_metrics)?;
276        let (atlas_w, atlas_h) = {
277            let (w, h) = image_cache.atlas_manager().config().atlas_size;
278            (w as u16, h as u16)
279        };
280        let recorder = self.recorder_for_page(atlas_slot.page_index, atlas_w, atlas_h);
281        Some((atlas_slot, recorder))
282    }
283
284    /// Drain all pending bitmap uploads, keeping the allocation for reuse.
285    pub fn drain_pending_uploads(&mut self) -> impl Iterator<Item = PendingBitmapUpload> + '_ {
286        self.pending_uploads.drain(..)
287    }
288
289    /// Drain all pending clear rects, keeping the allocation for reuse.
290    pub fn drain_pending_clear_rects(&mut self) -> impl Iterator<Item = PendingClearRect> + '_ {
291        self.pending_clear_rects.drain(..)
292    }
293
294    /// Queue a bitmap pixmap for later processing.
295    pub fn push_pending_upload(
296        &mut self,
297        image_id: ImageId,
298        pixmap: Arc<Pixmap>,
299        atlas_slot: AtlasSlot,
300    ) {
301        self.pending_uploads.push(PendingBitmapUpload {
302            image_id,
303            pixmap,
304            atlas_slot,
305        });
306    }
307
308    /// Replay all pending atlas command recorders (one per dirty page).
309    ///
310    /// The closure receives each non-empty recorder by mutable reference.
311    /// After the closure returns, the recorder's commands are cleared but
312    /// the allocation is kept for reuse next frame.
313    pub fn replay_pending_atlas_commands(&mut self, mut f: impl FnMut(&mut AtlasCommandRecorder)) {
314        for slot in &mut self.pending_atlas_commands {
315            if let Some(recorder) = slot.as_mut()
316                && !recorder.commands.is_empty()
317            {
318                f(recorder);
319                recorder.commands.clear();
320            }
321        }
322    }
323
324    /// Get (or create) the command recorder for the given atlas page.
325    pub fn recorder_for_page(
326        &mut self,
327        page_index: u32,
328        atlas_width: u16,
329        atlas_height: u16,
330    ) -> &mut AtlasCommandRecorder {
331        let idx = page_index as usize;
332        if self.pending_atlas_commands.len() <= idx {
333            self.pending_atlas_commands.resize_with(idx + 1, || None);
334        }
335        self.pending_atlas_commands[idx]
336            .get_or_insert_with(|| AtlasCommandRecorder::new(page_index, atlas_width, atlas_height))
337    }
338
339    /// Advance the frame counter and potentially evict old entries.
340    pub fn maintain(&mut self, image_cache: &mut ImageCache) {
341        self.tick();
342        let frames_since_eviction = self.serial - self.last_eviction_serial;
343        if frames_since_eviction < self.eviction_config.eviction_frequency {
344            return;
345        }
346
347        self.last_eviction_serial = self.serial;
348        self.evict_old_entries(image_cache);
349    }
350
351    /// Advance the frame counter.
352    fn tick(&mut self) {
353        self.serial += 1;
354    }
355
356    /// Evict entries that haven't been used recently.
357    ///
358    /// For each evicted entry, queues a [`PendingClearRect`] covering the full
359    /// padded atlas region. The application must drain these via
360    /// [`drain_pending_clear_rects`](GlyphAtlas::drain_pending_clear_rects) and
361    /// zero each region so that stale pixel data doesn't bleed through when
362    /// the slot is later reused and composited with `SrcOver`.
363    fn evict_old_entries(&mut self, image_cache: &mut ImageCache) {
364        let serial = self.serial;
365        let max_entry_age = self.eviction_config.max_entry_age;
366        let entry_count = &mut self.entry_count;
367        let pending_clear_rects = &mut self.pending_clear_rects;
368
369        let mut should_retain = |entry: &GlyphCacheEntry| -> bool {
370            let age = serial - entry.serial;
371            if age > max_entry_age {
372                image_cache.deallocate(entry.atlas_slot.image_id);
373                *entry_count = entry_count.saturating_sub(1);
374                push_clear_rect_for_slot(pending_clear_rects, &entry.atlas_slot);
375                false
376            } else {
377                true
378            }
379        };
380
381        self.static_entries.retain(|_, entry| should_retain(entry));
382
383        self.variable_entries.retain(|_, entries| {
384            entries.retain(|_, entry| should_retain(entry));
385            !entries.is_empty()
386        });
387    }
388
389    /// Clear all cache entries, pending work queues, and statistics.
390    pub fn clear(&mut self) {
391        self.static_entries.clear();
392        self.variable_entries.clear();
393        self.serial = 0;
394        self.last_eviction_serial = 0;
395        self.entry_count = 0;
396        self.pending_uploads.clear();
397        self.pending_clear_rects.clear();
398        self.pending_atlas_commands.clear();
399        self.cache_hits = 0;
400        self.cache_misses = 0;
401    }
402
403    /// Get the number of cached glyphs.
404    #[inline]
405    pub fn len(&self) -> usize {
406        self.entry_count
407    }
408
409    /// Returns `true` if the cache contains no entries.
410    #[inline]
411    pub fn is_empty(&self) -> bool {
412        self.entry_count == 0
413    }
414
415    /// Get the number of cache hits since last `clear_stats()`.
416    #[inline]
417    pub fn cache_hits(&self) -> u64 {
418        self.cache_hits
419    }
420
421    /// Get the number of cache misses since last `clear_stats()`.
422    #[inline]
423    pub fn cache_misses(&self) -> u64 {
424        self.cache_misses
425    }
426
427    /// Reset cache hit/miss counters without clearing the cache itself.
428    pub fn clear_stats(&mut self) {
429        self.cache_hits = 0;
430        self.cache_misses = 0;
431    }
432}
433
434/// Queue a clear rect covering the full padded region of an evicted atlas slot.
435///
436/// The slot's `x`/`y` are already inset by [`GLYPH_PADDING`] from the
437/// allocation origin, so we subtract it back to get the padded top-left
438/// corner and add `2 * GLYPH_PADDING` to each dimension.
439fn push_clear_rect_for_slot(pending: &mut Vec<PendingClearRect>, slot: &AtlasSlot) {
440    pending.push(PendingClearRect {
441        page_index: slot.page_index,
442        x: slot.x - GLYPH_PADDING,
443        y: slot.y - GLYPH_PADDING,
444        width: slot.width + 2 * GLYPH_PADDING,
445        height: slot.height + 2 * GLYPH_PADDING,
446    });
447}
448
449/// Statistics about cached glyphs.
450#[cfg(all(debug_assertions, feature = "std"))]
451#[derive(Debug)]
452pub struct GlyphCacheStats {
453    /// Number of glyphs from static (non-variable) fonts.
454    pub static_glyphs: usize,
455    /// Number of glyphs from variable fonts.
456    pub variable_glyphs: usize,
457    /// Number of atlas pages currently allocated.
458    pub page_count: usize,
459    /// Number of unique glyph IDs (same glyph may have multiple entries due to subpixel).
460    pub unique_glyph_ids: usize,
461    /// Distribution of entries across subpixel buckets.
462    pub subpixel_distribution: [usize; SUBPIXEL_BUCKETS as usize],
463    /// List of unique font sizes used.
464    pub sizes_used: Vec<f32>,
465}
466
467#[cfg(all(debug_assertions, feature = "std"))]
468impl GlyphCacheStats {
469    /// Total number of cached glyph entries (static + variable).
470    pub fn total_glyphs(&self) -> usize {
471        self.static_glyphs + self.variable_glyphs
472    }
473}
474
475#[cfg(all(debug_assertions, feature = "std"))]
476impl GlyphAtlas {
477    /// Get detailed statistics about cached glyphs.
478    pub fn stats(&self, page_count: usize) -> GlyphCacheStats {
479        use std::collections::HashSet;
480
481        let mut unique_ids = HashSet::new();
482        let mut subpixel_dist = [0; SUBPIXEL_BUCKETS as usize];
483        let mut sizes = HashSet::new();
484
485        for key in self.static_entries.keys() {
486            unique_ids.insert(key.glyph_id);
487            subpixel_dist[key.subpixel_x as usize] += 1;
488            sizes.insert(key.size_bits);
489        }
490
491        let variable_count: usize = self.variable_entries.values().map(|m| m.len()).sum();
492
493        for entries in self.variable_entries.values() {
494            for key in entries.keys() {
495                unique_ids.insert(key.glyph_id);
496                subpixel_dist[key.subpixel_x as usize] += 1;
497                sizes.insert(key.size_bits);
498            }
499        }
500
501        GlyphCacheStats {
502            static_glyphs: self.static_entries.len(),
503            variable_glyphs: variable_count,
504            page_count,
505            unique_glyph_ids: unique_ids.len(),
506            subpixel_distribution: subpixel_dist,
507            sizes_used: sizes.into_iter().map(f32::from_bits).collect(),
508        }
509    }
510
511    /// Log cache hit/miss statistics at debug level.
512    pub fn log_hit_miss_stats(&self) {
513        let total = self.cache_hits + self.cache_misses;
514        let hit_rate = if total > 0 {
515            (self.cache_hits as f64 / total as f64) * 100.0
516        } else {
517            0.0
518        };
519        log::debug!("=== Cache Hit/Miss Statistics ===");
520        log::debug!("Cache hits:   {}", self.cache_hits);
521        log::debug!("Cache misses: {}", self.cache_misses);
522        log::debug!("Total lookups: {}", total);
523        log::debug!("Hit rate:     {:.2}%", hit_rate);
524    }
525
526    /// Log detailed atlas statistics at debug level.
527    pub fn log_atlas_stats(&self, page_count: usize) {
528        let stats = self.stats(page_count);
529        log::debug!("=== Glyph Atlas Statistics ===");
530        log::debug!("Total cached glyphs: {}", stats.total_glyphs());
531        log::debug!("Unique glyph IDs: {}", stats.unique_glyph_ids);
532        log::debug!("Atlas pages: {}", stats.page_count);
533        log::debug!("Static font glyphs: {}", stats.static_glyphs);
534        log::debug!("Variable font glyphs: {}", stats.variable_glyphs);
535        log::debug!("Subpixel distribution: {:?}", stats.subpixel_distribution);
536        log::debug!("Font sizes: {:?}", stats.sizes_used);
537
538        if stats.unique_glyph_ids > 0 {
539            let ratio = stats.total_glyphs() as f32 / stats.unique_glyph_ids as f32;
540            log::debug!("Avg entries per unique glyph: {:.2}", ratio);
541        }
542    }
543
544    /// Returns all cached glyph keys (for debugging).
545    pub fn all_keys(&self) -> impl Iterator<Item = &GlyphCacheKey> {
546        self.static_entries
547            .keys()
548            .chain(self.variable_entries.values().flat_map(|e| e.keys()))
549    }
550
551    /// Log all cached keys grouped by glyph ID at debug level.
552    ///
553    /// This is useful for understanding why the same glyph appears multiple
554    /// times in the atlas (e.g., different subpixel positions or sizes).
555    pub fn log_keys_grouped(&self) {
556        let mut by_glyph: HashMap<u32, Vec<(&GlyphCacheKey, &str)>> = HashMap::new();
557
558        for key in self.static_entries.keys() {
559            by_glyph
560                .entry(key.glyph_id)
561                .or_default()
562                .push((key, "stat"));
563        }
564        for entries in self.variable_entries.values() {
565            for key in entries.keys() {
566                by_glyph
567                    .entry(key.glyph_id)
568                    .or_default()
569                    .push((key, "var "));
570            }
571        }
572
573        log::debug!(
574            "=== Glyph Keys Grouped by ID ({} unique) ===",
575            by_glyph.len()
576        );
577
578        let mut ids: Vec<_> = by_glyph.keys().copied().collect();
579        ids.sort();
580
581        for glyph_id in ids {
582            let keys = &by_glyph[&glyph_id];
583            let suffix = if keys.len() == 1 { "entry" } else { "entries" };
584            log::debug!("glyph_id {:4} ({} {}):", glyph_id, keys.len(), suffix);
585            for (k, source) in keys {
586                log::debug!(
587                    "    [{}] subpx: {}, size: {:.2}, hinted: {}, font_id: {:016x}, font_index: {}",
588                    source,
589                    k.subpixel_x,
590                    f32::from_bits(k.size_bits),
591                    k.hinted,
592                    k.font_id,
593                    k.font_index,
594                );
595            }
596        }
597    }
598}
599
600impl Default for GlyphAtlas {
601    fn default() -> Self {
602        Self::new()
603    }
604}
605
606impl Debug for GlyphAtlas {
607    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
608        f.debug_struct("GlyphAtlas")
609            .field("entry_count", &self.entry_count)
610            .field("static_entries", &self.static_entries.len())
611            .field("variable_fonts", &self.variable_entries.len())
612            .field("serial", &self.serial)
613            .finish_non_exhaustive()
614    }
615}
616
617/// Internal cache entry storing atlas slot and access time.
618struct GlyphCacheEntry {
619    /// Atlas slot information for blitting.
620    atlas_slot: AtlasSlot,
621    /// Frame serial when last accessed (for LRU eviction).
622    serial: u64,
623}
624
625/// Key for variable font caches (owned version).
626type VarKey = SmallVec<[skrifa::instance::NormalizedCoord; 4]>;
627
628/// Lookup key for variable font caches (borrowed version).
629#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
630struct VarLookupKey<'a>(&'a [skrifa::instance::NormalizedCoord]);
631
632impl hashbrown::Equivalent<VarKey> for VarLookupKey<'_> {
633    fn equivalent(&self, other: &VarKey) -> bool {
634        self.0 == other.as_slice()
635    }
636}
637
638impl From<VarLookupKey<'_>> for VarKey {
639    fn from(key: VarLookupKey<'_>) -> Self {
640        Self::from_slice(key.0)
641    }
642}