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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user