Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,14 @@ still to land.
as the M2-SPRITE-04 profiler feed, the pre-allocated instance buffer
(zero per-frame allocation), and the offscreen 1 000-sprite render
verified against a CPU reference.
- [Sprite frames](api/sprite_frames.md) —
`laige::render::SpriteFrameLayout` + `spriteFrameUv`: the atlas UV
frame animation hook (M2-SPRITE-03; FR-2.1 "atlas UV animation (sheet
frames)"): the sheet frame layout (frame size, row/col, margins) and
the pure frame-index → UV sub-rect computation (out-of-range frame →
documented error, never wrap) the caller uses to fill
`SpriteItem.uv`/`frameIndex` — the data-driven hook M3 animation
drives.

## Guides

Expand Down
9 changes: 7 additions & 2 deletions docs/api/sprite_batcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ struct SpriteItem {
std::uint32_t depthKey{}; // the M2-ISO-01 key (isoDepthKey<Backend>)
bool depthOverride{}; // G-R11 escape hatch (counted + warned)
SpriteUvRect uv{}; // UV sub-rect in the atlas, [0,1]^2
std::uint32_t frameIndex{}; // animation frame (M2-SPRITE-03 hook)
float rotation{}; // radians
Vec2 scale{1, 1}; // world-unit scale (x, y)
SpriteTint tint{}; // multiplicative RGBA (1,1,1,1 = none)
Expand Down Expand Up @@ -76,7 +77,7 @@ The frame protocol (the frame pipeline's cull/batch stage, M2-GL-02):
- **`create(options)`** is the **set-up path** (scene load): one
allocation per storage structure (the sprite pool, the M2-SORT-01
sorter, the key scratch, the instance array, the group table, the
cursor array, the batch array — ~132 bytes per capacity slot, 6.6 MB
cursor array, the batch array — ~136 bytes per capacity slot, 6.8 MB
at the 50k stress budget). Fails with `InvalidArgument` (no
allocation) when `maxSprites` is 0 or exceeds
`kSpriteBatcherMaxCapacity` (0xFFFFFFFF — the slot width). Size the
Expand Down Expand Up @@ -170,7 +171,7 @@ depth overrides counted + warned).

## Performance (PERF-002/003/004, DOC-004)

- **Complexity:** `create` O(maxSprites) (8 allocations, ~132 B/slot);
- **Complexity:** `create` O(maxSprites) (8 allocations, ~136 B/slot);
`beginFrame` O(previous frame count) (the pool reset); `add`
**O(1)** (one pool create or one ring overwrite); `build`
**O(4n + 4·256)** (the M2-SORT-01 sort) **+ O(n·log G)** (two group
Expand Down Expand Up @@ -238,6 +239,10 @@ depth overrides counted + warned).

- [`api/sprite_renderer.md`](sprite_renderer.md) — the submit stage
that draws this batcher's built frame (M2-SPRITE-02).
- [`api/sprite_frames.md`](sprite_frames.md) — the atlas UV frame
animation hook: the sheet frame layout + the frame index → UV
sub-rect computation the caller uses to fill `SpriteItem.uv`
(M2-SPRITE-03).
- [`api/depth_sort.md`](depth_sort.md) — the M2-SORT-01 stable radix
sort this batcher consumes.
- [`api/iso_depth_key.md`](iso_depth_key.md) — the 32-bit depth key
Expand Down
215 changes: 215 additions & 0 deletions docs/api/sprite_frames.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
# Sprite frames (`laige::render::SpriteFrameLayout`, `laige::render::spriteFrameUv`)

The atlas UV frame animation hook (M2-SPRITE-03; FR-2.1: "atlas UV
animation (sheet frames)"): the atlas sheet frame LAYOUT (frame size,
row/col, margins — the documented sheet model below) and the pure
frame-index → UV sub-rect computation that turns a caller-set animation
frame index into the `SpriteItem.uv` the M2-SPRITE-02 shader consumes.
This is the data-driven half of the feature: for now the frame index is
set by the caller per frame (the game's declaration pipeline); M3
animation will own the frame-advance policy on top of this same layout
(the hook, below). Public header:
`src/laige-render/include/laige/render/sprite_frames.h` (header-only —
a pure function over a plain value; there is no implementation file).
Unit suite: `ctest -R sprite_frames`
(`tests/laige-render/sprite_frames_tests.cpp`) — pure float/integer
math, no GL environment required: it runs in every local tree and in
CI, with the hand-computed UV rects for documented layouts, the
documented failure contract (out-of-range frame → `InvalidArgument`,
never wrap), the sheet model against the documented formula + the
float-exact domain, and the `SpriteItem.frameIndex` hook through the
batcher.

## The API

```cpp
// The float-exact atlas domain: atlas dimensions in [1, 2^24].
inline constexpr std::uint32_t kSpriteFrameMaxAtlasTexels = 1u << 24;

// The atlas sheet's frame layout (texels — the sheet model, below).
struct SpriteFrameLayout {
std::uint32_t frameWidth{}; // texels per frame (>= 1)
std::uint32_t frameHeight{}; // texels per frame (>= 1)
std::uint32_t columns{}; // frames per row (>= 1)
std::uint32_t rows{}; // frame rows (>= 1)
std::uint32_t frameSpacing{}; // gap between adjacent frames (texels)
std::uint32_t sheetBorder{}; // margin at the sheet edge (texels)
};

// The UV sub-rect of `frameIndex` in the atlas, under `layout`.
// Out-of-range frameIndex -> InvalidArgument (never wraps).
[[nodiscard]] Result<SpriteUvRect> spriteFrameUv(
std::uint32_t frameIndex, const SpriteFrameLayout& layout,
std::uint32_t atlasWidth, std::uint32_t atlasHeight) noexcept;
```

`SpriteUvRect` is the batcher's per-sprite UV sub-rect
([`api/sprite_batcher.md`](sprite_batcher.md)): normalized `[0, 1]²`,
`u1 > u0`, `v1 > v0`.

## The sheet model (the documented layout)

A sprite sheet is a `rows × columns` grid of frames, **row-major, frame
0 at the top-left**:

```
frame i: col = i % columns, row = i / columns
```

Each frame is `frameWidth × frameHeight` texels. `frameSpacing` is the
gap **between** adjacent frames (texels); `sheetBorder` is the margin
between the sheet edge and the first/last frame on every side (texels).
Frame `(col, row)` occupies the texel rect

```
x: [border + col·(fw + spacing), border + col·(fw + spacing) + fw)
y: [border + row·(fh + spacing), border + row·(fh + spacing) + fh)
```

— the gap lives to the RIGHT of each frame and BELOW each row, and a
**tight** sheet is exactly

```
width = 2·border + columns·fw + (columns − 1)·spacing
height = 2·border + rows·fh + (rows − 1)·spacing
```

(a wider atlas is fine — the extra slack is just margin; the fit check
is the authoritative validity rule).

The UV rect is the frame's texel rect normalized into the atlas:
`u0 = x0/W`, `v0 = y0/H`, `u1 = x1/W`, `v1 = y1/H`. **V-axis
convention:** v = 0 is the FIRST texel row of the uploaded RGBA array
(the sheet's TOP row — the M2-SPRITE-02 `GL_NEAREST` "texel row =
floor(v·h)" contract), so frame row 0 carries the smallest v and a
frame's v rect is measured from the top, not the bottom.

## Failure (CORE-008, API-008) — first failure wins

The layout is CALLER-OWNED asset metadata (the game's import pipeline
for now, the asset system from M3 — untrusted input, SCALE-004), so
every field is validated at the use boundary. `spriteFrameUv` returns
`InvalidArgument` for:

| # | Condition | Why |
|---|---|---|
| 1 | `frameWidth`, `frameHeight`, `columns`, or `rows` is 0 | a zero extent is an invalid layout, not an "empty" one |
| 2 | `atlasWidth`/`atlasHeight` outside `[1, 2^24]` | the float-exact domain (below) |
| 3 | `frameIndex >= columns·rows` | the documented OUT-OF-RANGE contract: the engine never wraps silently |
| 4 | the frame's texel rect does not fit the atlas | the layout does not describe the sheet it claims to |

A failed call returns the error; it never produces a UV rect.

## Precision (the float-exact domain)

All texel coordinates are exact in float — and the invariant
`u1 > u0`, `v1 > v0` holds EXACTLY (two distinct `k/W` values never
round to the same float) — when the atlas dimensions are within
`[1, kSpriteFrameMaxAtlasTexels] = [1, 2^24]` (16 777 216 — far beyond
the practical driver maxTextureSize of 16K–32K): every pixel
coordinate is then `< 2^24`, exactly representable in float, and the UV
corners are the EXACTLY-rounded `k/W` values (one correctly-rounded
division per corner — no extra arithmetic, no `floor`/`ceil`, no
backend dependency). Outside the domain the call fails `InvalidArgument`
(the domain is part of the layout contract — a sheet that does not fit a
float-exact atlas is an invalid layout, not a degraded one).

The conversion is a PURE function: no allocation, no logging, no state,
no GL — a handful of integer checks + 4 float divisions, bit-identical
on every platform/build (render-side float — the ARCH-009/010
presentation-only scope; it is never in the sim state hash or the
replay state).

## The M3 hook (data-driven, ARCH-009)

For now the frame index is set by the caller: the game's declaration
pipeline holds each animated entity's current frame index (and its
atlas's `SpriteFrameLayout` + atlas size — asset-side data), and per
frame declares the sprite with

- `item.frameIndex = frame;` — the declared animation frame (the
batcher carries it through untouched — presentation state, never
sim state);
- `item.uv = spriteFrameUv(frame, layout, atlasW, atlasH);` — the
frame's UV sub-rect, **what the M2-SPRITE-02 shader draws**.

M3 animation will drive the frame-advance policy (timers, events,
state machines) on top of this same layout — no engine change: it
writes the same two fields per frame. The caller's invariant: `uv` is
this item's `frameIndex` under its atlas's layout (the engine does not
cross-check the pair — `uv` is authoritative for the draw).

## Ownership, lifetime, threading (CORE-009, CONC-001)

- **Owner:** none — `SpriteFrameLayout` is a plain value (no ownership,
nothing to release) and `spriteFrameUv` is a stateless free function.
- **Threading:** callable from any thread — no locks, no shared state.
The declaration pipeline calls it once per animated sprite per frame
(the render phase, after the tick→handoff — ARCH-002).
- **Lifetime:** the returned `SpriteUvRect` is a value; the caller owns
it (the `SpriteItem` field). The layout outlives the calls that use
it (the asset's metadata).

## Performance (PERF-002/003, DOC-004)

- **Complexity:** O(1) — a handful of integer checks + 4 float
divisions per call.
- **Allocation:** zero (PERF-003) — proven by the zero-allocation test
(1 000 consecutive conversions = 0 heap blocks, non-sanitizer
trees).
- **Blocking/IO/GPU:** none — no logging, no GL calls.
- **Budget:** no standalone `budgets.json` entry — the per-frame
conversion cost is part of the composite 50k render-CPU budget
(2 ms, PRD §8.1 `sprites_50k_cpu`), measured when M2-PERF-01 lands
with the submit stage.
- **Call site:** once per ANIMATED sprite per frame, in the declaration
pipeline — never per texel, never in the simulation tick (ARCH-002).
A 50k-sprite frame of fully-animated sprites is 50 000 × (4 float
divisions + a few integer checks) — well inside the composite budget
(the M2-PERF-01 measurement will say so with a number).

## Performant example

```cpp
// Per frame, per animated entity (the declaration pipeline — the
// frame pipeline's cull/batch stage, M2-GL-02):
SpriteItem item;
item.pos = entity.worldPos();
item.depthKey = laige::render::isoDepthKey<laige::fpx16_16>(
entity.worldPos(), entity.stepHeight(), entity.layer());
item.frameIndex = anim.frame(); // the M3 hook (for now: the caller)
item.uv = std::move(laige::render::spriteFrameUv(
anim.frame(), anim.layout(), atlasWidth, atlasHeight)).takeValue();
item.atlasId = atlasId;
batcher.add(item);
```

## Misuse warnings

- **Do not wrap the frame index by hand around the layout:** the engine
fails out-of-range indices (`InvalidArgument`, never wrap — CORE-008).
A wrapping game computes the index itself (`frame % frameCount`); a
non-wrapping animation clamps it.
- **Keep `uv` consistent with `frameIndex`:** the renderer draws `uv` —
the index is the declaration's frame record. If the two disagree,
the drawn frame is the `uv` one (the M3 invariant above).
- **The margins are texels, not world units or normalized UV:** the
layout describes the atlas's pixels; the world-scale is
`SpriteItem.scale`.
- **The atlas size must match the bound texture:** `atlasWidth`/
`atlasHeight` are the `bindAtlas(id, w, h, rgba)` dimensions
(M2-SPRITE-02) — a mismatch misaligns every frame's UV.
- **Do not use the result across frames:** the UV rect is a value copy
per declaration (presentation state) — recompute it each frame from
the frame's index.

## Related

- [`api/sprite_batcher.md`](sprite_batcher.md) — the declaration window
that carries `SpriteItem` (the `frameIndex` + `uv` pair).
- [`api/sprite_renderer.md`](sprite_renderer.md) — the submit stage
that draws the item's `uv` (M2-SPRITE-02).
- [`api/errors.md`](errors.md) — the `InvalidArgument` error code
(M0-CORE-02).
- [`concepts/coordinates.md` §4.8](../concepts/coordinates.md) — the
sprite-draw narrative (the UV sub-rect's place in the pipeline).
8 changes: 7 additions & 1 deletion docs/concepts/coordinates.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,9 @@ computes, per instance:
by the per-instance rotation — the `SpriteItem.rotation` contract
(rotation is a screen-space property, not a world property);
- **UV sub-rect**: the per-instance UV rect mapped onto the quad
(`(-0.5,-0.5) → u0/v0`, `(0.5,0.5) → u1/v1`);
(`(-0.5,-0.5) → u0/v0`, `(0.5,0.5) → u1/v1`) — for animated sprites
the rect is `spriteFrameUv`'s output for the caller-set frame index
under the atlas's sheet layout (M2-SPRITE-03);
- **Tint**: `texture(uAtlas, uv) * tint` (multiplicative RGBA).

The sprites are painted back-to-front (the batcher's §4.7 order) with
Expand All @@ -309,6 +311,7 @@ and the draw submissions are observable (`SpriteDrawStats` /
| Depth key → render order | key (+ entity id) → sorted order → groups | `laige-render` (`DepthSort`, M2-SORT-01 stable radix sort; `SpriteBatcher`, M2-SPRITE-01 batcher) | **Shipped (M2-SORT-01 + M2-SPRITE-01)** |
| Screen → world (per mode) | picking, screen↔world transforms | `laige-render` (`ProjectionView`: `worldToScreen`, `screenToWorldRay`, `screenToWorld`, M2-PROJ-01; `screenToGrid` iso grid picking, M2-ISO-03) | **Shipped (M2-PROJ-01 + M2-ISO-03)** |
| World → screen (render) | sim state → NDC → pixels | camera + preset matrix (M2-CAM-01/02, M2-GL-03), `ProjectionView::worldToScreen` (M2-PROJ-01), sprite draw (M2-SPRITE-02) | **Shipped** (matrices + camera core M2-CAM-01, iso presets + grid-snap M2-CAM-02, world→screen transform M2-PROJ-01, pixels: `SpriteRenderer::submit`'s offscreen instanced draw M2-SPRITE-02) |
| Atlas frame → UV sub-rect | animation frame index + sheet layout → UV rect | `laige-render` (`spriteFrameUv`, M2-SPRITE-03) | **Shipped (M2-SPRITE-03)** |

### 5.1 The isometric grid picking (M2-ISO-03)

Expand Down Expand Up @@ -406,6 +409,9 @@ state → same keys → same order, every frame (RENDER-003).
- [`api/sprite_renderer.md`](../api/sprite_renderer.md) — the instanced
draw contract: `SpriteRenderer` (groups → one instanced draw call
each, the frame's pixels) (M2-SPRITE-02).
- [`api/sprite_frames.md`](../api/sprite_frames.md) — the atlas UV
frame animation hook: `SpriteFrameLayout` + `spriteFrameUv`
(frame index + sheet layout → the item's UV sub-rect) (M2-SPRITE-03).
- [`api/matrices.md`](../api/matrices.md) — the matrix builders and NDC
conventions (M2-GL-03).
- [`decisions/0005-iso-default.md`](../decisions/0005-iso-default.md) —
Expand Down
Loading
Loading