From 748c2aa3eb0cccdf30286c1bb2eb048202037772 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 11 Oct 2026 00:25:35 +0200 Subject: [PATCH] [M2-TEXT-03] SDF glyph atlas + SDF text (distance field + threshold shader) - New laige::render SDF glyph atlas (public header laige/render/font_sdf.h, implementation font_sdf.cpp; reuses the pinned stb_truetype vendor boundary -- ADR 0009, no new dependency): the FR-2.8 SDF half of the text feature (P1). - SdfGlyphAtlas::create(fontBytes, Options{atlasWidth, atlasHeight, fontSize, glyphFirst, glyphLast, sdfRadius}): converts the font + glyph range ONCE at scene set-up into a fixed-size 8-bit signed distance field. Per glyph, the 1x ink 0.5-coverage pixels (alpha > 127 -- kSdfInsideCut) are the inside set of a two-pass 8-neighborhood distance transform (the Felice & Nienhuis 8SDD -- CORE-014: forward pass left/up-left/up/up-right, backward pass the reverse, orthogonal 1.0 / diagonal 1.414213562373095, float32) over the glyph's PADDED cell (the ink grown by the radius r in [1, 16] -- kSdfDefaultRadius 8, kSdfMaxRadius 16 -- on all four sides), quantized to byte = clamp(128 + lround(127*d/r), 0, 255) with d positive inside: 128 = the 0.5-coverage contour, the field exact within r px (byte 255/1 at exactly |d| = r), clamped to 0/255 beyond (the documented threshold edge behavior). - The metrics are the M2-TEXT-01 GlyphMetrics UNCHANGED (the layout is identical on both paths -- the width match is exact, tolerance 0); atlasX/Y are the PADDED cell's origin (the ink sits at +r/+r); shelf packing is deterministic (same font + options -> identical atlas bytes, the machine-greppable font-sdf-determinism FNV-1a line); glyph(code) is the M2-TEXT-01 fallback (total, never a crash). Options validate first (InvalidArgument, no log); the untrusted-font boundary is the M2-TEXT-01 guard (MalformedInput + one Error font/font_invalid); a padded cell not fitting the shelf is BudgetExhausted + one Error font/atlas_full (the capacity is pre-checked over ALL glyphs before any rasterization); success is one Info font/sdf_atlas_created. Move-only, stopped default state. - TextItem gained FontRenderMode mode (Bitmap = 0 the default, Sdf = 1 -- the documented scene-config key) + non-owning const SdfGlyphAtlas* sdfFont. The shared declare walk runs per glyph path: the SDF quads cover the padded cell (size (w+2r)*scale x (h+2r)*scale, left lineX0 + pen + (bearingX - r)*scale, top baseline - (bearingY + r)*scale, the padded-cell uv rect) while the pen still advances by the ink's advance*scale (wrap + layout unchanged); the measureText SDF overload returns the same width (tolerance 0); expandSdfGlyphAtlasRgba8 is the white-on-alpha upload of the field (the bindSdfAtlas input). - SpriteRenderer gained bindSdfAtlas(atlasId, w, h, rgba, threshold): the same upload contract as bindAtlas (GL_RGBA8, GL_NEAREST, GL_CLAMP_TO_EDGE, no mipmaps) + the per-atlas shader MODE + threshold in [0, 1] (the 0.5-coverage contour at 0.5; outside the domain InvalidArgument, no GL work, no log). The mode travels with the texture: a re-bind on the id replaces the texture AND the mode (the lastTexture tracking), and the submit publishes uSdf/uSdfThreshold at each group's texture bind. The fragment shader's SDF branch (the M2-SPRITE-02 pattern, extended): aa = max( length(vec2(dFdx(t.a), dFdy(t.a))), 1/255) then smoothstep(uSdfThreshold - aa, uSdfThreshold + aa, t.a) -- the screen-space-derivative AA rule: a band exactly one screen pixel wide at ANY zoom (the P1 crispness motivation, FR-2.8). - Tests: CTest entry font_sdf (17 tests / 9 suites; LAIGE_TEST_FONT_PATH on the entry + the unfiltered entry + both sanitizer blocks): FontSdfCreate (the validation matrix incl. the radius 0/17 bounds, the untrusted-font boundary -- 3 Errors font_invalid, the padded-cell capacity guard on 8x8 / 32x32 atlases -- 2 Errors atlas_full, the success introspection + 1 Info, the move-only stopped state), FontSdfMetrics (metrics parity with the bitmap atlas for every code + the padded-cell fit; the exact width match -- "Ma"@1, "Hello World"@1/@2, "W"@4, empty + whitespace-only -- tolerance 0), FontSdfDeterminism (two creates byte-identical + the font-sdf-determinism line; radius 8 vs 4 diverges), FontSdfThreshold (per ink texel: bitmap alpha > 127 <=> SDF byte >= 128; both padded-cell corners byte 0 for every ink glyph; the engine bytes vs a brute-force double min-Euclidean oracle within one quantization byte), FontSdfFallback (out-of-range -> the U+0020 slot; a narrow range's missing space -> the empty metric; stopped -> nullptr), FontSdfExpand (white-on-alpha bytes; wrong-size + stopped -> InvalidArgument), FontSdfZeroAlloc (the 1000-lookup zero-alloc window), and FontSdfSmoke (the offscreen SDF frame: 64x2 headless, bindSdfAtlas + an SDF-mode TextItem "Ma" through the real SpriteRenderer::submit -- 1 draw call / 2 instances, the deep-inside texel (field >= 128 + 2*127/r) renders exactly (255,255,255,255), the padded-cell corner renders exactly the clear color, the edge scan finds the anti-aliased band pixel (0 < rgb < 255, r==g==b) -- the threshold edge behavior in a rendered frame; GTEST_SKIPs where no usable OpenGL 3.3 environment). - No standalone budgets.json entry (the atlas builds once at set-up; the per-frame cost is part of the composite 50k render-CPU budget, M2-PERF-01 -- the pin count stays 17). - Docs: NEW docs/api/font_sdf.md (the field model, the layout, the 8SDD algorithm, the threshold edge behavior, the width match, the upload, ownership/perf/misuse, DOC-004); text_items.md (the FontRenderMode key, the padded-cell quads, the width match, the SDF upload + example); sprite_renderer.md (bindSdfAtlas + the SDF fragment branch); font.md (the shipped SDF ref); the docs index; the module README status; coordinates.md 4.12 + the conversion-row. - Verified locally (per the GPU local-execution policy, the GL smoke suites are excluded by explicit suite filter and are CI-verified only): all six canonical trees warning-free with 277/277 CPU tests each; include-lint + determinism-lint real-tree OK; laige-api.json regenerated LAST (1512 -> 1548 symbols, 44 -> 45 headers), api-real-tree green. - Roadmap: M2-TEXT-03 box checked + Shipped block; Progress Board M2 24/33 (total 70/194); changelog row. --- docs/README.md | 13 +- docs/api/font.md | 9 +- docs/api/font_sdf.md | 381 +++++++ docs/api/sprite_renderer.md | 60 +- docs/api/text_items.md | 119 ++- docs/concepts/coordinates.md | 14 +- laige-api.json | 229 +++-- roadmap/M2-rendering-2.5d.md | 42 +- roadmap/README.md | 5 +- src/laige-render/CMakeLists.txt | 10 +- src/laige-render/README.md | 41 +- src/laige-render/font_sdf.cpp | 418 ++++++++ .../include/laige/render/font_sdf.h | 344 +++++++ .../include/laige/render/sprite_renderer.h | 39 +- .../include/laige/render/text_items.h | 364 +++++-- src/laige-render/sprite_renderer.cpp | 133 ++- tests/laige-render/CMakeLists.txt | 33 +- tests/laige-render/font_sdf_tests.cpp | 938 ++++++++++++++++++ 18 files changed, 2931 insertions(+), 261 deletions(-) create mode 100644 docs/api/font_sdf.md create mode 100644 src/laige-render/font_sdf.cpp create mode 100644 src/laige-render/include/laige/render/font_sdf.h create mode 100644 tests/laige-render/font_sdf_tests.cpp diff --git a/docs/README.md b/docs/README.md index c2589d6..41fe214 100644 --- a/docs/README.md +++ b/docs/README.md @@ -361,7 +361,18 @@ still to land. [1, 16], the exact word-wrap model), one batched quad per ink glyph in the UI pass (the white-on-alpha upload through M2-SPRITE-02 `bindAtlas`, the hand-typed UI z through - `depthOverride`), and the O(glyphs) zero-allocation declare pass. + `depthOverride`), the glyph-path selection (`FontRenderMode`, + M2-TEXT-03), and the O(glyphs) zero-allocation declare pass. + - [SDF glyph atlas](api/font_sdf.md) — + `laige::render::SdfGlyphAtlas` (M2-TEXT-03, the SDF half of + FR-2.8): the same font bytes + glyph range converted ONCE at scene + set-up into a fixed-size 8-bit signed distance-field glyph atlas + (the Felice & Nienhuis 8SDD transform — CORE-014), the padded + cells, the documented field model (128 = the 0.5-coverage + contour, exact within the radius, the threshold edge behavior), + the identical M2-TEXT-01 metrics (the width match, tolerance 0), + and the deterministic packing (same font + options → identical + bytes). ## Guides diff --git a/docs/api/font.md b/docs/api/font.md index 840f1e2..ff0160b 100644 --- a/docs/api/font.md +++ b/docs/api/font.md @@ -7,8 +7,8 @@ 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`](text_items.md)); the SDF path (P1) -lands in M2-TEXT-03 on top of the same vendored rasterizer -(ADR 0009). +ships in M2-TEXT-03 ([`font_sdf.md`](font_sdf.md)) on top of the +same vendored rasterizer (ADR 0009). Public header: `src/laige-render/include/laige/render/font.h` @@ -234,8 +234,11 @@ fontBytes.clear(); // safe: the atlas owns a copy of the bitmap ## Related +- [`font_sdf.md`](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`](text_items.md) — the text-item / layout pass - (M2-TEXT-02) that consumes this atlas. + (M2-TEXT-02/03) that consumes both atlases. - [`sprite_batcher.md`](sprite_batcher.md) — the batcher the text quads are declared into. - [`sprite_frames.md`](sprite_frames.md) — the atlas UV conventions diff --git a/docs/api/font_sdf.md b/docs/api/font_sdf.md new file mode 100644 index 0000000..b21f0b7 --- /dev/null +++ b/docs/api/font_sdf.md @@ -0,0 +1,381 @@ +# SDF glyph atlas (`laige::render::SdfGlyphAtlas`) + +The SDF half of FR-2.8 — "Text rendering: ... SDF (P1)" +(M2-TEXT-03): the caller's TTF/OTF font bytes + a configured glyph +range + a distance-field radius are converted **once, at scene +set-up**, into a fixed-size 8-bit **signed distance-field** glyph +atlas + per-glyph metrics (the same metrics as the bitmap path — +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 +([`text_items.md`](text_items.md)) selects the SDF path through +`TextItem.mode` (the documented scene-config key); the SDF fragment +shader mode lives in the M2-SPRITE-02 `SpriteRenderer` +([`sprite_renderer.md`](sprite_renderer.md)) — the `bindSdfAtlas` +bind + the shader's SDF branch. + +Public header: +`src/laige-render/include/laige/render/font_sdf.h` +(implementation: `src/laige-render/font_sdf.cpp`; the vendor +boundary is the `laige-stb` target — ADR 0009, the same vendored +rasterizer as the bitmap path). Unit suite: `ctest -R font_sdf` +(`tests/laige-render/font_sdf_tests.cpp`) — pure engine data, no GL +environment required (the offscreen GL smoke GTEST_SKIPs where +absent — the documented environment contract): it runs in every +local tree and in CI, with the validation matrix, the untrusted- +font boundary, the capacity guard, the metrics parity with the +bitmap path, the exact width match, the deterministic-atlas +contract, the threshold edge behavior (against a brute-force +distance oracle), the missing-glyph fallback, the upload bytes, +and the zero-allocation lookup window. + +## Why SDF (the P1 motivation, documented) + +The bitmap path (M2-TEXT-01) rasterizes each glyph once at the +configured pixel size and scales it as a texture: at integer scales +the glyph stays aligned, but its edges are the 1x rasterization's +(a 16 px "a" at 4x is a 16 px edge stretched 4x). The SDF path +stores, per texel, the **signed distance to the glyph's +0.5-coverage contour in pixels at 1x**. The fragment shader's SDF +branch thresholds the field with a smoothstep whose anti-aliasing +band tracks the field's **screen-space derivative** (GLSL +`dFdx`/`dFdy` — the standard SDF anti-aliasing, the "screen-space +derivative" rule): the AA band is **exactly one screen pixel wide +at any zoom**, so text stays crisp at any zoom (the P1 motivation +of FR-2.8) and the rendered contour matches the 0.5-coverage +contour of the 1x rasterization, not a stretched copy of it. + +The trade is set-up cost and texture memory: the field carries the +outside distances in the padded cell around each glyph (below), +and the shader branch is a per-fragment smoothstep (the SDF atlas +is the render-phase's, not a sim-state, cost — ARCH-009). + +## The API + +```cpp +// The configured SDF glyph atlas (move-only). +class SdfGlyphAtlas { + 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) + std::uint32_t sdfRadius{kSdfDefaultRadius}; // 8 px at 1x + }; + + // Set-up path: validate, parse, rasterize, distance transform. + [[nodiscard]] static Result create( + std::span 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::uint32_t radius() const noexcept; // 0 when stopped + [[nodiscard]] std::span atlas() const noexcept; + [[nodiscard]] const GlyphMetrics* glyph(std::uint32_t code) const noexcept; +}; +``` + +Constants (`CORE-005`): `kSdfDefaultRadius` (8 px at 1x), +`kSdfMaxRadius` (16 px at 1x — the radius domain ceiling), +`kSdfInsideCut` (127 — the 0.5-coverage cut of the 1x rasterization: +a texel with alpha > 127 is inside, <= 127 is outside). The +atlas/font constants are the M2-TEXT-01 ones (`kFontMaxAtlasDimension` +4096, `kFontMaxFontSize` 1024, `kFontMaxGlyphCode` 0x10FFFF, +`kFontMaxGlyphCount` 8192, `kFontFallbackCode` 32, +`kFontEmptyGlyphMetrics` — see [`font.md`](font.md)). + +## The model (documented units) + +All metric values are **pixels at 1x** — exactly the M2-TEXT-01 +convention. The SDF metric is the M2-TEXT-01 `GlyphMetrics` +**unchanged** (advance, bearingX, bearingY, width, height are the +ink's metrics), with two SDF-specific meanings: + +- `radius()` — the distance-field **extent** in px at 1x (the + `Options::sdfRadius`): distances are stored exactly for at most + this many px on each side of the contour, and **saturate** beyond + it (the atlas cell's corners hold the saturated value — the + documented saturation). The radius domain is `[1, 16]` px: 16 px + covers the bitmap path's integer-scale domain (`kTextMaxScale` + 16 — at 16x zoom one screen px is 1/16 of an atlas px, far inside + the 16 px field), and a 16 px pad on a 16 px-font ink cell keeps + the padded cell <= ~56 px, comfortably inside the 4096^2 atlas + ceiling. +- the **padded cell**: each ink glyph's cell is its ink box grown by + the radius on all four sides (a `w x h` ink occupies a + `(w + 2r) x (h + 2r)` cell — the distance field needs the + surrounding space to carry the outside distances); +- `GlyphMetrics.atlasX/atlasY` — the **padded cell's** top-left + texel (not the ink's: the ink sits at `(atlasX + r, atlasY + r)`). + +`lineHeight()` is the line height in px at 1x: **`lineHeight() == +fontSize()`** exactly (the M2-TEXT-01 formula). + +## The atlas layout (deterministic shelf packing, padded cells) + +The atlas is `atlasWidth x atlasHeight` texels of 8-bit SDF values +(128 = the 0.5-coverage contour — the field section below). +Codepoints are laid out in **ascending order**, one shelf row at a +time: the cursor starts at (0, 0); a glyph's **padded cell** is +placed at the cursor; the cursor advances right by the padded +cell's width; when the cell would not fit on the row it wraps to +the next row (the cursor's y advances by the row's max padded +height). A cell is the padded box `(ink + 2*r) x (ink + 2*r)` (no +inter-cell margin — the field saturates at the cell's corners, so +no glyph can bleed into a neighbor; the M2-SPRITE-02 `GL_NEAREST` +"texel = floor(u*W)" contract applies). Empty glyphs (zero- +width/height ink — e.g. the space) occupy **no cell space**. The +packing is a pure function of (font bytes, options): same input to +**bit-identical atlas bytes** (the determinism test). + +The glyph's UV rect for the `SpriteRenderer` upload is the **padded +cell's** rect: `(atlasX/W, atlasY/H, (atlasX + w + 2r)/W, +(atlasY + h + 2r)/H)` (the M2-SPRITE-03 v-axis convention: v = 0 = +first uploaded texel row). The text pass declares the glyph quad +over the padded cell, so the field's outside region is what the +fragment's smoothstep sees at the glyph's screen edges. + +## The field (byte value, units, threshold edge behavior) + +Each texel stores an 8-bit value mapping a **signed distance d** +(px at 1x, to the glyph's 0.5-coverage contour) to a byte: + +```text +byte = clamp(128 + round(127 * d / r), 0, 255) +``` + +with d **positive inside** the glyph, **negative outside** (the +Felice & Nienhuis sign convention, the paper's section III-B): + +- 128 = the exact contour (d = 0); +- inside the glyph: byte >= 128, monotonically rising with distance + from the contour; +- outside the glyph: byte <= 128, monotonically falling; +- saturation: the field is **exact for |d| <= r** (byte 255/1 at + exactly |d| = r — the documented distance accuracy) and clamps to + the 0/255 extremes beyond it. + +Distances are measured **center-to-center in pixels** (a pixel is +the unit cell at its center, the 8SDD convention): the 128 +contour therefore crosses between texels, one texel deep in from +the 0.5-coverage pixels of the 1x rasterization (the standard +SDF-from-raster behavior — the rendered glyph is the same glyph as +the bitmap path's, anti-aliased by the field instead of by the 1x +raster). + +**The threshold edge behavior** (documented; the fragment +shader's contract). The `SpriteRenderer`'s SDF branch renders a +texel with + +```text +alpha = smoothstep(t - aa, t + aa, byte / 255) +``` + +where `t` is the atlas's threshold (the `bindSdfAtlas` argument, +default 0.5 = the 0.5-coverage contour = byte 128) and `aa` is the +screen-space derivative band (the shader's `dFdx`/`dFdy` of the +field, floored at one quantization step, 1/255). So: + +- a texel **within the band** (at 1x, aa ~ 1/(2r), so the band + spans ~127/r bytes either side of 128 — about one distance-pixel + either side of the contour) renders anti-aliased (alpha strictly + between 0 and 1); +- a texel **more than one distance-px inside** the glyph (byte >= + 128 + 127/r) renders **exactly opaque**; +- a texel **more than one distance-px outside** (byte <= 128 - + 127/r) renders **exactly transparent**. + +Raising `t` renders the glyph thinner (the contour retreats into +the ink); lowering it renders it fatter. The knob is usable while +the contour stays inside the padded cell: the field carries up to +r px on each side of 128. + +**Width match with the bitmap path** (the documented contract, +tolerance **0**): the SDF path's layout reads the identical +M2-TEXT-01 metrics (advance, bearing, ink size), so +`measureText` returns the **same width** for the same text on +both paths — exactly, not within a tolerance. The quads differ +only in size (the padded cell vs the ink rect) and UV rect +(padded cell vs ink cell); the pen walk, the line layout, and the +wrap are unchanged. + +## The distance-field algorithm (CORE-014) + +The field is the **8-neighborhood signed distance transform** of +the ink bitmap's 0.5-coverage pixels: the **Felice & Nienhuis 8SDD +algorithm** — L. Felice and M. Nienhuis, "Simple and Efficient +Cartesian Signed Distance Transform", IEEE TPAMI 36(3), 2014, +section III-B (the two-pass, 8-neighborhood, Euclidean-distance +dynamic program). Per glyph cell: one pass computes the Euclidean +distance from every pixel to the nearest INSIDE pixel, one pass to +the nearest OUTSIDE pixel (the paper's two passes — forward raster +scan then reverse, 8 neighbors each, orthogonal neighbors cost 1, +diagonal sqrt(2) — exact for the 8-neighborhood graph in 2D, +O(8*W*H) per pass, no auxiliary structures beyond the cell's two +float grids). The signed field is then `+dOut` on inside pixels, +`-dIn` on outside pixels, quantized by the formula above. +Determinism: a fixed sequence of float min/add/round operations — +**bit-identical for the same input on every build** (the +M2-TEXT-01 determinism scope, presentation-only float, ARCH-009). + +## Missing glyphs (the documented fallback) + +`glyph(code)` is a **total function** — it never crashes and, for +a live atlas, never returns null (the M2-TEXT-01 contract, +verbatim): + +- `code` in `[glyphFirst, glyphLast]` to that codepoint's slot. A + codepoint the font does not carry maps to the font's `.notdef` + glyph (stbtt maps unknown codepoints to glyph 0) — + deterministic per font. +- `code` **outside** the range to the **fallback glyph**: the + U+0020 space slot if `kFontFallbackCode` lies in the configured + range (the default does), else `kFontEmptyGlyphMetrics` (a zero + metric — the advance applies, nothing is drawn). +- The stopped atlas to `nullptr` (check `valid()` first). + +## Failure behavior (CORE-008) + +`create` validates in this order (first failure wins): + +1. **Options** (no log — the M2-TEXT-01 create precedent): + `atlasWidth`/`atlasHeight` in `[1, 4096]`, `fontSize` in + `[1, 1024]`, `glyphFirst <= glyphLast <= 0x10FFFF`, + `(glyphLast - glyphFirst + 1) <= 8192`, `sdfRadius` in + `[1, 16]` to `InvalidArgument`. +2. **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 must be finite, positive, and <= 2^15 + to `MalformedInput` + one structured Error `font/font_invalid` + (field: `bytes`) — the M2-TEXT-01 untrusted-font contract, + verbatim. A truncated, non-font, or degenerate-metric file is + an error, never a crash and never a silent fallback. +3. **Capacity**: a glyph whose **padded cell** does not fit the + fixed atlas (wider/taller than the atlas, or the shelf is + full) to `BudgetExhausted` + one structured Error + `font/atlas_full` (fields: `code`, `width`, `height`, + `atlas_width`, `atlas_height`). The atlas is fixed-size by + contract: grow the atlas, not the font. +4. Success to one Info `font/sdf_atlas_created` (fields: + `atlas_width`, `atlas_height`, `font_size`, `sdf_radius`, + `glyphs` — the set-up path is low-volume; the lookup path logs + nothing). + +A failed create returns the error; no atlas is produced. + +## The upload (the `bindSdfAtlas` input) + +`expandSdfGlyphAtlasRgba8` (text_items.h, M2-TEXT-03) expands the +8-bit field into white-on-alpha RGBA8 bytes — every texel becomes +`(255, 255, 255, field)` — for the M2-SPRITE-02 `bindSdfAtlas` +upload (one call per font per scene load, before the one-time +texture upload — RENDER-004). The shader's SDF branch then +thresholds the field's alpha channel (the field section above); +the multiplicative tint colors the drawn glyph exactly as the +bitmap path's tint does. + +## Ownership, threading (CORE-009, CONC-001) + +`SdfGlyphAtlas` owns its atlas bytes + slot table (move-only, the +`GlyphAtlas` precedent). One owner (the scene set-up thread); the +render phase reads it (the text pass declares the quads, the +`SpriteRenderer`'s `bindSdfAtlas` uploads the field). The font +bytes are a **non-owning span**: the caller owns them (the game's +asset pipeline); 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 header. + +## Performance (DOC-004) + +- `create` (set-up path, once per scene): O(glyphs x + glyph-area) rasterization + O(8 * cell-area) per-cell distance + transform — 224 glyphs at the default Latin-1 range is a few ms + (the bitmap path's cost plus two float grids per cell, freed on + return). The two persistent allocations are the atlas bytes + (1 MiB at the default 1024^2) + the slot table (28 B per + codepoint — 6.3 KB for Latin-1); the per-glyph bitmap buffer + (<= 16 777 216 bytes, capacity-bounded) and the two per-cell + float grids are transient. +- `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). +- The SDF fragment branch (per-fragment smoothstep on the SDF + quads only — per-atlas state, the `bindSdfAtlas` argument; the + bitmap quads take the existing branch): the per-frame text cost + is part of the composite 50k render-CPU budget (M2-PERF-01). +- No `budgets.json` entry: the atlas is built once at set-up, not + per frame (the budgets.json count stays 17 — the + `BudgetHarnessTable` pin asserts the repo entry count). + +**Common traps:** building the atlas per frame (the FR-2.8 "no +runtime re-rasterization" contract); binding the SDF field through +`bindAtlas` (the bitmap shader branch would draw the field's raw +alpha — the per-atlas mode travels with the texture bind, so the +SDF field goes through `bindSdfAtlas`); using a radius smaller +than the zoom the field must support (a 16 px zoom needs the 16 px +field — `kSdfMaxRadius`). + +## Example (performant path) + +```cpp +// Scene set-up (once): read the font file (the asset pipeline owns +// the bytes), build the SDF atlas, upload it (expandSdfGlyphAtlasRgba8, +// then bindSdfAtlas — the per-atlas shader mode), and keep the +// SdfGlyphAtlas for the frame pipeline's declaration stage. +std::vector fontBytes = readAsset("fonts/vera.ttf"); +laige::render::SdfGlyphAtlas::Options opts; // the defaults + the radius +auto sdf = laige::render::SdfGlyphAtlas::create(fontBytes, opts); +if (sdf.isError()) { /* handle: the font asset is invalid */ } +fontBytes.clear(); // safe: the atlas owns a copy of the field +// Per frame (M2-TEXT-02/03): a TextItem with mode = Sdf and sdfFont = +// &sdf declares through declareText (text_items.h) — the quads cover +// the padded cells, the pen advances by the unchanged advances, and +// measureText matches the bitmap path exactly. +``` + +## Misuse warnings + +- Do not free the font bytes before `create` returns (the span is + non-owning, read during creation). +- Do not `create` per frame — build the atlas once at scene set-up. +- Do not bind the SDF field with `bindAtlas`: the per-atlas shader + mode travels with the texture bind; the field must go through + `bindSdfAtlas` (the SDF branch thresholds it). +- 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). +- Do not pick a radius below the zoom the field must support: the + field is exact only within `radius` px of the contour + (`kSdfMaxRadius` 16 covers the `kTextMaxScale` 16 domain). + +## Related + +- [`font.md`](font.md) — the bitmap path (M2-TEXT-01) this step + builds on: the same vendor boundary, the same metrics, the same + untrusted-font contract. +- [`text_items.md`](text_items.md) — the text-item / layout pass + (M2-TEXT-02/03) that consumes both atlases: `TextItem.mode` + selects the path, the width match is exact. +- [`sprite_renderer.md`](sprite_renderer.md) — the M2-SPRITE-02 + submit stage: `bindSdfAtlas` (the per-atlas SDF mode) and the + fragment shader's SDF branch (the screen-space derivative AA). +- [`sprite_batcher.md`](sprite_batcher.md) — the batcher the text + quads are declared into. +- ADR 0009 — the stb_truetype vendoring (the dependency both font + paths share). diff --git a/docs/api/sprite_renderer.md b/docs/api/sprite_renderer.md index b220310..67d5451 100644 --- a/docs/api/sprite_renderer.md +++ b/docs/api/sprite_renderer.md @@ -10,7 +10,10 @@ below). The renderer consumes the M2-SPRITE-01 batcher's BUILT frame and makes exactly ONE `glDrawArraysInstanced` per (atlas, material, blend) group; the per-group state (one texture bind, one blend function) is set once, so state changes are O(group count), not -O(sprite count). +O(sprite count). The M2-TEXT-03 SDF mode extends the same program +with a per-atlas SDF fragment branch (the `bindSdfAtlas` bind — the +SDF glyph atlas's field is thresholded with a screen-space-derivative +AA band, so SDF text stays crisp at any zoom; the shader section). Render observability + the draw-call budget (M2-SPRITE-04, PRD §9.3 G-R2): the counters carry the profiler's per-frame fields — program @@ -88,6 +91,9 @@ class SpriteRenderer { [[nodiscard]] std::uint32_t maxDrawCalls() const noexcept; [[nodiscard]] Status bindAtlas(std::uint32_t atlasId, std::uint32_t w, std::uint32_t h, std::span rgba); + [[nodiscard]] Status bindSdfAtlas(std::uint32_t atlasId, std::uint32_t w, + std::uint32_t h, std::span rgba, + float threshold); [[nodiscard]] Status submit(SpriteBatcher& batcher, const Mat4& worldToNdc); [[nodiscard]] SpriteDrawStats frameStats() const noexcept; // last success (else zero) [[nodiscard]] SpriteDrawTotals totals() const noexcept; @@ -132,6 +138,21 @@ class SpriteRenderer { failures: `InvalidArgument` (no GL work, no logging — the precondition contract); a GL upload failure: `GlUnavailable` + one Error (`atlas_upload_failed`). +- **`bindSdfAtlas(atlasId, w, h, rgba, threshold)`** (M2-TEXT-03) is + the same set-up/asset path for an **SDF glyph atlas** + ([`font_sdf.md`](font_sdf.md)): the upload is identical (GL_RGBA8, + `GL_NEAREST`, `GL_CLAMP_TO_EDGE`, no mipmaps — the SDF quads are + 1:1-mapped, the fragment's smoothstep does the edge work), plus the + per-atlas **shader mode**: this atlas is drawn by the shader's SDF + branch (below) with `threshold ∈ [0, 1]` (the field's 0.5-coverage + contour at 0.5 — the documented default). The mode travels WITH the + texture: a re-bind on the id replaces the texture AND the mode (a + bitmap re-bind clears the SDF flag and vice versa), and the submit + publishes the matching `uSdf`/`uSdfThreshold` uniforms at each + group's texture bind (the per-atlas state — one set per group, not + per frame). Validation: the same argument domain as `bindAtlas` + + the threshold domain (outside `[0, 1]` → `InvalidArgument`, no GL + work, no log). - **`submit(batcher, worldToNdc)`** is the per-frame draw (the frame protocol: after the batch stage's `beginFrame()`/`add()`×n/`build()`). Preconditions, checked in order (first failure wins — a FAILED @@ -290,6 +311,29 @@ matrix + the atlas unit): - **Fragment**: `texture(uAtlas, vUv) * vTint` (the multiplicative RGBA tint — the `SpriteItem.tint` contract), blended per the per-group BlendMode. +- **Fragment, SDF mode (M2-TEXT-03)**: for an atlas bound through + `bindSdfAtlas`, the sampled texture's ALPHA channel is the signed + distance field (128 = the 0.5-coverage contour — font_sdf.md) and + the branch renders + + ```text + aa = max(length(vec2(dFdx(t.a), dFdy(t.a))), 1/255) + alpha = smoothstep(uSdfThreshold - aa, uSdfThreshold + aa, t.a) + out = vec4(t.rgb, alpha) * vTint + ``` + + (`t.a` is the field normalized to [0, 1]). `aa` is the field's + **screen-space derivative** (GLSL `dFdx`/`dFdy` — the standard SDF + anti-aliasing, the "screen-space derivative" rule), floored at one + quantization step (1/255): the AA band is exactly **one screen + pixel wide at any zoom** — the P1 motivation (FR-2.8: text stays + crisp at any zoom). The documented edge behavior (font_sdf.md): a + texel more than one distance-px inside the glyph renders exactly + opaque, one more than one distance-px outside renders exactly + transparent, the band between renders anti-aliased. `uSdf` / + `uSdfThreshold` are per-atlas state set at the texture bind of each + group (the 0/0.5 defaults are inert — the branch only runs when + `uSdf > 0.5`). The pass's GL state model (what `submit` establishes per frame, in order): the render-target frame buffer (`GlContext::frameBuffer()` — @@ -377,8 +421,13 @@ state. `maxSprites` — a frame above the renderer's budget fails `BudgetExhausted` (the batcher's own overflow policy bounds the frame to ITS budget first). -- `bindAtlas` is the setup/asset path (one call per atlas per scene - load), not a per-frame call: it creates a GL texture. +- `bindAtlas` / `bindSdfAtlas` are the setup/asset path (one call per + atlas per scene load), not per-frame calls: they create a GL + texture. +- An SDF field must be bound through `bindSdfAtlas` (and an SDF-mode + text item's `atlasId` must name that bind): the per-atlas shader + mode travels with the texture, and the bitmap branch would draw the + field's raw alpha (the text_items.md misuse note). - `submit` requires the batcher BUILT for the current frame — an open window with declared items fails `InvalidArgument`; it is never drawn as an empty frame (CORE-008). @@ -395,6 +444,11 @@ state. - `docs/api/sprite_batcher.md` — the declare/build half the renderer consumes (M2-SPRITE-01). +- `docs/api/font_sdf.md` — the SDF glyph atlas the `bindSdfAtlas` + upload carries (M2-TEXT-03): the field model, the threshold edge + behavior, the padded-cell quads. +- `docs/api/text_items.md` — the text pass that declares the SDF + quads (`TextItem.mode`, M2-TEXT-02/03). - `docs/api/depth_sort.md` — the back-to-front order (M2-SORT-01). - `docs/api/iso_camera.md` — the world → NDC matrix (M2-CAM-02). - `docs/api/gl_context.md` — the context, the FBO render target, the diff --git a/docs/api/text_items.md b/docs/api/text_items.md index b900ee8..85384f0 100644 --- a/docs/api/text_items.md +++ b/docs/api/text_items.md @@ -1,16 +1,20 @@ # Text items — the UI-pass text half of FR-2.8 (`laige::render` text_items) -The text-item half of FR-2.8 — "Text rendering: ... bitmap (P0)" -(M2-TEXT-02): `TextItem` declares a block of text into the sprite -batcher as **batched glyph quads in the UI pass** (screen space, y -down). The text bytes come from a **string table** (interned u32 code -points — PRD §10.4: no per-frame `std::string` in the path), the glyph -metrics come from the [bitmap font atlas](font.md) -(M2-TEXT-01), and each glyph with ink becomes one `SpriteItem` in the -batcher (M2-SPRITE-02). The text renders as ordinary instanced sprite -work — one draw call per (atlas, material, blend) group -(RENDER-001), no separate text pipeline. The SDF path (P1) lands in -M2-TEXT-03 on top of the same vendored rasterizer (ADR 0009). +The text-item half of FR-2.8 — "Text rendering: ... bitmap (P0), SDF +(P1)" (M2-TEXT-02/03): `TextItem` declares a block of text into the +sprite batcher as **batched glyph quads in the UI pass** (screen +space, y down). The text bytes come from a **string table** (interned +u32 code points — PRD §10.4: no per-frame `std::string` in the path), +the glyph metrics come from the [bitmap font atlas](font.md) +(M2-TEXT-01) or the [SDF glyph atlas](font_sdf.md) (M2-TEXT-03, +selected per item by `TextItem.mode` — the documented scene-config +key), and each glyph with ink becomes one `SpriteItem` in the batcher +(M2-SPRITE-02). The text renders as ordinary instanced sprite work — +one draw call per (atlas, material, blend) group (RENDER-001), no +separate text pipeline. Both glyph paths share the vendored +rasterizer (ADR 0009) and the identical M2-TEXT-01 metrics: the +layout, the wrap, and `measureText` are the same on both paths (the +width match is exact — tolerance 0, below). Public header: `src/laige-render/include/laige/render/text_items.h` (header-only — @@ -24,7 +28,9 @@ per-glyph quad goldens, the group + order contract, the failure paths, the 1 000-frame zero-allocation declare loop, and the offscreen GL smoke (the white-on-alpha upload through the M2-SPRITE-02 submit stage — needs a usable OpenGL 3.3 environment and `GTEST_SKIP`s -where absent). +where absent). The SDF mode's suites (the width match, the padded- +cell quads, the offscreen SDF render) live in `ctest -R font_sdf` +(font_sdf.md). ## The API @@ -59,10 +65,15 @@ class StringTable { enum class TextAlignment : std::uint8_t { Left, Center, Right }; +// The glyph path (M2-TEXT-03 — the documented scene-config key). +enum class FontRenderMode : std::uint8_t { Bitmap = 0, Sdf = 1 }; + // One text block for the UI pass (screen pixels, y down). struct TextItem { StringRef text{}; // the string-table handle - const GlyphAtlas* font{}; // non-owning (scene-owned) + const GlyphAtlas* font{}; // non-owning (scene-owned), Bitmap + FontRenderMode mode{FontRenderMode::Bitmap}; // the glyph path + const SdfGlyphAtlas* sdfFont{}; // non-owning (scene-owned), Sdf float x{}; // the block's anchor x (by alignment) float y{}; // the FIRST line's baseline std::uint32_t scale{1}; // integer scale, [1, 16] @@ -70,18 +81,24 @@ struct TextItem { TextAlignment alignment{TextAlignment::Left}; std::int32_t maxWidth{0}; // 0 = no wrap; else [1, 2^20] std::uint32_t depthKey{}; // the hand-tuned UI z (G-R11) - std::uint32_t atlasId{}; // the scene's bindAtlas id + std::uint32_t atlasId{}; // the scene's bind id (per mode) std::uint32_t materialId{}; // 0 = the default material BlendMode blend{BlendMode::Alpha}; // the text convention }; -// The no-wrap ink-block width in screen px (O(glyphs)). +// The no-wrap ink-block width in screen px (O(glyphs)). One overload +// per glyph path; the width is the SAME on both (the identical +// M2-TEXT-01 metrics — the width match, tolerance 0). [[nodiscard]] laige::Result measureText( const GlyphAtlas& font, const StringTable& table, StringRef text, std::uint32_t scale) noexcept; +[[nodiscard]] laige::Result measureText( + const SdfGlyphAtlas& font, const StringTable& table, StringRef text, + std::uint32_t scale) noexcept; // The declaration (O(glyphs) quads into the batcher; the frame -// protocol is the batcher's — see the section below). +// protocol is the batcher's — see the section below). The item's mode +// selects the font (Bitmap: `font`, Sdf: `sdfFont`). [[nodiscard]] laige::Status declareText( SpriteBatcher& batcher, const StringTable& table, TextItem item) noexcept; @@ -89,6 +106,12 @@ struct TextItem { // (the M2-SPRITE-02 `bindAtlas` input; the setup path). [[nodiscard]] laige::Status expandGlyphAtlasRgba8( const GlyphAtlas& atlas, std::span rgba) noexcept; + +// The 8-bit SDF field -> white-on-alpha RGBA8 upload bytes +// (the M2-SPRITE-02 `bindSdfAtlas` input; the setup path — +// M2-TEXT-03). +[[nodiscard]] laige::Status expandSdfGlyphAtlasRgba8( + const SdfGlyphAtlas& atlas, std::span rgba) noexcept; ``` Constants: `kStringTableMaxStrings` / `kStringTableMaxCodePoints` @@ -180,10 +203,34 @@ after the M2-TEXT-01 fallback — a space declares no quad): value (the G-R11 escape hatch — counted + warned at build, the sprite_batcher.h contract). +**The SDF glyph path (M2-TEXT-03):** the same quad model over the +glyph's **padded cell** instead of the ink rect — the ink rect grown +by the atlas's distance-field radius `r` on all four sides (the SDF +field needs the surrounding space to carry the outside distances; the +fragment's smoothstep is what produces the sharp screen-space edge). +Per glyph: the quad's size is `(w + 2r)·scale × (h + 2r)·scale`, its +left is `lineX0 + pen + (bearingX − r)·scale`, its top is +`baseline − (bearingY + r)·scale`, and its uv is the padded cell's +rect `(atlasX/W, atlasY/H, (atlasX + w + 2r)/W, (atlasY + h + 2r)/H)` +(the `GlyphMetrics.atlasX/atlasY` are already the padded cell's +origin on the SDF path — font_sdf.md). The **layout is unchanged**: +the pen still advances by the ink's `advance`·scale, so the wrap, the +line layout, and `measureText` return the SAME width on both paths — +the documented width-match contract is **tolerance 0** (both paths +read the identical M2-TEXT-01 metrics), not "within a tolerance". +The SDF atlas's texture must be bound through +`SpriteRenderer::bindSdfAtlas` (the per-atlas shader mode travels +with the texture bind — sprite_renderer.md), and the item's +`atlasId` must name it. + ## The frame protocol (the batcher's) `declareText` appends the item's quads to the batcher's open frame -window (the `beginFrame`/`add`/`build` protocol — M2-SPRITE-01). +window (the `beginFrame`/`add`/`build` protocol — M2-SPRITE-01). The +item's `mode` selects the font up front (Bitmap reads `font`, Sdf +reads `sdfFont`; a null or stopped one is `InvalidArgument`, no log — +the failure section): the walk that follows is one shared code path +for both (the metrics are the M2-TEXT-01 metrics on either atlas). Before the first quad is appended, a **pre-check walk** lays out every line: any line wider than `kTextMaxLineWidthPx` (the screen domain guard) fails the call `InvalidArgument` with **nothing declared** @@ -196,6 +243,7 @@ declared quads stay in the frame (the `declareParticles` precedent). `measureText(font, table, ref, scale)` is the no-wrap width query (O(glyphs), zero alloc): the ink-block width of one line at the scale. It is the layout half's cheap probe (UI hit-testing, menu sizing). +The SDF overload takes the same width (the width match, tolerance 0). ## The white-on-alpha upload (M2-SPRITE-02) @@ -208,14 +256,22 @@ draws the glyph at `color × alpha` (the alpha blend, the text convention). Wrong-size span or a stopped atlas → `InvalidArgument`, nothing written. +`expandSdfGlyphAtlasRgba8(sdfAtlas, rgba)` (M2-TEXT-03) does the same +for the SDF field: every texel becomes `(255, 255, 255, field)`, +uploaded through `SpriteRenderer::bindSdfAtlas(id, W, H, rgba, +threshold)`. The shader's SDF branch then thresholds the field's +alpha channel (the screen-space derivative AA — sprite_renderer.md), +and the tint colors the drawn glyph exactly as the bitmap path does. + ## Ownership, threading (CORE-009, CONC-001) - `StringTable` owns its two pools (move-only; the moved-from table is stopped — `valid()` false). The scene set-up thread creates + interns; the render phase reads it (`text`, `isValid` — read-only, no mutation after set-up). -- `TextItem.font` is a **non-owning pointer** to the scene's - `GlyphAtlas` (M2-TEXT-01) — it must outlive the frame. +- `TextItem.font` (Bitmap) and `TextItem.sdfFont` (Sdf) are + **non-owning pointers** to the scene's glyph atlas (M2-TEXT-01 / + M2-TEXT-03) — the mode's atlas must outlive the frame. - `declareText` mutates only the batcher (the frame window) and reads the table + atlas. One owner thread per batcher, as always (M2-SPRITE-01). @@ -278,6 +334,19 @@ item.color = {1.0f, 1.0f, 1.0f, 1.0f}; item.maxWidth = 220; // wrap at 220 px item.atlasId = /*the scene's atlas id*/; laige::render::declareText(batcher, strings, item); // then build() + +// The SDF path (M2-TEXT-03): the same item, mode Sdf, the SDF atlas +// (bound through bindSdfAtlas), the same width. +auto sdfAtlas = laige::render::SdfGlyphAtlas::create(fontBytes, + laige::render::SdfGlyphAtlas::Options{}); +std::vector sdfRgba; +sdfRgba.resize(1024u * 1024u * 4u); +laige::render::expandSdfGlyphAtlasRgba8(sdfAtlas, sdfRgba); +renderer.bindSdfAtlas(/*id2*/, 1024, 1024, sdfRgba, 0.5f); // the contour +item.mode = laige::render::FontRenderMode::Sdf; +item.sdfFont = &sdfAtlas; +item.atlasId = /*id2*/; +laige::render::declareText(batcher, strings, item); // same width ``` ## Misuse warnings @@ -291,11 +360,19 @@ laige::render::declareText(batcher, strings, item); // then build() table-local; the invalid-handle read is empty, by contract). - Do not set `scale` outside [1, 16] or `maxWidth` outside 0 / [1, `kTextMaxLineWidthPx`] (InvalidArgument, nothing declared). +- Do not leave an SDF-mode item's `sdfFont` null (or its Bitmap + `font` null): the mode selects the font, a null or stopped one is + InvalidArgument, nothing declared. +- Do not bind the SDF field with `bindAtlas` (the per-atlas shader + mode travels with the texture bind — the field must go through + `bindSdfAtlas`, and the item's `atlasId` must name that bind). ## Related -- [`font.md`](font.md) — the glyph atlas this pass consumes - (M2-TEXT-01). +- [`font.md`](font.md) — the bitmap glyph atlas (M2-TEXT-01). +- [`font_sdf.md`](font_sdf.md) — the SDF glyph atlas (M2-TEXT-03): + the field model, the padded cells, the threshold edge behavior, + the width match. - [`sprite_batcher.md`](sprite_batcher.md) — the batcher the text quads are declared into. - [`sprite_renderer.md`](sprite_renderer.md) — the submit stage the diff --git a/docs/concepts/coordinates.md b/docs/concepts/coordinates.md index 40d6fa0..fc6b8c0 100644 --- a/docs/concepts/coordinates.md +++ b/docs/concepts/coordinates.md @@ -435,7 +435,7 @@ deterministic sequence) — fpx16_16 bit-identical across platforms/builds, fp32_pinned same-build/same-platform. -### 4.12 The text: screen-space UI quads (M2-TEXT-02) +### 4.12 The text: screen-space UI quads (M2-TEXT-02/03) The text items ([`text_items.md`](../api/text_items.md), the UI-pass text half of FR-2.8) live in **SCREEN space, y DOWN** — the UI pass's @@ -447,7 +447,15 @@ by the scale — exact, no fractional-pixel rasterization; the only float is the final presentation conversion, ARCH-009). Each glyph with ink becomes one `SpriteItem` in the batcher (the M2-SPRITE-02 quad model: centered quad, the atlas cell's uv rect, the hand-tuned UI z -through `depthOverride` — the G-R11 escape hatch). Text is +through `depthOverride` — the G-R11 escape hatch). The item's +`FontRenderMode` selects the glyph path (M2-TEXT-03): the bitmap +atlas's ink cell (default) or the SDF atlas's **padded cell** (the +ink grown by the field radius — the SDF field needs the surrounding +space to carry the outside distances; the fragment's smoothstep +produces the screen-space edge, so the SDF text stays crisp at any +zoom). The layout is the same on both paths: the pen advances by the +identical M2-TEXT-01 metrics, so the widths match exactly (tolerance +0 — [`font_sdf.md`](../api/font_sdf.md)). Text is presentation-only (ARCH-009): never sim state, never in the sim hash or replay; the string bytes come from the scene's string table (PRD §10.4 — no per-frame `std::string`). @@ -465,7 +473,7 @@ or replay; the string bytes come from the scene's string table | Tile grid → tile quads | tile data + table keys (+ the animation's frame state; the parallax layer's `worldOffset`) → declared sprites | `laige-render` (`TileMap::declareTo` — static + animated frames, the parallax tile layer overload, M2-TILE-01/02) | **Shipped (M2-TILE-01 + M2-TILE-02)** | | Camera position → parallax offset | camera (x, y) + layer def → world-space offset (the exact formula) + wrap quads | `laige-render` (`ParallaxLayers::worldOffsetAt` / `declareTo`, M2-PAR-01) | **Shipped (M2-PAR-01)** | | Particle state → sprite items | particle (pos, depth, size, faded tint) → declared sprites (one batch per emitter set, the key from the particle's depth) | `laige-render` (`declareParticles` / `particleDepthToStepHeight` — consumes `ParticleSystem::liveParticles()`, read-only after the sim phase, M2-PART-02) | **Shipped (M2-PART-02)** | -| Text item → glyph quads | string handle + metrics + anchor (screen px, y down) → declared glyph quads (one per ink glyph; the UI z through `depthOverride`) | `laige-render` (`measureText` / `declareText` — consumes `GlyphAtlas::glyph` + the string table, M2-TEXT-02) | **Shipped (M2-TEXT-02)** | +| Text item → glyph quads | string handle + metrics + anchor (screen px, y down) → declared glyph quads (one per ink glyph; the UI z through `depthOverride`; the mode's cell — bitmap ink or SDF padded) | `laige-render` (`measureText` / `declareText` — consumes `GlyphAtlas::glyph` / `SdfGlyphAtlas::glyph` + the string table, M2-TEXT-02/03) | **Shipped (M2-TEXT-02 + M2-TEXT-03)** | ### 5.1 The isometric grid picking (M2-ISO-03) diff --git a/laige-api.json b/laige-api.json index d2d7323..3b10f3a 100644 --- a/laige-api.json +++ b/laige-api.json @@ -16,6 +16,7 @@ "src/laige-render/include/laige/render/camera.h", "src/laige-render/include/laige/render/depth_sort.h", "src/laige-render/include/laige/render/font.h", + "src/laige-render/include/laige/render/font_sdf.h", "src/laige-render/include/laige/render/frame_pipeline.h", "src/laige-render/include/laige/render/gl_context.h", "src/laige-render/include/laige/render/iso_camera.h", @@ -553,6 +554,34 @@ {"name": "laige::render::GlyphAtlas::glyphLast", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 253, "signature": "[[nodiscard]] std::uint32_t glyphLast() const noexcept", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::GlyphAtlas::atlas", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 259, "signature": "[[nodiscard]] std::span atlas() const noexcept", "summary": "The atlas bytes: 8-bit alpha, row-major, `atlasWidth() × atlasHeight()` texels (empty in the stopped state). The render read path: the text_items.h (M2-TEXT-02) uploads this as a white-on-alpha RGBA8 atlas (the M2-SPRITE-02 `bindAtlas` contract). O(1); never writes.", "budget": null, "experimental": false}, {"name": "laige::render::GlyphAtlas::glyph", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 269, "signature": "[[nodiscard]] const GlyphMetrics* glyph(std::uint32_t code) const noexcept", "summary": "The metrics for `code` (the header preamble: a total function — null only in the stopped state; the in-range slot, else the documented fallback). O(1); no allocation, no logging, no GL. The returned pointer is valid until this atlas is moved/destroyed (the slot table is member storage, never reallocated after create).", "budget": null, "experimental": false}, + {"name": "laige::render::kSdfDefaultRadius", "kind": "variable", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 233, "signature": "inline constexpr std::uint32_t kSdfDefaultRadius = 8", "summary": "Named constants (CORE-005).", "budget": null, "experimental": false}, + {"name": "laige::render::kSdfMaxRadius", "kind": "variable", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 234, "signature": "inline constexpr std::uint32_t kSdfMaxRadius = 16", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kSdfInsideCut", "kind": "variable", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 239, "signature": "inline constexpr std::uint8_t kSdfInsideCut = 127", "summary": "The 0.5-coverage cut of the 1× rasterization (the header's field section: a texel with coverage > 127 is INSIDE, ≤ 127 is OUTSIDE — the standard alpha threshold; the 128 byte value is the exact contour).", "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas", "kind": "class", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 244, "signature": "class SdfGlyphAtlas", "summary": "The configured SDF glyph atlas (M2-TEXT-03): the signed distance-field half of FR-2.8, built ONCE at scene set-up from the same font bytes + glyph range as the M2-TEXT-01 bitmap atlas.", "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 248, "signature": "struct Options", "summary": "The create options (API-006): the M2-TEXT-01 options + the distance-field radius (the header's model section).", "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::Options::atlasWidth", "kind": "variable", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 249, "signature": "std::uint32_t atlasWidth{kFontDefaultAtlasWidth}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::Options::atlasHeight", "kind": "variable", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 250, "signature": "std::uint32_t atlasHeight{kFontDefaultAtlasHeight}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::Options::fontSize", "kind": "variable", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 251, "signature": "std::uint32_t fontSize{kFontDefaultFontSize}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::Options::glyphFirst", "kind": "variable", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 252, "signature": "std::uint32_t glyphFirst{kFontDefaultGlyphFirst}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::Options::glyphLast", "kind": "variable", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 253, "signature": "std::uint32_t glyphLast{kFontDefaultGlyphLast}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::Options::sdfRadius", "kind": "variable", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 254, "signature": "std::uint32_t sdfRadius{kSdfDefaultRadius}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::create", "kind": "method", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 274, "signature": "[[nodiscard]] static laige::Result create(std::span font, Options options) noexcept", "summary": "Converts the configured glyph range of `font` into the fixed-size 8-bit SDF atlas (set-up path — the only allocations: the atlas bytes + the slot table + the per-glyph distance grids, freed on return). Validation order, first failure wins: 1. atlasWidth/atlasHeight in [1, kFontMaxAtlasDimension], fontSize in [1, kFontMaxFontSize], glyphFirst ≤ glyphLast ≤ kFontMaxGlyphCode, (glyphLast − glyphFirst + 1) ≤ kFontMaxGlyphCount, sdfRadius in [1, kSdfMaxRadius] (InvalidArgument, no log — the M2-TEXT-01 create precedent); 2. the font bytes: a non-font / truncated / corrupt file → MalformedInput + one Error `font/font_invalid` (the M2-TEXT-01 untrusted-font contract); 3. a glyph whose PADDED cell does not fit the fixed atlas → BudgetExhausted + one Error `font/atlas_full` (fields: the codepoint, its padded cell size, the atlas size). A failed create returns the error; no atlas is produced.", "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::SdfGlyphAtlas", "kind": "constructor", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 280, "signature": "SdfGlyphAtlas() noexcept = default", "summary": "The stopped state (default / moved-from): `valid()` false, `glyph()` returns null, `atlas()` empty, introspection zero — the `GlyphAtlas` stopped-state precedent. Nothing owned.", "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::SdfGlyphAtlas", "kind": "constructor", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 282, "signature": "SdfGlyphAtlas(const SdfGlyphAtlas&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 283, "signature": "SdfGlyphAtlas& operator=(const SdfGlyphAtlas&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::SdfGlyphAtlas", "kind": "constructor", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 284, "signature": "SdfGlyphAtlas(SdfGlyphAtlas&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 285, "signature": "SdfGlyphAtlas& operator=(SdfGlyphAtlas&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::~SdfGlyphAtlas", "kind": "destructor", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 286, "signature": "~SdfGlyphAtlas() noexcept = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::valid", "kind": "method", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 289, "signature": "[[nodiscard]] bool valid() const noexcept", "summary": "True iff the atlas is live (create succeeded).", "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::atlasWidth", "kind": "method", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 292, "signature": "[[nodiscard]] std::uint32_t atlasWidth() const noexcept", "summary": "The atlas dimensions (texels; zero in the stopped state).", "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::atlasHeight", "kind": "method", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 293, "signature": "[[nodiscard]] std::uint32_t atlasHeight() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::fontSize", "kind": "method", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 298, "signature": "[[nodiscard]] std::uint32_t fontSize() const noexcept", "summary": "The configured pixel height, px at 1x.", "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::lineHeight", "kind": "method", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 302, "signature": "[[nodiscard]] std::uint32_t lineHeight() const noexcept", "summary": "The line height, px at 1x: `fontSize()` (the stbtt pixel-height convention — the M2-TEXT-01 formula).", "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::glyphFirst", "kind": "method", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 305, "signature": "[[nodiscard]] std::uint32_t glyphFirst() const noexcept", "summary": "The configured glyph range (inclusive).", "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::glyphLast", "kind": "method", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 306, "signature": "[[nodiscard]] std::uint32_t glyphLast() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::radius", "kind": "method", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 310, "signature": "[[nodiscard]] std::uint32_t radius() const noexcept", "summary": "The distance-field radius, px at 1x (the header's model section; 0 in the stopped state).", "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::atlas", "kind": "method", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 318, "signature": "[[nodiscard]] std::span atlas() const noexcept", "summary": "The atlas bytes: 8-bit SDF values, row-major, `atlasWidth() × atlasHeight()` texels (128 = the 0.5-coverage contour — the header's field section; empty in the stopped state). The render read path: the text pass (text_items.h) uploads this as a white-on-alpha RGBA8 atlas (the M2-SPRITE-02 `bindSdfAtlas` contract). O(1); never writes.", "budget": null, "experimental": false}, + {"name": "laige::render::SdfGlyphAtlas::glyph", "kind": "method", "header": "src/laige-render/include/laige/render/font_sdf.h", "line": 331, "signature": "[[nodiscard]] const GlyphMetrics* glyph(std::uint32_t code) const noexcept", "summary": "The metrics for `code` (the header's fallback section: a total function — null only in the stopped state; the in-range slot, else the documented fallback). O(1); no allocation, no logging, no GL. The M2-TEXT-01 `GlyphMetrics` UNCHANGED (advance, bearingX, bearingY, width, height = the ink's metrics; `atlasX`/`atlasY` = the PADDED CELL's origin — the header's model section). The returned pointer is valid until this atlas is moved/destroyed (the slot table is member storage, never reallocated after create).", "budget": null, "experimental": false}, {"name": "laige::render::FrameDescriptor", "kind": "struct", "header": "src/laige-render/include/laige/render/frame_pipeline.h", "line": 261, "signature": "struct FrameDescriptor", "summary": "One presentation frame's handed-off data (PRD §10.2: the frame descriptor of the lock-free handoff). A 32-byte POD — one plain copy per publish (PERF-005: no pointer graph, no allocation).", "budget": null, "experimental": false}, {"name": "laige::render::FrameDescriptor::frameIndex", "kind": "variable", "header": "src/laige-render/include/laige/render/frame_pipeline.h", "line": 264, "signature": "std::uint64_t frameIndex{}", "summary": "The producer's 1-based frame counter (see the header ownership section).", "budget": null, "experimental": false}, {"name": "laige::render::FrameDescriptor::simTick", "kind": "variable", "header": "src/laige-render/include/laige/render/frame_pipeline.h", "line": 267, "signature": "std::uint64_t simTick{}", "summary": "The last completed sim tick at publish time (0 before the first tick — the GameLoop's currentTick() value).", "budget": null, "experimental": false}, @@ -904,102 +933,110 @@ {"name": "laige::render::SpriteFrameLayout::frameSpacing", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_frames.h", "line": 155, "signature": "std::uint32_t frameSpacing{}", "summary": "The gap between adjacent frames, texels (0 = packed).", "budget": null, "experimental": false}, {"name": "laige::render::SpriteFrameLayout::sheetBorder", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_frames.h", "line": 158, "signature": "std::uint32_t sheetBorder{}", "summary": "The margin between the sheet edge and the first/last frame on every side, texels (0 = flush to the edge).", "budget": null, "experimental": false}, {"name": "laige::render::spriteFrameUv", "kind": "function", "header": "src/laige-render/include/laige/render/sprite_frames.h", "line": 174, "signature": "[[nodiscard]] inline Result spriteFrameUv( std::uint32_t frameIndex, const SpriteFrameLayout& layout, std::uint32_t atlasWidth, std::uint32_t atlasHeight) noexcept", "summary": "The UV sub-rect of `frameIndex` in the `atlasWidth × atlasHeight` atlas, under `layout` (the header: the sheet model, the exactness domain, the failure contract). The frame index is 0-based row-major (frame 0 = top-left); an out-of-range index fails `InvalidArgument` — the engine NEVER wraps silently (CORE-008).", "budget": "O(1): a handful of integer checks + 4 float divisions; zero", "experimental": false}, - {"name": "laige::render::SpriteDrawStats", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 193, "signature": "struct SpriteDrawStats", "summary": "The per-frame counters of one successful submit (RENDER-001 — the state changes and draw submissions made observable; the M2-SPRITE-04 profiler feed). Read-only published state. `primitives` is 0 unless the opt-in primitive query (Options::primitiveQuery) is enabled: GL 3.3 core has no draw-call query primitive, so the dispatch count is the engine's own bookkeeping (drawCalls — what the profiler ships), and the GL-side cross-check is the PRIMITIVES_GENERATED count of the pass (2 per instance of the 4-vertex strip). The M2-SPRITE-04 fields: `programChanges` (the glUseProgram calls in the pass — 1 for every non-empty successful submit: the pass sets its own program and restores 0 at the end), `uploadBytes` (the frame's instance-upload volume, n * 52 B — the upload's RENDER-004 observable work), `renderTargetBytes` (the render-target size drawn, width * height * 4 — the render-target use), and `drawCallCapExceeded` (the G-R2 frame-graph flag: the frame's draw calls exceeded Options::maxDrawCalls — the frame was still drawn, the cap is observation, never an execution gate).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::drawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 194, "signature": "std::uint32_t drawCalls{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::textureBinds", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 195, "signature": "std::uint32_t textureBinds{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::blendChanges", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 196, "signature": "std::uint32_t blendChanges{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::programChanges", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 197, "signature": "std::uint32_t programChanges{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::instances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 198, "signature": "std::uint32_t instances{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::primitives", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 199, "signature": "std::uint32_t primitives{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::uploadBytes", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 200, "signature": "std::uint64_t uploadBytes{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::renderTargetBytes", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 201, "signature": "std::uint64_t renderTargetBytes{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::drawCallCapExceeded", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 202, "signature": "bool drawCallCapExceeded{false}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 209, "signature": "struct SpriteDrawTotals", "summary": "The since-construction totals (the frames counter counts successful submits only). The M2-SPRITE-04 profiler reads these on the owner thread (DBG-005). `capExceededFrames` counts the successful submits whose draw calls exceeded the cap (the G-R2 frame-graph total).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::frames", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 210, "signature": "std::uint64_t frames{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::drawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 211, "signature": "std::uint64_t drawCalls{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::textureBinds", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 212, "signature": "std::uint64_t textureBinds{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::blendChanges", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 213, "signature": "std::uint64_t blendChanges{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::programChanges", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 214, "signature": "std::uint64_t programChanges{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::instances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 215, "signature": "std::uint64_t instances{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::primitives", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 216, "signature": "std::uint64_t primitives{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::uploadBytes", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 217, "signature": "std::uint64_t uploadBytes{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::renderTargetBytes", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 218, "signature": "std::uint64_t renderTargetBytes{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::capExceededFrames", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 219, "signature": "std::uint64_t capExceededFrames{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer", "kind": "class", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 225, "signature": "class SpriteRenderer", "summary": "The submit stage of the frame pipeline (M2-SPRITE-02): one instanced draw call per (atlas, material, blend) group per frame (FR-2.1) + the minimal GLSL 3.30 sprite shader (the header preamble).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::kSpriteRendererMaxInstances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 229, "signature": "static constexpr std::uint32_t kSpriteRendererMaxInstances = 0xFFFFFFFFu", "summary": "The frame-slot index width (the batcher's slot domain, the instance buffer's index domain).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::kSpriteRendererMaxAtlases", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 232, "signature": "static constexpr std::uint32_t kSpriteRendererMaxAtlases = 4096u", "summary": "The atlas registry cap (the Options comment: 32 KB at 4096 slots).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::kSpriteRendererDefaultDrawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 237, "signature": "static constexpr std::uint32_t kSpriteRendererDefaultDrawCalls = 64u", "summary": "The documented default per-pass draw-call cap (G-R2, PRD §9.3): 2x the PRD §8.1 worst-case reference-scene budget of 30 draw calls — the 50k-sprite exit scene passes with 2x headroom, and a scene that blows the cap by more than 2x is visible at a glance.", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 241, "signature": "struct Options", "summary": "The submit stage's configuration (API-006): all fields are validated at create (the first failure wins, one Warn).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::Options::maxInstances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 248, "signature": "std::uint32_t maxInstances{0}", "summary": "The per-frame instance budget: the instance buffer is sized to this at create (52 B per instance). Must be >= the batcher's maxSprites. Domain [1, kSpriteRendererMaxInstances]; the practical ceiling is the driver's GL buffer size — an allocation that large fails create GlUnavailable (the clean failure, CORE-008).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::Options::maxAtlases", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 255, "signature": "std::uint32_t maxAtlases{0}", "summary": "The atlas-id domain: atlas ids are [0, maxAtlases) — the atlas registry is a flat table (16 B per slot with the M2-SPRITE-04 width/height bookkeeping). Domain [1, kSpriteRendererMaxAtlases]: 4096 slots = 64 KB — far beyond the PRD's 50k-sprite scene (<= 30 draw calls = tens of atlases at most).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::Options::maxDrawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 267, "signature": "std::uint32_t maxDrawCalls{kSpriteRendererDefaultDrawCalls}", "summary": "The per-pass draw-call cap (G-R2, PRD §9.3): the maximum instanced draw calls (== the frame's group count) ONE submit may make. A frame ABOVE the cap is still drawn (observation, never an execution gate — the G-R5 precedent): one rate-limited Warn (`sprite_renderer/draw_call_cap`) fires, the frame carries the `drawCallCapExceeded` flag (the frame-graph flag M2-PROF-01 reports), and the total `capExceededFrames` counts it. Domain [1, kSpriteRendererMaxInstances] (a frame's draw calls can never exceed its instance count — each group has >= 1 instance); the documented default is kSpriteRendererDefaultDrawCalls (64).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::Options::primitiveQuery", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 274, "signature": "bool primitiveQuery{false}", "summary": "The opt-in GL PRIMITIVES_GENERATED query around every submit (the GL-side dispatch cross-check + the M2-SPRITE-04 primitive feed). OFF by default: zero GL work, zero cost (the hot path, PERF-002). ON: one query object per submit + a glFinish — a CPU/GPU sync, a DIAGNOSTIC mode for the offscreen test/CI path and the profiler, never the shipping frame loop (RENDER-005).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::SpriteRenderer", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 283, "signature": "SpriteRenderer() noexcept", "summary": "The stopped state (the failed create / moved-from): valid() is false, every operation fails InvalidArgument, the counters read zero. No logging. Defined in the .cpp (the defaulted form would instantiate the unique_ptr destructor against the incomplete Impl — the GlContext precedent).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::~SpriteRenderer", "kind": "destructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 284, "signature": "~SpriteRenderer()", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::SpriteRenderer", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 287, "signature": "SpriteRenderer(SpriteRenderer&&) noexcept", "summary": "Defined in the .cpp, where Impl is complete (the unique_ptr move operations need the complete pointee — the GlContext precedent).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 288, "signature": "SpriteRenderer& operator=(SpriteRenderer&&) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::SpriteRenderer", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 289, "signature": "SpriteRenderer(const SpriteRenderer&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 290, "signature": "SpriteRenderer& operator=(const SpriteRenderer&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::create", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 303, "signature": "[[nodiscard]] static laige::Result create(const GlContext& ctx, Options options) noexcept", "summary": "One-time setup (the engine's renderer construction, before the frame pipeline starts — never inside a frame, PERF-002): validates the options (first failure wins, one Warn options_invalid), requires a valid context (makeCurrent, idempotent), then compiles the sprite shader, creates the quad VBO, the per-frame instance buffer (maxInstances * 52 B), the VAO, and the atlas registry. Any GL failure returns GlUnavailable with one structured Error event (no partial renderer: the failure is the stopped state). allocations (52 * maxInstances B instance buffer), no loop.", "budget": "one-time: two shader compiles, one link, three GPU", "experimental": false}, - {"name": "laige::render::SpriteRenderer::valid", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 307, "signature": "[[nodiscard]] bool valid() const noexcept", "summary": "True when the create succeeded (the stopped state is false).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::maxInstances", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 311, "signature": "[[nodiscard]] std::uint32_t maxInstances() const noexcept", "summary": "The validated configuration (the stopped state reads zero). O(1), no allocation.", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::maxAtlases", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 312, "signature": "[[nodiscard]] std::uint32_t maxAtlases() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::maxDrawCalls", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 313, "signature": "[[nodiscard]] std::uint32_t maxDrawCalls() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::bindAtlas", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 331, "signature": "[[nodiscard]] laige::Status bindAtlas(std::uint32_t atlasId, std::uint32_t width, std::uint32_t height, std::span rgba)", "summary": "Bind one atlas texture from CPU RGBA8 bytes — the SETUP/asset path (one call per atlas per scene load; the asset system re-uploads them later, M3 — RENDER-004: no unexpected GPU work in the frame hot path). The context must be current on the calling thread (makeCurrent, idempotent). The atlas id is the batcher's SpriteItem.atlasId domain: [0, maxAtlases). width/height are [1, the context's maxTextureSize]; rgba must be exactly width * height * 4 bytes. The texture is uploaded GL_RGBA8, GL_NEAREST, GL_CLAMP_TO_EDGE, no mipmaps (the sub-rects are pixel-exact — the M2-GOLD-01 contract). Re-binding an id REPLACES the texture (the old one is deleted — the last bind wins). Validation failures return InvalidArgument (no GL work, no logging — the precondition contract, the GlContext::readPixel precedent); a GL failure returns GlUnavailable + one Error. upload); no loop, no allocation beyond the texture object.", "budget": "one-time per atlas: one glTexImage2D (width*height*4 B", "experimental": false}, - {"name": "laige::render::SpriteRenderer::submit", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 374, "signature": "[[nodiscard]] laige::Status submit(SpriteBatcher& batcher, const Mat4& worldToNdc)", "summary": "The submit stage: draw the batcher's BUILT frame (the frame protocol, the header preamble). Preconditions (checked in order, the first failure wins — a failed submit leaves the frame counters ZERO and the since-construction totals UNCHANGED and draws nothing; the pass re-establishes its own GL state at the start of every successful submit, so a failure leaves no stale sprite-pass state for the next frame): 1. the renderer valid (stopped -> InvalidArgument, no logging); 2. the context valid + current (makeCurrent — idempotent; a cross-thread live takeover -> GlUnavailable, the P0 EGL contract); 3. the batcher built for the current frame (batcher.frameBuilt() — an open window is InvalidArgument: declared items are never silently dropped, CORE-008); 4. the frame's instance count <= maxInstances (else BudgetExhausted + one rate-limited Warn instance_capacity — the budget, PERF-008); 5. every group's atlas in the registry and bound (else InvalidArgument — the stateless pre-state validation, one failure per frame). After the preconditions, BEFORE any GL state: the G-R2 per-pass draw-call cap (PRD §9.3) — the frame's group count above maxDrawCalls fires one rate-limited Warn (`sprite_renderer/draw_call_cap`) and sets the frame's `drawCallCapExceeded` flag (the frame-graph flag); the frame is STILL drawn — the cap is observation, never an execution gate (the G-R5 precedent). The M2-SPRITE-04 counters (programChanges, uploadBytes, renderTargetBytes) are recorded on the frame before/during the GL work; a FAILED submit zeroes them (a failed frame reports nothing — the frameStats() contract). The frame's world -> NDC matrix (the IsoCamera / M2-PROJ-01 combined matrix — RENDER-006) is the per-frame uniform. The batcher reference is non-const only because the batcher's pool accessors are (the M2-SPRITE-01 house quirk); submit mutates nothing on the batcher. n * 52 B, G draw calls, zero allocation — the header Performance section.", "budget": "O(G * 5 + n) CPU (G groups, n instances), one upload of", "experimental": false}, - {"name": "laige::render::SpriteRenderer::frameStats", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 379, "signature": "[[nodiscard]] SpriteDrawStats frameStats() const noexcept", "summary": "The per-frame counters of the last SUCCESSFUL submit (a failed submit reads zero — it zeroes them). O(1).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::totals", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 382, "signature": "[[nodiscard]] SpriteDrawTotals totals() const noexcept", "summary": "The since-construction totals (successful submits only). O(1).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::textureMemoryBytes", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 391, "signature": "[[nodiscard]] std::uint64_t textureMemoryBytes() const noexcept", "summary": "The VRAM estimate of the atlas registry (M2-SPRITE-04, the RENDER-001 texture-memory feed): the sum of width * height * 4 bytes over the BOUND atlases (GL_RGBA8 — the upload's byte count). Updated at bindAtlas (a re-bind replaces: the old texture's bytes are subtracted, the new ones added); reads 0 in the stopped state. A GAUGE (not a per-frame counter) — it changes only on the set-up/asset path, never per frame. O(1).", "budget": null, "experimental": false}, - {"name": "laige::render::kStringTableMaxStrings", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 288, "signature": "inline constexpr std::uint32_t kStringTableMaxStrings = 1u << 24", "summary": "The string-table pool domains: [1, 2^24] each (16 777 216 strings / code points — 64 MiB of pool at the ceiling, far beyond any UI string budget; the 24-bit width is the index domain of the header table).", "budget": null, "experimental": false}, - {"name": "laige::render::kStringTableMaxCodePoints", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 289, "signature": "inline constexpr std::uint32_t kStringTableMaxCodePoints = 1u << 24", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::kStringTableDefaultStrings", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 292, "signature": "inline constexpr std::uint32_t kStringTableDefaultStrings = 4096", "summary": "The documented defaults (a typical UI scene: a few hundred distinct strings, a few thousand code points — 64 KiB of pool).", "budget": null, "experimental": false}, - {"name": "laige::render::kStringTableDefaultCodePoints", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 293, "signature": "inline constexpr std::uint32_t kStringTableDefaultCodePoints = 16384", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::kTextMaxScale", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 300, "signature": "inline constexpr std::uint32_t kTextMaxScale = 16", "summary": "The integer scale domain (the M2-TEXT-01 \"integer scale\" convention): [1, kTextMaxScale]. 16 × the 1024 px font ceiling is a 16 384 px line height — beyond any practical UI text (the layout math is int64; the 2^20 px line-width domain below is the representability guard).", "budget": null, "experimental": false}, - {"name": "laige::render::kTextMaxLineWidthPx", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 307, "signature": "inline constexpr std::uint32_t kTextMaxLineWidthPx = 1u << 20", "summary": "The line-width / max-width domain in screen px: [1, 2^20]. A 20-bit screen dimension — far beyond any UI panel, and < 2^24 so every layout coordinate inside the domain is exact in float (the M2-SPRITE-03 float-exact domain). A line wider than this is unrepresentable (the header's screen-domain section).", "budget": null, "experimental": false}, - {"name": "laige::render::kTextSpaceCode", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 311, "signature": "inline constexpr std::uint32_t kTextSpaceCode = 0x20", "summary": "The ONLY word separator in the wrap model (U+0020 SPACE — the header's wrap section; U+00A0 and friends are ordinary characters).", "budget": null, "experimental": false}, - {"name": "laige::render::StringRef", "kind": "struct", "header": "src/laige-render/include/laige/render/text_items.h", "line": 322, "signature": "struct StringRef", "summary": "A stable handle into one `StringTable` (the engine-owned string storage). Plain value: the id is dense from 0, stable for the table's lifetime (the table never moves, frees, or reallocates after create — CORE-007). No generation counter is needed (nothing is ever destroyed — the pool is fixed at create).", "budget": null, "experimental": false}, - {"name": "laige::render::StringRef::id", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 323, "signature": "std::uint32_t id{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::StringTable", "kind": "class", "header": "src/laige-render/include/laige/render/text_items.h", "line": 334, "signature": "class StringTable", "summary": "The fixed pool of interned u32 code-point strings (the header's model + failure + performance sections). Move-only (CORE-009); the default state is stopped (nothing owned; `intern` fails, `text` reads empty).", "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/text_items.h", "line": 340, "signature": "struct Options", "summary": "The table budget (API-006): both fields in [1, kStringTableMax*] (validated at create — first failure wins, InvalidArgument, no allocation). `maxCodePoints` bounds the TOTAL code points stored (across all strings); `maxStrings` the distinct string count.", "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::Options::maxStrings", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 341, "signature": "std::uint32_t maxStrings{kStringTableDefaultStrings}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::Options::maxCodePoints", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 342, "signature": "std::uint32_t maxCodePoints{kStringTableDefaultCodePoints}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::StringTable", "kind": "constructor", "header": "src/laige-render/include/laige/render/text_items.h", "line": 347, "signature": "StringTable() noexcept = default", "summary": "The stopped state (default / moved-from): valid() false, intern -> InvalidArgument, text reads empty, introspection zero.", "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::create", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 352, "signature": "[[nodiscard]] static laige::Result create(Options options) noexcept", "summary": "The set-up path (scene load): two allocations (the 8 B header per string + the 4 B pool per code point — the header's performance section).", "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::StringTable", "kind": "constructor", "header": "src/laige-render/include/laige/render/text_items.h", "line": 355, "signature": "StringTable(const StringTable&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 356, "signature": "StringTable& operator=(const StringTable&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::StringTable", "kind": "constructor", "header": "src/laige-render/include/laige/render/text_items.h", "line": 360, "signature": "StringTable(StringTable&& o) noexcept : maxStrings_(o.maxStrings_), maxCodePoints_(o.maxCodePoints_), count_(o.count_), stored_(o.stored_), headers_(std::move(o.headers_)), pool_(std::move(o.pool_))", "summary": "The move (the house pattern — the moved-from table is stopped: its storage moves out with the target, and the scalars are zeroed so the moved-from table reports the stopped state).", "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 372, "signature": "StringTable& operator=(StringTable&& o) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::~StringTable", "kind": "destructor", "header": "src/laige-render/include/laige/render/text_items.h", "line": 378, "signature": "~StringTable() noexcept = default", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::valid", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 381, "signature": "[[nodiscard]] bool valid() const noexcept", "summary": "True iff the table is live (create succeeded).", "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::intern", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 396, "signature": "[[nodiscard]] laige::Result intern(std::span codes) noexcept", "summary": "Interns `codes` (u32 code points — the header's model section): a copy lands in the pool, the handle is stable for the table's lifetime. The same bytes already interned return the SAME handle (the dedup — the identity property). An empty span interns the empty string (a 0-width text).", "budget": "O(count·len + len) (the dedup scan + the copy) — the", "experimental": false}, - {"name": "laige::render::StringTable::isValid", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 401, "signature": "[[nodiscard]] bool isValid(StringRef ref) const noexcept", "summary": "True iff `ref` names a live string (false in the stopped state or for an out-of-range id). O(1).", "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::text", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 408, "signature": "[[nodiscard]] std::span text( StringRef ref) const noexcept", "summary": "The string's code points (a non-owning view into the pool — valid until the table is destroyed). An invalid handle reads an empty span (the total read — never UB). O(1); no allocation.", "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::count", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 417, "signature": "[[nodiscard]] std::uint32_t count() const noexcept", "summary": "The live string count (0 in the stopped state). O(1).", "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::storedCodePoints", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 419, "signature": "[[nodiscard]] std::uint32_t storedCodePoints() const noexcept", "summary": "The total code points stored (0 in the stopped state). O(1).", "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::stringCapacity", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 423, "signature": "[[nodiscard]] std::uint32_t stringCapacity() const noexcept", "summary": "The create() budgets (0 in the stopped state). O(1).", "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::codePointCapacity", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 426, "signature": "[[nodiscard]] std::uint32_t codePointCapacity() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::Header::offset", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 434, "signature": "std::uint32_t offset{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::Header::length", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 435, "signature": "std::uint32_t length{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TextAlignment", "kind": "enum", "header": "src/laige-render/include/laige/render/text_items.h", "line": 523, "signature": "enum class TextAlignment : std::uint8_t", "summary": "The per-line alignment of the text block (the header's model section: the anchor semantics of TextItem.x).", "budget": null, "experimental": false}, - {"name": "laige::render::TextAlignment::Left", "kind": "enumerator", "header": "src/laige-render/include/laige/render/text_items.h", "line": 525, "signature": "Left = 0", "summary": "The anchor x is the block's LEFT edge (every line starts there).", "budget": null, "experimental": false}, - {"name": "laige::render::TextAlignment::Center", "kind": "enumerator", "header": "src/laige-render/include/laige/render/text_items.h", "line": 527, "signature": "Center = 1", "summary": "The anchor x is the block's CENTER x (each line is centered).", "budget": null, "experimental": false}, - {"name": "laige::render::TextAlignment::Right", "kind": "enumerator", "header": "src/laige-render/include/laige/render/text_items.h", "line": 529, "signature": "Right = 2", "summary": "The anchor x is the block's RIGHT edge (each line ends there).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem", "kind": "struct", "header": "src/laige-render/include/laige/render/text_items.h", "line": 538, "signature": "struct TextItem", "summary": "One declared text item (M2-TEXT-02): the value the game declares for one UI text widget (S-5: declaration, not draw). A plain value — no ownership (the `font` pointer and the `text` handle are NON-OWNING: the scene's GlyphAtlas and StringTable must outlive the frame, the header's ownership section). Screen pixels, y down (the header's model section).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::text", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 541, "signature": "StringRef text{}", "summary": "The text's string-table handle (PRD §10.4 — no per-frame std::string in the path).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::font", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 544, "signature": "const GlyphAtlas* font{}", "summary": "The glyph atlas (M2-TEXT-01) — non-owning (scene-owned; must outlive the frame).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::x", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 547, "signature": "float x{}", "summary": "The text block's anchor x, screen px (the header's model section: left/center/right edge by `alignment`).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::y", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 549, "signature": "float y{}", "summary": "The FIRST line's baseline, screen px (y down).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::scale", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 553, "signature": "std::uint32_t scale{1}", "summary": "The integer scale factor (1x = the font's rasterized size — the M2-TEXT-01 convention). Domain [1, kTextMaxScale]; validated by measureText / declareText (InvalidArgument, no log).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::color", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 557, "signature": "SpriteTint color{}", "summary": "The text color (the M2-SPRITE-02 multiplicative tint over the white-on-alpha upload — the drawn color = color × glyph alpha). Default: white.", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::alignment", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 559, "signature": "TextAlignment alignment{TextAlignment::Left}", "summary": "The per-line alignment (default: left).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::maxWidth", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 563, "signature": "std::int32_t maxWidth{0}", "summary": "The max line width, screen px (0 = no wrap — one line). Domain 0 or [1, kTextMaxLineWidthPx]; validated by declareText (InvalidArgument, no log).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::depthKey", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 568, "signature": "std::uint32_t depthKey{}", "summary": "The hand-tuned UI z (the G-R11 escape hatch — declareText sets depthOverride = true; counted + warned at build, the sprite_batcher.h G-R11 contract). The UI pass is screen-space: this is the documented order value, not a world-key workaround.", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 571, "signature": "std::uint32_t atlasId{}", "summary": "The glyph atlas's texture id (the scene's bindAtlas id — M2-SPRITE-02).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 574, "signature": "std::uint32_t materialId{}", "summary": "The material id (0 = the default material — the group key carries it per FR-2.1).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 577, "signature": "BlendMode blend{BlendMode::Alpha}", "summary": "The blend state (the group key's third field). Alpha is the text convention (the standard alpha blend over the UI panels).", "budget": null, "experimental": false}, - {"name": "laige::render::measureText", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 684, "signature": "[[nodiscard]] inline laige::Result measureText( const GlyphAtlas& font, const StringTable& table, StringRef text, std::uint32_t scale) noexcept", "summary": "The rendered ink-block width of `text` in screen px at `scale` (the header's wrap model, no-wrap: the sum of the glyph advances + one space advance per inter-word gap, leading/trailing spaces excluded — exactly what the declared quads span). O(glyphs); no allocation, no logging, no GL.", "budget": null, "experimental": false}, - {"name": "laige::render::declareText", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 717, "signature": "[[nodiscard]] inline laige::Status declareText( SpriteBatcher& batcher, const StringTable& table, const TextItem& item) noexcept", "summary": "Declares `item`'s glyphs into `batcher` under its (atlasId, materialId, blend) group (the header's model + wrap + domain sections): O(glyphs), no allocation, no logging on the happy path, no GL. The batcher must be live (created, nonzero capacity) and its frame window OPEN (the frame-protocol contract, the `declareParticles` precedent): a stopped batcher fails the first add with BudgetExhausted, a closed window with InvalidArgument. A line wider than kTextMaxLineWidthPx fails InvalidArgument BEFORE the first add (nothing declared — the pre-check). An empty text declares nothing and succeeds. The first failed add fails the call with the batcher's error (the items already declared remain in the frame — the batcher owns the frame state).", "budget": null, "experimental": false}, - {"name": "laige::render::expandGlyphAtlasRgba8", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 852, "signature": "[[nodiscard]] inline laige::Status expandGlyphAtlasRgba8( const GlyphAtlas& atlas, std::span rgba) noexcept", "summary": "Expands the 8-bit alpha glyph atlas into the white-on-alpha RGBA8 bytes the M2-SPRITE-02 `bindAtlas` uploads (one call per font per scene load — the SETUP/asset path, before the one-time texture upload, RENDER-004). Every texel becomes (255, 255, 255, alpha): the M2-SPRITE-02 shader's multiplicative tint then makes the drawn color = tint × glyph alpha (the header's glyph-quad section — the standard text coloring). O(W·H); no allocation (the caller owns the span — one W·H·4-byte buffer at set-up), no logging, no GL.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 205, "signature": "struct SpriteDrawStats", "summary": "The per-frame counters of one successful submit (RENDER-001 — the state changes and draw submissions made observable; the M2-SPRITE-04 profiler feed). Read-only published state. `primitives` is 0 unless the opt-in primitive query (Options::primitiveQuery) is enabled: GL 3.3 core has no draw-call query primitive, so the dispatch count is the engine's own bookkeeping (drawCalls — what the profiler ships), and the GL-side cross-check is the PRIMITIVES_GENERATED count of the pass (2 per instance of the 4-vertex strip). The M2-SPRITE-04 fields: `programChanges` (the glUseProgram calls in the pass — 1 for every non-empty successful submit: the pass sets its own program and restores 0 at the end), `uploadBytes` (the frame's instance-upload volume, n * 52 B — the upload's RENDER-004 observable work), `renderTargetBytes` (the render-target size drawn, width * height * 4 — the render-target use), and `drawCallCapExceeded` (the G-R2 frame-graph flag: the frame's draw calls exceeded Options::maxDrawCalls — the frame was still drawn, the cap is observation, never an execution gate).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::drawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 206, "signature": "std::uint32_t drawCalls{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::textureBinds", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 207, "signature": "std::uint32_t textureBinds{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::blendChanges", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 208, "signature": "std::uint32_t blendChanges{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::programChanges", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 209, "signature": "std::uint32_t programChanges{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::instances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 210, "signature": "std::uint32_t instances{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::primitives", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 211, "signature": "std::uint32_t primitives{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::uploadBytes", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 212, "signature": "std::uint64_t uploadBytes{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::renderTargetBytes", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 213, "signature": "std::uint64_t renderTargetBytes{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::drawCallCapExceeded", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 214, "signature": "bool drawCallCapExceeded{false}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 221, "signature": "struct SpriteDrawTotals", "summary": "The since-construction totals (the frames counter counts successful submits only). The M2-SPRITE-04 profiler reads these on the owner thread (DBG-005). `capExceededFrames` counts the successful submits whose draw calls exceeded the cap (the G-R2 frame-graph total).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::frames", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 222, "signature": "std::uint64_t frames{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::drawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 223, "signature": "std::uint64_t drawCalls{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::textureBinds", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 224, "signature": "std::uint64_t textureBinds{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::blendChanges", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 225, "signature": "std::uint64_t blendChanges{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::programChanges", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 226, "signature": "std::uint64_t programChanges{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::instances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 227, "signature": "std::uint64_t instances{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::primitives", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 228, "signature": "std::uint64_t primitives{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::uploadBytes", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 229, "signature": "std::uint64_t uploadBytes{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::renderTargetBytes", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 230, "signature": "std::uint64_t renderTargetBytes{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::capExceededFrames", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 231, "signature": "std::uint64_t capExceededFrames{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer", "kind": "class", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 237, "signature": "class SpriteRenderer", "summary": "The submit stage of the frame pipeline (M2-SPRITE-02): one instanced draw call per (atlas, material, blend) group per frame (FR-2.1) + the minimal GLSL 3.30 sprite shader (the header preamble).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::kSpriteRendererMaxInstances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 241, "signature": "static constexpr std::uint32_t kSpriteRendererMaxInstances = 0xFFFFFFFFu", "summary": "The frame-slot index width (the batcher's slot domain, the instance buffer's index domain).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::kSpriteRendererMaxAtlases", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 244, "signature": "static constexpr std::uint32_t kSpriteRendererMaxAtlases = 4096u", "summary": "The atlas registry cap (the Options comment: 32 KB at 4096 slots).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::kSpriteRendererDefaultDrawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 249, "signature": "static constexpr std::uint32_t kSpriteRendererDefaultDrawCalls = 64u", "summary": "The documented default per-pass draw-call cap (G-R2, PRD §9.3): 2x the PRD §8.1 worst-case reference-scene budget of 30 draw calls — the 50k-sprite exit scene passes with 2x headroom, and a scene that blows the cap by more than 2x is visible at a glance.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 253, "signature": "struct Options", "summary": "The submit stage's configuration (API-006): all fields are validated at create (the first failure wins, one Warn).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::Options::maxInstances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 260, "signature": "std::uint32_t maxInstances{0}", "summary": "The per-frame instance budget: the instance buffer is sized to this at create (52 B per instance). Must be >= the batcher's maxSprites. Domain [1, kSpriteRendererMaxInstances]; the practical ceiling is the driver's GL buffer size — an allocation that large fails create GlUnavailable (the clean failure, CORE-008).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::Options::maxAtlases", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 267, "signature": "std::uint32_t maxAtlases{0}", "summary": "The atlas-id domain: atlas ids are [0, maxAtlases) — the atlas registry is a flat table (16 B per slot with the M2-SPRITE-04 width/height bookkeeping). Domain [1, kSpriteRendererMaxAtlases]: 4096 slots = 64 KB — far beyond the PRD's 50k-sprite scene (<= 30 draw calls = tens of atlases at most).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::Options::maxDrawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 279, "signature": "std::uint32_t maxDrawCalls{kSpriteRendererDefaultDrawCalls}", "summary": "The per-pass draw-call cap (G-R2, PRD §9.3): the maximum instanced draw calls (== the frame's group count) ONE submit may make. A frame ABOVE the cap is still drawn (observation, never an execution gate — the G-R5 precedent): one rate-limited Warn (`sprite_renderer/draw_call_cap`) fires, the frame carries the `drawCallCapExceeded` flag (the frame-graph flag M2-PROF-01 reports), and the total `capExceededFrames` counts it. Domain [1, kSpriteRendererMaxInstances] (a frame's draw calls can never exceed its instance count — each group has >= 1 instance); the documented default is kSpriteRendererDefaultDrawCalls (64).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::Options::primitiveQuery", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 286, "signature": "bool primitiveQuery{false}", "summary": "The opt-in GL PRIMITIVES_GENERATED query around every submit (the GL-side dispatch cross-check + the M2-SPRITE-04 primitive feed). OFF by default: zero GL work, zero cost (the hot path, PERF-002). ON: one query object per submit + a glFinish — a CPU/GPU sync, a DIAGNOSTIC mode for the offscreen test/CI path and the profiler, never the shipping frame loop (RENDER-005).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::SpriteRenderer", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 295, "signature": "SpriteRenderer() noexcept", "summary": "The stopped state (the failed create / moved-from): valid() is false, every operation fails InvalidArgument, the counters read zero. No logging. Defined in the .cpp (the defaulted form would instantiate the unique_ptr destructor against the incomplete Impl — the GlContext precedent).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::~SpriteRenderer", "kind": "destructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 296, "signature": "~SpriteRenderer()", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::SpriteRenderer", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 299, "signature": "SpriteRenderer(SpriteRenderer&&) noexcept", "summary": "Defined in the .cpp, where Impl is complete (the unique_ptr move operations need the complete pointee — the GlContext precedent).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 300, "signature": "SpriteRenderer& operator=(SpriteRenderer&&) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::SpriteRenderer", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 301, "signature": "SpriteRenderer(const SpriteRenderer&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 302, "signature": "SpriteRenderer& operator=(const SpriteRenderer&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::create", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 315, "signature": "[[nodiscard]] static laige::Result create(const GlContext& ctx, Options options) noexcept", "summary": "One-time setup (the engine's renderer construction, before the frame pipeline starts — never inside a frame, PERF-002): validates the options (first failure wins, one Warn options_invalid), requires a valid context (makeCurrent, idempotent), then compiles the sprite shader, creates the quad VBO, the per-frame instance buffer (maxInstances * 52 B), the VAO, and the atlas registry. Any GL failure returns GlUnavailable with one structured Error event (no partial renderer: the failure is the stopped state). allocations (52 * maxInstances B instance buffer), no loop.", "budget": "one-time: two shader compiles, one link, three GPU", "experimental": false}, + {"name": "laige::render::SpriteRenderer::valid", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 319, "signature": "[[nodiscard]] bool valid() const noexcept", "summary": "True when the create succeeded (the stopped state is false).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::maxInstances", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 323, "signature": "[[nodiscard]] std::uint32_t maxInstances() const noexcept", "summary": "The validated configuration (the stopped state reads zero). O(1), no allocation.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::maxAtlases", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 324, "signature": "[[nodiscard]] std::uint32_t maxAtlases() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::maxDrawCalls", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 325, "signature": "[[nodiscard]] std::uint32_t maxDrawCalls() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::bindAtlas", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 343, "signature": "[[nodiscard]] laige::Status bindAtlas(std::uint32_t atlasId, std::uint32_t width, std::uint32_t height, std::span rgba)", "summary": "Bind one atlas texture from CPU RGBA8 bytes — the SETUP/asset path (one call per atlas per scene load; the asset system re-uploads them later, M3 — RENDER-004: no unexpected GPU work in the frame hot path). The context must be current on the calling thread (makeCurrent, idempotent). The atlas id is the batcher's SpriteItem.atlasId domain: [0, maxAtlases). width/height are [1, the context's maxTextureSize]; rgba must be exactly width * height * 4 bytes. The texture is uploaded GL_RGBA8, GL_NEAREST, GL_CLAMP_TO_EDGE, no mipmaps (the sub-rects are pixel-exact — the M2-GOLD-01 contract). Re-binding an id REPLACES the texture (the old one is deleted — the last bind wins). Validation failures return InvalidArgument (no GL work, no logging — the precondition contract, the GlContext::readPixel precedent); a GL failure returns GlUnavailable + one Error. upload); no loop, no allocation beyond the texture object.", "budget": "one-time per atlas: one glTexImage2D (width*height*4 B", "experimental": false}, + {"name": "laige::render::SpriteRenderer::bindSdfAtlas", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 363, "signature": "[[nodiscard]] laige::Status bindSdfAtlas(std::uint32_t atlasId, std::uint32_t width, std::uint32_t height, std::span rgba, float threshold)", "summary": "Bind one SDF atlas texture (M2-TEXT-03) — the same setup/asset path as `bindAtlas` (one call per atlas per scene load; the same GL texture contract: GL_RGBA8, GL_NEAREST, GL_CLAMP_TO_EDGE, the same argument domains and failure behavior — an invalid `threshold` is InvalidArgument with no GL work, no logging), with one extra argument: the SDF threshold in the field's 0..1 domain (the fragment shader's `uSdfThreshold`; 0.5 = the 0.5-coverage contour — the documented default). The atlas's bytes are a font_sdf.h SDF field uploaded as white-on-alpha RGBA8 (the text_items.h `expandSdfGlyphAtlasRgba8`). Re-binding an id REPLACES both the texture and the SDF state (the last bind wins). The per-group SDF branch + threshold travel with the texture bind in `submit` (the header's shader section). upload); no loop, no allocation beyond the texture object.", "budget": "one-time per atlas: one glTexImage2D (width*height*4 B", "experimental": false}, + {"name": "laige::render::SpriteRenderer::submit", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 407, "signature": "[[nodiscard]] laige::Status submit(SpriteBatcher& batcher, const Mat4& worldToNdc)", "summary": "The submit stage: draw the batcher's BUILT frame (the frame protocol, the header preamble). Preconditions (checked in order, the first failure wins — a failed submit leaves the frame counters ZERO and the since-construction totals UNCHANGED and draws nothing; the pass re-establishes its own GL state at the start of every successful submit, so a failure leaves no stale sprite-pass state for the next frame): 1. the renderer valid (stopped -> InvalidArgument, no logging); 2. the context valid + current (makeCurrent — idempotent; a cross-thread live takeover -> GlUnavailable, the P0 EGL contract); 3. the batcher built for the current frame (batcher.frameBuilt() — an open window is InvalidArgument: declared items are never silently dropped, CORE-008); 4. the frame's instance count <= maxInstances (else BudgetExhausted + one rate-limited Warn instance_capacity — the budget, PERF-008); 5. every group's atlas in the registry and bound (else InvalidArgument — the stateless pre-state validation, one failure per frame). After the preconditions, BEFORE any GL state: the G-R2 per-pass draw-call cap (PRD §9.3) — the frame's group count above maxDrawCalls fires one rate-limited Warn (`sprite_renderer/draw_call_cap`) and sets the frame's `drawCallCapExceeded` flag (the frame-graph flag); the frame is STILL drawn — the cap is observation, never an execution gate (the G-R5 precedent). The M2-SPRITE-04 counters (programChanges, uploadBytes, renderTargetBytes) are recorded on the frame before/during the GL work; a FAILED submit zeroes them (a failed frame reports nothing — the frameStats() contract). The frame's world -> NDC matrix (the IsoCamera / M2-PROJ-01 combined matrix — RENDER-006) is the per-frame uniform. The batcher reference is non-const only because the batcher's pool accessors are (the M2-SPRITE-01 house quirk); submit mutates nothing on the batcher. n * 52 B, G draw calls, zero allocation — the header Performance section.", "budget": "O(G * 5 + n) CPU (G groups, n instances), one upload of", "experimental": false}, + {"name": "laige::render::SpriteRenderer::frameStats", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 412, "signature": "[[nodiscard]] SpriteDrawStats frameStats() const noexcept", "summary": "The per-frame counters of the last SUCCESSFUL submit (a failed submit reads zero — it zeroes them). O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::totals", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 415, "signature": "[[nodiscard]] SpriteDrawTotals totals() const noexcept", "summary": "The since-construction totals (successful submits only). O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::textureMemoryBytes", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 424, "signature": "[[nodiscard]] std::uint64_t textureMemoryBytes() const noexcept", "summary": "The VRAM estimate of the atlas registry (M2-SPRITE-04, the RENDER-001 texture-memory feed): the sum of width * height * 4 bytes over the BOUND atlases (GL_RGBA8 — the upload's byte count). Updated at bindAtlas (a re-bind replaces: the old texture's bytes are subtracted, the new ones added); reads 0 in the stopped state. A GAUGE (not a per-frame counter) — it changes only on the set-up/asset path, never per frame. O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::kStringTableMaxStrings", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 328, "signature": "inline constexpr std::uint32_t kStringTableMaxStrings = 1u << 24", "summary": "The string-table pool domains: [1, 2^24] each (16 777 216 strings / code points — 64 MiB of pool at the ceiling, far beyond any UI string budget; the 24-bit width is the index domain of the header table).", "budget": null, "experimental": false}, + {"name": "laige::render::kStringTableMaxCodePoints", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 329, "signature": "inline constexpr std::uint32_t kStringTableMaxCodePoints = 1u << 24", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kStringTableDefaultStrings", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 332, "signature": "inline constexpr std::uint32_t kStringTableDefaultStrings = 4096", "summary": "The documented defaults (a typical UI scene: a few hundred distinct strings, a few thousand code points — 64 KiB of pool).", "budget": null, "experimental": false}, + {"name": "laige::render::kStringTableDefaultCodePoints", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 333, "signature": "inline constexpr std::uint32_t kStringTableDefaultCodePoints = 16384", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kTextMaxScale", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 340, "signature": "inline constexpr std::uint32_t kTextMaxScale = 16", "summary": "The integer scale domain (the M2-TEXT-01 \"integer scale\" convention): [1, kTextMaxScale]. 16 × the 1024 px font ceiling is a 16 384 px line height — beyond any practical UI text (the layout math is int64; the 2^20 px line-width domain below is the representability guard).", "budget": null, "experimental": false}, + {"name": "laige::render::kTextMaxLineWidthPx", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 347, "signature": "inline constexpr std::uint32_t kTextMaxLineWidthPx = 1u << 20", "summary": "The line-width / max-width domain in screen px: [1, 2^20]. A 20-bit screen dimension — far beyond any UI panel, and < 2^24 so every layout coordinate inside the domain is exact in float (the M2-SPRITE-03 float-exact domain). A line wider than this is unrepresentable (the header's screen-domain section).", "budget": null, "experimental": false}, + {"name": "laige::render::kTextSpaceCode", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 351, "signature": "inline constexpr std::uint32_t kTextSpaceCode = 0x20", "summary": "The ONLY word separator in the wrap model (U+0020 SPACE — the header's wrap section; U+00A0 and friends are ordinary characters).", "budget": null, "experimental": false}, + {"name": "laige::render::StringRef", "kind": "struct", "header": "src/laige-render/include/laige/render/text_items.h", "line": 362, "signature": "struct StringRef", "summary": "A stable handle into one `StringTable` (the engine-owned string storage). Plain value: the id is dense from 0, stable for the table's lifetime (the table never moves, frees, or reallocates after create — CORE-007). No generation counter is needed (nothing is ever destroyed — the pool is fixed at create).", "budget": null, "experimental": false}, + {"name": "laige::render::StringRef::id", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 363, "signature": "std::uint32_t id{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::StringTable", "kind": "class", "header": "src/laige-render/include/laige/render/text_items.h", "line": 374, "signature": "class StringTable", "summary": "The fixed pool of interned u32 code-point strings (the header's model + failure + performance sections). Move-only (CORE-009); the default state is stopped (nothing owned; `intern` fails, `text` reads empty).", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/text_items.h", "line": 380, "signature": "struct Options", "summary": "The table budget (API-006): both fields in [1, kStringTableMax*] (validated at create — first failure wins, InvalidArgument, no allocation). `maxCodePoints` bounds the TOTAL code points stored (across all strings); `maxStrings` the distinct string count.", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::Options::maxStrings", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 381, "signature": "std::uint32_t maxStrings{kStringTableDefaultStrings}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::Options::maxCodePoints", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 382, "signature": "std::uint32_t maxCodePoints{kStringTableDefaultCodePoints}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::StringTable", "kind": "constructor", "header": "src/laige-render/include/laige/render/text_items.h", "line": 387, "signature": "StringTable() noexcept = default", "summary": "The stopped state (default / moved-from): valid() false, intern -> InvalidArgument, text reads empty, introspection zero.", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::create", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 392, "signature": "[[nodiscard]] static laige::Result create(Options options) noexcept", "summary": "The set-up path (scene load): two allocations (the 8 B header per string + the 4 B pool per code point — the header's performance section).", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::StringTable", "kind": "constructor", "header": "src/laige-render/include/laige/render/text_items.h", "line": 395, "signature": "StringTable(const StringTable&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 396, "signature": "StringTable& operator=(const StringTable&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::StringTable", "kind": "constructor", "header": "src/laige-render/include/laige/render/text_items.h", "line": 400, "signature": "StringTable(StringTable&& o) noexcept : maxStrings_(o.maxStrings_), maxCodePoints_(o.maxCodePoints_), count_(o.count_), stored_(o.stored_), headers_(std::move(o.headers_)), pool_(std::move(o.pool_))", "summary": "The move (the house pattern — the moved-from table is stopped: its storage moves out with the target, and the scalars are zeroed so the moved-from table reports the stopped state).", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 412, "signature": "StringTable& operator=(StringTable&& o) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::~StringTable", "kind": "destructor", "header": "src/laige-render/include/laige/render/text_items.h", "line": 418, "signature": "~StringTable() noexcept = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::valid", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 421, "signature": "[[nodiscard]] bool valid() const noexcept", "summary": "True iff the table is live (create succeeded).", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::intern", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 436, "signature": "[[nodiscard]] laige::Result intern(std::span codes) noexcept", "summary": "Interns `codes` (u32 code points — the header's model section): a copy lands in the pool, the handle is stable for the table's lifetime. The same bytes already interned return the SAME handle (the dedup — the identity property). An empty span interns the empty string (a 0-width text).", "budget": "O(count·len + len) (the dedup scan + the copy) — the", "experimental": false}, + {"name": "laige::render::StringTable::isValid", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 441, "signature": "[[nodiscard]] bool isValid(StringRef ref) const noexcept", "summary": "True iff `ref` names a live string (false in the stopped state or for an out-of-range id). O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::text", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 448, "signature": "[[nodiscard]] std::span text( StringRef ref) const noexcept", "summary": "The string's code points (a non-owning view into the pool — valid until the table is destroyed). An invalid handle reads an empty span (the total read — never UB). O(1); no allocation.", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::count", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 457, "signature": "[[nodiscard]] std::uint32_t count() const noexcept", "summary": "The live string count (0 in the stopped state). O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::storedCodePoints", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 459, "signature": "[[nodiscard]] std::uint32_t storedCodePoints() const noexcept", "summary": "The total code points stored (0 in the stopped state). O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::stringCapacity", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 463, "signature": "[[nodiscard]] std::uint32_t stringCapacity() const noexcept", "summary": "The create() budgets (0 in the stopped state). O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::codePointCapacity", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 466, "signature": "[[nodiscard]] std::uint32_t codePointCapacity() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::Header::offset", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 474, "signature": "std::uint32_t offset{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::Header::length", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 475, "signature": "std::uint32_t length{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::FontRenderMode", "kind": "enum", "header": "src/laige-render/include/laige/render/text_items.h", "line": 568, "signature": "enum class FontRenderMode : std::uint8_t", "summary": "The glyph path of a text item (M2-TEXT-03 — the documented scene- config key that selects the font): Bitmap is the default (the M2-TEXT-01 glyph atlas — `TextItem.font`); Sdf is the signed distance-field atlas (the M2-TEXT-03 `SdfGlyphAtlas` — `TextItem.sdfFont`): the same metrics and layout, the glyph's screen edge thresholded from the field (the header's glyph-quad section).", "budget": null, "experimental": false}, + {"name": "laige::render::FontRenderMode::Bitmap", "kind": "enumerator", "header": "src/laige-render/include/laige/render/text_items.h", "line": 570, "signature": "Bitmap = 0", "summary": "The M2-TEXT-01 bitmap glyph atlas (`TextItem.font`).", "budget": null, "experimental": false}, + {"name": "laige::render::FontRenderMode::Sdf", "kind": "enumerator", "header": "src/laige-render/include/laige/render/text_items.h", "line": 573, "signature": "Sdf = 1", "summary": "The M2-TEXT-03 signed distance-field glyph atlas (`TextItem. sdfFont`).", "budget": null, "experimental": false}, + {"name": "laige::render::TextAlignment", "kind": "enum", "header": "src/laige-render/include/laige/render/text_items.h", "line": 578, "signature": "enum class TextAlignment : std::uint8_t", "summary": "The per-line alignment of the text block (the header's model section: the anchor semantics of TextItem.x).", "budget": null, "experimental": false}, + {"name": "laige::render::TextAlignment::Left", "kind": "enumerator", "header": "src/laige-render/include/laige/render/text_items.h", "line": 580, "signature": "Left = 0", "summary": "The anchor x is the block's LEFT edge (every line starts there).", "budget": null, "experimental": false}, + {"name": "laige::render::TextAlignment::Center", "kind": "enumerator", "header": "src/laige-render/include/laige/render/text_items.h", "line": 582, "signature": "Center = 1", "summary": "The anchor x is the block's CENTER x (each line is centered).", "budget": null, "experimental": false}, + {"name": "laige::render::TextAlignment::Right", "kind": "enumerator", "header": "src/laige-render/include/laige/render/text_items.h", "line": 584, "signature": "Right = 2", "summary": "The anchor x is the block's RIGHT edge (each line ends there).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem", "kind": "struct", "header": "src/laige-render/include/laige/render/text_items.h", "line": 593, "signature": "struct TextItem", "summary": "One declared text item (M2-TEXT-02): the value the game declares for one UI text widget (S-5: declaration, not draw). A plain value — no ownership (the `font` pointer and the `text` handle are NON-OWNING: the scene's GlyphAtlas and StringTable must outlive the frame, the header's ownership section). Screen pixels, y down (the header's model section).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::text", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 596, "signature": "StringRef text{}", "summary": "The text's string-table handle (PRD §10.4 — no per-frame std::string in the path).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::font", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 599, "signature": "const GlyphAtlas* font{}", "summary": "The glyph atlas (M2-TEXT-01) — non-owning (scene-owned; must outlive the frame). Read when `mode == FontRenderMode::Bitmap`.", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::mode", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 602, "signature": "FontRenderMode mode{FontRenderMode::Bitmap}", "summary": "The glyph path (M2-TEXT-03 — the documented scene-config key): Bitmap (default) draws from `font`, Sdf from `sdfFont`.", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::sdfFont", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 605, "signature": "const SdfGlyphAtlas* sdfFont{}", "summary": "The SDF glyph atlas (M2-TEXT-03) — non-owning (scene-owned; must outlive the frame). Read when `mode == FontRenderMode::Sdf`.", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::x", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 608, "signature": "float x{}", "summary": "The text block's anchor x, screen px (the header's model section: left/center/right edge by `alignment`).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::y", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 610, "signature": "float y{}", "summary": "The FIRST line's baseline, screen px (y down).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::scale", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 614, "signature": "std::uint32_t scale{1}", "summary": "The integer scale factor (1x = the font's rasterized size — the M2-TEXT-01 convention). Domain [1, kTextMaxScale]; validated by measureText / declareText (InvalidArgument, no log).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::color", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 618, "signature": "SpriteTint color{}", "summary": "The text color (the M2-SPRITE-02 multiplicative tint over the white-on-alpha upload — the drawn color = color × glyph alpha). Default: white.", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::alignment", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 620, "signature": "TextAlignment alignment{TextAlignment::Left}", "summary": "The per-line alignment (default: left).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::maxWidth", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 624, "signature": "std::int32_t maxWidth{0}", "summary": "The max line width, screen px (0 = no wrap — one line). Domain 0 or [1, kTextMaxLineWidthPx]; validated by declareText (InvalidArgument, no log).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::depthKey", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 629, "signature": "std::uint32_t depthKey{}", "summary": "The hand-tuned UI z (the G-R11 escape hatch — declareText sets depthOverride = true; counted + warned at build, the sprite_batcher.h G-R11 contract). The UI pass is screen-space: this is the documented order value, not a world-key workaround.", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 632, "signature": "std::uint32_t atlasId{}", "summary": "The glyph atlas's texture id (the scene's bindAtlas id — M2-SPRITE-02).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 635, "signature": "std::uint32_t materialId{}", "summary": "The material id (0 = the default material — the group key carries it per FR-2.1).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 638, "signature": "BlendMode blend{BlendMode::Alpha}", "summary": "The blend state (the group key's third field). Alpha is the text convention (the standard alpha blend over the UI panels).", "budget": null, "experimental": false}, + {"name": "laige::render::measureText", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 898, "signature": "[[nodiscard]] inline laige::Result measureText( const GlyphAtlas& font, const StringTable& table, StringRef text, std::uint32_t scale) noexcept", "summary": "The rendered ink-block width of `text` in screen px at `scale` (the header's wrap model, no-wrap: the sum of the glyph advances + one space advance per inter-word gap, leading/trailing spaces excluded — exactly what the declared quads span). O(glyphs); no allocation, no logging, no GL.", "budget": null, "experimental": false}, + {"name": "laige::render::measureText", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 907, "signature": "[[nodiscard]] inline laige::Result measureText( const SdfGlyphAtlas& font, const StringTable& table, StringRef text, std::uint32_t scale) noexcept", "summary": "The SDF overload (M2-TEXT-03): the SAME width for the same text — both paths read the identical M2-TEXT-01 metrics (the documented width-match contract, tolerance 0).", "budget": null, "experimental": false}, + {"name": "laige::render::declareText", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 936, "signature": "[[nodiscard]] inline laige::Status declareText( SpriteBatcher& batcher, const StringTable& table, const TextItem& item) noexcept", "summary": "Declares `item`'s glyphs into `batcher` under its (atlasId, materialId, blend) group (the header's model + wrap + domain sections): O(glyphs), no allocation, no logging on the happy path, no GL. The batcher must be live (created, nonzero capacity) and its frame window OPEN (the frame-protocol contract, the `declareParticles` precedent): a stopped batcher fails the first add with BudgetExhausted, a closed window with InvalidArgument. A line wider than kTextMaxLineWidthPx fails InvalidArgument BEFORE the first add (nothing declared — the pre-check). An empty text declares nothing and succeeds. The first failed add fails the call with the batcher's error (the items already declared remain in the frame — the batcher owns the frame state).", "budget": null, "experimental": false}, + {"name": "laige::render::expandGlyphAtlasRgba8", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 1016, "signature": "[[nodiscard]] inline laige::Status expandGlyphAtlasRgba8( const GlyphAtlas& atlas, std::span rgba) noexcept", "summary": "Expands the 8-bit alpha glyph atlas (M2-TEXT-01) into the white-on-alpha RGBA8 bytes the M2-SPRITE-02 `bindAtlas` uploads (see the shared implementation above — one call per font per scene load, before the one-time texture upload, RENDER-004).", "budget": null, "experimental": false}, + {"name": "laige::render::expandSdfGlyphAtlasRgba8", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 1026, "signature": "[[nodiscard]] inline laige::Status expandSdfGlyphAtlasRgba8( const SdfGlyphAtlas& atlas, std::span rgba) noexcept", "summary": "Expands the 8-bit SDF glyph atlas (M2-TEXT-03) into the white-on- alpha RGBA8 bytes the M2-SPRITE-02 `bindSdfAtlas` uploads (the same expansion — the SDF field's value is the texture's alpha channel; the shader's SDF branch thresholds it). One call per font per scene load, before the one-time texture upload (RENDER-004).", "budget": null, "experimental": false}, {"name": "laige::render::kTileAnimMaxFrames", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 311, "signature": "inline constexpr std::uint32_t kTileAnimMaxFrames = 64", "summary": "The frame budget of ONE tile animation (M2-TILE-02): the practical tile-sheet frame count (a 16 x 4 sheet); a longer cycle is the game's split into two animations (the M3 animation editor owns the authoring). The per-animation setup storage is frameCount x sizeof(SpriteUvRect) (1 KB at the cap).", "budget": null, "experimental": false}, {"name": "laige::render::kTileMapDefaultAnimations", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 318, "signature": "inline constexpr std::uint32_t kTileMapDefaultAnimations = 8", "summary": "The tilemap's animation slot count: the default — the reference scene's handful of animated tiles + headroom for custom (the M2-SCENE-01 precedent, the parallax default-layers pattern) — and the cap: the practical tileset's full animation catalog (256 slots ~= 14 KB of setup storage).", "budget": null, "experimental": false}, {"name": "laige::render::kTileMapMaxAnimations", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 319, "signature": "inline constexpr std::uint32_t kTileMapMaxAnimations = 256", "summary": null, "budget": null, "experimental": false}, diff --git a/roadmap/M2-rendering-2.5d.md b/roadmap/M2-rendering-2.5d.md index 4590de3..6d40642 100644 --- a/roadmap/M2-rendering-2.5d.md +++ b/roadmap/M2-rendering-2.5d.md @@ -305,7 +305,7 @@ if M2 slips, and its status is recorded in M2-EXIT-01. model, the per-glyph quad goldens, the failure paths, the 1000-frame zero-alloc window, the offscreen GL smoke). -- [ ] **M2-TEXT-03 · SDF font rendering (P1)** +- [x] **M2-TEXT-03 · SDF font rendering (P1)** - **Refs:** FR-2.8 (SDF — P1, placed here: it is a text/render feature next to the bitmap path; PRD §7 P1 = M6–M7), PRD §11 (stb_truetype) - **Depends:** M2-TEXT-02 - **Scope:** @@ -315,6 +315,46 @@ if M2 slips, and its status is recorded in M2-EXIT-01. - Unit tests: SDF atlas generation deterministic (same font+size → identical bytes); SDF text width matches the bitmap path within documented tolerance; threshold-edge behavior documented. - **Verify:** `ctest -R font_sdf` green. - **Size:** ~150 lines + tests + - **Shipped:** M2-TEXT-03 (`laige/render/font_sdf.h`, + `font_sdf.cpp`, plus the `SpriteRenderer` SDF mode and the + `TextItem.mode` / `FontRenderMode` key): `SdfGlyphAtlas::create` + converts the same font bytes + glyph range ONCE at scene set-up + (the vendored `stb_truetype`, ADR 0009, same pin as M2-TEXT-01) + into a fixed-size 8-bit signed distance-field atlas. Per glyph, + the 1x ink's 0.5-coverage pixels (alpha > 127) become the inside + set of a two-pass 8-neighborhood distance transform (the Felice + & Nienhuis 8SDD — CORE-014, orthogonal 1.0 / diagonal + sqrt(2)) over the glyph's PADDED cell (the ink grown by the + radius `r ∈ [1, 16]` on all four sides), quantized to + `byte = clamp(128 + round(127·d/r), 0, 255)` with d positive + inside: 128 = the 0.5-coverage contour, exact within `r` px + (byte 255/1 at exactly `|d| = r`), clamped to 0/255 beyond + (the documented threshold edge behavior — a texel more than one + distance-px inside renders exactly opaque, one more than one + outside exactly transparent, the band between anti-aliased). The + metrics are the M2-TEXT-01 `GlyphMetrics` unchanged (the layout + is identical on both paths — the width match is EXACT, tolerance + 0), `atlasX/Y` mark the padded cell's origin, the packing is + shelf-wise and deterministic (same font + options → identical + bytes, the `font-sdf-determinism` line), and `glyph(code)` is + the M2-TEXT-01 fallback (total, never a crash). `TextItem.mode` + + `TextItem.sdfFont` select the path (Bitmap the default); the + SDF quads cover the padded cell; the `SpriteRenderer` gained + `bindSdfAtlas` (the per-atlas shader mode travels with the + texture bind) and the fragment shader's SDF branch thresholds + the field with a screen-space-derivative AA band (one screen + pixel wide at any zoom — the P1 crispness motivation, + FR-2.8). The `font_sdf` suite: the validation matrix, the + untrusted-font boundary, the capacity guard, the metrics parity + + exact width match, the determinism contract, the threshold + edge behavior (byte-exact against a brute-force min-distance + oracle), the fallback, the upload bytes, the 1000-lookup + zero-alloc window, and the offscreen SDF GL smoke (the + deep-inside / corner / anti-alias-band probes; `GTEST_SKIP`s + where no usable OpenGL 3.3 environment). No standalone + `budgets.json` entry (the atlas builds once at set-up; the + per-frame cost is part of the composite 50k render-CPU budget, + M2-PERF-01). - [ ] **M2-UI-01 · UI widget tree (retained, screen-space)** - **Refs:** FR-2.8 (panel, button, text, image, list, slider, input), FR-4.4 (UI input routing base) diff --git a/roadmap/README.md b/roadmap/README.md index 7a9c2e9..f0a455c 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -157,7 +157,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. |---|---|---|---| | M0 | 22 | 22 | ✅ complete (2026-09-13, M0-EXIT-01) | | M1 | 25 | 25 | ✅ complete (M1-EXIT-01, 2026-09-25) | -| M2 | 33 | 23 | ⬜ in progress | +| M2 | 33 | 24 | ⬜ in progress | | M3 | 36 | 0 | ⬜ not started | | M4 | 12 | 0 | ⬜ not started | | M5 | 21 | 0 | ⬜ not started | @@ -165,7 +165,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | M7 | 15 | 0 | ⬜ not started | | M8 | 8 | 0 | ⬜ not started | | M9 | 6 | 0 | ⬜ proposals only | -| **Total** | **194** | **69** | | +| **Total** | **194** | **70** | | --- @@ -250,6 +250,7 @@ One line per completed (or split/renumbered) step. | 2026-10-08 | M2-PART-02 | `—` | **Budget revision (CI follow-up to M2-PART-02)** — the `particle_render_10k` gate (step-level, mean ≤ 2.0 ms, 10 000-particle declare pass, the `particle_render` ctest entry) failed the CI `Linux x64 (clang++)` reference lane on **every merge to master since M2-PART-02 landed**, on unchanged engine code: the reference lane (Clang 18.1.3, CMake Debug, ubuntu-24.04 shared runner) measures the workload at **1.91–2.68 ms**, consistently above the 2.0 ms bar (evidence — merge-lane runs 37780159655 on `bdb323b`: 2.67853 / 1.88209 ms; 37782008880 on `2f2c5a9`: 2.62795 / 1.94712 ms; 37850579155 on `4f81866`: 1.91031 / 2.07786 ms — fpx16_16 / fp32_pinned means — while the `Linux x64 (g++)` lane passed the same gated workload on the same commit `4f81866`); the 2.0 ms bar was calibrated from local-machine runs (1.07798 ms canonical g++ -O0, 1.18578 ms local Clang 23.1.1 -O0) and never verified with margin on the CI reference toolchain — the M2-ISO-02 rebaseline lesson; **revision** per methodology §5: **`budgets.json`** `target` 2.0 → **3.0** (44% above the latest recorded reference-lane value 2.07786, 12.0% above the worst observed reference-lane mean 2.67853), `measured` 1.07798 → **2.07786** (latest recorded CI reference value, worse backend, run 37850579155); **new baseline** `docs/benchmarks/baselines/m2-particle-render-budget-rebaseline.md` (the eleventh baseline, verbatim CI reports) + the baselines index entry; **docs** — `docs/api/particle_render.md` (the Budget bullet, linked to the new baseline) and the `src/laige-render/README.md` M2-PART-02 paragraph updated to the revised values in the same change (DOC-003). Step-level budget — no PRD revision (the `depth_sort_10k` precedent). No engine code, workload, or test change — budget + docs only (methodology §1: no regression occurred; this is a calibration of the gate's margin on the reference toolchain) | | 2026-10-10 | M2-TEXT-02 | `—` / PR (open) | Text items in the UI pass (FR-2.8 — the text-item half of the bitmap P0 text; M2-TEXT-02 scope, nothing else): **API** — NEW header-only `laige::render` text module (public header `include/laige/render/text_items.h`, no new `.cpp`, no GL calls): `StringTable` (the scene's text store — PRD §10.4, no per-frame `std::string` in the path): u32 code points in a fixed pool (two budgeted allocations at `create` — header table + code-point pool; defaults 4096 strings / 16 384 code points, both capped at 2^24 via `kStringTableMax*`), `intern` idempotent (same bytes return the first interned handle — the layout pass's shared-string identity), pool-bound `BudgetExhausted` + one rate-limited Warn `string_table/table_full` (fields `limit` = `"strings"`/`"code_points"`, `capacity`), stopped table rejects with `InvalidArgument` no log, move-only with the moved-from table stopped (explicit move zeroes the source); `TextItem` (the block: `StringRef` text, non-owning `GlyphAtlas*`, anchor `x`/`y` = the first line's baseline, integer `scale` in [1, 16], `SpriteTint` color, per-line `TextAlignment` left/center/right, `maxWidth` in 0 or [1, 2^20] px, the hand-typed UI z `depthKey`, atlasId/materialId/blend); `measureText` the no-wrap O(glyphs) width probe; `declareText` the declaration — the shared `detail::nextLine` walk (word wrap, one collapsed space per inter-word gap, the character split for over-wide words, the over-wide single character taking its own line, leading/trailing spaces excluded, all widths at the scale) used TWICE: the pre-check (any line wider than `kTextMaxLineWidthPx` fails the call `InvalidArgument` with NOTHING declared) then the emit (one `SpriteItem` per ink glyph — the centered quad at the ink center, `scale` = the ink size, the atlas cell's uv rect `(atlasX/W, atlasY/H, (atlasX+w)/W, (atlasY+h)/H)`, `depthOverride = true` (the G-R11 escape hatch, counted + warned at build); a first failed `batcher.add` fails the call, declared items stay); `expandGlyphAtlasRgba8` fills the caller's W·H·4 span with the white-on-alpha RGBA8 upload (the M2-SPRITE-02 `bindAtlas` input); **tests** — `tests/laige-render` CTest entry `text_items` (the step's Verify command; 19 tests / 6 suites; `LAIGE_TEST_FONT_PATH` env var on the entry + the unfiltered entry + BOTH sanitizer blocks): `TextStringTable` (create validation no-log, intern identity + dedup, the two pool bounds + the cumulative warn count, stopped/moved-from, the zero-alloc create), `TextMeasure` (width goldens against the committed Vera test font — measured advances at the runtime metrics, no-wrap), `TextLayout` (the exact wrap model: word wrap, the collapsed spaces, the character split, the over-wide character, empty/whitespace, alignment offsets), `TextDeclare` (the per-glyph quad goldens — pos/scale/uv/depthOverride against hand-computed values, the group + order contract, the failure paths — null/stopped font, invalid handle, scale/maxWidth domain, the screen-domain pre-check declares nothing, the batcher overflow drop-oldest), `TextZeroAlloc` (1 000 frames of a wrapped 2x-scale declare = 0 heap blocks, machine-greppable line), `TextSmoke` (the offscreen GL smoke — the white-on-alpha upload through the real M2-SPRITE-02 submit stage: a 64² frame, the M + a glyphs at hand-computed positions, the exact pixel checks — opaque texels → (255,255,255,255), transparent texels → the clear, semi-transparent texels → rgb exactly the atlas alpha + alpha within ±1 of the documented SRC_ALPHA/ONE_MINUS_SRC_ALPHA blend on the alpha channel; `GTEST_SKIP` on no GL, as the sprite_draw smoke); **budget** — 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); **docs** (DOC-006/007) — NEW `docs/api/text_items.md` (the full contract incl. the DOC-004 Performance section), `docs/api/font.md` (the M2-TEXT-02 references updated to shipped + cross-links), `src/laige-render/include/laige/render/font.h` (the header comments), `docs/concepts/coordinates.md` (§4.12 the text coordinate section + the §5 conversion row + the Related link), `docs/README.md` (the API index row), `src/laige-render/README.md` (the status paragraph); **local verification** — the canonical `build` tree warning-clean, `ctest -R text_items` green (19/19) + full ctest 117/118 with only `api-real-tree` pending the manifest regen; `laige-api.json` regenerated LAST; `tools/laige-include-lint` + `tools/laige-determinism-lint` OK; the five remaining trees verified (prior counts + 1 each); Progress Board M2 23/33 (total 69/194) | +| 2026-10-10 | M2-TEXT-03 | `—` / PR (open) | SDF glyph atlas + SDF text (FR-2.8 SDF P1; M2-TEXT-03 scope, nothing else): **API** — NEW `laige::render::SdfGlyphAtlas` (public header `include/laige/render/font_sdf.h`, implementation `font_sdf.cpp`; reuses the pinned `stb_truetype` vendor boundary — ADR 0009, no new dependency): `SdfGlyphAtlas::create(fontBytes, Options{atlasWidth, atlasHeight, fontSize, glyphFirst, glyphLast, sdfRadius})` converts the font bytes + glyph range ONCE at scene set-up into a fixed-size 8-bit SIGNED DISTANCE FIELD atlas: per glyph, the 1x ink's 0.5-coverage pixels (alpha > 127 — `kSdfInsideCut`) are the inside set of a two-pass 8-neighborhood distance transform (the Felice & Nienhuis 8SDD — CORE-014: forward pass left/up-left/up/up-right, backward pass the reverse, orthogonal 1.0 / diagonal 1.414213562373095, float32, INF 1e30) over the glyph's PADDED cell (the ink grown by the radius `r ∈ [1, 16]` — `kSdfDefaultRadius` = 8, `kSdfMaxRadius` = 16 — on all four sides), quantized to `byte = clamp(128 + lround(127·d/r), 0, 255)` with d positive inside: 128 = the 0.5-coverage contour, the field exact within `r` px (byte 255/1 at exactly `|d| = r`), clamped to 0/255 beyond (the corners saturate to 0 — the documented threshold edge behavior); options validate first (radius domain, dims, range — `InvalidArgument` no log), the untrusted-font boundary is the M2-TEXT-01 guard (`MalformedInput` + one Error `font/font_invalid`), a padded cell not fitting the shelf → `BudgetExhausted` + one Error `font/atlas_full` (capacity pre-checked over ALL glyphs before any rasterization), success → one Info `font/sdf_atlas_created`; move-only, stopped default state; the metrics are the M2-TEXT-01 `GlyphMetrics` UNCHANGED (the text layout is identical on both paths — the width match is EXACT, tolerance 0, pinned by the parity + width tests against the committed Vera test font), `atlasX/atlasY` are the PADDED cell's origin (the ink sits at `+r/+r`), shelf packing deterministic (same font + options → identical atlas bytes — the machine-greppable `font-sdf-determinism` FNV-1a line), `glyph(code)` the M2-TEXT-01 fallback (total — out-of-range → the U+0020 slot else the empty metric; stopped → nullptr); **text pass** — `TextItem` gained `FontRenderMode mode` (Bitmap = 0 the default, Sdf = 1 — the documented scene-config key) + non-owning `const SdfGlyphAtlas* sdfFont`; the shared declare walk now runs per glyph path: the SDF quads cover the PADDED cell (size `(w+2r)·scale × (h+2r)·scale`, left `lineX0 + pen + (bearingX − r)·scale`, top `baseline − (bearingY + r)·scale`, the uv the padded-cell rect) while the pen still advances by the ink's `advance·scale` (wrap + layout unchanged); the `measureText` SDF overload returns the same width (tolerance 0); `expandSdfGlyphAtlasRgba8` (white-on-alpha upload of the field, the `bindSdfAtlas` input); **renderer** — `SpriteRenderer::bindSdfAtlas(atlasId, w, h, rgba, threshold)` (same upload contract as `bindAtlas` + the per-atlas shader MODE + threshold ∈ [0, 1], the 0.5-coverage contour at 0.5; the mode travels with the texture — a re-bind replaces it; the submit publishes `uSdf`/`uSdfThreshold` at each group's texture bind) and the fragment shader's SDF branch (the M2-SPRITE-02 pattern, extended): `aa = max(length(vec2(dFdx(t.a), dFdy(t.a))), 1/255)` then `smoothstep(uSdfThreshold − aa, uSdfThreshold + aa, t.a)` — the screen-space derivative AA rule, a band exactly one screen pixel wide AT ANY ZOOM (the P1 crispness motivation, FR-2.8); **tests** — NEW `tests/laige-render/font_sdf_tests.cpp` (new CTest entry `font_sdf` = the step's Verify command; 17 tests / 9 suites; `LAIGE_TEST_FONT_PATH` on the entry + the unfiltered entry + both sanitizer blocks): `FontSdfCreate` (the validation matrix incl. the radius 0/17 bounds, the untrusted-font boundary — 3 Errors `font_invalid`, the padded-cell capacity guard on 8×8 / 32×32 atlases — 2 Errors `atlas_full`, the success introspection + 1 Info, the move-only stopped state), `FontSdfMetrics` (metrics parity with the bitmap atlas for every code + the padded cell fit; the exact width match — "Ma"@1, "Hello World"@1/@2, "W"@4, empty + whitespace-only — tolerance 0), `FontSdfDeterminism` (two creates byte-identical + the `font-sdf-determinism` FNV-1a line; radius 8 vs 4 diverges), `FontSdfThreshold` (the inside/outside boundary — every ink texel: bitmap alpha > 127 ⟺ SDF byte ≥ 128; the corner saturation — both padded-cell corners byte 0 for every ink glyph; the byte-exact oracle test — the 'a' field vs a brute-force double min-Euclidean distance to the opposite set within one quantization byte), `FontSdfFallback` (out-of-range → the space slot; a narrow range's missing space → the empty metric; stopped → nullptr), `FontSdfExpand` (the white-on-alpha bytes; wrong-size + stopped → `InvalidArgument`), `FontSdfZeroAlloc` (the 1000-lookup zero-alloc window), `FontSdfSmoke` (the offscreen SDF frame — 64² headless, `bindSdfAtlas` + an SDF-mode TextItem "Ma" through the real `SpriteRenderer::submit`: 1 draw call / 2 instances, the deep-inside texel (field ≥ 128 + 2·127/r) renders exactly (255,255,255,255), the padded-cell corner renders exactly the clear color, the edge scan finds the anti-aliased band pixel (0 < rgb < 255, r==g==b) — the threshold edge behavior visible in a rendered frame; `GTEST_SKIP`s where no usable OpenGL 3.3 environment); **docs** — NEW `docs/api/font_sdf.md` (the field model, the layout, the 8SDD algorithm, the threshold edge behavior, the width match, the upload, ownership/perf/misuse, DOC-004); `docs/api/text_items.md` (the `FontRenderMode` key, the padded-cell quads, the width match, the SDF upload, the SDF example), `docs/api/sprite_renderer.md` (`bindSdfAtlas` + the SDF fragment branch), `docs/api/font.md` (the shipped SDF ref), `docs/README.md` index, `src/laige-render/README.md` status, `docs/concepts/coordinates.md` §4.12 + the §5 conversion row; **local verification** — all six canonical trees build warning-free and run 277/277 CPU tests (the GL smoke suites excluded by explicit suite filter — the GPU local-execution policy: `FontSdfSmoke` is CI-verified only, the Linux merge lane's offscreen Mesa path); `laige-api.json` regenerated LAST (new public header — `api-real-tree` green); `tools/laige-include-lint` + `tools/laige-determinism-lint` OK; no `budgets.json` entry (the atlas builds once at set-up; the per-frame cost is part of the composite 50k render-CPU budget, M2-PERF-01 — the pin count stays 17); Progress Board M2 24/33 (total 70/194) | --- diff --git a/src/laige-render/CMakeLists.txt b/src/laige-render/CMakeLists.txt index 8f3895e..a4a9bfb 100644 --- a/src/laige-render/CMakeLists.txt +++ b/src/laige-render/CMakeLists.txt @@ -95,7 +95,8 @@ target_include_directories(laige-stb PRIVATE # fixed-size 8-bit alpha atlas + per-glyph metrics — the FR-2.8 P0 # bitmap path, no GL calls; the vendor boundary is laige-stb, above). set(LAIGE_RENDER_SOURCES gl_context.cpp frame_pipeline.cpp matrices.cpp - camera.cpp iso_camera.cpp projection.cpp sprite_renderer.cpp font.cpp) + camera.cpp iso_camera.cpp projection.cpp sprite_renderer.cpp font.cpp + font_sdf.cpp) if(LAIGE_BUILD_SHARED) add_library(laige-render SHARED ${LAIGE_RENDER_SOURCES}) @@ -127,9 +128,10 @@ target_link_libraries(laige-render PRIVATE glfw laige-glad) # headers), likewise PRIVATE. target_include_directories(laige-render PRIVATE ${CMAKE_SOURCE_DIR}/deps/glad/include) -# M2-TEXT-01: the stb_truetype boundary — font.cpp uses the vendor -# header's declarations (the implementation lives in laige-stb); public -# headers never name stb types (CPP-010, DEP-004, ADR 0009). +# M2-TEXT-01/03: the stb_truetype boundary — font.cpp (the bitmap +# atlas) and font_sdf.cpp (the SDF atlas, M2-TEXT-03) use the vendor +# header's declarations (the implementation lives in laige-stb); +# public headers never name stb types (CPP-010, DEP-004, ADR 0009). target_link_libraries(laige-render PRIVATE laige-stb) target_include_directories(laige-render PRIVATE ${CMAKE_SOURCE_DIR}/deps/stb) diff --git a/src/laige-render/README.md b/src/laige-render/README.md index 891f574..e8dbdf5 100644 --- a/src/laige-render/README.md +++ b/src/laige-render/README.md @@ -647,5 +647,42 @@ that needs a usable OpenGL 3.3 environment and `GTEST_SKIP`s where absent). No standalone `budgets.json` entry: the per-frame text cost is part of the composite 50k render-CPU budget (M2-PERF-01). -The remaining M2 steps (the UI pass, M2-SCENE-01, M2-PERF-01) land -in later steps. +M2-TEXT-03 landed the SDF glyph atlas — the SDF half of FR-2.8: +`laige::render::SdfGlyphAtlas` (public header +`include/laige/render/font_sdf.h`, implementation `font_sdf.cpp`) +converts the same font bytes + glyph range ONCE at scene set-up into +a fixed-size 8-bit signed distance-field glyph atlas (the Felice & +Nienhuis 8SDD transform — CORE-014): per glyph, the 1× ink's +0.5-coverage pixels (alpha > 127) become the inside set of a +two-pass 8-neighborhood distance transform over the glyph's PADDED +cell (the ink grown by the radius `r ∈ [1, 16]` on all four sides), +quantized to `byte = clamp(128 + round(127·d/r), 0, 255)` with d +positive inside (128 = the contour, exact within `r` px, saturated +beyond — the documented threshold edge behavior). The metrics are +the M2-TEXT-01 `GlyphMetrics` unchanged (the text layout is +identical on both paths — the width match, tolerance 0), +`GlyphMetrics.atlasX/atlasY` are the padded cell's origin, and the +atlas is packed shelf-wise, deterministically (same font + options → +identical bytes — the `font-sdf-determinism` line). `glyph(code)` is +the same total fallback function as M2-TEXT-01. The text pass +selects the path per item (`TextItem.mode` + `TextItem.sdfFont`, +`FontRenderMode` — the documented scene-config key), the SDF quads +cover the padded cell, and the `SpriteRenderer` gained the per-atlas +SDF mode: `bindSdfAtlas(id, w, h, rgba, threshold)` uploads the +field (through `expandSdfGlyphAtlasRgba8`) and the fragment shader's +SDF branch thresholds it with a screen-space-derivative AA band (one +screen pixel wide at any zoom — the P1 crispness motivation). No +standalone `budgets.json` entry (the atlas is built once at set-up; +the per-frame cost is part of the composite 50k render-CPU budget, +M2-PERF-01). API contract in +[docs/api/font_sdf.md](../docs/api/font_sdf.md), tests under +[tests/laige-render](../tests/laige-render) (CTest entry `font_sdf` — +the validation matrix, the untrusted-font boundary, the capacity +guard, the metrics parity + exact width match, the determinism +contract, the threshold edge behavior against a brute-force +distance oracle, the fallback, the upload bytes, the zero-alloc +lookup window, and the offscreen SDF GL smoke that `GTEST_SKIP`s +where no usable OpenGL 3.3 environment). + +The remaining M2 steps (M2-UI-01, M2-SCENE-01, M2-PERF-01) land in +later steps. diff --git a/src/laige-render/font_sdf.cpp b/src/laige-render/font_sdf.cpp new file mode 100644 index 0000000..83cda1f --- /dev/null +++ b/src/laige-render/font_sdf.cpp @@ -0,0 +1,418 @@ +// laige-render SDF glyph atlas implementation (M2-TEXT-03). +// +// The engine boundary of the vendored stb_truetype rasterizer (ADR +// 0009) for the SDF path: this file uses the vendor header's +// DECLARATIONS only (the implementation is compiled once in laige-stb, +// plain compiler policy), and the public header (font_sdf.h) names no +// stb type (CPP-010, DEP-004). +// +// The distance field (font_sdf.h "The distance-field algorithm", +// CORE-014): the Felice & Nienhuis 8SDD two-pass, 8-neighborhood, +// Euclidean signed distance transform (IEEE TPAMI 36(3), 2014, +// §III-B) of the 1× rasterization's 0.5-coverage pixels, quantized to +// the documented 8-bit field (128 = the contour, ±r px exact). + +#include "laige/render/font_sdf.h" + +#include +#include +#include +#include +#include + +#include "laige/logging.h" +#include "stb_truetype.h" // declarations only (impl: laige-stb target) + +namespace laige::render { +namespace { + +// The TrueType/OpenType header + table-directory guard (the untrusted- +// input boundary, SCALE-004 — the M2-TEXT-01 guard, verbatim): stb +// reads the font header and scans the table directory WITHOUT bounds +// checks (its buf-based table parsing is bounds-checked, the directory +// scan is not — verified against the pinned header), so the engine +// requires the header (12 bytes) and the FULL table directory to be +// present before any stb call. A truncated or non-font file is +// rejected here, never read past. +// Returns false (not a parsable font header) when the guard fails. +bool fontDirectoryPresent(std::span font) noexcept { + constexpr std::size_t kHeaderBytes = 12; // 4 version + 4 count + 4 offset + if (font.size() < kHeaderBytes) { + return false; + } + const std::uint32_t numTables = + (static_cast(font[4]) << 8) | + static_cast(font[5]); + return kHeaderBytes + 16 * numTables <= font.size(); +} + +// One rasterized glyph bitmap. Owns the stbtt-malloc'd buffer — the +// C-library allocation is encapsulated here (CPP-005 — the M2-TEXT-01 +// GlyphBitmap). +struct GlyphBitmap { + std::uint8_t* data = nullptr; + std::int32_t width = 0; + std::int32_t height = 0; + ~GlyphBitmap() { + if (data != nullptr) { + std::free(data); + } + } +}; + +// The 8SDD Euclidean distance transform (Felice & Nienhuis 2014, +// §III-B): the exact Euclidean distance, in pixels (center-to-center), +// from every cell pixel to the nearest pixel of the target set — 0 on +// the set itself. `inside` is the W × H 0.5-coverage grid; the target +// set is `inside` when `targetIsInside`, else its complement (the +// outside). Two passes over the 8-neighborhood graph — the forward +// (raster) pass relaxes from the upper-left half of the neighborhood, +// the backward pass from the lower-right — which the paper proves +// exact for the 8-neighborhood graph in 2D: every shortest 8-path +// reaches every pixel from one of the two scan directions. +// +// Complexity: O(8·W·H) per call (two passes, 4 neighbor candidates +// each). Fixed float operation order: min of (candidate + constant) +// over a FIXED neighbor order — deterministic, bit-identical for the +// same grid on every build (ARCH-009 presentation float). +// +// The neighbor constants: orthogonal steps cost exactly 1.0 px +// (center-to-center), diagonal steps √2 px (the house dyadic-free +// float constant, the kIsoSnapSqrtTwo precedent — no libm call in the +// pass). INF keeps the relaxation total: 1e30 + 1.42 stays finite in +// float32 (the cell's true max distance is < 2^15 px at the domain +// ceiling, so no distance ever collides with the INF band). +constexpr float kSqrt2 = 1.4142135623730951f; +constexpr float kSdtInfinity = 1.0e30f; + +void signedDistanceTransform(std::span d, + std::span inside, + std::uint32_t w, std::uint32_t h, + bool targetIsInside) noexcept { + const std::size_t count = static_cast(w) * h; + // The initialization: 0 on the target set, INF elsewhere. The inside + // grid is 1 byte/pixel (the set-up path's temporary). + for (std::size_t i = 0; i < count; ++i) { + const bool onSet = targetIsInside ? inside[static_cast(i)] != 0 + : inside[static_cast(i)] == 0; + d[static_cast(i)] = onSet ? 0.0f : kSdtInfinity; + } + // The forward pass (raster order): relax from the upper-left half of + // the 8-neighborhood (left, upper-left, up, upper-right). + for (std::uint32_t y = 0; y < h; ++y) { + for (std::uint32_t x = 0; x < w; ++x) { + std::size_t i = static_cast(y) * w + x; + float best = d[i]; + if (x > 0) { + best = std::min(best, d[i - 1] + 1.0f); + } + if (y > 0) { + const std::size_t up = i - static_cast(w); + if (x > 0) { + best = std::min(best, d[up - 1] + kSqrt2); + } + best = std::min(best, d[up] + 1.0f); + if (x + 1 < w) { + best = std::min(best, d[up + 1] + kSqrt2); + } + } + d[i] = best; + } + } + // The backward pass (reverse raster order): relax from the + // lower-right half (right, lower-left, down, lower-right). + for (std::uint32_t y = h; y-- > 0;) { + for (std::uint32_t x = w; x-- > 0;) { + std::size_t i = static_cast(y) * w + x; + float best = d[i]; + if (x + 1 < w) { + best = std::min(best, d[i + 1] + 1.0f); + } + if (y + 1 < h) { + const std::size_t down = i + static_cast(w); + if (x > 0) { + best = std::min(best, d[down - 1] + kSqrt2); + } + best = std::min(best, d[down] + 1.0f); + if (x + 1 < w) { + best = std::min(best, d[down + 1] + kSqrt2); + } + } + d[i] = best; + } + } +} + +} // namespace + +laige::Result SdfGlyphAtlas::create( + std::span font, Options options) noexcept { + // --- 1. Options validation (the documented order, first failure + // wins; no log — the M2-TEXT-01 create precedent) ------------------- + if (options.atlasWidth < 1 || + options.atlasWidth > kFontMaxAtlasDimension || + options.atlasHeight < 1 || + options.atlasHeight > kFontMaxAtlasDimension || + options.fontSize < 1 || options.fontSize > kFontMaxFontSize || + options.glyphFirst > options.glyphLast || + options.glyphLast > kFontMaxGlyphCode || + options.glyphLast - options.glyphFirst + 1 > kFontMaxGlyphCount || + options.sdfRadius < 1 || options.sdfRadius > kSdfMaxRadius) { + return laige::Result::failure( + laige::ErrorCode::InvalidArgument); + } + + // --- 2. The font bytes (untrusted input, SCALE-004 — the M2-TEXT-01 + // untrusted-font contract, verbatim) -------------------------------- + if (!fontDirectoryPresent(font)) { + LAIGE_LOG_ERROR("font", "font_invalid", + "The configured font file is not a parsable TTF/OTF " + "font (truncated or non-font bytes)", + laige::log::field("bytes", font.size())); + return laige::Result::failure( + laige::ErrorCode::MalformedInput); + } + stbtt_fontinfo stbFont; + // The directory guard above makes the scan reads in-bounds; the + // offset for a plain font is 0 (a TTC's first font is what index 0 + // names). + const int offset = stbtt_GetFontOffsetForIndex(font.data(), 0); + if (offset < 0 || !stbtt_InitFont(&stbFont, font.data(), offset)) { + LAIGE_LOG_ERROR("font", "font_invalid", + "The configured font file failed TTF/OTF parsing " + "(missing required tables or corrupt table data)", + laige::log::field("bytes", font.size())); + return laige::Result::failure( + laige::ErrorCode::MalformedInput); + } + + // --- 3. The fixed-size atlas + per-codepoint slots ------------------- + SdfGlyphAtlas atlas; + atlas.atlasWidth_ = options.atlasWidth; + atlas.atlasHeight_ = options.atlasHeight; + atlas.fontSize_ = options.fontSize; + atlas.glyphFirst_ = options.glyphFirst; + atlas.glyphLast_ = options.glyphLast; + atlas.radius_ = options.sdfRadius; + atlas.atlas_.assign( + static_cast(options.atlasWidth) * + static_cast(options.atlasHeight), + 0); + const std::size_t slotCount = + static_cast(options.glyphLast - options.glyphFirst + 1); + atlas.slots_.reserve(slotCount); // the set-up path's allocations + + const float scale = + stbtt_ScaleForPixelHeight(&stbFont, static_cast(options.fontSize)); + // The degenerate-metric guard (SCALE-004) — the M2-TEXT-01 guard, + // verbatim (the same int-domain argument over stbtt's float→int box + // rounding and the lround'd advance). + constexpr float kMaxPixelScale = 32768.0f; // 2^15, the int-domain bound + if (!std::isfinite(scale) || scale <= 0.0f || scale > kMaxPixelScale) { + LAIGE_LOG_ERROR("font", "font_invalid", + "The font's vertical metrics are degenerate (the " + "pixel-height scale is not a finite value in the " + "documented domain)", + laige::log::field("bytes", font.size())); + return laige::Result::failure( + laige::ErrorCode::MalformedInput); + } + + // The deterministic shelf packing (font_sdf.h preamble): ascending + // codepoint order, one shelf row at a time, cells the ink boxes GROWN + // by the radius on all four sides (the padded cell). + const std::int32_t radius = static_cast(options.sdfRadius); + std::uint32_t cursorX = 0; + std::uint32_t cursorY = 0; + std::uint32_t rowHeight = 0; + for (std::uint32_t code = options.glyphFirst; code <= options.glyphLast; + ++code) { + const int glyphIndex = + stbtt_FindGlyphIndex(&stbFont, static_cast(code)); + int x0 = 0; + int y0 = 0; + int x1 = 0; + int y1 = 0; + stbtt_GetGlyphBitmapBox(&stbFont, glyphIndex, scale, scale, &x0, &y0, &x1, + &y1); + const std::int32_t w = static_cast(x1 - x0); + const std::int32_t h = static_cast(y1 - y0); + + // The capacity check BEFORE the bitmap allocation: the PADDED cell + // must fit the fixed atlas (a single padded cell wider/taller than + // the atlas can never pack), and it bounds stb's w * h allocation + // to (atlasWidth − 2r) * (atlasHeight − 2r) <= 2^24 bytes (no int + // overflow, no unbounded malloc — the malicious-font guard, + // SCALE-004). + if (w > 0 && + (w + 2 * radius > static_cast(options.atlasWidth) || + h + 2 * radius > static_cast(options.atlasHeight))) { + LAIGE_LOG_ERROR("font", "atlas_full", + "A glyph's padded SDF cell does not fit the fixed " + "glyph atlas (grow the atlas or shrink the font " + "size or the SDF radius)", + laige::log::field("code", code), + laige::log::field("width", w + 2 * radius), + laige::log::field("height", h + 2 * radius), + laige::log::field("atlas_width", options.atlasWidth), + laige::log::field("atlas_height", options.atlasHeight)); + return laige::Result::failure( + laige::ErrorCode::BudgetExhausted); + } + + // The pen advance (unscaled font units; the bearing comes from the + // ink box's x0, the bitmap's true left edge — the M2-TEXT-01 + // metrics, verbatim). + int advanceUnits = 0; + stbtt_GetGlyphHMetrics(&stbFont, glyphIndex, &advanceUnits, nullptr); + + GlyphMetrics metrics; + metrics.bearingX = x0; // the bitmap's left edge from the pen (right +) + metrics.bearingY = -y0; // the bitmap's top edge above the baseline (up +) + metrics.width = w; + metrics.height = h; + metrics.advance = + static_cast(std::lround(advanceUnits * scale)); + + if (w > 0) { + // The shelf packing over the PADDED cell (the row's cursor + // advances by the padded width, wraps on the padded width). + const std::uint32_t paddedW = + static_cast(w + 2 * radius); + const std::uint32_t paddedH = + static_cast(h + 2 * radius); + if (cursorX + paddedW > options.atlasWidth) { + cursorX = 0; + cursorY += rowHeight; + rowHeight = 0; + } + if (cursorY + paddedH > options.atlasHeight) { + LAIGE_LOG_ERROR("font", "atlas_full", + "The SDF glyph atlas is full (the configured glyph " + "range does not fit the fixed atlas with the " + "distance-field padding; grow the atlas, shrink " + "the font size, or reduce the SDF radius)", + laige::log::field("code", code), + laige::log::field("atlas_width", options.atlasWidth), + laige::log::field("atlas_height", options.atlasHeight)); + return laige::Result::failure( + laige::ErrorCode::BudgetExhausted); + } + } + // The cell origin is the cursor AFTER any row wrap (the M2-TEXT-01 + // post-wrap cursor — a wrapped cell starts the new row, not at the + // stale end of the old one). For the SDF path the origin is the + // PADDED CELL's top-left (font_sdf.h preamble: the ink sits at + // origin + (r, r)). + metrics.atlasX = cursorX; + metrics.atlasY = cursorY; + + if (w > 0 && h > 0) { + // The ink bitmap (the 1× rasterization — the M2-TEXT-01 cell). + GlyphBitmap bitmap; + bitmap.width = w; + bitmap.height = h; + bitmap.data = stbtt_GetGlyphBitmap(&stbFont, scale, scale, glyphIndex, + &bitmap.width, &bitmap.height, + nullptr, nullptr); + if (bitmap.data == nullptr) { + // Unreachable for a validated (w, h): STBTT_malloc failure is + // the only null path, and w * h <= 2^24 bytes. + LAIGE_LOG_ERROR("font", "rasterize_failed", + "Glyph bitmap rasterization failed (allocation " + "failure in the font rasterizer)", + laige::log::field("code", code)); + return laige::Result::failure( + laige::ErrorCode::MalformedInput); + } + // The 0.5-coverage grid of the PADDED cell (the inside set of the + // distance transform): 1 byte/pixel, the ink at offset (r, r). + const std::uint32_t cellW = static_cast(w + 2 * radius); + const std::uint32_t cellH = static_cast(h + 2 * radius); + const std::size_t cellCount = + static_cast(cellW) * cellH; + std::vector inside(cellCount, 0); + for (std::int32_t row = 0; row < h; ++row) { + const std::uint8_t* src = + bitmap.data + static_cast(row) * w; + std::uint8_t* dst = inside.data() + + static_cast(static_cast(row) + + static_cast(radius)) * + cellW + + static_cast(radius); + for (std::int32_t col = 0; col < w; ++col) { + // The documented 0.5-coverage cut (font_sdf.h field section): + // coverage > 127 is inside, ≤ 127 is outside. + dst[col] = src[col] > kSdfInsideCut ? 1 : 0; + } + } + // The two 8SDD passes (the header's algorithm section): the + // distance to the nearest inside pixel (dIn — 0 on inside) and to + // the nearest outside pixel (dOut — 0 on outside). The signed + // field is +dOut on inside pixels, −dIn on outside pixels + // (positive inside, the Felice & Nienhuis convention). + std::vector dIn(cellCount); + std::vector dOut(cellCount); + signedDistanceTransform(dIn, inside, cellW, cellH, true); + signedDistanceTransform(dOut, inside, cellW, cellH, false); + // The quantization (font_sdf.h field section): byte = + // clamp(128 + round(127 · d / r), 0, 255). d/r is exact float in + // the cell's domain (|d| < 2^15 px, the domain ceiling), and + // 127 · d/r < 2^31, so lround is exact; the clamp: byte 255/1 at + // exactly |d| = r, 0/255 beyond (the documented saturation). + std::uint8_t* dstRow = + atlas.atlas_.data() + + static_cast(cursorY) * + static_cast(options.atlasWidth) + + cursorX; + const float invRadius = 1.0f / static_cast(options.sdfRadius); + for (std::uint32_t row = 0; row < cellH; ++row) { + for (std::uint32_t col = 0; col < cellW; ++col) { + const std::size_t i = static_cast(row) * cellW + col; + const float signedDist = + inside[i] != 0 ? dOut[i] : -dIn[i]; + const long quantized = + std::lround(128.0 + 127.0f * signedDist * invRadius); + const int clamped = static_cast(std::max(0L, std::min(255L, quantized))); + dstRow[static_cast(row) * + static_cast(options.atlasWidth) + + static_cast(col)] = + static_cast(clamped); + } + } + cursorX += static_cast(w + 2 * radius); + if (cellH > rowHeight) { + rowHeight = cellH; + } + } + atlas.slots_.push_back(metrics); + } + + LAIGE_LOG_INFO("font", "sdf_atlas_created", + "The SDF glyph atlas was built at scene set-up", + laige::log::field("atlas_width", options.atlasWidth), + laige::log::field("atlas_height", options.atlasHeight), + laige::log::field("font_size", options.fontSize), + laige::log::field("sdf_radius", options.sdfRadius), + laige::log::field("glyphs", slotCount)); + return laige::Result::success( + std::move(atlas)); +} + +const GlyphMetrics* SdfGlyphAtlas::glyph(std::uint32_t code) const noexcept { + if (!valid()) { + return nullptr; + } + if (code >= glyphFirst_ && code <= glyphLast_) { + return &slots_[static_cast(code - glyphFirst_)]; + } + // The documented fallback (font_sdf.h preamble — the M2-TEXT-01 + // contract, verbatim): the U+0020 slot when the configured range + // includes it, else the zero metric. + if (kFontFallbackCode >= glyphFirst_ && kFontFallbackCode <= glyphLast_) { + return &slots_[static_cast(kFontFallbackCode - glyphFirst_)]; + } + return &kFontEmptyGlyphMetrics; +} + +} // namespace laige::render diff --git a/src/laige-render/include/laige/render/font_sdf.h b/src/laige-render/include/laige/render/font_sdf.h new file mode 100644 index 0000000..cbeb0ec --- /dev/null +++ b/src/laige-render/include/laige/render/font_sdf.h @@ -0,0 +1,344 @@ +// laige-render SDF glyph atlas (M2-TEXT-03): the SDF half of FR-2.8 — +// "Text rendering ... SDF (P1)". +// +// FR-2.8: "Text rendering: ... bitmap font atlas (P0), SDF (P1), ..." +// This step ships the P1 SDF path on top of the P0 bitmap path +// (font.h, M2-TEXT-01): the caller's TTF/OTF font bytes + a configured +// glyph range + a distance-field radius are converted ONCE, at scene +// set-up, into a fixed-size 8-bit SIGNED DISTANCE FIELD glyph atlas + +// per-glyph metrics (the SAME metrics as the bitmap path — advance, +// bearing, line height). There is NO runtime re-rasterization: after +// `create`, `glyph(code)` is a pure table lookup. The text-item / +// layout pass (text_items.h, M2-TEXT-02) selects the SDF path through +// `TextItem.mode` (the documented scene-config key); the SDF fragment +// shader mode lives in the M2-SPRITE-02 `SpriteRenderer` (the +// `bindSdfAtlas` bind + the shader's SDF branch). +// +// SdfGlyphAtlas The configured SDF glyph atlas (create + lookup) +// kSdf* The named constants (CORE-005) +// +// --------------------------------------------------------------------------- +// Why SDF (the P1 motivation, documented) +// --------------------------------------------------------------------------- +// +// The bitmap path (M2-TEXT-01) rasterizes each glyph once at the +// configured pixel size and scales it as a texture: at integer scales +// the glyph stays aligned but its edges are the 1× rasterization's +// (a 16 px "a" at 4× is a 16 px edge stretched 4×). The SDF path +// stores, per texel, the SIGNED DISTANCE to the glyph's 0.5-coverage +// contour in PIXELS AT 1x. The fragment shader (the `SpriteRenderer`' +// SDF branch) thresholds the field with a smoothstep whose anti-aliasing +// band tracks the field's SCREEN-SPACE DERIVATIVE (GLSL +// `dFdx`/`dFdy` — the standard SDF anti-aliasing, the "screen-space +// derivative" rule): the AA band is exactly ONE SCREEN PIXEL WIDE AT ANY +// ZOOM, so text stays crisp at any zoom (the P1 motivation of FR-2.8) +// and the rendered contour matches the 0.5-coverage contour of the 1× +// rasterization, not a stretched copy of it. +// +// --------------------------------------------------------------------------- +// The model (documented units) +// --------------------------------------------------------------------------- +// +// All metric values are PIXELS AT 1x — exactly the M2-TEXT-01 convention +// (the atlas's configured `fontSize`, the stbtt pixel-height +// convention). The SDF metric is the M2-TEXT-01 `GlyphMetrics` +// UNCHANGED (advance, bearingX, bearingY, width, height are the ink's +// metrics); the SDF-specific values are: +// +// - `radius()` — the distance-field EXTENT in px at 1x (the +// `Options::sdfRadius`): distances are stored exactly for at most +// this many px on each side of the contour, and SATURATE beyond +// it (the atlas cell's corners hold the saturated value — the +// documented saturation); +// - the padded cell: each ink glyph's cell is its ink box GROWN by +// the radius on all four sides (a `w × h` ink occupies a +// `(w + 2r) × (h + 2r)` cell — the distance field needs the +// surrounding space to carry the outside distances); +// - `GlyphMetrics.atlasX/atlasY` — the PADDED CELL's top-left texel +// (NOT the ink's — the ink sits at `(atlasX + r, atlasY + r)`). +// +// `lineHeight()` is the line height in px at 1x: `fontSize()` (the +// stbtt pixel-height convention — the M2-TEXT-01 formula). +// +// --------------------------------------------------------------------------- +// `SdfGlyphAtlas::create(font, options)` +// --------------------------------------------------------------------------- +// +// 1. Validates `options` (the documented order below, first failure +// wins — all `InvalidArgument`, no log); +// 2. Validates the FONT BYTES at the untrusted-input boundary +// (SCALE-004): a non-font / truncated / corrupt file → +// `MalformedInput` + one structured Error `font/font_invalid` +// (the M2-TEXT-01 untrusted-font contract, verbatim); +// 3. Rasterizes every codepoint in `[glyphFirst, glyphLast]` into the +// fixed `atlasWidth × atlasHeight` 8-bit SDF atlas with the +// deterministic shelf packing below (a glyph whose PADDED cell +// does not fit → `BudgetExhausted` + one structured Error +// `font/atlas_full` — the atlas is fixed-size by contract; grow +// the atlas, not the font); +// 4. On success: one Info `font/sdf_atlas_created` (the set-up path +// is low-volume; the lookup path logs nothing). +// +// The font bytes are a NON-OWNING span (the M2-TEXT-01 contract: the +// caller owns them; they are read only during `create`). +// +// --------------------------------------------------------------------------- +// The atlas layout (deterministic shelf packing, padded cells) +// --------------------------------------------------------------------------- +// +// The atlas is `atlasWidth × atlasHeight` texels of 8-bit SDF values +// (128 = the 0.5-coverage contour — the header's field section). +// Codepoints are laid out in ASCENDING order, one shelf row at a time: +// the cursor starts at (0, 0); a glyph's PADDED cell is placed at the +// cursor; the cursor advances right by the padded cell's width; when +// the cell would not fit on the row it wraps to the next row (the +// cursor's y advances by the row's max padded height). A cell is the +// padded box `(ink + 2·radius) × (ink + 2·radius)` (no inter-cell +// margin — the field saturates at the cell's corners, so no glyph can +// bleed into a neighbor; the M2-SPRITE-02 `GL_NEAREST` "texel = +// floor(u·W)" contract applies). Empty glyphs (zero-width/height ink — +// e.g. the space) occupy NO cell space. The packing is a pure function +// of (font bytes, options): same input → bit-identical atlas bytes +// (the determinism test). +// +// The glyph's UV rect for the `SpriteRenderer` upload is the PADDED +// CELL's rect: `(atlasX/W, atlasY/H, (atlasX + w + 2r)/W, +// (atlasY + h + 2r)/H)` (the M2-SPRITE-03 v-axis convention: v = 0 = +// first uploaded texel row) — the text pass (text_items.h) declares +// the glyph quad over the padded cell, so the field's outside region +// is what the fragment's smoothstep sees at the glyph's screen edges. +// +// --------------------------------------------------------------------------- +// The field (byte value, units, threshold edge behavior) +// --------------------------------------------------------------------------- +// +// Each texel stores an 8-bit value mapping a SIGNED DISTANCE d (px at +// 1x, the M2-TEXT-01 0.5-coverage contour) to a byte: +// +// byte = clamp(128 + round(127 · d / r), 0, 255) +// +// with d POSITIVE INSIDE the glyph, NEGATIVE OUTSIDE (the Felice & +// Nienhuis sign convention, the paper's §3): +// +// - 128 = the exact contour (d = 0); +// - inside the glyph: byte ≥ 128, monotonically rising with distance +// from the contour; +// - outside the glyph: byte ≤ 128, monotonically falling; +// - saturation: the field is EXACT for |d| ≤ r (byte 255/1 at exactly +// |d| = r — the documented distance accuracy) and clamps to the +// 0/255 extremes beyond it. +// +// Distances are measured CENTER-TO-CENTER in pixels (a pixel is the +// unit cell at its center, the 8SDD convention): the 128 contour +// therefore crosses between texels, one texel deep in from the 0.5- +// coverage pixels of the 1× rasterization (the standard SDF-from- +// raster behavior — the rendered glyph is the same glyph as the +// bitmap path's, anti-aliased by the field instead of by the 1× +// raster). +// +// THE THRESHOLD EDGE BEHAVIOR (documented, the fragment shader's +// contract): the `SpriteRenderer`'s SDF branch renders a texel with +// +// alpha = smoothstep(t − aa, t + aa, byte / 255) +// +// where `t` is the atlas's threshold (the `bindSdfAtlas` argument, +// default 0.5 = the 0.5-coverage contour = byte 128) and `aa` is the +// screen-space derivative band (the shader's `dFdx`/`dFdy` of the +// field, floored at one quantization step, 1/255). So: a texel within +// the band (at 1×, aa ≈ 1/(2r), so the band spans ±127/r bytes around +// 128 — about one distance-pixel either side of the contour) renders +// anti-aliased (alpha strictly between 0 and 1); a texel more than +// one distance-px INSIDE the glyph (byte ≥ 128 + 127/r) renders exactly +// opaque; a texel more than one distance-px OUTSIDE (byte ≤ 128 − +// 127/r) renders exactly transparent. Raising `t` renders the glyph +// thinner (the contour retreats into the ink), lowering it renders it +// fatter — the documented threshold knob (the field carries up to r px +// on each side of 128, so the knob is usable while the contour stays +// inside the padded cell). +// +// --------------------------------------------------------------------------- +// The distance-field algorithm (CORE-014) +// --------------------------------------------------------------------------- +// +// The field is the 8-neighborhood signed distance transform of the +// ink bitmap's 0.5-coverage pixels: the Felice & Nienhuis 8SDD +// algorithm — L. Felice and M. Nienhuis, "Simple and Efficient +// Cartesian Signed Distance Transform", IEEE TPAMI 36(3), 2014, +// §III-B (the two-pass, 8-neighborhood, Euclidean-distance dynamic +// program). Per glyph cell: one pass computes the Euclidean distance +// from every pixel to the nearest INSIDE pixel, one pass to the +// nearest OUTSIDE pixel (the two passes of the paper's algorithm — +// forward raster scan then reverse, 8 neighbors each, orthogonal +// neighbors cost 1, diagonal √2 — exact for the 8-neighborhood graph +// in 2D, O(8·W·H) per pass, no auxiliary structures beyond the cell's +// two float grids). The signed field is then `+dOut` on inside +// pixels, `−dIn` on outside pixels, quantized by the formula above. +// Determinism: a fixed sequence of float min/add/round operations — +// bit-identical for the same input on every build (the M2-TEXT-01 +// determinism scope, presentation-only float, ARCH-009). +// +// --------------------------------------------------------------------------- +// Missing glyphs (the documented fallback — the M2-TEXT-01 contract) +// --------------------------------------------------------------------------- +// +// `glyph(code)` is a TOTAL function — it never crashes and, for a live +// atlas, never returns null: +// +// - `code` in `[glyphFirst, glyphLast]` → that codepoint's slot. If +// the font has no glyph for the codepoint, the slot holds the font's +// `.notdef` glyph (stbtt maps unknown codepoints to glyph 0) — +// deterministic per font; +// - `code` OUTSIDE the configured range → the FALLBACK glyph: the +// U+0020 (space) slot if `kFontFallbackCode` lies in the configured +// range (the default Latin-1 range does), else +// `kFontEmptyGlyphMetrics` (a zero metric — the advance applies, +// nothing is drawn). +// +// --------------------------------------------------------------------------- +// Ownership, threading (CORE-009, CONC-001) +// --------------------------------------------------------------------------- +// +// `SdfGlyphAtlas` owns its atlas bytes and slot table (move-only, the +// `GlyphAtlas` precedent); one owner (the scene set-up thread); the +// render phase reads it (the text pass declares the quads, the +// `SpriteRenderer`'s `bindSdfAtlas` uploads the field). +// Presentation-only (ARCH-009): the atlas is never part of the sim +// state hash or replay state. No GL calls anywhere in this header. +// +// Canonical narrative: docs/api/font_sdf.md (the full contract). +// The text feature's place in the 2.5D model: +// docs/concepts/coordinates.md (§4.12 — the text row, shipped). + +#pragma once + +#include +#include +#include + +#include "laige/errors.h" +#include "laige/render/font.h" +#include "laige/result.h" + +namespace laige::render { + +// Named constants (CORE-005). +// +// The distance-field radius domain: [1, kSdfMaxRadius] px at 1x. The +// radius is the field's ACCURATE extent (the header's field section) +// and the cell padding: 16 px covers the bitmap path's integer-scale +// domain (kTextMaxScale = 16: at 16× zoom one screen px is 1/16 of an +// atlas px, far inside the 16 px field) — and a 16 px pad on a +// 16 px-font ink cell (≤ ~24 px) keeps the padded cell ≤ ~56 px, +// comfortably inside the 4096² atlas ceiling. +inline constexpr std::uint32_t kSdfDefaultRadius = 8; +inline constexpr std::uint32_t kSdfMaxRadius = 16; +// The 0.5-coverage cut of the 1× rasterization (the header's field +// section: a texel with coverage > 127 is INSIDE, ≤ 127 is OUTSIDE — +// the standard alpha threshold; the 128 byte value is the exact +// contour). +inline constexpr std::uint8_t kSdfInsideCut = 127; + +// The configured SDF glyph atlas (M2-TEXT-03): the signed +// distance-field half of FR-2.8, built ONCE at scene set-up from the +// same font bytes + glyph range as the M2-TEXT-01 bitmap atlas. +class SdfGlyphAtlas { + public: + // The create options (API-006): the M2-TEXT-01 options + the + // distance-field radius (the header's model section). + struct Options { + std::uint32_t atlasWidth{kFontDefaultAtlasWidth}; + std::uint32_t atlasHeight{kFontDefaultAtlasHeight}; + std::uint32_t fontSize{kFontDefaultFontSize}; + std::uint32_t glyphFirst{kFontDefaultGlyphFirst}; + std::uint32_t glyphLast{kFontDefaultGlyphLast}; + std::uint32_t sdfRadius{kSdfDefaultRadius}; + }; + + // Converts the configured glyph range of `font` into the fixed-size + // 8-bit SDF atlas (set-up path — the only allocations: the atlas + // bytes + the slot table + the per-glyph distance grids, freed on + // return). Validation order, first failure wins: + // 1. atlasWidth/atlasHeight in [1, kFontMaxAtlasDimension], + // fontSize in [1, kFontMaxFontSize], + // glyphFirst ≤ glyphLast ≤ kFontMaxGlyphCode, + // (glyphLast − glyphFirst + 1) ≤ kFontMaxGlyphCount, + // sdfRadius in [1, kSdfMaxRadius] + // (InvalidArgument, no log — the M2-TEXT-01 create precedent); + // 2. the font bytes: a non-font / truncated / corrupt file → + // MalformedInput + one Error `font/font_invalid` (the + // M2-TEXT-01 untrusted-font contract); + // 3. a glyph whose PADDED cell does not fit the fixed atlas → + // BudgetExhausted + one Error `font/atlas_full` (fields: the + // codepoint, its padded cell size, the atlas size). + // A failed create returns the error; no atlas is produced. + [[nodiscard]] static laige::Result + create(std::span font, Options options) noexcept; + + // The stopped state (default / moved-from): `valid()` false, + // `glyph()` returns null, `atlas()` empty, introspection zero — the + // `GlyphAtlas` stopped-state precedent. Nothing owned. + SdfGlyphAtlas() noexcept = default; + + SdfGlyphAtlas(const SdfGlyphAtlas&) = delete; + SdfGlyphAtlas& operator=(const SdfGlyphAtlas&) = delete; + SdfGlyphAtlas(SdfGlyphAtlas&&) noexcept = default; + SdfGlyphAtlas& operator=(SdfGlyphAtlas&&) noexcept = default; + ~SdfGlyphAtlas() noexcept = default; + + // True iff the atlas is live (create succeeded). + [[nodiscard]] bool valid() const noexcept { return !atlas_.empty(); } + + // The atlas dimensions (texels; zero in the stopped state). + [[nodiscard]] std::uint32_t atlasWidth() const noexcept { return atlasWidth_; } + [[nodiscard]] std::uint32_t atlasHeight() const noexcept { + return atlasHeight_; + } + + // The configured pixel height, px at 1x. + [[nodiscard]] std::uint32_t fontSize() const noexcept { return fontSize_; } + + // The line height, px at 1x: `fontSize()` (the stbtt pixel-height + // convention — the M2-TEXT-01 formula). + [[nodiscard]] std::uint32_t lineHeight() const noexcept { return fontSize_; } + + // The configured glyph range (inclusive). + [[nodiscard]] std::uint32_t glyphFirst() const noexcept { return glyphFirst_; } + [[nodiscard]] std::uint32_t glyphLast() const noexcept { return glyphLast_; } + + // The distance-field radius, px at 1x (the header's model section; + // 0 in the stopped state). + [[nodiscard]] std::uint32_t radius() const noexcept { return radius_; } + + // The atlas bytes: 8-bit SDF values, row-major, + // `atlasWidth() × atlasHeight()` texels (128 = the 0.5-coverage + // contour — the header's field section; empty in the stopped state). + // The render read path: the text pass (text_items.h) uploads this as + // a white-on-alpha RGBA8 atlas (the M2-SPRITE-02 `bindSdfAtlas` + // contract). O(1); never writes. + [[nodiscard]] std::span atlas() const noexcept { + return atlas_; + } + + // The metrics for `code` (the header's fallback section: a total + // function — null only in the stopped state; the in-range slot, else + // the documented fallback). O(1); no allocation, no logging, no GL. + // The M2-TEXT-01 `GlyphMetrics` UNCHANGED (advance, bearingX, + // bearingY, width, height = the ink's metrics; `atlasX`/`atlasY` = + // the PADDED CELL's origin — the header's model section). The + // returned pointer is valid until this atlas is moved/destroyed + // (the slot table is member storage, never reallocated after + // create). + [[nodiscard]] const GlyphMetrics* glyph(std::uint32_t code) const noexcept; + + private: + std::vector atlas_; // atlasWidth_ × atlasHeight_ + std::vector slots_; // one per codepoint in [glyphFirst_, glyphLast_] + std::uint32_t atlasWidth_{0}; + std::uint32_t atlasHeight_{0}; + std::uint32_t fontSize_{0}; + std::uint32_t glyphFirst_{0}; + std::uint32_t glyphLast_{0}; + std::uint32_t radius_{0}; +}; + +} // namespace laige::render diff --git a/src/laige-render/include/laige/render/sprite_renderer.h b/src/laige-render/include/laige/render/sprite_renderer.h index 37026e5..cc38cfa 100644 --- a/src/laige-render/include/laige/render/sprite_renderer.h +++ b/src/laige-render/include/laige/render/sprite_renderer.h @@ -68,6 +68,18 @@ // is engine-owned (FR-2.2 / M2-ISO-01), never derived from the // projection. // +// The SDF mode (M2-TEXT-03): per-ATLAS state, set at the texture bind +// of each group from the atlas registry (an atlas bound through +// `bindSdfAtlas` — a font_sdf.h SDF glyph atlas — draws with the SDF +// branch, an atlas bound through `bindAtlas` draws with the bitmap +// branch; the per-instance layout is UNCHANGED — no SDF flag in the +// 52 B instance). The SDF branch thresholds the field's alpha with +// alpha = smoothstep(t − aa, t + aa, field/255): `t` is the atlas's +// threshold (the `bindSdfAtlas` argument), `aa` is the screen-space +// derivative band (the field's `dFdx`/`dFdy`, floored at one 8-bit +// quantization step, 1/255 — the band is one screen pixel wide at +// ANY zoom: the P1 "sharp at any zoom" property of FR-2.8). +// // The GPU's cos/sin (the rotation) is driver-float: bit-exact across // runs of the same driver, not across drivers — the golden-image // contract (M2-GOLD-01) pins the environment; this step's tests @@ -149,9 +161,9 @@ // maxSprites — a frame above the renderer's budget fails // BudgetExhausted (the batcher's own overflow policy bounds the // frame to ITS budget first); -// - bindAtlas is the SETUP/asset path (one call per atlas per -// scene load — the asset system streams them later, M3), not a -// per-frame call: it creates a GL texture; +// - bindAtlas / bindSdfAtlas are the SETUP/asset path (one call +// per atlas per scene load — the asset system streams them +// later, M3), not a per-frame call: they create a GL texture; // - submit requires the batcher BUILT for the current frame — an // open window with declared items fails InvalidArgument, it is // never drawn as an empty frame (CORE-008); @@ -333,6 +345,27 @@ class SpriteRenderer { std::uint32_t height, std::span rgba); + // Bind one SDF atlas texture (M2-TEXT-03) — the same setup/asset + // path as `bindAtlas` (one call per atlas per scene load; the same + // GL texture contract: GL_RGBA8, GL_NEAREST, GL_CLAMP_TO_EDGE, the + // same argument domains and failure behavior — an invalid + // `threshold` is InvalidArgument with no GL work, no logging), with + // one extra argument: the SDF threshold in the field's 0..1 domain + // (the fragment shader's `uSdfThreshold`; 0.5 = the 0.5-coverage + // contour — the documented default). The atlas's bytes are a + // font_sdf.h SDF field uploaded as white-on-alpha RGBA8 (the + // text_items.h `expandSdfGlyphAtlasRgba8`). Re-binding an id + // REPLACES both the texture and the SDF state (the last bind + // wins). The per-group SDF branch + threshold travel with the + // texture bind in `submit` (the header's shader section). + // @budget one-time per atlas: one glTexImage2D (width*height*4 B + // upload); no loop, no allocation beyond the texture object. + [[nodiscard]] laige::Status bindSdfAtlas(std::uint32_t atlasId, + std::uint32_t width, + std::uint32_t height, + std::span rgba, + float threshold); + // The submit stage: draw the batcher's BUILT frame (the frame // protocol, the header preamble). Preconditions (checked in order, // the first failure wins — a failed submit leaves the frame diff --git a/src/laige-render/include/laige/render/text_items.h b/src/laige-render/include/laige/render/text_items.h index d850b26..f479ad9 100644 --- a/src/laige-render/include/laige/render/text_items.h +++ b/src/laige-render/include/laige/render/text_items.h @@ -1,30 +1,36 @@ // laige-render text items (M2-TEXT-02): the text half of FR-2.8 — // screen-space text items for the UI pass. // -// FR-2.8: "Text rendering: ... bitmap font atlas (P0) ...; UI is a -// separate render pass, screen-space." The P0 bitmap half (M2-TEXT-01) -// owns the font rasterization (`laige/render/font.h` — the -// `GlyphAtlas`). This header is the UI pass's text side: the -// `StringTable` (PRD §10.4 — strings live in an engine-owned table with -// `StringRef` handles; no per-frame `std::string` in the path), the -// `TextItem` value (string, font, scale, color, alignment, max-width -// wrap), the O(glyphs) width measurement, and the `declareText` pass -// that turns one `TextItem` into batched glyph quads in the UI pass's -// sprite batcher (S-5: rendering goes through the batcher — text is -// never drawn directly). The M2-UI-01 widget tree declares its text -// widgets through this same pass; the M2-UI-02 render pass submits the -// UI batcher through the M2-SPRITE-02 `SpriteRenderer` (identity -// screen-space matrix — RENDER-006). +// FR-2.8: "Text rendering: ... bitmap font atlas (P0), SDF (P1) ...; +// UI is a separate render pass, screen-space." The P0 bitmap half +// (M2-TEXT-01) owns the font rasterization (`laige/render/font.h` — +// the `GlyphAtlas`); the P1 SDF half (M2-TEXT-03) owns the signed +// distance-field atlas (`laige/render/font_sdf.h` — the +// `SdfGlyphAtlas`). This header is the UI pass's text side: the +// `StringTable` (PRD §10.4 — strings live in an engine-owned table +// with `StringRef` handles; no per-frame `std::string` in the path), +// the `TextItem` value (string, font, glyph path, scale, color, +// alignment, max-width wrap), the O(glyphs) width measurement, and the +// `declareText` pass that turns one `TextItem` into batched glyph quads +// in the UI pass's sprite batcher (S-5: rendering goes through the +// batcher — text is never drawn directly). The M2-UI-01 widget tree +// declares its text widgets through this same pass; the M2-UI-02 +// render pass submits the UI batcher through the M2-SPRITE-02 +// `SpriteRenderer` (identity screen-space matrix — RENDER-006). // // StringRef The string-table handle (PRD §10.4) // StringTable The engine-owned interned string storage // TextAlignment The per-line alignment (left/center/right) +// FontRenderMode The glyph path (bitmap default / SDF) // TextItem One declared text item (UI pass, screen px) // measureText The O(glyphs) width measurement // declareText The O(glyphs) text -> glyph-quads pass // expandGlyphAtlasRgba8 The 8-bit alpha atlas -> white-on-alpha // RGBA8 upload bytes (the M2-SPRITE-02 // `bindAtlas` input) +// expandSdfGlyphAtlasRgba8 The 8-bit SDF atlas -> white-on-alpha +// RGBA8 upload bytes (the M2-SPRITE-02 +// `bindSdfAtlas` input, M2-TEXT-03) // // --------------------------------------------------------------------------- // The model (documented units) @@ -96,6 +102,24 @@ // frameIndex 0 (glyph animation is not a feature in M2 — the SDF // pass of M2-TEXT-03 changes the shader, not this hook). // +// The SDF glyph path (M2-TEXT-03): the SAME quad model with the quad +// covering the glyph's PADDED CELL instead of the ink rect — the ink +// rect grown by the atlas's distance-field radius on all four sides +// (the SDF field needs the surrounding space to carry the outside +// distances, and the fragment's smoothstep is what produces the sharp +// screen-space edge). So per glyph: the quad's `scale` is +// `(width + 2r, height + 2r) * scale`, its `pos` is the padded cell's +// center, and its `uv` is the padded cell's rect in the SDF atlas +// (`GlyphMetrics.atlasX/atlasY` are already the padded cell's origin +// on the SDF path — the font_sdf.h preamble). The LAYOUT is +// UNCHANGED: the pen advances by the ink's `advance` exactly as on the +// the bitmap path, so `measureText` returns the SAME width for the +// same text on both paths (the documented width-match contract — +// tolerance 0: both paths read the same M2-TEXT-01 metrics). The SDF +// atlas's texture must be bound through `SpriteRenderer::bindSdfAtlas` +// (the per-atlas shader mode travels with the texture bind); the scene +// gives the item the matching `atlasId`. +// // Declaration order is the ROW-MAJOR order (line by line, code point by // code point): the UI pass's deterministic order (RENDER-003) — all // glyphs of one item share the same depthKey, so the batcher's stable @@ -171,14 +195,16 @@ // `StringTable` is move-only (the `SpriteBatcher` precedent): one owner // (the scene set-up thread); the render phase reads it (`declareText` // / `measureText` read the spans) — the table is never shared with a -// concurrent writer (the single-owner pattern). `GlyphAtlas` is the -// scene's (M2-TEXT-01): a `TextItem.font` is a NON-OWNING pointer that +// concurrent writer (the single-owner pattern). `GlyphAtlas` (M2-TEXT- +// 01) and `SdfGlyphAtlas` (M2-TEXT-03) are the scene's: a +// `TextItem.font` / `TextItem.sdfFont` is a NON-OWNING pointer that // MUST outlive every frame that declares through it (the scene owns -// the atlas, the frame owns the items). `TextItem` is a plain value -// (no ownership, nothing to release). Presentation-only (ARCH-009): -// none of this is sim state (no state-hash / replay involvement). -// No GL calls anywhere in this header (the GL half is the existing -// M2-SPRITE-02 `SpriteRenderer::submit` + `bindAtlas`). +// the atlas, the frame owns the items — the mode's atlas is the one +// read). `TextItem` is a plain value (no ownership, nothing to +// release). Presentation-only (ARCH-009): none of this is sim state +// (no state-hash / replay involvement). No GL calls anywhere in this +// header (the GL half is the M2-SPRITE-02 `SpriteRenderer::submit` + +// `bindAtlas` / `bindSdfAtlas`). // // --------------------------------------------------------------------------- // Performance (PERF-002/003/004, DOC-004) @@ -207,6 +233,8 @@ // before the one-time `bindAtlas` upload — // RENDER-004: no unexpected GPU work in the // frame hot path). +// expandSdfGlyphAtlasRgba8 the same, for the SDF field (M2-TEXT-03, +// before the one-time `bindSdfAtlas` upload). // // Call site: once per frame per text item, in the RENDER phase (the // M2-GL-02 cull/batch stage of the UI pass) — never in the simulation @@ -224,12 +252,15 @@ // - StringTable::intern: stopped table → InvalidArgument (no log); // beyond either bound → BudgetExhausted + one rate-limited Warn // `string_table/table_full` (fields: `limit`, `capacity`); -// - measureText: stopped/missing font, invalid scale, or an invalid -// handle → InvalidArgument (no log — the precondition contract); -// - declareText: stopped/missing font, invalid scale, an invalid -// handle, an out-of-domain maxWidth, or a line wider than -// kTextMaxLineWidthPx → InvalidArgument, NOTHING declared (the -// pre-check runs before the first add — no partial text); a +// - measureText: stopped/missing font (the mode's font — `font` for +// Bitmap, `sdfFont` for SDF), invalid scale, or an invalid handle +// → InvalidArgument (no log — the precondition contract); +// - declareText: stopped/missing font (the item's mode selects it: +// Bitmap reads `font`, SDF reads `sdfFont` — a null or stopped one +// fails), invalid scale, an invalid handle, an out-of-domain +// maxWidth, or a line wider than kTextMaxLineWidthPx → +// InvalidArgument, NOTHING declared (the pre-check runs before the +// first add — no partial text); a // failed batcher add (stopped batcher → BudgetExhausted, closed // window → InvalidArgument) fails the call with the batcher's // error — the items already declared by the call remain in the @@ -254,8 +285,16 @@ // it for the frame's total glyph count (a 100-character label at // 4x scale is ~100 quads). // - Do not expect sub-pixel text: the scale is INTEGER (the -// M2-TEXT-01 convention); fractional scales are out of scope in M2 -// (the SDF pass, M2-TEXT-03, is the zoom-crispness path). +// M2-TEXT-01 convention); fractional scales are out of scope in M2. +// The SDF path (M2-TEXT-03, `FontRenderMode::Sdf`) is the +// zoom-crispness path: same integer scale, but the glyph's screen +// edge is thresholded from the distance field with a screen-space +// AA band, so it stays crisp at any zoom (the font_sdf.h preamble). +// - An SDF-mode item's `atlasId` must name the atlas the scene bound +// with `bindSdfAtlas` (the per-atlas shader mode travels with the +// texture bind — binding the SDF field with `bindAtlas` would draw +// it with the bitmap branch and the field's raw alpha would render +// wrong). // // Canonical narrative: docs/concepts/coordinates.md §4.12 (ARCH-008); // API contract: docs/api/text_items.md. @@ -272,6 +311,7 @@ #include "laige/errors.h" #include "laige/logging.h" #include "laige/render/font.h" +#include "laige/render/font_sdf.h" #include "laige/render/sprite_batcher.h" #include "laige/result.h" @@ -518,6 +558,21 @@ StringTable::intern(std::span codes) noexcept { // The per-item presentation values (UI pass, screen px) // --------------------------------------------------------------------------- +// The glyph path of a text item (M2-TEXT-03 — the documented scene- +// config key that selects the font): Bitmap is the default (the +// M2-TEXT-01 glyph atlas — `TextItem.font`); Sdf is the signed +// distance-field atlas (the M2-TEXT-03 `SdfGlyphAtlas` — +// `TextItem.sdfFont`): the same metrics and layout, the glyph's +// screen edge thresholded from the field (the header's glyph-quad +// section). +enum class FontRenderMode : std::uint8_t { + // The M2-TEXT-01 bitmap glyph atlas (`TextItem.font`). + Bitmap = 0, + // The M2-TEXT-03 signed distance-field glyph atlas (`TextItem. + // sdfFont`). + Sdf = 1, +}; + // The per-line alignment of the text block (the header's model // section: the anchor semantics of TextItem.x). enum class TextAlignment : std::uint8_t { @@ -540,8 +595,14 @@ struct TextItem { // std::string in the path). StringRef text{}; // The glyph atlas (M2-TEXT-01) — non-owning (scene-owned; must - // outlive the frame). + // outlive the frame). Read when `mode == FontRenderMode::Bitmap`. const GlyphAtlas* font{}; + // The glyph path (M2-TEXT-03 — the documented scene-config key): + // Bitmap (default) draws from `font`, Sdf from `sdfFont`. + FontRenderMode mode{FontRenderMode::Bitmap}; + // The SDF glyph atlas (M2-TEXT-03) — non-owning (scene-owned; must + // outlive the frame). Read when `mode == FontRenderMode::Sdf`. + const SdfGlyphAtlas* sdfFont{}; // The text block's anchor x, screen px (the header's model section: // left/center/right edge by `alignment`). float x{}; @@ -598,8 +659,12 @@ struct TextLine { // widths are in screen px at the item's `scale` (the integer-scale // convention — every advance is multiplied by the scale). Pure // (no allocation, no logging): a handful of integer sums over the -// line's codes. -[[nodiscard]] inline bool nextLine(const GlyphAtlas& font, +// line's codes. Templated over the font type (M2-TEXT-03): the walk +// reads only `glyph(code)->advance` — the `GlyphAtlas` and the +// `SdfGlyphAtlas` expose the identical metrics (the same M2-TEXT-01 +// metrics), so one walk serves both glyph paths. +template +[[nodiscard]] inline bool nextLine(const Font& font, std::span codes, std::uint32_t scale, std::int64_t maxWidth, std::uint32_t pos, @@ -671,18 +736,16 @@ struct TextLine { // The width measurement (the M2-UI-01 panel-sizing read) // --------------------------------------------------------------------------- -// The rendered ink-block width of `text` in screen px at `scale` -// (the header's wrap model, no-wrap: the sum of the glyph advances + -// one space advance per inter-word gap, leading/trailing spaces -// excluded — exactly what the declared quads span). O(glyphs); no -// allocation, no logging, no GL. -// -// Fails (no log — the precondition contract): a stopped/missing font, -// a scale outside [1, kTextMaxScale], or a handle the table does not -// own → InvalidArgument. An empty string (or an all-space one) -// measures 0. -[[nodiscard]] inline laige::Result measureText( - const GlyphAtlas& font, const StringTable& table, StringRef text, +namespace detail { + +// The shared measurement walk (M2-TEXT-03): one code path for both +// glyph paths — the metrics are the M2-TEXT-01 metrics on either +// atlas, so the width is EXACTLY the same for the same text on the +// bitmap and the SDF path (the documented width-match contract, +// tolerance 0). +template +[[nodiscard]] inline laige::Result measureTextImpl( + const Font& font, const StringTable& table, StringRef text, std::uint32_t scale) noexcept { if (!font.valid() || scale < 1 || scale > kTextMaxScale || !table.isValid(text)) { @@ -698,37 +761,18 @@ struct TextLine { return laige::Result::success(line.width); } -// --------------------------------------------------------------------------- -// The text -> glyph-quads pass (the UI pass's declaration) -// --------------------------------------------------------------------------- - -// Declares `item`'s glyphs into `batcher` under its (atlasId, -// materialId, blend) group (the header's model + wrap + domain -// sections): O(glyphs), no allocation, no logging on the happy path, -// no GL. The batcher must be live (created, nonzero capacity) and its -// frame window OPEN (the frame-protocol contract, the -// `declareParticles` precedent): a stopped batcher fails the first add -// with BudgetExhausted, a closed window with InvalidArgument. A line -// wider than kTextMaxLineWidthPx fails InvalidArgument BEFORE the -// first add (nothing declared — the pre-check). An empty text declares -// nothing and succeeds. The first failed add fails the call with the -// batcher's error (the items already declared remain in the frame — -// the batcher owns the frame state). -[[nodiscard]] inline laige::Status declareText( - SpriteBatcher& batcher, const StringTable& table, - const TextItem& item) noexcept { - // The preconditions (first failure wins, InvalidArgument, no log — - // the header's failure section): the scene-owned storage is live, - // the item fields are in domain. - if (item.font == nullptr || !item.font->valid() || - !table.isValid(item.text) || item.scale < 1 || - item.scale > kTextMaxScale || - (item.maxWidth != 0 && - (item.maxWidth < 1 || - item.maxWidth > static_cast(kTextMaxLineWidthPx)))) { - return laige::Status::failure(laige::ErrorCode::InvalidArgument); - } - const GlyphAtlas& font = *item.font; +// The shared declare walk (M2-TEXT-03): one code path for both glyph +// paths. `pad` is the distance-field radius in px at 1x (0 on the +// bitmap path, the atlas's `radius()` on the SDF path): the quad +// covers the ink rect grown by `pad` on all four sides (the padded +// cell — the header's glyph-quad section), the pen still advances by +// the ink's advance, and the layout is otherwise unchanged. The font +// type's metrics are the M2-TEXT-01 metrics on either atlas; its +// `atlasX`/`atlasY` are the (padded) cell's origin on both. +template +[[nodiscard]] inline laige::Status declareTextImpl( + SpriteBatcher& batcher, const StringTable& table, const TextItem& item, + const Font& font, std::int32_t pad) noexcept { const std::span codes = table.text(item.text); const std::int64_t maxWidth = static_cast(item.maxWidth); // 0 = no wrap @@ -783,18 +827,26 @@ struct TextLine { } const GlyphMetrics& m = *font.glyph(code); if (m.width > 0 && m.height > 0) { - // The ink rect (exact int math, one float conversion per - // field — the header's screen-domain section). - const float inkW = static_cast( - static_cast(m.width) * item.scale); - const float inkH = static_cast( - static_cast(m.height) * item.scale); - const float inkLeft = - pen + static_cast(static_cast(m.bearingX) * - item.scale); - const float inkTop = - baselineY - static_cast(static_cast( - m.bearingY) * item.scale); + // The quad's cell rect: the ink rect grown by `pad` on all + // four sides (exact int math, one float conversion per field + // — the header's screen-domain section). pad = 0 on the + // bitmap path (the M2-TEXT-02 ink rect, verbatim); pad = the + // SDF radius on the SDF path (the padded cell — the + // header's glyph-quad section). + const std::int32_t cellW = m.width + 2 * pad; + const std::int32_t cellH = m.height + 2 * pad; + const float inkW = + static_cast(static_cast(cellW) * + item.scale); + const float inkH = + static_cast(static_cast(cellH) * + item.scale); + const float inkLeft = pen + + static_cast(static_cast(m.bearingX - pad) * + item.scale); + const float inkTop = baselineY - + static_cast(static_cast(m.bearingY + pad) * + item.scale); SpriteItem quad; quad.pos = Vec2{inkLeft + inkW * 0.5f, inkTop + inkH * 0.5f}; quad.depthKey = item.depthKey; @@ -804,11 +856,9 @@ struct TextLine { quad.uv = SpriteUvRect{ static_cast(m.atlasX) / static_cast(atlasW), static_cast(m.atlasY) / static_cast(atlasH), - static_cast(m.atlasX + - static_cast(m.width)) / + static_cast(m.atlasX + static_cast(cellW)) / static_cast(atlasW), - static_cast(m.atlasY + - static_cast(m.height)) / + static_cast(m.atlasY + static_cast(cellH)) / static_cast(atlasH)}; quad.frameIndex = 0; quad.rotation = 0.0f; @@ -833,24 +883,109 @@ struct TextLine { return laige::Status{}; } +} // namespace detail + +// The rendered ink-block width of `text` in screen px at `scale` +// (the header's wrap model, no-wrap: the sum of the glyph advances + +// one space advance per inter-word gap, leading/trailing spaces +// excluded — exactly what the declared quads span). O(glyphs); no +// allocation, no logging, no GL. +// +// Fails (no log — the precondition contract): a stopped/missing font, +// a scale outside [1, kTextMaxScale], or a handle the table does not +// own → InvalidArgument. An empty string (or an all-space one) +// measures 0. +[[nodiscard]] inline laige::Result measureText( + const GlyphAtlas& font, const StringTable& table, StringRef text, + std::uint32_t scale) noexcept { + return detail::measureTextImpl(font, table, text, scale); +} + +// The SDF overload (M2-TEXT-03): the SAME width for the same text — +// both paths read the identical M2-TEXT-01 metrics (the documented +// width-match contract, tolerance 0). +[[nodiscard]] inline laige::Result measureText( + const SdfGlyphAtlas& font, const StringTable& table, StringRef text, + std::uint32_t scale) noexcept { + return detail::measureTextImpl(font, table, text, scale); +} + +// --------------------------------------------------------------------------- +// The text -> glyph-quads pass (the UI pass's declaration) +// --------------------------------------------------------------------------- + +// Declares `item`'s glyphs into `batcher` under its (atlasId, +// materialId, blend) group (the header's model + wrap + domain +// sections): O(glyphs), no allocation, no logging on the happy path, +// no GL. The batcher must be live (created, nonzero capacity) and its +// frame window OPEN (the frame-protocol contract, the +// `declareParticles` precedent): a stopped batcher fails the first add +// with BudgetExhausted, a closed window with InvalidArgument. A line +// wider than kTextMaxLineWidthPx fails InvalidArgument BEFORE the +// first add (nothing declared — the pre-check). An empty text declares +// nothing and succeeds. The first failed add fails the call with the +// batcher's error (the items already declared remain in the frame — +// the batcher owns the frame state). +// +// The item's MODE selects the font (M2-TEXT-03 — the documented scene- +// config key): `FontRenderMode::Bitmap` (default) declares from +// `item.font` with the ink-cell quads; `FontRenderMode::Sdf` declares +// from `item.sdfFont` with the padded-cell quads (the header's +// glyph-quad section — the layout and the width are UNCHANGED, the +// quad covers the field's padded cell). +[[nodiscard]] inline laige::Status declareText( + SpriteBatcher& batcher, const StringTable& table, + const TextItem& item) noexcept { + // The mode-independent preconditions (first failure wins, + // InvalidArgument, no log — the header's failure section): the + // string handle is live, the item fields are in domain. + if (!table.isValid(item.text) || item.scale < 1 || + item.scale > kTextMaxScale || + (item.maxWidth != 0 && + (item.maxWidth < 1 || + item.maxWidth > static_cast(kTextMaxLineWidthPx)))) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + // The mode selects the scene-owned font (the header's failure + // section: a null or stopped one fails — first failure wins, no + // log). + if (item.mode == FontRenderMode::Sdf) { + if (item.sdfFont == nullptr || !item.sdfFont->valid()) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + return detail::declareTextImpl( + batcher, table, item, *item.sdfFont, + static_cast(item.sdfFont->radius())); + } + if (item.font == nullptr || !item.font->valid()) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + return detail::declareTextImpl(batcher, table, item, *item.font, 0); +} + // --------------------------------------------------------------------------- -// The atlas upload bytes (the M2-SPRITE-02 bindAtlas input) +// The atlas upload bytes (the M2-SPRITE-02 bindAtlas / bindSdfAtlas +// input) // --------------------------------------------------------------------------- -// Expands the 8-bit alpha glyph atlas into the white-on-alpha RGBA8 -// bytes the M2-SPRITE-02 `bindAtlas` uploads (one call per font per -// scene load — the SETUP/asset path, before the one-time texture -// upload, RENDER-004). Every texel becomes (255, 255, 255, alpha): -// the M2-SPRITE-02 shader's multiplicative tint then makes the drawn -// color = tint × glyph alpha (the header's glyph-quad section — the -// standard text coloring). O(W·H); no allocation (the caller owns the -// span — one W·H·4-byte buffer at set-up), no logging, no GL. +namespace detail { + +// The shared atlas upload-byte expansion: every texel becomes +// (255, 255, 255, value) — the white-on-alpha RGBA8 the M2-SPRITE-02 +// `bindAtlas` / `bindSdfAtlas` upload (one call per font per scene +// load — the SETUP/asset path, before the one-time texture upload, +// RENDER-004). The M2-SPRITE-02 shader's multiplicative tint then +// makes the drawn color = tint × the texel's alpha (the header's +// glyph-quad section — the standard text coloring). O(W·H); no +// allocation (the caller owns the span — one W·H·4-byte buffer at +// set-up), no logging, no GL. // // Fails (no GL work, no log — the precondition contract): a stopped // atlas, or `rgba` not exactly `atlasWidth * atlasHeight * 4` bytes → // InvalidArgument. -[[nodiscard]] inline laige::Status expandGlyphAtlasRgba8( - const GlyphAtlas& atlas, std::span rgba) noexcept { +template +[[nodiscard]] inline laige::Status expandGlyphAtlasRgba8Impl( + const Atlas& atlas, std::span rgba) noexcept { if (!atlas.valid()) { return laige::Status::failure(laige::ErrorCode::InvalidArgument); } @@ -860,9 +995,9 @@ struct TextLine { if (rgba.size() != need) { return laige::Status::failure(laige::ErrorCode::InvalidArgument); } - const std::span alpha = atlas.atlas(); + const std::span values = atlas.atlas(); for (std::uint64_t i = 0; i < need / 4; ++i) { - const std::uint8_t a = alpha[static_cast(i)]; + const std::uint8_t a = values[static_cast(i)]; std::uint8_t* px = rgba.data() + i * 4; px[0] = 255; px[1] = 255; @@ -872,4 +1007,25 @@ struct TextLine { return laige::Status{}; } +} // namespace detail + +// Expands the 8-bit alpha glyph atlas (M2-TEXT-01) into the +// white-on-alpha RGBA8 bytes the M2-SPRITE-02 `bindAtlas` uploads +// (see the shared implementation above — one call per font per scene +// load, before the one-time texture upload, RENDER-004). +[[nodiscard]] inline laige::Status expandGlyphAtlasRgba8( + const GlyphAtlas& atlas, std::span rgba) noexcept { + return detail::expandGlyphAtlasRgba8Impl(atlas, rgba); +} + +// Expands the 8-bit SDF glyph atlas (M2-TEXT-03) into the white-on- +// alpha RGBA8 bytes the M2-SPRITE-02 `bindSdfAtlas` uploads (the same +// expansion — the SDF field's value is the texture's alpha channel; +// the shader's SDF branch thresholds it). One call per font per +// scene load, before the one-time texture upload (RENDER-004). +[[nodiscard]] inline laige::Status expandSdfGlyphAtlasRgba8( + const SdfGlyphAtlas& atlas, std::span rgba) noexcept { + return detail::expandGlyphAtlasRgba8Impl(atlas, rgba); +} + } // namespace laige::render diff --git a/src/laige-render/sprite_renderer.cpp b/src/laige-render/sprite_renderer.cpp index 73c3ee2..58cbdd0 100644 --- a/src/laige-render/sprite_renderer.cpp +++ b/src/laige-render/sprite_renderer.cpp @@ -76,11 +76,33 @@ constexpr const char* kVertexShader = constexpr const char* kFragmentShader = "#version 330 core\n" "uniform sampler2D uAtlas;\n" + // The SDF mode (M2-TEXT-03): 0.0 = the bitmap path (the alpha is + // the texel's own alpha), 1.0 = the SDF glyph atlas (the alpha is + // the distance field thresholded below). Per-ATLAS state: set at + // the texture bind of each group (the atlas registry carries it), + // never per instance (the instance buffer's 52 B layout is + // unchanged). + "uniform float uSdf;\n" + // The SDF threshold in the field's 0..1 domain (the bindSdfAtlas + // argument; 0.5 = the 0.5-coverage contour = the 128 byte value). + "uniform float uSdfThreshold;\n" "in vec2 vUv;\n" "in vec4 vTint;\n" "out vec4 outColor;\n" "void main() {\n" - " outColor = texture(uAtlas, vUv) * vTint;\n" + " vec4 t = texture(uAtlas, vUv);\n" + " float alpha = t.a;\n" + " if (uSdf > 0.5) {\n" + // The SDF anti-aliasing: the screen-space derivative band (the + // standard SDF rule — the band tracks the field's on-screen slope, + // so it is exactly one screen pixel wide at ANY zoom). The floor + // is one 8-bit quantization step of the field, 1/255 (the + // kSdfMinSpread of the font_sdf.h threshold section). + " float aa = max(length(vec2(dFdx(t.a), dFdy(t.a))),\n" + " 0.00392156862745098f);\n" + " alpha = smoothstep(uSdfThreshold - aa, uSdfThreshold + aa, t.a);\n" + " }\n" + " outColor = vec4(t.rgb, alpha) * vTint;\n" "}\n"; // One float in the per-instance staging layout (52 B per instance): @@ -145,6 +167,12 @@ struct SpriteRenderer::Impl { // width/height (w * h * 4 bytes, GL_RGBA8). std::uint32_t width{0}; std::uint32_t height{0}; + // The SDF mode (M2-TEXT-03): this atlas is a signed distance-field + // glyph atlas (font_sdf.h) rather than a bitmap; the shader's + // uSdf/uSdfThreshold uniforms are set from these at the texture + // bind of each group (the fragment shader's per-atlas state). + bool sdf{false}; + float sdfThreshold{0.5f}; bool bound{false}; }; std::unique_ptr atlases; @@ -160,16 +188,27 @@ struct SpriteRenderer::Impl { // pinned by the GLSL layout qualifiers, 1..5). int locMatrix{-1}; int locAtlas{-1}; + // The SDF mode uniforms (M2-TEXT-03 — per-atlas state, set at the + // texture bind of each group; the 0/0.5 defaults are inert: the + // fragment shader only reads them when uSdf > 0.5, and the first + // group of the first submit always sets them on its bind). + int locSdf{-1}; + int locSdfThreshold{-1}; // The opt-in PRIMITIVES_GENERATED query (the header's Options // section: diagnostic mode, zero cost when off). bool primitiveQuery{false}; // Per-frame state (reset at the start of every submit; a failed - // submit leaves them ZERO). + // submit leaves them ZERO). The last-TEXTURE (not last-atlas-id) + // tracking: a re-bind on the SAME atlas id (the bindAtlas "last + // bind wins" contract) replaces the texture OBJECT — the change + // detection must see the new object, or the next frame would draw + // the deleted old texture (and publish the old per-atlas SDF state, + // M2-TEXT-03) from a stale bind. SpriteDrawStats frame{}; - std::uint32_t lastAtlas{0}; - bool haveLastAtlas{false}; + std::uint32_t lastTexture{0}; + bool haveLastTexture{false}; BlendMode lastBlend{BlendMode::Alpha}; bool haveLastBlend{false}; @@ -207,6 +246,15 @@ struct SpriteRenderer::Impl { } } + // The shared bind path (the GL texture upload + the registry slot + // commit — defined after the struct, below the lifecycle section): + // the per-atlas shader state (the SDF mode, M2-TEXT-03) is committed + // WITH the texture, so a re-bind replaces both. + laige::Status uploadAtlas(std::uint32_t atlasId, std::uint32_t width, + std::uint32_t height, + std::span rgba, bool sdf, + float sdfThreshold); + ~Impl() { destroy(); } }; @@ -394,7 +442,11 @@ laige::Result SpriteRenderer::create( "uWorldToNdc")); impl->locAtlas = static_cast( glGetUniformLocation(program, "uAtlas")); - if (impl->locMatrix < 0 || impl->locAtlas < 0) { + impl->locSdf = static_cast(glGetUniformLocation(program, "uSdf")); + impl->locSdfThreshold = + static_cast(glGetUniformLocation(program, "uSdfThreshold")); + if (impl->locMatrix < 0 || impl->locAtlas < 0 || impl->locSdf < 0 || + impl->locSdfThreshold < 0) { return fail("uniform_lookup", glGetError(), nullptr); } @@ -481,7 +533,8 @@ laige::Result SpriteRenderer::create( } // ------------------------------------------------------------------------ -// bindAtlas — the setup/asset path (never the frame hot path) +// bindAtlas / bindSdfAtlas — the setup/asset path (never the frame hot +// path) // ------------------------------------------------------------------------ laige::Status SpriteRenderer::bindAtlas(std::uint32_t atlasId, @@ -505,7 +558,49 @@ laige::Status SpriteRenderer::bindAtlas(std::uint32_t atlasId, static_cast(width) * static_cast(height) * 4u) { return laige::Status(laige::ErrorCode::InvalidArgument); } - const laige::Status current = i.ctx->makeCurrent(); + return i.uploadAtlas(atlasId, width, height, rgba, false, 0.5f); +} + +laige::Status SpriteRenderer::bindSdfAtlas(std::uint32_t atlasId, + std::uint32_t width, + std::uint32_t height, + std::span rgba, + float threshold) { + if (!valid()) { // the stopped state: impl_ is null + return laige::Status(laige::ErrorCode::InvalidArgument); + } + Impl& i = *impl_; + // The SDF threshold domain (the field's 0..1 domain — + // font_sdf.h's field section): outside it the shader's smoothstep + // band would be degenerate (the precondition contract — no log). + if (threshold < 0.0f || threshold > 1.0f) { + return laige::Status(laige::ErrorCode::InvalidArgument); + } + // The argument domain (the bindAtlas check, verbatim). + if (atlasId >= i.maxAtlases) { + return laige::Status(laige::ErrorCode::InvalidArgument); + } + if (width < 1 || static_cast(i.maxTextureSize) < width || + height < 1 || static_cast(i.maxTextureSize) < height) { + return laige::Status(laige::ErrorCode::InvalidArgument); + } + if (rgba.size() != + static_cast(width) * static_cast(height) * 4u) { + return laige::Status(laige::ErrorCode::InvalidArgument); + } + return i.uploadAtlas(atlasId, width, height, rgba, true, threshold); +} + +// The shared bind path (Impl member — the setup/asset path only): the +// GL texture upload + the registry slot commit. The atlas's per-frame +// shader state (the fragment shader's per-atlas SDF mode, M2-TEXT-03) +// is committed WITH the texture — the submit's texture bind of each +// group publishes the matching uSdf/uSdfThreshold uniforms from the +// slot (a re-bind replaces both). +laige::Status SpriteRenderer::Impl::uploadAtlas( + std::uint32_t atlasId, std::uint32_t width, std::uint32_t height, + std::span rgba, bool sdf, float sdfThreshold) { + const laige::Status current = ctx->makeCurrent(); if (current.isError()) { return current; } @@ -543,21 +638,25 @@ laige::Status SpriteRenderer::bindAtlas(std::uint32_t atlasId, // The last bind wins: replace any previous texture on this id. // The M2-SPRITE-04 VRAM estimate tracks the replacement (subtract // the old upload, add the new — both exact GL_RGBA8 byte counts). - Impl::Atlas& slot = i.atlases[atlasId]; + // The per-atlas shader state is replaced WITH the texture (a + // bitmap re-bind clears the SDF flag and vice versa). + Atlas& slot = atlases[atlasId]; const std::uint64_t newBytes = static_cast(width) * static_cast(height) * 4u; if (slot.bound) { glDeleteTextures(1, &slot.texture); - i.textureMemoryBytes -= + textureMemoryBytes -= static_cast(slot.width) * static_cast(slot.height) * 4u; } slot.texture = texture; slot.width = width; slot.height = height; + slot.sdf = sdf; + slot.sdfThreshold = sdfThreshold; slot.bound = true; - i.textureMemoryBytes += newBytes; + textureMemoryBytes += newBytes; return laige::Status{}; } @@ -731,10 +830,18 @@ laige::Status SpriteRenderer::submit(SpriteBatcher& batcher, } std::uint32_t base = 0; for (const SpriteBatch& b : batcher.batches()) { - if (!i.haveLastAtlas || b.atlasId != i.lastAtlas) { + if (!i.haveLastTexture || + i.atlases[b.atlasId].texture != i.lastTexture) { glBindTexture(GL_TEXTURE_2D, i.atlases[b.atlasId].texture); - i.lastAtlas = b.atlasId; - i.haveLastAtlas = true; + // The per-atlas SDF state (M2-TEXT-03): the shader's uSdf/ + // uSdfThreshold uniforms travel WITH the texture bind (a group + // draws with the SDF branch iff its atlas was bound through + // bindSdfAtlas). The 0/0.5 defaults are never observed: the + // first group of the first submit always takes this branch. + glUniform1f(i.locSdf, i.atlases[b.atlasId].sdf ? 1.0f : 0.0f); + glUniform1f(i.locSdfThreshold, i.atlases[b.atlasId].sdfThreshold); + i.lastTexture = i.atlases[b.atlasId].texture; + i.haveLastTexture = true; i.frame.textureBinds += 1; } if (!i.haveLastBlend || b.blend != i.lastBlend) { diff --git a/tests/laige-render/CMakeLists.txt b/tests/laige-render/CMakeLists.txt index 33b0758..a18e941 100644 --- a/tests/laige-render/CMakeLists.txt +++ b/tests/laige-render/CMakeLists.txt @@ -240,7 +240,7 @@ add_executable(laige-render_tests depth_sort_tests.cpp sprite_batcher_tests.cpp sprite_draw_tests.cpp sprite_frames_tests.cpp render_counters_tests.cpp tilemap_tests.cpp tilemap_anim_tests.cpp parallax_tests.cpp particle_render_tests.cpp - font_tests.cpp text_items_tests.cpp) + font_tests.cpp text_items_tests.cpp font_sdf_tests.cpp) laige_apply_engine_policy(laige-render_tests) target_link_libraries(laige-render_tests PRIVATE gtest_main laige-render) # The randomized suites draw through the test-only seed helper @@ -447,6 +447,23 @@ set_tests_properties(text_items PROPERTIES ENVIRONMENT "LAIGE_TEST_FONT_PATH=${CMAKE_SOURCE_DIR}/tests/laige-render/assets/vera.ttf") +# M2-TEXT-03: the step's Verify command is `ctest -R font_sdf`. The +# FontSdf* suites (the SDF atlas create/validate, the metrics parity +# with the bitmap path, the determinism property, the threshold edge +# behavior, the fallback, the upload bytes, the zero-alloc lookup) are +# pure engine data (no GL calls) — they run in every local tree and in +# CI; the FontSdfSmoke suite needs the same usable OpenGL 3.3 +# environment as the TextSmoke suite (it GTEST_SKIPs where absent — +# the documented environment contract). All read the committed test +# font through LAIGE_TEST_FONT_PATH. No budget gate: the step's +# roadmap scope has no standalone budgets.json entry (the SDF atlas is +# built once at scene set-up — the per-frame text cost is part of the +# composite 50k render-CPU budget, M2-PERF-01). +add_test(NAME font_sdf COMMAND laige-render_tests --gtest_filter=FontSdf*) +set_tests_properties(font_sdf PROPERTIES + ENVIRONMENT + "LAIGE_TEST_FONT_PATH=${CMAKE_SOURCE_DIR}/tests/laige-render/assets/vera.ttf") + if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux") target_compile_definitions(laige-render_tests PRIVATE LAIGE_ISO_DEPTH_BUDGET=1) target_compile_definitions(laige-render_tests PRIVATE LAIGE_ISO_PICK_BUDGET=1) @@ -488,7 +505,7 @@ if(LAIGE_TSAN) iso_depth_key iso_depth_table camera iso_camera projection iso_picking depth_sort batcher sprite_draw sprite_frames render_counters tilemap tilemap_anim parallax particle_render font - text_items PROPERTIES + text_items font_sdf PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1;LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json;LAIGE_TEST_FONT_PATH=${CMAKE_SOURCE_DIR}/tests/laige-render/assets/vera.ttf") endif() @@ -514,12 +531,13 @@ if(LAIGE_ASAN) # stays fully active (the smoke still performs its GL work under # the sanitizer). The test-level ENVIRONMENT replaces the job-level # ASAN_OPTIONS (GitHub workflow), so mirror its options here. - # The unfiltered entry runs the Font* suites too (M2-TEXT-01) and the - # TextSmoke* suite (M2-TEXT-02 — the offscreen smoke creates the same + # The unfiltered entry runs the Font* suites too (M2-TEXT-01), the + # FontSdf* suites (M2-TEXT-03), and the TextSmoke* / FontSdfSmoke* + # suites (M2-TEXT-02/03 — the offscreen smokes create the same # headless context as the other render entries), so the replacement # environment carries the test-font path as well. set_tests_properties(laige-render_tests gl_context render_thread - sprite_draw render_counters text_items PROPERTIES + sprite_draw render_counters text_items font_sdf PROPERTIES ENVIRONMENT "ASAN_OPTIONS=abort_on_error=1:halt_on_error=1:detect_leaks=0:log_path=${CMAKE_SOURCE_DIR}/asan-reports/asan;LAIGE_TEST_FONT_PATH=${CMAKE_SOURCE_DIR}/tests/laige-render/assets/vera.ttf") endif() @@ -574,3 +592,8 @@ set_tests_properties(font PROPERTIES TIMEOUT 60) # 1 000-frame zero-allocation declare loop — generous headroom for a # slow headless runner, like the `render_counters` entry. set_tests_properties(text_items PROPERTIES TIMEOUT 120) +# The `font_sdf` entry is the same shape (the SDF atlas's create is a +# two-pass distance transform over the Latin-1 range at set-up; the +# smoke is one 1024x1024 SDF-field upload + one frame + pixel reads, +# and the zero-alloc loop is 1 000 lookups). +set_tests_properties(font_sdf PROPERTIES TIMEOUT 120) diff --git a/tests/laige-render/font_sdf_tests.cpp b/tests/laige-render/font_sdf_tests.cpp new file mode 100644 index 0000000..32ef0cf --- /dev/null +++ b/tests/laige-render/font_sdf_tests.cpp @@ -0,0 +1,938 @@ +// laige-render SDF glyph atlas tests (M2-TEXT-03): the SDF half of +// FR-2.8 — the configured font + glyph range + distance-field radius +// converted ONCE at scene set-up into a fixed-size 8-bit signed +// distance-field glyph atlas + per-glyph metrics (the M2-TEXT-01 +// metrics unchanged), in laige/render/font_sdf.h. +// +// The FontSdfCreate/FontSdfMetrics/FontSdfDeterminism/ +// FontSdfThreshold/FontSdfFallback/FontSdfExpand/FontSdfZeroAlloc +// suites are pure engine data (no GL context, no GL environment +// needed): every one runs in every local tree and in CI. The +// FontSdfSmoke suite is the offscreen GL render (the SDF field +// uploaded through `bindSdfAtlas`, one frame, pixel reads) and +// GTEST_SKIPs where no usable OpenGL 3.3 environment — the documented +// environment contract. The field goldens are MEASURED against the +// committed test font (tests/laige-render/assets/vera.ttf — Bitstream +// Vera Sans, OFL 1.1, see the assets/ README) at fontSize 16 and the +// default radius 8: the font is a committed in-tree asset (P0 runners +// differ in their font packages, NFR-8.8 — no system font is trusted). +// +// The determinism contract is IN-PROCESS (same font bytes + options → +// identical atlas bytes; presentation-only float, ARCH-009 — the +// scope of the M2-TEXT-01 font-atlas determinism test), printed as the +// machine-greppable `font-sdf-determinism` line (docs/testing.md §4). +// The width-match contract (the roadmap Verify property) is tested +// EXACT (tolerance 0 — both paths read the identical M2-TEXT-01 +// metrics). +// +// No budget gate: the step's roadmap scope has no standalone +// budgets.json entry (the SDF atlas is built once at scene set-up — +// the per-frame text cost is part of the composite 50k render-CPU +// budget, M2-PERF-01). +// +// The font bytes come from the LAIGE_TEST_FONT_PATH env var (set by +// the `font_sdf`, unfiltered `laige-render_tests`, and sanitizer-block +// CTest entries — see tests/laige-render/CMakeLists.txt). + +#include "laige/render/font.h" +#include "laige/render/font_sdf.h" +#include "laige/render/gl_context.h" +#include "laige/render/matrices.h" +#include "laige/render/sprite_renderer.h" +#include "laige/render/text_items.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/alloc_watch.h" +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige_test_seed.h" + +namespace { + +using laige::render::GlyphAtlas; +using laige::render::GlyphMetrics; +using laige::render::SdfGlyphAtlas; +using laige::render::kSdfDefaultRadius; +using laige::ErrorCode; + +// --------------------------------------------------------------------------- +// The committed test font (tests/laige-render/assets/vera.ttf — the +// LAIGE_TEST_FONT_PATH env var, CTest ENVIRONMENT). +// --------------------------------------------------------------------------- + +std::vector loadFont() { + // The cross-platform env read (tests/support/laige_test_seed.h — MSVC + // deprecates plain getenv under /WX, C4996). + const std::string path = laige::testing::ReadEnvVar("LAIGE_TEST_FONT_PATH"); + if (path.empty()) { + ADD_FAILURE() << "LAIGE_TEST_FONT_PATH is not set (CTest ENVIRONMENT " + "contract — see tests/laige-render/CMakeLists.txt)"; + return {}; + } + // The cross-platform open (fopen_s on MSVC — C4996). + std::FILE* f = nullptr; +#if defined(_MSC_VER) + if (fopen_s(&f, path.c_str(), "rb") != 0) { + ADD_FAILURE() << "Cannot open the test font: " << path; + return {}; + } +#else + f = std::fopen(path.c_str(), "rb"); + if (f == nullptr) { + ADD_FAILURE() << "Cannot open the test font: " << path; + return {}; + } +#endif + std::fseek(f, 0, SEEK_END); + const long size = std::ftell(f); + std::fseek(f, 0, SEEK_SET); + std::vector bytes(static_cast(size)); + const std::size_t got = std::fread(bytes.data(), 1, bytes.size(), f); + std::fclose(f); + if (got != bytes.size()) { + ADD_FAILURE() << "Short read of the test font: " << path; + return {}; + } + return bytes; +} + +GlyphAtlas makeBitmapFont() { + std::vector font = loadFont(); + auto r = GlyphAtlas::create(font, GlyphAtlas::Options{}); + if (!r.ok()) { + std::fprintf(stderr, "makeBitmapFont: create failed\n"); + std::abort(); + } + return std::move(r).takeValue(); +} + +SdfGlyphAtlas makeSdfFont(std::uint32_t radius) { + std::vector font = loadFont(); + SdfGlyphAtlas::Options options; + options.sdfRadius = radius; + auto r = SdfGlyphAtlas::create(font, options); + if (!r.ok()) { + std::fprintf(stderr, "makeSdfFont: create failed\n"); + std::abort(); + } + return std::move(r).takeValue(); +} + +std::vector makeSdfBytes() { + const SdfGlyphAtlas atlas = makeSdfFont(kSdfDefaultRadius); + return std::vector(atlas.atlas().begin(), atlas.atlas().end()); +} + +// FNV-1a 64 over a byte span (the machine-greppable fingerprint +// convention, docs/testing.md §4 — the font_tests atlasFnv1a64 +// pattern). +std::uint64_t atlasFnv1a64(const std::uint8_t* bytes, std::size_t n) { + std::uint64_t h = 0xcbf29ce484222325ull; // FNV offset basis (FNV-1a spec) + for (std::size_t i = 0; i < n; ++i) { + h ^= static_cast(bytes[i]); + h *= 0x10000001b3ull; // FNV prime (FNV-1a spec) + } + return h; +} + +// --------------------------------------------------------------------------- +// Log capture (the font_tests MemorySink pattern) +// --------------------------------------------------------------------------- + +class MemorySink : public laige::log::Sink { + public: + struct Entry { + laige::log::Severity severity{}; + std::string subsystem; + std::string event; + std::string message; + }; + + void emit(const laige::log::LogRecord& record) override { + Entry e; + e.severity = record.severity; + e.subsystem = record.subsystem; + e.event = record.event; + e.message = record.message; + entries.push_back(std::move(e)); + } + void flush() override {} + + std::vector entries; +}; + +MemorySink* installCaptureSink() { + auto sink = std::make_unique(); + MemorySink* ptr = sink.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + opts.rateLimiting = false; + if (!laige::log::Logger::instance().init(std::move(opts)).ok()) { + ADD_FAILURE() << "Logger::init (capture sink) failed"; + std::abort(); + } + return ptr; +} + +void restoreLogger() { + laige::log::LoggerOptions defaults; + if (!laige::log::Logger::instance().init(std::move(defaults)).ok()) { + ADD_FAILURE() << "Logger::init (restore default sink) failed"; + } +} + +// A small interned string (the text_items_tests makeTable/internText +// pattern). +laige::render::StringRef internText(laige::render::StringTable& table, + const char* s) { + std::vector codes; + for (const char* p = s; *p != '\0'; ++p) { + codes.push_back(static_cast( + static_cast(*p))); + } + auto r = table.intern(codes); + if (!r.ok()) { + ADD_FAILURE() << "intern failed"; + } + return std::move(r).takeValue(); +} + +} // namespace + +// --------------------------------------------------------------------------- +// FontSdfCreate — the create validation matrix (first failure wins), +// the untrusted-font boundary (SCALE-004), the stopped state, the +// capacity guard (the PADDED cell), the success log, and the move +// semantics +// --------------------------------------------------------------------------- + +TEST(FontSdfCreate, OptionsValidation) { + std::vector font = loadFont(); + MemorySink* sink = installCaptureSink(); + struct Case { + SdfGlyphAtlas::Options options; + const char* name; + }; + const std::vector cases = { + {SdfGlyphAtlas::Options{.atlasWidth = 0}, "atlasWidth zero"}, + {SdfGlyphAtlas::Options{.atlasWidth = laige::render::kFontMaxAtlasDimension + 1}, + "atlasWidth above the domain"}, + {SdfGlyphAtlas::Options{.atlasHeight = 0}, "atlasHeight zero"}, + {SdfGlyphAtlas::Options{.atlasHeight = laige::render::kFontMaxAtlasDimension + 1}, + "atlasHeight above the domain"}, + {SdfGlyphAtlas::Options{.fontSize = 0}, "fontSize zero"}, + {SdfGlyphAtlas::Options{.fontSize = laige::render::kFontMaxFontSize + 1}, + "fontSize above the domain"}, + {SdfGlyphAtlas::Options{.glyphFirst = 100, .glyphLast = 99}, + "glyphFirst above glyphLast"}, + {SdfGlyphAtlas::Options{.glyphLast = laige::render::kFontMaxGlyphCode + 1}, + "glyphLast above the Unicode ceiling"}, + {SdfGlyphAtlas::Options{.glyphFirst = 0, .glyphLast = laige::render::kFontMaxGlyphCount}, + "glyph range above the size bound"}, + {SdfGlyphAtlas::Options{.sdfRadius = 0}, "radius below the domain"}, + {SdfGlyphAtlas::Options{.sdfRadius = laige::render::kSdfMaxRadius + 1}, + "radius above the domain"}, + }; + for (const Case& c : cases) { + auto r = SdfGlyphAtlas::create(font, c.options); + EXPECT_TRUE(r.isError()) << c.name; + if (r.isError()) { + EXPECT_EQ(r.error(), ErrorCode::InvalidArgument) << c.name; + } + } + EXPECT_EQ(sink->entries.size(), 0u); // no log — the create precedent + restoreLogger(); +} + +TEST(FontSdfCreate, EmptyOrTruncatedFont) { + MemorySink* sink = installCaptureSink(); + const std::vector empty; + auto r = SdfGlyphAtlas::create(empty, SdfGlyphAtlas::Options{}); + EXPECT_TRUE(r.isError()); + if (r.isError()) { + EXPECT_EQ(r.error(), ErrorCode::MalformedInput); + } + // A buffer shorter than the header + table directory guard (12 + // bytes) — rejected before any stb read (SCALE-004). + const std::vector shortBuf(10, 0xAB); + r = SdfGlyphAtlas::create(shortBuf, SdfGlyphAtlas::Options{}); + EXPECT_TRUE(r.isError()); + if (r.isError()) { + EXPECT_EQ(r.error(), ErrorCode::MalformedInput); + } + // A valid font cut inside the table directory: the guard rejects it + // (the directory is longer than the buffer) — never read past. + std::vector font = loadFont(); + auto rCut = SdfGlyphAtlas::create( + std::span(font.data(), 16), + SdfGlyphAtlas::Options{}); + EXPECT_TRUE(rCut.isError()); + if (rCut.isError()) { + EXPECT_EQ(rCut.error(), ErrorCode::MalformedInput); + } + // Exactly one structured Error per rejection (LOG-002). + const std::size_t errors = static_cast(std::count_if( + sink->entries.begin(), sink->entries.end(), + [](const MemorySink::Entry& e) { + return e.severity == laige::log::Severity::Error && + e.subsystem == "font" && e.event == "font_invalid"; + })); + EXPECT_EQ(errors, 3u); + restoreLogger(); +} + +TEST(FontSdfCreate, PaddedCellCapacity) { + // The M "M" ink at 16 px is 10x11; with the default radius 8 its + // PADDED cell is 26x27. An 8x8 atlas cannot hold it — the capacity + // guard fires before any bitmap allocation (BudgetExhausted + one + // structured Error). + std::vector font = loadFont(); + MemorySink* sink = installCaptureSink(); + SdfGlyphAtlas::Options small; + small.atlasWidth = 8; + small.atlasHeight = 8; + auto r = SdfGlyphAtlas::create(font, small); + EXPECT_TRUE(r.isError()); + if (r.isError()) { + EXPECT_EQ(r.error(), ErrorCode::BudgetExhausted); + } + // A 32x32 atlas holds the padded M (26x27) but the 1024^2 default + // range still overflows it shelf-wise: the packing fills the 32x32 + // and the later glyphs exhaust the atlas (BudgetExhausted either + // way — the atlas is fixed-size by contract). + SdfGlyphAtlas::Options tiny = small; + tiny.atlasWidth = 32; + tiny.atlasHeight = 32; + auto r2 = SdfGlyphAtlas::create(font, tiny); + EXPECT_TRUE(r2.isError()); + if (r2.isError()) { + EXPECT_EQ(r2.error(), ErrorCode::BudgetExhausted); + } + const std::size_t errors = static_cast(std::count_if( + sink->entries.begin(), sink->entries.end(), + [](const MemorySink::Entry& e) { + return e.severity == laige::log::Severity::Error && + e.subsystem == "font" && e.event == "atlas_full"; + })); + EXPECT_EQ(errors, 2u); + restoreLogger(); +} + +TEST(FontSdfCreate, Success) { + MemorySink* sink = installCaptureSink(); + SdfGlyphAtlas atlas = makeSdfFont(kSdfDefaultRadius); + EXPECT_TRUE(atlas.valid()); + EXPECT_EQ(atlas.atlasWidth(), laige::render::kFontDefaultAtlasWidth); + EXPECT_EQ(atlas.atlasHeight(), laige::render::kFontDefaultAtlasHeight); + EXPECT_EQ(atlas.fontSize(), laige::render::kFontDefaultFontSize); + EXPECT_EQ(atlas.lineHeight(), atlas.fontSize()); // the pixel-height rule + EXPECT_EQ(atlas.glyphFirst(), laige::render::kFontDefaultGlyphFirst); + EXPECT_EQ(atlas.glyphLast(), laige::render::kFontDefaultGlyphLast); + EXPECT_EQ(atlas.radius(), kSdfDefaultRadius); + EXPECT_EQ(atlas.atlas().size(), + static_cast(atlas.atlasWidth()) * + static_cast(atlas.atlasHeight())); + // One structured Info per successful build (the set-up path). + const std::size_t infos = static_cast(std::count_if( + sink->entries.begin(), sink->entries.end(), + [](const MemorySink::Entry& e) { + return e.severity == laige::log::Severity::Info && + e.subsystem == "font" && e.event == "sdf_atlas_created"; + })); + EXPECT_EQ(infos, 1u); + restoreLogger(); +} + +TEST(FontSdfCreate, StoppedAndMove) { + SdfGlyphAtlas stopped; + EXPECT_FALSE(stopped.valid()); + EXPECT_EQ(stopped.glyph(65), nullptr); + EXPECT_EQ(stopped.atlas().size(), 0u); + EXPECT_EQ(stopped.atlasWidth(), 0u); + EXPECT_EQ(stopped.radius(), 0u); + // The move (the GlyphAtlas precedent): the target is live, the + // source stopped. + SdfGlyphAtlas source = makeSdfFont(kSdfDefaultRadius); + ASSERT_TRUE(source.valid()); + SdfGlyphAtlas moved = std::move(source); + EXPECT_FALSE(source.valid()); + EXPECT_EQ(source.glyph(65), nullptr); + EXPECT_TRUE(moved.valid()); + EXPECT_EQ(moved.atlas().size(), + static_cast(moved.atlasWidth()) * + static_cast(moved.atlasHeight())); +} + +// --------------------------------------------------------------------------- +// FontSdfMetrics — the metrics parity with the bitmap path (the +// layout must be identical: same advance/bearing/ink, the same +// measureText widths) and the padded-cell packing invariants +// --------------------------------------------------------------------------- + +TEST(FontSdfMetrics, ParityWithBitmap) { + const GlyphAtlas bitmap = makeBitmapFont(); + const SdfGlyphAtlas sdf = makeSdfFont(kSdfDefaultRadius); + EXPECT_EQ(sdf.fontSize(), bitmap.fontSize()); + EXPECT_EQ(sdf.glyphFirst(), bitmap.glyphFirst()); + EXPECT_EQ(sdf.glyphLast(), bitmap.glyphLast()); + const std::uint32_t W = sdf.atlasWidth(); + const std::uint32_t H = sdf.atlasHeight(); + for (std::uint32_t code = bitmap.glyphFirst(); code <= bitmap.glyphLast(); + ++code) { + const GlyphMetrics* b = bitmap.glyph(code); + const GlyphMetrics* s = sdf.glyph(code); + ASSERT_NE(b, nullptr); + ASSERT_NE(s, nullptr); + // The M2-TEXT-01 metrics are UNCHANGED (advance, bearing, ink + // size — the layout contract). + EXPECT_EQ(s->advance, b->advance) << "code " << code; + EXPECT_EQ(s->bearingX, b->bearingX) << "code " << code; + EXPECT_EQ(s->bearingY, b->bearingY) << "code " << code; + EXPECT_EQ(s->width, b->width) << "code " << code; + EXPECT_EQ(s->height, b->height) << "code " << code; + // The padded cell fits the atlas (the packing invariant). + if (s->width > 0 && s->height > 0) { + const std::uint32_t cellW = + static_cast(s->width) + 2u * sdf.radius(); + const std::uint32_t cellH = + static_cast(s->height) + 2u * sdf.radius(); + EXPECT_LE(s->atlasX + cellW, W) << "code " << code; + EXPECT_LE(s->atlasY + cellH, H) << "code " << code; + } + } +} + +TEST(FontSdfMetrics, WidthMatchesBitmap) { + // The roadmap Verify property: the SDF text width matches the + // bitmap path within the documented tolerance — 0, exactly: both + // paths read the identical M2-TEXT-01 metrics. + const GlyphAtlas bitmap = makeBitmapFont(); + const SdfGlyphAtlas sdf = makeSdfFont(kSdfDefaultRadius); + auto rc = laige::render::StringTable::create({8, 64}); + ASSERT_TRUE(rc.ok()); + laige::render::StringTable table = std::move(rc).takeValue(); + struct Case { + const char* text; + std::uint32_t scale; + }; + const std::vector cases = { + {"Ma", 1}, + {"Hello World", 1}, + {"Hello World", 2}, + {"W", 4}, + {"", 1}, + {" ", 1}, + }; + for (const Case& c : cases) { + const laige::render::StringRef ref = internText(table, c.text); + auto rb = laige::render::measureText(bitmap, table, ref, c.scale); + auto rs = laige::render::measureText(sdf, table, ref, c.scale); + ASSERT_TRUE(rb.ok()); + ASSERT_TRUE(rs.ok()); + // takeValue() is rvalue-only (the house Result contract): the + // valueIfOk read. + EXPECT_EQ(*rb.valueIfOk(), *rs.valueIfOk()) + << c.text << " @ " << c.scale; + } +} + +// --------------------------------------------------------------------------- +// FontSdfDeterminism — same font bytes + options → identical atlas +// bytes (the roadmap Verify property; presentation-only float, +// ARCH-009, so the scope is in-process / same-build) +// --------------------------------------------------------------------------- + +TEST(FontSdfDeterminism, IdenticalBytes) { + const std::vector b1 = makeSdfBytes(); + const std::vector b2 = makeSdfBytes(); + EXPECT_EQ(b1.size(), b2.size()); + EXPECT_TRUE(std::equal(b1.begin(), b1.end(), b2.begin())); + const SdfGlyphAtlas a1 = makeSdfFont(kSdfDefaultRadius); + const SdfGlyphAtlas a2 = makeSdfFont(kSdfDefaultRadius); + for (std::uint32_t code = a1.glyphFirst(); code <= a1.glyphLast(); ++code) { + const GlyphMetrics* m1 = a1.glyph(code); + const GlyphMetrics* m2 = a2.glyph(code); + ASSERT_NE(m1, nullptr); + ASSERT_NE(m2, nullptr); + EXPECT_EQ(*m1, *m2) << "code " << code; // GlyphMetrics is a value + } + // The machine-greppable fingerprint (docs/testing.md §4 — no pinned + // value: the determinism contract is in-process, the hash records + // the build's output for run-to-run comparison). + std::printf("font-sdf-determinism font=vera.ttf size=%u glyphs=%u " + "atlas=%ux%u radius=%u fnv1a=0x%016llx\n", + static_cast(a1.fontSize()), + static_cast(a1.glyphLast() - a1.glyphFirst() + 1), + static_cast(a1.atlasWidth()), + static_cast(a1.atlasHeight()), + static_cast(a1.radius()), + static_cast( + atlasFnv1a64(b1.data(), b1.size()))); +} + +TEST(FontSdfDeterminism, RadiusDiverges) { + const SdfGlyphAtlas a1 = makeSdfFont(kSdfDefaultRadius); + const SdfGlyphAtlas a2 = makeSdfFont(4); + // A different radius is a different field (the padded cells are a + // different size — the packing AND the field differ). + EXPECT_NE(atlasFnv1a64(a1.atlas().data(), a1.atlas().size()), + atlasFnv1a64(a2.atlas().data(), a2.atlas().size())); +} + +// --------------------------------------------------------------------------- +// FontSdfThreshold — the documented threshold edge behavior: the +// 128 boundary (inside >= 128, outside <= 128), the saturation at the +// cell corners, and the exact field value against a brute-force +// oracle (the distance transform's correctness on the test font) +// --------------------------------------------------------------------------- + +TEST(FontSdfThreshold, InsideOutsideBoundary) { + const GlyphAtlas bitmap = makeBitmapFont(); + const SdfGlyphAtlas sdf = makeSdfFont(kSdfDefaultRadius); + const std::uint32_t W = sdf.atlasWidth(); + const std::uint32_t r = sdf.radius(); + std::size_t checked = 0; + for (std::uint32_t code = bitmap.glyphFirst(); code <= bitmap.glyphLast(); + ++code) { + const GlyphMetrics& b = *bitmap.glyph(code); + const GlyphMetrics& s = *sdf.glyph(code); + if (b.width <= 0 || b.height <= 0) continue; // no cell, no field + for (std::int32_t ty = 0; ty < b.height; ++ty) { + for (std::int32_t tx = 0; tx < b.width; ++tx) { + // The ink texel (tx, ty) of the bitmap cell maps to the + // same texel at offset (r, r) of the SDF padded cell (the + // packing invariant — font_sdf.h model section). + const std::uint8_t alpha = bitmap.atlas()[ + (b.atlasY + static_cast(ty)) * W + + b.atlasX + static_cast(tx)]; + const std::uint8_t v = sdf.atlas()[ + (s.atlasY + r + static_cast(ty)) * W + + s.atlasX + r + static_cast(tx)]; + // The documented boundary (font_sdf.h field section): the + // 0.5-coverage cut at 127 -> inside (alpha > 127) has byte + // >= 128, outside (alpha <= 127) has byte <= 128. + if (alpha > 127) { + EXPECT_GE(v, 128) << "code " << code << " texel " << tx << "," + << ty; + } else { + EXPECT_LE(v, 128) << "code " << code << " texel " << tx << "," + << ty; + } + ++checked; + } + } + } + EXPECT_GT(checked, 0u); +} + +TEST(FontSdfThreshold, CornerSaturation) { + // The padded cell's corners are sqrt(2) * r from the nearest ink + // pixel (the ink sits at offset (r, r) of the cell) — beyond the + // radius: the field clamps to the 0 byte value exactly (the + // documented saturation, font_sdf.h field section). + const SdfGlyphAtlas sdf = makeSdfFont(kSdfDefaultRadius); + for (std::uint32_t code = sdf.glyphFirst(); code <= sdf.glyphLast(); + ++code) { + const GlyphMetrics* s = sdf.glyph(code); + if (s == nullptr || s->width <= 0 || s->height <= 0) continue; + const std::uint32_t W = sdf.atlasWidth(); + const std::uint32_t cellW = + static_cast(s->width) + 2u * sdf.radius(); + const std::uint32_t cellH = + static_cast(s->height) + 2u * sdf.radius(); + const std::uint8_t tl = + sdf.atlas()[s->atlasY * W + s->atlasX]; + const std::uint8_t br = sdf.atlas()[ + (s->atlasY + cellH - 1) * W + (s->atlasX + cellW - 1)]; + EXPECT_EQ(tl, 0u) << "code " << code; + EXPECT_EQ(br, 0u) << "code " << code; + } +} + +TEST(FontSdfThreshold, ExactValuesAgainstOracle) { + // The distance transform's correctness: the engine's byte for a + // deep-inside pixel of "a" and a deep-outside pixel matches a + // brute-force exact oracle (the min Euclidean distance to the + // opposite set, the 8SDD algorithm's contract — Felice & Nienhuis + // 2014). The oracle runs in double over the small cell; the engine + // in float32 — the tolerance is one quantization byte (the float + // rounding of the distance can only matter at an lround half- + // boundary, the documented worst case). + const GlyphAtlas bitmap = makeBitmapFont(); + const SdfGlyphAtlas sdf = makeSdfFont(kSdfDefaultRadius); + const std::uint32_t W = sdf.atlasWidth(); + const std::uint32_t r = sdf.radius(); + const GlyphMetrics& b = *bitmap.glyph('a'); + const GlyphMetrics& s = *sdf.glyph('a'); + ASSERT_GT(b.width, 0); + ASSERT_GT(b.height, 0); + // The SDF cell's origin (the SDF packing differs from the bitmap + // packing — the cell origins diverge after the first glyph). + ASSERT_EQ(s.width, b.width); + ASSERT_EQ(s.height, b.height); + // The padded cell of "a" in both atlases (the bitmap cell at the + // bitmap origin, the SDF cell at the SDF origin + (r, r)). + const std::uint32_t cw = static_cast(b.width) + 2u * r; + const std::uint32_t ch = static_cast(b.height) + 2u * r; + // The 0.5-coverage grid of the SDF cell (the oracle's inside set). + std::vector inside(static_cast(cw) * ch, 0); + for (std::uint32_t ty = 0; ty < static_cast(b.height); ++ty) { + for (std::uint32_t tx = 0; tx < static_cast(b.width); ++tx) { + const std::uint8_t alpha = bitmap.atlas()[ + (b.atlasY + ty) * W + b.atlasX + tx]; + if (alpha > 127) { + inside[(r + ty) * cw + (r + tx)] = 1; + } + } + } + // The brute-force min distance (double) from (px, py) to the pixels + // of `set`. + auto minDist = [&](std::uint32_t px, std::uint32_t py, bool toInside) { + double best = 1.0e30; + for (std::uint32_t oy = 0; oy < ch; ++oy) { + for (std::uint32_t ox = 0; ox < cw; ++ox) { + const bool onSet = toInside ? inside[oy * cw + ox] != 0 + : inside[oy * cw + ox] == 0; + if (!onSet) continue; + const double dx = static_cast(static_cast(px) - + static_cast(ox)); + const double dy = static_cast(static_cast(py) - + static_cast(oy)); + best = std::min(best, std::sqrt(dx * dx + dy * dy)); + } + } + return best; + }; + // A fully-covered inside pixel (alpha 255 — the oracle's distance to + // the outside set is then >= 1 px and the byte is >= 128 + 127/r; + // the oracle below verifies the exact value either way). + std::uint32_t ipx = 0; + std::uint32_t ipy = 0; + bool foundInside = false; + for (std::uint32_t ty = 0; ty < static_cast(b.height); ++ty) { + for (std::uint32_t tx = 0; tx < static_cast(b.width); ++tx) { + const std::uint8_t alpha = bitmap.atlas()[ + (b.atlasY + ty) * W + b.atlasX + tx]; + if (alpha == 255) { + ipx = r + tx; + ipy = r + ty; + foundInside = true; + break; + } + } + if (foundInside) break; + } + ASSERT_TRUE(foundInside) << "the test font's \"a\" has no fully-covered " + "inside pixel (the asset's contract)"; + const double dIn = minDist(ipx, ipy, false); // to the outside set + const int expectedIn = + static_cast(std::max(0.0, std::min(255.0, + 128.0 + std::round(127.0 * dIn / static_cast(r))))); + // The engine's byte for the cell pixel (cell coords -> atlas coords + // through the SDF cell's origin). + const std::uint8_t actualIn = + sdf.atlas()[static_cast(s.atlasY + ipy) * W + + s.atlasX + ipx]; + // One quantization byte of tolerance (the float32 distance rounding + // can only matter at an lround half-boundary). + EXPECT_GE(static_cast(actualIn), expectedIn - 1); + EXPECT_LE(static_cast(actualIn), expectedIn + 1); + // A deep-outside pixel (the padded cell's corner — the saturated + // region; the oracle agrees the byte is 1). + const double dOut = minDist(0, 0, true); // to the inside set + const int expectedOut = + static_cast(std::max(0.0, std::min(255.0, + 128.0 - std::round(127.0 * dOut / static_cast(r))))); + const std::uint8_t actualOut = + sdf.atlas()[s.atlasY * W + s.atlasX]; + EXPECT_GE(static_cast(actualOut), expectedOut - 1); + EXPECT_LE(static_cast(actualOut), expectedOut + 1); + EXPECT_EQ(actualOut, 0u); // the corner saturates (the boundary test) +} + +// --------------------------------------------------------------------------- +// FontSdfFallback — the documented fallback (the M2-TEXT-01 contract, +// verbatim) and the stopped state +// --------------------------------------------------------------------------- + +TEST(FontSdfFallback, OutOfRange) { + const SdfGlyphAtlas sdf = makeSdfFont(kSdfDefaultRadius); + // An out-of-range code falls back to the U+0020 slot (the default + // range includes it). + const GlyphMetrics* fallback = sdf.glyph(0x0100); + ASSERT_NE(fallback, nullptr); + const GlyphMetrics* space = sdf.glyph(0x20); + ASSERT_NE(space, nullptr); + EXPECT_EQ(fallback->advance, space->advance); + // A range WITHOUT the fallback code reads the zero metric. + std::vector font = loadFont(); + SdfGlyphAtlas::Options noSpace; + noSpace.glyphFirst = 65; // 'A'..'Z' — no U+0020 + noSpace.glyphLast = 90; + auto r = SdfGlyphAtlas::create(font, noSpace); + ASSERT_TRUE(r.ok()); + const SdfGlyphAtlas narrow = std::move(r).takeValue(); + const GlyphMetrics* empty = narrow.glyph(0x20); + ASSERT_NE(empty, nullptr); + EXPECT_EQ(empty->advance, 0); + EXPECT_EQ(empty->width, 0); + // An in-range code of the narrow range is live. + const GlyphMetrics* a = narrow.glyph('A'); + ASSERT_NE(a, nullptr); + EXPECT_GT(a->advance, 0); +} + +TEST(FontSdfFallback, Stopped) { + SdfGlyphAtlas stopped; + EXPECT_EQ(stopped.glyph(65), nullptr); +} + +// --------------------------------------------------------------------------- +// FontSdfExpand — the white-on-alpha upload bytes (the bindSdfAtlas +// input) +// --------------------------------------------------------------------------- + +TEST(FontSdfExpand, Rgba8) { + const SdfGlyphAtlas atlas = makeSdfFont(kSdfDefaultRadius); + const std::size_t need = + static_cast(atlas.atlasWidth()) * + static_cast(atlas.atlasHeight()) * 4u; + std::vector rgba(need, 0xEE); + ASSERT_TRUE(laige::render::expandSdfGlyphAtlasRgba8(atlas, + rgba).ok()); + const std::span field = atlas.atlas(); + for (std::size_t i = 0; i < need / 4; ++i) { + EXPECT_EQ(rgba[i * 4 + 0], 255u); + EXPECT_EQ(rgba[i * 4 + 1], 255u); + EXPECT_EQ(rgba[i * 4 + 2], 255u); + EXPECT_EQ(rgba[i * 4 + 3], field[i]); + } + // The wrong-size span: InvalidArgument, nothing written. + std::vector shortRgba(need - 4, 0xEE); + EXPECT_FALSE(laige::render::expandSdfGlyphAtlasRgba8(atlas, shortRgba) + .ok()); + // The stopped atlas: InvalidArgument. + SdfGlyphAtlas stopped; + EXPECT_FALSE( + laige::render::expandSdfGlyphAtlasRgba8(stopped, rgba).ok()); +} + +// --------------------------------------------------------------------------- +// FontSdfZeroAlloc — the per-lookup path allocates nothing (PERF-003: +// the render read path is a pure table lookup) +// --------------------------------------------------------------------------- + +TEST(FontSdfZeroAlloc, LookupZeroAlloc) { + const SdfGlyphAtlas atlas = makeSdfFont(kSdfDefaultRadius); + laige::allocWatchArm(); + std::int64_t sum = 0; + for (std::uint32_t i = 0; i < 1000; ++i) { + const GlyphMetrics* inRange = atlas.glyph( + atlas.glyphFirst() + i % (atlas.glyphLast() - atlas.glyphFirst() + 1)); + const GlyphMetrics* fallback = atlas.glyph(0x0100 + i); + if (inRange != nullptr) { + sum += inRange->advance; + } + if (fallback != nullptr) { + sum += fallback->advance; + } + } + const laige::AllocWatchReading reading = laige::allocWatchRead(); + EXPECT_EQ(reading.allocs, 0u); + EXPECT_GT(sum, 0); // the lookups actually happened +} + +// --------------------------------------------------------------------------- +// FontSdfSmoke — the offscreen GL render (the M2-SPRITE-02 submit +// stage with the SDF field uploaded through `bindSdfAtlas`; +// GTEST_SKIPs where no usable OpenGL 3.3 environment — the documented +// environment contract) +// --------------------------------------------------------------------------- + +namespace { + +constexpr std::int32_t kSmokeWidth = 64; +constexpr std::int32_t kSmokeHeight = 64; + +// The UI pass's identity screen matrix (screen px, y down -> NDC): +// x_ndc = 2x/w - 1, y_ndc = 1 - 2y/h (the M2-UI-02 plane — the +// text_items_tests smokeMatrix pattern; the GLM scalar constructor +// is the identity: diagonal s, off-diagonal 0). +laige::render::Mat4 smokeMatrix() { + // Column-major (the house Mat4 convention): the linear part in + // columns 0..1, the translation in column 3. + laige::render::Mat4 m(1.0f); + m[0].x = 2.0f / static_cast(kSmokeWidth); + m[1].y = -2.0f / static_cast(kSmokeHeight); + m[3].x = -1.0f; + m[3].y = 1.0f; + return m; +} + +} // namespace + +TEST(FontSdfSmoke, OffscreenSdfFrame) { + auto ctx = laige::render::GlContext::createHeadless(kSmokeWidth, + kSmokeHeight); + if (!ctx.ok()) { + GTEST_SKIP() << "No usable OpenGL 3.3 environment: " + << laige::errorText(ctx.error()) + << " (the documented environment contract — the P0 CI " + "runners always have one)"; + } + laige::render::GlContext gl = std::move(ctx).takeValue(); + const SdfGlyphAtlas sdf = makeSdfFont(kSdfDefaultRadius); + const GlyphMetrics& mM = *sdf.glyph('M'); + ASSERT_EQ(10, mM.width); + ASSERT_EQ(11, mM.height); + ASSERT_EQ(1, mM.bearingX); + ASSERT_EQ(11, mM.bearingY); + // The upload bytes (the setup path — one call per font per scene). + std::vector rgba; + rgba.resize(static_cast(sdf.atlasWidth()) * + static_cast(sdf.atlasHeight()) * 4u); + ASSERT_TRUE(laige::render::expandSdfGlyphAtlasRgba8(sdf, rgba).ok()); + auto rc = laige::render::SpriteRenderer::create( + gl, laige::render::SpriteRenderer::Options{16, 8}); + ASSERT_TRUE(rc.ok()); + laige::render::SpriteRenderer renderer = std::move(rc).takeValue(); + // The SDF bind (threshold 0.5 = the 0.5-coverage contour — the + // documented default). + ASSERT_TRUE(renderer + .bindSdfAtlas(3, sdf.atlasWidth(), sdf.atlasHeight(), rgba, + 0.5f) + .ok()); + // The invalid threshold domain: InvalidArgument, no state change. + EXPECT_FALSE(renderer + .bindSdfAtlas(3, sdf.atlasWidth(), sdf.atlasHeight(), rgba, + 1.5f) + .ok()); + auto tc = laige::render::StringTable::create({8, 64}); + ASSERT_TRUE(tc.ok()); + laige::render::StringTable table = std::move(tc).takeValue(); + const laige::render::StringRef ref = internText(table, "Ma"); + auto bc = laige::render::SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + laige::render::SpriteBatcher batcher = std::move(bc).takeValue(); + laige::render::TextItem item; + item.text = ref; + item.mode = laige::render::FontRenderMode::Sdf; + item.sdfFont = &sdf; + item.x = 10.0f; + item.y = 20.0f; // the first line's baseline (screen y down) + item.atlasId = 3; + ASSERT_TRUE(laige::render::declareText(batcher, table, item).ok()); + ASSERT_TRUE(batcher.build().ok()); + // The clear BEFORE the first draw (the offscreen frame protocol — + // the M2-SPRITE-04 TSan lesson: a query-enabled submit as the first + // FBO op of the frame raced the Mesa teardown; the query is off + // here, but the clear is the documented protocol either way). + ASSERT_TRUE(gl.clear(0.0f, 0.0f, 0.0f, 1.0f).ok()); + ASSERT_TRUE(renderer.submit(batcher, smokeMatrix()).ok()); + // One (atlas, material, blend) group: one instanced draw, 2 + // instances (RENDER-001). + const laige::render::SpriteDrawStats stats = renderer.frameStats(); + EXPECT_EQ(1u, stats.drawCalls); + EXPECT_EQ(2u, stats.instances); + // The pixel checks (screen y down -> GL y = H - 1 - sy; the clear + // alpha is 1.0 -> the composite alpha is exactly 255 over the black + // clear; the SDF branch's alpha feeds the engine's + // SRC_ALPHA/ONE_MINUS_SRC_ALPHA blend on all four channels). + auto readScreen = [&](std::int32_t sx, std::int32_t sy) { + std::uint8_t rgba4[4]{}; + auto r = gl.readPixel(sx, kSmokeHeight - 1 - sy, rgba4); + if (!r.ok()) return std::array{}; + return std::array{rgba4[0], rgba4[1], rgba4[2], + rgba4[3]}; + }; + // The SDF quad covers the M's PADDED cell (the header's glyph-quad + // section): ink left 10 + bearingX = 11, top 20 - bearingY = 9; the + // padded cell left 11 - r = 3, top 9 - r = 1. + const std::int32_t r = static_cast(sdf.radius()); + const int inkML = 10 + mM.bearingX; + const int inkMT = 20 - mM.bearingY; + const int padML = inkML - r; + const int padMT = inkMT - r; + // 1. The background (outside every quad): the clear shows through. + { + const std::array p = readScreen(1, 55); + EXPECT_EQ(0u, p[0]); + EXPECT_EQ(0u, p[1]); + EXPECT_EQ(0u, p[2]); + EXPECT_EQ(255u, p[3]); + } + // 2. A deep-inside pixel of the M: the field is at least 2 distance- + // px inside the glyph (byte >= 128 + 2*127/r, checked against the + // engine's own field — self-consistent) — the smooth step is + // exactly 1 -> (255, 255, 255, 255) exactly (the documented + // threshold edge behavior, font_sdf.md). + { + const std::span field = sdf.atlas(); + const std::uint32_t deepIn = static_cast( + 128 + 2 * static_cast(127.0 / static_cast(sdf.radius()))); + bool found = false; + int tx = 0; + int ty = 0; + for (int y = 0; y < mM.height && !found; ++y) { + for (int x = 0; x < mM.width; ++x) { + const std::uint8_t v = field[(mM.atlasY + sdf.radius() + y) * + static_cast(1024) + + mM.atlasX + sdf.radius() + x]; + if (v >= deepIn) { + tx = x; + ty = y; + found = true; + break; + } + } + } + ASSERT_TRUE(found) << "the test font's \"M\" has no deep-inside texel"; + const std::array p = + readScreen(inkML + tx, inkMT + ty); + EXPECT_EQ(255u, p[0]); + EXPECT_EQ(255u, p[1]); + EXPECT_EQ(255u, p[2]); + EXPECT_EQ(255u, p[3]); + } + // 3. A deep-outside pixel of the PADDED CELL (the field's corner — + // saturated to the 1 byte value, more than one distance-px outside): + // the smooth step is exactly 0 -> the clear shows through exactly. + { + const std::array p = readScreen(padML, padMT); + EXPECT_EQ(0u, p[0]); + EXPECT_EQ(0u, p[1]); + EXPECT_EQ(0u, p[2]); + EXPECT_EQ(255u, p[3]); + } + // 4. The screen-space anti-aliasing (the P1 motivation): the field's + // edge is anti-aliased with a band one screen pixel wide at 1x — + // scanning across the M's left stem at mid-height, at least one + // pixel is strictly between clear and opaque (0 < rgb < 255). + { + const int midY = inkMT + mM.height / 2; + bool sawEdge = false; + for (int x = inkML - 2; x <= inkML + 2; ++x) { + const std::array p = readScreen(x, midY); + if (p[0] > 0 && p[0] < 255 && p[1] == p[0] && p[2] == p[0]) { + sawEdge = true; + break; + } + } + EXPECT_TRUE(sawEdge) << "no anti-aliased edge pixel across the M's left " + "stem (the SDF band is missing)"; + } + // The context outlives the renderer (the ownership contract). +}