The bitmap half of FR-2.8 — "Text rendering: ... bitmap (P0)"
(M2-TEXT-01): the caller's TTF/OTF font bytes + a configured glyph range
are rasterized once, at scene set-up, into a fixed-size 8-bit alpha
glyph atlas + per-glyph metrics (advance, bearing, line height). There
is no runtime re-rasterization: after create, glyph(code) is a
pure O(1) table lookup. The text-item / layout pass ships in
M2-TEXT-02 (text_items.md); the SDF path (P1)
ships in M2-TEXT-03 (font_sdf.md) on top of the
same vendored rasterizer (ADR 0009).
Public header:
src/laige-render/include/laige/render/font.h
(implementation: src/laige-render/font.cpp; the vendor boundary is
the laige-stb target — ADR 0009). Unit suite: ctest -R font
(tests/laige-render/font_tests.cpp) — pure engine data, no GL
environment required: it runs in every local tree and in CI, with the
validation matrix, the untrusted-font boundary, the capacity guard,
the metric goldens (measured against the committed test font — see
tests/laige-render/assets/README.md), the deterministic-atlas
contract, the missing-glyph fallback, and the zero-allocation lookup
window.
// The per-glyph metrics + atlas cell origin (px at 1x).
struct GlyphMetrics {
std::int32_t advance{0}; // pen advance, px at 1x
std::int32_t bearingX{0}; // bitmap left edge from the pen (right +)
std::int32_t bearingY{0}; // bitmap top edge above the baseline (up +)
std::int32_t width{0}; // bitmap ink width, texels (0 = empty)
std::int32_t height{0}; // bitmap ink height, texels (0 = empty)
std::uint32_t atlasX{0}; // cell top-left, texels in the atlas
std::uint32_t atlasY{0};
};
// The configured bitmap glyph atlas (move-only).
class GlyphAtlas {
public:
struct Options {
std::uint32_t atlasWidth{kFontDefaultAtlasWidth}; // 1024
std::uint32_t atlasHeight{kFontDefaultAtlasHeight}; // 1024
std::uint32_t fontSize{kFontDefaultFontSize}; // 16 px at 1x
std::uint32_t glyphFirst{kFontDefaultGlyphFirst}; // 32 (U+0020)
std::uint32_t glyphLast{kFontDefaultGlyphLast}; // 255 (Latin-1)
};
// Set-up path: validate, parse, rasterize (the only allocations).
[[nodiscard]] static Result<GlyphAtlas, ErrorCode> create(
std::span<const std::uint8_t> font, Options options) noexcept;
[[nodiscard]] bool valid() const noexcept;
[[nodiscard]] std::uint32_t atlasWidth() const noexcept;
[[nodiscard]] std::uint32_t atlasHeight() const noexcept;
[[nodiscard]] std::uint32_t fontSize() const noexcept;
[[nodiscard]] std::uint32_t lineHeight() const noexcept; // == fontSize()
[[nodiscard]] std::uint32_t glyphFirst() const noexcept;
[[nodiscard]] std::uint32_t glyphLast() const noexcept;
[[nodiscard]] std::span<const std::uint8_t> atlas() const noexcept;
[[nodiscard]] const GlyphMetrics* glyph(std::uint32_t code) const noexcept;
};Constants (CORE-005): kFontMaxAtlasDimension (4096),
kFontMaxFontSize (1024), kFontMaxGlyphCode (0x10FFFF),
kFontMaxGlyphCount (8192 — the range-size bound),
kFontDefaultAtlasWidth/Height (1024), kFontDefaultFontSize (16),
kFontDefaultGlyphFirst/Last (32..255, the Latin-1 set),
kFontFallbackCode (32 = U+0020 SPACE), kFontEmptyGlyphMetrics (the
zero metric).
All metric values are pixels at 1x — at the atlas's configured
fontSize (the stbtt pixel-height convention: the font's ascent +
descent scaled to exactly that height). To render at an integer scale
s, multiply every metric by s (exact for integer s); the atlas bitmap
is drawn at s× its texel size (the M2-SPRITE-02 sprite scale).
advance— the pen advance after the glyph (positive = right; a negative value is stored as-is — RTL/quirk fonts are out of scope, the field is total).bearingX— the bitmap's LEFT edge from the pen (right positive; negative = the ink overhangs the pen left). Place the cell atpenX + bearingX.bearingY— the bitmap's TOP edge ABOVE the baseline (up positive). The ink bottom is atbearingY − heightbelow the baseline (a descender whenheight > bearingY).width/height— the ink size in texels (0 × 0= the empty glyph — nothing is drawn, the advance still applies).lineHeight()— the line height in px at 1x:lineHeight() == fontSize()exactly (the documented formula, not a separate measurement).
The atlas is atlasWidth × atlasHeight texels of 8-bit alpha.
Codepoints are laid out in ascending order, one shelf row at a
time: the cursor starts at (0, 0); a glyph's cell is placed at the
cursor; the cursor advances right by the cell's width; when the cell
would not fit on the row it wraps to the next row (y advances by the
row's max cell height). A cell is exactly the glyph's ink bitmap
(no padding — the rasterization is clipped to the cell, so no glyph
can bleed into a neighbor; the M2-SPRITE-02 GL_NEAREST
"texel = floor(u·W)" contract applies). Empty glyphs occupy no cell
space. The packing is a pure function of (font bytes, options):
same input → bit-identical atlas bytes (the determinism test).
GlyphMetrics.atlasX/atlasY is the cell's top-left texel; the glyph's
UV rect for the M2-SPRITE-02 upload is
(atlasX/W, atlasY/H, (atlasX+width)/W, (atlasY+height)/H)
(the M2-SPRITE-03 v-axis convention: v = 0 = first uploaded texel row).
The atlas bytes are 8-bit alpha; the text-item pass
(text_items.md, M2-TEXT-02) uploads them as a
white-on-alpha RGBA8 atlas through the M2-SPRITE-02 bindAtlas
path (expandGlyphAtlasRgba8).
glyph(code) is a total function — it never crashes and, for a
live atlas, never returns null:
codein[glyphFirst, glyphLast]→ that codepoint's slot. A codepoint the font does not carry maps to the font's.notdefglyph (the stbtt unknown-codepoint → glyph 0 contract) — deterministic per font.codeOUTSIDE the range → the fallback glyph: the U+0020 space slot ifkFontFallbackCodelies in the configured range (the default does), elsekFontEmptyGlyphMetrics(the zero metric).- The stopped atlas →
nullptr(checkvalid()first).
The fallback is documented, never silent: a game that wants a visible "missing character" box configures a glyph range that includes its box glyph — the engine does not draw extra glyphs.
create validates in this order (first failure wins):
- Options (no log — the M2-SPRITE-01 create precedent):
atlasWidth/atlasHeightin[1, 4096],fontSizein[1, 1024],glyphFirst ≤ glyphLast ≤ 0x10FFFF,(glyphLast − glyphFirst + 1) ≤ 8192→InvalidArgument. - Font bytes (the untrusted-input boundary, SCALE-004): the
header + full table directory must be present (the engine-side
guard — stb's directory scan is not bounds-checked; the buf-based
table parsing is), the required tables must parse, and the
pixel-height scale (px per font unit) must be finite, positive, and
≤ 2^15 (the int-domain ceiling — the reachable max is 1024; a font
with 0 or inverted vertical metrics makes it inf or negative) →
MalformedInput+ one structured Errorfont/font_invalid(field:bytes). A truncated, non-font, or degenerate-metric file is an error, never a crash and never a silent fallback. - Capacity: a glyph whose ink cell does not fit the fixed atlas
(wider/taller than the atlas, or the shelf is full) →
BudgetExhausted+ one structured Errorfont/atlas_full(fields:code,width,height,atlas_width,atlas_height). The atlas is fixed-size by contract: grow the atlas, not the font. - Success → one Info
font/atlas_created(the set-up path is low-volume; the lookup path logs nothing).
A failed create returns the error; no atlas is produced.
GlyphAtlas owns its atlas bytes + slot table (move-only, the
SpriteBatcher precedent). One owner (the scene set-up thread); the
render phase reads it (the M2-GL-02 cull/batch stage uploads the
atlas + declares text quads — text_items.md,
M2-TEXT-02). The font bytes are a
non-owning span: the caller owns them (the game's asset pipeline —
the M3 asset system); they are read only during create and may be
freed immediately after. Presentation-only (ARCH-009): the atlas is
never part of the sim state hash or replay state. No GL calls
anywhere in this API.
create(set-up path, once per scene): O(glyphs × glyph-area) rasterization — 224 glyphs × 16 px ≈ a few ms for the default Latin-1 range; the two allocations are the atlas bytes (1 MiB at the default 1024²) + the slot table (28 B per codepoint — 6.3 KB for Latin-1). The per-glyphstbttbitmap buffer is a transient ≤ 16 777 216 bytes (atlasWidth × atlasHeight, the capacity-bounded guard — never an int overflow, never an unbounded malloc).glyph(code)(render read path, per text quad per frame): O(1), no allocation, no logging, no GL — a bounds check + a pointer return (the zero-allocation test: 1000 mixed lookups = 0 heap blocks). The returned pointer is valid until the atlas is moved/destroyed (the slot table is never reallocated after create).- No
budgets.jsonentry: the atlas is built once at set-up, not per frame — the per-frame text cost ships withtext_items.md(M2-TEXT-02; part of the composite 50k render-CPU budget, M2-PERF-01).
Common trap: rasterizing in a per-frame code path. The atlas is a
set-up artifact — create per frame re-parses and re-rasterizes the
font (the FR-2.8 "no runtime re-rasterization" contract exists for
this reason).
// Scene set-up (once): read the font file (the asset pipeline owns
// the bytes), build the atlas, upload it
// (expandGlyphAtlasRgba8 — M2-TEXT-02), and keep the GlyphAtlas for
// the frame pipeline's declaration stage.
std::vector<std::uint8_t> fontBytes = readAsset("fonts/vera.ttf");
auto atlas = laige::render::GlyphAtlas::create(fontBytes,
laige::render::GlyphAtlas::Options{});
if (atlas.isError()) { /* handle: the font asset is invalid */ }
fontBytes.clear(); // safe: the atlas owns a copy of the bitmap
// Per frame (M2-TEXT-02): declareText (text_items.h) turns the
// string into one quad per drawn glyph — metrics = atlas.glyph(code),
// quad uv = (atlasX/W, atlasY/H, (atlasX+width)/W, (atlasY+height)/H),
// pen advances by advance * scale.- Do not free the font bytes before
createreturns (the span is non-owning, read during creation). - Do not
createper frame — build the atlas once at scene set-up. - Do not assume
glyph(code)covers a codepoint: the range is configured (default Latin-1); outside-range codes fall back to the space glyph (the documented contract), they are never rasterized on demand. - Do not reuse the pointer from
glyph()after the atlas is moved (the slot table storage moves with the atlas).
font_sdf.md— the SDF glyph atlas (M2-TEXT-03): the same vendor boundary and metrics, the distance field, the padded cells, the threshold edge behavior.text_items.md— the text-item / layout pass (M2-TEXT-02/03) that consumes both atlases.sprite_batcher.md— the batcher the text quads are declared into.sprite_frames.md— the atlas UV conventions (the v-axis, the float-exact domain).particle_render.md— the sibling render-phase declaration pass (the pattern the text pass follows).- ADR 0009 — the stb_truetype vendoring (the dependency this step adds).