From 5e58f7aadd066a25e4f40d51dafeb2a9d5c963c7 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sat, 3 Oct 2026 16:00:53 +0200 Subject: [PATCH] [M2-SPRITE-01] Sprite item + batcher (declare, don't draw) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine-owned sprite declaration window + batch builder (S-5, FR-2.1): SpriteItem (world 2D position, the M2-ISO-01 depth key, depthOverride flag, UV sub-rect, rotation, scale, tint, blend, atlas/material refs) in a budgeted ArenaPool; SpriteBatcher (header-only, move-only; default = stopped state) — create(Options{maxSprites}) at scene set-up (~132 B/slot, 6.6 MB at the 50k stress budget), the frame protocol beginFrame() -> add(item) x n -> build(). build() sorts the frame's depth keys with the M2-SORT-01 DepthSort, groups into (atlas, material, blend) batches in deterministic order (ascending (atlas, material, blend)), and scatters each group's instances in global back-to-front order (the (key, entity id) total order per group; one instanced draw call per group at submit, M2-SPRITE-02). Overflow: bounded drop-oldest + rate-limited warn (PERF-008, S-2). G-R11: manual depth overrides counted per frame + cumulative + warned once per frame (prefer tile height). Zero per-frame allocation (PERF-003 — 1000-frame proof). No standalone budget entry: the sort cost is the depth_sort_10k budget; the composite 50k render-CPU budget lands with the submit stage (M2-PERF-01). ctest -R batcher green on all six local trees; laige-api.json 1175 symbols / 36 headers; include-lint + determinism-lint green. Docs: docs/api/sprite_batcher.md, docs/README index, coordinates.md 4.7, module README, iso_depth_key/iso_depth_table example updates. --- docs/README.md | 11 + docs/api/iso_depth_key.md | 9 +- docs/api/iso_depth_table.md | 4 +- docs/api/sprite_batcher.md | 240 ++++++ docs/concepts/coordinates.md | 36 +- laige-api.json | 89 +- roadmap/M2-rendering-2.5d.md | 2 +- roadmap/README.md | 6 +- src/laige-render/README.md | 34 +- .../include/laige/render/iso_depth_key.h | 5 +- .../include/laige/render/sprite_batcher.h | 789 ++++++++++++++++++ tests/laige-render/CMakeLists.txt | 46 +- tests/laige-render/sprite_batcher_tests.cpp | 727 ++++++++++++++++ 13 files changed, 1957 insertions(+), 41 deletions(-) create mode 100644 docs/api/sprite_batcher.md create mode 100644 src/laige-render/include/laige/render/sprite_batcher.h create mode 100644 tests/laige-render/sprite_batcher_tests.cpp diff --git a/docs/README.md b/docs/README.md index 7bd66c6..6113a48 100644 --- a/docs/README.md +++ b/docs/README.md @@ -266,6 +266,17 @@ still to land. `BudgetExhausted`-when-overflowing contract, and the PRD §8.1 budget (10k keys sorted mean ≤ 1.0 ms — the `depth_sort_10k` entry) (M2-SORT-01; `laige-render`). +- [Sprite batcher](api/sprite_batcher.md) — + `laige::render::SpriteBatcher`: the engine-owned "declare, don't + draw" sprite declaration window + batch builder (S-5, FR-2.1): the + frame protocol (`beginFrame` → `add` × n → `build`), the pool-backed + `SpriteItem` (position, depth key, UV sub-rect, rotation, scale, + tint, blend, atlas/material refs), the (atlas, material, blend) + grouping with deterministic group order and the M2-SORT-01 sorted + instance order per group (one draw call per group at submit, + M2-SPRITE-02), the bounded drop-oldest + warn overflow policy, the + G-R11 counted + warned per-sprite depth override, and the + zero-per-frame-allocation contract (M2-SPRITE-01; `laige-render`). ## Guides diff --git a/docs/api/iso_depth_key.md b/docs/api/iso_depth_key.md index 821ee6e..deb5917 100644 --- a/docs/api/iso_depth_key.md +++ b/docs/api/iso_depth_key.md @@ -158,7 +158,9 @@ if (posOk.ok()) { const std::uint32_t key = laige::render::isoDepthKey( pos, /*stepHeight=*/tileHeight, // the tile the entity stands on /*layer=*/laige::render::kIsoDepthGroundLayer); - batcher.add(key, entity, /*sprite data...*/); // M2-SPRITE-01 + laige::render::SpriteItem item; // the declared sprite (world pos, + item.depthKey = key; // uv, rotation, scale, tint, ... + batcher.add(item); // M2-SPRITE-01, in entity order } // Equal keys: the stable sort (M2-SORT-01) + the entity-id insertion // order below fix the order — laige::render::isoDepthOrderLess is the @@ -172,8 +174,9 @@ if (posOk.ok()) { shears, and camera motion, and is exactly what this API exists to prevent (S-5, G-R11: engine-owned depth). - **Do not hand-roll per-sprite z-ordering in game code** (G-R11): the - per-sprite depth override lands with M2-SPRITE-01 as a counted + - warned escape hatch ("prefer tile height"). + per-sprite depth override is the M2-SPRITE-01 batcher's counted + + warned escape hatch ("prefer tile height" — + [`api/sprite_batcher.md`](sprite_batcher.md)). - **Pass the standing-surface height, not the sprite's top.** A tree 3 units tall standing on ground passes `stepHeight = 0`, not `3` — the key anchors the object's BASE (its standing surface). diff --git a/docs/api/iso_depth_table.md b/docs/api/iso_depth_table.md index 6609ce2..45f39aa 100644 --- a/docs/api/iso_depth_table.md +++ b/docs/api/iso_depth_table.md @@ -206,7 +206,9 @@ if (!edited.ok()) { /* log: uncovered cell or out-of-domain height */ } // only covered tiles (the batch culls against the covered region): if (table.value().covers(gx, gy)) { const std::uint32_t key = table.value().keyAt(gx, gy); - batcher.add(key, /*tile sprite data...*/); // M2-SPRITE-01 + laige::render::SpriteItem item; // the declared tile sprite + item.depthKey = key; // (the tile grid's precomputed key) + batcher.add(item); // M2-SPRITE-01, in tile order } // Equal keys: the stable sort (M2-SORT-01) + the entity-id insertion // order fix the order — isoDepthOrderLess (M2-ISO-01) is the diff --git a/docs/api/sprite_batcher.md b/docs/api/sprite_batcher.md new file mode 100644 index 0000000..fec7a77 --- /dev/null +++ b/docs/api/sprite_batcher.md @@ -0,0 +1,240 @@ +# Sprite batcher (`laige::render::SpriteBatcher`) + +The engine-owned "declare, don't draw" sprite declaration window + +batch builder (M2-SPRITE-01; S-5: "Rendering goes through the +batcher. Scene content is declared; the engine batches"; FR-2.1: +"one draw call per (atlas, material, blend) group per frame"; FR-2.2 +"no per-frame allocation"; G-R11: per-sprite depth overrides counted ++ warned; AGENTS RENDER-001/003, PERF-003/008, CORE-005/008/009, +API-001/006, DOC-004). Public header: +`src/laige-render/include/laige/render/sprite_batcher.h` (header-only +— the batch is pure integer bookkeeping over pre-allocated storage; +there is no implementation file). Unit suite: `ctest -R batcher` +(`tests/laige-render/sprite_batcher_tests.cpp`) — pure integer +bookkeeping, no GL environment required: it runs in every local tree +and in CI, with the grouping correctness, the in-group order, the +drop-oldest overflow policy, the G-R11 counted + warned override, the +stopped-state / protocol edges, the determinism property against the +stable-sort oracle, and the 1 000-frame zero-allocation proof. + +The batcher is the render-side consumer of the M2-SORT-01 sorter: the +sorter turns the frame's 32-bit depth keys into the back-to-front +order, and the batcher turns that order into the frame's (atlas, +material, blend) GROUPS — one instanced draw call per group at submit +(M2-SPRITE-02). The engine owns both (S-5, G-R11) — game code declares +sprites; it never batches, sorts, or draws. + +## The API + +```cpp +struct SpriteItem { + Vec2 pos{}; // world ground-plane position, world units + std::uint32_t depthKey{}; // the M2-ISO-01 key (isoDepthKey) + bool depthOverride{}; // G-R11 escape hatch (counted + warned) + SpriteUvRect uv{}; // UV sub-rect in the atlas, [0,1]^2 + float rotation{}; // radians + Vec2 scale{1, 1}; // world-unit scale (x, y) + SpriteTint tint{}; // multiplicative RGBA (1,1,1,1 = none) + std::uint32_t atlasId{}; // atlas/texture ref (group key field 1) + std::uint32_t materialId{}; // material ref (group key field 2) + BlendMode blend{}; // group key field 3 (Alpha default) +}; + +struct SpriteBatch { // one output group (the submit stage's read) + std::uint32_t atlasId{}; + std::uint32_t materialId{}; + BlendMode blend{}; + std::span instances{}; // pool slots, back-to-front +}; + +class SpriteBatcher { + public: + struct Options { std::uint32_t maxSprites{}; }; // the frame budget + SpriteBatcher() noexcept; // stopped state (capacity 0) + [[nodiscard]] static Result create(Options) noexcept; + void beginFrame() noexcept; // open the frame window + [[nodiscard]] Result add(SpriteItem item) noexcept; + [[nodiscard]] Status build() noexcept; // sort + group + publish + [[nodiscard]] std::size_t batchCount() const noexcept; + [[nodiscard]] std::span batches() const noexcept; + [[nodiscard]] std::size_t frameCount() const noexcept; + [[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]] std::uint32_t capacity() const noexcept; + [[nodiscard]] PoolStats itemPoolStats() const noexcept; + [[nodiscard]] const SpriteItem* get(std::uint32_t slot) noexcept; // null-safe + [[nodiscard]] const SpriteItem& at(std::uint32_t slot); // asserts live + // move-only (copy deleted) +}; +``` + +The frame protocol (the frame pipeline's cull/batch stage, M2-GL-02): +`beginFrame()` → `add(item)` × n → `build()`, per frame. + +- **`create(options)`** is the **set-up path** (scene load): one + allocation per storage structure (the sprite pool, the M2-SORT-01 + sorter, the key scratch, the instance array, the group table, the + cursor array, the batch array — ~132 bytes per capacity slot, 6.6 MB + at the 50k stress budget). Fails with `InvalidArgument` (no + allocation) when `maxSprites` is 0 or exceeds + `kSpriteBatcherMaxCapacity` (0xFFFFFFFF — the slot width). Size the + budget to the scene's worst-case visible count (API-006); the + batcher never grows beyond it. +- **`beginFrame()`** closes the previous window and opens a fresh one + (the previous frame's declared items are released — the pool's + reset). Idempotent; a no-op on the stopped batcher. +- **`add(item)`** declares one sprite for the current frame and + returns its frame-scoped slot. 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). Overflow + policy: a frame beyond the budget **drops the oldest declaration** + (the ring head is overwritten) and takes the new one, one + rate-limited Warn per drop (`sprite_batcher/frame_overflow_dropped`) + — bounded, never growing (PERF-008, S-2). Fails: capacity 0 → + `BudgetExhausted`; window closed (after `build`) → `InvalidArgument`. +- **`build()`** is the **batch stage**: sorts the frame's depth keys + (M2-SORT-01), groups into (atlas, material, blend) batches (the + deterministic group order below), scatters each group's instances in + global back-to-front order, and publishes `batches()`. Closes the + declaration window. Fails: double build → `InvalidArgument`; sort + overflow → `BudgetExhausted` (unreachable — the ring keeps + n ≤ capacity). +- **Output** (`batchCount()`, `batches()`, `get(slot)`, `at(slot)`): + read **within the frame** — the next `beginFrame()`/`build()` + invalidates it. The instance spans alias the batcher's storage. +- **G-R11 accounting**: `overrideCount()` (this frame, reset by + `beginFrame`) and `overrideTotal()` (cumulative) — the profiler's + feeds (PRD §10.4); `droppedTotal()` (cumulative overflow); + `itemPoolStats()` (the pool's accounting snapshot). + +## Grouping and order (RENDER-001/003) + +The frame's GROUPS are the DISTINCT (atlas, material, blend) +combinations of its declared sprites. One instanced draw call per +group per frame (FR-2.1, M2-SPRITE-02): the per-group state (texture +bind, material, blend) is set ONCE, so texture binds and blend changes +are O(group count), not O(sprite count) (RENDER-001). + +- **Group order**: ascending (atlas, material, blend) — a function of + the DISTINCT group keys alone (independent of membership and + insertion order). The submit stage walks the groups in this order. +- **In-group instance order**: the frame's GLOBAL back-to-front order + (M2-SORT-01) **restricted to the group** — the (key, declaration + order) total order of `iso_depth_key.h`, per group. + +**Determinism** (RENDER-003, ARCH-010 scope — presentation-only, never +the sim state hash or replay state): a pure function of the +declaration SEQUENCE (items, order) — integer arithmetic only (the +item's floats are carried through untouched), no hashing, no RNG, no +platform container order. Same declaration sequence → bit-identical +batches, every platform/build (the `SpriteBatcherDeterminism` suite +pins this on 3 000-sprite frames against the stable-sort oracle). The +SAME multiset in a DIFFERENT declaration order yields a different +(equally valid) in-group order for equal keys — the batcher's +entity-id insertion order is what makes the per-frame order +reproducible. + +## Overflow: bounded, drop oldest + warn (PERF-008, S-2) + +The frame budget is fixed at set-up. A frame that declares more than +the budget does not grow, fail, or truncate silently: each excess +declaration **drops the oldest live declaration** (the ring head) and +takes the new one — one rate-limited Warn per drop +(`sprite_batcher/frame_overflow_dropped`, LOG-004), the cumulative +count in `droppedTotal()`. The visible set is bounded by +construction; the degradation is logged (CORE-008) and accounted +(PRD §10.4). Size the budget to the scene's worst case at set-up — a +repeatedly overflowing scene is a budget bug, not a normal state. + +## G-R11: the counted + warned depth-override escape hatch + +The normal path: the caller computes the key with the engine-owned +`isoDepthKey` (M2-ISO-01) and declares it. The escape hatch: +the caller sets `depthKey` by hand and flags `depthOverride = true`. +The batcher SORTS the key either way (the order is what the key says) +but COUNTS every override declaration (`overrideCount()` per frame, +`overrideTotal()` cumulative) and WARNS once per frame that overrides +were used (`sprite_batcher/depth_override_used`, rate-limited — +LOG-004), advising "prefer tile height" (PRD §9.3 G-R11: per-sprite +depth overrides counted + warned). + +## Performance (PERF-002/003/004, DOC-004) + +- **Complexity:** `create` O(maxSprites) (8 allocations, ~132 B/slot); + `beginFrame` O(previous frame count) (the pool reset); `add` + **O(1)** (one pool create or one ring overwrite); `build` + **O(4n + 4·256)** (the M2-SORT-01 sort) **+ O(n·log G)** (two group + lookups per instance) **+ O(G·(log G + G))** (the group-table + growth: a binary search + an O(G) descriptor shift per NEW group) + **+ O(n)** (start prefix + instance scatter) — with n = the frame's + declared sprites (≤ capacity) and G = the frame's DISTINCT + (atlas, material, blend) count. +- **Allocation:** zero per frame (PERF-003) — proven by the + zero-allocation test (1 000 frames × 512 declarations = 0 heap + blocks, non-sanitizer trees; the sanitizer trees run the same + workload leak-free). +- **Blocking/IO/GPU:** none — pure integer bookkeeping; the only + logging is the two cold, rate-limited Warn paths (LOG-003: a + disabled event costs one atomic load + branch). +- **Budget:** the step has no standalone `budgets.json` entry — the + sort cost is the M2-SORT-01 `depth_sort_10k` budget (10k keys sorted + mean ≤ 1.0 ms, 4.4× inside), and the composite 50k render-CPU budget + (2 ms, PRD §8.1 `sprites_50k_cpu`) is measured when the submit stage + lands (M2-PERF-01). The grouping adds O(n log G) — G ≪ n in every + realistic scene (the 50k-stress budget caps draw calls at 30). +- **Call site:** once per frame per sprite pass, in the render phase + (the frame pipeline's cull/batch stage) — never per sprite, never in + the simulation tick (ARCH-002). + +## Ownership, lifetime, threading (CORE-009, CONC-001) + +- **Owner:** one batcher per sprite pass — owned by the render + thread's cull/batch stage (the frame pipeline's stage callback, + M2-GL-02). Move-only; copy deleted. +- **Phases:** `create` at scene set-up; `beginFrame` / `add` / + `build` in the render phase (after the tick→handoff, before the + submit stage). The phases never overlap; the batcher is never shared + with a concurrent writer — not thread-safe by design (the + single-owner pattern of `DepthSort` and `IsoDepthKeyTable`). +- **Lifetime:** the declared items die with the frame (the pool's + reset at `beginFrame`); the since-construction counters + (`overrideTotal()`, `droppedTotal()`) and the pool's accounting + survive. The output spans alias the batcher's storage — read within + the frame. +- **Moved-from state:** the stopped state (capacity 0) — total, never + UB (add → `BudgetExhausted`; build → empty output). + +## Misuse warnings + +- **Do not hand-roll z-ordering in game code (G-R11):** compute the + key with `isoDepthKey` (M2-ISO-01). The manual depthKey + + `depthOverride` is the counted + warned escape hatch ("prefer tile + height") — not the default path. +- **Do not derive the key from screen-space coordinates (PRD §4):** + the key is world-space by contract (the M2-ISO-01 domain). +- **Declare in the deterministic entity-id iteration order (FR-1.2):** + a nondeterministic declaration order makes the equal-key (same + screen row) order nondeterministic — RENDER-003 breaks in exactly + the places painter's order is visible. +- **Do not declare more than the frame budget without a real reason:** + overflow drops the OLDEST declarations (the scene is visibly + truncated) — size the budget to the scene's worst-case visible count + at set-up (API-006). +- **Do not carry a slot across frames:** the slot is frame-scoped + (the ring reuses it, the next `beginFrame` releases it). Read items + within the frame. + +## Related + +- [`api/depth_sort.md`](depth_sort.md) — the M2-SORT-01 stable radix + sort this batcher consumes. +- [`api/iso_depth_key.md`](iso_depth_key.md) — the 32-bit depth key + the items carry (M2-ISO-01). +- [`concepts/coordinates.md` §4.7](../concepts/coordinates.md) — the + render-order narrative (ARCH-008). +- [`api/frame_pipeline.md`](frame_pipeline.md) — the render thread + + frame handoff the batcher plugs into (M2-GL-02). +- [`api/pools.md`](pools.md) — the pool storage behind the declared + items (M0-CORE-05). diff --git a/docs/concepts/coordinates.md b/docs/concepts/coordinates.md index 5b334b5..308034a 100644 --- a/docs/concepts/coordinates.md +++ b/docs/concepts/coordinates.md @@ -240,13 +240,44 @@ deterministic, pre-allocated sorter for the frame's 32-bit keys: `depth_sort_10k`) — [baselines/m2-depth-sort.md](../benchmarks/baselines/m2-depth-sort.md). +### 4.7 The sprite batcher (M2-SPRITE-01) + +The frame's sorted keys become the frame's draw groups by +`laige::render::SpriteBatcher` (`laige/render/sprite_batcher.h`) — the +engine-owned "declare, don't draw" window (S-5): the game DECLARES the +frame's sprites (`beginFrame` → `add(item)` × n → `build()`), the +engine batches: + +- **Declare, don't draw**: the game declares `SpriteItem`s (world + position, the M2-ISO-01 depth key, UV sub-rect, rotation, scale, + tint, blend, atlas/material refs); there is no "draw this quad now" + in the safe API (S-5). +- **Grouping (FR-2.1)**: `build()` produces one group per DISTINCT + (atlas, material, blend) combination — the submit stage (M2-SPRITE-02) + makes ONE instanced draw call per group, so texture binds and blend + changes are O(group count), not O(sprite count) (RENDER-001). +- **Order (RENDER-003)**: the group order is ascending + (atlas, material, blend) (a function of the distinct group keys + alone); each group's instances keep the frame's GLOBAL + back-to-front order (§4.6's stable sort RESTRICTED to the group) — + the (key, entity id) total order per group. +- **Overflow (PERF-008, S-2)**: the frame budget is fixed at set-up + (`Options::maxSprites`); a frame beyond it drops the OLDEST + declaration + warns (never grows, never silent). +- **G-R11**: a manually-set depth key (`depthOverride`) is counted per + frame + warned once per frame ("prefer tile height") — the escape + hatch, not the default path. +- **Zero per-frame allocation** (FR-2.2, PERF-003): the batch is pure + integer bookkeeping over the pre-allocated storage (~132 B per + capacity slot — 6.6 MB at the 50k stress budget). + ## 5. Conversion rules (the module boundaries, RENDER-006) | Conversion | Direction | Owner | Status | |---|---|---|---| | World → depth key | sim state → `uint32` key | `laige-render` (`isoDepthKey`, M2-ISO-01) | **This document / shipped** | | Tile grid → depth key table | tile heights → precomputed per-tile keys | `laige-render` (`IsoDepthKeyTable`, M2-ISO-02) | **This document / shipped** | -| Depth key → render order | key (+ entity id) → sorted order | `laige-render` (`DepthSort`, M2-SORT-01 stable radix sort; the batcher M2-SPRITE-01) | **Shipped (M2-SORT-01)** | +| Depth key → render order | key (+ entity id) → sorted order → groups | `laige-render` (`DepthSort`, M2-SORT-01 stable radix sort; `SpriteBatcher`, M2-SPRITE-01 batcher) | **Shipped (M2-SORT-01 + M2-SPRITE-01)** | | Screen → world (per mode) | picking, screen↔world transforms | `laige-render` (`ProjectionView`: `worldToScreen`, `screenToWorldRay`, `screenToWorld`, M2-PROJ-01; `screenToGrid` iso grid picking, M2-ISO-03) | **Shipped (M2-PROJ-01 + M2-ISO-03)** | | World → screen (render) | sim state → NDC → pixels | camera + preset matrix (M2-CAM-01/02, M2-GL-03), `ProjectionView::worldToScreen` (M2-PROJ-01), sprite draw (M2-SPRITE-02) | shipped to NDC (matrices, camera core M2-CAM-01, iso presets + grid-snap M2-CAM-02, world→screen transform M2-PROJ-01); pixels: M2-SPRITE-02 planned | @@ -339,6 +370,9 @@ state → same keys → same order, every frame (RENDER-003). - [`api/depth_sort.md`](../api/depth_sort.md) — the deterministic stable depth sort contract: `DepthSort` (keys → sorted order) (M2-SORT-01). +- [`api/sprite_batcher.md`](../api/sprite_batcher.md) — the declare, + don't draw batcher contract: `SpriteBatcher` (declared sprites → + (atlas, material, blend) groups) (M2-SPRITE-01). - [`api/matrices.md`](../api/matrices.md) — the matrix builders and NDC conventions (M2-GL-03). - [`decisions/0005-iso-default.md`](../decisions/0005-iso-default.md) — diff --git a/laige-api.json b/laige-api.json index f9e3ba2..bc37dd2 100644 --- a/laige-api.json +++ b/laige-api.json @@ -23,6 +23,7 @@ "src/laige-render/include/laige/render/iso_picking.h", "src/laige-render/include/laige/render/matrices.h", "src/laige-render/include/laige/render/projection.h", + "src/laige-render/include/laige/render/sprite_batcher.h", "src/laige-sim/include/laige/sim/archetype.h", "src/laige-sim/include/laige/sim/component.h", "src/laige-sim/include/laige/sim/config.h", @@ -623,24 +624,24 @@ {"name": "laige::render::IsoCamera::update", "kind": "method", "header": "src/laige-render/include/laige/render/iso_camera.h", "line": 380, "signature": "void update() noexcept", "summary": "The per-frame update: the M2-CAM-01 update (follow step, bounds clamp, shake decay) + the grid snap of the position (snap mode only — the continuous choice, the header preamble). No-op on a stopped camera. No logging on the healthy paths. allocation.", "budget": "O(1) float ops (+ two snap divisions in snap mode); no", "experimental": false}, {"name": "laige::render::IsoCamera::snapCoord", "kind": "method", "header": "src/laige-render/include/laige/render/iso_camera.h", "line": 389, "signature": "[[nodiscard]] static float snapCoord(float v, float gridSize) noexcept", "summary": "The nearest grid multiple of v (the header preamble's position snap): float-only, nearest, ties away from zero, deterministic per build. Precondition: gridSize finite, > 0. @budget O(1).", "budget": null, "experimental": false}, {"name": "laige::render::IsoCamera::snapZoomLevel", "kind": "method", "header": "src/laige-render/include/laige/render/iso_camera.h", "line": 396, "signature": "[[nodiscard]] static float snapZoomLevel(float z, float zoomMin, float zoomMax) noexcept", "summary": "The snapped zoom level of z for the [zoomMin, zoomMax] dyadic ladder (the header preamble's zoom section): clamps into the bounds, nearest level in log2 space, exact float tie to the higher zoom; the result is always an exact member of L. Preconditions: 0 < zoomMin <= zoomMax, finite. @budget O(log2(zoomMax/zoomMin)).", "budget": null, "experimental": false}, - {"name": "laige::render::kIsoDepthQuantScale", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 206, "signature": "inline constexpr std::int32_t kIsoDepthQuantScale = 16", "summary": "Quantization scale: 16 key units per world unit of v = x + y - z. The tie window of equal keys is 1/16 world units (the \"documented precision\" of the key order, docs/concepts/coordinates.md).", "budget": null, "experimental": false}, - {"name": "laige::render::kIsoDepthFineBits", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 209, "signature": "inline constexpr std::int32_t kIsoDepthFineBits = 22", "summary": "Bits of the quantized-depth field (biased) in the 32-bit key.", "budget": null, "experimental": false}, - {"name": "laige::render::kIsoDepthLayerBits", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 211, "signature": "inline constexpr std::int32_t kIsoDepthLayerBits = 32 - kIsoDepthFineBits", "summary": "Bits of the layer field (biased): 32 - fine bits (exactly 32).", "budget": null, "experimental": false}, - {"name": "laige::render::kIsoDepthFineBias", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 217, "signature": "inline constexpr std::uint32_t kIsoDepthFineBias = 1u << (kIsoDepthFineBits - 1)", "summary": "Field biases (the bias makes each field unsigned within the key: a 22-bit field holding -2^21..+2^21-1 is biased by 2^21; a 10-bit field holding -512..+511 by 512 — CORE-005: the bit widths above are the only \"magic\" numbers — every other constant derives from them).", "budget": null, "experimental": false}, - {"name": "laige::render::kIsoDepthLayerBias", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 219, "signature": "inline constexpr std::uint32_t kIsoDepthLayerBias = 1u << (kIsoDepthLayerBits - 1)", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::kIsoDepthFineMask", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 223, "signature": "inline constexpr std::uint32_t kIsoDepthFineMask = (1u << kIsoDepthFineBits) - 1u", "summary": "The quantized-depth field's unsigned mask (bits 0..21).", "budget": null, "experimental": false}, - {"name": "laige::render::kIsoDepthMaxWorldUnits", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 230, "signature": "inline constexpr std::int32_t kIsoDepthMaxWorldUnits = 32767", "summary": "Documented domain (the \"world units\" of PRD §4; 1 unit = 1 tile in the tile map — M2-TILE-01). kIsoDepthMaxWorldUnits is the Q16.16 fixed-point coordinate bound (fpx16_16.h: +/-32767.996), so the domain is the same on both SimMath backends (ADR 0002).", "budget": null, "experimental": false}, - {"name": "laige::render::kIsoDepthMaxWorldSum", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 235, "signature": "inline constexpr std::int32_t kIsoDepthMaxWorldSum = 2 * kIsoDepthMaxWorldUnits", "summary": "The fp32_pinned quantizer's saturation bound: |x + y| <= 2 * the per-coordinate bound. (The fpx16_16 backend's saturating add is tighter — its storage bound is +/- (2^16 - 2^-16); see the \"Exactness zone\" note above for the cross-backend statement.)", "budget": null, "experimental": false}, - {"name": "laige::render::kIsoDepthMaxStepHeight", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 237, "signature": "inline constexpr std::int32_t kIsoDepthMaxStepHeight = 2047", "summary": "Step heights (tile elevations) in world units.", "budget": null, "experimental": false}, - {"name": "laige::render::kIsoDepthLayerMax", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 240, "signature": "inline constexpr std::int32_t kIsoDepthLayerMax = static_cast(kIsoDepthLayerBias) - 1", "summary": "Render layers (the parallax step documents the values; background layers are negative, foreground positive — M2-PAR-01).", "budget": null, "experimental": false}, - {"name": "laige::render::kIsoDepthGroundLayer", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 246, "signature": "inline constexpr std::int32_t kIsoDepthGroundLayer = 0", "summary": "The default ground layer (k = 0: no layer offset — the plain isometric scene of the reference scene M2-SCENE-01 and the template M2-SAMPLE-01).", "budget": null, "experimental": false}, - {"name": "laige::render::IsoDepthKeyParts", "kind": "struct", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 253, "signature": "struct IsoDepthKeyParts", "summary": "The unpacked key (the exact inverse of isoDepthKey(); the debug/ diagnostics view of a key — DBG-007/008)", "budget": null, "experimental": false}, - {"name": "laige::render::IsoDepthKeyParts::layer", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 255, "signature": "std::int32_t layer{}", "summary": "The (unbiased) layer field: -512..+511.", "budget": null, "experimental": false}, - {"name": "laige::render::IsoDepthKeyParts::quantizedDepth", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 258, "signature": "std::int32_t quantizedDepth{}", "summary": "The quantized depth d = round((x + y) * scale) - z * scale, in 1/16-world-unit key units: -2^21..+2^21-1.", "budget": null, "experimental": false}, - {"name": "laige::render::isoDepthKeyParts", "kind": "function", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 263, "signature": "[[nodiscard]] inline IsoDepthKeyParts isoDepthKeyParts(std::uint32_t key) noexcept", "summary": "The exact inverse of isoDepthKey(): key -> (layer, quantized depth). O(1); no allocation.", "budget": null, "experimental": false}, - {"name": "laige::render::isoDepthOrderLess", "kind": "function", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 283, "signature": "[[nodiscard]] inline bool isoDepthOrderLess(std::uint32_t keyA, std::uint32_t entityA, std::uint32_t keyB, std::uint32_t entityB) noexcept", "summary": "The lexicographic (key, entity id) comparison — the total order the render pipeline sorts by. The key carries (layer, quantized depth); the entity id is the final stable tie-break (the stable sort of M2-SORT-01 + the batcher's deterministic entity-id insertion order, FR-1.2). Strict weak ordering (never true for (a, a)); O(1).", "budget": "O(1); two integer comparisons.", "experimental": false}, - {"name": "laige::render::isoDepthKey", "kind": "function", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 383, "signature": "template [[nodiscard]] inline std::uint32_t isoDepthKey( typename sim::SimMath::Vec2 pos, std::int32_t stepHeight, std::int32_t layer) noexcept", "summary": "The deterministic 32-bit sortable depth key of an isometric object (the formula, bit layout, domain, and shear contract: the header preamble; the canonical narrative: docs/concepts/coordinates.md).", "budget": "O(1): one backend add + one rounding + a few integer ops; no", "experimental": false}, - {"name": "laige::render::isoShearSupported", "kind": "function", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 422, "signature": "[[nodiscard]] inline bool isoShearSupported(IsoAxes axes) noexcept", "summary": "True iff `axes` is a depth-key-supported isometric shear: all components finite, the ground map invertible (det != 0), and -dx.y == -dy.y == zUnit > 0 (EXACT float equality — both ground axes project downward with the same slope A and the height unit equals A, so NDC_y = -A*(x + y - z): the header preamble's shear contract). Both built-in presets pass (2:1 dimetric, true 30°/60°); a custom shear must pass to use isometric depth sorting (M2-CAM-02 validates scene shears against this). O(1); no allocation.", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoDepthQuantScale", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 207, "signature": "inline constexpr std::int32_t kIsoDepthQuantScale = 16", "summary": "Quantization scale: 16 key units per world unit of v = x + y - z. The tie window of equal keys is 1/16 world units (the \"documented precision\" of the key order, docs/concepts/coordinates.md).", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoDepthFineBits", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 210, "signature": "inline constexpr std::int32_t kIsoDepthFineBits = 22", "summary": "Bits of the quantized-depth field (biased) in the 32-bit key.", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoDepthLayerBits", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 212, "signature": "inline constexpr std::int32_t kIsoDepthLayerBits = 32 - kIsoDepthFineBits", "summary": "Bits of the layer field (biased): 32 - fine bits (exactly 32).", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoDepthFineBias", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 218, "signature": "inline constexpr std::uint32_t kIsoDepthFineBias = 1u << (kIsoDepthFineBits - 1)", "summary": "Field biases (the bias makes each field unsigned within the key: a 22-bit field holding -2^21..+2^21-1 is biased by 2^21; a 10-bit field holding -512..+511 by 512 — CORE-005: the bit widths above are the only \"magic\" numbers — every other constant derives from them).", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoDepthLayerBias", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 220, "signature": "inline constexpr std::uint32_t kIsoDepthLayerBias = 1u << (kIsoDepthLayerBits - 1)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoDepthFineMask", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 224, "signature": "inline constexpr std::uint32_t kIsoDepthFineMask = (1u << kIsoDepthFineBits) - 1u", "summary": "The quantized-depth field's unsigned mask (bits 0..21).", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoDepthMaxWorldUnits", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 231, "signature": "inline constexpr std::int32_t kIsoDepthMaxWorldUnits = 32767", "summary": "Documented domain (the \"world units\" of PRD §4; 1 unit = 1 tile in the tile map — M2-TILE-01). kIsoDepthMaxWorldUnits is the Q16.16 fixed-point coordinate bound (fpx16_16.h: +/-32767.996), so the domain is the same on both SimMath backends (ADR 0002).", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoDepthMaxWorldSum", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 236, "signature": "inline constexpr std::int32_t kIsoDepthMaxWorldSum = 2 * kIsoDepthMaxWorldUnits", "summary": "The fp32_pinned quantizer's saturation bound: |x + y| <= 2 * the per-coordinate bound. (The fpx16_16 backend's saturating add is tighter — its storage bound is +/- (2^16 - 2^-16); see the \"Exactness zone\" note above for the cross-backend statement.)", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoDepthMaxStepHeight", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 238, "signature": "inline constexpr std::int32_t kIsoDepthMaxStepHeight = 2047", "summary": "Step heights (tile elevations) in world units.", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoDepthLayerMax", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 241, "signature": "inline constexpr std::int32_t kIsoDepthLayerMax = static_cast(kIsoDepthLayerBias) - 1", "summary": "Render layers (the parallax step documents the values; background layers are negative, foreground positive — M2-PAR-01).", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoDepthGroundLayer", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 247, "signature": "inline constexpr std::int32_t kIsoDepthGroundLayer = 0", "summary": "The default ground layer (k = 0: no layer offset — the plain isometric scene of the reference scene M2-SCENE-01 and the template M2-SAMPLE-01).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoDepthKeyParts", "kind": "struct", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 254, "signature": "struct IsoDepthKeyParts", "summary": "The unpacked key (the exact inverse of isoDepthKey(); the debug/ diagnostics view of a key — DBG-007/008)", "budget": null, "experimental": false}, + {"name": "laige::render::IsoDepthKeyParts::layer", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 256, "signature": "std::int32_t layer{}", "summary": "The (unbiased) layer field: -512..+511.", "budget": null, "experimental": false}, + {"name": "laige::render::IsoDepthKeyParts::quantizedDepth", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 259, "signature": "std::int32_t quantizedDepth{}", "summary": "The quantized depth d = round((x + y) * scale) - z * scale, in 1/16-world-unit key units: -2^21..+2^21-1.", "budget": null, "experimental": false}, + {"name": "laige::render::isoDepthKeyParts", "kind": "function", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 264, "signature": "[[nodiscard]] inline IsoDepthKeyParts isoDepthKeyParts(std::uint32_t key) noexcept", "summary": "The exact inverse of isoDepthKey(): key -> (layer, quantized depth). O(1); no allocation.", "budget": null, "experimental": false}, + {"name": "laige::render::isoDepthOrderLess", "kind": "function", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 284, "signature": "[[nodiscard]] inline bool isoDepthOrderLess(std::uint32_t keyA, std::uint32_t entityA, std::uint32_t keyB, std::uint32_t entityB) noexcept", "summary": "The lexicographic (key, entity id) comparison — the total order the render pipeline sorts by. The key carries (layer, quantized depth); the entity id is the final stable tie-break (the stable sort of M2-SORT-01 + the batcher's deterministic entity-id insertion order, FR-1.2). Strict weak ordering (never true for (a, a)); O(1).", "budget": "O(1); two integer comparisons.", "experimental": false}, + {"name": "laige::render::isoDepthKey", "kind": "function", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 384, "signature": "template [[nodiscard]] inline std::uint32_t isoDepthKey( typename sim::SimMath::Vec2 pos, std::int32_t stepHeight, std::int32_t layer) noexcept", "summary": "The deterministic 32-bit sortable depth key of an isometric object (the formula, bit layout, domain, and shear contract: the header preamble; the canonical narrative: docs/concepts/coordinates.md).", "budget": "O(1): one backend add + one rounding + a few integer ops; no", "experimental": false}, + {"name": "laige::render::isoShearSupported", "kind": "function", "header": "src/laige-render/include/laige/render/iso_depth_key.h", "line": 423, "signature": "[[nodiscard]] inline bool isoShearSupported(IsoAxes axes) noexcept", "summary": "True iff `axes` is a depth-key-supported isometric shear: all components finite, the ground map invertible (det != 0), and -dx.y == -dy.y == zUnit > 0 (EXACT float equality — both ground axes project downward with the same slope A and the height unit equals A, so NDC_y = -A*(x + y - z): the header preamble's shear contract). Both built-in presets pass (2:1 dimetric, true 30°/60°); a custom shear must pass to use isometric depth sorting (M2-CAM-02 validates scene shears against this). O(1); no allocation.", "budget": null, "experimental": false}, {"name": "laige::render::kIsoDepthTableChunkTiles", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_table.h", "line": 226, "signature": "inline constexpr std::int32_t kIsoDepthTableChunkTiles = 16", "summary": "The default chunk side in tiles: a square chunk holds chunkTiles^2 cells. A power of two (chunk coordinates are shift/mask — division-free — on the cold paths); 16 x 16 = 256 tiles is the standard tilemap chunk size (M2-TILE-01).", "budget": null, "experimental": false}, {"name": "laige::render::kIsoDepthTableDefaultMaxChunks", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_table.h", "line": 232, "signature": "inline constexpr std::int32_t kIsoDepthTableDefaultMaxChunks = 1024", "summary": "The default growth cap: the most chunks a table may ever own (initial grid + streamed extensions). 1024 chunks x 256 cells x 12 B (CellRecord) ~ 3 MiB worst case — bounded and documented (CORE-006, API-006).", "budget": null, "experimental": false}, {"name": "laige::render::kIsoDepthTableUpdateRadius", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_table.h", "line": 239, "signature": "inline constexpr std::int32_t kIsoDepthTableUpdateRadius = 0", "summary": "The documented update neighborhood radius (the \"Incremental update contract\" preamble): 0 for the M2-ISO-01 key formula — a cell's key is a function of the cell's own (x, y, height, layer) and the table stores each cell's own height, so a height edit affects exactly the edited cell.", "budget": null, "experimental": false}, @@ -729,6 +730,58 @@ {"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": 243, "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": 253, "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": 256, "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": 258, "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": 268, "signature": "struct SpriteUvRect", "summary": "The sprite's UV sub-rect inside its atlas, normalized [0, 1]² (u1 > u0, v1 > v0 — the caller's invariant; M2-SPRITE-03 computes these from the atlas sheet frame layout).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteUvRect::u0", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 269, "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": 270, "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": 271, "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": 272, "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": 276, "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": 277, "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": 278, "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": 279, "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": 280, "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": 291, "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": 295, "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": 299, "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": 303, "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": 305, "signature": "SpriteUvRect uv{}", "summary": "The UV sub-rect in the atlas (SpriteUvRect above).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::rotation", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 308, "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": 310, "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": 312, "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": 315, "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": 318, "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": 320, "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": 331, "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": 332, "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": 333, "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": 334, "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": 340, "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": 371, "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": 377, "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": 378, "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": 385, "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": 393, "signature": "[[nodiscard]] static Result create(Options options) noexcept", "summary": "The set-up path (scene load): one allocation per storage structure (the preamble's layout — ~132 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": 402, "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": 424, "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": 440, "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": 445, "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": 447, "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": 452, "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::overrideCount", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 456, "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": 462, "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": 468, "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": 473, "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": 476, "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": 482, "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": 487, "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": 489, "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": 490, "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": 494, "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": 495, "signature": "SpriteBatcher& operator=(SpriteBatcher&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, {"name": "laige::kMaxArchetypes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 184, "signature": "inline constexpr std::uint32_t kMaxArchetypes = 256", "summary": "The engine-level cap on distinct component sets (archetypes) per world (CORE-005: a named engine constant, the kMaxComponentTypes precedent — a game's component-set vocabulary is orders of magnitude smaller than its entity count; raising it is an ADR, not a knob).", "budget": null, "experimental": false}, {"name": "laige::kMaxArchetypeComponents", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 189, "signature": "inline constexpr std::uint32_t kMaxArchetypeComponents = 32", "summary": "The engine-level cap on components per entity (per archetype) (CORE-005). A 33rd distinct component on one entity fails addComponent with BudgetExhausted.", "budget": null, "experimental": false}, {"name": "laige::kInitialArchetypeRows", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 194, "signature": "inline constexpr std::uint32_t kInitialArchetypeRows = 16", "summary": "Rows reserved when an archetype is created (or the world's entity capacity, when smaller). Growth doubles from here (see the header preamble, \"Reserve policy\").", "budget": null, "experimental": false}, diff --git a/roadmap/M2-rendering-2.5d.md b/roadmap/M2-rendering-2.5d.md index 46644b7..a256473 100644 --- a/roadmap/M2-rendering-2.5d.md +++ b/roadmap/M2-rendering-2.5d.md @@ -151,7 +151,7 @@ if M2 slips, and its status is recorded in M2-EXIT-01. - **Verify:** `ctest -R depth_sort` green; 10k-sort cost recorded in baseline. - **Size:** ~250 lines + tests -- [ ] **M2-SPRITE-01 · Sprite item + batcher API (declare, don't draw)** +- [x] **M2-SPRITE-01 · Sprite item + batcher API (declare, don't draw)** - **Refs:** FR-2.1 (batched quads, one draw call per (atlas, material, blend) group), S-5 - **Depends:** M2-SORT-01 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index 30d587d..60e8eb0 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 | 12 | ⬜ in progress | +| M2 | 33 | 13 | ⬜ 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** | **58** | | +| **Total** | **194** | **59** | | --- @@ -235,6 +235,8 @@ One line per completed (or split/renumbered) step. | 2026-10-03 | M2-SORT-01 | `—` | Deterministic depth sort (M2-SORT-01 scope, nothing else): **API** — header-only `src/laige-render/include/laige/render/depth_sort.h` (`laige::render`, public, additive): `DepthSort` (move-only; default = the empty capacity-0 stopped state) — `create(capacity)` one flat 16 B/slot allocation at scene set-up (InvalidArgument for capacity 0 or > `kDepthSortMaxCapacity` = 0xFFFFFFFF — the index width), `sort(span keys)` the per-frame O(4n + 4·256) pass: zero allocation, no logging, no GL; overflow `n > capacity` → `BudgetExhausted`, the sorter UNCHANGED (the previous frame's order intact — the caller handles/logs it, LOG-002); the sorted order read back as parallel spans `sortedKeys()`/`sortedIndices()` (the i-th key back-to-front + its original input position — payload-agnostic: the M2-SPRITE-01 batcher maps indices to its sprite pool); **algorithm** — 4 × 8-bit LSD radix (stable bucket) passes: one stable 256-bucket counting sort per 8-bit digit, LSB first (count → in-place prefix → stable scatter → role swap; Knuth TAOCP Vol. 3 §7.2.1; the even-pass-count static_assert keeps the result in the base buffers without a copy-out; 8-bit digits = the 1 KB counter-table sweet spot); **stable tie-break (RENDER-003)** — equal keys keep their INPUT order; the batcher's deterministic entity-id insertion order (FR-1.2) makes the output the (key, entity id) total order without the sorter seeing the ids; **determinism** — pure integer arithmetic: same key sequence → bit-identical sorted order on every platform/build (presentation-only, ARCH-009/010 scope — never in the sim state hash or replay state); **budget** — NEW `budgets.json` entry `depth_sort_10k` (mean, ms, target 1.0 — 6% of the 16.7 ms 60 FPS frame budget of AC-4.3, half of the 2 ms 50k render-CPU budget); the `DepthSortBudget` suite gates it on the Linux non-instrumented trees (`LAIGE_DEPTH_SORT_BUDGET` — the iso_depth_table/iso_picking precedent): 100 warm-up + 3 000 measured sorts of 10 000 precomputed keys (deterministic mixKey finalizer carved to the M2-ISO-01 22-bit fine-depth range — ~2.4 equal keys per value, the realistic overlapping-scene load; no RNG/division in the measured path) — the roadmap's 10k-sprites × 3 000-frames stress test; **tests** — `tests/laige-render/depth_sort_tests.cpp` (5 tests / 5 suites; CTest entry `depth_sort` = the step's Verify command, TSan `halt_on_error=1`, TIMEOUT 120): `DepthSortGolden` (the hand-computed 6-key order {1,3,3,5,5,9}/indices {3,1,4,0,2,5}, the BudgetExhausted-unchanged overflow, the at-capacity sort, the create validation), `DepthSortEdges` (n=0, n=1, all-equal 100, ascending, descending, alternating two keys — oracle-checked; the empty-stopped-state behavior), `DepthSortStability` (the determinism property: same input twice → bit-identical; 64 Fisher-Yates shuffled permutations of a 512-value 4096-key multiset — the sorted-key sequence invariant under shuffling (multiset determinism) + the full (key, index) order == the std::stable_sort oracle of THAT sequence (stability), TestPrng substream), `DepthSortProperty` (10 000 random 32-bit keys vs the oracle, the non-decreasing pin, the 1 000-sort zero-allocation proof under the process-wide allocation watch — non-sanitizer trees), `DepthSortBudget` (the gate + the ungated sanitizer/non-reference runs); `BudgetHarnessTable.LoadsTheRepoBudgetsFile` count updated 15 → 16 (the repo budgets.json entry-count pin — the M2-ISO-02/03 convention); **docs** (DOC-007, same change) — NEW `docs/api/depth_sort.md` (the full contract: the API, the tie-break, the algorithm + digit-width rationale, determinism, ownership/threading, Performance, misuse warnings), `docs/concepts/coordinates.md` §4.6 (the render-order narrative + the conversion table row now shipped) + §6/Related, NEW `docs/benchmarks/baselines/m2-depth-sort.md` (the SEVENTH baseline: measured 0.225852 ms mean canonical Debug g++ n=3000, 4.4× inside the 1.0 ms gate; cross-compiler clang -O0 0.161389 ms, 6.2× inside — the CI-shape evidence; the 50k scaling watch item for M2-PERF-01) + `baselines/README.md` index (+ the retroactive `m2-iso-picking.md` index entry the M2-ISO-03 step omitted — stale-index fix), `docs/README.md` API index, `src/laige-render/README.md` status; `laige-api.json` regenerated (1123 symbols / 35 headers — +17); `api-real-tree`/`api-check-fresh`, `include-lint` (63 files), and `determinism-lint` green; all six local trees verified (build 107/107, build-clang 107/107, build-release 96/96, build-shared 107/107, build-asan 104/104, build-tsan 104/104 — full `ctest` green, no new warnings); **scope note** — the implementation exceeds the roadmap's "~250 lines" sanity note for the same documented-contract reason as M2-CAM-01/02/ISO-03 (the header preamble + the API doc are part of the implementation per CORE-006/DOC-004); Progress Board M2 12/33, total 58/194 | +| 2026-10-03 | M2-SPRITE-01 | `—` | Sprite item + batcher API, "declare, don't draw" (M2-SPRITE-01 scope, nothing else): **API** — header-only `src/laige-render/include/laige/render/sprite_batcher.h` (`laige::render`, public, additive): `SpriteItem` (the declared sprite — world 2D position, the M2-ISO-01 `depthKey`, `depthOverride` flag, `SpriteUvRect` UV sub-rect, `rotation` (rad), `Vec2 scale`, `SpriteTint` RGBA, `atlasId`/`materialId` refs, `BlendMode`) + `SpriteBatch` (one (atlas, material, blend) group: atlas/material/blend + the in-group `instances` span of frame-scoped pool slots) + `SpriteBatcher` (move-only; default = the empty capacity-0 stopped state) — `create(Options{maxSprites})` one allocation per storage structure at scene set-up (the `ArenaPool` pool, the M2-SORT-01 `DepthSort`, the key scratch, the instance array, the group table, the cursor array, the batch array — ~132 B/capacity slot, 6.6 MB at the 50k stress budget; InvalidArgument for capacity 0 or > `kSpriteBatcherMaxCapacity` = 0xFFFFFFFF); the frame protocol `beginFrame()` → `add(item) × n` → `build()`; `add` returns the frame-scoped pool slot and declares the sprite in the engine's deterministic entity-id iteration order (FR-1.2 — the stable tie-break's carrier); **grouping (FR-2.1)** — `build()` sorts the frame's depth keys with `DepthSort`, groups into (atlas, material, blend) batches in DETERMINISTIC order (ascending (atlas, material, blend) — a function of the distinct group keys alone), and scatters each group's instances in GLOBAL back-to-front order (the sorted order RESTRICTED to the group — the (key, entity id) total order per group); one instanced draw call per group at submit (M2-SPRITE-02, RENDER-001); **overflow (PERF-008, S-2)** — bounded, never grows: a frame beyond the budget drops the OLDEST live declaration (ring-head overwrite) + one rate-limited Warn per drop (`sprite_batcher/frame_overflow_dropped`), cumulative in `droppedTotal()`; **G-R11** — a manually-set `depthKey` (`depthOverride`) is COUNTED per frame (`overrideCount()`) + CUMULATIVE (`overrideTotal()`) + WARNED once per frame (`sprite_batcher/depth_override_used`, "prefer tile height") — the counted/warned escape hatch, not the default path; **determinism** — pure integer arithmetic: same declaration sequence → bit-identical batches on every platform/build (presentation-only, ARCH-009/010); **no per-frame allocation** (FR-2.2, PERF-003) — every per-frame path is pre-allocated integer bookkeeping, proven by a 1 000-frame zero-allocation test; **no standalone budget entry** — the sort cost is the `depth_sort_10k` budget, the composite 50k render-CPU budget (2 ms, PRD §8.1 `sprites_50k_cpu`) is measured with the submit stage (M2-PERF-01); `ctest -R batcher` green (grouping correctness N atlases × materials × blends → exact group count, in-group order vs hand-computed orders, drop-oldest + warn, G-R11 counted + warned, stopped/protocol/slot edges, the 3 000-sprite determinism property vs the stable-sort oracle, the zero-alloc proof) — verified across all 6 local trees (build/build-clang/build-release/build-shared/build-asan/build-tsan). API contract in `docs/api/sprite_batcher.md`, module README + `docs/README.md` index + `docs/concepts/coordinates.md` §4.7 updated in the same change | + --- ## 6. Global invariants that apply to *every* step diff --git a/src/laige-render/README.md b/src/laige-render/README.md index 4892068..528199b 100644 --- a/src/laige-render/README.md +++ b/src/laige-render/README.md @@ -246,5 +246,35 @@ API contract in [tests/laige-render](../tests/laige-render) (CTest entry `depth_sort` — pure integer math, no GL environment required). -The sprite batcher, draw submits, and sim→render wiring land in the -remaining M2 steps (M2-SPRITE-01/02 on). +M2-SPRITE-01 landed the sprite item + batcher — the engine-owned +"declare, don't draw" declaration window + batch builder (S-5: scene +content is declared, the engine batches; FR-2.1: one draw call per +(atlas, material, blend) group per frame). Header-only public header +`include/laige/render/sprite_batcher.h`: `SpriteItem` (the declared +sprite — world position, the M2-ISO-01 depth key, UV sub-rect, +rotation, scale, tint, blend, atlas/material refs, the G-R11 +depth-override flag) in a budgeted, accounted `ArenaPool` +(`SpriteBatcher`, `create(Options{maxSprites})` — ~132 B per capacity +slot, 6.6 MB at the 50k stress budget); the frame protocol +`beginFrame()` → `add(item) × n` → `build()`. `build()` sorts the +frame's depth keys with the M2-SORT-01 `DepthSort`, groups into +(atlas, material, blend) batches in deterministic order (ascending +(atlas, material, blend) — RENDER-003), and scatters each group's +instances in global back-to-front order (the sorted order restricted +to the group — the (key, entity id) total order per group, the +entity-id insertion order FR-1.2 carries). The overflow policy is +bounded drop-oldest + warn (PERF-008, S-2); the per-sprite depth +override is counted + warned per frame (G-R11, "prefer tile height"); +every per-frame path allocates nothing (PERF-003 — the +zero-allocation proof). The submit stage (M2-SPRITE-02) reads +`batches()` — one instanced draw call per group. No standalone +`budgets.json` entry: the sort cost is the `depth_sort_10k` budget and +the composite 50k render-CPU budget is measured with the submit stage +(M2-PERF-01). API contract in +[docs/api/sprite_batcher.md](../docs/api/sprite_batcher.md), tests +under [tests/laige-render](../tests/laige-render) (CTest entry +`batcher` — pure integer bookkeeping, no GL environment required). + +The GPU instanced draw submits (M2-SPRITE-02), atlas UV animation +(M2-SPRITE-03), render observability (M2-SPRITE-04), and sim→render +wiring land in the remaining M2 steps. diff --git a/src/laige-render/include/laige/render/iso_depth_key.h b/src/laige-render/include/laige/render/iso_depth_key.h index d1c797d..54191e0 100644 --- a/src/laige-render/include/laige/render/iso_depth_key.h +++ b/src/laige-render/include/laige/render/iso_depth_key.h @@ -180,8 +180,9 @@ // camera motion, and is exactly what this API exists to prevent // (S-5, G-R11). // - Do not hand-roll per-sprite z-ordering in game code (G-R11): -// the per-sprite depth override lands with M2-SPRITE-01 as a -// counted + warned escape hatch ("prefer tile height"). +// the per-sprite depth override is the M2-SPRITE-01 batcher's +// counted + warned escape hatch ("prefer tile height", +// sprite_batcher.h). // - z is the object's STANDING SURFACE elevation (the tile height), // not the object's height — passing the sprite's top elevation // pushes it behind its own base's row. diff --git a/src/laige-render/include/laige/render/sprite_batcher.h b/src/laige-render/include/laige/render/sprite_batcher.h new file mode 100644 index 0000000..05c2b2c --- /dev/null +++ b/src/laige-render/include/laige/render/sprite_batcher.h @@ -0,0 +1,789 @@ +// laige-render sprite batcher (M2-SPRITE-01): the engine-owned +// "declare, don't draw" sprite declaration window + batch builder. +// +// S-5 (PRD §9.1): "Rendering goes through the batcher. Scene content +// is declared (layers, sprites, tiles, depth, material); the engine +// batches. There is no 'draw this quad now' call in the safe API." +// FR-2.1: "Batched textured quads; one draw call per (atlas, material, +// blend) group per frame; GPU-instanced; supports rotation, scale, +// tint, per-sprite depth, UV sub-rect". FR-2.2: "no per-frame +// allocation". G-R11: isometric depth is engine-owned — the per-sprite +// depth override exists as a COUNTED + WARNED escape hatch ("prefer +// tile height"). RENDER-003: deterministic ordering, explicit stable +// tie-break. RENDER-001: one draw call per group (the submit stage's +// read, M2-SPRITE-02). +// +// BlendMode The per-sprite blend state (group-key field 3) +// SpriteUvRect The per-sprite UV sub-rect in the atlas +// SpriteTint The per-sprite RGBA tint +// SpriteItem One declared sprite (the value the game declares) +// SpriteBatch One output group: (atlas, material, blend) + the +// in-group instance list (back-to-front) +// SpriteBatcher The frame declaration window + batch builder +// +// --------------------------------------------------------------------------- +// The frame protocol (the frame pipeline's cull/batch stage, M2-GL-02) +// --------------------------------------------------------------------------- +// +// One batcher per sprite pass, owned by the render thread's cull/batch +// stage (the frame pipeline's stage callback). Per frame: +// +// beginFrame() close the previous window, open a new one (the +// previous frame's declared items are released — +// the pool's reset) +// add(item) × n declare the frame's n sprites IN THE ENGINE'S +// DETERMINISTIC ENTITY-ID ITERATION ORDER (FR-1.2) — +// the declaration order is the insertion order that +// M2-SORT-01's stability turns into the (key, entity +// id) total order (RENDER-003) +// build() sort the frame's depth keys (M2-SORT-01), group +// into (atlas, material, blend) batches, publish +// batches() for the submit stage (M2-SPRITE-02) +// +// The declared items are PRESENTATION state (ARCH-009): the game +// re-declares from its per-frame snapshot (the interpolated positions, +// M1-LOOP-02) — nothing in the batcher survives a frame except the +// since-construction counters (G-R11/G-R4-style accounting, PRD §10.4). +// +// --------------------------------------------------------------------------- +// The batch model: groups, order, determinism (RENDER-001/003) +// --------------------------------------------------------------------------- +// +// build() produces the frame's GROUPS — one per DISTINCT +// (atlas, material, blend) combination — and, per group, the group's +// instances in the frame's GLOBAL back-to-front order (the M2-SORT-01 +// sorted order RESTRICTED to the group). The submit stage then makes +// one instanced draw call per group (FR-2.1, M2-SPRITE-02): the +// per-group (atlas, material, blend) state is set once, so texture +// binds and blend changes are O(group count), not O(sprite count) +// (RENDER-001). +// +// Deterministic (RENDER-003, ARCH-010 scope — presentation-only, never +// the sim state hash or replay state): +// +// - the SORT is the M2-SORT-01 stable radix sort: a pure function of +// the key SEQUENCE (same sequence → bit-identical order, every +// platform/build); +// - the group TABLE is built in ascending (atlas, material, blend) +// order — the group order is a function of the DISTINCT group +// keys alone (independent of the frame's membership or insertion +// order); +// - the in-group INSTANCE order is the global sorted order +// restricted to the group: equal keys keep their DECLARATION +// order (the stable sort + the FR-1.2 entity-id insertion order — +// the (key, entity id) total order of iso_depth_key.h). +// +// Two frames with the same scene state (same items, same declaration +// order) produce bit-identical batches. Pure integer arithmetic over +// the item data: the item's floats (pos, uv, rotation, scale, tint) +// are carried through untouched — no float arithmetic here. +// +// --------------------------------------------------------------------------- +// Overflow: bounded, drop oldest + warn (PERF-008, S-2) +// --------------------------------------------------------------------------- +// +// The frame budget is fixed at set-up (Options::maxSprites — the +// declared scene budget, S-6, API-006). A frame that declares MORE +// than the budget does not grow, fail, or truncate silently: each +// excess declaration DROPS THE OLDEST live declaration (the ring head +// is overwritten) and takes the new one — one rate-limited Warn per +// drop (LOG-004), the cumulative count in droppedTotal(). The visible +// set is bounded by construction (never unbounded, PERF-008); the +// degradation is logged (CORE-008) and accounted (PRD §10.4). +// +// --------------------------------------------------------------------------- +// G-R11: the counted + warned depth-override escape hatch +// --------------------------------------------------------------------------- +// +// The normal path: the caller computes the item's depth key with the +// engine-owned `isoDepthKey` (M2-ISO-01) and declares it. The +// escape hatch: the caller may set `depthKey` BY HAND (e.g. a +// hand-tuned UI z) and flag `depthOverride = true`. The batcher +// SORTS the key either way (the order is what the key says) but COUNTS +// every override declaration (overrideCount() per frame, overrideTotal() +// cumulative) and WARNS once per frame that overrides were used +// (`sprite_batcher/depth_override_used`, rate-limited — LOG-004), +// advising "prefer tile height" (PRD §9.3 G-R11: per-sprite depth +// overrides counted + warned). +// +// --------------------------------------------------------------------------- +// Storage layout (CORE-005, PERF-004; ~132 bytes per capacity slot — +// 6.6 MB at the 50k stress budget, PRD §8.1) +// --------------------------------------------------------------------------- +// +// sprite pool ArenaPool, 72 B/slot (budgeted, +// accounted — PRD §10.4; the pool's reset() is the +// per-frame release) +// key scratch capacity u32 (the frame's keys in declaration order +// — the M2-SORT-01 input) +// instance order capacity u32 (the published per-group instance +// segments, back-to-front) +// group table capacity GroupDesc (20 B: atlas, material, blend, +// start, count — the working table; the published +// view is SpriteBatch) +// group cursor capacity u32 (the scatter cursor, zeroed per group) +// batch array capacity SpriteBatch (28 B: the published groups) +// +// One allocation per structure at set-up; EVERY per-frame path +// (beginFrame, add, build) allocates nothing (PERF-003 — the +// zero-allocation proof test). +// +// --------------------------------------------------------------------------- +// Ownership, threading, failure (CORE-009, CONC-001, CORE-008) +// --------------------------------------------------------------------------- +// +// One owner: the render thread's cull/batch stage (the frame pipeline, +// M2-GL-02). Set-up: `create` (one allocation per structure). Render +// phase: `beginFrame` / `add` / `build` (the declaration + batch +// stages — after the tick→handoff, before the submit stage). The phases +// never overlap and the batcher is never shared with a concurrent +// writer — not thread-safe by design (the single-owner pattern of +// DepthSort and IsoDepthKeyTable). +// +// Failure (no silent failure, CORE-008): +// +// - create(maxSprites 0 or > kSpriteBatcherMaxCapacity) → +// InvalidArgument (no allocation); +// - add() on the EMPTY (default-constructed, capacity 0) batcher → +// BudgetExhausted (the stopped-state pattern — total, never UB); +// - add() with the window CLOSED (after build) → InvalidArgument +// (call beginFrame first — the frame protocol is explicit); +// - frame overflow → drop oldest + one rate-limited Warn (above); +// - build() with the window closed (double build) → InvalidArgument; +// - build() sort overflow (n > capacity) → BudgetExhausted — +// unreachable: the ring keeps n ≤ capacity (the overflow policy +// is what bounds n). +// +// The moved-from batcher is the stopped state (capacity 0 — the +// ArenaPool/DepthSort move contracts). +// +// --------------------------------------------------------------------------- +// Performance (PERF-002/003/004, DOC-004) +// --------------------------------------------------------------------------- +// +// create O(maxSprites): one allocation per structure (6 flat +// buffers + the pool + the sorter). Setup path only. +// beginFrame O(previous frame count): the pool reset (trivial +// destructors) + counter resets. No allocation. +// add O(1): one pool create (first frame's fill) or one ring +// overwrite. No allocation; the overflow Warn is a cold, +// rate-limited path (LOG-003: a disabled event costs one +// atomic load + branch). +// build O(4n + 4·256) the M2-SORT-01 radix sort +// + O(n·log G) two group lookups per instance (binary +// search over the ≤ G-group table) +// + O(G·(log G + G)) the group-table growth (a binary +// search + an O(G) descriptor shift per NEW +// group) +// + O(n) start prefix + instance scatter +// +// n = the frame's declared sprites (≤ capacity), G = the frame's +// DISTINCT (atlas, material, blend) count. G is the scene's +// atlas × material × blend combination count — small by design +// (the PRD §8.1 50k-stress budget caps draw calls at 30, so +// G ≪ n in every realistic scene). Zero allocation, no logging on +// the path, no locks, no GL. +// +// Call site: once per frame per sprite pass, in the RENDER phase — +// never per sprite, never in the simulation tick (ARCH-002). The +// sort dominates (the M2-SORT-01 `depth_sort_10k` budget); the +// grouping adds O(n log G) — the composite 50k render-CPU budget +// (2 ms, PRD §8.1 `sprites_50k_cpu`) is measured when the submit +// stage lands (M2-PERF-01). +// +// --------------------------------------------------------------------------- +// Misuse warnings +// --------------------------------------------------------------------------- +// +// - Do not hand-roll z-ordering in game code (G-R11): compute the +// key with `isoDepthKey` (M2-ISO-01). The manual +// depthKey + depthOverride is the counted + warned escape hatch +// ("prefer tile height") — not the default path. +// - Do not derive the key from screen-space coordinates (PRD §4): +// the key is world-space by contract (the M2-ISO-01 domain). +// - Declare in the deterministic entity-id iteration order +// (FR-1.2): a nondeterministic declaration order makes the +// equal-key (same screen row) order nondeterministic — RENDER-003 +// breaks in exactly the places painter's order is visible. +// - Do not declare more than the frame budget without a real +// reason: overflow drops the OLDEST declarations (the scene is +// visibly truncated) — size the budget to the scene's worst-case +// visible count at set-up (API-006). +// - Read the output (batches(), at(), get()) only WITHIN the frame — +// the next beginFrame()/build() invalidates it. +// +// Canonical narrative: docs/concepts/coordinates.md §4.7 (ARCH-008); +// API contract: docs/api/sprite_batcher.md. + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/pools.h" +#include "laige/render/depth_sort.h" +#include "laige/render/matrices.h" +#include "laige/result.h" + +namespace laige::render { + +// --------------------------------------------------------------------------- +// Named constants (CORE-005) +// --------------------------------------------------------------------------- + +// 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). +inline constexpr std::uint32_t kSpriteBatcherMaxCapacity = 0xFFFFFFFFu; + +// --------------------------------------------------------------------------- +// The per-sprite blend state (FR-2.1: one draw call per +// (atlas, material, BLEND) group) +// --------------------------------------------------------------------------- + +// 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. +enum class BlendMode : std::uint8_t { + // Standard alpha blend (src·srcAlpha + dst·(1 − srcAlpha)): the + // default for textured sprites. + Alpha = 0, + // Additive (src + dst): particles, light, glow (M2-PART-01). + Additive = 1, +}; + +// --------------------------------------------------------------------------- +// The per-sprite presentation values +// --------------------------------------------------------------------------- + +// The sprite's UV sub-rect inside its atlas, normalized [0, 1]² +// (u1 > u0, v1 > v0 — the caller's invariant; M2-SPRITE-03 computes +// these from the atlas sheet frame layout). +struct SpriteUvRect { + float u0{}; + float v0{}; + float u1{}; + float v1{}; +}; + +// The sprite's multiplicative RGBA tint (1, 1, 1, 1 = untinted). +struct SpriteTint { + float r{1.0f}; + float g{1.0f}; + float b{1.0f}; + float a{1.0f}; +}; + +// --------------------------------------------------------------------------- +// One declared sprite (M2-SPRITE-01) +// --------------------------------------------------------------------------- + +// 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. +struct SpriteItem { + // The world ground-plane position, world units (the presentation + // snapshot's interpolated position, M1-LOOP-02 — NEVER screen + // space, PRD §4). + Vec2 pos{0.0f, 0.0f}; + // 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. + std::uint32_t depthKey{}; + // 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. + bool depthOverride{}; + // The UV sub-rect in the atlas (SpriteUvRect above). + SpriteUvRect uv{}; + // The rotation in radians (0 = unrotated; the submit stage applies + // it in screen space, M2-SPRITE-02). + float rotation{}; + // The world-unit scale (x, y) — non-uniform free (1, 1 = unscaled). + Vec2 scale{1.0f, 1.0f}; + // The multiplicative RGBA tint (SpriteTint above). + SpriteTint tint{}; + // The atlas/texture reference (a stable asset handle — the asset + // system lands with M3-ASSET-01; for now the game assigns it). + std::uint32_t atlasId{}; + // The material reference (0 = the default material; the material + // system is future work — the group key carries it per FR-2.1). + std::uint32_t materialId{}; + // The blend state (the group key's third field). + BlendMode blend{}; +}; + +// --------------------------------------------------------------------------- +// One output group (the submit stage's read, M2-SPRITE-02) +// --------------------------------------------------------------------------- + +// 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). +struct SpriteBatch { + std::uint32_t atlasId{}; + std::uint32_t materialId{}; + BlendMode blend{}; + // 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. + std::span instances{}; +}; + +namespace detail { + +// One group's working descriptor (build's internal table — the public +// view is SpriteBatch). The table is kept in ascending +// (atlas, material, blend) order (the deterministic group order, +// RENDER-003). 20 bytes. +struct GroupDesc { + std::uint32_t atlasId{}; + std::uint32_t materialId{}; + BlendMode blend{}; + std::uint32_t start{}; // the group's first instance-order position + std::uint32_t count{}; // the group's instance count +}; + +} // namespace detail + +// --------------------------------------------------------------------------- +// The sprite batcher (M2-SPRITE-01) +// --------------------------------------------------------------------------- + +// 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). +// +// Move-only (CORE-009): the batcher owns the sprite pool, the sorter, +// and the flat frame storage; it is created by create() and handed to +// its owner (the cull/batch stage). +class SpriteBatcher { + public: + // 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). + struct Options { + std::uint32_t maxSprites{}; + }; + + // 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. + SpriteBatcher() noexcept : items_(ArenaPool::Options{0}) {} + + // The set-up path (scene load): one allocation per storage + // structure (the preamble's layout — ~132 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. + [[nodiscard]] static Result create(Options options) + noexcept; + + // 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. + void beginFrame() noexcept; + + // 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). + // + // Overflow policy (PERF-008, S-2 — never grow unbounded): a frame + // beyond the budget drops the OLDEST live declaration (the ring + // head is overwritten) and takes the new one; one rate-limited Warn + // per drop (`sprite_batcher/frame_overflow_dropped`, LOG-004), the + // cumulative count in droppedTotal(). + // + // Fails: capacity 0 → BudgetExhausted; window closed (after build) + // → InvalidArgument (call beginFrame first). Returns the + // frame-scoped slot (read the item with at(slot)/get(slot); valid + // until it leaves the ring window or the next beginFrame). + // + // @budget O(1); no allocation on the success path (the pool create + // is the first frame's fill). + [[nodiscard]] Result add(SpriteItem item) noexcept; + + // 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. + // + // Fails: window closed (double build) → InvalidArgument; sort + // overflow (n > capacity — unreachable: the ring keeps n ≤ + // capacity) → BudgetExhausted. + // + // @budget O(4n + 4·256 + n·log G + G·(log G + G)), G = the frame's + // distinct (atlas, material, blend) count; zero allocation, no GL, + // the G-R11 warn is a cold rate-limited path (LOG-003). + [[nodiscard]] Status build() noexcept; + + // The frame's output (read within the frame; invalidated by the + // next beginFrame()/build()). + // @budget O(1). + [[nodiscard]] std::size_t batchCount() const noexcept { return batchCount_; } + // @budget O(1); the span aliases the batcher's batch array. + [[nodiscard]] std::span batches() const noexcept { + return std::span(batches_.get(), batchCount_); + } + // The frame's declared count, after the overflow drops. + // @budget O(1). + [[nodiscard]] std::size_t frameCount() const noexcept { return count_; } + // G-R11: the current frame's manual depth-override declaration + // count (reset by beginFrame). + // @budget O(1). + [[nodiscard]] std::uint32_t overrideCount() const noexcept { + return overrideCount_; + } + // G-R11: the since-construction override total (the profiler's + // cumulative guardrail feed, PRD §9.3). + // @budget O(1). + [[nodiscard]] std::uint64_t overrideTotal() const noexcept { + return overrideTotal_; + } + // The since-construction dropped-oldest total (the overflow policy's + // accounting, the preamble). + // @budget O(1). + [[nodiscard]] std::uint64_t droppedTotal() const noexcept { + return droppedTotal_; + } + // The frame budget (the create() argument; 0 in the stopped state). + // @budget O(1). + [[nodiscard]] std::uint32_t capacity() const noexcept { return capacity_; } + // The sprite pool's accounting snapshot (PRD §10.4, DBG-008). + // @budget O(1); no allocation. + [[nodiscard]] PoolStats itemPoolStats() const noexcept { + return items_.stats(); + } + // 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. + [[nodiscard]] const SpriteItem* get(std::uint32_t slot) noexcept; + // 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. + [[nodiscard]] const SpriteItem& at(std::uint32_t slot); + + SpriteBatcher(const SpriteBatcher&) = delete; + SpriteBatcher& operator=(const SpriteBatcher&) = delete; + // 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). + SpriteBatcher(SpriteBatcher&&) noexcept = default; + SpriteBatcher& operator=(SpriteBatcher&&) noexcept = default; + + private: + // Constructed by create() only (the capacity is validated there — + // 1..kSpriteBatcherMaxCapacity, so every allocation below is sized). + explicit SpriteBatcher(std::uint32_t capacity, DepthSort sorter) noexcept + : capacity_(capacity), + frameOpen_(true), + items_(ArenaPool::Options{capacity}), + sorter_(std::move(sorter)), + keyScratch_(std::make_unique(capacity)), + instanceOrder_(std::make_unique(capacity)), + groups_(std::make_unique(capacity)), + groupCursor_(std::make_unique(capacity)), + batches_(std::make_unique(capacity)) {} + + // The pool slot of the i-th declaration (0 = the oldest, in the ring + // window order — the declaration/insertion order, FR-1.2). Only + // called with capacity_ > 0 and i < count_ (the build() guards). + std::uint32_t slotAt(std::uint32_t i) const noexcept { + return static_cast( + (static_cast(head_) + i) % capacity_); + } + + // 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, + std::uint32_t material, + std::uint8_t blend) noexcept { + if (g.atlasId != atlas) return g.atlasId < atlas; + if (g.materialId != material) return g.materialId < material; + return static_cast(g.blend) < blend; + } + static bool groupEqual(const detail::GroupDesc& g, std::uint32_t atlas, + std::uint32_t material, + std::uint8_t blend) noexcept { + return g.atlasId == atlas && g.materialId == material && + g.blend == static_cast(blend); + } + + // The group index of (atlas, material, blend) in the sorted table + // [0, groupCount_), inserting a NEW group at the search position + // (the table stays sorted — the deterministic group order). A new + // group appears at most once per frame and the distinct-group count + // never exceeds n ≤ capacity_, so lo < capacity_ (the table has + // room) at every insertion. + // + // ponytail: the insertion is an O(G) descriptor shift (20 B each) — + // G is the scene's (atlas × material × blend) combination count, + // small by design (the PRD §8.1 50k-stress budget caps draw calls at + // 30, so G << n in every realistic scene); if a pathological G ever + // measures hot, upgrade to an open-addressing table (RENDER-001: the + // state-change count is the metric that would show it). + std::uint32_t findOrInsertGroup(std::uint32_t atlas, std::uint32_t material, + BlendMode blend) noexcept { + std::uint32_t lo = 0; + std::uint32_t hi = groupCount_; + while (lo < hi) { + const std::uint32_t mid = lo + (hi - lo) / 2; + if (groupLess(groups_[mid], atlas, material, + static_cast(blend))) { + lo = mid + 1; + } else { + hi = mid; + } + } + if (lo < groupCount_ && + groupEqual(groups_[lo], atlas, material, + static_cast(blend))) { + return lo; + } + std::memmove(&groups_[lo + 1], &groups_[lo], + static_cast(groupCount_ - lo) * + sizeof(detail::GroupDesc)); + groups_[lo] = detail::GroupDesc{atlas, material, blend, 0, 0}; + ++groupCount_; + return lo; + } + + // 0 in the default (stopped) state. + std::uint32_t capacity_{0}; + // The declaration window is open: after create()/beginFrame(), until + // build(). false in the default state. + bool frameOpen_{false}; + // The ring head: the pool slot of the frame's OLDEST declaration + // (0 while the frame has never been full — head_ advances only on + // overflow). + std::uint32_t head_{0}; + // The frame's declared count (≤ capacity_; == capacity_ only when + // the frame is full). + std::uint32_t count_{0}; + // The group table's live size (the sorted prefix of groups_). + std::uint32_t groupCount_{0}; + // The published batch count (== groupCount_ after build; 0 before). + std::uint32_t batchCount_{0}; + // The current frame's manual depth-override declaration count + // (G-R11; reset by beginFrame). + std::uint32_t overrideCount_{0}; + // The since-construction override total (G-R11). + std::uint64_t overrideTotal_{0}; + // The since-construction dropped-oldest total (the overflow policy). + std::uint64_t droppedTotal_{0}; + // The declared items (budgeted, accounted — PRD §10.4). The pool's + // reset() is the per-frame release; the batcher's ring (head_, + // count_) is the frame window over the pool's slots. + ArenaPool items_; + // The frame's 32-bit depth keys, sorted back-to-front (M2-SORT-01; + // created with the same capacity, so n ≤ capacity_ always). + DepthSort sorter_; + // The frame's keys in declaration (ring window) order — the sorter's + // input (pre-allocated, PERF-003). + std::unique_ptr keyScratch_; + // The published per-group instance segments (pool slots, back-to- + // front — the preamble's storage layout). + std::unique_ptr instanceOrder_; + // The working group table (the preamble's layout). + std::unique_ptr groups_; + // The per-group scatter cursor (zeroed for [0, groupCount_) per + // build). + std::unique_ptr groupCursor_; + // The published batch descriptors (the batches() span aliases this). + std::unique_ptr batches_; +}; + +// --------------------------------------------------------------------------- +// Implementation (the class is header-only — the batch is pure integer +// bookkeeping over the pre-allocated storage) +// --------------------------------------------------------------------------- + +inline Result SpriteBatcher::create(Options options) noexcept { + // Capacity validation (first failure wins, InvalidArgument — no + // allocation on the failure path): the slot-width domain. + const std::uint32_t capacity = options.maxSprites; + if (capacity < 1 || capacity > kSpriteBatcherMaxCapacity) { + return Result::failure(ErrorCode::InvalidArgument); + } + auto sorter = DepthSort::create(capacity); + if (!sorter.ok()) { + return Result::failure(sorter.error()); + } + return Result::success( + SpriteBatcher(capacity, std::move(sorter).takeValue())); +} + +inline void SpriteBatcher::beginFrame() noexcept { + // Release the previous frame's declared items (the pool's reset — + // O(inUse), trivial destructors) and open a fresh window. The + // since-construction counters (overrideTotal_, droppedTotal_) + // survive: they are the profiler's cumulative feeds (PRD §10.4). + items_.reset(); + head_ = 0; + count_ = 0; + groupCount_ = 0; + batchCount_ = 0; + overrideCount_ = 0; + frameOpen_ = true; +} + +inline Result SpriteBatcher::add(SpriteItem item) noexcept { + if (capacity_ == 0) { + // The stopped state (the preamble's failure section). + return Result::failure(ErrorCode::BudgetExhausted); + } + if (!frameOpen_) { + // The frame protocol is explicit: build() closed the window — + // call beginFrame() before the next frame's declarations. + return Result::failure(ErrorCode::InvalidArgument); + } + // G-R11: count the manual depth-override declarations (per frame + + // cumulative — the warn fires at build, the preamble). + if (item.depthOverride) { + ++overrideCount_; + ++overrideTotal_; + } + if (count_ < capacity_) { + // The frame is not full: the next pool slot (the invariant — the + // pool's inUse tracks count_ 1:1 while the frame is not full). + const std::uint32_t slot = count_; + const auto r = items_.create(item); + if (!r.ok()) { + // Unreachable (the invariant above) — keep the failure channel + // honest (CORE-008). + return Result::failure(r.error()); + } + ++count_; + return Result::success(slot); + } + // The frame is full: the documented overflow policy (the preamble) — + // drop the OLDEST declaration (the ring head) and take the new one. + // Bounded, never growing (PERF-008); logged (LOG-002/004 — the + // facade rate-limits the repeats). + const std::uint32_t slot = head_; + items_.at(slot) = item; + head_ = static_cast( + (static_cast(head_) + 1) % capacity_); + ++droppedTotal_; + LAIGE_LOG_WARN("sprite_batcher", "frame_overflow_dropped", + "Sprite declaration exceeded the frame budget; the oldest " + "declaration was dropped", + laige::log::field("capacity", capacity_), + laige::log::field("frame_count", count_), + laige::log::field("dropped_total", droppedTotal_)); + return Result::success(slot); +} + +inline Status SpriteBatcher::build() noexcept { + if (capacity_ > 0 && !frameOpen_) { + // Double build: the window was closed by the previous build — + // call beginFrame() first (the frame protocol is explicit). + return Status(ErrorCode::InvalidArgument); + } + const std::uint32_t n = count_; + batchCount_ = 0; + groupCount_ = 0; + if (n == 0) { + frameOpen_ = false; + return Status{}; + } + // 1. The frame's keys in declaration (ring window) order — the + // sorter's INPUT order (FR-1.2: the entity-id iteration order — + // the stable tie-break's carrier, M2-SORT-01). + for (std::uint32_t i = 0; i < n; ++i) { + keyScratch_[i] = items_.at(slotAt(i)).depthKey; + } + const Status s = sorter_.sort( + std::span(keyScratch_.get(), n)); + if (!s.ok()) return s; // BudgetExhausted unreachable (n <= capacity_) + const auto sorted = sorter_.sortedIndices(); + // 2. Pass 1: the group membership counts (the table grows in sorted + // (atlas, material, blend) order — the deterministic group order). + for (std::uint32_t i = 0; i < n; ++i) { + const SpriteItem& it = items_.at(slotAt(sorted[i])); + const std::uint32_t g = + findOrInsertGroup(it.atlasId, it.materialId, it.blend); + ++groups_[g].count; + } + // 3. The groups' start offsets (the prefix) + the zero cursors. + std::uint32_t start = 0; + for (std::uint32_t g = 0; g < groupCount_; ++g) { + groups_[g].start = start; + groupCursor_[g] = 0; + start += groups_[g].count; + } + // 4. Pass 2: the instance scatter — each instance lands in its + // group's segment in GLOBAL back-to-front order (the sorted order + // restricted to the group; the (key, declaration order) total + // order, the preamble). + for (std::uint32_t i = 0; i < n; ++i) { + const std::uint32_t pos = sorted[i]; + const SpriteItem& it = items_.at(slotAt(pos)); + const std::uint32_t g = + findOrInsertGroup(it.atlasId, it.materialId, it.blend); + instanceOrder_[groups_[g].start + groupCursor_[g]++] = slotAt(pos); + } + // 5. The published batch descriptors (the submit stage's read, + // M2-SPRITE-02). + for (std::uint32_t g = 0; g < groupCount_; ++g) { + batches_[g] = SpriteBatch{ + groups_[g].atlasId, groups_[g].materialId, groups_[g].blend, + std::span( + instanceOrder_.get() + groups_[g].start, groups_[g].count)}; + } + batchCount_ = groupCount_; + frameOpen_ = false; + // G-R11: the manual depth-override escape hatch is counted + + // warned (the preamble — the facade rate-limits the per-frame + // repeats, LOG-004). + if (overrideCount_ > 0) { + LAIGE_LOG_WARN("sprite_batcher", "depth_override_used", + "Per-sprite depth override in use; prefer tile height " + "(isoDepthKey)", + laige::log::field("count", overrideCount_), + laige::log::field("override_total", overrideTotal_)); + } + return Status{}; +} + +inline const SpriteItem* SpriteBatcher::get(std::uint32_t slot) noexcept { + if (capacity_ == 0 || slot >= capacity_) return nullptr; + // The slot is in the current frame window iff its ring age (distance + // from the head, forward) is < the frame count. + const std::uint32_t age = static_cast( + (static_cast(slot) - static_cast(head_)) % + static_cast(capacity_)); + return (age < count_) ? &items_.at(slot) : nullptr; +} + +inline const SpriteItem& SpriteBatcher::at(std::uint32_t slot) { + const SpriteItem* p = get(slot); + assert(p != nullptr && + "SpriteBatcher::at: slot is not live in the current frame"); + return *p; +} + +} // namespace laige::render diff --git a/tests/laige-render/CMakeLists.txt b/tests/laige-render/CMakeLists.txt index b4cbc8e..de18e97 100644 --- a/tests/laige-render/CMakeLists.txt +++ b/tests/laige-render/CMakeLists.txt @@ -61,7 +61,19 @@ # 10k-key oracle comparison + the zero-allocation proof, and the PRD # §8.1 `depth_sort_10k` budget gate — 10 000 keys sorted per frame, # 3 000 frames, mean <= 1.0 ms) — pure integer math: no GL -# environment needed. +# environment needed; and the sprite item + batcher (M2-SPRITE-01) — +# the engine-owned "declare, don't draw" declaration window + batch +# builder (sprite_batcher_tests.cpp's SpriteBatcher* suites: the +# grouping correctness (N atlases x materials x blends -> the exact +# group count, the deterministic group order, the per-group +# membership), the in-group instance order against hand-computed +# expected orders (the M2-SORT-01 sorted order restricted to the +# group, stable on equal keys), the bounded drop-oldest + warn +# overflow policy, the G-R11 counted + warned depth-override escape +# hatch, the stopped state / create validation / frame protocol / +# slot access edges, the determinism property on 3 000-sprite frames +# against the stable-sort oracle, and the 1 000-frame zero-allocation +# proof) — pure integer bookkeeping: no GL environment needed. # # One executable per module (tests/README.md): laige-render_tests links # the module under test plus gtest_main. The unfiltered entry runs the @@ -76,10 +88,11 @@ # `iso_camera` entry is the M2-CAM-02 Verify command # (`ctest -R iso_camera`), the `projection` entry is the M2-PROJ-01 # Verify command (`ctest -R projection`), the `iso_picking` entry is -# the M2-ISO-03 Verify command (`ctest -R iso_picking`), and the +# the M2-ISO-03 Verify command (`ctest -R iso_picking`), the # `depth_sort` entry is the M2-SORT-01 Verify command -# (`ctest -R depth_sort`), each selecting exactly its suites from the -# shared executable. +# (`ctest -R depth_sort`), and the `batcher` entry is the +# M2-SPRITE-01 Verify command (`ctest -R batcher`), each selecting +# exactly its suites from the shared executable. # # Environment note: the GlContextSmoke and RenderThreadOffscreen suites # require a usable OpenGL 3.3 environment — always present on the P0 CI @@ -94,7 +107,7 @@ add_executable(laige-render_tests gl_context_tests.cpp render_thread_tests.cpp matrices_tests.cpp iso_depth_key_tests.cpp iso_depth_table_tests.cpp camera_tests.cpp iso_camera_tests.cpp projection_tests.cpp iso_picking_tests.cpp - depth_sort_tests.cpp) + depth_sort_tests.cpp sprite_batcher_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 @@ -196,6 +209,15 @@ add_test(NAME depth_sort set_tests_properties(depth_sort PROPERTIES ENVIRONMENT "LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json") +# M2-SPRITE-01: the step's Verify command is `ctest -R batcher`. Pure +# integer bookkeeping over pre-allocated storage (no GL calls) — +# runs in every local tree and in CI. No budget gate: the step's +# roadmap scope has no standalone budget entry (the composite 50k +# render-CPU budget of PRD §8.1 is measured with the submit stage, +# M2-PERF-01). +add_test(NAME batcher + COMMAND laige-render_tests --gtest_filter=SpriteBatcher*) + 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) @@ -215,14 +237,14 @@ if(LAIGE_TSAN) # The M2-GL-02 `render_thread` entry is the step's TSan gate (the # lock-free handoff's race-freedom, the 3000-frame no-deadlock run); # the pure-math `matrices`, `iso_depth_key`, `iso_depth_table`, - # `camera`, `iso_camera`, `projection`, `iso_picking`, and - # `depth_sort` entries carry the option for uniformity (they have + # `camera`, `iso_camera`, `projection`, `iso_picking`, `depth_sort`, + # and `batcher` entries carry the option for uniformity (they have # no shared state, but the flag is harmless). The test-level # ENVIRONMENT replaces the job-level one, so the budgets path is # restated here. set_tests_properties(laige-render_tests gl_context render_thread matrices iso_depth_key iso_depth_table camera iso_camera projection - iso_picking depth_sort PROPERTIES + iso_picking depth_sort batcher PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1;LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json") endif() @@ -253,10 +275,11 @@ endif() # The offscreen smoke creates a real (software) context + FBO; give a # slow headless runner generous headroom. The pure-math `matrices`, -# `iso_depth_key`, `camera`, `iso_camera`, `projection`, and -# `iso_picking` entries are small (thousands of O(1) computations, +# `iso_depth_key`, `camera`, `iso_camera`, `projection`, `iso_picking`, +# and `batcher` entries are small (thousands of O(1) computations, # comparisons, and O(1) camera / projection updates and picks at most, -# plus 10k-cell round trips) — 60s is generous headroom. +# plus 10k-cell round trips and a few hundred-sprite batch builds) — +# 60s is generous headroom. set_tests_properties(laige-render_tests gl_context PROPERTIES TIMEOUT 180) set_tests_properties(render_thread PROPERTIES TIMEOUT 300) set_tests_properties(matrices PROPERTIES TIMEOUT 60) @@ -265,6 +288,7 @@ set_tests_properties(camera PROPERTIES TIMEOUT 60) set_tests_properties(iso_camera PROPERTIES TIMEOUT 60) set_tests_properties(projection PROPERTIES TIMEOUT 60) set_tests_properties(iso_picking PROPERTIES TIMEOUT 60) +set_tests_properties(batcher PROPERTIES TIMEOUT 60) # The `iso_depth_table` and `depth_sort` entries include their budget # gates: 2 backends x (100 warm-up + 3000 measured) iterations of 10 000 # setTile calls, and 3 000 sorts of 10 000 keys respectively — a few diff --git a/tests/laige-render/sprite_batcher_tests.cpp b/tests/laige-render/sprite_batcher_tests.cpp new file mode 100644 index 0000000..82756f3 --- /dev/null +++ b/tests/laige-render/sprite_batcher_tests.cpp @@ -0,0 +1,727 @@ +// laige-render sprite batcher tests (M2-SPRITE-01): the engine-owned +// "declare, don't draw" sprite declaration window + batch builder in +// laige/render/sprite_batcher.h. +// +// Pure integer bookkeeping over pre-allocated storage — no GL context, +// no GL environment needed: every suite runs in every local tree and +// in CI. The groups suite pins the roadmap's grouping correctness +// (N atlases × materials × blends produce the exact group count, the +// deterministic group order, the per-group membership); the order +// suite pins the in-group instance order against hand-computed +// expected orders (the M2-SORT-01 sorted order restricted to the +// group, stable on equal keys); the overflow suite pins the +// documented drop-oldest + warn policy (PERF-008); the overrides +// suite pins the G-R11 counted + warned escape hatch; the edges suite +// pins the stopped state, the create validation, and the frame +// protocol (closed window, double build, slot access); the +// determinism suite pins the same-declarations → identical-batches +// property against the stable-sort oracle; the zero-allocation suite +// proves the per-frame paths allocate nothing (PERF-003, the +// iso_picking / depth_sort test precedent). +// +// Seed: the repo-wide documented default seed via +// tests/support/laige_test_seed.h (docs/testing.md §4), one named +// substream per randomized suite. + +#include "laige/render/sprite_batcher.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/alloc_watch.h" +#include "laige/errors.h" +#include "laige/prng.h" +#include "laige_test_seed.h" + +namespace { + +using laige::render::BlendMode; +using laige::render::SpriteBatch; +using laige::render::SpriteBatcher; +using SpriteBatcherOptions = laige::render::SpriteBatcher::Options; +using laige::render::SpriteItem; +using laige::render::SpriteTint; +using laige::render::SpriteUvRect; +using laige::render::Vec2; + +// The randomized suites' substream ids (docs/testing.md §4 — stable +// named constants, CORE-005). +constexpr std::uint32_t kDeterminismSubstreamId = 0x53425444; // "SBDT" +constexpr std::uint32_t kZeroAllocSubstreamId = 0x5342544A; // "SBZA" + +// A deterministic 32-bit mix of i (the key generator — the +// precomputed workloads are built OUTSIDE every measured window: no +// RNG and no division in the measured path, the depth_sort test +// discipline). The splitmix64 finalizer (Stafford 2018) spreads the +// low bits of i, and the mask carves a realistic isometric key range +// (the M2-ISO-01 fine-depth field is 22 bits — the ~2.4 equal-key +// pairs per key of a 3000-sprite scene). +std::uint32_t mixKey(std::uint64_t i, std::uint32_t mask = 0xFFFFFFFFu) { + std::uint64_t v = + i * laige::Prng::kMixMultiplierA + laige::Prng::kSplitmix64Increment; + v ^= v >> 30; + v *= laige::Prng::kMixMultiplierA; + v ^= v >> 27; + v *= 0x94D049BB133111EBull; + v ^= v >> 31; + return static_cast(v) & mask; +} + +// One declared sprite with the test's identifying fields (key, group, +// override) and inert presentation fields. +SpriteItem makeItem(std::uint32_t depthKey, std::uint32_t atlasId, + std::uint32_t materialId, BlendMode blend, + bool depthOverride) { + SpriteItem it; + it.pos = Vec2{1.0f, 2.0f}; + it.depthKey = depthKey; + it.depthOverride = depthOverride; + it.uv = SpriteUvRect{0.0f, 0.0f, 1.0f, 1.0f}; + it.rotation = 0.0f; + it.scale = Vec2{1.0f, 1.0f}; + it.tint = SpriteTint{}; + it.atlasId = atlasId; + it.materialId = materialId; + it.blend = blend; + return it; +} + +// --------------------------------------------------------------------------- +// Log capture (the iso_camera_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* firstEvent(const MemorySink& sink, + std::string_view event) { + for (const auto& e : sink.entries) { + if (e.event == event) return &e; + } + return nullptr; +} + +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 grouping correctness (roadmap M2-SPRITE-01 scope): N atlases × +// materials × blends produce the EXACT group count, in the +// deterministic (atlas, material, blend) sorted order, each group's +// membership exact. +// --------------------------------------------------------------------------- + +TEST(SpriteBatcherGroups, AtlasesTimesMaterialsTimesBlends) { + constexpr std::uint32_t kAtlases = 2; + constexpr std::uint32_t kMaterials = 3; + constexpr std::uint32_t kBlends = 2; + constexpr std::uint32_t kPerCombo = 2; + const std::uint32_t n = kAtlases * kMaterials * kBlends * kPerCombo; + + SpriteBatcherOptions o; + o.maxSprites = n; + auto r = SpriteBatcher::create(o); + ASSERT_TRUE(r.ok()); + SpriteBatcher b = std::move(r).takeValue(); + + b.beginFrame(); + std::uint32_t slot = 0; + for (std::uint32_t a = 0; a < kAtlases; ++a) { + for (std::uint32_t m = 0; m < kMaterials; ++m) { + for (std::uint32_t bl = 0; bl < kBlends; ++bl) { + for (std::uint32_t k = 0; k < kPerCombo; ++k) { + const auto add = + b.add(makeItem(mixKey(n - slot), a, m, + bl == 0 ? BlendMode::Alpha : BlendMode::Additive, + false)); + ASSERT_TRUE(add.ok()); + ASSERT_EQ(add.value(), slot); + ++slot; + } + } + } + } + ASSERT_TRUE(b.build().ok()); + + // The exact group count: every (atlas, material, blend) combination + // is used, so exactly kAtlases * kMaterials * kBlends groups. + EXPECT_EQ(b.batchCount(), kAtlases * kMaterials * kBlends); + EXPECT_EQ(b.frameCount(), n); + + const auto batches = b.batches(); + // The deterministic group order: ascending (atlas, material, blend). + for (std::size_t g = 1; g < batches.size(); ++g) { + const auto& prev = batches[g - 1]; + const auto& cur = batches[g]; + const bool ordered = + prev.atlasId < cur.atlasId || + (prev.atlasId == cur.atlasId && + (prev.materialId < cur.materialId || + (prev.materialId == cur.materialId && + static_cast(prev.blend) < + static_cast(cur.blend)))); + EXPECT_TRUE(ordered) << "group " << g << " out of order"; + } + // Each group's membership is exact: kPerCombo instances, every slot + // of the declared (a, m, bl) items, no slot in two groups. + std::vector seen(n, false); + std::size_t total = 0; + for (std::size_t g = 0; g < batches.size(); ++g) { + const auto& batch = batches[g]; + EXPECT_EQ(batch.instances.size(), kPerCombo); + for (const std::uint32_t s : batch.instances) { + const SpriteItem& it = b.at(s); + EXPECT_EQ(it.atlasId, batch.atlasId); + EXPECT_EQ(it.materialId, batch.materialId); + EXPECT_EQ(it.blend, batch.blend); + EXPECT_FALSE(seen[s]) << "slot " << s << " in two groups"; + seen[s] = true; + } + total += batch.instances.size(); + } + EXPECT_EQ(total, n); + for (std::uint32_t s = 0; s < n; ++s) EXPECT_TRUE(seen[s]); +} + +TEST(SpriteBatcherGroups, OnlyUsedCombinationsAreCounted) { + // 3 atlases x 2 blends = 6 possible combinations; the + // (atlas 1, Additive) combination is never declared -> 5 groups. + SpriteBatcherOptions o; + o.maxSprites = 16; + auto r = SpriteBatcher::create(o); + ASSERT_TRUE(r.ok()); + SpriteBatcher b = std::move(r).takeValue(); + + b.beginFrame(); + std::uint32_t i = 0; + for (std::uint32_t a = 0; a < 3; ++a) { + for (std::uint32_t bl = 0; bl < 2; ++bl) { + const bool used = !(a == 1 && bl == 1); + if (used) { + const auto add = + b.add(makeItem(mixKey(1000 + i), a, 0, + bl == 0 ? BlendMode::Alpha : BlendMode::Additive, + false)); + ASSERT_TRUE(add.ok()); + ++i; + } + } + } + ASSERT_TRUE(b.build().ok()); + EXPECT_EQ(b.batchCount(), 5); + const auto batches = b.batches(); + for (const auto& batch : batches) { + EXPECT_FALSE(batch.atlasId == 1 && batch.blend == BlendMode::Additive); + } +} + +TEST(SpriteBatcherGroups, AllOneGroup) { + SpriteBatcherOptions o; + o.maxSprites = 10; + auto r = SpriteBatcher::create(o); + ASSERT_TRUE(r.ok()); + SpriteBatcher b = std::move(r).takeValue(); + + b.beginFrame(); + for (std::uint32_t i = 0; i < 10; ++i) { + const auto add = b.add(makeItem(mixKey(i), 7, 3, BlendMode::Alpha, false)); + ASSERT_TRUE(add.ok()); + } + ASSERT_TRUE(b.build().ok()); + EXPECT_EQ(b.batchCount(), 1); + EXPECT_EQ(b.batches()[0].instances.size(), 10u); + EXPECT_EQ(b.batches()[0].atlasId, 7u); + EXPECT_EQ(b.batches()[0].materialId, 3u); + EXPECT_EQ(b.batches()[0].blend, BlendMode::Alpha); +} + +// --------------------------------------------------------------------------- +// The in-group instance order: the M2-SORT-01 sorted order (stable on +// equal keys) RESTRICTED to the group. +// --------------------------------------------------------------------------- + +TEST(SpriteBatcherOrder, SingleGroupSortedKeyOrder) { + // One group; declaration order (entity order) vs keys: + // pos 0: key 5, pos 1: key 1, pos 2: key 3, + // pos 3: key 1, pos 4: key 9, pos 5: key 0 + // Stable sorted order: (0,5) (1,1) (1,3) (3,2) (5,0) (9,4) + // -> instance slots {5, 1, 3, 2, 0, 4} + constexpr std::uint32_t keys[6] = {5, 1, 3, 1, 9, 0}; + constexpr std::uint32_t expected[6] = {5, 1, 3, 2, 0, 4}; + + SpriteBatcherOptions o; + o.maxSprites = 6; + auto r = SpriteBatcher::create(o); + ASSERT_TRUE(r.ok()); + SpriteBatcher b = std::move(r).takeValue(); + + b.beginFrame(); + for (std::uint32_t i = 0; i < 6; ++i) { + ASSERT_TRUE(b.add(makeItem(keys[i], 0, 0, BlendMode::Alpha, false)).ok()); + } + ASSERT_TRUE(b.build().ok()); + ASSERT_EQ(b.batchCount(), 1); + const auto inst = b.batches()[0].instances; + ASSERT_EQ(inst.size(), 6); + for (std::uint32_t i = 0; i < 6; ++i) EXPECT_EQ(inst[i], expected[i]); +} + +TEST(SpriteBatcherOrder, InterleavedGroupsKeepGlobalOrder) { + // Two groups (atlas 0 / atlas 1) with interleaved keys: + // pos 0: (A, key 10), pos 1: (B, key 5), + // pos 2: (A, key 7), pos 3: (B, key 9) + // Global sorted: (5,B1) (7,A2) (9,B3) (10,A0) + // -> A instances {2, 0}, B instances {1, 3} + SpriteBatcherOptions o; + o.maxSprites = 4; + auto r = SpriteBatcher::create(o); + ASSERT_TRUE(r.ok()); + SpriteBatcher b = std::move(r).takeValue(); + + b.beginFrame(); + ASSERT_TRUE(b.add(makeItem(10, 0, 0, BlendMode::Alpha, false)).ok()); + ASSERT_TRUE(b.add(makeItem(5, 1, 0, BlendMode::Alpha, false)).ok()); + ASSERT_TRUE(b.add(makeItem(7, 0, 0, BlendMode::Alpha, false)).ok()); + ASSERT_TRUE(b.add(makeItem(9, 1, 0, BlendMode::Alpha, false)).ok()); + ASSERT_TRUE(b.build().ok()); + ASSERT_EQ(b.batchCount(), 2); + const auto batches = b.batches(); + EXPECT_EQ(batches[0].atlasId, 0u); + EXPECT_EQ(batches[1].atlasId, 1u); + const auto a = batches[0].instances; + const auto c = batches[1].instances; + ASSERT_EQ(a.size(), 2); + ASSERT_EQ(c.size(), 2); + EXPECT_EQ(a[0], 2u); + EXPECT_EQ(a[1], 0u); + EXPECT_EQ(c[0], 1u); + EXPECT_EQ(c[1], 3u); +} + +TEST(SpriteBatcherOrder, EqualKeysKeepDeclarationOrder) { + // All keys equal: the stable sort keeps the DECLARATION order — + // instances == {0, 1, ..., 7} (the FR-1.2 entity-id order, + // RENDER-003). + SpriteBatcherOptions o; + o.maxSprites = 8; + auto r = SpriteBatcher::create(o); + ASSERT_TRUE(r.ok()); + SpriteBatcher b = std::move(r).takeValue(); + + b.beginFrame(); + for (std::uint32_t i = 0; i < 8; ++i) { + ASSERT_TRUE(b.add(makeItem(0xABCDu, 0, 0, BlendMode::Additive, false)).ok()); + } + ASSERT_TRUE(b.build().ok()); + ASSERT_EQ(b.batchCount(), 1); + const auto inst = b.batches()[0].instances; + for (std::uint32_t i = 0; i < 8; ++i) EXPECT_EQ(inst[i], i); +} + +// --------------------------------------------------------------------------- +// The overflow policy: bounded, drop oldest + warn (PERF-008, S-2). +// --------------------------------------------------------------------------- + +TEST(SpriteBatcherOverflow, DropOldestAndWarn) { + MemorySink* sink = installCaptureSink(); + SpriteBatcherOptions o; + o.maxSprites = 4; + auto r = SpriteBatcher::create(o); + ASSERT_TRUE(r.ok()); + SpriteBatcher b = std::move(r).takeValue(); + + // Six declarations into a budget of 4: the two OLDEST (keys 0, 1) + // are dropped, the frame keeps keys 2..5. + b.beginFrame(); + for (std::uint32_t i = 0; i < 6; ++i) { + ASSERT_TRUE(b.add(makeItem(i, 0, 0, BlendMode::Alpha, false)).ok()); + } + EXPECT_EQ(b.frameCount(), 4u); + EXPECT_EQ(b.droppedTotal(), 2u); + EXPECT_EQ(countEvents(*sink, "sprite_batcher", "frame_overflow_dropped"), + 2u); + + ASSERT_TRUE(b.build().ok()); + ASSERT_EQ(b.batchCount(), 1); + const auto inst = b.batches()[0].instances; + // Keys 2,3,4,5 in back-to-front order. The kept items live at slots + // 2, 3 (original) and 0, 1 (ring-reused by the overflow adds): + // key 2 -> slot 2, key 3 -> slot 3, key 4 -> slot 0, key 5 -> slot 1. + ASSERT_EQ(inst.size(), 4); + const std::uint32_t expected[4] = {2, 3, 0, 1}; + for (std::uint32_t i = 0; i < 4; ++i) EXPECT_EQ(inst[i], expected[i]); + + // The next frame is clean again: four declarations, no drops. + b.beginFrame(); + for (std::uint32_t i = 0; i < 4; ++i) { + ASSERT_TRUE(b.add(makeItem(100 + i, 0, 0, BlendMode::Alpha, false)).ok()); + } + EXPECT_EQ(b.frameCount(), 4u); + EXPECT_EQ(b.droppedTotal(), 2u); // unchanged + EXPECT_EQ(countEvents(*sink, "sprite_batcher", "frame_overflow_dropped"), + 2u); // unchanged + ASSERT_TRUE(b.build().ok()); + EXPECT_EQ(b.batches()[0].instances.size(), 4u); +} + +// --------------------------------------------------------------------------- +// G-R11: the manual depth-override escape hatch is counted + warned. +// --------------------------------------------------------------------------- + +TEST(SpriteBatcherOverrides, CountedAndWarned) { + MemorySink* sink = installCaptureSink(); + SpriteBatcherOptions o; + o.maxSprites = 3; + auto r = SpriteBatcher::create(o); + ASSERT_TRUE(r.ok()); + SpriteBatcher b = std::move(r).takeValue(); + + b.beginFrame(); + ASSERT_TRUE(b.add(makeItem(10, 0, 0, BlendMode::Alpha, false)).ok()); + // The override: a hand-set key (the escape hatch) — it sorts like + // any key, but the declaration is counted + warned. + ASSERT_TRUE(b.add(makeItem(3, 0, 0, BlendMode::Alpha, true)).ok()); + ASSERT_TRUE(b.add(makeItem(7, 0, 0, BlendMode::Alpha, false)).ok()); + EXPECT_EQ(b.overrideCount(), 1u); + EXPECT_EQ(b.overrideTotal(), 1u); + + ASSERT_TRUE(b.build().ok()); + // The override participates in the sort at its key's position: + // sorted keys (3,7,10) -> slots {1, 2, 0}. + ASSERT_EQ(b.batchCount(), 1); + const auto inst = b.batches()[0].instances; + ASSERT_EQ(inst.size(), 3); + EXPECT_EQ(inst[0], 1u); + EXPECT_EQ(inst[1], 2u); + EXPECT_EQ(inst[2], 0u); + // The warn fired once with the per-frame count + the cumulative. + const std::size_t warns = + countEvents(*sink, "sprite_batcher", "depth_override_used"); + EXPECT_EQ(warns, 1u); + const MemorySink::Entry* e = firstEvent(*sink, "depth_override_used"); + ASSERT_NE(e, nullptr); + EXPECT_EQ(e->severity, laige::log::Severity::Warn); + EXPECT_EQ(fieldOf(*e, "count"), "1"); + EXPECT_EQ(fieldOf(*e, "override_total"), "1"); + + // The next frame: two overrides -> the per-frame count resets, the + // cumulative adds, and a second warn fires (rate limiting is OFF in + // the capture sink). + b.beginFrame(); + ASSERT_TRUE(b.add(makeItem(0, 0, 0, BlendMode::Alpha, true)).ok()); + ASSERT_TRUE(b.add(makeItem(1, 0, 0, BlendMode::Alpha, true)).ok()); + EXPECT_EQ(b.overrideCount(), 2u); + EXPECT_EQ(b.overrideTotal(), 3u); + ASSERT_TRUE(b.build().ok()); + EXPECT_EQ(countEvents(*sink, "sprite_batcher", "depth_override_used"), + 2u); + e = firstEvent(*sink, "depth_override_used"); + // firstEvent returns the FIRST event — read the last one instead: + ASSERT_FALSE(sink->entries.empty()); + const MemorySink::Entry& last = sink->entries.back(); + EXPECT_EQ(last.event, "depth_override_used"); + EXPECT_EQ(fieldOf(last, "count"), "2"); + EXPECT_EQ(fieldOf(last, "override_total"), "3"); +} + +// --------------------------------------------------------------------------- +// The stopped state, the create validation, and the frame protocol. +// --------------------------------------------------------------------------- + +TEST(SpriteBatcherEdges, StoppedState) { + SpriteBatcher b; // default: the EMPTY (capacity 0) batcher + EXPECT_EQ(b.capacity(), 0u); + EXPECT_EQ(b.frameCount(), 0u); + EXPECT_EQ(b.batchCount(), 0u); + EXPECT_FALSE(b.add(makeItem(0, 0, 0, BlendMode::Alpha, false)).ok()); + EXPECT_EQ(b.add(makeItem(0, 0, 0, BlendMode::Alpha, false)).error(), + laige::ErrorCode::BudgetExhausted); + EXPECT_TRUE(b.build().ok()); // empty output, total + EXPECT_EQ(b.batchCount(), 0u); + EXPECT_TRUE(b.batches().empty()); + b.beginFrame(); // idempotent no-op + EXPECT_TRUE(b.build().ok()); +} + +TEST(SpriteBatcherEdges, CreateValidation) { + SpriteBatcherOptions o; + o.maxSprites = 0; + auto r = SpriteBatcher::create(o); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); +} + +TEST(SpriteBatcherEdges, FrameProtocol) { + SpriteBatcherOptions o; + o.maxSprites = 4; + auto r = SpriteBatcher::create(o); + ASSERT_TRUE(r.ok()); + SpriteBatcher b = std::move(r).takeValue(); + + // The window opens at create: declare without an explicit + // beginFrame (the pipeline may rely on either). + ASSERT_TRUE(b.add(makeItem(0, 0, 0, BlendMode::Alpha, false)).ok()); + ASSERT_TRUE(b.build().ok()); + // add() after build: the window is closed (InvalidArgument). + EXPECT_FALSE(b.add(makeItem(1, 0, 0, BlendMode::Alpha, false)).ok()); + EXPECT_EQ(b.add(makeItem(1, 0, 0, BlendMode::Alpha, false)).error(), + laige::ErrorCode::InvalidArgument); + // build() twice: double build (InvalidArgument). + EXPECT_FALSE(b.build().ok()); + EXPECT_EQ(b.build().error(), laige::ErrorCode::InvalidArgument); + // beginFrame reopens the window. + b.beginFrame(); + ASSERT_TRUE(b.add(makeItem(2, 0, 0, BlendMode::Alpha, false)).ok()); + ASSERT_TRUE(b.build().ok()); + + // The exact-capacity frame (n == capacity) is legal, no drops. + b.beginFrame(); + for (std::uint32_t i = 0; i < 4; ++i) { + ASSERT_TRUE(b.add(makeItem(i, 0, 0, BlendMode::Alpha, false)).ok()); + } + EXPECT_EQ(b.droppedTotal(), 0u); + ASSERT_TRUE(b.build().ok()); + EXPECT_EQ(b.batchCount(), 1u); + + // The empty frame builds to zero batches. + b.beginFrame(); + ASSERT_TRUE(b.build().ok()); + EXPECT_EQ(b.batchCount(), 0u); +} + +TEST(SpriteBatcherEdges, SlotAccess) { + SpriteBatcherOptions o; + o.maxSprites = 3; + auto r = SpriteBatcher::create(o); + ASSERT_TRUE(r.ok()); + SpriteBatcher b = std::move(r).takeValue(); + + b.beginFrame(); + ASSERT_TRUE(b.add(makeItem(0, 0, 0, BlendMode::Alpha, false)).ok()); + ASSERT_TRUE(b.add(makeItem(1, 0, 0, BlendMode::Alpha, false)).ok()); + ASSERT_TRUE(b.add(makeItem(2, 0, 0, BlendMode::Alpha, false)).ok()); + EXPECT_NE(b.get(0u), nullptr); + EXPECT_EQ(b.at(1u).depthKey, 1u); + EXPECT_NE(b.get(2u), nullptr); + // One more: the ring overwrites the oldest (slot 0), head -> 1. + // Window: slots 1, 2, 0 (oldest -> newest). + ASSERT_TRUE(b.add(makeItem(3, 0, 0, BlendMode::Alpha, false)).ok()); + EXPECT_EQ(b.at(1u).depthKey, 1u); // now the oldest + EXPECT_EQ(b.at(2u).depthKey, 2u); + EXPECT_EQ(b.at(0u).depthKey, 3u); // the newest (reused slot) + EXPECT_EQ(b.droppedTotal(), 1u); + // beginFrame releases everything. + b.beginFrame(); + for (std::uint32_t s = 0; s < 3; ++s) EXPECT_EQ(b.get(s), nullptr); +} + +// --------------------------------------------------------------------------- +// The determinism property (RENDER-003, ARCH-009/010 scope): same +// declaration sequence -> bit-identical batches, on every platform +// and build. The per-group order is oracle-checked against the +// stable sort of the group's (key, position) pairs. +// --------------------------------------------------------------------------- + +TEST(SpriteBatcherDeterminism, SameDeclarationsGiveIdenticalBatches) { + constexpr std::uint32_t kSprites = 3000; + laige::Prng prng = laige::testing::TestPrng(kDeterminismSubstreamId); + // The declaration sequence, precomputed OUTSIDE any window (the + // test discipline): a realistic isometric key range (the M2-ISO-01 + // 22-bit fine-depth field — ~2.4 equal-key partners per key on + // average in a 3000-sprite scene), 4 atlases x 3 materials x 2 + // blends, occasional overrides (the warn path stays out of the + // determinism question — the count is a counter, not order state). + std::vector items; + items.reserve(kSprites); + for (std::uint32_t i = 0; i < kSprites; ++i) { + items.push_back(makeItem( + mixKey(i, 0x3FFFFFu), + prng.next_range(0, 4), prng.next_range(0, 3), + prng.next_range(0, 2) == 0 ? BlendMode::Alpha : BlendMode::Additive, + prng.next_range(0, 4) == 0)); + } + auto declare = [](SpriteBatcher& b, const std::vector& its) { + b.beginFrame(); + for (const auto& it : its) { + const auto r = b.add(it); + if (!r.ok()) return false; + } + return b.build().ok(); + }; + + SpriteBatcherOptions o; + o.maxSprites = kSprites; // the exact-capacity frame (no overflow) + auto ra = SpriteBatcher::create(o); + auto rb = SpriteBatcher::create(o); + ASSERT_TRUE(ra.ok()); + ASSERT_TRUE(rb.ok()); + SpriteBatcher a = std::move(ra).takeValue(); + SpriteBatcher b = std::move(rb).takeValue(); + ASSERT_TRUE(declare(a, items)); + ASSERT_TRUE(declare(b, items)); + + // Bit-identical batches: group count, group keys, group order, and + // the per-group instance sequences. + EXPECT_EQ(a.batchCount(), b.batchCount()); + const auto ba = a.batches(); + const auto bb = b.batches(); + for (std::size_t g = 0; g < ba.size(); ++g) { + EXPECT_EQ(ba[g].atlasId, bb[g].atlasId) << "group " << g; + EXPECT_EQ(ba[g].materialId, bb[g].materialId) << "group " << g; + EXPECT_EQ(ba[g].blend, bb[g].blend) << "group " << g; + EXPECT_EQ(ba[g].instances.size(), bb[g].instances.size()) << "group " << g; + for (std::size_t i = 0; i < ba[g].instances.size(); ++i) { + EXPECT_EQ(ba[g].instances[i], bb[g].instances[i]) + << "group " << g << " instance " << i; + } + } + + // The per-group ORACLE: each group's instances are the STABLE sort + // of the group's (key, declaration position) pairs — non-decreasing + // keys, equal keys in declaration order (the M2-SORT-01 contract, + // the (key, entity id) total order of iso_depth_key.h). + std::vector>> groups( + ba.size()); + for (std::uint32_t pos = 0; pos < kSprites; ++pos) { + const SpriteItem& it = a.at(pos); + // The group's index: the batches are in sorted (atlas, material, + // blend) order — a linear scan (the test's oracle, O(G) per + // position: 3000 x <= 24, cheap). + for (std::size_t g = 0; g < ba.size(); ++g) { + if (ba[g].atlasId == it.atlasId && ba[g].materialId == it.materialId && + ba[g].blend == it.blend) { + groups[g].emplace_back(it.depthKey, pos); + break; + } + } + } + for (std::size_t g = 0; g < ba.size(); ++g) { + std::stable_sort(groups[g].begin(), groups[g].end(), + [](const auto& x, const auto& y) { + return x.first < y.first; + }); + ASSERT_EQ(groups[g].size(), ba[g].instances.size()) << "group " << g; + for (std::size_t i = 0; i < groups[g].size(); ++i) { + EXPECT_EQ(groups[g][i].second, ba[g].instances[i]) + << "group " << g << " instance " << i; + } + } +} + +// --------------------------------------------------------------------------- +// The zero-allocation proof (PERF-003, the iso_picking / depth_sort +// precedent): 1 000 full frames of the beginFrame/add/build protocol +// allocate nothing — the per-frame paths are pure bookkeeping over the +// pre-allocated storage (the logging facade's own event memory is +// attributed to the diagnostic subsystem, not this window, and no +// warn fires here: no overflow, no overrides). +// --------------------------------------------------------------------------- + +TEST(SpriteBatcherZeroAlloc, NoAllocationPerFrame) { + if (!laige::allocWatchLive()) return; // sanitizer trees: no-op (the + // leak-free run covers it) + constexpr std::uint32_t kCapacity = 1024; + constexpr std::uint32_t kPerFrame = 512; + constexpr std::uint32_t kFrames = 1000; + laige::Prng prng = laige::testing::TestPrng(kZeroAllocSubstreamId); + // The declaration data, precomputed OUTSIDE the measured window: + // the group fields are fixed (a stable G of <= 16 distinct groups), + // the keys vary per frame (the mix is cheap integer arithmetic in + // the measured path — no RNG, no division, the test discipline). + std::vector items; + items.reserve(kPerFrame); + for (std::uint32_t i = 0; i < kPerFrame; ++i) { + items.push_back(makeItem(0, prng.next_range(0, 2), prng.next_range(0, 2), + prng.next_range(0, 2) == 0 ? BlendMode::Alpha + : BlendMode::Additive, + false)); + } + SpriteBatcherOptions o; + o.maxSprites = kCapacity; + auto r = SpriteBatcher::create(o); + ASSERT_TRUE(r.ok()); + SpriteBatcher b = std::move(r).takeValue(); + // One warm frame BEFORE the armed window: the pool's first-fill + // creates happen here (the setup path), not in the measured run. + b.beginFrame(); + for (const auto& it : items) { + ASSERT_TRUE(b.add(it).ok()); + } + ASSERT_TRUE(b.build().ok()); + + laige::allocWatchArm(); + for (std::uint32_t frame = 0; frame < kFrames; ++frame) { + b.beginFrame(); + for (std::uint32_t i = 0; i < kPerFrame; ++i) { + SpriteItem it = items[i]; + it.depthKey = mixKey(i) ^ frame; + ASSERT_TRUE(b.add(it).ok()); + } + ASSERT_TRUE(b.build().ok()); + } + const laige::AllocWatchReading reading = laige::allocWatchRead(); + EXPECT_EQ(reading.allocs, 0u) + << kFrames << " frames allocated " << reading.allocs + << " heap blocks (first site: " + << reinterpret_cast(reading.firstSite) << ")"; +} + +} // namespace