Skip to content

[M2-UI-02] UI rendering pass (screen-space, identity camera) - #91

Merged
offdev merged 3 commits into
masterfrom
feat/m2-ui-02-ui-render-pass
Oct 11, 2026
Merged

offdev merged 3 commits into
masterfrom
feat/m2-ui-02-ui-render-pass

Conversation

@offdev

@offdev offdev commented Oct 11, 2026

Copy link
Copy Markdown
Owner

M2-UI-02 · UI rendering pass (FR-2.8 draw half): the screen-space UI pass on the identity camera. declareUi walks the laid-out tree, culls the invisible widgets (offscreen + later-opaque-panel occlusion — the documented simple model), and declares one batched quad per visible widget (+ the glyph quads of the text widgets and the pass-generated labels) into the pass's own SpriteBatcher (the monotone UI z via G-R11 depthOverride, one texture per atlas). The GL half is the existing M2-SPRITE-02 submit with uiScreenMatrix. Golden image fixture: UiRenderSmoke 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-PNG infra lands with M2-GOLD-01).

  • New header-only laige/render/ui_render.h (no GL, no new dependency); new ui_render CTest entry (12 tests / 7 suites; 11 GL-free + 1 offscreen GL smoke with the GTEST_SKIP contract).
  • Verified locally (GL-free): all six canonical trees build warning-free; ctest -R ui_render 11/11 GL-free suites green in each; full GL-free ctest per tree green (api-real-tree fixed by the manifest regeneration); include + determinism lints OK; laige-api.json regenerated LAST (1688 symbols / 47 headers).
  • CI: the GL smoke is CI-only (the local GPU policy). macOS PR lane (arm64 skips — no usable GL; Intel runs it); merge lane = Linux + Windows MSVC first compile of this C++.
  • Docs: new docs/api/ui_render.md, the docs/README.md index row, the module README status, docs/concepts/coordinates.md §4.14 + §5 row + Related, cross-links from ui_widgets.md / text_items.md; roadmap M2-UI-02 shipped + board M2 26/33 (72/194).
  • 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).

- New header-only laige::render UI pass (public header
  laige/render/ui_render.h, no GL calls, no new dependency):
  the draw half of FR-2.8 -- "UI is a separate render pass,
  screen-space". The S-5 declare half; the GL half is the
  M2-SPRITE-02 SpriteRenderer::submit with the UI pass's
  identity screen matrix (nothing world-space leaks in,
  RENDER-006).
- UiDeclareOptions{defaultFont, fontAtlasId, fontScale}: the
  default UI font (non-owning const GlyphAtlas*), its bindAtlas
  id, its integer scale in [1, kTextMaxScale]; a null font
  turns off the pass-generated text (button labels / input
  content declare nothing, the art still renders).
- uiScreenMatrix(width, height): the identity screen matrix,
  screen px y down -> NDC (x_ndc = 2x/w - 1, y_ndc = 1 - 2y/h;
  column-major: m[0].x = 2/w, m[1].y = -2/h, m[3].x = -1,
  m[3].y = +1, m[3].w = 1); out-of-dimension domain
  [1, kUiMaxDimPx] -> the identity matrix.
- declareUi(batcher, table, tree, options): the O(n) tree ->
  batched-quads pass. Walks the laid-out rects in the
  deterministic traversal order (RENDER-003); culls the
  invisible widgets (the documented simple model -- zero area
  or entirely offscreen on all four half-open edges, O(1) per
  node; fully covered by an opaque panel drawn LATER in the
  traversal -- the ADR 0006 occlusion model -- an
  early-stopping re-walk, worst case O(n) per node, O(n^2)
  per frame, ponytail: ceiling comment + the O(n*P) upgrade);
  declares one centered quad per visible widget (pos = the
  rect center, scale = the rect size): the opaque panel
  background (a non-opaque panel declares nothing), the
  button art + centered label (the exact int64 baseline
  rectY + (rectH + lineStep)/2, lineStep = lineHeight() *
  fontScale, the node's tint), the text item verbatim with
  the depthKey overwritten by the widget's z (the rect is
  culling-only -- no clipping quads in M2), the image quad,
  the list nothing (a container nests an opaque panel as its
  first child), the slider track + fill only for 0 < t < 1
  (t = (value - min)/(max - min); t = 0 is a zero-width quad,
  t = 1 double-blends the identical track pixels -- the
  alpha blend is not idempotent), the input's left-aligned
  white content.
- One batcher per sprite pass: the UI pass is its own
  SpriteBatcher (M2-SPRITE-01). Every quad carries the
  monotone per-node UI z (the index among the nodes that
  declare something, in traversal order -- a culled /
  no-declare node consumes no z) with depthOverride = true
  (the G-R11 escape hatch -- the UI pass is screen-space,
  there is no isoDepthKey; counted + warned at build),
  materialId 0 + BlendMode::Alpha, one texture per atlas
  (the scene binds its UI atlas + glyph atlas(es) through the
  existing bindAtlas / bindSdfAtlas).
- Failure model: first failure wins (stopped tree / missing
  root or an out-of-domain fontScale with a font ->
  InvalidArgument before the first add; a Text / button-label
  / input-text handle not owned by the string table or a
  stopped font -> InvalidArgument; a failed batcher add ->
  the batcher's error). A failed call leaves the quads
  ALREADY DECLARED in the frame (the batcher owns the frame
  state -- the declareParticles / declareText precedent). No
  logging on preconditions.
- Tests: tests/laige-render/ui_render_tests.cpp (new CTest
  entry ui_render; 12 tests / 7 suites): UiRenderScreenMatrix
  (the column-major pin incl. m[3].w = 1, the corner/center
  NDC, the out-of-domain identity), UiRenderDeclare (the
  200x150 all-seven-kinds scene -> 12 quads / 2 groups / 12
  overrides against the hand-computed goldens, the
  machine-greppable ui-render-declare line; no default font ->
  2 quads), UiRenderCull (the offscreen rule, the occlusion
  rule -- the later-opaque-panel early stop, the
  non-opaque-panel exception), UiRenderSlider (t = 0/1 track
  only, t = 0.5 track + fill), UiRenderFailure (the
  precondition failures declare nothing; the handle failures
  with real rects -- the button's art precedes its label ->
  frameCount 1; the batcher-state failures),
  UiRenderZeroAlloc (1000 frames = 0 heap blocks, the
  machine-greppable ui-render-zeroalloc line), UiRenderSmoke
  (the golden image fixture -- a fixed 128x128 UI scene
  rendered offscreen through the real SpriteRenderer::submit
  (7 quads, 2 groups, 2 draw calls, 7 instances, 364 upload
  bytes) against a CPU reference rasterizer (the
  strict-interior pixel-center rule -- all scene edges are
  integer px, never on an edge; the per-quad 4-channel double
  blend out.a = a^2 + (1 - a), byte = min(255, v*255 + 0.5)):
  the whole frame within the documented +/- 1 byte tolerance
  (16384 readbacks) + the exact probes -- the "a" semi texel
  pins the all-four-channel glBlendFunc contract (rgb == A
  exact, alpha within +/- 1 of round(255(a^2 + 1 - a)));
  GTEST_SKIP where no usable OpenGL 3.3; the committed-PNG
  infra lands with M2-GOLD-01).
- Docs (DOC-003/004/006/007): NEW docs/api/ui_render.md (the
  full contract + the DOC-004 Performance section), the
  docs/README.md index row, the src/laige-render/README.md
  status paragraph, docs/concepts/coordinates.md NEW section
  4.14 (the screen-space identity camera) + the section 5
  conversion row + the Related link (the header's normative
  reference), cross-links from ui_widgets.md / text_items.md.
- Tests/CMake: the ui_render entry (TIMEOUT 120, the
  LAIGE_TEST_FONT_PATH env) + the TSan block + the ASan
  detect_leaks=0 list (the smoke can create a headless
  context) + the header comments.
- laige-api.json regenerated LAST (1682 -> 1688 symbols, 46 ->
  47 headers; api-real-tree green).
- No budgets.json entry (the per-frame UI cost is part of the
  composite 50k render-CPU budget, M2-PERF-01 -- the pin
  count stays 17).

Verified locally (GL-free): all six canonical trees build
warning-free; ctest -R ui_render 11/11 GL-free suites green in
each; the full GL-free ctest per tree green except the
pre-regeneration api-real-tree (fixed by the manifest
regeneration); tools/laige-include-lint +
tools/laige-determinism-lint OK.
CI-validated (pending): the UiRenderSmoke GL smoke runs on
the macOS PR lane (arm64 skips -- no usable GL; Intel runs
it) and the merge lane (Linux + Windows MSVC -- the first
MSVC compile of this C++). The local GPU execution policy
prohibits running the smoke locally.
The header's model comment said a node that declares nothing takes no
z; a Text widget with empty content is the exception (its declareText
succeeds, with no quads, and it consumes the z). Match the header
comment to the shipped behavior (docs/api/ui_render.md already stated
it).
…sh manifest line numbers

CI run 38158862667 (macOS Intel + arm64, AppleClang) failed
UiRenderScreenMatrix.UiPlaneMapping: the center composition
m[0].x * 640.0f + m[3].x evaluates to 1.4901161e-08 instead of
0.0f. AppleClang contracts the mul+add to an FMA (its default
-ffp-contract=fast on Apple targets), which rounds the exact
residual (exact a*640 - 1, a = float(2/1280)) in a single step;
the non-contracted path (separate mul+add, e.g. GCC without FMA,
MSVC) rounds a*640 to exactly 1.0f first and subtracts to exactly
0.0f. Both are within one ULP of 1.0 of the exact value 0, so
assert the center mappings with the house absolute tolerance
(kEps = 1e-6f, the matrices_tests pattern) instead of ULP
equality against zero, which has no tolerance. The corner checks
(against +-1.0f) keep EXPECT_FLOAT_EQ: both evaluation paths
yield exactly +-1.0f for the pinned 1280x720 dimensions, and
UlpEq's 4-ULP tolerance covers the 1.0 scale. Verified: the FMA
value reproduces locally with g++ -O2 -mfma on a runtime
matrix (vx = 1.4901161194e-08, vy = -2.4214386940e-08, both
within kEps); all six canonical trees build warning-free with
the eleven GL-free ui_render suites green.

Also: laige-api.json regenerated (LAST) for the header comment
change in the previous commit, which shifted the ui_render.h
symbol line numbers (+2) by twelve manifest fields.
@offdev
offdev merged commit a8b1c98 into master Oct 11, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant