Skip to main content

retroglyph_window/
atlas.rs

1//! Grid-packed glyph atlas layout shared by the GPU backends.
2//!
3//! A GPU backend uploads every glyph of a [`FontChain`] once and then addresses one by a flat
4//! *slot* index that goes straight into an instance buffer. This module owns both halves of that:
5//! [`AtlasData`] is the CPU-side coverage buffer to upload, and [`GlyphAtlas`] is the
6//! `char` -> slot map to look up.
7//!
8//! # Why a grid, not one glyph per layer
9//!
10//! The obvious packing (one glyph per array-texture layer) caps a chain at the array-layer limit,
11//! which is 256 on the OpenGL 3.3 / GL ES 3.0 floor and on wgpu's downlevel defaults. Packing a
12//! fixed [`ATLAS_COLS`]x[`ATLAS_ROWS`] grid of glyphs into each layer instead means `N` glyphs need
13//! only `ceil(N / 256)` layers, which lifts the cap to [`MAX_SLOTS`] glyphs while still fitting
14//! inside that 256-layer minimum. A shader turns a slot back into its `(layer, column, row)`
15//! sub-rect.
16//!
17//! # Coverage, not colour
18//!
19//! [`AtlasData::coverage`] is one byte per texel: `0xFF` where the glyph's bit is set and `0` where
20//! it isn't, meant for a single-channel (`R8`) texture. A backend samples it with nearest filtering
21//! and blends the cell's foreground over its background by that coverage, so glyphs stay crisp at
22//! any integer scale and take the cell's colours like any other glyph.
23//!
24//! Those two values are the only ones that ever appear, and that is load-bearing rather than
25//! incidental. Coverage used as an alpha is the usual place text rendering goes wrong on colour
26//! space: a rasterizer's partial coverage is a linear quantity, so interpolating between an
27//! sRGB-encoded foreground and background by it produces text that is too thin or too fat, and
28//! correcting for that is fiddly. The question does not arise for a bitmap font, because a
29//! blend factor of exactly 0 or exactly 1 selects one endpoint outright and every colour space
30//! agrees on the result.
31//!
32//! Those two values being the only ones is an **invariant of this module**, not an accident of the
33//! current font sources, and every backend is entitled to rely on it. `coverage_is_strictly_binary`
34//! enforces it.
35//!
36//! Anything that introduces partial coverage (an antialiased or grayscale-AA font source,
37//! multisampling, a non-integer render scale) reopens the colour-space question for all three
38//! backends simultaneously, and has to be a deliberate decision rather than a side effect. See
39//! `docs/references/core/color-space.md`.
40//!
41//! # Examples
42//!
43//! ```
44//! # #[cfg(feature = "default-font")] {
45//! use retroglyph_window::atlas::GlyphAtlas;
46//! use retroglyph_window::font::{FontChain, unscii16};
47//!
48//! let atlas = GlyphAtlas::new(FontChain::from(unscii16::FONT), (8, 16));
49//! // Unscii 16 is 256 CP437 glyphs, so it occupies exactly one 16x16 layer.
50//! assert_eq!(atlas.slot_count(), 256);
51//! assert_eq!(atlas.data().geometry.layers, 1);
52//! // A character resolves to the slot its coverage was written to.
53//! assert_eq!(atlas.resolve('A'), Some(u16::from(b'A')));
54//! # }
55//! ```
56
57use crate::font::{BitmapFont, FontChain};
58
59/// Glyph columns packed into one array layer.
60pub const ATLAS_COLS: u32 = 16;
61
62/// Glyph rows packed into one array layer.
63pub const ATLAS_ROWS: u32 = 16;
64
65/// Glyph slots per array layer (`ATLAS_COLS * ATLAS_ROWS`).
66pub const SLOTS_PER_LAYER: u32 = ATLAS_COLS * ATLAS_ROWS;
67
68/// The number of slots the atlas can address, set by the `u16` slot id an instance buffer carries.
69///
70/// A [`FontChain`] with more glyphs than this cannot be packed; a backend's builder is expected to
71/// reject one, since [`GlyphAtlas::resolve`] has no slot to name them with.
72pub const MAX_SLOTS: u32 = u16::MAX as u32 + 1;
73
74/// The number of atlas slots `font` occupies: its glyph count, capped at the 256 a `u8` glyph
75/// index can address (see [`BitmapFont::rows`]).
76///
77/// A font that declares more glyphs than that has no way to name them, so the atlas doesn't
78/// reserve slots for them either.
79#[must_use]
80pub fn addressable_glyphs(font: &BitmapFont) -> u32 {
81    u32::from(font.glyph_count()).min(256)
82}
83
84/// The packing of glyph cells into an array texture: a fixed [`ATLAS_COLS`]x[`ATLAS_ROWS`] grid of
85/// `cell_w`x`cell_h` glyph cells per layer, across `layers` layers.
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87#[non_exhaustive]
88pub struct AtlasGeometry {
89    /// Glyph cell width in texels (one unscaled font pixel per texel).
90    pub cell_w: u32,
91    /// Glyph cell height in texels.
92    pub cell_h: u32,
93    /// Number of array layers.
94    pub layers: u32,
95}
96
97impl AtlasGeometry {
98    /// Geometry with enough layers to hold `capacity` glyph slots (at least one layer).
99    #[must_use]
100    pub const fn new(cell_w: u32, cell_h: u32, capacity: u32) -> Self {
101        let layers = capacity.div_ceil(SLOTS_PER_LAYER);
102        Self {
103            cell_w,
104            cell_h,
105            layers: if layers == 0 { 1 } else { layers },
106        }
107    }
108
109    /// One layer's texture width in texels.
110    #[must_use]
111    pub const fn tex_w(&self) -> u32 {
112        self.cell_w * ATLAS_COLS
113    }
114
115    /// One layer's texture height in texels.
116    #[must_use]
117    pub const fn tex_h(&self) -> u32 {
118        self.cell_h * ATLAS_ROWS
119    }
120
121    /// Maps a flat slot index to its `(layer, glyph_column, glyph_row)`.
122    #[must_use]
123    pub const fn locate(slot: u32) -> (u32, u32, u32) {
124        let layer = slot / SLOTS_PER_LAYER;
125        let within = slot % SLOTS_PER_LAYER;
126        (layer, within % ATLAS_COLS, within / ATLAS_COLS)
127    }
128}
129
130/// Per-font slot bases and their total, the shared answer to "where does font `i`'s glyphs start,
131/// and how many slots does the whole chain need": font `i` starts at the sum of the addressable
132/// glyph counts of every font before it in chain order.
133struct SlotLayout {
134    /// Flat atlas slot at which each font in the chain's glyphs start, indexed by the font's
135    /// position in the chain.
136    bases: Vec<u32>,
137    /// Total slots the chain occupies (`bases.last() + that font's own glyph count`, or 0 for an
138    /// empty chain).
139    total: u32,
140}
141
142/// Computes [`SlotLayout`] for `fonts`, one pass over the chain.
143fn slot_layout(fonts: &FontChain<'_>) -> SlotLayout {
144    let mut bases = Vec::with_capacity(fonts.font_count());
145    let mut total = 0;
146    for font in fonts.fonts() {
147        bases.push(total);
148        total += addressable_glyphs(font);
149    }
150    SlotLayout { bases, total }
151}
152
153/// The CPU-side coverage buffer for a whole atlas, grid-packed per [`AtlasGeometry`].
154#[derive(Clone, Debug)]
155#[non_exhaustive]
156pub struct AtlasData {
157    /// The glyph packing.
158    pub geometry: AtlasGeometry,
159    /// Row-major coverage bytes, length `tex_w * tex_h * layers`. Texel `(x, y)` of layer `l` is at
160    /// `((l * tex_h + y) * tex_w + x)`. Row 0 is the glyph's top row, so a shader that flips y when
161    /// projecting to clip space samples a glyph's top at `uv.y == 0`.
162    pub coverage: Vec<u8>,
163}
164
165impl AtlasData {
166    /// Builds a fully-populated, grid-packed atlas for every glyph of every font in `fonts`, one
167    /// slot per glyph, so a static bitmap font needs no runtime rasterization.
168    ///
169    /// The fonts are laid out back to back in chain order, so a font's slots start at the sum of
170    /// the glyph counts before it, which is the same base [`GlyphAtlas::resolve`] adds to a
171    /// resolved glyph's own index. Every font in the chain is assumed to share `cell_size`; a
172    /// backend's builder checks that via [`FontChain::glyph_size`] before getting here.
173    #[must_use]
174    #[allow(clippy::cast_possible_truncation)]
175    pub fn build(fonts: &FontChain<'static>, cell_size: (u32, u32)) -> Self {
176        let (cell_w, cell_h) = cell_size;
177        let SlotLayout { bases, total } = slot_layout(fonts);
178        let geometry = AtlasGeometry::new(cell_w, cell_h, total);
179
180        let tex_w = geometry.tex_w();
181        let tex_h = geometry.tex_h();
182        let mut coverage = vec![0u8; (tex_w * tex_h * geometry.layers) as usize];
183
184        for (font, base) in fonts.fonts().zip(bases) {
185            for index in 0..addressable_glyphs(font) {
186                let (layer, gcol, grow) = AtlasGeometry::locate(base + index);
187                let (ox, oy) = (gcol * cell_w, grow * cell_h);
188                // `glyph_pixels` yields each set pixel `(x, y)` decoded MSB-first (the bit order
189                // lives in the font module, #164), so this stays width-agnostic.
190                for (x, y) in font.glyph_pixels(index as u8) {
191                    let px = ox + u32::from(x);
192                    let py = oy + u32::from(y);
193                    let idx = ((layer * tex_h + py) * tex_w + px) as usize;
194                    coverage[idx] = 0xFF;
195                }
196            }
197        }
198
199        Self { geometry, coverage }
200    }
201}
202
203/// A static [`FontChain`] plus the `char` -> slot map for its grid-packed atlas.
204///
205/// Every glyph of every font in the chain is uploaded once; a character maps to a flat slot, which
206/// is the font's base offset in the atlas plus that font's own glyph index. A renderer never sees
207/// characters past this point: [`resolve`](Self::resolve) hands back a `u16` that goes straight
208/// into an instance buffer.
209#[derive(Clone, Debug)]
210pub struct GlyphAtlas {
211    fonts: FontChain<'static>,
212    /// Flat atlas slot at which each font in the chain's glyphs start, indexed by the font's
213    /// position in the chain ([`ResolvedGlyph::font_index`](crate::font::ResolvedGlyph::font_index)).
214    bases: Vec<u32>,
215    total: u32,
216    cell_w: u32,
217    cell_h: u32,
218    space_slot: u16,
219}
220
221impl GlyphAtlas {
222    /// An atlas over a static font chain, whose slots are assigned back to back in chain order.
223    ///
224    /// `glyph_size` is the cell size every font in `fonts` agrees on. Pass what
225    /// [`FontChain::glyph_size`] returned, and reject a chain it returned `None` for: a grid has
226    /// one cell size, so a chain whose fonts disagree has no atlas geometry to build.
227    #[must_use]
228    pub fn new(fonts: FontChain<'static>, glyph_size: (u8, u8)) -> Self {
229        let SlotLayout { bases, total } = slot_layout(&fonts);
230        let space_slot = fonts
231            .resolve(' ')
232            .map_or(0, |glyph| Self::slot_of(&bases, &glyph));
233        Self {
234            fonts,
235            bases,
236            total,
237            cell_w: u32::from(glyph_size.0),
238            cell_h: u32::from(glyph_size.1),
239            space_slot,
240        }
241    }
242
243    /// A resolved glyph's flat atlas slot: its font's base plus its own glyph index.
244    ///
245    /// # Panics
246    ///
247    /// Does not panic. The `u16` cast cannot truncate for a chain whose
248    /// [`slot_count`](Self::slot_count) is within [`MAX_SLOTS`], which a backend's builder is
249    /// expected to have checked.
250    #[allow(clippy::cast_possible_truncation)]
251    fn slot_of(bases: &[u32], glyph: &crate::font::ResolvedGlyph) -> u16 {
252        (bases[glyph.font_index()] + u32::from(glyph.index())) as u16
253    }
254
255    /// Glyph cell size in unscaled pixels.
256    #[must_use]
257    pub const fn cell_size(&self) -> (u32, u32) {
258        (self.cell_w, self.cell_h)
259    }
260
261    /// The atlas slot of the space glyph, or `0` for a chain with no space glyph at all.
262    ///
263    /// Only ever used for cells that draw no glyph (a backend still needs *some* slot in the
264    /// instance it writes), so a chain without a space still renders correctly.
265    #[must_use]
266    pub const fn space_slot(&self) -> u16 {
267        self.space_slot
268    }
269
270    /// The total number of glyph slots the chain occupies in the atlas.
271    ///
272    /// Compare against [`MAX_SLOTS`] before building: a chain past that has glyphs
273    /// [`resolve`](Self::resolve) cannot name.
274    #[must_use]
275    pub const fn slot_count(&self) -> u32 {
276        self.total
277    }
278
279    /// The backing font chain.
280    #[must_use]
281    pub const fn fonts(&self) -> &FontChain<'static> {
282        &self.fonts
283    }
284
285    /// The full coverage buffer to upload, built fresh each call.
286    ///
287    /// Not cached: a backend uploads it once at resource creation and, on some platforms, again
288    /// after a lost device, which is rare enough that holding a second copy of the atlas for the
289    /// renderer's whole life costs more than rebuilding it.
290    #[must_use]
291    pub fn data(&self) -> AtlasData {
292        AtlasData::build(&self.fonts, self.cell_size())
293    }
294
295    /// Resolves `ch` to its atlas slot, or `None` when no font in the chain can draw it, not even
296    /// as the substituted solid block, in which case the caller draws no glyph for that cell.
297    #[must_use]
298    pub fn resolve(&self, ch: char) -> Option<u16> {
299        let glyph = self.fonts.resolve(ch)?;
300        Some(Self::slot_of(&self.bases, &glyph))
301    }
302
303    /// Whether [`resolve`](Self::resolve) fell back to the substituted "not defined" glyph for
304    /// `ch`, rather than a font actually covering it.
305    ///
306    /// [`resolve`](Self::resolve) itself returns a flat slot index, which carries no notdef bit,
307    /// so a caller that wants to warn on the fallback (see
308    /// [`diagnostics::warn_notdef_glyph`](crate::diagnostics::warn_notdef_glyph)) checks this
309    /// separately rather than through the slot it already resolved.
310    #[must_use]
311    pub fn is_notdef(&self, ch: char) -> bool {
312        self.fonts
313            .resolve(ch)
314            .is_some_and(|glyph| glyph.is_notdef())
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::{ATLAS_COLS, AtlasData, AtlasGeometry, GlyphAtlas, MAX_SLOTS, SLOTS_PER_LAYER};
321    use crate::font::{BitmapFont, FontChain};
322
323    #[test]
324    fn geometry_layers_cover_capacity() {
325        // 256 glyphs fit one 16x16 layer; 257 spill into a second.
326        assert_eq!(AtlasGeometry::new(8, 16, 256).layers, 1);
327        assert_eq!(AtlasGeometry::new(8, 16, 257).layers, 2);
328        assert_eq!(AtlasGeometry::new(8, 16, 4096).layers, 16);
329        // Never zero layers, even for an empty atlas.
330        assert_eq!(AtlasGeometry::new(8, 16, 0).layers, 1);
331    }
332
333    #[test]
334    fn a_full_slot_space_stays_within_the_256_layer_floor() {
335        // The whole point of grid-packing: the largest addressable chain must still fit the
336        // 256-layer minimum both GL ES 3.0 and wgpu's downlevel defaults guarantee.
337        assert_eq!(AtlasGeometry::new(8, 16, MAX_SLOTS).layers, 256);
338    }
339
340    #[test]
341    fn locate_walks_row_major_then_layer() {
342        assert_eq!(AtlasGeometry::locate(0), (0, 0, 0));
343        assert_eq!(AtlasGeometry::locate(1), (0, 1, 0));
344        assert_eq!(AtlasGeometry::locate(ATLAS_COLS), (0, 0, 1));
345        assert_eq!(AtlasGeometry::locate(SLOTS_PER_LAYER), (1, 0, 0));
346        assert_eq!(AtlasGeometry::locate(SLOTS_PER_LAYER + 1), (1, 1, 0));
347    }
348
349    #[test]
350    fn tex_dims_are_grid_times_cell() {
351        let g = AtlasGeometry::new(8, 16, 256);
352        assert_eq!(g.tex_w(), 8 * 16);
353        assert_eq!(g.tex_h(), 16 * 16);
354        assert_eq!(g.layers, 1);
355    }
356
357    /// Issue #539: every font in a chain is packed into the same atlas, back to back, so a
358    /// fallback font's glyphs occupy the slots after the primary font's and carry their own
359    /// coverage rather than the primary's.
360    #[test]
361    fn a_chain_packs_each_font_back_to_back() {
362        // Primary: 256 blank glyphs. Fallback: one glyph with its top row fully set.
363        static PRIMARY_DATA: [u8; 256 * 2] = [0; 256 * 2];
364        const PRIMARY: BitmapFont = BitmapFont::new(&PRIMARY_DATA, 8, 2, 256);
365        static FALLBACK_DATA: [u8; 2] = [0xFF, 0x00];
366        const CHARSET: [char; 1] = ['▘'];
367        static FALLBACKS: [BitmapFont; 1] =
368            [BitmapFont::with_charset(&FALLBACK_DATA, 8, 2, 1, &CHARSET)];
369
370        let atlas = AtlasData::build(&FontChain::new(PRIMARY, &FALLBACKS), (8, 2));
371
372        // 257 slots: the fallback font's only glyph is slot 256, the first cell of layer 1.
373        assert_eq!(atlas.geometry.layers, 2);
374        let (layer, gcol, grow) = AtlasGeometry::locate(256);
375        assert_eq!((layer, gcol, grow), (1, 0, 0));
376
377        let tex_w = atlas.geometry.tex_w();
378        let tex_h = atlas.geometry.tex_h();
379        let row0 = ((layer * tex_h) * tex_w) as usize;
380        assert!(
381            atlas.coverage[row0..row0 + 8].iter().all(|&c| c == 0xFF),
382            "the fallback glyph's top row is covered at its own slot"
383        );
384        assert!(
385            atlas.coverage[..row0].iter().all(|&c| c == 0),
386            "the primary font's blank glyphs are untouched"
387        );
388    }
389
390    /// Issue #539's other half: a character only a fallback font declares must resolve to its own
391    /// slot instead of colliding with the primary's solid block.
392    #[test]
393    fn fallback_font_glyphs_get_slots_after_the_primary_font() {
394        static PRIMARY_DATA: [u8; 256 * 16] = [0; 256 * 16];
395        const PRIMARY: BitmapFont = BitmapFont::new(&PRIMARY_DATA, 8, 16, 256);
396
397        static QUADRANT_DATA: [u8; 2 * 16] = [0; 2 * 16];
398        const CHARSET: [char; 2] = ['▘', '▝'];
399        static FALLBACKS: [BitmapFont; 1] =
400            [BitmapFont::with_charset(&QUADRANT_DATA, 8, 16, 2, &CHARSET)];
401
402        let atlas = GlyphAtlas::new(FontChain::new(PRIMARY, &FALLBACKS), (8, 16));
403        assert_eq!(atlas.slot_count(), 258);
404        assert_eq!(atlas.resolve('A'), Some(u16::from(b'A')));
405        assert_eq!(atlas.resolve('▘'), Some(256));
406        assert_eq!(atlas.resolve('▝'), Some(257));
407        // Still no coverage anywhere in the chain: the substituted solid block, not a quadrant.
408        assert_eq!(atlas.resolve('あ'), Some(0xDB));
409        assert!(!atlas.is_notdef('A'), "'A' is covered by the primary font");
410        assert!(!atlas.is_notdef('▘'), "'▘' is covered by the fallback font");
411        assert!(atlas.is_notdef('あ'), "nothing in the chain covers 'あ'");
412    }
413
414    /// Every coverage byte is exactly `0x00` or `0xFF`, never anything between.
415    ///
416    /// This is what lets every backend treat coverage as a blend factor without worrying about
417    /// colour space: an alpha of exactly 0 or 1 selects one endpoint outright, so sRGB-encoded and
418    /// linear-light compositing agree bit for bit. Partial coverage would make that false and make
419    /// gamma-correct text a real problem to solve, in three backends at once. A change that
420    /// introduces it should fail here first and be a deliberate decision, not a silent regression.
421    #[test]
422    fn coverage_is_strictly_binary() {
423        // A font with a half-set row, to prove the check reacts to bit patterns rather than
424        // trivially passing on an all-zero atlas.
425        static DATA: [u8; 4] = [0b1010_1010, 0x00, 0xFF, 0b0000_1111];
426        const FONT: BitmapFont = BitmapFont::new(&DATA, 8, 4, 1);
427        let atlas = AtlasData::build(&FontChain::from(FONT), (8, 4));
428        assert!(
429            atlas.coverage.contains(&0xFF),
430            "the fixture should cover some texels, or this proves nothing"
431        );
432        for (index, &byte) in atlas.coverage.iter().enumerate() {
433            assert!(
434                byte == 0x00 || byte == 0xFF,
435                "texel {index} has partial coverage ({byte:#04x}); see this module's docs on why \
436                 that reopens the colour-space question for every backend"
437            );
438        }
439    }
440
441    #[cfg(feature = "default-font")]
442    #[test]
443    fn coverage_is_strictly_binary_for_the_bundled_font() {
444        use crate::font::unscii16;
445        let atlas = AtlasData::build(&FontChain::from(unscii16::FONT), (8, 16));
446        assert!(
447            atlas.coverage.iter().all(|&c| c == 0x00 || c == 0xFF),
448            "the bundled font produced partial coverage"
449        );
450        assert!(atlas.coverage.contains(&0xFF));
451    }
452
453    #[cfg(feature = "default-font")]
454    #[test]
455    fn unscii16_packs_into_one_layer() {
456        use crate::font::unscii16;
457        let atlas = AtlasData::build(&FontChain::from(unscii16::FONT), (8, 16));
458        assert_eq!(atlas.geometry.cell_w, 8);
459        assert_eq!(atlas.geometry.cell_h, 16);
460        assert_eq!(atlas.geometry.layers, 1);
461        assert_eq!(
462            atlas.coverage.len(),
463            (atlas.geometry.tex_w() * atlas.geometry.tex_h()) as usize
464        );
465    }
466
467    #[cfg(feature = "default-font")]
468    #[test]
469    fn space_is_blank_and_full_block_is_solid_in_their_cells() {
470        use crate::font::unscii16;
471        let atlas = AtlasData::build(&FontChain::from(unscii16::FONT), (8, 16));
472        let g = atlas.geometry;
473        let tex_w = g.tex_w();
474
475        let cell_covered = |slot: u32| -> (bool, bool) {
476            let (_, col, row) = AtlasGeometry::locate(slot);
477            let (ox, oy) = (col * g.cell_w, row * g.cell_h);
478            let mut any = false;
479            let mut all = true;
480            for y in 0..g.cell_h {
481                for x in 0..g.cell_w {
482                    let idx = (((oy + y) * tex_w) + ox + x) as usize;
483                    let set = atlas.coverage[idx] != 0;
484                    any |= set;
485                    all &= set;
486                }
487            }
488            (any, all)
489        };
490
491        // 0x20 space: entirely clear. 0xDB full block: entirely set.
492        assert!(!cell_covered(0x20).0, "space must be blank");
493        assert!(cell_covered(0xDB).1, "full block must be solid");
494    }
495
496    #[cfg(feature = "default-font")]
497    #[test]
498    fn atlas_maps_char_to_font_index() {
499        use crate::font::unscii16;
500        let atlas = GlyphAtlas::new(FontChain::from(unscii16::FONT), (8, 16));
501        assert_eq!(
502            atlas.resolve('A'),
503            unscii16::FONT.glyph_index('A').map(u16::from)
504        );
505        assert_eq!(
506            atlas.space_slot(),
507            unscii16::FONT.glyph_index(' ').map(u16::from).unwrap()
508        );
509        assert_eq!(atlas.cell_size(), (8, 16));
510    }
511}