Draw tfrag per-vertex colour, unlit, and record why the frame is dark

The tfrag RGBA array now modulates the texture. It is indexed by
*position*, not by vertex-info: several vertex-info entries can share a
position, and the array is sized vert_count, which is a position count.

Colour follows the same rule as the vertex itself. Morphing blends the
position only -- the game keeps the high-detail colour -- while collapse
replaces the vertex outright, colour included. Getting that the other way
round would tint distant terrain.

The material is unlit, and that is a decision rather than a shortcut.
Level 0's colours really are dark (patch 0 runs (25,26,26) to
(108,144,141)) and the game's lighting is what brightens them, so what is
on screen is the true albedo.

Worth recording: the obvious move -- add a directional light, or crank
ambient brightness -- does nothing at all. With no normals the PBR path
is degenerate, so the brightness change has no effect, and a host light
would have hidden the gap rather than shown it. The colour data was
always reaching the mesh; what is missing is lighting, not a material
flag.

Written up in plan/level-assets.md, including what LightTfrags actually
needs: the light bank from the level's gameplay extent, the 256-entry
normal table at BOOT_NORMAL_TABLE_VADDR (0x165500) which every level
overlay carries, and the already-parsed azimuth/elevation light records.
This commit is contained in:
2026-10-10 03:02:52 +02:00
parent 192dcde247
commit 17c7e8ccc9
4 changed files with 79 additions and 7 deletions
+32 -4
View File
@@ -516,12 +516,40 @@ 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.
### Why the first frame is dark
The renderer draws unlit: texture times the tfrag's own per-vertex colour.
That product is genuinely dark -- level 0 patch 0's colours are `(25, 26, 26)`
and `(92, 121, 119)` in 0..255 -- and the game's lighting is what brightens it.
Trying a host directional light or cranking ambient brightness was the wrong
instinct and it showed: with no normals the PBR path is degenerate, so
brightness changes did nothing at all. Faking it would also have hidden the
gap. The colour attribute is picked up from the mesh automatically, so it is
the *colour* data that is missing lighting, not a flag that was left off.
The real model is `LightTfrags`, which needs three things this project has not
parsed yet:
* the **light bank** from the level's `gameplay` extent (directional and point
light sets, and the per-vertex light `select` field);
* the **normal table**, 256 pairs of cos/sin read from an ELF at
`BOOT_NORMAL_TABLE_VADDR` (`0x165500`) -- every level overlay carries one, so
it does not need the boot ELF;
* the **light records** themselves, which are already parsed: azimuth and
elevation at pi/128 radians per unit, and the base colour.
Given the table, a normal is `(cos(az)cos(el), sin(az)cos(el), sin(el))`,
negated, exactly as `decode_normal` builds it. The lighting pass itself is
`base + A.rgb * max(dA, dA*wA) + B.rgb * max(dB, dB*wB)` per vertex, and it
must be reproduced in the PS2's float model rather than in Rust `f32`, per
the fidelity rule.
### Still open
The GS register values the level load builds from the texture entry (TBP0/TBP1
and so on), which a renderer needs if it emulates the GS rather than resolving
texture addresses itself, and the guard-band clip test that sends patches
crossing the guard band to the clipping program.
The lighting above, the GS register values the level load builds from the
texture entry (TBP0/TBP1 and so on), and the guard-band clip test that sends
patches crossing the guard band to the clipping program.
A tfrag can now be turned into positioned, UV-mapped, textured triangles,
told which LOD to draw, and morphed toward its parents at the right rate.