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, parse_tfrag, tfrag_lod_link, tfrag_triangles, TfragAdGifs, TfragDrawMode, TfragGeometry,
TfragHeader, TfragsBlock, TfragHeader, TfragsBlock,
}; };
use gc_formats::tfrag_light::NormalTable; use gc_formats::tfrag_light::{LightBank, NormalTable};
/// Raw position units per world unit. /// Raw position units per world unit.
pub const UNITS_PER_UNIT: f32 = 1024.0; pub const UNITS_PER_UNIT: f32 = 1024.0;
@@ -53,6 +53,10 @@ pub struct LevelStats {
pub skipped_unparsed: usize, pub skipped_unparsed: usize,
pub triangles: usize, pub triangles: usize,
pub vertices: 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 { pub struct PatchBounds {
@@ -84,6 +88,8 @@ pub struct LevelSource<'a> {
/// The level overlay, for the normal table. Optional: without it the level /// The level overlay, for the normal table. Optional: without it the level
/// still builds, just without normals. /// still builds, just without normals.
pub overlay: Option<&'a [u8]>, 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 /// 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]] [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. /// The stored colour of a vertex's position, or opaque white if absent.
pub fn vertex_color(g: &TfragGeometry, vinfo: usize) -> [f32; 4] { pub fn vertex_color(g: &TfragGeometry, vinfo: usize) -> [f32; 4] {
let i = g.position_index(vinfo); 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`. /// 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> { 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 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 ch = LevelCoreHeader::parse(src.core_index)?;
let table = texture_table(src.core_index, ch.tfrag_textures)?; 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); 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); 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.normals.push(vertex_normal(&g, v, normals.as_ref()));
} }
batch.indices.extend_from_slice(&[base, base + 1, base + 2]); batch.indices.extend_from_slice(&[base, base + 1, base + 2]);
@@ -594,6 +654,7 @@ mod normal_tests {
azimuth: azimuth as i8, azimuth: azimuth as i8,
elevation: elevation as i8, elevation: elevation as i8,
color: 0, color: 0,
select: 0,
}], }],
..TfragGeometry::default() ..TfragGeometry::default()
} }
+2
View File
@@ -35,3 +35,5 @@
//! noticeable. //! noticeable.
pub mod level; pub mod level;
pub mod ps2;
pub mod tfrag_light;
+14
View File
@@ -245,11 +245,13 @@ fn load_source(dir: &std::path::Path, level: usize) -> Result<(LevelFiles, [f32;
let core_index = std::fs::read(level_dir.join("core_index.bin")).context("core_index.bin")?; let core_index = std::fs::read(level_dir.join("core_index.bin")).context("core_index.bin")?;
let gs_ram = std::fs::read(level_dir.join("gs_ram.bin")).context("gs_ram.bin")?; let gs_ram = std::fs::read(level_dir.join("gs_ram.bin")).context("gs_ram.bin")?;
let overlay = std::fs::read(level_dir.join("overlay.bin")).ok(); let overlay = std::fs::read(level_dir.join("overlay.bin")).ok();
let gameplay = std::fs::read(level_dir.join("gameplay.bin")).ok();
let src = LevelSource { let src = LevelSource {
core_data: &core_data, core_data: &core_data,
core_index: &core_index, core_index: &core_index,
gs_ram: &gs_ram, gs_ram: &gs_ram,
overlay: overlay.as_deref(), overlay: overlay.as_deref(),
gameplay: gameplay.as_deref(),
}; };
let (centre, _radius) = level_bounds(&src)?; let (centre, _radius) = level_bounds(&src)?;
Ok(( Ok((
@@ -258,6 +260,7 @@ fn load_source(dir: &std::path::Path, level: usize) -> Result<(LevelFiles, [f32;
core_index, core_index,
gs_ram, gs_ram,
overlay, overlay,
gameplay,
}, },
centre, centre,
)) ))
@@ -269,6 +272,7 @@ struct LevelFiles {
core_index: Vec<u8>, core_index: Vec<u8>,
gs_ram: Vec<u8>, gs_ram: Vec<u8>,
overlay: Option<Vec<u8>>, overlay: Option<Vec<u8>>,
gameplay: Option<Vec<u8>>,
} }
impl LevelFiles { impl LevelFiles {
@@ -278,6 +282,7 @@ impl LevelFiles {
core_index: &self.core_index, core_index: &self.core_index,
gs_ram: &self.gs_ram, gs_ram: &self.gs_ram,
overlay: self.overlay.as_deref(), overlay: self.overlay.as_deref(),
gameplay: self.gameplay.as_deref(),
} }
} }
} }
@@ -334,6 +339,15 @@ fn report(g: &LevelGeometry) {
for (m, n) in modes { for (m, n) in modes {
eprintln!(" {m}: {n} patches"); eprintln!(" {m}: {n} patches");
} }
// Say so when the level is not lit. Falling back silently to the tfrag's
// stored colours produces a plausible but wrong picture.
if s.lit_vertices < s.vertices {
eprintln!(
" lighting: {} of {} vertices shaded by LightTfrags; the rest fall back to the \
tfrag's stored colours (no usable light bank)",
s.lit_vertices, s.vertices
);
}
} }
fn mode_name(p: &gc_engine::level::PatchBounds) -> &'static str { fn mode_name(p: &gc_engine::level::PatchBounds) -> &'static str {
+293
View File
@@ -0,0 +1,293 @@
//! The Emotion Engine's FPU, as the tfrag lighting pass uses it.
//!
//! Spec: `docs/formats/ps2-float.md`.
//!
//! **This is not `f32`.** The EE has no NaN, no infinity and no denormals: an
//! exponent of 0 *is* zero and an exponent of 255 is an ordinary value. Every
//! result truncates toward zero rather than rounding to nearest, and the adder
//! discards the low bits of the smaller operand that sit more than one bit
//! below the larger's LSB.
//!
//! That last rule is the one that matters for fidelity. `(a + b)` and
//! `a + b` in Rust can differ in the last bit, and the lighting accumulates
//! those differences across every vertex until the colours visibly drift.
//!
//! Arithmetic is therefore done on raw `u32` bit patterns. Passing `f32` in
//! would mean `to_bits`, an operation, `from_bits` on every step -- and the
//! temptation to "just use f32" that follows from it.
/// Sign bit.
pub const SIGN: u32 = 0x8000_0000;
/// Largest representable magnitude.
pub const MAX: u32 = 0x7fff_ffff;
/// 1.0.
pub const ONE: u32 = 0x3f80_0000;
fn exp(x: u32) -> i32 {
((x >> 23) & 0xff) as i32
}
fn man(x: u32) -> u64 {
((x & 0x7f_ffff) | 0x80_0000) as u64
}
/// Pack `sign * mag * 2^(lsb_exp)`, truncating to 24 significant bits.
///
/// Overflow clamps to ±`MAX` and underflow flushes to ±0, because the hardware
/// has neither an infinity nor a denormal to land on.
fn pack(sign: u32, mag: u64, lsb_exp: i32) -> u32 {
if mag == 0 {
return sign;
}
let k = 63 - mag.leading_zeros() as i32;
let e = lsb_exp + (k - 23);
let m = if k >= 23 {
mag >> (k - 23)
} else {
mag << (23 - k)
};
if e > 255 {
sign | MAX
} else if e < 1 {
sign
} else {
sign | ((e as u32) << 23) | ((m as u32) & 0x7f_ffff)
}
}
/// `MUL`. Truncating.
pub fn mul(a: u32, b: u32) -> u32 {
let s = (a ^ b) & SIGN;
if exp(a) == 0 || exp(b) == 0 {
return s;
}
// man(a) * man(b) has its binary point at bit 46, so >> 23 leaves bit 23
// worth 2^(ea + eb - 254).
pack(s, (man(a) * man(b)) >> 23, exp(a) + exp(b) - 127)
}
/// `ADD`, truncating, with the EE's operand alignment.
pub fn add(a: u32, b: u32) -> u32 {
let (ea, eb) = (exp(a), exp(b));
if ea == 0 || eb == 0 {
if ea != 0 {
return a;
}
if eb != 0 {
return b;
}
// Both zero: 0 unless both are negative zero, which is -0.
return if a & b & SIGN != 0 { SIGN } else { 0 };
}
let (hi, lo) = if ea >= eb { (a, b) } else { (b, a) };
let d = exp(hi) - exp(lo);
// A 25-bit exponent gap leaves nothing of the smaller operand to add.
if d >= 25 {
return hi;
}
let lo = if d >= 1 {
lo & (u32::MAX << (d - 1))
} else {
lo
};
let sv = |x: u32, m: u64| if x & SIGN != 0 { -(m as i64) } else { m as i64 };
let sum = sv(hi, man(hi) << d) + sv(lo, man(lo));
if sum == 0 {
return 0;
}
pack(if sum < 0 { SIGN } else { 0 }, sum.unsigned_abs(), exp(lo))
}
/// `SUB`.
pub fn sub(a: u32, b: u32) -> u32 {
add(a, b ^ SIGN)
}
/// `DIV`. Truncated quotient; division by zero gives ±`MAX`, not an infinity,
/// because the EE has none.
pub fn div(a: u32, b: u32) -> u32 {
let s = (a ^ b) & SIGN;
if exp(b) == 0 {
return s | MAX;
}
if exp(a) == 0 {
return s;
}
let q = (man(a) << 24) / man(b);
pack(s, q, exp(a) - exp(b) + 127 - 1)
}
/// `SQRT` of the magnitude, truncated.
pub fn sqrt(x: u32) -> u32 {
if exp(x) == 0 {
return 0;
}
let e = exp(x) - 127;
let odd = e.rem_euclid(2);
let r = isqrt(man(x) << (23 + odd));
pack(0, r, (e - odd) / 2 + 127)
}
/// `RSQRT a, b`: the square root is truncated *first*, then divided. Doing it
/// as one step gives a different answer.
pub fn rsqrt(a: u32, b: u32) -> u32 {
div(a, sqrt(b))
}
/// `ITOF12`: signed integer scaled by 1/4096, truncating integers wider than 24
/// bits.
pub fn itof12(i: i32) -> u32 {
if i == 0 {
return 0;
}
pack(
if i < 0 { SIGN } else { 0 },
i.unsigned_abs() as u64,
127 + 23 - 12,
)
}
/// Ordering key. The sign bit has to be folded in or every negative compares
/// above every positive.
fn key(x: u32) -> i64 {
if x & SIGN != 0 {
-((x & MAX) as i64)
} else {
x as i64
}
}
/// `MAX`, where -0 < 0.
pub fn max(a: u32, b: u32) -> u32 {
if key(b) > key(a) {
b
} else {
a
}
}
/// `MIN`, where -0 < 0.
pub fn min(a: u32, b: u32) -> u32 {
if key(b) < key(a) {
b
} else {
a
}
}
/// Integer square root, exact.
fn isqrt(n: u64) -> u64 {
let mut r = (n as f64).sqrt() as u64;
while r * r > n {
r -= 1;
}
while (r + 1) * (r + 1) <= n {
r += 1;
}
r
}
#[cfg(test)]
mod tests {
use super::*;
fn f(x: f32) -> u32 {
x.to_bits()
}
fn v(x: u32) -> f32 {
f32::from_bits(x)
}
#[test]
fn one_is_one() {
assert_eq!(v(mul(ONE, ONE)), 1.0);
assert_eq!(v(add(ONE, 0)), 1.0);
assert_eq!(v(div(ONE, ONE)), 1.0);
}
#[test]
fn there_is_no_denormal() {
// The smallest f32 denormal would be exp 0 with a nonzero mantissa.
// On the EE that bit pattern is simply zero.
let denormal = 1u32;
assert_eq!(exp(denormal), 0);
assert_eq!(v(mul(denormal, ONE)), 0.0);
}
#[test]
fn there_is_no_infinity() {
// Dividing by zero saturates rather than producing an infinity. The
// comparison is on bits: `MAX` is exponent 255 with a non-zero
// mantissa, which Rust reads back as NaN precisely because the EE has
// no infinity for it to be. That asymmetry is the whole reason this
// module exists.
assert_eq!(div(ONE, 0), MAX);
assert!(v(MAX).is_nan());
assert_eq!(sqrt(f(-1.0)), f(1.0));
}
#[test]
fn overflow_saturates() {
let big = f(3.0e38);
assert_eq!(mul(big, big), MAX);
}
#[test]
fn results_truncate_toward_zero() {
// 1/3 is 0.333...; the EE truncates rather than rounding to nearest.
let third = div(ONE, f(3.0));
assert!(v(third) < 1.0 / 3.0, "got {}", v(third));
assert!(v(third) > 1.0 / 3.0 - 1e-6);
}
#[test]
fn a_large_operand_absorbs_a_small_one() {
let big = f(1.0e10);
let tiny = f(1.0e-10);
// A 40-bit exponent gap is past the adder's 25-bit window.
assert_eq!(v(add(big, tiny)), v(big));
}
#[test]
fn sqrt_and_rsqrt_are_truncated_at_each_step() {
let two = f(2.0);
let s = sqrt(two);
// Truncated, so the root never overshoots.
assert!(v(s) * v(s) <= 2.0);
// RSQRT is sqrt-then-divide, with truncation between the two. That is
// the contract to preserve: computing 1/sqrt(x) in one go agrees only
// by luck, and disagrees as soon as either step lands on a different
// bit.
for x in [2.0f32, 3.0, 7.0, 11.5, 0.25, 1000.0] {
assert_eq!(rsqrt(ONE, f(x)), div(ONE, sqrt(f(x))), "x={x}");
}
}
#[test]
fn negatives_order_below_positives() {
let neg = f(-5.0);
let pos = f(5.0);
assert_eq!(max(neg, pos), pos);
assert_eq!(min(neg, pos), neg);
assert_eq!(max(pos, neg), pos);
// -0 and +0 compare equal, and the hardware keeps the first operand,
// so both min and max return it. An unsigned compare would get this
// backwards for every negative number.
assert_eq!(min(SIGN, 0), SIGN);
assert_eq!(max(SIGN, 0), SIGN);
}
#[test]
fn itof12_is_a_twelve_bit_fraction() {
assert_eq!(v(itof12(4096)), 1.0);
assert_eq!(v(itof12(-4096)), -1.0);
assert_eq!(v(itof12(2048)), 0.5);
assert_eq!(itof12(0), 0);
}
#[test]
fn subtraction_is_addition_of_the_negated_operand() {
assert_eq!(sub(f(5.0), f(3.0)), add(f(5.0), f(-3.0)));
assert!((v(sub(f(5.0), f(3.0))) - 2.0).abs() < 1e-6);
}
}
+292
View File
@@ -0,0 +1,292 @@
//! `LightTfrags`: the per-vertex lighting pass the game runs on tfrag geometry.
//!
//! Spec: `docs/formats/tfrag-lighting.md`.
//!
//! Every arithmetic step goes through [`crate::ps2`]. This is not
//! fastidiousness: the FPU truncates toward zero and has no denormals, and the
//! pass accumulates across every vertex of every patch. Computing it in `f32`
//! produces a visibly different level, not a subtly different one.
use gc_formats::tfrag::TfragGeometry;
use gc_formats::tfrag_light::{DirLightSet, LightBank, NormalTable};
use crate::ps2;
/// A vector of four PS2 float bit patterns.
type V4 = [u32; 4];
fn bits(v: [f32; 4]) -> V4 {
v.map(f32::to_bits)
}
/// `vmul`: lane-wise product.
fn vmul(a: V4, b: V4) -> V4 {
[0, 1, 2, 3].map(|k| ps2::mul(a[k], b[k]))
}
/// `vmulbc`: every lane times one scalar.
fn vmuls(a: V4, s: u32) -> V4 {
a.map(|x| ps2::mul(x, s))
}
/// `vadda` / `vmadd`: `acc + a * s`, lane-wise.
fn vmadds(acc: V4, a: V4, s: u32) -> V4 {
[0, 1, 2, 3].map(|k| ps2::add(acc[k], ps2::mul(a[k], s)))
}
/// `(ax*bx + ay*by) + 1.0*(az*bz)`.
///
/// The `1.0 *` is in the microprogram: `vmaddz.x r, vf21(1.0), t`. It matters
/// only for overflow behaviour, but dropping it changes the flush case.
fn dot3(a: V4, b: V4) -> u32 {
let p = vmul(a, b);
ps2::add(ps2::add(p[0], p[1]), ps2::mul(ps2::ONE, p[2]))
}
/// A colour byte as the float the microprogram builds: `65536 + c / 128`,
/// formed by `padduw` against `0x47800000`.
fn color_float(c: [u8; 4]) -> V4 {
c.map(|b| 0x4780_0000 + b as u32)
}
/// Clamp to a byte with `vminibcx` against `0x478000ff`, then take the low
/// byte of each lane.
fn pack_color(v: V4) -> [u8; 4] {
let clamp = 0x4780_00ff;
[
ps2::min(v[0], clamp) as u8,
ps2::min(v[1], clamp) as u8,
ps2::min(v[2], clamp) as u8,
v[3] as u8,
]
}
/// PEXT5 of the 5:5:5:1 base colour.
pub fn pext5(c: u16) -> [u8; 4] {
[
((c & 0x1f) << 3) as u8,
((c >> 5 & 0x1f) << 3) as u8,
((c >> 10 & 0x1f) << 3) as u8,
((c >> 15) << 7) as u8,
]
}
/// The light set a vertex uses, with the back-face factors pulled out of the
/// colour w lanes and those lanes cleared.
struct ActiveSet {
ca: V4,
da: V4,
cb: V4,
db: V4,
wa: u32,
wb: u32,
}
/// `select` names one light set, or blends two.
///
/// Bits 8..15 clear means "just set bits 0..3". Otherwise set bits 0..3 is
/// blended with set bits 4..7 by `t = bits 8..15 / 256`.
fn active_set(bank: &LightBank, select: u16) -> Option<ActiveSet> {
let set = |i: u16| -> Option<DirLightSet> { bank.sets.get((i & 0xf) as usize).copied() };
let (mut ca, da, mut cb, db);
if select & 0xff00 == 0 {
let s = set(select)?;
(ca, da, cb, db) = (
bits(s.color_a),
bits(s.dir_a),
bits(s.color_b),
bits(s.dir_b),
);
} else {
let t = ps2::itof12(((select as u32 >> 4) & 0xff0) as i32);
let w = ps2::sub(ps2::ONE, t);
let a = set(select)?;
let b = set(select >> 4)?;
let blend = |x: [f32; 4], y: [f32; 4]| -> V4 {
let (p, q) = (vmuls(bits(x), w), vmuls(bits(y), t));
[0, 1, 2, 3].map(|k| ps2::add(p[k], q[k]))
};
ca = blend(a.color_a, b.color_a);
cb = blend(a.color_b, b.color_b);
// Blended directions are re-normalised, which the unblended path does
// not need because the stored ones are already unit.
let norm = |v: V4| -> V4 {
let q = ps2::rsqrt(ps2::ONE, dot3(v, v));
[
ps2::mul(v[0], q),
ps2::mul(v[1], q),
ps2::mul(v[2], q),
v[3],
]
};
da = norm(blend(a.dir_a, b.dir_a));
db = norm(blend(a.dir_b, b.dir_b));
}
// `vaddw.x/.y vf31 = 0 + colour.w`, then `vsubw.w colour = 1 - 1`.
let (wa, wb) = (ps2::add(0, ca[3]), ps2::add(0, cb[3]));
ca[3] = 0;
cb[3] = 0;
Some(ActiveSet {
ca,
da,
cb,
db,
wa,
wb,
})
}
/// Light one vertex: `base + A.rgb * max(dA, dA*wA) + B.rgb * max(dB, dB*wB)`.
pub fn light_vertex(
bank: &LightBank,
n: V4,
light: &gc_formats::tfrag::TfragLight,
) -> Option<[u8; 4]> {
light_vertex_n(bank, n, light, light.select)
}
fn light_vertex_n(
bank: &LightBank,
n: V4,
light: &gc_formats::tfrag::TfragLight,
select: u16,
) -> Option<[u8; 4]> {
let s = active_set(bank, select)?;
let da = dot3(n, s.da);
let db = dot3(n, s.db);
let da = ps2::max(da, ps2::mul(da, s.wa));
let db = ps2::max(db, ps2::mul(db, s.wb));
let acc = vmuls(color_float(pext5(light.color as u16)), ps2::ONE);
let acc = vmadds(acc, s.ca, da);
Some(pack_color(vmadds(acc, s.cb, db)))
}
/// The normal a vertex lights with, as raw PS2 floats.
///
/// The stored record's azimuth and elevation are *indices*, so they index the
/// sine table rather than naming an angle.
pub fn vertex_normal_bits(table: &NormalTable, azimuth: u8, elevation: u8) -> V4 {
let (ca, sa) = table.get(azimuth);
let (ce, se) = table.get(elevation);
let x = ps2::mul(ca.to_bits(), ce.to_bits());
let y = ps2::mul(sa.to_bits(), ce.to_bits());
[
ps2::sub(0, x),
ps2::sub(0, y),
ps2::add(0, se.to_bits()),
se.to_bits(),
]
}
/// Run the lighting pass over a patch's light records.
///
/// Returns one colour per light record, in the same order. Records whose
/// position has no light, or whose light set does not exist, yield transparent
/// black rather than being skipped, so the result lines up with the record
/// array.
pub fn light_tfrag(g: &TfragGeometry, bank: &LightBank, table: &NormalTable) -> Vec<[u8; 4]> {
let mut out = Vec::with_capacity(g.lights.len());
for l in &g.lights {
let n = vertex_normal_bits(table, l.azimuth_index(), l.elevation_index());
out.push(light_vertex_n(bank, n, l, l.select).unwrap_or([0, 0, 0, 0]));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use gc_formats::tfrag::TfragLight;
fn table() -> NormalTable {
let mut cos_sin = Vec::with_capacity(256);
for i in 0..256usize {
let a = i as f64 * std::f64::consts::PI / 128.0;
cos_sin.push([(a.cos() as f32).to_bits(), (a.sin() as f32).to_bits()]);
}
NormalTable { cos_sin }
}
fn set(color: [f32; 4], dir: [f32; 4]) -> DirLightSet {
DirLightSet {
color_a: color,
dir_a: dir,
color_b: color,
dir_b: dir,
}
}
fn bank() -> LightBank {
// One set: a white light pointing along -X. The table's azimuth 0
// normal is -X too, so a vertex with that normal faces the light and
// one with azimuth 128 faces away.
LightBank {
sets: vec![set([1.0, 1.0, 1.0, 1.0], [-1.0, 0.0, 0.0, 0.0])],
point_lights: Vec::new(),
}
}
fn light(azimuth: i8) -> TfragLight {
TfragLight {
unknown_0: 0,
intensity: 0,
azimuth,
elevation: 0,
color: 0,
select: 0,
}
}
#[test]
fn pext5_expands_five_bits_to_eight() {
assert_eq!(pext5(0x0000), [0, 0, 0, 0]);
assert_eq!(pext5(0xffff), [248, 248, 248, 128]);
assert_eq!(pext5(0x001f), [248, 0, 0, 0]);
}
#[test]
fn a_facing_vertex_is_lit_and_a_back_facing_one_is_not() {
let bank = bank();
let t = table();
// Azimuth 0 gives the normal -X, which faces the light. Azimuth 128
// gives +X, which does not.
let lit = light_tfrag(&patch(vec![light(0), light(-128)]), &bank, &t);
assert_eq!(lit.len(), 2);
assert!(
lit[0][0] > 200,
"facing vertex should be bright: {:?}",
lit[0]
);
assert_eq!(
lit[1][0], 0,
"back-facing vertex should be dark: {:?}",
lit[1]
);
}
/// A patch with one light record, wired to `table` and `bank`.
fn patch(lights: Vec<TfragLight>) -> TfragGeometry {
TfragGeometry {
lights,
..TfragGeometry::default()
}
}
#[test]
fn an_empty_bank_lights_nothing() {
let bank = LightBank::default();
let t = table();
let out = light_tfrag(&patch(vec![light(0)]), &bank, &t);
assert_eq!(out, vec![[0, 0, 0, 0]]);
}
#[test]
fn normals_are_negated_and_unit_length() {
let t = table();
let n = vertex_normal_bits(&t, 0, 0);
// Azimuth 0, elevation 0 is (1, 0, 0) before negation.
assert_eq!(n[0], f32::to_bits(-1.0));
assert_eq!(n[1], 0);
assert_eq!(n[2], 0);
}
}
+20
View File
@@ -615,10 +615,29 @@ pub struct TfragLight {
pub azimuth: i8, pub azimuth: i8,
/// Normal elevation, as an index into the same table. /// Normal elevation, as an index into the same table.
pub elevation: i8, pub elevation: i8,
/// Base colour as 5:5:5:1.
pub color: i16, pub color: i16,
/// Light-set select. Bits 8..15 clear means "use set bits 0..3"; otherwise
/// set bits 0..3 is blended with set bits 4..7 by `bits 8..15 / 256`.
pub select: u16,
} }
impl TfragLight { impl TfragLight {
/// The light-set select word at +6 of the record.
///
/// The parsed struct exposes bytes 0..6; the select is the `u16` that
/// follows `color`, so it is read from the record rather than stored as its
/// own field.
pub fn select(&self) -> u16 {
let b = unsafe {
std::slice::from_raw_parts(
self as *const TfragLight as *const u8,
std::mem::size_of::<TfragLight>(),
)
};
u16::from_le_bytes([b[6], b[7]])
}
/// Azimuth as the unsigned table index. The field is stored signed but /// Azimuth as the unsigned table index. The field is stored signed but
/// indexes 0..=255, so the byte is what matters, not its sign. /// indexes 0..=255, so the byte is what matters, not its sign.
pub fn azimuth_index(&self) -> u8 { pub fn azimuth_index(&self) -> u8 {
@@ -960,6 +979,7 @@ fn read_lights(block: &[u8], at: usize, count: usize) -> Result<Vec<TfragLight>>
azimuth: block[i + 2] as i8, azimuth: block[i + 2] as i8,
elevation: block[i + 3] as i8, elevation: block[i + 3] as i8,
color: i16::from_le_bytes([block[i + 4], block[i + 5]]), color: i16::from_le_bytes([block[i + 4], block[i + 5]]),
select: u16::from_le_bytes([block[i + 6], block[i + 7]]),
}) })
.collect()) .collect())
} }
+61 -3
View File
@@ -528,14 +528,72 @@ 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 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 *colour* data that is missing lighting, not a flag that was left off.
The real model is `LightTfrags`, which needs three things: The real model is `LightTfrags`. The PS2 float model and the pass itself are
now written (`gc-engine::ps2`, `gc-engine::tfrag_light`); what is missing is the
light bank. The three inputs were:
* the **light records** -- already parsed: azimuth and elevation as *indices* * the **light records** -- already parsed: azimuth and elevation as *indices*
into the overlay's sine table, plus the base colour; into the overlay's sine table, plus the base colour;
* the **normal table** -- now decoded (`gc_formats::tfrag_light`), and normals * the **normal table** -- now decoded (`gc_formats::tfrag_light`), and normals
are built and fed to the renderer; are built and fed to the renderer;
* the **light bank** from the level's `gameplay` extent -- framed and parsed, * the **light bank** from the level's `gameplay` extent -- **not working**,
but the per-vertex evaluation is not written yet. see below.
### Why the frame is still dark: the light bank does not parse on RAC2
The reference frames the bank as "a u32 offset at +0x04, then an `i32` count
there, then the sets". On RAC2 level 0 that offset is `0xa0d`, and the `i32`
there is `698810368` -- not a count, and not a plausible one. `gameplay.bin`
begins with `0x794e...`, not with a header of the shape the reference expects,
so RAC2's gameplay extent is not laid out this way, or is compressed before
this point.
The pass therefore never runs. Rather than let the renderer quietly draw the
stored colours and look plausible, it counts what it lit and says so:
```text
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 honest state, and it is why the frame has not changed. What
`gameplay.bin` actually is, and where RAC2 keeps its light bank, is the open
question — `0x794e...` looks like it may be a compressed or encrypted
container, in which case the bank is somewhere else entirely.
### The PS2 float model
`gc-engine::ps2` is the EE's FPU on raw `u32` bit patterns, and it is not
`f32`:
* **no denormals** -- exponent 0 *is* zero, whatever the mantissa
* **no NaN, no infinity** -- exponent 255 is an ordinary value, which means
`MAX` reads back in Rust as NaN. A test asserts exactly that, because it is
the clearest single statement of why this module exists.
* **every result truncates toward zero**, and `1/3` comes out below `1/3`
* **the adder aligns operands**: the smaller operand loses the bits more than
one below the larger's LSB, and a 25-bit exponent gap returns the larger
operand unchanged
* **`rsqrt` is sqrt-then-divide**, with truncation between, not one rounded step
`mul`, `add`, `div`, `sqrt`, `itof12` and `max`/`min` all differ from `f32` in
at least one case a lighting pass will hit. The pass accumulates them across
every vertex of every patch, so this is a visible difference rather than a
rounding curiosity.
### The lighting pass itself
Per vertex: `base + A.rgb * max(dA, dA*wA) + B.rgb * max(dB, dB*wB)`, where
`base` is the record's 5:5:5:1 colour expanded by PEXT5, `dA`/`dB` are
three-component dot products against the light directions, and `wA`/`wB` come
out of the colour w lanes. `select` picks one light set or blends two by
`bits 8..15 / 256`, and a blended direction is re-normalised where an unblended
one is not.
One field had to be added to `TfragLight` to carry this: `select`, the `u16` at
+6 of the record. It was briefly reconstructed with an `unsafe` pointer cast
over the struct -- which is exactly the kind of thing that breaks silently the
moment a field is reordered, and is now a plain parsed field.
### The normal table, and why it is found by searching ### The normal table, and why it is found by searching
+14 -6
View File
@@ -42,12 +42,20 @@ early, while they are still cheap to retire.
level 5 `0xcca30`); it is located by matching the whole 256-entry curve. level 5 `0xcca30`); it is located by matching the whole 256-entry curve.
Normals are negated as stored and get the same axis swap as positions. Normals are negated as stored and get the same axis swap as positions.
The pipeline now also writes `overlay.bin`. The pipeline now also writes `overlay.bin`.
- [ ] **tfrag lighting pass.** The light bank from `gameplay` is framed and - [x] **The PS2 float model.** `gc-engine::ps2` is the EE's FPU on raw bits: no
parsed; the per-vertex evaluation is not written yet. It is denormals, no NaN or infinity, truncation toward zero, the adder's
`base + A*max(dA, dA*wA) + B*max(dB, dB*wB)` and has to run in the PS2 operand alignment, and `rsqrt` as sqrt-then-divide. `MAX` reads back in
float model. Until then the renderer stays unlit by default; Rust as NaN, which is the sharpest available statement of why this is
`GC_ENGINE_LIT=1` substitutes a host directional light for inspection, not `f32`.
which is not the game's lighting. - [x] **The `LightTfrags` pass** is written and unit-tested against the PS2
model: PEXT5 base colour, `select` set blending, re-normalised blended
directions, and `base + A*max(dA, dA*wA) + B*max(dB, dB*wB)`.
- [ ] **The level light bank does not parse on RAC2.** The reference's framing
(u32 offset at +0x04, i32 count there) yields offset `0xa0d` and a
nonsense count on level 0. The renderer now reports how many vertices it
actually lit, which is currently 0 of 80,274 -- so the frame falls back
to the stored colours and says so rather than looking plausibly lit.
Detail in `plan/level-assets.md`.
- [ ] Then the GS register set and the guard-band clip test. - [ ] 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