From d5d8be435953cd5e6a4a3c643d7d7d67ce3cde53 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 11 Oct 2026 19:05:15 +0200 Subject: [PATCH 1/3] [M2-UI-02] UI rendering pass (screen-space, identity camera) - New header-only laige::render UI pass (public header laige/render/ui_render.h, no GL calls, no new dependency): the draw half of FR-2.8 -- "UI is a separate render pass, screen-space". The S-5 declare half; the GL half is the M2-SPRITE-02 SpriteRenderer::submit with the UI pass's identity screen matrix (nothing world-space leaks in, RENDER-006). - UiDeclareOptions{defaultFont, fontAtlasId, fontScale}: the default UI font (non-owning const GlyphAtlas*), its bindAtlas id, its integer scale in [1, kTextMaxScale]; a null font turns off the pass-generated text (button labels / input content declare nothing, the art still renders). - uiScreenMatrix(width, height): the identity screen matrix, screen px y down -> NDC (x_ndc = 2x/w - 1, y_ndc = 1 - 2y/h; column-major: m[0].x = 2/w, m[1].y = -2/h, m[3].x = -1, m[3].y = +1, m[3].w = 1); out-of-dimension domain [1, kUiMaxDimPx] -> the identity matrix. - declareUi(batcher, table, tree, options): the O(n) tree -> batched-quads pass. Walks the laid-out rects in the deterministic traversal order (RENDER-003); culls the invisible widgets (the documented simple model -- zero area or entirely offscreen on all four half-open edges, O(1) per node; fully covered by an opaque panel drawn LATER in the traversal -- the ADR 0006 occlusion model -- an early-stopping re-walk, worst case O(n) per node, O(n^2) per frame, ponytail: ceiling comment + the O(n*P) upgrade); declares one centered quad per visible widget (pos = the rect center, scale = the rect size): the opaque panel background (a non-opaque panel declares nothing), the button art + centered label (the exact int64 baseline rectY + (rectH + lineStep)/2, lineStep = lineHeight() * fontScale, the node's tint), the text item verbatim with the depthKey overwritten by the widget's z (the rect is culling-only -- no clipping quads in M2), the image quad, the list nothing (a container nests an opaque panel as its first child), the slider track + fill only for 0 < t < 1 (t = (value - min)/(max - min); t = 0 is a zero-width quad, t = 1 double-blends the identical track pixels -- the alpha blend is not idempotent), the input's left-aligned white content. - One batcher per sprite pass: the UI pass is its own SpriteBatcher (M2-SPRITE-01). Every quad carries the monotone per-node UI z (the index among the nodes that declare something, in traversal order -- a culled / no-declare node consumes no z) with depthOverride = true (the G-R11 escape hatch -- the UI pass is screen-space, there is no isoDepthKey; counted + warned at build), materialId 0 + BlendMode::Alpha, one texture per atlas (the scene binds its UI atlas + glyph atlas(es) through the existing bindAtlas / bindSdfAtlas). - Failure model: first failure wins (stopped tree / missing root or an out-of-domain fontScale with a font -> InvalidArgument before the first add; a Text / button-label / input-text handle not owned by the string table or a stopped font -> InvalidArgument; a failed batcher add -> the batcher's error). A failed call leaves the quads ALREADY DECLARED in the frame (the batcher owns the frame state -- the declareParticles / declareText precedent). No logging on preconditions. - Tests: tests/laige-render/ui_render_tests.cpp (new CTest entry ui_render; 12 tests / 7 suites): UiRenderScreenMatrix (the column-major pin incl. m[3].w = 1, the corner/center NDC, the out-of-domain identity), UiRenderDeclare (the 200x150 all-seven-kinds scene -> 12 quads / 2 groups / 12 overrides against the hand-computed goldens, the machine-greppable ui-render-declare line; no default font -> 2 quads), UiRenderCull (the offscreen rule, the occlusion rule -- the later-opaque-panel early stop, the non-opaque-panel exception), UiRenderSlider (t = 0/1 track only, t = 0.5 track + fill), UiRenderFailure (the precondition failures declare nothing; the handle failures with real rects -- the button's art precedes its label -> frameCount 1; the batcher-state failures), UiRenderZeroAlloc (1000 frames = 0 heap blocks, the machine-greppable ui-render-zeroalloc line), UiRenderSmoke (the golden image fixture -- a fixed 128x128 UI scene rendered offscreen through the real SpriteRenderer::submit (7 quads, 2 groups, 2 draw calls, 7 instances, 364 upload bytes) against a CPU reference rasterizer (the strict-interior pixel-center rule -- all scene edges are integer px, never on an edge; the per-quad 4-channel double blend out.a = a^2 + (1 - a), byte = min(255, v*255 + 0.5)): the whole frame within the documented +/- 1 byte tolerance (16384 readbacks) + the exact probes -- the "a" semi texel pins the all-four-channel glBlendFunc contract (rgb == A exact, alpha within +/- 1 of round(255(a^2 + 1 - a))); GTEST_SKIP where no usable OpenGL 3.3; the committed-PNG infra lands with M2-GOLD-01). - Docs (DOC-003/004/006/007): NEW docs/api/ui_render.md (the full contract + the DOC-004 Performance section), the docs/README.md index row, the src/laige-render/README.md status paragraph, docs/concepts/coordinates.md NEW section 4.14 (the screen-space identity camera) + the section 5 conversion row + the Related link (the header's normative reference), cross-links from ui_widgets.md / text_items.md. - Tests/CMake: the ui_render entry (TIMEOUT 120, the LAIGE_TEST_FONT_PATH env) + the TSan block + the ASan detect_leaks=0 list (the smoke can create a headless context) + the header comments. - laige-api.json regenerated LAST (1682 -> 1688 symbols, 46 -> 47 headers; api-real-tree green). - No budgets.json entry (the per-frame UI cost is part of the composite 50k render-CPU budget, M2-PERF-01 -- the pin count stays 17). Verified locally (GL-free): all six canonical trees build warning-free; ctest -R ui_render 11/11 GL-free suites green in each; the full GL-free ctest per tree green except the pre-regeneration api-real-tree (fixed by the manifest regeneration); tools/laige-include-lint + tools/laige-determinism-lint OK. CI-validated (pending): the UiRenderSmoke GL smoke runs on the macOS PR lane (arm64 skips -- no usable GL; Intel runs it) and the merge lane (Linux + Windows MSVC -- the first MSVC compile of this C++). The local GPU execution policy prohibits running the smoke locally. --- docs/README.md | 11 + docs/api/text_items.md | 6 +- docs/api/ui_render.md | 239 +++ docs/api/ui_widgets.md | 9 +- docs/concepts/coordinates.md | 32 + laige-api.json | 7 + roadmap/M2-rendering-2.5d.md | 27 +- roadmap/README.md | 5 +- src/laige-render/README.md | 45 +- .../include/laige/render/ui_render.h | 592 +++++++ tests/laige-render/CMakeLists.txt | 59 +- tests/laige-render/ui_render_tests.cpp | 1422 +++++++++++++++++ 12 files changed, 2434 insertions(+), 20 deletions(-) create mode 100644 docs/api/ui_render.md create mode 100644 src/laige-render/include/laige/render/ui_render.h create mode 100644 tests/laige-render/ui_render_tests.cpp diff --git a/docs/README.md b/docs/README.md index d985c69..ae32c9f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -382,6 +382,17 @@ still to land. coordinate saturation), the focus system (button / slider / input), and the per-frame bounded pointer-event list (the FR-4.4 UI-first routing hook through `pointerHit`). + - [UI render pass](api/ui_render.md) — + `laige::render::declareUi` (M2-UI-02, the draw half of the UI + work): the screen-space UI pass (the identity camera, + `uiScreenMatrix` — nothing world-space leaks in, RENDER-006) that + walks the laid-out tree in traversal order, culls the invisible + widgets (the offscreen rule + the later-opaque-panel occlusion + rule — the documented simple model), and declares one batched + quad per visible widget (plus the glyph quads of the text widgets + and the pass-generated button labels / input content through + `declareText`) into the pass's own `SpriteBatcher` (one texture + per atlas, the G-R11 monotone UI z). ## Guides diff --git a/docs/api/text_items.md b/docs/api/text_items.md index 85384f0..7f64658 100644 --- a/docs/api/text_items.md +++ b/docs/api/text_items.md @@ -144,7 +144,8 @@ contract), never to a re-rasterization. ## The model (documented units) All layout values are **SCREEN PIXELS, screen space, y DOWN** (the UI -pass — the M2-UI-02 identity screen matrix maps them to NDC; nothing +pass — the M2-UI-02 identity screen matrix +([`ui_render.md`](ui_render.md)) maps them to NDC; nothing world-space leaks in, RENDER-006). `TextItem.scale` is an **INTEGER** scale factor in [1, `kTextMaxScale`] (the M2-TEXT-01 "integer scale" convention): every metric is multiplied by the scale — exact, no @@ -379,5 +380,8 @@ laige::render::declareText(batcher, strings, item); // same width quads render through (the white-on-alpha blend, M2-SPRITE-02). - [`particle_render.md`](particle_render.md) — the sibling render-phase declaration pass (the same batcher pattern). +- [`ui_render.md`](ui_render.md) — the UI render pass (M2-UI-02) + that declares the Text widgets' items through this path and the + identity screen matrix the quads draw with. - `docs/concepts/coordinates.md` §4.12 + §5 — the text coordinate row (screen space, y down). diff --git a/docs/api/ui_render.md b/docs/api/ui_render.md new file mode 100644 index 0000000..4c3c202 --- /dev/null +++ b/docs/api/ui_render.md @@ -0,0 +1,239 @@ +# UI render pass — the screen-space draw half of FR-2.8 (`laige::render` ui_render) + +`laige/render/ui_render.h` (M2-UI-02) is the **declare half** (S-5) of +the FR-2.8 UI work: it turns the M2-UI-01 retained widget tree +(`laige/render/ui_widgets.h`) into batched quads + text for the +M2-SPRITE-02 submit stage (`laige/render/sprite_renderer.h`). The GL +half is the existing `SpriteRenderer::submit` with the UI pass's +identity screen matrix; this header adds no GL calls of its own. + +FR-2.8: "Text rendering …; immediate-mode-style UI widget tree +(panel, button, text, image, list, slider, input) with a retained +mode for menus; **UI is a separate render pass, screen-space**." This +pass is that separate pass: it runs after the world pass, on its own +`SpriteBatcher`, with an identity camera — nothing world-space leaks +in (RENDER-006). One texture per atlas: the scene binds its UI atlas +and its glyph atlas(es) through the existing `bindAtlas` / +`bindSdfAtlas`; the pass adds no texture machinery. + +## The API + +| Symbol | Kind | Purpose | +| --- | --- | --- | +| `UiDeclareOptions` | struct | The pass's scene configuration: the default UI font (non-owning `const GlyphAtlas*`), its `bindAtlas` id (`fontAtlasId`), its integer scale (`fontScale`, domain [1, `kTextMaxScale`]). A null `defaultFont` turns off the pass-generated text (button labels and input content declare nothing; the art still renders). | +| `uiScreenMatrix(width, height)` | `Mat4` | The UI pass's identity screen matrix (screen px, y down → NDC): `x_ndc = 2·x/width − 1`, `y_ndc = 1 − 2·y/height`. Column-major; the linear part in columns 0..1, the translation in column 3. Out-of-domain dimension (outside [1, `kUiMaxDimPx`]) → the identity matrix (the caller passes the tree's screen rect, which is in domain by construction). | +| `declareUi(batcher, table, tree, options)` | `Status` | The O(n) tree → batched-quads pass (culling included). See the frame protocol below. | + +`declareUi` requires the tree to be laid out (`layout()` first — a +node added or re-placed since the last layout has a zero rect and is +culled as offscreen) and the batcher to be in an open frame window +(`beginFrame` before, `build` after — the M2-SPRITE-01 frame +protocol). + +## The per-widget rendering model (the seven ADR 0006 kinds) + +All values are screen pixels, y down (the M2-UI-01 tree's and the +M2-TEXT-02 items' coordinate system). Every widget quad is the +M2-SPRITE-02 centered quad: `pos = (rectX + rectW/2, rectY + rectH/2)`, +`scale = (rectW, rectH)` — the quad's ±0.5·scale spans exactly the +widget's laid-out rect. Every quad (and the pass-generated text) uses +`materialId 0` and `BlendMode::Alpha`; a node's `atlasId` is its UI +atlas's texture id, the pass-generated text uses +`UiDeclareOptions::fontAtlasId`. + +| Kind | What it declares | +| --- | --- | +| Panel | OPAQUE only: one background quad over the whole rect (the node's `image` UV rect, `atlasId`, `tint`). A non-opaque panel declares nothing (its children still render — they come later in traversal order, so they draw on top). | +| Button | One ART quad over the whole rect + the LABEL (with a default font): a stack-built M2-TEXT-02 `TextItem` — the node's `label` handle, CENTER-aligned on the rect's center x, the first line's baseline vertically centered (`rectY + (rectH + lineStep)/2`, exact int64, `lineStep = lineHeight() · fontScale`), the node's `tint` as the text color, `maxWidth` 0 (one line), the pass's z, the `fontAtlasId`. | +| Text | The node's `TextItem` verbatim, **except** the pass overwrites `item.depthKey` with the widget's z (in the tree the traversal order owns the z). The widget's rect is used for CULLING only: the glyphs are not clipped (the scene sizes the rect from `measureText`; no clipping quads in M2). | +| Image | One quad over the whole rect. | +| List | NOTHING (a container with no M2 background option — the scene gives it a background with a nested opaque panel as the list's first child, the M2-UI-01 stacking convention). | +| Slider | A TRACK quad over the whole rect + a FILL quad spanning `[rectX, rectX + rectW·t) × [rectY, rectY + rectH)` at full height, `t = (value − min)/(max − min)`. The fill is declared only for `0 < t < 1` (t = 0 is a zero-width quad; t = 1 double-blends the identical track pixels — the alpha blend is not idempotent). The fill samples the node's `image` rect at the full UV span. | +| Input | The CONTENT (with a default font): a stack-built `TextItem` — the node's `text` handle, LEFT-aligned at `rectX`, the baseline vertically centered as for the button label, WHITE color (no text-color field in M2), `maxWidth` 0. No background quad in M2 (the scene wraps an input in an opaque panel for a visible field). | + +A widget that declares nothing (culled, a non-opaque panel, a list, +an input without a default font, empty content) leaves the rest of +the frame untouched: the pass is a pure declaration walk, the batcher +owns the frame state. + +## The order (the monotone UI z, G-R11) + +The pass's order is the tree's traversal order (preorder, children in +insertion order — the M2-UI-01 draw-order contract, RENDER-003). The +pass hands the batcher a **monotone per-node z**: the index of the +node among the nodes that declare something, in traversal order (a +culled / no-declare node consumes no z — but a Text widget with empty +content still consumes its z, and an Input without a font does not). +Every declared quad carries `depthKey = the node's z` and +`depthOverride = true` (the G-R11 escape hatch — the UI pass is +screen-space, there is no `isoDepthKey` in this pass; the manual UI z +is counted + warned at build by the batcher, the sprite_batcher.h +G-R11 contract). Within one node the declaration order is the draw +order (art before label glyphs, track before fill — the batcher's +stable sort keeps it for the equal-key instances). + +## Culling (the documented simple model) + +A widget is **culled** (declares nothing) when: + +1. **Offscreen** — its laid-out rect has zero area (`rectW` or + `rectH` is 0 — a fresh / zero-sized widget renders nothing), or it + lies entirely outside the screen rect: `rectX >= screenW`, + `rectY >= screenH`, `rectX + rectW <= 0`, or `rectY + rectH <= 0` + (half-open edges — the iso-picking boundary rule; a widget + touching the screen's right or bottom edge with its last visible + column/row is drawn). O(1) per node; the rect sums are exact int32 + (the M2-UI-01 saturation domain bounds the edge to 2^22 − 2). +2. **Occluded** — it is FULLY COVERED by an opaque panel drawn after + it (later in traversal order — on top, the painter's order): an + opaque panel P culles a node N iff P is later than N in the + traversal and P's laid-out rect contains N's (all four half-open + edges: `P.x <= N.x`, `P.y <= N.y`, `P.x + P.w >= N.x + N.w`, + `P.y + P.h >= N.y + N.h`). This is the ADR 0006 occlusion model: + the opaque panel that occludes the pointer to a widget occludes + its render too (the M2-UI-01 hit test's "opaque panel in front" + clause). An opaque panel never culls its own children; a + non-opaque panel never culls anything; a partial overlap culls + nothing (M2 has no clipping quads — the visible part of the + covered widget still draws). + +The offscreen test is O(1) per node. The occlusion test re-walks the +traversal from the node's successor to the end, stopping at the +FIRST later opaque panel that contains it (the early stop — the +common case when a modal panel is present); worst case O(n) per +node, O(n²) per frame. No allocation: the walk re-uses the tree's +structural links. + +## The frame protocol (the batcher's) + +```cpp +uiTree.layout(); // M2-UI-01 (every frame) +uiBatcher.beginFrame(); // M2-SPRITE-01 frame window +Status s = declareUi(uiBatcher, strings, uiTree, opts); +// … the world pass on its own batcher, its own submit … +uiBatcher.build(); // the group table (RENDER-003) +renderer.submit(uiBatcher, uiScreenMatrix(screenW, screenH)); +``` + +The UI pass is its **own** batcher (one batcher per sprite pass, +M2-SPRITE-01), sized for the frame's UI quads + glyph count. The +UI matrix and the frame buffer / FBO must match the tree's screen +rect (the M2-GOLD-01 reference resolution 1280×720 when it matters). + +## Failure behavior (first failure wins) + +| Condition | Error | Declared state | +| --- | --- | --- | +| Stopped tree (default / moved-from), or a missing root | `InvalidArgument` | nothing | +| `fontScale` outside [1, `kTextMaxScale`] with a default font | `InvalidArgument` | nothing (checked before the first add) | +| A Text widget's / button's label's / input's text handle not owned by the string table, or a stopped font (the item's `font` pointer) | `InvalidArgument` | the quads already declared stay in the frame (the batcher owns the frame state — the `declareParticles` / `declareText` precedent) | +| A failed batcher `add` | the batcher's error (`BudgetExhausted` on capacity, `InvalidArgument` on a closed window, …) | same — the quads already declared stay | + +No logging on preconditions (the house failure contract). + +## Ownership, threading (CORE-009, CONC-001) + +- `declareUi` is a pure declaration pass: it reads the tree (laid + out), the string table, and the font (non-owning `const GlyphAtlas*` + — the scene's atlas, the `TextItem.font` lifetime contract), and + writes to the batcher's frame. It does not mutate the tree. +- Owner-thread, render phase (the frame protocol): call it on the + thread that owns the batcher, between `beginFrame` and `build`. + No locks, no shared state beyond the documented frame protocol. +- One `declareUi` per frame per batcher (the S-5 declare phase); the + submit stage draws what was declared. + +## Performance (DOC-004) + +- `declareUi` is **O(n + G)** CPU: one preorder pass over the n + nodes (the offscreen test O(1) each, the occlusion test O(n) worst + case each — O(n²) worst per frame, see Culling), plus the + per-widget `declareText` walks for the text-bearing widgets (the + M2-TEXT-02 O(glyphs) each). No allocation in the walk (the z, the + cursor, and the stack-built `TextItem`s); the batcher's `add` is + the O(1) frame append (PERF-003). +- The per-frame UI cost is part of the composite 50k render-CPU + budget — measured with M2-PERF-01; this step has no standalone + `budgets.json` entry (the 17-entry pin is unchanged). +- One batcher per pass (RENDER-001): the UI groups are (atlas, + material, blend) — with the M2 conventions (one UI atlas, material + 0, alpha) a typical frame is **two** instanced draw calls (the UI + atlas + the glyph atlas), regardless of the widget count. +- The batcher's G-R11 `depthOverride` counter is bumped once per + UI quad; the build stage's rate-limited warn fires when it is + nonzero (expected for the UI pass — the screen-space manual z IS + the documented order value). + +**Common traps.** Sizing the UI batcher for the world pass only +(capacity under the frame's UI quad count → `BudgetExhausted`); +calling `declareUi` before `layout()` (fresh nodes cull as +zero-area); expecting the slider fill at t = 0 or t = 1 (it is +declared only for `0 < t < 1`); expecting a background under a +non-opaque panel or an input (M2 has none — nest an opaque panel). + +## Determinism (ARCH-010) + +The pass is deterministic within one build: the traversal order is +the tree's (insertion order, stable), the z is a monotone function of +that order, the culling tests are pure integer comparisons on the +laid-out rects, and the float values (the quad pos/scale, the slider +t, the text layout) are the same presentation floats the M2-UI-01 +layout and M2-TEXT-02 declare produce. Same build, same tree state +→ identical declared frame. + +## Example (performant path) + +```cpp +// Scene set-up (once): the tree, the font, the UI atlas. +UiTree ui = /* M2-UI-01 create + addChild ... */; +GlyphAtlas font = /* M2-TEXT-01 create ... */; +std::vector uiRgba = /* the UI atlas bytes ... */; +renderer.bindAtlas(7, atlasW, atlasH, uiRgba); // the UI atlas +// (the glyph atlas through bindAtlas / bindSdfAtlas, M2-TEXT-02/03) + +SpriteBatcher uiB = /* create({uiCapacity}) ... */; +UiDeclareOptions opts; +opts.defaultFont = &font; +opts.fontAtlasId = 9; // the glyph atlas's bindAtlas id +opts.fontScale = 1; + +// Per frame (the render phase, after the world pass): +ui.layout(); +uiB.beginFrame(); +Status s = declareUi(uiB, strings, ui, opts); +if (!s.ok()) { /* report — CORE-008 */ } +uiB.build(); +renderer.submit(uiB, uiScreenMatrix(1280, 720)); +``` + +## Misuse warnings + +- **Do not call `declareUi` without `layout()` first**: a node added + or re-placed since the last layout has a ZERO rect and is culled + as offscreen (the frame protocol calls `layout()` every frame — + the M2-UI-01 contract). +- **Do not size the UI batcher for the world pass only**: the UI + pass is its own batcher, sized for the frame's UI quads + glyph + count. +- **Do not expect sub-pixel text**: the font scale is integer (the + M2-TEXT-01 convention). +- **The UI matrix and the FBO must match the tree's screen rect**: + `uiScreenMatrix(screenWidth, screenHeight)` with a frame buffer of + the same size (the M2-GOLD-01 reference resolution 1280×720 when + it matters). + +## Related + +- [UI widget tree](ui_widgets.md) — the M2-UI-01 retained tree this + pass walks (the seven kinds, the layout, the focus, the pointer + events). +- [Text items](text_items.md) — the M2-TEXT-02 `declareText` path the + pass uses for the text widgets and the pass-generated labels. +- [Font](font.md) / [SDF glyph atlas](font_sdf.md) — the glyph + atlases the pass's text draws from. +- [Sprite batcher](sprite_batcher.md) — the frame protocol, the + (atlas, material, blend) groups, the G-R11 depth override. +- [Sprite renderer](sprite_renderer.md) — the submit stage the UI + quads draw through (the `bindAtlas` uploads, the stats). +- [Coordinates](../concepts/coordinates.md) — the screen-space → + NDC identity mapping (§4.14, ARCH-008). diff --git a/docs/api/ui_widgets.md b/docs/api/ui_widgets.md index 16aeeee..8ce3f6b 100644 --- a/docs/api/ui_widgets.md +++ b/docs/api/ui_widgets.md @@ -5,8 +5,9 @@ pool-backed tree of the ADR 0006 widget set — exactly **panel, button, text, image, list, slider, input**, in that canonical order — with stable generation-checked widget ids, a per-frame layout pass, a focus system, and a per-frame bounded list of pointer events. This -step is the **retained data only**: no rendering (the draw pass lands -with M2-UI-02), no simulation coupling (ARCH-009: the tree is +step is the **retained data only**: no rendering of its own (the +draw pass is [`ui_render.md`](ui_render.md), M2-UI-02), no +simulation coupling (ARCH-009: the tree is presentation state, owned by the game's UI update, and never mutates authoritative state). @@ -139,8 +140,8 @@ chain is safe), exact integer math: shorter than its rect never scrolls). - Computed coordinates are saturated into `±(2^21 − 1)` (`kUiMaxCoordPx`): a node pushed that far offscreen renders nothing - (the M2-UI-02 cull), and the saturation keeps the int32 storage - exact. + (the M2-UI-02 cull — [`ui_render.md`](ui_render.md)), and the + saturation keeps the int32 storage exact. - A node's rect is **zero until the next `layout()`** — a fresh `addChild` and every `setPlacement` zero the node's rect. Zero-rect nodes contain no pointer point. diff --git a/docs/concepts/coordinates.md b/docs/concepts/coordinates.md index 06acb6a..f4d5ed0 100644 --- a/docs/concepts/coordinates.md +++ b/docs/concepts/coordinates.md @@ -494,6 +494,32 @@ The UI tree is presentation-only (ARCH-009): it consumes raw pointer state (the FR-4.4 hook — `UiPointerState` in the same screen px, y down) and never mutates simulation state. +### 4.14 The UI render pass: screen-space quads on the identity camera (M2-UI-02) + +The UI render pass ([`ui_render.md`](../api/ui_render.md)) renders +the M2-UI-01 tree in **SCREEN space, y DOWN** — the separate UI +render pass of FR-2.8, with an identity camera: no world transform +leaks in (RENDER-006). `declareUi` walks the laid-out rects in the +tree's deterministic traversal order, culls the invisible widgets +(the offscreen rule — zero area or entirely outside the screen rect +on all four half-open edges — and the occlusion rule — fully +covered by an opaque panel drawn later in the traversal, the ADR +0006 occlusion model), and declares one centered M2-SPRITE-02 quad +per visible widget (`pos = (rectX + rectW/2, rectY + rectH/2)`, +`scale = (rectW, rectH)`) plus the glyph quads of the text widgets +and the pass-generated button labels / input content (M2-TEXT-02 +`declareText`). The UI pass is its own `SpriteBatcher`; every quad +carries the monotone per-node UI z with `depthOverride = true` +(the G-R11 escape hatch — there is no `isoDepthKey` in this pass), +materialId 0 + `BlendMode::Alpha`, and one texture per atlas. The +submit stage draws it with `uiScreenMatrix(width, height)` — the +identity screen matrix mapping screen px (y down) to NDC: +`x_ndc = 2·x/width − 1`, `y_ndc = 1 − 2·y/height` (column-major; +the linear part in columns 0..1, the translation in column 3; the +top screen edge maps to NDC +1, the bottom to NDC −1). The UI pass +is presentation-only (ARCH-009): it reads the laid-out tree and +declares; it never mutates sim state or the tree. + ## 5. Conversion rules (the module boundaries, RENDER-006) | Conversion | Direction | Owner | Status | @@ -509,6 +535,7 @@ screen px, y down) and never mutates simulation state. | 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`; 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)** | | UI widget spec → laid-out rect | widget spec (anchor, offsets, size; the parent's content box) → computed rect (screen px, y down; the coordinate saturation; the list stacking + scroll) | `laige-render` (`UiTree::layout` / `UiTree::handlePointer` — the retained tree + pointer hit test, M2-UI-01) | **Shipped (M2-UI-01)** | +| Laid-out UI widgets → declared quads + screen matrix | laid-out rects (screen px, y down) + traversal order → cull + one centered quad per visible widget (the glyph quads of the text widgets) + the identity screen matrix (screen px → NDC) | `laige-render` (`declareUi` / `uiScreenMatrix` — the screen-space UI pass on the identity camera, M2-UI-02) | **Shipped (M2-UI-02)** | ### 5.1 The isometric grid picking (M2-ISO-03) @@ -636,6 +663,11 @@ state → same keys → same order, every frame (RENDER-003). screen space y down, the nine anchors, the layout pass, the focus, the per-frame pointer events, the FR-4.4 routing hook) (M2-UI-01). +- [`api/ui_render.md`](../api/ui_render.md) — the UI render pass + contract: `declareUi` + `uiScreenMatrix` (the screen-space UI + pass on the identity camera — the culling model, the per-widget + quads, the monotone UI z, the screen px → NDC identity matrix) + (M2-UI-02). - [`api/matrices.md`](../api/matrices.md) — the matrix builders and NDC conventions (M2-GL-03). - [`decisions/0005-iso-default.md`](../decisions/0005-iso-default.md) — diff --git a/laige-api.json b/laige-api.json index 9b8b797..d6ce821 100644 --- a/laige-api.json +++ b/laige-api.json @@ -32,6 +32,7 @@ "src/laige-render/include/laige/render/sprite_renderer.h", "src/laige-render/include/laige/render/text_items.h", "src/laige-render/include/laige/render/tilemap.h", + "src/laige-render/include/laige/render/ui_render.h", "src/laige-render/include/laige/render/ui_widgets.h", "src/laige-sim/include/laige/sim/archetype.h", "src/laige-sim/include/laige/sim/component.h", @@ -1097,6 +1098,12 @@ {"name": "laige::render::TileMap::AnimSlot::tick", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 607, "signature": "std::uint32_t tick{0}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::TileMap::AnimSlot::valid", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 608, "signature": "bool valid{false}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::TileMap::AnimSlot::SpriteUvRect", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 610, "signature": "std::unique_ptr frameUv", "summary": "The precomputed frame UVs (frameCount rects — setAnimation).", "budget": null, "experimental": false}, + {"name": "laige::render::UiDeclareOptions", "kind": "struct", "header": "src/laige-render/include/laige/render/ui_render.h", "line": 207, "signature": "struct UiDeclareOptions", "summary": "The UI pass's scene configuration (API-006): the DEFAULT UI FONT (non-owning — the scene's GlyphAtlas, the M2-TEXT-01/03 atlas; it must outlive every frame that declares through it, the TextItem.font lifetime contract), its texture id (the scene's `bindAtlas` id for that atlas — the pass-generated text's `atlasId`), and its integer scale (the M2-TEXT-01 convention, domain [1, kTextMaxScale] — validated at `declareUi`). A NULL `defaultFont` turns off the pass-generated text: button labels and input content declare nothing (the art / art-less widgets still render — the scene composes a Text widget for its labels when it wants them without a default font).", "budget": null, "experimental": false}, + {"name": "laige::render::UiDeclareOptions::defaultFont", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_render.h", "line": 209, "signature": "const GlyphAtlas* defaultFont{}", "summary": "The default UI font (non-owning). null = no pass-generated text.", "budget": null, "experimental": false}, + {"name": "laige::render::UiDeclareOptions::fontAtlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_render.h", "line": 212, "signature": "std::uint32_t fontAtlasId{0}", "summary": "The default font's texture id (the scene's `bindAtlas` id — read when `defaultFont != nullptr`).", "budget": null, "experimental": false}, + {"name": "laige::render::UiDeclareOptions::fontScale", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_render.h", "line": 216, "signature": "std::uint32_t fontScale{1}", "summary": "The integer scale of the pass-generated text. Domain [1, kTextMaxScale]; 0 or > kTextMaxScale with a font fails `declareUi` (InvalidArgument, nothing declared).", "budget": null, "experimental": false}, + {"name": "laige::render::uiScreenMatrix", "kind": "function", "header": "src/laige-render/include/laige/render/ui_render.h", "line": 237, "signature": "[[nodiscard]] inline Mat4 uiScreenMatrix(std::int32_t width, std::int32_t height) noexcept", "summary": "The UI pass's screen matrix: maps screen px (y DOWN) to NDC, x_ndc = 2·x/width − 1, y_ndc = 1 − 2·y/height — the submit stage's `uWorldToNdc` for the UI pass (the identity CAMERA of FR-2.8: the UI renders screen-space, nothing world-space leaks in, RENDER-006). The frame buffer / FBO the matrix addresses MUST be the same width/height (the tree's screen rect — `UiTree::Options`, the M2-GOLD-01 reference resolution when it matters).", "budget": null, "experimental": false}, + {"name": "laige::render::declareUi", "kind": "function", "header": "src/laige-render/include/laige/render/ui_render.h", "line": 415, "signature": "[[nodiscard]] inline laige::Status declareUi(SpriteBatcher& batcher, const StringTable& table, UiTree& tree, const UiDeclareOptions& options) noexcept", "summary": "Walks the laid-out `tree` in its deterministic preorder (the M2-UI-01 draw order, RENDER-003), culls the invisible widgets (the header's culling section — offscreen + occluded by a later opaque panel), and declares the visible widgets' quads (and the text glyphs of the Text widgets and the pass-generated button labels / input content) into `batcher` (the header's per-widget model): O(n) per frame plus the culling's worst case (the header's culling section — menu-scale UIs are negligible), NO ALLOCATION (the cursor re-walks the tree's links; the pass-generated TextItems are stack values — PERF-003), no logging on the happy path (the batcher's G-R11 warn at build is the batcher's own cold, rate-limited path, LOG-003/004), no GL (the submit half is the M2-SPRITE-02 `SpriteRenderer::submit`).", "budget": null, "experimental": false}, {"name": "laige::render::kUiWidgetKindCount", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 349, "signature": "inline constexpr std::uint32_t kUiWidgetKindCount = 7", "summary": "The seven ADR 0006 widget kinds, in the ADR's canonical order (the M2-DEC-02 Verify clause: the implemented set is exactly this).", "budget": null, "experimental": false}, {"name": "laige::render::kUiMaxDimPx", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 355, "signature": "inline constexpr std::uint32_t kUiMaxDimPx = 1u << 20", "summary": "The screen-pixel dimension / offset domain: 2^20 px (a 20-bit screen dimension — far beyond any UI, and < 2^24 so every coordinate inside the domain is exact in float, the M2-SPRITE-03 float-exact domain).", "budget": null, "experimental": false}, {"name": "laige::render::kUiMaxCoordPx", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 361, "signature": "inline constexpr std::int32_t kUiMaxCoordPx = (1 << 21) - 1", "summary": "The computed-rect coordinate saturation bound (2^21 − 1): a node pushed that far offscreen renders nothing (the M2-UI-02 cull); the saturation keeps the int32 storage exact (the header's layout domain section).", "budget": null, "experimental": false}, diff --git a/roadmap/M2-rendering-2.5d.md b/roadmap/M2-rendering-2.5d.md index 2890e50..cd2c27e 100644 --- a/roadmap/M2-rendering-2.5d.md +++ b/roadmap/M2-rendering-2.5d.md @@ -387,7 +387,7 @@ if M2 slips, and its status is recorded in M2-EXIT-01. per-frame path is allocation-free and owner-thread-only (the 1 000-frame zero-allocation loop in `ctest -R ui_tree`). -- [ ] **M2-UI-02 · UI rendering pass** +- [x] **M2-UI-02 · UI rendering pass** - **Refs:** FR-2.8 (separate render pass, screen-space) - **Depends:** M2-UI-01, M2-SPRITE-02 - **Scope:** @@ -396,6 +396,31 @@ if M2 slips, and its status is recorded in M2-EXIT-01. - Golden image test fixture: a fixed UI scene renders to the committed reference image (M2-GOLD-01). - **Verify:** `ctest -R ui_render` green (offscreen); golden image matches within documented tolerance. - **Size:** ~250 lines + tests + - **Shipped:** M2-UI-02 (`laige/render/ui_render.h`, + header-only, no GL calls): `declareUi` is the screen-space UI + pass on the identity camera (the M2-SPRITE-02 submit stage with + `uiScreenMatrix` — screen px, y down → NDC; nothing + world-space leaks in, RENDER-006). It walks the M2-UI-01 tree's + laid-out rects in the deterministic traversal order, culls the + invisible widgets (the documented simple model: the offscreen + rule on all four half-open edges + the zero-area rule, and the + later-opaque-panel occlusion rule — the ADR 0006 occlusion + model), and declares one centered quad per visible widget (the + seven ADR 0006 kinds) plus the glyph quads of the text widgets + and the pass-generated button labels / input content through the + M2-TEXT-02 `declareText` path. The UI pass is its own + `SpriteBatcher` (one batcher per sprite pass); every quad + carries the monotone per-node UI z with `depthOverride = true` + (the G-R11 escape hatch — counted + warned at build), + materialId 0 + `BlendMode::Alpha`, one texture per atlas. The + golden image fixture (`UiRenderSmoke` in `ctest -R ui_render`) + renders a fixed 128×128 UI scene offscreen through the real + submit stage and compares the whole frame against a CPU + reference rasterizer within the documented ± 1 byte tolerance + (the committed reference-image infra lands with M2-GOLD-01). No + standalone `budgets.json` entry (the per-frame UI cost is part + of the composite 50k render-CPU budget, M2-PERF-01); the + 1 000-frame zero-allocation declare loop is in the entry. ## Reference scene & performance acceptance diff --git a/roadmap/README.md b/roadmap/README.md index 573ee45..12b6203 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 | 25 | ⬜ in progress | +| M2 | 33 | 26 | ⬜ 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** | **71** | | +| **Total** | **194** | **72** | | --- @@ -252,6 +252,7 @@ One line per completed (or split/renumbered) step. | 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) | | 2026-10-11 | M2-UI-01 | `—` / PR (open) | UI widget tree (retained, screen-space; M2-UI-01 scope, nothing else): **API** — NEW header-only `laige::render` UI module (public header `include/laige/render/ui_widgets.h`, no new `.cpp`, no GL calls, no new dependency): `UiTree` (move-only; `create(Options{maxWidgets, maxEvents, screenWidth, screenHeight})` — the two backing allocations: the fixed widget pool (`maxWidgets` INCLUDING the root) + the per-frame event array; screen dims validated in `[1, 2^20]`): a retained widget tree of the ADR 0006 set — exactly `panel`, `button`, `text`, `image`, `list`, `slider`, `input`, in the ADR's canonical order (the M2-DEC-02 Verify clause: widget list in ADR == widget list implemented) — `UiWidgetId` = `laige::PoolHandle` (stable generation-checked ids, `kInvalidUiWidgetId` never live, a stale id reads as `nullptr`, never UB); `addChild(spec)` (validation first-failure-wins `InvalidArgument` no log: kind in the seven, parent live-or-root, geometry domains w/h/pad/scroll in `[0, 2^20]`, x/y in `±2^20`, slider `min < max` with NaN rejected and the value clamped; the pool create → `BudgetExhausted` + one rate-limited Warn `ui_tree/widget_budget` (field `capacity`); the child-list append in insertion order = the traversal / draw order); `remove(id)` (iterative post-order — every node of the subtree unlinked + destroyed exactly once, a 65k-deep chain safe; the pool's LIFO recycling reuses the slots with a generation bump; the root / a stale id → `InvalidArgument`; a removed focused widget clears the focus); **layout** — `layout()` one iterative preorder pass (the parent always before its children), exact integer math, deterministic (RENDER-003): the root = the layout-only screen container `(0, 0, w, h)` (never pointer-hit, never focusable, never removable); a child's rect = its anchor point in the parent's CONTENT box (the rect inset by the padding, clamped to 0 x 0 when the padding is wider) + its offset − its anchor offset in its own size (the nine `UiAnchor` values — `TopLeft`/`Center`/`BottomRight` etc.); a **list** stacks its children top-to-bottom in insertion order at the content's left edge (the children's anchors/offsets ignored), shifted up by the normalized `scroll` ∈ `[0, totalContentHeight − contentHeight]`; computed coordinates saturated into `±(2^21 − 1)` (`kUiMaxCoordPx` — offscreen widgets render nothing, the M2-UI-02 cull; the int32 storage stays exact); a node's rect is zero until the next `layout()` (zero-rect nodes contain no pointer point); **focus** — the focusable kinds are button / slider / input (ADR 0006); `focus()` / `focusWidget(id)` / `focusNext()` / `focusPrev()` (the deterministic preorder tab order with wraparound; from unfocused, next → first / prev → last; a pointer press on a focusable takes the focus; no keyboard events here — action routing is M3-INPUT-04); **frame protocol + events** — `layout()` then `handlePointer(UiPointerState{x, y, pressed})` (or `beginFrame()` for a pointerless frame — resets the event list); `handlePointer` hit-tests the TOPMOST node (the LAST node in traversal order whose half-open rect `left ≤ x < right, top ≤ y < bottom` contains the point, the root excluded) and delivers, in order: leave-then-enter `PointerLeave`/`PointerEnter` on a top change; on press `PointerPress` + focus-on-focusable + the slider drag start; on release `PointerRelease` + `ButtonPressed` iff the press started on that SAME button (a press that moved off the button emits no `ButtonPressed`); on a pressed frame the press-owner slider keeps tracking (`value = min + t·(max−min)`, `t` clamped `[0, 1]` over the slider's width — a zero-width slider takes no input); the per-frame list is bounded: the first `maxEvents` events kept, a later one dropped + counted in `droppedEventsTotal()` + one rate-limited Warn `ui_tree/event_overflow` (the LOG-004 facade windows the repeats); `events()` span valid until the next frame boundary; `setSliderValue` (clamps, NaN → min, NO event — only the pointer drag emits), `setPlacement`, `setScroll` (layout normalizes), `setInputText` (the `InputText` event — a no-op for the same handle); **FR-4.4 hook** — `pointerHit()` = the topmost node under the last processed pointer position (a hit = the UI consumed the frame's pointer input — UI-first routing); **ownership** — owner-thread-only (CONC-001), no sim coupling (ARCH-009: presentation state, never mutates sim), two allocations at `create` only; **tests** — `tests/laige-render/ui_tree_tests.cpp` (new CTest entry `ui_tree` = the step's Verify command; 24 tests / 6 suites; TSan entry with `halt_on_error=1`; GL-free — no context, passes in every tree incl. ASan/TSan): `UiTreeCreate` (the validation matrix + the no-log failure contract, the stopped state, the two-alloc create, the move-only stopped source), `UiTreeCrud` (the seven kinds + the kind-specific payloads, the parent/kind/geometry/slider domain validation, the widget budget + the `widget_budget` Warn, the sibling-chain structure, the subtree removal + LIFO slot recycling (the generation bump), the setter failures), `UiTreeLayout` (the nine-anchor goldens against hand-computed rects, the offsets, the container padding (incl. padding wider than the container → the 0 x 0 content at its inset origin), the list stacking + scroll normalization (incl. the over-scroll clamp and the content-shorter-than-rect case), the negative rects + the coordinate saturation (both axes at the 2^20 screen bound), the zero-rect-before-layout + idempotence), `UiTreeFocus` (the focusable kinds only, the deterministic tab order (wraparound both directions, the nested preorder), the pointer-press focus + the removal clears), `UiTreeEvents` (the scripted pointer sequences — the exact per-frame event lists against hand-computed goldens: the hover leave-then-enter, the button click (press + release on the same spot → `ButtonPressed`; the drag-off → no `ButtonPressed`), the slider drag (the value from the pointer x across the frames, no hover events while pressed), the press-owner tracking across widgets, the input text events (the new-handle rule, the no-op same handle), the zero-size widgets contain nothing, the event overflow (the exact drop order + the 2 Warns + the cumulative count)), `UiTreeZeroAlloc` (1 000 frames of layout + pointer + placement + input = 0 heap blocks, the machine-greppable `ui-tree-zeroalloc` line); **docs** (DOC-006/007) — NEW `docs/api/ui_widgets.md` (the full contract: the API sketch, the layout model, the frame protocol + pointer events, the focus, the storage/ownership, the failure table, the DOC-004 Performance section, the misuse warning), `docs/README.md` (the API index row), `src/laige-render/README.md` (the status paragraph), `docs/concepts/coordinates.md` (§4.13 the UI widget tree coordinate section + the §5 conversion row + the Related link); **local verification** — all six canonical trees build warning-free and `ctest -R ui_tree` green in each (24/24); `tools/laige-include-lint` + `tools/laige-determinism-lint` OK; `laige-api.json` regenerated LAST (the new public header — `api-real-tree` green); no `budgets.json` entry (the per-frame cost is part of the composite 50k render-CPU budget, M2-PERF-01 — the pin count stays 17); no rendering (the UI draw pass is M2-UI-02); Progress Board M2 25/33 (total 71/194) | +| 2026-10-11 | M2-UI-02 | `—` / PR (open) | UI rendering pass (screen-space, identity camera; M2-UI-02 scope, nothing else): **API** — NEW header-only `laige::render` UI pass (public header `include/laige/render/ui_render.h`, no new `.cpp`, no GL calls, no new dependency): `UiDeclareOptions` (the default UI font — non-owning `const GlyphAtlas*`, its `bindAtlas` id, its integer `fontScale` in [1, 16]; a null font turns off the pass-generated text — the art still renders), `uiScreenMatrix(width, height)` (the UI pass's identity screen matrix, screen px y down → NDC: `x_ndc = 2x/w − 1`, `y_ndc = 1 − 2y/h`, column-major — `m[0].x = 2/w`, `m[1].y = −2/h`, `m[3].x = −1`, `m[3].y = +1`, `m[3].w = 1`; out-of-dimension domain `[1, kUiMaxDimPx]` → the identity matrix), `declareUi(batcher, table, tree, options)` (the O(n) tree → batched-quads pass — the S-5 declare half; the GL half is the M2-SPRITE-02 `SpriteRenderer::submit` with the UI matrix, a SEPARATE screen-space pass, nothing world-space leaks in, RENDER-006): walks the laid-out rects in the deterministic traversal order (RENDER-003), culls the invisible widgets (the documented simple model — zero area or entirely offscreen on all four half-open edges, O(1) per node; FULLY COVERED by an opaque panel drawn LATER in the traversal — the ADR 0006 occlusion model — an early-stopping re-walk, worst case O(n) per node, O(n²) per frame, the `ponytail:` ceiling comment + the O(n·P) opaque-panel scratch upgrade), and declares one M2-SPRITE-02 centered quad per visible widget (`pos` = the rect center, `scale` = the rect size): OPAQUE panel → one background quad (a non-opaque panel declares nothing — its children still render), button → the art quad + the centered label (the stack-built `TextItem`, the exact int64 baseline `rectY + (rectH + lineStep)/2`, `lineStep = lineHeight()·fontScale`, the node's tint, `maxWidth` 0), text → the node's `TextItem` verbatim with the `depthKey` OVERWRITTEN by the widget's z (the traversal order owns the z; the rect is culling-only — no clipping quads in M2), image → one quad, list → NOTHING (a container with no M2 background option — the scene nests an opaque panel as its first child), slider → the track quad + the fill quad only for `0 < t < 1` (`t = (value − min)/(max − min)`, the fill spans `[rectX, rectX + rectW·t) × [rectY, rectY + rectH)` at full height, the node's UV rect; t = 0 is a zero-width quad, t = 1 double-blends the identical track pixels — the alpha blend is not idempotent), input → the left-aligned WHITE content text at `rectX` (no background quad in M2); the UI pass is its own `SpriteBatcher` (one batcher per sprite pass, M2-SPRITE-01); every quad carries the monotone per-node UI z (the index among the nodes that declare something, in traversal order — a culled / no-declare node consumes no z) with `depthOverride = true` (the G-R11 escape hatch — the UI pass is screen-space, there is no `isoDepthKey`; counted + warned at build), materialId 0 + `BlendMode::Alpha` throughout, one texture per atlas (the scene binds its UI atlas + glyph atlas(es) through the existing `bindAtlas` / `bindSdfAtlas`); **failure** — first failure wins: a stopped tree / missing root or an out-of-domain `fontScale` (with a font) → `InvalidArgument` before the first add; a Text / button-label / input-text handle not owned by the string table or a stopped font → `InvalidArgument`; a failed batcher `add` → the batcher's error (`BudgetExhausted` on capacity, `InvalidArgument` on a closed window); a failed call leaves the quads ALREADY DECLARED in the frame (the batcher owns the frame state — the `declareParticles` / `declareText` precedent); no logging on preconditions (the house failure contract); **tests** — `tests/laige-render/ui_render_tests.cpp` (new CTest entry `ui_render` = the step's Verify command; 12 tests / 7 suites; the eleven GL-free suites pass in every tree, the twelfth is the offscreen GL smoke with the `GTEST_SKIP` contract where no usable OpenGL 3.3): `UiRenderScreenMatrix` (2: the column-major pin incl. `m[3].w = 1` — GLM's scalar ctor is the diagonal, identity at 1.0 — and the corner/center NDC at 1280×720 (y(0) = +1, y(h) = −1), the out-of-domain identity fallback), `UiRenderDeclare` (2: the 200×150 all-seven-kinds scene → 12 quads / 2 groups / 12 overrides against the hand-computed goldens (7 insts z 0,1,3,4,4,6,7 in the UI atlas + 5 glyph insts z 1,1,2,2,5 in the font atlas — the T item centered at lineX0 = 90, the B label at lineX0 = 30), the machine line `ui-render-declare: quads=12 groups=2 overrides=12`; no default font → 2 quads (the panel + the button art only)), `UiRenderCull` (2: the offscreen rule (50×50, 7 images → 2 declared — the on-edge column/row still draws), the occlusion rule (100×100, 12 nodes → 9 quads z0..z8 — the later-opaque-panel early stop, the non-opaque panel declares nothing, a partial overlap culls nothing)), `UiRenderSlider` (1: t = 0/1 → track only, t = 0.5 → track + fill, 4 quads), `UiRenderFailure` (3: the precondition failures declare nothing; the Text / button / input handle failures with real rects (no cull-before-validate) — the Text widget with a panel → `InvalidArgument`, frameCount 1 (the panel remains), the button's invalid label → frameCount 1 (the art quad precedes the label), the input's invalid text → 0, the stopped (moved-from) font → `InvalidArgument`; the batcher-state failures — a stopped batcher → `BudgetExhausted`, a closed window → `InvalidArgument`), `UiRenderZeroAlloc` (1: 1 000 frames of the declare loop = 0 heap blocks, the machine line `ui-render-zeroalloc frames=1000 allocs=0`), `UiRenderSmoke` (1: the golden image fixture (M2-GOLD-01's first fixture — the committed-PNG infra lands with M2-GOLD-01): a fixed 128×128 scene (a red panel (10,10,40,30) uv texel(0,0), a green button (16,16,20,14) label "M", an "a" text at (80,20), a blue image (80,40,16,16), a white slider (10,50,40,8) [0,4] value 2) renders offscreen through the REAL `SpriteRenderer::submit` (7 quads — P z0, B-art z1, B-M z1, T-a z2, I z3, S track + fill z4 — 2 groups, 2 draw calls, 7 instances, 364 upload bytes; the font metrics asserted: "a" adv 8, 8×9, by 8) against a CPU reference rasterizer (the unrotated rect, the strict-interior pixel-center rule — all scene edges are integer px, never on an edge, so GPU coverage == the reference, the sprite_draw golden convention; the per-quad 4-channel double blend `out.a = a² + (1−a)` over the opaque clear, `byte = min(255, v·255 + 0.5)`) — the WHOLE frame within ± 1 byte per channel (16 384 readbacks) + the exact probes ((20,20) red, (26,23) green, (88,48) blue, (20,54) white, (120,120) the clear, the "a" opaque texel → (255,255,255,255), the "a" semi texel → rgb == A EXACT + alpha within ± 1 of round(255(a²+1−a)) — the M2-SPRITE-02 all-four-channel `glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)` contract, pinned by the TextSmoke CI on every backend); the machine line `ui-render-golden: frame=128x128 quads=7 groups=2 draw_calls=2 instances=7 mismatches=0`; TIMEOUT 120); **docs** (DOC-003/004/006/007) — NEW `docs/api/ui_render.md` (the full contract: the API, the per-widget model, the monotone UI z + G-R11, the culling model, the frame protocol, the failure table, ownership/threading, the DOC-004 Performance section (O(n) walk + the occlusion worst case, zero allocation, one batcher per pass, the G-R11 count, the golden ± 1 tolerance, the common traps), determinism, the performant example, the misuse warnings), the `docs/README.md` API index row, `src/laige-render/README.md` (the status paragraph + the remaining-steps list), `docs/concepts/coordinates.md` (NEW §4.14 the UI render pass — the screen-space identity camera + the §5 conversion row + the Related link — the header's normative reference), cross-links from `docs/api/ui_widgets.md` (the two M2-UI-02 mentions) and `docs/api/text_items.md` (the model section + Related); **local verification** — all six canonical trees (`build`, `build-clang`, `build-release`, `build-shared`, `build-asan`, `build-tsan`) build warning-free and the eleven GL-free `ui_render` suites pass in each (11/11); the full GL-free `ctest` in every tree is green except the expected `api-real-tree` (fixed by the manifest regeneration); the GL smoke is CI-validated only (local GPU execution is policy-prohibited — one accidental local GL run of the smoke occurred before the policy update and is disclosed in the final report); `tools/laige-include-lint` + `tools/laige-determinism-lint` OK; `laige-api.json` regenerated LAST (the new public header — 46 → 47 headers; `api-real-tree` green); no `budgets.json` entry (the per-frame UI cost is part of the composite 50k render-CPU budget — M2-PERF-01 — the pin count stays 17); Progress Board M2 26/33 (total 72/194) | --- diff --git a/src/laige-render/README.md b/src/laige-render/README.md index 4cff33a..2b4c9ae 100644 --- a/src/laige-render/README.md +++ b/src/laige-render/README.md @@ -710,7 +710,8 @@ routing hook: a hit means the UI consumed the frame's pointer input. The tree owns two allocations at `create` (the fixed-capacity widget pool — `maxWidgets` INCLUDING the root — and the event array); every per-frame path is allocation-free and owner-thread-only. No -rendering (the UI draw pass lands with M2-UI-02), no simulation +rendering of its own (the UI draw pass is `ui_render.h`, M2-UI-02), +no simulation coupling (ARCH-009), no standalone `budgets.json` entry (the per- frame cost is part of the composite 50k render-CPU budget, M2-PERF-01). API contract in @@ -724,5 +725,43 @@ the focus system, the scripted pointer sequences (the button click, the slider drag, the input text, the event overflow), and the 1 000- frame zero-allocation loop). -The remaining M2 steps (M2-UI-02, M2-SCENE-01, M2-PERF-01) land in -later steps. +M2-UI-02 landed the UI render pass — the draw half of the UI work: +`declareUi` (public header `include/laige/render/ui_render.h`, +header-only, no GL) is the screen-space UI pass (FR-2.8: "UI is a +separate render pass, screen-space" — the identity camera, nothing +world-space leaks in, RENDER-006). It walks the M2-UI-01 tree's +laid-out rects in the deterministic traversal order (RENDER-003), +culls the invisible widgets (the documented simple model: the +offscreen rule on all four half-open edges + the zero-area rule, and +the later-opaque-panel occlusion rule — the ADR 0006 occlusion +model), and declares one M2-SPRITE-02 centered quad per visible +widget (the seven ADR 0006 kinds — the opaque-panel background, the +button art + centered label, the text item verbatim with the pass's +z, the image quad, the list nothing, the slider track + interior- +only fill, the input's left-aligned content) plus the pass-generated +text through the M2-TEXT-02 `declareText` path. The UI pass is its +own `SpriteBatcher` (one batcher per sprite pass, M2-SPRITE-01), its +quads carry the monotone per-node UI z with `depthOverride = true` +(the G-R11 escape hatch — counted + warned at build), materialId 0 + +`BlendMode::Alpha` throughout, and one texture per atlas (the scene +binds its UI atlas + glyph atlas(es) through the existing +`bindAtlas` / `bindSdfAtlas`). The GL half is the M2-SPRITE-02 +`SpriteRenderer::submit` with `uiScreenMatrix` (screen px, y down → +NDC, the column-major identity-camera matrix). No standalone +`budgets.json` entry (the per-frame UI cost is part of the composite +50k render-CPU budget, measured with M2-PERF-01). API contract in +[docs/api/ui_render.md](../docs/api/ui_render.md), tests under +[tests/laige-render](../tests/laige-render) (CTest entry `ui_render` — +the identity screen matrix + its out-of-domain fallback, the +per-widget declaration goldens against the hand-computed layout + +glyph quads, the culling model (offscreen + occlusion + the +exceptions), the slider fill rule, the failure paths (first failure +wins, the partial-state-on-failure precedent), the 1 000-frame +zero-allocation declare loop, and the golden image fixture +(`UiRenderSmoke` — the fixed UI scene rendered offscreen through the +real submit stage, the whole 128×128 frame against a CPU reference +rasterizer within the documented ± 1 byte tolerance + the exact +probe pixels; it `GTEST_SKIP`s where no usable OpenGL 3.3 +environment). + +The remaining M2 steps (M2-SCENE-01, M2-PERF-01) land in later steps. diff --git a/src/laige-render/include/laige/render/ui_render.h b/src/laige-render/include/laige/render/ui_render.h new file mode 100644 index 0000000..c34a6b0 --- /dev/null +++ b/src/laige-render/include/laige/render/ui_render.h @@ -0,0 +1,592 @@ +// laige-render UI render pass (M2-UI-02): the draw half of FR-2.8 — +// the screen-space UI pass that turns the M2-UI-01 retained widget +// tree into batched quads + text for the M2-SPRITE-02 submit stage. +// +// FR-2.8: "Text rendering ...; immediate-mode-style UI widget tree +// (panel, button, text, image, list, slider, input) with a retained +// mode for menus; UI is a separate render pass, screen-space." This +// header is the pass's DECLARE half (S-5: declaration, not draw): +// `declareUi` walks the tree's laid-out rects in the deterministic +// preorder (the M2-UI-01 traversal / draw order, RENDER-003), culls +// the invisible widgets (the header's culling section), and declares +// one batched quad per visible widget (plus the glyph quads of the +// text widgets and the pass-generated button labels / input content +// through the M2-TEXT-02 `declareText` path). The GL half is the +// M2-SPRITE-02 `SpriteRenderer::submit` with the UI pass's identity +// screen matrix (`uiScreenMatrix` below — screen px, y down -> NDC; +// nothing world-space leaks in, RENDER-006). One texture per atlas: +// the scene binds its UI atlas and its glyph atlas(es) through the +// existing `bindAtlas` / `bindSdfAtlas` (M2-SPRITE-02); the pass adds +// no texture machinery of its own. +// +// UiDeclareOptions The pass's scene configuration (the default +// UI font + its texture id + its integer scale) +// uiScreenMatrix The UI pass's identity screen matrix (the +// submit stage's uWorldToNdc) +// declareUi The O(n) tree -> batched-quads pass (the +// culling included) +// +// --------------------------------------------------------------------------- +// The model (documented units) +// --------------------------------------------------------------------------- +// +// All values are SCREEN PIXELS, screen space, y DOWN — the UI pass's +// coordinate system (the M2-UI-01 tree's, the M2-TEXT-02 items'). The +// SCREEN RECT is the tree's root rect (`UiTree::Options` +// screenWidth/Height, default 1280 x 720 — the M2-GOLD-01 reference +// resolution): the root is the layout-only screen container, and the +// pass reads the rect from the laid-out root (valid at create — the +// root's rect is initialized to the screen rect, not zero). +// +// Every widget quad is the M2-SPRITE-02 centered quad: +// `pos = (rectX + rectW/2, rectY + rectH/2)`, `scale = (rectW, rectH)` +// — the quad's ±0.5·scale spans exactly the widget's laid-out rect +// (one presentation-only float conversion per field, ARCH-009; the +// rect is exact int32, the M2-UI-01 layout contract). +// +// The UI pass's ORDER is the tree's traversal order (preorder, +// children in insertion order — the M2-UI-01 draw-order contract, +// RENDER-003): the pass hands the batcher a MONOTONE per-node UI z — +// the index of the node among the nodes that declare something, in +// traversal order (a node that declares nothing, culled or not, takes +// no z). Every declared quad carries `depthKey = the node's z` and +// `depthOverride = true` (the G-R11 escape hatch: the UI pass is +// screen-space, there is no isoDepthKey in this pass — the manual UI +// z is the documented order value, counted + warned at build by the +// batcher, the sprite_batcher.h G-R11 contract). Within one node the +// declaration order is the draw order (the art quad before its label +// glyphs, the slider track before its fill — the batcher's stable +// sort keeps it for the equal-key instances). +// +// Every widget quad (and the pass-generated text) uses materialId 0 +// and `BlendMode::Alpha` (the M2 UI convention — per-widget blend / +// material is not part of the M2-UI-01 node model; the additive +// widget list is M4). A node's `atlasId` is its UI atlas's texture id +// (the scene's `bindAtlas` id); the pass-generated text uses +// `UiDeclareOptions::fontAtlasId` (the scene's `bindAtlas` id of the +// default font). +// +// --------------------------------------------------------------------------- +// The per-widget rendering model (the seven ADR 0006 kinds) +// --------------------------------------------------------------------------- +// +// Panel OPAQUE only: one background quad over the whole rect (the +// node's `image` UV rect, `atlasId`, `tint`). A non-opaque +// panel declares nothing (its children still render — they +// come LATER in traversal order, so they draw on top). +// Button one ART quad over the whole rect (the node's `image` UV +// rect, `atlasId`, `tint`) + the LABEL (when the pass has a +// default font): a M2-TEXT-02 `TextItem` built by the pass +// on the stack — the node's `label` handle, CENTER-aligned +// on the rect's center x, the first line's baseline +// vertically centered (`rectY + (rectH + lineStep)/2`, the +// exact int64 line step `lineHeight() · fontScale`), the +// node's `tint` as the text color, `maxWidth` 0 (no wrap — +// a button label is one line), the pass's z, the +// `fontAtlasId`. +// Text the node's `TextItem` verbatim, EXCEPT the pass overwrites +// `item.depthKey` with the widget's z (the item's hand-typed +// z is the free-standing text's order value; in the tree +// the traversal order owns the z — the item's font / +// alignment / wrap / scale / color / atlas / material / +// blend are the game's). The widget's rect is used for the +// CULLING only: the glyphs are not clipped (the scene sizes +// the rect from `measureText` — the M2-UI-01 panel-sizing +// read; no clipping quads in M2). +// Image one quad over the whole rect (the node's `image` UV rect, +// `atlasId`, `tint`). +// List NOTHING (a container with no M2 background option — the +// scene gives it a background with a nested opaque panel, +// the M2-UI-01 stacking convention: the panel is the list's +// FIRST child, so it draws first, behind the rows). +// Slider a TRACK quad over the whole rect (the node's `image` UV +// rect, `atlasId`, `tint`) + a FILL quad spanning +// `[rectX, rectX + rectW · t) x [rectY, rectY + rectH)` at +// the full height, `t = (value − min) / (max − min)` +// (presentation float, the node's `sliderValue`; min < max +// is the addChild contract). The fill is declared only for +// `0 < t < 1` (t = 0 is a zero-width quad — degenerate GL +// work; t = 1 double-blends the identical track pixels — +// the alpha blend is not idempotent). The fill samples the +// node's `image` rect at the full UV span (a solid-color UI +// atlas region — the scene convention — makes the stretch +// invisible). +// Input the CONTENT (when the pass has a default font): a +// M2-TEXT-02 `TextItem` built by the pass — the node's +// `text` handle, LEFT-aligned at `rectX`, the baseline +// vertically centered as for the button label, the WHITE +// color (the node has no text-color field in M2), +// `maxWidth` 0. No background quad (the node has no art +// field in M2 — the scene wraps an input in an opaque +// panel for a visible field). +// +// A widget that declares nothing (culled, a non-opaque panel, a +// list, an input without a default font, a text widget with empty +// content that still consumes its z) leaves the rest of the frame +// untouched: the pass is a pure declaration walk, the batcher owns +// the frame state. +// +// --------------------------------------------------------------------------- +// Culling (the documented simple model) +// --------------------------------------------------------------------------- +// +// A widget is CULLED (declares nothing) when: +// +// 1. OFFSCREEN: its laid-out rect has zero area (rectW or rectH +// is 0 — a fresh / zero-sized widget renders nothing, the +// M2-UI-01 contract), or it lies ENTIRELY outside the screen +// rect: `rectX >= screenW`, `rectY >= screenH`, `rectX + rectW +// <= 0`, or `rectY + rectH <= 0` (half-open edges — the +// iso-picking boundary rule; a widget touching the screen's +// right or bottom edge with its last visible column/row is +// drawn). The rect coordinates are saturated int32 +// (±(2^21 − 1), the M2-UI-01 domain); the half-open right / +// bottom edges are exact int32 sums (a coordinate ±(2^21 − 1) +// plus an extent ≤ 2^21 − 1 reaches at most 2^22 − 2, well +// inside int32). +// +// 2. OCCLUDED: a widget is FULLY COVERED by an opaque panel drawn +// after it (LATER in traversal order — on top, the painter's +// order): an opaque panel P culles a node N iff P is later +// than N in the traversal and P's laid-out rect contains N's +// (all four half-open edges: `P.x <= N.x`, `P.y <= N.y`, +// `P.x + P.w >= N.x + N.w`, `P.y + P.h >= N.y + N.h`). This is +// the ADR 0006 occlusion model: the opaque panel that occludes +// the pointer to a widget occludes its render too (the +// M2-UI-01 hit test's "opaque panel in front" clause). An +// opaque panel NEVER culls its own children (they are later in +// the traversal — they draw on top of the background); a +// non-opaque panel never culls anything; a partial overlap +// culls nothing (M2 has no clipping quads — the visible part +// of the covered widget still draws). +// +// The offscreen test is O(1) per node. The occlusion test re-walks +// the traversal from the node's successor to the end, stopping at +// the FIRST later opaque panel that contains it (the early stop — +// the common case when a modal panel is present); worst case O(n) +// per node, O(n²) per frame (a menu-scale UI — a few hundred +// widgets, a few opaque panels — measures far below the frame +// budget; the M2-SCENE-01 reference scene's UI is a single panel). +// No allocation: the walk re-uses the tree's structural links. +// ponytail: O(n²) ceiling on a deep/large UI; upgrade if a large +// scene measures hot: collect the frame's opaque panels into a +// create-sized scratch (one u32 + rect per panel, ≤ maxWidgets) and +// test each node only against the panels after it — O(n·P). + +// The header continues below the includes. + +#pragma once + +#include + +#include "laige/errors.h" +#include "laige/render/font.h" +#include "laige/render/matrices.h" +#include "laige/render/sprite_batcher.h" +#include "laige/render/text_items.h" +#include "laige/render/ui_widgets.h" +#include "laige/result.h" + +namespace laige::render { + +// --------------------------------------------------------------------------- +// The pass's scene configuration +// --------------------------------------------------------------------------- + +// The UI pass's scene configuration (API-006): the DEFAULT UI FONT +// (non-owning — the scene's GlyphAtlas, the M2-TEXT-01/03 atlas; it +// must outlive every frame that declares through it, the +// TextItem.font lifetime contract), its texture id (the scene's +// `bindAtlas` id for that atlas — the pass-generated text's +// `atlasId`), and its integer scale (the M2-TEXT-01 convention, +// domain [1, kTextMaxScale] — validated at `declareUi`). A NULL +// `defaultFont` turns off the pass-generated text: button labels and +// input content declare nothing (the art / art-less widgets still +// render — the scene composes a Text widget for its labels when it +// wants them without a default font). +struct UiDeclareOptions { + // The default UI font (non-owning). null = no pass-generated text. + const GlyphAtlas* defaultFont{}; + // The default font's texture id (the scene's `bindAtlas` id — + // read when `defaultFont != nullptr`). + std::uint32_t fontAtlasId{0}; + // The integer scale of the pass-generated text. Domain + // [1, kTextMaxScale]; 0 or > kTextMaxScale with a font fails + // `declareUi` (InvalidArgument, nothing declared). + std::uint32_t fontScale{1}; +}; + +// --------------------------------------------------------------------------- +// The UI pass's identity screen matrix (RENDER-006) +// --------------------------------------------------------------------------- + +// The UI pass's screen matrix: maps screen px (y DOWN) to NDC, +// x_ndc = 2·x/width − 1, y_ndc = 1 − 2·y/height +// — the submit stage's `uWorldToNdc` for the UI pass (the identity +// CAMERA of FR-2.8: the UI renders screen-space, nothing +// world-space leaks in, RENDER-006). The frame buffer / FBO the +// matrix addresses MUST be the same width/height (the tree's screen +// rect — `UiTree::Options`, the M2-GOLD-01 reference resolution when +// it matters). +// +// Column-major Mat4 (the house convention): the linear part in +// columns 0..1, the translation in column 3. Total function: a +// dimension outside [1, kUiMaxDimPx] is unrepresentable — the +// identity matrix is returned (the caller passes the tree's screen +// rect, which is in domain by construction). O(1), no allocation. +[[nodiscard]] inline Mat4 uiScreenMatrix(std::int32_t width, + std::int32_t height) noexcept { + Mat4 m(1.0f); // the identity (the out-of-domain fallback) + if (width >= 1 && width <= static_cast(kUiMaxDimPx) && + height >= 1 && height <= static_cast(kUiMaxDimPx)) { + m[0].x = 2.0f / static_cast(width); + m[1].y = -2.0f / static_cast(height); + m[3].x = -1.0f; + m[3].y = 1.0f; + } + return m; +} + +namespace detail { + +// The offscreen cull (the header's culling section, rule 1): true +// when the node's laid-out rect has zero area or lies entirely +// outside the screen rect (half-open edges — the iso-picking +// boundary rule). The rect sums are exact int32 (the header's +// culling section: the saturation domain bounds the edge to +// 2^22 − 2). +[[nodiscard]] inline bool uiRectOffscreen(const UiNode& n, + std::int32_t screenW, + std::int32_t screenH) noexcept { + if (n.rectW <= 0 || n.rectH <= 0) return true; + return n.rectX >= screenW || n.rectY >= screenH || + n.rectX + n.rectW <= 0 || n.rectY + n.rectH <= 0; +} + +// The occlusion containment (the header's culling section, rule 2): +// true when the (opaque panel's) rect `p` fully contains the node's +// rect `n` (all four half-open edges — exact int32, the same domain +// argument as uiRectOffscreen). +[[nodiscard]] inline bool uiRectCovers(const UiNode& p, + const UiNode& n) noexcept { + return p.rectX <= n.rectX && p.rectY <= n.rectY && + p.rectX + p.rectW >= n.rectX + n.rectW && + p.rectY + p.rectH >= n.rectY + n.rectH; +} + +// The traversal-order walk cursor (the M2-UI-01 preorder — parent +// before children, children in insertion order, the draw order): +// the current node + one step to the successor (the first child, +// else the next sibling, else the nearest ancestor with a next +// sibling; the end at the traversal's end). Iterative (no +// recursion — a 65k-deep chain must not overflow the stack), O(1) +// AMORTIZED per step (each parent climb exits one subtree; a full +// traversal climbs each edge at most once). The cursor holds no +// tree state of its own (it re-walks the structural links — the +// culling's allocation-free contract). Note: the Pool API has no +// const element read (pools.h), so the tree is taken non-const +// (the M2-UI-01 house note — a logically const walk). +struct UiPreorderCursor { + UiTree* tree; // non-owning (the cursor outlives nothing) + UiWidgetId cur{}; + + [[nodiscard]] bool end() const noexcept { + return cur == kInvalidUiWidgetId; + } + [[nodiscard]] UiWidgetId id() const noexcept { return cur; } + [[nodiscard]] const UiNode* node() const noexcept { + return tree->node(cur); + } + void step() noexcept { + const UiNode* n = tree->node(cur); + if (n == nullptr) { + cur = kInvalidUiWidgetId; + return; + } + if (tree->isValid(n->firstChild)) { + cur = n->firstChild; + return; + } + if (tree->isValid(n->nextSibling)) { + cur = n->nextSibling; + return; + } + UiWidgetId a = n->parent; + while (tree->isValid(a)) { + const UiNode* pa = tree->node(a); + if (pa == nullptr) break; + if (tree->isValid(pa->nextSibling)) { + cur = pa->nextSibling; + return; + } + a = pa->parent; + } + cur = kInvalidUiWidgetId; + } +}; + +// The occlusion cull (the header's culling section, rule 2): true +// when a LATER node in the traversal (drawn on top) is an opaque +// panel whose rect fully contains the node's. The walk starts at +// the node's successor (one cursor step — one amortized-O(1) climb) +// and stops at the first containing opaque panel (the early stop). +[[nodiscard]] inline bool uiOccluded(UiTree& tree, UiWidgetId id, + const UiNode& n) noexcept { + UiPreorderCursor c{&tree, id}; + c.step(); // past the node itself + while (!c.end()) { + const UiNode* p = c.node(); + if (p != nullptr && p->kind == UiWidgetKind::Panel && p->opaque && + uiRectCovers(*p, n)) { + return true; + } + c.step(); + } + return false; +} + +} // namespace detail + +// --------------------------------------------------------------------------- +// The UI tree -> batched-quads pass (the M2-UI-02 declare half) +// --------------------------------------------------------------------------- + +// Walks the laid-out `tree` in its deterministic preorder (the +// M2-UI-01 draw order, RENDER-003), culls the invisible widgets (the +// header's culling section — offscreen + occluded by a later opaque +// panel), and declares the visible widgets' quads (and the text +// glyphs of the Text widgets and the pass-generated button labels / +// input content) into `batcher` (the header's per-widget model): +// O(n) per frame plus the culling's worst case (the header's +// culling section — menu-scale UIs are negligible), NO ALLOCATION +// (the cursor re-walks the tree's links; the pass-generated +// TextItems are stack values — PERF-003), no logging on the happy +// path (the batcher's G-R11 warn at build is the batcher's own +// cold, rate-limited path, LOG-003/004), no GL (the submit half is +// the M2-SPRITE-02 `SpriteRenderer::submit`). +// +// The batcher must be live and its frame window OPEN (the frame +// protocol, the `declareParticles` / `declareText` precedent): a +// stopped batcher fails the first add with BudgetExhausted, a +// closed window with InvalidArgument. The pass assigns the UI z +// (the header's model section): every node that declares something +// takes the next z in traversal order; its quads / glyphs carry +// `depthKey = z`, `depthOverride = true` (G-R11 — counted + warned +// at build). +// +// Fails (first failure wins, no log — the header's model section): +// a stopped tree, or a `fontScale` outside [1, kTextMaxScale] with a +// default font -> InvalidArgument BEFORE the first add (nothing +// declared); a text handle the table does not own (a Text widget's +// `item.text`, a Button's `label`, an Input's `text` — through +// `declareText`'s validation) or a stopped/missing font (a Text +// widget's mode-selected font — through `declareText`) -> +// InvalidArgument; a failed batcher add (stopped batcher -> +// BudgetExhausted, closed window -> InvalidArgument) -> the +// batcher's error. A failed call leaves the quads already declared +// BY THIS CALL in the frame (the batcher owns the frame state — the +// `declareParticles` precedent). +// +// Call site: once per frame per UI scene, in the RENDER phase (the +// M2-GL-02 cull/batch stage of the UI pass — AFTER the tree's +// `layout()` / `handlePointer()` frame protocol, BEFORE the +// batcher's `build()` and the `SpriteRenderer::submit`), NEVER in +// the simulation tick (ARCH-002). The per-frame UI cost is part of +// the composite 50k render-CPU budget (PRD §8.1 — measured with +// M2-PERF-01; no standalone budgets.json entry for this step). +// +// Misuse warnings: +// - Do not call `declareUi` without `layout()` first: a node added +// or re-placed since the last layout has a ZERO rect and is +// culled as offscreen (the frame protocol calls layout() every +// frame — the M2-UI-01 contract); +// - Do not size the UI batcher for the world pass only: the UI +// pass is its OWN batcher (one batcher per sprite pass, +// M2-SPRITE-01), sized for the frame's UI quads + glyph count; +// - Do not expect sub-pixel text: the font scale is INTEGER (the +// M2-TEXT-01 convention); +// - The UI matrix and the FBO must match the tree's screen rect: +// `uiScreenMatrix(screenWidth, screenHeight)` with a frame +// buffer of the same size (the M2-GOLD-01 reference resolution +// 1280 x 720 when it matters). +// +// Canonical narrative: docs/concepts/coordinates.md §4.14 (ARCH-008); +// API contract: docs/api/ui_render.md. +[[nodiscard]] inline laige::Status declareUi(SpriteBatcher& batcher, + const StringTable& table, + UiTree& tree, + const UiDeclareOptions& options) noexcept { + // The create-level preconditions (first failure wins, InvalidArgument, + // no log, nothing declared — the header's failure section). + if (!tree.valid()) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + if (options.defaultFont != nullptr && + (options.fontScale < 1 || options.fontScale > kTextMaxScale)) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + const UiNode* root = tree.node(tree.root()); + if (root == nullptr) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + // The screen rect (the header's model section): the laid-out root. + const std::int32_t screenW = root->rectW; + const std::int32_t screenH = root->rectH; + const std::int64_t lineStep = + options.defaultFont != nullptr + ? static_cast(options.defaultFont->lineHeight()) * + static_cast(options.fontScale) + : 0; + + // One widget quad over the node's whole rect (the header's model + // section): the centered quad, the node's art fields, the UI pass + // convention (materialId 0, Alpha), the G-R11 UI z. + auto declareQuad = [&](const UiNode& n, + std::uint32_t z) -> laige::Status { + SpriteItem q; + q.pos = Vec2{static_cast(n.rectX + n.rectW / 2), + static_cast(n.rectY + n.rectH / 2)}; + q.scale = Vec2{static_cast(n.rectW), + static_cast(n.rectH)}; + q.rotation = 0.0f; + q.frameIndex = 0; + q.uv = n.image; + q.tint = n.tint; + q.atlasId = n.atlasId; + q.materialId = 0; + q.blend = BlendMode::Alpha; + q.depthKey = z; + // The G-R11 escape hatch: the UI pass's manual z (the + // sprite_batcher.h contract — counted + warned at build). + q.depthOverride = true; + const auto r = batcher.add(std::move(q)); + if (!r.ok()) { + return laige::Status::failure(r.error()); + } + return laige::Status{}; + }; + + // The pass-generated text (the header's per-widget model): the + // button label (centered) and the input content (left). The handle + // validation is declareText's (first failure wins — the header's + // failure section). + auto declarePassText = [&](const UiNode& n, StringRef text, + float x, TextAlignment alignment, + SpriteTint color, + std::uint32_t z) -> laige::Status { + TextItem it; + it.text = text; + it.font = options.defaultFont; // non-null (the caller's branch) + it.mode = FontRenderMode::Bitmap; + it.scale = options.fontScale; + it.alignment = alignment; + it.x = x; + // The baseline, vertically centered (exact int64 — the + // header's per-widget model). + it.y = static_cast(static_cast(n.rectY) + + (static_cast(n.rectH) + lineStep) / + 2); + it.color = color; + it.maxWidth = 0; + it.depthKey = z; + it.atlasId = options.fontAtlasId; + it.materialId = 0; + it.blend = BlendMode::Alpha; + return declareText(batcher, table, it); + }; + + // The walk (the header's model section): the preorder, the cull, + // the per-node declare, the monotone UI z. + std::uint32_t z = 0; + detail::UiPreorderCursor c{&tree, tree.root()}; + while (!c.end()) { + const UiWidgetId id = c.id(); + const UiNode* n = c.node(); + c.step(); + if (n == nullptr || n == root) continue; // the root: layout-only + if (detail::uiRectOffscreen(*n, screenW, screenH)) continue; + if (detail::uiOccluded(tree, id, *n)) continue; + bool declared = false; + laige::Status s{}; + switch (n->kind) { + case UiWidgetKind::Panel: + if (n->opaque) { + s = declareQuad(*n, z); + if (s.ok()) declared = true; + } + break; + case UiWidgetKind::Button: + s = declareQuad(*n, z); + if (!s.ok()) break; + declared = true; + if (options.defaultFont != nullptr) { + s = declarePassText(*n, n->label, + static_cast(n->rectX + n->rectW / 2), + TextAlignment::Center, n->tint, z); + if (!s.ok()) break; + } + break; + case UiWidgetKind::Text: + { + TextItem it = n->item; + // The tree owns the order (the header's per-widget model): + // the pass overwrites the item's hand-typed z. + it.depthKey = z; + s = declareText(batcher, table, it); + if (s.ok()) declared = true; + } + break; + case UiWidgetKind::Image: + s = declareQuad(*n, z); + if (s.ok()) declared = true; + break; + case UiWidgetKind::List: + // NOTHING (the header's per-widget model): the container has + // no M2 background option. + break; + case UiWidgetKind::Slider: + if (n->rectW > 0 && n->rectH > 0) { + s = declareQuad(*n, z); // the track (the whole rect) + if (!s.ok()) break; + declared = true; + // The fill (the header's per-widget model): only 0 < t < 1. + const float t = + (n->sliderValue - n->sliderMin) / (n->sliderMax - n->sliderMin); + if (t > 0.0f && t < 1.0f) { + const float fillW = static_cast(n->rectW) * t; + SpriteItem f; + f.pos = Vec2{static_cast(n->rectX) + fillW * 0.5f, + static_cast(n->rectY + n->rectH / 2)}; + f.scale = Vec2{fillW, static_cast(n->rectH)}; + f.rotation = 0.0f; + f.frameIndex = 0; + f.uv = n->image; + f.tint = n->tint; + f.atlasId = n->atlasId; + f.materialId = 0; + f.blend = BlendMode::Alpha; + f.depthKey = z; + f.depthOverride = true; // G-R11 (the track's quad) + const auto r = batcher.add(std::move(f)); + if (!r.ok()) { + s = laige::Status::failure(r.error()); + break; + } + } + } + break; + case UiWidgetKind::Input: + if (options.defaultFont != nullptr) { + s = declarePassText(*n, n->text, static_cast(n->rectX), + TextAlignment::Left, SpriteTint{}, z); + if (s.ok()) declared = true; + } + break; + } + if (s.ok() && declared) ++z; + if (!s.ok()) return s; // first failure wins (the header's failure) + } + return laige::Status{}; +} + +} // namespace laige::render diff --git a/tests/laige-render/CMakeLists.txt b/tests/laige-render/CMakeLists.txt index b58cb10..a887409 100644 --- a/tests/laige-render/CMakeLists.txt +++ b/tests/laige-render/CMakeLists.txt @@ -202,7 +202,22 @@ # slider drag, the input text event, the panel press, the zero-size # widgets, the event overflow (the drop count + the warn)), and the # 1 000-frame zero-allocation loop (PERF-003)) — pure retained data: -# no GL calls (the rendering lands with M2-UI-02). +# no GL calls (the rendering lands with M2-UI-02); and the UI render +# pass (M2-UI-02) — the screen-space widget quads + text (ui_render_tests.cpp's +# UiRender* suites: the identity screen matrix + its out-of-domain +# fallback, the per-widget declaration goldens against the hand- +# computed layout + glyph quads (the seven ADR 0006 kinds, the group +# count, the in-group traversal order, the G-R11 override count), the +# culling model (the offscreen rule on all four edges + the zero-area +# rule, the later-opaque-panel occlusion rule + its exceptions), the +# slider fill rule (0 < t < 1 only), the failure paths (first failure +# wins, the nothing-declared preconditions, the partial-state-on- +# failure precedent), the 1 000-frame zero-allocation declare loop +# (PERF-003), and the golden image fixture (UiRenderSmoke: the fixed +# UI scene rendered offscreen through the M2-SPRITE-02 submit stage, +# the whole frame against a CPU reference rasterizer within the +# documented +/- 1 byte tolerance, the exact probe pixels) — pure +# data + batcher bookkeeping except the GL smoke. # # One executable per module (tests/README.md): laige-render_tests links # the module under test plus gtest_main. The unfiltered entry runs the @@ -231,9 +246,10 @@ # Verify command (`ctest -R parallax`), the `particle_render` entry # is the M2-PART-02 Verify command (`ctest -R # particle_render`), the `font` entry is the M2-TEXT-01 Verify command -# (`ctest -R font`), and the `ui_tree` entry is the M2-UI-01 Verify -# command (`ctest -R ui_tree`), each selecting exactly its suites from -# the shared executable. (The unanchored `ctest -R tilemap` regex also +# (`ctest -R font`), the `ui_tree` entry is the M2-UI-01 Verify +# command (`ctest -R ui_tree`), and the `ui_render` entry is the +# M2-UI-02 Verify command (`ctest -R ui_render`), each selecting +# exactly its suites from the shared executable. (The unanchored `ctest -R tilemap` regex also # selects the `tilemap_anim` entry — the M2-TILE-02 suites run under # the M2-TILE-01 command too; that is intended. Likewise the # unanchored `ctest -R particle` regex selects the M2-PART-01 @@ -259,7 +275,8 @@ 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_sdf_tests.cpp ui_tree_tests.cpp) + font_tests.cpp text_items_tests.cpp font_sdf_tests.cpp ui_tree_tests.cpp + ui_render_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 @@ -497,6 +514,23 @@ set_tests_properties(font_sdf PROPERTIES # 50k render-CPU budget, M2-PERF-01). add_test(NAME ui_tree COMMAND laige-render_tests --gtest_filter=UiTree*) +# M2-UI-02: the step's Verify command is `ctest -R ui_render`. The +# UiRenderScreenMatrix*/UiRenderDeclare*/UiRenderCull*/UiRenderSlider*/ +# UiRenderFailure*/UiRenderZeroAlloc* suites are pure data + batcher +# bookkeeping (no GL calls) — they run in every local tree and in CI; +# the font-backed ones read the committed test font through +# LAIGE_TEST_FONT_PATH. The UiRenderSmoke suite (the golden image +# fixture) needs the same usable OpenGL 3.3 environment as the +# TextSmoke suite (it GTEST_SKIPs where absent — the documented +# environment contract). No budget gate: the step's roadmap scope has +# no standalone budgets.json entry (the per-frame UI cost is part of +# the composite 50k render-CPU budget, M2-PERF-01). +add_test(NAME ui_render COMMAND laige-render_tests + --gtest_filter=UiRenderScreenMatrix.*:UiRenderDeclare.*:UiRenderCull.*:UiRenderSlider.*:UiRenderFailure.*:UiRenderZeroAlloc.*:UiRenderSmoke.*) +set_tests_properties(ui_render 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) @@ -538,7 +572,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 font_sdf ui_tree PROPERTIES + text_items font_sdf ui_tree ui_render 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() @@ -560,8 +594,9 @@ if(LAIGE_ASAN) # whose suites can create that context instead (gl_context's # GlContextSmoke, render_thread's RenderThreadOffscreen, sprite_draw's # SpriteDraw{State,Smoke,Pipeline}, render_counters's - # RenderCounters{Scene,Cap,Memory}, text_items's TextSmoke, and - # font_sdf's FontSdfSmoke suites create the same headless context): + # RenderCounters{Scene,Cap,Memory}, text_items's TextSmoke, + # font_sdf's FontSdfSmoke, and ui_render's UiRenderSmoke suites + # create the same headless context): # in-run ASan/UBSan error detection stays fully active (the smoke # still performs its GL work under the sanitizer). The font entry is # on the list for the same reason since M2-TEXT-03: its `Font*` filter @@ -576,7 +611,7 @@ if(LAIGE_ASAN) # font_sdf), so the replacement environment carries the test-font # path as well (harmless on the entries that do not use it). set_tests_properties(laige-render_tests gl_context render_thread - sprite_draw render_counters text_items font font_sdf PROPERTIES + sprite_draw render_counters text_items font font_sdf ui_render 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() @@ -638,3 +673,9 @@ set_tests_properties(text_items PROPERTIES TIMEOUT 120) # 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) +# The `ui_render` entry is the same shape as `text_items`: the golden +# image fixture's offscreen GL smoke (a 1024x1024 white-on-alpha glyph- +# atlas upload + a 4x4 UI-atlas upload + one frame + a full 128x128 +# frame readback against the CPU reference) plus the 1 000-frame zero- +# allocation declare loop. +set_tests_properties(ui_render PROPERTIES TIMEOUT 120) diff --git a/tests/laige-render/ui_render_tests.cpp b/tests/laige-render/ui_render_tests.cpp new file mode 100644 index 0000000..ee2b25d --- /dev/null +++ b/tests/laige-render/ui_render_tests.cpp @@ -0,0 +1,1422 @@ +// laige-render UI render pass tests (M2-UI-02): the step's Verify +// command is `ctest -R ui_render`. +// +// The contract pinned against: the UI pass's identity screen matrix +// (uiScreenMatrix: the screen-px y-down -> NDC mapping, the out-of- +// domain fallback), the per-widget declaration model (every ADR 0006 +// kind -> its batched quads + text: the hand-computed pos / scale / +// uv / tint / z goldens against the M2-UI-01 layout + the M2-TEXT-02 +// glyph model, the group count, the in-group traversal order, the +// G-R11 override count), the culling model (the offscreen rule on +// all four edges + the zero-area rule, the occlusion rule: a later +// opaque panel fully covering a node, the partial-overlap / +// non-opaque / own-children exceptions), the slider fill rule +// (0 < t < 1 only), the failure paths (first failure wins, the +// nothing-declared preconditions, the partial-state-on-failure +// precedent), the 1 000-frame zero-allocation declare loop +// (PERF-003), and the golden image fixture (UiRenderSmoke: the fixed +// UI scene rendered offscreen through the M2-SPRITE-02 submit stage, +// the whole frame against a CPU reference rasterizer within the +// documented +/- 1 byte tolerance, the exact probe pixels — the +// M2-GOLD-01 golden contract, tolerance documented in +// docs/api/ui_render.md). +// +// The UiRenderScreenMatrix* / UiRenderDeclare* / UiRenderCull* / +// UiRenderSlider* / UiRenderFailure* / UiRenderZeroAlloc* suites need +// no GL; the font-backed ones read the committed test font +// (tests/laige-render/assets/vera.ttf — the LAIGE_TEST_FONT_PATH env +// var, CTest ENVIRONMENT). The UiRenderSmoke* suite needs a usable +// OpenGL 3.3 environment (it GTEST_SKIPs where absent — the +// documented environment contract, not an engine failure). No budget +// gate: the step's roadmap scope has no standalone budgets.json entry +// (the per-frame UI cost is part of the composite 50k render-CPU +// budget, M2-PERF-01). + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "laige/alloc_watch.h" +#include "laige/errors.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 "laige/render/ui_render.h" +#include "laige/render/ui_widgets.h" +#include "laige/result.h" +#include "laige_test_seed.h" + +namespace { + +using laige::ErrorCode; +using laige::Result; +using laige::Status; +using laige::render::BlendMode; +using laige::render::GlyphAtlas; +using laige::render::GlyphMetrics; +using laige::render::Mat4; +using laige::render::SpriteBatch; +using laige::render::SpriteBatcher; +using laige::render::SpriteDrawStats; +using laige::render::SpriteItem; +using laige::render::SpriteRenderer; +using laige::render::SpriteTint; +using laige::render::SpriteUvRect; +using laige::render::StringRef; +using laige::render::StringTable; +using laige::render::TextAlignment; +using laige::render::TextItem; +using laige::render::UiAnchor; +using laige::render::kInvalidUiWidgetId; +using laige::render::UiDeclareOptions; +using laige::render::UiTree; +using laige::render::UiWidgetId; +using laige::render::UiWidgetKind; +using laige::render::UiWidgetSpec; +using laige::render::Vec2; +using laige::render::declareUi; +using laige::render::expandGlyphAtlasRgba8; +using laige::render::uiScreenMatrix; +using laige::render::kTextMaxScale; + +// --------------------------------------------------------------------------- +// The shared fixtures (the committed test font, the string table, the +// tree / spec helpers — the text_items_tests pattern) +// --------------------------------------------------------------------------- + +std::vector loadFont() { + 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 {}; + } + 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 makeAtlas() { + auto r = GlyphAtlas::create(loadFont(), GlyphAtlas::Options{}); + if (!r.ok()) { + std::fprintf(stderr, "makeAtlas: create failed\n"); + std::abort(); + } + return std::move(r).takeValue(); +} + +StringTable makeTable() { + auto r = StringTable::create(StringTable::Options{16, 64}); + if (!r.ok()) { + std::fprintf(stderr, "makeTable: create failed\n"); + std::abort(); + } + return std::move(r).takeValue(); +} + +StringRef internText(StringTable& table, const char* s) { + std::vector codes; + for (const char* c = s; *c != '\0'; ++c) { + codes.push_back(static_cast( + static_cast(*c))); + } + auto r = table.intern(codes); + if (!r.ok()) { + ADD_FAILURE() << "intern failed: " << laige::errorText(r.error()); + std::abort(); + } + return std::move(r).takeValue(); +} + +UiTree makeTree(std::int32_t width, std::int32_t height, + std::uint32_t maxWidgets = 64) { + auto r = UiTree::create( + UiTree::Options{maxWidgets, 16, width, height}); + if (!r.ok()) { + ADD_FAILURE() << "UiTree::create failed: " << laige::errorText(r.error()); + std::abort(); + } + return std::move(r).takeValue(); +} + +UiWidgetId addWidget(UiTree& tree, UiWidgetSpec spec) { + auto r = tree.addChild(spec); + if (!r.ok()) { + ADD_FAILURE() << "addChild failed: " << laige::errorText(r.error()); + std::abort(); + } + return std::move(r).takeValue(); +} + +// The UI-pass options of the font-backed scenes (the default font, +// its texture id 9, the 1x scale). +UiDeclareOptions fontOptions(const GlyphAtlas& font) { + return UiDeclareOptions{&font, 9, 1}; +} + +// --------------------------------------------------------------------------- +// The declare goldens (the hand-computed expected quads) +// --------------------------------------------------------------------------- + +SpriteItem expectQuad(float px, float py, float w, float h, + SpriteUvRect uv, std::uint32_t z, + std::uint32_t atlasId, + SpriteTint tint = SpriteTint{1.0f, 1.0f, 1.0f, 1.0f}) { + SpriteItem q; + q.pos = Vec2{px, py}; + q.scale = Vec2{w, h}; + q.rotation = 0.0f; + q.frameIndex = 0; + q.uv = uv; + q.tint = tint; + q.atlasId = atlasId; + q.materialId = 0; + q.blend = BlendMode::Alpha; + q.depthKey = z; + q.depthOverride = true; + return q; +} + +bool sameItem(const SpriteItem& a, const SpriteItem& e) { + return a.pos.x == e.pos.x && a.pos.y == e.pos.y && + a.scale.x == e.scale.x && a.scale.y == e.scale.y && + a.rotation == e.rotation && a.frameIndex == e.frameIndex && + a.uv.u0 == e.uv.u0 && a.uv.v0 == e.uv.v0 && a.uv.u1 == e.uv.u1 && + a.uv.v1 == e.uv.v1 && a.tint.r == e.tint.r && + a.tint.g == e.tint.g && a.tint.b == e.tint.b && + a.tint.a == e.tint.a && a.atlasId == e.atlasId && + a.materialId == e.materialId && a.blend == e.blend && + a.depthKey == e.depthKey && a.depthOverride == e.depthOverride; +} + +// The all-seven-kinds scene (screen 200 x 150; the hand-computed +// layout goldens are in the UiRenderDeclare test below): +// +// P opaque Panel (0, 0, 200, 150) uv texel (0,0) atlas 7 +// B Button (20, 20, 40, 20) uv texel (1,0) label "Ma" +// T Text (70, 20, 60, 20) item "Ma" @ (100, 38) center, atlas 9 +// I Image (100, 20, 20, 20) uv texel (2,0) +// S Slider (10, 60, 80, 10) [0, 10] value 5 uv texel (0,1) +// IN Input (100, 60, 60, 16) text "M" +// L List (10, 90, 80, 40) +// L1 Button (child) row (10, 90, 60, 16) uv texel (1,1) label "" +// L2 Button (child) row (10, 106, 60, 16) uv texel (2,1) label "" +// B2 Button (300, 100, 40, 20) OFFSCREEN (culled) +void buildDeclareScene(UiTree& tree, const GlyphAtlas& font, + StringRef ma, StringRef m, StringRef empty) { + UiWidgetSpec s; + s.kind = UiWidgetKind::Panel; + s.opaque = true; + s.x = 0; + s.y = 0; + s.width = 200; + s.height = 150; + s.image = SpriteUvRect{0.0f, 0.0f, 0.25f, 0.25f}; + s.atlasId = 7; + const UiWidgetId p = addWidget(tree, s); + + s = UiWidgetSpec{}; + s.kind = UiWidgetKind::Button; + s.parent = p; + s.x = 20; + s.y = 20; + s.width = 40; + s.height = 20; + s.image = SpriteUvRect{0.25f, 0.0f, 0.5f, 0.25f}; + s.atlasId = 7; + s.label = ma; + addWidget(tree, s); + + s = UiWidgetSpec{}; + s.kind = UiWidgetKind::Text; + s.parent = p; + s.x = 70; + s.y = 20; + s.width = 60; + s.height = 20; + TextItem item; + item.text = ma; + item.font = &font; + item.x = 100.0f; + item.y = 38.0f; + item.alignment = TextAlignment::Center; + item.scale = 1; + item.maxWidth = 0; + item.depthKey = 77; // the game's hand z (the pass overwrites it) + item.atlasId = 9; + s.item = item; + addWidget(tree, s); + + s = UiWidgetSpec{}; + s.kind = UiWidgetKind::Image; + s.parent = p; + s.x = 100; + s.y = 20; + s.width = 20; + s.height = 20; + s.image = SpriteUvRect{0.5f, 0.0f, 0.75f, 0.25f}; + s.atlasId = 7; + addWidget(tree, s); + + s = UiWidgetSpec{}; + s.kind = UiWidgetKind::Slider; + s.parent = p; + s.x = 10; + s.y = 60; + s.width = 80; + s.height = 10; + s.sliderMin = 0.0f; + s.sliderMax = 10.0f; + s.sliderValue = 5.0f; + s.image = SpriteUvRect{0.0f, 0.25f, 0.25f, 0.5f}; + s.atlasId = 7; + addWidget(tree, s); + + s = UiWidgetSpec{}; + s.kind = UiWidgetKind::Input; + s.parent = p; + s.x = 100; + s.y = 60; + s.width = 60; + s.height = 16; + s.text = m; + addWidget(tree, s); + + s = UiWidgetSpec{}; + s.kind = UiWidgetKind::List; + s.parent = p; + s.x = 10; + s.y = 90; + s.width = 80; + s.height = 40; + const UiWidgetId l = addWidget(tree, s); + + s = UiWidgetSpec{}; + s.kind = UiWidgetKind::Button; + s.parent = l; + s.width = 60; + s.height = 16; + s.image = SpriteUvRect{0.25f, 0.25f, 0.5f, 0.5f}; + s.atlasId = 7; + s.label = empty; + addWidget(tree, s); + + s = UiWidgetSpec{}; + s.kind = UiWidgetKind::Button; + s.parent = l; + s.width = 60; + s.height = 16; + s.image = SpriteUvRect{0.5f, 0.25f, 0.75f, 0.5f}; + s.atlasId = 7; + s.label = empty; + addWidget(tree, s); + + s = UiWidgetSpec{}; + s.kind = UiWidgetKind::Button; + s.parent = p; + s.x = 300; + s.y = 100; + s.width = 40; + s.height = 20; + s.image = SpriteUvRect{0.0f, 0.0f, 0.25f, 0.25f}; + s.atlasId = 7; + s.label = empty; + addWidget(tree, s); + +} + +} // namespace + +// --------------------------------------------------------------------------- +// UiRenderScreenMatrix — the identity screen matrix (no GL) +// --------------------------------------------------------------------------- + +TEST(UiRenderScreenMatrix, UiPlaneMapping) { + const Mat4 m = uiScreenMatrix(1280, 720); + // The column-major layout: the linear part in columns 0..1, the + // translation in column 3, everything else the identity. + EXPECT_FLOAT_EQ(2.0f / 1280.0f, m[0].x); + EXPECT_FLOAT_EQ(-2.0f / 720.0f, m[1].y); + EXPECT_FLOAT_EQ(-1.0f, m[3].x); + EXPECT_FLOAT_EQ(1.0f, m[3].y); + EXPECT_FLOAT_EQ(1.0f, m[2].z); + EXPECT_FLOAT_EQ(0.0f, m[0].y); + EXPECT_FLOAT_EQ(0.0f, m[1].x); + EXPECT_FLOAT_EQ(0.0f, m[3].z); + EXPECT_FLOAT_EQ(0.0f, m[2].w); + EXPECT_FLOAT_EQ(1.0f, m[3].w); + // The plane corners / center (x_ndc = 2x/w - 1, y_ndc = 1 - 2y/h; + // screen y down: the top edge (y = 0) maps to NDC +1, the bottom + // edge (y = h) to NDC -1). + EXPECT_FLOAT_EQ(-1.0f, m[0].x * 0.0f + m[3].x); + EXPECT_FLOAT_EQ(1.0f, m[1].y * 0.0f + m[3].y); + EXPECT_FLOAT_EQ(1.0f, m[0].x * 1280.0f + m[3].x); + EXPECT_FLOAT_EQ(-1.0f, m[1].y * 720.0f + m[3].y); + EXPECT_FLOAT_EQ(0.0f, m[0].x * 640.0f + m[3].x); + EXPECT_FLOAT_EQ(0.0f, m[1].y * 360.0f + m[3].y); +} + +TEST(UiRenderScreenMatrix, OutOfDomainIsIdentity) { + // A dimension outside [1, 2^20] is unrepresentable: the identity + // (the total-function contract — the caller passes the tree's + // screen rect, in domain by construction). + const Mat4 m0 = uiScreenMatrix(0, 720); + const Mat4 m1 = uiScreenMatrix(1280, 1 << 21); + const Mat4 id = Mat4(1.0f); + for (int c = 0; c < 4; ++c) { + for (int r = 0; r < 4; ++r) { + EXPECT_FLOAT_EQ(id[c][r], m0[c][r]); + EXPECT_FLOAT_EQ(id[c][r], m1[c][r]); + } + } +} + +// --------------------------------------------------------------------------- +// UiRenderDeclare — the per-widget model (no GL, the committed font) +// --------------------------------------------------------------------------- + +TEST(UiRenderDeclare, AllSevenKindsGolden) { + GlyphAtlas font = makeAtlas(); + const GlyphMetrics& mM = *font.glyph('M'); + const GlyphMetrics& ma = *font.glyph('a'); + // The Vera 16px metric goldens (the committed asset — the + // M2-TEXT-01 pin): the hand-computed quads below assume these. + ASSERT_EQ(12, mM.advance); + ASSERT_EQ(10, mM.width); + ASSERT_EQ(11, mM.height); + ASSERT_EQ(1, mM.bearingX); + ASSERT_EQ(11, mM.bearingY); + ASSERT_EQ(8, ma.advance); + ASSERT_EQ(8, ma.width); + ASSERT_EQ(9, ma.height); + ASSERT_EQ(8, ma.bearingY); + StringTable table = makeTable(); + const StringRef maRef = internText(table, "Ma"); + const StringRef mRef = internText(table, "M"); + const StringRef empty = internText(table, ""); + + UiTree tree = makeTree(200, 150); + buildDeclareScene(tree, font, maRef, mRef, empty); + ASSERT_TRUE(tree.layout().ok()); + + auto bc = SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + batcher.beginFrame(); + ASSERT_TRUE(declareUi(batcher, table, tree, fontOptions(font)).ok()); + ASSERT_TRUE(batcher.build().ok()); + + // The frame: 12 quads — P z0, B-art z1, B-label M/a z1, T M/a z2, + // I z3, S track + fill z4, IN M z5, L1 z6, L2 z7 (L declares + // nothing, B2 is culled offscreen). Two (atlas, material, blend) + // groups: the UI atlas (7, 0, Alpha) 7 instances, the glyph atlas + // (9, 0, Alpha) 5 — in-group traversal order, every quad a G-R11 + // override (the UI pass's manual z). + EXPECT_EQ(12u, batcher.frameCount()); + EXPECT_EQ(2u, batcher.batchCount()); + EXPECT_EQ(12u, batcher.overrideCount()); + const std::span batches = batcher.batches(); + ASSERT_EQ(7u, batches[0].atlasId); + ASSERT_EQ(0u, batches[0].materialId); + ASSERT_EQ(BlendMode::Alpha, batches[0].blend); + ASSERT_EQ(7u, batches[0].instances.size()); + ASSERT_EQ(9u, batches[1].atlasId); + ASSERT_EQ(5u, batches[1].instances.size()); + + // The glyph cells (the runtime atlas positions — the exact-float + // uv of the M2-TEXT-02 model: atlasX/Y and cell extent over the + // 1024 texel atlas). + const float invW = 1.0f / 1024.0f; + const SpriteUvRect uvM{mM.atlasX * invW, mM.atlasY * invW, + static_cast(mM.atlasX + 10u) * invW, + static_cast(mM.atlasY + 11u) * invW}; + const SpriteUvRect uvA{ma.atlasX * invW, ma.atlasY * invW, + static_cast(ma.atlasX + 8u) * invW, + static_cast(ma.atlasY + 9u) * invW}; + + // Group 0 (the UI atlas) in draw order (the hand-computed goldens — + // the header's per-widget model): P, B-art, I, S track, S fill, + // L1, L2. + const std::vector uiExpected = { + expectQuad(100.0f, 75.0f, 200.0f, 150.0f, + SpriteUvRect{0.0f, 0.0f, 0.25f, 0.25f}, 0, 7), + expectQuad(40.0f, 30.0f, 40.0f, 20.0f, + SpriteUvRect{0.25f, 0.0f, 0.5f, 0.25f}, 1, 7), + expectQuad(110.0f, 30.0f, 20.0f, 20.0f, + SpriteUvRect{0.5f, 0.0f, 0.75f, 0.25f}, 3, 7), + expectQuad(50.0f, 65.0f, 80.0f, 10.0f, + SpriteUvRect{0.0f, 0.25f, 0.25f, 0.5f}, 4, 7), + expectQuad(30.0f, 65.0f, 40.0f, 10.0f, + SpriteUvRect{0.0f, 0.25f, 0.25f, 0.5f}, 4, 7), + expectQuad(40.0f, 98.0f, 60.0f, 16.0f, + SpriteUvRect{0.25f, 0.25f, 0.5f, 0.5f}, 6, 7), + expectQuad(40.0f, 114.0f, 60.0f, 16.0f, + SpriteUvRect{0.5f, 0.25f, 0.75f, 0.5f}, 7, 7)}; + for (std::size_t k = 0; k < uiExpected.size(); ++k) { + const SpriteItem& q = batcher.at(batches[0].instances[k]); + EXPECT_TRUE(sameItem(q, uiExpected[k])) + << "UI group instance " << k; + } + + // Group 1 (the glyph atlas) in draw order: the B label (M, a at + // z1 — the centered label on the button's center x = 40, baseline + // 38: the line "Ma" width 20 centers to lineX0 = 30; the M ink + // left 30 + bearingX, top 38 - bearingY; the a starts at the M's + // advance), then the T item's glyphs (z2 — the pass overwrote the + // item's hand z 77; the item is CENTER-aligned on x = 100, baseline + // 38, so its line centers to lineX0 = 90), then the IN content (z5 + // — left-aligned at x = 100, baseline 60 + (16 + 16)/2 = 76). + const float bLineX0 = 30.0f; // 40 - 20/2 (the B label, center x = 40) + const float tLineX0 = 90.0f; // 100 - 20/2 (the T item, center x = 100) + const std::vector glyphExpected = { + expectQuad(bLineX0 + static_cast(mM.bearingX) + 5.0f, + 38.0f - static_cast(mM.bearingY) + 5.5f, 10.0f, 11.0f, + uvM, 1, 9), + expectQuad(bLineX0 + static_cast(mM.advance) + + static_cast(ma.bearingX) + 4.0f, + 38.0f - static_cast(ma.bearingY) + 4.5f, 8.0f, 9.0f, + uvA, 1, 9), + expectQuad(tLineX0 + static_cast(mM.bearingX) + 5.0f, + 38.0f - static_cast(mM.bearingY) + 5.5f, 10.0f, 11.0f, + uvM, 2, 9), + expectQuad(tLineX0 + static_cast(mM.advance) + + static_cast(ma.bearingX) + 4.0f, + 38.0f - static_cast(ma.bearingY) + 4.5f, 8.0f, 9.0f, + uvA, 2, 9), + expectQuad(106.0f, 70.5f, 10.0f, 11.0f, uvM, 5, 9)}; + for (std::size_t k = 0; k < glyphExpected.size(); ++k) { + const SpriteItem& q = batcher.at(batches[1].instances[k]); + EXPECT_TRUE(sameItem(q, glyphExpected[k])) + << "glyph group instance " << k; + } + std::printf( + "ui-render-declare: quads=%llu groups=%llu overrides=%llu\n", + static_cast(batcher.frameCount()), + static_cast(batcher.batchCount()), + static_cast(batcher.overrideCount())); +} + +TEST(UiRenderDeclare, NoDefaultFontDeclaresNoText) { + StringTable table = makeTable(); + const StringRef maRef = internText(table, "Ma"); + UiTree tree = makeTree(200, 150); + UiWidgetSpec s; + s.kind = UiWidgetKind::Panel; + s.opaque = true; + s.width = 200; + s.height = 150; + s.image = SpriteUvRect{0.0f, 0.0f, 0.25f, 0.25f}; + s.atlasId = 7; + const UiWidgetId p = addWidget(tree, s); + s = UiWidgetSpec{}; + s.kind = UiWidgetKind::Button; + s.parent = p; + s.x = 20; + s.y = 20; + s.width = 40; + s.height = 20; + s.image = SpriteUvRect{0.25f, 0.0f, 0.5f, 0.25f}; + s.atlasId = 7; + s.label = maRef; + addWidget(tree, s); + s = UiWidgetSpec{}; + s.kind = UiWidgetKind::Input; + s.parent = p; + s.x = 100; + s.y = 60; + s.width = 60; + s.height = 16; + s.text = maRef; + addWidget(tree, s); + ASSERT_TRUE(tree.layout().ok()); + auto bc = SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + batcher.beginFrame(); + // No default font: the art quads still render, the labels / + // content declare nothing (the header's per-widget model). + ASSERT_TRUE(declareUi(batcher, table, tree, UiDeclareOptions{}).ok()); + ASSERT_TRUE(batcher.build().ok()); + // The panel's quad (opaque) + the button's art quad = 2 (the label + // and the input's content declare nothing without a font). + EXPECT_EQ(2u, batcher.frameCount()); + EXPECT_EQ(1u, batcher.batchCount()); +} + +// --------------------------------------------------------------------------- +// UiRenderCull — the documented simple model (no GL) +// --------------------------------------------------------------------------- + +TEST(UiRenderCull, OffscreenRule) { + StringTable table = makeTable(); + UiTree tree = makeTree(50, 50); + auto addImage = [&](std::int32_t x, std::int32_t y, std::int32_t w, + std::int32_t h) { + UiWidgetSpec s; + s.kind = UiWidgetKind::Image; + s.x = x; + s.y = y; + s.width = w; + s.height = h; + s.image = SpriteUvRect{0.0f, 0.0f, 0.25f, 0.25f}; + s.atlasId = 7; + addWidget(tree, s); + }; + addImage(50, 0, 10, 10); // at the right edge (rectX >= screenW) + addImage(0, 50, 10, 10); // at the bottom edge + addImage(-20, 0, 10, 10); // right edge -10 <= 0 + addImage(0, -20, 10, 10); // bottom edge -10 <= 0 + addImage(0, 0, 0, 10); // zero width + addImage(-5, 0, 10, 10); // partially on screen (z0) + addImage(45, 0, 10, 10); // partially on screen (z1) + ASSERT_TRUE(tree.layout().ok()); + auto bc = SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + batcher.beginFrame(); + ASSERT_TRUE(declareUi(batcher, table, tree, UiDeclareOptions{}).ok()); + ASSERT_TRUE(batcher.build().ok()); + // The half-open edge rule: a widget touching the screen's right / + // bottom edge with a visible column/row is drawn; the rest is + // culled (zero-area or entirely outside). + EXPECT_EQ(2u, batcher.frameCount()); +} + +TEST(UiRenderCull, OcclusionRule) { + StringTable table = makeTable(); + UiTree tree = makeTree(100, 100); + auto addImage = [&](UiWidgetId parent, std::int32_t x, std::int32_t y, + std::int32_t w, std::int32_t h) { + UiWidgetSpec s; + s.kind = UiWidgetKind::Image; + s.parent = parent; + s.x = x; + s.y = y; + s.width = w; + s.height = h; + s.image = SpriteUvRect{0.0f, 0.0f, 0.25f, 0.25f}; + s.atlasId = 7; + return addWidget(tree, s); + }; + auto addPanel = [&](UiWidgetId parent, std::int32_t x, std::int32_t y, + std::int32_t w, std::int32_t h, bool opaque) { + UiWidgetSpec s; + s.kind = UiWidgetKind::Panel; + s.parent = parent; + s.x = x; + s.y = y; + s.width = w; + s.height = h; + s.opaque = opaque; + s.image = SpriteUvRect{0.0f, 0.0f, 0.25f, 0.25f}; + s.atlasId = 7; + return addWidget(tree, s); + }; + // The hand-computed occlusion matrix (the header's culling + // section): I1 is culled by the LATER same-rect opaque P1; I2 by + // nothing (P2 only partially overlaps); I3 by nothing (P3 is EARLIER + // — I3 draws on top); I4 by nothing (a child of the opaque P4 — it + // is LATER, on top of the background); I5 is culled by the later + // same-rect opaque P5 (a later sibling); I6 by nothing (P6 is not + // opaque — it declares nothing, culls nothing). + addImage(kInvalidUiWidgetId, 0, 0, 20, 20); // I1 + addPanel(kInvalidUiWidgetId, 0, 0, 20, 20, true); // P1 (covers I1) + addImage(kInvalidUiWidgetId, 30, 0, 20, 20); // I2 + addPanel(kInvalidUiWidgetId, 30, 0, 10, 20, true); // P2 (partial) + addPanel(kInvalidUiWidgetId, 60, 0, 20, 20, true); // P3 (earlier) + addImage(kInvalidUiWidgetId, 60, 0, 20, 20); // I3 + const UiWidgetId p4 = + addPanel(kInvalidUiWidgetId, 0, 60, 40, 40, true); // P4 + addImage(p4, 5, 5, 10, 10); // I4 (a child) + addImage(p4, 15, 5, 10, 10); // I5 + addPanel(p4, 15, 5, 10, 10, true); // P5 (covers I5) + addImage(kInvalidUiWidgetId, 80, 0, 10, 10); // I6 + addPanel(kInvalidUiWidgetId, 80, 0, 40, 40, false); // P6 (not opaque) + ASSERT_TRUE(tree.layout().ok()); + auto bc = SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + batcher.beginFrame(); + ASSERT_TRUE(declareUi(batcher, table, tree, UiDeclareOptions{}).ok()); + ASSERT_TRUE(batcher.build().ok()); + // 9 quads in traversal / z order: P1 z0, I2 z1, P2 z2, P3 z3, I3 z4, + // P4 z5, I4 z6, P5 z7, I6 z8 (I1 / I5 culled; P6 declares nothing). + EXPECT_EQ(9u, batcher.frameCount()); + EXPECT_EQ(9u, batcher.overrideCount()); + const std::span batches = batcher.batches(); + ASSERT_EQ(1u, batches.size()); + ASSERT_EQ(9u, batches[0].instances.size()); + const std::uint32_t expectedZ[9] = {0, 1, 2, 3, 4, 5, 6, 7, 8}; + for (std::size_t k = 0; k < 9; ++k) { + const SpriteItem& q = batcher.at(batches[0].instances[k]); + EXPECT_EQ(expectedZ[k], q.depthKey) << "instance " << k; + } + // The culled I1 / I5 contribute nothing: the first quad is P1 (the + // (0, 0, 20, 20) rect), the seventh is I4 (the (5, 65, 10, 10) + // child rect). + EXPECT_FLOAT_EQ(10.0f, batcher.at(batches[0].instances[0]).pos.x); + EXPECT_FLOAT_EQ(10.0f, batcher.at(batches[0].instances[6]).pos.x); + EXPECT_FLOAT_EQ(70.0f, batcher.at(batches[0].instances[6]).pos.y); +} + +// --------------------------------------------------------------------------- +// UiRenderSlider — the track + fill rule (no GL) +// --------------------------------------------------------------------------- + +TEST(UiRenderSlider, FillDeclaredOnlyForInteriorValues) { + StringTable table = makeTable(); + UiTree tree = makeTree(100, 100); + auto addSlider = [&](std::int32_t y, float value, std::int32_t w) { + UiWidgetSpec s; + s.kind = UiWidgetKind::Slider; + s.x = 10; + s.y = y; + s.width = w; + s.height = 8; + s.sliderMin = 0.0f; + s.sliderMax = 10.0f; + s.sliderValue = value; + s.image = SpriteUvRect{0.0f, 0.0f, 0.25f, 0.25f}; + s.atlasId = 7; + addWidget(tree, s); + }; + addSlider(10, 0.0f, 40); // t = 0: the track only + addSlider(30, 10.0f, 40); // t = 1: the track only (no double-blend) + addSlider(50, 5.0f, 40); // t = 0.5: the track + the fill + addSlider(70, 5.0f, 0); // zero width: culled (zero area) + ASSERT_TRUE(tree.layout().ok()); + auto bc = SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + batcher.beginFrame(); + ASSERT_TRUE(declareUi(batcher, table, tree, UiDeclareOptions{}).ok()); + ASSERT_TRUE(batcher.build().ok()); + // 4 quads: S0 track z0, S1 track z1, S2 track + fill z2, S3 nothing. + EXPECT_EQ(4u, batcher.frameCount()); + const std::span batches = batcher.batches(); + ASSERT_EQ(1u, batches.size()); + ASSERT_EQ(4u, batches[0].instances.size()); + const SpriteItem& track0 = batcher.at(batches[0].instances[0]); + const SpriteItem& track1 = batcher.at(batches[0].instances[1]); + const SpriteItem& track2 = batcher.at(batches[0].instances[2]); + const SpriteItem& fill2 = batcher.at(batches[0].instances[3]); + EXPECT_FLOAT_EQ(30.0f, track0.pos.x); + EXPECT_FLOAT_EQ(14.0f, track0.pos.y); + EXPECT_FLOAT_EQ(40.0f, track0.scale.x); + EXPECT_FLOAT_EQ(8.0f, track0.scale.y); + EXPECT_FLOAT_EQ(34.0f, track1.pos.y); + EXPECT_FLOAT_EQ(30.0f, track2.pos.x); + EXPECT_FLOAT_EQ(54.0f, track2.pos.y); + // The fill spans [10, 30) x [50, 58) (t = 0.5 of the 40 px track). + EXPECT_FLOAT_EQ(20.0f, fill2.pos.x); + EXPECT_FLOAT_EQ(54.0f, fill2.pos.y); + EXPECT_FLOAT_EQ(20.0f, fill2.scale.x); + EXPECT_FLOAT_EQ(8.0f, fill2.scale.y); + EXPECT_EQ(track2.depthKey, fill2.depthKey); // the node's z (track, fill) + EXPECT_EQ(track0.depthKey, track0.depthKey); // the t = 0 track only + (void)track0; +} + +// --------------------------------------------------------------------------- +// UiRenderFailure — first failure wins (no GL) +// --------------------------------------------------------------------------- + +namespace { + +SpriteBatcher makeBatcher() { + auto bc = SpriteBatcher::create({16}); + if (!bc.ok()) std::abort(); + return std::move(bc).takeValue(); +} + +UiTree onePanelTree() { + UiTree tree = makeTree(100, 100); + UiWidgetSpec s; + s.kind = UiWidgetKind::Panel; + s.opaque = true; + s.width = 100; + s.height = 100; + s.image = SpriteUvRect{0.0f, 0.0f, 0.25f, 0.25f}; + s.atlasId = 7; + addWidget(tree, s); + return tree; +} + +} // namespace + +TEST(UiRenderFailure, PreconditionFailuresDeclareNothing) { + StringTable table = makeTable(); + // A stopped tree: InvalidArgument, nothing declared. + { + UiTree stopped; + SpriteBatcher batcher = makeBatcher(); + batcher.beginFrame(); + auto r = declareUi(batcher, table, stopped, UiDeclareOptions{}); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + EXPECT_EQ(0u, batcher.frameCount()); + } + // The font scale domain (with a default font): 0 and 17 fail before + // the first add. + GlyphAtlas font = makeAtlas(); + for (std::uint32_t scale : {0u, kTextMaxScale + 1u}) { + UiTree tree = onePanelTree(); + ASSERT_TRUE(tree.layout().ok()); + SpriteBatcher batcher = makeBatcher(); + batcher.beginFrame(); + auto r = declareUi(batcher, table, tree, + UiDeclareOptions{&font, 9, scale}); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + EXPECT_EQ(0u, batcher.frameCount()); + } +} + +TEST(UiRenderFailure, TextAndLabelHandleFailures) { + GlyphAtlas font = makeAtlas(); + StringTable table = makeTable(); + internText(table, "M"); // the table owns exactly 1 string + const StringRef invalid{9}; + // A Text widget with a handle the table does not own: the EARLIER + // panel's quad stays in the frame (the batcher owns the frame state), + // the call fails InvalidArgument. + { + UiTree tree = makeTree(100, 100); + UiWidgetSpec s; + s.kind = UiWidgetKind::Panel; + s.opaque = true; + s.width = 100; + s.height = 100; + s.image = SpriteUvRect{0.0f, 0.0f, 0.25f, 0.25f}; + s.atlasId = 7; + addWidget(tree, s); + s = UiWidgetSpec{}; + s.kind = UiWidgetKind::Text; + s.x = 10; + s.y = 10; + s.width = 60; + s.height = 20; + TextItem item; + item.text = invalid; + item.font = &font; + item.atlasId = 9; + s.item = item; + addWidget(tree, s); + ASSERT_TRUE(tree.layout().ok()); + SpriteBatcher batcher = makeBatcher(); + batcher.beginFrame(); + auto r = declareUi(batcher, table, tree, fontOptions(font)); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + EXPECT_EQ(1u, batcher.frameCount()); // the panel's quad remains + } + // A Button's label with an invalid handle: the art quad is declared + // BEFORE the label (the per-widget model), so it remains in the + // frame on failure (the header's failure section). + { + UiTree tree = makeTree(100, 100); + UiWidgetSpec s; + s.kind = UiWidgetKind::Button; + s.x = 10; + s.y = 10; + s.width = 40; + s.height = 20; + s.image = SpriteUvRect{0.0f, 0.0f, 0.25f, 0.25f}; + s.atlasId = 7; + s.label = invalid; + addWidget(tree, s); + ASSERT_TRUE(tree.layout().ok()); + SpriteBatcher batcher = makeBatcher(); + batcher.beginFrame(); + auto r = declareUi(batcher, table, tree, fontOptions(font)); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + EXPECT_EQ(1u, batcher.frameCount()); // the art quad remains + } + // An Input's text with an invalid handle (the first node). + { + UiTree tree = makeTree(100, 100); + UiWidgetSpec s; + s.kind = UiWidgetKind::Input; + s.x = 10; + s.y = 10; + s.width = 60; + s.height = 16; + s.text = invalid; + addWidget(tree, s); + ASSERT_TRUE(tree.layout().ok()); + SpriteBatcher batcher = makeBatcher(); + batcher.beginFrame(); + auto r = declareUi(batcher, table, tree, fontOptions(font)); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + EXPECT_EQ(0u, batcher.frameCount()); + } + // A Text widget with a stopped (moved-from) font: InvalidArgument. + { + GlyphAtlas moved; // the stopped state + UiTree tree = makeTree(100, 100); + UiWidgetSpec s; + s.kind = UiWidgetKind::Text; + s.x = 10; + s.y = 10; + s.width = 60; + s.height = 20; + TextItem item; + item.text = internText(table, "M"); + item.font = &moved; + item.atlasId = 9; + s.item = item; + addWidget(tree, s); + ASSERT_TRUE(tree.layout().ok()); + SpriteBatcher batcher = makeBatcher(); + batcher.beginFrame(); + auto r = declareUi(batcher, table, tree, fontOptions(font)); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + EXPECT_EQ(0u, batcher.frameCount()); + } +} + +TEST(UiRenderFailure, BatcherStateFailures) { + GlyphAtlas font = makeAtlas(); + StringTable table = makeTable(); + UiTree tree = onePanelTree(); + ASSERT_TRUE(tree.layout().ok()); + // A stopped batcher: the first add fails BudgetExhausted. + { + SpriteBatcher stopped; + auto r = declareUi(stopped, table, tree, fontOptions(font)); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::BudgetExhausted, r.error()); + } + // A closed window: build() first, then the first add fails + // InvalidArgument. + { + SpriteBatcher batcher = makeBatcher(); + batcher.beginFrame(); + ASSERT_TRUE(batcher.build().ok()); + auto r = declareUi(batcher, table, tree, fontOptions(font)); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + EXPECT_EQ(0u, batcher.frameCount()); + } +} + +// --------------------------------------------------------------------------- +// UiRenderZeroAlloc — the 1 000-frame declare loop (no GL) +// --------------------------------------------------------------------------- + +TEST(UiRenderZeroAlloc, FrameLoopAllocatesNothing) { + GlyphAtlas font = makeAtlas(); + StringTable table = makeTable(); + const StringRef maRef = internText(table, "Ma"); + const StringRef mRef = internText(table, "M"); + const StringRef empty = internText(table, ""); + UiTree tree = makeTree(200, 150); + buildDeclareScene(tree, font, maRef, mRef, empty); + SpriteBatcher batcher = makeBatcher(); + const UiDeclareOptions options = fontOptions(font); + constexpr std::uint32_t kFrames = 1000; + auto runFrames = [&]() { + for (std::uint32_t f = 0; f < kFrames; ++f) { + if (!tree.layout().ok()) { + std::fprintf(stderr, "UiRenderZeroAlloc: layout failed\n"); + std::abort(); + } + batcher.beginFrame(); + if (!declareUi(batcher, table, tree, options).ok()) { + std::fprintf(stderr, "UiRenderZeroAlloc: declare failed\n"); + std::abort(); + } + if (!batcher.build().ok()) { + std::fprintf(stderr, "UiRenderZeroAlloc: build failed\n"); + std::abort(); + } + } + }; + if (laige::allocWatchLive()) { + laige::allocWatchArm(); + runFrames(); + const laige::AllocWatchReading reading = laige::allocWatchRead(); + EXPECT_EQ(0ull, reading.allocs) + << kFrames << " frames of the seven-kinds declare allocated " + << reading.allocs << " heap blocks on the loop thread"; + std::printf("ui-render-zeroalloc frames=%u allocs=%llu\n", kFrames, + static_cast(reading.allocs)); + } else { + runFrames(); // sanitizer tree: the leak-free run covers it + } +} + +// --------------------------------------------------------------------------- +// UiRenderSmoke — the golden image fixture (the offscreen GL render, +// GTEST_SKIPs where no usable OpenGL 3.3 environment — the documented +// environment contract) +// --------------------------------------------------------------------------- + +namespace { + +constexpr std::int32_t kSmokeWidth = 128; +constexpr std::int32_t kSmokeHeight = 128; + +// The fixed golden scene (screen 128 x 128 — the hand-computed +// layout): +// +// P opaque Panel (10, 10, 40, 30) uv texel (0,0) red atlas 7 +// B Button (16, 16, 20, 14) uv texel (1,0) green label "M" +// T Text (70, 12, 20, 20) "a" @ (80, 20) left atlas 9 +// I Image (80, 40, 16, 16) uv texel (2,0) blue +// S Slider (10, 50, 40, 8) [0, 4] value 2 uv texel (3,0) white +// +// 7 quads: P z0, B-art z1, B-label M z1, T-a z2, I z3, S track + fill +// z4. Two groups: (7, 0, Alpha) 5 instances, (9, 0, Alpha) 2. +void buildSmokeScene(UiTree& tree, const GlyphAtlas& font, StringRef mRef, + StringRef aRef) { + UiWidgetSpec s; + s.kind = UiWidgetKind::Panel; + s.opaque = true; + s.x = 10; + s.y = 10; + s.width = 40; + s.height = 30; + s.image = SpriteUvRect{0.0f, 0.0f, 0.25f, 0.25f}; + s.atlasId = 7; + addWidget(tree, s); + + s = UiWidgetSpec{}; + s.kind = UiWidgetKind::Button; + s.x = 16; + s.y = 16; + s.width = 20; + s.height = 14; + s.image = SpriteUvRect{0.25f, 0.0f, 0.5f, 0.25f}; + s.atlasId = 7; + s.label = mRef; + addWidget(tree, s); + + s = UiWidgetSpec{}; + s.kind = UiWidgetKind::Text; + s.x = 70; + s.y = 12; + s.width = 20; + s.height = 20; + TextItem item; + item.text = aRef; + item.font = &font; + item.x = 80.0f; + item.y = 20.0f; + item.alignment = TextAlignment::Left; + item.scale = 1; + item.maxWidth = 0; + item.atlasId = 9; + s.item = item; + addWidget(tree, s); + + s = UiWidgetSpec{}; + s.kind = UiWidgetKind::Image; + s.x = 80; + s.y = 40; + s.width = 16; + s.height = 16; + s.image = SpriteUvRect{0.5f, 0.0f, 0.75f, 0.25f}; + s.atlasId = 7; + addWidget(tree, s); + + s = UiWidgetSpec{}; + s.kind = UiWidgetKind::Slider; + s.x = 10; + s.y = 50; + s.width = 40; + s.height = 8; + s.sliderMin = 0.0f; + s.sliderMax = 4.0f; + s.sliderValue = 2.0f; + s.image = SpriteUvRect{0.75f, 0.0f, 1.0f, 0.25f}; + s.atlasId = 7; + addWidget(tree, s); +} + +// The 4x4 UI atlas (solid colors, all opaque — the golden's known +// texture: row-major RGBA8, 256 bytes). +const std::array& uiAtlasRgba() { + // The 4x4 UI atlas (16 texels x 4 channels = 64 bytes), row-major + // RGBA8. Row 0: red, green, blue, white. Row 1: yellow, cyan, + // magenta, gray. Row 2: dark slate (x4). Row 3: dark gray (x4). + // All texels are opaque (a = 255). + static const std::array rgba = { + 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, + 255, 255, 255, 255, 255, 255, 0, 255, 0, 255, 255, 255, + 255, 0, 255, 255, 128, 128, 128, 255, + 10, 20, 30, 255, 10, 20, 30, 255, 10, 20, 30, 255, + 10, 20, 30, 255, 64, 64, 64, 255, 64, 64, 64, 255, + 64, 64, 64, 255, 64, 64, 64, 255}; + return rgba; +} + +// --------------------------------------------------------------------------- +// The CPU reference rasterizer (the golden's oracle): the UI quads are +// UNROTATED axis-aligned rects — the pixel-center screen test, the +// exact UV recovery, the texture fetch (GL_NEAREST, no-mipmap, +// CLAMP_TO_EDGE: the floor of the uv in texel space), the tint, and +// the per-group blend in double (the GPU's float32 differs by <= 1 +// byte after the RGBA8 conversion — the documented +/- 1 tolerance; +// the probe pixels are asserted exactly). +// --------------------------------------------------------------------------- + +struct RefUiQuad { + double left{0.0}; + double top{0.0}; + double right{0.0}; + double bottom{0.0}; + double u0{0.0}; + double v0{0.0}; + double u1{0.0}; + double v1{0.0}; + double tr{1.0}; + double tg{1.0}; + double tb{1.0}; + double ta{1.0}; + std::uint32_t atlasId{0}; + BlendMode blend{BlendMode::Alpha}; +}; + +RefUiQuad makeRefUiQuad(const SpriteItem& it) { + RefUiQuad q; + q.left = static_cast(it.pos.x) - static_cast(it.scale.x) * 0.5; + q.top = static_cast(it.pos.y) - static_cast(it.scale.y) * 0.5; + q.right = static_cast(it.pos.x) + static_cast(it.scale.x) * 0.5; + q.bottom = static_cast(it.pos.y) + static_cast(it.scale.y) * 0.5; + q.u0 = it.uv.u0; + q.v0 = it.uv.v0; + q.u1 = it.uv.u1; + q.v1 = it.uv.v1; + q.tr = it.tint.r; + q.tg = it.tint.g; + q.tb = it.tint.b; + q.ta = it.tint.a; + q.atlasId = it.atlasId; + q.blend = it.blend; + return q; +} + +bool refUiInside(const RefUiQuad& q, double px, double py) { + // Strict interior (the pixel centers of this scene never land on a + // quad edge — the rect edges are integer screen px, the pixel + // centers are +0.5: the boundary is excluded by construction). + return px > q.left && px < q.right && py > q.top && py < q.bottom; +} + +std::array goldenAtlasTexel(std::uint32_t atlasId, + std::uint32_t tx, std::uint32_t ty, + const std::span& fontRgba) { + if (atlasId == 9) { + constexpr std::uint32_t W = 1024; + tx = std::min(tx, W - 1); + ty = std::min(ty, W - 1); + const std::uint8_t* p = + fontRgba.data() + static_cast(ty * W + tx) * 4; + return {p[0], p[1], p[2], p[3]}; + } + constexpr std::uint32_t W = 4; + tx = std::min(tx, W - 1); + ty = std::min(ty, W - 1); + const std::uint8_t* p = uiAtlasRgba().data() + + static_cast(ty * W + tx) * 4; + return {p[0], p[1], p[2], p[3]}; +} + +std::array refUiPixel(std::int32_t px, std::int32_t py, + const std::vector& quads, + const std::span& fontRgba) { + // The clear (0, 0, 0, 1) in float, accumulated through the draw + // order's blends (the shader's exact math, per the per-group + // BlendMode contract). + double r = 0.0, g = 0.0, b = 0.0, a = 1.0; + const double cx = static_cast(px) + 0.5; + const double cy = static_cast(py) + 0.5; + for (const RefUiQuad& q : quads) { + if (!refUiInside(q, cx, cy)) continue; + const double u = + q.u0 + (cx - q.left) / (q.right - q.left) * (q.u1 - q.u0); + const double v = + q.v0 + (cy - q.top) / (q.bottom - q.top) * (q.v1 - q.v0); + const std::uint32_t W = (q.atlasId == 9) ? 1024u : 4u; + const std::uint32_t tx = static_cast( + std::max(0.0, std::min(static_cast(W - 1), + std::floor(u * static_cast(W))))); + const std::uint32_t ty = static_cast( + std::max(0.0, std::min(static_cast(W - 1), + std::floor(v * static_cast(W))))); + const std::array t = + goldenAtlasTexel(q.atlasId, tx, ty, fontRgba); + const double srcR = static_cast(t[0]) / 255.0 * q.tr; + const double srcG = static_cast(t[1]) / 255.0 * q.tg; + const double srcB = static_cast(t[2]) / 255.0 * q.tb; + const double srcA = static_cast(t[3]) / 255.0 * q.ta; + if (q.blend == BlendMode::Alpha) { + // The engine's SRC_ALPHA / ONE_MINUS_SRC_ALPHA blend on ALL + // four channels (the M2-SPRITE-02 contract). + r = srcA * srcR + (1.0 - srcA) * r; + g = srcA * srcG + (1.0 - srcA) * g; + b = srcA * srcB + (1.0 - srcA) * b; + a = srcA * srcA + (1.0 - srcA) * a; + } else { + r = srcR + r; + g = srcG + g; + b = srcB + b; + a = srcA + a; + } + r = std::min(1.0, std::max(0.0, r)); + g = std::min(1.0, std::max(0.0, g)); + b = std::min(1.0, std::max(0.0, b)); + a = std::min(1.0, std::max(0.0, a)); + } + return {static_cast(std::min(255.0, r * 255.0 + 0.5)), + static_cast(std::min(255.0, g * 255.0 + 0.5)), + static_cast(std::min(255.0, b * 255.0 + 0.5)), + static_cast(std::min(255.0, a * 255.0 + 0.5))}; +} + +// The frame's quads in the exact draw order (the batcher's published +// group order, the in-group order — the submit stage's draw order). +std::vector smokeQuads(SpriteBatcher& batcher) { + std::vector quads; + quads.reserve(batcher.frameCount()); + for (const SpriteBatch& b : batcher.batches()) { + for (const std::uint32_t slot : b.instances) { + quads.push_back(makeRefUiQuad(batcher.at(slot))); + } + } + return quads; +} + +} // namespace + +TEST(UiRenderSmoke, GoldenUiScene) { + 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(); + GlyphAtlas font = makeAtlas(); + const GlyphMetrics& ma = *font.glyph('a'); + ASSERT_EQ(8, ma.advance); + ASSERT_EQ(8, ma.width); + ASSERT_EQ(9, ma.height); + ASSERT_EQ(8, ma.bearingY); + StringTable table = makeTable(); + const StringRef mRef = internText(table, "M"); + const StringRef aRef = internText(table, "a"); + // The setup uploads (one call per atlas per scene load — RENDER-004). + const auto& uiArr = uiAtlasRgba(); + const std::span uiRgba(uiArr.data(), uiArr.size()); + std::vector fontRgba; + fontRgba.resize(1024u * 1024u * 4u); + ASSERT_TRUE(expandGlyphAtlasRgba8(font, fontRgba).ok()); + // maxInstances = the batcher's capacity (16); maxAtlases = 16 so the + // UI atlas (id 7) and the glyph atlas (id 9) are both in domain + // ([0, maxAtlases)). + auto rc = laige::render::SpriteRenderer::create( + gl, SpriteRenderer::Options{16, 16}); + ASSERT_TRUE(rc.ok()); + laige::render::SpriteRenderer renderer = std::move(rc).takeValue(); + ASSERT_TRUE(renderer.bindAtlas(7, 4, 4, uiRgba).ok()); + ASSERT_TRUE( + renderer.bindAtlas(9, 1024, 1024, + std::span(fontRgba)).ok()); + UiTree tree = makeTree(kSmokeWidth, kSmokeHeight); + buildSmokeScene(tree, font, mRef, aRef); + ASSERT_TRUE(tree.layout().ok()); + auto bc = SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + batcher.beginFrame(); + ASSERT_TRUE(declareUi(batcher, table, tree, fontOptions(font)).ok()); + ASSERT_TRUE(batcher.build().ok()); + // The golden frame: 7 quads, 2 (atlas, material, blend) groups — + // the UI atlas + the glyph atlas, each one texture (one texture per + // atlas, the M2-UI-02 scope). + EXPECT_EQ(7u, batcher.frameCount()); + EXPECT_EQ(2u, batcher.batchCount()); + // The clear BEFORE the first draw (the offscreen frame protocol). + ASSERT_TRUE(gl.clear(0.0f, 0.0f, 0.0f, 1.0f).ok()); + ASSERT_TRUE(renderer.submit(batcher, uiScreenMatrix(kSmokeWidth, + kSmokeHeight)) + .ok()); + const SpriteDrawStats stats = renderer.frameStats(); + EXPECT_EQ(2u, stats.drawCalls); + EXPECT_EQ(7u, stats.instances); + EXPECT_EQ(7u * 52u, stats.uploadBytes); + // The whole 128x128 frame against the CPU reference (the GPU's + // float32 vs the reference double after the RGBA8 conversion — the + // documented +/- 1 byte tolerance; the probe pixels below are + // rounding-exact and asserted exactly). + const std::vector quads = smokeQuads(batcher); + 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]}; + }; + std::uint64_t mismatches = 0; + for (std::int32_t py = 0; py < kSmokeHeight; ++py) { + for (std::int32_t px = 0; px < kSmokeWidth; ++px) { + const std::array ref = + refUiPixel(px, py, quads, + std::span(fontRgba)); + const std::array p = readScreen(px, py); + for (std::size_t c = 0; c < 4; ++c) { + if (std::abs(static_cast(p[c]) - static_cast(ref[c])) > 1) { + ++mismatches; + break; + } + } + } + } + EXPECT_EQ(0ull, mismatches) << "pixels off by more than the documented " + "+/- 1 tolerance"; + // The exact probes (the rounding-exact pixels — the reference- + // independent anchors of the golden): + // The opaque solid texels over the opaque clear: src.a = 1 -> the + // texel's color, exactly (the GL 8-bit conversion of the exact + // blend result). + { + const std::array p = readScreen(20, 20); // the P red + EXPECT_EQ(255u, p[0]); + EXPECT_EQ(0u, p[1]); + EXPECT_EQ(0u, p[2]); + EXPECT_EQ(255u, p[3]); + } + { + const std::array p = readScreen(26, 23); // the B green + EXPECT_EQ(0u, p[0]); + EXPECT_EQ(255u, p[1]); + EXPECT_EQ(0u, p[2]); + EXPECT_EQ(255u, p[3]); + } + { + const std::array p = readScreen(88, 48); // the I blue + EXPECT_EQ(0u, p[0]); + EXPECT_EQ(0u, p[1]); + EXPECT_EQ(255u, p[2]); + EXPECT_EQ(255u, p[3]); + } + // The slider fill over the track (the two identical opaque white + // quads: the second blend is a no-op on the alpha-1 source): white. + { + const std::array p = readScreen(20, 54); + EXPECT_EQ(255u, p[0]); + EXPECT_EQ(255u, p[1]); + EXPECT_EQ(255u, p[2]); + EXPECT_EQ(255u, p[3]); + } + // The background (outside every quad): the clear. + { + const std::array p = readScreen(120, 120); + EXPECT_EQ(0u, p[0]); + EXPECT_EQ(0u, p[1]); + EXPECT_EQ(0u, p[2]); + EXPECT_EQ(255u, p[3]); + } + // The 'a' glyph ink (the white-on-alpha upload over the black clear): + // an opaque texel is exactly (255, 255, 255, 255) — the UV mapping + // pinned (that exact texel lands on that exact pixel); the + // semi-transparent edge texel is rgb = A exactly and the alpha + // channel within +/- 1 of the engine's four-channel blend (the + // driver-defined 8-bit rounding). + const std::span alpha = font.atlas(); + const int inkAL = 80 + ma.bearingX; // the a ink left (80 + 0) + const int inkAT = 20 - ma.bearingY; // the a ink top (20 - 8) + // The first texel of the 'a' cell in an alpha class ({tx, ty, a}; + // tx = -1 when the class is empty): 0 = transparent, 1 = semi, + // 2 = opaque. + auto firstTexel = [&](int cls) { + for (int ty = 0; ty < ma.height; ++ty) { + for (int tx = 0; tx < ma.width; ++tx) { + const std::uint8_t a = + alpha[(ma.atlasY + static_cast(ty)) * 1024u + + ma.atlasX + static_cast(tx)]; + const bool hit = cls == 0 + ? a == 0u + : (cls == 1 + ? (a > 0 && a < 255u) + : a == 255u); + if (hit) { + return std::array{tx, ty, static_cast(a)}; + } + } + } + return std::array{-1, 0, 0}; + }; + { + const std::array o = firstTexel(2); + ASSERT_EQ(255, o[2]); + const std::array p = + readScreen(inkAL + o[0], inkAT + o[1]); + EXPECT_EQ(255u, p[0]); + EXPECT_EQ(255u, p[1]); + EXPECT_EQ(255u, p[2]); + EXPECT_EQ(255u, p[3]); + } + { + const std::array s = firstTexel(1); + ASSERT_NE(-1, s[0]); + const std::uint8_t A = static_cast(s[2]); + const std::array p = + readScreen(inkAL + s[0], inkAT + s[1]); + EXPECT_EQ(A, p[0]); + EXPECT_EQ(A, p[1]); + EXPECT_EQ(A, p[2]); + const double a = static_cast(A) / 255.0; + const int expect = static_cast(255.0 * (a * a + (1.0 - a)) + 0.5); + EXPECT_LE(static_cast(p[3]) - expect + 1, 1); + EXPECT_LE(expect - static_cast(p[3]) + 1, 1); + } + std::printf( + "ui-render-golden: frame=%dx%d quads=%llu groups=%llu draw_calls=%llu " + "instances=%llu mismatches=%llu\n", + kSmokeWidth, kSmokeHeight, + static_cast(batcher.frameCount()), + static_cast(batcher.batchCount()), + static_cast(stats.drawCalls), + static_cast(stats.instances), + static_cast(mismatches)); + // The context outlives the renderer (the ownership contract). +} From 4ece94696e04959489f207b1badcd899eb3aa717 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 11 Oct 2026 19:15:02 +0200 Subject: [PATCH 2/3] Document the UI z edge case: an empty-content Text widget consumes its z The header's model comment said a node that declares nothing takes no z; a Text widget with empty content is the exception (its declareText succeeds, with no quads, and it consumes the z). Match the header comment to the shipped behavior (docs/api/ui_render.md already stated it). --- src/laige-render/include/laige/render/ui_render.h | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/laige-render/include/laige/render/ui_render.h b/src/laige-render/include/laige/render/ui_render.h index c34a6b0..ee20cc6 100644 --- a/src/laige-render/include/laige/render/ui_render.h +++ b/src/laige-render/include/laige/render/ui_render.h @@ -47,9 +47,11 @@ // The UI pass's ORDER is the tree's traversal order (preorder, // children in insertion order — the M2-UI-01 draw-order contract, // RENDER-003): the pass hands the batcher a MONOTONE per-node UI z — -// the index of the node among the nodes that declare something, in -// traversal order (a node that declares nothing, culled or not, takes -// no z). Every declared quad carries `depthKey = the node's z` and +// the index of the node among the nodes that declare, in traversal +// order (a culled node, a list, a non-opaque panel, and an input +// without a default font take no z — a Text widget with empty +// content still takes one: its declareText succeeds, with no quads). +// Every declared quad carries `depthKey = the node's z` and // `depthOverride = true` (the G-R11 escape hatch: the UI pass is // screen-space, there is no isoDepthKey in this pass — the manual UI // z is the documented order value, counted + warned at build by the From fbf6b075c01d003035d0a44d4abc0aaacea41d9a Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 11 Oct 2026 19:37:59 +0200 Subject: [PATCH 3/3] Fix the UiPlaneMapping center checks for FMA-contracted builds; refresh manifest line numbers CI run 38158862667 (macOS Intel + arm64, AppleClang) failed UiRenderScreenMatrix.UiPlaneMapping: the center composition m[0].x * 640.0f + m[3].x evaluates to 1.4901161e-08 instead of 0.0f. AppleClang contracts the mul+add to an FMA (its default -ffp-contract=fast on Apple targets), which rounds the exact residual (exact a*640 - 1, a = float(2/1280)) in a single step; the non-contracted path (separate mul+add, e.g. GCC without FMA, MSVC) rounds a*640 to exactly 1.0f first and subtracts to exactly 0.0f. Both are within one ULP of 1.0 of the exact value 0, so assert the center mappings with the house absolute tolerance (kEps = 1e-6f, the matrices_tests pattern) instead of ULP equality against zero, which has no tolerance. The corner checks (against +-1.0f) keep EXPECT_FLOAT_EQ: both evaluation paths yield exactly +-1.0f for the pinned 1280x720 dimensions, and UlpEq's 4-ULP tolerance covers the 1.0 scale. Verified: the FMA value reproduces locally with g++ -O2 -mfma on a runtime matrix (vx = 1.4901161194e-08, vy = -2.4214386940e-08, both within kEps); all six canonical trees build warning-free with the eleven GL-free ui_render suites green. Also: laige-api.json regenerated (LAST) for the header comment change in the previous commit, which shifted the ui_render.h symbol line numbers (+2) by twelve manifest fields. --- laige-api.json | 12 ++++++------ tests/laige-render/ui_render_tests.cpp | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/laige-api.json b/laige-api.json index d6ce821..d017034 100644 --- a/laige-api.json +++ b/laige-api.json @@ -1098,12 +1098,12 @@ {"name": "laige::render::TileMap::AnimSlot::tick", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 607, "signature": "std::uint32_t tick{0}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::TileMap::AnimSlot::valid", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 608, "signature": "bool valid{false}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::TileMap::AnimSlot::SpriteUvRect", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 610, "signature": "std::unique_ptr frameUv", "summary": "The precomputed frame UVs (frameCount rects — setAnimation).", "budget": null, "experimental": false}, - {"name": "laige::render::UiDeclareOptions", "kind": "struct", "header": "src/laige-render/include/laige/render/ui_render.h", "line": 207, "signature": "struct UiDeclareOptions", "summary": "The UI pass's scene configuration (API-006): the DEFAULT UI FONT (non-owning — the scene's GlyphAtlas, the M2-TEXT-01/03 atlas; it must outlive every frame that declares through it, the TextItem.font lifetime contract), its texture id (the scene's `bindAtlas` id for that atlas — the pass-generated text's `atlasId`), and its integer scale (the M2-TEXT-01 convention, domain [1, kTextMaxScale] — validated at `declareUi`). A NULL `defaultFont` turns off the pass-generated text: button labels and input content declare nothing (the art / art-less widgets still render — the scene composes a Text widget for its labels when it wants them without a default font).", "budget": null, "experimental": false}, - {"name": "laige::render::UiDeclareOptions::defaultFont", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_render.h", "line": 209, "signature": "const GlyphAtlas* defaultFont{}", "summary": "The default UI font (non-owning). null = no pass-generated text.", "budget": null, "experimental": false}, - {"name": "laige::render::UiDeclareOptions::fontAtlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_render.h", "line": 212, "signature": "std::uint32_t fontAtlasId{0}", "summary": "The default font's texture id (the scene's `bindAtlas` id — read when `defaultFont != nullptr`).", "budget": null, "experimental": false}, - {"name": "laige::render::UiDeclareOptions::fontScale", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_render.h", "line": 216, "signature": "std::uint32_t fontScale{1}", "summary": "The integer scale of the pass-generated text. Domain [1, kTextMaxScale]; 0 or > kTextMaxScale with a font fails `declareUi` (InvalidArgument, nothing declared).", "budget": null, "experimental": false}, - {"name": "laige::render::uiScreenMatrix", "kind": "function", "header": "src/laige-render/include/laige/render/ui_render.h", "line": 237, "signature": "[[nodiscard]] inline Mat4 uiScreenMatrix(std::int32_t width, std::int32_t height) noexcept", "summary": "The UI pass's screen matrix: maps screen px (y DOWN) to NDC, x_ndc = 2·x/width − 1, y_ndc = 1 − 2·y/height — the submit stage's `uWorldToNdc` for the UI pass (the identity CAMERA of FR-2.8: the UI renders screen-space, nothing world-space leaks in, RENDER-006). The frame buffer / FBO the matrix addresses MUST be the same width/height (the tree's screen rect — `UiTree::Options`, the M2-GOLD-01 reference resolution when it matters).", "budget": null, "experimental": false}, - {"name": "laige::render::declareUi", "kind": "function", "header": "src/laige-render/include/laige/render/ui_render.h", "line": 415, "signature": "[[nodiscard]] inline laige::Status declareUi(SpriteBatcher& batcher, const StringTable& table, UiTree& tree, const UiDeclareOptions& options) noexcept", "summary": "Walks the laid-out `tree` in its deterministic preorder (the M2-UI-01 draw order, RENDER-003), culls the invisible widgets (the header's culling section — offscreen + occluded by a later opaque panel), and declares the visible widgets' quads (and the text glyphs of the Text widgets and the pass-generated button labels / input content) into `batcher` (the header's per-widget model): O(n) per frame plus the culling's worst case (the header's culling section — menu-scale UIs are negligible), NO ALLOCATION (the cursor re-walks the tree's links; the pass-generated TextItems are stack values — PERF-003), no logging on the happy path (the batcher's G-R11 warn at build is the batcher's own cold, rate-limited path, LOG-003/004), no GL (the submit half is the M2-SPRITE-02 `SpriteRenderer::submit`).", "budget": null, "experimental": false}, + {"name": "laige::render::UiDeclareOptions", "kind": "struct", "header": "src/laige-render/include/laige/render/ui_render.h", "line": 209, "signature": "struct UiDeclareOptions", "summary": "The UI pass's scene configuration (API-006): the DEFAULT UI FONT (non-owning — the scene's GlyphAtlas, the M2-TEXT-01/03 atlas; it must outlive every frame that declares through it, the TextItem.font lifetime contract), its texture id (the scene's `bindAtlas` id for that atlas — the pass-generated text's `atlasId`), and its integer scale (the M2-TEXT-01 convention, domain [1, kTextMaxScale] — validated at `declareUi`). A NULL `defaultFont` turns off the pass-generated text: button labels and input content declare nothing (the art / art-less widgets still render — the scene composes a Text widget for its labels when it wants them without a default font).", "budget": null, "experimental": false}, + {"name": "laige::render::UiDeclareOptions::defaultFont", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_render.h", "line": 211, "signature": "const GlyphAtlas* defaultFont{}", "summary": "The default UI font (non-owning). null = no pass-generated text.", "budget": null, "experimental": false}, + {"name": "laige::render::UiDeclareOptions::fontAtlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_render.h", "line": 214, "signature": "std::uint32_t fontAtlasId{0}", "summary": "The default font's texture id (the scene's `bindAtlas` id — read when `defaultFont != nullptr`).", "budget": null, "experimental": false}, + {"name": "laige::render::UiDeclareOptions::fontScale", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_render.h", "line": 218, "signature": "std::uint32_t fontScale{1}", "summary": "The integer scale of the pass-generated text. Domain [1, kTextMaxScale]; 0 or > kTextMaxScale with a font fails `declareUi` (InvalidArgument, nothing declared).", "budget": null, "experimental": false}, + {"name": "laige::render::uiScreenMatrix", "kind": "function", "header": "src/laige-render/include/laige/render/ui_render.h", "line": 239, "signature": "[[nodiscard]] inline Mat4 uiScreenMatrix(std::int32_t width, std::int32_t height) noexcept", "summary": "The UI pass's screen matrix: maps screen px (y DOWN) to NDC, x_ndc = 2·x/width − 1, y_ndc = 1 − 2·y/height — the submit stage's `uWorldToNdc` for the UI pass (the identity CAMERA of FR-2.8: the UI renders screen-space, nothing world-space leaks in, RENDER-006). The frame buffer / FBO the matrix addresses MUST be the same width/height (the tree's screen rect — `UiTree::Options`, the M2-GOLD-01 reference resolution when it matters).", "budget": null, "experimental": false}, + {"name": "laige::render::declareUi", "kind": "function", "header": "src/laige-render/include/laige/render/ui_render.h", "line": 417, "signature": "[[nodiscard]] inline laige::Status declareUi(SpriteBatcher& batcher, const StringTable& table, UiTree& tree, const UiDeclareOptions& options) noexcept", "summary": "Walks the laid-out `tree` in its deterministic preorder (the M2-UI-01 draw order, RENDER-003), culls the invisible widgets (the header's culling section — offscreen + occluded by a later opaque panel), and declares the visible widgets' quads (and the text glyphs of the Text widgets and the pass-generated button labels / input content) into `batcher` (the header's per-widget model): O(n) per frame plus the culling's worst case (the header's culling section — menu-scale UIs are negligible), NO ALLOCATION (the cursor re-walks the tree's links; the pass-generated TextItems are stack values — PERF-003), no logging on the happy path (the batcher's G-R11 warn at build is the batcher's own cold, rate-limited path, LOG-003/004), no GL (the submit half is the M2-SPRITE-02 `SpriteRenderer::submit`).", "budget": null, "experimental": false}, {"name": "laige::render::kUiWidgetKindCount", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 349, "signature": "inline constexpr std::uint32_t kUiWidgetKindCount = 7", "summary": "The seven ADR 0006 widget kinds, in the ADR's canonical order (the M2-DEC-02 Verify clause: the implemented set is exactly this).", "budget": null, "experimental": false}, {"name": "laige::render::kUiMaxDimPx", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 355, "signature": "inline constexpr std::uint32_t kUiMaxDimPx = 1u << 20", "summary": "The screen-pixel dimension / offset domain: 2^20 px (a 20-bit screen dimension — far beyond any UI, and < 2^24 so every coordinate inside the domain is exact in float, the M2-SPRITE-03 float-exact domain).", "budget": null, "experimental": false}, {"name": "laige::render::kUiMaxCoordPx", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 361, "signature": "inline constexpr std::int32_t kUiMaxCoordPx = (1 << 21) - 1", "summary": "The computed-rect coordinate saturation bound (2^21 − 1): a node pushed that far offscreen renders nothing (the M2-UI-02 cull); the saturation keeps the int32 storage exact (the header's layout domain section).", "budget": null, "experimental": false}, diff --git a/tests/laige-render/ui_render_tests.cpp b/tests/laige-render/ui_render_tests.cpp index ee2b25d..cc7eee9 100644 --- a/tests/laige-render/ui_render_tests.cpp +++ b/tests/laige-render/ui_render_tests.cpp @@ -89,6 +89,13 @@ using laige::render::expandGlyphAtlasRgba8; using laige::render::uiScreenMatrix; using laige::render::kTextMaxScale; +// The house absolute float tolerance (the matrices_tests pattern): +// an `a * c + b` composition may be contracted to an FMA +// (AppleClang's default -ffp-contract=fast), which changes the +// rounding of the last step by at most one ULP of the result's +// scale — ULP-equality against zero has no such tolerance. +constexpr float kEps = 1e-6f; + // --------------------------------------------------------------------------- // The shared fixtures (the committed test font, the string table, the // tree / spec helpers — the text_items_tests pattern) @@ -383,8 +390,11 @@ TEST(UiRenderScreenMatrix, UiPlaneMapping) { EXPECT_FLOAT_EQ(1.0f, m[1].y * 0.0f + m[3].y); EXPECT_FLOAT_EQ(1.0f, m[0].x * 1280.0f + m[3].x); EXPECT_FLOAT_EQ(-1.0f, m[1].y * 720.0f + m[3].y); - EXPECT_FLOAT_EQ(0.0f, m[0].x * 640.0f + m[3].x); - EXPECT_FLOAT_EQ(0.0f, m[1].y * 360.0f + m[3].y); + // The exact center mapping is 0; the composition's last step + // (mul+add, possibly an FMA) carries at most a 1e-8-scale + // residual — the absolute tolerance, not ULP equality at zero. + EXPECT_NEAR(0.0f, m[0].x * 640.0f + m[3].x, kEps); + EXPECT_NEAR(0.0f, m[1].y * 360.0f + m[3].y, kEps); } TEST(UiRenderScreenMatrix, OutOfDomainIsIdentity) {