ailloli_ui_render_wgpu/text/text_atlas.rs
1//! Multi-page GPU glyph atlas with per-frame pinning and LRU page eviction.
2
3use std::collections::{HashMap, VecDeque};
4
5use super::glyph_upload::write_subtexture_rgba;
6use swash::zeno::Format;
7use swash::{
8 scale::{Render, ScaleContext, Source, StrikeWith},
9 FontRef, GlyphId,
10};
11
12/// Maximum atlas pages: eight 1024² RGBA8 textures (about 32 MiB total).
13///
14/// Each RGBA8 page occupies about 4 MiB, so the absolute texture storage ceiling
15/// is approximately 32 MiB across all DPR buckets represented in the keys.
16///
17/// # Examples
18///
19/// ```
20/// use ailloli_ui_render_wgpu::text::MAX_ATLAS_PAGES;
21/// assert_eq!(MAX_ATLAS_PAGES, 8);
22/// ```
23pub const MAX_ATLAS_PAGES: u8 = 8;
24
25/// Cache key for one rasterized glyph in the text atlas.
26///
27/// # Examples
28///
29/// ```
30/// use ailloli_ui_render_wgpu::text::GlyphKey;
31/// let key = GlyphKey { face_id: 3, font_index: 0, px_size: 16,
32/// glyph_id: 42, scale_100: 200 };
33/// assert_eq!((key.face_id, key.scale_100), (3, 200));
34/// ```
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub struct GlyphKey {
37 /// Stable face identity supplied by the text system.
38 pub face_id: u64,
39 /// Face index inside a font collection.
40 pub font_index: u32,
41 /// Raster size in physical pixels, clamped to `8..=128` when used.
42 pub px_size: u16,
43 /// Font-specific glyph identifier; rasterization narrows it to `u16`.
44 pub glyph_id: u32,
45 /// `round(dpr * 100)` to separate HiDPI cache buckets.
46 pub scale_100: u16,
47}
48
49/// UV layout and metrics for a glyph in the atlas.
50///
51/// # Examples
52///
53/// ```
54/// use ailloli_ui_render_wgpu::text::Glyph;
55/// let glyph = Glyph { uv_min: [0.0, 0.0], uv_max: [0.5, 0.5],
56/// size_px: [8.0, 10.0], offset_px: [1.0, -2.0], advance_px: 0.0 };
57/// assert_eq!(glyph.size_px, [8.0, 10.0]);
58/// ```
59#[derive(Debug, Clone, Copy)]
60pub struct Glyph {
61 /// Inclusive lower UV corner in normalized atlas coordinates.
62 pub uv_min: [f32; 2],
63 /// Exclusive upper UV corner in normalized atlas coordinates.
64 pub uv_max: [f32; 2],
65 /// Raster bitmap width and height in physical pixels.
66 pub size_px: [f32; 2],
67 /// Bitmap offset from the physical glyph pen position.
68 pub offset_px: [f32; 2],
69 /// Reserved advance in physical pixels; currently zero because layout owns advance.
70 pub advance_px: f32,
71}
72
73/// Per-frame atlas cache statistics.
74///
75/// Counters saturate at `u32::MAX`; `pages_active` reflects the current atlas,
76/// including pages allocated in earlier frames.
77///
78/// # Examples
79///
80/// ```
81/// use ailloli_ui_render_wgpu::text::TextAtlasStats;
82/// let stats = TextAtlasStats::default();
83/// assert_eq!((stats.hits, stats.misses, stats.pages_active), (0, 0, 0));
84/// ```
85#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
86pub struct TextAtlasStats {
87 /// Cache hits in the current frame.
88 pub hits: u32,
89 /// Cache misses in the current frame.
90 pub misses: u32,
91 /// Glyph bitmaps uploaded in the current frame.
92 pub rasterized: u32,
93 /// Atlas page resets caused by eviction in the current frame.
94 pub resets: u32,
95 /// Allocations skipped because every candidate page was frame-pinned.
96 pub evictions_blocked: u32,
97 /// Glyphs skipped for missing faces, raster failures, or allocation failure.
98 pub glyphs_skipped: u32,
99 /// Total allocated atlas pages after the latest operation.
100 pub pages_active: u32,
101}
102
103#[derive(Debug)]
104/// Shelf allocator cursor for one atlas page.
105struct Shelf {
106 /// Next free horizontal texel coordinate on the current shelf.
107 x: u32,
108 /// Top texel coordinate of the current shelf.
109 y: u32,
110 /// Maximum glyph height in texels on the current shelf.
111 h: u32,
112}
113
114/// Texture, binding, and allocator state for one atlas page.
115struct AtlasPage {
116 /// RGBA8 glyph texture owned by this page.
117 texture: wgpu::Texture,
118 /// Bind group exposing `texture` and the shared sampler to shaders.
119 bind_group: wgpu::BindGroup,
120 /// Monotonic shelf allocator state for new glyph rectangles.
121 shelf: Shelf,
122}
123
124/// Multi-page GPU glyph atlas with LRU eviction.
125///
126/// The atlas allocates one 1024-square RGBA8 page eagerly and grows to
127/// [`MAX_ATLAS_PAGES`]. Pinned pages cannot be reset during a frame. Unpinned
128/// eviction clears an entire least-recently-used page, not an individual glyph.
129///
130/// # Examples
131///
132/// ```
133/// use ailloli_ui_render_wgpu::text::TextAtlas;
134/// let _: usize = std::mem::size_of::<TextAtlas>();
135/// ```
136pub struct TextAtlas {
137 /// Width and height in physical texels of every square page.
138 tex_size: u32,
139 /// Maximum number of pages, representable by the `u8` glyph page index.
140 max_pages: u8,
141 /// Shared sampler used by every atlas page bind group.
142 sampler: wgpu::Sampler,
143
144 /// Allocated pages in stable `u8` index order.
145 pages: Vec<AtlasPage>,
146
147 /// Cached glyph metadata paired with its page index.
148 glyphs: HashMap<GlyphKey, (u8, Glyph)>,
149 /// Least-recently-used glyph keys, oldest at the front.
150 lru: VecDeque<GlyphKey>,
151 /// Per-page pin flags preventing reset during the active frame.
152 frame_pinned_pages: Vec<bool>,
153 /// Counters accumulated during the active frame.
154 frame_stats: TextAtlasStats,
155
156 /// Swash scale context reused for CPU glyph rasterization.
157 scale_cx: ScaleContext,
158}
159
160/// Scoped atlas access for one frame (pins pages until drop).
161///
162/// Dropping the guard calls `finish_frame`, unpinning every page. The guard's
163/// mutable borrow prevents other atlas access during the frame.
164///
165/// # Examples
166///
167/// ```
168/// use ailloli_ui_render_wgpu::text::TextAtlasFrame;
169/// let _: usize = std::mem::size_of::<TextAtlasFrame<'static>>();
170/// ```
171pub struct TextAtlasFrame<'a> {
172 /// Exclusively borrowed atlas whose pages remain pinned for this guard.
173 atlas: &'a mut TextAtlas,
174}
175
176impl<'a> TextAtlasFrame<'a> {
177 /// Looks up or rasterizes a glyph and pins its page until this guard drops.
178 ///
179 /// Returns `None` for invalid font data, a missing glyph image, an oversized
180 /// allocation, or when all pages are pinned and full.
181 ///
182 /// # Examples
183 ///
184 /// ```no_run
185 /// use ailloli_ui_render_wgpu::text::{GlyphKey, TextAtlasFrame};
186 /// fn lookup(frame: &mut TextAtlasFrame<'_>, device: &wgpu::Device,
187 /// queue: &wgpu::Queue, layout: &wgpu::BindGroupLayout, font: &[u8], key: GlyphKey) {
188 /// let _glyph: Option<(u8, ailloli_ui_render_wgpu::text::Glyph)> =
189 /// frame.get_or_rasterize(device, queue, layout, key, font);
190 /// }
191 /// ```
192 pub fn get_or_rasterize(
193 &mut self,
194 device: &wgpu::Device,
195 queue: &wgpu::Queue,
196 bind_group_layout: &wgpu::BindGroupLayout,
197 key: GlyphKey,
198 font_data: &[u8],
199 ) -> Option<(u8, Glyph)> {
200 self.atlas
201 .get_or_rasterize_pinned(device, queue, bind_group_layout, key, font_data)
202 }
203
204 /// Records one glyph skipped because its face bytes were unavailable.
205 ///
206 /// # Examples
207 ///
208 /// ```no_run
209 /// use ailloli_ui_render_wgpu::text::TextAtlasFrame;
210 /// fn missing(frame: &mut TextAtlasFrame<'_>) { frame.record_missing_face(); }
211 /// ```
212 pub fn record_missing_face(&mut self) {
213 self.atlas.record_missing_face();
214 }
215
216 /// Returns current per-frame counters and allocated page count.
217 ///
218 /// # Examples
219 ///
220 /// ```no_run
221 /// use ailloli_ui_render_wgpu::text::{TextAtlasFrame, TextAtlasStats};
222 /// fn stats(frame: &TextAtlasFrame<'_>) -> TextAtlasStats { frame.stats() }
223 /// ```
224 pub fn stats(&self) -> TextAtlasStats {
225 self.atlas.stats()
226 }
227}
228
229impl Drop for TextAtlasFrame<'_> {
230 fn drop(&mut self) {
231 self.atlas.finish_frame();
232 }
233}
234
235impl TextAtlas {
236 /// Creates an atlas, sampler, and the mandatory first 1024-square page.
237 ///
238 /// The supplied bind-group layout must contain a filterable 2D texture at
239 /// binding 0 and a filtering sampler at binding 1.
240 ///
241 /// # Examples
242 ///
243 /// ```no_run
244 /// use ailloli_ui_render_wgpu::text::TextAtlas;
245 /// fn create(device: &wgpu::Device, queue: &wgpu::Queue,
246 /// layout: &wgpu::BindGroupLayout) -> TextAtlas {
247 /// TextAtlas::new(device, queue, layout)
248 /// }
249 /// ```
250 pub fn new(
251 device: &wgpu::Device,
252 queue: &wgpu::Queue,
253 tex_bind_group_layout: &wgpu::BindGroupLayout,
254 ) -> Self {
255 let tex_size = 1024;
256 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
257 label: Some("text atlas sampler"),
258 address_mode_u: wgpu::AddressMode::ClampToEdge,
259 address_mode_v: wgpu::AddressMode::ClampToEdge,
260 address_mode_w: wgpu::AddressMode::ClampToEdge,
261 mag_filter: wgpu::FilterMode::Linear,
262 min_filter: wgpu::FilterMode::Linear,
263 mipmap_filter: wgpu::FilterMode::Nearest,
264 ..Default::default()
265 });
266 let mut atlas = Self {
267 tex_size,
268 max_pages: MAX_ATLAS_PAGES,
269 sampler,
270 pages: Vec::new(),
271 glyphs: HashMap::new(),
272 lru: VecDeque::new(),
273 frame_pinned_pages: Vec::new(),
274 frame_stats: TextAtlasStats::default(),
275 scale_cx: ScaleContext::new(),
276 };
277 atlas.allocate_page(device, queue, tex_bind_group_layout);
278 atlas
279 }
280
281 /// Page 0 bind group (always allocated; legacy renderer path).
282 ///
283 /// # Examples
284 ///
285 /// ```no_run
286 /// use ailloli_ui_render_wgpu::text::TextAtlas;
287 /// fn binding(atlas: &TextAtlas) -> &wgpu::BindGroup { atlas.bind_group() }
288 /// ```
289 pub fn bind_group(&self) -> &wgpu::BindGroup {
290 &self.pages[0].bind_group
291 }
292
293 /// Bind group for the given atlas page index.
294 ///
295 /// # Panics
296 ///
297 /// Panics if `page_idx >= page_count()`.
298 ///
299 /// # Examples
300 ///
301 /// ```no_run
302 /// use ailloli_ui_render_wgpu::text::TextAtlas;
303 /// fn first(atlas: &TextAtlas) -> &wgpu::BindGroup { atlas.page_bind_group(0) }
304 /// ```
305 pub fn page_bind_group(&self, page_idx: u8) -> &wgpu::BindGroup {
306 &self.pages[page_idx as usize].bind_group
307 }
308
309 /// Returns the number of allocated pages, always in `1..=MAX_ATLAS_PAGES`.
310 ///
311 /// # Examples
312 ///
313 /// ```no_run
314 /// use ailloli_ui_render_wgpu::text::TextAtlas;
315 /// fn pages(atlas: &TextAtlas) -> usize { atlas.page_count() }
316 /// ```
317 pub fn page_count(&self) -> usize {
318 self.pages.len()
319 }
320
321 /// Starts a frame and returns a guard that unpins pages on drop.
322 ///
323 /// Calling this discards the previous frame's counters and pins, so guards
324 /// must not conceptually overlap.
325 ///
326 /// # Examples
327 ///
328 /// ```no_run
329 /// use ailloli_ui_render_wgpu::text::TextAtlas;
330 /// fn frame(atlas: &mut TextAtlas) { let _guard = atlas.begin_frame(); }
331 /// ```
332 pub fn begin_frame(&mut self) -> TextAtlasFrame<'_> {
333 self.start_frame();
334 TextAtlasFrame { atlas: self }
335 }
336
337 /// Resets per-frame counters and clears all page pins.
338 ///
339 /// Prefer [`Self::begin_frame`] when scoped access is practical.
340 ///
341 /// # Examples
342 ///
343 /// ```no_run
344 /// use ailloli_ui_render_wgpu::text::TextAtlas;
345 /// fn start(atlas: &mut TextAtlas) { atlas.start_frame(); }
346 /// ```
347 pub fn start_frame(&mut self) {
348 self.frame_pinned_pages.clear();
349 self.frame_pinned_pages.resize(self.pages.len(), false);
350 self.frame_stats = TextAtlasStats {
351 pages_active: self.pages.len() as u32,
352 ..TextAtlasStats::default()
353 };
354 }
355
356 /// Clears page pins without changing counters, glyphs, or allocations.
357 ///
358 /// # Examples
359 ///
360 /// ```no_run
361 /// use ailloli_ui_render_wgpu::text::TextAtlas;
362 /// fn finish(atlas: &mut TextAtlas) { atlas.finish_frame(); }
363 /// ```
364 pub fn finish_frame(&mut self) {
365 self.frame_pinned_pages.fill(false);
366 }
367
368 /// Returns current frame counters with a live page-count snapshot.
369 ///
370 /// # Examples
371 ///
372 /// ```no_run
373 /// use ailloli_ui_render_wgpu::text::{TextAtlas, TextAtlasStats};
374 /// fn stats(atlas: &TextAtlas) -> TextAtlasStats { atlas.stats() }
375 /// ```
376 pub fn stats(&self) -> TextAtlasStats {
377 TextAtlasStats {
378 pages_active: self.pages.len() as u32,
379 ..self.frame_stats
380 }
381 }
382
383 /// Allocates and zero-fills one atlas page, then creates its sample binding.
384 fn allocate_page(
385 &mut self,
386 device: &wgpu::Device,
387 queue: &wgpu::Queue,
388 bind_group_layout: &wgpu::BindGroupLayout,
389 ) {
390 let format = wgpu::TextureFormat::Rgba8Unorm;
391 let texture = device.create_texture(&wgpu::TextureDescriptor {
392 label: Some("text atlas page"),
393 size: wgpu::Extent3d {
394 width: self.tex_size,
395 height: self.tex_size,
396 depth_or_array_layers: 1,
397 },
398 mip_level_count: 1,
399 sample_count: 1,
400 dimension: wgpu::TextureDimension::D2,
401 format,
402 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
403 view_formats: &[],
404 });
405 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
406 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
407 label: Some("text atlas page bind group"),
408 layout: bind_group_layout,
409 entries: &[
410 wgpu::BindGroupEntry {
411 binding: 0,
412 resource: wgpu::BindingResource::TextureView(&view),
413 },
414 wgpu::BindGroupEntry {
415 binding: 1,
416 resource: wgpu::BindingResource::Sampler(&self.sampler),
417 },
418 ],
419 });
420
421 let zero = vec![0u8; (self.tex_size * self.tex_size * 4) as usize];
422 write_subtexture_rgba(
423 queue,
424 &texture,
425 wgpu::Origin3d::ZERO,
426 self.tex_size,
427 self.tex_size,
428 &zero,
429 );
430
431 self.pages.push(AtlasPage {
432 texture,
433 bind_group,
434 shelf: Shelf { x: 0, y: 0, h: 0 },
435 });
436 }
437
438 /// Clears a page and removes every glyph and LRU entry resident on it.
439 fn reset_page(&mut self, page_idx: u8, queue: &wgpu::Queue) {
440 self.frame_stats.resets = self.frame_stats.resets.saturating_add(1);
441 let zero = vec![0u8; (self.tex_size * self.tex_size * 4) as usize];
442 write_subtexture_rgba(
443 queue,
444 &self.pages[page_idx as usize].texture,
445 wgpu::Origin3d::ZERO,
446 self.tex_size,
447 self.tex_size,
448 &zero,
449 );
450 self.pages[page_idx as usize].shelf = Shelf { x: 0, y: 0, h: 0 };
451
452 let evicted: Vec<GlyphKey> = self
453 .glyphs
454 .iter()
455 .filter_map(|(k, (p, _))| if *p == page_idx { Some(*k) } else { None })
456 .collect();
457 for k in evicted {
458 self.glyphs.remove(&k);
459 self.lru.retain(|key| key != &k);
460 }
461 }
462
463 /// Looks up or rasterizes a glyph without protecting its page from eviction.
464 ///
465 /// Returns `(page_index, metrics)` or `None` on raster/allocation failure.
466 /// Use [`Self::get_or_rasterize_pinned`] during frame preparation.
467 ///
468 /// # Examples
469 ///
470 /// ```no_run
471 /// use ailloli_ui_render_wgpu::text::{GlyphKey, TextAtlas};
472 /// fn lookup(atlas: &mut TextAtlas, device: &wgpu::Device, queue: &wgpu::Queue,
473 /// layout: &wgpu::BindGroupLayout, key: GlyphKey, font: &[u8]) {
474 /// let _ = atlas.get_or_rasterize(device, queue, layout, key, font);
475 /// }
476 /// ```
477 pub fn get_or_rasterize(
478 &mut self,
479 device: &wgpu::Device,
480 queue: &wgpu::Queue,
481 bind_group_layout: &wgpu::BindGroupLayout,
482 key: GlyphKey,
483 font_data: &[u8],
484 ) -> Option<(u8, Glyph)> {
485 self.get_or_rasterize_impl(device, queue, bind_group_layout, key, font_data, false)
486 }
487
488 /// Looks up or rasterizes a glyph and pins its page for the current frame.
489 ///
490 /// If every page is pinned and full, returns `None` and increments both
491 /// `evictions_blocked` and `glyphs_skipped`.
492 ///
493 /// # Examples
494 ///
495 /// ```no_run
496 /// use ailloli_ui_render_wgpu::text::{GlyphKey, TextAtlas};
497 /// fn lookup(atlas: &mut TextAtlas, device: &wgpu::Device, queue: &wgpu::Queue,
498 /// layout: &wgpu::BindGroupLayout, key: GlyphKey, font: &[u8]) {
499 /// let _ = atlas.get_or_rasterize_pinned(device, queue, layout, key, font);
500 /// }
501 /// ```
502 pub fn get_or_rasterize_pinned(
503 &mut self,
504 device: &wgpu::Device,
505 queue: &wgpu::Queue,
506 bind_group_layout: &wgpu::BindGroupLayout,
507 key: GlyphKey,
508 font_data: &[u8],
509 ) -> Option<(u8, Glyph)> {
510 self.get_or_rasterize_impl(device, queue, bind_group_layout, key, font_data, true)
511 }
512
513 /// Saturating-increments the missing-face/skip counter.
514 ///
515 /// # Examples
516 ///
517 /// ```no_run
518 /// use ailloli_ui_render_wgpu::text::TextAtlas;
519 /// fn missing(atlas: &mut TextAtlas) { atlas.record_missing_face(); }
520 /// ```
521 pub fn record_missing_face(&mut self) {
522 self.frame_stats.glyphs_skipped = self.frame_stats.glyphs_skipped.saturating_add(1);
523 }
524
525 /// Shared lookup, raster, page allocation, upload, and optional pin path.
526 fn get_or_rasterize_impl(
527 &mut self,
528 device: &wgpu::Device,
529 queue: &wgpu::Queue,
530 bind_group_layout: &wgpu::BindGroupLayout,
531 key: GlyphKey,
532 font_data: &[u8],
533 pin_for_frame: bool,
534 ) -> Option<(u8, Glyph)> {
535 if let Some((page, g)) = self.glyphs.get(&key).copied() {
536 self.touch_lru(key);
537 self.frame_stats.hits = self.frame_stats.hits.saturating_add(1);
538 if pin_for_frame {
539 self.pin_page(page);
540 }
541 return Some((page, g));
542 }
543
544 self.frame_stats.misses = self.frame_stats.misses.saturating_add(1);
545 let px = key.px_size.clamp(8, 128) as f32;
546 let Some((bmp, w, h, offset_x, offset_y)) =
547 self.rasterize(font_data, key.font_index, key.glyph_id, px)
548 else {
549 self.frame_stats.glyphs_skipped = self.frame_stats.glyphs_skipped.saturating_add(1);
550 return None;
551 };
552 if w == 0 || h == 0 || bmp.is_empty() {
553 let g = Glyph {
554 uv_min: [0.0, 0.0],
555 uv_max: [0.0, 0.0],
556 size_px: [0.0, 0.0],
557 offset_px: [0.0, 0.0],
558 advance_px: 0.0,
559 };
560 self.glyphs.insert(key, (0, g));
561 self.touch_lru(key);
562 if pin_for_frame {
563 self.pin_page(0);
564 }
565 return Some((0, g));
566 }
567 let w = w.max(1);
568 let h = h.max(1);
569
570 let pad = 1u32;
571 let alloc_w = w + pad * 2;
572 let alloc_h = h + pad * 2;
573
574 let Some((page_idx, x, y)) = self.alloc_or_grow(
575 device,
576 queue,
577 bind_group_layout,
578 alloc_w,
579 alloc_h,
580 pin_for_frame,
581 ) else {
582 self.frame_stats.glyphs_skipped = self.frame_stats.glyphs_skipped.saturating_add(1);
583 return None;
584 };
585
586 let mut rgba = vec![0u8; (alloc_w * alloc_h * 4) as usize];
587 for yy in 0..h {
588 for xx in 0..w {
589 let src_i = (yy * w + xx) as usize;
590 let a = bmp.get(src_i).copied().unwrap_or(0);
591 let dst_x = xx + pad;
592 let dst_y = yy + pad;
593 let dst_i = ((dst_y * alloc_w + dst_x) * 4) as usize;
594 rgba[dst_i] = 255;
595 rgba[dst_i + 1] = 255;
596 rgba[dst_i + 2] = 255;
597 rgba[dst_i + 3] = a;
598 }
599 }
600
601 write_subtexture_rgba(
602 queue,
603 &self.pages[page_idx as usize].texture,
604 wgpu::Origin3d { x, y, z: 0 },
605 alloc_w,
606 alloc_h,
607 &rgba,
608 );
609
610 let uv_min = [
611 (x + pad) as f32 / self.tex_size as f32,
612 (y + pad) as f32 / self.tex_size as f32,
613 ];
614 let uv_max = [
615 (x + pad + w) as f32 / self.tex_size as f32,
616 (y + pad + h) as f32 / self.tex_size as f32,
617 ];
618
619 let g = Glyph {
620 uv_min,
621 uv_max,
622 size_px: [w as f32, h as f32],
623 offset_px: [offset_x, offset_y],
624 advance_px: 0.0,
625 };
626 self.glyphs.insert(key, (page_idx, g));
627 self.touch_lru(key);
628 self.frame_stats.rasterized = self.frame_stats.rasterized.saturating_add(1);
629 if pin_for_frame {
630 self.pin_page(page_idx);
631 }
632 Some((page_idx, g))
633 }
634
635 /// Marks one page unavailable to eviction for the current frame.
636 fn pin_page(&mut self, page_idx: u8) {
637 let idx = page_idx as usize;
638 if idx >= self.frame_pinned_pages.len() {
639 self.frame_pinned_pages.resize(idx + 1, false);
640 }
641 self.frame_pinned_pages[idx] = true;
642 }
643
644 /// Moves a glyph key to the most-recently-used end of the queue.
645 fn touch_lru(&mut self, key: GlyphKey) {
646 self.lru.retain(|k| k != &key);
647 self.lru.push_back(key);
648 }
649
650 /// Rasterizes one glyph through Swash, returning mask and screen-space placement.
651 fn rasterize(
652 &mut self,
653 font_data: &[u8],
654 font_index: u32,
655 glyph_id: u32,
656 px: f32,
657 ) -> Option<(Vec<u8>, u32, u32, f32, f32)> {
658 let font = FontRef::from_index(font_data, font_index as usize)?;
659 let mut scaler = self.scale_cx.builder(font).size(px).hint(true).build();
660
661 let gid: GlyphId = glyph_id as u16;
662 let image = Render::new(&[
663 Source::ColorOutline(0),
664 Source::ColorBitmap(StrikeWith::BestFit),
665 Source::Outline,
666 ])
667 .format(Format::Alpha)
668 .render(&mut scaler, gid)?;
669
670 let w = image.placement.width as u32;
671 let h = image.placement.height as u32;
672 let offset_x = image.placement.left as f32;
673 // Swash `Placement::top` uses font coordinates; quads use screen pixels (Y down).
674 // Same convention as cosmic-text (`y = -placement.top` when blitting the mask).
675 let offset_y = -(image.placement.top as f32);
676 Some((image.data, w, h, offset_x, offset_y))
677 }
678
679 /// Allocates a shelf region, grows a page, or resets the oldest eligible page.
680 fn alloc_or_grow(
681 &mut self,
682 device: &wgpu::Device,
683 queue: &wgpu::Queue,
684 bind_group_layout: &wgpu::BindGroupLayout,
685 w: u32,
686 h: u32,
687 respect_frame_pins: bool,
688 ) -> Option<(u8, u32, u32)> {
689 if w > self.tex_size || h > self.tex_size {
690 return None;
691 }
692 if let Some((x, y)) = self.try_alloc_in(self.pages.len() - 1, w, h) {
693 return Some(((self.pages.len() - 1) as u8, x, y));
694 }
695 if (self.pages.len() as u8) < self.max_pages {
696 self.allocate_page(device, queue, bind_group_layout);
697 if let Some((x, y)) = self.try_alloc_in(self.pages.len() - 1, w, h) {
698 return Some(((self.pages.len() - 1) as u8, x, y));
699 }
700 }
701 let oldest_page = if respect_frame_pins {
702 match self.oldest_unpinned_page_idx() {
703 Some(page) => page,
704 None => {
705 self.frame_stats.evictions_blocked =
706 self.frame_stats.evictions_blocked.saturating_add(1);
707 return None;
708 }
709 }
710 } else {
711 self.oldest_page_idx().unwrap_or(0)
712 };
713 self.reset_page(oldest_page, queue);
714 let (x, y) = self.try_alloc_in(oldest_page as usize, w, h)?;
715 Some((oldest_page, x, y))
716 }
717
718 /// Returns the page containing the globally oldest cached glyph.
719 fn oldest_page_idx(&self) -> Option<u8> {
720 let key = self.lru.front()?;
721 let (page, _) = self.glyphs.get(key)?;
722 Some(*page)
723 }
724
725 /// Returns the oldest page not pinned by the current frame.
726 fn oldest_unpinned_page_idx(&self) -> Option<u8> {
727 oldest_unpinned_page_idx_from(&self.lru, &self.glyphs, &self.frame_pinned_pages)
728 }
729
730 /// Allocates one rectangle in a page's row-shelf cursor.
731 fn try_alloc_in(&mut self, page_idx: usize, w: u32, h: u32) -> Option<(u32, u32)> {
732 let page = self.pages.get_mut(page_idx)?;
733 if page.shelf.h == 0 {
734 page.shelf.h = h;
735 }
736 if h > page.shelf.h {
737 page.shelf.x = 0;
738 page.shelf.y += page.shelf.h;
739 page.shelf.h = h;
740 }
741 if page.shelf.x + w > self.tex_size {
742 page.shelf.x = 0;
743 page.shelf.y += page.shelf.h;
744 page.shelf.h = h;
745 }
746 if page.shelf.y + page.shelf.h > self.tex_size {
747 return None;
748 }
749 let x = page.shelf.x;
750 let y = page.shelf.y;
751 page.shelf.x += w;
752 Some((x, y))
753 }
754}
755
756/// Finds the first LRU glyph whose page is not marked pinned.
757fn oldest_unpinned_page_idx_from(
758 lru: &VecDeque<GlyphKey>,
759 glyphs: &HashMap<GlyphKey, (u8, Glyph)>,
760 pinned_pages: &[bool],
761) -> Option<u8> {
762 for key in lru {
763 let Some((page, _)) = glyphs.get(key) else {
764 continue;
765 };
766 if !pinned_pages.get(*page as usize).copied().unwrap_or(false) {
767 return Some(*page);
768 }
769 }
770 None
771}
772
773#[cfg(test)]
774/// Verifies LRU victim selection while current-frame atlas pages are pinned.
775mod tests {
776 use super::*;
777
778 /// Creates a stable key that varies only by glyph identifier.
779 fn key(glyph_id: u32) -> GlyphKey {
780 GlyphKey {
781 face_id: 1,
782 font_index: 0,
783 px_size: 14,
784 glyph_id,
785 scale_100: 100,
786 }
787 }
788
789 /// Creates a minimal one-pixel glyph entry for page-victim scenarios.
790 fn glyph() -> Glyph {
791 Glyph {
792 uv_min: [0.0, 0.0],
793 uv_max: [1.0, 1.0],
794 size_px: [1.0, 1.0],
795 offset_px: [0.0, 0.0],
796 advance_px: 0.0,
797 }
798 }
799
800 #[test]
801 fn oldest_unpinned_page_skips_pages_used_by_current_frame() {
802 let mut lru = VecDeque::new();
803 let a = key(1);
804 let b = key(2);
805 lru.push_back(a);
806 lru.push_back(b);
807
808 let mut glyphs = HashMap::new();
809 glyphs.insert(a, (0, glyph()));
810 glyphs.insert(b, (1, glyph()));
811
812 assert_eq!(
813 oldest_unpinned_page_idx_from(&lru, &glyphs, &[true, false]),
814 Some(1)
815 );
816 }
817
818 #[test]
819 fn oldest_unpinned_page_returns_none_when_all_pages_are_pinned() {
820 let mut lru = VecDeque::new();
821 let a = key(1);
822 let b = key(2);
823 lru.push_back(a);
824 lru.push_back(b);
825
826 let mut glyphs = HashMap::new();
827 glyphs.insert(a, (0, glyph()));
828 glyphs.insert(b, (1, glyph()));
829
830 assert_eq!(
831 oldest_unpinned_page_idx_from(&lru, &glyphs, &[true, true]),
832 None
833 );
834 }
835}