diff --git a/crates/gc-engine/src/level.rs b/crates/gc-engine/src/level.rs index 1daec1f..9793f5b 100644 --- a/crates/gc-engine/src/level.rs +++ b/crates/gc-engine/src/level.rs @@ -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, /// 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, camera: [f32; 3]) -> anyhow::Result Option { ); 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. diff --git a/docs/plan/level-assets.md b/docs/plan/level-assets.md index 8d1befc..35cd078 100644 --- a/docs/plan/level-assets.md +++ b/docs/plan/level-assets.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 8d22de7..323f6c9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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,