diff --git a/crates/benilla-app/src/ui_text/engine/mod.rs b/crates/benilla-app/src/ui_text/engine/mod.rs index b12b790c3..f17103ad5 100644 --- a/crates/benilla-app/src/ui_text/engine/mod.rs +++ b/crates/benilla-app/src/ui_text/engine/mod.rs @@ -554,7 +554,8 @@ impl TextEngine { /// (`0x5cb080` mode 0, `0x5cd099`), not at the bitmap's `floor(xMin)`; the bakes put the ink /// `pad` columns in (`0x5cf237` plain, `0x5ce481`/`0x5cea7e` outlined, `0x5ce48a`/`0x5cea87` one more for THICK). /// Its top sits `pad` rows over FreeType's `bitmap_top`, which the reference's vertical seat - /// (`0x5d1360`) hangs from the ascender, so the ink keeps the bitmap's own row. + /// (`0x5d1360`) hangs from the ascender, so the ink keeps the bitmap's own row. Only the + /// raster's first `ppem` rows are baked ([`em_rows`]), before any ring, and they keep their rows. fn ensure_cell(&mut self, face_id: fontdb::ID, ppem: u16, bake: Bake, g: &GlyphRef) { let key: GlyphKey = (face_id, g.glyph_id, ppem, bake); if self.cells.contains_key(&key) { @@ -570,15 +571,18 @@ impl TextEngine { // seat in whole px. let baked = match &outline { Some(o) if bake.mono => mono::rasterize(o, ppem).map(|m| { - let (rgba, w, h, pad) = mono_cell(&m.bits, m.w, m.h, bake.radius, self.dpi); + let (bits, rows) = em_rows(m.bits, m.w, m.h, ppem); + let (rgba, w, h, pad) = mono_cell(&bits, m.w, rows, bake.radius, self.dpi); (OwnedCell::Rgba(rgba), w, h, pad, o.bearing(), m.top) }), Some(o) => smooth::rasterize(o).map(|m| { - let (cell, w, h, pad) = self.coverage_cell(m.coverage, m.w, m.h, bake); + let (cov, rows) = em_rows(m.coverage, m.w, m.h, ppem); + let (cell, w, h, pad) = self.coverage_cell(cov, m.w, rows, bake); (cell, w, h, pad, o.bearing(), m.top) }), None => self.shaper_raster(g.key).map(|(cov, p, bearing)| { - let (cell, w, h, pad) = self.coverage_cell(cov, p.width, p.height, bake); + let (cov, rows) = em_rows(cov, p.width, p.height, ppem); + let (cell, w, h, pad) = self.coverage_cell(cov, p.width, rows, bake); (cell, w, h, pad, Some(bearing), p.top) }), }; @@ -869,6 +873,15 @@ fn outline_box( (x0 <= x1).then(|| (q(x0), q(x1), q(y0), q(y1))) } +/// A raster's first `em` rows, one byte per texel, and how many that is: the reference keeps +/// `min(em, rows)` of a glyph's rows from the top (`0x5d1195`), `em` being the pixel size its face +/// is set to (`0x5ca1b5`, `[CGxFont+0x24c]`), which here is the ppem the raster was made at. +fn em_rows(mut texels: Vec, w: u32, h: u32, em: u16) -> (Vec, u32) { + let rows = h.min(u32::from(em)); + texels.truncate((w * rows) as usize); + (texels, rows) +} + /// A baked cell's texels, owned until the sheet copies them ([`Cell`]). enum OwnedCell { Coverage(Vec), @@ -884,6 +897,23 @@ impl OwnedCell { } } +#[cfg(test)] +mod em_rows_tests { + use super::em_rows; + + #[test] + fn a_raster_keeps_at_most_its_em_rows_from_the_top() { + let tall: Vec = (0..12).collect(); // 3 wide, 4 rows + assert_eq!(em_rows(tall.clone(), 3, 4, 3), ((0..9).collect(), 3)); + assert_eq!(em_rows(tall.clone(), 3, 4, 4), (tall.clone(), 4)); + assert_eq!( + em_rows(tall.clone(), 3, 4, 9), + (tall, 4), + "a short glyph is whole" + ); + } +} + #[cfg(test)] mod line_law_tests { use super::*; @@ -1560,7 +1590,8 @@ mod ppem_tests { /// An antialiased cell is the unhinted raster (`0x5d1300`: both load-flag words carry /// `FT_LOAD_NO_HINTING`, rendered in mode 0 at `0x7cec20`). Arial Narrow's `e` at 18 px, the /// chat frame's, as FreeType's smooth rasterizer draws it unhinted, computed outside this code - /// base: the bar spreads over two partial rows, where a hinter snaps it to one full row. + /// base: the bar spreads over two partial rows, where a hinter snaps it to one full row. The + /// golden is the raster's coverage; the cell holds its top 4 bits ([`Cell::Coverage`]). #[test] fn an_antialiased_cell_is_the_unhinted_raster() { let Some(mut e) = engine_or_skip() else { @@ -1590,7 +1621,66 @@ mod ppem_tests { .chunks(8) .map(|row| row.iter().map(|t| format!("{:02x}", t[3])).collect()) .collect(); - assert_eq!(got, golden); + let want: Vec = golden + .iter() + .map(|row| { + (0..row.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&row[i..i + 2], 16).expect("hex")) + .map(|c| format!("{:02x}", crate::ui_text::pack::widen4(c >> 4))) + .collect() + }) + .collect(); + assert_eq!(got, want); + } + + /// A glyph taller than the em keeps its first `em` rows (`0x5d1195`), whatever the bake, and + /// they stay where they were. Arial Narrow's `Ò` at 14 px, the chat size, is 15 rows unhinted. + #[test] + fn an_over_tall_glyph_keeps_its_top_em_rows() { + let Some(mut e) = engine_or_skip() else { + return; + }; + let face = e.face_for(Some("Fonts\\ARIALN.TTF")); + let (info, texels) = cell_texels(&mut e, face, 14, Bake::ring(0), 'Ò'); + let gid = e.char_cell(face, 14, 'Ò').expect("shaped").glyphs[0].glyph_id; + let sfnt = e.faces[face].sfnt.clone().expect("Arial Narrow's tables"); + let outline = sfnt.outline(gid, 14).expect("outline"); + let raster = smooth::rasterize(&outline).expect("ink"); + assert_eq!(raster.h, 15, "the unhinted raster is one row over the em"); + assert!( + raster.coverage[(14 * raster.w) as usize..] + .iter() + .any(|&c| c >= 16), + "its last row has ink that would draw" + ); + assert_eq!((info.px_w, info.px_h), (raster.w as f32, 14.0)); + assert_eq!( + info.bearing_top, raster.top as f32, + "the kept rows keep their seat" + ); + let top: Vec = texels.iter().map(|t| t[3]).collect(); + let want: Vec = raster.coverage[..(14 * raster.w) as usize] + .iter() + .map(|&c| crate::ui_text::pack::widen4(c >> 4)) + .collect(); + assert_eq!(top, want, "the top 14 rows, not the bottom"); + + for (radius, mono) in [(1u8, false), (2, false), (0, true), (2, true)] { + let bake = Bake { radius, mono }; + let (info, _) = cell_texels(&mut e, face, 14, bake, 'Ò'); + let grow = 2.0 * f32::from(radius); + assert_eq!( + info.px_h, + 14.0 + grow, + "{bake:?}: an outline grows the em, not the raster" + ); + assert_eq!( + info.bearing_top, + raster.top as f32 + f32::from(radius), + "{bake:?}" + ); + } } /// A cell's left edge is the pen plus `ceil(horiBearingX)`, the quad x of `0x5ccbe0` (`0x5cb080` diff --git a/crates/benilla-app/src/ui_text/outline.rs b/crates/benilla-app/src/ui_text/outline.rs index 6ab86201e..072e3ec0d 100644 --- a/crates/benilla-app/src/ui_text/outline.rs +++ b/crates/benilla-app/src/ui_text/outline.rs @@ -6,6 +6,8 @@ use benilla_ui::script::Outline; +use super::pack::widen4; + /// What a glyph's cell is baked from, the font flags `0x5cf310` picks its bake by: the outline's /// ring reach in logical px (NORMAL 1, THICK 2) and the monochrome flag (bit 1). Part of the cell /// cache's key. @@ -39,6 +41,12 @@ impl Bake { /// texels in the in-bounds 3×3 box, centre included; its 4-bit alphas are widened × 17. const AA_NEIGHBOUR_LUT: [u8; 10] = [0, 17, 17, 51, 85, 119, 153, 187, 221, 255]; +/// A fill texel's gray, the 4-bit `(coverage · 0xff) >> 12` the AA-outline bake packs into each +/// colour channel (`0x5cf1b0`–`0x5cf1dd`), widened. +fn fill_gray(coverage: u8) -> u8 { + widen4(((u32::from(coverage) * 0xff) >> 12) as u8) +} + /// Composite one glyph's coverage into an outlined cell, the reference's AA-outline blit: /// /// 1. Mark every texel with coverage. @@ -46,10 +54,10 @@ const AA_NEIGHBOUR_LUT: [u8; 10] = [0, 17, 17, 51, 85, 119, 153, 187, 221, 255]; /// and 2 for THICK. /// 3. Alpha: the [`AA_NEIGHBOUR_LUT`] entry for the marked count in the texel's in-bounds 3×3 /// box, for ring and fill edge alike. -/// 4. Pack: the coverage as gray, so the pure ring is black and the fill ramps toward it. +/// 4. Pack: the coverage as a 4-bit gray ([`fill_gray`]), so the pure ring is black and the fill +/// ramps toward it. /// -/// Deviation: `r × round(dpi)` passes, so the ring keeps its logical weight on a high-DPI raster, -/// and 8-bit texels where the reference packs ARGB4444, so nothing bands. +/// Deviation: `r × round(dpi)` passes, so the ring keeps its logical weight on a high-DPI raster. /// /// It draws as one quad per glyph (`0x5ccbe0`) whose vertex colour tints the fill and leaves the /// ring black. Returns `(rgba, out_w, out_h, pad)`: the cell grows `pad` texels each side (the @@ -88,7 +96,7 @@ pub(super) fn outlined_cell( } let alpha = AA_NEIGHBOUR_LUT[count]; let fill = if row >= pad && row < pad + h && col >= pad && col < pad + w { - cov[((row - pad) * w + (col - pad)) as usize] + fill_gray(cov[((row - pad) * w + (col - pad)) as usize]) } else { 0 }; @@ -236,15 +244,36 @@ mod outlined_cell_tests { #[test] fn aa_fill_ramps_gray_toward_the_ring() { - // Half coverage packs as gray at the table's alpha, not white at a thin alpha. + // Half coverage packs as gray at the table's alpha, not white at a thin alpha: the gray is + // the 4-bit `(128 · 255) >> 12` = 7, read back as 7/15. let (rgba, w, _, _) = outlined_cell(&[128], 1, 1, 1, 1.0); assert_eq!( px(&rgba, w, 1, 1), - [128, 128, 128, 255], + [119, 119, 119, 255], "gray fill, LUT alpha" ); assert_eq!(px(&rgba, w, 1, 0), [0, 0, 0, 153], "ring stays black"); } + + #[test] + fn the_fill_gray_is_the_bakes_four_bit_channel() { + // `(c · 0xff) >> 12` (`0x5cf1b8`–`0x5cf1c8`): not `c >> 4`, which is 1 at 16 and 15 at 240. + for (c, n) in [ + (0u8, 0u8), + (16, 0), + (17, 1), + (128, 7), + (240, 14), + (241, 15), + (255, 15), + ] { + assert_eq!(fill_gray(c), n * 17, "coverage {c}"); + } + // Every texel of an outlined cell is 4-bit exact, as the 4444 page holds it. + let cov: Vec = (0..=255).collect(); + let (rgba, ..) = outlined_cell(&cov, 16, 16, 2, 1.0); + assert!(rgba.iter().all(|&b| b % 17 == 0)); + } } #[cfg(test)] diff --git a/crates/benilla-app/src/ui_text/pack.rs b/crates/benilla-app/src/ui_text/pack.rs index e3567334e..5c1f0f2d3 100644 --- a/crates/benilla-app/src/ui_text/pack.rs +++ b/crates/benilla-app/src/ui_text/pack.rs @@ -26,11 +26,18 @@ pub(super) const SHEET_SIZE: u32 = 2048; /// pixel samples under half a texel outside its cell at any magnification. const PAD: u32 = 1; +/// A 4-bit channel as the GPU widens it to 8 bits (`n / 15`): the reference's glyph pages are +/// ARGB4444 (`0x5cf4d0`), so every texel channel it bakes is one of these 16 values. +pub(super) const fn widen4(n: u8) -> u8 { + n * 17 +} + /// A cell's texel payload, in the two shapes the rasterizer produces. pub(super) enum Cell<'a> { - /// One byte per texel: coverage, expanded to white × alpha (the vertex color supplies the ink). + /// One byte per texel: coverage, expanded to white at its top 4 bits as alpha, the plain bake's + /// `(coverage << 8) | 0x0fff` texel (`0x5cf298`); the vertex color supplies the ink. Coverage(&'a [u8]), - /// Four bytes per texel, straight alpha: an outlined cell, taken verbatim. + /// Four bytes per texel, straight alpha, already 4-bit exact: a baked cell, taken verbatim. Rgba(&'a [u8]), } @@ -135,7 +142,7 @@ impl Sheet { Cell::Coverage(cov) => { let mut out = vec![255u8; (w * h * 4) as usize]; for (i, &a) in cov.iter().enumerate().take((w * h) as usize) { - out[i * 4 + 3] = a; + out[i * 4 + 3] = widen4(a >> 4); } out } @@ -266,7 +273,8 @@ mod tests { s.alloc(2, 1, &Cell::Coverage(&[0, 128])).expect("room"); let (_, uploads) = s.take_pending(); assert_eq!(texel(&uploads[0], 0, 0), [255, 255, 255, 0]); - assert_eq!(texel(&uploads[0], 1, 0), [255, 255, 255, 128]); + // 128's top 4 bits, 8, read back as 8/15. + assert_eq!(texel(&uploads[0], 1, 0), [255, 255, 255, 136]); let mut s = sheet(); let ring = vec![0u8, 0, 0, 153, 200, 200, 200, 255]; @@ -280,6 +288,24 @@ mod tests { ); } + /// The plain bake keeps coverage's top 4 bits as a 4444 texel's alpha (`0x5cf298`): 16 levels, + /// and coverage under 16 draws nothing. + #[test] + fn coverage_keeps_its_top_four_bits_of_alpha() { + let cov: Vec = (0..=255).collect(); + let mut s = sheet(); + s.alloc(256, 1, &Cell::Coverage(&cov)).expect("room"); + let (_, uploads) = s.take_pending(); + for (x, &c) in cov.iter().enumerate() { + let a = texel(&uploads[0], x as u32, 0)[3]; + assert_eq!(a % 17, 0, "coverage {c} packs to a 4-bit alpha, not {a}"); + assert_eq!(a / 17, c >> 4, "coverage {c}"); + } + assert_eq!(texel(&uploads[0], 15, 0)[3], 0, "coverage 15 vanishes"); + assert_eq!(texel(&uploads[0], 16, 0)[3], 17); + assert_eq!(texel(&uploads[0], 255, 0)[3], 255); + } + /// A cell uploads its own frame, its edge colour at alpha 0 on every side, since a reset leaves /// old ink in the gaps a magnified quad samples. #[test] diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 22fff2c47..e9b839a6f 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -441,11 +441,11 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - Text the reference magnifies from a smaller raster is rasterized at its true size here: a `SetTextHeight` size (the bit cleared at `0x771600`), an em past 32 px, and 3-D unit names (`UNIT_NAME_FONT`, em 32, string flag `0x80`), because the 32 px cap is a raster-memory budget that blurs big text on a modern display (`super::FONTSTRING_EM_CAP`). ([`src/ui_text/engine/mod.rs:8`](../crates/benilla-app/src/ui_text/engine/mod.rs#L8)) - The reference flushes every glyph cache on a window resize (`0x5c2b50` → `0x5ca6f0`); this keeps the old cells, because nameplate meshes bake UVs and a flush would rebuild them every resize frame. ([`src/ui_text/engine/mod.rs:13`](../crates/benilla-app/src/ui_text/engine/mod.rs#L13)) - The reference's measure kernels (`0x5c6940`, `0x5c6b70`, `0x5c6c50`, `0x5c7300`, `0x5c7470`) rasterize each character, but the step law reads only the advance, so skipping the bitmap changes no width. ([`src/ui_text/engine/mod.rs:362`](../crates/benilla-app/src/ui_text/engine/mod.rs#L362)) -- The reference evicts the least recently used glyph and repacks its hole (`0x5cad2b`, over `[CGxFont+0x64..0x6c]`); this drops every cell, because a shelf allocator cannot reclaim an interior cell and the sheet is far larger than the reference's 256×256 pages. ([`src/ui_text/engine/mod.rs:648`](../crates/benilla-app/src/ui_text/engine/mod.rs#L648)) +- The reference evicts the least recently used glyph and repacks its hole (`0x5cad2b`, over `[CGxFont+0x64..0x6c]`); this drops every cell, because a shelf allocator cannot reclaim an interior cell and the sheet is far larger than the reference's 256×256 pages. ([`src/ui_text/engine/mod.rs:652`](../crates/benilla-app/src/ui_text/engine/mod.rs#L652)) - Every UI text block sits 1 px below the reference's row, because at that row UI text reads high. ([`src/ui_text/layout/mod.rs:115`](../crates/benilla-app/src/ui_text/layout/mod.rs#L115)) - The cap is 32 logical units, not 32 device px, because the reference's raster-memory ceiling draws all big text at the same 32 device px on a modern display; at the 768-tall design height the two agree. ([`src/ui_text/mod.rs:54`](../crates/benilla-app/src/ui_text/mod.rs#L54)) -- `r × round(dpi)` passes, so the ring keeps its logical weight on a high-DPI raster, and 8-bit texels where the reference packs ARGB4444, so nothing bands. ([`src/ui_text/outline.rs:51`](../crates/benilla-app/src/ui_text/outline.rs#L51)) -- `r × round(dpi)` passes as in `outlined_cell`, the first `round(dpi)` of them the opaque ring. ([`src/ui_text/outline.rs:159`](../crates/benilla-app/src/ui_text/outline.rs#L159)) +- `r × round(dpi)` passes, so the ring keeps its logical weight on a high-DPI raster. ([`src/ui_text/outline.rs:60`](../crates/benilla-app/src/ui_text/outline.rs#L60)) +- `r × round(dpi)` passes as in `outlined_cell`, the first `round(dpi)` of them the opaque ring. ([`src/ui_text/outline.rs:167`](../crates/benilla-app/src/ui_text/outline.rs#L167)) - One sheet, where the reference keeps up to 8 pages per font (`[CGxFont+0x18c]`), because a nameplate bakes its glyphs into one mesh with one material, which a page would split. ([`src/ui_text/pack.rs:11`](../crates/benilla-app/src/ui_text/pack.rs#L11)) #### ui_trade