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..d017034 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": 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/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..ee20cc6 --- /dev/null +++ b/src/laige-render/include/laige/render/ui_render.h @@ -0,0 +1,594 @@ +// 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, 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 +// 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..cc7eee9 --- /dev/null +++ b/tests/laige-render/ui_render_tests.cpp @@ -0,0 +1,1432 @@ +// 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 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) +// --------------------------------------------------------------------------- + +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); + // 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) { + // 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). +}