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/// The CPU-side coverage buffer for a whole atlas, grid-packed per [`AtlasGeometry`].
131#[derive(Clone, Debug)]
132#[non_exhaustive]
133pub struct AtlasData {
134    /// The glyph packing.
135    pub geometry: AtlasGeometry,
136    /// Row-major coverage bytes, length `tex_w * tex_h * layers`. Texel `(x, y)` of layer `l` is at
137    /// `((l * tex_h + y) * tex_w + x)`. Row 0 is the glyph's top row, so a shader that flips y when
138    /// projecting to clip space samples a glyph's top at `uv.y == 0`.
139    pub coverage: Vec<u8>,
140}
141
142impl AtlasData {
143    /// Builds a fully-populated, grid-packed atlas for every glyph of every font in `fonts`, one
144    /// slot per glyph, so a static bitmap font needs no runtime rasterization.
145    ///
146    /// The fonts are laid out back to back in chain order, so a font's slots start at the sum of
147    /// the glyph counts before it, which is the same base [`GlyphAtlas::resolve`] adds to a
148    /// resolved glyph's own index. Every font in the chain is assumed to share `cell_size`; a
149    /// backend's builder checks that via [`FontChain::glyph_size`] before getting here.
150    #[must_use]
151    #[allow(clippy::cast_possible_truncation)]
152    pub fn build(fonts: &FontChain<'static>, cell_size: (u32, u32)) -> Self {
153        let (cell_w, cell_h) = cell_size;
154        let count: u32 = fonts.fonts().map(addressable_glyphs).sum();
155        let geometry = AtlasGeometry::new(cell_w, cell_h, count);
156
157        let tex_w = geometry.tex_w();
158        let tex_h = geometry.tex_h();
159        let mut coverage = vec![0u8; (tex_w * tex_h * geometry.layers) as usize];
160
161        let mut slot = 0;
162        for font in fonts.fonts() {
163            for index in 0..addressable_glyphs(font) {
164                let (layer, gcol, grow) = AtlasGeometry::locate(slot);
165                let (ox, oy) = (gcol * cell_w, grow * cell_h);
166                // `glyph_pixels` yields each set pixel `(x, y)` decoded MSB-first (the bit order
167                // lives in the font module, #164), so this stays width-agnostic.
168                for (x, y) in font.glyph_pixels(index as u8) {
169                    let px = ox + u32::from(x);
170                    let py = oy + u32::from(y);
171                    let idx = ((layer * tex_h + py) * tex_w + px) as usize;
172                    coverage[idx] = 0xFF;
173                }
174                slot += 1;
175            }
176        }
177
178        Self { geometry, coverage }
179    }
180}
181
182/// A static [`FontChain`] plus the `char` -> slot map for its grid-packed atlas.
183///
184/// Every glyph of every font in the chain is uploaded once; a character maps to a flat slot, which
185/// is the font's base offset in the atlas plus that font's own glyph index. A renderer never sees
186/// characters past this point: [`resolve`](Self::resolve) hands back a `u16` that goes straight
187/// into an instance buffer.
188#[derive(Clone, Debug)]
189pub struct GlyphAtlas {
190    fonts: FontChain<'static>,
191    /// Flat atlas slot at which each font in the chain's glyphs start, indexed by the font's
192    /// position in the chain ([`ResolvedGlyph::font_index`](crate::font::ResolvedGlyph::font_index)).
193    bases: Vec<u32>,
194    cell_w: u32,
195    cell_h: u32,
196    space_slot: u16,
197}
198
199impl GlyphAtlas {
200    /// An atlas over a static font chain, whose slots are assigned back to back in chain order.
201    ///
202    /// `glyph_size` is the cell size every font in `fonts` agrees on. Pass what
203    /// [`FontChain::glyph_size`] returned, and reject a chain it returned `None` for: a grid has
204    /// one cell size, so a chain whose fonts disagree has no atlas geometry to build.
205    #[must_use]
206    pub fn new(fonts: FontChain<'static>, glyph_size: (u8, u8)) -> Self {
207        let mut bases = Vec::with_capacity(fonts.font_count());
208        let mut next = 0;
209        for font in fonts.fonts() {
210            bases.push(next);
211            next += addressable_glyphs(font);
212        }
213        let mut atlas = Self {
214            fonts,
215            bases,
216            cell_w: u32::from(glyph_size.0),
217            cell_h: u32::from(glyph_size.1),
218            space_slot: 0,
219        };
220        atlas.space_slot = atlas.resolve(' ').unwrap_or(0);
221        atlas
222    }
223
224    /// Glyph cell size in unscaled pixels.
225    #[must_use]
226    pub const fn cell_size(&self) -> (u32, u32) {
227        (self.cell_w, self.cell_h)
228    }
229
230    /// The atlas slot of the space glyph, or `0` for a chain with no space glyph at all.
231    ///
232    /// Only ever used for cells that draw no glyph (a backend still needs *some* slot in the
233    /// instance it writes), so a chain without a space still renders correctly.
234    #[must_use]
235    pub const fn space_slot(&self) -> u16 {
236        self.space_slot
237    }
238
239    /// The total number of glyph slots the chain occupies in the atlas.
240    ///
241    /// Compare against [`MAX_SLOTS`] before building: a chain past that has glyphs
242    /// [`resolve`](Self::resolve) cannot name.
243    #[must_use]
244    pub fn slot_count(&self) -> u32 {
245        self.fonts.fonts().map(addressable_glyphs).sum()
246    }
247
248    /// The backing font chain.
249    #[must_use]
250    pub const fn fonts(&self) -> &FontChain<'static> {
251        &self.fonts
252    }
253
254    /// The full coverage buffer to upload, built fresh each call.
255    ///
256    /// Not cached: a backend uploads it once at resource creation and, on some platforms, again
257    /// after a lost device, which is rare enough that holding a second copy of the atlas for the
258    /// renderer's whole life costs more than rebuilding it.
259    #[must_use]
260    pub fn data(&self) -> AtlasData {
261        AtlasData::build(&self.fonts, self.cell_size())
262    }
263
264    /// Resolves `ch` to its atlas slot, or `None` when no font in the chain can draw it, not even
265    /// as the substituted solid block, in which case the caller draws no glyph for that cell.
266    ///
267    /// # Panics
268    ///
269    /// Does not panic. The `u16` cast cannot truncate for a chain whose
270    /// [`slot_count`](Self::slot_count) is within [`MAX_SLOTS`], which a backend's builder is
271    /// expected to have checked.
272    #[must_use]
273    #[allow(clippy::cast_possible_truncation)]
274    pub fn resolve(&self, ch: char) -> Option<u16> {
275        let glyph = self.fonts.resolve(ch)?;
276        Some((self.bases[glyph.font_index()] + u32::from(glyph.index())) as u16)
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::{ATLAS_COLS, AtlasData, AtlasGeometry, GlyphAtlas, MAX_SLOTS, SLOTS_PER_LAYER};
283    use crate::font::{BitmapFont, FontChain};
284
285    #[test]
286    fn geometry_layers_cover_capacity() {
287        // 256 glyphs fit one 16x16 layer; 257 spill into a second.
288        assert_eq!(AtlasGeometry::new(8, 16, 256).layers, 1);
289        assert_eq!(AtlasGeometry::new(8, 16, 257).layers, 2);
290        assert_eq!(AtlasGeometry::new(8, 16, 4096).layers, 16);
291        // Never zero layers, even for an empty atlas.
292        assert_eq!(AtlasGeometry::new(8, 16, 0).layers, 1);
293    }
294
295    #[test]
296    fn a_full_slot_space_stays_within_the_256_layer_floor() {
297        // The whole point of grid-packing: the largest addressable chain must still fit the
298        // 256-layer minimum both GL ES 3.0 and wgpu's downlevel defaults guarantee.
299        assert_eq!(AtlasGeometry::new(8, 16, MAX_SLOTS).layers, 256);
300    }
301
302    #[test]
303    fn locate_walks_row_major_then_layer() {
304        assert_eq!(AtlasGeometry::locate(0), (0, 0, 0));
305        assert_eq!(AtlasGeometry::locate(1), (0, 1, 0));
306        assert_eq!(AtlasGeometry::locate(ATLAS_COLS), (0, 0, 1));
307        assert_eq!(AtlasGeometry::locate(SLOTS_PER_LAYER), (1, 0, 0));
308        assert_eq!(AtlasGeometry::locate(SLOTS_PER_LAYER + 1), (1, 1, 0));
309    }
310
311    #[test]
312    fn tex_dims_are_grid_times_cell() {
313        let g = AtlasGeometry::new(8, 16, 256);
314        assert_eq!(g.tex_w(), 8 * 16);
315        assert_eq!(g.tex_h(), 16 * 16);
316        assert_eq!(g.layers, 1);
317    }
318
319    /// Issue #539: every font in a chain is packed into the same atlas, back to back, so a
320    /// fallback font's glyphs occupy the slots after the primary font's and carry their own
321    /// coverage rather than the primary's.
322    #[test]
323    fn a_chain_packs_each_font_back_to_back() {
324        // Primary: 256 blank glyphs. Fallback: one glyph with its top row fully set.
325        static PRIMARY_DATA: [u8; 256 * 2] = [0; 256 * 2];
326        const PRIMARY: BitmapFont = BitmapFont::new(&PRIMARY_DATA, 8, 2, 256);
327        static FALLBACK_DATA: [u8; 2] = [0xFF, 0x00];
328        const CHARSET: [(char, u8); 1] = [('▘', 0)];
329        static FALLBACKS: [BitmapFont; 1] =
330            [BitmapFont::with_charset(&FALLBACK_DATA, 8, 2, 1, &CHARSET)];
331
332        let atlas = AtlasData::build(&FontChain::new(PRIMARY, &FALLBACKS), (8, 2));
333
334        // 257 slots: the fallback font's only glyph is slot 256, the first cell of layer 1.
335        assert_eq!(atlas.geometry.layers, 2);
336        let (layer, gcol, grow) = AtlasGeometry::locate(256);
337        assert_eq!((layer, gcol, grow), (1, 0, 0));
338
339        let tex_w = atlas.geometry.tex_w();
340        let tex_h = atlas.geometry.tex_h();
341        let row0 = ((layer * tex_h) * tex_w) as usize;
342        assert!(
343            atlas.coverage[row0..row0 + 8].iter().all(|&c| c == 0xFF),
344            "the fallback glyph's top row is covered at its own slot"
345        );
346        assert!(
347            atlas.coverage[..row0].iter().all(|&c| c == 0),
348            "the primary font's blank glyphs are untouched"
349        );
350    }
351
352    /// Issue #539's other half: a character only a fallback font declares must resolve to its own
353    /// slot instead of colliding with the primary's solid block.
354    #[test]
355    fn fallback_font_glyphs_get_slots_after_the_primary_font() {
356        static PRIMARY_DATA: [u8; 256 * 16] = [0; 256 * 16];
357        const PRIMARY: BitmapFont = BitmapFont::new(&PRIMARY_DATA, 8, 16, 256);
358
359        static QUADRANT_DATA: [u8; 2 * 16] = [0; 2 * 16];
360        const CHARSET: [(char, u8); 2] = [('▘', 0), ('▝', 1)];
361        static FALLBACKS: [BitmapFont; 1] =
362            [BitmapFont::with_charset(&QUADRANT_DATA, 8, 16, 2, &CHARSET)];
363
364        let atlas = GlyphAtlas::new(FontChain::new(PRIMARY, &FALLBACKS), (8, 16));
365        assert_eq!(atlas.slot_count(), 258);
366        assert_eq!(atlas.resolve('A'), Some(u16::from(b'A')));
367        assert_eq!(atlas.resolve('▘'), Some(256));
368        assert_eq!(atlas.resolve('▝'), Some(257));
369        // Still no coverage anywhere in the chain: the substituted solid block, not a quadrant.
370        assert_eq!(atlas.resolve('あ'), Some(0xDB));
371    }
372
373    /// Every coverage byte is exactly `0x00` or `0xFF`, never anything between.
374    ///
375    /// This is what lets every backend treat coverage as a blend factor without worrying about
376    /// colour space: an alpha of exactly 0 or 1 selects one endpoint outright, so sRGB-encoded and
377    /// linear-light compositing agree bit for bit. Partial coverage would make that false and make
378    /// gamma-correct text a real problem to solve, in three backends at once. A change that
379    /// introduces it should fail here first and be a deliberate decision, not a silent regression.
380    #[test]
381    fn coverage_is_strictly_binary() {
382        // A font with a half-set row, to prove the check reacts to bit patterns rather than
383        // trivially passing on an all-zero atlas.
384        static DATA: [u8; 4] = [0b1010_1010, 0x00, 0xFF, 0b0000_1111];
385        const FONT: BitmapFont = BitmapFont::new(&DATA, 8, 4, 1);
386        let atlas = AtlasData::build(&FontChain::from(FONT), (8, 4));
387        assert!(
388            atlas.coverage.contains(&0xFF),
389            "the fixture should cover some texels, or this proves nothing"
390        );
391        for (index, &byte) in atlas.coverage.iter().enumerate() {
392            assert!(
393                byte == 0x00 || byte == 0xFF,
394                "texel {index} has partial coverage ({byte:#04x}); see this module's docs on why \
395                 that reopens the colour-space question for every backend"
396            );
397        }
398    }
399
400    #[cfg(feature = "default-font")]
401    #[test]
402    fn coverage_is_strictly_binary_for_the_bundled_font() {
403        use crate::font::unscii16;
404        let atlas = AtlasData::build(&FontChain::from(unscii16::FONT), (8, 16));
405        assert!(
406            atlas.coverage.iter().all(|&c| c == 0x00 || c == 0xFF),
407            "the bundled font produced partial coverage"
408        );
409        assert!(atlas.coverage.contains(&0xFF));
410    }
411
412    #[cfg(feature = "default-font")]
413    #[test]
414    fn unscii16_packs_into_one_layer() {
415        use crate::font::unscii16;
416        let atlas = AtlasData::build(&FontChain::from(unscii16::FONT), (8, 16));
417        assert_eq!(atlas.geometry.cell_w, 8);
418        assert_eq!(atlas.geometry.cell_h, 16);
419        assert_eq!(atlas.geometry.layers, 1);
420        assert_eq!(
421            atlas.coverage.len(),
422            (atlas.geometry.tex_w() * atlas.geometry.tex_h()) as usize
423        );
424    }
425
426    #[cfg(feature = "default-font")]
427    #[test]
428    fn space_is_blank_and_full_block_is_solid_in_their_cells() {
429        use crate::font::unscii16;
430        let atlas = AtlasData::build(&FontChain::from(unscii16::FONT), (8, 16));
431        let g = atlas.geometry;
432        let tex_w = g.tex_w();
433
434        let cell_covered = |slot: u32| -> (bool, bool) {
435            let (_, col, row) = AtlasGeometry::locate(slot);
436            let (ox, oy) = (col * g.cell_w, row * g.cell_h);
437            let mut any = false;
438            let mut all = true;
439            for y in 0..g.cell_h {
440                for x in 0..g.cell_w {
441                    let idx = (((oy + y) * tex_w) + ox + x) as usize;
442                    let set = atlas.coverage[idx] != 0;
443                    any |= set;
444                    all &= set;
445                }
446            }
447            (any, all)
448        };
449
450        // 0x20 space: entirely clear. 0xDB full block: entirely set.
451        assert!(!cell_covered(0x20).0, "space must be blank");
452        assert!(cell_covered(0xDB).1, "full block must be solid");
453    }
454
455    #[cfg(feature = "default-font")]
456    #[test]
457    fn atlas_maps_char_to_font_index() {
458        use crate::font::unscii16;
459        let atlas = GlyphAtlas::new(FontChain::from(unscii16::FONT), (8, 16));
460        assert_eq!(
461            atlas.resolve('A'),
462            unscii16::FONT.glyph_index('A').map(u16::from)
463        );
464        assert_eq!(
465            atlas.space_slot(),
466            unscii16::FONT.glyph_index(' ').map(u16::from).unwrap()
467        );
468        assert_eq!(atlas.cell_size(), (8, 16));
469    }
470}