Skip to content

[M2-TEXT-01] Bitmap font atlas (stb_truetype vendored) - #85

Merged
offdev merged 3 commits into
masterfrom
feat/m2-text-01-font
Oct 8, 2026
Merged

offdev merged 3 commits into
masterfrom
feat/m2-text-01-font

Conversation

@offdev

@offdev offdev commented Oct 8, 2026

Copy link
Copy Markdown
Owner

M2-TEXT-01 · Font rasterization (bitmap, P0)

FR-2.8 bitmap half: the configured TTF/OTF font bytes + glyph range rasterized once at scene set-up into a fixed-size 8-bit alpha glyph atlas + per-glyph metrics (advance, bearing, line height — documented px at 1x). No runtime re-rasterization; glyph(code) is a total O(1) zero-alloc lookup.

What landed

  • Vendoring — stb_truetype.h (nothings/stb, pinned commit 2c980bb, tree-hash-pinned in deps.lock; ADR 0009 with the full DEP-003 justification — FreeType / hand-rolled / baked-atlas alternatives all rejected). The pinned commit's API is the glyph-index + metrics surface (no stbtt_GetFontOffsetForData / codepoint wrappers — documented in the ADR).
  • laige::render::GlyphAtlas (include/laige/render/font.h, font.cpp) — create(fontBytes, Options): options validation first (first failure wins, no log), the untrusted-font boundary (the engine-side header + table-directory guard — stbtt's directory scan is not bounds-checked — plus the degenerate vertical-metrics guard: the pixel-height scale must be finite, positive, and ≤ 2^15, the int domain of stbtt's float→int conversions), the fixed-size capacity guard (BudgetExhausted + one Error font/atlas_full), one Info font/atlas_created on success. GlyphMetrics: advance, bearingX/Y, width/height, atlasX/Y (all px at 1x; lineHeight() == fontSize() by the stbtt pixel-height convention). glyph(code): in-range → the slot (unknown codepoints → the font's .notdef), out-of-range → the U+0020 space slot (else the zero metric), stopped → nullptr — never a crash, never silent.
  • Vendor boundary — the laige-stb static target (one impl TU, the plain-compiler policy, DEP-005); the public header names no stb type (include-lint R3).
  • Tests — ctest -R font (21 tests, GL-free — every local tree + CI): the validation matrix, the untrusted-font boundary (empty / short / directory-cut / non-font / degenerate metrics — exactly one structured Error each), the stopped state, the capacity guard, the move semantics, the metric goldens measured against the committed Vera test font (tests/laige-render/assets/vera.ttf, OFL 1.1), the .notdef + out-of-range fallbacks, the atlas invariants, the deterministic-atlas contract (same font + options → identical bytes; the machine-greppable font-atlas-determinism fnv1a line — no pinned cross-platform hash: presentation-only float, ARCH-009), and the 1000-lookup zero-allocation window.
  • Docs — ADR 0009 + index, docs/api/font.md (the full contract incl. the DOC-004 Performance section), deps/README.md (the stb row + the previously-missing GLM row), the docs index, the render module README, the assets README, the roadmap (M2 22/33, total 68/194) + Change Log.
  • API manifest — laige-api.json regenerated LAST (1419 → 1462 symbols, 42 → 43 headers). No budgets.json entry: the atlas is built once at set-up, not per frame (the per-frame text cost lands with M2-TEXT-02, part of the composite 50k render-CPU budget; the repo entry count stays 17).
  • Incidental — asan-reports/ (the ASan test entries' log_path output) added to .gitignore (the ASan verification runs produce it; it was polluting git status).

Local verification (all six canonical trees, warning-clean)

Tree ctest
build (Debug g++ 16) 117/117
build-release 106/106
build-shared 117/117
build-clang 117/117
build-asan 114/114
build-tsan 114/114

(prior counts + 1 for the new font entry each; ctest -R font green in all six; tools/laige-include-lint OK — 5/10 vendored deps; tools/laige-determinism-lint OK.)

CI scope

First MSVC compile of this C++ (font.h / font.cpp / stb_truetype_impl.cpp / font_tests.cpp) — ci:windows label; the C4267/C4244 audit was done against the final code (the degenerate-scale guard keeps every float→int conversion in its int domain; explicit casts at every size_t boundary).

offdev added 2 commits October 8, 2026 21:42
- Vendor stb_truetype (deps/stb, pinned commit 2c980bb, tree-hash in
  deps.lock; ADR 0009): the FR-2.8 bitmap P0 rasterizer. The pinned
  commit's API is the glyph-index + metrics surface (no
  stbtt_GetFontOffsetForData / codepoint convenience wrappers).
- New laige::render::GlyphAtlas (public header
  laige/render/font.h, implementation font.cpp): create(fontBytes,
  Options) rasterizes the configured glyph range ONCE at scene
  set-up into a fixed 8-bit alpha atlas (deterministic shelf
  packing) + per-glyph GlyphMetrics (advance, bearingX/Y,
  width/height — px at 1x, the scale factor documented;
  lineHeight() == fontSize() by the stbtt pixel-height convention);
  glyph(code) is total, O(1), zero-allocation, never logs
  (out-of-range -> the U+0020 space slot, else the zero metric;
  unknown in-range -> the font's .notdef).
- The untrusted-font boundary (SCALE-004): the engine-side header +
  table-directory guard (stbtt's directory scan is not
  bounds-checked in the pinned commit), the GetFontOffsetForIndex /
  InitFont reject, and the degenerate vertical-metrics guard (the
  pixel-height scale must be finite, positive, and <= 2^15 — the
  int domain of stbtt's box rounding and the lround'd advance).
  MalformedInput + one structured Error font/font_invalid.
- The fixed-size capacity guard: a glyph cell wider/taller than the
  atlas, or a full shelf -> BudgetExhausted + one Error
  font/atlas_full (the atlas is fixed-size by contract); success ->
  one Info font/atlas_created (the lookup path logs nothing).
- The vendor boundary is the laige-stb static target (one impl TU,
  the plain-compiler policy of the vendor impl TUs — DEP-005); the
  public header names no stb type (include-lint R3 owner rule).
- Tests: ctest -R font (21 tests, GL-free — every local tree + CI):
  the validation matrix (no log), the untrusted-font boundary
  (empty / short / directory-cut / non-font / degenerate metrics —
  exactly one structured Error each), the stopped state, the
  capacity guard, the introspection + one-Info contract, the move
  semantics, the metric goldens measured against the committed Vera
  test font (tests/laige-render/assets/vera.ttf, OFL 1.1; the
  assets/ README documents provenance + license), the .notdef +
  out-of-range fallbacks, the atlas invariants (every cell inside
  the fixed atlas), the deterministic-atlas contract (same font +
  options -> identical bytes; the font-atlas-determinism fnv1a
  line — no pinned cross-platform hash: presentation-only float,
  ARCH-009, the scope is in-process/same-build), and the 1000-lookup
  zero-allocation window.
- Docs: ADR 0009 (DEP-003 justification; FreeType / hand-rolled /
  baked-atlas alternatives rejected), docs/api/font.md (the full
  contract incl. the DOC-004 Performance section), deps/README.md
  (the stb row + the missing GLM row), the docs/README.md index, the
  render module README, the assets README, the roadmap (M2 22/33,
  total 68/194) + Change Log.
- laige-api.json regenerated LAST (1419 -> 1462 symbols, 42 -> 43
  headers; api-real-tree green). No budgets.json entry: the atlas is
  built once at set-up, not per frame — the per-frame text cost
  lands with M2-TEXT-02 (part of the composite 50k render-CPU
  budget, M2-PERF-01; the repo entry count stays 17).
- Local verification: all six canonical trees warning-clean, full
  ctest green (build 117/117, build-release 106/106, build-shared
  117/117, build-clang 117/117, build-asan 114/114, build-tsan
  114/114 — prior counts + 1 for the new font entry each);
  tools/laige-include-lint OK (5/10 vendored deps),
  tools/laige-determinism-lint OK.
@offdev
offdev merged commit 4f81866 into master Oct 8, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant