|
| 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). |
0 commit comments