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:
@@ -66,6 +66,7 @@ pub struct PatchBounds {
|
|||||||
pub struct TriangleBatch {
|
pub struct TriangleBatch {
|
||||||
pub positions: Vec<[f32; 3]>,
|
pub positions: Vec<[f32; 3]>,
|
||||||
pub uvs: Vec<[f32; 2]>,
|
pub uvs: Vec<[f32; 2]>,
|
||||||
|
pub colors: Vec<[f32; 4]>,
|
||||||
/// Three vertex indices per triangle.
|
/// Three vertex indices per triangle.
|
||||||
pub indices: Vec<u32>,
|
pub indices: Vec<u32>,
|
||||||
/// The ad-gif in force per triangle, parallel to `indices`.
|
/// 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 struct ResolvedVertex {
|
||||||
pub position: [f32; 3],
|
pub position: [f32; 3],
|
||||||
pub uv: [f32; 2],
|
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.
|
/// Resolve one vertex-info entry at a given view depth.
|
||||||
@@ -190,6 +216,7 @@ pub fn resolve_vertex(
|
|||||||
return ResolvedVertex {
|
return ResolvedVertex {
|
||||||
position: world(g, vinfo),
|
position: world(g, vinfo),
|
||||||
uv: uv(g, vinfo),
|
uv: uv(g, vinfo),
|
||||||
|
color: vertex_color(g, vinfo),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
let limit = link.tier.collapse_distance(lod_distances);
|
let limit = link.tier.collapse_distance(lod_distances);
|
||||||
@@ -197,12 +224,15 @@ pub fn resolve_vertex(
|
|||||||
return ResolvedVertex {
|
return ResolvedVertex {
|
||||||
position: world(g, link.parent1_vinfo),
|
position: world(g, link.parent1_vinfo),
|
||||||
uv: uv(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 {
|
if !link.morphs {
|
||||||
return ResolvedVertex {
|
return ResolvedVertex {
|
||||||
position: world(g, vinfo),
|
position: world(g, vinfo),
|
||||||
uv: uv(g, vinfo),
|
uv: uv(g, vinfo),
|
||||||
|
color: vertex_color(g, vinfo),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let u = link.tier.weight(depth, lod_distances);
|
let u = link.tier.weight(depth, lod_distances);
|
||||||
@@ -210,6 +240,7 @@ pub fn resolve_vertex(
|
|||||||
return ResolvedVertex {
|
return ResolvedVertex {
|
||||||
position: world(g, vinfo),
|
position: world(g, vinfo),
|
||||||
uv: uv(g, vinfo),
|
uv: uv(g, vinfo),
|
||||||
|
color: vertex_color(g, vinfo),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let p1 = raw_position(g, link.parent1_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[1] + (mid[1] - here[1]) * u,
|
||||||
here[2] + (mid[2] - here[2]) * 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 {
|
ResolvedVertex {
|
||||||
position: to_world(blended),
|
position: to_world(blended),
|
||||||
uv: uv(g, vinfo),
|
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 {
|
.or_insert_with(|| TriangleBatch {
|
||||||
positions: Vec::new(),
|
positions: Vec::new(),
|
||||||
uvs: Vec::new(),
|
uvs: Vec::new(),
|
||||||
|
colors: Vec::new(),
|
||||||
indices: Vec::new(),
|
indices: Vec::new(),
|
||||||
ad_gifs: 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);
|
let r = resolve_vertex(&g, v, depth, lod_distances, collapse);
|
||||||
batch.positions.push(r.position);
|
batch.positions.push(r.position);
|
||||||
batch.uvs.push(r.uv);
|
batch.uvs.push(r.uv);
|
||||||
|
batch.colors.push(r.color);
|
||||||
}
|
}
|
||||||
batch.indices.extend_from_slice(&[base, base + 1, base + 2]);
|
batch.indices.extend_from_slice(&[base, base + 1, base + 2]);
|
||||||
batch.ad_gifs.push(*ad);
|
batch.ad_gifs.push(*ad);
|
||||||
|
|||||||
@@ -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_POSITION, b.positions.clone());
|
||||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, b.uvs.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
|
// 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
|
// array, which is parsed but not yet reproduced. Inventing normals would
|
||||||
// make unlit terrain look lit and hide that gap.
|
// make unlit terrain look lit and hide that gap.
|
||||||
|
|||||||
@@ -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
|
and still describe the wrong thing. Where a value is absolute rather than
|
||||||
relative, check it against an independent source -- here, the bounding spheres.
|
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
|
### Still open
|
||||||
|
|
||||||
The GS register values the level load builds from the texture entry (TBP0/TBP1
|
The lighting above, the GS register values the level load builds from the
|
||||||
and so on), which a renderer needs if it emulates the GS rather than resolving
|
texture entry (TBP0/TBP1 and so on), and the guard-band clip test that sends
|
||||||
texture addresses itself, and the guard-band clip test that sends patches
|
patches crossing the guard band to the clipping program.
|
||||||
crossing the guard band to the clipping program.
|
|
||||||
|
|
||||||
A tfrag can now be turned into positioned, UV-mapped, textured triangles,
|
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.
|
told which LOD to draw, and morphed toward its parents at the right rate.
|
||||||
|
|||||||
+10
-2
@@ -31,8 +31,16 @@ early, while they are still cheap to retire.
|
|||||||
passed: the vertex count matched, the triangle count matched, only the
|
passed: the vertex count matched, the triangle count matched, only the
|
||||||
*place* was wrong. Fixed by taking the STROW immediately preceding the
|
*place* was wrong. Fixed by taking the STROW immediately preceding the
|
||||||
first position unpack rather than the last one.
|
first position unpack rather than the last one.
|
||||||
- [ ] Next: the per-vertex light records, which is why the first frame is flat
|
- [x] **Per-vertex colour.** The tfrag's RGBA array now modulates the texture,
|
||||||
and dark. Then the GS register set and the guard-band clip test.
|
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
|
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,
|
biggest favourable fact so far: no disc conversion, no regional timing work,
|
||||||
|
|||||||
Reference in New Issue
Block a user