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}