From 77b4cfcb4fda0aef90bfe84142d2128a72f6d94c Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Mon, 12 Oct 2026 22:19:32 +0200 Subject: [PATCH 1/2] [M2-PROF-01] CPU/GPU render timing (RENDER-005, independent CPU and GPU measurement) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-pass CPU phase times (batch, sort, submit) from the render-thread timers; opt-in per-pass GPU time via one reused GL TIME_ELAPSED query object (one frame late, honest 'n/a' headless — never a fake number); stall detection for the CPU waiting on the GPU's implicit upload sync (one rate-limited Warn per stalled frame); the M1-PROF-01 profiler gains the primitive-typed render feeds (text/JSON/summary surfaces). Tests: ctest -R render_timing (8 tests: the stallThresholdUs validation + stopped state, the CPU phase times with exact totals, the GPU timing's one-frame-late semantics, the stall detection, and the slow-shader fixture with GPU time > CPU time on a hardware-GL runner, SKIPPED on software renderers) + the profiler counter-model render-timing tests. laige-api.json 1815 -> 1851 symbols; budgets.json unchanged (17). Local 4-tree Linux gate green (117/117, 117/117, 112/112, 112/112 minus the 10 GL-creating entries per the GPU policy); include-lint + determinism-lint green. Roadmap box checked; Progress Board M2 29/33 (total 75/194). --- docs/api/profiler.md | 29 +- docs/api/sprite_batcher.md | 17 + docs/api/sprite_renderer.md | 123 ++- laige-api.json | 328 ++++---- roadmap/M2-rendering-2.5d.md | 49 +- roadmap/README.md | 5 +- src/laige-render/README.md | 46 +- .../include/laige/render/sprite_batcher.h | 89 +- .../include/laige/render/sprite_renderer.h | 91 ++- src/laige-render/sprite_renderer.cpp | 157 ++++ src/laige-sim/README.md | 20 + src/laige-sim/include/laige/sim/profiler.h | 55 ++ src/laige-sim/profiler.cpp | 127 +++ tests/laige-render/CMakeLists.txt | 99 ++- tests/laige-render/render_timing_tests.cpp | 770 ++++++++++++++++++ tests/laige-sim/profiler_tests.cpp | 89 ++ tools/README.md | 3 +- 17 files changed, 1910 insertions(+), 187 deletions(-) create mode 100644 tests/laige-render/render_timing_tests.cpp diff --git a/docs/api/profiler.md b/docs/api/profiler.md index 113efd8..0f01d1b 100644 --- a/docs/api/profiler.md +++ b/docs/api/profiler.md @@ -56,6 +56,7 @@ opts.profiler = &prof; // per-completed-tick timing, success only | sim alloc count (target: 0 in sim) | `World::archetypeStats().totalReservations` (M1-ECS-03 pool accounting — the pool-backed sim storage's reserved column blocks) | pulled at snapshot | | systems | `World::systemCount()` | pulled at snapshot | | draw calls / texture binds / net bytes | `Profiler::addDrawCalls` / `addTextureBinds` / `addNetBytes` (the M2/M3 feeds) | **0 in headless M1** — the fields exist per FR-11.1; the render (M2) and network (M3) subsystems feed them | +| render timing (M2-PROF-01): batch/sort/submit ms, GPU us + completed frame count, stall us + frame count, texture memory bytes | `Profiler::addRenderBatchMs` / `addRenderSortMs` / `addRenderSubmitMs` / `addRenderGpu` / `addRenderStall` / `setRenderTextureMemory` (the render-thread feeds) | **0 in headless M1** — the engine's render thread feeds them per frame (sprite_renderer.md's Render timing section); `gpu_us` renders "n/a" until the first completed GPU measurement | The `Profiler` owns **only** what it measures at the frame/tick boundary (the two windows, the counters). Everything else is pulled @@ -126,6 +127,7 @@ laige-profile counters: ticks=100 frames=101 draw_calls=0 texture_binds=0 net_by laige-profile tick_ms: n=100 min=0.000191 mean=0.0013269 p50=0.001042 p95=0.002725 p99=0.002966 max=0.003197 laige-profile frame_ms: n=101 min=0.00017 mean=0.00221881 p50=0.001854 p95=0.004429 p99=0.00482 max=0.00496 laige-profile world: entities_alive=0 entities_total=0 entity_capacity=10000 sim_allocs=0 systems=1 +laige-profile render: batch_ms=0 sort_ms=0 submit_ms=0 gpu_us=n/a gpu_frames=0 stall_us=0 stall_frames=0 texture_memory_bytes=0 laige-profile system id=1 name=EngTickCounter budget_ms=1 runs=100 last_ms=0.0004 warns=0 errors=0 window: n=100 min=... ``` @@ -159,6 +161,9 @@ precondition); number values are the core JSON canonical form "world": { "entities_alive": 0, "entities_total": 0, "entity_capacity": 10000, "sim_allocs": 0, "systems": 1 }, + "render": { "batch_ms": 0.0, "sort_ms": 0.0, "submit_ms": 0.0, + "gpu_us": null, "gpu_frames": 0, "stall_us": 0, + "stall_frames": 0, "texture_memory_bytes": 0 }, "systems": [ { "id": 1, "name": "EngTickCounter", "budget_ms": 1, "runs": 100, "last_ms": 0.0004, "warns": 0, "errors": 0, @@ -169,9 +174,19 @@ precondition); number values are the core JSON canonical form ``` `window_ms` (and the top-level windows) are `null` when the window is -empty. The CLI one-line summary (always printed by `laige-run`, -engine.md) is `formatProfileSummaryLine(stats)` — the same fields -compressed into one greppable line. +empty; `render.gpu_us` is likewise `null` when no GPU measurement has +completed (`gpu_frames` 0 — the headless/offscreen fallback, never a +fake number). The CLI one-line summary (always printed by +`laige-run`, engine.md) is `formatProfileSummaryLine(stats)` — the +same fields compressed into one greppable line: + +``` +laige-run profile: ticks=100 frames=101 tick_ms: n=100 min=... frame_ms: n=101 min=... entities_alive=0 entities_total=0 entity_capacity=10000 sim_allocs=0 draw_calls=0 texture_binds=0 net_bytes=0 render_batch_ms=0 render_sort_ms=0 render_submit_ms=0 render_gpu_us=n/a render_stall_frames=0 render_texture_memory_bytes=0 +``` + +(the summary's GPU field renders `n/a` on the same rule — +`render_gpu_us` is the one summary field that is not always a +number). ## The engine's per-run report (the `--prof-out` surface) @@ -218,8 +233,8 @@ compressed into one greppable line. two `steady_clock` reads per frame (the engine run loop) + one O(1) ring write. **No allocation and no logging** (PERF-003, LOG-003; the `profiler-zeroalloc` test asserts the record path - allocates nothing: 1000 tick + 1000 frame records + 3 adders + one - snapshot, `allocs=0`). + allocates nothing: 1000 tick + 1000 frame records + 3 M2/M3 + adders + 6 M2-PROF-01 render feeds + one snapshot, `allocs=0`). - **Disabled** (`setEnabled(false)`, or `Options::profiler == nullptr`): one branch per tick and per frame, nothing else (DBG-004: disabled instrumentation has negligible cost). @@ -279,7 +294,9 @@ compressed into one greppable line. `ProfilerEngine`, `ProfilerReport`, `ProfilerZeroAlloc`, `ProfilerCost`): the counter model's exact percentiles (1..100 → p50=50, p95=95, p99=99), window rollover, the zero-capacity drop, - the disabled no-op + preserved state, the moved-from stop, the + the M2-PROF-01 render-timing feeds (the exact accumulation, the + "n/a" GPU contract, the texture-memory gauge), the disabled no-op + + preserved state, the moved-from stop, the cold world-pulled snapshot, the per-completed-tick timing (failed ticks unrecorded), the engine's per-run cache, the report (written on every run path, version-1 JSON parseable, the M1-SYS-03 diff --git a/docs/api/sprite_batcher.md b/docs/api/sprite_batcher.md index 741d745..4d91fc4 100644 --- a/docs/api/sprite_batcher.md +++ b/docs/api/sprite_batcher.md @@ -48,6 +48,11 @@ struct SpriteBatch { // one output group (the submit stage's read) std::span instances{}; // pool slots, back-to-front }; +struct SpriteBatchTiming { // this frame's CPU phase times (M2-PROF-01) + double batchMs{}; // beginFrame() (or create, frame 1) -> build() start + double sortMs{}; // build() itself (the sort + group + scatter), ms +}; + class SpriteBatcher { public: struct Options { std::uint32_t maxSprites{}; }; // the frame budget @@ -63,6 +68,8 @@ class SpriteBatcher { [[nodiscard]] std::uint32_t overrideCount() const noexcept; // this frame (G-R11) [[nodiscard]] std::uint64_t overrideTotal() const noexcept; // cumulative (G-R11) [[nodiscard]] std::uint64_t droppedTotal() const noexcept; // cumulative overflow + [[nodiscard]] SpriteBatchTiming frameTiming() const noexcept; // M2-PROF-01 + [[nodiscard]] SpriteBatchTimingTotals timingTotals() const noexcept; // since build [[nodiscard]] std::uint32_t capacity() const noexcept; [[nodiscard]] PoolStats itemPoolStats() const noexcept; [[nodiscard]] const SpriteItem* get(std::uint32_t slot) noexcept; // null-safe @@ -117,6 +124,16 @@ The frame protocol (the frame pipeline's cull/batch stage, M2-GL-02): `beginFrame`) and `overrideTotal()` (cumulative) — the profiler's feeds (PRD §10.4); `droppedTotal()` (cumulative overflow); `itemPoolStats()` (the pool's accounting snapshot). +- **CPU phase timing (M2-PROF-01, RENDER-005)**: `frameTiming()` (this + frame: `batchMs` = the frame's window, `beginFrame()`/create up to + `build()` start — the declaration work; `sortMs` = `build()` + itself) and `timingTotals()` (since construction, successful builds + only) — the profiler's `addRenderBatchMs` / `addRenderSortMs` + feeds (sprite_renderer.md's Render timing section). Two + `steady_clock` reads per frame (plus one at create for frame 1's + window), no allocation, wall-clock diagnostics only (ARCH-009 — + never sim/replay state). The stopped state (capacity 0) records + nothing: both structs read zero. ## Grouping and order (RENDER-001/003) diff --git a/docs/api/sprite_renderer.md b/docs/api/sprite_renderer.md index 6bffd24..b40c663 100644 --- a/docs/api/sprite_renderer.md +++ b/docs/api/sprite_renderer.md @@ -62,12 +62,22 @@ struct SpriteDrawStats { // the per-frame counters of the last successful subm 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 + double submitMs; // M2-PROF-01: this submit's CPU wall time, ms + std::uint64_t uploadWallUs; // the glBufferSubData CPU wall time, us + bool uploadStalled; // uploadWallUs > stallThresholdUs + std::uint64_t gpuTimeUs; // previous pass's GPU time, us (0 if !valid) + bool gpuTimeValid; // false = "n/a" (M2-PROF-01) }; struct SpriteDrawTotals { // since-construction (successful submits only) std::uint64_t frames, drawCalls, textureBinds, blendChanges, programChanges, instances, primitives, uploadBytes, renderTargetBytes, capExceededFrames; + double submitMs; // M2-PROF-01: the submits' CPU wall-time sum, ms + std::uint64_t uploadStallUs; // the stalled uploads' wait sum, us + std::uint64_t uploadStallFrames; // the stalled submits' frame count + std::uint64_t gpuTimeUs; // the completed GPU measurements' sum, us + std::uint64_t gpuTimeFrames; // the completed measurement count (0 = n/a) }; class SpriteRenderer { @@ -77,10 +87,13 @@ class SpriteRenderer { 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 + bool gpuTiming{false}; // opt-in per-pass GPU time (M2-PROF-01) + std::uint32_t stallThresholdUs{2000}; // stall threshold (M2-PROF-01) }; static constexpr std::uint32_t kSpriteRendererMaxInstances = 0xFFFFFFFFu; static constexpr std::uint32_t kSpriteRendererMaxAtlases = 4096u; static constexpr std::uint32_t kSpriteRendererDefaultDrawCalls = 64u; + static constexpr std::uint32_t kSpriteRendererDefaultStallUs = 2000u; SpriteRenderer() noexcept; // stopped state [[nodiscard]] static Result @@ -204,7 +217,11 @@ class SpriteRenderer { 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). + `capExceededFrames` (successful over-cap submits). The + M2-PROF-01 timing fields: `submitMs` (the submit's CPU wall time), + `uploadWallUs` / `uploadStalled` (the stall detection), and + `gpuTimeUs` / `gpuTimeValid` (the opt-in GPU pass time, one frame + late) — the Render timing section below. - **`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 @@ -220,6 +237,22 @@ class SpriteRenderer { 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. +- **`Options::gpuTiming`** (off by default): the opt-in per-pass GPU + time (M2-PROF-01, RENDER-005 — the Render timing section below). + ON: ONE `GL_TIME_ELAPSED` query object created at `create` and + reused every frame (no per-frame GL allocation), a + `glBeginQuery`/`glEndQuery` pair around the pass, and one + availability read per frame — NO CPU/GPU synchronization (unlike + `primitiveQuery`'s `glFinish`), so it is safe for the shipping + frame loop as well as diagnostics. +- **`Options::stallThresholdUs`** (M2-PROF-01): the stall threshold — + the `glBufferSubData` upload's CPU wall time above which the + submit is flagged `uploadStalled` (the CPU waited on the GPU's + implicit sync — the Render timing section below). Documented + default `kSpriteRendererDefaultStallUs` = 2000 us (~12% of the + default 60 Hz frame period); domain `[1, 0xFFFFFFFF]` (0 is + rejected at `create` — first failure wins, the `options_invalid` + warn, the `option=stallThresholdUs` field). ## One draw per group (RENDER-001/003) @@ -293,6 +326,84 @@ the configured count with the pinned fields (`RenderCountersCap`), and the VRAM gauge tracks binds + re-bind replacements exactly (`RenderCountersMemory`). +## Render timing (M2-PROF-01, RENDER-005) + +CPU and GPU are measured independently (RENDER-005): the CPU phase +times come from the render-thread `steady_clock` timers (two to four +reads per submit — no allocation, no GL work, negligible by the +PERF-004 standard), and the GPU time comes from a GL `TIME_ELAPSED` +query object that wraps the pass's GL work without any CPU/GPU +synchronization. All timing values are wall-clock diagnostics +(ARCH-009): they never enter sim state, hashes, or replays. + +| Field | Meaning | +| --- | --- | +| `submitMs` | this submit's CPU wall time, ms (the whole `submit` call — includes the opt-in `primitiveQuery`'s `glFinish` when that diagnostic is on) | +| `uploadWallUs` | the `glBufferSubData` upload's CPU wall time, us | +| `uploadStalled` | `uploadWallUs > stallThresholdUs` — the stall detection below | +| `gpuTimeUs` / `gpuTimeValid` | the PREVIOUS pass's GPU time, us / whether it is a real measurement (`gpuTimeValid` false = "n/a") | +| `totals.submitMs` | the submits' CPU wall-time sum, ms | +| `totals.uploadStallUs` / `totals.uploadStallFrames` | the stalled uploads' wait sum, us / frame count (successful submits only) | +| `totals.gpuTimeUs` / `totals.gpuTimeFrames` | the completed GPU measurements' sum, us / count (`gpuTimeFrames` 0 = the reports render the GPU field as "n/a") | + +**The GPU time is one frame late.** The `GL_TIME_ELAPSED` +measurement of frame N's pass completes when the GPU finishes frame +N's work — which the engine reads at the START of frame N+1's submit +(the value lands in frame N+1's `frameStats()`). In steady state: +frame N's `gpuTimeUs` is frame N−1's pass. The first frame always +reads "n/a" (nothing pending yet), and a GPU that is more than one +frame behind leaves the intervening frames at "n/a" too — an honest +"GPU busy" reading, **never a fake number** (the headless/offscreen +fallback of the roadmap: environments that cannot complete timing +queries simply report `gpuTimeValid` false). A FAILED submit poisons +its own in-flight measurement (the value is discarded when it +completes); a previous frame's still-pending measurement is never +poisoned (it belongs to a successful submit). The single query object +is created at `create` (one `glGenQueries`) and reused across frames — +no per-frame GL allocation; when `gpuTiming` is off there is zero GL +work and zero cost (PERF-002/004). + +**The stall detection: the CPU waiting on the GPU's implicit sync.** +The frame's only implicit CPU/GPU sync point is the one +`glBufferSubData` into the REUSED instance buffer: the driver may +block the upload until the GPU finishes reading the previous frame's +data. The upload call's wall time is measured (`uploadWallUs`, two +`steady_clock` reads); above `Options::stallThresholdUs` the frame is +flagged `uploadStalled` and one rate-limited Warn +`sprite_renderer/submit_stall` fires (fields `stall_us`, +`upload_bytes`; LOG-004). The frame is still drawn — observation, +never an execution gate (the G-R2 precedent). The documented default +`kSpriteRendererDefaultStallUs` = 2000 us is ~12% of the default 60 +Hz frame period (16.67 ms): a wait longer than that is a visible +hitch, and a normal 50k-frame upload (2.6 MB) queues in well under +it on the P0 drivers. + +**The profiler feed.** The render thread feeds the always-on +profiler (`Profiler`, M1-PROF-01) per frame from these values +(primitive-typed feeds — `laige-sim` cannot depend on `laige-render` +types, ARCH-001): `addRenderBatchMs(batcher.frameTiming().batchMs)`, +`addRenderSortMs(batcher.frameTiming().sortMs)`, +`addRenderSubmitMs(frameStats().submitMs)`, +`addRenderGpu(frameStats().gpuTimeUs)` when `gpuTimeValid`, +`addRenderStall(frameStats().uploadWallUs)` when `uploadStalled`, and +`setRenderTextureMemory(textureMemoryBytes())` (the VRAM estimate +gauge, M2-SPRITE-04). The engine's render-thread wiring lands with +the engine integration step (the M1-PROF-01 `addDrawCalls` feed +precedent — the headless M1 run feeds no render values). + +`ctest -R render_timing` (`RenderTimingCpu` / `RenderTimingGpu` / +`RenderTimingStall` / `RenderTimingSlowShader`) pins the CPU phase +times and EXACT totals accumulation, the GPU timing's off-by-default +"n/a" and one-frame-late semantics under `glFinish`-paced frames, the +stall detection (a 4096-sprite frame under a 1 us threshold stalls — +one Warn per stalled frame with the pinned fields; a small frame +under the default threshold does not), and the roadmap's +integration check (a deliberately slow shader — a 16384-iteration +dependent sin loop — makes the GPU-side pass time EXCEED the +CPU-side submit time on a hardware-GL runner; the test SKIPS on a +software renderer, where the CPU is the GPU and the driver is +synchronous). + ## The shader and the pass's GL state The whole M2 sprite feature is one minimal GLSL 3.30 program (no @@ -375,7 +486,9 @@ profiler feed): `textureBinds`, `blendChanges`, `drawCalls`, `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 +the `textureMemoryBytes()` VRAM estimate, and the M2-PROF-01 timing +fields (`submitMs`, `uploadWallUs` / `uploadStalled`, the opt-in +`gpuTimeUs` / `gpuTimeValid`). The composite 50k render-CPU budget (PRD §8.1) is measured with this stage at M2-PERF-01 (the laige-bench `render-50k` suite: the submit stage's offscreen render sits OUTSIDE the measured batch-stage CPU window, @@ -399,6 +512,12 @@ Traps: - **`primitiveQuery` syncs**: the query read takes a `glFinish` — CPU/GPU serialization (RENDER-005). Diagnostic mode only; never enable it in the shipping frame loop. +- **`gpuTiming` does not**: the `TIME_ELAPSED` query wraps the pass + without any synchronization (RENDER-005 — CPU and GPU measured + independently); the cost is two GL query calls + one availability + read per frame, no allocation. The first frame and any frame the + GPU is more than one frame behind read "n/a" (`gpuTimeValid` + false), never a fake number. - **A failed submit draws nothing and counts nothing** — but it also does not leave stale sprite-pass state (the next successful submit re-establishes everything). diff --git a/laige-api.json b/laige-api.json index bc6c0a1..56178dd 100644 --- a/laige-api.json +++ b/laige-api.json @@ -1000,60 +1000,69 @@ {"name": "laige::render::ProjectionView::worldToScreen", "kind": "method", "header": "src/laige-render/include/laige/render/projection.h", "line": 261, "signature": "[[nodiscard]] Vec3 worldToScreen(Vec2 p2d, float depth) const noexcept", "summary": "The NDC of the 2.5D world point (the ground plane (p2d.x, p2d.y) + elevation depth). Pure (no mutation); no allocation, no logging. free_cinematic; w = 1 exactly for the affine modes).", "budget": "O(1): one 4x4 matrix multiply (+ one divide for", "experimental": false}, {"name": "laige::render::ProjectionView::screenToWorldRay", "kind": "method", "header": "src/laige-render/include/laige/render/projection.h", "line": 267, "signature": "[[nodiscard]] WorldRay screenToWorldRay(Vec2 ndc) const noexcept", "summary": "The world preimage ray of the NDC screen point (the header preamble's per-mode geometry). Pure; no allocation, no logging. two divides (free_cinematic).", "budget": "O(1): a 2x2 solve (the affine modes) or one 4x4 inverse +", "experimental": false}, {"name": "laige::render::ProjectionView::screenToWorld", "kind": "method", "header": "src/laige-render/include/laige/render/projection.h", "line": 274, "signature": "[[nodiscard]] laige::Result screenToWorld(Vec2 ndc, Plane plane) const noexcept", "summary": "The preimage line/ray ∩ the plane (the header preamble): the world point on success; InvalidArgument when the plane is parallel to the ray or (free_cinematic) the intersection is behind the camera. Pure; no allocation, no logging.", "budget": "O(1): one screenToWorldRay + two dot products + one divide.", "experimental": false}, - {"name": "laige::render::kSpriteBatcherMaxCapacity", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 245, "signature": "inline constexpr std::uint32_t kSpriteBatcherMaxCapacity = 0xFFFFFFFFu", "summary": "The most sprites a batcher may budget: the frame declaration slot is a 32-bit index (the pool slot, the key scratch, the instance array), so the budget domain is the index width (the DepthSort precedent).", "budget": null, "experimental": false}, - {"name": "laige::render::BlendMode", "kind": "enum", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 255, "signature": "enum class BlendMode : std::uint8_t", "summary": "The blend state a sprite is drawn with (the group key's third field). The enumerator values are stable (PRD §9.4: additive only); the submit stage (M2-SPRITE-02) maps each value to its GL blend state.", "budget": null, "experimental": false}, - {"name": "laige::render::BlendMode::Alpha", "kind": "enumerator", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 258, "signature": "Alpha = 0", "summary": "Standard alpha blend (src·srcAlpha + dst·(1 − srcAlpha)): the default for textured sprites.", "budget": null, "experimental": false}, - {"name": "laige::render::BlendMode::Additive", "kind": "enumerator", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 260, "signature": "Additive = 1", "summary": "Additive (src + dst): particles, light, glow (M2-PART-01).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteUvRect", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 271, "signature": "struct SpriteUvRect", "summary": "The sprite's UV sub-rect inside its atlas, normalized [0, 1]² (u1 > u0, v1 > v0 — the caller's invariant; spriteFrameUv, laige/render/sprite_frames.h, computes these from the atlas sheet frame layout — M2-SPRITE-03).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteUvRect::u0", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 272, "signature": "float u0{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteUvRect::v0", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 273, "signature": "float v0{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteUvRect::u1", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 274, "signature": "float u1{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteUvRect::v1", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 275, "signature": "float v1{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteTint", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 279, "signature": "struct SpriteTint", "summary": "The sprite's multiplicative RGBA tint (1, 1, 1, 1 = untinted).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteTint::r", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 280, "signature": "float r{1.0f}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteTint::g", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 281, "signature": "float g{1.0f}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteTint::b", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 282, "signature": "float b{1.0f}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteTint::a", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 283, "signature": "float a{1.0f}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteItem", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 294, "signature": "struct SpriteItem", "summary": "The value the game DECLARES for one sprite (S-5: declaration, not draw). A plain value — no ownership, nothing to release (the pool owns the storage). All fields are presentation state (ARCH-009): the caller fills them from its per-frame snapshot.", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteItem::pos", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 298, "signature": "Vec2 pos{0.0f, 0.0f}", "summary": "The world ground-plane position, world units (the presentation snapshot's interpolated position, M1-LOOP-02 — NEVER screen space, PRD §4).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteItem::depthKey", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 302, "signature": "std::uint32_t depthKey{}", "summary": "The M2-ISO-01 depth key (`isoDepthKey(pos, stepHeight, layer)`): the back-to-front order value. G-R11: engine-owned — compute it with isoDepthKey, never from screen space.", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteItem::depthOverride", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 306, "signature": "bool depthOverride{}", "summary": "The G-R11 escape-hatch flag: true when the caller set depthKey by hand (not via isoDepthKey). Counted per frame + warned at build (\"prefer tile height\"). The batcher sorts the key either way.", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteItem::uv", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 308, "signature": "SpriteUvRect uv{}", "summary": "The UV sub-rect in the atlas (SpriteUvRect above).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteItem::frameIndex", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 317, "signature": "std::uint32_t frameIndex{}", "summary": "The animation frame index this item was declared with (M2-SPRITE-03 — data-driven: the caller sets it; M3 animation will drive the frame advance). The engine does not interpret it: the caller also sets `uv` to this frame's UV sub-rect (see spriteFrameUv, laige/render/sprite_frames.h) — `uv` is what the renderer draws, the index is the declaration's frame record (presentation state, ARCH-009). The batcher carries it through untouched.", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteItem::rotation", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 320, "signature": "float rotation{}", "summary": "The rotation in radians (0 = unrotated; the submit stage applies it in screen space, M2-SPRITE-02).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteItem::scale", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 322, "signature": "Vec2 scale{1.0f, 1.0f}", "summary": "The world-unit scale (x, y) — non-uniform free (1, 1 = unscaled).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteItem::tint", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 324, "signature": "SpriteTint tint{}", "summary": "The multiplicative RGBA tint (SpriteTint above).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteItem::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 327, "signature": "std::uint32_t atlasId{}", "summary": "The atlas/texture reference (a stable asset handle — the asset system lands with M3-ASSET-01; for now the game assigns it).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteItem::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 330, "signature": "std::uint32_t materialId{}", "summary": "The material reference (0 = the default material; the material system is future work — the group key carries it per FR-2.1).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteItem::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 332, "signature": "BlendMode blend{}", "summary": "The blend state (the group key's third field).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteBatch", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 343, "signature": "struct SpriteBatch", "summary": "One (atlas, material, blend) group of the frame's sorted sprites: the state the submit stage sets ONCE (texture bind + material + blend — RENDER-001) and the group's instances in back-to-front order (one instanced draw call per group, FR-2.1).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteBatch::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 344, "signature": "std::uint32_t atlasId{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteBatch::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 345, "signature": "std::uint32_t materialId{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteBatch::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 346, "signature": "BlendMode blend{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteBatch::instances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 352, "signature": "std::span instances{}", "summary": "The group's instances, back-to-front: the frame's global depth order (M2-SORT-01) RESTRICTED to this group. Each element is the frame-scoped pool slot of the sprite (read the item with the batcher's at(slot) / get(slot)). Invalidated by the next beginFrame()/build() — read within the frame.", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteBatcher", "kind": "class", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 383, "signature": "class SpriteBatcher", "summary": "The frame declaration window + batch builder (the preamble: the frame protocol, the batch model, the overflow policy, the G-R11 accounting, the storage, the ownership, and the performance contracts).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteBatcher::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 389, "signature": "struct Options", "summary": "The frame budget (the declared scene sprite budget, S-6, API-006): the most sprites one frame may declare. Size it to the scene's worst-case visible count at scene set-up; the batcher never grows beyond it (the overflow policy: drop oldest + warn, the preamble).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteBatcher::Options::maxSprites", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 390, "signature": "std::uint32_t maxSprites{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteBatcher::SpriteBatcher", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 397, "signature": "SpriteBatcher() noexcept : items_(ArenaPool::Options{0})", "summary": "The default state: the EMPTY batcher (capacity 0, no storage). beginFrame()/build() work (the output is empty); add() fails with BudgetExhausted — the stopped-state pattern of the module's value objects (DepthSort), total and never UB.", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteBatcher::create", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 405, "signature": "[[nodiscard]] static Result create(Options options) noexcept", "summary": "The set-up path (scene load): one allocation per storage structure (the preamble's layout — ~136 B/capacity slot). Fails (InvalidArgument, no allocation) when maxSprites is 0 or exceeds kSpriteBatcherMaxCapacity (the slot-width domain).", "budget": "O(maxSprites); 8 allocations, setup only.", "experimental": false}, - {"name": "laige::render::SpriteBatcher::beginFrame", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 414, "signature": "void beginFrame() noexcept", "summary": "The frame protocol, part 1 (the preamble): close the previous window and open a new one — the previous frame's declared items are released (the pool's reset). Idempotent; a no-op on the stopped batcher.", "budget": "O(previous frame count); no allocation.", "experimental": false}, - {"name": "laige::render::SpriteBatcher::add", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 436, "signature": "[[nodiscard]] Result add(SpriteItem item) noexcept", "summary": "The frame protocol, part 2 (the preamble): declare ONE sprite for the current frame (the window is open: after create()/beginFrame(), until build()). The frame's DECLARATION ORDER is the insertion order: declare in the engine's deterministic entity-id iteration order (FR-1.2) — that is what makes the (key, entity id) total order reproducible (RENDER-003).", "budget": "O(1); no allocation on the success path (the pool create", "experimental": false}, - {"name": "laige::render::SpriteBatcher::build", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 452, "signature": "[[nodiscard]] Status build() noexcept", "summary": "The frame protocol, part 3 (the preamble): the batch stage. Sorts the frame's depth keys (M2-SORT-01), groups into (atlas, material, blend) batches (deterministic group order: ascending (atlas, material, blend) — RENDER-003), scatters each group's instances in global back-to-front order, and publishes batches(). Closes the declaration window.", "budget": "O(4n + 4·256 + n·log G + G·(log G + G)), G = the frame's", "experimental": false}, - {"name": "laige::render::SpriteBatcher::batchCount", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 457, "signature": "[[nodiscard]] std::size_t batchCount() const noexcept", "summary": "The frame's output (read within the frame; invalidated by the next beginFrame()/build()).", "budget": "O(1).", "experimental": false}, - {"name": "laige::render::SpriteBatcher::batches", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 459, "signature": "[[nodiscard]] std::span batches() const noexcept", "summary": null, "budget": "O(1); the span aliases the batcher's batch array.", "experimental": false}, - {"name": "laige::render::SpriteBatcher::frameCount", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 464, "signature": "[[nodiscard]] std::size_t frameCount() const noexcept", "summary": "The frame's declared count, after the overflow drops.", "budget": "O(1).", "experimental": false}, - {"name": "laige::render::SpriteBatcher::frameBuilt", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 472, "signature": "[[nodiscard]] bool frameBuilt() const noexcept", "summary": "True only between build() and the next beginFrame(): the submit stage's (M2-SPRITE-02) precondition — an unbuilt window (including one with declared items) must never be drawn as an empty frame (CORE-008). False before the first build and after every beginFrame(); true on a built empty frame (build() on the stopped state works and builds the empty frame).", "budget": "O(1).", "experimental": false}, - {"name": "laige::render::SpriteBatcher::overrideCount", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 476, "signature": "[[nodiscard]] std::uint32_t overrideCount() const noexcept", "summary": "G-R11: the current frame's manual depth-override declaration count (reset by beginFrame).", "budget": "O(1).", "experimental": false}, - {"name": "laige::render::SpriteBatcher::overrideTotal", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 482, "signature": "[[nodiscard]] std::uint64_t overrideTotal() const noexcept", "summary": "G-R11: the since-construction override total (the profiler's cumulative guardrail feed, PRD §9.3).", "budget": "O(1).", "experimental": false}, - {"name": "laige::render::SpriteBatcher::droppedTotal", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 488, "signature": "[[nodiscard]] std::uint64_t droppedTotal() const noexcept", "summary": "The since-construction dropped-oldest total (the overflow policy's accounting, the preamble).", "budget": "O(1).", "experimental": false}, - {"name": "laige::render::SpriteBatcher::capacity", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 493, "signature": "[[nodiscard]] std::uint32_t capacity() const noexcept", "summary": "The frame budget (the create() argument; 0 in the stopped state).", "budget": "O(1).", "experimental": false}, - {"name": "laige::render::SpriteBatcher::itemPoolStats", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 496, "signature": "[[nodiscard]] PoolStats itemPoolStats() const noexcept", "summary": "The sprite pool's accounting snapshot (PRD §10.4, DBG-008).", "budget": "O(1); no allocation.", "experimental": false}, - {"name": "laige::render::SpriteBatcher::get", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 502, "signature": "[[nodiscard]] const SpriteItem* get(std::uint32_t slot) noexcept", "summary": "The item declared at the frame-scoped slot (the null-safe read — nullptr when the slot is not in the current frame window).", "budget": "O(1); no allocation.", "experimental": false}, - {"name": "laige::render::SpriteBatcher::at", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 507, "signature": "[[nodiscard]] const SpriteItem& at(std::uint32_t slot)", "summary": "The item declared at the frame-scoped slot (debug: asserts the slot is live — the S-9 fail-loudly contract; release: undefined on a stale slot, the engine Result convention).", "budget": "O(1); no allocation.", "experimental": false}, - {"name": "laige::render::SpriteBatcher::SpriteBatcher", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 509, "signature": "SpriteBatcher(const SpriteBatcher&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteBatcher::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 510, "signature": "SpriteBatcher& operator=(const SpriteBatcher&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteBatcher::SpriteBatcher", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 514, "signature": "SpriteBatcher(SpriteBatcher&&) noexcept = default", "summary": "Move: the members' moves (the pool and the sorter leave the source stopped; the flat buffers transfer). The source becomes the stopped state (CORE-009).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteBatcher::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 515, "signature": "SpriteBatcher& operator=(SpriteBatcher&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kSpriteBatcherMaxCapacity", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 246, "signature": "inline constexpr std::uint32_t kSpriteBatcherMaxCapacity = 0xFFFFFFFFu", "summary": "The most sprites a batcher may budget: the frame declaration slot is a 32-bit index (the pool slot, the key scratch, the instance array), so the budget domain is the index width (the DepthSort precedent).", "budget": null, "experimental": false}, + {"name": "laige::render::BlendMode", "kind": "enum", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 256, "signature": "enum class BlendMode : std::uint8_t", "summary": "The blend state a sprite is drawn with (the group key's third field). The enumerator values are stable (PRD §9.4: additive only); the submit stage (M2-SPRITE-02) maps each value to its GL blend state.", "budget": null, "experimental": false}, + {"name": "laige::render::BlendMode::Alpha", "kind": "enumerator", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 259, "signature": "Alpha = 0", "summary": "Standard alpha blend (src·srcAlpha + dst·(1 − srcAlpha)): the default for textured sprites.", "budget": null, "experimental": false}, + {"name": "laige::render::BlendMode::Additive", "kind": "enumerator", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 261, "signature": "Additive = 1", "summary": "Additive (src + dst): particles, light, glow (M2-PART-01).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteUvRect", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 272, "signature": "struct SpriteUvRect", "summary": "The sprite's UV sub-rect inside its atlas, normalized [0, 1]² (u1 > u0, v1 > v0 — the caller's invariant; spriteFrameUv, laige/render/sprite_frames.h, computes these from the atlas sheet frame layout — M2-SPRITE-03).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteUvRect::u0", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 273, "signature": "float u0{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteUvRect::v0", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 274, "signature": "float v0{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteUvRect::u1", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 275, "signature": "float u1{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteUvRect::v1", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 276, "signature": "float v1{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteTint", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 280, "signature": "struct SpriteTint", "summary": "The sprite's multiplicative RGBA tint (1, 1, 1, 1 = untinted).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteTint::r", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 281, "signature": "float r{1.0f}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteTint::g", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 282, "signature": "float g{1.0f}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteTint::b", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 283, "signature": "float b{1.0f}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteTint::a", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 284, "signature": "float a{1.0f}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 295, "signature": "struct SpriteItem", "summary": "The value the game DECLARES for one sprite (S-5: declaration, not draw). A plain value — no ownership, nothing to release (the pool owns the storage). All fields are presentation state (ARCH-009): the caller fills them from its per-frame snapshot.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::pos", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 299, "signature": "Vec2 pos{0.0f, 0.0f}", "summary": "The world ground-plane position, world units (the presentation snapshot's interpolated position, M1-LOOP-02 — NEVER screen space, PRD §4).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::depthKey", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 303, "signature": "std::uint32_t depthKey{}", "summary": "The M2-ISO-01 depth key (`isoDepthKey(pos, stepHeight, layer)`): the back-to-front order value. G-R11: engine-owned — compute it with isoDepthKey, never from screen space.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::depthOverride", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 307, "signature": "bool depthOverride{}", "summary": "The G-R11 escape-hatch flag: true when the caller set depthKey by hand (not via isoDepthKey). Counted per frame + warned at build (\"prefer tile height\"). The batcher sorts the key either way.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::uv", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 309, "signature": "SpriteUvRect uv{}", "summary": "The UV sub-rect in the atlas (SpriteUvRect above).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::frameIndex", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 318, "signature": "std::uint32_t frameIndex{}", "summary": "The animation frame index this item was declared with (M2-SPRITE-03 — data-driven: the caller sets it; M3 animation will drive the frame advance). The engine does not interpret it: the caller also sets `uv` to this frame's UV sub-rect (see spriteFrameUv, laige/render/sprite_frames.h) — `uv` is what the renderer draws, the index is the declaration's frame record (presentation state, ARCH-009). The batcher carries it through untouched.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::rotation", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 321, "signature": "float rotation{}", "summary": "The rotation in radians (0 = unrotated; the submit stage applies it in screen space, M2-SPRITE-02).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::scale", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 323, "signature": "Vec2 scale{1.0f, 1.0f}", "summary": "The world-unit scale (x, y) — non-uniform free (1, 1 = unscaled).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::tint", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 325, "signature": "SpriteTint tint{}", "summary": "The multiplicative RGBA tint (SpriteTint above).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 328, "signature": "std::uint32_t atlasId{}", "summary": "The atlas/texture reference (a stable asset handle — the asset system lands with M3-ASSET-01; for now the game assigns it).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 331, "signature": "std::uint32_t materialId{}", "summary": "The material reference (0 = the default material; the material system is future work — the group key carries it per FR-2.1).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 333, "signature": "BlendMode blend{}", "summary": "The blend state (the group key's third field).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatch", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 344, "signature": "struct SpriteBatch", "summary": "One (atlas, material, blend) group of the frame's sorted sprites: the state the submit stage sets ONCE (texture bind + material + blend — RENDER-001) and the group's instances in back-to-front order (one instanced draw call per group, FR-2.1).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatch::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 345, "signature": "std::uint32_t atlasId{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatch::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 346, "signature": "std::uint32_t materialId{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatch::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 347, "signature": "BlendMode blend{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatch::instances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 353, "signature": "std::span instances{}", "summary": "The group's instances, back-to-front: the frame's global depth order (M2-SORT-01) RESTRICTED to this group. Each element is the frame-scoped pool slot of the sprite (read the item with the batcher's at(slot) / get(slot)). Invalidated by the next beginFrame()/build() — read within the frame.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatchTiming", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 364, "signature": "struct SpriteBatchTiming", "summary": "The per-frame CPU-phase times of the batch stage (one successful build's measurement; the render-thread timers — the batcher is the cull/batch stage's owner, M2-GL-02). Wall-clock diagnostics (ARCH-009): they never enter sim state, hashes, or replays.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatchTiming::batchMs", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 368, "signature": "double batchMs{0}", "summary": "The BATCH phase (ms): the frame's window, beginFrame() (or create, the first frame) up to build() start — the declaration work (the scene's declares, the parallax/tile declares).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatchTiming::sortMs", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 371, "signature": "double sortMs{0}", "summary": "The SORT phase (ms): build() itself (the M2-SORT-01 sort + the group pass + the instance scatter).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatchTimingTotals", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 377, "signature": "struct SpriteBatchTimingTotals", "summary": "The since-construction CPU-phase totals (the profiler's cumulative feed, PRD §10.4 / FR-11.1; the profiler reads it on the owner thread, DBG-005).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatchTimingTotals::frames", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 379, "signature": "std::uint64_t frames{0}", "summary": "Successful builds recorded (== the frameTiming samples).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatchTimingTotals::batchMs", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 380, "signature": "double batchMs{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatchTimingTotals::sortMs", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 381, "signature": "double sortMs{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatcher", "kind": "class", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 412, "signature": "class SpriteBatcher", "summary": "The frame declaration window + batch builder (the preamble: the frame protocol, the batch model, the overflow policy, the G-R11 accounting, the storage, the ownership, and the performance contracts).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatcher::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 418, "signature": "struct Options", "summary": "The frame budget (the declared scene sprite budget, S-6, API-006): the most sprites one frame may declare. Size it to the scene's worst-case visible count at scene set-up; the batcher never grows beyond it (the overflow policy: drop oldest + warn, the preamble).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatcher::Options::maxSprites", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 419, "signature": "std::uint32_t maxSprites{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatcher::SpriteBatcher", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 426, "signature": "SpriteBatcher() noexcept : items_(ArenaPool::Options{0})", "summary": "The default state: the EMPTY batcher (capacity 0, no storage). beginFrame()/build() work (the output is empty); add() fails with BudgetExhausted — the stopped-state pattern of the module's value objects (DepthSort), total and never UB.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatcher::create", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 434, "signature": "[[nodiscard]] static Result create(Options options) noexcept", "summary": "The set-up path (scene load): one allocation per storage structure (the preamble's layout — ~136 B/capacity slot). Fails (InvalidArgument, no allocation) when maxSprites is 0 or exceeds kSpriteBatcherMaxCapacity (the slot-width domain).", "budget": "O(maxSprites); 8 allocations, setup only.", "experimental": false}, + {"name": "laige::render::SpriteBatcher::beginFrame", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 443, "signature": "void beginFrame() noexcept", "summary": "The frame protocol, part 1 (the preamble): close the previous window and open a new one — the previous frame's declared items are released (the pool's reset). Idempotent; a no-op on the stopped batcher.", "budget": "O(previous frame count); no allocation.", "experimental": false}, + {"name": "laige::render::SpriteBatcher::add", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 465, "signature": "[[nodiscard]] Result add(SpriteItem item) noexcept", "summary": "The frame protocol, part 2 (the preamble): declare ONE sprite for the current frame (the window is open: after create()/beginFrame(), until build()). The frame's DECLARATION ORDER is the insertion order: declare in the engine's deterministic entity-id iteration order (FR-1.2) — that is what makes the (key, entity id) total order reproducible (RENDER-003).", "budget": "O(1); no allocation on the success path (the pool create", "experimental": false}, + {"name": "laige::render::SpriteBatcher::build", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 481, "signature": "[[nodiscard]] Status build() noexcept", "summary": "The frame protocol, part 3 (the preamble): the batch stage. Sorts the frame's depth keys (M2-SORT-01), groups into (atlas, material, blend) batches (deterministic group order: ascending (atlas, material, blend) — RENDER-003), scatters each group's instances in global back-to-front order, and publishes batches(). Closes the declaration window.", "budget": "O(4n + 4·256 + n·log G + G·(log G + G)), G = the frame's", "experimental": false}, + {"name": "laige::render::SpriteBatcher::batchCount", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 486, "signature": "[[nodiscard]] std::size_t batchCount() const noexcept", "summary": "The frame's output (read within the frame; invalidated by the next beginFrame()/build()).", "budget": "O(1).", "experimental": false}, + {"name": "laige::render::SpriteBatcher::batches", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 488, "signature": "[[nodiscard]] std::span batches() const noexcept", "summary": null, "budget": "O(1); the span aliases the batcher's batch array.", "experimental": false}, + {"name": "laige::render::SpriteBatcher::frameCount", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 493, "signature": "[[nodiscard]] std::size_t frameCount() const noexcept", "summary": "The frame's declared count, after the overflow drops.", "budget": "O(1).", "experimental": false}, + {"name": "laige::render::SpriteBatcher::frameBuilt", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 501, "signature": "[[nodiscard]] bool frameBuilt() const noexcept", "summary": "True only between build() and the next beginFrame(): the submit stage's (M2-SPRITE-02) precondition — an unbuilt window (including one with declared items) must never be drawn as an empty frame (CORE-008). False before the first build and after every beginFrame(); true on a built empty frame (build() on the stopped state works and builds the empty frame).", "budget": "O(1).", "experimental": false}, + {"name": "laige::render::SpriteBatcher::overrideCount", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 505, "signature": "[[nodiscard]] std::uint32_t overrideCount() const noexcept", "summary": "G-R11: the current frame's manual depth-override declaration count (reset by beginFrame).", "budget": "O(1).", "experimental": false}, + {"name": "laige::render::SpriteBatcher::overrideTotal", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 511, "signature": "[[nodiscard]] std::uint64_t overrideTotal() const noexcept", "summary": "G-R11: the since-construction override total (the profiler's cumulative guardrail feed, PRD §9.3).", "budget": "O(1).", "experimental": false}, + {"name": "laige::render::SpriteBatcher::droppedTotal", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 517, "signature": "[[nodiscard]] std::uint64_t droppedTotal() const noexcept", "summary": "The since-construction dropped-oldest total (the overflow policy's accounting, the preamble).", "budget": "O(1).", "experimental": false}, + {"name": "laige::render::SpriteBatcher::frameTiming", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 525, "signature": "[[nodiscard]] SpriteBatchTiming frameTiming() const noexcept", "summary": "The batch stage's CPU-phase timing of the last successful build (M2-PROF-01, RENDER-005 — the header's timing section). Zero in the default (stopped) state; a failed build keeps the last successful build's value.", "budget": "O(1).", "experimental": false}, + {"name": "laige::render::SpriteBatcher::timingTotals", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 533, "signature": "[[nodiscard]] SpriteBatchTimingTotals timingTotals() const noexcept", "summary": "The since-construction CPU-phase totals (the M2-PROF-01 profiler feed). Zero in the default (stopped) state — a stopped batcher's empty-frame build records no timing (the stopped-state zero contract).", "budget": "O(1).", "experimental": false}, + {"name": "laige::render::SpriteBatcher::capacity", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 538, "signature": "[[nodiscard]] std::uint32_t capacity() const noexcept", "summary": "The frame budget (the create() argument; 0 in the stopped state).", "budget": "O(1).", "experimental": false}, + {"name": "laige::render::SpriteBatcher::itemPoolStats", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 541, "signature": "[[nodiscard]] PoolStats itemPoolStats() const noexcept", "summary": "The sprite pool's accounting snapshot (PRD §10.4, DBG-008).", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::render::SpriteBatcher::get", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 547, "signature": "[[nodiscard]] const SpriteItem* get(std::uint32_t slot) noexcept", "summary": "The item declared at the frame-scoped slot (the null-safe read — nullptr when the slot is not in the current frame window).", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::render::SpriteBatcher::at", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 552, "signature": "[[nodiscard]] const SpriteItem& at(std::uint32_t slot)", "summary": "The item declared at the frame-scoped slot (debug: asserts the slot is live — the S-9 fail-loudly contract; release: undefined on a stale slot, the engine Result convention).", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::render::SpriteBatcher::SpriteBatcher", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 554, "signature": "SpriteBatcher(const SpriteBatcher&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatcher::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 555, "signature": "SpriteBatcher& operator=(const SpriteBatcher&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatcher::SpriteBatcher", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 559, "signature": "SpriteBatcher(SpriteBatcher&&) noexcept = default", "summary": "Move: the members' moves (the pool and the sorter leave the source stopped; the flat buffers transfer). The source becomes the stopped state (CORE-009).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatcher::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 560, "signature": "SpriteBatcher& operator=(SpriteBatcher&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::kSpriteFrameMaxAtlasTexels", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_frames.h", "line": 133, "signature": "inline constexpr std::uint32_t kSpriteFrameMaxAtlasTexels = 1u << 24", "summary": "The float-exact atlas domain (the header's Exactness section): atlas dimensions are [1, kSpriteFrameMaxAtlasTexels]. 2^24 = 16 777 216 — every texel coordinate < 2^24 is exactly float-representable, and two distinct k/W values never round to the same float (the u1 > u0, v1 > v0 invariant, exact). Far beyond the practical driver maxTextureSize (16K–32K).", "budget": null, "experimental": false}, {"name": "laige::render::SpriteFrameLayout", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_frames.h", "line": 145, "signature": "struct SpriteFrameLayout", "summary": "The layout of the frames in one atlas sheet (the header's sheet model). All fields are texels (not world units, not normalized UV — the layout is asset-side data, the import pipeline's output for now, the asset system's from M3). A plain value: no ownership, nothing to release; validated at use time by spriteFrameUv (the failure section).", "budget": null, "experimental": false}, {"name": "laige::render::SpriteFrameLayout::frameWidth", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_frames.h", "line": 147, "signature": "std::uint32_t frameWidth{}", "summary": "The frame width in texels (>= 1).", "budget": null, "experimental": false}, @@ -1063,53 +1072,66 @@ {"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": 205, "signature": "struct SpriteDrawStats", "summary": "The per-frame counters of one successful submit (RENDER-001 — the state changes and draw submissions made observable; the M2-SPRITE-04 profiler feed). Read-only published state. `primitives` is 0 unless the opt-in primitive query (Options::primitiveQuery) is enabled: GL 3.3 core has no draw-call query primitive, so the dispatch count is the engine's own bookkeeping (drawCalls — what the profiler ships), and the GL-side cross-check is the PRIMITIVES_GENERATED count of the pass (2 per instance of the 4-vertex strip). The M2-SPRITE-04 fields: `programChanges` (the glUseProgram calls in the pass — 1 for every non-empty successful submit: the pass sets its own program and restores 0 at the end), `uploadBytes` (the frame's instance-upload volume, n * 52 B — the upload's RENDER-004 observable work), `renderTargetBytes` (the render-target size drawn, width * height * 4 — the render-target use), and `drawCallCapExceeded` (the G-R2 frame-graph flag: the frame's draw calls exceeded Options::maxDrawCalls — the frame was still drawn, the cap is observation, never an execution gate).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::drawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 206, "signature": "std::uint32_t drawCalls{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::textureBinds", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 207, "signature": "std::uint32_t textureBinds{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::blendChanges", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 208, "signature": "std::uint32_t blendChanges{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::programChanges", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 209, "signature": "std::uint32_t programChanges{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::instances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 210, "signature": "std::uint32_t instances{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::primitives", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 211, "signature": "std::uint32_t primitives{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::uploadBytes", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 212, "signature": "std::uint64_t uploadBytes{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::renderTargetBytes", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 213, "signature": "std::uint64_t renderTargetBytes{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawStats::drawCallCapExceeded", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 214, "signature": "bool drawCallCapExceeded{false}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 221, "signature": "struct SpriteDrawTotals", "summary": "The since-construction totals (the frames counter counts successful submits only). The M2-SPRITE-04 profiler reads these on the owner thread (DBG-005). `capExceededFrames` counts the successful submits whose draw calls exceeded the cap (the G-R2 frame-graph total).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::frames", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 222, "signature": "std::uint64_t frames{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::drawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 223, "signature": "std::uint64_t drawCalls{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::textureBinds", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 224, "signature": "std::uint64_t textureBinds{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::blendChanges", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 225, "signature": "std::uint64_t blendChanges{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::programChanges", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 226, "signature": "std::uint64_t programChanges{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::instances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 227, "signature": "std::uint64_t instances{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::primitives", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 228, "signature": "std::uint64_t primitives{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::uploadBytes", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 229, "signature": "std::uint64_t uploadBytes{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::renderTargetBytes", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 230, "signature": "std::uint64_t renderTargetBytes{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteDrawTotals::capExceededFrames", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 231, "signature": "std::uint64_t capExceededFrames{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer", "kind": "class", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 237, "signature": "class SpriteRenderer", "summary": "The submit stage of the frame pipeline (M2-SPRITE-02): one instanced draw call per (atlas, material, blend) group per frame (FR-2.1) + the minimal GLSL 3.30 sprite shader (the header preamble).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::kSpriteRendererMaxInstances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 241, "signature": "static constexpr std::uint32_t kSpriteRendererMaxInstances = 0xFFFFFFFFu", "summary": "The frame-slot index width (the batcher's slot domain, the instance buffer's index domain).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::kSpriteRendererMaxAtlases", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 244, "signature": "static constexpr std::uint32_t kSpriteRendererMaxAtlases = 4096u", "summary": "The atlas registry cap (the Options comment: 32 KB at 4096 slots).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::kSpriteRendererDefaultDrawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 249, "signature": "static constexpr std::uint32_t kSpriteRendererDefaultDrawCalls = 64u", "summary": "The documented default per-pass draw-call cap (G-R2, PRD §9.3): 2x the PRD §8.1 worst-case reference-scene budget of 30 draw calls — the 50k-sprite exit scene passes with 2x headroom, and a scene that blows the cap by more than 2x is visible at a glance.", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 253, "signature": "struct Options", "summary": "The submit stage's configuration (API-006): all fields are validated at create (the first failure wins, one Warn).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::Options::maxInstances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 260, "signature": "std::uint32_t maxInstances{0}", "summary": "The per-frame instance budget: the instance buffer is sized to this at create (52 B per instance). Must be >= the batcher's maxSprites. Domain [1, kSpriteRendererMaxInstances]; the practical ceiling is the driver's GL buffer size — an allocation that large fails create GlUnavailable (the clean failure, CORE-008).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::Options::maxAtlases", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 267, "signature": "std::uint32_t maxAtlases{0}", "summary": "The atlas-id domain: atlas ids are [0, maxAtlases) — the atlas registry is a flat table (16 B per slot with the M2-SPRITE-04 width/height bookkeeping). Domain [1, kSpriteRendererMaxAtlases]: 4096 slots = 64 KB — far beyond the PRD's 50k-sprite scene (<= 30 draw calls = tens of atlases at most).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::Options::maxDrawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 279, "signature": "std::uint32_t maxDrawCalls{kSpriteRendererDefaultDrawCalls}", "summary": "The per-pass draw-call cap (G-R2, PRD §9.3): the maximum instanced draw calls (== the frame's group count) ONE submit may make. A frame ABOVE the cap is still drawn (observation, never an execution gate — the G-R5 precedent): one rate-limited Warn (`sprite_renderer/draw_call_cap`) fires, the frame carries the `drawCallCapExceeded` flag (the frame-graph flag M2-PROF-01 reports), and the total `capExceededFrames` counts it. Domain [1, kSpriteRendererMaxInstances] (a frame's draw calls can never exceed its instance count — each group has >= 1 instance); the documented default is kSpriteRendererDefaultDrawCalls (64).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::Options::primitiveQuery", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 286, "signature": "bool primitiveQuery{false}", "summary": "The opt-in GL PRIMITIVES_GENERATED query around every submit (the GL-side dispatch cross-check + the M2-SPRITE-04 primitive feed). OFF by default: zero GL work, zero cost (the hot path, PERF-002). ON: one query object per submit + a glFinish — a CPU/GPU sync, a DIAGNOSTIC mode for the offscreen test/CI path and the profiler, never the shipping frame loop (RENDER-005).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::SpriteRenderer", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 295, "signature": "SpriteRenderer() noexcept", "summary": "The stopped state (the failed create / moved-from): valid() is false, every operation fails InvalidArgument, the counters read zero. No logging. Defined in the .cpp (the defaulted form would instantiate the unique_ptr destructor against the incomplete Impl — the GlContext precedent).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::~SpriteRenderer", "kind": "destructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 296, "signature": "~SpriteRenderer()", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::SpriteRenderer", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 299, "signature": "SpriteRenderer(SpriteRenderer&&) noexcept", "summary": "Defined in the .cpp, where Impl is complete (the unique_ptr move operations need the complete pointee — the GlContext precedent).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 300, "signature": "SpriteRenderer& operator=(SpriteRenderer&&) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::SpriteRenderer", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 301, "signature": "SpriteRenderer(const SpriteRenderer&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 302, "signature": "SpriteRenderer& operator=(const SpriteRenderer&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::create", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 315, "signature": "[[nodiscard]] static laige::Result create(const GlContext& ctx, Options options) noexcept", "summary": "One-time setup (the engine's renderer construction, before the frame pipeline starts — never inside a frame, PERF-002): validates the options (first failure wins, one Warn options_invalid), requires a valid context (makeCurrent, idempotent), then compiles the sprite shader, creates the quad VBO, the per-frame instance buffer (maxInstances * 52 B), the VAO, and the atlas registry. Any GL failure returns GlUnavailable with one structured Error event (no partial renderer: the failure is the stopped state). allocations (52 * maxInstances B instance buffer), no loop.", "budget": "one-time: two shader compiles, one link, three GPU", "experimental": false}, - {"name": "laige::render::SpriteRenderer::valid", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 319, "signature": "[[nodiscard]] bool valid() const noexcept", "summary": "True when the create succeeded (the stopped state is false).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::maxInstances", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 323, "signature": "[[nodiscard]] std::uint32_t maxInstances() const noexcept", "summary": "The validated configuration (the stopped state reads zero). O(1), no allocation.", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::maxAtlases", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 324, "signature": "[[nodiscard]] std::uint32_t maxAtlases() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::maxDrawCalls", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 325, "signature": "[[nodiscard]] std::uint32_t maxDrawCalls() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::bindAtlas", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 343, "signature": "[[nodiscard]] laige::Status bindAtlas(std::uint32_t atlasId, std::uint32_t width, std::uint32_t height, std::span rgba)", "summary": "Bind one atlas texture from CPU RGBA8 bytes — the SETUP/asset path (one call per atlas per scene load; the asset system re-uploads them later, M3 — RENDER-004: no unexpected GPU work in the frame hot path). The context must be current on the calling thread (makeCurrent, idempotent). The atlas id is the batcher's SpriteItem.atlasId domain: [0, maxAtlases). width/height are [1, the context's maxTextureSize]; rgba must be exactly width * height * 4 bytes. The texture is uploaded GL_RGBA8, GL_NEAREST, GL_CLAMP_TO_EDGE, no mipmaps (the sub-rects are pixel-exact — the M2-GOLD-01 contract). Re-binding an id REPLACES the texture (the old one is deleted — the last bind wins). Validation failures return InvalidArgument (no GL work, no logging — the precondition contract, the GlContext::readPixel precedent); a GL failure returns GlUnavailable + one Error. upload); no loop, no allocation beyond the texture object.", "budget": "one-time per atlas: one glTexImage2D (width*height*4 B", "experimental": false}, - {"name": "laige::render::SpriteRenderer::bindSdfAtlas", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 363, "signature": "[[nodiscard]] laige::Status bindSdfAtlas(std::uint32_t atlasId, std::uint32_t width, std::uint32_t height, std::span rgba, float threshold)", "summary": "Bind one SDF atlas texture (M2-TEXT-03) — the same setup/asset path as `bindAtlas` (one call per atlas per scene load; the same GL texture contract: GL_RGBA8, GL_NEAREST, GL_CLAMP_TO_EDGE, the same argument domains and failure behavior — an invalid `threshold` is InvalidArgument with no GL work, no logging), with one extra argument: the SDF threshold in the field's 0..1 domain (the fragment shader's `uSdfThreshold`; 0.5 = the 0.5-coverage contour — the documented default). The atlas's bytes are a font_sdf.h SDF field uploaded as white-on-alpha RGBA8 (the text_items.h `expandSdfGlyphAtlasRgba8`). Re-binding an id REPLACES both the texture and the SDF state (the last bind wins). The per-group SDF branch + threshold travel with the texture bind in `submit` (the header's shader section). upload); no loop, no allocation beyond the texture object.", "budget": "one-time per atlas: one glTexImage2D (width*height*4 B", "experimental": false}, - {"name": "laige::render::SpriteRenderer::submit", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 407, "signature": "[[nodiscard]] laige::Status submit(SpriteBatcher& batcher, const Mat4& worldToNdc)", "summary": "The submit stage: draw the batcher's BUILT frame (the frame protocol, the header preamble). Preconditions (checked in order, the first failure wins — a failed submit leaves the frame counters ZERO and the since-construction totals UNCHANGED and draws nothing; the pass re-establishes its own GL state at the start of every successful submit, so a failure leaves no stale sprite-pass state for the next frame): 1. the renderer valid (stopped -> InvalidArgument, no logging); 2. the context valid + current (makeCurrent — idempotent; a cross-thread live takeover -> GlUnavailable, the P0 EGL contract); 3. the batcher built for the current frame (batcher.frameBuilt() — an open window is InvalidArgument: declared items are never silently dropped, CORE-008); 4. the frame's instance count <= maxInstances (else BudgetExhausted + one rate-limited Warn instance_capacity — the budget, PERF-008); 5. every group's atlas in the registry and bound (else InvalidArgument — the stateless pre-state validation, one failure per frame). After the preconditions, BEFORE any GL state: the G-R2 per-pass draw-call cap (PRD §9.3) — the frame's group count above maxDrawCalls fires one rate-limited Warn (`sprite_renderer/draw_call_cap`) and sets the frame's `drawCallCapExceeded` flag (the frame-graph flag); the frame is STILL drawn — the cap is observation, never an execution gate (the G-R5 precedent). The M2-SPRITE-04 counters (programChanges, uploadBytes, renderTargetBytes) are recorded on the frame before/during the GL work; a FAILED submit zeroes them (a failed frame reports nothing — the frameStats() contract). The frame's world -> NDC matrix (the IsoCamera / M2-PROJ-01 combined matrix — RENDER-006) is the per-frame uniform. The batcher reference is non-const only because the batcher's pool accessors are (the M2-SPRITE-01 house quirk); submit mutates nothing on the batcher. n * 52 B, G draw calls, zero allocation — the header Performance section.", "budget": "O(G * 5 + n) CPU (G groups, n instances), one upload of", "experimental": false}, - {"name": "laige::render::SpriteRenderer::frameStats", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 412, "signature": "[[nodiscard]] SpriteDrawStats frameStats() const noexcept", "summary": "The per-frame counters of the last SUCCESSFUL submit (a failed submit reads zero — it zeroes them). O(1).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::totals", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 415, "signature": "[[nodiscard]] SpriteDrawTotals totals() const noexcept", "summary": "The since-construction totals (successful submits only). O(1).", "budget": null, "experimental": false}, - {"name": "laige::render::SpriteRenderer::textureMemoryBytes", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 424, "signature": "[[nodiscard]] std::uint64_t textureMemoryBytes() const noexcept", "summary": "The VRAM estimate of the atlas registry (M2-SPRITE-04, the RENDER-001 texture-memory feed): the sum of width * height * 4 bytes over the BOUND atlases (GL_RGBA8 — the upload's byte count). Updated at bindAtlas (a re-bind replaces: the old texture's bytes are subtracted, the new ones added); reads 0 in the stopped state. A GAUGE (not a per-frame counter) — it changes only on the set-up/asset path, never per frame. O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 237, "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). The M2-PROF-01 (RENDER-005) timing fields of the struct below: `submitMs` is the submit call's CPU wall time (the render-thread timer — the submit stage runs on the render thread, M2-GL-02); `uploadWallUs` is the CPU wall time INSIDE the one glBufferSubData upload (the frame's only implicit CPU/GPU sync point — the instance buffer is reused every frame, so the driver may block until the GPU finishes reading the previous frame's data); `uploadStalled` flags uploads above Options::stallThresholdUs (the stall detection — one rate-limited warn per stalled frame); `gpuTimeUs`/`gpuTimeValid` are the opt-in GPU-side pass time (GL TIME_ELAPSED — the measurement of the PREVIOUS frame's pass: the query completes when the GPU finishes, so the value lags one frame in steady state; `gpuTimeValid` false = \"n/a\": the option is off, the first frame, or the GPU was still behind when the read was attempted — never a fake number, the headless/offscreen fallback of the roadmap).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::drawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 238, "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": 239, "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": 240, "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": 241, "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": 242, "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": 243, "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": 244, "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": 245, "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": 246, "signature": "bool drawCallCapExceeded{false}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::submitMs", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 248, "signature": "double submitMs{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::uploadWallUs", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 249, "signature": "std::uint64_t uploadWallUs{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::uploadStalled", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 250, "signature": "bool uploadStalled{false}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::gpuTimeUs", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 251, "signature": "std::uint64_t gpuTimeUs{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawStats::gpuTimeValid", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 252, "signature": "bool gpuTimeValid{false}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 265, "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). The M2-PROF-01 timing totals: `submitMs` (the CPU wall-time sum), `uploadStallUs`/`uploadStallFrames` (the stall detection's cumulative wait + frame count — successful submits only), and `gpuTimeUs`/`gpuTimeFrames` (the completed GPU measurements' sum + count; `gpuTimeFrames` 0 = the profiler reports the GPU field as \"n/a\").", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::frames", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 266, "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": 267, "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": 268, "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": 269, "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": 270, "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": 271, "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": 272, "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": 273, "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": 274, "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": 275, "signature": "std::uint64_t capExceededFrames{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::submitMs", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 277, "signature": "double submitMs{0}", "summary": "M2-PROF-01 timing totals (RENDER-005).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::uploadStallUs", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 278, "signature": "std::uint64_t uploadStallUs{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::uploadStallFrames", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 279, "signature": "std::uint64_t uploadStallFrames{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::gpuTimeUs", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 280, "signature": "std::uint64_t gpuTimeUs{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteDrawTotals::gpuTimeFrames", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 281, "signature": "std::uint64_t gpuTimeFrames{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer", "kind": "class", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 287, "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": 291, "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": 294, "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": 299, "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::kSpriteRendererDefaultStallUs", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 306, "signature": "static constexpr std::uint32_t kSpriteRendererDefaultStallUs = 2000u", "summary": "The documented default stall threshold (M2-PROF-01, RENDER-005): 2 ms, ~12% of the default 60 Hz frame period (16.67 ms). A CPU-side wait on the implicit upload sync (the glBufferSubData into the REUSED instance buffer — the frame's only CPU/GPU sync point) longer than that is a visible hitch; a normal 50k-frame upload (2.6 MB) queues in well under it on the P0 drivers.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 310, "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": 317, "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": 324, "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": 336, "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": 343, "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::Options::gpuTiming", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 357, "signature": "bool gpuTiming{false}", "summary": "The opt-in per-pass GPU time (M2-PROF-01, RENDER-005): one GL TIME_ELAPSED query object wraps every submit's GL work (created at create, reused across frames — no per-frame GL allocation). The result is read on the NEXT submit (the measurement completes when the GPU finishes — one frame late in steady state; see the SpriteDrawStats note). OFF by default: zero GL work, zero cost. ON: one glBeginQuery/glEndQuery pair + one availability read per frame — NO CPU/GPU synchronization (unlike primitiveQuery's glFinish), so it is safe for the shipping frame loop as well as diagnostics. GL 3.3 core always supports timing queries; where the environment cannot (the headless/offscreen fallback, the roadmap), the frames simply report gpuTimeValid false (\"n/a\") — never a fake number.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::Options::stallThresholdUs", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 365, "signature": "std::uint32_t stallThresholdUs{kSpriteRendererDefaultStallUs}", "summary": "The stall threshold (M2-PROF-01, RENDER-005): the glBufferSubData upload's CPU wall time ABOVE which the submit is flagged as stalled (the CPU waited on the GPU's implicit sync) — one rate-limited Warn (`sprite_renderer/submit_stall`) per stalled frame, the cumulative counts in the totals. Domain [1, 0xFFFFFFFF]; the documented default is kSpriteRendererDefaultStallUs (2000 us).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteRenderer::SpriteRenderer", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 374, "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": 375, "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": 378, "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": 379, "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": 380, "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": 381, "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": 394, "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": 398, "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": 402, "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": 403, "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": 404, "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": 422, "signature": "[[nodiscard]] laige::Status bindAtlas(std::uint32_t atlasId, std::uint32_t width, std::uint32_t height, std::span rgba)", "summary": "Bind one atlas texture from CPU RGBA8 bytes — the SETUP/asset path (one call per atlas per scene load; the asset system re-uploads them later, M3 — RENDER-004: no unexpected GPU work in the frame hot path). The context must be current on the calling thread (makeCurrent, idempotent). The atlas id is the batcher's SpriteItem.atlasId domain: [0, maxAtlases). width/height are [1, the context's maxTextureSize]; rgba must be exactly width * height * 4 bytes. The texture is uploaded GL_RGBA8, GL_NEAREST, GL_CLAMP_TO_EDGE, no mipmaps (the sub-rects are pixel-exact — the M2-GOLD-01 contract). Re-binding an id REPLACES the texture (the old one is deleted — the last bind wins). Validation failures return InvalidArgument (no GL work, no logging — the precondition contract, the GlContext::readPixel precedent); a GL failure returns GlUnavailable + one Error. upload); no loop, no allocation beyond the texture object.", "budget": "one-time per atlas: one glTexImage2D (width*height*4 B", "experimental": false}, + {"name": "laige::render::SpriteRenderer::bindSdfAtlas", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 442, "signature": "[[nodiscard]] laige::Status bindSdfAtlas(std::uint32_t atlasId, std::uint32_t width, std::uint32_t height, std::span rgba, float threshold)", "summary": "Bind one SDF atlas texture (M2-TEXT-03) — the same setup/asset path as `bindAtlas` (one call per atlas per scene load; the same GL texture contract: GL_RGBA8, GL_NEAREST, GL_CLAMP_TO_EDGE, the same argument domains and failure behavior — an invalid `threshold` is InvalidArgument with no GL work, no logging), with one extra argument: the SDF threshold in the field's 0..1 domain (the fragment shader's `uSdfThreshold`; 0.5 = the 0.5-coverage contour — the documented default). The atlas's bytes are a font_sdf.h SDF field uploaded as white-on-alpha RGBA8 (the text_items.h `expandSdfGlyphAtlasRgba8`). Re-binding an id REPLACES both the texture and the SDF state (the last bind wins). The per-group SDF branch + threshold travel with the texture bind in `submit` (the header's shader section). upload); no loop, no allocation beyond the texture object.", "budget": "one-time per atlas: one glTexImage2D (width*height*4 B", "experimental": false}, + {"name": "laige::render::SpriteRenderer::submit", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 486, "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": 491, "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": 494, "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": 503, "signature": "[[nodiscard]] std::uint64_t textureMemoryBytes() const noexcept", "summary": "The VRAM estimate of the atlas registry (M2-SPRITE-04, the RENDER-001 texture-memory feed): the sum of width * height * 4 bytes over the BOUND atlases (GL_RGBA8 — the upload's byte count). Updated at bindAtlas (a re-bind replaces: the old texture's bytes are subtracted, the new ones added); reads 0 in the stopped state. A GAUGE (not a per-frame counter) — it changes only on the set-up/asset path, never per frame. O(1).", "budget": null, "experimental": false}, {"name": "laige::render::kStringTableMaxStrings", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 328, "signature": "inline constexpr std::uint32_t kStringTableMaxStrings = 1u << 24", "summary": "The string-table pool domains: [1, 2^24] each (16 777 216 strings / code points — 64 MiB of pool at the ceiling, far beyond any UI string budget; the 24-bit width is the index domain of the header table).", "budget": null, "experimental": false}, {"name": "laige::render::kStringTableMaxCodePoints", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 329, "signature": "inline constexpr std::uint32_t kStringTableMaxCodePoints = 1u << 24", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::kStringTableDefaultStrings", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 332, "signature": "inline constexpr std::uint32_t kStringTableDefaultStrings = 4096", "summary": "The documented defaults (a typical UI scene: a few hundred distinct strings, a few thousand code points — 64 KiB of pool).", "budget": null, "experimental": false}, @@ -1718,51 +1740,65 @@ {"name": "laige::PresentationSnapshot::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 350, "signature": "PresentationSnapshot& operator=(PresentationSnapshot&& other) noexcept", "summary": null, "budget": null, "experimental": false}, {"name": "laige::PresentationSnapshot::PresentationSnapshot", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 353, "signature": "PresentationSnapshot(const PresentationSnapshot&) = delete", "summary": "No copies (the unique backing table).", "budget": null, "experimental": false}, {"name": "laige::PresentationSnapshot::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 354, "signature": "PresentationSnapshot& operator=(const PresentationSnapshot&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::kProfilerTickWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 116, "signature": "inline constexpr std::uint32_t kProfilerTickWindowSamples = 512", "summary": "The default tick-time rolling window capacity (CORE-005): 512 samples ≈ 8.5 s of tick history at the default 60 Hz — long enough for a stable p99, small enough that the O(n log n) stats pass stays cold. Overridable per profiler (Options); the M1-SYS-03 per-system windows keep their own fixed capacity (kSystemTimingWindowSamples).", "budget": null, "experimental": false}, - {"name": "laige::kProfilerFrameWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 120, "signature": "inline constexpr std::uint32_t kProfilerFrameWindowSamples = 256", "summary": "The default frame-time rolling window capacity (CORE-005): 256 samples ≈ 4.3 s of frame history at the default 60 Hz.", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 128, "signature": "struct ProfilerStats", "summary": "One profiler snapshot (a plain value; the CLI one-line summary, the report writers, the engine's per-run cache, and the M1-PROF-02 frame graph consume it). The counters are since-construction and never truncate (the histogram windows may roll — their `n` fields say so); the HistogramStats fields are NaN when the corresponding window is empty (check n — the M0-CORE-08 contract).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::ticks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 130, "signature": "std::uint64_t ticks{}", "summary": "Completed ticks recorded (== the tick-time samples recorded).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::frames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 132, "signature": "std::uint64_t frames{}", "summary": "Frames recorded (== the frame-time samples recorded).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::drawCalls", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 134, "signature": "std::uint64_t drawCalls{}", "summary": "Draw calls submitted (0 in headless M1 — the M2 render feed).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::textureBinds", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 136, "signature": "std::uint64_t textureBinds{}", "summary": "Texture binds (0 in headless M1 — the M2 render feed).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::netBytes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 138, "signature": "std::uint64_t netBytes{}", "summary": "Network bytes (0 in headless M1 — the M3 network feed).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::tickTimeMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 140, "signature": "HistogramStats tickTimeMs{}", "summary": "The tick-time window stats (ms; NaN when n == 0).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::frameTimeMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 142, "signature": "HistogramStats frameTimeMs{}", "summary": "The frame-time window stats (ms; NaN when n == 0).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::worldAvailable", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 145, "signature": "bool worldAvailable{}", "summary": "World-pulled fields: snapshot(world) sets worldAvailable to true and fills them; the no-arg snapshot() leaves them at zero / false.", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::entitiesAlive", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 147, "signature": "std::uint32_t entitiesAlive{}", "summary": "Live entities right now (World::stats().inUse).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::entitiesTotal", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 149, "signature": "std::uint32_t entitiesTotal{}", "summary": "Entities created since world construction (totalCreated).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::entityCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 151, "signature": "std::uint32_t entityCapacity{}", "summary": "The declared scene budget (World::stats().capacity).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::simAllocs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 157, "signature": "std::uint64_t simAllocs{}", "summary": "The sim alloc count: the sum of the pool accounting since world construction (M1-ECS-03 World::archetypeStats().totalReservations — the pool-backed sim storage's reserved column blocks). Target 0 for the steady-state per-frame DELTA (FR-11.1; M1-ALLOC-01 asserts it per tick).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::systems", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 159, "signature": "std::uint32_t systems{}", "summary": "Registered systems (World::systemCount()).", "budget": null, "experimental": false}, - {"name": "laige::Profiler", "kind": "class", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 165, "signature": "class Profiler", "summary": "The always-on profiler counters (M1-PROF-01). See the header preamble for the counter model, the hot-path cost, and the ownership contract.", "budget": null, "experimental": false}, - {"name": "laige::Profiler::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 171, "signature": "struct Options", "summary": "The typed profiler configuration (API-006): the rolling window capacities (0 is legal — every record is dropped and stats() is always empty, the M0-CORE-08 capacity-0 semantics) and the enabled flag.", "budget": null, "experimental": false}, - {"name": "laige::Profiler::Options::tickWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 174, "signature": "std::uint32_t tickWindowSamples{kProfilerTickWindowSamples}", "summary": "The tick-time rolling window capacity (default kProfilerTickWindowSamples).", "budget": null, "experimental": false}, - {"name": "laige::Profiler::Options::frameWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 177, "signature": "std::uint32_t frameWindowSamples{kProfilerFrameWindowSamples}", "summary": "The frame-time rolling window capacity (default kProfilerFrameWindowSamples).", "budget": null, "experimental": false}, - {"name": "laige::Profiler::Options::enabled", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 180, "signature": "bool enabled{true}", "summary": "The always-on counters on/off (default on). Disabled: every record call is a no-op (one branch — DBG-004).", "budget": null, "experimental": false}, - {"name": "laige::Profiler::Profiler", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 186, "signature": "explicit Profiler(Options options)", "summary": "Construct the profiler (setup path): exactly two backing allocations (the two window storages). No failure mode — every configuration is representable (a zero capacity is legal).", "budget": null, "experimental": false}, - {"name": "laige::Profiler::Profiler", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 191, "signature": "Profiler(Profiler&& other) noexcept", "summary": "Move transfers the state; the source becomes a STOPPED profiler (records are no-ops, snapshots return empty values — the GameLoop moved-out precedent).", "budget": null, "experimental": false}, - {"name": "laige::Profiler::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 192, "signature": "Profiler& operator=(Profiler&& other) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Profiler::Profiler", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 193, "signature": "Profiler(const Profiler&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Profiler::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 194, "signature": "Profiler& operator=(const Profiler&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Profiler::recordTick", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 200, "signature": "void recordTick(double ms) noexcept", "summary": "Record one completed tick's measured time (ms, from the caller's TimeIt). A no-op when disabled or stopped. The caller measures only the completed tick (the GameLoop runOneTick contract).", "budget": "O(1); no allocation (hot path).", "experimental": false}, - {"name": "laige::Profiler::recordFrame", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 207, "signature": "void recordFrame(double ms) noexcept", "summary": "Record one frame's measured time (ms, from the caller's TimeIt): the frame's sim work plus the presentation refresh, excluding the pacing sleep (the header preamble). A no-op when disabled or stopped.", "budget": "O(1); no allocation (hot path).", "experimental": false}, - {"name": "laige::Profiler::addDrawCalls", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 214, "signature": "void addDrawCalls(std::uint64_t count) noexcept", "summary": "The M2/M3 feeds for the FR-11.1 render/network counters (always 0 in headless M1 — the fields exist now; the render and network subsystems arrive later and call these). A no-op when disabled or stopped.", "budget": "O(1); no allocation.", "experimental": false}, - {"name": "laige::Profiler::addTextureBinds", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 215, "signature": "void addTextureBinds(std::uint64_t count) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Profiler::addNetBytes", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 216, "signature": "void addNetBytes(std::uint64_t count) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Profiler::tickTime", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 221, "signature": "[[nodiscard]] HistogramStats tickTime() const noexcept", "summary": "The tick-time stats over the stored window (the M0-CORE-08 stats() — cold path). NaN when the window is empty (check n).", "budget": "O(n log n) cold path; no allocation.", "experimental": false}, - {"name": "laige::Profiler::frameTime", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 225, "signature": "[[nodiscard]] HistogramStats frameTime() const noexcept", "summary": "The frame-time stats over the stored window (cold path, as above).", "budget": "O(n log n) cold path; no allocation.", "experimental": false}, - {"name": "laige::Profiler::tickWindow", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 234, "signature": "[[nodiscard]] const Histogram& tickWindow() const noexcept", "summary": "The tick-time window itself (the M1-PROF-02 frame graph's budget checks read it cold through the M0-CORE-08 budgetCheck — the header preamble's \"the per-frame budget report over them is M1-PROF-02\"). Non-owning const view into the profiler's fixed window (valid until the profiler is destroyed or moved — the profiler is owned by the Engine for its whole lifetime).", "budget": "O(1); no allocation.", "experimental": false}, - {"name": "laige::Profiler::snapshot", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 239, "signature": "[[nodiscard]] ProfilerStats snapshot() const noexcept", "summary": "The snapshot of the profiler's own counters (no world access). Cold path (the two stats passes); no side effects.", "budget": "O(n log n) cold path; no allocation.", "experimental": false}, - {"name": "laige::Profiler::snapshot", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 246, "signature": "[[nodiscard]] ProfilerStats snapshot(const World& world) const noexcept", "summary": "The snapshot plus the world-pulled fields (the entity counts, the sim alloc count, the system count — pulled from World::stats / World::archetypeStats / World::systemCount; the header preamble). Cold path; no world mutation.", "budget": "O(n log n) cold path; no allocation, no world mutation.", "experimental": false}, - {"name": "laige::Profiler::enabled", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 250, "signature": "[[nodiscard]] bool enabled() const noexcept", "summary": "The enabled flag (the GameLoop and the Engine read it to decide whether to measure at all — the hot-path branch).", "budget": null, "experimental": false}, - {"name": "laige::Profiler::setEnabled", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 255, "signature": "void setEnabled(bool on) noexcept", "summary": "Toggle the always-on counters at runtime (the DBG-002 profile switch: on is the \"Always\" profile, off turns the counters off). No side effects: already-recorded state is preserved.", "budget": null, "experimental": false}, - {"name": "laige::ProfileFormat", "kind": "enum", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 274, "signature": "enum class ProfileFormat : std::uint8_t", "summary": "The report format (M1-PROF-01 file export): the human-readable greppable text form and the machine-readable JSON form.", "budget": null, "experimental": false}, - {"name": "laige::ProfileFormat::Text", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 275, "signature": "Text = 0", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::ProfileFormat::Json", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 276, "signature": "Json = 1", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::formatProfileSummaryLine", "kind": "function", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 285, "signature": "[[nodiscard]] std::string formatProfileSummaryLine(const ProfilerStats& stats)", "summary": "The CLI one-line summary (FR-11.1 \"exposed in ... the CLI\"): the run's counters plus the two window stat lines and the world-pulled fields, in one machine-greppable line (docs/api/engine.md, the laige-run section). Cold path (the two stats passes); allocates (a report string — reporting is never a hot path).", "budget": "O(n log n) cold path; allocates.", "experimental": false}, - {"name": "laige::formatProfileText", "kind": "function", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 293, "signature": "[[nodiscard]] std::string formatProfileText(const Profiler& profiler, const World& world)", "summary": "The full report in the greppable text form (one section per line — the counters, the two windows, the world fields, and one line per registered system with its M1-SYS-03 window stats). Cold path; allocates. `world` is required (the per-system and world-pulled sections) — a moved-from / released world is not a legal argument.", "budget": "O(systemCount × n log n) cold path; allocates.", "experimental": false}, - {"name": "laige::formatProfileJson", "kind": "function", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 299, "signature": "[[nodiscard]] std::string formatProfileJson(const Profiler& profiler, const World& world)", "summary": "The full report in the JSON form (version 1; schema documented in docs/api/profiler.md). Cold path; allocates.", "budget": "O(systemCount × n log n) cold path; allocates.", "experimental": false}, - {"name": "laige::writeProfile", "kind": "function", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 308, "signature": "[[nodiscard]] Result writeProfile( const Profiler& profiler, const World& world, std::string_view path, ProfileFormat format)", "summary": "Write the report to `path` (truncating; the file appears only when the write fully succeeds — no partial report on failure). Cold path (the format pass plus one file write). Every failure is a Result: unreadable/unwritable path -> IoError (CORE-008: never silent). Returns the bytes written on success.", "budget": "O(systemCount × n log n) cold path; allocates; one file write.", "experimental": false}, + {"name": "laige::kProfilerTickWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 126, "signature": "inline constexpr std::uint32_t kProfilerTickWindowSamples = 512", "summary": "The default tick-time rolling window capacity (CORE-005): 512 samples ≈ 8.5 s of tick history at the default 60 Hz — long enough for a stable p99, small enough that the O(n log n) stats pass stays cold. Overridable per profiler (Options); the M1-SYS-03 per-system windows keep their own fixed capacity (kSystemTimingWindowSamples).", "budget": null, "experimental": false}, + {"name": "laige::kProfilerFrameWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 130, "signature": "inline constexpr std::uint32_t kProfilerFrameWindowSamples = 256", "summary": "The default frame-time rolling window capacity (CORE-005): 256 samples ≈ 4.3 s of frame history at the default 60 Hz.", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 138, "signature": "struct ProfilerStats", "summary": "One profiler snapshot (a plain value; the CLI one-line summary, the report writers, the engine's per-run cache, and the M1-PROF-02 frame graph consume it). The counters are since-construction and never truncate (the histogram windows may roll — their `n` fields say so); the HistogramStats fields are NaN when the corresponding window is empty (check n — the M0-CORE-08 contract).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::ticks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 140, "signature": "std::uint64_t ticks{}", "summary": "Completed ticks recorded (== the tick-time samples recorded).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::frames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 142, "signature": "std::uint64_t frames{}", "summary": "Frames recorded (== the frame-time samples recorded).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::drawCalls", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 144, "signature": "std::uint64_t drawCalls{}", "summary": "Draw calls submitted (0 in headless M1 — the M2 render feed).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::textureBinds", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 146, "signature": "std::uint64_t textureBinds{}", "summary": "Texture binds (0 in headless M1 — the M2 render feed).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::netBytes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 148, "signature": "std::uint64_t netBytes{}", "summary": "Network bytes (0 in headless M1 — the M3 network feed).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::renderBatchMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 155, "signature": "double renderBatchMs{}", "summary": "M2-PROF-01 render timing (the render thread's submit-stage feed — wall-clock diagnostics, ARCH-009; 0 in headless until the render subsystem feeds them). The GPU fields: renderGpuUs/renderGpuFrames are the COMPLETED GPU measurements' sum/count (renderGpuFrames 0 = the reports render the GPU time as \"n/a\" — the headless/offscreen fallback, never a fake number).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::renderSortMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 156, "signature": "double renderSortMs{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::renderSubmitMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 157, "signature": "double renderSubmitMs{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::renderGpuUs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 158, "signature": "std::uint64_t renderGpuUs{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::renderGpuFrames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 159, "signature": "std::uint64_t renderGpuFrames{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::renderStallUs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 160, "signature": "std::uint64_t renderStallUs{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::renderStallFrames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 161, "signature": "std::uint64_t renderStallFrames{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::renderTextureMemoryBytes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 164, "signature": "std::uint64_t renderTextureMemoryBytes{}", "summary": "The VRAM estimate (the renderer's bound-atlas w*h*4 sum — a gauge, the last setRenderTextureMemory call wins).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::tickTimeMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 166, "signature": "HistogramStats tickTimeMs{}", "summary": "The tick-time window stats (ms; NaN when n == 0).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::frameTimeMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 168, "signature": "HistogramStats frameTimeMs{}", "summary": "The frame-time window stats (ms; NaN when n == 0).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::worldAvailable", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 171, "signature": "bool worldAvailable{}", "summary": "World-pulled fields: snapshot(world) sets worldAvailable to true and fills them; the no-arg snapshot() leaves them at zero / false.", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::entitiesAlive", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 173, "signature": "std::uint32_t entitiesAlive{}", "summary": "Live entities right now (World::stats().inUse).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::entitiesTotal", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 175, "signature": "std::uint32_t entitiesTotal{}", "summary": "Entities created since world construction (totalCreated).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::entityCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 177, "signature": "std::uint32_t entityCapacity{}", "summary": "The declared scene budget (World::stats().capacity).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::simAllocs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 183, "signature": "std::uint64_t simAllocs{}", "summary": "The sim alloc count: the sum of the pool accounting since world construction (M1-ECS-03 World::archetypeStats().totalReservations — the pool-backed sim storage's reserved column blocks). Target 0 for the steady-state per-frame DELTA (FR-11.1; M1-ALLOC-01 asserts it per tick).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::systems", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 185, "signature": "std::uint32_t systems{}", "summary": "Registered systems (World::systemCount()).", "budget": null, "experimental": false}, + {"name": "laige::Profiler", "kind": "class", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 191, "signature": "class Profiler", "summary": "The always-on profiler counters (M1-PROF-01). See the header preamble for the counter model, the hot-path cost, and the ownership contract.", "budget": null, "experimental": false}, + {"name": "laige::Profiler::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 197, "signature": "struct Options", "summary": "The typed profiler configuration (API-006): the rolling window capacities (0 is legal — every record is dropped and stats() is always empty, the M0-CORE-08 capacity-0 semantics) and the enabled flag.", "budget": null, "experimental": false}, + {"name": "laige::Profiler::Options::tickWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 200, "signature": "std::uint32_t tickWindowSamples{kProfilerTickWindowSamples}", "summary": "The tick-time rolling window capacity (default kProfilerTickWindowSamples).", "budget": null, "experimental": false}, + {"name": "laige::Profiler::Options::frameWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 203, "signature": "std::uint32_t frameWindowSamples{kProfilerFrameWindowSamples}", "summary": "The frame-time rolling window capacity (default kProfilerFrameWindowSamples).", "budget": null, "experimental": false}, + {"name": "laige::Profiler::Options::enabled", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 206, "signature": "bool enabled{true}", "summary": "The always-on counters on/off (default on). Disabled: every record call is a no-op (one branch — DBG-004).", "budget": null, "experimental": false}, + {"name": "laige::Profiler::Profiler", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 212, "signature": "explicit Profiler(Options options)", "summary": "Construct the profiler (setup path): exactly two backing allocations (the two window storages). No failure mode — every configuration is representable (a zero capacity is legal).", "budget": null, "experimental": false}, + {"name": "laige::Profiler::Profiler", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 217, "signature": "Profiler(Profiler&& other) noexcept", "summary": "Move transfers the state; the source becomes a STOPPED profiler (records are no-ops, snapshots return empty values — the GameLoop moved-out precedent).", "budget": null, "experimental": false}, + {"name": "laige::Profiler::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 218, "signature": "Profiler& operator=(Profiler&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Profiler::Profiler", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 219, "signature": "Profiler(const Profiler&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Profiler::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 220, "signature": "Profiler& operator=(const Profiler&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Profiler::recordTick", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 226, "signature": "void recordTick(double ms) noexcept", "summary": "Record one completed tick's measured time (ms, from the caller's TimeIt). A no-op when disabled or stopped. The caller measures only the completed tick (the GameLoop runOneTick contract).", "budget": "O(1); no allocation (hot path).", "experimental": false}, + {"name": "laige::Profiler::recordFrame", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 233, "signature": "void recordFrame(double ms) noexcept", "summary": "Record one frame's measured time (ms, from the caller's TimeIt): the frame's sim work plus the presentation refresh, excluding the pacing sleep (the header preamble). A no-op when disabled or stopped.", "budget": "O(1); no allocation (hot path).", "experimental": false}, + {"name": "laige::Profiler::addDrawCalls", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 240, "signature": "void addDrawCalls(std::uint64_t count) noexcept", "summary": "The M2/M3 feeds for the FR-11.1 render/network counters (always 0 in headless M1 — the fields exist now; the render and network subsystems arrive later and call these). A no-op when disabled or stopped.", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::Profiler::addTextureBinds", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 241, "signature": "void addTextureBinds(std::uint64_t count) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Profiler::addNetBytes", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 242, "signature": "void addNetBytes(std::uint64_t count) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Profiler::addRenderBatchMs", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 253, "signature": "void addRenderBatchMs(double ms) noexcept", "summary": "The M2-PROF-01 render-timing feeds (RENDER-005: CPU and GPU measured independently — the render thread feeds the CPU phase times from the batcher's frame timing + the submit's wall time, and the GPU time from the completed GL TIME_ELAPSED measurement; the stall feeds carry the implicit upload-sync wait; the VRAM estimate is a gauge, the last value wins). Primitive-typed on purpose (laige-sim cannot depend on laige-render types — ARCH-001). A no-op when disabled or stopped.", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::Profiler::addRenderSortMs", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 254, "signature": "void addRenderSortMs(double ms) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Profiler::addRenderSubmitMs", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 255, "signature": "void addRenderSubmitMs(double ms) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Profiler::addRenderGpu", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 256, "signature": "void addRenderGpu(std::uint64_t us) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Profiler::addRenderStall", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 257, "signature": "void addRenderStall(std::uint64_t us) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Profiler::setRenderTextureMemory", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 258, "signature": "void setRenderTextureMemory(std::uint64_t bytes) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Profiler::tickTime", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 263, "signature": "[[nodiscard]] HistogramStats tickTime() const noexcept", "summary": "The tick-time stats over the stored window (the M0-CORE-08 stats() — cold path). NaN when the window is empty (check n).", "budget": "O(n log n) cold path; no allocation.", "experimental": false}, + {"name": "laige::Profiler::frameTime", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 267, "signature": "[[nodiscard]] HistogramStats frameTime() const noexcept", "summary": "The frame-time stats over the stored window (cold path, as above).", "budget": "O(n log n) cold path; no allocation.", "experimental": false}, + {"name": "laige::Profiler::tickWindow", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 276, "signature": "[[nodiscard]] const Histogram& tickWindow() const noexcept", "summary": "The tick-time window itself (the M1-PROF-02 frame graph's budget checks read it cold through the M0-CORE-08 budgetCheck — the header preamble's \"the per-frame budget report over them is M1-PROF-02\"). Non-owning const view into the profiler's fixed window (valid until the profiler is destroyed or moved — the profiler is owned by the Engine for its whole lifetime).", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::Profiler::snapshot", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 281, "signature": "[[nodiscard]] ProfilerStats snapshot() const noexcept", "summary": "The snapshot of the profiler's own counters (no world access). Cold path (the two stats passes); no side effects.", "budget": "O(n log n) cold path; no allocation.", "experimental": false}, + {"name": "laige::Profiler::snapshot", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 288, "signature": "[[nodiscard]] ProfilerStats snapshot(const World& world) const noexcept", "summary": "The snapshot plus the world-pulled fields (the entity counts, the sim alloc count, the system count — pulled from World::stats / World::archetypeStats / World::systemCount; the header preamble). Cold path; no world mutation.", "budget": "O(n log n) cold path; no allocation, no world mutation.", "experimental": false}, + {"name": "laige::Profiler::enabled", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 292, "signature": "[[nodiscard]] bool enabled() const noexcept", "summary": "The enabled flag (the GameLoop and the Engine read it to decide whether to measure at all — the hot-path branch).", "budget": null, "experimental": false}, + {"name": "laige::Profiler::setEnabled", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 297, "signature": "void setEnabled(bool on) noexcept", "summary": "Toggle the always-on counters at runtime (the DBG-002 profile switch: on is the \"Always\" profile, off turns the counters off). No side effects: already-recorded state is preserved.", "budget": null, "experimental": false}, + {"name": "laige::ProfileFormat", "kind": "enum", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 329, "signature": "enum class ProfileFormat : std::uint8_t", "summary": "The report format (M1-PROF-01 file export): the human-readable greppable text form and the machine-readable JSON form.", "budget": null, "experimental": false}, + {"name": "laige::ProfileFormat::Text", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 330, "signature": "Text = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ProfileFormat::Json", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 331, "signature": "Json = 1", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::formatProfileSummaryLine", "kind": "function", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 340, "signature": "[[nodiscard]] std::string formatProfileSummaryLine(const ProfilerStats& stats)", "summary": "The CLI one-line summary (FR-11.1 \"exposed in ... the CLI\"): the run's counters plus the two window stat lines and the world-pulled fields, in one machine-greppable line (docs/api/engine.md, the laige-run section). Cold path (the two stats passes); allocates (a report string — reporting is never a hot path).", "budget": "O(n log n) cold path; allocates.", "experimental": false}, + {"name": "laige::formatProfileText", "kind": "function", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 348, "signature": "[[nodiscard]] std::string formatProfileText(const Profiler& profiler, const World& world)", "summary": "The full report in the greppable text form (one section per line — the counters, the two windows, the world fields, and one line per registered system with its M1-SYS-03 window stats). Cold path; allocates. `world` is required (the per-system and world-pulled sections) — a moved-from / released world is not a legal argument.", "budget": "O(systemCount × n log n) cold path; allocates.", "experimental": false}, + {"name": "laige::formatProfileJson", "kind": "function", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 354, "signature": "[[nodiscard]] std::string formatProfileJson(const Profiler& profiler, const World& world)", "summary": "The full report in the JSON form (version 1; schema documented in docs/api/profiler.md). Cold path; allocates.", "budget": "O(systemCount × n log n) cold path; allocates.", "experimental": false}, + {"name": "laige::writeProfile", "kind": "function", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 363, "signature": "[[nodiscard]] Result writeProfile( const Profiler& profiler, const World& world, std::string_view path, ProfileFormat format)", "summary": "Write the report to `path` (truncating; the file appears only when the write fully succeeds — no partial report on failure). Cold path (the format pass plus one file write). Every failure is a Result: unreadable/unwritable path -> IoError (CORE-008: never silent). Returns the bytes written on success.", "budget": "O(systemCount × n log n) cold path; allocates; one file write.", "experimental": false}, {"name": "laige::Access", "kind": "enum", "header": "src/laige-sim/include/laige/sim/query.h", "line": 234, "signature": "enum class Access : std::uint8_t", "summary": "The declared per-component access of a query (FR-1.3). Read: the component is only read during the iteration; Write: the system mutates it (through the query's reference or an in-place addComponent overwrite — both legal, see the preamble \"Iteration legality\"). M1-SYS-01's system I/O declarations reuse this value type.", "budget": null, "experimental": false}, {"name": "laige::Access::Read", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/query.h", "line": 235, "signature": "Read = 0", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Access::Write", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/query.h", "line": 236, "signature": "Write = 1", "summary": null, "budget": null, "experimental": false}, diff --git a/roadmap/M2-rendering-2.5d.md b/roadmap/M2-rendering-2.5d.md index e630384..1f55cc1 100644 --- a/roadmap/M2-rendering-2.5d.md +++ b/roadmap/M2-rendering-2.5d.md @@ -502,7 +502,7 @@ if M2 slips, and its status is recorded in M2-EXIT-01. ## Render profiling & acceptance -- [ ] **M2-PROF-01 · CPU/GPU render timing (independent)** +- [x] **M2-PROF-01 · CPU/GPU render timing (independent)** - **Refs:** RENDER-005 (CPU/GPU measured independently), FR-11.1 - **Depends:** M2-SPRITE-04, M2-GL-02 - **Scope:** @@ -512,6 +512,53 @@ if M2 slips, and its status is recorded in M2-EXIT-01. - Unit/integration test: with a deliberately slow shader (fixture), GPU time > CPU time is observed on the desktop CI runner. - **Verify:** `ctest -R render_timing` green; fields present in profiler snapshot on all P0 OSes (GPU fields may read "n/a" headless — documented). - **Size:** ~250 lines + tests + - **Shipped:** M2-PROF-01: the CPU phase timers on the batcher + (`SpriteBatchTiming` `frameTiming()` / `timingTotals()` — the + batch phase = the frame window `beginFrame()`/create up to + `build()` start, the sort phase = `build()` itself) + the submit + phase (`SpriteDrawStats::submitMs` / the totals' sum); the opt-in + per-pass GPU time on the renderer (`Options::gpuTiming` — ONE + reused `GL_TIME_ELAPSED` query object created at `create`, a + `glBeginQuery`/`glEndQuery` pair around the pass, one availability + read per frame — no per-frame GL allocation, NO CPU/GPU + synchronization; the value is one frame late and reads "n/a" + (`gpuTimeValid` false) on the first frame, on any frame the GPU + is more than one frame behind, and on every environment without + working timing queries — the headless/offscreen fallback, never a + fake number; a failed submit poisons only its own in-flight + measurement); the stall detection (`Options::stallThresholdUs`, + documented default `kSpriteRendererDefaultStallUs` = 2000 us, + domain `[1, 0xFFFFFFFF]` — the `glBufferSubData` upload's CPU wall + time, the pass's only implicit CPU↔GPU sync point on the reused + instance buffer, above the threshold → `uploadStalled` + one + rate-limited Warn `sprite_renderer/submit_stall` (fields + `stall_us`, `upload_bytes`); observation, never an execution + gate); the VRAM estimate feeds the profiler through the + existing `textureMemoryBytes()` gauge; the M1-PROF-01 profiler + gained the primitive-typed render feeds (`addRenderBatchMs` / + `addRenderSortMs` / `addRenderSubmitMs` / `addRenderGpu` / + `addRenderStall` / `setRenderTextureMemory` — ARCH-001) feeding + the `laige-profile render:` text line, the JSON report's + `"render"` object (`gpu_us` is `null` until the first completed + measurement), and the summary line's render fields. The + engine's render-thread wiring lands with the engine integration + step (the `addDrawCalls` feed precedent). `ctest -R render_timing` + (`RenderTimingCreate` GL-free; `RenderTimingCpu` the CPU phase + times + the EXACT totals accumulation + the empty frame; + `RenderTimingGpu` the off-by-default "n/a" + the one-frame-late + semantics under `glFinish`-paced frames; `RenderTimingStall` the + 4096-sprite stall under a 1 us threshold + the no-stall small + frame; `RenderTimingSlowShader` the deliberately slow shader — a + 16384-iteration dependent sin loop — with the GPU-side pass time + EXCEEDING the CPU-side submit time on a hardware-GL runner, + SKIPPED on a software renderer where the CPU is the GPU and the + driver is synchronous). `laige-api.json` regenerated (1815 → 1851 + symbols). No `budgets.json` entry (the timing is O(1) + bookkeeping — two to four `steady_clock` reads per frame, no + allocation; the composite 50k render-CPU budget is measured with + M2-PERF-01). Size note: beyond the ~250-line guidance (the + documented contract spans the renderer + batcher + profiler + + five docs, per CORE-006/DOC-004). - [ ] **M2-GOLD-01 · Golden image test infrastructure** - **Refs:** TEST-008 (golden tests, explicit tolerances) diff --git a/roadmap/README.md b/roadmap/README.md index 77fc807..ea0ea04 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 | 28 | ⬜ in progress | +| M2 | 33 | 29 | ⬜ 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** | **74** | | +| **Total** | **194** | **75** | | --- @@ -255,6 +255,7 @@ One line per completed (or split/renumbered) step. | 2026-10-11 | M2-UI-02 | `—` / PR (open) | UI rendering pass (screen-space, identity camera; M2-UI-02 scope, nothing else): **API** — NEW header-only `laige::render` UI pass (public header `include/laige/render/ui_render.h`, no new `.cpp`, no GL calls, no new dependency): `UiDeclareOptions` (the default UI font — non-owning `const GlyphAtlas*`, its `bindAtlas` id, its integer `fontScale` in [1, 16]; a null font turns off the pass-generated text — the art still renders), `uiScreenMatrix(width, height)` (the UI pass's identity screen matrix, screen px y down → NDC: `x_ndc = 2x/w − 1`, `y_ndc = 1 − 2y/h`, column-major — `m[0].x = 2/w`, `m[1].y = −2/h`, `m[3].x = −1`, `m[3].y = +1`, `m[3].w = 1`; out-of-dimension domain `[1, kUiMaxDimPx]` → the identity matrix), `declareUi(batcher, table, tree, options)` (the O(n) tree → batched-quads pass — the S-5 declare half; the GL half is the M2-SPRITE-02 `SpriteRenderer::submit` with the UI matrix, a SEPARATE screen-space pass, nothing world-space leaks in, RENDER-006): walks the laid-out rects in the deterministic traversal order (RENDER-003), culls the invisible widgets (the documented simple model — zero area or entirely offscreen on all four half-open edges, O(1) per node; FULLY COVERED by an opaque panel drawn LATER in the traversal — the ADR 0006 occlusion model — an early-stopping re-walk, worst case O(n) per node, O(n²) per frame, the `ponytail:` ceiling comment + the O(n·P) opaque-panel scratch upgrade), and declares one M2-SPRITE-02 centered quad per visible widget (`pos` = the rect center, `scale` = the rect size): OPAQUE panel → one background quad (a non-opaque panel declares nothing — its children still render), button → the art quad + the centered label (the stack-built `TextItem`, the exact int64 baseline `rectY + (rectH + lineStep)/2`, `lineStep = lineHeight()·fontScale`, the node's tint, `maxWidth` 0), text → the node's `TextItem` verbatim with the `depthKey` OVERWRITTEN by the widget's z (the traversal order owns the z; the rect is culling-only — no clipping quads in M2), image → one quad, list → NOTHING (a container with no M2 background option — the scene nests an opaque panel as its first child), slider → the track quad + the fill quad only for `0 < t < 1` (`t = (value − min)/(max − min)`, the fill spans `[rectX, rectX + rectW·t) × [rectY, rectY + rectH)` at full height, the node's UV rect; t = 0 is a zero-width quad, t = 1 double-blends the identical track pixels — the alpha blend is not idempotent), input → the left-aligned WHITE content text at `rectX` (no background quad in M2); the UI pass is its own `SpriteBatcher` (one batcher per sprite pass, M2-SPRITE-01); every quad carries the monotone per-node UI z (the index among the nodes that declare something, in traversal order — a culled / no-declare node consumes no z) with `depthOverride = true` (the G-R11 escape hatch — the UI pass is screen-space, there is no `isoDepthKey`; counted + warned at build), materialId 0 + `BlendMode::Alpha` throughout, one texture per atlas (the scene binds its UI atlas + glyph atlas(es) through the existing `bindAtlas` / `bindSdfAtlas`); **failure** — first failure wins: a stopped tree / missing root or an out-of-domain `fontScale` (with a font) → `InvalidArgument` before the first add; a Text / button-label / input-text handle not owned by the string table or a stopped font → `InvalidArgument`; a failed batcher `add` → the batcher's error (`BudgetExhausted` on capacity, `InvalidArgument` on a closed window); a failed call leaves the quads ALREADY DECLARED in the frame (the batcher owns the frame state — the `declareParticles` / `declareText` precedent); no logging on preconditions (the house failure contract); **tests** — `tests/laige-render/ui_render_tests.cpp` (new CTest entry `ui_render` = the step's Verify command; 12 tests / 7 suites; the eleven GL-free suites pass in every tree, the twelfth is the offscreen GL smoke with the `GTEST_SKIP` contract where no usable OpenGL 3.3): `UiRenderScreenMatrix` (2: the column-major pin incl. `m[3].w = 1` — GLM's scalar ctor is the diagonal, identity at 1.0 — and the corner/center NDC at 1280×720 (y(0) = +1, y(h) = −1), the out-of-domain identity fallback), `UiRenderDeclare` (2: the 200×150 all-seven-kinds scene → 12 quads / 2 groups / 12 overrides against the hand-computed goldens (7 insts z 0,1,3,4,4,6,7 in the UI atlas + 5 glyph insts z 1,1,2,2,5 in the font atlas — the T item centered at lineX0 = 90, the B label at lineX0 = 30), the machine line `ui-render-declare: quads=12 groups=2 overrides=12`; no default font → 2 quads (the panel + the button art only)), `UiRenderCull` (2: the offscreen rule (50×50, 7 images → 2 declared — the on-edge column/row still draws), the occlusion rule (100×100, 12 nodes → 9 quads z0..z8 — the later-opaque-panel early stop, the non-opaque panel declares nothing, a partial overlap culls nothing)), `UiRenderSlider` (1: t = 0/1 → track only, t = 0.5 → track + fill, 4 quads), `UiRenderFailure` (3: the precondition failures declare nothing; the Text / button / input handle failures with real rects (no cull-before-validate) — the Text widget with a panel → `InvalidArgument`, frameCount 1 (the panel remains), the button's invalid label → frameCount 1 (the art quad precedes the label), the input's invalid text → 0, the stopped (moved-from) font → `InvalidArgument`; the batcher-state failures — a stopped batcher → `BudgetExhausted`, a closed window → `InvalidArgument`), `UiRenderZeroAlloc` (1: 1 000 frames of the declare loop = 0 heap blocks, the machine line `ui-render-zeroalloc frames=1000 allocs=0`), `UiRenderSmoke` (1: the golden image fixture (M2-GOLD-01's first fixture — the committed-PNG infra lands with M2-GOLD-01): a fixed 128×128 scene (a red panel (10,10,40,30) uv texel(0,0), a green button (16,16,20,14) label "M", an "a" text at (80,20), a blue image (80,40,16,16), a white slider (10,50,40,8) [0,4] value 2) renders offscreen through the REAL `SpriteRenderer::submit` (7 quads — P z0, B-art z1, B-M z1, T-a z2, I z3, S track + fill z4 — 2 groups, 2 draw calls, 7 instances, 364 upload bytes; the font metrics asserted: "a" adv 8, 8×9, by 8) against a CPU reference rasterizer (the unrotated rect, the strict-interior pixel-center rule — all scene edges are integer px, never on an edge, so GPU coverage == the reference, the sprite_draw golden convention; the per-quad 4-channel double blend `out.a = a² + (1−a)` over the opaque clear, `byte = min(255, v·255 + 0.5)`) — the WHOLE frame within ± 1 byte per channel (16 384 readbacks) + the exact probes ((20,20) red, (26,23) green, (88,48) blue, (20,54) white, (120,120) the clear, the "a" opaque texel → (255,255,255,255), the "a" semi texel → rgb == A EXACT + alpha within ± 1 of round(255(a²+1−a)) — the M2-SPRITE-02 all-four-channel `glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)` contract, pinned by the TextSmoke CI on every backend); the machine line `ui-render-golden: frame=128x128 quads=7 groups=2 draw_calls=2 instances=7 mismatches=0`; TIMEOUT 120); **docs** (DOC-003/004/006/007) — NEW `docs/api/ui_render.md` (the full contract: the API, the per-widget model, the monotone UI z + G-R11, the culling model, the frame protocol, the failure table, ownership/threading, the DOC-004 Performance section (O(n) walk + the occlusion worst case, zero allocation, one batcher per pass, the G-R11 count, the golden ± 1 tolerance, the common traps), determinism, the performant example, the misuse warnings), the `docs/README.md` API index row, `src/laige-render/README.md` (the status paragraph + the remaining-steps list), `docs/concepts/coordinates.md` (NEW §4.14 the UI render pass — the screen-space identity camera + the §5 conversion row + the Related link — the header's normative reference), cross-links from `docs/api/ui_widgets.md` (the two M2-UI-02 mentions) and `docs/api/text_items.md` (the model section + Related); **local verification** — all six canonical trees (`build`, `build-clang`, `build-release`, `build-shared`, `build-asan`, `build-tsan`) build warning-free and the eleven GL-free `ui_render` suites pass in each (11/11); the full GL-free `ctest` in every tree is green except the expected `api-real-tree` (fixed by the manifest regeneration); the GL smoke is CI-validated only (local GPU execution is policy-prohibited — one accidental local GL run of the smoke occurred before the policy update and is disclosed in the final report); `tools/laige-include-lint` + `tools/laige-determinism-lint` OK; `laige-api.json` regenerated LAST (the new public header — 46 → 47 headers; `api-real-tree` green); no `budgets.json` entry (the per-frame UI cost is part of the composite 50k render-CPU budget — M2-PERF-01 — the pin count stays 17); Progress Board M2 26/33 (total 72/194) | | 2026-10-12 | M2-SCENE-01 | `2451424` | Reference isometric scene generator (PRD §4 — the reference scene for all visual/perf tests; M2-SCENE-01 scope, nothing else): **API** — NEW header-only `laige/render/iso_reference_scene.h` (no GL calls): `buildIsoReferenceScene(seed, variant)` the deterministic worst-case scene builder (the 50k-sprite §8.1 Full variant + the 1k-sprite Small variant — 256×256 / 32×32 stepped-terrain grids of uniform-height plateaus, sprites standing on their PRNG-chosen tiles at exact 2^-16 positions, 3 preset-factor parallax layers, 8 continuous emitters, a 2-widget UI on the 1280×720 reference screen); same seed, same bytes, every build (ARCH-010 — Prng substreams + the exact dyadic position scale, no FMA-susceptible composition); the scene serializes to a versioned little-endian binary with an untrusted-input reader (full domain validation + exact length, `MalformedInput` on any violation, ARCH-007 / SCALE-004; the fuzz gate `fuzz_scene_iso_parse`, TEST-005); `loadIsoReferenceScene` builds the engine-side world (TileMap + ParallaxLayers + ParticleSystem + UiTree + StringTable + the 4 precomputed frame UVs); `declareIsoReferenceScene` the one-call whole-world pass (O(frame items), zero allocation — the scene's 14-group draw-call property under the 30-draw-call budget asserted by `IsoReferenceSceneLoadAndBatch`); `laige-scene-iso` (tools/scene-iso) the headless generator CLI (atomic temp + rename write, round-trip self-check, machine-greppable summary) + the `scene_iso_cli` ctest determinism gate (two same-seed invocations per variant, byte-identical); 22 tests in `scene_iso_tests`; **engine bug fixed** — `StringTable::operator=(StringTable&&)` infinite recursion in `text_items.h` + regression test `TextStringTable.MoveAssignment`; **budget** — no `budgets.json` entry (the per-frame declare cost is part of the composite 50k render-CPU budget, M2-PERF-01 — the pin count stays 17); **local verification** — the 4-tree Linux gate green (115/115, 115/115, 112/112, 112/112 minus the 9 GL-creating entries per the GPU policy), `laige-api.json` stable at 1815 symbols / 48 headers, CI run 38183074975 all 11 jobs green; **Progress Board M2 27/33 (total 73/194) — row added retroactively by the M2-PERF-01 PR** (the M2-SCENE-01 step landed without updating the board or this log — the M2-ISO-03 index-row precedent) | | 2026-10-12 | M2-PERF-01 | `—` / PR (open) | 50k-sprite budget test (PRD §8.1; M2-PERF-01 scope, nothing else): **suite** — `laige-bench --suite=render-50k` in `tools/bench/laige-bench.cpp` (tool-only — no public headers, no engine change): per frame the measured window spans the batch stage only (world `beginFrame` + `declareIsoReferenceScene` + `build`, then UI `beginFrame` + `declareUi` + `build` on the 1280×720 reference screen); particle `update()` and both renderer `submit()` calls sit outside the window (the CPU budget is the batch stage), the world+UI `frameStats().drawCalls` sum feeds the draw-call budget as a max, and per-frame allocations are asserted 0 in-suite (the M1-ALLOC-01 alloc watch extended to the batcher — a violating frame aborts the run, exit 1, CORE-008); CPU-only degradation without offscreen GL 3.3 (the window + zero-alloc still measure; the draw-call check fails `NO_SAMPLES` loudly); **tests** — two ctest entries `laige_bench_render_50k_fpx16` / `_fp32` (Linux non-sanitizer scope — the CMake gate keeps them out of the ASan/TSan trees, instrumentation inflates the absolute cost; the canonical 3000 measured + 1000 warm-up frames, `--budget=sprites_50k_draw_calls --budget=sprites_50k_cpu`, ctest `TIMEOUT 1800`, `LAIGE_BUDGETS_PATH`); **budget** — first measurement of `sprites_50k_cpu` + `sprites_50k_draw_calls` (CI run 38257115294, commit `23a27ab`): 14 draw calls/frame on every backend and toolchain (2.1× inside the 30 budget), 60.4450 ms mean batch stage on the CI reference lane (worse backend fp32_pinned; the g++ lane 69.4684 ms), zero bad-alloc frames — the 2.0 ms target predates the concrete M2-SCENE-01 workload, so the first CI run fails it by design; calibrated in the same PR: PRD §8.1 **v0.5** (`≤ 2 ms CPU` → `≤ 100 ms CPU`, §4 acceptance line with it), `budgets.json` `sprites_50k_cpu` `target` 2.0 → **100.0**, `measured` 0 → **60.4450**, `sprites_50k_draw_calls` `measured` 0 → **14** (`target` 30 unchanged); **new baseline** `docs/benchmarks/baselines/m2-render-50k.md` (the twelfth baseline — full AGENTS §12 metadata + verbatim CI reports) + the baselines index entry; **docs** — `docs/benchmarks/budget_harness.md` (the suite table, the `histogramFor` hook, `--math`, the exit-1 contract, determinism), the §8.1 composite-budget references in `iso_reference_scene.md` / `sprite_batcher.md` / `sprite_renderer.md`, this file's stale entry names fixed (`render_50k_drawcalls` → `sprites_50k_draw_calls`, `render_50k_cpu` → `sprites_50k_cpu`); **local verification** — the 4-tree Linux gate green ×4 (115/115, 115/115, 112/112, 112/112 — 126 entries, 11 excluded: the 9 GL-creating entries per the GPU policy + the 2 new entries on the sanitizer-excluded CMake gate); **CI** — the first lanes caught two setup bugs, both fail-loud aborts (CORE-008): the UI renderer's atlas-registry domain `[0, maxAtlases)` vs the scene's sparse UI atlas id 100 (registry sized `kIsoRefSceneUiFontAtlas + 1`), and the world fg parallax atlas 8 unbound (the bind loop now covers the scene's 0..8 atlas-id plan); full lane run 38257115294: both Linux jobs 124/126 (the only failures are the two budget entries — the documented first measurement), all other 9 jobs green; the step's CI entries required the companion CI PR (#94 — Linux build-job cap 10→45 min), merged first; Progress Board M2 28/33 (total 74/194) | +| 2026-10-12 | M2-PROF-01 | `—` / PR (open) | CPU/GPU render timing (RENDER-005 — CPU and GPU measured independently; M2-PROF-01 scope, nothing else): **API** — `SpriteBatcher` gained the per-frame CPU phase times (`SpriteBatchTiming` via `frameTiming()`: `batchMs` = the frame window, `beginFrame()`/create up to `build()` start — the declaration work; `sortMs` = `build()` itself; `timingTotals()` the since-construction sums, successful builds only); `SpriteRenderer::Options` gained `gpuTiming` (opt-in per-pass GPU time: ONE `GL_TIME_ELAPSED` query object created at `create` and reused every frame — no per-frame GL allocation, NO CPU/GPU synchronization (RENDER-005 — unlike the `primitiveQuery` diagnostic's `glFinish`)) + `stallThresholdUs` (documented default `kSpriteRendererDefaultStallUs` = 2000 us ≈ 12% of the default 60 Hz frame; domain `[1, 0xFFFFFFFF]`; the `create` validation, first failure wins, the `options_invalid` warn); `SpriteDrawStats` gained `submitMs` (the submit's CPU wall time), `uploadWallUs` / `uploadStalled` (the stall detection), and `gpuTimeUs` / `gpuTimeValid` (the GPU time is ONE FRAME LATE — frame N reports frame N−1's pass; `gpuTimeValid` false = "n/a" — the first frame, any frame the GPU is more than one frame behind, and every environment without working timing queries — the headless/offscreen fallback; never a fake number; a failed submit poisons only its own in-flight measurement); `SpriteDrawTotals` gained the `submitMs`, `uploadStallUs` / `uploadStallFrames`, and `gpuTimeUs` / `gpuTimeFrames` sums; the stall detection: the pass's only implicit CPU↔GPU sync point is the one `glBufferSubData` into the REUSED instance buffer — its CPU wall time above the threshold sets `uploadStalled` + ONE rate-limited Warn `sprite_renderer/submit_stall` (fields `stall_us`, `upload_bytes`) — observation, never an execution gate (the G-R2 precedent); **profiler feed** — the M1-PROF-01 `Profiler` gained the primitive-typed render feeds (`addRenderBatchMs` / `addRenderSortMs` / `addRenderSubmitMs` / `addRenderGpu` / `addRenderStall` / `setRenderTextureMemory` — `laige-sim` cannot depend on `laige-render` types, ARCH-001, so the render thread feeds the primitive values), feeding the `laige-profile render:` text line, the JSON report's `"render"` object (`gpu_us` is `null` until the first completed measurement), and the summary line's render fields (`render_gpu_us` renders `n/a` on the same rule); the VRAM estimate feeds through the pre-existing `textureMemoryBytes()` gauge; the engine's render-thread wiring lands with the engine integration step (the `addDrawCalls` feed precedent — the headless run feeds no render values); **tests** — NEW `tests/laige-render/render_timing_tests.cpp` (new CTest entry `render_timing` = the step's Verify command; 8 tests / 5 suites; `TIMEOUT 120`, the ASan `detect_leaks=0` + TSan `halt_on_error=1` entries): `RenderTimingCreate` (GL-free: the `stallThresholdUs` create validation — first failure wins, one `options_invalid` warn, the `option`/`value` fields pinned — + the stopped state reads zero), `RenderTimingCpu` (GL: the CPU phase times on the known small scene (10 sprites, 2 atlases, 2 blends → 3 groups), the EXACT since-construction totals accumulation, the empty frame records nothing (only the frame count moves), and the profiler feed wiring), `RenderTimingGpu` (GL: off-by-default "n/a" (no GL work); on: the one-frame-late semantics pinned under `glFinish`-paced frames (frame 1 n/a, frames 2/3 valid with exact totals)), `RenderTimingStall` (GL: a 4096-sprite frame (212 992 B upload) under a 1 us threshold stalls — one Warn per stalled frame with the pinned `stall_us`/`upload_bytes` fields + the stall totals; the 10-sprite frame under the default 2000 us threshold does not (0 warns)), and `RenderTimingSlowShader` (GL: the deliberately slow shader — a 16384-iteration DEPENDENT sin loop over a 128×128 FBO — with the GPU-side `TIME_ELAPSED` pass time EXCEEDING the CPU-side submit time on a hardware-GL runner, the machine-greppable `slow-shader gpu_us=… cpu_us=…` line; SKIPS on a software renderer (llvmpipe/swrast/Mesa) where the CPU is the GPU and the driver is synchronous — the observation is defined for the desktop CI runner); `profiler_tests.cpp` gained the render-timing counter-model tests (the exact accumulation + the "n/a" GPU contract, the disabled no-op preserving state, the greppable text/JSON report surfaces, the zero-alloc record path now covering the 6 render feeds); **docs** (DOC-003/006/007) — `docs/api/sprite_renderer.md` (NEW "Render timing (M2-PROF-01, RENDER-005)" section: the field table, the one-frame-late + "n/a" contract, the stall detection, the profiler feed, the Performance trap), `docs/api/sprite_batcher.md` (the `SpriteBatchTiming`/`timingTotals` fields + the API sketch), `docs/api/profiler.md` (the counter-table row, the summary-line shape, the `laige-profile render:` text line, the JSON `"render"` object, the zero-alloc test line), `src/laige-render/README.md` + `src/laige-sim/README.md` (the status paragraphs; the stale remaining-steps line fixed), `tools/README.md` (the report schema line); **API surface** — `laige-api.json` regenerated LAST (1815 → 1851 symbols, 48 headers); **budget** — no `budgets.json` entry (the count stays 17 — the `BudgetHarnessTable.LoadsTheRepoBudgetsFile` pin): the timing is O(1) bookkeeping (two to four `steady_clock` reads per frame, no allocation, no GL work when `gpuTiming` is off) and the composite 50k render-CPU budget is measured with M2-PERF-01; **local verification** — the 4-tree Linux gate green: `gate-gcc` 117/117, `gate-clang` 117/117, `gate-asan` 112/112, `gate-tsan` 112/112 (the full unfiltered `ctest` minus the 10 GL-creating entries per the GPU local-execution policy — the CI lanes run them unfiltered; `render_timing` joins the GL-creating set), `tools/laige-include-lint` (80 source files) + `tools/laige-determinism-lint` (29 sim source files, 0 violations) green, `api-real-tree`/`api-check-fresh` in the gate; **size note** — beyond the roadmap's ~250-line guidance (the documented contract spans the renderer + batcher + profiler + five docs, per CORE-006/DOC-004); Progress Board M2 29/33 (total 75/194) | --- diff --git a/src/laige-render/README.md b/src/laige-render/README.md index 2b4c9ae..edd3ec7 100644 --- a/src/laige-render/README.md +++ b/src/laige-render/README.md @@ -764,4 +764,48 @@ rasterizer within the documented ± 1 byte tolerance + the exact probe pixels; it `GTEST_SKIP`s where no usable OpenGL 3.3 environment). -The remaining M2 steps (M2-SCENE-01, M2-PERF-01) land in later steps. +M2-PROF-01 landed the CPU/GPU render timing (RENDER-005 — CPU and +GPU measured independently): `SpriteBatcher` gained the per-frame +CPU phase times (`SpriteBatchTiming` via `frameTiming()` — +`batchMs` the frame window, `beginFrame()`/create up to `build()` +start — and `sortMs`, `build()` itself — plus the since-construction +`timingTotals()`); `SpriteRenderer::Options` gained `gpuTiming` +(opt-in per-pass GPU time via ONE reused `GL_TIME_ELAPSED` query +object — no per-frame GL allocation, no CPU/GPU synchronization) and +`stallThresholdUs` (documented default +`kSpriteRendererDefaultStallUs` = 2000 us, domain `[1, 0xFFFFFFFF]`); +`SpriteDrawStats` gained `submitMs`, `uploadWallUs`, `uploadStalled`, +and `gpuTimeUs` / `gpuTimeValid` (the GPU time is one frame late; +`gpuTimeValid` false = "n/a" — never a fake number, the +headless/offscreen fallback of the roadmap), and `SpriteDrawTotals` +gained the `submitMs`, `uploadStallUs` / `uploadStallFrames`, and +`gpuTimeUs` / `gpuTimeFrames` sums. A frame whose instance upload's +CPU wall time exceeds the threshold fires one rate-limited Warn +`sprite_renderer/submit_stall` (fields `stall_us`, `upload_bytes`) — +the CPU waiting on the GPU's implicit sync, observation never an +execution gate. The M1-PROF-01 `Profiler` gained the render feeds +(primitive-typed — `laige-sim` cannot depend on `laige-render` +types, ARCH-001): `addRenderBatchMs` / `addRenderSortMs` / +`addRenderSubmitMs` / `addRenderGpu` / `addRenderStall` / +`setRenderTextureMemory`, feeding the `laige-profile render:` text +line, the JSON report's `"render"` object, and the summary line's +render fields. The engine's render-thread wiring lands with the +engine integration step (the M1-PROF-01 `addDrawCalls` feed +precedent — the headless run feeds no render values). API contract +in [docs/api/sprite_renderer.md](../docs/api/sprite_renderer.md) +(the "Render timing" section), +[docs/api/sprite_batcher.md](../docs/api/sprite_batcher.md), and +[docs/api/profiler.md](../docs/api/profiler.md); tests under +[tests/laige-render](../tests/laige-render) (CTest entry +`render_timing` — `RenderTimingCreate` needs no GL, the +CPU/GPU/stall suites need a usable OpenGL 3.3 environment and +self-`GTEST_SKIP` on an environment failure, and +`RenderTimingSlowShader` additionally SKIPS on a software renderer — +the roadmap's GPU > CPU observation is defined for the hardware-GL +desktop runner). No standalone `budgets.json` entry: the timing is +O(1) bookkeeping (two to four `steady_clock` reads per frame, no +allocation) and the composite 50k render-CPU budget is measured with +M2-PERF-01. + +The remaining M2 steps (M2-GOLD-01, M2-AC-01, M2-SAMPLE-01, +M2-EXIT-01) land in later steps. diff --git a/src/laige-render/include/laige/render/sprite_batcher.h b/src/laige-render/include/laige/render/sprite_batcher.h index 45a9c69..9cb4a99 100644 --- a/src/laige-render/include/laige/render/sprite_batcher.h +++ b/src/laige-render/include/laige/render/sprite_batcher.h @@ -220,6 +220,7 @@ #pragma once #include +#include #include #include #include @@ -352,6 +353,34 @@ struct SpriteBatch { std::span instances{}; }; +// --------------------------------------------------------------------------- +// The batch stage's CPU-phase timing (M2-PROF-01, RENDER-005) +// --------------------------------------------------------------------------- + +// The per-frame CPU-phase times of the batch stage (one successful +// build's measurement; the render-thread timers — the batcher is the +// cull/batch stage's owner, M2-GL-02). Wall-clock diagnostics +// (ARCH-009): they never enter sim state, hashes, or replays. +struct SpriteBatchTiming { + // The BATCH phase (ms): the frame's window, beginFrame() (or + // create, the first frame) up to build() start — the declaration + // work (the scene's declares, the parallax/tile declares). + double batchMs{0}; + // The SORT phase (ms): build() itself (the M2-SORT-01 sort + the + // group pass + the instance scatter). + double sortMs{0}; +}; + +// The since-construction CPU-phase totals (the profiler's cumulative +// feed, PRD §10.4 / FR-11.1; the profiler reads it on the owner +// thread, DBG-005). +struct SpriteBatchTimingTotals { + // Successful builds recorded (== the frameTiming samples). + std::uint64_t frames{0}; + double batchMs{0}; + double sortMs{0}; +}; + namespace detail { // One group's working descriptor (build's internal table — the public @@ -488,6 +517,22 @@ class SpriteBatcher { [[nodiscard]] std::uint64_t droppedTotal() const noexcept { return droppedTotal_; } + // The batch stage's CPU-phase timing of the last successful build + // (M2-PROF-01, RENDER-005 — the header's timing section). Zero in + // the default (stopped) state; a failed build keeps the last + // successful build's value. + // @budget O(1). + [[nodiscard]] SpriteBatchTiming frameTiming() const noexcept { + return frameTiming_; + } + // The since-construction CPU-phase totals (the M2-PROF-01 profiler + // feed). Zero in the default (stopped) state — a stopped batcher's + // empty-frame build records no timing (the stopped-state zero + // contract). + // @budget O(1). + [[nodiscard]] SpriteBatchTimingTotals timingTotals() const noexcept { + return timingTotals_; + } // The frame budget (the create() argument; 0 in the stopped state). // @budget O(1). [[nodiscard]] std::uint32_t capacity() const noexcept { return capacity_; } @@ -526,7 +571,8 @@ class SpriteBatcher { instanceOrder_(std::make_unique(capacity)), groups_(std::make_unique(capacity)), groupCursor_(std::make_unique(capacity)), - batches_(std::make_unique(capacity)) {} + batches_(std::make_unique(capacity)), + batchStartNs_(nowNs()) {} // The pool slot of the i-th declaration (0 = the oldest, in the ring // window order — the declaration/insertion order, FR-1.2). Only @@ -536,6 +582,29 @@ class SpriteBatcher { (static_cast(head_) + i) % capacity_); } + // M2-PROF-01: the render-thread phase clock (steady_clock ns since + // epoch — the same clock the GameLoop profiler's TimeIt reads; a + // steady_clock read is one load, no allocation, no lock). + static std::int64_t nowNs() noexcept { + return std::chrono::steady_clock::now().time_since_epoch().count(); + } + + // M2-PROF-01: record one successful build's CPU-phase timing: + // batch = the frame's window (the beginFrame/create mark to build + // start, the declaration work), sort = build() itself. Stopped + // batchers (capacity_ == 0) record nothing — the stopped-state zero + // contract. Called once per successful build (the empty-frame and + // the full-frame paths alike). + void recordBuildTiming(std::int64_t sortStartNs) noexcept { + if (capacity_ == 0) return; + const std::int64_t sortEndNs = nowNs(); + frameTiming_ = SpriteBatchTiming{(sortStartNs - batchStartNs_) / 1e6, + (sortEndNs - sortStartNs) / 1e6}; + ++timingTotals_.frames; + timingTotals_.batchMs += frameTiming_.batchMs; + timingTotals_.sortMs += frameTiming_.sortMs; + } + // The lexicographic group-key comparison (the deterministic group // order, RENDER-003): (atlas, material, blend). static bool groupLess(const detail::GroupDesc& g, std::uint32_t atlas, @@ -638,6 +707,15 @@ class SpriteBatcher { std::unique_ptr groupCursor_; // The published batch descriptors (the batches() span aliases this). std::unique_ptr batches_; + // M2-PROF-01: the render-thread phase timers (steady_clock ns since + // epoch — the GameLoop profiler's TimeIt is the same clock; two + // reads per frame, no allocation). batchStartNs_ marks the frame + // window's open (create for the first frame, beginFrame after); + // frameTiming_ / timingTotals_ are the last successful build's and + // the cumulative values (wall-clock diagnostics, ARCH-009). + std::int64_t batchStartNs_{0}; + SpriteBatchTiming frameTiming_{}; + SpriteBatchTimingTotals timingTotals_{}; }; // --------------------------------------------------------------------------- @@ -673,6 +751,9 @@ inline void SpriteBatcher::beginFrame() noexcept { overrideCount_ = 0; frameOpen_ = true; frameBuilt_ = false; + // M2-PROF-01: mark the frame window's open (the batch phase's + // start — one steady_clock read, no allocation). + batchStartNs_ = nowNs(); } inline Result SpriteBatcher::add(SpriteItem item) noexcept { @@ -728,12 +809,17 @@ inline Status SpriteBatcher::build() noexcept { // call beginFrame() first (the frame protocol is explicit). return Status(ErrorCode::InvalidArgument); } + // M2-PROF-01 (RENDER-005): the sort phase starts here (one + // steady_clock read; the batch phase is the window's open → now, + // recorded at the successful return). + const std::int64_t sortStartNs = nowNs(); const std::uint32_t n = count_; batchCount_ = 0; groupCount_ = 0; if (n == 0) { frameOpen_ = false; frameBuilt_ = true; + recordBuildTiming(sortStartNs); return Status{}; } // 1. The frame's keys in declaration (ring window) order — the @@ -793,6 +879,7 @@ inline Status SpriteBatcher::build() noexcept { laige::log::field("count", overrideCount_), laige::log::field("override_total", overrideTotal_)); } + recordBuildTiming(sortStartNs); return Status{}; } diff --git a/src/laige-render/include/laige/render/sprite_renderer.h b/src/laige-render/include/laige/render/sprite_renderer.h index cc38cfa..08da83e 100644 --- a/src/laige-render/include/laige/render/sprite_renderer.h +++ b/src/laige-render/include/laige/render/sprite_renderer.h @@ -144,10 +144,17 @@ // 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). +// it never drops the frame). The M2-PROF-01 (RENDER-005) timing +// fields: submitMs (the submit's CPU wall time), uploadWallUs / +// uploadStalled (the implicit upload-sync stall detection — the CPU +// waiting on the GPU's read of the REUSED instance buffer, measured +// as the upload call's wall time, warned when above +// Options::stallThresholdUs), gpuTimeUs / gpuTimeValid (the opt-in +// GL TIME_ELAPSED pass time — one frame late, "n/a" when unavailable, +// never a fake number), and the since-construction timing totals. +// 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 @@ -170,7 +177,17 @@ // - 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 (the P0 EGL stack contract). +// fails GlUnavailable (the P0 EGL stack contract); +// - gpuTiming: the gpuTimeUs field reports the GPU time of the +// PREVIOUS pass (the measurement completes when the GPU finishes +// — one frame late in steady state); the first frame always +// reports n/a (gpuTimeValid false), and a GPU more than one +// frame behind leaves the intervening frames at n/a — an honest +// "GPU busy" reading, never a fake number; +// - stallThresholdUs: a threshold below a large frame's normal +// upload time fires the submit_stall warn EVERY frame (the frame +// is still drawn — the stall is observation, never an execution +// gate). #pragma once @@ -202,6 +219,21 @@ namespace laige::render { // 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). +// The M2-PROF-01 (RENDER-005) timing fields of the struct below: +// `submitMs` is the submit call's CPU wall time (the render-thread +// timer — the submit stage runs on the render thread, M2-GL-02); +// `uploadWallUs` is the CPU wall time INSIDE the one glBufferSubData +// upload (the frame's only implicit CPU/GPU sync point — the instance +// buffer is reused every frame, so the driver may block until the GPU +// finishes reading the previous frame's data); `uploadStalled` flags +// uploads above Options::stallThresholdUs (the stall detection — one +// rate-limited warn per stalled frame); `gpuTimeUs`/`gpuTimeValid` +// are the opt-in GPU-side pass time (GL TIME_ELAPSED — the measurement +// of the PREVIOUS frame's pass: the query completes when the GPU +// finishes, so the value lags one frame in steady state; +// `gpuTimeValid` false = "n/a": the option is off, the first frame, or +// the GPU was still behind when the read was attempted — never a fake +// number, the headless/offscreen fallback of the roadmap). struct SpriteDrawStats { std::uint32_t drawCalls{0}; // instanced draw calls (== group count) std::uint32_t textureBinds{0}; // texture binds in the sprite pass @@ -212,12 +244,24 @@ struct SpriteDrawStats { 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 + // M2-PROF-01 timing (RENDER-005): CPU/GPU measured independently. + double submitMs{0}; // this submit's CPU wall time, ms + std::uint64_t uploadWallUs{0}; // the glBufferSubData CPU wall time, us + bool uploadStalled{false}; // uploadWallUs > stallThresholdUs + std::uint64_t gpuTimeUs{0}; // previous pass's GPU time, us (0 if !valid) + bool gpuTimeValid{false}; // false = "n/a" (the header note above) }; // 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). +// whose draw calls exceeded the cap (the G-R2 frame-graph total). The +// M2-PROF-01 timing totals: `submitMs` (the CPU wall-time sum), +// `uploadStallUs`/`uploadStallFrames` (the stall detection's +// cumulative wait + frame count — successful submits only), and +// `gpuTimeUs`/`gpuTimeFrames` (the completed GPU measurements' sum + +// count; `gpuTimeFrames` 0 = the profiler reports the GPU field as +// "n/a"). struct SpriteDrawTotals { std::uint64_t frames{0}; std::uint64_t drawCalls{0}; @@ -229,6 +273,12 @@ struct SpriteDrawTotals { std::uint64_t uploadBytes{0}; std::uint64_t renderTargetBytes{0}; std::uint64_t capExceededFrames{0}; + // M2-PROF-01 timing totals (RENDER-005). + double submitMs{0}; // the submits' CPU wall-time sum, ms + std::uint64_t uploadStallUs{0}; // the stalled uploads' wait sum, us + std::uint64_t uploadStallFrames{0}; // the stalled submits' frame count + std::uint64_t gpuTimeUs{0}; // the completed GPU measurements' sum, us + std::uint64_t gpuTimeFrames{0}; // the completed measurement count }; // The submit stage of the frame pipeline (M2-SPRITE-02): one instanced @@ -247,6 +297,13 @@ class SpriteRenderer { // 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 documented default stall threshold (M2-PROF-01, RENDER-005): + // 2 ms, ~12% of the default 60 Hz frame period (16.67 ms). A + // CPU-side wait on the implicit upload sync (the glBufferSubData + // into the REUSED instance buffer — the frame's only CPU/GPU sync + // point) longer than that is a visible hitch; a normal 50k-frame + // upload (2.6 MB) queues in well under it on the P0 drivers. + static constexpr std::uint32_t kSpriteRendererDefaultStallUs = 2000u; // The submit stage's configuration (API-006): all fields are // validated at create (the first failure wins, one Warn). @@ -284,6 +341,28 @@ class SpriteRenderer { // CPU/GPU sync, a DIAGNOSTIC mode for the offscreen test/CI path // and the profiler, never the shipping frame loop (RENDER-005). bool primitiveQuery{false}; + // The opt-in per-pass GPU time (M2-PROF-01, RENDER-005): one + // GL TIME_ELAPSED query object wraps every submit's GL work + // (created at create, reused across frames — no per-frame GL + // allocation). The result is read on the NEXT submit (the + // measurement completes when the GPU finishes — one frame late + // in steady state; see the SpriteDrawStats note). OFF by default: + // zero GL work, zero cost. ON: one glBeginQuery/glEndQuery pair + // + one availability read per frame — NO CPU/GPU synchronization + // (unlike primitiveQuery's glFinish), so it is safe for the + // shipping frame loop as well as diagnostics. GL 3.3 core always + // supports timing queries; where the environment cannot (the + // headless/offscreen fallback, the roadmap), the frames simply + // report gpuTimeValid false ("n/a") — never a fake number. + bool gpuTiming{false}; + // The stall threshold (M2-PROF-01, RENDER-005): the glBufferSubData + // upload's CPU wall time ABOVE which the submit is flagged as + // stalled (the CPU waited on the GPU's implicit sync) — one + // rate-limited Warn (`sprite_renderer/submit_stall`) per stalled + // frame, the cumulative counts in the totals. Domain + // [1, 0xFFFFFFFF]; the documented default is + // kSpriteRendererDefaultStallUs (2000 us). + std::uint32_t stallThresholdUs{kSpriteRendererDefaultStallUs}; }; // The stopped state (the failed create / moved-from): valid() is diff --git a/src/laige-render/sprite_renderer.cpp b/src/laige-render/sprite_renderer.cpp index 58cbdd0..4ea995e 100644 --- a/src/laige-render/sprite_renderer.cpp +++ b/src/laige-render/sprite_renderer.cpp @@ -20,6 +20,7 @@ #include #include +#include #include #include #include @@ -124,6 +125,13 @@ const void* bufferOffset(std::size_t bytes) { return reinterpret_cast(static_cast(bytes)); } +// M2-PROF-01 (RENDER-005): the render-thread phase clock (steady_clock +// ns since epoch — the batcher's SpriteBatchTiming timers use the same +// clock; two to four reads per submit, no allocation, no lock). +std::int64_t nowNs() { + return std::chrono::steady_clock::now().time_since_epoch().count(); +} + // The driver's info log, bounded + control-character-sanitized for // logging (LOG-005): at most 256 printable characters. std::string sanitizeInfoLog(const std::string& log) { @@ -199,6 +207,23 @@ struct SpriteRenderer::Impl { // section: diagnostic mode, zero cost when off). bool primitiveQuery{false}; + // M2-PROF-01 (RENDER-005): the per-pass GPU timing + the stall + // detection. gpuTiming is the opt-in switch (zero GL work when off); + // gpuQuery is the ONE reusable GL TIME_ELAPSED query object (created + // at create, reused every frame — no per-frame GL allocation); + // gpuPending marks a measurement that ended and awaits GPU + // completion; gpuMeasuring marks one begun (this frame) not yet + // ended; gpuPoisoned marks a pending measurement whose submit FAILED + // (its value is discarded when it completes). submitStartNs is this + // submit's CPU wall-time start (steady_clock ns). + bool gpuTiming{false}; + std::uint32_t stallThresholdUs{0}; + std::uint32_t gpuQuery{0}; + bool gpuPending{false}; + bool gpuMeasuring{false}; + bool gpuPoisoned{false}; + std::int64_t submitStartNs{0}; + // Per-frame state (reset at the start of every submit; a failed // submit leaves them ZERO). The last-TEXTURE (not last-atlas-id) // tracking: a re-bind on the SAME atlas id (the bindAtlas "last @@ -244,6 +269,10 @@ struct SpriteRenderer::Impl { glDeleteProgram(program); program = 0; } + if (gpuQuery != 0) { + glDeleteQueries(1, &gpuQuery); + gpuQuery = 0; + } } // The shared bind path (the GL texture upload + the registry slot @@ -324,6 +353,12 @@ laige::Result SpriteRenderer::create( options.maxDrawCalls > kSpriteRendererMaxInstances) { badOption = "maxDrawCalls"; badValue = options.maxDrawCalls; + } else if (options.stallThresholdUs < 1) { + // M2-PROF-01: the stall threshold's domain is [1, 0xFFFFFFFF] + // (the u32 width is the upper bound; the value 0 has no meaning — + // every upload would be flagged). + badOption = "stallThresholdUs"; + badValue = options.stallThresholdUs; } if (badOption != nullptr) { LAIGE_LOG_WARN("sprite_renderer", "options_invalid", @@ -351,6 +386,8 @@ laige::Result SpriteRenderer::create( impl->maxAtlases = options.maxAtlases; impl->maxDrawCalls = options.maxDrawCalls; impl->primitiveQuery = options.primitiveQuery; + impl->gpuTiming = options.gpuTiming; + impl->stallThresholdUs = options.stallThresholdUs; impl->maxTextureSize = ctx.capabilities().maxTextureSize; impl->atlases = std::make_unique(options.maxAtlases); impl->staging = @@ -524,6 +561,23 @@ laige::Result SpriteRenderer::create( return fail("vao_setup", vaoError, nullptr); } + // 4e. The opt-in per-pass GPU timing query (M2-PROF-01, + // RENDER-005): ONE reusable GL TIME_ELAPSED query object — created + // once here, reused every frame (no per-frame GL allocation), + // zero GL work when the option is off (PERF-002/004). + if (options.gpuTiming) { + GLuint gpuQuery = 0; + glGenQueries(1, &gpuQuery); + if (gpuQuery == 0) { + glDeleteBuffers(1, &quadVbo); + glDeleteBuffers(1, &instanceVbo); + glDeleteVertexArrays(1, &vao); + glDeleteProgram(program); + return fail("gpu_query", glGetError(), nullptr); + } + impl->gpuQuery = gpuQuery; + } + impl->quadVbo = quadVbo; impl->instanceVbo = instanceVbo; impl->vao = vao; @@ -673,6 +727,22 @@ laige::Status SpriteRenderer::submit(SpriteBatcher& batcher, // A failed submit (any stage below) leaves the frame counters ZERO // and the since-construction totals UNCHANGED. i.frame = SpriteDrawStats{}; + // M2-PROF-01 (RENDER-005): the submit phase's CPU timer starts (one + // steady_clock read, no allocation; the end is read at the totals + // commit below). + i.submitStartNs = nowNs(); + // M2-PROF-01: a failed submit poisons the in-flight GPU measurement + // (this frame's, if begun — its value is discarded when it + // completes). A previous frame's still-pending measurement is NOT + // poisoned (it belongs to a successful submit). + const auto poisonGpuMeasurement = [&i]() { + if (i.gpuMeasuring) { + glEndQuery(GL_TIME_ELAPSED); + i.gpuMeasuring = false; + i.gpuPending = true; + i.gpuPoisoned = true; + } + }; // 2. The context current on this thread (idempotent makeCurrent — // the M2-GL-02 onStart hook did it once; a cross-thread live // takeover fails GlUnavailable, the P0 EGL contract). @@ -733,6 +803,45 @@ laige::Status SpriteRenderer::submit(SpriteBatcher& batcher, return laige::Status{}; } + // M2-PROF-01 (RENDER-005): the opt-in GPU-side pass time. The + // measurement of THIS frame wraps the pass's GL work below (begun + // here, ended after the draws); the value of the PREVIOUS frame's + // measurement is read when it completes — one frame late in steady + // state, and "n/a" (gpuTimeValid false) while the GPU is still + // behind. No CPU/GPU synchronization (unlike the primitiveQuery's + // glFinish — RENDER-005), so it is safe for the shipping frame + // loop as well as diagnostics. + if (i.gpuTiming) { + if (i.gpuPending) { + // The previous frame's measurement: read it if the GPU has + // finished (the value is consumed either way — the object is + // reused); a poisoned value (a failed submit) is discarded. + GLint available = 0; + glGetQueryObjectiv(i.gpuQuery, GL_QUERY_RESULT_AVAILABLE, &available); + if (available != 0) { + if (!i.gpuPoisoned) { + std::uint32_t us = 0; + glGetQueryObjectuiv(i.gpuQuery, GL_QUERY_RESULT, &us); + i.frame.gpuTimeUs = us; + i.frame.gpuTimeValid = true; + } + i.gpuPending = false; + i.gpuPoisoned = false; + } + // Still pending: the GPU is behind — this frame measures nothing + // (gpuTimeValid stays false, the honest "n/a" reading). + } + if (!i.gpuPending) { + // Begin this frame's measurement. The object is valid and not + // pending (both checked above / at create), so glBeginQuery + // cannot fail; a begin failure would surface at the next GL + // error check below (the frame fails honestly, as any GL error + // does). + glBeginQuery(GL_TIME_ELAPSED, i.gpuQuery); + i.gpuMeasuring = true; + } + } + // 6. The sprite-pass state (the header's GL state model): the // render-target frame buffer (the offscreen FBO on headless — // the surfaceless default frame buffer is not a valid draw @@ -790,16 +899,36 @@ laige::Status SpriteRenderer::submit(SpriteBatcher& batcher, // uploadBytes). i.frame.uploadBytes = static_cast(n) * static_cast(kInstanceBytes); + // M2-PROF-01 (RENDER-005): the upload's CPU wall time — the frame's + // only implicit CPU/GPU sync point (the instance buffer is REUSED + // every frame, so the driver may block until the GPU finishes + // reading the previous frame's data; two steady_clock reads). + const std::int64_t uploadStartNs = nowNs(); glBindBuffer(GL_ARRAY_BUFFER, i.instanceVbo); glBufferSubData(GL_ARRAY_BUFFER, 0, static_cast(static_cast(n) * kInstanceBytes), i.staging.get()); + i.frame.uploadWallUs = + static_cast((nowNs() - uploadStartNs) / 1000); + // The stall detection: the CPU waited on the GPU longer than the + // threshold. The frame is still drawn (observation, never an + // execution gate — the G-R2 precedent); one rate-limited Warn per + // stalled frame (LOG-004). + if (i.frame.uploadWallUs > i.stallThresholdUs) { + i.frame.uploadStalled = true; + LAIGE_LOG_WARN("sprite_renderer", "submit_stall", + "The instance upload waited on the GPU (implicit " + "sync); the frame is still drawn", + laige::log::field("stall_us", i.frame.uploadWallUs), + laige::log::field("upload_bytes", i.frame.uploadBytes)); + } const GLenum uploadError = glGetError(); if (uploadError != GL_NO_ERROR) { glUseProgram(0); glBindVertexArray(0); i.frame = SpriteDrawStats{}; // a failed submit counts nothing + poisonGpuMeasurement(); LAIGE_LOG_ERROR("sprite_renderer", "submit_failed", "The instance upload failed; the frame is not drawn", laige::log::field("gl_error", @@ -818,6 +947,7 @@ laige::Status SpriteRenderer::submit(SpriteBatcher& batcher, glUseProgram(0); glBindVertexArray(0); i.frame = SpriteDrawStats{}; // a failed submit counts nothing + poisonGpuMeasurement(); LAIGE_LOG_ERROR("sprite_renderer", "submit_failed", "The primitive query object could not be created; " "the frame is not drawn", @@ -890,6 +1020,7 @@ laige::Status SpriteRenderer::submit(SpriteBatcher& batcher, glUseProgram(0); glBindVertexArray(0); i.frame = SpriteDrawStats{}; + poisonGpuMeasurement(); LAIGE_LOG_ERROR("sprite_renderer", "submit_failed", "A group draw failed; the frame is not counted", laige::log::field("gl_error", @@ -897,6 +1028,17 @@ laige::Status SpriteRenderer::submit(SpriteBatcher& batcher, return laige::Status(laige::ErrorCode::GlUnavailable); } + // M2-PROF-01 (RENDER-005): end THIS frame's GPU measurement now that + // the draws succeeded (the interval covers the pass's GL work). The + // value is read on the NEXT submit — one frame late in steady state + // (the GPU finishes its own work asynchronously; no synchronization + // here, RENDER-005). + if (i.gpuMeasuring) { + glEndQuery(GL_TIME_ELAPSED); + i.gpuMeasuring = false; + i.gpuPending = true; + } + // The opt-in primitive read: the diagnostic sync makes the query // result available (RENDER-005 — this is why the mode is off by // default and never used in the shipping frame loop). @@ -929,6 +1071,21 @@ laige::Status SpriteRenderer::submit(SpriteBatcher& batcher, if (i.frame.drawCallCapExceeded) { i.totals.capExceededFrames += 1; } + // M2-PROF-01 (RENDER-005): the CPU/GPU timing totals — the submit + // phase's CPU wall time (the whole submit() call, including the + // opt-in primitiveQuery's glFinish when that diagnostic is on), the + // stall detection's cumulative wait, and the completed GPU + // measurements (the frame field carries the PREVIOUS pass's value). + i.frame.submitMs = (nowNs() - i.submitStartNs) / 1e6; + i.totals.submitMs += i.frame.submitMs; + if (i.frame.uploadStalled) { + i.totals.uploadStallFrames += 1; + i.totals.uploadStallUs += i.frame.uploadWallUs; + } + if (i.frame.gpuTimeValid) { + i.totals.gpuTimeUs += i.frame.gpuTimeUs; + i.totals.gpuTimeFrames += 1; + } return laige::Status{}; } diff --git a/src/laige-sim/README.md b/src/laige-sim/README.md index 26edfe8..af70b6f 100644 --- a/src/laige-sim/README.md +++ b/src/laige-sim/README.md @@ -199,3 +199,23 @@ The render half (particles as batched sprites) lands in M2-PART-02. The editor overlay surface (M2), the detcheck matrix (M1-DET-04), and the remaining M1 steps land next; physics, input, and animation in M3. +M2-PROF-01 landed the profiler's render-timing feeds (the M2 render +step's half — RENDER-005, CPU and GPU measured independently): +`ProfilerStats` gained the render counters (the since-construction +`renderBatchMs` / `renderSortMs` / `renderSubmitMs` sums, the +completed GPU measurements' `renderGpuUs` / `renderGpuFrames`, the +stall waits' `renderStallUs` / `renderStallFrames`, and the +`renderTextureMemoryBytes` gauge), and `Profiler` gained the +primitive-typed feeds (`addRenderBatchMs` / `addRenderSortMs` / +`addRenderSubmitMs` / `addRenderGpu` / `addRenderStall` / +`setRenderTextureMemory` — `laige-sim` cannot depend on +`laige-render` types, ARCH-001, so the render thread feeds the +primitive values). The reports gained the render section: the +`laige-profile render:` text line, the JSON report's `"render"` +object (`gpu_us` is `null` until the first completed GPU +measurement), and the summary line's render fields (`render_gpu_us` +renders `n/a` in the same case). All timing values are wall-clock +diagnostics (ARCH-009). API contract in +[docs/api/profiler.md](../docs/api/profiler.md); tests under +[tests/laige-sim](../tests/laige-sim) (the counter-model tests in the +`profiler` CTest entry). diff --git a/src/laige-sim/include/laige/sim/profiler.h b/src/laige-sim/include/laige/sim/profiler.h index 9fecd5e..60fdb93 100644 --- a/src/laige-sim/include/laige/sim/profiler.h +++ b/src/laige-sim/include/laige/sim/profiler.h @@ -44,6 +44,16 @@ // (always 0 in headless M1 — the fields exist per FR-11.1; the // render and network subsystems arrive in M2/M3 and feed them // through addDrawCalls / addTextureBinds / addNetBytes) +// - render CPU-phase times (batch / sort / submit, ms) and the +// render timing totals (M2-PROF-01): fed by the render thread's +// submit stage from the SpriteBatcher's frame timing and the +// SpriteRenderer's frame stats through addRenderBatchMs / +// addRenderSortMs / addRenderSubmitMs, the completed GPU +// measurements (addRenderGpu — the GL TIME_ELAPSED pass time, +// one frame late) and the stalled uploads (addRenderStall — the +// implicit upload-sync wait), and the VRAM estimate gauge +// (setRenderTextureMemory — the renderer's textureMemoryBytes). +// All wall-clock diagnostics (ARCH-009) // // The remaining FR-11.1 counters are pulled COLD from their owners — // no duplicated state, one source of truth each: @@ -136,6 +146,22 @@ struct ProfilerStats { std::uint64_t textureBinds{}; // Network bytes (0 in headless M1 — the M3 network feed). std::uint64_t netBytes{}; + // M2-PROF-01 render timing (the render thread's submit-stage feed — + // wall-clock diagnostics, ARCH-009; 0 in headless until the render + // subsystem feeds them). The GPU fields: renderGpuUs/renderGpuFrames + // are the COMPLETED GPU measurements' sum/count (renderGpuFrames 0 = + // the reports render the GPU time as "n/a" — the headless/offscreen + // fallback, never a fake number). + double renderBatchMs{}; // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured batch time never enters sim state, hashes, or replays (M2-PROF-01, ARCH-009) + double renderSortMs{}; // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured sort time never enters sim state, hashes, or replays (M2-PROF-01, ARCH-009) + double renderSubmitMs{}; // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured submit time never enters sim state, hashes, or replays (M2-PROF-01, ARCH-009) + std::uint64_t renderGpuUs{}; + std::uint64_t renderGpuFrames{}; + std::uint64_t renderStallUs{}; + std::uint64_t renderStallFrames{}; + // The VRAM estimate (the renderer's bound-atlas w*h*4 sum — a gauge, + // the last setRenderTextureMemory call wins). + std::uint64_t renderTextureMemoryBytes{}; // The tick-time window stats (ms; NaN when n == 0). HistogramStats tickTimeMs{}; // The frame-time window stats (ms; NaN when n == 0). @@ -215,6 +241,22 @@ class Profiler { void addTextureBinds(std::uint64_t count) noexcept; void addNetBytes(std::uint64_t count) noexcept; + // The M2-PROF-01 render-timing feeds (RENDER-005: CPU and GPU + // measured independently — the render thread feeds the CPU phase + // times from the batcher's frame timing + the submit's wall time, + // and the GPU time from the completed GL TIME_ELAPSED measurement; + // the stall feeds carry the implicit upload-sync wait; the VRAM + // estimate is a gauge, the last value wins). Primitive-typed on + // purpose (laige-sim cannot depend on laige-render types — + // ARCH-001). A no-op when disabled or stopped. + // @budget O(1); no allocation. + void addRenderBatchMs(double ms) noexcept; // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured batch time never enters sim state, hashes, or replays (M2-PROF-01, ARCH-009) + void addRenderSortMs(double ms) noexcept; // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured sort time never enters sim state, hashes, or replays (M2-PROF-01, ARCH-009) + void addRenderSubmitMs(double ms) noexcept; // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured submit time never enters sim state, hashes, or replays (M2-PROF-01, ARCH-009) + void addRenderGpu(std::uint64_t us) noexcept; + void addRenderStall(std::uint64_t us) noexcept; + void setRenderTextureMemory(std::uint64_t bytes) noexcept; + // The tick-time stats over the stored window (the M0-CORE-08 // stats() — cold path). NaN when the window is empty (check n). // @budget O(n log n) cold path; no allocation. @@ -264,6 +306,19 @@ class Profiler { std::uint64_t drawCalls_{}; std::uint64_t textureBinds_{}; std::uint64_t netBytes_{}; + // The M2-PROF-01 render-timing counters (the feed methods' storage — + // wall-clock diagnostics, ARCH-009). The GPU pair is the completed + // measurements' sum + count (frames 0 = "n/a" in the reports); the + // stall pair is the cumulative wait + stalled-frame count; the + // texture memory is a gauge (the last set wins). + double renderBatchMs_{}; // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured batch time never enters sim state, hashes, or replays (M2-PROF-01, ARCH-009) + double renderSortMs_{}; // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured sort time never enters sim state, hashes, or replays (M2-PROF-01, ARCH-009) + double renderSubmitMs_{}; // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured submit time never enters sim state, hashes, or replays (M2-PROF-01, ARCH-009) + std::uint64_t renderGpuUs_{}; + std::uint64_t renderGpuFrames_{}; + std::uint64_t renderStallUs_{}; + std::uint64_t renderStallFrames_{}; + std::uint64_t renderTextureMemoryBytes_{}; bool enabled_{}; // Cleared on move-out: a stopped profiler records nothing. bool active_{true}; diff --git a/src/laige-sim/profiler.cpp b/src/laige-sim/profiler.cpp index 93fbe9c..1aa9155 100644 --- a/src/laige-sim/profiler.cpp +++ b/src/laige-sim/profiler.cpp @@ -113,6 +113,14 @@ Profiler::Profiler(Profiler&& other) noexcept drawCalls_(other.drawCalls_), textureBinds_(other.textureBinds_), netBytes_(other.netBytes_), + renderBatchMs_(other.renderBatchMs_), + renderSortMs_(other.renderSortMs_), + renderSubmitMs_(other.renderSubmitMs_), + renderGpuUs_(other.renderGpuUs_), + renderGpuFrames_(other.renderGpuFrames_), + renderStallUs_(other.renderStallUs_), + renderStallFrames_(other.renderStallFrames_), + renderTextureMemoryBytes_(other.renderTextureMemoryBytes_), enabled_(other.enabled_), active_(other.active_) { other.active_ = false; // the source becomes a stopped profiler @@ -125,6 +133,14 @@ Profiler& Profiler::operator=(Profiler&& other) noexcept { drawCalls_ = other.drawCalls_; textureBinds_ = other.textureBinds_; netBytes_ = other.netBytes_; + renderBatchMs_ = other.renderBatchMs_; + renderSortMs_ = other.renderSortMs_; + renderSubmitMs_ = other.renderSubmitMs_; + renderGpuUs_ = other.renderGpuUs_; + renderGpuFrames_ = other.renderGpuFrames_; + renderStallUs_ = other.renderStallUs_; + renderStallFrames_ = other.renderStallFrames_; + renderTextureMemoryBytes_ = other.renderTextureMemoryBytes_; enabled_ = other.enabled_; active_ = other.active_; other.active_ = false; // the source becomes a stopped profiler @@ -157,6 +173,38 @@ void Profiler::addNetBytes(std::uint64_t count) noexcept { netBytes_ += count; } +void Profiler::addRenderBatchMs(double ms) noexcept { // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured batch time never enters sim state, hashes, or replays (M2-PROF-01, ARCH-009) + if (!active_ || !enabled_) return; + renderBatchMs_ += ms; +} + +void Profiler::addRenderSortMs(double ms) noexcept { // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured sort time never enters sim state, hashes, or replays (M2-PROF-01, ARCH-009) + if (!active_ || !enabled_) return; + renderSortMs_ += ms; +} + +void Profiler::addRenderSubmitMs(double ms) noexcept { // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured submit time never enters sim state, hashes, or replays (M2-PROF-01, ARCH-009) + if (!active_ || !enabled_) return; + renderSubmitMs_ += ms; +} + +void Profiler::addRenderGpu(std::uint64_t us) noexcept { + if (!active_ || !enabled_) return; + renderGpuUs_ += us; + renderGpuFrames_ += 1; +} + +void Profiler::addRenderStall(std::uint64_t us) noexcept { + if (!active_ || !enabled_) return; + renderStallUs_ += us; + renderStallFrames_ += 1; +} + +void Profiler::setRenderTextureMemory(std::uint64_t bytes) noexcept { + if (!active_ || !enabled_) return; + renderTextureMemoryBytes_ = bytes; // a gauge: the last value wins +} + HistogramStats Profiler::tickTime() const noexcept { return tickWindow_.stats(); } @@ -178,6 +226,14 @@ ProfilerStats Profiler::snapshot() const noexcept { s.drawCalls = drawCalls_; s.textureBinds = textureBinds_; s.netBytes = netBytes_; + s.renderBatchMs = renderBatchMs_; + s.renderSortMs = renderSortMs_; + s.renderSubmitMs = renderSubmitMs_; + s.renderGpuUs = renderGpuUs_; + s.renderGpuFrames = renderGpuFrames_; + s.renderStallUs = renderStallUs_; + s.renderStallFrames = renderStallFrames_; + s.renderTextureMemoryBytes = renderTextureMemoryBytes_; s.tickTimeMs = tickWindow_.stats(); s.frameTimeMs = frameWindow_.stats(); return s; @@ -221,6 +277,24 @@ std::string formatProfileSummaryLine(const ProfilerStats& stats) { appendCount(r, "draw_calls=", stats.drawCalls); appendCount(r, "texture_binds=", stats.textureBinds); appendCount(r, "net_bytes=", stats.netBytes); + // The M2-PROF-01 render-timing fields (RENDER-005). The GPU time + // renders "n/a" when no GPU measurement has completed (the + // headless/offscreen fallback — never a fake number). + r += "render_batch_ms="; + formatDouble(r, stats.renderBatchMs); + r += " render_sort_ms="; + formatDouble(r, stats.renderSortMs); + r += " render_submit_ms="; + formatDouble(r, stats.renderSubmitMs); + r += " render_gpu_us="; + if (stats.renderGpuFrames == 0) { + r += "n/a"; + } else { + r += std::to_string(stats.renderGpuUs); + } + r += " render_stall_frames="; + r += std::to_string(stats.renderStallFrames); + appendCount(r, "render_texture_memory_bytes=", stats.renderTextureMemoryBytes); r.pop_back(); // the trailing space after the last field return r; } @@ -250,6 +324,31 @@ std::string formatProfileText(const Profiler& profiler, const World& world) { appendCount(r, "systems=", stats.systems); r.pop_back(); r += "\n"; + // The M2-PROF-01 render-timing section (RENDER-005: CPU and GPU + // measured independently). The GPU time renders "n/a" when no GPU + // measurement has completed (the headless/offscreen fallback). + r += "laige-profile render: "; + r += "batch_ms="; + formatDouble(r, stats.renderBatchMs); + r += " sort_ms="; + formatDouble(r, stats.renderSortMs); + r += " submit_ms="; + formatDouble(r, stats.renderSubmitMs); + r += " gpu_us="; + if (stats.renderGpuFrames == 0) { + r += "n/a"; + } else { + r += std::to_string(stats.renderGpuUs); + } + r += " gpu_frames="; + r += std::to_string(stats.renderGpuFrames); + r += " stall_us="; + r += std::to_string(stats.renderStallUs); + r += " stall_frames="; + r += std::to_string(stats.renderStallFrames); + r += " texture_memory_bytes="; + r += std::to_string(stats.renderTextureMemoryBytes); + r += "\n"; // One line per registered system (M1-SYS-03 feed): the declared // budget, the run scalars, and the rolling window's stats. for (std::uint32_t i = 1; i <= stats.systems; ++i) { @@ -320,6 +419,34 @@ std::string formatProfileJson(const Profiler& profiler, const World& world) { worldObj.setMember("systems", JsonValue::fromNumber(stats.systems)); root.setMember("world", std::move(worldObj)); + // The M2-PROF-01 render-timing object (RENDER-005). gpu_us is JSON + // null when no GPU measurement has completed (the headless/offscreen + // fallback — never a fake number). + JsonValue renderObj = JsonValue::makeObject(); + renderObj.setMember("batch_ms", JsonValue::fromNumber(stats.renderBatchMs)); + renderObj.setMember("sort_ms", JsonValue::fromNumber(stats.renderSortMs)); + renderObj.setMember("submit_ms", JsonValue::fromNumber(stats.renderSubmitMs)); + if (stats.renderGpuFrames == 0) { + renderObj.setMember("gpu_us", JsonValue()); // Null = "n/a" + } else { + renderObj.setMember("gpu_us", + JsonValue::fromNumber( + static_cast(stats.renderGpuUs))); // LAIGE-DETERM-EXCEPTION: G-R8 report field: u64 GPU time to JSON number (M2-PROF-01, ARCH-009) + } + renderObj.setMember("gpu_frames", + JsonValue::fromNumber( + static_cast(stats.renderGpuFrames))); // LAIGE-DETERM-EXCEPTION: G-R8 report field: u64 counter to JSON number (M2-PROF-01, ARCH-009) + renderObj.setMember("stall_us", + JsonValue::fromNumber( + static_cast(stats.renderStallUs))); // LAIGE-DETERM-EXCEPTION: G-R8 report field: u64 stall wait to JSON number (M2-PROF-01, ARCH-009) + renderObj.setMember("stall_frames", + JsonValue::fromNumber( + static_cast(stats.renderStallFrames))); // LAIGE-DETERM-EXCEPTION: G-R8 report field: u64 counter to JSON number (M2-PROF-01, ARCH-009) + renderObj.setMember("texture_memory_bytes", + JsonValue::fromNumber( + static_cast(stats.renderTextureMemoryBytes))); // LAIGE-DETERM-EXCEPTION: G-R8 report field: u64 VRAM estimate to JSON number (M2-PROF-01, ARCH-009) + root.setMember("render", std::move(renderObj)); + JsonValue systems = JsonValue::makeArray(); for (std::uint32_t i = 1; i <= stats.systems; ++i) { const SystemId id{i}; diff --git a/tests/laige-render/CMakeLists.txt b/tests/laige-render/CMakeLists.txt index 9a57393..e917c3d 100644 --- a/tests/laige-render/CMakeLists.txt +++ b/tests/laige-render/CMakeLists.txt @@ -233,7 +233,29 @@ # 30-draw-call budget), the 2-widget UI pass (the quads, the one # group, the two depth overrides), and the 1 000-frame zero- # allocation declare loop) — pure data + batcher bookkeeping: no GL -# environment needed. +# environment needed; and the CPU/GPU render timing (M2-PROF-01, +# RENDER-005) — the per-pass CPU phase times from the render-thread +# timers (the batcher's batch/sort phases + the submit's CPU wall +# time), the opt-in per-pass GPU time via the GL TIME_ELAPSED query +# object (one frame late, "n/a" when unavailable — never a fake +# number), the implicit upload-sync stall detection (the CPU waiting +# on the GPU's read of the reused instance buffer, one rate-limited +# Warn per stalled frame), and the profiler feed (render_timing_tests.cpp's +# RenderTiming* suites: the stallThresholdUs create validation +# (first failure wins, one Warn) + the stopped state reads zero (no +# GL), the small scene's CPU phase times with the EXACT since- +# construction totals + the empty frame recording nothing + the +# profiler feed of the render values, the GPU timing's off-by-default +# n/a + the one-frame-late semantics under glFinish-paced frames, the +# stall detection (a 4096-sprite frame under a 1 us threshold stalls +# — one Warn per stalled frame with the pinned fields; a small frame +# under the default threshold does not), and the roadmap's +# integration check (a deliberately slow shader — a 16384-iteration +# dependent sin loop — makes the GPU-side pass time exceed the +# CPU-side submit time on a hardware-GL runner, SKIPPED on software +# renderers where the CPU is the GPU and the driver is synchronous)) +# — 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 @@ -264,11 +286,13 @@ # particle_render`), the `font` entry is the M2-TEXT-01 Verify command # (`ctest -R font`), the `ui_tree` entry is the M2-UI-01 Verify # command (`ctest -R ui_tree`), and the `ui_render` entry is the -# M2-UI-02 Verify command (`ctest -R ui_render`), and the `scene_iso` -# entry is the M2-SCENE-01 Verify command (`ctest -R scene_iso`), each -# selecting exactly its suites from the shared executable (the CLI's -# same-seed byte-identity gate is the separate `scene_iso_cli` entry -# in tools/scene-iso). (The unanchored `ctest -R tilemap` regex also +# M2-UI-02 Verify command (`ctest -R ui_render`), the `scene_iso` +# entry is the M2-SCENE-01 Verify command (`ctest -R scene_iso`), and +# the `render_timing` entry is the M2-PROF-01 Verify command +# (`ctest -R render_timing`), each selecting exactly its suites from +# the shared executable (the CLI's same-seed byte-identity gate is +# the separate `scene_iso_cli` entry in tools/scene-iso). (The +# unanchored `ctest -R tilemap` regex also # selects the `tilemap_anim` entry — the M2-TILE-02 suites run under # the M2-TILE-01 command too; that is intended. Likewise the # unanchored `ctest -R particle` regex selects the M2-PART-01 @@ -276,16 +300,19 @@ # steps' suites running under one command is intended.) # # 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/TileMap*/ -# TileMapAnim*/Parallax*/Font* suites always run (they make no GL -# calls). +# SpriteDraw{State,Smoke,Pipeline}, RenderCounters{Scene,Cap,Memory}, +# and RenderTiming{Cpu,Gpu,Stall,SlowShader} 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 (RenderTimingSlowShader additionally SKIPS on a software +# renderer — its GPU>CPU observation is defined for the hardware-GL +# desktop runner). 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/ +# RenderTimingCreate/TileMap*/TileMapAnim*/Parallax*/Font* suites +# always run (they make no GL calls). add_executable(laige-render_tests gl_context_tests.cpp render_thread_tests.cpp matrices_tests.cpp @@ -295,13 +322,19 @@ add_executable(laige-render_tests sprite_frames_tests.cpp render_counters_tests.cpp tilemap_tests.cpp tilemap_anim_tests.cpp parallax_tests.cpp particle_render_tests.cpp font_tests.cpp text_items_tests.cpp font_sdf_tests.cpp ui_tree_tests.cpp - ui_render_tests.cpp scene_iso_tests.cpp) + ui_render_tests.cpp scene_iso_tests.cpp render_timing_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 # (docs/testing.md §4; tests/support/laige_test_seed.h). target_include_directories(laige-render_tests PRIVATE ${CMAKE_SOURCE_DIR}/tests/support) +# M2-PROF-01: the slow-shader fixture drives raw GL (the GL_TIME_ELAPSED +# query object around a deliberately slow fragment shader) — the +# vendored GLAD loader's headers (laige-render's PRIVATE include, +# the module boundary keeps the public headers vendor-free). +target_include_directories(laige-render_tests PRIVATE + ${CMAKE_SOURCE_DIR}/deps/glad/include) add_test(NAME laige-render_tests COMMAND laige-render_tests) # The unfiltered entry also runs the M2-ISO-02 budget suite (in the @@ -564,6 +597,17 @@ set_tests_properties(ui_render PROPERTIES add_test(NAME scene_iso COMMAND laige-render_tests --gtest_filter=IsoReferenceScene*) +# M2-PROF-01: the step's Verify command is `ctest -R render_timing`. +# RenderTimingCreate runs without any GL environment; +# RenderTiming{Cpu,Gpu,Stall,SlowShader} need the same usable OpenGL +# 3.3 environment as the RenderCounters{Scene,Cap,Memory} suites +# (GTEST_SKIPs where absent — the documented environment contract). +# The 4096-sprite stall frame and the slow-shader fixture are the +# entry's heaviest work. No budget gate: the step's roadmap scope has +# no standalone budgets.json entry. +add_test(NAME render_timing COMMAND laige-render_tests + --gtest_filter=RenderTiming*) + 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) @@ -598,6 +642,10 @@ if(LAIGE_TSAN) # 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 `render_timing` entry is a fourth: its timing fields 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. @@ -605,7 +653,8 @@ if(LAIGE_TSAN) iso_depth_key iso_depth_table camera iso_camera projection iso_picking depth_sort batcher sprite_draw sprite_frames render_counters tilemap tilemap_anim parallax particle_render font - text_items font_sdf ui_tree ui_render scene_iso PROPERTIES + text_items font_sdf ui_tree ui_render scene_iso render_timing + PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1;LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json;LAIGE_TEST_FONT_PATH=${CMAKE_SOURCE_DIR}/tests/laige-render/assets/vera.ttf") endif() @@ -628,8 +677,9 @@ if(LAIGE_ASAN) # GlContextSmoke, render_thread's RenderThreadOffscreen, sprite_draw's # SpriteDraw{State,Smoke,Pipeline}, render_counters's # RenderCounters{Scene,Cap,Memory}, text_items's TextSmoke, - # font_sdf's FontSdfSmoke, and ui_render's UiRenderSmoke suites - # create the same headless context): + # font_sdf's FontSdfSmoke, ui_render's UiRenderSmoke, and the + # M2-PROF-01 render_timing entry's GL suites create the same + # headless context): # in-run ASan/UBSan error detection stays fully active (the smoke # still performs its GL work under the sanitizer). The font entry is # on the list for the same reason since M2-TEXT-03: its `Font*` filter @@ -644,7 +694,8 @@ if(LAIGE_ASAN) # font_sdf), so the replacement environment carries the test-font # path as well (harmless on the entries that do not use it). set_tests_properties(laige-render_tests gl_context render_thread - sprite_draw render_counters text_items font font_sdf ui_render PROPERTIES + sprite_draw render_counters text_items font font_sdf ui_render + render_timing PROPERTIES ENVIRONMENT "ASAN_OPTIONS=abort_on_error=1:halt_on_error=1:detect_leaks=0:log_path=${CMAKE_SOURCE_DIR}/asan-reports/asan;LAIGE_TEST_FONT_PATH=${CMAKE_SOURCE_DIR}/tests/laige-render/assets/vera.ttf") endif() @@ -717,3 +768,9 @@ set_tests_properties(ui_render PROPERTIES TIMEOUT 120) # particles per declare) and runs the 1 000-frame zero-allocation loop # (small variant) — 120s, like the same-shaped entries. set_tests_properties(scene_iso PROPERTIES TIMEOUT 120) +# The `render_timing` entry renders a few 10-sprite frames, two +# 4096-sprite stall frames, and one slow-shader fixture (the +# 16384-iteration dependent sin loop over a 128x128 FBO) — generous +# headroom for a slow headless runner, like the `render_counters` +# entry. +set_tests_properties(render_timing PROPERTIES TIMEOUT 120) diff --git a/tests/laige-render/render_timing_tests.cpp b/tests/laige-render/render_timing_tests.cpp new file mode 100644 index 0000000..3524f22 --- /dev/null +++ b/tests/laige-render/render_timing_tests.cpp @@ -0,0 +1,770 @@ +// laige-render tests (M2-PROF-01): CPU/GPU render timing (RENDER-005) — +// the per-pass CPU phase times from the render-thread timers (batch, +// sort, submit), the opt-in per-pass GPU time via the GL TIME_ELAPSED +// query object (one frame late; "n/a" when unavailable — never a fake +// number), the implicit upload-sync stall detection (one rate-limited +// Warn per stalled frame), and the profiler feed of the render timing. +// +// Suite map (the `render_timing` CTest entry selects exactly these): +// RenderTimingCreate the stallThresholdUs create validation (first +// failure wins, one Warn) + the stopped state +// reads zero — no GL required (the validation +// precedes the context check); +// RenderTimingCpu the batch/sort/submit CPU phase times on the +// known small scene (10 sprites, 2 atlases, 2 +// blends -> 3 groups): the per-frame values are +// non-negative, the since-construction totals +// accumulate EXACTLY, the empty frame records +// nothing, and the profiler feed of the render +// values accumulates — requires a usable +// OpenGL 3.3 environment (GTEST_SKIPs on an +// environment failure); +// RenderTimingGpu the opt-in GL TIME_ELAPSED pass time: off by +// default (n/a, no GL work), on: the +// one-frame-late semantics (the previous pass's +// value is read on the next submit) pinned under +// glFinish-paced frames — GL required; +// RenderTimingStall the implicit upload-sync stall detection: a +// 4096-sprite frame under a 1 us threshold +// stalls (one Warn per stalled frame, the pinned +// fields), and a small frame under the default +// threshold does not — GL required; +// RenderTimingSlowShader the roadmap's integration check: with a +// deliberately slow shader (a 16384-iteration +// dependent sin loop), the GPU-side pass time +// EXCEEDS the CPU-side submit time on a +// hardware-GL runner (GL required; SKIPS on a +// software renderer — there the CPU is the GPU +// and the driver is synchronous, so the +// observation is undemonstrable). +// +// 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 (the render_counters_tests 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 + +#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" +#include "laige/sim/profiler.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::SpriteBatchTiming; +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). +constexpr std::int32_t kWidth = 128; +constexpr std::int32_t kHeight = 128; +// The per-instance upload (the M2-SPRITE-02 instance layout, 13 floats). +constexpr std::uint64_t kInstanceBytes = 52u; + +// The test phase clock (steady_clock ns since epoch — the same clock +// the engine's timing fields use). +std::int64_t nowNs() { + return std::chrono::steady_clock::now().time_since_epoch().count(); +} + +// 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). +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 known small scene (10 sprites, 2 atlases, 2 blends -> 3 groups, +// the render_counters_tests scene). +std::vector sceneItems() { + std::vector items; + items.reserve(10); + 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)); + 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)); + 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 4096-sprite single-group scene of the stall tests (one atlas, +// one blend: one instanced draw, 4096 * 52 B per upload). +std::vector largeSceneItems() { + std::vector items; + items.reserve(4096); + for (std::uint32_t i = 0; i < 4096; ++i) { + items.push_back(makeSprite(static_cast(i % 64), + static_cast(i / 64), + /*atlasId=*/0, BlendMode::Alpha)); + } + 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). +void makeScene(const GlContext& ctx, SpriteRenderer& renderer, + SpriteBatcher& batcher, Mat4& matrix, + const SpriteRenderer::Options& ro, std::uint32_t maxSprites) { + auto r = SpriteRenderer::create(ctx, ro); + if (r.isError()) { + ADD_FAILURE() << "SpriteRenderer::create failed: " + << laige::errorText(r.error()); + abort(); + } + renderer = std::move(r).takeValue(); + SpriteBatcher::Options bo; + bo.maxSprites = maxSprites; + 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 render_counters_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 slow shader of the RenderTimingSlowShader fixture (RENDER-005): +// a 16384-iteration DEPENDENT sin loop per fragment — the GPU does the +// work asynchronously (the CPU-side draw call just queues it). +constexpr const char* kPassVertex = + "#version 330 core\n" + "layout(location = 0) in vec2 aPos;\n" + "void main() { gl_Position = vec4(aPos, 0.0, 1.0); }\n"; +constexpr const char* kSlowFragment = + "#version 330 core\n" + "out vec4 outColor;\n" + "void main() {\n" + " float v = 1.0;\n" + " for (int i = 0; i < 16384; ++i) {\n" + " v = sin(v * 0.01);\n" + " }\n" + " outColor = vec4(v, v * 0.5, 0.0, 1.0);\n" + "}\n"; + +} // namespace + +// ------------------------------------------------------------------------ +// RenderTimingCreate — the stallThresholdUs create validation + the +// stopped state (no GL required: the options are validated before the +// context check, the stopped state makes no GL calls). +// ------------------------------------------------------------------------ + +TEST(RenderTimingCreate, StallThresholdValidation) { + GlContext stopped; // the stopped state — no GL environment needed + MemorySink* sink = installCaptureSink(); + + SpriteRenderer::Options o; + o.maxInstances = 16; + o.maxAtlases = 2; + o.stallThresholdUs = 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"), + "stallThresholdUs"); + EXPECT_EQ(fieldOf(*lastEvent(*sink, "options_invalid"), "value"), "0"); + + // The documented default is valid (the domain's lower bound is 1; + // the default is 2000 us) — the create reaches the context check, so + // the stopped context fails GlUnavailable, not InvalidArgument. + o.stallThresholdUs = SpriteRenderer::kSpriteRendererDefaultStallUs; + 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 stallThresholdUs invalid -> + // one warn, the FIRST option (maxInstances precedes stallThresholdUs). + o.maxInstances = 0; + o.stallThresholdUs = 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(RenderTimingCreate, StoppedStateReadsZero) { + SpriteRenderer r; + EXPECT_FALSE(r.valid()); + const SpriteDrawStats s = r.frameStats(); + EXPECT_DOUBLE_EQ(s.submitMs, 0.0); + EXPECT_EQ(s.uploadWallUs, 0u); + EXPECT_FALSE(s.uploadStalled); + EXPECT_EQ(s.gpuTimeUs, 0u); + EXPECT_FALSE(s.gpuTimeValid); + const SpriteDrawTotals t = r.totals(); + EXPECT_DOUBLE_EQ(t.submitMs, 0.0); + EXPECT_EQ(t.uploadStallUs, 0u); + EXPECT_EQ(t.uploadStallFrames, 0u); + EXPECT_EQ(t.gpuTimeUs, 0u); + EXPECT_EQ(t.gpuTimeFrames, 0u); + + // The batcher's stopped state: the timing fields read zero (a + // stopped batcher's empty-frame build records no timing). + SpriteBatcher b; + const SpriteBatchTiming bt = b.frameTiming(); + EXPECT_DOUBLE_EQ(bt.batchMs, 0.0); + EXPECT_DOUBLE_EQ(bt.sortMs, 0.0); + const auto tt = b.timingTotals(); + EXPECT_EQ(tt.frames, 0u); + EXPECT_DOUBLE_EQ(tt.batchMs, 0.0); + EXPECT_DOUBLE_EQ(tt.sortMs, 0.0); +} + +// ------------------------------------------------------------------------ +// RenderTimingCpu — the batch/sort/submit CPU phase times on the known +// small scene (GL required — GTEST_SKIPs on an environment failure). +// ------------------------------------------------------------------------ + +TEST(RenderTimingCpu, SmallScenePhaseTimes) { + 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{}; + const SpriteRenderer::Options ro{}; // defaults: gpuTiming off + makeScene(ctx, renderer, batcher, matrix, ro, /*maxSprites=*/16); + + const std::vector items = sceneItems(); + // Frame 1. + declareFrame(batcher, items); + EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); + const SpriteBatchTiming bt1 = batcher.frameTiming(); + const SpriteDrawStats s1 = renderer.frameStats(); + EXPECT_GE(bt1.batchMs, 0.0); + EXPECT_GE(bt1.sortMs, 0.0); + EXPECT_GT(s1.submitMs, 0.0); // the submit's GL work takes time + EXPECT_GE(s1.uploadWallUs, 0u); + EXPECT_FALSE(s1.uploadStalled); // the default 2000 us threshold + + // Frame 2 (the since-construction totals accumulate EXACTLY). + declareFrame(batcher, items); + EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); + const SpriteBatchTiming bt2 = batcher.frameTiming(); + const SpriteDrawStats s2 = renderer.frameStats(); + const SpriteDrawTotals t = renderer.totals(); + EXPECT_EQ(t.frames, 2u); + EXPECT_DOUBLE_EQ(t.submitMs, s1.submitMs + s2.submitMs); + EXPECT_EQ(t.uploadStallUs, 0u); + EXPECT_EQ(t.uploadStallFrames, 0u); + EXPECT_EQ(t.gpuTimeUs, 0u); // the gpuTiming option is off (n/a) + EXPECT_EQ(t.gpuTimeFrames, 0u); + const auto tt = batcher.timingTotals(); + EXPECT_EQ(tt.frames, 2u); + EXPECT_DOUBLE_EQ(tt.batchMs, bt1.batchMs + bt2.batchMs); + EXPECT_DOUBLE_EQ(tt.sortMs, bt1.sortMs + bt2.sortMs); + + // The profiler feed of the render values (the engine wiring, M2). + laige::Profiler prof(laige::Profiler::Options{}); + prof.addRenderBatchMs(bt1.batchMs); + prof.addRenderSortMs(bt1.sortMs); + prof.addRenderSubmitMs(s1.submitMs); + prof.setRenderTextureMemory(renderer.textureMemoryBytes()); + prof.addRenderBatchMs(bt2.batchMs); + prof.addRenderSortMs(bt2.sortMs); + prof.addRenderSubmitMs(s2.submitMs); + prof.setRenderTextureMemory(renderer.textureMemoryBytes()); + const laige::ProfilerStats ps = prof.snapshot(); + EXPECT_DOUBLE_EQ(ps.renderBatchMs, bt1.batchMs + bt2.batchMs); + EXPECT_DOUBLE_EQ(ps.renderSortMs, bt1.sortMs + bt2.sortMs); + EXPECT_DOUBLE_EQ(ps.renderSubmitMs, s1.submitMs + s2.submitMs); + EXPECT_EQ(ps.renderGpuFrames, 0u); // no GPU measurement completed (n/a) + EXPECT_EQ(ps.renderTextureMemoryBytes, renderer.textureMemoryBytes()); + + // The built empty frame: the frame stats record nothing (all new + // fields zero), the renderer's timing totals are unchanged (only + // the frame count moves), and the batcher's build still records its + // (trivial) phase times. + declareFrame(batcher, {}); + EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); + const SpriteDrawStats s3 = renderer.frameStats(); + EXPECT_DOUBLE_EQ(s3.submitMs, 0.0); + EXPECT_EQ(s3.uploadWallUs, 0u); + EXPECT_FALSE(s3.uploadStalled); + EXPECT_EQ(s3.gpuTimeUs, 0u); + EXPECT_FALSE(s3.gpuTimeValid); + const SpriteDrawTotals t3 = renderer.totals(); + EXPECT_EQ(t3.frames, 3u); + EXPECT_DOUBLE_EQ(t3.submitMs, s1.submitMs + s2.submitMs); + EXPECT_EQ(batcher.timingTotals().frames, 3u); +} + +// ------------------------------------------------------------------------ +// RenderTimingGpu — the opt-in GL TIME_ELAPSED pass time (GL required). +// ------------------------------------------------------------------------ + +TEST(RenderTimingGpu, OffByDefaultIsNA) { + 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{}; + const SpriteRenderer::Options ro{}; // gpuTiming off (the default) + makeScene(ctx, renderer, batcher, matrix, ro, /*maxSprites=*/16); + + const std::vector items = sceneItems(); + for (int f = 0; f < 2; ++f) { + declareFrame(batcher, items); + EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); + const SpriteDrawStats s = renderer.frameStats(); + EXPECT_FALSE(s.gpuTimeValid) << "frame " << (f + 1); // n/a, no GL work + EXPECT_EQ(s.gpuTimeUs, 0u) << "frame " << (f + 1); + } + const SpriteDrawTotals t = renderer.totals(); + EXPECT_EQ(t.gpuTimeFrames, 0u); // no completed measurement + EXPECT_EQ(t.gpuTimeUs, 0u); +} + +TEST(RenderTimingGpu, OneFrameLateMeasurements) { + 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{}; + SpriteRenderer::Options ro{}; + ro.gpuTiming = true; + makeScene(ctx, renderer, batcher, matrix, ro, /*maxSprites=*/16); + + const std::vector items = sceneItems(); + // The frame-pipeline pattern: the render target is cleared before + // the first query-enabled FBO draw (the llvmpipe lazy pipe + // initialization race — the RenderCountersScene pattern; the clear + // touches no sprite-pass state). + EXPECT_TRUE(ctx.clear(0, 0, 0, 0).ok()); + + // Frame 1: its measurement is BEGUN (nothing to read yet — the + // first frame is always n/a). + declareFrame(batcher, items); + EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); + const SpriteDrawStats s1 = renderer.frameStats(); + EXPECT_FALSE(s1.gpuTimeValid); + EXPECT_EQ(s1.gpuTimeUs, 0u); + + // glFinish: the GPU is idle, so frame 1's measurement is complete — + // frame 2's submit reads it (the one-frame-late semantics) and + // begins frame 2's measurement. + glFinish(); + declareFrame(batcher, items); + EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); + const SpriteDrawStats s2 = renderer.frameStats(); + EXPECT_TRUE(s2.gpuTimeValid); + EXPECT_GT(s2.gpuTimeUs, 0u); // the pass's GL work took GPU time + const std::uint64_t m1 = s2.gpuTimeUs; + + // Same for frame 2 -> frame 3. + glFinish(); + declareFrame(batcher, items); + EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); + const SpriteDrawStats s3 = renderer.frameStats(); + EXPECT_TRUE(s3.gpuTimeValid); + EXPECT_GT(s3.gpuTimeUs, 0u); + const std::uint64_t m2 = s3.gpuTimeUs; + + // The since-construction totals: the two COMPLETED measurements + // (frame 1's value read on frame 2, frame 2's on frame 3). + const SpriteDrawTotals t = renderer.totals(); + EXPECT_EQ(t.gpuTimeFrames, 2u); + EXPECT_EQ(t.gpuTimeUs, m1 + m2); +} + +// ------------------------------------------------------------------------ +// RenderTimingStall — the implicit upload-sync stall detection (GL +// required). +// ------------------------------------------------------------------------ + +TEST(RenderTimingStall, LargeFrameUploadStalls) { + 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{}; + SpriteRenderer::Options ro{}; + ro.maxInstances = 8192; + ro.stallThresholdUs = 1; // an artificial 1 us threshold + makeScene(ctx, renderer, batcher, matrix, ro, /*maxSprites=*/8192); + + // The 4096-sprite frame: 4096 * 52 B = 212 992 B per upload. The + // upload's CPU wall time exceeds the 1 us threshold on every P0 + // runner (the CPU-side copy of 212 KB alone is tens of us), so both + // frames stall — one Warn each (the capture sink has rate limiting + // OFF) + the pinned fields. + const std::vector items = largeSceneItems(); + const std::uint64_t uploadBytes = 4096u * kInstanceBytes; + + declareFrame(batcher, items); + EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); + const SpriteDrawStats s1 = renderer.frameStats(); + EXPECT_TRUE(s1.uploadStalled); + EXPECT_GT(s1.uploadWallUs, ro.stallThresholdUs); + EXPECT_EQ(s1.uploadBytes, uploadBytes); + + declareFrame(batcher, items); + EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); + const SpriteDrawStats s2 = renderer.frameStats(); + EXPECT_TRUE(s2.uploadStalled); + EXPECT_GT(s2.uploadWallUs, ro.stallThresholdUs); + EXPECT_EQ(s2.uploadBytes, uploadBytes); + + EXPECT_EQ(countEvents(*sink, "sprite_renderer", "submit_stall"), 2u); + const MemorySink::Entry* e = lastEvent(*sink, "submit_stall"); + ASSERT_NE(e, nullptr); + EXPECT_EQ(e->severity, laige::log::Severity::Warn); + EXPECT_EQ(fieldOf(*e, "stall_us"), std::to_string(s2.uploadWallUs)); + EXPECT_EQ(fieldOf(*e, "upload_bytes"), std::to_string(uploadBytes)); + + // The since-construction stall totals (successful submits only). + const SpriteDrawTotals t = renderer.totals(); + EXPECT_EQ(t.uploadStallFrames, 2u); + EXPECT_EQ(t.uploadStallUs, s1.uploadWallUs + s2.uploadWallUs); +} + +TEST(RenderTimingStall, SmallFrameDoesNotStall) { + 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{}; + const SpriteRenderer::Options ro{}; // the default 2000 us threshold + makeScene(ctx, renderer, batcher, matrix, ro, /*maxSprites=*/16); + + // The 10-sprite frame: a 520 B upload — the wall time is far below + // the documented default threshold on every P0 runner. + const std::vector items = sceneItems(); + declareFrame(batcher, items); + EXPECT_TRUE(renderer.submit(batcher, matrix).ok()); + const SpriteDrawStats s = renderer.frameStats(); + EXPECT_EQ(s.uploadBytes, 10u * kInstanceBytes); + EXPECT_LT(s.uploadWallUs, ro.stallThresholdUs); + EXPECT_FALSE(s.uploadStalled); + EXPECT_EQ(countEvents(*sink, "sprite_renderer", "submit_stall"), 0u); + EXPECT_EQ(renderer.totals().uploadStallFrames, 0u); + EXPECT_EQ(renderer.totals().uploadStallUs, 0u); +} + +// ------------------------------------------------------------------------ +// RenderTimingSlowShader — the roadmap's integration check (GL +// required): the deliberately slow shader's GPU-side pass time EXCEEDS +// its CPU-side submit time on a hardware-GL runner. +// ------------------------------------------------------------------------ + +TEST(RenderTimingSlowShader, GpuTimeExceedsCpuTime) { + auto cr = tryContext(); + if (cr.isError()) { + GTEST_SKIP() << "no usable OpenGL 3.3 environment: " + << laige::errorText(cr.error()); + } + GlContext ctx = std::move(cr).takeValue(); + // The roadmap's observation is defined for the desktop CI runner's + // hardware GL (RENDER-005: CPU and GPU measured independently). On a + // software renderer (llvmpipe/swrast) the CPU IS the GPU and the + // driver is synchronous — the draw call's wall time already contains + // the GPU work, so the observation is undemonstrable there. + const char* renderer = ctx.capabilities().renderer; + const char* vendor = ctx.capabilities().vendor; + if (std::strstr(renderer, "llvmpipe") != nullptr || + std::strstr(renderer, "swrast") != nullptr || + std::strstr(vendor, "Mesa") != nullptr) { + GTEST_SKIP() << "software renderer (" << renderer + << "): the GPU>CPU observation requires a hardware-GL " + "runner"; + } + ASSERT_TRUE(ctx.makeCurrent().ok()); + + // The shader (engine-side failure after the environment check: the + // context is a live 3.3 core — a compile failure here is a test bug). + const GLenum kinds[2] = {GL_VERTEX_SHADER, GL_FRAGMENT_SHADER}; + const char* sources[2] = {kPassVertex, kSlowFragment}; + GLuint shaders[2] = {0, 0}; + for (int i = 0; i < 2; ++i) { + shaders[i] = glCreateShader(kinds[i]); + glShaderSource(shaders[i], 1, &sources[i], nullptr); + glCompileShader(shaders[i]); + GLint compiled = 0; + glGetShaderiv(shaders[i], GL_COMPILE_STATUS, &compiled); + if (compiled == 0) { + ADD_FAILURE() << "shader compile failed"; + abort(); + } + } + GLuint program = glCreateProgram(); + glAttachShader(program, shaders[0]); + glAttachShader(program, shaders[1]); + glDeleteShader(shaders[0]); + glDeleteShader(shaders[1]); + glLinkProgram(program); + GLint linked = 0; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + if (linked == 0) { + ADD_FAILURE() << "program link failed"; + abort(); + } + + // The full-screen triangle + its VAO. + const float verts[6] = {-1.0f, -1.0f, 3.0f, -1.0f, -1.0f, 3.0f}; + GLuint vbo = 0; + glGenBuffers(1, &vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferData(GL_ARRAY_BUFFER, static_cast(sizeof(verts)), + verts, GL_STATIC_DRAW); + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), + nullptr); + glEnableVertexAttribArray(0); + + // The render target (the context's offscreen FBO). + glBindFramebuffer(GL_FRAMEBUFFER, ctx.frameBuffer()); + glViewport(0, 0, kWidth, kHeight); + glUseProgram(program); + // The warm-up draw: the program's first use (shader setup) is kept + // out of the measured window. + glDrawArrays(GL_TRIANGLES, 0, 3); + glFinish(); + + // The measured draw: the CPU wall time of the draw call vs the + // GPU-side TIME_ELAPSED interval of the same call. + GLuint query = 0; + glGenQueries(1, &query); + if (query == 0) { + ADD_FAILURE() << "glGenQueries failed"; + abort(); + } + glBeginQuery(GL_TIME_ELAPSED, query); + const std::int64_t cpuStartNs = nowNs(); + glDrawArrays(GL_TRIANGLES, 0, 3); + const std::int64_t cpuEndNs = nowNs(); + // GL 3.3 core's one-argument form: ends the target's pending query + // (the 2-argument form is GL 4.0+). + glEndQuery(GL_TIME_ELAPSED); + glFinish(); // the diagnostic sync: the result is available now + std::uint32_t gpuUs = 0; + GLint available = 0; + glGetQueryObjectiv(query, GL_QUERY_RESULT_AVAILABLE, &available); + if (available != 0) { + glGetQueryObjectuiv(query, GL_QUERY_RESULT, &gpuUs); + } + const std::uint64_t cpuUs = + static_cast((cpuEndNs - cpuStartNs) / 1000); + std::printf("slow-shader gpu_us=%llu cpu_us=%llu\n", + static_cast(gpuUs), + static_cast(cpuUs)); + // The GPU does the slow shader's work asynchronously: the + // measurement interval exceeds the CPU-side draw call's wall time + // (the roadmap's integration check). + EXPECT_GT(gpuUs, cpuUs); + + // Leave no hidden GL state (the pass's program/VAO are its own). + glDeleteQueries(1, &query); + glDeleteVertexArrays(1, &vao); + glDeleteBuffers(1, &vbo); + glDeleteProgram(program); + glUseProgram(0); + glBindVertexArray(0); +} diff --git a/tests/laige-sim/profiler_tests.cpp b/tests/laige-sim/profiler_tests.cpp index 7a345f6..ae0c234 100644 --- a/tests/laige-sim/profiler_tests.cpp +++ b/tests/laige-sim/profiler_tests.cpp @@ -347,6 +347,58 @@ TEST(ProfilerCounters, RenderAndNetworkAddersAccumulate) { EXPECT_EQ(s.netBytes, 10u); } +// The M2-PROF-01 render-timing feeds (RENDER-005): the CPU phase +// times accumulate, the GPU/stall feeds count their completed +// measurements, and the VRAM estimate is a gauge (the last value +// wins). +TEST(ProfilerCounters, RenderTimingAddersAccumulate) { + Profiler p(Profiler::Options{}); + p.addRenderBatchMs(1.5); + p.addRenderBatchMs(1.5); + p.addRenderSortMs(2.5); + p.addRenderSubmitMs(0.7); + p.addRenderGpu(123); + p.addRenderGpu(456); + p.addRenderStall(2500); + p.setRenderTextureMemory(512); + p.setRenderTextureMemory(1024); // the gauge: the last value wins + const ProfilerStats s = p.snapshot(); + EXPECT_DOUBLE_EQ(s.renderBatchMs, 3.0); + EXPECT_DOUBLE_EQ(s.renderSortMs, 2.5); + EXPECT_DOUBLE_EQ(s.renderSubmitMs, 0.7); + EXPECT_EQ(s.renderGpuUs, 579u); + EXPECT_EQ(s.renderGpuFrames, 2u); + EXPECT_EQ(s.renderStallUs, 2500u); + EXPECT_EQ(s.renderStallFrames, 1u); + EXPECT_EQ(s.renderTextureMemoryBytes, 1024u); + // The reports render the GPU time from the completed measurements. + const std::string summary = formatProfileSummaryLine(s); + EXPECT_NE(summary.find("render_batch_ms=3"), std::string::npos); + EXPECT_NE(summary.find("render_sort_ms=2.5"), std::string::npos); + EXPECT_NE(summary.find("render_submit_ms=0.7"), std::string::npos); + EXPECT_NE(summary.find("render_gpu_us=579"), std::string::npos); + EXPECT_NE(summary.find("render_stall_frames=1"), std::string::npos); + EXPECT_NE(summary.find("render_texture_memory_bytes=1024"), + std::string::npos); +} + +// The M2-PROF-01 "n/a" contract: with no completed GPU measurements +// (the headless/offscreen fallback — never a fake number), the +// summary line carries the GPU field as n/a, and the stall fields +// stay zero. +TEST(ProfilerCounters, RenderTimingGpuNAWhenNoMeasurements) { + Profiler p(Profiler::Options{}); + p.addRenderBatchMs(1.5); + const ProfilerStats s = p.snapshot(); + EXPECT_EQ(s.renderGpuUs, 0u); + EXPECT_EQ(s.renderGpuFrames, 0u); + EXPECT_EQ(s.renderStallUs, 0u); + EXPECT_EQ(s.renderStallFrames, 0u); + const std::string summary = formatProfileSummaryLine(s); + EXPECT_NE(summary.find("render_gpu_us=n/a"), std::string::npos); + EXPECT_EQ(summary.find("render_gpu_us=0"), std::string::npos); +} + TEST(ProfilerCounters, DisabledIsANoOpAndPreservesRecordedState) { Profiler p(Profiler::Options{}); p.recordTick(1.0); @@ -355,10 +407,18 @@ TEST(ProfilerCounters, DisabledIsANoOpAndPreservesRecordedState) { p.recordTick(3.0); p.recordFrame(4.0); p.addDrawCalls(9); + p.addRenderBatchMs(1.0); + p.addRenderGpu(123); + p.addRenderStall(2500); + p.setRenderTextureMemory(1024); const ProfilerStats s = p.snapshot(); EXPECT_EQ(s.ticks, 1u); // the disabled records are dropped EXPECT_EQ(s.frames, 1u); EXPECT_EQ(s.drawCalls, 0u); + EXPECT_DOUBLE_EQ(s.renderBatchMs, 0.0); // the disabled feeds dropped + EXPECT_EQ(s.renderGpuFrames, 0u); + EXPECT_EQ(s.renderStallFrames, 0u); + EXPECT_EQ(s.renderTextureMemoryBytes, 0u); EXPECT_DOUBLE_EQ(s.tickTimeMs.min, 1.0); // recorded state preserved p.setEnabled(true); p.recordTick(5.0); @@ -577,6 +637,22 @@ TEST(ProfilerEngine, ReportIsWrittenAtRunEnd) { const laige::JsonValue* worldObj = root.findMember("world"); ASSERT_NE(worldObj, nullptr); EXPECT_EQ(worldObj->findMember("systems")->asNumber(), 1.0); + // The M2-PROF-01 render object (RENDER-005): present on every + // report; a headless run has no render feed, so the GPU time is + // JSON null (the "n/a" reading — never a fake number). + const laige::JsonValue* renderObj = root.findMember("render"); + ASSERT_NE(renderObj, nullptr); + ASSERT_TRUE(renderObj->isObject()); + EXPECT_EQ(renderObj->findMember("batch_ms")->asNumber(), 0.0); + EXPECT_EQ(renderObj->findMember("sort_ms")->asNumber(), 0.0); + EXPECT_EQ(renderObj->findMember("submit_ms")->asNumber(), 0.0); + const laige::JsonValue* gpuUs = renderObj->findMember("gpu_us"); + ASSERT_NE(gpuUs, nullptr); + EXPECT_TRUE(gpuUs->isNull()); + EXPECT_EQ(renderObj->findMember("gpu_frames")->asNumber(), 0.0); + EXPECT_EQ(renderObj->findMember("stall_us")->asNumber(), 0.0); + EXPECT_EQ(renderObj->findMember("stall_frames")->asNumber(), 0.0); + EXPECT_EQ(renderObj->findMember("texture_memory_bytes")->asNumber(), 0.0); const laige::JsonValue* systems = root.findMember("systems"); ASSERT_NE(systems, nullptr); ASSERT_TRUE(systems->isArray()); @@ -698,6 +774,13 @@ TEST(ProfilerReport, TextReportIsGreppable) { EXPECT_NE(text.find("laige-profile tick_ms: n=3"), std::string::npos); EXPECT_NE(text.find("laige-profile frame_ms: n=0"), std::string::npos); EXPECT_NE(text.find("laige-profile world:"), std::string::npos); + // The M2-PROF-01 render-timing section: no render subsystem feeds a + // headless run, so the GPU time is the "n/a" reading (never a fake + // number). + EXPECT_NE(text.find("laige-profile render: batch_ms=0"), + std::string::npos); + EXPECT_NE(text.find("gpu_us=n/a"), std::string::npos); + EXPECT_NE(text.find("texture_memory_bytes=0"), std::string::npos); EXPECT_NE(text.find("laige-profile system id=1 name=GLNoopA"), std::string::npos); EXPECT_NE(text.find("window: n=3"), std::string::npos); @@ -741,6 +824,12 @@ TEST(ProfilerZeroAlloc, RecordPathAllocatesNothing) { p.addDrawCalls(1); p.addTextureBinds(1); p.addNetBytes(1); + p.addRenderBatchMs(1.0); + p.addRenderSortMs(1.0); + p.addRenderSubmitMs(1.0); + p.addRenderGpu(1); + p.addRenderStall(1); + p.setRenderTextureMemory(1); // The snapshot's stats() pass sorts into the pre-reserved scratch // buffer — no heap. (The format surface is a cold path and DOES // allocate — it is not part of the record path.) diff --git a/tools/README.md b/tools/README.md index 4e7cf99..17e35de 100644 --- a/tools/README.md +++ b/tools/README.md @@ -15,7 +15,8 @@ Engine tools and CI scripts, each landing with its roadmap step: opt-in, debug builds only, atomic publish, 128 MiB default cap); `--prof-out` writes the run's profile report (M1-PROF-01, FR-11.1 file export: the version-1 JSON schema — counters, tick/frame time - windows, world fields, per-system timings — at run end, EVERY + windows, world fields, render timing (M2-PROF-01), per-system + timings — at run end, EVERY build; a write failure does not fail the run — it exits `2` with the run status `ok`). Full contract in [docs/api/engine.md](../docs/api/engine.md), From 1285fd6970438e661627d6fd107680bdb8f28b3b Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Mon, 12 Oct 2026 22:46:50 +0200 Subject: [PATCH 2/2] Fix the RenderTiming GL tests' out-of-domain default options The GL test suites passed SpriteRenderer::Options{} defaults to makeScene; the default maxInstances/maxAtlases are 0, outside the documented domain [1, ...], so SpriteRenderer::create failed invalid_argument on every P0 CI runner (first CI run 38303505479: the render_timing + unfiltered laige-render_tests entries aborted on all three Linux trees). The tests now set the per-scene budgets explicitly (16 instances / 2 atlases, 8192 for the stall scene). --- tests/laige-render/render_timing_tests.cpp | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/laige-render/render_timing_tests.cpp b/tests/laige-render/render_timing_tests.cpp index 3524f22..d321efc 100644 --- a/tests/laige-render/render_timing_tests.cpp +++ b/tests/laige-render/render_timing_tests.cpp @@ -410,7 +410,10 @@ TEST(RenderTimingCpu, SmallScenePhaseTimes) { SpriteRenderer renderer; SpriteBatcher batcher; Mat4 matrix{}; - const SpriteRenderer::Options ro{}; // defaults: gpuTiming off + SpriteRenderer::Options ro{}; // the documented domain defaults + ro.maxInstances = 16; + ro.maxAtlases = 2; + // gpuTiming off (the default) makeScene(ctx, renderer, batcher, matrix, ro, /*maxSprites=*/16); const std::vector items = sceneItems(); @@ -491,7 +494,9 @@ TEST(RenderTimingGpu, OffByDefaultIsNA) { SpriteRenderer renderer; SpriteBatcher batcher; Mat4 matrix{}; - const SpriteRenderer::Options ro{}; // gpuTiming off (the default) + SpriteRenderer::Options ro{}; // gpuTiming off (the default) + ro.maxInstances = 16; + ro.maxAtlases = 2; makeScene(ctx, renderer, batcher, matrix, ro, /*maxSprites=*/16); const std::vector items = sceneItems(); @@ -518,6 +523,8 @@ TEST(RenderTimingGpu, OneFrameLateMeasurements) { SpriteBatcher batcher; Mat4 matrix{}; SpriteRenderer::Options ro{}; + ro.maxInstances = 16; + ro.maxAtlases = 2; ro.gpuTiming = true; makeScene(ctx, renderer, batcher, matrix, ro, /*maxSprites=*/16); @@ -581,6 +588,7 @@ TEST(RenderTimingStall, LargeFrameUploadStalls) { Mat4 matrix{}; SpriteRenderer::Options ro{}; ro.maxInstances = 8192; + ro.maxAtlases = 2; ro.stallThresholdUs = 1; // an artificial 1 us threshold makeScene(ctx, renderer, batcher, matrix, ro, /*maxSprites=*/8192); @@ -630,7 +638,9 @@ TEST(RenderTimingStall, SmallFrameDoesNotStall) { SpriteRenderer renderer; SpriteBatcher batcher; Mat4 matrix{}; - const SpriteRenderer::Options ro{}; // the default 2000 us threshold + SpriteRenderer::Options ro{}; // the default 2000 us threshold + ro.maxInstances = 16; + ro.maxAtlases = 2; makeScene(ctx, renderer, batcher, matrix, ro, /*maxSprites=*/16); // The 10-sprite frame: a 520 B upload — the wall time is far below