Skip to content

[M2-TEXT-02] Text items in the UI pass (string table + glyph quads) - #87

Merged
offdev merged 2 commits into
masterfrom
feat/m2-text-02-text-items
Oct 10, 2026
Merged

offdev merged 2 commits into
masterfrom
feat/m2-text-02-text-items

Conversation

@offdev

@offdev offdev commented Oct 10, 2026

Copy link
Copy Markdown
Owner

M2-TEXT-02 · Text items in the UI pass

FR-2.8 text-item half: the scene's string table (interned u32 code points, PRD §10.4 — no per-frame std::string in the path) + TextItem layout (screen space, y down) declared into the sprite batcher as batched glyph quads in the UI pass (M2-TEXT-01 consumes the glyph atlas; M2-SPRITE-02 consumes the quads).

What landed

  • laige::render text module — new header-only public header include/laige/render/text_items.h (no new .cpp, no GL calls anywhere).
  • StringTable (PRD §10.4): u32 code points in a fixed pool (two budgeted allocations at create; defaults 4096 strings / 16 384 code points, both capped at 2^24 via kStringTableMax*). intern is idempotent (same bytes return the first interned handle — shared strings share one pool range); a pool-bound overrun is BudgetExhausted + one rate-limited Warn string_table/table_full; a stopped table rejects intern with InvalidArgument (no log); move-only, the moved-from table is stopped.
  • TextItem + declareText: screen-space layout (the first line's baseline anchor, integer scale in [1, 16] — every metric × scale, exact, no fractional-pixel rasterization; left/center/right per-line alignment; maxWidth wrap in 0 or [1, 2^20] px). One SpriteItem per ink glyph: the centered quad at the ink center, scale = the ink size, the atlas cell's uv rect, depthOverride = true (the G-R11 escape hatch — counted + warned at build). One (atlas, material, blend) group = one instanced draw call (RENDER-001). The shared nextLine walk (word wrap, one collapsed space per inter-word gap, the character split for over-wide words, an over-wide single character takes its own line, leading/trailing spaces excluded) is used twice: the pre-check (any line wider than kTextMaxLineWidthPx fails InvalidArgument with NOTHING declared) and the emit; a first failed batcher.add fails the call (the quads already declared remain — the declareParticles precedent). The declare path is O(glyphs), zero allocation, zero logging, zero GL.
  • measureText — the no-wrap O(glyphs) ink-block width probe (exact int sums).
  • expandGlyphAtlasRgba8 — fills the caller's W·H·4 span with the white-on-alpha RGBA8 upload bytes (the M2-SPRITE-02 bindAtlas input; the setup path).
  • Tests — ctest -R text_items (19 tests / 6 suites; LAIGE_TEST_FONT_PATH on the entry + the unfiltered entry + both sanitizer blocks): TextStringTable (create validation no-log, intern identity + dedup, both pool bounds + the cumulative warn, stopped/moved-from), TextMeasure (width goldens against the committed Vera test font), TextLayout (the exact wrap model + alignment anchors), TextDeclare (the per-glyph quad goldens against hand-computed values, the group + order contract, the failure paths incl. the nothing-declared screen-domain check, the batcher overflow drop-oldest), TextZeroAlloc (1000 frames of a wrapped 2x-scale declare = 0 heap blocks), TextSmoke (the offscreen GL smoke: the white-on-alpha upload through the real M2-SPRITE-02 submit stage — exact pixel checks; GTEST_SKIP on no GL). The smoke's semi-transparent check documents the engine's blend on the alpha channel: SRC_ALPHA/ONE_MINUS_SRC_ALPHA applies to all four channels, so over an opaque clear the output alpha is a² + (1−a) (one driver-defined 8-bit rounding step — asserted within ±1); the rgb channels are exactly the atlas alpha.
  • Docs (DOC-006/007) — NEW docs/api/text_items.md (the full contract incl. the DOC-004 Performance section); docs/api/font.md + font.h references updated to shipped; docs/concepts/coordinates.md (§4.12 the text coordinate section, the §5 conversion row, the Related link); docs/README.md API index; src/laige-render/README.md status.
  • Roadmap — M2-TEXT-02 box checked + Shipped box; Progress Board M2 23/33 (total 69/194); Change Log row.
  • API manifest — laige-api.json regenerated LAST (1462 → 1512 symbols, 43 → 44 headers). No budgets.json entry: the per-frame text cost is part of the composite 50k render-CPU budget (M2-PERF-01); the entry count stays 17.

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

Tree ctest
build (Debug g++ 16) 118/118
build-clang (Clang 22) 118/118
build-release 107/107
build-shared 118/118
build-asan (ASan+UBSan) 115/115
build-tsan 115/115

(prior counts + 1 for the new text_items entry each; ctest -R text_items green in all six; tools/laige-include-lint OK — 75 files, 16 allowed vendored edges; tools/laige-determinism-lint OK — 0 violations.)

CI scope

Both ci:windows + ci:macos labels on this PR (the standing instruction). Windows: first MSVC compile of the text C++ (text_items.h + text_items_tests.cpp) — the C4267/C4244 audit is done against the final code (explicit casts at every size boundary; the test font read uses the house laige::testing::ReadEnvVar + fopen_s pattern). macOS: arm64 (macos-15) has no usable GL, so TextSmoke skips there; the Intel lane runs it. The full matrix runs on the merge lane.

- New laige::render text module (public header-only
  laige/render/text_items.h; no new .cpp, no GL calls): the
  FR-2.8 text-item half of the bitmap P0 text.
- StringTable (PRD 10.4, no per-frame std::string in the path):
  u32 code points in a fixed pool (two budgeted allocations at
  create; defaults 4096 strings / 16 384 code points, both capped
  at 2^24 via kStringTableMax*); intern idempotent (same bytes ->
  the first interned handle); pool-bound BudgetExhausted + one
  rate-limited Warn string_table/table_full; a stopped table
  rejects intern with InvalidArgument (no log); move-only, the
  moved-from table is stopped.
- TextItem (screen space, y down): the first line's baseline
  anchor, integer scale in [1, 16] (every metric x scale, exact),
  left/center/right per-line alignment, maxWidth wrap in 0 or
  [1, 2^20] px, the hand-typed UI z depthKey, the white-on-alpha
  SpriteTint color.
- declareText: O(glyphs), zero allocation, zero logging, zero GL.
  One SpriteItem per ink glyph (the centered quad at the ink
  center, scale = the ink size, the atlas cell uv rect,
  depthOverride = true -- the G-R11 escape hatch, counted +
  warned at build). The shared detail::nextLine walk (word wrap,
  one collapsed space per inter-word gap, the character split for
  over-wide words, an over-wide single character takes its own
  line, leading/trailing spaces excluded) is used by the
  pre-check (any line wider than kTextMaxLineWidthPx fails
  InvalidArgument with NOTHING declared) and by the emit; a
  first failed batcher.add fails the call (the quads already
  declared remain in the frame -- the declareParticles
  precedent). An empty or all-space text declares nothing.
- measureText: the no-wrap O(glyphs) ink-block width probe
  (exact int sums).
- expandGlyphAtlasRgba8: fills the caller's W*H*4 span with the
  white-on-alpha RGBA8 upload bytes (the M2-SPRITE-02 bindAtlas
  input; the setup path).
- Tests: CTest entry text_items (19 tests / 6 suites;
  LAIGE_TEST_FONT_PATH on the entry + the unfiltered entry +
  both sanitizer blocks): TextStringTable (create validation
  no-log, intern identity + dedup, both pool bounds + the
  cumulative warn, stopped/moved-from), TextMeasure (width
  goldens against the committed Vera test font), TextLayout
  (the exact wrap model + alignment anchors), TextDeclare (the
  per-glyph quad goldens against hand-computed values, the
  group + order contract, the failure paths incl. the
  nothing-declared screen-domain check, the batcher
  overflow drop-oldest), TextZeroAlloc (1000 frames of a
  wrapped 2x-scale declare = 0 heap blocks), TextSmoke (the
  offscreen GL smoke: the white-on-alpha upload through the
  real M2-SPRITE-02 submit stage, exact pixel checks -- opaque
  texels (255,255,255,255), transparent texels the clear,
  semi-transparent texels rgb exactly the atlas alpha + the
  alpha channel within +/-1 of the documented
  SRC_ALPHA/ONE_MINUS_SRC_ALPHA blend; GTEST_SKIP on no GL).
- Docs (DOC-006/007): NEW docs/api/text_items.md (full
  contract incl. the DOC-004 Performance section);
  docs/api/font.md + font.h references updated to shipped;
  docs/concepts/coordinates.md (4.12 the text coordinate
  section, the 5 conversion row, the Related link);
  docs/README.md API index; src/laige-render/README.md status.
- Roadmap: M2-TEXT-02 box checked + Shipped box; Progress
  Board M2 23/33 (total 69/194); changelog row.
- No budgets.json entry: the per-frame text cost is part of
  the composite 50k render-CPU budget (M2-PERF-01); the entry
  count stays 17.
- laige-api.json regenerated LAST (1462 -> 1512 symbols,
  43 -> 44 headers); api-real-tree green; include-lint +
  determinism-lint OK.
@offdev
offdev merged commit b1cb822 into master Oct 10, 2026
13 of 14 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