No behaviour change. This is the pass over the last stretch of work to make sure the accumulated result hangs together rather than only each commit in isolation. Code. lit_color had grown a `let _ = (depth, lod_distances)` and a dead branch from repeated edits. The directional pass genuinely does not depend on distance, so the parameters are gone rather than discarded, with a note that the point lights -- not implemented -- will need them. lit_at folded into lit_color, which had become a wrapper around it. Checked for leftover scaffolding: only two env vars remain, both real (GC_ENGINE_SHOT and GC_ENGINE_DEBUG_BOUNDS); GC_ENGINE_UNLIT, GC_ENGINE_DEBUG_COLOR and GC_ENGINE_ABL were never actually written, because those edits silently failed to apply against rustfmt'd source. AGENTS.md. The crate table did not say that gc-engine owns hardware models. ps2.rs is the Emotion Engine's FPU and the lighting pass runs on it, so the boundary is now stated: gc-formats decodes inputs, gc-engine reproduces how the hardware computes with them. That is the distinction rule 2 depends on, and it was implicit. Docs. Three claims had been overtaken by what we learned and were actively misleading: * docs/formats/level-wad.md still said gameplay "does not decode with the WAD decoder". It does; it is a WAD stream. * docs/plan/level-assets.md carried a whole section, "a compression we have not identified", with a ruled-out list including "bit order", "header length" and "the WAD format at all". All three were ruled out against the wrong input. Replaced, with the failure mode kept: a uniform failure across 27 inputs was read as "the decoder is wrong" when it meant "the data was handed over uncompressed", and the positive control -- decompress and look -- was never run. * The same file still said the tfrag "record framing is not recovered". True, but it was a false lead that has since been superseded by the VIF1 framing. Kept as a recorded wrong turn rather than deleted. Also refreshed "Still open" to name what is actually outstanding: point lights, the GS register set, the guard-band clip test, the unread core_index class tables, and -- stated plainly -- that lighting magnitudes are unverified because there is still nothing to compare against. Verified after the changes: 148 tests pass, clippy and fmt clean, all 5,213 extracted files match the hash table, and level 0 still loads at 26,758 triangles with the same LOD split and fully lit.
699 lines
24 KiB
Rust
699 lines
24 KiB
Rust
//! Turning a level's parsed tfrags into something the renderer can draw.
|
|
//!
|
|
//! Nothing here parses bytes; `gc-formats` has done that. This is the join
|
|
//! between the format layer and the renderer: it applies LOD selection and
|
|
//! morphing for a given camera, then groups triangles by the texture they
|
|
//! sample so each texture is one draw rather than one per triangle.
|
|
//!
|
|
//! # Coordinates
|
|
//!
|
|
//! The PS2 world is **Z-up** and 1024 raw units to the world unit. Bevy is
|
|
//! Y-up and metres, so `(x, y, z) -> (x, z, -y)`. The sign flip on the third
|
|
//! axis is what keeps the level from coming out mirrored: it turns the PS2's
|
|
//! right-handed frame into Bevy's left-handed one rather than negating a
|
|
//! determinant.
|
|
//!
|
|
//! # Depth
|
|
//!
|
|
//! The game measures view-space depth, this uses distance from the camera.
|
|
//! They agree for a camera looking down -Z with no roll, which is all a
|
|
//! fly-camera needs; anything else should move the projection into
|
|
//! `gc-formats::tfrag::draw_mode`'s caller rather than being fudged here.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use gc_formats::level_core::{texture_table, LevelCoreHeader};
|
|
use gc_formats::texture::{base_image, Texture};
|
|
use gc_formats::tfrag::{
|
|
parse_tfrag, tfrag_lod_link, tfrag_triangles, TfragAdGifs, TfragDrawMode, TfragGeometry,
|
|
TfragHeader, TfragsBlock,
|
|
};
|
|
use gc_formats::tfrag_light::{LightBank, NormalTable};
|
|
|
|
/// Raw position units per world unit.
|
|
pub const UNITS_PER_UNIT: f32 = 1024.0;
|
|
|
|
/// A level's geometry, decoded and ready to draw.
|
|
pub struct LevelGeometry {
|
|
/// Draw batches keyed by level texture index.
|
|
pub batches: BTreeMap<usize, TriangleBatch>,
|
|
/// One decoded image per texture actually referenced.
|
|
pub textures: BTreeMap<usize, Texture>,
|
|
pub patches: Vec<PatchBounds>,
|
|
pub lod_distances: [f32; 3],
|
|
pub lod_thresholds_raw: [i32; 3],
|
|
/// Patches that drew nothing, and why they were skipped.
|
|
pub stats: LevelStats,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, Copy)]
|
|
pub struct LevelStats {
|
|
pub patches: usize,
|
|
pub drawn: usize,
|
|
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 {
|
|
pub centre: [f32; 3],
|
|
pub radius: f32,
|
|
pub base_only: bool,
|
|
pub mode: TfragDrawMode,
|
|
pub depth: f32,
|
|
}
|
|
|
|
/// One draw batch: every triangle sampling a single texture.
|
|
pub struct TriangleBatch {
|
|
pub positions: Vec<[f32; 3]>,
|
|
pub uvs: Vec<[f32; 2]>,
|
|
pub colors: Vec<[f32; 4]>,
|
|
/// Surface normals, from the patch's own light records.
|
|
pub normals: Vec<[f32; 3]>,
|
|
/// Three vertex indices per triangle.
|
|
pub indices: Vec<u32>,
|
|
/// The ad-gif in force per triangle, parallel to `indices`.
|
|
pub ad_gifs: Vec<TfragAdGifs>,
|
|
}
|
|
|
|
/// Everything a level's tfrags need from the unpacked disc.
|
|
pub struct LevelSource<'a> {
|
|
pub core_data: &'a [u8],
|
|
pub core_index: &'a [u8],
|
|
pub gs_ram: &'a [u8],
|
|
/// 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
|
|
/// a radius that contains them.
|
|
///
|
|
/// The camera has to start here rather than at the origin. Level geometry is
|
|
/// absolutely positioned, not centred, and level 0's terrain spans x 96..434
|
|
/// and y -36..472 in world units -- far enough that a camera at the origin
|
|
/// puts every patch beyond the LOD switch distance and the whole level draws
|
|
/// at its coarsest.
|
|
pub fn level_bounds(src: &LevelSource<'_>) -> anyhow::Result<([f32; 3], f32)> {
|
|
let region = |off: usize| -> usize {
|
|
i32::from_le_bytes(src.core_index[off..off + 4].try_into().unwrap()).max(0) as usize
|
|
};
|
|
let start = region(0x08);
|
|
let end = [0x0c_usize, 0x10, 0x14]
|
|
.iter()
|
|
.map(|&o| region(o))
|
|
.filter(|&v| v > 0)
|
|
.min()
|
|
.unwrap_or(src.core_data.len());
|
|
let block = &src.core_data[start..end];
|
|
let header = TfragsBlock::parse(block)?;
|
|
|
|
let mut lo = [f32::INFINITY; 3];
|
|
let mut hi = [f32::NEG_INFINITY; 3];
|
|
let mut n = 0usize;
|
|
for i in 0..header.tfrag_count as usize {
|
|
let Ok(th) = header.header(block, i) else {
|
|
continue;
|
|
};
|
|
let c = to_world([th.bsphere[0], th.bsphere[1], th.bsphere[2]]);
|
|
let r = th.bsphere[3] / UNITS_PER_UNIT;
|
|
for k in 0..3 {
|
|
lo[k] = lo[k].min(c[k] - r);
|
|
hi[k] = hi[k].max(c[k] + r);
|
|
}
|
|
n += 1;
|
|
}
|
|
if n == 0 {
|
|
anyhow::bail!("level has no patches");
|
|
}
|
|
let centre = [
|
|
(lo[0] + hi[0]) / 2.0,
|
|
(lo[1] + hi[1]) / 2.0,
|
|
(lo[2] + hi[2]) / 2.0,
|
|
];
|
|
let radius = (0..3)
|
|
.map(|k| ((hi[k] - lo[k]) / 2.0).powi(2))
|
|
.sum::<f32>()
|
|
.sqrt();
|
|
Ok((centre, radius))
|
|
}
|
|
|
|
/// PS2 world units to Bevy metres, Z-up to Y-up.
|
|
pub fn to_world(p: [f32; 3]) -> [f32; 3] {
|
|
[
|
|
p[0] / UNITS_PER_UNIT,
|
|
p[2] / UNITS_PER_UNIT,
|
|
-p[1] / UNITS_PER_UNIT,
|
|
]
|
|
}
|
|
|
|
/// World position of a vertex-info entry in raw PS2 units, before the axis
|
|
/// swap.
|
|
///
|
|
/// Morphing has to happen in this space: mixing a converted parent with an
|
|
/// unconverted child silently scales the blend by 1024.
|
|
pub fn raw_position(g: &TfragGeometry, vinfo: usize) -> [f32; 3] {
|
|
let local = g.positions[g.position_index(vinfo)];
|
|
[
|
|
(g.origin[0] + local[0]) as f32,
|
|
(g.origin[1] + local[1]) as f32,
|
|
(g.origin[2] + local[2]) as f32,
|
|
]
|
|
}
|
|
|
|
/// World position of a vertex-info entry, in Bevy metres.
|
|
pub fn world(g: &TfragGeometry, vinfo: usize) -> [f32; 3] {
|
|
to_world(raw_position(g, vinfo))
|
|
}
|
|
|
|
/// Texture coordinate of a vertex-info entry, as 0..1. The on-disc field is
|
|
/// signed 1/4096 fixed point.
|
|
pub fn uv(g: &TfragGeometry, vinfo: usize) -> [f32; 2] {
|
|
let v = g.vertex_info.get(vinfo).copied().unwrap_or_default();
|
|
[v.s as f32 / 4096.0, v.t as f32 / 4096.0]
|
|
}
|
|
|
|
/// A vertex as the GPU wants it, after LOD morphing and collapse.
|
|
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 surface normal of a vertex's position, in Bevy space.
|
|
///
|
|
/// Taken from the patch's per-vertex light record, which stores an azimuth and
|
|
/// elevation index into the overlay's sine table rather than an angle. Morphing
|
|
/// does not rotate the normal -- the game lerps positions and leaves lighting
|
|
/// to its own pass -- so this is the vertex's own normal regardless of blend.
|
|
pub fn vertex_normal(g: &TfragGeometry, vinfo: usize, table: Option<&NormalTable>) -> [f32; 3] {
|
|
let Some(table) = table else {
|
|
return [0.0, 1.0, 0.0];
|
|
};
|
|
let i = g.position_index(vinfo);
|
|
let Some(light) = g.lights.get(i) else {
|
|
return [0.0, 1.0, 0.0];
|
|
};
|
|
let n = table.normal(light.azimuth_index(), light.elevation_index());
|
|
// Same Z-up to Y-up swap as positions, or the lighting comes out sideways.
|
|
[n[0], n[2], -n[1]]
|
|
}
|
|
|
|
/// A vertex's colour after the lighting pass, and whether it was lit at all.
|
|
///
|
|
/// 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.
|
|
///
|
|
/// Takes no distance. The directional pass does not depend on it; the point
|
|
/// lights, which are not implemented yet, will.
|
|
///
|
|
/// Falls back to the tfrag's stored colour when there is no usable light bank.
|
|
/// That is a real difference from the game, so the caller reports how many
|
|
/// vertices were actually lit rather than quietly showing stored colours as
|
|
/// though they were lit.
|
|
fn lit_color(
|
|
g: &TfragGeometry,
|
|
vinfo: usize,
|
|
stored: [f32; 4],
|
|
lighting: Option<(&NormalTable, &LightBank)>,
|
|
) -> ([f32; 4], bool) {
|
|
let Some((table, bank)) = lighting.filter(|(_, b)| !b.sets.is_empty()) else {
|
|
return (stored, false);
|
|
};
|
|
let i = g.position_index(vinfo);
|
|
let Some(light) = g.lights.get(i) else {
|
|
return ([1.0, 1.0, 1.0, 1.0], true);
|
|
};
|
|
let n = crate::tfrag_light::vertex_normal_bits(
|
|
table,
|
|
light.azimuth_index(),
|
|
light.elevation_index(),
|
|
);
|
|
match crate::tfrag_light::light_vertex(bank, n, light) {
|
|
Some(c) => (
|
|
[
|
|
c[0] as f32 / 255.0,
|
|
c[1] as f32 / 255.0,
|
|
c[2] as f32 / 255.0,
|
|
1.0,
|
|
],
|
|
true,
|
|
),
|
|
// The light set this vertex names does not exist. Count it as unlit
|
|
// rather than inventing a colour.
|
|
None => (stored, false),
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// Two things happen here and they are not the same. **Morphing** blends the
|
|
/// vertex toward the midpoint of its two parents across its tier's distance
|
|
/// band. **Collapse** replaces the vertex outright -- position *and* UV -- with
|
|
/// its first parent once it is past the band's far edge, which is what stops
|
|
/// distant terrain from shimmering. Morphing is a blend; collapse is a cut.
|
|
pub fn resolve_vertex(
|
|
g: &TfragGeometry,
|
|
vinfo: usize,
|
|
depth: f32,
|
|
lod_distances: [f32; 3],
|
|
collapse: bool,
|
|
) -> ResolvedVertex {
|
|
let Some(link) = tfrag_lod_link(g, vinfo) else {
|
|
return ResolvedVertex {
|
|
position: world(g, vinfo),
|
|
uv: uv(g, vinfo),
|
|
color: vertex_color(g, vinfo),
|
|
};
|
|
};
|
|
let limit = link.tier.collapse_distance(lod_distances);
|
|
if collapse && depth >= limit {
|
|
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);
|
|
if u <= 0.0 {
|
|
return ResolvedVertex {
|
|
position: world(g, vinfo),
|
|
uv: uv(g, vinfo),
|
|
color: vertex_color(g, vinfo),
|
|
};
|
|
}
|
|
let p1 = raw_position(g, link.parent1_vinfo);
|
|
let p2 = g.positions[link.parent2_position];
|
|
let mid = [
|
|
(p1[0] + p2[0] as f32) / 2.0,
|
|
(p1[1] + p2[1] as f32) / 2.0,
|
|
(p1[2] + p2[2] as f32) / 2.0,
|
|
];
|
|
let here = raw_position(g, vinfo);
|
|
let blended = [
|
|
here[0] + (mid[0] - here[0]) * u,
|
|
here[1] + (mid[1] - here[1]) * u,
|
|
here[2] + (mid[2] - here[2]) * u,
|
|
];
|
|
// 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),
|
|
}
|
|
}
|
|
|
|
/// Whether a draw mode collapses vertices past their tier's limit rather than
|
|
/// only morphing them.
|
|
pub fn mode_collapses(mode: TfragDrawMode) -> bool {
|
|
matches!(
|
|
mode,
|
|
TfragDrawMode::Lod1Collapse | TfragDrawMode::Lod0Collapse | TfragDrawMode::Lod2
|
|
)
|
|
}
|
|
|
|
/// 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)?;
|
|
|
|
let region = |off: usize| -> usize {
|
|
i32::from_le_bytes(src.core_index[off..off + 4].try_into().unwrap()).max(0) as usize
|
|
};
|
|
let start = region(0x08);
|
|
let end = [0x0c_usize, 0x10, 0x14]
|
|
.iter()
|
|
.map(|&o| region(o))
|
|
.filter(|&v| v > 0)
|
|
.min()
|
|
.unwrap_or(src.core_data.len());
|
|
if start >= end {
|
|
anyhow::bail!("tfrags region is empty");
|
|
}
|
|
let block = &src.core_data[start..end];
|
|
let header = TfragsBlock::parse(block)?;
|
|
let header_off = header.table_offset as usize;
|
|
let thresholds = header.lod_thresholds_raw();
|
|
let lod_distances = header.lod_distances();
|
|
|
|
let textures_base = ch.textures_base.max(0) as usize;
|
|
let texture_block = src.core_data.get(textures_base..).ok_or_else(|| {
|
|
anyhow::anyhow!("textures base {textures_base} is past the end of core data")
|
|
})?;
|
|
|
|
let mut stats = LevelStats {
|
|
patches: header.tfrag_count as usize,
|
|
..Default::default()
|
|
};
|
|
let mut patches = Vec::with_capacity(header.tfrag_count as usize);
|
|
let mut batches: BTreeMap<usize, TriangleBatch> = BTreeMap::new();
|
|
|
|
for i in 0..header.tfrag_count as usize {
|
|
let th: TfragHeader = match header.header(block, i) {
|
|
Ok(h) => h,
|
|
Err(_) => {
|
|
stats.skipped_unparsed += 1;
|
|
continue;
|
|
}
|
|
};
|
|
let data_base = header_off.wrapping_add(th.data as usize);
|
|
if data_base >= block.len() {
|
|
stats.skipped_unparsed += 1;
|
|
continue;
|
|
}
|
|
let g = parse_tfrag(&block[data_base..], &th)?;
|
|
|
|
let centre = to_world([th.bsphere[0], th.bsphere[1], th.bsphere[2]]);
|
|
let radius = th.bsphere[3] / UNITS_PER_UNIT;
|
|
let depth = distance(camera, centre).max(0.0);
|
|
let mode = g.draw_mode(depth * UNITS_PER_UNIT, thresholds);
|
|
let collapse = mode_collapses(mode);
|
|
let lod = mode.lod();
|
|
|
|
let tris = tfrag_triangles(&g, lod)?;
|
|
let limit = g.lod_vinfo_count(lod);
|
|
let mut drawn_here = 0usize;
|
|
|
|
for t in tris {
|
|
let vi = [t.a as usize, t.b as usize, t.c as usize];
|
|
if vi.iter().any(|&v| v >= limit) {
|
|
continue;
|
|
}
|
|
let Some(ad) = g.ad_gifs.get(t.ad_gif as usize) else {
|
|
continue;
|
|
};
|
|
let batch = batches
|
|
.entry(ad.texture_index())
|
|
.or_insert_with(|| TriangleBatch {
|
|
positions: Vec::new(),
|
|
uvs: Vec::new(),
|
|
colors: Vec::new(),
|
|
normals: Vec::new(),
|
|
indices: Vec::new(),
|
|
ad_gifs: Vec::new(),
|
|
});
|
|
let base = batch.positions.len() as u32;
|
|
for v in vi {
|
|
let r = resolve_vertex(&g, v, depth, lod_distances, collapse);
|
|
batch.positions.push(r.position);
|
|
batch.uvs.push(r.uv);
|
|
let (colour, was_lit) = lit_color(&g, v, r.color, lighting);
|
|
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]);
|
|
batch.ad_gifs.push(*ad);
|
|
drawn_here += 1;
|
|
}
|
|
|
|
stats.triangles += drawn_here;
|
|
if drawn_here > 0 {
|
|
stats.drawn += 1;
|
|
}
|
|
patches.push(PatchBounds {
|
|
centre,
|
|
radius,
|
|
base_only: th.base_only != 0,
|
|
mode,
|
|
depth,
|
|
});
|
|
}
|
|
|
|
let mut textures = BTreeMap::new();
|
|
for &tex in batches.keys() {
|
|
if let Some(e) = table.get(tex) {
|
|
if let Ok(img) = base_image(texture_block, src.gs_ram, e) {
|
|
if let Ok(t) = img.decode() {
|
|
textures.insert(tex, t);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for b in batches.values() {
|
|
stats.vertices += b.positions.len();
|
|
}
|
|
// The vertex bounds are the check that matters: positions are i16 offsets
|
|
// from a per-patch origin, so a wrong origin collapses the level to a
|
|
// +-32 unit blob around the world origin while every parser test still
|
|
// passes. Print the real extent to confirm the level is where the patch
|
|
// bounding spheres say it is.
|
|
if std::env::var_os("GC_ENGINE_DEBUG_BOUNDS").is_some() {
|
|
let mut lo = [f32::INFINITY; 3];
|
|
let mut hi = [f32::NEG_INFINITY; 3];
|
|
for b in batches.values() {
|
|
for p in &b.positions {
|
|
for k in 0..3 {
|
|
lo[k] = lo[k].min(p[k]);
|
|
hi[k] = hi[k].max(p[k]);
|
|
}
|
|
}
|
|
}
|
|
eprintln!("vertex bounds lo {lo:?} hi {hi:?}");
|
|
}
|
|
|
|
Ok(LevelGeometry {
|
|
batches,
|
|
textures,
|
|
patches,
|
|
lod_distances,
|
|
lod_thresholds_raw: thresholds,
|
|
stats,
|
|
})
|
|
}
|
|
|
|
impl LevelGeometry {
|
|
pub fn triangle_count(&self) -> usize {
|
|
self.batches.values().map(|b| b.indices.len() / 3).sum()
|
|
}
|
|
}
|
|
|
|
fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
|
|
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_axis_swap_keeps_up_up() {
|
|
// PS2 +Z is up, so it must land on Bevy +Y.
|
|
let w = to_world([0.0, 0.0, 2048.0]);
|
|
assert_eq!(w, [0.0, 2.0, 0.0]);
|
|
// PS2 +X is right, so it stays right.
|
|
assert_eq!(to_world([1024.0, 0.0, 0.0])[0], 1.0);
|
|
// PS2 +Y is forward, which is Bevy -Z.
|
|
assert_eq!(to_world([0.0, 1024.0, 0.0])[2], -1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn only_collapse_modes_collapse() {
|
|
assert!(mode_collapses(TfragDrawMode::Lod2));
|
|
assert!(mode_collapses(TfragDrawMode::Lod0Collapse));
|
|
assert!(mode_collapses(TfragDrawMode::Lod1Collapse));
|
|
assert!(!mode_collapses(TfragDrawMode::Lod0));
|
|
assert!(!mode_collapses(TfragDrawMode::Lod0Morph));
|
|
assert!(!mode_collapses(TfragDrawMode::Lod1Morph));
|
|
}
|
|
|
|
#[test]
|
|
fn a_common_vertex_does_not_move() {
|
|
let mut g = TfragGeometry {
|
|
origin: [0, 0, 0, 0],
|
|
positions: vec![[1024, 0, 0], [0, 2048, 0]],
|
|
vertex_info: vec![
|
|
gc_formats::tfrag::TfragVertexInfo {
|
|
s: 0,
|
|
t: 0,
|
|
parent: 0,
|
|
vertex: 0,
|
|
},
|
|
gc_formats::tfrag::TfragVertexInfo {
|
|
s: 4096,
|
|
t: 2048,
|
|
parent: 0,
|
|
vertex: 2,
|
|
},
|
|
],
|
|
..TfragGeometry::default()
|
|
};
|
|
let v = resolve_vertex(&g, 0, 999.0, [192.0, 128.0, 64.0], true);
|
|
assert_eq!(v.position, [1.0, 0.0, 0.0]);
|
|
assert_eq!(v.uv, [0.0, 0.0]);
|
|
// UVs are 1/4096 fixed point.
|
|
assert_eq!(uv(&g, 1), [1.0, 0.5]);
|
|
g.origin = [0, 0, 0, 0];
|
|
}
|
|
|
|
#[test]
|
|
fn morphing_blends_toward_the_parent_midpoint() {
|
|
// One common vertex at the origin, one LOD-0 vertex far away whose two
|
|
// parents are both the common one, so the midpoint is the common one.
|
|
let mut g = TfragGeometry {
|
|
positions: vec![[0, 0, 0], [4096, 0, 0]],
|
|
vertex_info: vec![
|
|
gc_formats::tfrag::TfragVertexInfo {
|
|
s: 0,
|
|
t: 0,
|
|
parent: 0,
|
|
vertex: 0,
|
|
},
|
|
gc_formats::tfrag::TfragVertexInfo {
|
|
s: 0,
|
|
t: 0,
|
|
parent: 0,
|
|
vertex: 2,
|
|
},
|
|
],
|
|
vinfo_tier: [1, 0, 1],
|
|
vu_positions_lod_0_count: 1,
|
|
parent_indices_lod0: vec![0],
|
|
..TfragGeometry::default()
|
|
};
|
|
g.origin = [0, 0, 0, 0];
|
|
// Fully morphed: the vertex sits on the midpoint of its two parents.
|
|
let full = resolve_vertex(&g, 1, 200.0, [192.0, 128.0, 64.0], false);
|
|
assert!(
|
|
(full.position[0] - 0.0).abs() < 1e-4,
|
|
"got {:?}",
|
|
full.position
|
|
);
|
|
// Not morphed: it stays where it is.
|
|
let none = resolve_vertex(&g, 1, 0.0, [192.0, 128.0, 64.0], false);
|
|
assert!(
|
|
(none.position[0] - 4.0).abs() < 1e-4,
|
|
"got {:?}",
|
|
none.position
|
|
);
|
|
// Collapsed: replaced outright by the first parent.
|
|
let cut = resolve_vertex(&g, 1, 200.0, [192.0, 128.0, 64.0], true);
|
|
assert!(
|
|
(cut.position[0] - 0.0).abs() < 1e-4,
|
|
"got {:?}",
|
|
cut.position
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod normal_tests {
|
|
use super::*;
|
|
use gc_formats::tfrag::TfragLight;
|
|
use gc_formats::tfrag_light::NormalTable;
|
|
|
|
fn table() -> NormalTable {
|
|
// 256 entries of (cos, sin) at i * pi / 128, as the overlay holds them.
|
|
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 geometry_with_light(azimuth: i16, elevation: i16) -> TfragGeometry {
|
|
TfragGeometry {
|
|
positions: vec![[0, 0, 0]],
|
|
vertex_info: vec![gc_formats::tfrag::TfragVertexInfo {
|
|
s: 0,
|
|
t: 0,
|
|
parent: 0,
|
|
vertex: 0,
|
|
}],
|
|
lights: vec![TfragLight {
|
|
unknown_0: 0,
|
|
intensity: 0,
|
|
azimuth: azimuth as i8,
|
|
elevation: elevation as i8,
|
|
color: 0,
|
|
select: 0,
|
|
}],
|
|
..TfragGeometry::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_light_record_indexes_the_table() {
|
|
// Azimuth 0, elevation 0 is -X in the table, which becomes -X in Bevy.
|
|
let g = geometry_with_light(0, 0);
|
|
let n = vertex_normal(&g, 0, Some(&table()));
|
|
assert!((n[0] + 1.0).abs() < 1e-5, "{n:?}");
|
|
assert!(n[1].abs() < 1e-5 && n[2].abs() < 1e-5, "{n:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn normals_get_the_same_axis_swap_as_positions() {
|
|
// Elevation 64 is pi/2, so the table gives a normal along Z, negated.
|
|
let g = geometry_with_light(0, 64);
|
|
let n = vertex_normal(&g, 0, Some(&table()));
|
|
// PS2 (0, 0, -1) must land on Bevy -Y.
|
|
assert!(n[0].abs() < 1e-5 && n[2].abs() < 1e-5, "{n:?}");
|
|
assert!((n[1] + 1.0).abs() < 1e-5, "{n:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_unsigned_index_is_used_even_when_the_field_reads_negative() {
|
|
// 200 as an i8 is -56, but it indexes table entry 200, not 56.
|
|
let g = geometry_with_light(-56, 0);
|
|
let from_field = vertex_normal(&g, 0, Some(&table()));
|
|
let mut g2 = g.clone();
|
|
g2.lights[0].azimuth = -56;
|
|
assert_eq!(from_field, vertex_normal(&g2, 0, Some(&table())));
|
|
}
|
|
|
|
#[test]
|
|
fn missing_normals_fall_back_rather_than_panicking() {
|
|
let g = geometry_with_light(0, 0);
|
|
assert_eq!(vertex_normal(&g, 0, None), [0.0, 1.0, 0.0]);
|
|
let empty = TfragGeometry::default();
|
|
assert_eq!(vertex_normal(&empty, 7, Some(&table())), [0.0, 1.0, 0.0]);
|
|
}
|
|
}
|