1use rustc_hash::FxHashMap;
2use std::sync::Arc;
3
4use valo_geometry::{Cap, Join, Path};
5use valo_text::{Font, GlyphImage, GlyphStroke, Rasterizer};
6
7const MAX_PAGES: usize = 4;
9
10#[derive(Clone, Copy, Debug)]
16pub struct TextTiers {
17 pub sdf_min: f32,
19 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
32pub(crate) const SDF_BUCKETS: [f32; 4] = [32.0, 72.0, 162.0, 256.0];
37const GUTTER: i32 = 1;
39const DEFAULT_PAGE_SIZE: u32 = 2048;
40
41#[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#[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
66const IDLE_FRAMES: u64 = 3;
69
70#[derive(Clone, Copy, Debug, PartialEq)]
77pub enum Coverage {
78 Fill,
79 Sdf,
80 Stroke(GlyphStroke),
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
95enum CoverageKey {
96 Fill,
97 Sdf,
98 Stroke {
99 width_16: u32,
100 miter_bits: u32,
103 cap: u8,
104 join: u8,
105 },
106}
107
108fn sixteenths(value: f32) -> u32 {
111 (value * 16.0).round().clamp(0.0, u32::MAX as f32) as u32
112}
113
114fn 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 font: u64,
177 glyph: u32,
178 px_bits: u32,
180 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 shadow: Option<Box<[u8]>>,
212 dirty: Option<[u32; 4]>,
214}
215
216#[derive(Clone, Copy)]
220struct Resident {
221 page: PageRef,
222 glyph: AtlasGlyph,
223 slot: etagere::AllocId,
224}
225
226const EVICT_BATCH: usize = 64;
229
230pub 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 paths: FxHashMap<(u64, u32, u32), PathEntry>,
248 frame: u64,
250 pub generation: u64,
252 pub tiers: TextTiers,
253 rasters: u32,
256 gcs: u32,
259 hold: bool,
266 held: u32,
269 hide_missing_glyphs: bool,
274 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 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 pub fn set_text_raster_hold(&mut self, held: bool) {
313 self.hold = held;
314 }
315
316 pub fn set_hide_missing_glyphs(&mut self, hide: bool) {
318 self.hide_missing_glyphs = hide;
319 }
320
321 pub fn hides_missing_glyphs(&self) -> bool {
324 self.hide_missing_glyphs
325 }
326
327 pub fn frame_counters(&self) -> (u32, u32, u32) {
329 (self.rasters, self.gcs, self.held)
330 }
331
332 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 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 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 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 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 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 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 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 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 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 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 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 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 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
736fn 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 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 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 #[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 assert!(
848 store.generation >= 1,
849 "the page budget forced at least one GC"
850 );
851
852 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 #[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 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 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 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 #[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 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 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 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 #[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 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 #[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 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 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 #[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 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 #[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 #[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 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 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 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}