The rendering-side camera and projection matrix builders (M2-GL-03;
PRD §11, §4, §10.3; AGENTS ARCH-009/ARCH-006, CORE-005, PERF-003,
CPP-008; ADR 0008 for the GLM vendoring, ADR 0005 for the isometric
preset tables). Public header:
src/laige-render/include/laige/render/matrices.h; implementation:
src/laige-render/matrices.cpp. Unit suite: ctest -R matrices
(tests/laige-render/matrices_tests.cpp) — pure math, no GL
environment required: it runs in every local tree and in CI, and the
goldens are hand-computed from the documented formulas.
These are stateless pure functions: each returns a matrix that is a function only of its arguments. They create no state, read no state, make no GL calls, allocate nothing, and log nothing. The camera objects that arrive with M2-CAM-01/02 own the build cadence (build once per scene or camera change, in the set-up phase); these builders do not.
- World space: right-handed;
(x, y)is the ground plane,+zis height/up (theSimMath2.5D convention — the simulation is axis-aligned 2D plus a depth/height value, PRD §4). - NDC (OpenGL): x right, y up, z ∈ [−1, +1] with the near plane at z = −1 and the far plane at z = +1. A camera looks along its own −z axis (camera space: x right, y up, −z forward); the near plane sits at camera z = −zNear, the far plane at camera z = −zFar.
- Storage: column-major GLM
Mat4(m[c][r]is row r of column c). The affine translation lives in column 3. View matrices map world → camera space; projection matrices map camera → NDC.lookAtand the projection builders are meant to be composed (M = P * V);planeOrthoand the iso builders return the combined world → NDC affine matrix in one step (affine oblique projections have no meaningful view/projection split). - "Screen positions" are NDC values. The mapping from NDC to window pixels is M2-PROJ-01's job (the viewport transform); these builders stop at NDC.
The canonical home for the engine's coordinate conventions is
docs/concepts/coordinates.md (created with M2-ISO-01); the header
preamble carries the pinned copy.
| Builder | Returns | Precondition set (debug-asserted; release UB) |
|---|---|---|
ortho(left, right, bottom, top, zNear, zFar) |
camera → NDC | left < right, bottom < top, zNear < zFar, all finite |
perspective(fovY, aspect, zNear, zFar) |
camera → NDC | 0 < fovY < π, aspect > 0, 0 < zNear < zFar, all finite |
lookAt(eye, center, up) |
world → camera | eye != center, up not parallel to center − eye, all finite |
planeOrtho(center, right, up, halfWidth, halfHeight, zNear, zFar) |
world → NDC (combined P·V) | right/up orthonormal (unit + perpendicular, tolerance 1e-5), halfWidth > 0, halfHeight > 0, zNear < zFar, all finite |
isoMatrix(IsoAxes) |
world → NDC (combined affine) | all finite, ground 2×2 invertible (det ≠ 0) |
isoDimetric2To1(scale) |
world → NDC (2:1 dimetric, ADR 0005 default) | scale > 0, finite |
isoTrueIso3060(scale) |
world → NDC (true 30°/60°) | scale > 0, finite |
Every builder's exact element formulas are documented in the header
next to the declaration (CORE-005: no unexplained constants — the
1/√3 constants in the true-iso preset are the inverse of the
ground-axis foreshortening of a unit diagonal step, documented at the
site). A precondition violation is a programmer error (misconfigured
camera data), not a recoverable engine failure: debug builds assert,
release builds treat the input as undefined — the engine's
Result/Status convention for precondition violations (cf. SimMath:: clamp, laige/sim_math.h). The builders themselves return no errors
and log nothing (there is no failure to report: the inputs are the
camera configuration).
The top_down and side_view projection modes (M2-PROJ-01): an
orthographic camera looking straight at the plane spanned by right
and up through center. The plane normal is
n = normalize(cross(right, up)) (the toward-the-viewer side); the
camera sits at center + n*zNear and looks along −n; the plane
through center lands exactly on the NDC near plane (z = −1); the
visible depth slab is [zNear, zFar] measured from the plane toward
the viewer. M2-PROJ-01 instantiates it:
- top_down —
center = (ox, oy, 0),right = (1,0,0),up = (0,1,0); - side_view —
center = (ox, oy, oz),right = (1,0,0),up = (0,0,1)(screen y = world height).
All three presets are affine oblique projections with NDC z = 0 for every point — the 2.5D depth is engine-owned (the FR-2.2 depth keys, M2-ISO-01), never derived from the projection (PRD §4):
| Preset | dx (per +x step) |
dy (per +y step) |
zUnit (per +z step) |
Geometry |
|---|---|---|---|---|
| 2:1 dimetric (default) | (2k, −k) |
(−2k, −k) |
k |
Affine, 45° azimuth; per-step delta (±2, 1)·k — integer at k = 1; the tile is a 4k × 2k rhombus; the vertical squashed to ½ the tile height |
| true 30°/60° | (s, −s/√3) |
(−s, −s/√3) |
s/√3 |
True orthographic axonometric: 45° azimuth, elevation arcsin(1/√3) ≈ 35.264°; ground axes at 30° to horizontal; all three axes equally foreshortened; tile ratio √3:1 |
| custom shear | user IsoAxes |
user IsoAxes |
user | Any invertible oblique view of the ground plane (isoMatrix) |
Both ground axes project downward (negative NDC-y) and +z
projects upward, so the axis-aligned depth key f(x + y, height, layer) (FR-2.2; M2-ISO-01) sorts back-to-front for every preset, and
the O(1) picking inverse (FR-2.11; M2-ISO-03) exists whenever the
ground 2×2 is invertible — both properties are unit-tested. M2-CAM-02
selects the preset by config and builds through one mechanism: the
preset id maps to IsoAxes/scale, then isoMatrix (the preset
builders are thin named constructors over it — equality is tested).
- Ownership: none. The builders return by value; there is nothing to own, release, or invalidate.
- Threading / phase: pure functions — callable from any thread at any phase (sim tick, render set-up, main thread); no shared state, no locks, no GL context required. The M2-CAM-01/02 camera objects own the build cadence (set-up phase, once per scene/camera change); a per-sprite call is a misuse (see below).
- GLM exposure:
Vec2/Vec3/Mat4are GLM value types exposed through this public API by design (ADR 0008) — consumers get GLM transitively via the module's public include interface and never#include <glm/...>themselves (include-graph lint R3).
- Every builder is O(1) — a few dozen flops (
perspectiveadds onetan;lookAt/planeOrthoadd two normalizations and one cross;isoTrueIso3060adds onesqrt). - Zero allocation, zero locks, zero logging, zero GL calls. Nothing on any path scales with scene size.
- Set-up phase work: build the camera matrices once per scene or
camera change; store them; the per-frame render path only uses the
stored matrices (the M2-PROJ-01
world_to_screenmultiplies a stored matrix by each presentation position — that multiply is the hot path and is O(1) per point, no allocation). - Common trap: rebuilding
lookAt/planeOrthoper sprite per frame. They are configuration functions, not per-entity functions.
// Set-up phase (once per scene / camera change) — the M2-CAM-01/02
// camera objects own this cadence.
auto vp = laige::render::planeOrtho( // top_down mode
laige::render::Vec3{origin.x, origin.y, 0.0f},
laige::render::Vec3{1.0f, 0.0f, 0.0f},
laige::render::Vec3{0.0f, 1.0f, 0.0f},
/*halfWidth=*/8.0f * zoom, /*halfHeight=*/4.0f * zoom,
/*zNear=*/1.0f, /*zFar=*/21.0f);
// Render phase (per presentation position, O(1), no allocation):
const laige::render::Vec3 p{tile.x, tile.y, 0.0f};
const laige::render::Vec4 clip = vp * laige::render::Vec4{p, 1.0f};
// clip.xy is the NDC position (M2-PROJ-01 maps NDC -> pixels).- Do not call a builder per sprite per frame. They are set-up-phase configuration functions; rebuild only when the camera/scene changes.
- Do not use the iso matrices for depth. Their NDC z is 0 by design (PRD §4); depth ordering comes from the engine's FR-2.2 depth keys, not from the projection.
- Do not feed a degenerate iso camera (parallel ground axes,
det = 0): the picking inverse (M2-ISO-03) does not exist and the debug assert fires. - Do not mix conventions. The builders assume the pinned conventions above (right-handed world, +z up, OpenGL NDC, camera looks along −z). A left-handed or +z-forward input is a precondition violation, not a supported configuration.