From 171a50bafd836dee60c885cb233121eabc6b12ce Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 4 Oct 2026 16:58:09 +0200 Subject: [PATCH] [M2-SPRITE-03] Atlas UV frame animation hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atlas sheet frame layout (SpriteFrameLayout: frame size, row/col, inter-frame spacing + sheet-edge margin, all texels) + the pure frame-index -> UV sub-rect computation (spriteFrameUv): O(1), zero allocation, float-exact at atlas dimensions <= 2^24 (the documented u1 > u0, v1 > v0 invariant); out-of-range frame -> InvalidArgument, never wrap (the layout is untrusted asset metadata — validated first-failure-wins, with the u64 overflow guard before the col·stride multiplication). SpriteItem gains the animation frame index (frameIndex — caller-set, carried through the batcher untouched; uv is what the renderer draws). This is the data-driven hook M3 animation drives. Docs: docs/api/sprite_frames.md (new), sprite_batcher.md (SpriteItem row + 76 B/slot), coordinates.md §4.8 + §5 table row, docs/README index, module README. laige-api.json regenerated (1221 symbols / 38 headers). ctest entry sprite_frames (11 tests / 4 suites, no GL); all six local trees warning-clean + full ctest green; api / include / determinism lints green. No standalone budgets.json entry (composite 50k render-CPU budget measured at M2-PERF-01). --- docs/README.md | 8 + docs/api/sprite_batcher.md | 9 +- docs/api/sprite_frames.md | 215 ++++++++++ docs/concepts/coordinates.md | 8 +- laige-api.json | 117 +++--- roadmap/M2-rendering-2.5d.md | 2 +- roadmap/README.md | 5 +- src/laige-render/README.md | 37 +- .../include/laige/render/sprite_batcher.h | 25 +- .../include/laige/render/sprite_frames.h | 243 +++++++++++ tests/laige-render/CMakeLists.txt | 41 +- tests/laige-render/sprite_frames_tests.cpp | 379 ++++++++++++++++++ 12 files changed, 1007 insertions(+), 82 deletions(-) create mode 100644 docs/api/sprite_frames.md create mode 100644 src/laige-render/include/laige/render/sprite_frames.h create mode 100644 tests/laige-render/sprite_frames_tests.cpp diff --git a/docs/README.md b/docs/README.md index 938fb2c..5c36c7b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -287,6 +287,14 @@ still to land. as the M2-SPRITE-04 profiler feed, the pre-allocated instance buffer (zero per-frame allocation), and the offscreen 1 000-sprite render verified against a CPU reference. +- [Sprite frames](api/sprite_frames.md) — + `laige::render::SpriteFrameLayout` + `spriteFrameUv`: the atlas UV + frame animation hook (M2-SPRITE-03; FR-2.1 "atlas UV animation (sheet + frames)"): the sheet frame layout (frame size, row/col, margins) and + the pure frame-index → UV sub-rect computation (out-of-range frame → + documented error, never wrap) the caller uses to fill + `SpriteItem.uv`/`frameIndex` — the data-driven hook M3 animation + drives. ## Guides diff --git a/docs/api/sprite_batcher.md b/docs/api/sprite_batcher.md index 35e70f5..0306b0d 100644 --- a/docs/api/sprite_batcher.md +++ b/docs/api/sprite_batcher.md @@ -32,6 +32,7 @@ struct SpriteItem { 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 + std::uint32_t frameIndex{}; // animation frame (M2-SPRITE-03 hook) float rotation{}; // radians Vec2 scale{1, 1}; // world-unit scale (x, y) SpriteTint tint{}; // multiplicative RGBA (1,1,1,1 = none) @@ -76,7 +77,7 @@ The frame protocol (the frame pipeline's cull/batch stage, M2-GL-02): - **`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 + cursor array, the batch array — ~136 bytes per capacity slot, 6.8 MB at the 50k stress budget). Fails with `InvalidArgument` (no allocation) when `maxSprites` is 0 or exceeds `kSpriteBatcherMaxCapacity` (0xFFFFFFFF — the slot width). Size the @@ -170,7 +171,7 @@ depth overrides counted + warned). ## Performance (PERF-002/003/004, DOC-004) -- **Complexity:** `create` O(maxSprites) (8 allocations, ~132 B/slot); +- **Complexity:** `create` O(maxSprites) (8 allocations, ~136 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 @@ -238,6 +239,10 @@ depth overrides counted + warned). - [`api/sprite_renderer.md`](sprite_renderer.md) — the submit stage that draws this batcher's built frame (M2-SPRITE-02). +- [`api/sprite_frames.md`](sprite_frames.md) — the atlas UV frame + animation hook: the sheet frame layout + the frame index → UV + sub-rect computation the caller uses to fill `SpriteItem.uv` + (M2-SPRITE-03). - [`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 diff --git a/docs/api/sprite_frames.md b/docs/api/sprite_frames.md new file mode 100644 index 0000000..0bc8cb3 --- /dev/null +++ b/docs/api/sprite_frames.md @@ -0,0 +1,215 @@ +# Sprite frames (`laige::render::SpriteFrameLayout`, `laige::render::spriteFrameUv`) + +The atlas UV frame animation hook (M2-SPRITE-03; FR-2.1: "atlas UV +animation (sheet frames)"): the atlas sheet frame LAYOUT (frame size, +row/col, margins — the documented sheet model below) and the pure +frame-index → UV sub-rect computation that turns a caller-set animation +frame index into the `SpriteItem.uv` the M2-SPRITE-02 shader consumes. +This is the data-driven half of the feature: for now the frame index is +set by the caller per frame (the game's declaration pipeline); M3 +animation will own the frame-advance policy on top of this same layout +(the hook, below). Public header: +`src/laige-render/include/laige/render/sprite_frames.h` (header-only — +a pure function over a plain value; there is no implementation file). +Unit suite: `ctest -R sprite_frames` +(`tests/laige-render/sprite_frames_tests.cpp`) — pure float/integer +math, no GL environment required: it runs in every local tree and in +CI, with the hand-computed UV rects for documented layouts, the +documented failure contract (out-of-range frame → `InvalidArgument`, +never wrap), the sheet model against the documented formula + the +float-exact domain, and the `SpriteItem.frameIndex` hook through the +batcher. + +## The API + +```cpp +// The float-exact atlas domain: atlas dimensions in [1, 2^24]. +inline constexpr std::uint32_t kSpriteFrameMaxAtlasTexels = 1u << 24; + +// The atlas sheet's frame layout (texels — the sheet model, below). +struct SpriteFrameLayout { + std::uint32_t frameWidth{}; // texels per frame (>= 1) + std::uint32_t frameHeight{}; // texels per frame (>= 1) + std::uint32_t columns{}; // frames per row (>= 1) + std::uint32_t rows{}; // frame rows (>= 1) + std::uint32_t frameSpacing{}; // gap between adjacent frames (texels) + std::uint32_t sheetBorder{}; // margin at the sheet edge (texels) +}; + +// The UV sub-rect of `frameIndex` in the atlas, under `layout`. +// Out-of-range frameIndex -> InvalidArgument (never wraps). +[[nodiscard]] Result spriteFrameUv( + std::uint32_t frameIndex, const SpriteFrameLayout& layout, + std::uint32_t atlasWidth, std::uint32_t atlasHeight) noexcept; +``` + +`SpriteUvRect` is the batcher's per-sprite UV sub-rect +([`api/sprite_batcher.md`](sprite_batcher.md)): normalized `[0, 1]²`, +`u1 > u0`, `v1 > v0`. + +## The sheet model (the documented layout) + +A sprite sheet is a `rows × columns` grid of frames, **row-major, frame +0 at the top-left**: + +``` +frame i: col = i % columns, row = i / columns +``` + +Each frame is `frameWidth × frameHeight` texels. `frameSpacing` is the +gap **between** adjacent frames (texels); `sheetBorder` is the margin +between the sheet edge and the first/last frame on every side (texels). +Frame `(col, row)` occupies the texel rect + +``` +x: [border + col·(fw + spacing), border + col·(fw + spacing) + fw) +y: [border + row·(fh + spacing), border + row·(fh + spacing) + fh) +``` + +— the gap lives to the RIGHT of each frame and BELOW each row, and a +**tight** sheet is exactly + +``` +width = 2·border + columns·fw + (columns − 1)·spacing +height = 2·border + rows·fh + (rows − 1)·spacing +``` + +(a wider atlas is fine — the extra slack is just margin; the fit check +is the authoritative validity rule). + +The UV rect is the frame's texel rect normalized into the atlas: +`u0 = x0/W`, `v0 = y0/H`, `u1 = x1/W`, `v1 = y1/H`. **V-axis +convention:** v = 0 is the FIRST texel row of the uploaded RGBA array +(the sheet's TOP row — the M2-SPRITE-02 `GL_NEAREST` "texel row = +floor(v·h)" contract), so frame row 0 carries the smallest v and a +frame's v rect is measured from the top, not the bottom. + +## Failure (CORE-008, API-008) — first failure wins + +The layout is CALLER-OWNED asset metadata (the game's import pipeline +for now, the asset system from M3 — untrusted input, SCALE-004), so +every field is validated at the use boundary. `spriteFrameUv` returns +`InvalidArgument` for: + +| # | Condition | Why | +|---|---|---| +| 1 | `frameWidth`, `frameHeight`, `columns`, or `rows` is 0 | a zero extent is an invalid layout, not an "empty" one | +| 2 | `atlasWidth`/`atlasHeight` outside `[1, 2^24]` | the float-exact domain (below) | +| 3 | `frameIndex >= columns·rows` | the documented OUT-OF-RANGE contract: the engine never wraps silently | +| 4 | the frame's texel rect does not fit the atlas | the layout does not describe the sheet it claims to | + +A failed call returns the error; it never produces a UV rect. + +## Precision (the float-exact domain) + +All texel coordinates are exact in float — and the invariant +`u1 > u0`, `v1 > v0` holds EXACTLY (two distinct `k/W` values never +round to the same float) — when the atlas dimensions are within +`[1, kSpriteFrameMaxAtlasTexels] = [1, 2^24]` (16 777 216 — far beyond +the practical driver maxTextureSize of 16K–32K): every pixel +coordinate is then `< 2^24`, exactly representable in float, and the UV +corners are the EXACTLY-rounded `k/W` values (one correctly-rounded +division per corner — no extra arithmetic, no `floor`/`ceil`, no +backend dependency). Outside the domain the call fails `InvalidArgument` +(the domain is part of the layout contract — a sheet that does not fit a +float-exact atlas is an invalid layout, not a degraded one). + +The conversion is a PURE function: no allocation, no logging, no state, +no GL — a handful of integer checks + 4 float divisions, bit-identical +on every platform/build (render-side float — the ARCH-009/010 +presentation-only scope; it is never in the sim state hash or the +replay state). + +## The M3 hook (data-driven, ARCH-009) + +For now the frame index is set by the caller: the game's declaration +pipeline holds each animated entity's current frame index (and its +atlas's `SpriteFrameLayout` + atlas size — asset-side data), and per +frame declares the sprite with + +- `item.frameIndex = frame;` — the declared animation frame (the + batcher carries it through untouched — presentation state, never + sim state); +- `item.uv = spriteFrameUv(frame, layout, atlasW, atlasH);` — the + frame's UV sub-rect, **what the M2-SPRITE-02 shader draws**. + +M3 animation will drive the frame-advance policy (timers, events, +state machines) on top of this same layout — no engine change: it +writes the same two fields per frame. The caller's invariant: `uv` is +this item's `frameIndex` under its atlas's layout (the engine does not +cross-check the pair — `uv` is authoritative for the draw). + +## Ownership, lifetime, threading (CORE-009, CONC-001) + +- **Owner:** none — `SpriteFrameLayout` is a plain value (no ownership, + nothing to release) and `spriteFrameUv` is a stateless free function. +- **Threading:** callable from any thread — no locks, no shared state. + The declaration pipeline calls it once per animated sprite per frame + (the render phase, after the tick→handoff — ARCH-002). +- **Lifetime:** the returned `SpriteUvRect` is a value; the caller owns + it (the `SpriteItem` field). The layout outlives the calls that use + it (the asset's metadata). + +## Performance (PERF-002/003, DOC-004) + +- **Complexity:** O(1) — a handful of integer checks + 4 float + divisions per call. +- **Allocation:** zero (PERF-003) — proven by the zero-allocation test + (1 000 consecutive conversions = 0 heap blocks, non-sanitizer + trees). +- **Blocking/IO/GPU:** none — no logging, no GL calls. +- **Budget:** no standalone `budgets.json` entry — the per-frame + conversion cost is part of the composite 50k render-CPU budget + (2 ms, PRD §8.1 `sprites_50k_cpu`), measured when M2-PERF-01 lands + with the submit stage. +- **Call site:** once per ANIMATED sprite per frame, in the declaration + pipeline — never per texel, never in the simulation tick (ARCH-002). + A 50k-sprite frame of fully-animated sprites is 50 000 × (4 float + divisions + a few integer checks) — well inside the composite budget + (the M2-PERF-01 measurement will say so with a number). + +## Performant example + +```cpp +// Per frame, per animated entity (the declaration pipeline — the +// frame pipeline's cull/batch stage, M2-GL-02): +SpriteItem item; +item.pos = entity.worldPos(); +item.depthKey = laige::render::isoDepthKey( + entity.worldPos(), entity.stepHeight(), entity.layer()); +item.frameIndex = anim.frame(); // the M3 hook (for now: the caller) +item.uv = std::move(laige::render::spriteFrameUv( + anim.frame(), anim.layout(), atlasWidth, atlasHeight)).takeValue(); +item.atlasId = atlasId; +batcher.add(item); +``` + +## Misuse warnings + +- **Do not wrap the frame index by hand around the layout:** the engine + fails out-of-range indices (`InvalidArgument`, never wrap — CORE-008). + A wrapping game computes the index itself (`frame % frameCount`); a + non-wrapping animation clamps it. +- **Keep `uv` consistent with `frameIndex`:** the renderer draws `uv` — + the index is the declaration's frame record. If the two disagree, + the drawn frame is the `uv` one (the M3 invariant above). +- **The margins are texels, not world units or normalized UV:** the + layout describes the atlas's pixels; the world-scale is + `SpriteItem.scale`. +- **The atlas size must match the bound texture:** `atlasWidth`/ + `atlasHeight` are the `bindAtlas(id, w, h, rgba)` dimensions + (M2-SPRITE-02) — a mismatch misaligns every frame's UV. +- **Do not use the result across frames:** the UV rect is a value copy + per declaration (presentation state) — recompute it each frame from + the frame's index. + +## Related + +- [`api/sprite_batcher.md`](sprite_batcher.md) — the declaration window + that carries `SpriteItem` (the `frameIndex` + `uv` pair). +- [`api/sprite_renderer.md`](sprite_renderer.md) — the submit stage + that draws the item's `uv` (M2-SPRITE-02). +- [`api/errors.md`](errors.md) — the `InvalidArgument` error code + (M0-CORE-02). +- [`concepts/coordinates.md` §4.8](../concepts/coordinates.md) — the + sprite-draw narrative (the UV sub-rect's place in the pipeline). diff --git a/docs/concepts/coordinates.md b/docs/concepts/coordinates.md index e79b807..ef35e32 100644 --- a/docs/concepts/coordinates.md +++ b/docs/concepts/coordinates.md @@ -290,7 +290,9 @@ computes, per instance: by the per-instance rotation — the `SpriteItem.rotation` contract (rotation is a screen-space property, not a world property); - **UV sub-rect**: the per-instance UV rect mapped onto the quad - (`(-0.5,-0.5) → u0/v0`, `(0.5,0.5) → u1/v1`); + (`(-0.5,-0.5) → u0/v0`, `(0.5,0.5) → u1/v1`) — for animated sprites + the rect is `spriteFrameUv`'s output for the caller-set frame index + under the atlas's sheet layout (M2-SPRITE-03); - **Tint**: `texture(uAtlas, uv) * tint` (multiplicative RGBA). The sprites are painted back-to-front (the batcher's §4.7 order) with @@ -309,6 +311,7 @@ and the draw submissions are observable (`SpriteDrawStats` / | 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** (matrices + camera core M2-CAM-01, iso presets + grid-snap M2-CAM-02, world→screen transform M2-PROJ-01, pixels: `SpriteRenderer::submit`'s offscreen instanced draw M2-SPRITE-02) | +| Atlas frame → UV sub-rect | animation frame index + sheet layout → UV rect | `laige-render` (`spriteFrameUv`, M2-SPRITE-03) | **Shipped (M2-SPRITE-03)** | ### 5.1 The isometric grid picking (M2-ISO-03) @@ -406,6 +409,9 @@ state → same keys → same order, every frame (RENDER-003). - [`api/sprite_renderer.md`](../api/sprite_renderer.md) — the instanced draw contract: `SpriteRenderer` (groups → one instanced draw call each, the frame's pixels) (M2-SPRITE-02). +- [`api/sprite_frames.md`](../api/sprite_frames.md) — the atlas UV + frame animation hook: `SpriteFrameLayout` + `spriteFrameUv` + (frame index + sheet layout → the item's UV sub-rect) (M2-SPRITE-03). - [`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 26ce78f..183a640 100644 --- a/laige-api.json +++ b/laige-api.json @@ -24,6 +24,7 @@ "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-render/include/laige/render/sprite_frames.h", "src/laige-render/include/laige/render/sprite_renderer.h", "src/laige-sim/include/laige/sim/archetype.h", "src/laige-sim/include/laige/sim/component.h", @@ -732,59 +733,69 @@ {"name": "laige::render::ProjectionView::worldToScreen", "kind": "method", "header": "src/laige-render/include/laige/render/projection.h", "line": 261, "signature": "[[nodiscard]] Vec3 worldToScreen(Vec2 p2d, float depth) const noexcept", "summary": "The NDC of the 2.5D world point (the ground plane (p2d.x, p2d.y) + elevation depth). Pure (no mutation); no allocation, no logging. free_cinematic; w = 1 exactly for the affine modes).", "budget": "O(1): one 4x4 matrix multiply (+ one divide for", "experimental": false}, {"name": "laige::render::ProjectionView::screenToWorldRay", "kind": "method", "header": "src/laige-render/include/laige/render/projection.h", "line": 267, "signature": "[[nodiscard]] WorldRay screenToWorldRay(Vec2 ndc) const noexcept", "summary": "The world preimage ray of the NDC screen point (the header preamble's per-mode geometry). Pure; no allocation, no logging. two divides (free_cinematic).", "budget": "O(1): a 2x2 solve (the affine modes) or one 4x4 inverse +", "experimental": false}, {"name": "laige::render::ProjectionView::screenToWorld", "kind": "method", "header": "src/laige-render/include/laige/render/projection.h", "line": 274, "signature": "[[nodiscard]] laige::Result screenToWorld(Vec2 ndc, Plane plane) const noexcept", "summary": "The preimage line/ray ∩ the plane (the header preamble): the world point on success; InvalidArgument when the plane is parallel to the ray or (free_cinematic) the intersection is behind the camera. Pure; no allocation, no logging.", "budget": "O(1): one screenToWorldRay + two dot products + one divide.", "experimental": false}, - {"name": "laige::render::kSpriteBatcherMaxCapacity", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 244, "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": 254, "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": 257, "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": 259, "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": 269, "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": 270, "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": 271, "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": 272, "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": 273, "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": 277, "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": 278, "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": 279, "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": 280, "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": 281, "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": 292, "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": 296, "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": 300, "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": 304, "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": 306, "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": 309, "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": 311, "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": 313, "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": 316, "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": 319, "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": 321, "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": 332, "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": 333, "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": 334, "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": 335, "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": 341, "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": 372, "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": 378, "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": 379, "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": 386, "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": 394, "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": 403, "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": 425, "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": 441, "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": 446, "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": 448, "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": 453, "signature": "[[nodiscard]] std::size_t frameCount() const noexcept", "summary": "The frame's declared count, after the overflow drops.", "budget": "O(1).", "experimental": false}, - {"name": "laige::render::SpriteBatcher::frameBuilt", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 461, "signature": "[[nodiscard]] bool frameBuilt() const noexcept", "summary": "True only between build() and the next beginFrame(): the submit stage's (M2-SPRITE-02) precondition — an unbuilt window (including one with declared items) must never be drawn as an empty frame (CORE-008). False before the first build and after every beginFrame(); true on a built empty frame (build() on the stopped state works and builds the empty frame).", "budget": "O(1).", "experimental": false}, - {"name": "laige::render::SpriteBatcher::overrideCount", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 465, "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": 471, "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": 477, "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": 482, "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": 485, "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": 491, "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": 496, "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": 498, "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": 499, "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": 503, "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": 504, "signature": "SpriteBatcher& operator=(SpriteBatcher&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kSpriteBatcherMaxCapacity", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 245, "signature": "inline constexpr std::uint32_t kSpriteBatcherMaxCapacity = 0xFFFFFFFFu", "summary": "The most sprites a batcher may budget: the frame declaration slot is a 32-bit index (the pool slot, the key scratch, the instance array), so the budget domain is the index width (the DepthSort precedent).", "budget": null, "experimental": false}, + {"name": "laige::render::BlendMode", "kind": "enum", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 255, "signature": "enum class BlendMode : std::uint8_t", "summary": "The blend state a sprite is drawn with (the group key's third field). The enumerator values are stable (PRD §9.4: additive only); the submit stage (M2-SPRITE-02) maps each value to its GL blend state.", "budget": null, "experimental": false}, + {"name": "laige::render::BlendMode::Alpha", "kind": "enumerator", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 258, "signature": "Alpha = 0", "summary": "Standard alpha blend (src·srcAlpha + dst·(1 − srcAlpha)): the default for textured sprites.", "budget": null, "experimental": false}, + {"name": "laige::render::BlendMode::Additive", "kind": "enumerator", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 260, "signature": "Additive = 1", "summary": "Additive (src + dst): particles, light, glow (M2-PART-01).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteUvRect", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 271, "signature": "struct SpriteUvRect", "summary": "The sprite's UV sub-rect inside its atlas, normalized [0, 1]² (u1 > u0, v1 > v0 — the caller's invariant; spriteFrameUv, laige/render/sprite_frames.h, computes these from the atlas sheet frame layout — M2-SPRITE-03).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteUvRect::u0", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 272, "signature": "float u0{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteUvRect::v0", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 273, "signature": "float v0{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteUvRect::u1", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 274, "signature": "float u1{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteUvRect::v1", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 275, "signature": "float v1{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteTint", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 279, "signature": "struct SpriteTint", "summary": "The sprite's multiplicative RGBA tint (1, 1, 1, 1 = untinted).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteTint::r", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 280, "signature": "float r{1.0f}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteTint::g", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 281, "signature": "float g{1.0f}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteTint::b", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 282, "signature": "float b{1.0f}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteTint::a", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 283, "signature": "float a{1.0f}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 294, "signature": "struct SpriteItem", "summary": "The value the game DECLARES for one sprite (S-5: declaration, not draw). A plain value — no ownership, nothing to release (the pool owns the storage). All fields are presentation state (ARCH-009): the caller fills them from its per-frame snapshot.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::pos", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 298, "signature": "Vec2 pos{0.0f, 0.0f}", "summary": "The world ground-plane position, world units (the presentation snapshot's interpolated position, M1-LOOP-02 — NEVER screen space, PRD §4).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::depthKey", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 302, "signature": "std::uint32_t depthKey{}", "summary": "The M2-ISO-01 depth key (`isoDepthKey(pos, stepHeight, layer)`): the back-to-front order value. G-R11: engine-owned — compute it with isoDepthKey, never from screen space.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::depthOverride", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 306, "signature": "bool depthOverride{}", "summary": "The G-R11 escape-hatch flag: true when the caller set depthKey by hand (not via isoDepthKey). Counted per frame + warned at build (\"prefer tile height\"). The batcher sorts the key either way.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::uv", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 308, "signature": "SpriteUvRect uv{}", "summary": "The UV sub-rect in the atlas (SpriteUvRect above).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::frameIndex", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 317, "signature": "std::uint32_t frameIndex{}", "summary": "The animation frame index this item was declared with (M2-SPRITE-03 — data-driven: the caller sets it; M3 animation will drive the frame advance). The engine does not interpret it: the caller also sets `uv` to this frame's UV sub-rect (see spriteFrameUv, laige/render/sprite_frames.h) — `uv` is what the renderer draws, the index is the declaration's frame record (presentation state, ARCH-009). The batcher carries it through untouched.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::rotation", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 320, "signature": "float rotation{}", "summary": "The rotation in radians (0 = unrotated; the submit stage applies it in screen space, M2-SPRITE-02).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::scale", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 322, "signature": "Vec2 scale{1.0f, 1.0f}", "summary": "The world-unit scale (x, y) — non-uniform free (1, 1 = unscaled).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::tint", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 324, "signature": "SpriteTint tint{}", "summary": "The multiplicative RGBA tint (SpriteTint above).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 327, "signature": "std::uint32_t atlasId{}", "summary": "The atlas/texture reference (a stable asset handle — the asset system lands with M3-ASSET-01; for now the game assigns it).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 330, "signature": "std::uint32_t materialId{}", "summary": "The material reference (0 = the default material; the material system is future work — the group key carries it per FR-2.1).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteItem::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 332, "signature": "BlendMode blend{}", "summary": "The blend state (the group key's third field).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatch", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 343, "signature": "struct SpriteBatch", "summary": "One (atlas, material, blend) group of the frame's sorted sprites: the state the submit stage sets ONCE (texture bind + material + blend — RENDER-001) and the group's instances in back-to-front order (one instanced draw call per group, FR-2.1).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatch::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 344, "signature": "std::uint32_t atlasId{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatch::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 345, "signature": "std::uint32_t materialId{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatch::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 346, "signature": "BlendMode blend{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatch::instances", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 352, "signature": "std::span instances{}", "summary": "The group's instances, back-to-front: the frame's global depth order (M2-SORT-01) RESTRICTED to this group. Each element is the frame-scoped pool slot of the sprite (read the item with the batcher's at(slot) / get(slot)). Invalidated by the next beginFrame()/build() — read within the frame.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatcher", "kind": "class", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 383, "signature": "class SpriteBatcher", "summary": "The frame declaration window + batch builder (the preamble: the frame protocol, the batch model, the overflow policy, the G-R11 accounting, the storage, the ownership, and the performance contracts).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatcher::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 389, "signature": "struct Options", "summary": "The frame budget (the declared scene sprite budget, S-6, API-006): the most sprites one frame may declare. Size it to the scene's worst-case visible count at scene set-up; the batcher never grows beyond it (the overflow policy: drop oldest + warn, the preamble).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatcher::Options::maxSprites", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 390, "signature": "std::uint32_t maxSprites{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatcher::SpriteBatcher", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 397, "signature": "SpriteBatcher() noexcept : items_(ArenaPool::Options{0})", "summary": "The default state: the EMPTY batcher (capacity 0, no storage). beginFrame()/build() work (the output is empty); add() fails with BudgetExhausted — the stopped-state pattern of the module's value objects (DepthSort), total and never UB.", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatcher::create", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 405, "signature": "[[nodiscard]] static Result create(Options options) noexcept", "summary": "The set-up path (scene load): one allocation per storage structure (the preamble's layout — ~136 B/capacity slot). Fails (InvalidArgument, no allocation) when maxSprites is 0 or exceeds kSpriteBatcherMaxCapacity (the slot-width domain).", "budget": "O(maxSprites); 8 allocations, setup only.", "experimental": false}, + {"name": "laige::render::SpriteBatcher::beginFrame", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 414, "signature": "void beginFrame() noexcept", "summary": "The frame protocol, part 1 (the preamble): close the previous window and open a new one — the previous frame's declared items are released (the pool's reset). Idempotent; a no-op on the stopped batcher.", "budget": "O(previous frame count); no allocation.", "experimental": false}, + {"name": "laige::render::SpriteBatcher::add", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 436, "signature": "[[nodiscard]] Result add(SpriteItem item) noexcept", "summary": "The frame protocol, part 2 (the preamble): declare ONE sprite for the current frame (the window is open: after create()/beginFrame(), until build()). The frame's DECLARATION ORDER is the insertion order: declare in the engine's deterministic entity-id iteration order (FR-1.2) — that is what makes the (key, entity id) total order reproducible (RENDER-003).", "budget": "O(1); no allocation on the success path (the pool create", "experimental": false}, + {"name": "laige::render::SpriteBatcher::build", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 452, "signature": "[[nodiscard]] Status build() noexcept", "summary": "The frame protocol, part 3 (the preamble): the batch stage. Sorts the frame's depth keys (M2-SORT-01), groups into (atlas, material, blend) batches (deterministic group order: ascending (atlas, material, blend) — RENDER-003), scatters each group's instances in global back-to-front order, and publishes batches(). Closes the declaration window.", "budget": "O(4n + 4·256 + n·log G + G·(log G + G)), G = the frame's", "experimental": false}, + {"name": "laige::render::SpriteBatcher::batchCount", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 457, "signature": "[[nodiscard]] std::size_t batchCount() const noexcept", "summary": "The frame's output (read within the frame; invalidated by the next beginFrame()/build()).", "budget": "O(1).", "experimental": false}, + {"name": "laige::render::SpriteBatcher::batches", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 459, "signature": "[[nodiscard]] std::span batches() const noexcept", "summary": null, "budget": "O(1); the span aliases the batcher's batch array.", "experimental": false}, + {"name": "laige::render::SpriteBatcher::frameCount", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 464, "signature": "[[nodiscard]] std::size_t frameCount() const noexcept", "summary": "The frame's declared count, after the overflow drops.", "budget": "O(1).", "experimental": false}, + {"name": "laige::render::SpriteBatcher::frameBuilt", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 472, "signature": "[[nodiscard]] bool frameBuilt() const noexcept", "summary": "True only between build() and the next beginFrame(): the submit stage's (M2-SPRITE-02) precondition — an unbuilt window (including one with declared items) must never be drawn as an empty frame (CORE-008). False before the first build and after every beginFrame(); true on a built empty frame (build() on the stopped state works and builds the empty frame).", "budget": "O(1).", "experimental": false}, + {"name": "laige::render::SpriteBatcher::overrideCount", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 476, "signature": "[[nodiscard]] std::uint32_t overrideCount() const noexcept", "summary": "G-R11: the current frame's manual depth-override declaration count (reset by beginFrame).", "budget": "O(1).", "experimental": false}, + {"name": "laige::render::SpriteBatcher::overrideTotal", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 482, "signature": "[[nodiscard]] std::uint64_t overrideTotal() const noexcept", "summary": "G-R11: the since-construction override total (the profiler's cumulative guardrail feed, PRD §9.3).", "budget": "O(1).", "experimental": false}, + {"name": "laige::render::SpriteBatcher::droppedTotal", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 488, "signature": "[[nodiscard]] std::uint64_t droppedTotal() const noexcept", "summary": "The since-construction dropped-oldest total (the overflow policy's accounting, the preamble).", "budget": "O(1).", "experimental": false}, + {"name": "laige::render::SpriteBatcher::capacity", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 493, "signature": "[[nodiscard]] std::uint32_t capacity() const noexcept", "summary": "The frame budget (the create() argument; 0 in the stopped state).", "budget": "O(1).", "experimental": false}, + {"name": "laige::render::SpriteBatcher::itemPoolStats", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 496, "signature": "[[nodiscard]] PoolStats itemPoolStats() const noexcept", "summary": "The sprite pool's accounting snapshot (PRD §10.4, DBG-008).", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::render::SpriteBatcher::get", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 502, "signature": "[[nodiscard]] const SpriteItem* get(std::uint32_t slot) noexcept", "summary": "The item declared at the frame-scoped slot (the null-safe read — nullptr when the slot is not in the current frame window).", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::render::SpriteBatcher::at", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 507, "signature": "[[nodiscard]] const SpriteItem& at(std::uint32_t slot)", "summary": "The item declared at the frame-scoped slot (debug: asserts the slot is live — the S-9 fail-loudly contract; release: undefined on a stale slot, the engine Result convention).", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::render::SpriteBatcher::SpriteBatcher", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 509, "signature": "SpriteBatcher(const SpriteBatcher&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatcher::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 510, "signature": "SpriteBatcher& operator=(const SpriteBatcher&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatcher::SpriteBatcher", "kind": "constructor", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 514, "signature": "SpriteBatcher(SpriteBatcher&&) noexcept = default", "summary": "Move: the members' moves (the pool and the sorter leave the source stopped; the flat buffers transfer). The source becomes the stopped state (CORE-009).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteBatcher::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_batcher.h", "line": 515, "signature": "SpriteBatcher& operator=(SpriteBatcher&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kSpriteFrameMaxAtlasTexels", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_frames.h", "line": 133, "signature": "inline constexpr std::uint32_t kSpriteFrameMaxAtlasTexels = 1u << 24", "summary": "The float-exact atlas domain (the header's Exactness section): atlas dimensions are [1, kSpriteFrameMaxAtlasTexels]. 2^24 = 16 777 216 — every texel coordinate < 2^24 is exactly float-representable, and two distinct k/W values never round to the same float (the u1 > u0, v1 > v0 invariant, exact). Far beyond the practical driver maxTextureSize (16K–32K).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteFrameLayout", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_frames.h", "line": 145, "signature": "struct SpriteFrameLayout", "summary": "The layout of the frames in one atlas sheet (the header's sheet model). All fields are texels (not world units, not normalized UV — the layout is asset-side data, the import pipeline's output for now, the asset system's from M3). A plain value: no ownership, nothing to release; validated at use time by spriteFrameUv (the failure section).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteFrameLayout::frameWidth", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_frames.h", "line": 147, "signature": "std::uint32_t frameWidth{}", "summary": "The frame width in texels (>= 1).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteFrameLayout::frameHeight", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_frames.h", "line": 149, "signature": "std::uint32_t frameHeight{}", "summary": "The frame height in texels (>= 1).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteFrameLayout::columns", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_frames.h", "line": 151, "signature": "std::uint32_t columns{}", "summary": "The frame columns per row (>= 1).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteFrameLayout::rows", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_frames.h", "line": 153, "signature": "std::uint32_t rows{}", "summary": "The frame rows (>= 1).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteFrameLayout::frameSpacing", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_frames.h", "line": 155, "signature": "std::uint32_t frameSpacing{}", "summary": "The gap between adjacent frames, texels (0 = packed).", "budget": null, "experimental": false}, + {"name": "laige::render::SpriteFrameLayout::sheetBorder", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_frames.h", "line": 158, "signature": "std::uint32_t sheetBorder{}", "summary": "The margin between the sheet edge and the first/last frame on every side, texels (0 = flush to the edge).", "budget": null, "experimental": false}, + {"name": "laige::render::spriteFrameUv", "kind": "function", "header": "src/laige-render/include/laige/render/sprite_frames.h", "line": 174, "signature": "[[nodiscard]] inline Result spriteFrameUv( std::uint32_t frameIndex, const SpriteFrameLayout& layout, std::uint32_t atlasWidth, std::uint32_t atlasHeight) noexcept", "summary": "The UV sub-rect of `frameIndex` in the `atlasWidth × atlasHeight` atlas, under `layout` (the header: the sheet model, the exactness domain, the failure contract). The frame index is 0-based row-major (frame 0 = top-left); an out-of-range index fails `InvalidArgument` — the engine NEVER wraps silently (CORE-008).", "budget": "O(1): a handful of integer checks + 4 float divisions; zero", "experimental": false}, {"name": "laige::render::SpriteDrawStats", "kind": "struct", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 167, "signature": "struct SpriteDrawStats", "summary": "The per-frame counters of one successful submit (RENDER-001 — the state changes and draw submissions made observable; the M2-SPRITE-04 profiler feed). Read-only published state. `primitives` is 0 unless the opt-in primitive query (Options::primitiveQuery) is enabled: GL 3.3 core has no draw-call query primitive, so the dispatch count is the engine's own bookkeeping (drawCalls — what the profiler ships), and the GL-side cross-check is the PRIMITIVES_GENERATED count of the pass (2 per instance of the 4-vertex strip).", "budget": null, "experimental": false}, {"name": "laige::render::SpriteDrawStats::drawCalls", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 168, "signature": "std::uint32_t drawCalls{0}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::SpriteDrawStats::textureBinds", "kind": "variable", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 169, "signature": "std::uint32_t textureBinds{0}", "summary": null, "budget": null, "experimental": false}, diff --git a/roadmap/M2-rendering-2.5d.md b/roadmap/M2-rendering-2.5d.md index d1ed61b..2b2cbb7 100644 --- a/roadmap/M2-rendering-2.5d.md +++ b/roadmap/M2-rendering-2.5d.md @@ -173,7 +173,7 @@ if M2 slips, and its status is recorded in M2-EXIT-01. - **Verify:** `ctest -R sprite_draw` green (offscreen CI); draw-call count == group count in test log. - **Size:** ~300 lines + tests -- [ ] **M2-SPRITE-03 · Atlas UV frame animation hook** +- [x] **M2-SPRITE-03 · Atlas UV frame animation hook** - **Refs:** FR-2.1 (atlas UV animation, sheet frames) - **Depends:** M2-SPRITE-02 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index c15f8d2..4c35d66 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 | 14 | ⬜ in progress | +| M2 | 33 | 15 | ⬜ 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** | **60** | | +| **Total** | **194** | **61** | | --- @@ -238,6 +238,7 @@ One line per completed (or split/renumbered) step. | 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 | | 2026-10-04 | M2-SPRITE-02 | `—` | GPU instanced draw + sprite shader (M2-SPRITE-02 scope, nothing else): **API** — `SpriteRenderer` (public header `src/laige-render/include/laige/render/sprite_renderer.h` + implementation `sprite_renderer.cpp`, move-only; default = stopped state) — `create(const GlContext&, Options{maxInstances, maxAtlases, primitiveQuery})` the set-up path (validates first-failure-wins → `InvalidArgument` + one rate-limited Warn `sprite_renderer/options_invalid`; requires a valid context + `makeCurrent`; compiles ONE GLSL 3.30 shader pair (vertex + fragment) + links one program; creates the 32 B quad VBO (`GL_STATIC_DRAW`), the per-frame instance buffer (`maxInstances × 52` B, `GL_DYNAMIC_DRAW` — 13 floats: pos.xy, scale.xy, uv u0v0u1v1, tint rgba, rot), and the VAO (the quad corner at divisor 0; the five instance attributes at divisor 1, pinned `layout(location=0..5)`); the atlas registry (`maxAtlases × 8` B) — any GL failure → `GlUnavailable` + ONE structured Error (`sprite_renderer/program_creation_failed` or `resource_creation_failed`, with the GL error code + the sanitized info log), no partial renderer); `bindAtlas(atlasId, w, h, rgba)` the set-up/asset path (one call per atlas per scene load; `atlasId ∈ [0, maxAtlases)`, `w, h ∈ [1, capabilities().maxTextureSize]`, `rgba.size() == w·h·4`; GL_RGBA8, `GL_NEAREST`, `GL_CLAMP_TO_EDGE`, no mipmaps — the UV sub-rects are pixel-exact, the M2-GOLD-01 contract; re-binding an id REPLACES the texture; a GL upload failure → `GlUnavailable` + one Error `atlas_upload_failed`); `submit(batcher, worldToNdc)` the per-frame draw (preconditions, first failure wins — a FAILED submit draws nothing, zeroes the frame counters, leaves the totals unchanged, and leaves no stale sprite-pass state: stopped renderer → `InvalidArgument`; context valid + current (`makeCurrent` idempotent — a cross-thread live takeover → `GlUnavailable`, the P0 EGL contract); the batcher built for the current frame (`frameBuilt()` — an open window with declared items is never drawn as an empty frame, CORE-008); the frame's instance count ≤ `maxInstances` (else `BudgetExhausted` + one rate-limited Warn `instance_capacity`, PERF-008); every group's atlas in the registry AND bound (else `InvalidArgument` — the stateless pre-state validation, one failure per frame); then: the per-frame state setup (the render-target frame buffer bind — `GlContext::frameBuffer()`, the offscreen FBO on headless contexts, the surfaceless default frame buffer is not a valid draw target — + the viewport matched to the render-target size, the driver default 0×0 would clip every draw to nothing — + the depth test OFF (the painter's order is the batcher's — the 2.5D depth is engine-owned, FR-2.2/M2-ISO-01, never derived from the projection) + the blend ENABLED + the program + the per-frame `uWorldToNdc` uniform), the frame's instances packed into the pre-allocated staging (a contiguous verbatim float copy — no arithmetic on the CPU — the GPU owns the math, FR-2.2/PERF-003), ONE `glBufferSubData` upload, and per group IN THE Batcher's published order (ascending (atlas, material, blend) — RENDER-003) the texture bind (only when the atlas CHANGED → counted in `textureBinds`), the blend function (only when the mode CHANGED → counted in `blendChanges` — Alpha: `SRC_ALPHA`/`ONE_MINUS_SRC_ALPHA`, Additive: `ONE`/`ONE`), and ONE `glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, n_group)` (counted in `drawCalls`/`instances`); after the pass (success or failure): program + VAO restored to 0 — the pass owns only its own program/VAO; the blend function, texture bind, frame buffer, and viewport PERSIST (the last atlas/blend carry across frames — the counters count real changes)); **shader** (the whole M2 sprite feature, minimal GLSL 3.30) — vertex: `world = aPos + aCorner * aScale` (the unit quad's corner scaled in WORLD units and translated — the scale applied BEFORE the projection, the `SpriteItem.scale` contract), projected through `uWorldToNdc` (2D ground plane, z = 0), the projected offset rotated by the per-instance rotation IN SCREEN SPACE (NDC — the `SpriteItem.rotation` contract), the per-vertex UV the per-instance UV sub-rect mapped onto the quad (`(-0.5,-0.5) → u0/v0`, `(0.5,0.5) → u1/v1`); fragment: `texture(uAtlas, vUv) * vTint` (the multiplicative RGBA tint); **counters (RENDER-001 — the M2-SPRITE-04 profiler feed)** — `SpriteDrawStats` (the per-frame counters of the last successful submit: `drawCalls`, `textureBinds`, `blendChanges`, `instances`, `primitives`) + `SpriteDrawTotals` (since-construction, successful submits only — `frames`, `drawCalls`, `textureBinds`, `blendChanges`, `instances`, `primitives`); GL 3.3 core has NO draw-call query primitive: the dispatch count is the engine's own bookkeeping (`drawCalls` == the group count), and the GL-side cross-check is the OPT-IN `Options::primitiveQuery` (a `PRIMITIVES_GENERATED` query around every submit + a `glFinish` read — a CPU/GPU sync, a DIAGNOSTIC mode for the offscreen test/CI path and the profiler, never the shipping frame loop, RENDER-005; the 32-bit `glGetQueryObjectuiv` read caps the count at 2^32−1 primitives — beyond the realistic frame of 2^31 instances (2 per instance); the glad-generated `glQueryCounter` has a broken 2-argument signature in the vendored 2.0.8 loader, hence the 32-bit read; `ponytail:` comment at the site); **determinism** — presentation-only (ARCH-009): the submit path is a verbatim float copy (no arithmetic on the CPU — the GPU owns the math); the rotation's cos/sin is driver-float (bit-exact across runs of the same driver, not across drivers — the golden-image contract M2-GOLD-01 pins the environment); no allocation on the per-frame path (the staging buffer, the instance buffer, and the registry are sized at create — PERF-003); one owner thread (the render thread's submit stage — CONC-001), no locks/atomics; **no new budget entry** — the composite 50k render-CPU budget (2 ms, PRD §8.1 `sprites_50k_cpu`) is measured with this stage (M2-PERF-01); **tests** — `tests/laige-render/sprite_draw_tests.cpp` (12 tests / 4 suites; CTest entry `sprite_draw` = the step's Verify command, TIMEOUT 300): `SpriteDrawCreate` (options validation matrix + the stopped state — no GL), `SpriteDrawState` (the stopped-state behavior + the `frameBuilt` gate + the instance budget + the unbound-atlas rejection — no GL), `SpriteDrawSmoke` (the roadmap's offscreen render: a 1 000-sprite scene — a 32×32 lattice of unit tiles (scale 0.125) in two atlases (a 4×4 checkerboard + a solid) and three groups ((0,0,Alpha) 796 instances, (0,0,Additive) 200, (1,0,Alpha) 4) on a cleared (0,0,255) frame, plus 3 probe sprites) — `SpriteRenderer::create` + `bindAtlas` ×2 + one submit: asserts `drawCalls == 3 == the group count` (the machine-greppable `sprite-draw:` line: `groups=3 draw_calls=3 instances=1000 primitives=2000 texture_binds=2 blend_changes=3 nonempty=16384 reference_mismatches=0`), the GL-side cross-check `primitives == 2000 == 2 × 1000` (the opt-in query ON), and the whole 128×128 frame against a CPU reference rasterizer that walks the built frame in the exact draw order and accumulates the per-group blend in double (±1 byte per channel — the GPU float32 vs the reference double — plus three rounding-exact probe pixels: an alpha checkerboard texel, an additive-over-clear texel, and the solid atlas-1 texel; the reference is a CPU double-precision reimplementation of the shader's exact pipeline — the 2×2 linear inverse + the screen-space rotation inverse + the UV mapping); `SpriteDrawPipeline` (the M2-GL-02 integration: a 100-frame offscreen run through `RenderThread` — the batch stage (clear + `beginFrame` + 1 000 `add` + `build`) + the submit stage (`SpriteRenderer::submit`) on the render thread, the `GlContext` handoff (release on the test thread → `makeCurrent` in `onStart` → `release` in `onStop`), the submit loop PACED to the render thread (`waitIdle` per frame — a tight loop would outrun the software-GL render and drop 98 of 100); asserts the exact since-construction totals: `frames=100`, `drawCalls=300`, `instances=100 000`, `textureBinds=200` (2/frame — the last-atlas carries across frames), `blendChanges=201` (3 on frame 1 + 2 on each later frame — the last-blend carries across frames), `primitives=0` (the query OFF in this renderer), `framesSubmitted=100`/`framesRendered=100`/`framesDropped=0` — + the last frame survives in the FBO after ordered shutdown (the P0 probe pixel read back exact)); `ctest -R sprite_draw` green (the GL suites `GTEST_SKIP` on an environment failure — the CI path: Mesa software GL on the offscreen FBO, the sandbox's no-GPU rule); **docs** (DOC-007, same change) — NEW `docs/api/sprite_renderer.md` (the full API contract: the API, the one-draw-per-group + the state-persistence model, the shader + the pass's GL state model, the counters, the DOC-004 **Performance** section — O(G×5 + n) per frame, zero allocation, the state-change observability, the render-target/viewport/state-persistence/primitiveQuery traps — ownership/lifetime/threading (the context outlives the renderer), a performant example, the misuse warnings), `docs/api/gl_context.md` (the NEW `frameBuffer()` row — the render-target frame buffer handle: the offscreen FBO on headless, 0 on windowed/stopped, no GL call; the per-frame draw path binds it once per frame), `docs/api/sprite_batcher.md` (the NEW `frameBuilt()` row + the submit-stage gate + the Related link), `docs/README.md` API index, `docs/concepts/coordinates.md` §4.8 (the sprite-draw narrative + the World→screen conversion table row now shipped + §6/Related), `src/laige-render/README.md` status; `laige-api.json` regenerated (`cmake --build build --target laige-api` — 1211 symbols / 37 headers — +36: `SpriteDrawStats` + 5 fields, `SpriteDrawTotals` + 6 fields, `SpriteRenderer` + 8 members + 2 constants, `GlContext::frameBuffer`, `SpriteBatcher::frameBuilt`; `api-real-tree`/`api-check-fresh` green), `include-lint` (65 files), and `determinism-lint` OK; **local verification** — the canonical tree builds warning-free under NFR-8.10 with full `ctest` green (incl. `sprite_draw` 12/12 + the API/lint entries); the remaining five trees re-verified in the follow-up (build-clang/build-release/build-shared/build-asan/build-tsan); **compat** — additive only (no existing symbol's signature or meaning changed; the new public API is `SpriteRenderer`/`SpriteDrawStats`/`SpriteDrawTotals` + `GlContext::frameBuffer` + `SpriteBatcher::frameBuilt`); **scope note** — the implementation exceeds the roadmap's "~300 lines + tests" sanity note for the same documented-contract reason as M2-SORT-01/M2-SPRITE-01 (the header preamble + the API doc are part of the implementation per CORE-006/DOC-004); Progress Board M2 14/33, total 60/194 | | 2026-10-04 | M2-ISO-02 | `—` | **Budget revision (CI follow-up to M2-ISO-02)** — the `iso_depthkey_rebuild` gate (PRD §8.1, mean ≤ 0.2 ms, 10k dirty cells after a terrain edit) failed the CI `Linux x64 (clang++)` reference lane repeatedly on **unchanged engine code** (the `setTile` path has not changed since the 2026-10-01 workload fix): the reference lane (Clang 18.1.3, CMake Debug, ubuntu-24.04 shared runner) measures the workload at **0.194–0.267 ms**, straddling the 0.2 ms bar — a zero-margin gate whose pass/fail was decided by runner load, not engine regression (evidence: PR lane run 37192995927 attempts 1–2 — 0.194142 PASS / 0.201495 FAIL, then 0.266709 / 0.267328 FAIL on a slow shared runner; master merge-lane run 37194767648 on commit `70830a7` — 0.202146 / 0.202018 FAIL, the `Linux x64 (g++)` lane passing the same workload on the same commit). Revised per the NFR-8.1 policy ("unless the budget is revised via a PRD revision"): **PRD v0.4** §8.1 `≤ 0.2 ms` → `≤ 0.3 ms`; **`budgets.json`** `target` 0.2 → **0.3**, `measured` 0.0814067 → **0.202204** (latest recorded CI reference value, worse backend); **new baseline** `docs/benchmarks/baselines/m2-iso-depth-table-budget-rebaseline.md` (the eighth baseline, verbatim CI reports); **docs** — `docs/api/iso_depth_table.md`, `docs/api/iso_depth_key.md`, `docs/concepts/coordinates.md`, `docs/README.md`, and the M2/M5/M8 roadmap budget references updated to the revised value in the same change (DOC-003), baselines index entry added. No engine code, workload, or test change — budget + docs only (methodology §1: no regression occurred; this is a calibration of the gate's margin on the reference toolchain). | +| 2026-10-04 | M2-SPRITE-03 | `—` | Atlas UV frame animation hook (M2-SPRITE-03 scope, nothing else): **API** — header-only `src/laige-render/include/laige/render/sprite_frames.h` (`laige::render`, public, additive): `SpriteFrameLayout` (the atlas sheet frame layout in texels — `frameWidth`/`frameHeight`, `columns`/`rows`, `frameSpacing` (the gap between adjacent frames), `sheetBorder` (the sheet-edge margin); plain value, no ownership, validated at use time) + `kSpriteFrameMaxAtlasTexels` (2^24 — the float-exact atlas domain) + `spriteFrameUv(frameIndex, layout, atlasWidth, atlasHeight) → Result` (the pure frame-index → UV sub-rect computation: O(1), zero allocation, no logging, no GL; 4 exactly-rounded float divisions — the UV corners are the exactly-rounded k/W values); **sheet model (documented)** — row-major, frame 0 at the top-left (`col = i % columns`, `row = i / columns`); frame (c, r) occupies `[border + c·(fw+spacing), +fw) × [border + r·(fh+spacing), +fh)` texels; the tight sheet is `2·border + cols·fw + (cols−1)·spacing` wide/tall (a wider atlas = slack margin, the fit check is authoritative); **v-axis** — v = 0 is the first texel row of the uploaded RGBA array (the sheet's TOP row — the M2-SPRITE-02 GL_NEAREST "texel row = floor(v·h)" contract), so frame row 0 carries the smallest v; **failure (CORE-008, API-008, first failure wins)** — the layout is caller-owned, untrusted asset metadata (SCALE-004): zero extents / atlas outside [1, 2^24] / frameIndex ≥ columns·rows (the documented OUT-OF-RANGE contract: the engine NEVER wraps silently) / the frame's rect beyond the atlas — all `InvalidArgument`, never a UV rect (the u64 overflow guard rejects an adversarial stride before the col·stride multiplication can wrap — CPP-004/SCALE-004); **exactness** — float-exact domain (atlas ≤ 2^24): every pixel coordinate < 2^24 is exactly float-representable and the invariant u1 > u0, v1 > v0 holds EXACTLY (two distinct k/W never round to the same float) — pure function, bit-identical every platform/build (presentation-only, ARCH-009/010); **the M3 hook (data-driven, ARCH-009)** — `SpriteItem` gained `frameIndex` (the declared animation frame — the caller sets it + sets `uv` to the frame's rect via `spriteFrameUv`; the batcher carries the index through untouched — `uv` is what the M2-SPRITE-02 renderer draws; M3 animation drives the frame advance on top of this same layout); **batcher delta** — `SpriteItem` +1 u32 (76 B/slot, ~136 B/capacity slot, 6.8 MB at 50k); the batcher stays pure integer bookkeeping (the index passes through untouched); **tests** — `tests/laige-render/sprite_frames_tests.cpp` (new CTest entry `sprite_frames` = the step's Verify command; 11 tests / 4 suites, no GL): `SpriteFrameUvGolden` (hand-computed UVs for documented layouts: packed 4×4 sheet, tight 82×82 margin sheet, non-square 12×8 frames with spacing, single column/row, the idempotent call), `SpriteFrameErrors` (out-of-range at count / beyond / u32 top with the no-wrap pin, zero-extent layouts, the atlas domain incl. the exact 2^24 top, the fit failures incl. the one-texel-short spacing + the exact boundary + the adversarial stride), `SpriteFrameProperty` (2 000 seeded random tight sheets vs the documented formula — the float-exact invariants + the row/col adjacency rule (exact touch packed, strict gap spaced) — + the 1 000-conversion zero-allocation proof, the iso_picking/depth_sort precedent), `SpriteFrameItemPassThrough` (the frameIndex + uv pair survives the batcher's add/build/get — the M3 entry point); **docs** — NEW `docs/api/sprite_frames.md` (the full contract + Performance per DOC-004 + the M3 hook + misuse warnings), `docs/api/sprite_batcher.md` (the SpriteItem table row + the 76 B/slot update + Related), `docs/concepts/coordinates.md` §4.8 (the UV bullet) + §5 table row (Atlas frame → UV sub-rect, shipped) + Related, `docs/README.md` API index, the module README status paragraph; `laige-api.json` regenerated (1221 symbols / 38 headers, +10 symbols / +1 header); **verification** — all six local trees warning-clean + full ctest green (build, build-clang, build-release, build-shared, build-asan, build-tsan: `ctest -R sprite_frames` + the full `laige-render_tests`); `api-real-tree`/`api-check-fresh`, `include-lint` (38 public headers), and `determinism-lint` green; **budget** — no standalone `budgets.json` entry (the per-frame conversion cost is part of the composite 50k render-CPU budget, measured with M2-PERF-01); **compat** — additive only (no existing symbol's signature or meaning changed; the new public API is `SpriteFrameLayout`/`spriteFrameUv`/`kSpriteFrameMaxAtlasTexels` + `SpriteItem.frameIndex`); **scope note** — the implementation exceeds the roadmap's "~100 lines" sanity note for the same documented-contract reason as M2-SORT-01/M2-SPRITE-01 (the header preamble + the API doc are part of the implementation per CORE-006/DOC-004); Progress Board M2 15/33, total 61/194 | --- diff --git a/src/laige-render/README.md b/src/laige-render/README.md index 5936140..0af99c9 100644 --- a/src/laige-render/README.md +++ b/src/laige-render/README.md @@ -251,11 +251,12 @@ M2-SPRITE-01 landed the sprite item + batcher — the engine-owned 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 +sprite — world position, the M2-ISO-01 depth key, UV sub-rect, the +animation frame index (M2-SPRITE-03), rotation, scale, tint, blend, +atlas/material refs, the G-R11 depth-override flag) in a budgeted, +accounted `ArenaPool` (`SpriteBatcher`, `create(Options{maxSprites})` +— ~136 B per capacity slot, 6.8 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 @@ -309,5 +310,27 @@ software GL on the CI offscreen path; the suites self- entry: the composite 50k render-CPU budget is measured with this stage (M2-PERF-01). -Atlas UV animation (M2-SPRITE-03), render observability (M2-SPRITE-04), -and sim→render wiring land in the remaining M2 steps. +M2-SPRITE-03 landed the atlas UV frame animation hook — the +data-driven half of FR-2.1's "atlas UV animation (sheet frames)": the +atlas sheet frame LAYOUT (`SpriteFrameLayout`: frame size, row/col, +the inter-frame spacing + the sheet-edge margin, all in texels — the +documented sheet model: row-major, frame 0 at the top-left, tight +sheet `2·border + cols·fw + (cols−1)·spacing`) and the pure +frame-index → UV sub-rect computation (`spriteFrameUv(frameIndex, +layout, atlasWidth, atlasHeight)` — O(1), zero allocation, +float-exact at atlas dimensions ≤ 2^24, the documented `u1 > u0`, +`v1 > v0` invariant; out-of-range frame → `InvalidArgument`, never +wrap). `SpriteItem` gained the animation frame index (`frameIndex` — +the caller sets it, the batcher carries it through untouched; the +caller also sets `uv` to the frame's UV rect — M3 animation will +drive the frame advance on top of this same layout). Header-only +`include/laige/render/sprite_frames.h`; API contract in +[docs/api/sprite_frames.md](../docs/api/sprite_frames.md), tests +under [tests/laige-render](../tests/laige-render) (CTest entry +`sprite_frames` — pure float/integer math, no GL environment +required). No standalone `budgets.json` entry: the per-frame +conversion cost is part of the composite 50k render-CPU budget, +measured with M2-PERF-01. + +Render observability (M2-SPRITE-04) and sim→render wiring land in the +remaining M2 steps. diff --git a/src/laige-render/include/laige/render/sprite_batcher.h b/src/laige-render/include/laige/render/sprite_batcher.h index e0e709a..45a9c69 100644 --- a/src/laige-render/include/laige/render/sprite_batcher.h +++ b/src/laige-render/include/laige/render/sprite_batcher.h @@ -77,7 +77,8 @@ // 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. +// and its animation frame index (frameIndex — M2-SPRITE-03) are +// carried through untouched — no float arithmetic here. // // --------------------------------------------------------------------------- // Overflow: bounded, drop oldest + warn (PERF-008, S-2) @@ -108,11 +109,11 @@ // 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) +// Storage layout (CORE-005, PERF-004; ~136 bytes per capacity slot — +// 6.8 MB at the 50k stress budget, PRD §8.1) // --------------------------------------------------------------------------- // -// sprite pool ArenaPool, 72 B/slot (budgeted, +// sprite pool ArenaPool, 76 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 @@ -264,8 +265,9 @@ enum class BlendMode : std::uint8_t { // --------------------------------------------------------------------------- // 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). +// (u1 > u0, v1 > v0 — the caller's invariant; spriteFrameUv, +// laige/render/sprite_frames.h, computes these from the atlas sheet +// frame layout — M2-SPRITE-03). struct SpriteUvRect { float u0{}; float v0{}; @@ -304,6 +306,15 @@ struct SpriteItem { bool depthOverride{}; // The UV sub-rect in the atlas (SpriteUvRect above). SpriteUvRect uv{}; + // The animation frame index this item was declared with + // (M2-SPRITE-03 — data-driven: the caller sets it; M3 animation + // will drive the frame advance). The engine does not interpret it: + // the caller also sets `uv` to this frame's UV sub-rect (see + // spriteFrameUv, laige/render/sprite_frames.h) — `uv` is what the + // renderer draws, the index is the declaration's frame record + // (presentation state, ARCH-009). The batcher carries it through + // untouched. + std::uint32_t frameIndex{}; // The rotation in radians (0 = unrotated; the submit stage applies // it in screen space, M2-SPRITE-02). float rotation{}; @@ -386,7 +397,7 @@ class SpriteBatcher { 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 + // structure (the preamble's layout — ~136 B/capacity slot). Fails // (InvalidArgument, no allocation) when maxSprites is 0 or exceeds // kSpriteBatcherMaxCapacity (the slot-width domain). // diff --git a/src/laige-render/include/laige/render/sprite_frames.h b/src/laige-render/include/laige/render/sprite_frames.h new file mode 100644 index 0000000..2708e94 --- /dev/null +++ b/src/laige-render/include/laige/render/sprite_frames.h @@ -0,0 +1,243 @@ +// laige-render atlas frame layout (M2-SPRITE-03): the atlas UV frame +// animation hook. +// +// FR-2.1: "Batched textured quads; ... supports rotation, scale, tint, +// per-sprite depth, UV sub-rect, atlas UV animation (sheet frames)." +// This step ships the DATA-DRIVEN half of "atlas UV animation": the +// atlas sheet frame LAYOUT (frame size, row/col, margins — documented +// below) and the pure frame-index → UV sub-rect computation that turns +// a caller-set animation frame index into the `SpriteItem.uv` the +// M2-SPRITE-02 shader consumes. This is the hook M3 animation drives: +// for now the frame index is set by the caller per frame (the game's +// declaration pipeline); M3 will own the frame-advance policy on top +// of this same layout. +// +// SpriteFrameLayout The atlas sheet's frame layout (texels) +// spriteFrameUv The frame index → UV sub-rect function +// +// --------------------------------------------------------------------------- +// The sheet model (the documented layout) +// --------------------------------------------------------------------------- +// +// A sprite sheet is a rows × columns grid of frames, ROW-MAJOR, frame +// 0 at the top-left: +// +// frame i: col = i % columns, row = i / columns +// +// Each frame is `frameWidth` × `frameHeight` texels. `frameSpacing` is +// the gap BETWEEN adjacent frames (texels); `sheetBorder` is the margin +// between the sheet edge and the first/last frame on every side +// (texels). Frame (col, row) occupies the texel rect +// +// x: [border + col·(fw + spacing), border + col·(fw + spacing) + fw) +// y: [border + row·(fh + spacing), border + row·(fh + spacing) + fh) +// +// — the gap lives to the RIGHT of each frame and BELOW each row, and a +// TIGHT sheet is exactly +// +// width = 2·border + columns·fw + (columns − 1)·spacing +// height = 2·border + rows·fh + (rows − 1)·spacing +// +// (the last column's/row's gap is absorbed into the border). A WIDER +// atlas is fine — the extra slack is just margin; the fit check in +// `spriteFrameUv` (every frame's rect inside the atlas) is the +// authoritative validity rule. +// +// The UV rect is the frame's texel rect normalized into the +// `atlasWidth × atlasHeight` atlas: `u0 = x0 / W`, `v0 = y0 / H`, +// `u1 = x1 / W`, `v1 = y1 / H` — the `SpriteUvRect` of +// `sprite_batcher.h` (u1 > u0, v1 > v0). V-axis convention: v = 0 is +// the FIRST texel row of the uploaded RGBA array (the sheet's TOP row — +// the M2-SPRITE-02 `GL_NEAREST` "texel row = floor(v·h)" contract), so +// frame row 0 (the sheet's top row) carries the smallest v, and a +// frame's v rect is measured from the top, not the bottom. +// +// --------------------------------------------------------------------------- +// Exactness (the float-exact domain) +// --------------------------------------------------------------------------- +// +// All texel coordinates are exact in float — and the invariant +// u1 > u0, v1 > v0 holds EXACTLY (two distinct k/W values never round +// to the same float) — when the atlas dimensions are within +// [1, kSpriteFrameMaxAtlasTexels] = [1, 2^24] (16 777 216 — far beyond +// the practical driver maxTextureSize of 16K–32K): every pixel +// coordinate is then < 2^24, exactly representable in float, and the +// UV corners are the EXACTLY-rounded k/W values. Outside the domain the +// call fails `InvalidArgument` (the domain is part of the layout +// contract — a sheet that does not fit a float-exact atlas is an +// invalid layout, not a degraded one). +// +// The conversion is a PURE function (no allocation, no logging, no +// state, no GL): a handful of integer checks + 4 float divisions, +// bit-identical on every platform/build (render-side float — the +// ARCH-009/010 presentation-only scope; never in the sim state hash). +// +// --------------------------------------------------------------------------- +// Failure (CORE-008, API-008) — first failure wins +// --------------------------------------------------------------------------- +// +// The layout is CALLER-OWNED asset metadata (the game's import pipeline +// for now, the asset system from M3 — untrusted input, SCALE-004), so +// every field is validated at the use boundary: +// +// 1. frameWidth, frameHeight, columns, rows must be >= 1 +// (InvalidArgument — a zero extent is an invalid layout, not an +// "empty" one); +// 2. atlasWidth, atlasHeight must be within [1, 2^24] +// (InvalidArgument — the float-exact domain above); +// 3. frameIndex < columns·rows (InvalidArgument — the documented +// OUT-OF-RANGE contract: the engine never wraps silently; a +// wrapping game computes the index itself); +// 4. the frame's texel rect fits the atlas (InvalidArgument — the +// layout does not describe the sheet it claims to). +// +// A failed call returns the error; it never produces a UV rect. +// +// --------------------------------------------------------------------------- +// Ownership, threading (CORE-009, CONC-001) +// --------------------------------------------------------------------------- +// +// Stateless: `SpriteFrameLayout` is a plain value (no ownership, +// nothing to release) and `spriteFrameUv` is a free function — no +// owner, no locks, callable from any thread (the declaration pipeline +// calls it once per animated sprite per frame). The `SpriteItem` +// carries the result: `frameIndex` (the declared animation frame — +// carried through the batcher untouched) and `uv` (the frame's UV +// rect — what the renderer draws). The caller's invariant: `uv` is +// this item's `frameIndex` under its atlas's layout (M3 keeps it). +// +// Canonical narrative: docs/api/sprite_frames.md (the full contract); +// the sheet model's place in the sprite pipeline: +// docs/concepts/coordinates.md §4.8. + +#pragma once + +#include + +#include "laige/errors.h" +#include "laige/render/sprite_batcher.h" // SpriteUvRect +#include "laige/result.h" + +namespace laige::render { + +// --------------------------------------------------------------------------- +// Named constants (CORE-005) +// --------------------------------------------------------------------------- + +// The float-exact atlas domain (the header's Exactness section): +// atlas dimensions are [1, kSpriteFrameMaxAtlasTexels]. 2^24 = +// 16 777 216 — every texel coordinate < 2^24 is exactly +// float-representable, and two distinct k/W values never round to the +// same float (the u1 > u0, v1 > v0 invariant, exact). Far beyond the +// practical driver maxTextureSize (16K–32K). +inline constexpr std::uint32_t kSpriteFrameMaxAtlasTexels = 1u << 24; + +// --------------------------------------------------------------------------- +// The atlas sheet's frame layout (texel units) +// --------------------------------------------------------------------------- + +// The layout of the frames in one atlas sheet (the header's sheet +// model). All fields are texels (not world units, not normalized UV — +// the layout is asset-side data, the import pipeline's output for now, +// the asset system's from M3). A plain value: no ownership, nothing to +// release; validated at use time by spriteFrameUv (the failure +// section). +struct SpriteFrameLayout { + // The frame width in texels (>= 1). + std::uint32_t frameWidth{}; + // The frame height in texels (>= 1). + std::uint32_t frameHeight{}; + // The frame columns per row (>= 1). + std::uint32_t columns{}; + // The frame rows (>= 1). + std::uint32_t rows{}; + // The gap between adjacent frames, texels (0 = packed). + std::uint32_t frameSpacing{}; + // The margin between the sheet edge and the first/last frame on + // every side, texels (0 = flush to the edge). + std::uint32_t sheetBorder{}; +}; + +// --------------------------------------------------------------------------- +// The frame index → UV sub-rect computation +// --------------------------------------------------------------------------- + +// The UV sub-rect of `frameIndex` in the `atlasWidth × atlasHeight` +// atlas, under `layout` (the header: the sheet model, the exactness +// domain, the failure contract). The frame index is 0-based row-major +// (frame 0 = top-left); an out-of-range index fails +// `InvalidArgument` — the engine NEVER wraps silently (CORE-008). +// +// @budget O(1): a handful of integer checks + 4 float divisions; zero +// allocation; no logging, no GL. One call per animated sprite per +// frame in the declaration pipeline — never per texel. +[[nodiscard]] inline Result spriteFrameUv( + std::uint32_t frameIndex, const SpriteFrameLayout& layout, + std::uint32_t atlasWidth, std::uint32_t atlasHeight) noexcept { + // 1. The layout's extents (the header's failure section — first + // failure wins). + if (layout.frameWidth == 0 || layout.frameHeight == 0 || + layout.columns == 0 || layout.rows == 0) { + return Result::failure(ErrorCode::InvalidArgument); + } + // 2. The float-exact domain ([1, 2^24]). + if (atlasWidth == 0 || atlasWidth > kSpriteFrameMaxAtlasTexels || + atlasHeight == 0 || atlasHeight > kSpriteFrameMaxAtlasTexels) { + return Result::failure(ErrorCode::InvalidArgument); + } + // 3. The frame index: no silent wrap (CORE-008). The product is + // u64 (columns·rows can exceed 2^32 for adversarial layouts). + const std::uint64_t frameCount = + static_cast(layout.columns) * layout.rows; + if (frameIndex >= frameCount) { + return Result::failure(ErrorCode::InvalidArgument); + } + const std::uint32_t col = frameIndex % layout.columns; + const std::uint32_t row = frameIndex / layout.columns; + // The frame stride (u64: frameWidth + frameSpacing can exceed 2^32 + // for adversarial layouts). + const std::uint64_t strideX = + static_cast(layout.frameWidth) + layout.frameSpacing; + const std::uint64_t strideY = + static_cast(layout.frameHeight) + layout.frameSpacing; + // 4. The fit — and the overflow guard: with col > 0 the origin is at + // least strideX, so a stride beyond the atlas already fails the + // fit; reject BEFORE the col·stride multiplication (which can + // wrap u64 for adversarial strides — unsigned wrap is well-defined + // but a wrapped origin could pass the fit check with the WRONG + // frame, CPP-004 / SCALE-004: the layout is untrusted metadata). + if (col > 0 && strideX > atlasWidth) { + return Result::failure(ErrorCode::InvalidArgument); + } + if (row > 0 && strideY > atlasHeight) { + return Result::failure(ErrorCode::InvalidArgument); + } + // The pixel origin (now < 2^56: col < 2^32, stride <= atlas <= 2^24 + // on this path — no u64 wrap). + const std::uint64_t x0 = + static_cast(layout.sheetBorder) + + static_cast(col) * strideX; + const std::uint64_t y0 = + static_cast(layout.sheetBorder) + + static_cast(row) * strideY; + if (x0 + static_cast(layout.frameWidth) > atlasWidth || + y0 + static_cast(layout.frameHeight) > atlasHeight) { + return Result::failure(ErrorCode::InvalidArgument); + } + // Past the fit: x0 + frameWidth <= atlasWidth <= 2^24, so the u32 + // casts are lossless and the float conversions exact (the header's + // Exactness section). + const std::uint32_t x1 = static_cast( + x0 + static_cast(layout.frameWidth)); + const std::uint32_t y1 = static_cast( + y0 + static_cast(layout.frameHeight)); + const float w = static_cast(atlasWidth); + const float h = static_cast(atlasHeight); + return Result::success(SpriteUvRect{ + static_cast(static_cast(x0)) / w, + static_cast(static_cast(y0)) / h, + static_cast(x1) / w, + static_cast(y1) / h}); +} + +} // namespace laige::render diff --git a/tests/laige-render/CMakeLists.txt b/tests/laige-render/CMakeLists.txt index 8fc26d8..80c3c3a 100644 --- a/tests/laige-render/CMakeLists.txt +++ b/tests/laige-render/CMakeLists.txt @@ -87,7 +87,16 @@ # against a CPU reference rasterizer), and the 100-frame integration # run through the M2-GL-02 RenderThread with the GL handoff) — the # GL suites need a usable OpenGL 3.3 environment (the documented -# environment contract: GTEST_SKIPs where absent). +# environment contract: GTEST_SKIPs where absent); and the atlas +# frame layout (M2-SPRITE-03) — the atlas UV frame animation hook +# (sprite_frames_tests.cpp's SpriteFrame* suites: the hand-computed +# UV rects for documented layouts (tight sheets, margins, non-square +# frames, single row/column), the documented failure contract +# (out-of-range frame -> InvalidArgument, never wrap; layout +# validation first-failure-wins), the sheet model against the +# documented formula + the float-exact domain + the adjacency rule, +# and the SpriteItem.frameIndex hook through the batcher) — pure +# float/integer math: no GL environment needed. # # One executable per module (tests/README.md): laige-render_tests links # the module under test plus gtest_main. The unfiltered entry runs the @@ -105,9 +114,11 @@ # 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`), the `batcher` entry is the M2-SPRITE-01 -# Verify command (`ctest -R batcher`), and the `sprite_draw` entry is -# the M2-SPRITE-02 Verify command (`ctest -R sprite_draw`), each -# selecting exactly its suites from the shared executable. +# Verify command (`ctest -R batcher`), the `sprite_draw` entry is the +# M2-SPRITE-02 Verify command (`ctest -R sprite_draw`), and the +# `sprite_frames` entry is the M2-SPRITE-03 Verify command +# (`ctest -R sprite_frames`), each selecting exactly its suites from +# the shared executable. # # Environment note: the GlContextSmoke, RenderThreadOffscreen, and # SpriteDraw{State,Smoke,Pipeline} suites require a usable OpenGL 3.3 @@ -123,7 +134,8 @@ add_executable(laige-render_tests gl_context_tests.cpp render_thread_tests.cpp matrices_tests.cpp iso_depth_key_tests.cpp iso_depth_table_tests.cpp camera_tests.cpp iso_camera_tests.cpp projection_tests.cpp iso_picking_tests.cpp - depth_sort_tests.cpp sprite_batcher_tests.cpp sprite_draw_tests.cpp) + depth_sort_tests.cpp sprite_batcher_tests.cpp sprite_draw_tests.cpp + sprite_frames_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 @@ -243,6 +255,14 @@ add_test(NAME sprite_draw COMMAND laige-render_tests --gtest_filter=SpriteDrawCreate.*:SpriteDrawState.*:SpriteDrawSmoke.*:SpriteDrawPipeline.*) +# M2-SPRITE-03: the step's Verify command is `ctest -R sprite_frames`. +# Pure float/integer math (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 per-frame conversion cost is part of the composite +# 50k render-CPU budget, measured with M2-PERF-01). +add_test(NAME sprite_frames COMMAND laige-render_tests + --gtest_filter=SpriteFrame*) + 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) @@ -263,8 +283,9 @@ if(LAIGE_TSAN) # 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`, `depth_sort`, - # `batcher`, and `sprite_draw` entries carry the option for uniformity - # (the pure-math ones have no shared state, but the flag is harmless). + # `batcher`, `sprite_draw`, and `sprite_frames` entries carry the + # option for uniformity (the pure-math ones have no shared state, but + # the flag is harmless). # The `sprite_draw` entry is a second TSan gate: the # SpriteDrawPipeline integration run drives the render thread's # stages against the renderer + batcher from two threads (the GL @@ -273,7 +294,7 @@ if(LAIGE_TSAN) # restated here. set_tests_properties(laige-render_tests gl_context render_thread matrices iso_depth_key iso_depth_table camera iso_camera projection - iso_picking depth_sort batcher sprite_draw PROPERTIES + iso_picking depth_sort batcher sprite_draw sprite_frames PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1;LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json") endif() @@ -307,7 +328,8 @@ 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`, `iso_picking`, -# and `batcher` entries are small (thousands of O(1) computations, +# `batcher`, and `sprite_frames` entries are small (thousands of O(1) +# computations, # comparisons, and O(1) camera / projection updates and picks at most, # plus 10k-cell round trips and a few hundred-sprite batch builds) — # 60s is generous headroom. @@ -326,6 +348,7 @@ 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) +set_tests_properties(sprite_frames 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_frames_tests.cpp b/tests/laige-render/sprite_frames_tests.cpp new file mode 100644 index 0000000..e995ced --- /dev/null +++ b/tests/laige-render/sprite_frames_tests.cpp @@ -0,0 +1,379 @@ +// laige-render atlas frame tests (M2-SPRITE-03): the atlas UV frame +// animation hook in laige/render/sprite_frames.h. +// +// Pure float/integer math — no GL context, no GL environment needed: +// every suite runs in every local tree and in CI. The golden suite +// pins the roadmap's "frame index → UV rect exact for a documented +// atlas layout"; the errors suite pins the documented failure +// contract (out-of-range frame → InvalidArgument, never wrap; layout +// validation first-failure-wins); the property suite pins the sheet +// model against the documented formula + the float-exact domain; the +// pass-through suite pins the SpriteItem.frameIndex hook through the +// batcher (the M3 animation's data-driven entry point). +// +// 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_frames.h" + +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/alloc_watch.h" +#include "laige/errors.h" +#include "laige/prng.h" +#include "laige/result.h" +#include "laige_test_seed.h" + +#include "laige/render/sprite_batcher.h" + +namespace { + +using laige::render::SpriteBatcher; +using SpriteBatcherOptions = laige::render::SpriteBatcher::Options; +using laige::render::SpriteFrameLayout; +using laige::render::SpriteItem; +using laige::render::SpriteUvRect; +using laige::render::kSpriteFrameMaxAtlasTexels; +using laige::render::spriteFrameUv; + +// The randomized suite's substream id (docs/testing.md §4 — stable +// named constants, CORE-005). +constexpr std::uint32_t kPropertySubstreamId = 0x5346524D; // "SFRM" + +// The documented sheet formula, recomputed independently of the +// implementation's expression (the property suite's oracle): frame i's +// texel rect under the layout. +struct PixelRect { + std::uint32_t x0{}; + std::uint32_t y0{}; + std::uint32_t x1{}; + std::uint32_t y1{}; +}; + +PixelRect framePixelRect(std::uint32_t frameIndex, + const SpriteFrameLayout& l) { + const std::uint32_t col = frameIndex % l.columns; + const std::uint32_t row = frameIndex / l.columns; + const std::uint32_t x0 = + l.sheetBorder + col * (l.frameWidth + l.frameSpacing); + const std::uint32_t y0 = + l.sheetBorder + row * (l.frameHeight + l.frameSpacing); + return PixelRect{x0, y0, x0 + l.frameWidth, y0 + l.frameHeight}; +} + +// The UV rect the documented formula yields (the oracle's float step — +// the same single correctly-rounded division per corner). +SpriteUvRect expectUv(std::uint32_t frameIndex, const SpriteFrameLayout& l, + std::uint32_t w, std::uint32_t h) { + const PixelRect r = framePixelRect(frameIndex, l); + return SpriteUvRect{ + static_cast(r.x0) / static_cast(w), + static_cast(r.y0) / static_cast(h), + static_cast(r.x1) / static_cast(w), + static_cast(r.y1) / static_cast(h)}; +} + +void expectUvExact(const char* what, std::uint32_t frameIndex, + const SpriteFrameLayout& l, std::uint32_t w, + std::uint32_t h, SpriteUvRect expected) { + auto uv = spriteFrameUv(frameIndex, l, w, h); + ASSERT_TRUE(uv.ok()) << what << ": " << frameIndex << " failed: " + << laige::errorText(uv.error()); + const SpriteUvRect got = std::move(uv).takeValue(); + EXPECT_EQ(got.u0, expected.u0) << what; + EXPECT_EQ(got.v0, expected.v0) << what; + EXPECT_EQ(got.u1, expected.u1) << what; + EXPECT_EQ(got.v1, expected.v1) << what; +} + +void expectInvalid(const char* what, std::uint32_t frameIndex, + const SpriteFrameLayout& l, std::uint32_t w, + std::uint32_t h) { + const auto uv = spriteFrameUv(frameIndex, l, w, h); + ASSERT_FALSE(uv.ok()) << what; + EXPECT_EQ(uv.error(), laige::ErrorCode::InvalidArgument) << what; +} + +} // namespace + +// --------------------------------------------------------------------------- +// SpriteFrameUvGolden — hand-computed UV rects for documented layouts +// (the roadmap's "frame index → UV rect exact for a documented atlas +// layout"). +// --------------------------------------------------------------------------- + +TEST(SpriteFrameUvGolden, PlainSheetNoMargins) { + // 128x128 atlas, 16x16 frames, 4x4 grid, packed (the tight sheet is + // 64x64 — the atlas is wider/taller than the tight sheet, the + // slack is margin). + const SpriteFrameLayout l{16, 16, 4, 4, 0, 0}; + expectUvExact("plain", 0, l, 128, 128, + SpriteUvRect{0.0f, 0.0f, 16.0f / 128.0f, 16.0f / 128.0f}); + expectUvExact("plain", 3, l, 128, 128, + SpriteUvRect{48.0f / 128.0f, 0.0f, 64.0f / 128.0f, + 16.0f / 128.0f}); + expectUvExact("plain", 5, l, 128, 128, + SpriteUvRect{16.0f / 128.0f, 16.0f / 128.0f, 32.0f / 128.0f, + 32.0f / 128.0f}); + expectUvExact("plain", 15, l, 128, 128, + SpriteUvRect{48.0f / 128.0f, 48.0f / 128.0f, 64.0f / 128.0f, + 64.0f / 128.0f}); +} + +TEST(SpriteFrameUvGolden, TightSheetWithMargins) { + // 82x82 atlas, 32x32 frames, 2x2 grid, spacing 2, border 8 — the + // tight sheet: 2*8 + 2*32 + 1*2 = 82. Frame (c, r): origin + // (8 + 34c, 8 + 34r). + const SpriteFrameLayout l{32, 32, 2, 2, 2, 8}; + expectUvExact("margins", 0, l, 82, 82, + SpriteUvRect{8.0f / 82.0f, 8.0f / 82.0f, 40.0f / 82.0f, + 40.0f / 82.0f}); + expectUvExact("margins", 1, l, 82, 82, + SpriteUvRect{42.0f / 82.0f, 8.0f / 82.0f, 74.0f / 82.0f, + 40.0f / 82.0f}); + expectUvExact("margins", 2, l, 82, 82, + SpriteUvRect{8.0f / 82.0f, 42.0f / 82.0f, 40.0f / 82.0f, + 74.0f / 82.0f}); + expectUvExact("margins", 3, l, 82, 82, + SpriteUvRect{42.0f / 82.0f, 42.0f / 82.0f, 74.0f / 82.0f, + 74.0f / 82.0f}); +} + +TEST(SpriteFrameUvGolden, NonSquareFramesAndRows) { + // 44x24 atlas, 12x8 frames, 3 columns x 2 rows, spacing 1, border 2 + // (tight: 2*2 + 3*12 + 2*1 = 42 — 2 px slack horizontally). + const SpriteFrameLayout l{12, 8, 3, 2, 1, 2}; + expectUvExact("nonsquare", 0, l, 44, 24, + SpriteUvRect{2.0f / 44.0f, 2.0f / 24.0f, 14.0f / 44.0f, + 10.0f / 24.0f}); + expectUvExact("nonsquare", 4, l, 44, 24, + SpriteUvRect{15.0f / 44.0f, 11.0f / 24.0f, 27.0f / 44.0f, + 19.0f / 24.0f}); + expectUvExact("nonsquare", 5, l, 44, 24, + SpriteUvRect{28.0f / 44.0f, 11.0f / 24.0f, 40.0f / 44.0f, + 19.0f / 24.0f}); +} + +TEST(SpriteFrameUvGolden, SingleColumnAndSingleRow) { + // 8x48 atlas, 8x16 frames, 1 column x 3 rows, packed. + const SpriteFrameLayout column{8, 16, 1, 3, 0, 0}; + expectUvExact("column", 0, column, 8, 48, + SpriteUvRect{0.0f, 0.0f, 1.0f, 16.0f / 48.0f}); + expectUvExact("column", 2, column, 8, 48, + SpriteUvRect{0.0f, 32.0f / 48.0f, 1.0f, 1.0f}); + // 64x8 atlas, 16x8 frames, 4 columns x 1 row, packed. + const SpriteFrameLayout row{16, 8, 4, 1, 0, 0}; + expectUvExact("row", 2, row, 64, 8, + SpriteUvRect{32.0f / 64.0f, 0.0f, 48.0f / 64.0f, 1.0f}); +} + +TEST(SpriteFrameUvGolden, DeterministicAndIdempotent) { + // The pure function: the same call twice is bit-identical. + const SpriteFrameLayout l{16, 16, 4, 4, 0, 0}; + const auto a = spriteFrameUv(5, l, 128, 128); + const auto b = spriteFrameUv(5, l, 128, 128); + ASSERT_TRUE(a.ok()); + ASSERT_TRUE(b.ok()); + const SpriteUvRect& ra = *a.valueIfOk(); + const SpriteUvRect& rb = *b.valueIfOk(); + EXPECT_EQ(ra.u0, rb.u0); + EXPECT_EQ(ra.v0, rb.v0); + EXPECT_EQ(ra.u1, rb.u1); + EXPECT_EQ(ra.v1, rb.v1); +} + +// --------------------------------------------------------------------------- +// SpriteFrameErrors — the documented failure contract (first failure +// wins; out-of-range frames fail, never wrap). +// --------------------------------------------------------------------------- + +TEST(SpriteFrameErrors, OutOfRangeFrameNeverWraps) { + const SpriteFrameLayout l{16, 16, 4, 4, 0, 0}; // 16 frames + // Exactly at the count, beyond it, and at the u32 top: + expectInvalid("out-of-range at count", 16, l, 128, 128); + expectInvalid("out-of-range beyond", 17, l, 128, 128); + expectInvalid("out-of-range u32 top", 0xFFFFFFFFu, l, 128, 128); + // NO WRAP: frame 16 must NOT return frame 0's rect (the last frame + // is frame 15; the in-range ends stay exact): + expectUvExact("no-wrap frame 15", 15, l, 128, 128, + SpriteUvRect{48.0f / 128.0f, 48.0f / 128.0f, 64.0f / 128.0f, + 64.0f / 128.0f}); +} + +TEST(SpriteFrameErrors, ZeroExtentLayouts) { + const std::uint32_t atlas = 128; + expectInvalid("frameWidth 0", 0, + SpriteFrameLayout{0, 16, 4, 4, 0, 0}, atlas, atlas); + expectInvalid("frameHeight 0", 0, + SpriteFrameLayout{16, 0, 4, 4, 0, 0}, atlas, atlas); + expectInvalid("columns 0", 0, SpriteFrameLayout{16, 16, 0, 4, 0, 0}, atlas, + atlas); + expectInvalid("rows 0", 0, SpriteFrameLayout{16, 16, 4, 0, 0, 0}, atlas, + atlas); +} + +TEST(SpriteFrameErrors, AtlasDomain) { + const SpriteFrameLayout l{1, 1, 1, 1, 0, 0}; + expectInvalid("atlasWidth 0", 0, l, 0, 1); + expectInvalid("atlasHeight 0", 0, l, 1, 0); + expectInvalid("atlasWidth above domain", 0, l, kSpriteFrameMaxAtlasTexels + 1, + 1); + expectInvalid("atlasHeight above domain", 0, l, 1, + kSpriteFrameMaxAtlasTexels + 1); + // The domain top is exact (1x1 frame in a max-domain atlas): + expectUvExact("domain top", 0, l, kSpriteFrameMaxAtlasTexels, + kSpriteFrameMaxAtlasTexels, + SpriteUvRect{0.0f, 0.0f, + 1.0f / static_cast(kSpriteFrameMaxAtlasTexels), + 1.0f / static_cast(kSpriteFrameMaxAtlasTexels)}); +} + +TEST(SpriteFrameErrors, FrameDoesNotFit) { + // The border alone pushes the frame past the edge: + expectInvalid("border too big", 0, SpriteFrameLayout{32, 32, 1, 1, 0, 40}, + 64, 64); + // The spacing pushes the second column past the edge — one texel + // short, then exact at the boundary (the fit check accepts a frame + // whose x1 == atlas width): + expectInvalid("spacing too big", 1, SpriteFrameLayout{32, 32, 2, 1, 32, 0}, + 95, 32); + expectUvExact("spacing at boundary", 1, SpriteFrameLayout{32, 32, 2, 1, + 32, 0}, + 96, 32, SpriteUvRect{64.0f / 96.0f, 0.0f, 1.0f, 1.0f}); + // Adversarial stride beyond the atlas (the header's overflow guard: + // frameWidth + frameSpacing exceeds the u32-friendly domain and the + // col·stride product would be beyond the atlas long before the fit + // check — rejected, not a wrapped origin): + expectInvalid("adversarial stride", 1, + SpriteFrameLayout{1, 1, 8, 2, 0xFFFFFFFFu, 0}, 4, 4); +} + +// --------------------------------------------------------------------------- +// SpriteFrameProperty — the sheet model against the documented formula, +// the float-exact invariants, the adjacency rule, and the +// zero-allocation proof (the iso_picking / depth_sort precedent — +// non-sanitizer trees). +// --------------------------------------------------------------------------- + +TEST(SpriteFrameProperty, LayoutModelAndZeroAlloc) { + laige::Prng prng = laige::testing::TestPrng(kPropertySubstreamId); + for (int i = 0; i < 2000; ++i) { + const std::uint32_t fw = prng.next_range(1, 33); + const std::uint32_t fh = prng.next_range(1, 33); + const std::uint32_t columns = prng.next_range(1, 9); + const std::uint32_t rows = prng.next_range(1, 9); + const std::uint32_t spacing = prng.next_range(0, 9); + const std::uint32_t border = prng.next_range(0, 17); + // The tight sheet (the header's tight formula) — always fits its + // own layout: + const std::uint32_t w = + 2 * border + columns * fw + (columns - 1) * spacing; + const std::uint32_t h = + 2 * border + rows * fh + (rows - 1) * spacing; + const SpriteFrameLayout l{fw, fh, columns, rows, spacing, border}; + const std::uint32_t frameCount = columns * rows; + const std::uint32_t frameIndex = prng.next_range(0, frameCount); + auto uv = spriteFrameUv(frameIndex, l, w, h); + ASSERT_TRUE(uv.ok()) << "i=" << i; + const SpriteUvRect got = std::move(uv).takeValue(); + const SpriteUvRect expected = expectUv(frameIndex, l, w, h); + EXPECT_EQ(got.u0, expected.u0) << "i=" << i; + EXPECT_EQ(got.v0, expected.v0) << "i=" << i; + EXPECT_EQ(got.u1, expected.u1) << "i=" << i; + EXPECT_EQ(got.v1, expected.v1) << "i=" << i; + // The float-exact invariants (the header's Exactness section): + EXPECT_LT(got.u0, got.u1) << "i=" << i; + EXPECT_LT(got.v0, got.v1) << "i=" << i; + EXPECT_LE(got.u1, 1.0f) << "i=" << i; + EXPECT_LE(got.v1, 1.0f) << "i=" << i; + // The adjacency rule: the next frame in the row starts where this + // frame's gap ends — exact touch when packed, a strict gap + // otherwise. The frame after the last in the row is the next row + // (checked on the row only). + const std::uint32_t col = frameIndex % columns; + if (col + 1 < columns) { + const SpriteUvRect next = expectUv(frameIndex + 1, l, w, h); + if (spacing == 0) { + EXPECT_EQ(got.u1, next.u0) << "i=" << i; + } else { + EXPECT_LT(got.u1, next.u0) << "i=" << i; + } + EXPECT_EQ(got.v0, next.v0) << "i=" << i; + } + const std::uint32_t row = frameIndex / columns; + if (row + 1 < rows) { + const SpriteUvRect next = expectUv(frameIndex + columns, l, w, h); + if (spacing == 0) { + EXPECT_EQ(got.v1, next.v0) << "i=" << i; + } else { + EXPECT_LT(got.v1, next.v0) << "i=" << i; + } + EXPECT_EQ(got.u0, next.u0) << "i=" << i; + } + } + + // Zero-allocation proof (where the process-wide watch is live — the + // non-sanitizer trees; the sanitizer runtimes own operator new, the + // depth_sort test precedent): 1 000 consecutive conversions allocate + // nothing — the function is fixed-size value traffic, structurally + // zero-heap (PERF-003). + const SpriteFrameLayout l{16, 16, 4, 4, 0, 0}; + if (laige::allocWatchLive()) { + laige::allocWatchArm(); + for (int i = 0; i < 1000; ++i) { + const auto uv = spriteFrameUv(static_cast(i % 16), l, 128, + 128); + if (!uv.ok()) { + ADD_FAILURE() << "conversion " << i << " failed: " + << laige::errorText(uv.error()); + break; + } + } + const laige::AllocWatchReading reading = laige::allocWatchRead(); + EXPECT_EQ(reading.allocs, 0u) + << "1000 conversions allocated " << reading.allocs + << " heap blocks (first site: " + << reinterpret_cast(reading.firstSite) << ")"; + } +} + +// --------------------------------------------------------------------------- +// SpriteFrameItemPassThrough — the SpriteItem.frameIndex hook through +// the batcher (the M3 animation's data-driven entry point: the caller +// sets frameIndex + uv, the batcher carries both untouched). +// --------------------------------------------------------------------------- + +TEST(SpriteFrameItemPassThrough, ItemCarriesFrameIndexAndUv) { + const SpriteFrameLayout l{16, 16, 4, 4, 0, 0}; + auto uv = spriteFrameUv(3, l, 128, 128); + ASSERT_TRUE(uv.ok()); + const SpriteUvRect uvRect = std::move(uv).takeValue(); + SpriteItem item; + item.pos = laige::render::Vec2{1.0f, 2.0f}; + item.depthKey = 42; + item.uv = uvRect; + item.frameIndex = 3; + item.rotation = 0.25f; + item.scale = laige::render::Vec2{1.0f, 1.0f}; + item.atlasId = 7; + + auto r = SpriteBatcher::create(SpriteBatcherOptions{8}); + ASSERT_TRUE(r.ok()); + SpriteBatcher batcher = std::move(r).takeValue(); + auto slot = batcher.add(item); + ASSERT_TRUE(slot.ok()); + const auto build = batcher.build(); + ASSERT_TRUE(build.ok()); + const SpriteItem* got = batcher.get(std::move(slot).takeValue()); + ASSERT_NE(got, nullptr); + EXPECT_EQ(got->frameIndex, 3u); + EXPECT_EQ(got->uv.u0, uvRect.u0); + EXPECT_EQ(got->uv.v0, uvRect.v0); + EXPECT_EQ(got->uv.u1, uvRect.u1); + EXPECT_EQ(got->uv.v1, uvRect.v1); + EXPECT_EQ(got->rotation, 0.25f); +}