AGENTS §6 ARCH-008: "Define and document the engine's world axes,
handedness, units, depth convention, render ordering, and conversion
rules." This document is the canonical home for those rules. It was
created with M2-ISO-01 (isometric depth keys); the earlier interim
homes were the Vec2/Vec3 comments in
laige/sim_math.h and
the matrices conventions in
laige/render/matrices.h
(both remain pinned in code and are restated here for reference).
- Axes:
(x, y)span the ground plane;+zis height/up. The 2.5D world of PRD §4: the simulation is axis-aligned 2D plus a depth/height value — physics, collision, pathing, and replicated state are all 2D (no oblique simulation, ever). - Handedness: right-handed —
x × y = +z. - Units: world units, dimensionless. One world unit is one tile of the tile map (M2-TILE-01 documents the tile map's grid in world units). Distances, heights, and camera parameters are all in world units.
- Coordinate domain:
|x|, |y| ≤ 32767world units — the Q16.16 fixed-point bound (fpx16_16.h: ±32767.996) so the domain is identical on both SimMath backends (ADR 0002). Scene content lives well inside this bound; the bound is a world-size guarantee, not a per-frame cost. - Simulation coordinates are the source of truth. Render code reads them (presentation snapshots, M1-LOOP-02) and never writes them (ARCH-009).
OpenGL conventions (pinned in
matrices.h):
NDC x right, NDC y up, NDC z in [-1, +1] with the near plane at
z = -1. View matrices map world → camera space; projection matrices
map camera → NDC; the isometric matrices and planeOrtho() return the
combined world → NDC affine matrix in one step. NDC is an
intermediate of the render path only — no game-facing API returns or
accepts NDC (RENDER-006: camera projection must not leak into game
logic).
All isometric presets are affine oblique projections of the form
(pinned in matrices.h; NDC y up):
NDC_x = dx.x*x + dy.x*y
NDC_y = dx.y*x + dy.y*y + zUnit*z (screen_z = 0)
with both ground axes projecting downward (negative NDC-y
contribution) and a positive (x + y) screen-y contribution:
| Preset | dx | dy | zUnit | A |
|---|---|---|---|---|
| 2:1 dimetric (default, ADR 0005) | (2k, −k) |
(−2k, −k) |
k |
k |
| True 30°/60° | (s, −s/√3) |
(−s, −s/√3) |
s/√3 |
s/√3 |
| Custom shear | user | user | user | must satisfy §5 |
A is the per-world-unit downward screen slope of the ground axes
(A = −dx.y = −dy.y = zUnit for a depth-key-supported shear). The
scene config selects the preset via the M2-CAM-02 IsoCamera preset
config (shipped — docs/api/iso_camera.md; the matrix builders are
M2-GL-03's isoDimetric2To1 / isoTrueIso3060 / isoMatrix).
Depth is presentation-only (PRD §4, ARCH-009). In isometric scenes the engine owns the z-order: game code never computes it (S-5, G-R11). The order is a deterministic function of world state only — never screen space, camera state, or zoom (PRD §4):
key = f(x, y, z, layer) (32-bit, uint32, unsigned order)
For an object at ground position (x, y) standing on a surface of
tile/step height z (world units; the standing surface's elevation —
not the object's own sprite height):
v(x, y, z) = (x + y) − z
Under any depth-key-supported shear, the object's base projects to
NDC_y = −A·v with A > 0. The painter's back-to-front order is
therefore ascending v: smaller v = higher on screen = further from
the camera = drawn first. A tall object is anchored by its base:
its sprite height never enters v (the standard isometric painter's
rule — a tree is sorted by its trunk, and the tile in front of it
correctly overdraws the trunk's lower part).
v is quantized in one rounding (monotone — the key order never
inverts the true v order) and packed with the layer into 32 bits
(constants in laige/render/iso_depth_key.h, CORE-005):
q = round((x + y) · 16) round = nearest, ties away from zero
d = q − z · 16 (quantized depth, 1/16 world units)
key = (layer + 512) << 22 | (d + 2^21)
bits 31..22 biased layer 10 bits −512..+511
bits 21..0 biased depth 22 bits −2^21..+2^21−1
- Exactness:
keyA < keyB(same layer) ⇒vA ≤ vBexactly (ties-away rounding is monotone).keyA == keyB⇒|vA − vB| ≤ 1/16world units — the documented precision of the key order (the two bases sit on the same screen row to within 1/16 unit; either order is visually degenerate). - Domain (documented; the function is total — no per-call asserts
on the hot path, saturated outside):
|x|, |y| ≤ 32767(kIsoDepthMaxWorldUnits, the Q16.16 coordinate bound),|z| ≤ 2047(kIsoDepthMaxStepHeight),|layer| ≤ 511(kIsoDepthLayerMax). Non-finite fp32 input saturates to the domain bound (NaN → lower bound); out-of-range values saturate at the packing boundary. - Exactness zone: the
(x + y)contribution is exact on both backends for|x + y| ≤ 32767(the fpx16_16 storage bound is the tighter constraint; the fp32 backend is exact to|x + y| ≤ 65534). Beyond it, each backend's documented saturation applies — the saturations are monotone, so the key order is never inverted anywhere, it only coarsens at the bound (extreme corner inputs collapse to the bound's key; the tie-break applies). No real scene approaches the bound (32767 world units ≈ 32 km at 1 m/tile). - Backends: the key is a pure function of the SimMath backend's state (ADR 0002 scope per backend; fpx16_16 exact on all platforms — it is integer arithmetic). Across backends keys are not promised equal (presentation, not replay state — ARCH-009); they agree for exactly representable inputs inside the exactness zone (e.g. the 1/16 grid lattice), which the tests pin.
The render order is the lexicographic tuple (key, entity id):
- key (this step, M2-ISO-01): layer ascending as the coarse primary order (background layers first — the parallax layer values are documented, M2-PAR-01, §4.10), then the quantized depth;
- 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
iteration order (FR-1.2).
isoDepthOrderLess(key, id)inlaige/render/iso_depth_key.his the comparison.
This realizes the FR-2.2 / roadmap tie tuple "(layer, depth, entity id)": layer and depth (step height) are packed into the key; the entity id is the final stable tie-break.
A shear (dx, dy, zUnit) (IsoAxes, matrices.h) is
depth-key-supported iff all components are finite, the ground map
is invertible (det ≠ 0), and
−dx.y == −dy.y == zUnit > 0 (exact float equality)
i.e. A = C (both ground axes project downward with the same slope and
the height unit equals that slope). Both built-in presets satisfy it
exactly; a custom shear must satisfy it to use isometric depth
sorting (isoShearSupported() is the checker; the M2-CAM-02
IsoCamera preset config validates scene shears —
docs/api/iso_camera.md). An invertible shear that violates it still
renders, but its depth-key order is not guaranteed.
§4.1's per-sprite key is the computation; the scene's static tile
grid has the precomputed form (FR-2.2: "precomputed at scene build
and incrementally updated on tile/height changes"):
IsoDepthKeyTable<Backend> (laige/render/iso_depth_table.h) holds
one depth key per tile of the scene's tile grid, organized in square
chunks (default 16×16 tiles — 256 cells each):
- Built at scene load (setup path): one flat, pre-sized storage
for the covered — chunk-aligned — region (one allocation at
creation; growth re-allocates once, per growth). Every cell holds
{key, qBase, height}—qBaseis the backend-quantized ground contribution of the cell's center (a pure function of position, computed once at creation/growth), andheightis the tile's current step height (0 = flat ground on a fresh table). - Incremental updates:
setTile(gx, gy, h)stores the new height and recomputes only the affected cells — the edited cell plus its documented neighborhood (kIsoDepthTableUpdateRadius; radius 0 for the §4.1 formula, which couples a cell's key to the cell's own (x, y, height, layer) alone). O(1), zero allocation, no GL calls. - Rebuild from scratch (
rebuild): the scene-load path; every key through the full §4.1 function, sorebuild(final grid) == any edit sequence reaching the same grid(the property the tests pin). - Bounded + logged growth (
ensureChunk): streamed regions extend the covered region chunk by chunk, up to a documented cap (BudgetExhaustedbeyond it). - Budget: 10k dirty cells ≤ 0.3 ms mean (PRD §8.1,
iso_depthkey_rebuild— re-baselined 2026-10-04) — baselines/m2-iso-depth-table-budget-rebaseline.md.
Tiles are grid-locked (M2-TILE-01), so the table's cells agree with
isoDepthKey at the same positions bit-for-bit — and across backends
(dyadic centers in the exactness zone). Moving sprites keep the
per-frame isoDepthKey (§4.1); the table serves the static tile grid
(the tilemap, M2-TILE-01) only.
§4.3's total render order is realized per frame by
laige::render::DepthSort (laige/render/depth_sort.h) — the stable,
deterministic, pre-allocated sorter for the frame's 32-bit keys:
- Pre-allocated, zero per-frame allocation:
create(capacity)at scene set-up owns one flat storage (16 B/slot);sort(keys)per frame is O(4n + 4·256) over pre-allocated buffers — no heap, no logging, no GL (PERF-003; FR-2.2 "no per-frame allocation"). - Stable: equal keys keep their INPUT order. The batcher (M2-SPRITE-01) inserts the frame's keys in the deterministic entity-id iteration order (FR-1.2), so the sorted output is the §4.3 (key, entity id) total order — without the sorter ever seeing the ids (the input position IS the entity order). RENDER-003's "tie-breaking explicit and stable" is this stability plus the insertion order.
- Algorithm: 4 × 8-bit LSD radix (stable bucket) passes — one stable 256-bucket counting sort per 8-bit digit, least significant digit first (Knuth, TAOCP Vol. 3 §7.2.1). Pure integer arithmetic: same key sequence → bit-identical sorted order on every platform and build (RENDER-003; presentation-only — ARCH-009/010 scope).
- Failure:
sort(n > capacity)→BudgetExhausted, the sorter unchanged (the previous frame's order is intact); the batcher handles and logs it (LOG-002). - Budget: 10k keys sorted, mean ≤ 1.0 ms (PRD §8.1,
depth_sort_10k) — baselines/m2-depth-sort.md.
The frame's sorted keys become the frame's draw groups by
laige::render::SpriteBatcher (laige/render/sprite_batcher.h) — the
engine-owned "declare, don't draw" window (S-5): the game DECLARES the
frame's sprites (beginFrame → add(item) × n → build()), the
engine batches:
- Declare, don't draw: the game declares
SpriteItems (world position, the M2-ISO-01 depth key, UV sub-rect, rotation, scale, tint, blend, atlas/material refs); there is no "draw this quad now" in the safe API (S-5). - Grouping (FR-2.1):
build()produces one group per DISTINCT (atlas, material, blend) combination — the submit stage (M2-SPRITE-02) makes ONE instanced draw call per group, so texture binds and blend changes are O(group count), not O(sprite count) (RENDER-001). - Order (RENDER-003): the group order is ascending (atlas, material, blend) (a function of the distinct group keys alone); each group's instances keep the frame's GLOBAL back-to-front order (§4.6's stable sort RESTRICTED to the group) — the (key, entity id) total order per group.
- Overflow (PERF-008, S-2): the frame budget is fixed at set-up
(
Options::maxSprites); a frame beyond it drops the OLDEST declaration + warns (never grows, never silent). - G-R11: a manually-set depth key (
depthOverride) is counted per frame + warned once per frame ("prefer tile height") — the escape hatch, not the default path. - Zero per-frame allocation (FR-2.2, PERF-003): the batch is pure integer bookkeeping over the pre-allocated storage (~132 B per capacity slot — 6.6 MB at the 50k stress budget).
The frame's groups become pixels by
laige::render::SpriteRenderer
(laige/render/sprite_renderer.h) — the frame pipeline's SUBMIT
stage: the engine draws the built frame with ONE GPU-instanced draw
call per group (FR-2.1, RENDER-001). The minimal GLSL 3.30 shader
computes, per instance:
- World position + scale:
world = pos + corner * scale— the unit quad's corner ([-0.5, 0.5]²) is scaled in WORLD units and translated to the world position (the scale is applied BEFORE the projection — theSpriteItem.scalecontract); - Projection:
worldToNdc * world(the frame's combined camera matrix — §5's conversion boundary; the 2D ground plane, z = 0); - Rotation IN SCREEN SPACE (NDC): the projected offset is rotated
by the per-instance rotation — the
SpriteItem.rotationcontract (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) — for animated sprites the rect isspriteFrameUv'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
the DEPTH TEST DISABLED for the pass — the 2.5D depth is engine-owned
(FR-2.2, §4), never derived from the projection. The per-group state
(one texture bind, one blend function) is set once; the state changes
and the draw submissions are observable (SpriteDrawStats /
SpriteDrawTotals — the M2-SPRITE-04 profiler feed).
The scene's static tile grid is
laige::render::TileMap<Backend>
(laige/render/tilemap.h) — the data + the auto-depth wiring of §4.5
- the batch path of §4.7 + the tile animation and parallax tile layer (M2-TILE-02):
- Chunked grid data (FR-2.6): the requested grid of tiles, per
tile a
textureId(the atlas reference) and ananimationId(0 = a STATIC tile; 1..maxAnimations = the animation slot — M2-TILE-02 cycles the frame from it), in one flat pre-sized array (8 B/tile). The tile's height lives in the ownedIsoDepthKeyTablealone (one source of truth): tile(gx, gy)is the world cell[gx, gx+1) × [gy, gy+1)(§5.1's grid atg = 1), so the tilemap is grid-locked by construction. - Auto-depth (FR-2.6): a tile's Y height is automatically
reflected in its depth key —
setTile/rebuildroute the heights into the table (§4.5), which recomputes exactly the affected cell's key (radius 0;rebuild(final grid) == any edit sequence reaching the same grid— the §4.5 property). The game never writes the key (G-R11). - The batch path (S-5):
declareTo(batcher, options)declares oneSpriteItemper tile — the tile's center(gx + 0.5, gy + 0.5)(the same point the table quantizes), scale (1, 1) (the unit quad spans the tile's cell), rotation 0, the tile's current frame UV (the static tile's fixed frame — default the full tile texture — or the animation's frame UV, M2-TILE-02) +frameIndex(the M2-SPRITE-03 hook), the table's key (auto-depth), the tile's texture asatlasId— in the grid's row-major order (the tile's grid position is a static tile's stable identity, the §4.6 insertion-order analog; RENDER-003). - Tile animation (M2-TILE-02): an ANIMATION is a slot of the
tilemap's pre-sized animation table — the frame COUNT, the
documented TICK RATE (
frameTicks— the sim ticks per frame), and the tile sheet's frame layout (the M2-SPRITE-03 layout). The scene owner callsadvanceAnimations()ONCE PER SIM TICK (ARCH-002 — per sim tick, never per render frame): every set animation's frame steps everyframeTicksticks, wrapping atframeCount(frame(ticks) = (ticks / frameTicks) mod frameCount). All tiles of one animation share its phase (per-tile offsets are the M3 editor's control). The frame UVs are precomputed atsetAnimation(the setup allocation); the per-frame path only reads them (no per-frame allocation, FR-2.2). The frame state is presentation state (ARCH-009). - Bounded draw calls (FR-2.1, RENDER-001): the batcher's (atlas, material, blend) grouping renders the 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); the count is a function of the distinct group keys, never of the tile count.
- The parallax tile layer (M2-TILE-02, the M2-PAR-01 hook): a
parallax layer with the
Tilemapsource declares the tilemap's quads UNDER the layer (declareTooverload): every quad is TRANSLATED by the layer'sworldOffset(cameraPos)(§4.10), and its key is the §4.1 key of the translated center at the TILEMAP's ownOptions::layer(the scene-setup convention: the layer's defdepthLayerequals it — bg/mid/fg tilemaps get -2/-1/+1). The translated keys are computed per tile per frame (the camera- dependent translation is not precomputable — O(tileCount), zero allocation).
The scene's parallax layers are
laige::render::ParallaxLayers<Backend>
(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) isworldOffset(p) = factor * (p - center) + offset— the EXACT formula (the tests pin it bit-exactly for dyadic values).factorin [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 atp = center. - The depth layer values (the §4.1 layer field, engine-owned —
G-R11):
kParallaxDepthLayerBackground= -2 (thebgpreset),kParallaxDepthLayerMidground= -1 (themidpreset),kIsoDepthGroundLayer= 0 (the ground),kParallaxDepthLayerForeground= +1 (thefgpreset); 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) onadvanceScrolls()— 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'sdepthLayer. - Tilemap-source layers (M2-TILE-02): a layer's
source = Tilemapnames a tilemap; the tilemap declares its tiles UNDER the layer (theTileMap::declareTooverload, §4.9) — the quads translated by this layer'sworldOffset, their keys at the tilemap's ownOptions::layer(the scene-setup convention: this def'sdepthLayermust equal it). This registry's owndeclareToskips Tilemap-source layers (no log — the game declares the tiles through the tilemap's path, not this one); itssize/uvfields are not validated.
The particle state (ParticleSystem<Backend>::Particle, the
particle simulation step) lives in SIM space:
each particle carries a 2D position (x, y) + a CONSTANT depth value
- a per-tick velocity + a life (sim ticks) + a size. The per-tick
advance (
age += 1,pos += vel— one SimMath vector add, ADR
- runs in the simulation tick (ARCH-002); the render path reads
the state read-only (
liveParticles()— the M2-GL-02 cull/batch stage). The particle's depth value is the §4.1 key input M2-PART-02 feeds the batcher (particles render as batched sprites — one draw call per emitter set, RENDER-001). The state is deterministic (ARCH-010): a pure function of (seed, emitter definitions, operation sequence) — fpx16_16 bit-identical across platforms/builds, fp32_pinned same-build/same-platform.
| Conversion | Direction | Owner | Status |
|---|---|---|---|
| World → depth key | sim state → uint32 key |
laige-render (isoDepthKey, M2-ISO-01) |
This document / shipped |
| Tile grid → depth key table | tile heights → precomputed per-tile keys | laige-render (IsoDepthKeyTable, M2-ISO-02) |
This document / shipped |
| 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) |
| Tile grid → tile quads | tile data + table keys (+ the animation's frame state; the parallax layer's worldOffset) → declared sprites |
laige-render (TileMap::declareTo — static + animated frames, the parallax tile layer overload, M2-TILE-01/02) |
Shipped (M2-TILE-01 + M2-TILE-02) |
| 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) |
| Particle state → sprite items | particle (pos, depth, size, faded tint) → declared sprites (one batch per emitter set, the key from the particle's depth) | laige-render (M2-PART-02 — consumes ParticleSystem<Backend>::liveParticles(), read-only after the sim phase) |
Planned (M2-PART-02) |
The safe screen → grid-cell transform (FR-2.11; PRD §4's
click-to-select / click-to-move): screenToGrid(screen, camera, grid)
in laige/render/iso_picking.h
— engine-owned (S-5, G-R11: game code never inverts the iso matrix
itself). The screen point is NDC (the projection.h convention —
the documented pixel ↔ NDC conversion is the input boundary,
RENDER-006); the grid plane is the ground plane (z = 0). The inverse
is the O(1) 2×2 solve on the frame matrix's ground rows (no per-pick
4×4 inverse):
det = a·d − b·c
w.x = (d·(ndc.x − tx) − b·(ndc.y − ty)) / det
w.y = (a·(ndc.y − ty) − c·(ndc.x − tx)) / det
- The grid cells are half-open (the documented boundary rule):
cell
(gx, gy)is[gx·g, (gx+1)·g) × [gy·g, (gy+1)·g)— i.e.gx = floor(w.x / g). A point exactly on a cell's lower or left boundary belongs to THAT cell; exactly on its upper or right boundary to the cell beyond it; a corner to the cell to its upper right. The grid is anchored at the world origin: the tile map's tile(gx, gy)(M2-TILE-01) is exactly this cell atg = 1, with center(gx + 0.5, gy + 0.5)— the grid the M2-CAM-02 grid-snap camera locks to. - Exact at all supported zoom levels: the inverse is a fixed
sequence of float ops — zoom enters only through the matrix's
entries — so the same stored screen point resolves to the same
cell at every zoom the camera supports (the continuous
[zoomMin, zoomMax]range without snap, the dyadic ladder with snap). - Precision (the boundary zone): the computed ground point
ŵis within16·2⁻²⁴·κ·(|e| + |w|)world units of the exact ground point (κ= the ∞-norm condition number of the ground 2×2: 4.5 for 2:1 dimetric, ~2.73 for 30/60 — constants iniso_picking.h). A point is guaranteed to resolve to its exact cell unless its exact ground coordinate lies within that bound of a cell boundary; inside the zone either of the two adjacent cells may be returned (the float rounding decides — deterministic per build). Domain-worst zone:kIsoPickDomainBoundaryEps(~4 world units at|e| = |w| = 32767,κ≤ 64). - Total function: non-finite screen input saturates at the
documented world domain (±32767; NaN → lower bound — the
isoDepthKey convention) and the result cell fits int32 for every
valid cell size (
g >= kIsoPickMinCellSize = 1e-4). A stopped camera picks with the identity matrix (degenerate but total). - Budget: one pick mean ≤ 0.01 ms (PRD §8.1,
iso_picking) — baselines/m2-iso-picking.md.
Rules:
- Sim never sees projection. No projection mode, camera, or screen
quantity appears in
laige-sim/laige-netpublic surface (AC-4.2; the include-graph lint landed with M2-PROJ-01 — rule R2 blocks everylaige-sim→laige-renderinclude, fixture-tested by theinclude-lint-sim-to-renderCTest entry; the companion surface check lands with M2-AC-01). - Render reads sim state, never writes it (ARCH-009): the depth key is computed from the presentation snapshot's interpolated position (M1-LOOP-02) — a read-only boundary.
- The key is world-space; the screen is a projection of the world. Deriving ordering from screen coordinates is the misuse this design exists to prevent (PRD §4; G-R11).
For one isometric frame, objects render in ascending
(key, entity id) order (back to front), batched by the sprite
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, §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).
api/iso_depth_key.md— the depth-key API contract (M2-ISO-01).api/projection.md— the projection modes and screen↔world transforms contract (M2-PROJ-01).api/iso_picking.md— the isometric grid picking contract:screenToGrid(screen → ground → grid cell) (M2-ISO-03).api/depth_sort.md— the deterministic stable depth sort contract:DepthSort(keys → sorted order) (M2-SORT-01).api/sprite_batcher.md— the declare, don't draw batcher contract:SpriteBatcher(declared sprites → (atlas, material, blend) groups) (M2-SPRITE-01).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— the atlas UV frame animation hook:SpriteFrameLayout+spriteFrameUv(frame index + sheet layout → the item's UV sub-rect) (M2-SPRITE-03).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— 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/particles.md— the CPU particle simulation contract:ParticleSystem(the bounded pool, the burst + continuous emitters, the per-tick advance, the exact integer fade, the determinism contract) (M2-PART-01).api/matrices.md— the matrix builders and NDC conventions (M2-GL-03).decisions/0005-iso-default.md— the 2:1 dimetric default (ADR 0005).api/presentation.md— the interpolated world positions the key is computed from (M1-LOOP-02).- PRD §4 ("What 2.5D means"), §10.1 (module map), §10.3 (determinism), FR-2.2, FR-2.5, FR-2.11, AC-4.2/4.4.