Implement the PS2 float model and the LightTfrags pass

Two pieces, and the first is the reason the second can be trusted.

ps2.rs is the EE's FPU on raw u32 bit patterns. It is not f32: there are
no denormals (exponent 0 *is* zero), no NaN and no infinity (exponent 255
is an ordinary value, so MAX reads back in Rust as NaN -- a test asserts
exactly that, because it is the sharpest available statement of why this
module exists), every result truncates toward zero, the adder discards
the small operand's bits more than one below the large one's LSB and
returns the large operand unchanged across a 25-bit exponent gap, and
rsqrt is sqrt-then-divide with truncation between the steps rather than
one rounded operation. The lighting pass accumulates all of these across
every vertex of every patch, so this is a visible difference rather than
a rounding curiosity.

tfrag_light.rs is the pass itself: PEXT5 base colour, the select word
picking one light set or blending two by bits 8..15 / 256, blended
directions re-normalised where unblended ones are not, and
base + A.rgb*max(dA, dA*wA) + B.rgb*max(dB, dB*wB). Lighting indexes
positions, not vertex-info entries, since a common entry and a LOD entry
can share a position.

TfragLight gains a `select` field for the u16 at +6. It was briefly
reconstructed with an unsafe pointer cast over the struct, which is
exactly the sort of thing that breaks silently the moment a field is
reordered; it is now a plainly parsed field.

The pass does not run yet. The light bank framing the reference uses --
a u32 offset at +0x04, an i32 count there -- gives offset 0xa0d and a
count of 698810368 on RAC2 level 0, so RAC2's gameplay extent is not
laid out that way. The renderer now counts the vertices it actually lit
and prints it, because falling back silently to the stored colours
produces a plausible but wrong picture:

  lighting: 0 of 80274 vertices shaded by LightTfrags; the rest fall
  back to the tfrag's stored colours (no usable light bank)

That line is the current honest state, and the frame has not changed as
a result. Where RAC2 keeps its light bank is the open question.
This commit is contained in:
2026-10-10 03:21:52 +02:00
parent 90f02890f8
commit 65b5d366de
8 changed files with 759 additions and 11 deletions
+63 -2
View File
@@ -28,7 +28,7 @@ use gc_formats::tfrag::{
parse_tfrag, tfrag_lod_link, tfrag_triangles, TfragAdGifs, TfragDrawMode, TfragGeometry,
TfragHeader, TfragsBlock,
};
use gc_formats::tfrag_light::NormalTable;
use gc_formats::tfrag_light::{LightBank, NormalTable};
/// Raw position units per world unit.
pub const UNITS_PER_UNIT: f32 = 1024.0;
@@ -53,6 +53,10 @@ pub struct LevelStats {
pub skipped_unparsed: usize,
pub triangles: usize,
pub vertices: usize,
/// Vertices shaded by `LightTfrags`. Below `vertices` means the level fell
/// back to the tfrag's stored colours, which is a different picture and
/// should be visible in the log rather than discovered by eye.
pub lit_vertices: usize,
}
pub struct PatchBounds {
@@ -84,6 +88,8 @@ pub struct LevelSource<'a> {
/// The level overlay, for the normal table. Optional: without it the level
/// still builds, just without normals.
pub overlay: Option<&'a [u8]>,
/// The level's `gameplay` extent, for the light bank. Optional.
pub gameplay: Option<&'a [u8]>,
}
/// Where a level's terrain sits: the centre of its patch bounding spheres and
@@ -205,6 +211,54 @@ pub fn vertex_normal(g: &TfragGeometry, vinfo: usize, table: Option<&NormalTable
[n[0], n[2], -n[1]]
}
/// A vertex's colour after the lighting pass.
///
/// Falls back to the tfrag's stored colour when there is no light bank or no
/// normal table. That is a real difference from the game, which has both, so
/// the renderer reports how many patches were actually lit rather than
/// quietly showing an unlit level as if it were a lit one.
fn lit_color(
g: &TfragGeometry,
vinfo: usize,
stored: [f32; 4],
lighting: Option<(&NormalTable, &LightBank)>,
depth: f32,
lod_distances: [f32; 3],
) -> ([f32; 4], bool) {
let _ = (depth, lod_distances);
// Lighting runs over *positions*, not vertex-info entries: a common entry
// and a LOD entry can share a position, and the light record is indexed by
// position. So every entry resolves through the same lookup.
let Some((table, bank)) = lighting else {
return (stored, false);
};
if bank.sets.is_empty() {
return (stored, false);
}
(lit_at(g, vinfo, table, bank), true)
}
fn lit_at(g: &TfragGeometry, vinfo: usize, table: &NormalTable, bank: &LightBank) -> [f32; 4] {
let i = g.position_index(vinfo);
let Some(light) = g.lights.get(i) else {
return [1.0, 1.0, 1.0, 1.0];
};
let n = crate::tfrag_light::vertex_normal_bits(
table,
light.azimuth_index(),
light.elevation_index(),
);
let Some(c) = crate::tfrag_light::light_vertex(bank, n, light) else {
return [1.0, 1.0, 1.0, 1.0];
};
[
c[0] as f32 / 255.0,
c[1] as f32 / 255.0,
c[2] as f32 / 255.0,
1.0,
]
}
/// 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);
@@ -302,6 +356,8 @@ pub fn mode_collapses(mode: TfragDrawMode) -> bool {
/// Decode every patch in a level's tfrags block, as seen from `camera`.
pub fn build_level(src: &LevelSource<'_>, camera: [f32; 3]) -> anyhow::Result<LevelGeometry> {
let normals = src.overlay.and_then(|o| NormalTable::from_overlay(o).ok());
let bank = src.gameplay.and_then(|g| LightBank::parse(g).ok());
let lighting = normals.as_ref().zip(bank.as_ref());
let ch = LevelCoreHeader::parse(src.core_index)?;
let table = texture_table(src.core_index, ch.tfrag_textures)?;
@@ -385,7 +441,11 @@ 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);
let (colour, was_lit) = lit_color(&g, v, r.color, lighting, depth, lod_distances);
if was_lit {
stats.lit_vertices += 1;
}
batch.colors.push(colour);
batch.normals.push(vertex_normal(&g, v, normals.as_ref()));
}
batch.indices.extend_from_slice(&[base, base + 1, base + 2]);
@@ -594,6 +654,7 @@ mod normal_tests {
azimuth: azimuth as i8,
elevation: elevation as i8,
color: 0,
select: 0,
}],
..TfragGeometry::default()
}