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.