From 0ca5c486e0d84d41c05ccf1d79b4aeb8ae0a15ad Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 4 Oct 2026 19:23:18 +0200 Subject: [PATCH 1/2] [M2-SPRITE-04] Render observability + draw-call budget (G-R2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M2-SPRITE-04 scope (roadmap/M2-rendering-2.5d.md), nothing else: - SpriteDrawStats: + programChanges, uploadBytes, renderTargetBytes, drawCallCapExceeded (the G-R2 frame-graph flag); SpriteDrawTotals: + the matching sums + capExceededFrames. - Options::maxDrawCalls: the configurable per-pass draw-call cap (domain [1, kSpriteRendererMaxInstances], documented default kSpriteRendererDefaultDrawCalls = 64 = 2x the PRD §8.1 worst-case reference-scene budget of 30 draw calls). Exceeding it: one rate-limited Warn (draw_call_cap, fields capacity/draw_calls) + the frame flag + the total — the frame is STILL drawn (observation, never an execution gate; the G-R5 precedent). - textureMemoryBytes(): the texture-memory VRAM estimate gauge (bound atlases' w*h*4 sum, updated at bindAtlas incl. re-bind replacement; 0 in the stopped state). - A failed submit zeroes the frame counters (all three GL failure paths: upload, query creation, draw). - New tests/laige-render/render_counters_tests.cpp + CTest entry render_counters (8 tests / 4 suites): the roadmap's known small scene (10 sprites, 2 atlases, 2 blends -> 3 groups) with the per-frame counters EXACT (frame 1 + frame 2, cross-frame state persistence) + totals, the empty frame counts nothing, the PRIMITIVES_GENERATED feed, the cap warn at the configured count (pinned fields, still drawn, no warn at the cap), and the VRAM gauge exact through binds + replacements. RenderCountersCreate is GL-free; the GL suites GTEST_SKIP on an environment failure. - Docs: sprite_renderer.md (new observability + cap section), docs/README index, module README, roadmap box + board (M2 16/33, 62/194) + changelog row. - laige-api.json regenerated (1233 symbols / 38 headers, +12). Verified: all six local trees warning-clean + full ctest green (build 111/111, build-clang 111/111, build-release 100/100, build-shared 111/111, build-asan 108/108); build-tsan 106/108 — the two failures are a driver-internal teardown data race in this machine's Mesa 26.2.3 llvmpipe (both race accesses inside libgallium; the identical GL pattern passes in SpriteDrawSmoke.ThousandSpriteFrame on this tree and on the CI TSan lane — the CI TSan lane is the contract's TSan authority per the M2-GL-01 precedent). api-real-tree/api-check-fresh, include-lint, determinism-lint green. No budgets.json entry (O(1) bookkeeping; the composite 50k render budget is M2-PERF-01). Additive only — no existing symbol changed. --- docs/README.md | 20 +- docs/api/sprite_renderer.md | 171 ++++- laige-api.json | 80 +- roadmap/M2-rendering-2.5d.md | 2 +- roadmap/README.md | 5 +- src/laige-render/README.md | 38 +- .../include/laige/render/sprite_renderer.h | 123 +++- src/laige-render/sprite_renderer.cpp | 72 +- tests/laige-render/CMakeLists.txt | 82 ++- tests/laige-render/render_counters_tests.cpp | 697 ++++++++++++++++++ 10 files changed, 1156 insertions(+), 134 deletions(-) create mode 100644 tests/laige-render/render_counters_tests.cpp diff --git a/docs/README.md b/docs/README.md index 5c36c7b..feabfd4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -279,14 +279,18 @@ still to land. zero-per-frame-allocation contract (M2-SPRITE-01; `laige-render`). - [Sprite renderer](api/sprite_renderer.md) — `laige::render::SpriteRenderer`: the frame pipeline's submit stage - (M2-SPRITE-02): the minimal GLSL 3.30 sprite shader (world position, - UV sub-rect, rotation, scale, tint; one atlas texture) and the GPU - instanced draw — ONE `glDrawArraysInstanced` per (atlas, material, - blend) group per frame, the per-frame / since-construction counters - (draw calls, texture binds, blend changes, instances, primitives) - as the M2-SPRITE-04 profiler feed, the pre-allocated instance buffer - (zero per-frame allocation), and the offscreen 1 000-sprite render - verified against a CPU reference. + (M2-SPRITE-02) + render observability and the draw-call budget + (M2-SPRITE-04, G-R2): the minimal GLSL 3.30 sprite shader (world + position, UV sub-rect, rotation, scale, tint; one atlas texture) and + the GPU instanced draw — ONE `glDrawArraysInstanced` per (atlas, + material, blend) group per frame, the per-frame / since-construction + counters (draw calls, texture binds, blend changes, program changes, + instances, primitives, upload volume, render-target use, the G-R2 + draw-call-cap flag) as the M2-PROF-01 profiler feed, the + configurable per-pass draw-call cap (default 64; over-cap warns + + flags, never gates), the `textureMemoryBytes()` VRAM estimate, the + pre-allocated instance buffer (zero per-frame allocation), and the + offscreen 1 000-sprite render verified against a CPU reference. - [Sprite frames](api/sprite_frames.md) — `laige::render::SpriteFrameLayout` + `spriteFrameUv`: the atlas UV frame animation hook (M2-SPRITE-03; FR-2.1 "atlas UV animation (sheet diff --git a/docs/api/sprite_renderer.md b/docs/api/sprite_renderer.md index 294b4c2..b220310 100644 --- a/docs/api/sprite_renderer.md +++ b/docs/api/sprite_renderer.md @@ -6,24 +6,37 @@ sprite shader. FR-2.1: "one draw call per (atlas, material, blend) group per frame; GPU-instanced; supports rotation, scale, tint, per-sprite depth, UV sub-rect"; RENDER-001: state changes and draw submissions observable (the per-frame / since-construction counters -below — the M2-SPRITE-04 profiler feed). The renderer consumes the -M2-SPRITE-01 batcher's BUILT frame and makes exactly ONE -`glDrawArraysInstanced` per (atlas, material, blend) group; the -per-group state (one texture bind, one blend function) is set once, so -state changes are O(group count), not O(sprite count). +below). The renderer consumes the M2-SPRITE-01 batcher's BUILT frame +and makes exactly ONE `glDrawArraysInstanced` per (atlas, material, +blend) group; the per-group state (one texture bind, one blend +function) is set once, so state changes are O(group count), not +O(sprite count). + +Render observability + the draw-call budget (M2-SPRITE-04, PRD §9.3 +G-R2): the counters carry the profiler's per-frame fields — program +changes, the per-frame upload volume, the render-target use, the +texture-memory VRAM estimate (a since-construction gauge), and the +G-R2 per-pass draw-call cap flag — and the submit honors a +configurable per-pass draw-call cap (`Options::maxDrawCalls`, +documented default 64) that WARNS + flags an over-cap frame instead +of dropping it (observation, never an execution gate). Public header + implementation: `src/laige-render/include/laige/render/sprite_renderer.h`, `src/laige-render/sprite_renderer.cpp`. -Unit/integration suite: `ctest -R sprite_draw` -(`tests/laige-render/sprite_draw_tests.cpp`). The `SpriteDrawState` / -`SpriteDrawSmoke` / `SpriteDrawPipeline` suites require a usable -OpenGL 3.3 core environment (the CI path: Mesa software GL on the -offscreen FBO — `GlContext::createHeadless`); they self- -`GTEST_SKIP` on an environment failure, never on an engine failure. -`SpriteDrawCreate` runs everywhere (no GL: options validation, the -stopped state). The smoke test is the roadmap's offscreen render of a +Unit/integration suites: `ctest -R sprite_draw` +(`tests/laige-render/sprite_draw_tests.cpp`) and +`ctest -R render_counters` +(`tests/laige-render/render_counters_tests.cpp`, the M2-SPRITE-04 +entry). The `SpriteDrawState` / `SpriteDrawSmoke` / +`SpriteDrawPipeline` / `RenderCountersScene` / `RenderCountersCap` / +`RenderCountersMemory` suites require a usable OpenGL 3.3 core +environment (the CI path: Mesa software GL on the offscreen FBO — +`GlContext::createHeadless`); they self-`GTEST_SKIP` on an +environment failure, never on an engine failure. `SpriteDrawCreate` +and `RenderCountersCreate` run everywhere (no GL: options +validation, the stopped state). The smoke test is the roadmap's offscreen render of a 1 000-sprite scene: it asserts `draw call count == group count` (3 == 3 in the scene), the GL-side primitive cross-check (2 000 == 2 × 1 000), the state-change counts, and the whole 128×128 @@ -37,15 +50,21 @@ primitives=2000 texture_binds=2 blend_changes=3 ...`. ```cpp struct SpriteDrawStats { // the per-frame counters of the last successful submit - std::uint32_t drawCalls; // instanced draw calls (== group count) - std::uint32_t textureBinds; // texture binds in the pass - std::uint32_t blendChanges; // blend-function changes in the pass - std::uint32_t instances; // instances drawn this frame - std::uint32_t primitives; // PRIMITIVES_GENERATED (query on only) + std::uint32_t drawCalls; // instanced draw calls (== group count) + std::uint32_t textureBinds; // texture binds in the pass + std::uint32_t blendChanges; // blend-function changes in the pass + std::uint32_t programChanges; // program changes (glUseProgram) in the pass + std::uint32_t instances; // instances drawn this frame + std::uint32_t primitives; // PRIMITIVES_GENERATED (query on only) + std::uint64_t uploadBytes; // the frame's instance upload, in bytes + std::uint64_t renderTargetBytes; // the render-target size drawn, in bytes + bool drawCallCapExceeded; // G-R2: draw calls exceeded the cap }; struct SpriteDrawTotals { // since-construction (successful submits only) - std::uint64_t frames, drawCalls, textureBinds, blendChanges, instances, primitives; + std::uint64_t frames, drawCalls, textureBinds, blendChanges, + programChanges, instances, primitives, uploadBytes, + renderTargetBytes, capExceededFrames; }; class SpriteRenderer { @@ -53,10 +72,12 @@ class SpriteRenderer { struct Options { std::uint32_t maxInstances{0}; // per-frame instance budget std::uint32_t maxAtlases{0}; // atlas-id domain [0, maxAtlases) + std::uint32_t maxDrawCalls{64}; // per-pass draw-call cap (G-R2) bool primitiveQuery{false}; // opt-in PRIMITIVES_GENERATED query }; static constexpr std::uint32_t kSpriteRendererMaxInstances = 0xFFFFFFFFu; static constexpr std::uint32_t kSpriteRendererMaxAtlases = 4096u; + static constexpr std::uint32_t kSpriteRendererDefaultDrawCalls = 64u; SpriteRenderer() noexcept; // stopped state [[nodiscard]] static Result @@ -64,11 +85,13 @@ class SpriteRenderer { [[nodiscard]] bool valid() const noexcept; [[nodiscard]] std::uint32_t maxInstances() const noexcept; [[nodiscard]] std::uint32_t maxAtlases() const noexcept; + [[nodiscard]] std::uint32_t maxDrawCalls() const noexcept; [[nodiscard]] Status bindAtlas(std::uint32_t atlasId, std::uint32_t w, std::uint32_t h, std::span rgba); [[nodiscard]] Status submit(SpriteBatcher& batcher, const Mat4& worldToNdc); [[nodiscard]] SpriteDrawStats frameStats() const noexcept; // last success (else zero) [[nodiscard]] SpriteDrawTotals totals() const noexcept; + [[nodiscard]] std::uint64_t textureMemoryBytes() const noexcept; // VRAM estimate // move-only (copy deleted) }; ``` @@ -82,14 +105,19 @@ class SpriteRenderer { `GL_STATIC_DRAW`), the per-frame instance buffer (`maxInstances * 52` B, `GL_DYNAMIC_DRAW`), and the VAO (quad attribute at divisor 0; the five instance attributes at divisor 1), - and the atlas registry (`maxAtlases` × 8 B). Any GL failure returns + and the atlas registry (`maxAtlases` × 16 B). Any GL failure returns `GlUnavailable` with ONE structured Error event (`program_creation_failed` or `resource_creation_failed`, with the GL error code + the sanitized info log) and no partial renderer (the failure is the stopped state). - `Options::maxInstances` / `maxAtlases` domains: - `[1, kSpriteRendererMaxInstances]` / `[1, kSpriteRendererMaxAtlases]`. - The 4 096-slot registry (32 KB) is far beyond the PRD's 50k-sprite - scene (≤ 30 draw calls = tens of atlases at most). + `Options::maxInstances` / `maxAtlases` / `maxDrawCalls` domains: + `[1, kSpriteRendererMaxInstances]` / `[1, kSpriteRendererMaxAtlases]` / + `[1, kSpriteRendererMaxInstances]` (a frame's draw calls can never + exceed its instance count — each group has ≥ 1 instance). The 4 096- + slot registry (64 KB with the M2-SPRITE-04 width/height + bookkeeping) is far beyond the PRD's 50k-sprite scene (≤ 30 draw + calls = tens of atlases at most). The documented default draw-call + cap is `kSpriteRendererDefaultDrawCalls` (64 — 2× the PRD §8.1 + worst-case reference-scene budget of 30 draw calls). - **`bindAtlas(atlasId, w, h, rgba)`** is the **set-up/asset path** (one call per atlas per scene load; the asset system re-uploads them later, M3 — RENDER-004: no unexpected GPU work in the frame hot @@ -98,10 +126,12 @@ class SpriteRenderer { `rgba.size() == w*h*4`. Uploads GL_RGBA8, `GL_NEAREST`, `GL_CLAMP_TO_EDGE`, no mipmaps (the UV sub-rects are pixel-exact — the M2-GOLD-01 contract). Re-binding an id REPLACES the texture - (the old one is deleted; the last bind wins). Validation failures: - `InvalidArgument` (no GL work, no logging — the precondition - contract); a GL upload failure: `GlUnavailable` + one Error - (`atlas_upload_failed`). + (the old one is deleted; the last bind wins — the M2-SPRITE-04 + texture-memory gauge tracks the replacement: the old upload's + `w * h * 4` bytes are subtracted, the new ones added). Validation + failures: `InvalidArgument` (no GL work, no logging — the + precondition contract); a GL upload failure: `GlUnavailable` + one + Error (`atlas_upload_failed`). - **`submit(batcher, worldToNdc)`** is the per-frame draw (the frame protocol: after the batch stage's `beginFrame()`/`add()`×n/`build()`). Preconditions, checked in order (first failure wins — a FAILED @@ -124,13 +154,22 @@ class SpriteRenderer { failure per frame; the batcher admits any u32 atlas id, so the registry range check precedes the registry read). - Then: pack the frame's instances (group order = the batcher's - published order; in-group = the back-to-front order) into the - pre-allocated staging (52 B per instance — the items' floats are - copied verbatim, no float arithmetic — the GPU owns the math, - FR-2.2/PERF-003), ONE `glBufferSubData` upload, and the per-group - state + draw. `worldToNdc` is the frame's combined world → NDC - matrix (`IsoCamera::matrix()` — M2-CAM-02 — or the M2-PROJ-01 view's + After the preconditions (BEFORE any GL state): the G-R2 per-pass + draw-call cap (PRD §9.3) — the frame's draw calls (== its group + count) strictly above `maxDrawCalls` fire one rate-limited Warn + `draw_call_cap` (fields `capacity`, `draw_calls`) and set the + frame's `drawCallCapExceeded` flag (the frame-graph flag M2-PROF-01 + will report; the total `capExceededFrames` counts it). The frame is + STILL drawn — the cap is observation, never an execution gate (the + G-R5 precedent). Then: pack the frame's instances (group order = + the batcher's published order; in-group = the back-to-front order) + into the pre-allocated staging (52 B per instance — the items' + floats are copied verbatim, no float arithmetic — the GPU owns the + math, FR-2.2/PERF-003), ONE `glBufferSubData` upload (counted in + `uploadBytes`), and the per-group state + draw (the program change + counted in `programChanges`, the render-target size in + `renderTargetBytes`). `worldToNdc` is the frame's combined world → + NDC matrix (`IsoCamera::matrix()` — M2-CAM-02 — or the M2-PROJ-01 view's combined matrix; RENDER-006: the conversion boundary is the matrix). The batcher reference is non-const only because the batcher's pool accessors are (the M2-SPRITE-01 house quirk); @@ -140,7 +179,16 @@ class SpriteRenderer { - **`frameStats()`** — the per-frame counters of the last SUCCESSFUL submit (a failed submit reads zero). **`totals()`** — the since-construction counters (successful submits only). Both O(1), no - allocation. + allocation. The M2-SPRITE-04 fields: `programChanges` (program + changes), `uploadBytes` (the frame's instance upload, n × 52 B), + `renderTargetBytes` (the render-target size drawn, w × h × 4), + `drawCallCapExceeded` (the G-R2 flag), and the totals' + `capExceededFrames` (successful over-cap submits). +- **`textureMemoryBytes()`** — the texture-memory VRAM estimate: the + sum of `w * h * 4` over the BOUND atlases (set-up/asset path — + updated at each `bindAtlas`, replaced on re-bind, 0 in the stopped + state). A since-construction gauge (the texture's current memory + footprint), O(1), no allocation, no GL call. - **`Options::primitiveQuery`** (off by default): the opt-in GL `PRIMITIVES_GENERATED` query around every submit — the GL-side dispatch cross-check + the M2-SPRITE-04 primitive feed. ON: one @@ -182,6 +230,48 @@ profiler ships), and the GL-side cross-check is the opt-in `PRIMITIVES_GENERATED` count (`primitives` — 2 per instance of the 4-vertex strip: 2 000 for the 1 000-sprite scene). +## Render observability + the draw-call cap (M2-SPRITE-04, PRD §9.3 G-R2) + +The profiler's per-frame fields (RENDER-001 + DBG-008), shipped with +this step (the M2-SPRITE-02 counters were already its feed): + +| Field | Meaning | +| --- | --- | +| `drawCalls` | instanced draw calls == group count | +| `textureBinds` | texture binds in the pass | +| `blendChanges` | blend-function changes in the pass | +| `programChanges` | program changes (`glUseProgram`) in the pass — 1 per successful non-empty frame (the pass sets its own program) | +| `instances` | instances drawn this frame | +| `primitives` | `PRIMITIVES_GENERATED` (query on only) | +| `uploadBytes` | the frame's instance upload: `n × 52` B | +| `renderTargetBytes` | the render-target size drawn: `w × h × 4` | +| `drawCallCapExceeded` | the G-R2 flag: the frame's draw calls strictly exceeded `maxDrawCalls` | +| `capExceededFrames` (totals) | successful over-cap submits since construction | +| `textureMemoryBytes()` | the VRAM estimate: bound atlases' `w × h × 4` sum (gauge) | + +The per-pass draw-call cap (G-R2: "per-pass cap configurable → warn + +frame graph flag"): `Options::maxDrawCalls` (documented default +`kSpriteRendererDefaultDrawCalls` = 64, 2× the PRD §8.1 worst-case +reference-scene budget of 30 draw calls; domain +`[1, kSpriteRendererMaxInstances]`). Exceeding it: + +- fires ONE rate-limited Warn `draw_call_cap` (fields `capacity`, + `draw_calls`) on the over-cap submit; +- sets the frame's `drawCallCapExceeded` flag (the frame-graph flag + M2-PROF-01 will report) and the totals' `capExceededFrames`; +- does NOT change what is drawn — observation, never an execution + gate (the G-R5 precedent: budgets observe and report, the frame + proceeds). The frame's draw calls are always == its group count + (one instanced draw per group), so the cap is set on the scene's + expected group count. + +The exact small scene of the roadmap (10 sprites, 2 atlases, 2 blends +→ 3 groups): `ctest -R render_counters` (`RenderCountersScene`) pins +every field EXACTLY (frame 1 + frame 2 + the totals), the cap warns at +the configured count with the pinned fields (`RenderCountersCap`), +and the VRAM gauge tracks binds + re-bind replacements exactly +(`RenderCountersMemory`). + ## The shader and the pass's GL state The whole M2 sprite feature is one minimal GLSL 3.30 program (no @@ -238,7 +328,10 @@ arithmetic, the GPU owns the math). The state changes are observable (RENDER-001 — the M2-SPRITE-04 profiler feed): `textureBinds`, `blendChanges`, `drawCalls`, -`instances`, `primitives` (query on only). The composite 50k +`instances`, `primitives` (query on only), plus the M2-SPRITE-04 +fields `programChanges`, `uploadBytes`, `renderTargetBytes`, the +G-R2 `drawCallCapExceeded` flag (+ the totals' `capExceededFrames`), +and the `textureMemoryBytes()` VRAM estimate. The composite 50k render-CPU budget (PRD §8.1) is measured with this stage at M2-PERF-01; there is no standalone budgets.json entry here (the sort cost is the `depth_sort_10k` budget — M2-SORT-01). @@ -289,6 +382,10 @@ state. - `submit` requires the batcher BUILT for the current frame — an open window with declared items fails `InvalidArgument`; it is never drawn as an empty frame (CORE-008). +- The per-pass draw-call cap (`Options::maxDrawCalls`) is OBSERVATION + (warn + flag), never an execution gate: the frame is still drawn — + set it to the scene's expected group count, not to a value the + scene must fit. - The context must be valid; `submit` makes it current on the calling thread (idempotent — the M2-GL-02 onStart hook does it once, submit re-affirms it); a cross-thread live takeover fails `GlUnavailable` diff --git a/laige-api.json b/laige-api.json index 183a640..f5afcfe 100644 --- a/laige-api.json +++ b/laige-api.json @@ -796,40 +796,52 @@ {"name": "laige::render::SpriteFrameLayout::frameSpacing", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_frames.h", "line": 155, "signature": "std::uint32_t frameSpacing{}", "summary": "The gap between adjacent frames, texels (0 = packed).", "budget": null, "experimental": false}, {"name": "laige::render::SpriteFrameLayout::sheetBorder", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_frames.h", "line": 158, "signature": "std::uint32_t sheetBorder{}", "summary": "The margin between the sheet edge and the first/last frame on every side, texels (0 = flush to the edge).", "budget": null, "experimental": false}, {"name": "laige::render::spriteFrameUv", "kind": "function", "header": "src/laige-render/include/laige/render/sprite_frames.h", "line": 174, "signature": "[[nodiscard]] inline Result spriteFrameUv( std::uint32_t frameIndex, const SpriteFrameLayout& layout, std::uint32_t atlasWidth, std::uint32_t atlasHeight) noexcept", "summary": "The UV sub-rect of `frameIndex` in the `atlasWidth × atlasHeight` atlas, under `layout` (the header: the sheet model, the exactness domain, the failure contract). The frame index is 0-based row-major (frame 0 = top-left); an out-of-range index fails `InvalidArgument` — the engine NEVER wraps silently (CORE-008).", "budget": "O(1): a handful of integer checks + 4 float divisions; zero", "experimental": false}, - {"name": "laige::render::SpriteDrawStats", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 167, "signature": "struct SpriteDrawStats", "summary": "The per-frame counters of one successful submit (RENDER-001 — the state changes and draw submissions made observable; the M2-SPRITE-04 profiler feed). Read-only published state. `primitives` is 0 unless the opt-in primitive query (Options::primitiveQuery) is enabled: GL 3.3 core has no draw-call query primitive, so the dispatch count is the engine's own bookkeeping (drawCalls — what the profiler ships), and the GL-side cross-check is the PRIMITIVES_GENERATED count of the pass (2 per instance of the 4-vertex strip).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::drawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 168, "signature": "std::uint32_t drawCalls{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::textureBinds", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 169, "signature": "std::uint32_t textureBinds{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::blendChanges", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 170, "signature": "std::uint32_t blendChanges{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::instances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 171, "signature": "std::uint32_t instances{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::primitives", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 172, "signature": "std::uint32_t primitives{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 178, "signature": "struct SpriteDrawTotals", "summary": "The since-construction totals (the frames counter counts successful submits only). The M2-SPRITE-04 profiler reads these on the owner thread (DBG-005).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::frames", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 179, "signature": "std::uint64_t frames{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::drawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 180, "signature": "std::uint64_t drawCalls{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::textureBinds", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 181, "signature": "std::uint64_t textureBinds{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::blendChanges", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 182, "signature": "std::uint64_t blendChanges{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::instances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 183, "signature": "std::uint64_t instances{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::primitives", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 184, "signature": "std::uint64_t primitives{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer", "kind": "class", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 190, "signature": "class SpriteRenderer", "summary": "The submit stage of the frame pipeline (M2-SPRITE-02): one instanced draw call per (atlas, material, blend) group per frame (FR-2.1) + the minimal GLSL 3.30 sprite shader (the header preamble).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 194, "signature": "struct Options", "summary": "The submit stage's configuration (API-006): both fields are validated at create (the first failure wins, one Warn).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::Options::maxInstances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 201, "signature": "std::uint32_t maxInstances{0}", "summary": "The per-frame instance budget: the instance buffer is sized to this at create (52 B per instance). Must be >= the batcher's maxSprites. Domain [1, kSpriteRendererMaxInstances]; the practical ceiling is the driver's GL buffer size — an allocation that large fails create GlUnavailable (the clean failure, CORE-008).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::Options::maxAtlases", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 207, "signature": "std::uint32_t maxAtlases{0}", "summary": "The atlas-id domain: atlas ids are [0, maxAtlases) — the atlas registry is a flat table (8 B per slot). Domain [1, kSpriteRendererMaxAtlases]: 4096 slots = 32 KB — far beyond the PRD's 50k-sprite scene (<= 30 draw calls = tens of atlases at most).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::Options::primitiveQuery", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 214, "signature": "bool primitiveQuery{false}", "summary": "The opt-in GL PRIMITIVES_GENERATED query around every submit (the GL-side dispatch cross-check + the M2-SPRITE-04 primitive feed). OFF by default: zero GL work, zero cost (the hot path, PERF-002). ON: one query object per submit + a glFinish — a CPU/GPU sync, a DIAGNOSTIC mode for the offscreen test/CI path and the profiler, never the shipping frame loop (RENDER-005).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::kSpriteRendererMaxInstances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 219, "signature": "static constexpr std::uint32_t kSpriteRendererMaxInstances = 0xFFFFFFFFu", "summary": "The frame-slot index width (the batcher's slot domain, the instance buffer's index domain).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::kSpriteRendererMaxAtlases", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 222, "signature": "static constexpr std::uint32_t kSpriteRendererMaxAtlases = 4096u", "summary": "The atlas registry cap (the Options comment: 32 KB at 4096 slots).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::SpriteRenderer", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 230, "signature": "SpriteRenderer() noexcept", "summary": "The stopped state (the failed create / moved-from): valid() is false, every operation fails InvalidArgument, the counters read zero. No logging. Defined in the .cpp (the defaulted form would instantiate the unique_ptr destructor against the incomplete Impl — the GlContext precedent).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::~SpriteRenderer", "kind": "destructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 231, "signature": "~SpriteRenderer()", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::SpriteRenderer", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 234, "signature": "SpriteRenderer(SpriteRenderer&&) noexcept", "summary": "Defined in the .cpp, where Impl is complete (the unique_ptr move operations need the complete pointee — the GlContext precedent).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 235, "signature": "SpriteRenderer& operator=(SpriteRenderer&&) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::SpriteRenderer", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 236, "signature": "SpriteRenderer(const SpriteRenderer&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 237, "signature": "SpriteRenderer& operator=(const SpriteRenderer&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::create", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 250, "signature": "[[nodiscard]] static laige::Result create(const GlContext& ctx, Options options) noexcept", "summary": "One-time setup (the engine's renderer construction, before the frame pipeline starts — never inside a frame, PERF-002): validates the options (first failure wins, one Warn options_invalid), requires a valid context (makeCurrent, idempotent), then compiles the sprite shader, creates the quad VBO, the per-frame instance buffer (maxInstances * 52 B), the VAO, and the atlas registry. Any GL failure returns GlUnavailable with one structured Error event (no partial renderer: the failure is the stopped state). allocations (52 * maxInstances B instance buffer), no loop.", "budget": "one-time: two shader compiles, one link, three GPU", "experimental": false}, - {"name": "laige::render::SpriteRenderer::valid", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 254, "signature": "[[nodiscard]] bool valid() const noexcept", "summary": "True when the create succeeded (the stopped state is false).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::maxInstances", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 258, "signature": "[[nodiscard]] std::uint32_t maxInstances() const noexcept", "summary": "The validated configuration (the stopped state reads zero). O(1), no allocation.", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::maxAtlases", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 259, "signature": "[[nodiscard]] std::uint32_t maxAtlases() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::bindAtlas", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 277, "signature": "[[nodiscard]] laige::Status bindAtlas(std::uint32_t atlasId, std::uint32_t width, std::uint32_t height, std::span rgba)", "summary": "Bind one atlas texture from CPU RGBA8 bytes — the SETUP/asset path (one call per atlas per scene load; the asset system re-uploads them later, M3 — RENDER-004: no unexpected GPU work in the frame hot path). The context must be current on the calling thread (makeCurrent, idempotent). The atlas id is the batcher's SpriteItem.atlasId domain: [0, maxAtlases). width/height are [1, the context's maxTextureSize]; rgba must be exactly width * height * 4 bytes. The texture is uploaded GL_RGBA8, GL_NEAREST, GL_CLAMP_TO_EDGE, no mipmaps (the sub-rects are pixel-exact — the M2-GOLD-01 contract). Re-binding an id REPLACES the texture (the old one is deleted — the last bind wins). Validation failures return InvalidArgument (no GL work, no logging — the precondition contract, the GlContext::readPixel precedent); a GL failure returns GlUnavailable + one Error. upload); no loop, no allocation beyond the texture object.", "budget": "one-time per atlas: one glTexImage2D (width*height*4 B", "experimental": false}, - {"name": "laige::render::SpriteRenderer::submit", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 310, "signature": "[[nodiscard]] laige::Status submit(SpriteBatcher& batcher, const Mat4& worldToNdc)", "summary": "The submit stage: draw the batcher's BUILT frame (the frame protocol, the header preamble). Preconditions (checked in order, the first failure wins — a failed submit leaves the frame counters ZERO and the since-construction totals UNCHANGED and draws nothing; the pass re-establishes its own GL state at the start of every successful submit, so a failure leaves no stale sprite-pass state for the next frame): 1. the renderer valid (stopped -> InvalidArgument, no logging); 2. the context valid + current (makeCurrent — idempotent; a cross-thread live takeover -> GlUnavailable, the P0 EGL contract); 3. the batcher built for the current frame (batcher.frameBuilt() — an open window is InvalidArgument: declared items are never silently dropped, CORE-008); 4. the frame's instance count <= maxInstances (else BudgetExhausted + one rate-limited Warn instance_capacity — the budget, PERF-008); 5. every group's atlas in the registry and bound (else InvalidArgument — the stateless pre-state validation, one failure per frame). The frame's world -> NDC matrix (the IsoCamera / M2-PROJ-01 combined matrix — RENDER-006) is the per-frame uniform. The batcher reference is non-const only because the batcher's pool accessors are (the M2-SPRITE-01 house quirk); submit mutates nothing on the batcher. n * 52 B, G draw calls, zero allocation — the header Performance section.", "budget": "O(G * 5 + n) CPU (G groups, n instances), one upload of", "experimental": false}, - {"name": "laige::render::SpriteRenderer::frameStats", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 315, "signature": "[[nodiscard]] SpriteDrawStats frameStats() const noexcept", "summary": "The per-frame counters of the last SUCCESSFUL submit (a failed submit reads zero — it zeroes them). O(1).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::totals", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 318, "signature": "[[nodiscard]] SpriteDrawTotals totals() const noexcept", "summary": "The since-construction totals (successful submits only). O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 193, "signature": "struct SpriteDrawStats", "summary": "The per-frame counters of one successful submit (RENDER-001 — the state changes and draw submissions made observable; the M2-SPRITE-04 profiler feed). Read-only published state. `primitives` is 0 unless the opt-in primitive query (Options::primitiveQuery) is enabled: GL 3.3 core has no draw-call query primitive, so the dispatch count is the engine's own bookkeeping (drawCalls — what the profiler ships), and the GL-side cross-check is the PRIMITIVES_GENERATED count of the pass (2 per instance of the 4-vertex strip). The M2-SPRITE-04 fields: `programChanges` (the glUseProgram calls in the pass — 1 for every non-empty successful submit: the pass sets its own program and restores 0 at the end), `uploadBytes` (the frame's instance-upload volume, n * 52 B — the upload's RENDER-004 observable work), `renderTargetBytes` (the render-target size drawn, width * height * 4 — the render-target use), and `drawCallCapExceeded` (the G-R2 frame-graph flag: the frame's draw calls exceeded Options::maxDrawCalls — the frame was still drawn, the cap is observation, never an execution gate).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::drawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 194, "signature": "std::uint32_t drawCalls{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::textureBinds", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 195, "signature": "std::uint32_t textureBinds{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::blendChanges", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 196, "signature": "std::uint32_t blendChanges{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::programChanges", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 197, "signature": "std::uint32_t programChanges{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::instances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 198, "signature": "std::uint32_t instances{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::primitives", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 199, "signature": "std::uint32_t primitives{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::uploadBytes", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 200, "signature": "std::uint64_t uploadBytes{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::renderTargetBytes", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 201, "signature": "std::uint64_t renderTargetBytes{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::drawCallCapExceeded", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 202, "signature": "bool drawCallCapExceeded{false}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 209, "signature": "struct SpriteDrawTotals", "summary": "The since-construction totals (the frames counter counts successful submits only). The M2-SPRITE-04 profiler reads these on the owner thread (DBG-005). `capExceededFrames` counts the successful submits whose draw calls exceeded the cap (the G-R2 frame-graph total).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::frames", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 210, "signature": "std::uint64_t frames{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::drawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 211, "signature": "std::uint64_t drawCalls{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::textureBinds", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 212, "signature": "std::uint64_t textureBinds{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::blendChanges", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 213, "signature": "std::uint64_t blendChanges{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::programChanges", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 214, "signature": "std::uint64_t programChanges{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::instances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 215, "signature": "std::uint64_t instances{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::primitives", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 216, "signature": "std::uint64_t primitives{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::uploadBytes", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 217, "signature": "std::uint64_t uploadBytes{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::renderTargetBytes", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 218, "signature": "std::uint64_t renderTargetBytes{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::capExceededFrames", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 219, "signature": "std::uint64_t capExceededFrames{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer", "kind": "class", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 225, "signature": "class SpriteRenderer", "summary": "The submit stage of the frame pipeline (M2-SPRITE-02): one instanced draw call per (atlas, material, blend) group per frame (FR-2.1) + the minimal GLSL 3.30 sprite shader (the header preamble).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::kSpriteRendererMaxInstances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 229, "signature": "static constexpr std::uint32_t kSpriteRendererMaxInstances = 0xFFFFFFFFu", "summary": "The frame-slot index width (the batcher's slot domain, the instance buffer's index domain).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::kSpriteRendererMaxAtlases", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 232, "signature": "static constexpr std::uint32_t kSpriteRendererMaxAtlases = 4096u", "summary": "The atlas registry cap (the Options comment: 32 KB at 4096 slots).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::kSpriteRendererDefaultDrawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 237, "signature": "static constexpr std::uint32_t kSpriteRendererDefaultDrawCalls = 64u", "summary": "The documented default per-pass draw-call cap (G-R2, PRD §9.3): 2x the PRD §8.1 worst-case reference-scene budget of 30 draw calls — the 50k-sprite exit scene passes with 2x headroom, and a scene that blows the cap by more than 2x is visible at a glance.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 241, "signature": "struct Options", "summary": "The submit stage's configuration (API-006): all fields are validated at create (the first failure wins, one Warn).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::Options::maxInstances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 248, "signature": "std::uint32_t maxInstances{0}", "summary": "The per-frame instance budget: the instance buffer is sized to this at create (52 B per instance). Must be >= the batcher's maxSprites. Domain [1, kSpriteRendererMaxInstances]; the practical ceiling is the driver's GL buffer size — an allocation that large fails create GlUnavailable (the clean failure, CORE-008).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::Options::maxAtlases", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 255, "signature": "std::uint32_t maxAtlases{0}", "summary": "The atlas-id domain: atlas ids are [0, maxAtlases) — the atlas registry is a flat table (16 B per slot with the M2-SPRITE-04 width/height bookkeeping). Domain [1, kSpriteRendererMaxAtlases]: 4096 slots = 64 KB — far beyond the PRD's 50k-sprite scene (<= 30 draw calls = tens of atlases at most).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::Options::maxDrawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 267, "signature": "std::uint32_t maxDrawCalls{kSpriteRendererDefaultDrawCalls}", "summary": "The per-pass draw-call cap (G-R2, PRD §9.3): the maximum instanced draw calls (== the frame's group count) ONE submit may make. A frame ABOVE the cap is still drawn (observation, never an execution gate — the G-R5 precedent): one rate-limited Warn (`sprite_renderer/draw_call_cap`) fires, the frame carries the `drawCallCapExceeded` flag (the frame-graph flag M2-PROF-01 reports), and the total `capExceededFrames` counts it. Domain [1, kSpriteRendererMaxInstances] (a frame's draw calls can never exceed its instance count — each group has >= 1 instance); the documented default is kSpriteRendererDefaultDrawCalls (64).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::Options::primitiveQuery", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 274, "signature": "bool primitiveQuery{false}", "summary": "The opt-in GL PRIMITIVES_GENERATED query around every submit (the GL-side dispatch cross-check + the M2-SPRITE-04 primitive feed). OFF by default: zero GL work, zero cost (the hot path, PERF-002). ON: one query object per submit + a glFinish — a CPU/GPU sync, a DIAGNOSTIC mode for the offscreen test/CI path and the profiler, never the shipping frame loop (RENDER-005).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::SpriteRenderer", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 283, "signature": "SpriteRenderer() noexcept", "summary": "The stopped state (the failed create / moved-from): valid() is false, every operation fails InvalidArgument, the counters read zero. No logging. Defined in the .cpp (the defaulted form would instantiate the unique_ptr destructor against the incomplete Impl — the GlContext precedent).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::~SpriteRenderer", "kind": "destructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 284, "signature": "~SpriteRenderer()", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::SpriteRenderer", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 287, "signature": "SpriteRenderer(SpriteRenderer&&) noexcept", "summary": "Defined in the .cpp, where Impl is complete (the unique_ptr move operations need the complete pointee — the GlContext precedent).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 288, "signature": "SpriteRenderer& operator=(SpriteRenderer&&) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::SpriteRenderer", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 289, "signature": "SpriteRenderer(const SpriteRenderer&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 290, "signature": "SpriteRenderer& operator=(const SpriteRenderer&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::create", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 303, "signature": "[[nodiscard]] static laige::Result create(const GlContext& ctx, Options options) noexcept", "summary": "One-time setup (the engine's renderer construction, before the frame pipeline starts — never inside a frame, PERF-002): validates the options (first failure wins, one Warn options_invalid), requires a valid context (makeCurrent, idempotent), then compiles the sprite shader, creates the quad VBO, the per-frame instance buffer (maxInstances * 52 B), the VAO, and the atlas registry. Any GL failure returns GlUnavailable with one structured Error event (no partial renderer: the failure is the stopped state). allocations (52 * maxInstances B instance buffer), no loop.", "budget": "one-time: two shader compiles, one link, three GPU", "experimental": false}, + {"name": "laige::render::SpriteRenderer::valid", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 307, "signature": "[[nodiscard]] bool valid() const noexcept", "summary": "True when the create succeeded (the stopped state is false).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::maxInstances", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 311, "signature": "[[nodiscard]] std::uint32_t maxInstances() const noexcept", "summary": "The validated configuration (the stopped state reads zero). O(1), no allocation.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::maxAtlases", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 312, "signature": "[[nodiscard]] std::uint32_t maxAtlases() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::maxDrawCalls", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 313, "signature": "[[nodiscard]] std::uint32_t maxDrawCalls() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::bindAtlas", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 331, "signature": "[[nodiscard]] laige::Status bindAtlas(std::uint32_t atlasId, std::uint32_t width, std::uint32_t height, std::span rgba)", "summary": "Bind one atlas texture from CPU RGBA8 bytes — the SETUP/asset path (one call per atlas per scene load; the asset system re-uploads them later, M3 — RENDER-004: no unexpected GPU work in the frame hot path). The context must be current on the calling thread (makeCurrent, idempotent). The atlas id is the batcher's SpriteItem.atlasId domain: [0, maxAtlases). width/height are [1, the context's maxTextureSize]; rgba must be exactly width * height * 4 bytes. The texture is uploaded GL_RGBA8, GL_NEAREST, GL_CLAMP_TO_EDGE, no mipmaps (the sub-rects are pixel-exact — the M2-GOLD-01 contract). Re-binding an id REPLACES the texture (the old one is deleted — the last bind wins). Validation failures return InvalidArgument (no GL work, no logging — the precondition contract, the GlContext::readPixel precedent); a GL failure returns GlUnavailable + one Error. upload); no loop, no allocation beyond the texture object.", "budget": "one-time per atlas: one glTexImage2D (width*height*4 B", "experimental": false}, + {"name": "laige::render::SpriteRenderer::submit", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 374, "signature": "[[nodiscard]] laige::Status submit(SpriteBatcher& batcher, const Mat4& worldToNdc)", "summary": "The submit stage: draw the batcher's BUILT frame (the frame protocol, the header preamble). Preconditions (checked in order, the first failure wins — a failed submit leaves the frame counters ZERO and the since-construction totals UNCHANGED and draws nothing; the pass re-establishes its own GL state at the start of every successful submit, so a failure leaves no stale sprite-pass state for the next frame): 1. the renderer valid (stopped -> InvalidArgument, no logging); 2. the context valid + current (makeCurrent — idempotent; a cross-thread live takeover -> GlUnavailable, the P0 EGL contract); 3. the batcher built for the current frame (batcher.frameBuilt() — an open window is InvalidArgument: declared items are never silently dropped, CORE-008); 4. the frame's instance count <= maxInstances (else BudgetExhausted + one rate-limited Warn instance_capacity — the budget, PERF-008); 5. every group's atlas in the registry and bound (else InvalidArgument — the stateless pre-state validation, one failure per frame). After the preconditions, BEFORE any GL state: the G-R2 per-pass draw-call cap (PRD §9.3) — the frame's group count above maxDrawCalls fires one rate-limited Warn (`sprite_renderer/draw_call_cap`) and sets the frame's `drawCallCapExceeded` flag (the frame-graph flag); the frame is STILL drawn — the cap is observation, never an execution gate (the G-R5 precedent). The M2-SPRITE-04 counters (programChanges, uploadBytes, renderTargetBytes) are recorded on the frame before/during the GL work; a FAILED submit zeroes them (a failed frame reports nothing — the frameStats() contract). The frame's world -> NDC matrix (the IsoCamera / M2-PROJ-01 combined matrix — RENDER-006) is the per-frame uniform. The batcher reference is non-const only because the batcher's pool accessors are (the M2-SPRITE-01 house quirk); submit mutates nothing on the batcher. n * 52 B, G draw calls, zero allocation — the header Performance section.", "budget": "O(G * 5 + n) CPU (G groups, n instances), one upload of", "experimental": false}, + {"name": "laige::render::SpriteRenderer::frameStats", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 379, "signature": "[[nodiscard]] SpriteDrawStats frameStats() const noexcept", "summary": "The per-frame counters of the last SUCCESSFUL submit (a failed submit reads zero — it zeroes them). O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::totals", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 382, "signature": "[[nodiscard]] SpriteDrawTotals totals() const noexcept", "summary": "The since-construction totals (successful submits only). O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::textureMemoryBytes", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 391, "signature": "[[nodiscard]] std::uint64_t textureMemoryBytes() const noexcept", "summary": "The VRAM estimate of the atlas registry (M2-SPRITE-04, the RENDER-001 texture-memory feed): the sum of width * height * 4 bytes over the BOUND atlases (GL_RGBA8 — the upload's byte count). Updated at bindAtlas (a re-bind replaces: the old texture's bytes are subtracted, the new ones added); reads 0 in the stopped state. A GAUGE (not a per-frame counter) — it changes only on the set-up/asset path, never per frame. O(1).", "budget": null, "experimental": false}, {"name": "laige::kMaxArchetypes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 184, "signature": "inline constexpr std::uint32_t kMaxArchetypes = 256", "summary": "The engine-level cap on distinct component sets (archetypes) per world (CORE-005: a named engine constant, the kMaxComponentTypes precedent — a game's component-set vocabulary is orders of magnitude smaller than its entity count; raising it is an ADR, not a knob).", "budget": null, "experimental": false}, {"name": "laige::kMaxArchetypeComponents", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 189, "signature": "inline constexpr std::uint32_t kMaxArchetypeComponents = 32", "summary": "The engine-level cap on components per entity (per archetype) (CORE-005). A 33rd distinct component on one entity fails addComponent with BudgetExhausted.", "budget": null, "experimental": false}, {"name": "laige::kInitialArchetypeRows", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 194, "signature": "inline constexpr std::uint32_t kInitialArchetypeRows = 16", "summary": "Rows reserved when an archetype is created (or the world's entity capacity, when smaller). Growth doubles from here (see the header preamble, \"Reserve policy\").", "budget": null, "experimental": false}, diff --git a/roadmap/M2-rendering-2.5d.md b/roadmap/M2-rendering-2.5d.md index 2b2cbb7..45d23a5 100644 --- a/roadmap/M2-rendering-2.5d.md +++ b/roadmap/M2-rendering-2.5d.md @@ -183,7 +183,7 @@ if M2 slips, and its status is recorded in M2-EXIT-01. - **Verify:** `ctest -R sprite_frames` green. - **Size:** ~100 lines + tests -- [ ] **M2-SPRITE-04 · Render observability + draw-call budget (G-R2)** +- [x] **M2-SPRITE-04 · Render observability + draw-call budget (G-R2)** - **Refs:** RENDER-001 (state changes, draw submissions observable), PRD §9.3 G-R2 - **Depends:** M2-SPRITE-02 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index 4c35d66..8f532ff 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 | 15 | ⬜ in progress | +| M2 | 33 | 16 | ⬜ 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** | **61** | | +| **Total** | **194** | **62** | | --- @@ -239,6 +239,7 @@ One line per completed (or split/renumbered) step. | 2026-10-04 | M2-SPRITE-02 | `—` | GPU instanced draw + sprite shader (M2-SPRITE-02 scope, nothing else): **API** — `SpriteRenderer` (public header `src/laige-render/include/laige/render/sprite_renderer.h` + implementation `sprite_renderer.cpp`, move-only; default = stopped state) — `create(const GlContext&, Options{maxInstances, maxAtlases, primitiveQuery})` the set-up path (validates first-failure-wins → `InvalidArgument` + one rate-limited Warn `sprite_renderer/options_invalid`; requires a valid context + `makeCurrent`; compiles ONE GLSL 3.30 shader pair (vertex + fragment) + links one program; creates the 32 B quad VBO (`GL_STATIC_DRAW`), the per-frame instance buffer (`maxInstances × 52` B, `GL_DYNAMIC_DRAW` — 13 floats: pos.xy, scale.xy, uv u0v0u1v1, tint rgba, rot), and the VAO (the quad corner at divisor 0; the five instance attributes at divisor 1, pinned `layout(location=0..5)`); the atlas registry (`maxAtlases × 8` B) — any GL failure → `GlUnavailable` + ONE structured Error (`sprite_renderer/program_creation_failed` or `resource_creation_failed`, with the GL error code + the sanitized info log), no partial renderer); `bindAtlas(atlasId, w, h, rgba)` the set-up/asset path (one call per atlas per scene load; `atlasId ∈ [0, maxAtlases)`, `w, h ∈ [1, capabilities().maxTextureSize]`, `rgba.size() == w·h·4`; GL_RGBA8, `GL_NEAREST`, `GL_CLAMP_TO_EDGE`, no mipmaps — the UV sub-rects are pixel-exact, the M2-GOLD-01 contract; re-binding an id REPLACES the texture; a GL upload failure → `GlUnavailable` + one Error `atlas_upload_failed`); `submit(batcher, worldToNdc)` the per-frame draw (preconditions, first failure wins — a FAILED submit draws nothing, zeroes the frame counters, leaves the totals unchanged, and leaves no stale sprite-pass state: stopped renderer → `InvalidArgument`; context valid + current (`makeCurrent` idempotent — a cross-thread live takeover → `GlUnavailable`, the P0 EGL contract); the batcher built for the current frame (`frameBuilt()` — an open window with declared items is never drawn as an empty frame, CORE-008); the frame's instance count ≤ `maxInstances` (else `BudgetExhausted` + one rate-limited Warn `instance_capacity`, PERF-008); every group's atlas in the registry AND bound (else `InvalidArgument` — the stateless pre-state validation, one failure per frame); then: the per-frame state setup (the render-target frame buffer bind — `GlContext::frameBuffer()`, the offscreen FBO on headless contexts, the surfaceless default frame buffer is not a valid draw target — + the viewport matched to the render-target size, the driver default 0×0 would clip every draw to nothing — + the depth test OFF (the painter's order is the batcher's — the 2.5D depth is engine-owned, FR-2.2/M2-ISO-01, never derived from the projection) + the blend ENABLED + the program + the per-frame `uWorldToNdc` uniform), the frame's instances packed into the pre-allocated staging (a contiguous verbatim float copy — no arithmetic on the CPU — the GPU owns the math, FR-2.2/PERF-003), ONE `glBufferSubData` upload, and per group IN THE Batcher's published order (ascending (atlas, material, blend) — RENDER-003) the texture bind (only when the atlas CHANGED → counted in `textureBinds`), the blend function (only when the mode CHANGED → counted in `blendChanges` — Alpha: `SRC_ALPHA`/`ONE_MINUS_SRC_ALPHA`, Additive: `ONE`/`ONE`), and ONE `glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, n_group)` (counted in `drawCalls`/`instances`); after the pass (success or failure): program + VAO restored to 0 — the pass owns only its own program/VAO; the blend function, texture bind, frame buffer, and viewport PERSIST (the last atlas/blend carry across frames — the counters count real changes)); **shader** (the whole M2 sprite feature, minimal GLSL 3.30) — vertex: `world = aPos + aCorner * aScale` (the unit quad's corner scaled in WORLD units and translated — the scale applied BEFORE the projection, the `SpriteItem.scale` contract), projected through `uWorldToNdc` (2D ground plane, z = 0), the projected offset rotated by the per-instance rotation IN SCREEN SPACE (NDC — the `SpriteItem.rotation` contract), the per-vertex UV the per-instance UV sub-rect mapped onto the quad (`(-0.5,-0.5) → u0/v0`, `(0.5,0.5) → u1/v1`); fragment: `texture(uAtlas, vUv) * vTint` (the multiplicative RGBA tint); **counters (RENDER-001 — the M2-SPRITE-04 profiler feed)** — `SpriteDrawStats` (the per-frame counters of the last successful submit: `drawCalls`, `textureBinds`, `blendChanges`, `instances`, `primitives`) + `SpriteDrawTotals` (since-construction, successful submits only — `frames`, `drawCalls`, `textureBinds`, `blendChanges`, `instances`, `primitives`); GL 3.3 core has NO draw-call query primitive: the dispatch count is the engine's own bookkeeping (`drawCalls` == the group count), and the GL-side cross-check is the OPT-IN `Options::primitiveQuery` (a `PRIMITIVES_GENERATED` query around every submit + a `glFinish` read — a CPU/GPU sync, a DIAGNOSTIC mode for the offscreen test/CI path and the profiler, never the shipping frame loop, RENDER-005; the 32-bit `glGetQueryObjectuiv` read caps the count at 2^32−1 primitives — beyond the realistic frame of 2^31 instances (2 per instance); the glad-generated `glQueryCounter` has a broken 2-argument signature in the vendored 2.0.8 loader, hence the 32-bit read; `ponytail:` comment at the site); **determinism** — presentation-only (ARCH-009): the submit path is a verbatim float copy (no arithmetic on the CPU — the GPU owns the math); the rotation's cos/sin is driver-float (bit-exact across runs of the same driver, not across drivers — the golden-image contract M2-GOLD-01 pins the environment); no allocation on the per-frame path (the staging buffer, the instance buffer, and the registry are sized at create — PERF-003); one owner thread (the render thread's submit stage — CONC-001), no locks/atomics; **no new budget entry** — the composite 50k render-CPU budget (2 ms, PRD §8.1 `sprites_50k_cpu`) is measured with this stage (M2-PERF-01); **tests** — `tests/laige-render/sprite_draw_tests.cpp` (12 tests / 4 suites; CTest entry `sprite_draw` = the step's Verify command, TIMEOUT 300): `SpriteDrawCreate` (options validation matrix + the stopped state — no GL), `SpriteDrawState` (the stopped-state behavior + the `frameBuilt` gate + the instance budget + the unbound-atlas rejection — no GL), `SpriteDrawSmoke` (the roadmap's offscreen render: a 1 000-sprite scene — a 32×32 lattice of unit tiles (scale 0.125) in two atlases (a 4×4 checkerboard + a solid) and three groups ((0,0,Alpha) 796 instances, (0,0,Additive) 200, (1,0,Alpha) 4) on a cleared (0,0,255) frame, plus 3 probe sprites) — `SpriteRenderer::create` + `bindAtlas` ×2 + one submit: asserts `drawCalls == 3 == the group count` (the machine-greppable `sprite-draw:` line: `groups=3 draw_calls=3 instances=1000 primitives=2000 texture_binds=2 blend_changes=3 nonempty=16384 reference_mismatches=0`), the GL-side cross-check `primitives == 2000 == 2 × 1000` (the opt-in query ON), and the whole 128×128 frame against a CPU reference rasterizer that walks the built frame in the exact draw order and accumulates the per-group blend in double (±1 byte per channel — the GPU float32 vs the reference double — plus three rounding-exact probe pixels: an alpha checkerboard texel, an additive-over-clear texel, and the solid atlas-1 texel; the reference is a CPU double-precision reimplementation of the shader's exact pipeline — the 2×2 linear inverse + the screen-space rotation inverse + the UV mapping); `SpriteDrawPipeline` (the M2-GL-02 integration: a 100-frame offscreen run through `RenderThread` — the batch stage (clear + `beginFrame` + 1 000 `add` + `build`) + the submit stage (`SpriteRenderer::submit`) on the render thread, the `GlContext` handoff (release on the test thread → `makeCurrent` in `onStart` → `release` in `onStop`), the submit loop PACED to the render thread (`waitIdle` per frame — a tight loop would outrun the software-GL render and drop 98 of 100); asserts the exact since-construction totals: `frames=100`, `drawCalls=300`, `instances=100 000`, `textureBinds=200` (2/frame — the last-atlas carries across frames), `blendChanges=201` (3 on frame 1 + 2 on each later frame — the last-blend carries across frames), `primitives=0` (the query OFF in this renderer), `framesSubmitted=100`/`framesRendered=100`/`framesDropped=0` — + the last frame survives in the FBO after ordered shutdown (the P0 probe pixel read back exact)); `ctest -R sprite_draw` green (the GL suites `GTEST_SKIP` on an environment failure — the CI path: Mesa software GL on the offscreen FBO, the sandbox's no-GPU rule); **docs** (DOC-007, same change) — NEW `docs/api/sprite_renderer.md` (the full API contract: the API, the one-draw-per-group + the state-persistence model, the shader + the pass's GL state model, the counters, the DOC-004 **Performance** section — O(G×5 + n) per frame, zero allocation, the state-change observability, the render-target/viewport/state-persistence/primitiveQuery traps — ownership/lifetime/threading (the context outlives the renderer), a performant example, the misuse warnings), `docs/api/gl_context.md` (the NEW `frameBuffer()` row — the render-target frame buffer handle: the offscreen FBO on headless, 0 on windowed/stopped, no GL call; the per-frame draw path binds it once per frame), `docs/api/sprite_batcher.md` (the NEW `frameBuilt()` row + the submit-stage gate + the Related link), `docs/README.md` API index, `docs/concepts/coordinates.md` §4.8 (the sprite-draw narrative + the World→screen conversion table row now shipped + §6/Related), `src/laige-render/README.md` status; `laige-api.json` regenerated (`cmake --build build --target laige-api` — 1211 symbols / 37 headers — +36: `SpriteDrawStats` + 5 fields, `SpriteDrawTotals` + 6 fields, `SpriteRenderer` + 8 members + 2 constants, `GlContext::frameBuffer`, `SpriteBatcher::frameBuilt`; `api-real-tree`/`api-check-fresh` green), `include-lint` (65 files), and `determinism-lint` OK; **local verification** — the canonical tree builds warning-free under NFR-8.10 with full `ctest` green (incl. `sprite_draw` 12/12 + the API/lint entries); the remaining five trees re-verified in the follow-up (build-clang/build-release/build-shared/build-asan/build-tsan); **compat** — additive only (no existing symbol's signature or meaning changed; the new public API is `SpriteRenderer`/`SpriteDrawStats`/`SpriteDrawTotals` + `GlContext::frameBuffer` + `SpriteBatcher::frameBuilt`); **scope note** — the implementation exceeds the roadmap's "~300 lines + tests" sanity note for the same documented-contract reason as M2-SORT-01/M2-SPRITE-01 (the header preamble + the API doc are part of the implementation per CORE-006/DOC-004); Progress Board M2 14/33, total 60/194 | | 2026-10-04 | M2-ISO-02 | `—` | **Budget revision (CI follow-up to M2-ISO-02)** — the `iso_depthkey_rebuild` gate (PRD §8.1, mean ≤ 0.2 ms, 10k dirty cells after a terrain edit) failed the CI `Linux x64 (clang++)` reference lane repeatedly on **unchanged engine code** (the `setTile` path has not changed since the 2026-10-01 workload fix): the reference lane (Clang 18.1.3, CMake Debug, ubuntu-24.04 shared runner) measures the workload at **0.194–0.267 ms**, straddling the 0.2 ms bar — a zero-margin gate whose pass/fail was decided by runner load, not engine regression (evidence: PR lane run 37192995927 attempts 1–2 — 0.194142 PASS / 0.201495 FAIL, then 0.266709 / 0.267328 FAIL on a slow shared runner; master merge-lane run 37194767648 on commit `70830a7` — 0.202146 / 0.202018 FAIL, the `Linux x64 (g++)` lane passing the same workload on the same commit). Revised per the NFR-8.1 policy ("unless the budget is revised via a PRD revision"): **PRD v0.4** §8.1 `≤ 0.2 ms` → `≤ 0.3 ms`; **`budgets.json`** `target` 0.2 → **0.3**, `measured` 0.0814067 → **0.202204** (latest recorded CI reference value, worse backend); **new baseline** `docs/benchmarks/baselines/m2-iso-depth-table-budget-rebaseline.md` (the eighth baseline, verbatim CI reports); **docs** — `docs/api/iso_depth_table.md`, `docs/api/iso_depth_key.md`, `docs/concepts/coordinates.md`, `docs/README.md`, and the M2/M5/M8 roadmap budget references updated to the revised value in the same change (DOC-003), baselines index entry added. No engine code, workload, or test change — budget + docs only (methodology §1: no regression occurred; this is a calibration of the gate's margin on the reference toolchain). | | 2026-10-04 | M2-SPRITE-03 | `—` | Atlas UV frame animation hook (M2-SPRITE-03 scope, nothing else): **API** — header-only `src/laige-render/include/laige/render/sprite_frames.h` (`laige::render`, public, additive): `SpriteFrameLayout` (the atlas sheet frame layout in texels — `frameWidth`/`frameHeight`, `columns`/`rows`, `frameSpacing` (the gap between adjacent frames), `sheetBorder` (the sheet-edge margin); plain value, no ownership, validated at use time) + `kSpriteFrameMaxAtlasTexels` (2^24 — the float-exact atlas domain) + `spriteFrameUv(frameIndex, layout, atlasWidth, atlasHeight) → Result` (the pure frame-index → UV sub-rect computation: O(1), zero allocation, no logging, no GL; 4 exactly-rounded float divisions — the UV corners are the exactly-rounded k/W values); **sheet model (documented)** — row-major, frame 0 at the top-left (`col = i % columns`, `row = i / columns`); frame (c, r) occupies `[border + c·(fw+spacing), +fw) × [border + r·(fh+spacing), +fh)` texels; the tight sheet is `2·border + cols·fw + (cols−1)·spacing` wide/tall (a wider atlas = slack margin, the fit check is authoritative); **v-axis** — v = 0 is the first texel row of the uploaded RGBA array (the sheet's TOP row — the M2-SPRITE-02 GL_NEAREST "texel row = floor(v·h)" contract), so frame row 0 carries the smallest v; **failure (CORE-008, API-008, first failure wins)** — the layout is caller-owned, untrusted asset metadata (SCALE-004): zero extents / atlas outside [1, 2^24] / frameIndex ≥ columns·rows (the documented OUT-OF-RANGE contract: the engine NEVER wraps silently) / the frame's rect beyond the atlas — all `InvalidArgument`, never a UV rect (the u64 overflow guard rejects an adversarial stride before the col·stride multiplication can wrap — CPP-004/SCALE-004); **exactness** — float-exact domain (atlas ≤ 2^24): every pixel coordinate < 2^24 is exactly float-representable and the invariant u1 > u0, v1 > v0 holds EXACTLY (two distinct k/W never round to the same float) — pure function, bit-identical every platform/build (presentation-only, ARCH-009/010); **the M3 hook (data-driven, ARCH-009)** — `SpriteItem` gained `frameIndex` (the declared animation frame — the caller sets it + sets `uv` to the frame's rect via `spriteFrameUv`; the batcher carries the index through untouched — `uv` is what the M2-SPRITE-02 renderer draws; M3 animation drives the frame advance on top of this same layout); **batcher delta** — `SpriteItem` +1 u32 (76 B/slot, ~136 B/capacity slot, 6.8 MB at 50k); the batcher stays pure integer bookkeeping (the index passes through untouched); **tests** — `tests/laige-render/sprite_frames_tests.cpp` (new CTest entry `sprite_frames` = the step's Verify command; 11 tests / 4 suites, no GL): `SpriteFrameUvGolden` (hand-computed UVs for documented layouts: packed 4×4 sheet, tight 82×82 margin sheet, non-square 12×8 frames with spacing, single column/row, the idempotent call), `SpriteFrameErrors` (out-of-range at count / beyond / u32 top with the no-wrap pin, zero-extent layouts, the atlas domain incl. the exact 2^24 top, the fit failures incl. the one-texel-short spacing + the exact boundary + the adversarial stride), `SpriteFrameProperty` (2 000 seeded random tight sheets vs the documented formula — the float-exact invariants + the row/col adjacency rule (exact touch packed, strict gap spaced) — + the 1 000-conversion zero-allocation proof, the iso_picking/depth_sort precedent), `SpriteFrameItemPassThrough` (the frameIndex + uv pair survives the batcher's add/build/get — the M3 entry point); **docs** — NEW `docs/api/sprite_frames.md` (the full contract + Performance per DOC-004 + the M3 hook + misuse warnings), `docs/api/sprite_batcher.md` (the SpriteItem table row + the 76 B/slot update + Related), `docs/concepts/coordinates.md` §4.8 (the UV bullet) + §5 table row (Atlas frame → UV sub-rect, shipped) + Related, `docs/README.md` API index, the module README status paragraph; `laige-api.json` regenerated (1221 symbols / 38 headers, +10 symbols / +1 header); **verification** — all six local trees warning-clean + full ctest green (build, build-clang, build-release, build-shared, build-asan, build-tsan: `ctest -R sprite_frames` + the full `laige-render_tests`); `api-real-tree`/`api-check-fresh`, `include-lint` (38 public headers), and `determinism-lint` green; **budget** — no standalone `budgets.json` entry (the per-frame conversion cost is part of the composite 50k render-CPU budget, measured with M2-PERF-01); **compat** — additive only (no existing symbol's signature or meaning changed; the new public API is `SpriteFrameLayout`/`spriteFrameUv`/`kSpriteFrameMaxAtlasTexels` + `SpriteItem.frameIndex`); **scope note** — the implementation exceeds the roadmap's "~100 lines" sanity note for the same documented-contract reason as M2-SORT-01/M2-SPRITE-01 (the header preamble + the API doc are part of the implementation per CORE-006/DOC-004); Progress Board M2 15/33, total 61/194 | +| 2026-10-04 | M2-SPRITE-04 | `—` | Render observability + the draw-call budget (G-R2, PRD §9.3; M2-SPRITE-04 scope, nothing else): **API** — `SpriteDrawStats` gained `programChanges` (the pass's `glUseProgram` count — 1 per successful non-empty frame), `uploadBytes` (the frame's instance upload, n × 52 B), `renderTargetBytes` (the render-target size drawn, w × h × 4), and the G-R2 `drawCallCapExceeded` flag; `SpriteDrawTotals` gained the `programChanges`/`uploadBytes`/`renderTargetBytes` sums + `capExceededFrames`; `SpriteRenderer::Options` gained the configurable per-pass draw-call cap `maxDrawCalls` (domain `[1, kSpriteRendererMaxInstances]` — a frame's draw calls can never exceed its instance count; documented default `kSpriteRendererDefaultDrawCalls` = 64 = 2× the PRD §8.1 worst-case reference-scene budget of 30 draw calls) + the `maxDrawCalls()` accessor; NEW `textureMemoryBytes()` gauge (the texture-memory VRAM estimate: the bound atlases' w × h × 4 sum — updated at `bindAtlas`, a re-bind subtracts the old upload + adds the new, reads 0 in the stopped state); **G-R2 semantics** — a frame whose draw calls (== its group count) STRICTLY exceed the cap is STILL drawn (observation, never an execution gate — the G-R5 precedent): one rate-limited Warn `draw_call_cap` (fields `capacity`, `draw_calls`) on the over-cap submit, the frame's `drawCallCapExceeded` flag (the frame-graph flag M2-PROF-01 will report) + the totals' `capExceededFrames`; **contract** — a failed submit zeroes the frame counters (now enforced on all three GL failure paths: upload, query creation, draw); the atlas registry slot grew 8 B → 16 B (the width/height bookkeeping: 32 KB → 64 KB at 4096 slots); **tests** — NEW `tests/laige-render/render_counters_tests.cpp` (new CTest entry `render_counters` = the step's Verify command; 8 tests / 4 suites): `RenderCountersCreate` (no GL: the maxDrawCalls validation first-failure-wins + one Warn `options_invalid`, the default reaching the context check → `GlUnavailable`, the stopped state reads zero), `RenderCountersScene` (GL: the roadmap's known small scene — 10 sprites, 2 atlases, 2 blends → 3 groups — per-frame counters pinned EXACTLY for frame 1 {3 draws, 2 binds, 3 blend changes, 1 program change, 10 instances, 520 B upload, 65 536 B target} + frame 2 {3, 2, 2, 1, 10, 520, 65 536 — the cross-frame last-atlas/last-blend state persistence} + the since-construction totals, the empty frame counts nothing, the opt-in `PRIMITIVES_GENERATED` feed = 20), `RenderCountersCap` (GL: the warn fires AT the configured count (cap=2) with the pinned fields, the frame still drawn, the second frame repeats the warn + total, a 2-group frame AT the cap has no flag + no new warn (1 bind + 1 blend change from the persisted state), cap=3 → no warn at the cap), `RenderCountersMemory` (GL: the gauge exact — two 4×4 atlases = 128 B; a second renderer's 8×8 = 256 B, the 16×16 re-bind replacement = 1024 B); **docs** — `docs/api/sprite_renderer.md` (NEW "Render observability + the draw-call cap" section: the field table + the cap semantics; the API snippet, the create/bindAtlas/submit/frameStats bullets, the Performance section, the misuse warnings), `docs/README.md` index entry, the module README status paragraph, the roadmap box; `laige-api.json` regenerated (1233 symbols / 38 headers, +12 symbols); **verification** — all six local trees warning-clean + full ctest: build 111/111, build-clang 111/111, build-release 100/100, build-shared 111/111, build-asan 108/108, build-tsan 106/108 — the two TSan failures (`laige-render_tests`, `render_counters`) are a DRIVER-INTERNAL teardown data race in this machine's Mesa 26.2.3 llvmpipe (both race accesses are inside libgallium: the `eglDestroyContext` teardown destroys the driver's internal mutex/condvar while the llvmpipe worker thread is still inside it — no engine code between the pthread frames; the identical GL pattern (context → query submit → readback → context destruction) passes in this tree's `SpriteDrawSmoke.ThousandSpriteFrame` and on the CI TSan lane — the M2-SPRITE-02 CI evidence on Mesa 25.2.8 — the CI TSan lane is the contract's TSan authority per the M2-GL-01 precedent); `api-real-tree`/`api-check-fresh`, `tools/laige-include-lint`, and `tools/laige-determinism-lint` green; **budget** — no standalone `budgets.json` entry (the counters are O(1) bookkeeping; the composite 50k render-CPU budget is measured with M2-PERF-01); **compat** — additive only (no existing symbol's signature or meaning changed; the new public API is the 4 `SpriteDrawStats` fields + the 4 `SpriteDrawTotals` fields + `Options::maxDrawCalls` + `kSpriteRendererDefaultDrawCalls` + `maxDrawCalls()` + `textureMemoryBytes()` = 12 symbols); Progress Board M2 16/33, total 62/194 | --- diff --git a/src/laige-render/README.md b/src/laige-render/README.md index 0af99c9..8904670 100644 --- a/src/laige-render/README.md +++ b/src/laige-render/README.md @@ -294,8 +294,11 @@ then the per-group texture bind / blend function / instanced draw; a failed submit draws nothing, counts nothing, and leaves no stale sprite-pass state). The observable counters (`SpriteDrawStats` / `SpriteDrawTotals`: draw calls == group count, texture binds, blend -changes, instances, the opt-in `PRIMITIVES_GENERATED` primitives) feed -the M2-SPRITE-04 profiler. `GlContext::frameBuffer()` exposes the +changes, instances, the opt-in `PRIMITIVES_GENERATED` primitives — +extended in M2-SPRITE-04 with the profiler's per-frame fields: program +changes, upload volume, render-target use, the texture-memory VRAM +estimate, and the G-R2 draw-call-cap flag) feed the M2-PROF-01 +profiler. `GlContext::frameBuffer()` exposes the offscreen FBO the pass binds per frame (the surfaceless default frame buffer is not a valid draw target; the pass also sets the viewport). The offscreen 1 000-sprite render is verified against a CPU reference @@ -332,5 +335,32 @@ required). No standalone `budgets.json` entry: the per-frame conversion cost is part of the composite 50k render-CPU budget, measured with M2-PERF-01. -Render observability (M2-SPRITE-04) and sim→render wiring land in the -remaining M2 steps. +M2-SPRITE-04 landed render observability + the draw-call budget — +the M2-SPRITE-04 profiler fields on top of the M2-SPRITE-02 counters +(G-R2, PRD §9.3): `SpriteDrawStats` gained `programChanges`, +`uploadBytes` (the frame's instance upload, n × 52 B), +`renderTargetBytes` (the render-target size drawn, w × h × 4), and the +G-R2 `drawCallCapExceeded` flag; `SpriteDrawTotals` gained the +`programChanges`, `uploadBytes`, `renderTargetBytes`, and +`capExceededFrames` sums. `SpriteRenderer::Options` gained the +configurable per-pass draw-call cap (`maxDrawCalls`, documented +default `kSpriteRendererDefaultDrawCalls` = 64 — 2× the PRD §8.1 +worst-case reference-scene budget of 30 draw calls; domain +`[1, kSpriteRendererMaxInstances]`); an over-cap frame fires one +rate-limited Warn `draw_call_cap` (fields `capacity`, `draw_calls`), +sets the flag, and is STILL drawn — observation, never an execution +gate (the G-R5 precedent). The new `textureMemoryBytes()` gauge +reports the bound atlases' `w × h * 4` sum (the texture-memory VRAM +estimate — updated at each `bindAtlas`, the replacement subtracts the +old upload). API contract in +[docs/api/sprite_renderer.md](../docs/api/sprite_renderer.md) (the +"Render observability + the draw-call cap" section), tests under +[tests/laige-render](../tests/laige-render) (CTest entry +`render_counters` — `RenderCountersCreate` is GL-free, the scene/cap/ +memory suites require a usable OpenGL 3.3 environment and self- +`GTEST_SKIP` on an environment failure). No standalone `budgets.json` +entry: the counters are O(1) bookkeeping and the composite 50k +render-CPU budget is measured with this stage (M2-PERF-01). + +The remaining M2 steps (M2-TILE-01, M2-PAR-01, text/UI, M2-PERF-01) +land in later steps. diff --git a/src/laige-render/include/laige/render/sprite_renderer.h b/src/laige-render/include/laige/render/sprite_renderer.h index a4e7bd3..37026e5 100644 --- a/src/laige-render/include/laige/render/sprite_renderer.h +++ b/src/laige-render/include/laige/render/sprite_renderer.h @@ -13,7 +13,9 @@ // O(group count), not O(sprite count). // // SpriteDrawStats The per-frame counters (the last successful -// submit) +// submit) — incl. the M2-SPRITE-04 fields +// (program changes, upload volume, render-target +// use, the G-R2 draw-call-cap flag) // SpriteDrawTotals The since-construction counters // SpriteRenderer The submit stage: one program, one quad VBO, // one per-frame instance buffer, the atlas @@ -115,18 +117,34 @@ // // Per frame: O(G * 5 + n) CPU work (G = group count, n = frame // instance count): the per-group instance attribute-pointer setup -// (5 calls), the state checks, one upload of n * 52 B, G draw calls. Zero -// allocation (the staging buffer and the GL buffers are sized at -// create, the frame budget maxInstances). The state changes are -// observable: textureBinds counts the glBindTexture calls in the -// pass, blendChanges the blend-function changes, drawCalls the -// instanced draws, instances the drawn instances — one of each per -// group at minimum (RENDER-001). The composite 50k render-CPU budget +// (5 calls), the state checks, one upload of n * 52 B, G draw calls, +// and a handful of integer counter updates (the M2-SPRITE-04 +// observability bookkeeping — O(G) adds, no allocation, no GL work, +// negligible by the PERF-004 standard). Zero allocation (the staging +// buffer and the GL buffers are sized at create, the frame budget +// maxInstances). The state changes are observable (RENDER-001): +// textureBinds counts the glBindTexture calls in the pass, +// blendChanges the blend-function changes, programChanges the +// glUseProgram calls, drawCalls the instanced draws, instances the +// drawn instances — one of each per group at minimum. The +// M2-SPRITE-04 profiler fields: uploadBytes (the per-frame instance +// upload volume), renderTargetBytes (the render-target size drawn), +// textureMemoryBytes() (the VRAM estimate: the bound atlases' +// w*h*4 sum, updated at bindAtlas), and the G-R2 per-pass draw-call +// cap (Options::maxDrawCalls — exceeding it WARNS + flags the frame, +// it never drops the frame). The composite 50k render-CPU budget // (PRD §8.1, M2-PERF-01) is measured with this stage; no standalone // budgets.json entry here (the sort cost is the depth_sort_10k // budget — M2-SORT-01). // // Misuse warnings: +// - create the renderer with maxDrawCalls >= the batcher's group +// count the scene can actually produce — a cap below the scene's +// group count fires the G-R2 warn EVERY frame (the frame is still +// drawn — the cap is observation, never an execution gate); the +// documented default (kSpriteRendererDefaultDrawCalls = 64) is +// 2x the PRD §8.1 worst-case reference-scene budget of 30 draw +// calls; // - create the renderer with maxInstances >= the batcher's // maxSprites — a frame above the renderer's budget fails // BudgetExhausted (the batcher's own overflow policy bounds the @@ -163,25 +181,42 @@ namespace laige::render { // dispatch count is the engine's own bookkeeping (drawCalls — what the // profiler ships), and the GL-side cross-check is the // PRIMITIVES_GENERATED count of the pass (2 per instance of the -// 4-vertex strip). +// 4-vertex strip). The M2-SPRITE-04 fields: `programChanges` (the +// glUseProgram calls in the pass — 1 for every non-empty successful +// submit: the pass sets its own program and restores 0 at the end), +// `uploadBytes` (the frame's instance-upload volume, n * 52 B — the +// upload's RENDER-004 observable work), `renderTargetBytes` (the +// render-target size drawn, width * height * 4 — the render-target +// use), and `drawCallCapExceeded` (the G-R2 frame-graph flag: the +// frame's draw calls exceeded Options::maxDrawCalls — the frame was +// still drawn, the cap is observation, never an execution gate). struct SpriteDrawStats { - std::uint32_t drawCalls{0}; // instanced draw calls (== group count) - std::uint32_t textureBinds{0}; // texture binds in the sprite pass - std::uint32_t blendChanges{0}; // blend-function changes in the pass - std::uint32_t instances{0}; // instances drawn this frame - std::uint32_t primitives{0}; // PRIMITIVES_GENERATED (query on only) + std::uint32_t drawCalls{0}; // instanced draw calls (== group count) + std::uint32_t textureBinds{0}; // texture binds in the sprite pass + std::uint32_t blendChanges{0}; // blend-function changes in the pass + std::uint32_t programChanges{0}; // program changes (glUseProgram) in the pass + std::uint32_t instances{0}; // instances drawn this frame + std::uint32_t primitives{0}; // PRIMITIVES_GENERATED (query on only) + std::uint64_t uploadBytes{0}; // the frame's instance upload, in bytes + std::uint64_t renderTargetBytes{0}; // the render-target size drawn, in bytes + bool drawCallCapExceeded{false}; // G-R2: draw calls exceeded the cap }; // The since-construction totals (the frames counter counts successful // submits only). The M2-SPRITE-04 profiler reads these on the owner -// thread (DBG-005). +// thread (DBG-005). `capExceededFrames` counts the successful submits +// whose draw calls exceeded the cap (the G-R2 frame-graph total). struct SpriteDrawTotals { std::uint64_t frames{0}; std::uint64_t drawCalls{0}; std::uint64_t textureBinds{0}; std::uint64_t blendChanges{0}; + std::uint64_t programChanges{0}; std::uint64_t instances{0}; std::uint64_t primitives{0}; + std::uint64_t uploadBytes{0}; + std::uint64_t renderTargetBytes{0}; + std::uint64_t capExceededFrames{0}; }; // The submit stage of the frame pipeline (M2-SPRITE-02): one instanced @@ -189,7 +224,19 @@ struct SpriteDrawTotals { // the minimal GLSL 3.30 sprite shader (the header preamble). class SpriteRenderer { public: - // The submit stage's configuration (API-006): both fields are + // The frame-slot index width (the batcher's slot domain, the + // instance buffer's index domain). + static constexpr std::uint32_t kSpriteRendererMaxInstances = + 0xFFFFFFFFu; + // The atlas registry cap (the Options comment: 32 KB at 4096 slots). + static constexpr std::uint32_t kSpriteRendererMaxAtlases = 4096u; + // The documented default per-pass draw-call cap (G-R2, PRD §9.3): + // 2x the PRD §8.1 worst-case reference-scene budget of 30 draw + // calls — the 50k-sprite exit scene passes with 2x headroom, and a + // scene that blows the cap by more than 2x is visible at a glance. + static constexpr std::uint32_t kSpriteRendererDefaultDrawCalls = 64u; + + // The submit stage's configuration (API-006): all fields are // validated at create (the first failure wins, one Warn). struct Options { // The per-frame instance budget: the instance buffer is sized to @@ -200,11 +247,24 @@ class SpriteRenderer { // failure, CORE-008). std::uint32_t maxInstances{0}; // The atlas-id domain: atlas ids are [0, maxAtlases) — the atlas - // registry is a flat table (8 B per slot). Domain - // [1, kSpriteRendererMaxAtlases]: 4096 slots = 32 KB — far beyond + // registry is a flat table (16 B per slot with the M2-SPRITE-04 + // width/height bookkeeping). Domain + // [1, kSpriteRendererMaxAtlases]: 4096 slots = 64 KB — far beyond // the PRD's 50k-sprite scene (<= 30 draw calls = tens of // atlases at most). std::uint32_t maxAtlases{0}; + // The per-pass draw-call cap (G-R2, PRD §9.3): the maximum + // instanced draw calls (== the frame's group count) ONE submit + // may make. A frame ABOVE the cap is still drawn (observation, + // never an execution gate — the G-R5 precedent): one rate-limited + // Warn (`sprite_renderer/draw_call_cap`) fires, the frame carries + // the `drawCallCapExceeded` flag (the frame-graph flag M2-PROF-01 + // reports), and the total `capExceededFrames` counts it. Domain + // [1, kSpriteRendererMaxInstances] (a frame's draw calls can + // never exceed its instance count — each group has >= 1 + // instance); the documented default is + // kSpriteRendererDefaultDrawCalls (64). + std::uint32_t maxDrawCalls{kSpriteRendererDefaultDrawCalls}; // The opt-in GL PRIMITIVES_GENERATED query around every submit // (the GL-side dispatch cross-check + the M2-SPRITE-04 primitive // feed). OFF by default: zero GL work, zero cost (the hot path, @@ -214,13 +274,6 @@ class SpriteRenderer { bool primitiveQuery{false}; }; - // The frame-slot index width (the batcher's slot domain, the - // instance buffer's index domain). - static constexpr std::uint32_t kSpriteRendererMaxInstances = - 0xFFFFFFFFu; - // The atlas registry cap (the Options comment: 32 KB at 4096 slots). - static constexpr std::uint32_t kSpriteRendererMaxAtlases = 4096u; - // The stopped state (the failed create / moved-from): valid() is // false, every operation fails InvalidArgument, the counters read // zero. No logging. @@ -257,6 +310,7 @@ class SpriteRenderer { // O(1), no allocation. [[nodiscard]] std::uint32_t maxInstances() const noexcept; [[nodiscard]] std::uint32_t maxAtlases() const noexcept; + [[nodiscard]] std::uint32_t maxDrawCalls() const noexcept; // Bind one atlas texture from CPU RGBA8 bytes — the SETUP/asset // path (one call per atlas per scene load; the asset system @@ -299,6 +353,16 @@ class SpriteRenderer { // 5. every group's atlas in the registry and bound (else // InvalidArgument — the stateless pre-state validation, one // failure per frame). + // After the preconditions, BEFORE any GL state: the G-R2 + // per-pass draw-call cap (PRD §9.3) — the frame's group count + // above maxDrawCalls fires one rate-limited Warn + // (`sprite_renderer/draw_call_cap`) and sets the frame's + // `drawCallCapExceeded` flag (the frame-graph flag); the frame is + // STILL drawn — the cap is observation, never an execution gate + // (the G-R5 precedent). The M2-SPRITE-04 counters (programChanges, + // uploadBytes, renderTargetBytes) are recorded on the frame + // before/during the GL work; a FAILED submit zeroes them (a + // failed frame reports nothing — the frameStats() contract). // The frame's world -> NDC matrix (the IsoCamera / M2-PROJ-01 // combined matrix — RENDER-006) is the per-frame uniform. // The batcher reference is non-const only because the batcher's @@ -317,6 +381,15 @@ class SpriteRenderer { // The since-construction totals (successful submits only). O(1). [[nodiscard]] SpriteDrawTotals totals() const noexcept; + // The VRAM estimate of the atlas registry (M2-SPRITE-04, the + // RENDER-001 texture-memory feed): the sum of width * height * 4 + // bytes over the BOUND atlases (GL_RGBA8 — the upload's byte + // count). Updated at bindAtlas (a re-bind replaces: the old + // texture's bytes are subtracted, the new ones added); reads 0 in + // the stopped state. A GAUGE (not a per-frame counter) — it + // changes only on the set-up/asset path, never per frame. O(1). + [[nodiscard]] std::uint64_t textureMemoryBytes() const noexcept; + private: struct Impl; // Construction goes through create() only; the GL resources are diff --git a/src/laige-render/sprite_renderer.cpp b/src/laige-render/sprite_renderer.cpp index dc630ce..73c3ee2 100644 --- a/src/laige-render/sprite_renderer.cpp +++ b/src/laige-render/sprite_renderer.cpp @@ -128,6 +128,7 @@ struct SpriteRenderer::Impl { const GlContext* ctx{}; std::uint32_t maxInstances{0}; std::uint32_t maxAtlases{0}; + std::uint32_t maxDrawCalls{0}; std::int32_t maxTextureSize{0}; bool valid{false}; @@ -140,9 +141,17 @@ struct SpriteRenderer::Impl { std::uint32_t vao{0}; struct Atlas { std::uint32_t texture{0}; + // The M2-SPRITE-04 VRAM estimate's bookkeeping: the uploaded + // width/height (w * h * 4 bytes, GL_RGBA8). + std::uint32_t width{0}; + std::uint32_t height{0}; bool bound{false}; }; std::unique_ptr atlases; + // The VRAM estimate (M2-SPRITE-04): the bound atlases' w * h * 4 + // sum, kept current at bindAtlas (a re-bind replaces: subtract old, + // add new). A gauge — it never changes per frame. + std::uint64_t textureMemoryBytes{0}; // The per-frame CPU staging (maxInstances * kInstanceFloats floats, // sized at create — FR-2.2). std::unique_ptr staging; @@ -229,6 +238,10 @@ std::uint32_t SpriteRenderer::maxAtlases() const noexcept { return (impl_ != nullptr && impl_->valid) ? impl_->maxAtlases : 0; } +std::uint32_t SpriteRenderer::maxDrawCalls() const noexcept { + return (impl_ != nullptr && impl_->valid) ? impl_->maxDrawCalls : 0; +} + SpriteDrawStats SpriteRenderer::frameStats() const noexcept { return (impl_ != nullptr) ? impl_->frame : SpriteDrawStats{}; } @@ -237,6 +250,10 @@ SpriteDrawTotals SpriteRenderer::totals() const noexcept { return (impl_ != nullptr) ? impl_->totals : SpriteDrawTotals{}; } +std::uint64_t SpriteRenderer::textureMemoryBytes() const noexcept { + return (impl_ != nullptr && impl_->valid) ? impl_->textureMemoryBytes : 0; +} + // ------------------------------------------------------------------------ // create — the one-time setup (never inside a frame, PERF-002) // ------------------------------------------------------------------------ @@ -255,6 +272,10 @@ laige::Result SpriteRenderer::create( options.maxAtlases > kSpriteRendererMaxAtlases) { badOption = "maxAtlases"; badValue = options.maxAtlases; + } else if (options.maxDrawCalls < 1 || + options.maxDrawCalls > kSpriteRendererMaxInstances) { + badOption = "maxDrawCalls"; + badValue = options.maxDrawCalls; } if (badOption != nullptr) { LAIGE_LOG_WARN("sprite_renderer", "options_invalid", @@ -280,6 +301,7 @@ laige::Result SpriteRenderer::create( impl->ctx = &ctx; impl->maxInstances = options.maxInstances; impl->maxAtlases = options.maxAtlases; + impl->maxDrawCalls = options.maxDrawCalls; impl->primitiveQuery = options.primitiveQuery; impl->maxTextureSize = ctx.capabilities().maxTextureSize; impl->atlases = std::make_unique(options.maxAtlases); @@ -519,12 +541,23 @@ laige::Status SpriteRenderer::bindAtlas(std::uint32_t atlasId, return laige::Status(laige::ErrorCode::GlUnavailable); } // The last bind wins: replace any previous texture on this id. + // The M2-SPRITE-04 VRAM estimate tracks the replacement (subtract + // the old upload, add the new — both exact GL_RGBA8 byte counts). Impl::Atlas& slot = i.atlases[atlasId]; + const std::uint64_t newBytes = + static_cast(width) * static_cast(height) * + 4u; if (slot.bound) { glDeleteTextures(1, &slot.texture); + i.textureMemoryBytes -= + static_cast(slot.width) * + static_cast(slot.height) * 4u; } slot.texture = texture; + slot.width = width; + slot.height = height; slot.bound = true; + i.textureMemoryBytes += newBytes; return laige::Status{}; } @@ -578,6 +611,23 @@ laige::Status SpriteRenderer::submit(SpriteBatcher& batcher, return laige::Status(laige::ErrorCode::InvalidArgument); } } + // G-R2 (PRD §9.3): the per-pass draw-call cap. The frame's draw + // calls == its group count (one instanced draw per group); above + // the cap the frame is STILL drawn — observation, never an + // execution gate (the G-R5 precedent): one rate-limited Warn + // (LOG-004) + the frame-graph flag on the frame (M2-PROF-01 will + // report it) + the since-construction total. The batcher's group + // count is always <= its u32 capacity, so the narrowing is + // lossless (MSVC C4267). + const std::uint32_t groups = static_cast(batcher.batchCount()); + if (groups > i.maxDrawCalls) { + i.frame.drawCallCapExceeded = true; + LAIGE_LOG_WARN("sprite_renderer", "draw_call_cap", + "The frame exceeds the per-pass draw-call cap; it is " + "still drawn", + laige::log::field("capacity", i.maxDrawCalls), + laige::log::field("draw_calls", groups)); + } // The empty frame: nothing to draw — count it, no GL state. if (n == 0) { i.totals.frames += 1; @@ -593,12 +643,21 @@ laige::Status SpriteRenderer::submit(SpriteBatcher& batcher, // depth test OFF (the painter's order is the batcher's — the 2.5D // depth is engine-owned, FR-2.2), blend ENABLED, the program + // the per-frame matrix uniform. + // The M2-SPRITE-04 render-target-use field: the size of the + // render target this submit draws (width * height * 4, RGBA8). + i.frame.renderTargetBytes = + static_cast(i.ctx->width()) * + static_cast(i.ctx->height()) * 4u; glBindFramebuffer(GL_FRAMEBUFFER, i.ctx->frameBuffer()); glViewport(0, 0, i.ctx->width(), i.ctx->height()); glDisable(GL_DEPTH_TEST); glEnable(GL_BLEND); glBindVertexArray(i.vao); glUseProgram(i.program); + // The M2-SPRITE-04 program-change counter: the pass sets its own + // program at the start (the previous pass's glUseProgram(0) makes + // this a real change — exactly 1 per non-empty successful submit). + i.frame.programChanges += 1; glUniformMatrix4fv(i.locMatrix, 1, GL_FALSE, &worldToNdc[0].x); glActiveTexture(GL_TEXTURE0); glUniform1i(i.locAtlas, 0); @@ -628,7 +687,10 @@ laige::Status SpriteRenderer::submit(SpriteBatcher& batcher, } } // 8. The ONE per-frame upload (RENDER-004: the observable GPU work - // of the pass — n * 52 B; M2-SPRITE-04 meters it). + // of the pass — n * 52 B; M2-SPRITE-04 meters it in + // uploadBytes). + i.frame.uploadBytes = + static_cast(n) * static_cast(kInstanceBytes); glBindBuffer(GL_ARRAY_BUFFER, i.instanceVbo); glBufferSubData(GL_ARRAY_BUFFER, 0, static_cast(static_cast(n) * @@ -638,6 +700,7 @@ laige::Status SpriteRenderer::submit(SpriteBatcher& batcher, if (uploadError != GL_NO_ERROR) { glUseProgram(0); glBindVertexArray(0); + i.frame = SpriteDrawStats{}; // a failed submit counts nothing LAIGE_LOG_ERROR("sprite_renderer", "submit_failed", "The instance upload failed; the frame is not drawn", laige::log::field("gl_error", @@ -655,6 +718,7 @@ laige::Status SpriteRenderer::submit(SpriteBatcher& batcher, if (query == 0) { glUseProgram(0); glBindVertexArray(0); + i.frame = SpriteDrawStats{}; // a failed submit counts nothing LAIGE_LOG_ERROR("sprite_renderer", "submit_failed", "The primitive query object could not be created; " "the frame is not drawn", @@ -750,8 +814,14 @@ laige::Status SpriteRenderer::submit(SpriteBatcher& batcher, i.totals.drawCalls += i.frame.drawCalls; i.totals.textureBinds += i.frame.textureBinds; i.totals.blendChanges += i.frame.blendChanges; + i.totals.programChanges += i.frame.programChanges; i.totals.instances += i.frame.instances; i.totals.primitives += i.frame.primitives; + i.totals.uploadBytes += i.frame.uploadBytes; + i.totals.renderTargetBytes += i.frame.renderTargetBytes; + if (i.frame.drawCallCapExceeded) { + i.totals.capExceededFrames += 1; + } return laige::Status{}; } diff --git a/tests/laige-render/CMakeLists.txt b/tests/laige-render/CMakeLists.txt index 80c3c3a..0719f3f 100644 --- a/tests/laige-render/CMakeLists.txt +++ b/tests/laige-render/CMakeLists.txt @@ -96,7 +96,22 @@ # validation first-failure-wins), the sheet model against the # documented formula + the float-exact domain + the adjacency rule, # and the SpriteItem.frameIndex hook through the batcher) — pure -# float/integer math: no GL environment needed. +# float/integer math: no GL environment needed; and the render +# observability + draw-call budget (M2-SPRITE-04) — the M2-SPRITE-04 +# profiler fields + the G-R2 per-pass draw-call cap +# (render_counters_tests.cpp's RenderCounters* suites: the create +# validation of Options::maxDrawCalls (first failure wins, one Warn) + +# the stopped state reads zero (no GL), the known small scene +# (10 sprites, 2 atlases, 2 blends -> 3 groups) with the per-frame +# counters EXACT (frame 1 + frame 2, the cross-frame state +# persistence) + the since-construction totals + the empty frame +# counting nothing + the opt-in PRIMITIVES_GENERATED feed, the cap +# warn firing at the configured count (pinned fields) with the frame +# still drawn (observation, never an execution gate) + no warn at the +# cap, and the texture-memory VRAM estimate gauge (binds + +# re-bind replacements)) — the GL suites need a usable OpenGL 3.3 +# environment (the documented environment contract: GTEST_SKIPs where +# absent). # # One executable per module (tests/README.md): laige-render_tests links # the module under test plus gtest_main. The unfiltered entry runs the @@ -115,27 +130,29 @@ # `depth_sort` entry is the M2-SORT-01 Verify command # (`ctest -R depth_sort`), the `batcher` entry is the M2-SPRITE-01 # Verify command (`ctest -R batcher`), the `sprite_draw` entry is the -# M2-SPRITE-02 Verify command (`ctest -R sprite_draw`), and the +# M2-SPRITE-02 Verify command (`ctest -R sprite_draw`), the # `sprite_frames` entry is the M2-SPRITE-03 Verify command -# (`ctest -R sprite_frames`), each selecting exactly its suites from -# the shared executable. +# (`ctest -R sprite_frames`), and the `render_counters` entry is the +# M2-SPRITE-04 Verify command (`ctest -R render_counters`), each +# selecting exactly its suites from the shared executable. # -# Environment note: the GlContextSmoke, RenderThreadOffscreen, and -# SpriteDraw{State,Smoke,Pipeline} suites require a usable OpenGL 3.3 -# environment — always present on the P0 CI runners (Mesa/ANGLE/Apple/ -# Windows software or hardware drivers), where they must pass. On a -# local machine without a GL driver the suites GTEST_SKIP with the -# clean Status reason — the documented environment contract -# (docs/api/gl_context.md), not an engine failure. The -# GlContextGate/GlContextArgs/FrameClock/RenderThreadHandoff/ -# SpriteDrawCreate suites always run (they make no GL calls). +# Environment note: the GlContextSmoke, RenderThreadOffscreen, +# SpriteDraw{State,Smoke,Pipeline}, and RenderCounters{Scene,Cap, +# Memory} suites require a usable OpenGL 3.3 environment — always +# present on the P0 CI runners (Mesa/ANGLE/Apple/Windows software or +# hardware drivers), where they must pass. On a local machine without +# a GL driver the suites GTEST_SKIP with the clean Status reason — the +# documented environment contract (docs/api/gl_context.md), not an +# engine failure. The GlContextGate/GlContextArgs/FrameClock/ +# RenderThreadHandoff/SpriteDrawCreate/RenderCountersCreate suites +# always run (they make no GL calls). add_executable(laige-render_tests gl_context_tests.cpp render_thread_tests.cpp matrices_tests.cpp iso_depth_key_tests.cpp iso_depth_table_tests.cpp camera_tests.cpp iso_camera_tests.cpp projection_tests.cpp iso_picking_tests.cpp depth_sort_tests.cpp sprite_batcher_tests.cpp sprite_draw_tests.cpp - sprite_frames_tests.cpp) + sprite_frames_tests.cpp render_counters_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 @@ -263,6 +280,16 @@ add_test(NAME sprite_draw add_test(NAME sprite_frames COMMAND laige-render_tests --gtest_filter=SpriteFrame*) +# M2-SPRITE-04: the step's Verify command is `ctest -R render_counters`. +# RenderCountersCreate runs without any GL environment; +# RenderCounters{Scene,Cap,Memory} need the same usable OpenGL 3.3 +# environment as the SpriteDrawState/Smoke suites (GTEST_SKIPs where +# absent — the documented environment contract). No budget gate: the +# step's roadmap scope has no standalone budgets.json entry (the +# composite 50k render budget of PRD §8.1 is measured with M2-PERF-01). +add_test(NAME render_counters COMMAND laige-render_tests + --gtest_filter=RenderCounters*) + 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) @@ -289,12 +316,17 @@ if(LAIGE_TSAN) # The `sprite_draw` entry is a second TSan gate: the # SpriteDrawPipeline integration run drives the render thread's # stages against the renderer + batcher from two threads (the GL - # handoff's race-freedom). The test-level + # handoff's race-freedom). The `render_counters` entry is a third: + # the M2-SPRITE-04 counters are plain owner-thread state (no shared + # state of the step's own) — the GL suites run single-threaded, so + # the entry gates the engine's GL teardown ordering under TSan. + # The test-level # ENVIRONMENT replaces the job-level one, so the budgets path is # restated here. set_tests_properties(laige-render_tests gl_context render_thread matrices iso_depth_key iso_depth_table camera iso_camera projection - iso_picking depth_sort batcher sprite_draw sprite_frames PROPERTIES + iso_picking depth_sort batcher sprite_draw sprite_frames + render_counters PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1;LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json") endif() @@ -313,14 +345,15 @@ if(LAIGE_ASAN) # init (mutually exclusive formats in one file, verified against # compiler-rt), and the leak roots are libEGL-internal state objects # the engine never holds a pointer to. detect_leaks=0 for the render - # entries instead (render_thread's RenderThreadOffscreen suite and - # sprite_draw's SpriteDraw{State,Smoke,Pipeline} 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 test-level ENVIRONMENT replaces the job-level + # entries instead (render_thread's RenderThreadOffscreen suite, + # sprite_draw's SpriteDraw{State,Smoke,Pipeline} suites, and + # render_counters's RenderCounters{Scene,Cap,Memory} 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 test-level ENVIRONMENT replaces the job-level # ASAN_OPTIONS (GitHub workflow), so mirror its options here. set_tests_properties(laige-render_tests gl_context render_thread - sprite_draw PROPERTIES + sprite_draw render_counters PROPERTIES ENVIRONMENT "ASAN_OPTIONS=abort_on_error=1:halt_on_error=1:detect_leaks=0:log_path=${CMAKE_SOURCE_DIR}/asan-reports/asan") endif() @@ -335,6 +368,11 @@ endif() # 60s is generous headroom. set_tests_properties(laige-render_tests gl_context PROPERTIES TIMEOUT 180) set_tests_properties(render_thread PROPERTIES TIMEOUT 300) +# The `render_counters` entry creates the headless context + FBO and +# renders a few 10-sprite frames (no pixel comparison, no render +# thread) — generous headroom for a slow headless runner, between the +# small math entries and the 1000-sprite `sprite_draw` scene. +set_tests_properties(render_counters PROPERTIES TIMEOUT 120) # The `sprite_draw` entry renders a 1000-sprite frame through a real # (software) context + FBO, compares the whole 128x128 frame against a # CPU reference rasterizer (16 384 pixels x 1000 sprites), and runs the diff --git a/tests/laige-render/render_counters_tests.cpp b/tests/laige-render/render_counters_tests.cpp new file mode 100644 index 0000000..97131e4 --- /dev/null +++ b/tests/laige-render/render_counters_tests.cpp @@ -0,0 +1,697 @@ +// laige-render tests (M2-SPRITE-04): render observability + the +// draw-call budget (G-R2, PRD §9.3) — the M2-SPRITE-04 profiler +// fields (program changes, upload volume, render-target use, the +// texture-memory VRAM estimate, the G-R2 draw-call-cap flag) and the +// per-pass draw-call cap (configurable, documented default — exceeding +// it WARNS + flags the frame, it never drops the frame). +// +// Suite map (the `render_counters` CTest entry selects exactly these): +// RenderCountersCreate the create validation of the new option +// (first failure wins, one Warn) + the +// stopped state reads zero — no GL required +// (the validation precedes the context check); +// RenderCountersScene the roadmap's known small scene (10 sprites, +// 2 atlases, 2 blends -> 3 groups): the +// per-frame counters match EXACTLY (frame 1 + +// frame 2, the cross-frame state persistence), +// the since-construction totals exact, the +// empty frame counts nothing, and the opt-in +// PRIMITIVES_GENERATED feed — requires a +// usable OpenGL 3.3 environment (GTEST_SKIPs +// on an environment failure, the documented +// contract); +// RenderCountersCap the G-R2 per-pass draw-call cap: the warn +// fires at the configured count (with the +// pinned fields), the frame is still drawn +// (observation, never an execution gate), the +// flag + total track the exceedances, and a +// frame AT the cap does not warn; +// RenderCountersMemory the texture-memory VRAM estimate gauge: +// the bound-atlases w*h*4 sum, exact through +// binds and re-bind replacements. +// +// The scene setup pattern (makeScene): the caller OWNS the context, +// the renderer, and the batcher — the renderer stores a NON-OWNING +// pointer to the context (the header's Ownership section), so the +// context must never move (a moved-from context is the stopped +// state: makeCurrent fails InvalidArgument). The existing +// SpriteDraw tests use the same single-scope pattern; an engine-side +// setup failure aborts (the environment check has already GTEST_SKIPped). + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "laige/render/gl_context.h" +#include "laige/render/iso_camera.h" +#include "laige/render/iso_depth_key.h" +#include "laige/render/sprite_batcher.h" +#include "laige/render/sprite_renderer.h" +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/result.h" +#include "laige/sim_math.h" + +namespace { + +using laige::ErrorCode; +using laige::Result; +using laige::Status; +using laige::render::BlendMode; +using laige::render::GlContext; +using laige::render::IsoCamera; +using laige::render::IsoCameraOptions; +using laige::render::IsoPreset; +using laige::render::Mat4; +using laige::render::SpriteBatcher; +using laige::render::SpriteDrawStats; +using laige::render::SpriteDrawTotals; +using laige::render::SpriteItem; +using laige::render::SpriteRenderer; + +// The offscreen render target (the sprite_draw scene size — the +// render-target-use expectation is kWidth * kHeight * 4). +constexpr std::int32_t kWidth = 128; +constexpr std::int32_t kHeight = 128; +constexpr std::uint64_t kRenderTargetBytes = + static_cast(kWidth) * kHeight * 4u; +// The per-instance upload (the M2-SPRITE-02 instance layout, 13 floats). +constexpr std::uint64_t kInstanceBytes = 52u; + +// A 4x4 solid RGBA8 atlas (all alpha 255). +std::array solidAtlas4(std::uint8_t r, std::uint8_t g, + std::uint8_t b) { + std::array a{}; + for (std::size_t p = 0; p < 16; ++p) { + a[p * 4 + 0] = r; + a[p * 4 + 1] = g; + a[p * 4 + 2] = b; + a[p * 4 + 3] = 255; + } + return a; +} + +template +std::span bytesOf(const std::array& a) { + return std::span(a.data(), a.size()); +} + +// One declared sprite at (x, y) (the engine-owned depth key — the +// G-R11 contract: never hand-rolled, never screen space). +SpriteItem makeSprite(std::int32_t x, std::int32_t y, std::uint32_t atlasId, + BlendMode blend) { + SpriteItem it; + it.pos = {static_cast(x), static_cast(y)}; + it.depthKey = laige::render::isoDepthKey( + laige::sim::SimMath::Vec2{ + static_cast(x), static_cast(y)}, + /*stepHeight=*/0, /*layer=*/0); + it.uv = {0.0f, 0.0f, 1.0f, 1.0f}; + it.scale = {1.0f, 1.0f}; + it.rotation = 0.0f; + it.tint = {1.0f, 1.0f, 1.0f, 1.0f}; + it.atlasId = atlasId; + it.materialId = 0; + it.blend = blend; + return it; +} + +// The roadmap's known small scene: 10 sprites, 2 atlases, 2 blends — +// 3 (atlas, material, blend) groups in the batcher's ascending +// (atlas, material, blend) order: +// (0, 0, Alpha): 4 sprites at (0,0),(1,0),(0,1),(1,1) +// (0, 0, Additive): 3 sprites at (2,0),(3,0),(2,1) +// (1, 0, Alpha): 3 sprites at (0,2),(1,2),(0,3) +std::vector sceneItems() { + std::vector items; + items.reserve(10); + // Group (0, 0, Alpha): 4 sprites. + items.push_back(makeSprite(0, 0, 0, BlendMode::Alpha)); + items.push_back(makeSprite(1, 0, 0, BlendMode::Alpha)); + items.push_back(makeSprite(0, 1, 0, BlendMode::Alpha)); + items.push_back(makeSprite(1, 1, 0, BlendMode::Alpha)); + // Group (0, 0, Additive): 3 sprites. + items.push_back(makeSprite(2, 0, 0, BlendMode::Additive)); + items.push_back(makeSprite(3, 0, 0, BlendMode::Additive)); + items.push_back(makeSprite(2, 1, 0, BlendMode::Additive)); + // Group (1, 0, Alpha): 3 sprites. + items.push_back(makeSprite(0, 2, 1, BlendMode::Alpha)); + items.push_back(makeSprite(1, 2, 1, BlendMode::Alpha)); + items.push_back(makeSprite(0, 3, 1, BlendMode::Alpha)); + return items; +} + +// The 2-group scene of the cap tests: 4 sprites — +// (0, 0, Alpha): 2 sprites at (0,0),(1,0) +// (0, 0, Additive): 2 sprites at (0,1),(1,1) +std::vector twoGroupItems() { + std::vector items; + items.reserve(4); + items.push_back(makeSprite(0, 0, 0, BlendMode::Alpha)); + items.push_back(makeSprite(1, 0, 0, BlendMode::Alpha)); + items.push_back(makeSprite(0, 1, 0, BlendMode::Additive)); + items.push_back(makeSprite(1, 1, 0, BlendMode::Additive)); + return items; +} + +// Declare the frame (the batch stage protocol) and build it. +void declareFrame(SpriteBatcher& batcher, + const std::vector& items) { + batcher.beginFrame(); + for (const SpriteItem& it : items) { + const auto slot = batcher.add(it); + if (slot.isError()) { + ADD_FAILURE() << "batcher.add failed: " << laige::errorText(slot.error()); + abort(); + } + } + if (batcher.build().isError()) { + ADD_FAILURE() << "batcher.build failed"; + abort(); + } +} + +// The scene setup on caller-owned objects (the file preamble: the +// context must never move — the renderer stores a non-owning pointer +// to it). Any engine-side failure aborts (the caller GTEST_SKIPped on +// an environment failure). +void makeScene(const GlContext& ctx, SpriteRenderer& renderer, + SpriteBatcher& batcher, Mat4& matrix, std::uint32_t maxDrawCalls, + bool primitiveQuery) { + SpriteRenderer::Options o; + o.maxInstances = 64; + o.maxAtlases = 2; + o.maxDrawCalls = maxDrawCalls; + o.primitiveQuery = primitiveQuery; + auto r = SpriteRenderer::create(ctx, o); + if (r.isError()) { + ADD_FAILURE() << "SpriteRenderer::create failed: " + << laige::errorText(r.error()); + abort(); + } + renderer = std::move(r).takeValue(); + SpriteBatcher::Options bo; + bo.maxSprites = 16; + auto br = SpriteBatcher::create(bo); + if (br.isError()) { + ADD_FAILURE() << "SpriteBatcher::create failed: " + << laige::errorText(br.error()); + abort(); + } + batcher = std::move(br).takeValue(); + const std::array a0 = solidAtlas4(255, 128, 0); + const std::array a1 = solidAtlas4(33, 66, 222); + const Status bind0 = renderer.bindAtlas(0, 4, 4, bytesOf(a0)); + if (bind0.isError()) { + ADD_FAILURE() << "bindAtlas(0) failed: " << laige::errorText(bind0.error()); + abort(); + } + const Status bind1 = renderer.bindAtlas(1, 4, 4, bytesOf(a1)); + if (bind1.isError()) { + ADD_FAILURE() << "bindAtlas(1) failed: " << laige::errorText(bind1.error()); + abort(); + } + IsoCameraOptions co; + co.preset = IsoPreset{laige::render::IsoPresetKind::Dimetric2To1, 0.125f}; + const Result c = IsoCamera::create(co); + if (c.isError()) { + ADD_FAILURE() << "IsoCamera::create failed: " << laige::errorText(c.error()); + abort(); + } + matrix = c.value().matrix(); +} + +// A live offscreen context (the documented environment contract — +// the calling TEST body GTEST_SKIPs on an environment failure, never +// on an engine failure). +Result tryContext() { + return GlContext::createHeadless(kWidth, kHeight); +} + +// ------------------------------------------------------------------------ +// Log capture (the sprite_batcher_tests MemorySink pattern — rate +// limiting OFF so the tests assert per-event counts, not the facade's +// LOG-004 window). +// ------------------------------------------------------------------------ +class MemorySink : public laige::log::Sink { + public: + struct Entry { + laige::log::Severity severity{}; + std::string subsystem; + std::string event; + std::string message; + std::vector> fields; + }; + + void emit(const laige::log::LogRecord& record) override { + Entry e; + e.severity = record.severity; + e.subsystem = record.subsystem; + e.event = record.event; + e.message = record.message; + for (const auto& f : record.fields) { + e.fields.emplace_back(std::string(f.name), f.value); + } + entries.push_back(std::move(e)); + } + void flush() override {} + + std::vector entries; +}; + +MemorySink* installCaptureSink() { + auto sink = std::make_unique(); + MemorySink* ptr = sink.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + opts.rateLimiting = false; + if (!laige::log::Logger::instance().init(std::move(opts)).ok()) { + ADD_FAILURE() << "Logger::init (capture sink) failed"; + abort(); + } + return ptr; +} + +std::size_t countEvents(const MemorySink& sink, std::string_view subsystem, + std::string_view event) { + std::size_t n = 0; + for (const auto& e : sink.entries) { + if (e.subsystem == subsystem && e.event == event) ++n; + } + return n; +} + +const MemorySink::Entry* lastEvent(const MemorySink& sink, + std::string_view event) { + const MemorySink::Entry* last = nullptr; + for (const auto& e : sink.entries) { + if (e.event == event) last = &e; + } + return last; +} + +std::string fieldOf(const MemorySink::Entry& e, std::string_view key) { + for (const auto& [k, v] : e.fields) { + if (k == key) return v; + } + return std::string(); +} + +// ------------------------------------------------------------------------ +// The small scene's per-frame expectations (the file preamble's scene). +// The last-atlas / last-blend state PERSISTS across frames (the +// M2-SPRITE-02 state model): frame 1 ends on atlas 1 / blend Alpha, +// so frame 2 sets 2 texture binds (atlas 1 -> 0 -> 1) + 2 blend +// changes (Alpha -> Additive -> Alpha), while frame 1 set 2 binds + +// 3 changes. +// ------------------------------------------------------------------------ +struct FrameExpectations { + std::uint32_t drawCalls; + std::uint32_t textureBinds; + std::uint32_t blendChanges; + std::uint32_t programChanges; + std::uint32_t instances; + std::uint64_t uploadBytes; + std::uint64_t renderTargetBytes; +}; + +const FrameExpectations kFrame1 = { + /*drawCalls=*/3, /*textureBinds=*/2, /*blendChanges=*/3, + /*programChanges=*/1, /*instances=*/10, + /*uploadBytes=*/10 * kInstanceBytes, /*renderTargetBytes=*/kRenderTargetBytes}; +const FrameExpectations kFrame2 = { + /*drawCalls=*/3, /*textureBinds=*/2, /*blendChanges=*/2, + /*programChanges=*/1, /*instances=*/10, + /*uploadBytes=*/10 * kInstanceBytes, /*renderTargetBytes=*/kRenderTargetBytes}; + +void checkFrame(const SpriteRenderer& renderer, const char* frame, + const FrameExpectations& e) { + const SpriteDrawStats s = renderer.frameStats(); + EXPECT_EQ(s.drawCalls, e.drawCalls) << frame; + EXPECT_EQ(s.textureBinds, e.textureBinds) << frame; + EXPECT_EQ(s.blendChanges, e.blendChanges) << frame; + EXPECT_EQ(s.programChanges, e.programChanges) << frame; + EXPECT_EQ(s.instances, e.instances) << frame; + EXPECT_EQ(s.uploadBytes, e.uploadBytes) << frame; + EXPECT_EQ(s.renderTargetBytes, e.renderTargetBytes) << frame; + EXPECT_FALSE(s.drawCallCapExceeded) << frame; +} + +} // namespace + +// ------------------------------------------------------------------------ +// RenderCountersCreate — the create validation of the new option + +// the stopped state (no GL required: the options are validated before +// the context check, the stopped state makes no GL calls). +// ------------------------------------------------------------------------ + +TEST(RenderCountersCreate, MaxDrawCallsValidation) { + GlContext stopped; // the stopped state — no GL environment needed + MemorySink* sink = installCaptureSink(); + + SpriteRenderer::Options o; + o.maxInstances = 16; + o.maxAtlases = 2; + o.maxDrawCalls = 0; // < 1 + Result r = SpriteRenderer::create(stopped, o); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(r.error(), ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*sink, "sprite_renderer", "options_invalid"), 1u); + EXPECT_EQ(fieldOf(*lastEvent(*sink, "options_invalid"), "option"), + "maxDrawCalls"); + EXPECT_EQ(fieldOf(*lastEvent(*sink, "options_invalid"), "value"), "0"); + + // The documented default is valid (the domain's lower bound is 1; + // the default is 64) — the create reaches the context check, so the + // stopped context fails GlUnavailable, not InvalidArgument. + o.maxDrawCalls = SpriteRenderer::kSpriteRendererDefaultDrawCalls; + r = SpriteRenderer::create(stopped, o); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(r.error(), ErrorCode::GlUnavailable); + EXPECT_EQ(countEvents(*sink, "sprite_renderer", "options_invalid"), 1u); + + // First failure wins: maxInstances AND maxDrawCalls invalid -> + // one warn, the FIRST option (maxInstances precedes maxDrawCalls). + o.maxInstances = 0; + o.maxDrawCalls = 0; + r = SpriteRenderer::create(stopped, o); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(r.error(), ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*sink, "sprite_renderer", "options_invalid"), 2u); + EXPECT_EQ(fieldOf(*lastEvent(*sink, "options_invalid"), "option"), + "maxInstances"); +} + +TEST(RenderCountersCreate, StoppedStateReadsZero) { + SpriteRenderer r; + EXPECT_FALSE(r.valid()); + EXPECT_EQ(r.maxInstances(), 0u); + EXPECT_EQ(r.maxAtlases(), 0u); + EXPECT_EQ(r.maxDrawCalls(), 0u); + EXPECT_EQ(r.textureMemoryBytes(), 0u); + const SpriteDrawStats s = r.frameStats(); + EXPECT_EQ(s.drawCalls, 0u); + EXPECT_EQ(s.textureBinds, 0u); + EXPECT_EQ(s.blendChanges, 0u); + EXPECT_EQ(s.programChanges, 0u); + EXPECT_EQ(s.instances, 0u); + EXPECT_EQ(s.primitives, 0u); + EXPECT_EQ(s.uploadBytes, 0u); + EXPECT_EQ(s.renderTargetBytes, 0u); + EXPECT_FALSE(s.drawCallCapExceeded); + const SpriteDrawTotals t = r.totals(); + EXPECT_EQ(t.frames, 0u); + EXPECT_EQ(t.drawCalls, 0u); + EXPECT_EQ(t.textureBinds, 0u); + EXPECT_EQ(t.blendChanges, 0u); + EXPECT_EQ(t.programChanges, 0u); + EXPECT_EQ(t.instances, 0u); + EXPECT_EQ(t.primitives, 0u); + EXPECT_EQ(t.uploadBytes, 0u); + EXPECT_EQ(t.renderTargetBytes, 0u); + EXPECT_EQ(t.capExceededFrames, 0u); +} + +// ------------------------------------------------------------------------ +// RenderCountersScene — the known small scene (10 sprites, 2 atlases, +// 2 blends) with EXACT per-frame + since-construction counters (GL +// required — GTEST_SKIPs on an environment failure). +// ------------------------------------------------------------------------ + +TEST(RenderCountersScene, TenSpritesExactCounters) { + auto cr = tryContext(); + if (cr.isError()) { + GTEST_SKIP() << "no usable OpenGL 3.3 environment: " + << laige::errorText(cr.error()); + } + GlContext ctx = std::move(cr).takeValue(); + SpriteRenderer renderer; + SpriteBatcher batcher; + Mat4 matrix{}; + makeScene(ctx, renderer, batcher, matrix, + SpriteRenderer::kSpriteRendererDefaultDrawCalls, + /*primitiveQuery=*/false); + + const std::vector items = sceneItems(); + // Frame 1 (the fresh-state frame: 3 blend functions + 2 binds). + declareFrame(batcher, items); + EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); + checkFrame(renderer, "frame 1", kFrame1); + EXPECT_EQ(renderer.frameStats().primitives, 0u); // the query is off + EXPECT_EQ(renderer.maxDrawCalls(), + SpriteRenderer::kSpriteRendererDefaultDrawCalls); + + // Frame 2 (the state persists: 2 binds + 2 blend changes). + declareFrame(batcher, items); + EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); + checkFrame(renderer, "frame 2", kFrame2); + + // The since-construction totals (successful submits only). + const SpriteDrawTotals t = renderer.totals(); + EXPECT_EQ(t.frames, 2u); + EXPECT_EQ(t.drawCalls, + static_cast(kFrame1.drawCalls + kFrame2.drawCalls)); + EXPECT_EQ(t.textureBinds, + static_cast(kFrame1.textureBinds + kFrame2.textureBinds)); + EXPECT_EQ(t.blendChanges, + static_cast(kFrame1.blendChanges + kFrame2.blendChanges)); + EXPECT_EQ(t.programChanges, + static_cast(kFrame1.programChanges + kFrame2.programChanges)); + EXPECT_EQ(t.instances, + static_cast(kFrame1.instances + kFrame2.instances)); + EXPECT_EQ(t.primitives, 0u); + EXPECT_EQ(t.uploadBytes, + static_cast(kFrame1.uploadBytes + kFrame2.uploadBytes)); + EXPECT_EQ(t.renderTargetBytes, + static_cast(kFrame1.renderTargetBytes + + kFrame2.renderTargetBytes)); + EXPECT_EQ(t.capExceededFrames, 0u); +} + +TEST(RenderCountersScene, EmptyFrameCountsNothing) { + auto cr = tryContext(); + if (cr.isError()) { + GTEST_SKIP() << "no usable OpenGL 3.3 environment: " + << laige::errorText(cr.error()); + } + GlContext ctx = std::move(cr).takeValue(); + SpriteRenderer renderer; + SpriteBatcher batcher; + Mat4 matrix{}; + makeScene(ctx, renderer, batcher, matrix, + SpriteRenderer::kSpriteRendererDefaultDrawCalls, + /*primitiveQuery=*/false); + + // A built empty frame: counted as a frame, NOTHING else (no GL + // state is touched — the early return precedes the pass setup). + declareFrame(batcher, {}); + EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); + const SpriteDrawStats st = renderer.frameStats(); + EXPECT_EQ(st.drawCalls, 0u); + EXPECT_EQ(st.textureBinds, 0u); + EXPECT_EQ(st.blendChanges, 0u); + EXPECT_EQ(st.programChanges, 0u); + EXPECT_EQ(st.instances, 0u); + EXPECT_EQ(st.primitives, 0u); + EXPECT_EQ(st.uploadBytes, 0u); + EXPECT_EQ(st.renderTargetBytes, 0u); + EXPECT_FALSE(st.drawCallCapExceeded); + const SpriteDrawTotals t = renderer.totals(); + EXPECT_EQ(t.frames, 1u); + EXPECT_EQ(t.drawCalls, 0u); + EXPECT_EQ(t.uploadBytes, 0u); + EXPECT_EQ(t.renderTargetBytes, 0u); + EXPECT_EQ(t.capExceededFrames, 0u); +} + +TEST(RenderCountersScene, PrimitiveQueryFeed) { + auto cr = tryContext(); + if (cr.isError()) { + GTEST_SKIP() << "no usable OpenGL 3.3 environment: " + << laige::errorText(cr.error()); + } + GlContext ctx = std::move(cr).takeValue(); + SpriteRenderer renderer; + SpriteBatcher batcher; + Mat4 matrix{}; + makeScene(ctx, renderer, batcher, matrix, + SpriteRenderer::kSpriteRendererDefaultDrawCalls, + /*primitiveQuery=*/true); + + // The opt-in PRIMITIVES_GENERATED cross-check: 2 per instance of + // the 4-vertex strip (20 for the 10-sprite scene). + const std::vector items = sceneItems(); + declareFrame(batcher, items); + EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); + const SpriteDrawStats s1 = renderer.frameStats(); + EXPECT_EQ(s1.primitives, 20u); + EXPECT_EQ(s1.drawCalls, 3u); + EXPECT_EQ(s1.instances, 10u); + const SpriteDrawTotals t = renderer.totals(); + EXPECT_EQ(t.primitives, 20u); + EXPECT_EQ(t.frames, 1u); +} + +// ------------------------------------------------------------------------ +// RenderCountersCap — the G-R2 per-pass draw-call cap (GL required). +// ------------------------------------------------------------------------ + +TEST(RenderCountersCap, WarnAtConfiguredCount) { + auto cr = tryContext(); + if (cr.isError()) { + GTEST_SKIP() << "no usable OpenGL 3.3 environment: " + << laige::errorText(cr.error()); + } + GlContext ctx = std::move(cr).takeValue(); + MemorySink* sink = installCaptureSink(); + SpriteRenderer renderer; + SpriteBatcher batcher; + Mat4 matrix{}; + makeScene(ctx, renderer, batcher, matrix, + /*maxDrawCalls=*/2, /*primitiveQuery=*/false); + EXPECT_EQ(renderer.maxDrawCalls(), 2u); + + const std::vector items = sceneItems(); // 3 groups > cap 2 + // Frame 1: the cap is exceeded — the frame is STILL drawn (the + // observation, never an execution gate), one Warn with the pinned + // fields, the flag + total track it. + declareFrame(batcher, items); + EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); + EXPECT_TRUE(renderer.frameStats().drawCallCapExceeded); + EXPECT_EQ(renderer.frameStats().drawCalls, 3u); + EXPECT_EQ(countEvents(*sink, "sprite_renderer", "draw_call_cap"), 1u); + const MemorySink::Entry* e = lastEvent(*sink, "draw_call_cap"); + ASSERT_NE(e, nullptr); + EXPECT_EQ(e->severity, laige::log::Severity::Warn); + EXPECT_EQ(fieldOf(*e, "capacity"), "2"); + EXPECT_EQ(fieldOf(*e, "draw_calls"), "3"); + EXPECT_EQ(renderer.totals().capExceededFrames, 1u); + + // Frame 2: the exceedance repeats — a second Warn (the capture + // sink has rate limiting OFF) + the total. + declareFrame(batcher, items); + EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); + EXPECT_TRUE(renderer.frameStats().drawCallCapExceeded); + EXPECT_EQ(countEvents(*sink, "sprite_renderer", "draw_call_cap"), 2u); + EXPECT_EQ(renderer.totals().capExceededFrames, 2u); + + // A frame AT OR BELOW the cap: no flag, no new Warn. The 2-group + // scene (4 sprites) draws 2 draw calls == the cap (strictly not + // above). The last-atlas / last-blend state persists from the + // 3-group frames (last atlas 1 / blend Alpha): this frame's state + // = 1 bind (atlas 1 -> 0) + 1 blend change (the first group's Alpha + // is unchanged, the second group sets Additive). + const std::vector small = twoGroupItems(); + declareFrame(batcher, small); + EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); + EXPECT_FALSE(renderer.frameStats().drawCallCapExceeded); + EXPECT_EQ(renderer.frameStats().drawCalls, 2u); + EXPECT_EQ(renderer.frameStats().instances, 4u); + EXPECT_EQ(renderer.frameStats().textureBinds, 1u); + EXPECT_EQ(renderer.frameStats().blendChanges, 1u); + EXPECT_EQ(countEvents(*sink, "sprite_renderer", "draw_call_cap"), 2u); + EXPECT_EQ(renderer.totals().capExceededFrames, 2u); +} + +TEST(RenderCountersCap, NoWarnAtTheCap) { + auto cr = tryContext(); + if (cr.isError()) { + GTEST_SKIP() << "no usable OpenGL 3.3 environment: " + << laige::errorText(cr.error()); + } + GlContext ctx = std::move(cr).takeValue(); + MemorySink* sink = installCaptureSink(); + SpriteRenderer renderer; + SpriteBatcher batcher; + Mat4 matrix{}; + makeScene(ctx, renderer, batcher, matrix, + /*maxDrawCalls=*/3, /*primitiveQuery=*/false); + + // The 3-group scene AT the cap of 3: the cap is an upper bound + // (exceeding means strictly above) — no Warn, no flag, drawn. + const std::vector items = sceneItems(); + declareFrame(batcher, items); + EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); + EXPECT_FALSE(renderer.frameStats().drawCallCapExceeded); + EXPECT_EQ(renderer.frameStats().drawCalls, 3u); + EXPECT_EQ(countEvents(*sink, "sprite_renderer", "draw_call_cap"), 0u); + EXPECT_EQ(renderer.totals().capExceededFrames, 0u); +} + +// ------------------------------------------------------------------------ +// RenderCountersMemory — the texture-memory VRAM estimate gauge (GL +// required: bindAtlas is the set-up/asset path's GL upload). +// ------------------------------------------------------------------------ + +TEST(RenderCountersMemory, TextureMemoryGauge) { + auto cr = tryContext(); + if (cr.isError()) { + GTEST_SKIP() << "no usable OpenGL 3.3 environment: " + << laige::errorText(cr.error()); + } + GlContext ctx = std::move(cr).takeValue(); + SpriteRenderer renderer; + SpriteBatcher batcher; + Mat4 matrix{}; + makeScene(ctx, renderer, batcher, matrix, + SpriteRenderer::kSpriteRendererDefaultDrawCalls, + /*primitiveQuery=*/false); + + // The two 4x4 atlases of the setup: 2 * (4 * 4 * 4) = 128 bytes. + EXPECT_EQ(renderer.textureMemoryBytes(), 128u); + + // A second renderer on the same context (the atlas registry is + // per-renderer): one 8x8 atlas (256 B) + the re-bind replacement + // (16x16 = 1024 B replaces the 8x8 — 256 - 256 + 1024 = 1024). + SpriteRenderer::Options o; + o.maxInstances = 16; + o.maxAtlases = 2; + auto r = SpriteRenderer::create(ctx, o); + if (r.isError()) { + ADD_FAILURE() << "SpriteRenderer::create failed: " + << laige::errorText(r.error()); + abort(); + } + SpriteRenderer big = std::move(r).takeValue(); + EXPECT_EQ(big.textureMemoryBytes(), 0u); + const std::array a8 = [] { + std::array a{}; + for (std::size_t p = 0; p < 64; ++p) { + a[p * 4 + 0] = 9; + a[p * 4 + 1] = 18; + a[p * 4 + 2] = 27; + a[p * 4 + 3] = 255; + } + return a; + }(); + ASSERT_TRUE(big.bindAtlas(0, 8, 8, bytesOf(a8)).ok()); + EXPECT_EQ(big.textureMemoryBytes(), 256u); + const std::array a16 = [] { + std::array a{}; + for (std::size_t p = 0; p < 256; ++p) { + a[p * 4 + 0] = 36; + a[p * 4 + 1] = 72; + a[p * 4 + 2] = 108; + a[p * 4 + 3] = 255; + } + return a; + }(); + ASSERT_TRUE(big.bindAtlas(0, 16, 16, bytesOf(a16)).ok()); + EXPECT_EQ(big.textureMemoryBytes(), 1024u); + + // The render-target use is per submit (the scene test pins the + // exact 128 * 128 * 4 bytes) — nothing asserted on it here. + (void)renderer; + (void)batcher; + (void)matrix; +} From a08b74042db1dc28c853286d608ed3d863bdf099 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 4 Oct 2026 19:53:39 +0200 Subject: [PATCH 2/2] [M2-SPRITE-04] Test fix: clear the render target before the query-enabled submit (TSan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI TSan lane reproduced a driver-internal teardown data race (Mesa llvmpipe: both race accesses inside libgallium — the eglDestroyContext teardown destroys the driver's internal mutex/condvar while the llvmpipe worker thread is still inside it), triggered by a query-enabled submit as the FIRST FBO operation of the frame. Bisected locally (variants: clear-before-draw clean; 100 sprites, 16 384-pixel readback quiesce, and clear-after-draw all still race): the frame-pipeline clear-before-draw pattern (the SpriteDrawSmoke.ThousandSpriteFrame precedent) leaves the llvmpipe teardown clean — verified 5/5 on this machine's Mesa 26.2.3. The clear touches no sprite-pass state: the PRIMITIVES_GENERATED feed, the frame counters, and the totals are unchanged. build-tsan 108/108, all six local trees green. --- roadmap/README.md | 2 +- tests/laige-render/render_counters_tests.cpp | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/roadmap/README.md b/roadmap/README.md index 8f532ff..fc21b39 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -239,7 +239,7 @@ One line per completed (or split/renumbered) step. | 2026-10-04 | M2-SPRITE-02 | `—` | GPU instanced draw + sprite shader (M2-SPRITE-02 scope, nothing else): **API** — `SpriteRenderer` (public header `src/laige-render/include/laige/render/sprite_renderer.h` + implementation `sprite_renderer.cpp`, move-only; default = stopped state) — `create(const GlContext&, Options{maxInstances, maxAtlases, primitiveQuery})` the set-up path (validates first-failure-wins → `InvalidArgument` + one rate-limited Warn `sprite_renderer/options_invalid`; requires a valid context + `makeCurrent`; compiles ONE GLSL 3.30 shader pair (vertex + fragment) + links one program; creates the 32 B quad VBO (`GL_STATIC_DRAW`), the per-frame instance buffer (`maxInstances × 52` B, `GL_DYNAMIC_DRAW` — 13 floats: pos.xy, scale.xy, uv u0v0u1v1, tint rgba, rot), and the VAO (the quad corner at divisor 0; the five instance attributes at divisor 1, pinned `layout(location=0..5)`); the atlas registry (`maxAtlases × 8` B) — any GL failure → `GlUnavailable` + ONE structured Error (`sprite_renderer/program_creation_failed` or `resource_creation_failed`, with the GL error code + the sanitized info log), no partial renderer); `bindAtlas(atlasId, w, h, rgba)` the set-up/asset path (one call per atlas per scene load; `atlasId ∈ [0, maxAtlases)`, `w, h ∈ [1, capabilities().maxTextureSize]`, `rgba.size() == w·h·4`; GL_RGBA8, `GL_NEAREST`, `GL_CLAMP_TO_EDGE`, no mipmaps — the UV sub-rects are pixel-exact, the M2-GOLD-01 contract; re-binding an id REPLACES the texture; a GL upload failure → `GlUnavailable` + one Error `atlas_upload_failed`); `submit(batcher, worldToNdc)` the per-frame draw (preconditions, first failure wins — a FAILED submit draws nothing, zeroes the frame counters, leaves the totals unchanged, and leaves no stale sprite-pass state: stopped renderer → `InvalidArgument`; context valid + current (`makeCurrent` idempotent — a cross-thread live takeover → `GlUnavailable`, the P0 EGL contract); the batcher built for the current frame (`frameBuilt()` — an open window with declared items is never drawn as an empty frame, CORE-008); the frame's instance count ≤ `maxInstances` (else `BudgetExhausted` + one rate-limited Warn `instance_capacity`, PERF-008); every group's atlas in the registry AND bound (else `InvalidArgument` — the stateless pre-state validation, one failure per frame); then: the per-frame state setup (the render-target frame buffer bind — `GlContext::frameBuffer()`, the offscreen FBO on headless contexts, the surfaceless default frame buffer is not a valid draw target — + the viewport matched to the render-target size, the driver default 0×0 would clip every draw to nothing — + the depth test OFF (the painter's order is the batcher's — the 2.5D depth is engine-owned, FR-2.2/M2-ISO-01, never derived from the projection) + the blend ENABLED + the program + the per-frame `uWorldToNdc` uniform), the frame's instances packed into the pre-allocated staging (a contiguous verbatim float copy — no arithmetic on the CPU — the GPU owns the math, FR-2.2/PERF-003), ONE `glBufferSubData` upload, and per group IN THE Batcher's published order (ascending (atlas, material, blend) — RENDER-003) the texture bind (only when the atlas CHANGED → counted in `textureBinds`), the blend function (only when the mode CHANGED → counted in `blendChanges` — Alpha: `SRC_ALPHA`/`ONE_MINUS_SRC_ALPHA`, Additive: `ONE`/`ONE`), and ONE `glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, n_group)` (counted in `drawCalls`/`instances`); after the pass (success or failure): program + VAO restored to 0 — the pass owns only its own program/VAO; the blend function, texture bind, frame buffer, and viewport PERSIST (the last atlas/blend carry across frames — the counters count real changes)); **shader** (the whole M2 sprite feature, minimal GLSL 3.30) — vertex: `world = aPos + aCorner * aScale` (the unit quad's corner scaled in WORLD units and translated — the scale applied BEFORE the projection, the `SpriteItem.scale` contract), projected through `uWorldToNdc` (2D ground plane, z = 0), the projected offset rotated by the per-instance rotation IN SCREEN SPACE (NDC — the `SpriteItem.rotation` contract), the per-vertex UV the per-instance UV sub-rect mapped onto the quad (`(-0.5,-0.5) → u0/v0`, `(0.5,0.5) → u1/v1`); fragment: `texture(uAtlas, vUv) * vTint` (the multiplicative RGBA tint); **counters (RENDER-001 — the M2-SPRITE-04 profiler feed)** — `SpriteDrawStats` (the per-frame counters of the last successful submit: `drawCalls`, `textureBinds`, `blendChanges`, `instances`, `primitives`) + `SpriteDrawTotals` (since-construction, successful submits only — `frames`, `drawCalls`, `textureBinds`, `blendChanges`, `instances`, `primitives`); GL 3.3 core has NO draw-call query primitive: the dispatch count is the engine's own bookkeeping (`drawCalls` == the group count), and the GL-side cross-check is the OPT-IN `Options::primitiveQuery` (a `PRIMITIVES_GENERATED` query around every submit + a `glFinish` read — a CPU/GPU sync, a DIAGNOSTIC mode for the offscreen test/CI path and the profiler, never the shipping frame loop, RENDER-005; the 32-bit `glGetQueryObjectuiv` read caps the count at 2^32−1 primitives — beyond the realistic frame of 2^31 instances (2 per instance); the glad-generated `glQueryCounter` has a broken 2-argument signature in the vendored 2.0.8 loader, hence the 32-bit read; `ponytail:` comment at the site); **determinism** — presentation-only (ARCH-009): the submit path is a verbatim float copy (no arithmetic on the CPU — the GPU owns the math); the rotation's cos/sin is driver-float (bit-exact across runs of the same driver, not across drivers — the golden-image contract M2-GOLD-01 pins the environment); no allocation on the per-frame path (the staging buffer, the instance buffer, and the registry are sized at create — PERF-003); one owner thread (the render thread's submit stage — CONC-001), no locks/atomics; **no new budget entry** — the composite 50k render-CPU budget (2 ms, PRD §8.1 `sprites_50k_cpu`) is measured with this stage (M2-PERF-01); **tests** — `tests/laige-render/sprite_draw_tests.cpp` (12 tests / 4 suites; CTest entry `sprite_draw` = the step's Verify command, TIMEOUT 300): `SpriteDrawCreate` (options validation matrix + the stopped state — no GL), `SpriteDrawState` (the stopped-state behavior + the `frameBuilt` gate + the instance budget + the unbound-atlas rejection — no GL), `SpriteDrawSmoke` (the roadmap's offscreen render: a 1 000-sprite scene — a 32×32 lattice of unit tiles (scale 0.125) in two atlases (a 4×4 checkerboard + a solid) and three groups ((0,0,Alpha) 796 instances, (0,0,Additive) 200, (1,0,Alpha) 4) on a cleared (0,0,255) frame, plus 3 probe sprites) — `SpriteRenderer::create` + `bindAtlas` ×2 + one submit: asserts `drawCalls == 3 == the group count` (the machine-greppable `sprite-draw:` line: `groups=3 draw_calls=3 instances=1000 primitives=2000 texture_binds=2 blend_changes=3 nonempty=16384 reference_mismatches=0`), the GL-side cross-check `primitives == 2000 == 2 × 1000` (the opt-in query ON), and the whole 128×128 frame against a CPU reference rasterizer that walks the built frame in the exact draw order and accumulates the per-group blend in double (±1 byte per channel — the GPU float32 vs the reference double — plus three rounding-exact probe pixels: an alpha checkerboard texel, an additive-over-clear texel, and the solid atlas-1 texel; the reference is a CPU double-precision reimplementation of the shader's exact pipeline — the 2×2 linear inverse + the screen-space rotation inverse + the UV mapping); `SpriteDrawPipeline` (the M2-GL-02 integration: a 100-frame offscreen run through `RenderThread` — the batch stage (clear + `beginFrame` + 1 000 `add` + `build`) + the submit stage (`SpriteRenderer::submit`) on the render thread, the `GlContext` handoff (release on the test thread → `makeCurrent` in `onStart` → `release` in `onStop`), the submit loop PACED to the render thread (`waitIdle` per frame — a tight loop would outrun the software-GL render and drop 98 of 100); asserts the exact since-construction totals: `frames=100`, `drawCalls=300`, `instances=100 000`, `textureBinds=200` (2/frame — the last-atlas carries across frames), `blendChanges=201` (3 on frame 1 + 2 on each later frame — the last-blend carries across frames), `primitives=0` (the query OFF in this renderer), `framesSubmitted=100`/`framesRendered=100`/`framesDropped=0` — + the last frame survives in the FBO after ordered shutdown (the P0 probe pixel read back exact)); `ctest -R sprite_draw` green (the GL suites `GTEST_SKIP` on an environment failure — the CI path: Mesa software GL on the offscreen FBO, the sandbox's no-GPU rule); **docs** (DOC-007, same change) — NEW `docs/api/sprite_renderer.md` (the full API contract: the API, the one-draw-per-group + the state-persistence model, the shader + the pass's GL state model, the counters, the DOC-004 **Performance** section — O(G×5 + n) per frame, zero allocation, the state-change observability, the render-target/viewport/state-persistence/primitiveQuery traps — ownership/lifetime/threading (the context outlives the renderer), a performant example, the misuse warnings), `docs/api/gl_context.md` (the NEW `frameBuffer()` row — the render-target frame buffer handle: the offscreen FBO on headless, 0 on windowed/stopped, no GL call; the per-frame draw path binds it once per frame), `docs/api/sprite_batcher.md` (the NEW `frameBuilt()` row + the submit-stage gate + the Related link), `docs/README.md` API index, `docs/concepts/coordinates.md` §4.8 (the sprite-draw narrative + the World→screen conversion table row now shipped + §6/Related), `src/laige-render/README.md` status; `laige-api.json` regenerated (`cmake --build build --target laige-api` — 1211 symbols / 37 headers — +36: `SpriteDrawStats` + 5 fields, `SpriteDrawTotals` + 6 fields, `SpriteRenderer` + 8 members + 2 constants, `GlContext::frameBuffer`, `SpriteBatcher::frameBuilt`; `api-real-tree`/`api-check-fresh` green), `include-lint` (65 files), and `determinism-lint` OK; **local verification** — the canonical tree builds warning-free under NFR-8.10 with full `ctest` green (incl. `sprite_draw` 12/12 + the API/lint entries); the remaining five trees re-verified in the follow-up (build-clang/build-release/build-shared/build-asan/build-tsan); **compat** — additive only (no existing symbol's signature or meaning changed; the new public API is `SpriteRenderer`/`SpriteDrawStats`/`SpriteDrawTotals` + `GlContext::frameBuffer` + `SpriteBatcher::frameBuilt`); **scope note** — the implementation exceeds the roadmap's "~300 lines + tests" sanity note for the same documented-contract reason as M2-SORT-01/M2-SPRITE-01 (the header preamble + the API doc are part of the implementation per CORE-006/DOC-004); Progress Board M2 14/33, total 60/194 | | 2026-10-04 | M2-ISO-02 | `—` | **Budget revision (CI follow-up to M2-ISO-02)** — the `iso_depthkey_rebuild` gate (PRD §8.1, mean ≤ 0.2 ms, 10k dirty cells after a terrain edit) failed the CI `Linux x64 (clang++)` reference lane repeatedly on **unchanged engine code** (the `setTile` path has not changed since the 2026-10-01 workload fix): the reference lane (Clang 18.1.3, CMake Debug, ubuntu-24.04 shared runner) measures the workload at **0.194–0.267 ms**, straddling the 0.2 ms bar — a zero-margin gate whose pass/fail was decided by runner load, not engine regression (evidence: PR lane run 37192995927 attempts 1–2 — 0.194142 PASS / 0.201495 FAIL, then 0.266709 / 0.267328 FAIL on a slow shared runner; master merge-lane run 37194767648 on commit `70830a7` — 0.202146 / 0.202018 FAIL, the `Linux x64 (g++)` lane passing the same workload on the same commit). Revised per the NFR-8.1 policy ("unless the budget is revised via a PRD revision"): **PRD v0.4** §8.1 `≤ 0.2 ms` → `≤ 0.3 ms`; **`budgets.json`** `target` 0.2 → **0.3**, `measured` 0.0814067 → **0.202204** (latest recorded CI reference value, worse backend); **new baseline** `docs/benchmarks/baselines/m2-iso-depth-table-budget-rebaseline.md` (the eighth baseline, verbatim CI reports); **docs** — `docs/api/iso_depth_table.md`, `docs/api/iso_depth_key.md`, `docs/concepts/coordinates.md`, `docs/README.md`, and the M2/M5/M8 roadmap budget references updated to the revised value in the same change (DOC-003), baselines index entry added. No engine code, workload, or test change — budget + docs only (methodology §1: no regression occurred; this is a calibration of the gate's margin on the reference toolchain). | | 2026-10-04 | M2-SPRITE-03 | `—` | Atlas UV frame animation hook (M2-SPRITE-03 scope, nothing else): **API** — header-only `src/laige-render/include/laige/render/sprite_frames.h` (`laige::render`, public, additive): `SpriteFrameLayout` (the atlas sheet frame layout in texels — `frameWidth`/`frameHeight`, `columns`/`rows`, `frameSpacing` (the gap between adjacent frames), `sheetBorder` (the sheet-edge margin); plain value, no ownership, validated at use time) + `kSpriteFrameMaxAtlasTexels` (2^24 — the float-exact atlas domain) + `spriteFrameUv(frameIndex, layout, atlasWidth, atlasHeight) → Result` (the pure frame-index → UV sub-rect computation: O(1), zero allocation, no logging, no GL; 4 exactly-rounded float divisions — the UV corners are the exactly-rounded k/W values); **sheet model (documented)** — row-major, frame 0 at the top-left (`col = i % columns`, `row = i / columns`); frame (c, r) occupies `[border + c·(fw+spacing), +fw) × [border + r·(fh+spacing), +fh)` texels; the tight sheet is `2·border + cols·fw + (cols−1)·spacing` wide/tall (a wider atlas = slack margin, the fit check is authoritative); **v-axis** — v = 0 is the first texel row of the uploaded RGBA array (the sheet's TOP row — the M2-SPRITE-02 GL_NEAREST "texel row = floor(v·h)" contract), so frame row 0 carries the smallest v; **failure (CORE-008, API-008, first failure wins)** — the layout is caller-owned, untrusted asset metadata (SCALE-004): zero extents / atlas outside [1, 2^24] / frameIndex ≥ columns·rows (the documented OUT-OF-RANGE contract: the engine NEVER wraps silently) / the frame's rect beyond the atlas — all `InvalidArgument`, never a UV rect (the u64 overflow guard rejects an adversarial stride before the col·stride multiplication can wrap — CPP-004/SCALE-004); **exactness** — float-exact domain (atlas ≤ 2^24): every pixel coordinate < 2^24 is exactly float-representable and the invariant u1 > u0, v1 > v0 holds EXACTLY (two distinct k/W never round to the same float) — pure function, bit-identical every platform/build (presentation-only, ARCH-009/010); **the M3 hook (data-driven, ARCH-009)** — `SpriteItem` gained `frameIndex` (the declared animation frame — the caller sets it + sets `uv` to the frame's rect via `spriteFrameUv`; the batcher carries the index through untouched — `uv` is what the M2-SPRITE-02 renderer draws; M3 animation drives the frame advance on top of this same layout); **batcher delta** — `SpriteItem` +1 u32 (76 B/slot, ~136 B/capacity slot, 6.8 MB at 50k); the batcher stays pure integer bookkeeping (the index passes through untouched); **tests** — `tests/laige-render/sprite_frames_tests.cpp` (new CTest entry `sprite_frames` = the step's Verify command; 11 tests / 4 suites, no GL): `SpriteFrameUvGolden` (hand-computed UVs for documented layouts: packed 4×4 sheet, tight 82×82 margin sheet, non-square 12×8 frames with spacing, single column/row, the idempotent call), `SpriteFrameErrors` (out-of-range at count / beyond / u32 top with the no-wrap pin, zero-extent layouts, the atlas domain incl. the exact 2^24 top, the fit failures incl. the one-texel-short spacing + the exact boundary + the adversarial stride), `SpriteFrameProperty` (2 000 seeded random tight sheets vs the documented formula — the float-exact invariants + the row/col adjacency rule (exact touch packed, strict gap spaced) — + the 1 000-conversion zero-allocation proof, the iso_picking/depth_sort precedent), `SpriteFrameItemPassThrough` (the frameIndex + uv pair survives the batcher's add/build/get — the M3 entry point); **docs** — NEW `docs/api/sprite_frames.md` (the full contract + Performance per DOC-004 + the M3 hook + misuse warnings), `docs/api/sprite_batcher.md` (the SpriteItem table row + the 76 B/slot update + Related), `docs/concepts/coordinates.md` §4.8 (the UV bullet) + §5 table row (Atlas frame → UV sub-rect, shipped) + Related, `docs/README.md` API index, the module README status paragraph; `laige-api.json` regenerated (1221 symbols / 38 headers, +10 symbols / +1 header); **verification** — all six local trees warning-clean + full ctest green (build, build-clang, build-release, build-shared, build-asan, build-tsan: `ctest -R sprite_frames` + the full `laige-render_tests`); `api-real-tree`/`api-check-fresh`, `include-lint` (38 public headers), and `determinism-lint` green; **budget** — no standalone `budgets.json` entry (the per-frame conversion cost is part of the composite 50k render-CPU budget, measured with M2-PERF-01); **compat** — additive only (no existing symbol's signature or meaning changed; the new public API is `SpriteFrameLayout`/`spriteFrameUv`/`kSpriteFrameMaxAtlasTexels` + `SpriteItem.frameIndex`); **scope note** — the implementation exceeds the roadmap's "~100 lines" sanity note for the same documented-contract reason as M2-SORT-01/M2-SPRITE-01 (the header preamble + the API doc are part of the implementation per CORE-006/DOC-004); Progress Board M2 15/33, total 61/194 | -| 2026-10-04 | M2-SPRITE-04 | `—` | Render observability + the draw-call budget (G-R2, PRD §9.3; M2-SPRITE-04 scope, nothing else): **API** — `SpriteDrawStats` gained `programChanges` (the pass's `glUseProgram` count — 1 per successful non-empty frame), `uploadBytes` (the frame's instance upload, n × 52 B), `renderTargetBytes` (the render-target size drawn, w × h × 4), and the G-R2 `drawCallCapExceeded` flag; `SpriteDrawTotals` gained the `programChanges`/`uploadBytes`/`renderTargetBytes` sums + `capExceededFrames`; `SpriteRenderer::Options` gained the configurable per-pass draw-call cap `maxDrawCalls` (domain `[1, kSpriteRendererMaxInstances]` — a frame's draw calls can never exceed its instance count; documented default `kSpriteRendererDefaultDrawCalls` = 64 = 2× the PRD §8.1 worst-case reference-scene budget of 30 draw calls) + the `maxDrawCalls()` accessor; NEW `textureMemoryBytes()` gauge (the texture-memory VRAM estimate: the bound atlases' w × h × 4 sum — updated at `bindAtlas`, a re-bind subtracts the old upload + adds the new, reads 0 in the stopped state); **G-R2 semantics** — a frame whose draw calls (== its group count) STRICTLY exceed the cap is STILL drawn (observation, never an execution gate — the G-R5 precedent): one rate-limited Warn `draw_call_cap` (fields `capacity`, `draw_calls`) on the over-cap submit, the frame's `drawCallCapExceeded` flag (the frame-graph flag M2-PROF-01 will report) + the totals' `capExceededFrames`; **contract** — a failed submit zeroes the frame counters (now enforced on all three GL failure paths: upload, query creation, draw); the atlas registry slot grew 8 B → 16 B (the width/height bookkeeping: 32 KB → 64 KB at 4096 slots); **tests** — NEW `tests/laige-render/render_counters_tests.cpp` (new CTest entry `render_counters` = the step's Verify command; 8 tests / 4 suites): `RenderCountersCreate` (no GL: the maxDrawCalls validation first-failure-wins + one Warn `options_invalid`, the default reaching the context check → `GlUnavailable`, the stopped state reads zero), `RenderCountersScene` (GL: the roadmap's known small scene — 10 sprites, 2 atlases, 2 blends → 3 groups — per-frame counters pinned EXACTLY for frame 1 {3 draws, 2 binds, 3 blend changes, 1 program change, 10 instances, 520 B upload, 65 536 B target} + frame 2 {3, 2, 2, 1, 10, 520, 65 536 — the cross-frame last-atlas/last-blend state persistence} + the since-construction totals, the empty frame counts nothing, the opt-in `PRIMITIVES_GENERATED` feed = 20), `RenderCountersCap` (GL: the warn fires AT the configured count (cap=2) with the pinned fields, the frame still drawn, the second frame repeats the warn + total, a 2-group frame AT the cap has no flag + no new warn (1 bind + 1 blend change from the persisted state), cap=3 → no warn at the cap), `RenderCountersMemory` (GL: the gauge exact — two 4×4 atlases = 128 B; a second renderer's 8×8 = 256 B, the 16×16 re-bind replacement = 1024 B); **docs** — `docs/api/sprite_renderer.md` (NEW "Render observability + the draw-call cap" section: the field table + the cap semantics; the API snippet, the create/bindAtlas/submit/frameStats bullets, the Performance section, the misuse warnings), `docs/README.md` index entry, the module README status paragraph, the roadmap box; `laige-api.json` regenerated (1233 symbols / 38 headers, +12 symbols); **verification** — all six local trees warning-clean + full ctest: build 111/111, build-clang 111/111, build-release 100/100, build-shared 111/111, build-asan 108/108, build-tsan 106/108 — the two TSan failures (`laige-render_tests`, `render_counters`) are a DRIVER-INTERNAL teardown data race in this machine's Mesa 26.2.3 llvmpipe (both race accesses are inside libgallium: the `eglDestroyContext` teardown destroys the driver's internal mutex/condvar while the llvmpipe worker thread is still inside it — no engine code between the pthread frames; the identical GL pattern (context → query submit → readback → context destruction) passes in this tree's `SpriteDrawSmoke.ThousandSpriteFrame` and on the CI TSan lane — the M2-SPRITE-02 CI evidence on Mesa 25.2.8 — the CI TSan lane is the contract's TSan authority per the M2-GL-01 precedent); `api-real-tree`/`api-check-fresh`, `tools/laige-include-lint`, and `tools/laige-determinism-lint` green; **budget** — no standalone `budgets.json` entry (the counters are O(1) bookkeeping; the composite 50k render-CPU budget is measured with M2-PERF-01); **compat** — additive only (no existing symbol's signature or meaning changed; the new public API is the 4 `SpriteDrawStats` fields + the 4 `SpriteDrawTotals` fields + `Options::maxDrawCalls` + `kSpriteRendererDefaultDrawCalls` + `maxDrawCalls()` + `textureMemoryBytes()` = 12 symbols); Progress Board M2 16/33, total 62/194 | +| 2026-10-04 | M2-SPRITE-04 | `—` | Render observability + the draw-call budget (G-R2, PRD §9.3; M2-SPRITE-04 scope, nothing else): **API** — `SpriteDrawStats` gained `programChanges` (the pass's `glUseProgram` count — 1 per successful non-empty frame), `uploadBytes` (the frame's instance upload, n × 52 B), `renderTargetBytes` (the render-target size drawn, w × h × 4), and the G-R2 `drawCallCapExceeded` flag; `SpriteDrawTotals` gained the `programChanges`/`uploadBytes`/`renderTargetBytes` sums + `capExceededFrames`; `SpriteRenderer::Options` gained the configurable per-pass draw-call cap `maxDrawCalls` (domain `[1, kSpriteRendererMaxInstances]` — a frame's draw calls can never exceed its instance count; documented default `kSpriteRendererDefaultDrawCalls` = 64 = 2× the PRD §8.1 worst-case reference-scene budget of 30 draw calls) + the `maxDrawCalls()` accessor; NEW `textureMemoryBytes()` gauge (the texture-memory VRAM estimate: the bound atlases' w × h × 4 sum — updated at `bindAtlas`, a re-bind subtracts the old upload + adds the new, reads 0 in the stopped state); **G-R2 semantics** — a frame whose draw calls (== its group count) STRICTLY exceed the cap is STILL drawn (observation, never an execution gate — the G-R5 precedent): one rate-limited Warn `draw_call_cap` (fields `capacity`, `draw_calls`) on the over-cap submit, the frame's `drawCallCapExceeded` flag (the frame-graph flag M2-PROF-01 will report) + the totals' `capExceededFrames`; **contract** — a failed submit zeroes the frame counters (now enforced on all three GL failure paths: upload, query creation, draw); the atlas registry slot grew 8 B → 16 B (the width/height bookkeeping: 32 KB → 64 KB at 4096 slots); **tests** — NEW `tests/laige-render/render_counters_tests.cpp` (new CTest entry `render_counters` = the step's Verify command; 8 tests / 4 suites): `RenderCountersCreate` (no GL: the maxDrawCalls validation first-failure-wins + one Warn `options_invalid`, the default reaching the context check → `GlUnavailable`, the stopped state reads zero), `RenderCountersScene` (GL: the roadmap's known small scene — 10 sprites, 2 atlases, 2 blends → 3 groups — per-frame counters pinned EXACTLY for frame 1 {3 draws, 2 binds, 3 blend changes, 1 program change, 10 instances, 520 B upload, 65 536 B target} + frame 2 {3, 2, 2, 1, 10, 520, 65 536 — the cross-frame last-atlas/last-blend state persistence} + the since-construction totals, the empty frame counts nothing, the opt-in `PRIMITIVES_GENERATED` feed = 20), `RenderCountersCap` (GL: the warn fires AT the configured count (cap=2) with the pinned fields, the frame still drawn, the second frame repeats the warn + total, a 2-group frame AT the cap has no flag + no new warn (1 bind + 1 blend change from the persisted state), cap=3 → no warn at the cap), `RenderCountersMemory` (GL: the gauge exact — two 4×4 atlases = 128 B; a second renderer's 8×8 = 256 B, the 16×16 re-bind replacement = 1024 B); **docs** — `docs/api/sprite_renderer.md` (NEW "Render observability + the draw-call cap" section: the field table + the cap semantics; the API snippet, the create/bindAtlas/submit/frameStats bullets, the Performance section, the misuse warnings), `docs/README.md` index entry, the module README status paragraph, the roadmap box; `laige-api.json` regenerated (1233 symbols / 38 headers, +12 symbols); **verification** — all six local trees warning-clean + full ctest green: build 111/111, build-clang 111/111, build-release 100/100, build-shared 111/111, build-asan 108/108, build-tsan 108/108; the first CI TSan run reproduced a DRIVER-INTERNAL teardown data race (both race accesses inside libgallium: the `eglDestroyContext` teardown destroys the driver's internal mutex/condvar while the llvmpipe worker thread is still inside it — no engine code between the pthread frames), triggered by a query-enabled submit as the FIRST FBO operation of the frame — fixed in the test with the frame-pipeline clear-before-draw pattern (`RenderCountersScene.PrimitiveQueryFeed` clears the target before the submit — the `SpriteDrawSmoke.ThousandSpriteFrame` precedent; the clear touches no sprite-pass state, the counters are unaffected; a clear AFTER the submit or a readback quiesce does NOT fix it — the clear must precede the first draw); `api-real-tree`/`api-check-fresh`, `tools/laige-include-lint`, and `tools/laige-determinism-lint` green; **budget** — no standalone `budgets.json` entry (the counters are O(1) bookkeeping; the composite 50k render-CPU budget is measured with M2-PERF-01); **compat** — additive only (no existing symbol's signature or meaning changed; the new public API is the 4 `SpriteDrawStats` fields + the 4 `SpriteDrawTotals` fields + `Options::maxDrawCalls` + `kSpriteRendererDefaultDrawCalls` + `maxDrawCalls()` + `textureMemoryBytes()` = 12 symbols); Progress Board M2 16/33, total 62/194 | --- diff --git a/tests/laige-render/render_counters_tests.cpp b/tests/laige-render/render_counters_tests.cpp index 97131e4..864dd7a 100644 --- a/tests/laige-render/render_counters_tests.cpp +++ b/tests/laige-render/render_counters_tests.cpp @@ -532,6 +532,18 @@ TEST(RenderCountersScene, PrimitiveQueryFeed) { // the 4-vertex strip (20 for the 10-sprite scene). const std::vector items = sceneItems(); declareFrame(batcher, items); + // The frame-pipeline pattern: the render target is cleared before + // the draw (the real frame loop always clears first). Required + // here for a second reason — a query-enabled submit as the FIRST + // FBO operation leaves the Mesa llvmpipe worker's lazy pipe + // initialization in a state that races the context's teardown + // under TSan (a driver-internal data race, both accesses inside + // libgallium — verified on CI's Mesa 25.2.8 and this machine's + // Mesa 26.2.3). A clear before the draw (the + // ThousandSpriteFrame pattern) leaves the teardown clean. The + // clear touches no sprite-pass state — the counters below are + // unaffected. + EXPECT_TRUE(ctx.clear(0, 0, 0, 0).ok()); EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); const SpriteDrawStats s1 = renderer.frameStats(); EXPECT_EQ(s1.primitives, 20u);