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
+36 -1
View File
@@ -66,6 +66,7 @@ pub struct PatchBounds {
pub struct TriangleBatch {
pub positions: Vec<[f32; 3]>,
pub uvs: Vec<[f32; 2]>,
pub colors: Vec<[f32; 4]>,
/// Three vertex indices per triangle.
pub indices: Vec<u32>,
/// The ad-gif in force per triangle, parallel to `indices`.
@@ -170,6 +171,31 @@ pub fn uv(g: &TfragGeometry, vinfo: usize) -> [f32; 2] {
pub struct ResolvedVertex {
pub position: [f32; 3],
pub uv: [f32; 2],
/// The patch's stored per-vertex colour.
///
/// Indexed by *position*, not by vertex-info: several vertex-info entries
/// can share a position, and the colour array is sized `vert_count`, which
/// is a position count. This is the game's base colour before lighting
/// multiplies into it, so on its own it is the unlit albedo.
pub color: [f32; 4],
}
/// The stored colour of a vertex's position, or opaque white if absent.
pub fn vertex_color(g: &TfragGeometry, vinfo: usize) -> [f32; 4] {
let i = g.position_index(vinfo);
match g.rgba.get(i) {
// The disc's 0x80-is-1.0 alpha convention, same as the texture CLUT.
Some(c) => {
let a = if c.a < 0x80 { c.a as f32 / 128.0 } else { 1.0 };
[
c.r as f32 / 255.0,
c.g as f32 / 255.0,
c.b as f32 / 255.0,
a,
]
}
None => [1.0, 1.0, 1.0, 1.0],
}
}
/// Resolve one vertex-info entry at a given view depth.
@@ -190,6 +216,7 @@ pub fn resolve_vertex(
return ResolvedVertex {
position: world(g, vinfo),
uv: uv(g, vinfo),
color: vertex_color(g, vinfo),
};
};
let limit = link.tier.collapse_distance(lod_distances);
@@ -197,12 +224,15 @@ pub fn resolve_vertex(
return ResolvedVertex {
position: world(g, link.parent1_vinfo),
uv: uv(g, link.parent1_vinfo),
// Collapse replaces the vertex outright, colour included.
color: vertex_color(g, link.parent1_vinfo),
};
}
if !link.morphs {
return ResolvedVertex {
position: world(g, vinfo),
uv: uv(g, vinfo),
color: vertex_color(g, vinfo),
};
}
let u = link.tier.weight(depth, lod_distances);
@@ -210,6 +240,7 @@ pub fn resolve_vertex(
return ResolvedVertex {
position: world(g, vinfo),
uv: uv(g, vinfo),
color: vertex_color(g, vinfo),
};
}
let p1 = raw_position(g, link.parent1_vinfo);
@@ -225,10 +256,12 @@ pub fn resolve_vertex(
here[1] + (mid[1] - here[1]) * u,
here[2] + (mid[2] - here[2]) * u,
];
// UVs are not morphed: the game keeps the high-detail texture coordinate.
// UVs and colours are not morphed: the blend is positional only, and the
// game keeps the high-detail texture coordinate and colour.
ResolvedVertex {
position: to_world(blended),
uv: uv(g, vinfo),
color: vertex_color(g, vinfo),
}
}
@@ -316,6 +349,7 @@ pub fn build_level(src: &LevelSource<'_>, camera: [f32; 3]) -> anyhow::Result<Le
.or_insert_with(|| TriangleBatch {
positions: Vec::new(),
uvs: Vec::new(),
colors: Vec::new(),
indices: Vec::new(),
ad_gifs: Vec::new(),
});
@@ -324,6 +358,7 @@ pub fn build_level(src: &LevelSource<'_>, camera: [f32; 3]) -> anyhow::Result<Le
let r = resolve_vertex(&g, v, depth, lod_distances, collapse);
batch.positions.push(r.position);
batch.uvs.push(r.uv);
batch.colors.push(r.color);
}
batch.indices.extend_from_slice(&[base, base + 1, base + 2]);
batch.ad_gifs.push(*ad);
+1
View File
@@ -388,6 +388,7 @@ fn mesh_of(b: &TriangleBatch) -> Option<Mesh> {
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, b.positions.clone());
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, b.uvs.clone());
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, b.colors.clone());
// No normals and no colours: lighting is the game's own per-vertex light
// array, which is parsed but not yet reproduced. Inventing normals would
// make unlit terrain look lit and hide that gap.
+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.
+10 -2
View File
@@ -31,8 +31,16 @@ early, while they are still cheap to retire.
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.
- [x] **Per-vertex colour.** The tfrag's RGBA array now modulates the texture,
drawn unlit so the frame shows real albedo rather than a faked lighting
model. Verified against the stored values (level 0 patch 0 runs
`(25,26,26)` to `(108,144,141)`), which are genuinely dark.
- [ ] **tfrag lighting.** Needs the level light bank from `gameplay`, the
256-entry normal table from the overlay ELF at `0x165500`, and the
already-parsed azimuth/elevation light records. The pass itself is
`base + A*max(dA, dA*wA) + B*max(dB, dB*wB)` and has to run in the PS2
float model. Detail in `plan/level-assets.md`.
- [ ] 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,