Render a level: first drawable frame, and the origin bug it exposed

Adds gc-engine's binary and a level.rs that joins the format layer to the
renderer: LOD selection and morphing per patch, then triangles grouped by
texture so each texture is one draw rather than one per triangle.

The PS2 world is Z-up at 1024 raw units per world unit, so the conversion is
(x, y, z) -> (x, z, -y). The sign flip on the third axis turns the PS2's
right-handed frame into Bevy's left-handed one rather than negating a
determinant, which is what stops the level rendering mirrored.

Morphing and collapse are kept distinct. Morphing blends a vertex toward the
midpoint of its two parents across its tier's band; collapse replaces the
vertex outright, position and UV, once it is past the far edge. Morphing is a
blend, collapse is a cut, and conflating them makes distant terrain
shimmer. Blending has to happen in raw units before the axis swap: mixing a
converted parent with an unconverted child scales the blend by 1024.

Level 0 renders as 26,758 textured triangles over 92 textures, correct UVs,
with LOD selection live (26 patches at LOD 0, 650 at LOD 2 from the start
camera). GC_ENGINE_SHOT writes one frame as a PPM and exits.

Rendering immediately exposed a bug no test could. Positions are i16 offsets
from a per-patch origin, and the obvious rule -- the last non-index STROW row --
is wrong on RAC2, which also uses STROW for per-vertex light records that come
*after* the origin. Taking the last one yields [186, 186, 186, 186] and
collapses the whole level onto the world origin. The entire level then fit in
a 63-unit box and every test still passed: vert_count matched, tri_count
matched, every parent and triangle index resolved, because all of those are
relative to the same broken origin. Only a screenshot showed it. The rule that
works is the STROW immediately preceding the first V3_16 position unpack; with
it, level 0 spans x 86..444, y 46..211, z -477..40, matching the patch
bounding spheres.

That is worth more than the renderer: a parser can be completely
self-consistent and still describe the wrong thing, and where a value is
absolute rather than relative it has to be checked against something
independent.

The frame is flat and dark because there are no normals and no lighting yet.
That is the next task -- the per-vertex light records -- not something to
paper over with an exposure fudge.
This commit is contained in:
2026-10-10 02:58:43 +02:00
parent 71a3185fa5
commit 192dcde247
7 changed files with 1019 additions and 12 deletions
+28
View File
@@ -488,6 +488,34 @@ never claims a parent.
Ruled out along the way: address-only classification (addresses collide), and
one parent array entry per unpack element (wrong unit).
### The origin, and why no parser test could catch it
Positions are `i16`, so a single patch spans at most +-32 world units around
its own origin. The origin comes from an STROW row, and the obvious rule --
the last non-index STROW in the list -- is **wrong on RAC2**. RAC2 also uses
STROW for the per-vertex light records, and those rows come *after* the origin,
so taking the last one yields `[186, 186, 186, 186]` and every patch collapses
onto the world origin.
Nothing in the test suite noticed, and that is the point worth recording:
* `vert_count` still matched, because it counts positions, not places.
* `tri_count` still matched, because the topology never changed.
* Every parent, morph target and triangle index still resolved, because those
are all relative to the same broken origin.
The entire level fit in a 63 x 62 x 62 unit box centred on the origin, and all
of it was self-consistent. Only rendering exposed it, and only because the
screenshot put the camera where the bounding spheres said the terrain was.
The rule that works: **the origin is the STROW immediately preceding the first
V3_16 position unpack** in the common list. With that, level 0's vertices span
x 86..444, y 46..211, z -477..40, which matches the patch bounding spheres.
Lesson for the rest of the project: a parser can be entirely self-consistent
and still describe the wrong thing. Where a value is absolute rather than
relative, check it against an independent source -- here, the bounding spheres.
### Still open
The GS register values the level load builds from the texture entry (TBP0/TBP1
+13 -2
View File
@@ -20,8 +20,19 @@ early, while they are still cheap to retire.
header, and the `data` extent is parsed as a container of byte ranges. The
geometry decompresses: level 0 yields 16,969,296 bytes of level content
from a 9.7 MB stream.
- [ ] Next: the first drawable frame — build the GS register set and a
Bevy fly-camera over level 0's tfrags.
- [x] **First drawable frame.** `gc-engine` loads a level, applies LOD selection
and morphing for the camera, groups triangles by texture and draws them
under a Bevy fly-camera. Level 0 renders as 26,758 textured triangles
across 92 textures with correct UVs. Set `GC_ENGINE_SHOT=<path.ppm>` to
capture a frame and exit.
- [ ] **The origin bug this exposed.** Positions are i16 offsets from a
per-patch origin, so taking the wrong STROW collapsed the whole level to
a +-32 unit blob at the world origin while every parser test still
passed: the vertex count matched, the triangle count matched, only the
*place* was wrong. Fixed by taking the STROW immediately preceding the
first position unpack rather than the last one.
- [ ] Next: the per-vertex light records, which is why the first frame is flat
and dark. Then the GS register set and the guard-band clip test.
The disc being the exact build the reference projects pin is the single
biggest favourable fact so far: no disc conversion, no regional timing work,