Add tfrag LOD selection, morph weights and parent linkage

The block header's float at +0x08 gives L and the switch distances are
[D0, D1, D2] = [6L, 4L, 2L] world units -- 192/128/64 on every level
measured. The distance test uses the patch's bounding sphere truncated
to integers against trunc(D * 1024), not the sphere's centre, so a
patch straddling a threshold takes the coarser mode. Six modes result:
LOD 2, LOD 1 collapse, LOD 1 morph, LOD 0 collapse, LOD 0 morph, LOD 0.

A morph weight runs 0 to 1 across its tier's band. Past the far edge an
entry is not blended but replaced by its first parent -- a collapse.

The tested property is that distance never moves back to a finer mode,
swept across the whole range, because that is what keeps terrain from
popping as the camera moves.

The four V4_8 parent arrays are classified by VU address and an
unrecognised address is now an error, which is a real check: all 18,102
patches still parse. Every recorded parent resolves to real vertex info
and real positions, and no common entry claims a parent.

That is as far as it goes, and the limit is a finding rather than a
rounding error. The addresses are not unique: on level 0 patch 1,
unk_indices_2_lod_01_addr == parent_indices_lod_0_addr == 291, with
unk_06 and unk_0a both zero, so nothing but unpacking order separates
them. Across all 27 levels 57,782 of 374,349 LOD entries resolve, and
316,548 unresolved LOD-0 entries against 28,739 resolved is far too
lopsided to be genuine -- either the arrays are misassigned where
addresses collide, or RAC2's positions_lod_0_count means something other
than RAC1's. Only 3,274 of 18,102 patches carry enough V4_8 bytes to
cover positions_lod_01_count + positions_lod_0_count + extras.

The vertex-info tiers themselves are confirmed: the addresses at +9 and
+11 match the observed V4_16 unpacks and the three tier counts sum to
vert_count on every patch, so the header layout is right and it is
specifically the parent arrays that are open. Ruled out so far:
address-only classification (collides) and one-parent-per-position (fails
on 15,000 patches). Not yet tried: ordering by VU address instead of by
declaration, and using unk_06/unk_0a to suppress an empty tier before
falling through. Written up in plan/level-assets.md.
This commit is contained in:
2026-10-10 02:36:40 +02:00
parent 29fdf89f53
commit 2a5307a157
4 changed files with 542 additions and 9 deletions
+347 -2
View File
@@ -78,6 +78,22 @@ impl TfragsBlock {
})
}
/// LOD switch distances `[D0, D1, D2] = [6L, 4L, 2L]` in world units,
/// where `L` is the block's LOD distance (32.0 on every level measured).
///
/// D0 is the far end of LOD 1, D1 the far end of LOD 0, and D2 the point
/// where LOD-0 vertices start morphing.
pub fn lod_distances(&self) -> [f32; 3] {
let l = self.lod_distance;
[l * 6.0, l * 4.0, l + l]
}
/// The integer thresholds the distance test compares against, in raw units
/// (1024 = 1 world unit).
pub fn lod_thresholds_raw(&self) -> [i32; 3] {
self.lod_distances().map(|d| (d * 1024.0) as i32)
}
/// The tfrag header at index `i`, given the whole block.
pub fn header(&self, bytes: &[u8], i: usize) -> Result<TfragHeader> {
let off = self.table_offset as usize + i * TFRAG_HEADER_SIZE;
@@ -123,6 +139,8 @@ pub struct TfragHeader {
pub lod_2_size: u8,
pub lod_1_size: u8,
pub lod_0_size: u8,
/// Non-zero: always draw LOD 2 and skip the distance test entirely.
pub base_only: u8,
pub texture_count: u8,
pub rgba_size: u8,
pub msphere_ofs: u16,
@@ -160,7 +178,7 @@ impl TfragHeader {
let _lod_2_rgba = b.u8("tfrag lod_2_rgba_count")?;
let _lod_1_rgba = b.u8("tfrag lod_1_rgba_count")?;
let _lod_0_rgba = b.u8("tfrag lod_0_rgba_count")?;
let _base_only = b.u8("tfrag base_only")?;
let base_only = b.u8("tfrag base_only")?;
let texture_count = b.u8("tfrag texture_count")?;
let rgba_size = b.u8("tfrag rgba_size")?;
let _rgba_verts_loc = b.u8("tfrag rgba_verts_loc")?;
@@ -192,6 +210,7 @@ impl TfragHeader {
lod_2_size,
lod_1_size,
lod_0_size,
base_only,
texture_count,
rgba_size,
msphere_ofs,
@@ -376,6 +395,10 @@ pub struct TfragVuHeader {
pub vertex_info_lod_01_addr: u16,
pub vertex_info_lod_0_addr: u16,
pub indices_addr: u16,
pub parent_indices_lod_01_addr: u16,
pub unk_indices_2_lod_01_addr: u16,
pub parent_indices_lod_0_addr: u16,
pub unk_indices_2_lod_0_addr: u16,
pub strips_addr: u16,
}
@@ -395,6 +418,10 @@ impl TfragVuHeader {
vertex_info_lod_01_addr: w[9],
vertex_info_lod_0_addr: w[11],
indices_addr: w[13],
parent_indices_lod_01_addr: w[14],
unk_indices_2_lod_01_addr: w[15],
parent_indices_lod_0_addr: w[16],
unk_indices_2_lod_0_addr: w[17],
strips_addr: w[18],
})
}
@@ -597,7 +624,22 @@ pub struct TfragGeometry {
/// Index 0 is highest detail.
pub lod: [TfragLod; 3],
/// Texture primitives; strips select one by ad-gif index.
/// LOD-01 and LOD-0 position counts from the VU header. These drive the
/// primary/extra split in the parent-index arrays.
/// Radius of the patch's bounding sphere, in raw units (1024 = 1 world unit).
pub bsphere_radius: f32,
/// Non-zero: always draw LOD 2 and skip the distance test.
pub base_only: u8,
pub vu_positions_lod_01_count: usize,
pub vu_positions_lod_0_count: usize,
pub ad_gifs: Vec<TfragAdGifs>,
/// For each primary LOD-01 vertex-info entry, the vertex-info index of its
/// first parent. The second parent is that entry's own `parent` field.
pub parent_indices_lod01: Vec<u8>,
/// Same, for the extra (non-position) LOD-01 entries.
pub unk_indices_2_lod01: Vec<u8>,
pub parent_indices_lod0: Vec<u8>,
pub unk_indices_2_lod0: Vec<u8>,
/// Per-vertex colours, indexed by position index.
pub rgba: Vec<TfragRgba>,
/// Per-vertex light and normal records, indexed by position index.
@@ -628,6 +670,32 @@ impl TfragGeometry {
}
}
/// The draw path for this tfrag at view depth `centre_depth_raw`, in raw
/// units. Frustum culling and the guard-band clip test are separate.
///
/// The bounds are the patch's bounding sphere truncated to integers, so a
/// sphere straddling a threshold picks the coarser mode — which is what
/// keeps geometry from popping as the camera moves.
pub fn draw_mode(&self, centre_depth_raw: f32, thresholds_raw: [i32; 3]) -> TfragDrawMode {
let r = self.bsphere_radius;
let far = (centre_depth_raw + r) as i32;
let near = (centre_depth_raw - r) as i32;
let [d0, d1, d2] = thresholds_raw;
if self.base_only != 0 || near >= d0 {
TfragDrawMode::Lod2
} else if far >= d0 {
TfragDrawMode::Lod1Collapse
} else if near >= d1 {
TfragDrawMode::Lod1Morph
} else if far >= d1 {
TfragDrawMode::Lod0Collapse
} else if near >= d2 || far >= d2 {
TfragDrawMode::Lod0Morph
} else {
TfragDrawMode::Lod0
}
}
/// World-space position of a vertex-info entry, `(origin + local) / 1024`.
pub fn world_position(&self, vinfo: usize) -> [f32; 3] {
let local = self.positions[self.position_index(vinfo)];
@@ -691,6 +759,8 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
match (v.vn(), v.vl()) {
(3, 1) if v.usn() && addr == 0 => {
vu = TfragVuHeader::parse(payload)?;
g.vu_positions_lod_01_count = vu.positions_lod_01_count as usize;
g.vu_positions_lod_0_count = vu.positions_lod_0_count as usize;
have_vu = true;
}
// Ad-gifs: 5 quadwords each.
@@ -741,10 +811,29 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
}
}
(3, 2) => {
let bytes = &payload[..v.count() * 4];
if !v.usn() && addr == vu.strips_addr {
strips = read_strips(payload, v.count())?;
} else if addr == vu.indices_addr {
indices = payload[..v.count() * 4].to_vec();
indices = bytes.to_vec();
} else if addr == vu.parent_indices_lod_01_addr
&& vu.positions_lod_01_count != 0
{
g.parent_indices_lod01 = bytes.to_vec();
} else if addr == vu.unk_indices_2_lod_01_addr {
g.unk_indices_2_lod01 = bytes.to_vec();
} else if addr == vu.parent_indices_lod_0_addr
&& vu.positions_lod_0_count != 0
{
g.parent_indices_lod0 = bytes.to_vec();
} else if addr == vu.unk_indices_2_lod_0_addr {
g.unk_indices_2_lod0 = bytes.to_vec();
} else {
return Err(Error {
offset: pos,
context: "unexpected V4_8 unpack address in tfrag list",
kind: ErrorKind::Invalid("unexpected V4_8 unpack address"),
});
}
}
_ => {}
@@ -771,6 +860,8 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
}
// These three arrays sit outside the VIF lists, at fixed offsets in the
// patch's data block.
g.bsphere_radius = header.bsphere[3];
g.base_only = header.base_only;
g.rgba = read_rgba(
block,
header.rgba_ofs as usize,
@@ -988,6 +1079,153 @@ pub fn tfrag_triangles(g: &TfragGeometry, lod: usize) -> Result<Vec<TfragTriangl
Ok(out)
}
/// The draw path chosen for a tfrag from its distance.
///
/// The two collapse modes are not just a coarser LOD: an entry whose parents
/// are both past the collapse distance is *replaced* by its parent rather than
/// morphed, which is what stops distant terrain shimmering.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TfragDrawMode {
/// LOD 2 list, no morph.
Lod2,
/// LOD 1 lists; LOD-01 vertices morph and collapse onto parent 1 once both
/// parents are past D0.
Lod1Collapse,
/// LOD 1 lists; LOD-01 vertices morph.
Lod1Morph,
/// LOD 0 lists; LOD-01 morphs, LOD-0 morphs with collapse at D1.
Lod0Collapse,
/// LOD 0 lists; LOD-01 at rest, LOD-0 morphs.
Lod0Morph,
/// LOD 0 lists, no morph.
Lod0,
}
impl TfragDrawMode {
/// Which strip and index list is drawn. 0 is highest detail.
pub fn lod(self) -> usize {
match self {
TfragDrawMode::Lod2 => 2,
TfragDrawMode::Lod1Collapse | TfragDrawMode::Lod1Morph => 1,
_ => 0,
}
}
/// The MSCAL address the game writes for the tfrag's VU1 program.
pub fn mscal(self) -> u8 {
match self {
TfragDrawMode::Lod2 => 6,
TfragDrawMode::Lod1Collapse => 8,
TfragDrawMode::Lod1Morph => 0xa,
TfragDrawMode::Lod0Collapse => 0xe,
TfragDrawMode::Lod0Morph => 0x10,
TfragDrawMode::Lod0 => 0x14,
}
}
}
/// Which tier a morphable vertex-info entry belongs to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TfragMorphTier {
/// Fades between D1 and D0.
Lod01,
/// Fades between D2 and D1.
Lod0,
}
impl TfragMorphTier {
/// Morph amount in 0..=1 for a vertex at view depth `depth`, in world units:
/// 0 is the vertex's own position, 1 is its parents' midpoint.
pub fn weight(self, depth: f32, lod_distances: [f32; 3]) -> f32 {
let [d0, d1, d2] = lod_distances;
let (near, far) = match self {
TfragMorphTier::Lod01 => (d1, d0),
TfragMorphTier::Lod0 => (d2, d1),
};
if (far - near).abs() < f32::EPSILON {
return 0.0;
}
((depth - near) / (far - near)).clamp(0.0, 1.0)
}
/// Distance past which an entry is replaced by its first parent.
pub fn collapse_distance(self, lod_distances: [f32; 3]) -> f32 {
match self {
TfragMorphTier::Lod01 => lod_distances[0],
TfragMorphTier::Lod0 => lod_distances[1],
}
}
}
/// The LOD linkage of one LOD-01 or LOD-0 vertex-info entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TfragLodLink {
pub tier: TfragMorphTier,
/// True for the tier's first `positions_*_count` entries, which the VU
/// morphs. Extra entries only collapse.
pub morphs: bool,
/// Position index of this entry, `vertex / 2`.
pub own_position: usize,
/// Vertex-info index of the first parent. Also the collapse replacement.
pub parent1_vinfo: usize,
/// Position index of the first parent.
pub parent1_position: usize,
/// Position index of the second parent, from this entry's `parent` field.
pub parent2_position: usize,
}
/// LOD linkage of vertex-info entry `vinfo`, or `None` for common entries, out
/// of range indices, or an entry with no parent recorded.
pub fn tfrag_lod_link(g: &TfragGeometry, vinfo: usize) -> Option<TfragLodLink> {
let common = g.vinfo_tier[0];
let l01 = common..common + g.vinfo_tier[1];
let l0 = l01.end..l01.end + g.vinfo_tier[2];
let (tier, k, primaries, parents, extras) = if l01.contains(&vinfo) {
(
TfragMorphTier::Lod01,
vinfo - l01.start,
g.vu_positions_lod_01_count,
&g.parent_indices_lod01,
&g.unk_indices_2_lod01,
)
} else if l0.contains(&vinfo) {
(
TfragMorphTier::Lod0,
vinfo - l0.start,
g.vu_positions_lod_0_count,
&g.parent_indices_lod0,
&g.unk_indices_2_lod0,
)
} else {
return None;
};
let (morphs, p1) = if k < primaries {
(true, *parents.get(k)? as usize)
} else {
(false, *extras.get(k - primaries)? as usize)
};
let e = *g.vertex_info.get(vinfo)?;
let pos = |v: i16| -> Option<usize> {
let p = v as isize as usize / 2;
(p < g.positions.len()).then_some(p)
};
Some(TfragLodLink {
tier,
morphs,
own_position: pos(e.vertex)?,
parent1_vinfo: p1,
parent1_position: pos(g.vertex_info.get(p1)?.vertex)?,
parent2_position: pos(e.parent)?,
})
}
/// Position indices a morphing entry blends toward, `(parent1, parent2)`.
pub fn tfrag_morph_parents(g: &TfragGeometry, vinfo: usize) -> Option<(usize, usize)> {
tfrag_lod_link(g, vinfo)
.filter(|l| l.morphs)
.map(|l| (l.parent1_position, l.parent2_position))
}
/// Vertex positions of a tfrag, in game order.
pub fn tfrag_positions(block: &[u8], header: &TfragHeader) -> Option<Vec<[i32; 3]>> {
parse_tfrag(block, header).ok().map(|g| g.positions)
@@ -1575,3 +1813,110 @@ mod ad_gif_tests {
assert_eq!(tris[2].ad_gif, 1);
}
}
#[cfg(test)]
mod lod_tests {
use super::*;
fn block() -> TfragsBlock {
TfragsBlock {
table_offset: 0x40,
tfrag_count: 1,
lod_distance: 32.0,
reserved: 0,
}
}
#[test]
fn switch_distances_scale_from_the_block_parameter() {
let b = block();
assert_eq!(b.lod_distances(), [192.0, 128.0, 64.0]);
assert_eq!(b.lod_thresholds_raw(), [196_608, 131_072, 65_536]);
}
/// A patch with the given bounding-sphere radius and `base_only` flag.
fn geometry(radius: f32, base_only: u8) -> TfragGeometry {
TfragGeometry {
bsphere_radius: radius,
base_only,
..TfragGeometry::default()
}
}
#[test]
fn a_sphere_straddling_a_threshold_takes_the_coarser_mode() {
let th = [196_608, 131_072, 65_536];
// Well inside LOD 0, then stepping back through every boundary.
let mode = |d: f32| geometry(1000.0, 0).draw_mode(d, th);
assert_eq!(mode(10_000.0), TfragDrawMode::Lod0);
assert_eq!(mode(70_000.0), TfragDrawMode::Lod0Morph); // near >= D2
assert_eq!(mode(131_000.0), TfragDrawMode::Lod0Collapse); // far >= D1
assert_eq!(mode(150_000.0), TfragDrawMode::Lod1Morph); // near >= D1
assert_eq!(mode(196_000.0), TfragDrawMode::Lod1Collapse); // far >= D0
assert_eq!(mode(200_000.0), TfragDrawMode::Lod2); // near >= D0
}
#[test]
fn the_mode_is_monotonic_in_distance() {
// Distance must never make the mesh finer again.
let th = [196_608, 131_072, 65_536];
let order = |m: TfragDrawMode| match m {
TfragDrawMode::Lod0 => 0,
TfragDrawMode::Lod0Morph => 1,
TfragDrawMode::Lod0Collapse => 2,
TfragDrawMode::Lod1Morph => 3,
TfragDrawMode::Lod1Collapse => 4,
TfragDrawMode::Lod2 => 5,
};
let g = geometry(4000.0, 0);
let mut last = 0;
for d in (0..600_000).step_by(997) {
let now = order(g.draw_mode(d as f32, th));
assert!(now >= last, "mode went backwards at depth {d}");
last = now;
}
}
#[test]
fn base_only_pins_a_patch_to_lod_2() {
let th = [196_608, 131_072, 65_536];
let g = geometry(1000.0, 1);
assert_eq!(g.draw_mode(0.0, th), TfragDrawMode::Lod2);
}
#[test]
fn each_mode_maps_to_a_lod_and_an_mscal() {
for (m, lod, mscal) in [
(TfragDrawMode::Lod2, 2usize, 6u8),
(TfragDrawMode::Lod1Collapse, 1, 8),
(TfragDrawMode::Lod1Morph, 1, 0xa),
(TfragDrawMode::Lod0Collapse, 0, 0xe),
(TfragDrawMode::Lod0Morph, 0, 0x10),
(TfragDrawMode::Lod0, 0, 0x14),
] {
assert_eq!(m.lod(), lod);
assert_eq!(m.mscal(), mscal);
}
}
#[test]
fn morph_weight_runs_from_zero_to_one_across_its_band() {
let d = [192.0, 128.0, 64.0];
// LOD-01 fades between D1 and D0; LOD 0 between D2 and D1.
assert_eq!(TfragMorphTier::Lod01.weight(128.0, d), 0.0);
assert_eq!(TfragMorphTier::Lod01.weight(160.0, d), 0.5);
assert_eq!(TfragMorphTier::Lod01.weight(192.0, d), 1.0);
assert_eq!(TfragMorphTier::Lod01.weight(500.0, d), 1.0);
assert_eq!(TfragMorphTier::Lod01.weight(0.0, d), 0.0);
assert_eq!(TfragMorphTier::Lod0.weight(64.0, d), 0.0);
assert_eq!(TfragMorphTier::Lod0.weight(96.0, d), 0.5);
assert_eq!(TfragMorphTier::Lod0.weight(128.0, d), 1.0);
}
#[test]
fn collapse_distances_are_the_far_edge_of_each_band() {
let d = [192.0, 128.0, 64.0];
assert_eq!(TfragMorphTier::Lod01.collapse_distance(d), 192.0);
assert_eq!(TfragMorphTier::Lod0.collapse_distance(d), 128.0);
}
}