Skip to main content

valo_renderer/
glyphs.rs

1use rustc_hash::FxHashMap;
2use std::sync::Arc;
3
4use valo_geometry::{Cap, Join, Path};
5use valo_text::{Font, GlyphImage, GlyphStroke, Rasterizer};
6
7/// Skia's kMaxMultitexturePages: open pages up to this, then GC.
8const MAX_PAGES: usize = 4;
9
10/// `TextTiers` selects the text rendering method by device-space font size.
11///
12/// Valo uses bitmap masks below `sdf_min`, SDF below `path_min`, and vector
13/// outlines at larger sizes. The defaults follow Skia's static text thresholds;
14/// zoom-heavy applications may lower `sdf_min` to reduce rerasterization.
15#[derive(Clone, Copy, Debug)]
16pub struct TextTiers {
17    /// `sdf_min` is the first device-pixel size rendered with SDF.
18    pub sdf_min: f32,
19    /// `path_min` is the first device-pixel size rendered as vector outlines.
20    pub path_min: f32,
21}
22
23impl Default for TextTiers {
24    fn default() -> Self {
25        Self {
26            sdf_min: 162.0,
27            path_min: 324.0,
28        }
29    }
30}
31
32/// The SDF strike sizes, ascending (Skia's kSmall/kMedium/kLargeDFFontLimit
33/// plus one for the static profile's 162–324 band). One raster serves a
34/// whole zoom band; under a text-raster hold the OTHER buckets of a glyph
35/// are its stand-in candidates.
36pub(crate) const SDF_BUCKETS: [f32; 4] = [32.0, 72.0, 162.0, 256.0];
37/// Transparent gutter between entries so linear sampling never bleeds.
38const GUTTER: i32 = 1;
39const DEFAULT_PAGE_SIZE: u32 = 2048;
40
41/// A packed glyph: where it lives in its page (uv) and how the bitmap hangs
42/// off the glyph origin (`left` right of origin, `top` above the baseline —
43/// swash placement, y-up).
44#[derive(Clone, Copy, Debug)]
45pub struct AtlasGlyph {
46    pub uv: [f32; 4],
47    pub left: f32,
48    pub top: f32,
49    pub width: f32,
50    pub height: f32,
51}
52
53/// Which atlas page a glyph landed on. `color` selects the RGBA page family
54/// (emoji); mask/SDF glyphs live on R8 pages.
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
56pub struct PageRef {
57    pub color: bool,
58    pub index: usize,
59}
60
61struct PathEntry {
62    path: Option<Arc<Path>>,
63    last_used: u64,
64}
65
66/// Frames an unused no-page-space entry (outline path, whitespace
67/// placeholder) survives before the sweep — same policy as ContourCache.
68const IDLE_FRAMES: u64 = 3;
69
70/// What an atlas entry holds. The stroke rides along because a stroked
71/// glyph is nothing more than another cached image — Impeller hashes the
72/// same parameters into `SubpixelGlyph`, Skia into `SkScalerContextRec`.
73/// A stroked SDF is deliberately not expressible: an SDF encodes distance
74/// from a FILL boundary, so it would be a different field, and Impeller's
75/// stroked glyphs go to the regular atlas for the same reason.
76#[derive(Clone, Copy, Debug, PartialEq)]
77pub enum Coverage {
78    Fill,
79    Sdf,
80    Stroke(GlyphStroke),
81}
82
83/// [`Coverage`] made hashable. The raster reads its parameters back out of
84/// this, so the image an entry holds is always exactly what its key says.
85///
86/// Width quantizes to 1/16 px: it moves the stroke's edge CONTINUOUSLY, so a
87/// sixteenth is finer than an antialiased edge resolves, and coarse enough
88/// that an animated width does not mint an entry per frame.
89///
90/// The miter limit does not quantize. It is a THRESHOLD, not a distance —
91/// crossing it flips a join between a bevel and a full spike, so two limits a
92/// sixteenth apart can produce visibly different glyphs and must never share
93/// a cell. Impeller compares the stroke floats exactly for the same reason.
94#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
95enum CoverageKey {
96    Fill,
97    Sdf,
98    Stroke {
99        width_16: u32,
100        /// `f32::to_bits` of a normalized miter limit — exact, and `Eq + Hash`
101        /// because the normalization rules out NaN.
102        miter_bits: u32,
103        cap: u8,
104        join: u8,
105    },
106}
107
108/// Sixteenths, saturating — a negative or NaN parameter can only ever have
109/// come from a caller, never from the tier policy.
110fn sixteenths(value: f32) -> u32 {
111    (value * 16.0).round().clamp(0.0, u32::MAX as f32) as u32
112}
113
114/// A miter limit that is safe to hash: NaN and negatives collapse onto the
115/// SVG default, and -0.0 onto 0.0, so bit equality matches value equality.
116fn normalized_miter_limit(limit: f32) -> f32 {
117    if limit.is_nan() || limit < 0.0 {
118        4.0
119    } else {
120        limit + 0.0
121    }
122}
123
124impl CoverageKey {
125    fn of(coverage: Coverage) -> Self {
126        match coverage {
127            Coverage::Fill => Self::Fill,
128            Coverage::Sdf => Self::Sdf,
129            Coverage::Stroke(stroke) => Self::Stroke {
130                width_16: sixteenths(stroke.width),
131                miter_bits: normalized_miter_limit(stroke.miter_limit).to_bits(),
132                cap: match stroke.cap {
133                    Cap::Butt => 0,
134                    Cap::Round => 1,
135                    Cap::Square => 2,
136                },
137                join: match stroke.join {
138                    Join::Miter => 0,
139                    Join::Round => 1,
140                    Join::Bevel => 2,
141                },
142            },
143        }
144    }
145
146    fn coverage(self) -> Coverage {
147        match self {
148            Self::Fill => Coverage::Fill,
149            Self::Sdf => Coverage::Sdf,
150            Self::Stroke {
151                width_16,
152                miter_bits,
153                cap,
154                join,
155            } => Coverage::Stroke(GlyphStroke {
156                width: width_16 as f32 / 16.0,
157                miter_limit: f32::from_bits(miter_bits),
158                cap: match cap {
159                    1 => Cap::Round,
160                    2 => Cap::Square,
161                    _ => Cap::Butt,
162                },
163                join: match join {
164                    1 => Join::Round,
165                    2 => Join::Bevel,
166                    _ => Join::Miter,
167                },
168            }),
169        }
170    }
171}
172
173#[derive(Clone, Copy, PartialEq, Eq, Hash)]
174struct GlyphKey {
175    /// The font INSTANCE's stable raster identity ([`Font::uid`]).
176    font: u64,
177    glyph: u32,
178    /// EXACT raster size (f32 bits) — quantization is the tier policy's job.
179    px_bits: u32,
180    /// Quarter-pixel x phase (0..4), mask tier only — Skia/Impeller's
181    /// subpixel positioning.
182    phase: u8,
183    coverage: CoverageKey,
184}
185
186impl GlyphKey {
187    fn new(font: u64, glyph: u32, px: f32, coverage: Coverage, phase: u8) -> Self {
188        Self {
189            font,
190            glyph,
191            px_bits: px.to_bits(),
192            phase,
193            coverage: CoverageKey::of(coverage),
194        }
195    }
196
197    fn px(&self) -> f32 {
198        f32::from_bits(self.px_bits)
199    }
200}
201
202struct Page {
203    allocator: etagere::AtlasAllocator,
204    texture: wgpu::Texture,
205    view: wgpu::TextureView,
206    bind: Option<wgpu::BindGroup>,
207    /// CPU copy of the page. New glyphs land here and the union of their
208    /// rects uploads as ONE `write_texture` per frame — Impeller and Skia
209    /// batch atlas uploads the same way, and per-glyph calls are the wasm
210    /// frame killer: each one is a browser API crossing.
211    shadow: Option<Box<[u8]>>,
212    /// Dirty region awaiting flush: [x0, y0, x1, y1), texels.
213    dirty: Option<[u32; 4]>,
214}
215
216/// A glyph's page space: where it lives plus the allocator handle that can
217/// give that exact rectangle back (WebRender's texture-cache shape — its
218/// eviction is entry-granular on this same allocator crate).
219#[derive(Clone, Copy)]
220struct Resident {
221    page: PageRef,
222    glyph: AtlasGlyph,
223    slot: etagere::AllocId,
224}
225
226/// Rects freed per eviction attempt — enough to make progress, small enough
227/// that a lucky early fit doesn't over-evict.
228const EVICT_BATCH: usize = 64;
229
230/// The renderer's glyph cache: rasterize misses via valo-text,
231/// pack with etagere, upload the region, hand out page + uv. Pages grow to
232/// [`MAX_PAGES`] per family; a full family evicts its least-recently-USED
233/// entries one rect at a time (glyphs drawn this frame are
234/// untouchable, so hot SDFs outlive any churn of stale mask scales). The
235/// wholesale GC survives only as the last-resort defragmenter. Mid-frame
236/// eviction is safe: already-emitted steps keep their pages alive through
237/// their bind groups (wgpu refcounts), so their uvs stay valid until submit.
238pub struct GlyphStore {
239    device: wgpu::Device,
240    queue: wgpu::Queue,
241    raster: Rasterizer,
242    page_size: u32,
243    mask_pages: Vec<Page>,
244    color_pages: Vec<Page>,
245    entries: FxHashMap<GlyphKey, (Option<Resident>, u64)>,
246    /// Outline tier cache: (font, glyph, px bits) → baseline-origin path.
247    paths: FxHashMap<(u64, u32, u32), PathEntry>,
248    /// Frame counter for the idle sweeps (see `end_frame`).
249    frame: u64,
250    /// Bumps on every GC — visible in tests and debugging.
251    pub generation: u64,
252    pub tiers: TextTiers,
253    /// This frame's cache misses (each = one swash raster) — the stats
254    /// line's cache-health number. A warm frame reads 0.
255    rasters: u32,
256    /// Wholesale family GCs this frame; nonzero on a warm frame means the
257    /// live set no longer fits the pages (thrash).
258    gcs: u32,
259    /// The host-owned gesture switch (OPT-IN, default off):
260    /// while held, a text-raster miss (SDF bucket OR bitmap scale) whose
261    /// glyph is resident at ANOTHER size skips rasterizing — the planner
262    /// draws the stand-in scaled (soft, mid-pinch Chrome). Misses with no
263    /// stand-in still raster: soft beats invisible. The host clears the
264    /// hold when input goes idle and re-renders; valo keeps no clocks.
265    hold: bool,
266    /// Rasters skipped by the hold this frame — the HUD number that proves
267    /// gesture frames are raster-free (and exposes a stuck hold).
268    held: u32,
269    /// OPT-IN (default off = Skia/browser behavior): skip
270    /// glyph 0 (`.notdef`) in every text tier, so unresolved chars render
271    /// blank instead of tofu boxes. Pair with [`FontDemand`] reporting —
272    /// hiding the box without watching the demand silently loses text.
273    hide_missing_glyphs: bool,
274    /// Resident (px, phase) per (font, glyph, coverage) — the stand-in
275    /// lookup. Bitmap scales are continuous (size × 1/200-quantized zoom),
276    /// so "nearest resident size" needs an index; SDF rides the same one.
277    /// A stroked entry's width is in raster pixels and so scales with `px`,
278    /// which puts every size in its own coverage bucket: stroked runs
279    /// simply find no stand-in and raster through a text-raster hold.
280    sizes: FxHashMap<(u64, u32, CoverageKey), Vec<(f32, u8)>>,
281}
282
283impl GlyphStore {
284    pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
285        Self::with_page_size(device, queue, DEFAULT_PAGE_SIZE)
286    }
287
288    /// Test seam: tiny pages force the page-add and GC paths.
289    pub fn with_page_size(device: &wgpu::Device, queue: &wgpu::Queue, page_size: u32) -> Self {
290        Self {
291            device: device.clone(),
292            queue: queue.clone(),
293            raster: Rasterizer::new(),
294            page_size,
295            mask_pages: Vec::new(),
296            color_pages: Vec::new(),
297            entries: FxHashMap::default(),
298            frame: 0,
299            paths: FxHashMap::default(),
300            generation: 0,
301            tiers: TextTiers::default(),
302            rasters: 0,
303            gcs: 0,
304            hold: false,
305            held: 0,
306            hide_missing_glyphs: false,
307            sizes: FxHashMap::default(),
308        }
309    }
310
311    /// See the `hold` field: the host's gesture switch for text rasters.
312    pub fn set_text_raster_hold(&mut self, held: bool) {
313        self.hold = held;
314    }
315
316    /// See the `hide_missing_glyphs` field: blank instead of tofu, opt-in.
317    pub fn set_hide_missing_glyphs(&mut self, hide: bool) {
318        self.hide_missing_glyphs = hide;
319    }
320
321    /// The planner reads this once per run (a closure calling into the
322    /// store would hold the borrow across the mutating batch loop).
323    pub fn hides_missing_glyphs(&self) -> bool {
324        self.hide_missing_glyphs
325    }
326
327    /// (cache-miss rasters, wholesale GCs, hold-skipped rasters) this frame.
328    pub fn frame_counters(&self) -> (u32, u32, u32) {
329        (self.rasters, self.gcs, self.held)
330    }
331
332    /// The registered collection, for overlays that lay text out against
333    /// Rasterize/pack a whole run BEFORE anyone batches page references:
334    /// packing may GC a family (all its pages drop), which would invalidate
335    /// any `PageRef` taken earlier. A pass that completes without a GC
336    /// proves every key coexists in the atlas (Impeller's collect-then-
337    /// build, scoped to the run); a run too large to EVER fit (an emoji
338    /// wall) stops retrying and drops its overflow glyphs for the frame —
339    /// resident entries always point at live pages either way.
340    pub fn ensure_run(&mut self, font: &Font, px: f32, coverage: Coverage, keys: &[(u32, u8)]) {
341        for _ in 0..2 {
342            let generation = self.generation;
343            for &(glyph, phase) in keys {
344                self.ensure(font, glyph, px, coverage, phase);
345            }
346            if self.generation == generation {
347                return;
348            }
349        }
350    }
351
352    /// Read-only lookup after [`Self::ensure_run`] — never rasterizes, so
353    /// it can never evict (page references stay valid while batching).
354    pub fn entry(
355        &self,
356        font: u64,
357        glyph: u32,
358        px: f32,
359        coverage: Coverage,
360        phase: u8,
361    ) -> Option<(PageRef, AtlasGlyph)> {
362        let (slot, _) = self
363            .entries
364            .get(&GlyphKey::new(font, glyph, px, coverage, phase))?;
365        slot.map(|r| (r.page, r.glyph))
366    }
367
368    /// The atlas slot for (font, glyph, px, coverage) — rasterizing,
369    /// packing, and uploading on first sight. Color glyphs (emoji) win over
370    /// the requested coverage and land on the RGBA family.
371    fn ensure(&mut self, font: &Font, glyph: u32, px: f32, coverage: Coverage, phase: u8) {
372        let key = GlyphKey::new(font.uid().0, glyph, px, coverage, phase);
373        if let Some((_, last_used)) = self.entries.get_mut(&key) {
374            *last_used = self.frame;
375            return;
376        }
377        // Held misses with a stand-in skip the raster; NO entry lands, so
378        // the key stays a miss and rasters on the first un-held frame.
379        if self.hold
380            && self
381                .find_stand_in(key.font, key.glyph, key.coverage, key.px())
382                .is_some()
383        {
384            self.held += 1;
385            return;
386        }
387        let entry = self.rasterize_and_pack(key, font);
388        if entry.is_some() {
389            self.sizes
390                .entry((key.font, key.glyph, key.coverage))
391                .or_default()
392                .push((key.px(), key.phase));
393        }
394        self.entries.insert(key, (entry, self.frame));
395    }
396
397    /// The nearest OTHER resident size of this glyph — what a held frame
398    /// draws through, scaled. Marks the stand-in USED THIS FRAME: a later
399    /// run's packing may evict idle rects, and an evicted-then-overwritten
400    /// rect would corrupt quads already batched against it.
401    pub fn resident_stand_in(
402        &mut self,
403        font: u64,
404        glyph: u32,
405        coverage: Coverage,
406        wanted_px: f32,
407    ) -> Option<(f32, PageRef, AtlasGlyph)> {
408        let (key, resident) =
409            self.find_stand_in(font, glyph, CoverageKey::of(coverage), wanted_px)?;
410        if let Some((_, last_used)) = self.entries.get_mut(&key) {
411            *last_used = self.frame;
412        }
413        Some((f32::from_bits(key.px_bits), resident.page, resident.glyph))
414    }
415
416    /// Read-only search over the glyph's resident sizes, nearest px first.
417    fn find_stand_in(
418        &self,
419        font: u64,
420        glyph: u32,
421        coverage: CoverageKey,
422        wanted_px: f32,
423    ) -> Option<(GlyphKey, Resident)> {
424        let mut best: Option<(GlyphKey, Resident, f32)> = None;
425        for &(px, phase) in self.sizes.get(&(font, glyph, coverage))? {
426            if px == wanted_px {
427                continue;
428            }
429            let distance = (px - wanted_px).abs();
430            if best.as_ref().is_some_and(|(.., d)| *d <= distance) {
431                continue;
432            }
433            let key = GlyphKey {
434                font,
435                glyph,
436                px_bits: px.to_bits(),
437                phase,
438                coverage,
439            };
440            if let Some((Some(resident), _)) = self.entries.get(&key) {
441                best = Some((key, *resident, distance));
442            }
443        }
444        best.map(|(key, resident, _)| (key, resident))
445    }
446
447    fn rasterize_and_pack(&mut self, key: GlyphKey, font: &Font) -> Option<Resident> {
448        self.rasters += 1;
449        let px = key.px();
450        if let Some(image) = self.raster.color(font, key.glyph, px) {
451            return self.pack(true, &image);
452        }
453        let dx = key.phase as f32 * 0.25;
454        let image = match key.coverage.coverage() {
455            Coverage::Sdf => self.raster.sdf(font, key.glyph, px),
456            Coverage::Fill => self.raster.alpha(font, key.glyph, px, dx),
457            Coverage::Stroke(stroke) => self.raster.stroked(font, key.glyph, px, dx, &stroke),
458        }?;
459        self.pack(false, &image)
460    }
461
462    /// Allocate on the family's pages: existing space → a new page → evict
463    /// the coldest idle rects → wholesale GC as the last-resort defrag
464    /// (fragmentation can starve a fit even with idle space freed).
465    fn pack(&mut self, color: bool, image: &GlyphImage) -> Option<Resident> {
466        if image.width == 0 || image.height == 0 {
467            return None;
468        }
469        if image.width + 2 > self.page_size || image.height + 2 > self.page_size {
470            debug_assert!(false, "glyph larger than an atlas page");
471            return None;
472        }
473        loop {
474            if let Some(hit) = self.try_pages(color, image) {
475                return Some(hit);
476            }
477            if self.pages(color).len() < MAX_PAGES {
478                self.add_page(color);
479            } else if !self.evict_lru(color) {
480                self.gc(color);
481            }
482        }
483    }
484
485    /// Free the least-recently-USED resident rects of the family until the
486    /// packer can make progress. Entries drawn THIS frame are untouchable —
487    /// so a run ensured earlier in the pass can never be invalidated by a
488    /// later one, and evicted glyphs re-raster on their next sighting.
489    /// `false` = nothing idle left (one frame truly outgrew the pages).
490    fn evict_lru(&mut self, color: bool) -> bool {
491        let mut idle: Vec<(u64, GlyphKey)> = self
492            .entries
493            .iter()
494            .filter_map(|(key, (resident, used))| {
495                let r = resident.as_ref()?;
496                (r.page.color == color && *used < self.frame).then_some((*used, *key))
497            })
498            .collect();
499        if idle.is_empty() {
500            return false;
501        }
502        idle.sort_unstable_by_key(|(used, _)| *used);
503        for (_, key) in idle.into_iter().take(EVICT_BATCH) {
504            let Some((Some(r), _)) = self.entries.remove(&key) else {
505                continue;
506            };
507            self.pages_mut(color)[r.page.index]
508                .allocator
509                .deallocate(r.slot);
510            if let Some(list) = self.sizes.get_mut(&(key.font, key.glyph, key.coverage)) {
511                list.retain(|&(px, phase)| px.to_bits() != key.px_bits || phase != key.phase);
512            }
513        }
514        true
515    }
516
517    fn try_pages(&mut self, color: bool, image: &GlyphImage) -> Option<Resident> {
518        let size = etagere::size2(
519            image.width as i32 + GUTTER * 2,
520            image.height as i32 + GUTTER * 2,
521        );
522        for index in 0..self.pages(color).len() {
523            let Some(slot) = self.pages_mut(color)[index].allocator.allocate(size) else {
524                continue;
525            };
526            let (x, y) = (
527                (slot.rectangle.min.x + GUTTER) as u32,
528                (slot.rectangle.min.y + GUTTER) as u32,
529            );
530            let page_size = self.page_size;
531            stage(
532                &mut self.pages_mut(color)[index],
533                page_size,
534                color,
535                x,
536                y,
537                image,
538            );
539            let s = 1.0 / self.page_size as f32;
540            return Some(Resident {
541                page: PageRef { color, index },
542                glyph: AtlasGlyph {
543                    uv: [
544                        x as f32 * s,
545                        y as f32 * s,
546                        (x + image.width) as f32 * s,
547                        (y + image.height) as f32 * s,
548                    ],
549                    left: image.left as f32,
550                    top: image.top as f32,
551                    width: image.width as f32,
552                    height: image.height as f32,
553                },
554                slot: slot.id,
555            });
556        }
557        None
558    }
559
560    fn add_page(&mut self, color: bool) {
561        let page = create_page(&self.device, self.page_size, color);
562        self.pages_mut(color).push(page);
563    }
564
565    /// Last-resort defrag (eviction found nothing idle, or freed space too
566    /// fragmented to fit): drop the family's pages ALL at once and restart
567    /// with a fresh one. This frame's still-needed glyphs re-rasterize on
568    /// demand; older steps keep the dropped textures alive via bind groups
569    /// until submit.
570    fn gc(&mut self, color: bool) {
571        self.generation += 1;
572        self.gcs += 1;
573        self.pages_mut(color).clear();
574        self.add_page(color);
575        self.entries
576            .retain(|_, (slot, _)| slot.is_none_or(|r| r.page.color != color));
577        // Rare and wholesale — rebuilding the size index beats tracking it.
578        self.sizes.clear();
579        for (key, (slot, _)) in &self.entries {
580            if slot.is_some() {
581                self.sizes
582                    .entry((key.font, key.glyph, key.coverage))
583                    .or_default()
584                    .push((key.px(), key.phase));
585            }
586        }
587    }
588
589    fn pages(&self, color: bool) -> &Vec<Page> {
590        if color {
591            &self.color_pages
592        } else {
593            &self.mask_pages
594        }
595    }
596
597    fn pages_mut(&mut self, color: bool) -> &mut Vec<Page> {
598        if color {
599            &mut self.color_pages
600        } else {
601            &mut self.mask_pages
602        }
603    }
604
605    /// Outline tier: the glyph as a path at `px`, cached until
606    /// the size goes idle (animating text size would otherwise grow this
607    /// forever — the audit's one unbounded cache).
608    pub fn path(&mut self, font: &Font, glyph: u32, px: f32) -> Option<Arc<Path>> {
609        let frame = self.frame;
610        let entry = self
611            .paths
612            .entry((font.uid().0, glyph, px.to_bits()))
613            .or_insert_with(|| PathEntry {
614                path: valo_text::glyph_path(font, glyph, px),
615                last_used: frame,
616            });
617        entry.last_used = frame;
618        entry.path.clone()
619    }
620
621    /// Frame boundary: age-sweep the entries that hold NO page space —
622    /// outline paths and whitespace placeholders. Rasters on pages stay
623    /// until a full family evicts its coldest — idleness
624    /// alone never frees page space, demand does.
625    pub fn end_frame(&mut self) {
626        self.frame += 1;
627        self.rasters = 0;
628        self.gcs = 0;
629        self.held = 0;
630        let now = self.frame;
631        let expired = |last: u64| now.saturating_sub(last) > IDLE_FRAMES;
632        self.paths.retain(|_, e| !expired(e.last_used));
633        self.entries
634            .retain(|_, (slot, last)| slot.is_some() || !expired(*last));
635    }
636
637    /// Push every dirty page region to the GPU — ONE `write_texture` per
638    /// page per frame regardless of how many glyphs landed.
639    /// The renderer calls this after planning, before encoding.
640    pub fn flush_uploads(&mut self) {
641        let (page_size, queue) = (self.page_size, self.queue.clone());
642        for (pages, color) in [(&mut self.mask_pages, false), (&mut self.color_pages, true)] {
643            for page in pages.iter_mut() {
644                let (Some([x0, y0, x1, y1]), Some(shadow)) = (page.dirty.take(), &page.shadow)
645                else {
646                    continue;
647                };
648                let bpp = if color { 4u32 } else { 1 };
649                let stride = page_size * bpp;
650                queue.write_texture(
651                    wgpu::TexelCopyTextureInfo {
652                        texture: &page.texture,
653                        mip_level: 0,
654                        origin: wgpu::Origin3d { x: x0, y: y0, z: 0 },
655                        aspect: wgpu::TextureAspect::All,
656                    },
657                    &shadow[(y0 * stride + x0 * bpp) as usize..],
658                    wgpu::TexelCopyBufferLayout {
659                        offset: 0,
660                        bytes_per_row: Some(stride),
661                        rows_per_image: None,
662                    },
663                    wgpu::Extent3d {
664                        width: x1 - x0,
665                        height: y1 - y0,
666                        depth_or_array_layers: 1,
667                    },
668                );
669            }
670        }
671    }
672
673    /// The page's bind group (texture + linear sampler), built lazily.
674    pub fn bind_group(&mut self, layout: &wgpu::BindGroupLayout, page: PageRef) -> wgpu::BindGroup {
675        let device = self.device.clone();
676        let entry = &mut self.pages_mut(page.color)[page.index];
677        if entry.bind.is_none() {
678            let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
679                label: Some("valo.glyph_atlas"),
680                mag_filter: wgpu::FilterMode::Linear,
681                min_filter: wgpu::FilterMode::Linear,
682                ..Default::default()
683            });
684            entry.bind = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
685                label: Some("valo.glyph_atlas"),
686                layout,
687                entries: &[
688                    wgpu::BindGroupEntry {
689                        binding: 0,
690                        resource: wgpu::BindingResource::TextureView(&entry.view),
691                    },
692                    wgpu::BindGroupEntry {
693                        binding: 1,
694                        resource: wgpu::BindingResource::Sampler(&sampler),
695                    },
696                ],
697            }));
698        }
699        entry.bind.clone().expect("just built")
700    }
701}
702
703fn create_page(device: &wgpu::Device, size: u32, color: bool) -> Page {
704    let texture = device.create_texture(&wgpu::TextureDescriptor {
705        label: Some(if color {
706            "valo.glyph_atlas.color"
707        } else {
708            "valo.glyph_atlas.mask"
709        }),
710        size: wgpu::Extent3d {
711            width: size,
712            height: size,
713            depth_or_array_layers: 1,
714        },
715        mip_level_count: 1,
716        sample_count: 1,
717        dimension: wgpu::TextureDimension::D2,
718        format: if color {
719            wgpu::TextureFormat::Rgba8Unorm
720        } else {
721            wgpu::TextureFormat::R8Unorm
722        },
723        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
724        view_formats: &[],
725    });
726    Page {
727        allocator: etagere::AtlasAllocator::new(etagere::size2(size as i32, size as i32)),
728        view: texture.create_view(&Default::default()),
729        texture,
730        bind: None,
731        shadow: None,
732        dirty: None,
733    }
734}
735
736/// Copy the glyph into the page's CPU shadow and grow the dirty region —
737/// the GPU sees it at `flush_uploads`, one call per page per frame.
738fn stage(page: &mut Page, page_size: u32, color: bool, x: u32, y: u32, image: &GlyphImage) {
739    let bpp = if color { 4u32 } else { 1 };
740    let stride = (page_size * bpp) as usize;
741    let shadow = page
742        .shadow
743        .get_or_insert_with(|| vec![0u8; stride * page_size as usize].into_boxed_slice());
744    for row in 0..image.height {
745        let src = (row * image.width * bpp) as usize;
746        let dst = (y + row) as usize * stride + (x * bpp) as usize;
747        shadow[dst..dst + (image.width * bpp) as usize]
748            .copy_from_slice(&image.data[src..src + (image.width * bpp) as usize]);
749    }
750    let (x1, y1) = (x + image.width, y + image.height);
751    page.dirty = Some(match page.dirty {
752        None => [x, y, x1, y1],
753        Some([dx0, dy0, dx1, dy1]) => [dx0.min(x), dy0.min(y), dx1.max(x1), dy1.max(y1)],
754    });
755}
756
757impl GlyphStore {
758    /// Atlas families: [mask/SDF (R8), color (RGBA8)].
759    pub(crate) fn report_atlas(&self) -> [crate::AtlasReport; 2] {
760        let page_px = self.page_size as u64 * self.page_size as u64;
761        let family = |pages: &Vec<Page>, color: bool| crate::AtlasReport {
762            pages: pages.len() as u32,
763            bytes: pages.len() as u64 * page_px * if color { 4 } else { 1 },
764            entries: self
765                .entries
766                .values()
767                .filter(|(slot, _)| slot.is_some_and(|r| r.page.color == color))
768                .count() as u32,
769        };
770        [
771            family(&self.mask_pages, false),
772            family(&self.color_pages, true),
773        ]
774    }
775
776    /// The outline-tier path cache (point bytes; verbs are noise).
777    pub(crate) fn report_paths(&self) -> crate::PoolReport {
778        let bytes: usize = self
779            .paths
780            .values()
781            .filter_map(|e| e.path.as_ref())
782            .map(|p| p.heap_bytes())
783            .sum();
784        crate::PoolReport {
785            count: self.paths.len() as u32,
786            bytes: bytes as u64,
787        }
788    }
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794
795    fn headless() -> Option<(wgpu::Device, wgpu::Queue)> {
796        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
797        let adapter =
798            pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
799                .ok()?;
800        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
801    }
802
803    use valo_text::FontCollection;
804
805    fn fira() -> FontCollection {
806        let path = concat!(
807            env!("CARGO_MANIFEST_DIR"),
808            "/../../assets/fonts/fira_sans.ttf"
809        );
810        let mut c = FontCollection::new();
811        c.register("Fira Sans", std::fs::read(path).unwrap())
812            .unwrap();
813        c
814    }
815
816    /// Tiny pages force the whole policy: fill page 1, open pages 2..4,
817    /// then GC (generation bumps, entries purge, packing continues).
818    #[test]
819    fn pages_grow_then_gc_wholesale() {
820        let Some((device, queue)) = headless() else {
821            eprintln!("SKIP pages_grow_then_gc_wholesale: no GPU adapter");
822            return;
823        };
824        let fonts = fira();
825        let mut store = GlyphStore::with_page_size(&device, &queue, 64);
826        let font = fonts.family("Fira Sans").unwrap();
827
828        let mut packed = 0;
829        for ch in ('A'..='Z').chain('a'..='z').chain('0'..='9') {
830            let Some(glyph) = fonts.get(font).glyph_for(ch) else {
831                continue;
832            };
833            store.ensure(fonts.get(font), glyph, 30.0, Coverage::Fill, 0);
834            if store
835                .entry(fonts.get(font).uid().0, glyph, 30.0, Coverage::Fill, 0)
836                .is_some()
837            {
838                packed += 1;
839            }
840        }
841        assert!(
842            packed >= 50,
843            "everything packs despite tiny pages: {packed}"
844        );
845        // Everything landed in ONE frame, so nothing was idle to evict —
846        // overflow had to take the wholesale last-resort path.
847        assert!(
848            store.generation >= 1,
849            "the page budget forced at least one GC"
850        );
851
852        // Post-GC, an early (purged) glyph re-ensures fine.
853        let a = fonts.get(font).glyph_for('A').unwrap();
854        store.ensure(fonts.get(font), a, 30.0, Coverage::Fill, 0);
855        assert!(store
856            .entry(fonts.get(font).uid().0, a, 30.0, Coverage::Fill, 0)
857            .is_some());
858    }
859
860    /// Across frames, overflow evicts the least-recently-USED
861    /// rects instead of wiping the family — the new set packs, the coldest
862    /// old rects leave, and the wholesale GC never fires.
863    #[test]
864    fn overflow_evicts_coldest_instead_of_wiping() {
865        let Some((device, queue)) = headless() else {
866            eprintln!("SKIP overflow_evicts_coldest_instead_of_wiping: no GPU adapter");
867            return;
868        };
869        let fonts = fira();
870        let mut store = GlyphStore::with_page_size(&device, &queue, 64);
871        let font = fonts.family("Fira Sans").unwrap();
872        let glyph = |ch: char| fonts.get(font).glyph_for(ch).unwrap();
873
874        // Frame 0: fill most of the budget, without overflowing it.
875        let old: Vec<char> = ('a'..='r').collect();
876        for &ch in &old {
877            store.ensure(fonts.get(font), glyph(ch), 30.0, Coverage::Fill, 0);
878        }
879        let generation_before = store.generation;
880        store.end_frame();
881
882        // Frame 1: a same-sized new set overflows — idle frame-0 rects go.
883        let new: Vec<char> = ('A'..='R').collect();
884        for &ch in &new {
885            store.ensure(fonts.get(font), glyph(ch), 30.0, Coverage::Fill, 0);
886        }
887        assert_eq!(
888            store.generation, generation_before,
889            "eviction sufficed; the wholesale GC never fired"
890        );
891        for &ch in &new {
892            assert!(
893                store
894                    .entry(fonts.get(font).uid().0, glyph(ch), 30.0, Coverage::Fill, 0)
895                    .is_some(),
896                "'{ch}' of the hot set is resident"
897            );
898        }
899        let evicted = old
900            .iter()
901            .filter(|&&ch| {
902                store
903                    .entry(fonts.get(font).uid().0, glyph(ch), 30.0, Coverage::Fill, 0)
904                    .is_none()
905            })
906            .count();
907        assert!(evicted > 0, "some cold frame-0 rects were evicted");
908
909        // An evicted glyph re-ensures on demand (and may evict in turn).
910        store.end_frame();
911        store.ensure(fonts.get(font), glyph('a'), 30.0, Coverage::Fill, 0);
912        assert!(store
913            .entry(fonts.get(font).uid().0, glyph('a'), 30.0, Coverage::Fill, 0)
914            .is_some());
915    }
916
917    /// While the host holds text rasters, an SDF miss with a
918    /// resident other-bucket stand-in skips the raster (drawn scaled by the
919    /// planner); a miss with NO stand-in still rasters; releasing the hold
920    /// rasters the wanted bucket on the next sighting.
921    #[test]
922    fn text_raster_hold_reuses_resident_buckets() {
923        let Some((device, queue)) = headless() else {
924            eprintln!("SKIP text_raster_hold_reuses_resident_buckets: no GPU adapter");
925            return;
926        };
927        let fonts = fira();
928        let mut store = GlyphStore::new(&device, &queue);
929        let font = fonts.family("Fira Sans").unwrap();
930        let glyph = fonts.get(font).glyph_for('H').unwrap();
931
932        // Warm bucket 32, then zoom crosses to 72 under a hold.
933        store.ensure(fonts.get(font), glyph, 32.0, Coverage::Sdf, 0);
934        store.end_frame();
935        store.set_text_raster_hold(true);
936
937        store.ensure(fonts.get(font), glyph, 72.0, Coverage::Sdf, 0);
938        assert!(
939            store
940                .entry(fonts.get(font).uid().0, glyph, 72.0, Coverage::Sdf, 0)
941                .is_none(),
942            "held: the wanted bucket was not rasterized"
943        );
944        let (px, ..) = store
945            .resident_stand_in(fonts.get(font).uid().0, glyph, Coverage::Sdf, 72.0)
946            .expect("the warm bucket stands in");
947        assert_eq!(px, 32.0);
948        assert_eq!(store.frame_counters().2, 1, "one held raster counted");
949
950        // First sight of a glyph with no stand-in rasters even while held.
951        let fresh = fonts.get(font).glyph_for('Q').unwrap();
952        store.ensure(fonts.get(font), fresh, 72.0, Coverage::Sdf, 0);
953        assert!(store
954            .entry(fonts.get(font).uid().0, fresh, 72.0, Coverage::Sdf, 0)
955            .is_some());
956
957        // Release: the wanted bucket rasters on the next sighting.
958        store.end_frame();
959        store.set_text_raster_hold(false);
960        store.ensure(fonts.get(font), glyph, 72.0, Coverage::Sdf, 0);
961        assert!(store
962            .entry(fonts.get(font).uid().0, glyph, 72.0, Coverage::Sdf, 0)
963            .is_some());
964    }
965
966    /// The mask tier re-rasters per 1/200 zoom step — under a hold, those
967    /// misses reuse the nearest resident SCALE of the glyph instead
968    /// (continuous px, so the stand-in comes from the size index, not
969    /// fixed buckets).
970    #[test]
971    fn text_raster_hold_covers_bitmap_scales() {
972        let Some((device, queue)) = headless() else {
973            eprintln!("SKIP text_raster_hold_covers_bitmap_scales: no GPU adapter");
974            return;
975        };
976        let fonts = fira();
977        let mut store = GlyphStore::new(&device, &queue);
978        let font = fonts.family("Fira Sans").unwrap();
979        let glyph = fonts.get(font).glyph_for('H').unwrap();
980
981        // Warm one quantized scale, then crawl one step under a hold.
982        store.ensure(fonts.get(font), glyph, 11.83, Coverage::Fill, 0);
983        store.end_frame();
984        store.set_text_raster_hold(true);
985
986        store.ensure(fonts.get(font), glyph, 11.96, Coverage::Fill, 0);
987        assert!(
988            store
989                .entry(fonts.get(font).uid().0, glyph, 11.96, Coverage::Fill, 0)
990                .is_none(),
991            "held: the fresh scale was not rasterized"
992        );
993        let (px, ..) = store
994            .resident_stand_in(fonts.get(font).uid().0, glyph, Coverage::Fill, 11.96)
995            .expect("the previous scale stands in");
996        assert_eq!(px, 11.83);
997        assert_eq!(store.frame_counters().2, 1);
998
999        store.end_frame();
1000        store.set_text_raster_hold(false);
1001        store.ensure(fonts.get(font), glyph, 11.96, Coverage::Fill, 0);
1002        assert!(store
1003            .entry(fonts.get(font).uid().0, glyph, 11.96, Coverage::Fill, 0)
1004            .is_some());
1005    }
1006
1007    /// The B1 invariant: after `ensure_run`, EVERY glyph of the run is
1008    /// resident simultaneously — even when packing the run forced a GC
1009    /// mid-way (which used to leave earlier page references stale).
1010    #[test]
1011    fn ensure_run_survives_a_mid_run_gc() {
1012        let Some((device, queue)) = headless() else {
1013            eprintln!("SKIP ensure_run_survives_a_mid_run_gc: no GPU adapter");
1014            return;
1015        };
1016        let fonts = fira();
1017        let mut store = GlyphStore::with_page_size(&device, &queue, 64);
1018        let font = fonts.family("Fira Sans").unwrap();
1019
1020        // 26 one-per-page glyphs against a 4-page budget: GC pressure is
1021        // guaranteed, wherever exactly the collections land.
1022        let glyph = |ch: char| fonts.get(font).glyph_for(ch).unwrap();
1023        for ch in 'a'..='z' {
1024            store.ensure(fonts.get(font), glyph(ch), 52.0, Coverage::Fill, 0);
1025        }
1026        assert!(store.generation >= 1, "junk fill forced GCs");
1027
1028        // The postcondition batching relies on: after ensure_run, EVERY key
1029        // of a run that fits the atlas is resident simultaneously, on live
1030        // pages — regardless of how many GCs the run itself triggered.
1031        let keys: Vec<(u32, u8)> = ['M', 'N', 'H'].map(|ch| (glyph(ch), 0)).into();
1032        store.ensure_run(fonts.get(font), 26.0, Coverage::Fill, &keys);
1033        let pages = store.pages(false).len();
1034        for &(g, phase) in &keys {
1035            let (page, _) = store
1036                .entry(fonts.get(font).uid().0, g, 26.0, Coverage::Fill, phase)
1037                .expect("resident after ensure_run");
1038            assert!(page.index < pages, "page reference is live");
1039        }
1040    }
1041
1042    /// The stroke is part of the address. Same font, glyph and size, three
1043    /// different coverages — three entries, three distinct rasters. Without
1044    /// the stroke in the key a stroked run would silently draw the filled
1045    /// mask that got there first.
1046    #[test]
1047    fn the_stroke_is_part_of_the_atlas_key() {
1048        let Some((device, queue)) = headless() else {
1049            eprintln!("SKIP the_stroke_is_part_of_the_atlas_key: no GPU adapter");
1050            return;
1051        };
1052        let fonts = fira();
1053        let mut store = GlyphStore::new(&device, &queue);
1054        let font = fonts.family("Fira Sans").unwrap();
1055        let glyph = fonts.get(font).glyph_for('M').unwrap();
1056        let stroke = |width: f32| {
1057            Coverage::Stroke(GlyphStroke {
1058                width,
1059                cap: Cap::Butt,
1060                join: Join::Miter,
1061                miter_limit: 4.0,
1062            })
1063        };
1064
1065        let coverages = [Coverage::Fill, stroke(2.0), stroke(6.0)];
1066        for coverage in coverages {
1067            store.ensure(fonts.get(font), glyph, 48.0, coverage, 0);
1068        }
1069        assert_eq!(store.frame_counters().0, 3, "one raster per coverage");
1070
1071        let cells: Vec<[f32; 4]> = coverages
1072            .iter()
1073            .map(|&coverage| {
1074                let (_, entry) = store
1075                    .entry(fonts.get(font).uid().0, glyph, 48.0, coverage, 0)
1076                    .expect("resident");
1077                [entry.left, entry.top, entry.width, entry.height]
1078            })
1079            .collect();
1080        for (wider, narrower) in [(cells[1], cells[0]), (cells[2], cells[1])] {
1081            assert!(
1082                wider[2] > narrower[2] && wider[3] > narrower[3],
1083                "a wider stroke needs a bigger cell: {wider:?} vs {narrower:?}"
1084            );
1085        }
1086
1087        // Re-ensuring the same coverages hits the cache — no fresh rasters.
1088        store.end_frame();
1089        for coverage in coverages {
1090            store.ensure(fonts.get(font), glyph, 48.0, coverage, 0);
1091        }
1092        assert_eq!(store.frame_counters().0, 0, "all three were cache hits");
1093    }
1094
1095    /// The pathological case: a run larger than the WHOLE atlas degrades to
1096    /// dropped glyphs — but never to a stale page reference.
1097    #[test]
1098    fn oversized_run_degrades_without_stale_pages() {
1099        let Some((device, queue)) = headless() else {
1100            eprintln!("SKIP oversized_run_degrades_without_stale_pages: no GPU adapter");
1101            return;
1102        };
1103        let fonts = fira();
1104        let mut store = GlyphStore::with_page_size(&device, &queue, 64);
1105        let font = fonts.family("Fira Sans").unwrap();
1106
1107        let keys: Vec<(u32, u8)> = ('A'..='Z')
1108            .chain('a'..='z')
1109            .chain('0'..='9')
1110            .filter_map(|ch| fonts.get(font).glyph_for(ch))
1111            .map(|g| (g, 0))
1112            .collect();
1113        store.ensure_run(fonts.get(font), 30.0, Coverage::Fill, &keys);
1114
1115        let pages = store.pages(false).len();
1116        let resident = keys
1117            .iter()
1118            .filter_map(|&(g, phase)| {
1119                store.entry(fonts.get(font).uid().0, g, 30.0, Coverage::Fill, phase)
1120            })
1121            .inspect(|(page, _)| assert!(page.index < pages, "live page"))
1122            .count();
1123        assert!(resident > 0, "the surviving subset still renders");
1124    }
1125
1126    /// The miter limit is a THRESHOLD: a corner needing ratio 15.015 bevels
1127    /// at limit 15.00 and spikes at 15.03. Quantizing the limit to 1/16 px
1128    /// would key both to 15.0 and serve one glyph's image for the other, so
1129    /// the key stores it exactly.
1130    #[test]
1131    fn near_identical_miter_limits_do_not_share_a_cell() {
1132        let stroke_at = |miter_limit: f32| {
1133            CoverageKey::of(Coverage::Stroke(GlyphStroke {
1134                width: 4.0,
1135                cap: Cap::Butt,
1136                join: Join::Miter,
1137                miter_limit,
1138            }))
1139        };
1140        assert_ne!(stroke_at(15.00), stroke_at(15.03));
1141        assert_eq!(stroke_at(15.00), stroke_at(15.00));
1142
1143        // The parameters still survive the round trip they are read back for.
1144        let Coverage::Stroke(recovered) = stroke_at(15.03).coverage() else {
1145            panic!("a stroke key must decode to a stroke");
1146        };
1147        assert_eq!(recovered.miter_limit, 15.03);
1148
1149        // NaN and negatives collapse onto the SVG default rather than
1150        // poisoning Eq — a key that never equals itself would never hit.
1151        assert_eq!(stroke_at(f32::NAN), stroke_at(4.0));
1152        assert_eq!(stroke_at(-1.0), stroke_at(4.0));
1153        assert_eq!(stroke_at(f32::NAN), stroke_at(f32::NAN));
1154
1155        // Width, being a continuous edge offset, still quantizes.
1156        let width_at = |width: f32| {
1157            CoverageKey::of(Coverage::Stroke(GlyphStroke {
1158                width,
1159                cap: Cap::Butt,
1160                join: Join::Miter,
1161                miter_limit: 4.0,
1162            }))
1163        };
1164        assert_eq!(width_at(4.0), width_at(4.001));
1165    }
1166}