Skip to content

Commit 74f3fe5

Browse files
authored
[M2-ISO-01] Isometric depth key computation (#61)
* [M2-ISO-01] Isometric depth key computation Deterministic 32-bit sortable depth key from axis-aligned world state (key = f(x, y, tile/step height, layer)) — engine-owned, computed from sim coordinates, never from screen space (PRD §4, ARCH-006/008, RENDER-003, FR-2.2/AC-4.4/S-5). M2-ISO-01 scope, nothing else; implemented independently of the unimplemented M2-CAM-01/M2-PROJ-01 (user-confirmed scope) and validated against the landed M2-GL-03 iso matrices. API (header-only, additive, laige::render): - isoDepthKey(pos, stepHeight, layer) — templated over the SimMath backends (Fp32Pinned / Fpx16_16); - IsoDepthKeyParts + isoDepthKeyParts (lossless unpack); - isoDepthOrderLess(keyA, idA, keyB, idB) — the (key, entity id) total order: back-to-front, ties broken by entity id; - isoShearSupported(axes) — exact-float predicate for the shear class the key is valid for; named constants (CORE-005). Formula: with v = (x + y) - z (NDC_y = -A*v for every supported shear, A = -dx.y = -dy.y = zUnit > 0, det != 0): d = round(16v) — exactly round(16(x+y)) - 16z (16z integral => rounding commutes with the shift: same layer => keyA < keyB => vA <= vB; equal keys => |dv| <= 1/16); key = (layer + 512) << 22 | (d + 2^21): 22-bit biased fine-depth field [-2^21, 2^21-1] + 10-bit biased layer field [-512, 511]; the layer dominates the order. Domain: per-coord |x|,|y| <= 32767, exactness zone |x+y| <= 32767 on both backends, monotone-saturating beyond (order never inverted); layer clamps to ±511; step heights beyond ±2047 stay defined. Presentation-only (ARCH-009): pure function of sim state — no clock, no render state, no allocation. Tests: tests/laige-render/iso_depth_key_tests.cpp (15 tests / 5 suites; CTest entry iso_depth_key = the step's Verify command): hand-computed golden keys (7 sprites, both backends, cross-backend agreement, per-layer back-to-front under three shears, unpack), the 10k random scene's back-to-front property against painter's order NDC_y = -A*v (2:1 dimetric, true 30/60, custom A = 1/2) + exact monotonicity + matrix match + six hand-checked overlapping pairs, the (key, entity id) total-order properties + deterministic recompute, the domain/saturation contract (±inf/NaN, both backends), and the supported-shear checker (accepted + rejected shears). Docs (DOC-007, same change): NEW docs/concepts/coordinates.md (the ARCH-008 world-coordinate page incl. the key's formula/quantization/ bit layout/domain) + NEW docs/api/iso_depth_key.md (full API contract incl. the DOC-004 Performance section, determinism/replication scope, failure behavior, performant example, misuse warnings); updated docs/README.md, docs/concepts/README.md, src/laige-render/README.md. laige-api.json regenerated (938 symbols / 29 headers, +32); api-real-tree green. Local verification: all six canonical trees build warning-free and ctest green — 100/100 build (Debug g++ 16.2.1), 100/100 build-clang (clang++ 22.1.8), 100/100 build-shared, 89/89 build-release, 97/97 build-asan, 97/97 build-tsan; include-lint OK; determinism-lint OK. Two pre-existing defects surfaced by this step's six-tree validation, fixed here (neither caused by this change; both reproduced on pristine master): 1. build-release failed -Werror=unused-variable on matrices.cpp — the isoMatrix invertibility `det` was used only inside a debug assert, so Release (-DNDEBUG) flagged it (latent M2-GL-03 break). Fixed with [[maybe_unused]] (behavior-identical). 2. The local build-tsan tree segfaulted at startup in EVERY entry (incl. untouched matrices/gl_context/render_thread). Root cause: that tree's stale configure state baked tsan/atomic into the C implicit link libraries, so every executable additionally linked the shared gcc libtsan.so.2 on top of the static clang TSan runtime — two __tsan_init runs, and the _cxa_atexit interceptors recursed (~150k frames, SIGSEGV before main). This is the "pre-existing environmental toolchain bug" recorded against M2-GL-01/02, now fully root-caused; a fresh reconfigure of build-tsan removes the double runtime and the tree passes 97/97, making the TSan lane locally authoritative for this module. Roadmap: M2-ISO-01 box checked; Progress Board M2 6/33 (total 53/194 — the board also counts M2-GL-03, which landed without a board/Change-Log update); one Change Log line. * [M2-ISO-01] Raise the TSan lane's job timeout: 10 min -> 30 min The first PR run's Linux x64 TSan (clang++) lane was cancelled at its 10-min job cap. Evidence from the run logs: the job needed ~11 min — setup + build took 3.7 min on that runner (vs ~1.7 min in the 8.8-min master merge run of PR #60), and the 97-entry TSan test step takes ~7.2 min (laige-sim_tests alone 177 s; unchanged by this step's new iso_depth_key entry, which adds ~2 s). The suite's growth (this step's 10k-scene property suite on top of the M2-GL-02 3000-frame integration run) plus runner variance pushed the lane past a cap it had already nearly hit (8.8 min pre-change). The linux-tsan job's timeout-minutes is now 30 in both ci-pull.yml and ci.yml, with the evidence recorded at the site. No gate weakened: the lane still runs the full 97-entry suite under TSan with halt_on_error=1; only the ceiling changed.
1 parent 6467bed commit 74f3fe5

14 files changed

Lines changed: 1751 additions & 24 deletions

File tree

‎.github/workflows/ci-pull.yml‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,19 @@ jobs:
191191
!contains(github.event.pull_request.labels.*.name, 'ci:macos') &&
192192
!contains(github.event.pull_request.labels.*.name, 'ci:windows')
193193
runs-on: ubuntu-24.04
194-
timeout-minutes: 10
194+
# 30 min (not 10): TSan is the slowest lane by design (~5-15x
195+
# instrumentation overhead), and the suite has grown past the old
196+
# margin — the M2-ISO-01 10k-scene property suite (iso_depth_key
197+
# entry) plus the M2-GL-02 3000-frame integration run. Evidence:
198+
# the master merge run of PR #60 took 8.8 min (96 entries: setup +
199+
# build ~1.7 min, the 96-entry TSan test step ~7.1 min); the
200+
# M2-ISO-01 PR run needed ~11 min (setup + build 3.7 min on a
201+
# slower runner, the 97-entry test step ~7.2 min — laige-sim_tests
202+
# alone 177 s) and was CANCELLED at the 10-min cap ~50 s before
203+
# completion. 30 min keeps a large margin for runner variance and
204+
# the suite's continued growth without weakening the gate
205+
# (M2-ISO-01, 2026-09-30).
206+
timeout-minutes: 30
195207
# Job-scoped token: the archive step below needs actions:write (see
196208
# the linux-asan job comment).
197209
permissions:

‎.github/workflows/ci.yml‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,19 @@ jobs:
241241
linux-tsan:
242242
name: Linux x64 TSan (clang++)
243243
runs-on: ubuntu-24.04
244-
timeout-minutes: 10
244+
# 30 min (not 10): TSan is the slowest lane by design (~5-15x
245+
# instrumentation overhead), and the suite has grown past the old
246+
# margin — the M2-ISO-01 10k-scene property suite (iso_depth_key
247+
# entry) plus the M2-GL-02 3000-frame integration run. Evidence:
248+
# the master merge run of PR #60 took 8.8 min (96 entries: setup +
249+
# build ~1.7 min, the 96-entry TSan test step ~7.1 min); the
250+
# M2-ISO-01 PR run needed ~11 min (setup + build 3.7 min on a
251+
# slower runner, the 97-entry test step ~7.2 min — laige-sim_tests
252+
# alone 177 s) and was CANCELLED at the 10-min cap ~50 s before
253+
# completion. 30 min keeps a large margin for runner variance and
254+
# the suite's continued growth without weakening the gate
255+
# (M2-ISO-01, 2026-09-30).
256+
timeout-minutes: 30
245257
# Job-scoped token: the archive step below needs actions:write (see
246258
# the linux-asan job comment).
247259
permissions:

‎docs/README.md‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,13 @@ still to land.
6060
(ARCH-008), lifecycle, threading, and determinism scope.
6161
[Determinism](concepts/determinism.md) is written (M1-DET-01: the
6262
same-build scope, the two-layer G-R8 enforcement, the exception
63-
policy, the PRNG substreams); the other topics name their planned
64-
document and interim home.
63+
policy, the PRNG substreams);
64+
[coordinates](concepts/coordinates.md) is written (M2-ISO-01: the
65+
world axes/handedness/units, the NDC conventions, the isometric
66+
projection family (ADR 0005), the depth key formula and 32-bit
67+
layout, the (key, entity id) total render order, the
68+
supported-iso-shear contract, and the sim↔render conversion rules);
69+
the other topics name their planned document and interim home.
6570

6671
## API contracts (per public header)
6772

@@ -197,6 +202,16 @@ still to land.
197202
presets): the pinned conventions (right-handed world, +z up, OpenGL
198203
NDC), the element formulas, and the Performance contract (M2-GL-03;
199204
ADR 0008; `laige-render`).
205+
- [Isometric depth keys](api/iso_depth_key.md) — the engine-owned
206+
32-bit sortable depth key for isometric render ordering:
207+
`isoDepthKey<Backend>(pos, stepHeight, layer)` (world-space by
208+
contract — PRD §4), `isoDepthKeyParts` (the exact inverse),
209+
`isoDepthOrderLess` (the explicit stable `(key, entity id)` total
210+
render order — RENDER-003), and `isoShearSupported` (the
211+
back-to-front shear contract; both built-in presets pass): the
212+
formula, bit layout, domain and saturation contract, the
213+
Performance contract, and the determinism/replication scope
214+
(M2-ISO-01; `laige-render`).
200215

201216
## Guides
202217

@@ -253,10 +268,11 @@ still to land.
253268

254269
## Not yet written (honest status)
255270

256-
- `concepts/` — the architecture, coordinates, lifecycle, and
257-
threading concept documents (the [index](concepts/README.md) names
258-
each and its interim home). [Determinism](concepts/determinism.md)
259-
is written (M1-DET-01).
271+
- `concepts/` — the architecture, lifecycle, and threading concept
272+
documents (the [index](concepts/README.md) names each and its
273+
interim home). [Determinism](concepts/determinism.md) is written
274+
(M1-DET-01) and [coordinates](concepts/coordinates.md) is written
275+
(M2-ISO-01).
260276
- `guides/` — task-oriented usage (first game, profiling, determinism)
261277
— see the [index](guides/README.md).
262278
- `debugging/` — the in-engine debug mode (AGENTS §15; profiling

‎docs/api/iso_depth_key.md‎

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
# Isometric depth keys (`laige::render` iso depth key)
2+
3+
The engine-owned 32-bit sortable depth key for isometric render
4+
ordering (M2-ISO-01; PRD §4, FR-2.2, FR-2.11 base, AC-4.4, S-5, G-R11;
5+
AGENTS ARCH-009, RENDER-003, CORE-005, PERF-002/003/006; ADR 0005).
6+
Public header:
7+
`src/laige-render/include/laige/render/iso_depth_key.h` (header-only —
8+
the API is a template over the SimMath backends, the
9+
[`presentation.h`](../src/laige-sim/include/laige/sim/presentation.h)
10+
pattern). Unit suite: `ctest -R iso_depth_key`
11+
(`tests/laige-render/iso_depth_key_tests.cpp`) — pure math, no GL
12+
environment required: it runs in every local tree and in CI, and the
13+
goldens are hand-computed from the documented formula.
14+
15+
The canonical narrative home for the coordinate/depth conventions is
16+
[`docs/concepts/coordinates.md`](../concepts/coordinates.md) (created
17+
with this step, ARCH-008); the header preamble carries the
18+
machine-checked contract.
19+
20+
## The API
21+
22+
| Function | Returns | Notes |
23+
|---|---|---|
24+
| `isoDepthKey<Backend>(pos, stepHeight, layer)` | `uint32_t` key | the 32-bit depth key (below) |
25+
| `isoDepthKeyParts(key)` | `IsoDepthKeyParts {layer, quantizedDepth}` | exact inverse of `isoDepthKey` |
26+
| `isoDepthOrderLess(keyA, idA, keyB, idB)` | `bool` | the (key, entity id) total order (RENDER-003) |
27+
| `isoShearSupported(axes)` | `bool` | the depth-key-supported shear check |
28+
29+
All are stateless pure functions: no state created, no state read, no
30+
GL calls, no allocation, no logging.
31+
32+
### `isoDepthKey<Backend>`
33+
34+
```cpp
35+
template <typename Backend>
36+
uint32_t isoDepthKey(sim::SimMath<Backend>::Vec2 pos,
37+
std::int32_t stepHeight,
38+
std::int32_t layer) noexcept;
39+
```
40+
41+
- **`pos`** — the object's world ground-plane position `(x, y)` in
42+
world units, the SimMath backend's `Vec2`. The intended source is the
43+
presentation snapshot's interpolated position
44+
(`PresentationSnapshot::sample_position`, M1-LOOP-02) —
45+
presentation-only (ARCH-009).
46+
- **`stepHeight`** — the tile/step height the object **stands on**:
47+
the elevation of its standing surface, an integer number of world
48+
units (the tile map's per-tile height, M2-TILE-01). *Not* the
49+
object's own sprite height.
50+
- **`layer`** — the render layer (`kIsoDepthGroundLayer` = 0 default;
51+
the parallax layer values land with M2-PAR-01 — background layers
52+
negative/sorted first, foreground positive/sorted last).
53+
54+
**The formula** (world space only — never screen space, PRD §4):
55+
56+
```
57+
v = (x + y) − stepHeight the painter's-order value
58+
q = round((x + y) · 16) one rounding: nearest, ties away
59+
d = q − stepHeight · 16 from zero, in 1/16-world units
60+
key = (layer + 512) << 22 | (d + 2^21) bits 31..22 layer, 21..0 depth
61+
```
62+
63+
Under every depth-key-supported shear the object's base projects to
64+
`NDC_y = −A·v` (`A > 0`), so **unsigned key order = back-to-front
65+
painter's order**: `keyA < keyB` ⇒ `vA ≤ vB` exactly (the monotone
66+
rounding never inverts), and equal keys mean `|vA − vB| ≤ 1/16` world
67+
units (same screen row to within the documented precision). Layer
68+
ascending is the coarse primary order (background first).
69+
70+
**Domain and saturation** (named constants in the header, CORE-005):
71+
`|x|, |y| ≤ kIsoDepthMaxWorldUnits` (32767), `|stepHeight| ≤
72+
kIsoDepthMaxStepHeight` (2047), `|layer| ≤ kIsoDepthLayerMax` (511).
73+
The function is **total** — it is a per-sprite hot-path call, so it
74+
carries no per-call asserts (PERF-006); instead: non-finite fp32 input
75+
saturates to the domain bound (NaN → lower bound, IEEE), and
76+
out-of-domain finite input saturates at the packing boundary.
77+
In-domain the key is exact on both backends.
78+
79+
### The (key, entity id) total order (RENDER-003)
80+
81+
The render order is lexicographic `(key, entity id)`: M2-SORT-01's
82+
stable radix sort preserves insertion order for equal keys, and the
83+
batcher (M2-SPRITE-01) inserts in the engine's deterministic entity-id
84+
iteration order (FR-1.2) — so equal keys resolve by entity id.
85+
`isoDepthOrderLess` is that comparison (strict weak ordering). This
86+
realizes the FR-2.2 tie tuple "(layer, depth, entity id)": layer and
87+
depth (step height) are packed into the key; the entity id is the
88+
final stable tie-break.
89+
90+
### `isoShearSupported`
91+
92+
True iff the shear is finite, invertible (`det ≠ 0`), and
93+
`−dx.y == −dy.y == zUnit > 0` (exact float equality): both ground axes
94+
project downward with the same slope `A` and the height unit equals
95+
`A`. Both built-in presets pass; a custom shear must pass to use
96+
isometric depth sorting (M2-CAM-02 validates scene shears against it).
97+
98+
## Ownership, lifetime, threading
99+
100+
- **Ownership:** none. All functions return by value; nothing to own,
101+
release, or invalidate. `IsoDepthKeyParts` is a plain value.
102+
- **Threading / phase:** pure functions — callable from any thread at
103+
any phase (sim tick, render thread, main thread); no shared state,
104+
no locks, no GL context. The per-frame render path calls
105+
`isoDepthKey` once per sprite (M2-SPRITE-02's batch phase).
106+
- **Backend selection:** the template parameter is the scene's
107+
selected SimMath backend (ADR 0002, the engine/zone init selection) —
108+
the same dispatch pattern as `PresentationSnapshot<Backend>` and
109+
`Position2D<Backend>`.
110+
111+
## Performance (PERF-001/003, DOC-004)
112+
113+
- **O(1)** per key: one backend add + one rounding (integer for
114+
fpx16_16; one exact double product + `llround` for fp32_pinned) + a
115+
few integer ops. Nothing scales with scene size.
116+
- **Zero allocation, zero logging, zero GL calls, no per-call
117+
asserts** on any path (the saturation is a few comparisons — the
118+
total-function contract; disabled diagnostics are absent by
119+
construction, DBG-004).
120+
- The 10k-sprite per-frame cost is measured with the M2-PERF-01
121+
render suite (the key step itself is a trivial fraction of the
122+
§8.1 render CPU budget); the M2-ISO-02 table step adds the
123+
precompute/incremental path (≤ 0.2 ms for 10k dirty cells,
124+
`iso_depth_rebuild` budget).
125+
- **Common trap:** recomputing keys from screen-space coordinates, or
126+
calling `isoDepthKey` from a getter that also runs a scene
127+
traversal (API-003) — the key is the *result* of a world-state
128+
read, O(1) in itself.
129+
130+
## Determinism, replication, network authority
131+
132+
- Deterministic per the backend's ADR 0002 scope: fpx16_16 bit-exact on
133+
every platform (the key is pure integer arithmetic over Q16.16);
134+
fp32_pinned bit-exact per same build/ISA.
135+
- **Presentation-only (ARCH-009):** the key is never part of replay
136+
state or the sim state hash. It is derived from the presentation
137+
snapshot (itself a read-only copy of authoritative state).
138+
- **Not replicated.** Depth keys are a per-client presentation
139+
concern; servers compute nothing of this API (ARCH-003). Replicated
140+
state carries only the world state the key is computed from.
141+
142+
## Failure behavior / invalidation
143+
144+
- No errors: the function is total (saturation above). There is no
145+
state to invalidate — each call is an independent pure computation.
146+
- Out-of-domain input is a **misconfiguration** (scene content beyond
147+
the documented world size / step range), bounded by saturation,
148+
never UB (CPP-004, CORE-008).
149+
150+
## Performant example
151+
152+
```cpp
153+
// Render phase, per sprite (the M2-SPRITE-02 batch pass): O(1), no
154+
// allocation — the fpx16_16 backend (the engine default, ADR 0002).
155+
using M = laige::sim::SimMathFpx16;
156+
auto [posOk, pos] = snap.sample_position(entity); // M1-LOOP-02
157+
if (posOk.ok()) {
158+
const std::uint32_t key = laige::render::isoDepthKey<M>(
159+
pos, /*stepHeight=*/tileHeight, // the tile the entity stands on
160+
/*layer=*/laige::render::kIsoDepthGroundLayer);
161+
batcher.add(key, entity, /*sprite data...*/); // M2-SPRITE-01
162+
}
163+
// Equal keys: the stable sort (M2-SORT-01) + the entity-id insertion
164+
// order below fix the order — laige::render::isoDepthOrderLess is the
165+
// comparison the sort uses.
166+
```
167+
168+
## Misuse warnings
169+
170+
- **Do not derive the key from screen space or camera state** (PRD
171+
§4, FR-2.2): a screen-space z-order breaks under zoom, custom
172+
shears, and camera motion, and is exactly what this API exists to
173+
prevent (S-5, G-R11: engine-owned depth).
174+
- **Do not hand-roll per-sprite z-ordering in game code** (G-R11): the
175+
per-sprite depth override lands with M2-SPRITE-01 as a counted +
176+
warned escape hatch ("prefer tile height").
177+
- **Pass the standing-surface height, not the sprite's top.** A tree 3
178+
units tall standing on ground passes `stepHeight = 0`, not `3` —
179+
the key anchors the object's BASE (its standing surface).
180+
- **Do not use the key for non-iso scenes.** The formula is the
181+
isometric painter's order (FR-2.2); side_view / top_down modes get
182+
their own ordering with M2-PROJ-01 (depth = z / y respectively).
183+
184+
## Related
185+
186+
- [`concepts/coordinates.md`](../concepts/coordinates.md) — the
187+
canonical coordinate/depth/ordering conventions (ARCH-008).
188+
- [`matrices.md`](matrices.md) — the iso preset matrices the key's
189+
shear contract is pinned against (M2-GL-03).
190+
- [`presentation.md`](presentation.md) — the interpolated positions the
191+
key reads (M1-LOOP-02).
192+
- Roadmap: M2-ISO-01 (this), M2-ISO-02 (key table), M2-SORT-01 (stable
193+
radix sort), M2-SPRITE-01/02 (batcher), M2-PAR-01 (layer values).

‎docs/concepts/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ them; until then the interim homes apply:
77
| Topic | Document | Status |
88
|---|---|---|
99
| Determinism scope (ARCH-010, S-7, G-R8) | [determinism.md](determinism.md) | **Written** (M1-DET-01): the same-build guarantee, the two-layer G-R8 enforcement (the compile-time trait + the CI source scan), the exception policy, the PRNG substreams, the config surface |
10-
| World axes, handedness, units, depth convention, render ordering, conversion rules (ARCH-008) | `coordinates.md` | Not yet written — interim home: the `Vec2`/`Vec3` comments in `src/laige-core/include/laige/sim_math.h` ((x, y) is the ground plane, z is depth/height) and the SimMath API contract in [api/sim_math.md](../api/sim_math.md) |
10+
| World axes, handedness, units, depth convention, render ordering, conversion rules (ARCH-008) | [coordinates.md](coordinates.md) | **Written** (M2-ISO-01): the world axes/handedness/units (right-handed, (x, y) ground plane, +z up, world units), the NDC conventions, the isometric projection family (ADR 0005), the depth key formula and 32-bit layout (M2-ISO-01), the (key, entity id) total render order (RENDER-003), the supported-iso-shear contract, and the sim↔render conversion rules (RENDER-006) |
1111
| Engine / scene / entity lifecycle; the fixed-timestep rule (ARCH-002) | `lifecycle.md` | Not yet written (the M1 loop steps define it) |
1212
| Threading and ownership model (CONC-001…CONC-007) | `threading.md` | Not yet written — interim home: the per-API contracts in [api/](../api/) — each document states its threading, lifetime, and phase rules |
1313
| Module architecture (PRD §10.1 stack) | `architecture.md` | Not yet written — interim home: the PRD §10.1 module map, enforced by the include-graph lint (`tools/laige-include-lint`, M0-CI-03) |

0 commit comments

Comments
 (0)