diff --git a/docs/README.md b/docs/README.md index f3245ba..dd50c10 100644 --- a/docs/README.md +++ b/docs/README.md @@ -309,6 +309,15 @@ still to land. a fixed frame; one tilemap renders in a bounded number of draw calls — one per (texture, material, blend) group) (M2-TILE-01; FR-2.6). + - [Parallax layers](api/parallax.md) — + `laige::render::ParallaxLayers`: the named + background/midground/foreground layer model (M2-PAR-01; FR-2.3): + the parallax factor (0..1), the EXACT world-space offset formula + `factor * (p - center) + offset`, the UV scroll (auto or manual) + with the exact wrap at the texture boundary (rendered through the + 2 x 2 wrap split into the sprite batcher), and the documented + background-first render order (the depth-key layer values: + background -2, midground -1, ground 0, foreground +1). ## Guides diff --git a/docs/api/iso_depth_key.md b/docs/api/iso_depth_key.md index 3b96b0b..8ba3ee8 100644 --- a/docs/api/iso_depth_key.md +++ b/docs/api/iso_depth_key.md @@ -48,8 +48,12 @@ uint32_t isoDepthKey(sim::SimMath::Vec2 pos, units (the tile map's per-tile height, M2-TILE-01). *Not* the object's own sprite height. - **`layer`** — the render layer (`kIsoDepthGroundLayer` = 0 default; - the parallax layer values land with M2-PAR-01 — background layers - negative/sorted first, foreground positive/sorted last). + the parallax layer values are documented, M2-PAR-01, + [`parallax.md`](parallax.md) — the presets are + `kParallaxDepthLayerBackground` = -2 (sorted first), + `kParallaxDepthLayerMidground` = -1, + `kParallaxDepthLayerForeground` = +1 (sorted last); a custom layer + picks any value in the domain [-512, +511]). **The formula** (world space only — never screen space, PRD §4): @@ -192,5 +196,7 @@ if (posOk.ok()) { shear contract is pinned against (M2-GL-03). - [`presentation.md`](presentation.md) — the interpolated positions the key reads (M1-LOOP-02). +- [`parallax.md`](parallax.md) — the parallax layer values that use + the key's layer field (M2-PAR-01). - Roadmap: M2-ISO-01 (this), M2-ISO-02 (key table), M2-SORT-01 (stable radix sort), M2-SPRITE-01/02 (batcher), M2-PAR-01 (layer values). diff --git a/docs/api/iso_depth_table.md b/docs/api/iso_depth_table.md index d95d2d4..13b6786 100644 --- a/docs/api/iso_depth_table.md +++ b/docs/api/iso_depth_table.md @@ -43,7 +43,8 @@ The per-sprite key this table precomputes is addressing shift/mask, division-free), `maxChunks` (default `kIsoDepthTableDefaultMaxChunks` = 1024; the growth cap), `layer` (default `kIsoDepthGroundLayer` = 0 — a parallax tile LAYER gets its -own table with its layer value, M2-PAR-01). +own table with its layer value, M2-PAR-01 — the values are +documented in [`parallax.md`](parallax.md)). ### The cell model @@ -244,6 +245,8 @@ if (table.value().covers(gx, gy)) { gate's harness. - [baselines/m2-iso-depth-table.md](../benchmarks/baselines/m2-iso-depth-table.md) — the recorded 10k-dirty-cell baseline. +- [`parallax.md`](parallax.md) — the parallax layer values that use + the table's layer (M2-PAR-01). - Roadmap: M2-ISO-02 (this), M2-TILE-01 (tilemap wiring), M2-SORT-01 (stable radix sort), M2-SPRITE-01/02 (batcher), M2-PAR-01 (layer values). diff --git a/docs/api/parallax.md b/docs/api/parallax.md new file mode 100644 index 0000000..f86ed20 --- /dev/null +++ b/docs/api/parallax.md @@ -0,0 +1,302 @@ +# Parallax layers (`laige::render` parallax) + +The named background / midground / foreground layer model of FR-2.3 — +the parallax factor, the world-space offset, the UV scroll (auto or +manual) with the exact wrap at the texture boundary, and the batch +path into the sprite batcher (M2-PAR-01; PRD §15 M2, FR-2.3 — +"Parallax layers: named background/midground/foreground layers with +parallax factor, offset, UV scroll, blend", §9.1 S-5, ARCH-009, +G-R11; AGENTS RENDER-001/003, CORE-002/004/005/008, PERF-002/003; ADR +0002). Public header: +`src/laige-render/include/laige/render/parallax.h` (header-only — the +API is a template over the SimMath backends, the +[`presentation.h`](../src/laige-sim/include/laige/sim/presentation.h) +pattern). Unit suite: `ctest -R parallax` +(`tests/laige-render/parallax_tests.cpp`) — pure data + batcher +bookkeeping, no GL environment required: it runs in every local tree +and in CI, and the goldens are hand-computed from the documented +formula. + +The canonical narrative home is +[`docs/concepts/coordinates.md`](../concepts/coordinates.md) §4.10 +(ARCH-008); the header preamble carries the machine-checked contract. +The layer quads' keys are the +[`iso_depth_key.md`](iso_depth_key.md) (M2-ISO-01) keys with the +layer's depth-layer value (engine-owned, G-R11); the batch path +declares into [`sprite_batcher.md`](sprite_batcher.md) +(M2-SPRITE-01). Tilemap-source layers (the M2-TILE-02 hook) declare +through the tilemap's path — +[`tilemap.md`](tilemap.md) — with this layer's `worldOffset` +translation and `depthLayer`. + +## The API + +| Member | Returns | Notes | +|---|---|---| +| `ParallaxLayers::create(options)` | `Result` | `maxLayers` in `[1, kParallaxLayersMaxLayers]` (64; default `kParallaxLayersDefaultLayers` = 8) — validates (no log), pre-sizes the slot table (setup path) | +| `ParallaxLayers() = default` | the stopped state | `valid()` false, every operation `InvalidArgument`, no log (the RenderThread stopped-state precedent) | +| `setLayer(def)` | `Status` | registers or REPLACES the layer (setup/config path — the scene config's hot-reload, FR-1.5); validates the def in the documented order (first failure wins); a rejection leaves the slot unchanged; a success RESETS the layer's UV offset to (0, 0) | +| `setUvOffset(id, uv)` | `Status` | sets the layer's CURRENT UV offset (any finite value — wrapped to [0, 1)²); unknown id → `InvalidArgument` (no log); non-finite → `InvalidArgument` + one rate-limited `parallax/uv_offset_invalid` warn | +| `advanceScrolls()` | void | the AUTO layers' per-frame UV advance (once per frame, before the declarations); manual layers untouched; O(layers), no allocation, no logging, no GL | +| `declareTo(batcher, cameraPos)` | `Status` | the render path: declares every SET, ENABLED Image-source layer's wrap quads into the batcher's frame window (the 2 x 2 split); Tilemap-source layers are skipped (the M2-TILE-02 hook — no log); O(layers x 4) batcher adds, no allocation, no logging, no GL | +| `worldOffsetAt(id, cameraPos)` | `Vec2` | the EXACT formula (1): `factor * (cameraPos - center) + offset` — pure O(1), no allocation, no logging, no GL | +| introspection | | `valid()`, `maxLayers()`, `layerCount()`, `has(id)`, `layerAt(id)`, `uvOffsetAt(id)` | + +`ParallaxLayerDef` is a plain value (the scene config — the value the +game declares): `id` (slot id < `maxLayers`), `source` +(`Image` | `Tilemap`), `factor` (in [0, 1] — one source of truth), +`center` (the reference camera position — where the layer sits at +exactly `offset`), `offset` (the world-space offset at `p = center`), +`size` (world units; Image only), `uv` (the atlas sub-rect; Image +only), `atlasId`, `tilemapId` (Tilemap only), `materialId`, `blend`, +`depthLayer` (the M2-ISO-01 key layer value — the presets are the +`kParallaxDepthLayer*` constants; a custom layer picks any value in +the M2-ISO-01 domain [-512, +511]), `scrollMode` +(`Manual` | `Auto`), `scrollSpeed` (UV units PER FRAME per axis — +negative scrolls the other way), `enabled`. The named presets: +`kParallaxLayerBackground` = 0 (bg), `kParallaxLayerMidground` = 1 +(mid), `kParallaxLayerForeground` = 2 (fg), +`kParallaxLayerCustomBase` = 3 (custom layers: any id ≥ 3). + +## The model + +A layer is a **world-space rectangle** of one texture (the Image +source) or of one tilemap (the Tilemap source, M2-TILE-02). The +layer's content position at camera position `p` (the camera's +ground-plane (x, y) — the M2-CAM-01 presentation position) is the +EXACT formula: + +``` +worldOffset(p) = factor * (p - center) + offset (1) +``` + +Everything is WORLD space (PRD §4, RENDER-006): (1) is a world-space +translation; nothing in it is screen space. The camera position is +the presentation-side input (ARCH-009) — the sim never sees it. + +- **`factor` = 0**: the layer is FIXED IN WORLD SPACE at `offset` + (it moves full-speed against the camera — maximum parallax, a + close-by background). **`factor` = 1**: the layer moves 1:1 WITH the + camera (it is fixed on screen — no parallax, the sky layer). + Between: the layer's screen position shifts by + `(factor - 1) * (p - center)`. +- **`center`**: the reference camera position (default (0, 0) — the + scene's world origin). +- **`offset`**: the layer's world-space offset at `p = center` + (default (0, 0) — the content origin). + +The Image source renders the layer's texture over the world rectangle +`[worldOffset(p), worldOffset(p) + size)` — `size` is the rectangle's +extent in world units. The texture's v axis (v = 0 = first uploaded +texel row, the M2-SPRITE-02 contract) maps to the world +y direction +(downward on screen under the iso projections — +[coordinates.md](../concepts/coordinates.md)). + +The Tilemap source references a tilemap (its `tilemapId`): the +tilemap defines its own world grid, and M2-TILE-02 declares its tiles +with this layer's `worldOffset` translation and its `depthLayer`. In +M2-PAR-01 a Tilemap-source layer is DATA ONLY: `declareTo` skips it +(the hook), its `size`/`uv` fields are not validated. + +## The render order (the background-first contract) + +The batcher draws one group per distinct (atlas, material, blend) in +its deterministic group order — ascending (atlas, material, blend) +(RENDER-003, M2-SPRITE-01) — and, within a group, the instances in the +M2-ISO-01 key order (the layer field dominant). The parallax layer's +quads carry the key's LAYER field: + +``` +kParallaxDepthLayerBackground = -2 (the `bg` preset) +kParallaxDepthLayerMidground = -1 (the `mid` preset) +kIsoDepthGroundLayer = 0 (the ground — M2-ISO-01) +kParallaxDepthLayerForeground = +1 (the `fg` preset) +``` + +- **WITHIN a shared (atlas, material, blend) group**: the layer field + dominates the key, so every background-layer quad sorts before + every ground object and every foreground quad AFTER it, whatever + the quads' v — background first, engine-guaranteed (the M2-ISO-01 + "layer dominates" contract; the tests pin the ordering against the + independent M2-ISO-01 oracle). +- **ACROSS groups**: the batcher's group order (ascending (atlas, + material, blend)) is the draw order — the scene's SET-UP must + assign the parallax layers' atlas ids so the group order matches + the depth order: background layers' atlas ids BELOW the world + content's, foreground layers' ABOVE it (the same convention as the + M2-TILE-01 texture-id assignment). +- **A layer's own quads** (the wrap split) tile its rectangle without + overlap: their relative order is the deterministic (key, + declaration) total order and is visually irrelevant. + +## The UV scroll (auto or manual) + the exact wrap + +Each layer carries a CURRENT UV OFFSET in [0, 1)² — the texture's +wrap position within its rectangle. Per texture, the sample UV at a +world point `w` in the rectangle is + +``` +u = frac((w.x - X) / size.x + uvOffset.x) X, Y = the +v = frac((w.y - Y) / size.y + uvOffset.y) rectangle's + world origin corner +``` + +(the M2-SPRITE-02 UV convention: v = 0 is the texture TOP). +Increasing the offset scrolls the texture toward +u (+x world) and +v +(+y world). + +- **Manual mode**: the caller drives the offset — `setUvOffset` (any + finite value; it is WRAPPED to [0, 1)²). +- **Auto mode**: the engine advances the offset by `scrollSpeed` (UV + units PER FRAME — frames are the presentation pace; frame-rate + independence is the caller's concern, the M2-CAM-01 lerp + precedent) on each `advanceScrolls()` call — once per frame, before + the declarations. + +The WRAP is exact at the texture boundary: `wrap(x) = x - floor(x)` +(in [0, 1) for every finite x; 1.0 wraps to exactly 0.0; -0.25 wraps +to exactly 0.75 — dyadic values wrap bit-exactly, which the tests +pin). + +**Rendering the wrap through the batcher (the 2 x 2 split):** a +single `SpriteItem` carries ONE UV rect — no wrap — so a scrolled +layer declares its rectangle as the four wrap-aligned quads (fixed +order q00, q10, q01, q11; a quad whose range is empty — uvOffset 0 +on that axis — is skipped). With `o = worldOffset(p)`, +`wx = o.x + (1 - uvOffset.x) * size.x`, +`wy = o.y + (1 - uvOffset.y) * size.y`: + +| Quad | World rect | Atlas uv (base range) | +|---|---|---| +| q00 | `[o.x, wx) x [o.y, wy)` | `[ox, 1) x [oy, 1)` (always) | +| q10 | `[wx, o.x+sx) x [o.y, wy)` | `[0, ox) x [oy, 1)` (ox > 0) | +| q01 | `[o.x, wx) x [wy, o.y+sy)` | `[ox, 1) x [0, oy)` (oy > 0) | +| q11 | `[wx, o.x+sx) x [wy, o.y+sy)` | `[0, ox) x [0, oy)` (both > 0) | + +The world rects tile the rectangle exactly (no gap, no overlap — the +tests pin the areas summing to the rectangle's area), and each quad's +atlas uv is the layer's `uv` sub-rect mapped over its base range (the +atlas sub-rect mapping, the tests pin it). + +Each quad declares `pos` = its world center, `scale` = its extent, +`rotation` 0, the full-default tint, `atlasId`/`materialId`/`blend` +from the def, and `depthKey` = the M2-ISO-01 key of the quad's world +center at the def's `depthLayer` (engine-owned, G-R11 — +`depthOverride` stays false). + +## Ownership, lifetime, threading + +- **Ownership:** one owner — the sim/scene-owner thread (the scene); + the M2-GL-02 frame pipeline's cull/batch stage owns the render-side + declaration. Move-only (CORE-009). +- **Storage:** pre-sized at creation (the only allocation: `create` — + one slot table, 48 B/slot at 8 layers ≈ 384 B default / 3 KB at 64). +- **Phases (CONC-001):** writes (`setLayer`, `setUvOffset`, + `advanceScrolls`) in the sim/setup phase; reads (`worldOffsetAt`, + `uvOffsetAt`, `declareTo`) in the render phase; the phases do not + overlap. Not thread-safe by design. +- **ARCH-009:** the layer state (definitions + the scroll offsets) is + presentation state — it reads the camera's presentation position, + never sim state, and is never part of replay state or the + simulation state hash (headless-buildable — no GL anywhere in this + API). +- **No silent failure (CORE-008):** every failure path returns the + `Status`/`Result` (the caller handles and logs); the rejected + definitions log one rate-limited `parallax/layer_invalid` warn + (fields `layer`, `field`) — LOG-004; the happy paths log nothing + (the Status is the failure channel — LOG-002 — and they are + budget-critical — LOG-003). + +## Performance (DOC-004) + +| Operation | Cost | Allocations | +|---|---|---| +| `create` | O(maxLayers) — one slot table | setup only | +| `setLayer` | O(1) (validate-before-write + two stores) | none | +| `setUvOffset` | O(1) (the wrap + a store) | none | +| `advanceScrolls` | O(layers) (auto layers only) | none | +| `declareTo` | O(layers x 4) — up to four O(1) batcher adds per layer | none | +| `worldOffsetAt` / `uvOffsetAt` / introspection | O(1) | none | + +- **No per-frame allocation** (FR-2.2): the `advanceScrolls` + + `declareTo` + `build` loop allocates nothing (the adds are O(1) + operations over the batcher's pre-allocated storage — the + zero-allocation proof, 1000 frames × 3 layers (up to 4 quads each) + + 2 sprites, the tests). +- **No standalone budget entry**: the per-frame declare cost is PART + of the composite 50k render-CPU budget (PRD §8.1, + `sprites_50k_cpu` — M2-PERF-01 measures the reference scene with the + parallax layers included — the M2-SCENE-01 reference scene has 3 + parallax layers within the 50k-sprite / ≤30-draw-call budget). +- **Draw calls** (FR-2.1, RENDER-001): the batcher's + (atlas, material, blend) grouping renders each parallax layer in + one draw call per DISTINCT (atlasId, materialId, blend) of its quads + — a full-texture un-scrolled layer is ONE quad (one instance); a + scrolled layer is up to four quads in ONE group (one draw call). +- **Common traps:** forgetting the `advanceScrolls()` per-frame call + in Auto mode (the offset freezes — no error, no log: the caller + paces it at the frame's pace); calling `advanceScrolls` more than + once per frame (the texture scrolls faster than `scrollSpeed` + says); setting `scrollSpeed` as if it were UV units per SECOND + (frames are the unit — frame-rate independence is the caller's + concern); hand-writing the quad's depth key (the key is the + M2-ISO-01 key of the quad's center at the layer's `depthLayer` — + G-R11); declaring on a built frame (`beginFrame` first — the + precondition is checked, first failure wins, nothing declared); + relying on the ACROSS-group order without following the scene's + atlas-id convention (background ids below the world content's, + foreground ids above — above). + +## Example (performant) and misuse warning + +```cpp +using laige::render::ParallaxLayerDef; +using laige::render::ParallaxLayers; +using laige::sim::Fpx16_16; + +// Scene load (sim phase, setup): +ParallaxLayers layers = + std::move(ParallaxLayers::create({}).valueIfOk().value()); +ParallaxLayerDef bg = {}; +bg.id = laige::render::kParallaxLayerBackground; +bg.factor = 0.25f; +bg.offset = {1.0f, 2.0f}; +bg.size = {2.0f, 2.0f}; +bg.atlasId = 0; // below the world content's ids +bg.depthLayer = laige::render::kParallaxDepthLayerBackground; +bg.scrollMode = laige::render::ParallaxScrollMode::Auto; +bg.scrollSpeed = {0.02f, 0.0f}; // UV units PER FRAME +layers.setLayer(bg); + +// Per frame (the render thread's cull/batch stage): +layers.advanceScrolls(); // once per frame, before the declares +batcher.beginFrame(); +layers.declareTo(batcher, camera.position()); // the camera's (x, y) +// ... declare the frame's dynamic sprites ... +batcher.build(); // the layer quads sort + group with the rest +``` + +**Misuse warning:** don't hand-write the layer quads' depth — the key +is the M2-ISO-01 key of each quad's world center at the layer's +`depthLayer` (G-R11). Don't skip the scene's atlas-id convention: +within one (atlas, material, blend) group the layer field guarantees +background-first, but ACROSS groups the draw order is the group +order — background layers' atlas ids below the world content's, +foreground layers' above it. Don't treat `scrollSpeed` as per-second +(frames are the unit). A Tilemap-source layer is DATA ONLY in +M2-PAR-01 (M2-TILE-02 declares its tiles — the hook). + +## Related + +- [`iso_depth_key.md`](iso_depth_key.md) — the key's layer field the + parallax layer values land in (M2-ISO-01). +- [`sprite_batcher.md`](sprite_batcher.md) — the declare, don't draw + batcher the layer quads go into (M2-SPRITE-01). +- [`tilemap.md`](tilemap.md) — the Tilemap-source hook (M2-TILE-01; + the M2-TILE-02 declaration). +- [`sprite_renderer.md`](sprite_renderer.md) — the instanced draw the + batched groups go through (M2-SPRITE-02). +- [`../concepts/coordinates.md`](../concepts/coordinates.md) §4.10 — + the canonical narrative (ARCH-008). +- Roadmap: M2-PAR-01 (this), M2-TILE-02 (tilemap-source layers), + M2-SCENE-01 (the reference scene's 3 parallax layers). diff --git a/docs/api/tilemap.md b/docs/api/tilemap.md index 2f7192d..cb10430 100644 --- a/docs/api/tilemap.md +++ b/docs/api/tilemap.md @@ -42,7 +42,8 @@ duplicated validation): `originTileX/Y` (default 0), `widthTiles` / `kIsoDepthTableChunkTiles` = 16; a power of two ≥ 1), `maxChunks` (default `kIsoDepthTableDefaultMaxChunks` = 1024), `layer` (default `kIsoDepthGroundLayer` = 0 — a parallax tile LAYER gets its own -tilemap with its layer value, M2-PAR-01). `TileData` is a plain value +tilemap with its layer value, M2-PAR-01 — the values are documented +in [`parallax.md`](parallax.md)). `TileData` is a plain value (8 B of data + the table's height — the value the game writes on load/edit and reads back). diff --git a/docs/concepts/coordinates.md b/docs/concepts/coordinates.md index ed5c51f..14e09b3 100644 --- a/docs/concepts/coordinates.md +++ b/docs/concepts/coordinates.md @@ -144,7 +144,7 @@ The render order is the lexicographic tuple **(key, entity id)**: 1. **key** (this step, M2-ISO-01): layer ascending as the coarse primary order (background layers first — the parallax layer values - land with M2-PAR-01), then the quantized depth; + are documented, M2-PAR-01, §4.10), then the quantized depth; 2. **entity id**: equal keys keep the deterministic insertion order — the stable sort (M2-SORT-01) preserves it and the batcher (M2-SPRITE-01) inserts in the engine's deterministic entity-id @@ -337,6 +337,53 @@ The scene's static tile grid is draw call per chunk group); the count is a function of the distinct group keys, never of the tile count. +### 4.10 The parallax layers: the named background/midground/foreground model (M2-PAR-01) + +The scene's parallax layers are +`laige::render::ParallaxLayers` +(`laige/render/parallax.h`) — the named bg/mid/fg model of FR-2.3, +declared into the batcher of §4.7 (S-5) with the §4.1 keys: + +- **The offset formula** (world space only — PRD §4, RENDER-006): a + layer's content position at camera position `p` (the camera's + ground-plane (x, y), the M2-CAM-01 presentation position) is + `worldOffset(p) = factor * (p - center) + offset` — the EXACT + formula (the tests pin it bit-exactly for dyadic values). + `factor` in [0, 1]: 0 = fixed in world space (maximum parallax, a + close-by background), 1 = fixed on screen (no parallax, the sky + layer); `center` = the reference camera position (default the world + origin); `offset` = the world-space offset at `p = center`. +- **The depth layer values** (the §4.1 layer field, engine-owned — + G-R11): `kParallaxDepthLayerBackground` = -2 (the `bg` preset), + `kParallaxDepthLayerMidground` = -1 (the `mid` preset), + `kIsoDepthGroundLayer` = 0 (the ground), + `kParallaxDepthLayerForeground` = +1 (the `fg` preset); a custom + layer picks any value in the M2-ISO-01 domain [-512, +511] + (more negative = further back). +- **Background first** (the documented render order): WITHIN a shared + (atlas, material, blend) group the layer field dominates the key — + every background-layer quad sorts before every ground object and + every foreground quad after it, whatever the quads' v + (engine-guaranteed, the §4.1 "layer dominates" contract). ACROSS + groups the draw order is the batcher's group order (ascending + (atlas, material, blend), §4.7) — the scene's SET-UP assigns the + parallax layers' atlas ids so the group order matches the depth + order: background ids BELOW the world content's, foreground ids + ABOVE it (the M2-TILE-01 texture-id convention). +- **The UV scroll** (auto or manual): each layer carries a CURRENT UV + OFFSET in [0, 1)²; Auto advances it by `scrollSpeed` (UV units PER + FRAME) on `advanceScrolls()` — once per frame, before the + declarations — and the wrap is EXACT at the texture boundary + (`wrap(x) = x - floor(x)`: 1.0 → exactly 0.0). A scrolled layer + renders through the 2 x 2 wrap split (up to four quads — one + SpriteItem carries one UV rect, no wrap); the quad's key is the + §4.1 key of the quad's world center at the layer's `depthLayer`. +- **Tilemap-source layers** (the M2-TILE-02 hook): a layer's + `source = Tilemap` declares its tilemap's tiles with this layer's + `worldOffset` translation and `depthLayer` (the tilemap's + `Options::layer`, §4.9); in M2-PAR-01 they are DATA ONLY + (`declareTo` skips them). + ## 5. Conversion rules (the module boundaries, RENDER-006) | Conversion | Direction | Owner | Status | @@ -348,6 +395,7 @@ The scene's static tile grid is | 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)** | | Tile grid → static tile quads | tile data + table keys → declared sprites (fixed-frame quads) | `laige-render` (`TileMap::declareTo`, M2-TILE-01) | **Shipped (M2-TILE-01)** | +| Camera position → parallax offset | camera (x, y) + layer def → world-space offset (the exact formula) + wrap quads | `laige-render` (`ParallaxLayers::worldOffsetAt` / `declareTo`, M2-PAR-01) | **Shipped (M2-PAR-01)** | ### 5.1 The isometric grid picking (M2-ISO-03) @@ -422,9 +470,11 @@ For one isometric frame, objects render in ascending batcher (M2-SPRITE-01) and drawn by the sprite renderer (M2-SPRITE-02) through the engine's stable depth sort (`DepthSort`, §4.6, M2-SORT-01). -Parallax layers (M2-PAR-01) render background-first via their layer -values; the UI pass (M2-UI-02) is a separate screen-space pass rendered -after all world passes. Determinism of the order is total: same world +Parallax layers (M2-PAR-01, §4.10) render background-first via their +depth-layer values (the layer field dominates within a group; the +scene's atlas-id convention orders the groups); the UI pass +(M2-UI-02) is a separate screen-space pass rendered after all world +passes. Determinism of the order is total: same world state → same keys → same order, every frame (RENDER-003). ## Related @@ -451,6 +501,10 @@ state → same keys → same order, every frame (RENDER-003). - [`api/tilemap.md`](../api/tilemap.md) — the tilemap contract: `TileMap` (chunked tile grid + the auto-depth wiring of the depth table + the static tile-quad batch path) (M2-TILE-01). +- [`api/parallax.md`](../api/parallax.md) — the parallax layer + contract: `ParallaxLayers` (the named bg/mid/fg model — the offset + formula, the depth-layer values, the UV scroll + exact wrap, the + 2 x 2 wrap-split batch path) (M2-PAR-01). - [`api/matrices.md`](../api/matrices.md) — the matrix builders and NDC conventions (M2-GL-03). - [`decisions/0005-iso-default.md`](../decisions/0005-iso-default.md) — diff --git a/laige-api.json b/laige-api.json index fe0037c..eb8a71e 100644 --- a/laige-api.json +++ b/laige-api.json @@ -22,6 +22,7 @@ "src/laige-render/include/laige/render/iso_depth_table.h", "src/laige-render/include/laige/render/iso_picking.h", "src/laige-render/include/laige/render/matrices.h", + "src/laige-render/include/laige/render/parallax.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", @@ -714,6 +715,56 @@ {"name": "laige::render::isoMatrix", "kind": "function", "header": "src/laige-render/include/laige/render/matrices.h", "line": 252, "signature": "[[nodiscard]] Mat4 isoMatrix(IsoAxes axes) noexcept", "summary": "The general affine oblique isometric matrix (world -> NDC) — the \"custom shear\" preset (ADR 0005). See the header preamble for the screen-space form:", "budget": "O(1); no allocation.", "experimental": false}, {"name": "laige::render::isoDimetric2To1", "kind": "function", "header": "src/laige-render/include/laige/render/matrices.h", "line": 265, "signature": "[[nodiscard]] Mat4 isoDimetric2To1(float scale) noexcept", "summary": "The 2:1 dimetric preset (ADR 0005 — the engine default): affine, 45° azimuth, the two ground axes equally foreshortened, the vertical squashed to 1/2. Per-unit ground step screen delta (±2, 1)·scale (NDC, y up); one world tile maps to a 4*scale-wide by 2*scale-tall rhombus; one world height unit maps to `scale` screen units (half the tile height).", "budget": "O(1); no allocation.", "experimental": false}, {"name": "laige::render::isoTrueIso3060", "kind": "function", "header": "src/laige-render/include/laige/render/matrices.h", "line": 278, "signature": "[[nodiscard]] Mat4 isoTrueIso3060(float scale) noexcept", "summary": "The true 30°/60° isometric preset: a true orthographic axonometric projection at 45° azimuth and elevation arcsin(1/√3) ≈ 35.264° — all three axes equally foreshortened, the ground axes at 30° to screen horizontal (hence \"30°/60°\": the tile's edges sit at 30° and 60° to horizontal), the tile a √3:1 rhombus.", "budget": "O(1); no allocation; one sqrt.", "experimental": false}, + {"name": "laige::render::kParallaxLayerBackground", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 293, "signature": "inline constexpr std::uint32_t kParallaxLayerBackground = 0", "summary": "The named-layer presets (the \"named layers\" of FR-2.3): the three canonical ids — the scene's custom layers use any id >= kParallaxLayerCustomBase (and any distinct depth-layer value — \"The render order\" above). \"Named\" = the stable id (the scene config, M3, maps the id to the scene-format name — no std::string in frame data, PRD §10.4).", "budget": null, "experimental": false}, + {"name": "laige::render::kParallaxLayerMidground", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 294, "signature": "inline constexpr std::uint32_t kParallaxLayerMidground = 1", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kParallaxLayerForeground", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 295, "signature": "inline constexpr std::uint32_t kParallaxLayerForeground = 2", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kParallaxLayerCustomBase", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 296, "signature": "inline constexpr std::uint32_t kParallaxLayerCustomBase = 3", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kParallaxDepthLayerBackground", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 303, "signature": "inline constexpr std::int32_t kParallaxDepthLayerBackground = -2", "summary": "The M2-ISO-01 key layer values of the presets (the M2-ISO-01 reserved parallax domain: background layers sort BEFORE the ground, foreground layers AFTER it — \"layer dominates\" in the key): the standard three leave every other domain value free for custom layers (more negative = further back).", "budget": null, "experimental": false}, + {"name": "laige::render::kParallaxDepthLayerMidground", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 304, "signature": "inline constexpr std::int32_t kParallaxDepthLayerMidground = -1", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kParallaxDepthLayerForeground", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 305, "signature": "inline constexpr std::int32_t kParallaxDepthLayerForeground = 1", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kParallaxLayersDefaultLayers", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 310, "signature": "inline constexpr std::uint32_t kParallaxLayersDefaultLayers = 8", "summary": "The registry's slot domain (API-006): [1, kParallaxLayersMaxLayers]; the default covers the PRD reference scene's 3 preset layers + custom headroom. 64 slots x ~104 B = 6.7 KB (the setup path).", "budget": null, "experimental": false}, + {"name": "laige::render::kParallaxLayersMaxLayers", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 311, "signature": "inline constexpr std::uint32_t kParallaxLayersMaxLayers = 64", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxScrollMode", "kind": "enum", "header": "src/laige-render/include/laige/render/parallax.h", "line": 316, "signature": "enum class ParallaxScrollMode : std::uint8_t", "summary": "Manual (the caller drives the UV offset via setUvOffset) | Auto (the engine advances it by scrollSpeed per frame — advanceScrolls).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxScrollMode::Manual", "kind": "enumerator", "header": "src/laige-render/include/laige/render/parallax.h", "line": 317, "signature": "Manual", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxScrollMode::Auto", "kind": "enumerator", "header": "src/laige-render/include/laige/render/parallax.h", "line": 318, "signature": "Auto", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxSource", "kind": "enum", "header": "src/laige-render/include/laige/render/parallax.h", "line": 324, "signature": "enum class ParallaxSource : std::uint8_t", "summary": "Image (a single texture — the atlasId + the atlas sub-rect `uv`) | Tilemap (a tilemap layer — the tilemapId; declared with M2-TILE-02, data only in M2-PAR-01).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxSource::Image", "kind": "enumerator", "header": "src/laige-render/include/laige/render/parallax.h", "line": 325, "signature": "Image", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxSource::Tilemap", "kind": "enumerator", "header": "src/laige-render/include/laige/render/parallax.h", "line": 326, "signature": "Tilemap", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef", "kind": "struct", "header": "src/laige-render/include/laige/render/parallax.h", "line": 335, "signature": "struct ParallaxLayerDef", "summary": "One named layer's definition (the value the game declares through setLayer; plain value — no GL, no allocation). All fields are validated (the setLayer validation order) except `source`, `materialId`, `atlasId`, `tilemapId`, `enabled` (opaque references/flags — the batcher and the asset system own their domains).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::id", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 338, "signature": "std::uint32_t id{}", "summary": "The layer id: [0, the registry's maxLayers); the presets are the kParallaxLayer* constants.", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::source", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 341, "signature": "ParallaxSource source{ParallaxSource::Image}", "summary": "Image (the default — the single-texture layer) | Tilemap (M2-TILE-02).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::factor", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 344, "signature": "float factor{}", "summary": "The parallax factor (1): [0, 1]; 0 = fixed in world space (maximum parallax), 1 = fixed on screen (no parallax).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::center", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 348, "signature": "Vec2 center{}", "summary": "The reference camera position (1): the camera position at which the layer sits at exactly `offset` (default (0, 0) — the world origin).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::offset", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 351, "signature": "Vec2 offset{}", "summary": "The layer's world-space offset at p = center (1) (default (0, 0) — the content origin).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::size", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 355, "signature": "Vec2 size{1.0f, 1.0f}", "summary": "The layer rectangle's extent in world units (Image source only — both > 0; ignored for the Tilemap source, which defines its own grid).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::uv", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 359, "signature": "SpriteUvRect uv{0.0f, 0.0f, 1.0f, 1.0f}", "summary": "The layer's texture sub-rect in its atlas (Image source only — the M2-SPRITE-01 UV domain: 0 <= u0 < u1 <= 1, 0 <= v0 < v1 <= 1; default the full texture).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 363, "signature": "std::uint32_t atlasId{}", "summary": "The layer's texture (atlas) reference (Image source — the batch group key's field 0; the scene's atlas-id convention, \"The render order\", orders the layer's group).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::tilemapId", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 365, "signature": "std::uint32_t tilemapId{}", "summary": "The tilemap's registry id (Tilemap source only — M2-TILE-02).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 368, "signature": "std::uint32_t materialId{}", "summary": "The material reference (0 = the default material — the group key's field 1).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 370, "signature": "BlendMode blend{BlendMode::Alpha}", "summary": "The blend state of every layer quad (the group key's field 3).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::depthLayer", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 375, "signature": "std::int32_t depthLayer{}", "summary": "The M2-ISO-01 key layer value of the layer's quads: the presets are the kParallaxDepthLayer* constants; a custom layer picks any value in the M2-ISO-01 domain [-512, +511] (more negative = further back).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::scrollMode", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 377, "signature": "ParallaxScrollMode scrollMode{ParallaxScrollMode::Manual}", "summary": "Manual (the default) | Auto.", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::scrollSpeed", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 381, "signature": "Vec2 scrollSpeed{}", "summary": "The auto-mode advance: UV units PER FRAME per axis (any finite value — negative scrolls the opposite way; ignored in Manual mode, but still validated finite).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::enabled", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 384, "signature": "bool enabled{true}", "summary": "A disabled layer is skipped by declareTo (its state — including the scroll offset — still evolves through advanceScrolls).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers", "kind": "class", "header": "src/laige-render/include/laige/render/parallax.h", "line": 406, "signature": "template class ParallaxLayers", "summary": "The scene's parallax layer registry: the bounded slot table + the per-frame protocol (the header preamble). Templated over the SimMath backends (the M2-TILE-01 pattern): the declared quads' depth keys are the scene's backend's M2-ISO-01 keys (the cross-backend consistency contract). Header-only — pure value math + batcher bookkeeping, no GL, no allocation in the per-frame path.", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/parallax.h", "line": 416, "signature": "struct Options", "summary": "The create options (API-006): `maxLayers` in [1, kParallaxLayersMaxLayers] (default kParallaxLayersDefaultLayers = 8).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::Options::maxLayers", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 417, "signature": "std::uint32_t maxLayers{kParallaxLayersDefaultLayers}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::create", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 426, "signature": "[[nodiscard]] static laige::Result create(Options options) noexcept", "summary": "Creates the registry (setup path — the only allocation: the pre-sized slot table). maxLayers outside [1, kParallaxLayersMaxLayers] -> InvalidArgument (no log — the M2-SPRITE-01 create precedent). The stopped state (failed create / default) follows the RenderThread precedent: `valid()` false, every operation InvalidArgument, no log.", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::ParallaxLayers", "kind": "constructor", "header": "src/laige-render/include/laige/render/parallax.h", "line": 433, "signature": "ParallaxLayers() noexcept = default", "summary": "The stopped state (default / failed create): `valid()` false, every operation InvalidArgument, no log — the RenderThread stopped-state precedent. Nothing owned (the slot table is null).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::valid", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 436, "signature": "[[nodiscard]] bool valid() const noexcept", "summary": "True iff the registry is live (create succeeded).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::maxLayers", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 439, "signature": "[[nodiscard]] std::uint32_t maxLayers() const noexcept", "summary": "The registry's slot domain (0 in the stopped state).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::layerCount", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 445, "signature": "[[nodiscard]] std::uint32_t layerCount() const noexcept", "summary": "The count of SET slots (setup-path accounting — changes only on setLayer).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::has", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 450, "signature": "[[nodiscard]] bool has(std::uint32_t id) const noexcept", "summary": "True iff `id` is a live, SET layer.", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::layerAt", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 457, "signature": "[[nodiscard]] const ParallaxLayerDef& layerAt(std::uint32_t id) const noexcept", "summary": "The set layer's definition. Precondition: has(id) (asserted — check when the id comes from untrusted input).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::uvOffsetAt", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 465, "signature": "[[nodiscard]] Vec2 uvOffsetAt(std::uint32_t id) const noexcept", "summary": "The layer's CURRENT UV offset (in [0, 1)^2 — (0, 0) for an unset slot). Precondition: has(id) (asserted).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::worldOffsetAt", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 475, "signature": "[[nodiscard]] Vec2 worldOffsetAt(std::uint32_t id, Vec2 cameraPos) const noexcept", "summary": "The layer's world-space offset at camera position `cameraPos` (the camera's ground-plane (x, y)) — the EXACT formula (1): factor * (cameraPos - center) + offset. Pure O(1); no allocation, no logging, no GL. Precondition: has(id) (checked-free read — the M2-TILE-01 tileAt pattern; assert in debug).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::setLayer", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 490, "signature": "[[nodiscard]] laige::Status setLayer(const ParallaxLayerDef& def) noexcept", "summary": "Registers or REPLACES the layer (setup / config path — the scene config's hot-reload of non-simulation config, FR-1.5). Validates the def (the documented order, first failure wins); a rejection leaves the slot unchanged (InvalidArgument + one rate-limited parallax/layer_invalid warn — fields layer, field). A successful (re)set RESETS the layer's UV offset to (0, 0) (the scroll restarts — the def carries no scroll state). O(1); no GL.", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::setUvOffset", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 498, "signature": "[[nodiscard]] laige::Status setUvOffset(std::uint32_t id, Vec2 uvOffset) noexcept", "summary": "Sets the layer's current UV offset (any finite value — wrapped to [0, 1)^2). Works in BOTH scroll modes (a manual nudge on an Auto layer composes with the per-frame advance). Unknown layer id -> InvalidArgument (no log — precondition); non-finite value -> InvalidArgument + one rate-limited parallax/uv_offset_invalid warn; a rejection leaves the offset unchanged. O(1); no GL.", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::advanceScrolls", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 508, "signature": "void advanceScrolls() noexcept", "summary": "Advances the AUTO layers' UV offsets by their scrollSpeed (the exact wrap, the header preamble) — ONCE PER FRAME, before the declarations (the caller paces it at the frame's pace; the auto speed is PER FRAME — frame-rate independence is the caller's concern, the M2-CAM-01 lerp precedent). Manual layers are untouched. O(layers); no allocation, no logging, no GL. No-op in the stopped state.", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::declareTo", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 523, "signature": "[[nodiscard]] laige::Status declareTo(SpriteBatcher& batcher, Vec2 cameraPos) const noexcept", "summary": "The render path (the frame pipeline's cull/batch stage): declares every SET, ENABLED Image-source layer's wrap quads into `batcher` at camera position `cameraPos` (the 2 x 2 split, the header preamble; the quads carry the def's group-key fields, rotation 0, the full-default tint, and the M2-ISO-01 key of the quad's world center at the def's depthLayer — engine-owned, G-R11). Tilemap-source layers are skipped (the M2-TILE-02 hook — no log). Declaration order: ascending layer id, then the fixed quad order (RENDER-003). Precondition: the batcher's window is open (a built frame -> InvalidArgument, nothing declared); the stopped registry -> InvalidArgument. The batcher's own failures (BudgetExhausted) propagate from the first failed add. O(layers x 4) batcher adds; NO allocation, no logging, no GL.", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::LayerSlot::def", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 531, "signature": "ParallaxLayerDef def", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::LayerSlot::uvOffset", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 532, "signature": "Vec2 uvOffset{0.0f, 0.0f}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::LayerSlot::valid", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 533, "signature": "bool valid{false}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::ProjectionMode", "kind": "enum", "header": "src/laige-render/include/laige/render/projection.h", "line": 200, "signature": "enum class ProjectionMode", "summary": "The per-scene/view projection mode (FR-2.5). iso is the engine default (ADR 0005, PRD v0.2) — the default member of ProjectionView.", "budget": null, "experimental": false}, {"name": "laige::render::ProjectionMode::Iso", "kind": "enumerator", "header": "src/laige-render/include/laige/render/projection.h", "line": 201, "signature": "Iso", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::ProjectionMode::SideView", "kind": "enumerator", "header": "src/laige-render/include/laige/render/projection.h", "line": 202, "signature": "SideView", "summary": null, "budget": null, "experimental": false}, @@ -848,33 +899,33 @@ {"name": "laige::render::TileData::height", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 190, "signature": "std::int32_t height{}", "summary": "The tile's step height in world units (the standing surface's elevation — the auto-depth source, the M2-ISO-01 `z` input).", "budget": null, "experimental": false}, {"name": "laige::render::TileData::animationId", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 193, "signature": "std::uint32_t animationId{}", "summary": "The animation-table reference (DATA ONLY in M2 — M2-TILE-02 cycles frames from it; the batch path never touches it).", "budget": null, "experimental": false}, {"name": "laige::render::TileMap", "kind": "class", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 204, "signature": "template class TileMap", "summary": "The tile grid + its depth table (M2-ISO-02) + the batch path.", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::Options", "kind": "alias", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 220, "signature": "using Options = IsoDepthKeyTable::Options", "summary": "The create options — the table's options themselves (one type, passed through to the table's create — the validation is the table's, first failure wins, InvalidArgument): `originTileX/Y` (default 0), `widthTiles`/`heightTiles` (required >= 1), `chunkTiles` (default `kIsoDepthTableChunkTiles` = 16, a power of two >= 1), `maxChunks` (default `kIsoDepthTableDefaultMaxChunks` = 1024), `layer` (default `kIsoDepthGroundLayer` = 0 — a parallax tile LAYER gets its own tilemap with its layer value, M2-PAR-01).", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::DeclareOptions", "kind": "struct", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 224, "signature": "struct DeclareOptions", "summary": "The per-frame declare options (the fixed-frame quad model, above): the group-key fields for every tile sprite + the fixed UV frame.", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::DeclareOptions::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 226, "signature": "std::uint32_t materialId{0}", "summary": "The material reference (0 = the default material).", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::DeclareOptions::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 229, "signature": "BlendMode blend{BlendMode::Alpha}", "summary": "The blend state of every tile sprite (the group key's third field).", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::DeclareOptions::uv", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 232, "signature": "SpriteUvRect uv{0.0f, 0.0f, 1.0f, 1.0f}", "summary": "The fixed frame: every tile quad's UV sub-rect (default (0, 0, 1, 1) = the full tile texture).", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::create", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 241, "signature": "[[nodiscard]] static Result create(const Options& options) noexcept", "summary": "Creates the tilemap (setup path — the only allocations besides rebuild's setup temporary): validates the options (the table's create — first failure wins, InvalidArgument), pre-sizes the table's initial grid chunks, and pre-sizes the per-tile data array (8 B/tile, the requested grid). Flat/empty init: every tile is {textureId 0, height 0, animationId 0} — no tile is set.", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::setTile", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 256, "signature": "[[nodiscard]] Status setTile(std::int32_t tileX, std::int32_t tileY, std::uint32_t textureId, std::int32_t height, std::uint32_t animationId) noexcept", "summary": "The per-tile edit (sim phase). Stores the tile's textureId and animationId and routes the height into the table's setTile — the AUTO-DEPTH wiring: the table stores the height and recomputes exactly that cell's key (the M2-ISO-02 incremental update, radius 0; the other cells' keys are untouched). O(1), zero allocation.", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::rebuild", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 276, "signature": "[[nodiscard]] Status rebuild(std::span tiles) noexcept", "summary": "Scene load (setup path — the only place besides create that allocates: one temporary covered-height span for the table's from-scratch rebuild). Stores the whole tile grid: the per-tile data and the heights through the table's rebuild (every key through the full M2-ISO-01 function). `tiles` covers the REQUESTED grid — `widthTiles() * heightTiles()` entries, row-major, tileX fastest.", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::declareTo", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 294, "signature": "[[nodiscard]] Status declareTo(SpriteBatcher& batcher, const DeclareOptions& options) noexcept", "summary": "The render path (the frame pipeline's cull/batch stage): declares this tilemap's static tile quads into the batcher's current frame window — one SpriteItem per tile of the REQUESTED grid, in the grid's row-major order (tileY outer, tileX fastest — the preamble's quad model: the tile's center, the table's key (auto-depth), the tile's texture, the fixed frame). O(tileCount), zero allocation, no GL, no logging — the adds are O(1) batcher operations.", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::tileAt", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 299, "signature": "[[nodiscard]] TileData tileAt(std::int32_t tileX, std::int32_t tileY) const noexcept", "summary": "The read path (render phase, O(1), zero allocation, no GL):", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::depthKeyAt", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 301, "signature": "[[nodiscard]] std::uint32_t depthKeyAt(std::int32_t tileX, std::int32_t tileY) const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::tileHeightAt", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 303, "signature": "[[nodiscard]] std::int32_t tileHeightAt(std::int32_t tileX, std::int32_t tileY) const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::covers", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 309, "signature": "[[nodiscard]] bool covers(std::int32_t tileX, std::int32_t tileY) const noexcept", "summary": "Whether the tile lies in the REQUESTED grid (the batch path and the tileAt/depthKeyAt/tileHeightAt preconditions). The table's covered region may extend past the requested grid (its chunk- aligned superset) — those cells are table cells, not tiles.", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::originTileX", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 313, "signature": "std::int32_t originTileX() const noexcept", "summary": "Introspection (O(1); the table-backed values forward the table):", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::originTileY", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 314, "signature": "std::int32_t originTileY() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::widthTiles", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 315, "signature": "std::int32_t widthTiles() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::heightTiles", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 316, "signature": "std::int32_t heightTiles() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::layer", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 317, "signature": "std::int32_t layer() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::chunkTiles", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 318, "signature": "std::int32_t chunkTiles() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::tileCount", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 320, "signature": "std::size_t tileCount() const noexcept", "summary": "The requested grid's tile count (widthTiles * heightTiles).", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::TileMap", "kind": "constructor", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 325, "signature": "TileMap(const TileMap&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 326, "signature": "TileMap& operator=(const TileMap&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::TileMap", "kind": "constructor", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 327, "signature": "TileMap(TileMap&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 328, "signature": "TileMap& operator=(TileMap&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::~TileMap", "kind": "destructor", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 329, "signature": "~TileMap() = default", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::TileSlot::textureId", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 340, "signature": "std::uint32_t textureId{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::TileSlot::animationId", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 341, "signature": "std::uint32_t animationId{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::Options", "kind": "alias", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 221, "signature": "using Options = IsoDepthKeyTable::Options", "summary": "The create options — the table's options themselves (one type, passed through to the table's create — the validation is the table's, first failure wins, InvalidArgument): `originTileX/Y` (default 0), `widthTiles`/`heightTiles` (required >= 1), `chunkTiles` (default `kIsoDepthTableChunkTiles` = 16, a power of two >= 1), `maxChunks` (default `kIsoDepthTableDefaultMaxChunks` = 1024), `layer` (default `kIsoDepthGroundLayer` = 0 — a parallax tile LAYER gets its own tilemap with its layer value, M2-PAR-01 — the values are documented in laige/render/parallax.h).", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::DeclareOptions", "kind": "struct", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 225, "signature": "struct DeclareOptions", "summary": "The per-frame declare options (the fixed-frame quad model, above): the group-key fields for every tile sprite + the fixed UV frame.", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::DeclareOptions::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 227, "signature": "std::uint32_t materialId{0}", "summary": "The material reference (0 = the default material).", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::DeclareOptions::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 230, "signature": "BlendMode blend{BlendMode::Alpha}", "summary": "The blend state of every tile sprite (the group key's third field).", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::DeclareOptions::uv", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 233, "signature": "SpriteUvRect uv{0.0f, 0.0f, 1.0f, 1.0f}", "summary": "The fixed frame: every tile quad's UV sub-rect (default (0, 0, 1, 1) = the full tile texture).", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::create", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 242, "signature": "[[nodiscard]] static Result create(const Options& options) noexcept", "summary": "Creates the tilemap (setup path — the only allocations besides rebuild's setup temporary): validates the options (the table's create — first failure wins, InvalidArgument), pre-sizes the table's initial grid chunks, and pre-sizes the per-tile data array (8 B/tile, the requested grid). Flat/empty init: every tile is {textureId 0, height 0, animationId 0} — no tile is set.", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::setTile", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 257, "signature": "[[nodiscard]] Status setTile(std::int32_t tileX, std::int32_t tileY, std::uint32_t textureId, std::int32_t height, std::uint32_t animationId) noexcept", "summary": "The per-tile edit (sim phase). Stores the tile's textureId and animationId and routes the height into the table's setTile — the AUTO-DEPTH wiring: the table stores the height and recomputes exactly that cell's key (the M2-ISO-02 incremental update, radius 0; the other cells' keys are untouched). O(1), zero allocation.", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::rebuild", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 277, "signature": "[[nodiscard]] Status rebuild(std::span tiles) noexcept", "summary": "Scene load (setup path — the only place besides create that allocates: one temporary covered-height span for the table's from-scratch rebuild). Stores the whole tile grid: the per-tile data and the heights through the table's rebuild (every key through the full M2-ISO-01 function). `tiles` covers the REQUESTED grid — `widthTiles() * heightTiles()` entries, row-major, tileX fastest.", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::declareTo", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 295, "signature": "[[nodiscard]] Status declareTo(SpriteBatcher& batcher, const DeclareOptions& options) noexcept", "summary": "The render path (the frame pipeline's cull/batch stage): declares this tilemap's static tile quads into the batcher's current frame window — one SpriteItem per tile of the REQUESTED grid, in the grid's row-major order (tileY outer, tileX fastest — the preamble's quad model: the tile's center, the table's key (auto-depth), the tile's texture, the fixed frame). O(tileCount), zero allocation, no GL, no logging — the adds are O(1) batcher operations.", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::tileAt", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 300, "signature": "[[nodiscard]] TileData tileAt(std::int32_t tileX, std::int32_t tileY) const noexcept", "summary": "The read path (render phase, O(1), zero allocation, no GL):", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::depthKeyAt", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 302, "signature": "[[nodiscard]] std::uint32_t depthKeyAt(std::int32_t tileX, std::int32_t tileY) const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::tileHeightAt", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 304, "signature": "[[nodiscard]] std::int32_t tileHeightAt(std::int32_t tileX, std::int32_t tileY) const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::covers", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 310, "signature": "[[nodiscard]] bool covers(std::int32_t tileX, std::int32_t tileY) const noexcept", "summary": "Whether the tile lies in the REQUESTED grid (the batch path and the tileAt/depthKeyAt/tileHeightAt preconditions). The table's covered region may extend past the requested grid (its chunk- aligned superset) — those cells are table cells, not tiles.", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::originTileX", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 314, "signature": "std::int32_t originTileX() const noexcept", "summary": "Introspection (O(1); the table-backed values forward the table):", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::originTileY", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 315, "signature": "std::int32_t originTileY() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::widthTiles", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 316, "signature": "std::int32_t widthTiles() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::heightTiles", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 317, "signature": "std::int32_t heightTiles() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::layer", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 318, "signature": "std::int32_t layer() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::chunkTiles", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 319, "signature": "std::int32_t chunkTiles() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::tileCount", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 321, "signature": "std::size_t tileCount() const noexcept", "summary": "The requested grid's tile count (widthTiles * heightTiles).", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::TileMap", "kind": "constructor", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 326, "signature": "TileMap(const TileMap&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 327, "signature": "TileMap& operator=(const TileMap&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::TileMap", "kind": "constructor", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 328, "signature": "TileMap(TileMap&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 329, "signature": "TileMap& operator=(TileMap&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::~TileMap", "kind": "destructor", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 330, "signature": "~TileMap() = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::TileSlot::textureId", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 341, "signature": "std::uint32_t textureId{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::TileSlot::animationId", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 342, "signature": "std::uint32_t animationId{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::kMaxArchetypes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 184, "signature": "inline constexpr std::uint32_t kMaxArchetypes = 256", "summary": "The engine-level cap on distinct component sets (archetypes) per world (CORE-005: a named engine constant, the kMaxComponentTypes precedent — a game's component-set vocabulary is orders of magnitude smaller than its entity count; raising it is an ADR, not a knob).", "budget": null, "experimental": false}, {"name": "laige::kMaxArchetypeComponents", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 189, "signature": "inline constexpr std::uint32_t kMaxArchetypeComponents = 32", "summary": "The engine-level cap on components per entity (per archetype) (CORE-005). A 33rd distinct component on one entity fails addComponent with BudgetExhausted.", "budget": null, "experimental": false}, {"name": "laige::kInitialArchetypeRows", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 194, "signature": "inline constexpr std::uint32_t kInitialArchetypeRows = 16", "summary": "Rows reserved when an archetype is created (or the world's entity capacity, when smaller). Growth doubles from here (see the header preamble, \"Reserve policy\").", "budget": null, "experimental": false}, diff --git a/roadmap/M2-rendering-2.5d.md b/roadmap/M2-rendering-2.5d.md index e7d9c5f..59d1490 100644 --- a/roadmap/M2-rendering-2.5d.md +++ b/roadmap/M2-rendering-2.5d.md @@ -206,7 +206,7 @@ if M2 slips, and its status is recorded in M2-EXIT-01. - **Verify:** `ctest -R tilemap` green. - **Size:** ~250 lines + tests -- [ ] **M2-PAR-01 · Parallax layers** +- [x] **M2-PAR-01 · Parallax layers** - **Refs:** FR-2.3 (named layers, factor, offset, UV scroll, blend) - **Depends:** M2-CAM-01 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index 84d923f..ed045f0 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 | 17 | ⬜ in progress | +| M2 | 33 | 18 | ⬜ 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** | **63** | | +| **Total** | **194** | **64** | | --- @@ -241,6 +241,7 @@ One line per completed (or split/renumbered) step. | 2026-10-04 | M2-SPRITE-03 | `—` | Atlas UV frame animation hook (M2-SPRITE-03 scope, nothing else): **API** — header-only `src/laige-render/include/laige/render/sprite_frames.h` (`laige::render`, public, additive): `SpriteFrameLayout` (the atlas sheet frame layout in texels — `frameWidth`/`frameHeight`, `columns`/`rows`, `frameSpacing` (the gap between adjacent frames), `sheetBorder` (the sheet-edge margin); plain value, no ownership, validated at use time) + `kSpriteFrameMaxAtlasTexels` (2^24 — the float-exact atlas domain) + `spriteFrameUv(frameIndex, layout, atlasWidth, atlasHeight) → Result` (the pure frame-index → UV sub-rect computation: O(1), zero allocation, no logging, no GL; 4 exactly-rounded float divisions — the UV corners are the exactly-rounded k/W values); **sheet model (documented)** — row-major, frame 0 at the top-left (`col = i % columns`, `row = i / columns`); frame (c, r) occupies `[border + c·(fw+spacing), +fw) × [border + r·(fh+spacing), +fh)` texels; the tight sheet is `2·border + cols·fw + (cols−1)·spacing` wide/tall (a wider atlas = slack margin, the fit check is authoritative); **v-axis** — v = 0 is the first texel row of the uploaded RGBA array (the sheet's TOP row — the M2-SPRITE-02 GL_NEAREST "texel row = floor(v·h)" contract), so frame row 0 carries the smallest v; **failure (CORE-008, API-008, first failure wins)** — the layout is caller-owned, untrusted asset metadata (SCALE-004): zero extents / atlas outside [1, 2^24] / frameIndex ≥ columns·rows (the documented OUT-OF-RANGE contract: the engine NEVER wraps silently) / the frame's rect beyond the atlas — all `InvalidArgument`, never a UV rect (the u64 overflow guard rejects an adversarial stride before the col·stride multiplication can wrap — CPP-004/SCALE-004); **exactness** — float-exact domain (atlas ≤ 2^24): every pixel coordinate < 2^24 is exactly float-representable and the invariant u1 > u0, v1 > v0 holds EXACTLY (two distinct k/W never round to the same float) — pure function, bit-identical every platform/build (presentation-only, ARCH-009/010); **the M3 hook (data-driven, ARCH-009)** — `SpriteItem` gained `frameIndex` (the declared animation frame — the caller sets it + sets `uv` to the frame's rect via `spriteFrameUv`; the batcher carries the index through untouched — `uv` is what the M2-SPRITE-02 renderer draws; M3 animation drives the frame advance on top of this same layout); **batcher delta** — `SpriteItem` +1 u32 (76 B/slot, ~136 B/capacity slot, 6.8 MB at 50k); the batcher stays pure integer bookkeeping (the index passes through untouched); **tests** — `tests/laige-render/sprite_frames_tests.cpp` (new CTest entry `sprite_frames` = the step's Verify command; 11 tests / 4 suites, no GL): `SpriteFrameUvGolden` (hand-computed UVs for documented layouts: packed 4×4 sheet, tight 82×82 margin sheet, non-square 12×8 frames with spacing, single column/row, the idempotent call), `SpriteFrameErrors` (out-of-range at count / beyond / u32 top with the no-wrap pin, zero-extent layouts, the atlas domain incl. the exact 2^24 top, the fit failures incl. the one-texel-short spacing + the exact boundary + the adversarial stride), `SpriteFrameProperty` (2 000 seeded random tight sheets vs the documented formula — the float-exact invariants + the row/col adjacency rule (exact touch packed, strict gap spaced) — + the 1 000-conversion zero-allocation proof, the iso_picking/depth_sort precedent), `SpriteFrameItemPassThrough` (the frameIndex + uv pair survives the batcher's add/build/get — the M3 entry point); **docs** — NEW `docs/api/sprite_frames.md` (the full contract + Performance per DOC-004 + the M3 hook + misuse warnings), `docs/api/sprite_batcher.md` (the SpriteItem table row + the 76 B/slot update + Related), `docs/concepts/coordinates.md` §4.8 (the UV bullet) + §5 table row (Atlas frame → UV sub-rect, shipped) + Related, `docs/README.md` API index, the module README status paragraph; `laige-api.json` regenerated (1221 symbols / 38 headers, +10 symbols / +1 header); **verification** — all six local trees warning-clean + full ctest green (build, build-clang, build-release, build-shared, build-asan, build-tsan: `ctest -R sprite_frames` + the full `laige-render_tests`); `api-real-tree`/`api-check-fresh`, `include-lint` (38 public headers), and `determinism-lint` green; **budget** — no standalone `budgets.json` entry (the per-frame conversion cost is part of the composite 50k render-CPU budget, measured with M2-PERF-01); **compat** — additive only (no existing symbol's signature or meaning changed; the new public API is `SpriteFrameLayout`/`spriteFrameUv`/`kSpriteFrameMaxAtlasTexels` + `SpriteItem.frameIndex`); **scope note** — the implementation exceeds the roadmap's "~100 lines" sanity note for the same documented-contract reason as M2-SORT-01/M2-SPRITE-01 (the header preamble + the API doc are part of the implementation per CORE-006/DOC-004); Progress Board M2 15/33, total 61/194 | | 2026-10-04 | M2-SPRITE-04 | `—` | Render observability + the draw-call budget (G-R2, PRD §9.3; M2-SPRITE-04 scope, nothing else): **API** — `SpriteDrawStats` gained `programChanges` (the pass's `glUseProgram` count — 1 per successful non-empty frame), `uploadBytes` (the frame's instance upload, n × 52 B), `renderTargetBytes` (the render-target size drawn, w × h × 4), and the G-R2 `drawCallCapExceeded` flag; `SpriteDrawTotals` gained the `programChanges`/`uploadBytes`/`renderTargetBytes` sums + `capExceededFrames`; `SpriteRenderer::Options` gained the configurable per-pass draw-call cap `maxDrawCalls` (domain `[1, kSpriteRendererMaxInstances]` — a frame's draw calls can never exceed its instance count; documented default `kSpriteRendererDefaultDrawCalls` = 64 = 2× the PRD §8.1 worst-case reference-scene budget of 30 draw calls) + the `maxDrawCalls()` accessor; NEW `textureMemoryBytes()` gauge (the texture-memory VRAM estimate: the bound atlases' w × h × 4 sum — updated at `bindAtlas`, a re-bind subtracts the old upload + adds the new, reads 0 in the stopped state); **G-R2 semantics** — a frame whose draw calls (== its group count) STRICTLY exceed the cap is STILL drawn (observation, never an execution gate — the G-R5 precedent): one rate-limited Warn `draw_call_cap` (fields `capacity`, `draw_calls`) on the over-cap submit, the frame's `drawCallCapExceeded` flag (the frame-graph flag M2-PROF-01 will report) + the totals' `capExceededFrames`; **contract** — a failed submit zeroes the frame counters (now enforced on all three GL failure paths: upload, query creation, draw); the atlas registry slot grew 8 B → 16 B (the width/height bookkeeping: 32 KB → 64 KB at 4096 slots); **tests** — NEW `tests/laige-render/render_counters_tests.cpp` (new CTest entry `render_counters` = the step's Verify command; 8 tests / 4 suites): `RenderCountersCreate` (no GL: the maxDrawCalls validation first-failure-wins + one Warn `options_invalid`, the default reaching the context check → `GlUnavailable`, the stopped state reads zero), `RenderCountersScene` (GL: the roadmap's known small scene — 10 sprites, 2 atlases, 2 blends → 3 groups — per-frame counters pinned EXACTLY for frame 1 {3 draws, 2 binds, 3 blend changes, 1 program change, 10 instances, 520 B upload, 65 536 B target} + frame 2 {3, 2, 2, 1, 10, 520, 65 536 — the cross-frame last-atlas/last-blend state persistence} + the since-construction totals, the empty frame counts nothing, the opt-in `PRIMITIVES_GENERATED` feed = 20), `RenderCountersCap` (GL: the warn fires AT the configured count (cap=2) with the pinned fields, the frame still drawn, the second frame repeats the warn + total, a 2-group frame AT the cap has no flag + no new warn (1 bind + 1 blend change from the persisted state), cap=3 → no warn at the cap), `RenderCountersMemory` (GL: the gauge exact — two 4×4 atlases = 128 B; a second renderer's 8×8 = 256 B, the 16×16 re-bind replacement = 1024 B); **docs** — `docs/api/sprite_renderer.md` (NEW "Render observability + the draw-call cap" section: the field table + the cap semantics; the API snippet, the create/bindAtlas/submit/frameStats bullets, the Performance section, the misuse warnings), `docs/README.md` index entry, the module README status paragraph, the roadmap box; `laige-api.json` regenerated (1233 symbols / 38 headers, +12 symbols); **verification** — all six local trees warning-clean + full ctest green: build 111/111, build-clang 111/111, build-release 100/100, build-shared 111/111, build-asan 108/108, build-tsan 108/108; the first CI TSan run reproduced a DRIVER-INTERNAL teardown data race (both race accesses inside libgallium: the `eglDestroyContext` teardown destroys the driver's internal mutex/condvar while the llvmpipe worker thread is still inside it — no engine code between the pthread frames), triggered by a query-enabled submit as the FIRST FBO operation of the frame — fixed in the test with the frame-pipeline clear-before-draw pattern (`RenderCountersScene.PrimitiveQueryFeed` clears the target before the submit — the `SpriteDrawSmoke.ThousandSpriteFrame` precedent; the clear touches no sprite-pass state, the counters are unaffected; a clear AFTER the submit or a readback quiesce does NOT fix it — the clear must precede the first draw); `api-real-tree`/`api-check-fresh`, `tools/laige-include-lint`, and `tools/laige-determinism-lint` green; **budget** — no standalone `budgets.json` entry (the counters are O(1) bookkeeping; the composite 50k render-CPU budget is measured with M2-PERF-01); **compat** — additive only (no existing symbol's signature or meaning changed; the new public API is the 4 `SpriteDrawStats` fields + the 4 `SpriteDrawTotals` fields + `Options::maxDrawCalls` + `kSpriteRendererDefaultDrawCalls` + `maxDrawCalls()` + `textureMemoryBytes()` = 12 symbols); Progress Board M2 16/33, total 62/194 | | 2026-10-04 | M2-TILE-01 | `—` / PR (open) | Tilemap data + auto-depth from tile height (FR-2.6; M2-TILE-01 scope, nothing else): **API** — NEW `laige::render::TileMap` (header-only, templated over the SimMath backends — the `presentation.h` pattern; `TileMap::Options` IS the M2-ISO-02 table's `Options`, one type, no duplicated validation) + `TileData` (the per-tile value: `textureId` — the game-assigned atlas ref, `height` — read from the owned table, `animationId` — DATA ONLY in M2, M2-TILE-02 cycles frames from it): `create(options)` (setup path: the table's create validation first-failure-wins + the pre-sized 8 B/tile data array; flat/empty init), `setTile(gx, gy, textureId, height, animationId)` (the per-tile edit: the height routes into the table's `setTile` — the AUTO-DEPTH wiring, the table recomputes exactly that cell's key, radius 0 — O(1), zero allocation), `rebuild(tiles)` (scene load: the requested grid row-major tileX fastest → the heights mapped into the table's covered rectangle → the table's from-scratch `rebuild`; property `rebuild(final grid) == any setTile sequence reaching the same grid` pinned; one setup-path temporary), `declareTo(batcher, options)` (the render path / S-5: one `SpriteItem` per tile of the requested grid — the tile's CENTER pos `(gx+0.5, gy+0.5)`, scale (1,1) (the unit quad spans the tile cell), rotation 0, the fixed-frame UV (`DeclareOptions::uv`, default the full texture), the table's key (auto-depth — `depthOverride` stays false, G-R11), the tile's texture as `atlasId`, the options' `materialId`/`blend` — in the grid's row-major order (RENDER-003: the tile's grid position is its stable identity, the FR-1.2 analog); preconditions: the batcher window open (a built frame's window is closed → `InvalidArgument`, nothing declared; a stopped batcher → `BudgetExhausted`, nothing declared); the WHOLE requested grid is declared — visible-rect culling lands with M2-PERF-01, the composite 50k budget's worst case is the full grid), `tileAt`/`depthKeyAt`/`tileHeightAt`/`covers` (the O(1) read path; `covers` = the requested grid — the table's superset margin cells are table cells, not tiles), introspection `originTileX/Y()`, `widthTiles()`, `heightTiles()`, `layer()`, `chunkTiles()`, `tileCount()`; **bounded draw calls** (FR-2.1, RENDER-001) — the batcher's (atlas, material, blend) grouping renders one tilemap in one draw call per DISTINCT (textureId, material, blend) combination: tiles of one chunk sharing one texture and blend form ONE group (one draw call per chunk group); **semantics** — the tile's height lives in the table ALONE (one source of truth; `tileAt` combines the three fields); rejected operations (tile outside the requested grid, height outside `|h| ≤ 2047`, wrong span size, any out-of-domain height in the span) leave the tile data AND the table unchanged (validated before any write; the `Status` is the failure channel — LOG-002); the happy update/declare paths log nothing (LOG-002/003 — pinned by a no-log test); ARCH-009: headless-buildable, presentation-only, sim-phase writes / render-phase reads; **tests** — NEW `tests/laige-render/tilemap_tests.cpp` (new CTest entry `tilemap` = the step's Verify command; 17 tests / 6 suites, no GL — runs in every tree): `TileMapCreate` (the grid options + the flat/empty contract, the non-zero origin, the rejected options — first failure wins, the create-time grid-over-cap → `InvalidArgument` (a misconfiguration; the runtime growth cap is the `BudgetExhausted` of `ensureChunk`)), `TileMapData` (the per-tile read/write, rejected edits leave no state (incl. the superset-margin cells — table cells, not tiles), the scene load against the hand-computed goldens, the rebuild validation (wrong size / out-of-domain height — whole-span validation), the rebuild-from-scratch == incremental property (8×8, both backends)), `TileMapAutoDepth` (the roadmap's "height change → depth table increment": a single tile-height edit changes ONLY the edited cell's key (radius 0), the hand-computed golden + the independent oracle, last-write-wins, the cross-backend key agreement on the dyadic grid-locked centers), `TileMapDeclareGolden` (the roadmap's "tile quad positions/depth for a 4×4 chunk": all 16 declared quads pinned field-by-field against the hand-computed position + depth goldens (the 16 literal keys — four screen rows with the exact tie structure), the fixed-frame fields, the (texture, material, blend) grouping — 2 texture ids → exactly 2 groups, ascending atlas order, the hand-computed in-group instance sequences (the (key, declaration-position) stable sort restricted to the group) — + the cross-frame determinism (bit-identical second frame) + the `tilemap-golden:` machine line), `TileMapDeclare` (the frame protocol — a built frame's window is closed, `InvalidArgument`, nothing declared; a stopped batcher → `BudgetExhausted`; the custom `DeclareOptions` (material/blend/uv) carried on every item; the no-log happy path), `TileMapZeroAlloc` (the 1000-frame × 256-tile `beginFrame`/`declareTo`/`build` loop allocates NOTHING under the allocation watch — FR-2.2); **docs** — NEW `docs/api/tilemap.md` (the full contract: the API table, the tile model + auto-depth, the quad model + bounded draw calls, the declaration-order determinism, ownership/lifetime/threading, the DOC-004 Performance table, the misuse warnings, a performant example), `docs/README.md` index entry, the module README status paragraph, `docs/concepts/coordinates.md` (NEW §4.9 + the §5 conversion row "Tile grid → static tile quads" + the Related link); **API surface** — `laige-api.json` regenerated (1233 → 1265 symbols, 38 → 39 headers: `TileData` + 3 fields, `TileMap` + `Options` alias + `DeclareOptions` + 3 fields, the 15 public members, the move/copy ops, the private `TileSlot` + its 2 fields — the `CellRecord`-style private nested-type convention); **budget** — no standalone `budgets.json` entry (the count stays 16 — the `BudgetHarnessTable.LoadsTheRepoBudgetsFile` pin): the per-frame declare cost is PART of the composite 50k render-CPU budget (PRD §8.1, `sprites_50k_cpu` — M2-PERF-01 measures the reference scene with tile quads included); **compat** — additive only (no existing symbol's signature or meaning changed); **local verification** — all six local trees warning-clean + full `ctest` green: build 112/112, build-clang 112/112, build-release 101/101, build-shared 112/112, build-asan 109/109, build-tsan 109/109; `ctest -R tilemap` green (17/17, both backends); `api-real-tree`/`api-check-fresh` (6/6), `tools/laige-include-lint` (68 source files), and `tools/laige-determinism-lint` (28 sim source files, 0 violations) green; Progress Board M2 17/33, total 63/194 | +| 2026-10-07 | M2-PAR-01 | `—` / PR (open) | Parallax layers (FR-2.3; M2-PAR-01 scope, nothing else): **API** — NEW `laige::render::ParallaxLayers` (header-only, templated over the SimMath backends — the `presentation.h` pattern; `parallax.h`) + `ParallaxLayerDef` (the named layer value: `id` < `maxLayers`, `source` (Image | Tilemap — the M2-TILE-02 hook), `factor` in [0, 1] (one source of truth — 0 = fixed in world space, 1 = fixed on screen), `center` (the reference camera position — where the layer sits at exactly `offset`; default (0,0)), `offset` (the world-space offset at `p = center`; default (0,0)), `size`/`uv` (Image only), `atlasId`, `tilemapId` (Tilemap only), `materialId`, `blend`, `depthLayer` (the M2-ISO-01 key layer value), `scrollMode` (Manual | Auto), `scrollSpeed` (UV units PER FRAME per axis — negative scrolls the other way), `enabled`) + `ParallaxScrollMode`/`ParallaxSource` + the named presets (`kParallaxLayerBackground/Midground/Foreground/CustomBase` = 0/1/2/3) + the depth-layer values (`kParallaxDepthLayerBackground` = -2, `kParallaxDepthLayerMidground` = -1, `kIsoDepthGroundLayer` = 0 (the ground), `kParallaxDepthLayerForeground` = +1; a custom layer picks any value in the M2-ISO-01 domain [-512, +511] — more negative = further back); `create(options)` (`maxLayers` in [1, `kParallaxLayersMaxLayers` = 64], default `kParallaxLayersDefaultLayers` = 8 — the pre-sized slot table; no log), `setLayer(def)` (validates the def in the documented order — id → factor → center → offset → scroll_speed → size (Image) → uv (Image) → depth_layer — first failure wins; a rejection leaves the slot unchanged + one rate-limited `parallax/layer_invalid` warn (fields `layer`, `field`) — LOG-004; a success RESETS the layer's UV offset to (0, 0)), `setUvOffset(id, uv)` (any finite value — WRAPPED to [0, 1)²; unknown id → `InvalidArgument` (no log); non-finite → `InvalidArgument` + one rate-limited `parallax/uv_offset_invalid` warn), `advanceScrolls()` (the AUTO layers only — once per frame, before the declarations; O(layers), no allocation, no logging, no GL), `declareTo(batcher, cameraPos)` (the render path / S-5: declares every SET, ENABLED Image-source layer's wrap quads — the 2 x 2 split (q00, q10, q01, q11 — a quad whose range is empty — uvOffset 0 on that axis — is skipped); each quad: its world CENTER `pos`, its extent `scale`, rotation 0, the full-default tint, the def's `atlasId`/`materialId`/`blend`, and the M2-ISO-01 key of the quad's world center at the def's `depthLayer` (engine-owned — G-R11, `depthOverride` stays false); Tilemap-source layers are SKIPPED (the M2-TILE-02 hook — no log); a built frame's window is closed → `InvalidArgument` (nothing declared); a stopped registry → `InvalidArgument` (no log); the first failed add fails the call), `worldOffsetAt(id, cameraPos)` (the EXACT formula `factor * (p - center) + offset` — O(1), no allocation, no logging, no GL), introspection (`valid()`, `maxLayers()`, `layerCount()`, `has()`, `layerAt()`, `uvOffsetAt()`); **the render order** (documented — background first): WITHIN a shared (atlas, material, blend) group the key's LAYER field dominates — every background-layer quad sorts before every ground object and every foreground quad after it, whatever the quads' v (the M2-ISO-01 "layer dominates" contract); ACROSS groups the draw order is the batcher's group order (ascending (atlas, material, blend) — RENDER-003), so the scene's SET-UP assigns the parallax layers' atlas ids so the group order matches the depth order: background ids BELOW the world content's, foreground ids ABOVE it (the M2-TILE-01 texture-id convention); **the UV scroll** (auto or manual): each layer carries a CURRENT UV OFFSET in [0, 1)² — Manual via `setUvOffset`, Auto via `scrollSpeed` on `advanceScrolls()` (frames are the presentation pace — frame-rate independence is the caller's concern, the M2-CAM-01 lerp precedent); the WRAP is exact at the texture boundary (`wrap(x) = x - floor(x)`: 1.0 → exactly 0.0; -0.25 → exactly 0.75 — dyadic values wrap bit-exactly, pinned); the texture's v axis (v = 0 = first uploaded texel row, the M2-SPRITE-02 contract) maps to the world +y direction; a scrolled layer renders through the 2 x 2 wrap split (a single `SpriteItem` carries ONE UV rect — no wrap): up to four quads per layer, one draw call per layer group (FR-2.1, RENDER-001); **semantics** — everything is WORLD space (PRD §4, RENDER-006 — the formula is a world-space translation); ARCH-009: headless-buildable, presentation-only (the layer state reads the camera's presentation position, never sim state — never part of replay state or the simulation state hash); one owner (the sim/scene-owner thread; the M2-GL-02 cull/batch stage owns the render-side declaration), sim-phase writes / render-phase reads (CONC-001); no per-frame allocation (FR-2.2 — the zero-allocation proof: 1000 frames × 3 layers (up to 4 quads each) + 2 sprites allocates NOTHING on the owner thread); the rejected-definition warn is the only logging (LOG-002/004 — the happy paths log nothing); **tests** — NEW `tests/laige-render/parallax_tests.cpp` (new CTest entry `parallax` = the step's Verify command; 12 tests / 6 suites, no GL — runs in every tree): `ParallaxCreate` (the create-domain edges + the stopped-state matrix), `ParallaxLayer` (the `setLayer` validation matrix — 15 rejection cases with the pinned warn fields (first failure wins), the domain edges accepted, the Tilemap-source size/uv NOT validated, state-unchanged-on-rejection, the replace + scroll-reset), `ParallaxOffset` (the EXACT offset formula — hand-computed dyadic goldens for factors 0/1/0.5/0.25 (the dyadic exactness zone, both backends agree) + factor 0.3f linearity within tolerance; the world-origin reference case (center/offset zeroed)), `ParallaxScroll` (the auto advance + the EXACT wrap at the 1.0 boundary (→ (0,0)) + the negative axis, the manual wrap (1.5 → 0.5, 1.0 → 0, -1/-2 → 0), the offset persists across frames, the non-finite rejection + the pinned warn, the unknown-id no-log), `ParallaxDeclare` (the roadmap's golden: the 4-layer + ground scene at camera (10,6) — both backends: the 5 declared quads pinned field-by-field (world centers, extents, the atlas uv mapping incl. the layer `uv` sub-rect, the hand-computed 32-bit keys — WITH the 2^21 base — against the independent M2-ISO-01 oracle), the batcher's group (draw) order (atlas 0..4 ascending), the layer-dominance orderings (bg < mid < ground < custom < fg, even when the quads' v is out of order), the cross-frame determinism (bit-identical second frame); the scrolled split — the FULL/half/un-scrolled cases (4/2/1 quads — the empty-range quads skipped; the world rects tile the rectangle exactly: the areas sum to the rectangle's area; the q00/q10/q01/q11 order pinned), the atlas SUB-RECT mapping (the layer uv (0.25,0,0.75,0.5) mapped over the quad's base range), the OFFSET layer's position, the `frameCounts` pin (6/4/3/6); the frame protocol — a built frame → `InvalidArgument`, a stopped batcher → `BudgetExhausted`, a stopped registry → `InvalidArgument` (no log), the disabled + tilemap-hook skips (no log)), `ParallaxZeroAlloc` (the 1000-frame loop under the allocation watch — 0 owner-thread allocs — FR-2.2); **docs** — NEW `docs/api/parallax.md` (the full contract: the API table, the model + the exact offset formula, the render-order section (within-group layer dominance + the across-group atlas-id convention), the UV scroll + the exact wrap + the 2 x 2 split table (world/uv per quad), ownership/lifetime/threading, the DOC-004 Performance table, the performant example + the misuse warnings), `docs/README.md` index entry, the module README status paragraph, `docs/concepts/coordinates.md` (NEW §4.10 + the §5 conversion row "Camera position → parallax offset" + the §6 render-order summary + the Related link); the stale "land with M2-PAR-01" references updated in the same change (`docs/api/iso_depth_key.md` layer field + Related, `docs/api/iso_depth_table.md`, `docs/api/tilemap.md`, the `tilemap.h` comment); **API surface** — `laige-api.json` regenerated LAST (1265 → 1315 symbols, 39 → 40 headers: the 2 enums, `ParallaxLayerDef` + 16 fields + the field-wise `operator==` (a defaulted `==` is deleted — `SpriteUvRect` has no `operator==`), `ParallaxLayers` + `Options` + 13 public members, the 6 `kParallax*` constants — the `TileSlot`-style private nested-type convention); **budget** — no standalone `budgets.json` entry (the count stays 16 — the `BudgetHarnessTable.LoadsTheRepoBudgetsFile` pin): the per-frame declare cost is PART of the composite 50k render-CPU budget (PRD §8.1, `sprites_50k_cpu` — M2-PERF-01 measures the reference scene with the parallax layers included — the M2-SCENE-01 reference scene has 3 parallax layers within the 50k-sprite / ≤30-draw-call budget); **compat** — additive only (no existing symbol's signature or meaning changed); **local verification** — all six local trees warning-clean + full `ctest` green: build 113/113, build-clang 113/113, build-release 102/102, build-shared 113/113, build-asan 110/110, build-tsan 110/110 (the prior counts +1 each — the new `parallax` entry); `ctest -R parallax` green (12/12, both backends); `api-real-tree`/`api-check-fresh` (6/6), `tools/laige-include-lint` (69 source files), and `tools/laige-determinism-lint` (28 sim source files, 0 violations) green; Progress Board M2 18/33, total 64/194 | --- diff --git a/src/laige-render/README.md b/src/laige-render/README.md index eebd039..f6e6fc4 100644 --- a/src/laige-render/README.md +++ b/src/laige-render/README.md @@ -404,5 +404,49 @@ protocol / failure paths / custom options / no-log happy path, and the entry: the per-frame declare cost is part of the composite 50k render-CPU budget, measured with M2-PERF-01. -The remaining M2 steps (M2-PAR-01, text/UI, M2-PERF-01) land in later -steps. +M2-PAR-01 landed the parallax layers — +`laige::render::ParallaxLayers` (public header +`include/laige/render/parallax.h`, header-only — a template over the +SimMath backends): the named bg/mid/fg model of FR-2.3. Each layer is +a WORLD-SPACE RECTANGLE (a texture, or a tilemap — the M2-TILE-02 +hook, data-only in M2-PAR-01) whose content position at camera +position `p` is the EXACT formula `worldOffset(p) = factor * (p - +center) + offset` (factor 0 = fixed in world space, 1 = fixed on +screen — one source of truth). The layer's quads carry the M2-ISO-01 +key's LAYER field (engine-owned, G-R11): the presets are +`kParallaxDepthLayerBackground` = -2, `kParallaxDepthLayerMidground` += -1, `kIsoDepthGroundLayer` = 0 (the ground), +`kParallaxDepthLayerForeground` = +1 — WITHIN a shared (atlas, +material, blend) group the layer field dominates (background first, +engine-guaranteed); ACROSS groups the draw order is the batcher's +group order, so the scene's SET-UP assigns the layers' atlas ids +background-below / foreground-above the world content (the +M2-TILE-01 texture-id convention). The UV scroll (auto or manual) +carries a per-layer offset in [0, 1)² with the EXACT wrap at the +texture boundary (`wrap(x) = x - floor(x)` — 1.0 → exactly 0.0), +rendered through the 2 x 2 wrap split (a single SpriteItem carries one +UV rect — no wrap): up to four quads per layer, one draw call per +layer group (FR-2.1). ARCH-009: headless-buildable, presentation-only +(the layer state reads the camera's presentation position, never sim +state); the per-frame `advanceScrolls` + `declareTo` loop allocates +NOTHING (FR-2.2 — the zero-allocation proof, the tests). Rejected +definitions leave the slot unchanged (one rate-limited +`parallax/layer_invalid` warn with the failing field — LOG-002/004). +API contract in [docs/api/parallax.md](../docs/api/parallax.md), +tests under [tests/laige-render](../tests/laige-render) (CTest entry +`parallax` — pure data + batcher bookkeeping, no GL environment +required: the registry create/stopped state, the `setLayer` +validation matrix with the pinned warn fields, the EXACT offset +formula (hand-computed dyadic goldens), the auto/manual UV scroll with +the EXACT wrap, the hand-computed golden keys of the 4-layer + ground +scene (both backends — the dyadic exactness zone) with the group +(draw) order and the layer-dominance orderings, the 2 x 2 wrap split's +exact world/UV rects (incl. the atlas sub-rect mapping), the frame +protocol / stopped / disabled / tilemap-hook paths, cross-frame +determinism, and the 1000-frame zero-allocation declare loop). No +standalone `budgets.json` entry: the per-frame declare cost is part of +the composite 50k render-CPU budget, measured with M2-PERF-01 (the +M2-SCENE-01 reference scene has 3 parallax layers within the 50k-sprite +/ ≤30-draw-call budget). + +The remaining M2 steps (text/UI, M2-PERF-01) land in later steps. diff --git a/src/laige-render/include/laige/render/parallax.h b/src/laige-render/include/laige/render/parallax.h new file mode 100644 index 0000000..8d04040 --- /dev/null +++ b/src/laige-render/include/laige/render/parallax.h @@ -0,0 +1,763 @@ +// laige-render parallax layers (M2-PAR-01): the named background / +// midground / foreground layer model of FR-2.3, with the parallax +// factor, the world-space offset, the UV scroll (auto or manual), and +// the batch path into the sprite batcher. +// +// FR-2.3: "Parallax layers: named background/midground/foreground +// layers with parallax factor, offset, UV scroll, blend". S-5 (PRD +// §9.1): rendering goes through the batcher — a parallax layer's quads +// are declared into the sprite batcher as sprites with engine-computed +// depth keys, never drawn directly. ARCH-009: the layer state +// (definitions + the scroll offsets) is presentation state — it reads +// the camera's presentation position, never sim state, and is never +// part of replay state or the simulation state hash. G-R11: the +// layer's depth key is engine-owned (the M2-ISO-01 key with the +// layer's depth-layer value — the game never writes the key). +// RENDER-003: the declaration order is deterministic (ascending layer +// id, then the fixed wrap-quad order); the render order is documented +// below ("The render order"). +// +// ParallaxScrollMode Manual (caller-driven) | Auto (per-frame +// engine advance) +// ParallaxSource Image (a single texture) | Tilemap (a tilemap +// layer — declared with M2-TILE-02) +// ParallaxLayerDef One named layer's definition (the value the +// game declares) +// ParallaxLayers The scene's layer registry: the bounded slot +// table + the per-frame protocol +// +// --------------------------------------------------------------------------- +// The model +// --------------------------------------------------------------------------- +// +// A layer is a WORLD-SPACE RECTANGLE of one texture (the Image +// source) or of one tilemap (the Tilemap source, M2-TILE-02). The +// layer's content position at camera position p (the camera's +// ground-plane (x, y) — the M2-CAM-01 presentation position) is: +// +// worldOffset(p) = factor * (p - center) + offset (1) +// +// the EXACT formula (the roadmap's pinned contract — the tests pin +// it bit-exactly for dyadic values): +// +// factor the parallax factor in [0, 1] (one source of truth): +// 0 = the layer is FIXED IN WORLD SPACE at `offset` (it +// moves full-speed against the camera — maximum parallax, +// a close-by background); 1 = the layer moves 1:1 WITH +// the camera (it is fixed on screen — no parallax, the +// sky layer); between: the layer's screen position +// shifts by (factor - 1) * (p - center). +// center the REFERENCE camera position (the camera position at +// which the layer sits at exactly `offset`; default +// (0, 0) — the scene's world origin) +// offset the layer's world-space offset at p = center (default +// (0, 0) — the content origin at the world origin) +// +// Everything is WORLD space (PRD §4, RENDER-006): (1) is a world-space +// translation; nothing in it is screen space. The camera position is +// the presentation-side input (ARCH-009) — the sim never sees it. +// +// The Image source renders the layer's texture over the world +// rectangle [worldOffset(p), worldOffset(p) + size) — `size` is the +// rectangle's extent in world units (the texture spans the whole +// rectangle once, and WRAPS at the rectangle's edges under the UV +// scroll — below). The texture's v axis (v = 0 = first uploaded texel +// row, the M2-SPRITE-02 contract) maps to the world +y direction +// (downward on screen under the iso projections — coordinates.md). +// +// The Tilemap source references a tilemap (its `tilemapId` — the +// game's registry id; M3-ASSET-01 owns the asset system): the tilemap +// defines its own world grid, and M2-TILE-02 declares its tiles with +// this layer's `worldOffset` translation and its `depthLayer` (the +// tilemap's Options::layer). In M2-PAR-01 a Tilemap-source layer is +// DATA ONLY: `declareTo` skips it (the hook), its `size`/`uv` fields +// are not validated. +// +// --------------------------------------------------------------------------- +// The render order (the documented background-first contract) +// --------------------------------------------------------------------------- +// +// The batcher draws one group per distinct (atlas, material, blend) +// in its DETERMINISTIC group order — ascending (atlas, material, +// blend) (RENDER-003, M2-SPRITE-01) — and, within a group, the +// instances in the M2-ISO-01 key order (layer field dominant). The +// parallax layer's quads carry the key's LAYER field: +// +// kParallaxDepthLayerBackground = -2 (the `bg` preset) +// kParallaxDepthLayerMidground = -1 (the `mid` preset) +// kIsoDepthGroundLayer = 0 (the ground — M2-ISO-01) +// kParallaxDepthLayerForeground = +1 (the `fg` preset) +// +// - WITHIN a shared (atlas, material, blend) group: the layer +// field dominates the key, so every background-layer quad sorts +// before every ground object and every foreground quad AFTER it, +// whatever the quads' v — background first, engine-guaranteed +// (the M2-ISO-01 "layer dominates" contract). +// - ACROSS groups: the batcher's group order (ascending +// (atlas, material, blend)) is the draw order — the scene's +// SET-UP must assign the parallax layers' atlas ids so the group +// order matches the depth order: background layers' atlas ids +// BELOW the world content's, foreground layers' ABOVE it (the +// same convention as the M2-TILE-01 texture-id assignment). +// - A layer's own quads (the wrap split, below) tile its +// rectangle WITHOUT overlap: their relative order is the +// deterministic (key, declaration) total order and is visually +// irrelevant. +// +// The canonical presets (the "named layers"): +// +// kParallaxLayerBackground = 0 (bg — factor ~0, depth layer -2) +// kParallaxLayerMidground = 1 (mid — factor ~0.5, depth layer -1) +// kParallaxLayerForeground = 2 (fg — factor 1, depth layer +1) +// kParallaxLayerCustomBase = 3 (custom layers: any id >= 3, any +// distinct depth-layer value in the M2-ISO-01 domain +// [-512, +511] — more negative = further back) +// +// --------------------------------------------------------------------------- +// The UV scroll (auto or manual) + the exact wrap +// --------------------------------------------------------------------------- +// +// Each layer carries a CURRENT UV OFFSET in [0, 1)^2 — the texture's +// wrap position within its rectangle. Per texture: the sample UV at +// a world point w in the rectangle is +// +// u = frac((w.x - X) / size.x + uvOffset.x) X, Y = the +// v = frac((w.y - Y) / size.y + uvOffset.y) rectangle's +// world origin corner +// +// (the M2-SPRITE-02 UV convention: v = 0 is the texture TOP). +// Increasing the offset scrolls the texture toward +u (+x world) and +// +v (+y world). +// +// Manual mode: the caller drives the offset — `setUvOffset` (any +// finite value; it is WRAPPED to [0, 1)^2). +// Auto mode: the engine advances the offset by `scrollSpeed` +// (UV units PER FRAME — frames are the presentation pace; frame- +// rate independence is the caller's concern, the M2-CAM-01 +// lerp precedent) on each `advanceScrolls()` call — once per +// frame, before the declarations. +// +// The WRAP is exact at the texture boundary: `wrap(x) = x - floor(x)` +// (in [0, 1) for every finite x; 1.0 wraps to exactly 0.0; -0.25 +// wraps to exactly 0.75 — dyadic values wrap bit-exactly, which the +// tests pin). +// +// RENDERING the wrap through the batcher (the 2 x 2 split): a single +// SpriteItem carries ONE UV rect — no wrap — so a scrolled layer +// declares its rectangle as the four wrap-aligned quads (fixed order +// q00, q10, q01, q11; a quad whose range is empty — uvOffset 0 on +// that axis — is skipped): +// +// wx = X + (1 - uvOffset.x) * size.x (the u-wrap world line) +// wy = Y + (1 - uvOffset.y) * size.y (the v-wrap world line) +// +// q00 world [X, wx) x [Y, wy) base uv [ox, 1) x [oy, 1) +// q10 world [wx, X+sx) x [Y, wy) base uv [0, ox) x [oy, 1) +// q01 world [X, wx) x [wy, Y+sy) base uv [ox, 1) x [0, oy) +// q11 world [wx, X+sx) x [wy, Y+sy) base uv [0, ox) x [0, oy) +// +// each quad's UV rect is the layer's atlas sub-rect (the def's `uv`) +// mapped over its base uv range. At uvOffset (0, 0) exactly ONE quad +// (q00, the full rectangle, the full texture) is declared — the +// un-scrolled cost. The quads tile the rectangle exactly (their +// world areas sum to size.x * size.y) and sample the texture exactly +// once — the wrap is exact at every boundary. +// +// --------------------------------------------------------------------------- +// The per-frame protocol (the frame pipeline's cull/batch stage) +// --------------------------------------------------------------------------- +// +// One ParallaxLayers per scene, owned by the render set-up / +// cull-batch stage (one owner — CONC-001). Per frame: +// +// advanceScrolls() the auto layers' UV advance +// (O(layers), no allocation) +// batcher.beginFrame() +// parallax.declareTo(batcher, cameraPos) the layer quads +// ... the scene's other content (the M2-TILE-01 tilemap, the +// sprites — the batcher's frame window stays open) ... +// batcher.build() +// +// `declareTo` is READ-ONLY over the registry (the declarations are +// built from the current state); the ONLY per-frame mutation is +// `advanceScrolls` (the scroll offsets). The declared quads are +// PRESENTATION state (ARCH-009) — the batcher owns them for one frame. +// +// --------------------------------------------------------------------------- +// Determinism (RENDER-003, ARCH-010 scope — presentation-only) +// --------------------------------------------------------------------------- +// +// The declaration of a frame is a pure function of (the registry +// state — the definitions + the current scroll offsets, and the +// camera position): same state + same position → bit-identical +// quads, every frame, every platform (fixed float op order; the keys +// are the M2-ISO-01 keys of the scene's backend — ADR 0002 scope). +// The declaration ORDER is deterministic: ascending layer id, then +// the fixed quad order q00, q10, q01, q11 — the (key, insertion) +// total order (RENDER-003) resolves ties for equal keys. +// +// --------------------------------------------------------------------------- +// Ownership, threading, performance +// --------------------------------------------------------------------------- +// +// Move-only (the M2-TILE-01 pattern). ONE heap allocation (the +// pre-sized slot table, at create — the setup path). Per frame: +// advanceScrolls is O(layers) adds/wraps; declareTo is O(layers x 4) +// adds into the batcher's PRE-ALLOCATED frame — NO per-frame +// allocation (FR-2.2, PERF-003), no logging (LOG-003), no GL calls +// (this header is GL-free — the batch path is bookkeeping; the +// submit is the M2-SPRITE-02 stage). Not thread-safe (one owner — +// the cull/batch stage; CONC-001). +// +// No standalone budgets.json entry: the per-frame declare cost +// (O(layers x 4) adds) is PART of the composite 50k render-CPU +// budget (PRD §8.1, `sprites_50k_cpu` — M2-PERF-01 measures the +// reference scene with the parallax quads included; the M2-SCENE-01 +// reference scene has 3 layers, at most 12 quads — trivial against +// the 50k sprites). +// +// --------------------------------------------------------------------------- +// Failure behavior (CORE-008, first failure wins) +// --------------------------------------------------------------------------- +// +// create: maxLayers outside [1, kParallaxLayersMaxLayers] -> +// InvalidArgument (no log — the M2-SPRITE-01 create +// precedent). +// setLayer: the documented validation order, first failure wins: +// id -> factor -> center -> offset -> scroll_speed -> +// size (Image) -> uv (Image) -> depth_layer; each +// failure = InvalidArgument + one rate-limited +// parallax/layer_invalid warn (fields layer, field — +// the setup/config boundary, the M2-CAM-01 options +// precedent); a rejected definition leaves the slot +// UNCHANGED. +// setUvOffset: unknown layer id -> InvalidArgument (no log — +// precondition, the M2-TILE-01 rejected-edit +// precedent); non-finite value -> InvalidArgument + one +// rate-limited parallax/uv_offset_invalid warn. +// declareTo: stopped registry (maxLayers 0) -> InvalidArgument; +// built frame (the window closed) -> InvalidArgument, +// NOTHING declared; the batcher's own overflow/stopped +// failures (BudgetExhausted) propagate from the first +// failed add. +// +// --------------------------------------------------------------------------- +// Misuse warnings +// --------------------------------------------------------------------------- +// +// - Don't call `worldOffsetAt`/`uvOffsetAt`/`layerAt` for an id that +// is not set (asserted precondition — check `has()` first when the +// id comes from untrusted input). +// - Don't call `advanceScrolls` more than once per frame (the auto +// speed is PER FRAME — the caller paces it once per frame, the +// batcher's beginFrame pace). +// - Don't declare a Tilemap-source layer through `declareTo` in +// M2: it is skipped (no log — the M2-TILE-02 step implements the +// tilemap declare path on this layer's `worldOffset` + +// `depthLayer`). +// - Don't expect the parallax factor to be "how much the layer +// moves": it is the (1) coefficient — factor 1 is the SCREEN-FIXED +// layer (no parallax), factor 0 the full-parallax one. The formula +// (1) is the contract; the tests pin it. +// - Don't hand-write the layer quads' depth keys: they are the +// M2-ISO-01 keys (G-R11) — the def's `depthLayer` is the only +// ordering input. +// - Don't mix backends within one scene: the registry's backend is +// the scene's backend (the keys must agree with the M2-TILE-01 +// table's — the M2-ISO-01 cross-backend consistency contract). + +#pragma once + +#include +#include +#include +#include +#include + +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/render/iso_depth_key.h" +#include "laige/render/matrices.h" +#include "laige/render/sprite_batcher.h" +#include "laige/result.h" +#include "laige/sim_math.h" + +namespace laige::render { + +// The named-layer presets (the "named layers" of FR-2.3): the three +// canonical ids — the scene's custom layers use any id >= +// kParallaxLayerCustomBase (and any distinct depth-layer value — +// "The render order" above). "Named" = the stable id (the scene +// config, M3, maps the id to the scene-format name — no std::string +// in frame data, PRD §10.4). +inline constexpr std::uint32_t kParallaxLayerBackground = 0; +inline constexpr std::uint32_t kParallaxLayerMidground = 1; +inline constexpr std::uint32_t kParallaxLayerForeground = 2; +inline constexpr std::uint32_t kParallaxLayerCustomBase = 3; + +// The M2-ISO-01 key layer values of the presets (the M2-ISO-01 +// reserved parallax domain: background layers sort BEFORE the +// ground, foreground layers AFTER it — "layer dominates" in the +// key): the standard three leave every other domain value free for +// custom layers (more negative = further back). +inline constexpr std::int32_t kParallaxDepthLayerBackground = -2; +inline constexpr std::int32_t kParallaxDepthLayerMidground = -1; +inline constexpr std::int32_t kParallaxDepthLayerForeground = 1; + +// The registry's slot domain (API-006): [1, kParallaxLayersMaxLayers]; +// the default covers the PRD reference scene's 3 preset layers + +// custom headroom. 64 slots x ~104 B = 6.7 KB (the setup path). +inline constexpr std::uint32_t kParallaxLayersDefaultLayers = 8; +inline constexpr std::uint32_t kParallaxLayersMaxLayers = 64; + +// Manual (the caller drives the UV offset via setUvOffset) | Auto +// (the engine advances it by scrollSpeed per frame — +// advanceScrolls). +enum class ParallaxScrollMode : std::uint8_t { + Manual, + Auto, +}; + +// Image (a single texture — the atlasId + the atlas sub-rect `uv`) +// | Tilemap (a tilemap layer — the tilemapId; declared with +// M2-TILE-02, data only in M2-PAR-01). +enum class ParallaxSource : std::uint8_t { + Image, + Tilemap, +}; + +// One named layer's definition (the value the game declares through +// setLayer; plain value — no GL, no allocation). All fields are +// validated (the setLayer validation order) except `source`, +// `materialId`, `atlasId`, `tilemapId`, `enabled` (opaque +// references/flags — the batcher and the asset system own their +// domains). +struct ParallaxLayerDef { + // The layer id: [0, the registry's maxLayers); the presets are the + // kParallaxLayer* constants. + std::uint32_t id{}; + // Image (the default — the single-texture layer) | Tilemap + // (M2-TILE-02). + ParallaxSource source{ParallaxSource::Image}; + // The parallax factor (1): [0, 1]; 0 = fixed in world space + // (maximum parallax), 1 = fixed on screen (no parallax). + float factor{}; + // The reference camera position (1): the camera position at which + // the layer sits at exactly `offset` (default (0, 0) — the world + // origin). + Vec2 center{}; + // The layer's world-space offset at p = center (1) (default + // (0, 0) — the content origin). + Vec2 offset{}; + // The layer rectangle's extent in world units (Image source only — + // both > 0; ignored for the Tilemap source, which defines its own + // grid). + Vec2 size{1.0f, 1.0f}; + // The layer's texture sub-rect in its atlas (Image source only — + // the M2-SPRITE-01 UV domain: 0 <= u0 < u1 <= 1, 0 <= v0 < v1 <= 1; + // default the full texture). + SpriteUvRect uv{0.0f, 0.0f, 1.0f, 1.0f}; + // The layer's texture (atlas) reference (Image source — the batch + // group key's field 0; the scene's atlas-id convention, "The + // render order", orders the layer's group). + std::uint32_t atlasId{}; + // The tilemap's registry id (Tilemap source only — M2-TILE-02). + std::uint32_t tilemapId{}; + // The material reference (0 = the default material — the group + // key's field 1). + std::uint32_t materialId{}; + // The blend state of every layer quad (the group key's field 3). + BlendMode blend{BlendMode::Alpha}; + // The M2-ISO-01 key layer value of the layer's quads: the presets + // are the kParallaxDepthLayer* constants; a custom layer picks any + // value in the M2-ISO-01 domain [-512, +511] (more negative = + // further back). + std::int32_t depthLayer{}; + // Manual (the default) | Auto. + ParallaxScrollMode scrollMode{ParallaxScrollMode::Manual}; + // The auto-mode advance: UV units PER FRAME per axis (any finite + // value — negative scrolls the opposite way; ignored in Manual + // mode, but still validated finite). + Vec2 scrollSpeed{}; + // A disabled layer is skipped by declareTo (its state — including + // the scroll offset — still evolves through advanceScrolls). + bool enabled{true}; + + // Field-wise (SpriteUvRect carries no operator== — the M2-SPRITE-01 + // value type): the uv rect is compared per field. + friend bool operator==(const ParallaxLayerDef& a, const ParallaxLayerDef& b) { + return a.id == b.id && a.source == b.source && a.factor == b.factor && + a.center == b.center && a.offset == b.offset && a.size == b.size && + a.uv.u0 == b.uv.u0 && a.uv.v0 == b.uv.v0 && a.uv.u1 == b.uv.u1 && + a.uv.v1 == b.uv.v1 && a.atlasId == b.atlasId && + a.tilemapId == b.tilemapId && a.materialId == b.materialId && + a.blend == b.blend && a.depthLayer == b.depthLayer && + a.scrollMode == b.scrollMode && a.scrollSpeed == b.scrollSpeed && + a.enabled == b.enabled; + } +}; + +// The scene's parallax layer registry: the bounded slot table + the +// per-frame protocol (the header preamble). Templated over the SimMath +// backends (the M2-TILE-01 pattern): the declared quads' depth keys +// are the scene's backend's M2-ISO-01 keys (the cross-backend +// consistency contract). Header-only — pure value math + batcher +// bookkeeping, no GL, no allocation in the per-frame path. +template +class ParallaxLayers { + static_assert(std::is_same_v || + std::is_same_v, + "ParallaxLayers is templated over the SimMath backends"); + + public: + // The create options (API-006): `maxLayers` in + // [1, kParallaxLayersMaxLayers] (default + // kParallaxLayersDefaultLayers = 8). + struct Options { + std::uint32_t maxLayers{kParallaxLayersDefaultLayers}; + }; + + // Creates the registry (setup path — the only allocation: the + // pre-sized slot table). maxLayers outside [1, + // kParallaxLayersMaxLayers] -> InvalidArgument (no log — the + // M2-SPRITE-01 create precedent). The stopped state (failed create + // / default) follows the RenderThread precedent: `valid()` false, + // every operation InvalidArgument, no log. + [[nodiscard]] static laige::Result + create(Options options) noexcept; + + // The stopped state (default / failed create): `valid()` false, + // every operation InvalidArgument, no log — the RenderThread + // stopped-state precedent. Nothing owned (the slot table is + // null). + ParallaxLayers() noexcept = default; + + // True iff the registry is live (create succeeded). + [[nodiscard]] bool valid() const noexcept { return maxLayers_ > 0; } + + // The registry's slot domain (0 in the stopped state). + [[nodiscard]] std::uint32_t maxLayers() const noexcept { + return maxLayers_; + } + + // The count of SET slots (setup-path accounting — changes only on + // setLayer). + [[nodiscard]] std::uint32_t layerCount() const noexcept { + return layerCount_; + } + + // True iff `id` is a live, SET layer. + [[nodiscard]] bool has(std::uint32_t id) const noexcept { + return maxLayers_ > 0 && id < maxLayers_ && slots_[id].valid; + } + + // The set layer's definition. + // Precondition: has(id) (asserted — check when the id comes from + // untrusted input). + [[nodiscard]] const ParallaxLayerDef& layerAt(std::uint32_t id) + const noexcept { + assert(has(id) && "ParallaxLayers::layerAt: layer not set"); + return slots_[id].def; + } + + // The layer's CURRENT UV offset (in [0, 1)^2 — (0, 0) for an unset + // slot). Precondition: has(id) (asserted). + [[nodiscard]] Vec2 uvOffsetAt(std::uint32_t id) const noexcept { + assert(has(id) && "ParallaxLayers::uvOffsetAt: layer not set"); + return slots_[id].uvOffset; + } + + // The layer's world-space offset at camera position `cameraPos` + // (the camera's ground-plane (x, y)) — the EXACT formula (1): + // factor * (cameraPos - center) + offset. Pure O(1); no allocation, + // no logging, no GL. Precondition: has(id) (checked-free read — the + // M2-TILE-01 tileAt pattern; assert in debug). + [[nodiscard]] Vec2 worldOffsetAt(std::uint32_t id, Vec2 cameraPos) + const noexcept { + assert(has(id) && "ParallaxLayers::worldOffsetAt: layer not set"); + const ParallaxLayerDef& d = slots_[id].def; + return Vec2{d.factor * (cameraPos.x - d.center.x) + d.offset.x, + d.factor * (cameraPos.y - d.center.y) + d.offset.y}; + } + + // Registers or REPLACES the layer (setup / config path — the scene + // config's hot-reload of non-simulation config, FR-1.5). Validates + // the def (the documented order, first failure wins); a rejection + // leaves the slot unchanged (InvalidArgument + one rate-limited + // parallax/layer_invalid warn — fields layer, field). A successful + // (re)set RESETS the layer's UV offset to (0, 0) (the scroll + // restarts — the def carries no scroll state). O(1); no GL. + [[nodiscard]] laige::Status setLayer(const ParallaxLayerDef& def) noexcept; + + // Sets the layer's current UV offset (any finite value — wrapped + // to [0, 1)^2). Works in BOTH scroll modes (a manual nudge on an + // Auto layer composes with the per-frame advance). Unknown layer id + // -> InvalidArgument (no log — precondition); non-finite value -> + // InvalidArgument + one rate-limited parallax/uv_offset_invalid + // warn; a rejection leaves the offset unchanged. O(1); no GL. + [[nodiscard]] laige::Status setUvOffset(std::uint32_t id, Vec2 uvOffset) + noexcept; + + // Advances the AUTO layers' UV offsets by their scrollSpeed (the + // exact wrap, the header preamble) — ONCE PER FRAME, before the + // declarations (the caller paces it at the frame's pace; the auto + // speed is PER FRAME — frame-rate independence is the caller's + // concern, the M2-CAM-01 lerp precedent). Manual layers are + // untouched. O(layers); no allocation, no logging, no GL. No-op in + // the stopped state. + void advanceScrolls() noexcept; + + // The render path (the frame pipeline's cull/batch stage): + // declares every SET, ENABLED Image-source layer's wrap quads into + // `batcher` at camera position `cameraPos` (the 2 x 2 split, the + // header preamble; the quads carry the def's group-key fields, + // rotation 0, the full-default tint, and the M2-ISO-01 key of the + // quad's world center at the def's depthLayer — engine-owned, + // G-R11). Tilemap-source layers are skipped (the M2-TILE-02 hook — + // no log). Declaration order: ascending layer id, then the fixed + // quad order (RENDER-003). Precondition: the batcher's window is + // open (a built frame -> InvalidArgument, nothing declared); the + // stopped registry -> InvalidArgument. The batcher's own failures + // (BudgetExhausted) propagate from the first failed add. + // O(layers x 4) batcher adds; NO allocation, no logging, no GL. + [[nodiscard]] laige::Status declareTo(SpriteBatcher& batcher, + Vec2 cameraPos) const noexcept; + + private: + // One slot's state: the definition + the current UV offset + the + // set flag (the slot table is pre-sized at create — no per-frame + // growth, PERF-003). + struct LayerSlot { + ParallaxLayerDef def; + Vec2 uvOffset{0.0f, 0.0f}; + bool valid{false}; + }; + + explicit ParallaxLayers(std::uint32_t maxLayers) noexcept + : maxLayers_(maxLayers), + layerCount_(0), + slots_(std::make_unique(maxLayers)) {} + + // The [0, 1) wrap (the texture boundary): x - floor(x) — in [0, 1) + // for every finite x; exact for dyadic x (1.0 -> exactly 0.0, + // -0.25 -> exactly 0.75 — the tests pin it). + // std::floor's float overload (the C++ standard guarantees it; + // std::floorf does not — not every libstdc++ exposes the C suffix + // overloads in std). + static float wrapUv(float x) noexcept { return x - std::floor(x); } + + // The quad's M2-ISO-01 key position: the float world point in the + // scene's backend (Fp32Pinned: identity; Fpx16_16: the backend's + // Q16.16 conversion — the M2-TILE-01 float-conversion pattern). + [[nodiscard]] typename laige::sim::SimMath::Vec2 + backendVec2(float x, float y) const noexcept { + using M = laige::sim::SimMath; + if constexpr (std::is_same_v) { + return typename M::Vec2{x, y}; + } else { + return typename M::Vec2{laige::fpx16_16::fromFloat(x), + laige::fpx16_16::fromFloat(y)}; + } + } + + // Declares one wrap quad (the quad's world rectangle [min, max) + + // its ATLAS uv rect) into the batcher: center + world-unit scale + + // rotation 0 + the engine key of the center at the def's + // depthLayer. Propagates the batcher's add result. + [[nodiscard]] laige::Status declareQuad(SpriteBatcher& batcher, + const ParallaxLayerDef& def, + Vec2 worldMin, Vec2 worldMax, + SpriteUvRect atlasUv) + const noexcept { + SpriteItem item{}; + const float cx = (worldMin.x + worldMax.x) * 0.5f; + const float cy = (worldMin.y + worldMax.y) * 0.5f; + item.pos = Vec2{cx, cy}; + item.scale = Vec2{worldMax.x - worldMin.x, worldMax.y - worldMin.y}; + item.rotation = 0.0f; + item.uv = atlasUv; + item.frameIndex = 0; + item.atlasId = def.atlasId; + item.materialId = def.materialId; + item.blend = def.blend; + item.depthOverride = false; + item.depthKey = isoDepthKey(backendVec2(cx, cy), 0, + def.depthLayer); + const auto r = batcher.add(item); + if (!r.ok()) return r.error(); + return laige::Status{}; + } + + std::uint32_t maxLayers_{0}; + std::uint32_t layerCount_{0}; + std::unique_ptr slots_; // empty <=> stopped +}; + +template +laige::Result, laige::ErrorCode> +ParallaxLayers::create(Options options) noexcept { + if (options.maxLayers < 1 || options.maxLayers > kParallaxLayersMaxLayers) { + return laige::Result, laige::ErrorCode>::failure( + laige::ErrorCode::InvalidArgument); + } + return laige::Result, laige::ErrorCode>::success( + ParallaxLayers(options.maxLayers)); +} + +template +laige::Status ParallaxLayers::setLayer(const ParallaxLayerDef& def) + noexcept { + if (maxLayers_ == 0) { + return laige::Status(laige::ErrorCode::InvalidArgument); + } + // The documented validation order (first failure wins — the + // M2-CAM-01 options precedent): id -> factor -> center -> offset + // -> scroll_speed -> size (Image) -> uv (Image) -> depth_layer. + // Tilemap-source layers carry the tilemap's geometry (M2-TILE-02): + // their size/uv are not validated here (ignored). + const char* field = nullptr; + if (def.id >= maxLayers_) { + field = "id"; + } else if (!std::isfinite(def.factor) || def.factor < 0.0f || + def.factor > 1.0f) { + field = "factor"; + } else if (!std::isfinite(def.center.x) || !std::isfinite(def.center.y)) { + field = "center"; + } else if (!std::isfinite(def.offset.x) || !std::isfinite(def.offset.y)) { + field = "offset"; + } else if (!std::isfinite(def.scrollSpeed.x) || + !std::isfinite(def.scrollSpeed.y)) { + field = "scroll_speed"; + } else if (def.source == ParallaxSource::Image && + (!std::isfinite(def.size.x) || !std::isfinite(def.size.y) || + def.size.x <= 0.0f || def.size.y <= 0.0f)) { + field = "size"; + } else if (def.source == ParallaxSource::Image && + (def.uv.u0 < 0.0f || def.uv.u1 > 1.0f || + def.uv.u0 >= def.uv.u1 || def.uv.v0 < 0.0f || + def.uv.v1 > 1.0f || def.uv.v0 >= def.uv.v1)) { + field = "uv"; + } else if (def.depthLayer < -static_cast(kIsoDepthLayerBias) || + def.depthLayer > kIsoDepthLayerMax) { + field = "depth_layer"; + } + if (field != nullptr) { + LAIGE_LOG_WARN("parallax", "layer_invalid", + "Rejected parallax layer definition", + laige::log::field("layer", def.id), + laige::log::field("field", field)); + return laige::Status(laige::ErrorCode::InvalidArgument); + } + const std::uint32_t id = def.id; + if (!slots_[id].valid) { + ++layerCount_; + } + slots_[id].def = def; + slots_[id].uvOffset = Vec2{0.0f, 0.0f}; // a (re)set restarts the scroll + slots_[id].valid = true; + return laige::Status{}; +} + +template +laige::Status ParallaxLayers::setUvOffset(std::uint32_t id, + Vec2 uvOffset) noexcept { + if (maxLayers_ == 0 || id >= maxLayers_ || !slots_[id].valid) { + // Precondition failure (unknown layer) — no log (the M2-TILE-01 + // rejected-edit precedent). + return laige::Status(laige::ErrorCode::InvalidArgument); + } + if (!std::isfinite(uvOffset.x) || !std::isfinite(uvOffset.y)) { + LAIGE_LOG_WARN("parallax", "uv_offset_invalid", + "Rejected non-finite parallax UV offset", + laige::log::field("layer", id)); + return laige::Status(laige::ErrorCode::InvalidArgument); + } + slots_[id].uvOffset = + Vec2{wrapUv(uvOffset.x), wrapUv(uvOffset.y)}; + return laige::Status{}; +} + +template +void ParallaxLayers::advanceScrolls() noexcept { + for (std::uint32_t i = 0; i < maxLayers_; ++i) { + LayerSlot& s = slots_[i]; + if (!s.valid || s.def.scrollMode != ParallaxScrollMode::Auto) { + continue; + } + s.uvOffset.x = wrapUv(s.uvOffset.x + s.def.scrollSpeed.x); + s.uvOffset.y = wrapUv(s.uvOffset.y + s.def.scrollSpeed.y); + } +} + +template +laige::Status ParallaxLayers::declareTo(SpriteBatcher& batcher, + Vec2 cameraPos) const + noexcept { + if (maxLayers_ == 0) { + return laige::Status(laige::ErrorCode::InvalidArgument); + } + // The batcher's window must be open (a built frame rejects — the + // M2-TILE-01 declareTo precedent; nothing declared). + if (batcher.frameBuilt()) { + return laige::Status(laige::ErrorCode::InvalidArgument); + } + for (std::uint32_t i = 0; i < maxLayers_; ++i) { + const LayerSlot& s = slots_[i]; + if (!s.valid || !s.def.enabled) { + continue; + } + // The Tilemap source declares through the M2-TILE-02 tilemap + // path (this layer's worldOffset + depthLayer) — skipped in + // M2-PAR-01 (no log — the documented hook). + if (s.def.source == ParallaxSource::Tilemap) { + continue; + } + // The layer rectangle's world origin corner (formula 1). + const Vec2 o = worldOffsetAt(i, cameraPos); + const float ox = s.uvOffset.x; // in [0, 1) + const float oy = s.uvOffset.y; // in [0, 1) + const float sx = s.def.size.x; + const float sy = s.def.size.y; + // The wrap world lines (the texture wraps where the base uv + + // offset reaches 1). + const float wx = o.x + (1.0f - ox) * sx; + const float wy = o.y + (1.0f - oy) * sy; + const float xr = o.x + sx; + const float yr = o.y + sy; + // The layer's atlas sub-rect -> the quad's ATLAS uv rect over its + // base uv range (the 2 x 2 split, the header preamble). + const SpriteUvRect U = s.def.uv; + const auto mapUv = [U](float bu0, float bv0, float bu1, float bv1) { + return SpriteUvRect{U.u0 + bu0 * (U.u1 - U.u0), + U.v0 + bv0 * (U.v1 - U.v0), + U.u0 + bu1 * (U.u1 - U.u0), + U.v0 + bv1 * (U.v1 - U.v0)}; + }; + // q00: base uv [ox, 1) x [oy, 1) — always (the full rectangle at + // offset (0, 0)). + laige::Status st = declareQuad(batcher, s.def, Vec2{o.x, o.y}, + Vec2{wx, wy}, mapUv(ox, oy, 1.0f, 1.0f)); + if (!st.ok()) return st; + // q10: base uv [0, ox) x [oy, 1) — only when ox > 0. + if (ox > 0.0f) { + st = declareQuad(batcher, s.def, Vec2{wx, o.y}, Vec2{xr, wy}, + mapUv(0.0f, oy, ox, 1.0f)); + if (!st.ok()) return st; + } + // q01: base uv [ox, 1) x [0, oy) — only when oy > 0. + if (oy > 0.0f) { + st = declareQuad(batcher, s.def, Vec2{o.x, wy}, Vec2{wx, yr}, + mapUv(ox, 0.0f, 1.0f, oy)); + if (!st.ok()) return st; + } + // q11: base uv [0, ox) x [0, oy) — only when both > 0. + if (ox > 0.0f && oy > 0.0f) { + st = declareQuad(batcher, s.def, Vec2{wx, wy}, Vec2{xr, yr}, + mapUv(0.0f, 0.0f, ox, oy)); + if (!st.ok()) return st; + } + } + return laige::Status{}; +} + +} // namespace laige::render diff --git a/src/laige-render/include/laige/render/tilemap.h b/src/laige-render/include/laige/render/tilemap.h index dce7e90..aa822e1 100644 --- a/src/laige-render/include/laige/render/tilemap.h +++ b/src/laige-render/include/laige/render/tilemap.h @@ -216,7 +216,8 @@ class TileMap { // two >= 1), `maxChunks` (default // `kIsoDepthTableDefaultMaxChunks` = 1024), `layer` (default // `kIsoDepthGroundLayer` = 0 — a parallax tile LAYER gets its own - // tilemap with its layer value, M2-PAR-01). + // tilemap with its layer value, M2-PAR-01 — the values are + // documented in laige/render/parallax.h). using Options = IsoDepthKeyTable::Options; // The per-frame declare options (the fixed-frame quad model, above): diff --git a/tests/laige-render/CMakeLists.txt b/tests/laige-render/CMakeLists.txt index f2566da..6954e7e 100644 --- a/tests/laige-render/CMakeLists.txt +++ b/tests/laige-render/CMakeLists.txt @@ -122,7 +122,20 @@ # positions/depth goldens + the (texture, material, blend) grouping # and cross-frame determinism, the frame protocol/failure paths/custom # options/no-log happy path, and the 1000-frame zero-allocation declare -# loop) — pure data + batcher bookkeeping: no GL environment needed. +# loop) — pure data + batcher bookkeeping: no GL environment needed; +# and the parallax layers (M2-PAR-01) — the named bg/mid/fg layer +# model of FR-2.3 (parallax_tests.cpp's Parallax* suites: the registry +# create/stopped state, the setLayer validation matrix with the +# pinned warn fields, the EXACT offset formula factor * (p - center) + +# offset (hand-computed dyadic goldens), the auto/manual UV scroll +# with the EXACT wrap at the texture boundary, the hand-computed +# golden keys of the 4-layer + ground scene (both backends — the +# dyadic exactness zone) with the group (draw) order and the +# layer-dominance orderings, the 2 x 2 wrap split's exact world/UV +# rects (incl. the atlas sub-rect mapping), the frame protocol / +# stopped / disabled / tilemap-hook paths, cross-frame determinism, +# and the 1000-frame zero-allocation declare loop) — pure data + +# batcher bookkeeping: no GL environment needed. # # One executable per module (tests/README.md): laige-render_tests links # the module under test plus gtest_main. The unfiltered entry runs the @@ -144,10 +157,11 @@ # M2-SPRITE-02 Verify command (`ctest -R sprite_draw`), the # `sprite_frames` entry is the M2-SPRITE-03 Verify command # (`ctest -R sprite_frames`), the `render_counters` entry is the -# M2-SPRITE-04 Verify command (`ctest -R render_counters`), and the +# M2-SPRITE-04 Verify command (`ctest -R render_counters`), the # `tilemap` entry is the M2-TILE-01 Verify command (`ctest -R -# tilemap`), each selecting exactly its suites from the shared -# executable. +# tilemap`), and the `parallax` entry is the M2-PAR-01 Verify command +# (`ctest -R parallax`), each selecting exactly its suites from the +# shared executable. # # Environment note: the GlContextSmoke, RenderThreadOffscreen, # SpriteDraw{State,Smoke,Pipeline}, and RenderCounters{Scene,Cap, @@ -157,15 +171,16 @@ # a GL driver the suites GTEST_SKIP with the clean Status reason — the # documented environment contract (docs/api/gl_context.md), not an # engine failure. The GlContextGate/GlContextArgs/FrameClock/ -# RenderThreadHandoff/SpriteDrawCreate/RenderCountersCreate/TileMap* -# suites always run (they make no GL calls). +# RenderThreadHandoff/SpriteDrawCreate/RenderCountersCreate/TileMap*/ +# Parallax* suites always run (they make no GL calls). add_executable(laige-render_tests gl_context_tests.cpp render_thread_tests.cpp matrices_tests.cpp iso_depth_key_tests.cpp iso_depth_table_tests.cpp camera_tests.cpp iso_camera_tests.cpp projection_tests.cpp iso_picking_tests.cpp depth_sort_tests.cpp sprite_batcher_tests.cpp sprite_draw_tests.cpp - sprite_frames_tests.cpp render_counters_tests.cpp tilemap_tests.cpp) + sprite_frames_tests.cpp render_counters_tests.cpp tilemap_tests.cpp + parallax_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 @@ -311,6 +326,14 @@ add_test(NAME render_counters COMMAND laige-render_tests add_test(NAME tilemap COMMAND laige-render_tests --gtest_filter=TileMap*) +# M2-PAR-01: the step's Verify command is `ctest -R parallax`. Pure +# data + batcher bookkeeping (no GL calls) — runs in every local tree +# and in CI. No budget gate: the step's roadmap scope has no +# standalone budgets.json entry (the per-frame declare cost is part of +# the composite 50k render-CPU budget, measured with M2-PERF-01). +add_test(NAME parallax COMMAND laige-render_tests + --gtest_filter=Parallax*) + 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) @@ -347,7 +370,7 @@ if(LAIGE_TSAN) set_tests_properties(laige-render_tests gl_context render_thread matrices iso_depth_key iso_depth_table camera iso_camera projection iso_picking depth_sort batcher sprite_draw sprite_frames - render_counters tilemap PROPERTIES + render_counters tilemap parallax PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1;LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json") endif() @@ -409,6 +432,7 @@ set_tests_properties(iso_picking PROPERTIES TIMEOUT 60) set_tests_properties(batcher PROPERTIES TIMEOUT 60) set_tests_properties(sprite_frames PROPERTIES TIMEOUT 60) set_tests_properties(tilemap PROPERTIES TIMEOUT 60) +set_tests_properties(parallax 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/parallax_tests.cpp b/tests/laige-render/parallax_tests.cpp new file mode 100644 index 0000000..e6ed2e2 --- /dev/null +++ b/tests/laige-render/parallax_tests.cpp @@ -0,0 +1,1077 @@ +// laige-render parallax layer tests (M2-PAR-01): the named background / +// midground / foreground layer model of FR-2.3 — the parallax factor, +// the world-space offset formula, the UV scroll (auto/manual) with the +// exact wrap at the texture boundary, and the batch path into the +// sprite batcher — in laige/render/parallax.h. +// +// Pure data + batcher bookkeeping — no GL context, no GL environment +// needed: every suite runs in every local tree and in CI. The offset +// goldens are HAND-COMPUTED from the pinned formula (factor * (p - +// center) + offset) at dyadic values (bit-exact in float, and in the +// dyadic exactness zone of both SimMath backends); the depth-key +// goldens are hand-computed from the M2-ISO-01 formula ((l + 512) +// << 22 | (d + 2^21), d = 16 * (cx + cy) at the dyadic centers), and +// the independent oracle is the M2-ISO-01 function itself (the +// tilemap test pattern). The zero-allocation window covers the +// per-frame declare loop (FR-2.2 "no per-frame allocation" — the +// sanitizer trees run the same workload shapes leak-free instead, +// methodology §4). +// +// No budget gate: the step's roadmap scope has no standalone +// budgets.json entry (the per-frame declare cost is part of the +// composite 50k render-CPU budget — M2-PERF-01). + +#include "laige/render/iso_depth_key.h" +#include "laige/render/parallax.h" +#include "laige/render/sprite_batcher.h" + +#include +#include +#include +#include +#include +#if !defined(_MSC_VER) +#include // dladdr (the alloc-site module/symbol, POSIX) +#endif +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/alloc_watch.h" +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/sim_math.h" + +namespace { + +using laige::fpx16_16; +using laige::render::BlendMode; +using laige::render::ParallaxLayerDef; +using laige::render::ParallaxScrollMode; +using laige::render::ParallaxSource; +using laige::render::SpriteBatch; +using laige::render::SpriteBatcher; +using laige::render::SpriteItem; +using laige::render::SpriteUvRect; +using laige::render::kParallaxDepthLayerBackground; +using laige::render::kParallaxDepthLayerForeground; +using laige::render::kParallaxDepthLayerMidground; +using laige::render::kParallaxLayerCustomBase; +using laige::sim::Fp32Pinned; +using laige::sim::Fpx16_16; + +// --------------------------------------------------------------------------- +// The independent oracle: the M2-ISO-01 function on the quad's world +// center — the parallax keys must equal it (the dyadic centers are in +// the exactness zone of both backends — the bit-identity contract). +// The test converts the float point into the backend's Vec2 itself +// (never reusing the registry's conversion). +// --------------------------------------------------------------------------- + +template +laige::sim::SimMath::Vec2 backendVec2(float x, float y) { + using M = laige::sim::SimMath; + if constexpr (std::is_same_v) { + return typename M::Vec2{x, y}; + } else { + return typename M::Vec2{fpx16_16::fromFloat(x), fpx16_16::fromFloat(y)}; + } +} + +template +std::uint32_t oracleKey(float x, float y, std::int32_t layer) { + return laige::render::isoDepthKey(backendVec2(x, y), 0, + layer); +} + +// --------------------------------------------------------------------------- +// Log capture (the camera_tests MemorySink pattern) +// --------------------------------------------------------------------------- + +class MemorySink : public laige::log::Sink { + public: + struct Entry { + laige::log::Severity severity{}; + std::string subsystem; + std::string event; + std::string message; + std::vector> fields; + }; + + void emit(const laige::log::LogRecord& record) override { + Entry e; + e.severity = record.severity; + e.subsystem = record.subsystem; + e.event = record.event; + e.message = record.message; + for (const auto& f : record.fields) { + e.fields.emplace_back(std::string(f.name), f.value); + } + entries.push_back(std::move(e)); + } + void flush() override {} + + std::vector entries; +}; + +// Installs a fresh capture sink (heap-owned by the logger) with rate +// limiting OFF (the iso_depth_table_tests pattern). +MemorySink* installCaptureSink() { + auto sink = std::make_unique(); + MemorySink* ptr = sink.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + opts.rateLimiting = false; + if (!laige::log::Logger::instance().init(std::move(opts)).ok()) { + ADD_FAILURE() << "Logger::init (capture sink) failed"; + abort(); + } + return ptr; +} + +void restoreLogger() { + laige::log::LoggerOptions defaults; + if (!laige::log::Logger::instance().init(std::move(defaults)).ok()) { + ADD_FAILURE() << "Logger::init (restore default sink) failed"; + } +} + +std::size_t countEvents(const MemorySink& sink, std::string_view subsystem, + std::string_view event) { + std::size_t n = 0; + for (const auto& e : sink.entries) { + if (e.subsystem == subsystem && e.event == event) ++n; + } + return n; +} + +std::string fieldOf(const MemorySink::Entry& e, std::string_view key) { + for (const auto& [k, v] : e.fields) { + if (k == key) return v; + } + return std::string(); +} + +laige::Status expectRejected(laige::Status s, laige::ErrorCode want) { + EXPECT_TRUE(s.isError()); + EXPECT_EQ(s.error(), want); + return s; +} + +// SpriteUvRect has no operator== (the M2-SPRITE-01 value type): +// the field-wise comparison (the sprite_frames_tests pattern). +void expectUvRect(const char* what, const SpriteUvRect& uv, float u0, + float v0, float u1, float v1) { + EXPECT_EQ(uv.u0, u0) << what; + EXPECT_EQ(uv.v0, v0) << what; + EXPECT_EQ(uv.u1, u1) << what; + EXPECT_EQ(uv.v1, v1) << what; +} + +// The valid `bg` preset layer definition (the template for the +// validation matrix): the roadmap's named bg layer. +ParallaxLayerDef bgDef(std::uint32_t id) { + ParallaxLayerDef d; + d.id = id; + d.source = ParallaxSource::Image; + d.factor = 0.25f; + d.center = {4.0f, 4.0f}; + d.offset = {1.0f, 2.0f}; + d.size = {2.0f, 2.0f}; + d.uv = {0.0f, 0.0f, 1.0f, 1.0f}; + d.atlasId = 0; + d.materialId = 0; + d.blend = BlendMode::Alpha; + d.depthLayer = kParallaxDepthLayerBackground; + d.scrollMode = ParallaxScrollMode::Manual; + d.scrollSpeed = {0.0f, 0.0f}; + d.enabled = true; + return d; +} + +} // namespace + +// --------------------------------------------------------------------------- +// ParallaxCreate — the registry options and the stopped state +// --------------------------------------------------------------------------- + +TEST(ParallaxCreate, CreateValidation) { + using Layers = laige::render::ParallaxLayers; + typename Layers::Options o; + // The domain bounds (no log — the M2-SPRITE-01 create precedent): + o.maxLayers = 0; + auto r = Layers::create(o); + EXPECT_TRUE(r.isError()); + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + o.maxLayers = laige::render::kParallaxLayersMaxLayers + 1; + r = Layers::create(o); + EXPECT_TRUE(r.isError()); + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + // The full domain: + o.maxLayers = laige::render::kParallaxLayersMaxLayers; + r = Layers::create(o); + ASSERT_TRUE(r.ok()); + auto maxed = std::move(r).takeValue(); + EXPECT_TRUE(maxed.valid()); + EXPECT_EQ(maxed.maxLayers(), laige::render::kParallaxLayersMaxLayers); + EXPECT_EQ(maxed.layerCount(), 0u); + EXPECT_FALSE(maxed.has(0)); + // The default (stopped) state: every operation InvalidArgument, no + // crash, no log (the RenderThread stopped-state precedent). + Layers stopped; + EXPECT_FALSE(stopped.valid()); + EXPECT_EQ(stopped.maxLayers(), 0u); + expectRejected(stopped.setLayer(bgDef(0)), + laige::ErrorCode::InvalidArgument); + expectRejected(stopped.setUvOffset(0, {0.5f, 0.0f}), + laige::ErrorCode::InvalidArgument); + SpriteBatcher batcher; + expectRejected(stopped.declareTo(batcher, {1.0f, 1.0f}), + laige::ErrorCode::InvalidArgument); + stopped.advanceScrolls(); // no-op (no crash) +} + +// --------------------------------------------------------------------------- +// ParallaxLayer — the setLayer validation (first failure wins), the +// warn's fields, the state-unchanged-on-rejection, and the replace +// semantics +// --------------------------------------------------------------------------- + +template +void setValidation() { + using Layers = laige::render::ParallaxLayers; + typename Layers::Options o; + o.maxLayers = 4; + auto r = Layers::create(o); + ASSERT_TRUE(r.ok()); + auto layers = std::move(r).takeValue(); + + // The happy path: accepted, introspection, no log. + auto sink = installCaptureSink(); + ParallaxLayerDef d = bgDef(0); + EXPECT_TRUE(layers.setLayer(d).ok()); + EXPECT_EQ(sink->entries.size(), 0u) << "the happy path logs nothing"; + EXPECT_TRUE(layers.has(0)); + EXPECT_EQ(layers.layerCount(), 1u); + EXPECT_TRUE(layers.layerAt(0) == d); + EXPECT_EQ(layers.uvOffsetAt(0), (glm::vec2{0.0f, 0.0f})); + restoreLogger(); + + // The validation matrix: one capture sink per case, one + // parallax/layer_invalid warn with the failing field PINNED. + auto runCase = [&](const ParallaxLayerDef& bad, const char* field) { + auto s = installCaptureSink(); + expectRejected(layers.setLayer(bad), + laige::ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*s, "parallax", "layer_invalid"), 1u) + << "one warn for the rejection"; + ASSERT_EQ(s->entries.size(), 1u); + EXPECT_EQ(fieldOf(s->entries.front(), "layer"), + std::to_string(bad.id)); + EXPECT_EQ(fieldOf(s->entries.front(), "field"), field); + restoreLogger(); + }; + { + ParallaxLayerDef bad = bgDef(0); + bad.id = 4; // == maxLayers + runCase(bad, "id"); + } + { + ParallaxLayerDef bad = bgDef(0); + bad.factor = -0.1f; + runCase(bad, "factor"); + } + { + ParallaxLayerDef bad = bgDef(0); + bad.factor = 1.1f; + runCase(bad, "factor"); + } + { + ParallaxLayerDef bad = bgDef(0); + bad.factor = std::nanf(""); + runCase(bad, "factor"); + } + { + ParallaxLayerDef bad = bgDef(0); + bad.center = {std::nanf(""), 1.0f}; + runCase(bad, "center"); + } + { + ParallaxLayerDef bad = bgDef(0); + bad.offset = {1.0f, std::numeric_limits::infinity()}; + runCase(bad, "offset"); + } + { + ParallaxLayerDef bad = bgDef(0); + bad.scrollSpeed = {std::nanf(""), 0.0f}; + runCase(bad, "scroll_speed"); + } + { + ParallaxLayerDef bad = bgDef(0); + bad.size = {0.0f, 1.0f}; + runCase(bad, "size"); + } + { + ParallaxLayerDef bad = bgDef(0); + bad.size = {2.0f, -1.0f}; + runCase(bad, "size"); + } + { + ParallaxLayerDef bad = bgDef(0); + bad.size = {std::nanf(""), 2.0f}; + runCase(bad, "size"); + } + { + ParallaxLayerDef bad = bgDef(0); + bad.uv = {0.5f, 0.0f, 0.25f, 1.0f}; // u0 > u1 + runCase(bad, "uv"); + } + { + ParallaxLayerDef bad = bgDef(0); + bad.uv = {0.0f, 0.0f, 1.1f, 1.0f}; // u1 > 1 + runCase(bad, "uv"); + } + { + ParallaxLayerDef bad = bgDef(0); + bad.uv = {0.0f, -0.1f, 1.0f, 1.0f}; // v0 < 0 + runCase(bad, "uv"); + } + { + ParallaxLayerDef bad = bgDef(0); + bad.depthLayer = -513; // below the M2-ISO-01 domain + runCase(bad, "depth_layer"); + } + { + ParallaxLayerDef bad = bgDef(0); + bad.depthLayer = 512; // above the M2-ISO-01 domain + runCase(bad, "depth_layer"); + } + + // The domain EDGES are accepted (the full M2-ISO-01 layer domain, + // the factor bounds, and the factor extremes): + { + ParallaxLayerDef ok = bgDef(1); + ok.depthLayer = -512; + EXPECT_TRUE(layers.setLayer(ok).ok()); + } + { + ParallaxLayerDef ok = bgDef(2); + ok.depthLayer = 511; + EXPECT_TRUE(layers.setLayer(ok).ok()); + } + { + ParallaxLayerDef ok = bgDef(3); + ok.factor = 0.0f; + EXPECT_TRUE(layers.setLayer(ok).ok()); + ok.factor = 1.0f; + EXPECT_TRUE(layers.setLayer(ok).ok()); + } + // The Tilemap source: the geometry is the tilemap's (M2-TILE-02) — + // a degenerate size/uv is IGNORED, not rejected: + { + ParallaxLayerDef ok = bgDef(0); + ok.source = ParallaxSource::Tilemap; + ok.tilemapId = 7; + ok.size = {0.0f, 0.0f}; + ok.uv = {0.5f, 0.0f, 0.2f, 1.0f}; // invalid as an Image + EXPECT_TRUE(layers.setLayer(ok).ok()); + } + + // A rejection leaves the slot UNCHANGED (validate-before-write): + auto sink2 = installCaptureSink(); + ParallaxLayerDef one = bgDef(1); + one.offset = {9.0f, 9.0f}; + EXPECT_TRUE(layers.setLayer(one).ok()); + ParallaxLayerDef bad = bgDef(1); + bad.factor = 7.0f; + expectRejected(layers.setLayer(bad), + laige::ErrorCode::InvalidArgument); + EXPECT_EQ(layers.layerAt(1).offset, (glm::vec2{9.0f, 9.0f})); + // id 0 was replaced by the Tilemap case above (still set); id 2 and + // 3 are set from the domain-edge cases: the rejected ids were never + // written, and the layerCount counts SET slots only: + EXPECT_TRUE(layers.has(0)); + EXPECT_TRUE(layers.has(2)); + EXPECT_TRUE(layers.has(3)); + EXPECT_EQ(layers.layerCount(), 4u); // ids 0, 1, 2, 3 all set + EXPECT_EQ(countEvents(*sink2, "parallax", "layer_invalid"), 1u); + restoreLogger(); + + // The replace semantics: the last definition wins, and the scroll + // offset RESETS (the def carries no scroll state): + EXPECT_TRUE(layers.setUvOffset(1, {0.5f, 0.0f}).ok()); + ParallaxLayerDef again = bgDef(1); + again.factor = 0.75f; + EXPECT_TRUE(layers.setLayer(again).ok()); + EXPECT_EQ(layers.layerAt(1).factor, 0.75f); + EXPECT_EQ(layers.uvOffsetAt(1), (glm::vec2{0.0f, 0.0f})); +} + +TEST(ParallaxLayer, SetValidationFpx16) { setValidation(); } +TEST(ParallaxLayer, SetValidationFp32) { setValidation(); } + +// --------------------------------------------------------------------------- +// ParallaxOffset — the EXACT offset formula: factor * (p - center) + +// offset (the roadmap's pinned contract; the dyadic values are +// bit-exact) +// --------------------------------------------------------------------------- + +TEST(ParallaxOffset, Formula) { + using Layers = laige::render::ParallaxLayers; + auto r = Layers::create({}); // 8 slots + ASSERT_TRUE(r.ok()); + auto layers = std::move(r).takeValue(); + // id 0: factor 0 — FIXED IN WORLD SPACE at `offset` (maximum + // parallax). + auto d0 = bgDef(0); + d0.factor = 0.0f; + d0.center = {3.0f, -7.0f}; + d0.offset = {2.0f, 5.0f}; + ASSERT_TRUE(layers.setLayer(d0).ok()); + // id 1: factor 1 — FIXED ON SCREEN (moves 1:1 with the camera). + auto d1 = d0; + d1.id = 1; + d1.factor = 1.0f; + ASSERT_TRUE(layers.setLayer(d1).ok()); + // id 2: factor 0.5 (dyadic). + auto d2 = d0; + d2.id = 2; + d2.factor = 0.5f; + d2.center = {2.0f, -4.0f}; + d2.offset = {1.0f, 3.0f}; + ASSERT_TRUE(layers.setLayer(d2).ok()); + // id 3: factor 0.25, the world-origin reference. + auto d3 = d0; + d3.id = 3; + d3.factor = 0.25f; + d3.center = {0.0f, 0.0f}; + d3.offset = {0.5f, -0.25f}; + ASSERT_TRUE(layers.setLayer(d3).ok()); + // id 4: factor 0.3 (NON-dyadic — the linearity check), the + // world-origin reference (center/offset zeroed — the d0 template + // carries its own). + auto d4 = d0; + d4.id = 4; + d4.factor = 0.3f; + d4.center = {0.0f, 0.0f}; + d4.offset = {0.0f, 0.0f}; + ASSERT_TRUE(layers.setLayer(d4).ok()); + + // factor 0: the offset for EVERY camera position (exact): + for (const glm::vec2 p : {glm::vec2{0.0f, 0.0f}, glm::vec2{10.0f, 6.0f}, + glm::vec2{-4.0f, 4.0f}}) { + EXPECT_EQ(layers.worldOffsetAt(0, p), (glm::vec2{2.0f, 5.0f})); + } + // factor 1: p - center + offset (exact, dyadic): + // (10, 6) - (3, -7) + (2, 5) = (9, 18); p = center -> (2, 5). + EXPECT_EQ(layers.worldOffsetAt(1, {10.0f, 6.0f}), (glm::vec2{9.0f, 18.0f})); + EXPECT_EQ(layers.worldOffsetAt(1, {3.0f, -7.0f}), (glm::vec2{2.0f, 5.0f})); + // factor 0.5: + // p (10, 2): 0.5 * (8, 6) + (1, 3) = (5, 6) + // p (-6, 8): 0.5 * (-8, 12) + (1, 3) = (-3, 9) + // p = center: (1, 3) + EXPECT_EQ(layers.worldOffsetAt(2, {10.0f, 2.0f}), (glm::vec2{5.0f, 6.0f})); + EXPECT_EQ(layers.worldOffsetAt(2, {-6.0f, 8.0f}), (glm::vec2{-3.0f, 9.0f})); + EXPECT_EQ(layers.worldOffsetAt(2, {2.0f, -4.0f}), (glm::vec2{1.0f, 3.0f})); + // factor 0.25: p (4, -8): 0.25 * (4, -8) + (0.5, -0.25) = (1.5, -2.25) + EXPECT_EQ(layers.worldOffsetAt(3, {4.0f, -8.0f}), (glm::vec2{1.5f, -2.25f})); + // factor 0.3 (non-dyadic — the LINEARITY of the formula, 1e-4 + // float tolerance): offset(p2) - offset(p1) == factor * (p2 - p1), + // and offset(p1) == factor * p1: + const glm::vec2 p1{0.1f, 2.0f}; + const glm::vec2 p2{11.3f, 4.2f}; + const glm::vec2 o1 = layers.worldOffsetAt(4, p1); + const glm::vec2 o2 = layers.worldOffsetAt(4, p2); + EXPECT_NEAR(o2.x - o1.x, 0.3f * (p2.x - p1.x), 1.0e-4f); + EXPECT_NEAR(o2.y - o1.y, 0.3f * (p2.y - p1.y), 1.0e-4f); + EXPECT_NEAR(o1.x, 0.3f * p1.x, 1.0e-4f); + EXPECT_NEAR(o1.y, 0.3f * p1.y, 1.0e-4f); +} + +// --------------------------------------------------------------------------- +// ParallaxScroll — the UV scroll (auto/manual) + the EXACT wrap at the +// texture boundary +// --------------------------------------------------------------------------- + +TEST(ParallaxScroll, Wrap) { + using Layers = laige::render::ParallaxLayers; + auto r = Layers::create({}); // 8 slots + ASSERT_TRUE(r.ok()); + auto layers = std::move(r).takeValue(); + // id 0: AUTO, speed (0.25, 0). + auto d0 = bgDef(0); + d0.scrollMode = ParallaxScrollMode::Auto; + d0.scrollSpeed = {0.25f, 0.0f}; + ASSERT_TRUE(layers.setLayer(d0).ok()); + // id 1: AUTO, speed (0.5, -0.25) (a negative axis). + auto d1 = d0; + d1.id = 1; + d1.scrollSpeed = {0.5f, -0.25f}; + ASSERT_TRUE(layers.setLayer(d1).ok()); + // id 2: MANUAL. + auto d2 = d0; + d2.id = 2; + d2.scrollMode = ParallaxScrollMode::Manual; + ASSERT_TRUE(layers.setLayer(d2).ok()); + + // Auto id 0: 3 advances of 0.25 -> (0.75, 0) EXACT; the 4th crosses + // the texture boundary: 1.0 wraps to EXACTLY 0.0. + EXPECT_EQ(layers.uvOffsetAt(0), (glm::vec2{0.0f, 0.0f})); + layers.advanceScrolls(); + EXPECT_EQ(layers.uvOffsetAt(0), (glm::vec2{0.25f, 0.0f})); + layers.advanceScrolls(); + EXPECT_EQ(layers.uvOffsetAt(0), (glm::vec2{0.5f, 0.0f})); + layers.advanceScrolls(); + EXPECT_EQ(layers.uvOffsetAt(0), (glm::vec2{0.75f, 0.0f})); + layers.advanceScrolls(); + EXPECT_EQ(layers.uvOffsetAt(0), (glm::vec2{0.0f, 0.0f})); // 1.0 -> 0.0 + // Auto id 1: (0.5, -0.25) advances: + // 1: (0.5, 0.75) 2: (0.0, 0.5) 3: (0.5, 0.25) — all exact + // (the negative axis wraps the other way: -0.25 -> 0.75). + layers.advanceScrolls(); + EXPECT_EQ(layers.uvOffsetAt(1), (glm::vec2{0.5f, 0.75f})); + layers.advanceScrolls(); + EXPECT_EQ(layers.uvOffsetAt(1), (glm::vec2{0.0f, 0.5f})); + layers.advanceScrolls(); + EXPECT_EQ(layers.uvOffsetAt(1), (glm::vec2{0.5f, 0.25f})); + // Manual id 2: advanceScrolls leaves it UNTOUCHED. + EXPECT_EQ(layers.uvOffsetAt(2), (glm::vec2{0.0f, 0.0f})); + + // Manual: setUvOffset WRAPS any finite value to [0, 1)^2 (exact at + // the dyadic boundaries): + EXPECT_TRUE(layers.setUvOffset(2, {1.5f, -0.5f}).ok()); + EXPECT_EQ(layers.uvOffsetAt(2), (glm::vec2{0.5f, 0.5f})); + EXPECT_TRUE(layers.setUvOffset(2, {1.0f, 0.25f}).ok()); + EXPECT_EQ(layers.uvOffsetAt(2), (glm::vec2{0.0f, 0.25f})); // 1.0 -> 0.0 + EXPECT_TRUE(layers.setUvOffset(2, {-1.0f, 2.0f}).ok()); + EXPECT_EQ(layers.uvOffsetAt(2), (glm::vec2{0.0f, 0.0f})); + // A manual set on an AUTO layer composes with the per-frame advance + // (0.75 + 0.25 -> exactly 0.0): + EXPECT_TRUE(layers.setUvOffset(0, {0.75f, 0.0f}).ok()); + layers.advanceScrolls(); + EXPECT_EQ(layers.uvOffsetAt(0), (glm::vec2{0.0f, 0.0f})); + + // Non-finite: rejected (InvalidArgument + one rate-limited warn), + // state unchanged: + auto sink = installCaptureSink(); + expectRejected(layers.setUvOffset(2, {std::nanf(""), 0.0f}), + laige::ErrorCode::InvalidArgument); + expectRejected( + layers.setUvOffset(2, {1.0f, std::numeric_limits::infinity()}), + laige::ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*sink, "parallax", "uv_offset_invalid"), 2u); + EXPECT_EQ(fieldOf(sink->entries.front(), "layer"), "2"); + EXPECT_EQ(layers.uvOffsetAt(2), (glm::vec2{0.0f, 0.0f})); + // Unknown layer id: InvalidArgument, NO log (precondition): + auto sink2 = installCaptureSink(); + expectRejected(layers.setUvOffset(3, {0.5f, 0.5f}), + laige::ErrorCode::InvalidArgument); + EXPECT_EQ(sink2->entries.size(), 0u); + restoreLogger(); + + // The offset PERSISTS across frames (it is state, not a per-frame + // reset): advance, run a full frame, the offset is unchanged. + EXPECT_TRUE(layers.setUvOffset(0, {0.25f, 0.0f}).ok()); + SpriteBatcher::Options bo; + bo.maxSprites = 16; + auto rb = SpriteBatcher::create(bo); + ASSERT_TRUE(rb.ok()); + auto batcher = std::move(rb).takeValue(); + batcher.beginFrame(); + ASSERT_TRUE(layers.declareTo(batcher, {1.0f, 1.0f}).ok()); + ASSERT_TRUE(batcher.build().ok()); + EXPECT_EQ(layers.uvOffsetAt(0), (glm::vec2{0.25f, 0.0f})); +} + +// --------------------------------------------------------------------------- +// ParallaxDeclare — the batch path: the golden scene (4 layers + a +// ground sprite), the hand-computed golden keys (both backends — the +// dyadic exactness zone), the group (draw) order, the layer-dominance +// orderings, and the cross-frame determinism +// --------------------------------------------------------------------------- + +template +void declareGolden() { + using Layers = laige::render::ParallaxLayers; + auto r = Layers::create({}); // 8 slots + ASSERT_TRUE(r.ok()); + auto layers = std::move(r).takeValue(); + // bg: factor 0.25, center (4, 4), offset (1, 2), size 2 x 2. + ASSERT_TRUE(layers.setLayer(bgDef(0)).ok()); + // mid: factor 0.5, offset (0.5, 0.5), size 1 x 1. + auto dMid = bgDef(1); + dMid.factor = 0.5f; + dMid.center = {0.0f, 0.0f}; + dMid.offset = {0.5f, 0.5f}; + dMid.size = {1.0f, 1.0f}; + dMid.atlasId = 1; + dMid.depthLayer = kParallaxDepthLayerMidground; + ASSERT_TRUE(layers.setLayer(dMid).ok()); + // fg: factor 1.0 (screen-fixed), offset (-1, 1), size 0.5 x 0.5. + auto dFg = bgDef(2); + dFg.factor = 1.0f; + dFg.center = {0.0f, 0.0f}; + dFg.offset = {-1.0f, 1.0f}; + dFg.size = {0.5f, 0.5f}; + dFg.atlasId = 2; + dFg.depthLayer = kParallaxDepthLayerForeground; + ASSERT_TRUE(layers.setLayer(dFg).ok()); + // custom-fg: factor 0 (fixed in world space), offset (-10, -10), + // depth layer +1 — v = -19, LOWER than the bg's v = 7, yet its + // layer (+1) must still sort AFTER the bg (layer dominates). + auto dCustom = bgDef(kParallaxLayerCustomBase); + dCustom.factor = 0.0f; + dCustom.center = {0.0f, 0.0f}; + dCustom.offset = {-10.0f, -10.0f}; + dCustom.size = {1.0f, 1.0f}; + dCustom.atlasId = 3; + dCustom.depthLayer = kParallaxDepthLayerForeground; + ASSERT_TRUE(layers.setLayer(dCustom).ok()); + + const glm::vec2 p{10.0f, 6.0f}; // the camera's ground position + SpriteBatcher::Options bo; + bo.maxSprites = 16; + auto rb = SpriteBatcher::create(bo); + ASSERT_TRUE(rb.ok()); + auto batcher = std::move(rb).takeValue(); + + auto declareFrame = [&]() { + batcher.beginFrame(); + ASSERT_TRUE(layers.declareTo(batcher, p).ok()); + // The ground sprite (the world content, layer 0, atlas 4 — the + // scene's atlas-id convention: the parallax layers' atlas ids + // ordered bg < ground < fg, "The render order"). + SpriteItem ground{}; + ground.pos = {0.5f, 0.5f}; + ground.scale = {1.0f, 1.0f}; + ground.uv = {0.0f, 0.0f, 1.0f, 1.0f}; + ground.atlasId = 4; + ground.materialId = 0; + ground.blend = BlendMode::Alpha; + ground.depthOverride = false; + ground.depthKey = oracleKey(0.5f, 0.5f, 0); + auto rAdd = batcher.add(ground); + ASSERT_TRUE(rAdd.ok()); + ASSERT_TRUE(batcher.build().ok()); + }; + declareFrame(); + ASSERT_EQ(batcher.frameCount(), 5u); // 4 layer quads + the ground + + // The group (draw) order: ascending (atlas, material, blend) — + // bg, mid, fg, custom-fg, ground (background FIRST). + ASSERT_EQ(batcher.batchCount(), 5u); + const auto batches = batcher.batches(); + const std::uint32_t wantAtlas[5] = {0, 1, 2, 3, 4}; + for (std::size_t g = 0; g < 5; ++g) { + EXPECT_EQ(batches[g].atlasId, wantAtlas[g]); + EXPECT_EQ(batches[g].instances.size(), 1u); + } + // One instance per group: pin the EXACT quad fields (all uvOffsets + // (0, 0) — one quad per layer, the full texture). + struct QuadGolden { + float cx, cy, sx, sy; + std::uint32_t key; // hand-computed (both backends — dyadic) + std::int32_t layer; + }; + const std::array goldens = {{ + // bg: offset 0.25 * (6, 2) + (1, 2) = (2.5, 2.5); center + // (3.5, 3.5), v = 7, d = 112; l = -2 -> + // (510 << 22) | (112 + 2^21) = 0x7F800000 | 0x200070 + {3.5f, 3.5f, 2.0f, 2.0f, 0x7FA00070u, kParallaxDepthLayerBackground}, + // mid: offset 0.5 * (10, 6) + (0.5, 0.5) = (5.5, 3.5); center + // (6, 4), v = 10, d = 160; l = -1 -> + // (511 << 22) | (160 + 2^21) + {6.0f, 4.0f, 1.0f, 1.0f, 0x7FE000A0u, kParallaxDepthLayerMidground}, + // fg: offset (10, 6) + (-1, 1) = (9, 7); center (9.25, 7.25), + // v = 16.5, d = 264; l = +1 -> + // (513 << 22) | (264 + 2^21) + {9.25f, 7.25f, 0.5f, 0.5f, 0x80600108u, kParallaxDepthLayerForeground}, + // custom-fg: offset (-10, -10); center (-9.5, -9.5), v = -19, + // d = -304; l = +1 -> + // (513 << 22) | (-304 + 2^21) + {-9.5f, -9.5f, 1.0f, 1.0f, 0x805FFED0u, kParallaxDepthLayerForeground}, + // ground: center (0.5, 0.5), v = 1, d = 16; l = 0 -> + // (512 << 22) | (16 + 2^21) + {0.5f, 0.5f, 1.0f, 1.0f, 0x80200010u, 0}, + }}; + std::uint32_t keys[5] = {0, 0, 0, 0, 0}; + for (std::size_t g = 0; g < 5; ++g) { + const SpriteItem& item = batcher.at(batches[g].instances[0]); + const QuadGolden& q = goldens[g]; + EXPECT_EQ(item.pos, (glm::vec2{q.cx, q.cy})); + EXPECT_EQ(item.scale, (glm::vec2{q.sx, q.sy})); + EXPECT_EQ(item.rotation, 0.0f); + expectUvRect("golden quad uv", item.uv, 0.0f, 0.0f, 1.0f, 1.0f); + EXPECT_EQ(item.atlasId, wantAtlas[g]); + EXPECT_FALSE(item.depthOverride); + // The golden key (hand-computed — the M2-ISO-01 formula) and the + // independent oracle (the M2-ISO-01 function on the same center): + EXPECT_EQ(item.depthKey, q.key); + EXPECT_EQ(item.depthKey, oracleKey(q.cx, q.cy, q.layer)); + // The key's layer field (the background-first contract): + EXPECT_EQ(laige::render::isoDepthKeyParts(item.depthKey).layer, q.layer); + keys[g] = item.depthKey; + } + // The LAYER DOMINANCE (the engine-guaranteed within-group order, + // independent of v — the bg's v = 7 is ABOVE the ground's v = 1 and + // the custom-fg's v = -19, yet its layer sorts them apart): + EXPECT_LT(keys[0], keys[4]); // bg < ground (l -2 < 0) + EXPECT_LT(keys[1], keys[4]); // mid < ground (l -1 < 0) + EXPECT_GT(keys[3], keys[4]); // custom> ground (l +1 > 0, v -19 < 1) + EXPECT_GT(keys[3], keys[0]); // custom> bg (l +1 > -2, v -19 < 7) + EXPECT_LT(keys[0], keys[1]); // bg < mid (l -2 < -1) + // Machine-greppable summary: + std::printf( + "parallax-golden: quads=5 keys bg=0x%08x mid=0x%08x fg=0x%08x " + "custom=0x%08x ground=0x%08x order=bg bit-identical declarations (every field of + // every quad). + const std::array first = {{ + batcher.at(batches[0].instances[0]), batcher.at(batches[1].instances[0]), + batcher.at(batches[2].instances[0]), batcher.at(batches[3].instances[0]), + batcher.at(batches[4].instances[0])}}; + declareFrame(); + ASSERT_EQ(batcher.frameCount(), 5u); + const auto second = batcher.batches(); + for (std::size_t g = 0; g < 5; ++g) { + const SpriteItem& a = first[g]; + const SpriteItem& b = batcher.at(second[g].instances[0]); + EXPECT_EQ(a.pos, b.pos); + EXPECT_EQ(a.scale, b.scale); + expectUvRect("determinism uv", b.uv, a.uv.u0, a.uv.v0, a.uv.u1, a.uv.v1); + EXPECT_EQ(a.depthKey, b.depthKey); + EXPECT_EQ(a.atlasId, b.atlasId); + } +} + +TEST(ParallaxDeclare, GoldenFpx16) { declareGolden(); } +TEST(ParallaxDeclare, GoldenFp32) { declareGolden(); } + +// --------------------------------------------------------------------------- +// ParallaxDeclare (ScrolledSplit) — the 2 x 2 wrap split: the exact +// world rects + the exact atlas UV rects at a scrolled offset (the +// wrap is exact at every texture boundary), the degenerate cases, +// and the atlas sub-rect mapping +// --------------------------------------------------------------------------- + +template +void scrolledSplit() { + using Layers = laige::render::ParallaxLayers; + auto r = Layers::create({}); // 8 slots + ASSERT_TRUE(r.ok()); + auto layers = std::move(r).takeValue(); + // id 0: factor 0, offset (0, 0) — fixed at the origin — size 2 x 2, + // the full texture. + auto d0 = bgDef(0); + d0.factor = 0.0f; + d0.offset = {0.0f, 0.0f}; + ASSERT_TRUE(layers.setLayer(d0).ok()); + // id 1: the atlas SUB-RECT layer (uv (0.25, 0, 0.75, 0.5)). + auto d1 = bgDef(1); + d1.factor = 0.0f; + d1.atlasId = 1; + d1.uv = {0.25f, 0.0f, 0.75f, 0.5f}; + ASSERT_TRUE(layers.setLayer(d1).ok()); + // id 2: the OFFSET layer (offset (3, -1), size 2 x 1). + auto d2 = bgDef(2); + d2.factor = 0.0f; + d2.offset = {3.0f, -1.0f}; + d2.size = {2.0f, 1.0f}; + d2.atlasId = 2; + ASSERT_TRUE(layers.setLayer(d2).ok()); + + SpriteBatcher::Options bo; + bo.maxSprites = 64; + auto rb = SpriteBatcher::create(bo); + ASSERT_TRUE(rb.ok()); + auto batcher = std::move(rb).takeValue(); + const glm::vec2 p{0.0f, 0.0f}; // the layers are factor 0: p is moot + + // The fully-scrolled layer: uvOffset (0.5, 0.25) -> the FULL 2 x 2 + // split (4 quads; the world areas sum to the rectangle's area and + // the uv rects tile the texture exactly). + ASSERT_TRUE(layers.setUvOffset(0, {0.5f, 0.25f}).ok()); + batcher.beginFrame(); + ASSERT_TRUE(layers.declareTo(batcher, p).ok()); + ASSERT_TRUE(batcher.build().ok()); + // 4 (scrolled layer 0) + 1 (layer 1, un-scrolled) + 1 (layer 2). + ASSERT_EQ(batcher.frameCount(), 6u); + // Layer 0's four quads (the wrap lines at x = 1, y = 1.5), pinned + // by the slot order (the frame's declaration order: q00, q10, q01, + // q11 — layers 1 and 2 are un-scrolled here, 4 and 1 quads): + struct SplitGolden { + float cx, cy, sx, sy; + SpriteUvRect uv; // the full-texture layer: base uv == atlas uv + }; + const std::array split = {{ + {0.5f, 0.75f, 1.0f, 1.5f, {0.5f, 0.25f, 1.0f, 1.0f}}, // q00 + {1.5f, 0.75f, 1.0f, 1.5f, {0.0f, 0.25f, 0.5f, 1.0f}}, // q10 + {0.5f, 1.75f, 1.0f, 0.5f, {0.5f, 0.0f, 1.0f, 0.25f}}, // q01 + {1.5f, 1.75f, 1.0f, 0.5f, {0.0f, 0.0f, 0.5f, 0.25f}}, // q11 + }}; + for (std::uint32_t q = 0; q < 4; ++q) { + const SpriteItem& item = batcher.at(q); + const SplitGolden& g = split[q]; + EXPECT_EQ(item.pos, (glm::vec2{g.cx, g.cy})); + EXPECT_EQ(item.scale, (glm::vec2{g.sx, g.sy})); + expectUvRect("split quad uv", item.uv, g.uv.u0, g.uv.v0, g.uv.u1, + g.uv.v1); + EXPECT_EQ(item.atlasId, 0u); + } + // The wrap is EXACT: the world areas sum to the rectangle's area: + float worldArea = 0.0f; + for (std::uint32_t q = 0; q < 4; ++q) { + const SpriteItem& item = batcher.at(q); + worldArea += item.scale.x * item.scale.y; + } + EXPECT_EQ(worldArea, 2.0f * 2.0f); + // The sorted order (group atlas 0): q00 (d = 20) first, then the + // EQUAL-key pair q10/q01 (d = 36 — the stable tie-break is the + // declaration order: q10 before q01), then q11 (d = 52): + const auto batches = batcher.batches(); + const SpriteBatch* group0 = nullptr; + for (const auto& b : batches) { + if (b.atlasId == 0) { + group0 = &b; + break; + } + } + ASSERT_NE(group0, nullptr); + ASSERT_EQ(group0->instances.size(), 4u); + EXPECT_EQ(batcher.at(group0->instances[0]).pos, (glm::vec2{0.5f, 0.75f})); // q00 + EXPECT_EQ(batcher.at(group0->instances[1]).pos, (glm::vec2{1.5f, 0.75f})); // q10 + EXPECT_EQ(batcher.at(group0->instances[2]).pos, (glm::vec2{0.5f, 1.75f})); // q01 + EXPECT_EQ(batcher.at(group0->instances[3]).pos, (glm::vec2{1.5f, 1.75f})); // q11 + EXPECT_EQ(batcher.at(group0->instances[0]).depthKey, + oracleKey(0.5f, 0.75f, kParallaxDepthLayerBackground)); + EXPECT_EQ(batcher.at(group0->instances[1]).depthKey, + oracleKey(1.5f, 0.75f, kParallaxDepthLayerBackground)); + EXPECT_EQ(batcher.at(group0->instances[2]).depthKey, + oracleKey(0.5f, 1.75f, kParallaxDepthLayerBackground)); + EXPECT_EQ(batcher.at(group0->instances[3]).depthKey, + oracleKey(1.5f, 1.75f, kParallaxDepthLayerBackground)); + + // The half-scrolled layer: uvOffset (0, 0.25) -> exactly 2 quads + // (the zero-width quads are skipped): + batcher.beginFrame(); + ASSERT_TRUE(layers.setUvOffset(0, {0.0f, 0.25f}).ok()); + ASSERT_TRUE(layers.declareTo(batcher, p).ok()); + ASSERT_TRUE(batcher.build().ok()); + ASSERT_EQ(batcher.frameCount(), 4u); // 2 + 1 + 1 + const SpriteItem& halfA = batcher.at(0); + const SpriteItem& halfB = batcher.at(1); + EXPECT_EQ(halfA.pos, (glm::vec2{1.0f, 0.75f})); // q00: [0,2) x [0,1.5) + EXPECT_EQ(halfA.scale, (glm::vec2{2.0f, 1.5f})); + expectUvRect("half-scrolled q00 uv", halfA.uv, 0.0f, 0.25f, 1.0f, 1.0f); + EXPECT_EQ(halfB.pos, (glm::vec2{1.0f, 1.75f})); // q01: [0,2) x [1.5,2) + EXPECT_EQ(halfB.scale, (glm::vec2{2.0f, 0.5f})); + expectUvRect("half-scrolled q01 uv", halfB.uv, 0.0f, 0.0f, 1.0f, 0.25f); + + // The un-scrolled layer: uvOffset (0, 0) -> exactly ONE quad (the + // full rectangle, the full texture): + batcher.beginFrame(); + ASSERT_TRUE(layers.setUvOffset(0, {0.0f, 0.0f}).ok()); + ASSERT_TRUE(layers.declareTo(batcher, p).ok()); + ASSERT_TRUE(batcher.build().ok()); + ASSERT_EQ(batcher.frameCount(), 3u); // 1 + 1 + 1 + const SpriteItem& full = batcher.at(0); + EXPECT_EQ(full.pos, (glm::vec2{1.0f, 1.0f})); + EXPECT_EQ(full.scale, (glm::vec2{2.0f, 2.0f})); + expectUvRect("un-scrolled uv", full.uv, 0.0f, 0.0f, 1.0f, 1.0f); + + // The atlas sub-rect mapping (layer 1, uvOffset (0.5, 0.25), the + // sub-rect (0.25, 0, 0.75, 0.5)): layer 1's four quads are slots + // 1..4 (declared after layer 0's single quad): + batcher.beginFrame(); + ASSERT_TRUE(layers.setUvOffset(1, {0.5f, 0.25f}).ok()); + ASSERT_TRUE(layers.declareTo(batcher, p).ok()); + ASSERT_TRUE(batcher.build().ok()); + ASSERT_EQ(batcher.frameCount(), 6u); // 1 + 4 + 1 (layer 0 un-scrolled) + const std::array subSplit = {{ + // q00: (0.25 + 0.5*0.5, 0 + 0.25*0.5, 0.75, 0.5) + {0.5f, 0.125f, 0.75f, 0.5f}, + // q10: (0.25, 0 + 0.25*0.5, 0.25 + 0.5*0.5, 0.5) + {0.25f, 0.125f, 0.5f, 0.5f}, + // q01: (0.5, 0, 0.75, 0 + 0.25*0.5) + {0.5f, 0.0f, 0.75f, 0.125f}, + // q11: (0.25, 0, 0.5, 0.125) + {0.25f, 0.0f, 0.5f, 0.125f}, + }}; + for (std::uint32_t q = 0; q < 4; ++q) { + expectUvRect("sub-rect split uv", batcher.at(1 + q).uv, subSplit[q].u0, + subSplit[q].v0, subSplit[q].u1, subSplit[q].v1); + EXPECT_EQ(batcher.at(1 + q).atlasId, 1u); + } + + // The offset layer (layer 2, uvOffset (0, 0)): one quad at the + // offset world rectangle: + const SpriteItem& off = batcher.at(5); + EXPECT_EQ(off.pos, (glm::vec2{4.0f, -0.5f})); // center of [3,5) x [-1,0) + EXPECT_EQ(off.scale, (glm::vec2{2.0f, 1.0f})); + expectUvRect("offset layer uv", off.uv, 0.0f, 0.0f, 1.0f, 1.0f); + EXPECT_EQ(off.depthKey, oracleKey(4.0f, -0.5f, + kParallaxDepthLayerBackground)); +} + +TEST(ParallaxDeclare, ScrolledSplitFpx16) { scrolledSplit(); } +TEST(ParallaxDeclare, ScrolledSplitFp32) { scrolledSplit(); } + +// --------------------------------------------------------------------------- +// ParallaxDeclare (Protocol) — the window/stopped/disabled/tilemap +// failure paths + the no-log behavior +// --------------------------------------------------------------------------- + +template +void declareProtocol() { + using Layers = laige::render::ParallaxLayers; + auto r = Layers::create({}); + ASSERT_TRUE(r.ok()); + auto layers = std::move(r).takeValue(); + // A disabled layer + a Tilemap-source layer (the M2-TILE-02 hook). + auto dOff = bgDef(1); + dOff.enabled = false; + ASSERT_TRUE(layers.setLayer(dOff).ok()); + auto dTile = bgDef(2); + dTile.source = ParallaxSource::Tilemap; + dTile.tilemapId = 9; + ASSERT_TRUE(layers.setLayer(dTile).ok()); + ASSERT_TRUE(layers.setLayer(bgDef(0)).ok()); + + SpriteBatcher::Options bo; + bo.maxSprites = 16; + auto rb = SpriteBatcher::create(bo); + ASSERT_TRUE(rb.ok()); + auto batcher = std::move(rb).takeValue(); + + // The disabled + tilemap layers declare NOTHING (no log): + auto sink = installCaptureSink(); + batcher.beginFrame(); + EXPECT_TRUE(layers.declareTo(batcher, {0.0f, 0.0f}).ok()); + EXPECT_EQ(batcher.frameCount(), 1u); // only layer 0 + EXPECT_EQ(sink->entries.size(), 0u) << "the skipped layers log nothing"; + // A built frame: the window is closed — nothing declared. + EXPECT_TRUE(batcher.build().ok()); + expectRejected(layers.declareTo(batcher, {0.0f, 0.0f}), + laige::ErrorCode::InvalidArgument); + EXPECT_EQ(batcher.frameCount(), 1u); + // The stopped batcher: the first add fails (BudgetExhausted), + // nothing declared. + SpriteBatcher stopped; // the default (stopped) batcher: capacity 0 + expectRejected(layers.declareTo(stopped, {0.0f, 0.0f}), + laige::ErrorCode::BudgetExhausted); + EXPECT_EQ(stopped.frameCount(), 0u); + restoreLogger(); + + // The stopped registry: InvalidArgument. + Layers stoppedLayers; + expectRejected(stoppedLayers.declareTo(batcher, {0.0f, 0.0f}), + laige::ErrorCode::InvalidArgument); +} + +TEST(ParallaxDeclare, ProtocolFpx16) { declareProtocol(); } +TEST(ParallaxDeclare, ProtocolFp32) { declareProtocol(); } + +// --------------------------------------------------------------------------- +// ParallaxZeroAlloc — the per-frame declare loop allocates nothing +// --------------------------------------------------------------------------- + +// The first offending site, resolved to its module + symbol when the +// platform provides dladdr (POSIX — the tilemap test's path): the +// actionable context for the failure. Named distinctly from the +// tilemap test's same-role global helper (one TU per test file, one +// shared executable). +#if !defined(_MSC_VER) +std::string describeParallaxAllocSite(const void* site) { + if (site == nullptr) { + return ""; + } + Dl_info info; + if (dladdr(const_cast(const_cast(site)), &info) != 0) { + std::string out; + out += (info.dli_fname != nullptr) ? info.dli_fname : ""; + out += " @ "; + out += (info.dli_sname != nullptr) ? info.dli_sname : ""; + out += " (addr "; + out += std::to_string(reinterpret_cast(site)); + out += ")"; + return out; + } + return "(site)) + ">"; +} +#else +std::string describeParallaxAllocSite(const void* site) { + return "(site)) + ">"; +} +#endif + +template +void declareLoopAllocatesNothing() { + using Layers = laige::render::ParallaxLayers; + auto r = Layers::create({}); // 8 slots + ASSERT_TRUE(r.ok()); + auto layers = std::move(r).takeValue(); + // One AUTO layer (the full 2 x 2 split per frame — 4 quads) + two + // manual layers (1 quad each) + two sprites: 8 items per frame. + auto d0 = bgDef(0); + d0.scrollMode = ParallaxScrollMode::Auto; + d0.scrollSpeed = {0.25f, 0.25f}; // crosses the wrap boundary often + ASSERT_TRUE(layers.setLayer(d0).ok()); + auto d1 = bgDef(1); + d1.atlasId = 1; + ASSERT_TRUE(layers.setLayer(d1).ok()); + auto d2 = bgDef(2); + d2.atlasId = 2; + ASSERT_TRUE(layers.setLayer(d2).ok()); + SpriteBatcher::Options bo; + bo.maxSprites = 64; + auto rb = SpriteBatcher::create(bo); + ASSERT_TRUE(rb.ok()); + auto batcher = std::move(rb).takeValue(); + auto runFrames = [&]() { + for (std::int32_t frame = 0; frame < 1000; ++frame) { + layers.advanceScrolls(); + batcher.beginFrame(); + ASSERT_TRUE(layers.declareTo(batcher, {1.0f, -1.0f}).ok()) + << "frame " << frame; + SpriteItem s{}; + s.pos = {0.5f, 0.5f}; + s.scale = {1.0f, 1.0f}; + s.uv = {0.0f, 0.0f, 1.0f, 1.0f}; + s.atlasId = 3; + s.depthKey = oracleKey(0.5f, 0.5f, 0); + ASSERT_TRUE(batcher.add(s).ok()) << "frame " << frame; + ASSERT_TRUE(batcher.build().ok()) << "frame " << frame; + } + }; + // Zero-allocation proof (where the watch is live — the non- + // sanitizer trees; the sanitizer runtimes own operator new): + // 1000 frames of the declare loop allocate nothing (the registry's + // slot table and the batcher's frame storage are pre-allocated — + // FR-2.2 "no per-frame allocation"). + // + // The window's owner is THIS thread: the watch counts owner-thread + // allocations only (the attribution contract, alloc_watch.h). + if (laige::allocWatchLive()) { + laige::allocWatchArm(); + runFrames(); + const laige::AllocWatchReading reading = laige::allocWatchRead(); + EXPECT_EQ(reading.allocs, 0u) + << "1000 frames of advanceScrolls/beginFrame/declareTo/build " + << "allocated " << reading.allocs + << " heap blocks on the loop thread (first site: " + << describeParallaxAllocSite(reading.firstSite) << ")"; + } +} + +TEST(ParallaxZeroAlloc, DeclareLoopAllocatesNothing) { + declareLoopAllocatesNothing(); + declareLoopAllocatesNothing(); +}