The per-scene-chunk, precomputed, incrementally-updated mapping from the
tile grid to engine-owned isometric depth keys (M2-ISO-02; PRD §4,
FR-2.2 — "precomputed at scene build and incrementally updated on
tile/height changes (not recomputed per frame)", §8.1 — 10k dirty cells
≤ 0.3 ms (re-baselined 2026-10-04), AC-4.4 base, S-5, G-R11; AGENTS ARCH-008/009, RENDER-003,
CORE-002/005, PERF-002/003, SCALE-001/003; ADR 0002). Public header:
src/laige-render/include/laige/render/iso_depth_table.h (header-only —
the API is a template over the SimMath backends, the
presentation.h
pattern). Unit suite: ctest -R iso_depth_table
(tests/laige-render/iso_depth_table_tests.cpp) — pure data, no GL
environment required: it runs in every local tree and in CI, the
goldens are hand-computed from the documented formula, and the
IsoDepthTableBudget suite gates the PRD §8.1 iso_depthkey_rebuild
budget (the reference platform, non-instrumented trees — methodology
§4/§5).
The canonical narrative home is
docs/concepts/coordinates.md §4.5
(ARCH-008); the header preamble carries the machine-checked contract.
The per-sprite key this table precomputes is
iso_depth_key.md (M2-ISO-01).
| Member | Returns | Notes |
|---|---|---|
IsoDepthKeyTable<Backend>::create(options) |
Result<IsoDepthKeyTable> |
validates the options, pre-sizes the initial grid's chunks, flat-ground init (setup path) |
rebuild(heights) |
Status |
from-scratch: stores the whole height grid, recomputes every key through the full M2-ISO-01 function (setup path) |
setTile(gx, gy, height) |
Status |
the incremental update: stores the height, recomputes the affected cells only (the update path) |
ensureChunk(gx, gy) |
Status |
growth: extends the covered region to the chunk containing the tile (setup path; bounded + logged) |
keyAt(gx, gy) |
uint32_t |
the cell's current depth key (render read path; O(1)) |
tileHeightAt(gx, gy) |
int32_t |
the cell's last stored tile height (diagnostics view — DBG-008) |
covers(gx, gy) |
bool |
whether the cell lies in the covered region (O(1)) |
| introspection | int32_t / size_t |
layer(), chunkTiles(), originTileX/Y(), widthTiles(), heightTiles(), coveredTileMinX/Y(), coveredTileMaxX/Y(), chunkCount(), maxChunks(), coveredCellCount() |
Options (the create argument, validated — first failure wins,
InvalidArgument): originTileX/Y (default 0), widthTiles /
heightTiles (required ≥ 1), chunkTiles (default
kIsoDepthTableChunkTiles = 16; a power of two ≥ 1 — keeps cell
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 — the values are
documented in parallax.md).
One CellRecord per covered cell: { key, qBase, height }.
qBase is the backend-quantized ground contribution of the cell's
center (gx + 0.5, gy + 0.5) — a pure function of position, computed
once per chunk at creation through the same backend add + quantize the
M2-ISO-01 key uses; height is the tile's current step height; key
is the current depth key — bit-identical to
isoDepthKey<Backend>(center, height, layer) (the header preamble's
derivation; the tests pin it).
The covered region is the chunk-aligned superset of the requested
grid: it may extend up to chunkTiles − 1 tiles past a requested edge
(the partial edge chunks carry real cells, flat unless the scene sets
them). rebuild's span is the covered rectangle —
coveredCellCount() cells, row-major, tileX fastest.
Stores the height and recomputes the keys of the affected cells only —
the edited cell plus its documented neighborhood,
kIsoDepthTableUpdateRadius (radius 0 for the M2-ISO-01 formula:
a cell's key is a function of the cell's own (x, y, height, layer)
alone, and the table stores each cell's own height — so a height edit
changes exactly that tile's key). Each affected cell is recomputed from
its own stored height, so the neighborhood loop is correct for any
radius a future formula may need; widening the named constant changes
no API. Rejected edits (uncovered cell, out-of-domain height) leave the
table unchanged; the returned Status is the failure channel the
caller handles and logs (LOG-002: no duplicate engine log per call).
Stores the whole height grid and recomputes every key through the
full M2-ISO-01 function — independent of the stored qBase. Property:
rebuild(final grid) == any sequence of setTile` calls reaching the
same grid — the property test pins it (the roadmap's "rebuild-from-
scratch == incremental result").
Extends the covered region to include the chunk containing the tile
(the region stays a chunk-aligned rectangle). Bounded by maxChunks
(exceeding it: BudgetExhausted) and logged (one Debug event per
created chunk, one rate-limited Warn on the cap —
render/iso_depth_table_growth_cap). No-op success when the tile is
already covered; InvalidArgument when the chunk's extreme tile
centers would leave the key domain.
- Ownership: one owner — the sim/scene-owner thread (the
scene/tilemap). The table is move-only (CORE-009): created by
create(), handed to its owner; destroyed with the scene. It owns its chunk storages; nothing else references them. - Threading / phase: writes (
setTile,rebuild,ensureChunk) happen in the simulation phase (a terrain edit is a sim-side command — M2-TILE-01); reads (keyAt,covers,tileHeightAt) happen in the render phase (the tile batch path — M2-TILE-01/M2-SPRITE-01). The phases never overlap (the frame pipeline's tick → handoff → render ordering, M2-GL-02), so no synchronization is needed; the table is never shared with a concurrent writer. - Backend selection: the template parameter is the scene's
selected SimMath backend (ADR 0002) — the same dispatch pattern as
PresentationSnapshot<Backend>andisoDepthKey<Backend>.
create/ensureChunk/rebuild— setup paths. One flat allocation for the covered — chunk-aligned — region atcreate(a pre-sized row-majorCellRecordarray);ensureChunkgrowth re-allocates exactly once (setup path, bounded bymaxChunks, logged);rebuildis O(covered cells) backend work (the from-scratch contract). Never called per frame.setTile— O(1), zero allocation, no logging on success, no GL. The body is flat (no helper calls, no container calls) because the budget gate is measured on the canonical Debug tree (methodology §4), where a helper call is a real function call — notably the cell is read through a cached raw pointer, sinceunique_ptr::operator[]is a six-level call chain on that tree. Budget: 10k dirty cells ≤ 0.3 ms mean (PRD §8.1,iso_depthkey_rebuild— re-baselined from 0.2 ms on 2026-10-04 after the CI reference lane measured 0.194–0.267 ms on unchanged engine code: the 0.2 ms bar had zero margin on that toolchain — see baselines/m2-iso-depth-table-budget-rebaseline.md); measured 0.081 ms mean on the canonical Debug tree and 0.202204 ms on the CI reference lane (the worse of the two backends, recorded inbudgets.json; the original runs in baselines/m2-iso-depth-table.md and baselines/m2-iso-depth-table-workload-fix.md). The zero-allocation contract is asserted by the update suite where the allocation watch is live (the non-sanitizer trees).keyAt/covers/tileHeightAt— O(1) flat-index reads, no allocation, no logging.- Common traps:
- Rebuilding the table per frame to "refresh" keys — the FR-2.2
anti-pattern; the table IS the precomputation. Per-frame keys for
moving sprites come from
isoDepthKey(M2-ISO-01). keyAton an uncovered cell is undefined behavior (a program bug — the read path is check-free by contract, PERF-002/006, theisoDepthKeytotal-within-domain pattern). Checkcovers()when the tile coordinate comes from untrusted input; the batch path reads only covered tiles by construction (it culls against the covered region).- Pass the tile's own height to
setTile(its standing-surface elevation), not the sprite's top. rebuild's span is the covered rectangle, not the requested grid (the chunk-aligned superset —coveredCellCount()cells).
- Rebuilding the table per frame to "refresh" keys — the FR-2.2
anti-pattern; the table IS the precomputation. Per-frame keys for
moving sprites come from
- Deterministic per the backend's ADR 0002 scope: fpx16_16 bit-exact on every platform; fp32_pinned bit-exact per same build/ISA.
- Presentation-only (ARCH-009): the contents are a deterministic function of (options, edit sequence, backend) — presentation state, never authoritative sim state, replay state, or the sim state hash.
- Not replicated. The table is a per-client presentation concern (ARCH-003); replicated state carries only the tile heights (the sim- side data) it is computed from. Grid-locked cells agree across backends (dyadic centers in the exactness zone) — the tests pin it.
create: option validation, first failure wins (InvalidArgument) — grid extents,chunkTiles(power of two),maxChunks ≥ 1, layer domain, tile domain, grid-vs-cap.setTile/rebuild: boundary validation (InvalidArgument) — uncovered cell (setTile), out-of-domain height (setTile/rebuild), wrong span size (rebuild). A rejected call leaves the table unchanged.ensureChunk:BudgetExhaustedat the cap (logged);InvalidArgumentbeyond the tile domain.- There is no per-frame invalidation: the table is the precomputation.
Its contents change only through
setTile/rebuild/ensureChunk(sim phase).
// Scene load (sim thread, setup): the 128 x 128 tile grid, one 16 x 16
// chunk per 256 tiles, layer 0 — the fpx16_16 backend (the engine
// default, ADR 0002).
using Table = laige::render::IsoDepthKeyTable<laige::sim::Fpx16_16>;
Table::Options opts;
opts.widthTiles = 128;
opts.heightTiles = 128;
auto table = Table::create(opts);
if (!table.ok()) { /* log the InvalidArgument reason (LOG-002) */ }
// The from-scratch load of the height grid (the covered rectangle —
// the chunk-aligned superset — row-major, tileX fastest):
std::vector<std::int32_t> heights(table.value().coveredCellCount());
/* ...fill from the scene's tile heights... */
auto rebuilt = table.value().rebuild(heights);
if (!rebuilt.ok()) { /* log the InvalidArgument reason */ }
// Terrain edit (sim phase, M2-TILE-01): one tile height write — O(1),
// zero allocation:
auto edited = table.value().setTile(gx, gy, /*newHeight=*/h);
if (!edited.ok()) { /* log: uncovered cell or out-of-domain height */ }
// Render phase (the tile batch, M2-TILE-01/SPRITE-01): read the keys —
// only covered tiles (the batch culls against the covered region):
if (table.value().covers(gx, gy)) {
const std::uint32_t key = table.value().keyAt(gx, gy);
laige::render::SpriteItem item; // the declared tile sprite
item.depthKey = key; // (the tile grid's precomputed key)
batcher.add(item); // M2-SPRITE-01, in tile order
}
// Equal keys: the stable sort (M2-SORT-01) + the entity-id insertion
// order fix the order — isoDepthOrderLess (M2-ISO-01) is the
// comparison the sort uses.- Do not call
setTileper frame to "refresh" keys (FR-2.2): the table is updated only on tile/height changes; per-frame per-sprite keys for moving sprites come fromisoDepthKey(M2-ISO-01). - Do not use the table for moving objects. It maps the static tile
grid; a sprite's standing tile is a read (the sprite's current
grid cell's
keyAt), never a write. - Do not pass
rebuildthe requested grid. The span is the covered rectangle (coveredCellCount()cells) — the chunk-aligned superset (the preamble's cell model). - Do not share the table with a concurrent writer. One owner, sim-phase writes, render-phase reads (the frame pipeline's phase ordering, M2-GL-02) — no locks, no atomics, by contract.
concepts/coordinates.md§4.5 — the canonical narrative (ARCH-008).iso_depth_key.md— the per-sprite key the table precomputes (M2-ISO-01).budget_harness.md— theiso_depthkey_rebuildgate's harness.- baselines/m2-iso-depth-table.md — the recorded 10k-dirty-cell baseline.
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).