//! 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, /// One decoded image per texture actually referenced. pub textures: BTreeMap, pub patches: Vec, 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, /// The ad-gif in force per triangle, parallel to `indices`. pub ad_gifs: Vec, } /// 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::() .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. // `gc_formats::gs::alpha_to_8` states this rule now; kept inline here // because this path wants a float, and the rule has only one live site. 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 { 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 = 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]); } }