diff --git a/crates/gc-engine/Cargo.toml b/crates/gc-engine/Cargo.toml index c5fb1c2..4612b28 100644 --- a/crates/gc-engine/Cargo.toml +++ b/crates/gc-engine/Cargo.toml @@ -13,3 +13,7 @@ thiserror.workspace = true gc-formats.workspace = true gc-game.workspace = true bevy.workspace = true + +[[bin]] +name = "gc-engine" +path = "src/main.rs" diff --git a/crates/gc-engine/src/level.rs b/crates/gc-engine/src/level.rs new file mode 100644 index 0000000..1daec1f --- /dev/null +++ b/crates/gc-engine/src/level.rs @@ -0,0 +1,501 @@ +//! 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, +}; + +/// 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, +} + +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]>, + /// 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], +} + +/// 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], +} + +/// 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), + }; + }; + 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), + }; + } + if !link.morphs { + return ResolvedVertex { + position: world(g, vinfo), + uv: uv(g, vinfo), + }; + } + let u = link.tier.weight(depth, lod_distances); + if u <= 0.0 { + return ResolvedVertex { + position: world(g, vinfo), + uv: uv(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 are not morphed: the game keeps the high-detail texture coordinate. + ResolvedVertex { + position: to_world(blended), + uv: uv(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 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(), + 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); + } + 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 + ); + } +} diff --git a/crates/gc-engine/src/lib.rs b/crates/gc-engine/src/lib.rs index ef40d36..2f3fe4c 100644 --- a/crates/gc-engine/src/lib.rs +++ b/crates/gc-engine/src/lib.rs @@ -33,3 +33,5 @@ //! comparing frames. Without that loop, "looks close" and "is correct" drift //! apart, and this game's difficulty and movement make that drift very //! noticeable. + +pub mod level; diff --git a/crates/gc-engine/src/main.rs b/crates/gc-engine/src/main.rs new file mode 100644 index 0000000..2f2b14e --- /dev/null +++ b/crates/gc-engine/src/main.rs @@ -0,0 +1,456 @@ +//! A fly-camera over one level's terrain. +//! +//! The first thing in the project that puts the disc's own geometry on screen. +//! It is deliberately crude: no GS emulation, no sky, no tie/moby, no fog, no +//! lighting. Its job is to answer the one question parser tests cannot — +//! whether everything decoded so far actually looks like a place. +//! +//! Run it against unpacked level data: +//! +//! ```text +//! cargo run -p gc-engine -- [level] +//! ``` +//! +//! Controls: `A`/`D` orbit, `W`/`S` dolly, `Q`/`E` change height, `R` reloads +//! the level so LOD selection can be seen changing. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use bevy::asset::RenderAssetUsages; +use bevy::image::{ImageFilterMode, ImageSampler, ImageSamplerDescriptor}; +use bevy::prelude::*; +use bevy::render::mesh::{Indices, PrimitiveTopology}; +use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; +use bevy::render::view::window::screenshot::{CapturedScreenshots, Screenshot}; +use bevy::window::WindowResolution; + +use gc_engine::level::{build_level, level_bounds, LevelGeometry, LevelSource, TriangleBatch}; + +fn main() -> Result<()> { + let args: Vec = std::env::args().skip(1).collect(); + let (dir, level) = match args.len() { + 0 => { + println!("usage: gc-engine [level]"); + return Ok(()); + } + 1 => (PathBuf::from(&args[0]), 0usize), + _ => ( + PathBuf::from(&args[0]), + args[1].parse().context("level number")?, + ), + }; + + // Read the level once here rather than in a startup system: a resource + // inserted through `Commands` is deferred to the end of the schedule, so + // `setup` could not see a focus inserted that way. + let (files, focus) = load_source(&dir, level)?; + let geometry = build_level(&files.as_source(), [focus[0], focus[1] + 150.0, focus[2]])?; + report(&geometry); + + let mut app = App::new(); + app.add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + title: format!("Going Commando — level{level}"), + resolution: WindowResolution::new(1280, 720), + ..default() + }), + ..default() + })) + .add_systems(Startup, (first_load, setup).chain()) + .add_systems(Update, (orbit, reload, request_shot, save_shot)) + .insert_resource(Level(level)) + .insert_resource(LoadedDir(dir)) + .insert_resource(Focus(focus)) + .insert_resource(LoadedFiles(files)) + .init_resource::(); + + if let Ok(path) = std::env::var("GC_ENGINE_SHOT") { + app.insert_resource(Shot { + path: PathBuf::from(path), + frames: 20, + }); + } + app.run(); + Ok(()) +} + +#[derive(Resource)] +struct LoadedDir(PathBuf); + +/// The level's bytes, loaded in `main` so every system sees the same data. +#[derive(Resource)] +struct LoadedFiles(LevelFiles); + +/// The middle of the level's terrain, which is what the camera orbits. +#[derive(Resource)] +struct Focus([f32; 3]); + +/// Set `GC_ENGINE_SHOT` to a path to capture one frame and exit. +/// +/// Everything here can be verified by parsing, and the parse can be wrong in +/// ways no assertion catches -- a mirrored axis, a UV in the wrong half of the +/// texture, a hundredfold scale error all produce a well-formed frame. One +/// look is worth more than any of them. +#[derive(Resource)] +struct Shot { + path: std::path::PathBuf, + frames: u32, +} + +fn request_shot( + mut commands: Commands, + shot: Res, + mut frame: Local, + mut done: Local, +) { + *frame += 1; + if *done || *frame < shot.frames { + return; + } + *done = true; + commands.spawn(Screenshot::primary_window()); +} + +fn save_shot( + captured: Res, + shot: Res, + mut exit: MessageWriter, +) { + let Ok((_entity, image)) = captured.0.lock().unwrap().try_recv() else { + return; + }; + let (w, h) = (image.width(), image.height()); + // Bevy hands back RGBA; PPM has no alpha, so composite over black rather + // than pretending the level has an alpha channel. + let Some(data) = image.data.as_ref() else { + eprintln!("screenshot returned no pixels"); + let _ = exit.write(bevy::app::AppExit::error()); + return; + }; + let mut out = format!("P6\n{w} {h}\n255\n").into_bytes(); + for px in data.chunks_exact(4) { + out.extend_from_slice(&px[..3]); + } + if let Err(e) = std::fs::write(&shot.path, &out) { + eprintln!("screenshot: {e}"); + } else { + eprintln!("screenshot written to {}", shot.path.display()); + } + let _ = exit.write(bevy::app::AppExit::Success); +} + +#[derive(Resource)] +struct Level(usize); + +#[derive(Resource)] +struct Orbit { + azimuth: f32, + pitch: f32, + distance: f32, + height: f32, +} + +impl Default for Orbit { + fn default() -> Self { + // Looking down at roughly 30 degrees from a few hundred units out. + Orbit { + azimuth: 0.6, + pitch: 0.5, + distance: 260.0, + height: 40.0, + } + } +} + +fn setup(mut commands: Commands, focus: Res, orbit: Res) { + // Start where `orbit` would put it. `orbit` only runs when a key changes + // something, so a camera spawned anywhere else sits at its initial pose + // until the first keypress -- and pointing at the world origin, which is + // nowhere near the terrain, that is an empty screen. + let target = Vec3::from_array(focus.0); + let (sin_p, cos_p) = orbit.pitch.sin_cos(); + let (sin_a, cos_a) = orbit.azimuth.sin_cos(); + let offset = Vec3::new( + cos_p * cos_a * orbit.distance, + sin_p * orbit.distance, + cos_p * sin_a * orbit.distance, + ); + commands.spawn(( + Camera3d::default(), + Transform::from_translation(target + offset).looking_at(target, Vec3::Y), + // A long far plane: levels are hundreds of units across and the whole + // level is drawn at once, so culling is the patch bounding spheres' + // job, not the projection's. + Projection::Perspective(PerspectiveProjection { + far: 8000.0, + ..default() + }), + )); + // Ambient only. The game's lighting is a per-vertex light array that is + // parsed but not yet reproduced, and a directional light would make the + // terrain look lit when it is not. + commands.spawn(AmbientLight { + color: Color::WHITE, + brightness: 1600.0, + ..default() + }); +} + +/// Load the level once at startup. It is the same path `R` takes afterwards, +/// so the first frame and a reload cannot disagree. +/// Spawn the level `main` already loaded. +fn first_load( + mut commands: Commands, + images: ResMut>, + meshes: ResMut>, + materials: ResMut>, + files: Res, + focus: Res, +) { + let camera = [focus.0[0], focus.0[1] + 150.0, focus.0[2]]; + match build_level(&files.0.as_source(), camera) { + Ok(g) => spawn_level(&mut commands, images, meshes, materials, &g), + Err(e) => eprintln!("could not build level: {e:#}"), + } +} + +fn reload( + keys: Res>, + mut commands: Commands, + images: ResMut>, + meshes: ResMut>, + materials: ResMut>, + level: Res, + dir: Res, +) { + if !keys.just_pressed(KeyCode::KeyR) { + return; + } + let src = match load(&dir.0, level.0) { + Ok(s) => s, + Err(e) => { + eprintln!("reload failed: {e:#}"); + return; + } + }; + spawn_level(&mut commands, images, meshes, materials, &src); +} + +/// Read a level's three files and find the middle of its terrain. +fn load_source(dir: &std::path::Path, level: usize) -> Result<(LevelFiles, [f32; 3])> { + let level_dir = dir.join(format!("level{level}")); + let core_data = std::fs::read(level_dir.join("core_data.bin")).context("core_data.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 src = LevelSource { + core_data: &core_data, + core_index: &core_index, + gs_ram: &gs_ram, + }; + let (centre, _radius) = level_bounds(&src)?; + Ok(( + LevelFiles { + core_data, + core_index, + gs_ram, + }, + centre, + )) +} + +/// The level's bytes, owned so a system can outlive the load call. +struct LevelFiles { + core_data: Vec, + core_index: Vec, + gs_ram: Vec, +} + +impl LevelFiles { + fn as_source(&self) -> LevelSource<'_> { + LevelSource { + core_data: &self.core_data, + core_index: &self.core_index, + gs_ram: &self.gs_ram, + } + } +} + +/// Load a level as seen from above the middle of its terrain. +fn load(dir: &std::path::Path, level: usize) -> Result { + let (files, focus) = load_source(dir, level)?; + build_level(&files.as_source(), [focus[0], focus[1] + 150.0, focus[2]]) +} + +fn spawn_level( + commands: &mut Commands, + mut images: ResMut>, + mut meshes: ResMut>, + mut materials: ResMut>, + g: &LevelGeometry, +) { + report(g); + for (image, mesh) in to_gpu(g) { + let texture = images.add(image); + let material = materials.add(StandardMaterial { + base_color_texture: Some(texture), + // Double-sided: the strips as stored do not carry a consistent + // winding, and the game relies on GS defaults rather than culling. + cull_mode: None, + ..default() + }); + commands.spawn(( + Mesh3d(meshes.add(mesh)), + MeshMaterial3d(material), + Transform::IDENTITY, + )); + } +} + +fn report(g: &LevelGeometry) { + let s = &g.stats; + eprintln!( + "level: {} patches, {} drawn, {} unparsed | {tri} triangles, {v} vertices, {t} textures\n\ + LOD distances {d:?} (raw {r:?})", + s.patches, + s.drawn, + s.skipped_unparsed, + tri = s.triangles, + v = s.vertices, + t = g.textures.len(), + d = g.lod_distances, + r = g.lod_thresholds_raw, + ); + let mut modes: BTreeMap<&str, usize> = BTreeMap::new(); + for p in &g.patches { + *modes.entry(mode_name(p)).or_default() += 1; + } + for (m, n) in modes { + eprintln!(" {m}: {n} patches"); + } +} + +fn mode_name(p: &gc_engine::level::PatchBounds) -> &'static str { + use gc_formats::tfrag::TfragDrawMode::*; + match p.mode { + Lod2 => "LOD2", + Lod1Collapse => "LOD1 collapse", + Lod1Morph => "LOD1 morph", + Lod0Collapse => "LOD0 collapse", + Lod0Morph => "LOD0 morph", + Lod0 => "LOD0", + } +} + +fn to_gpu(g: &LevelGeometry) -> Vec<(Image, Mesh)> { + let mut out = Vec::new(); + for (&tex, batch) in &g.batches { + let Some(image) = g.textures.get(&tex) else { + continue; + }; + let Some(mesh) = mesh_of(batch) else { continue }; + out.push((image_of(image), mesh)); + } + out +} + +fn image_of(t: &gc_formats::texture::Texture) -> Image { + let mut image = Image::new( + Extent3d { + width: t.width, + height: t.height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + t.rgba.clone(), + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::default(), + ); + // Only mip level 0 is decoded, so minification must clamp rather than + // invent a level the disc does not have here. + image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { + label: Some("tfrag texture".into()), + mipmap_filter: ImageFilterMode::Linear, + mag_filter: ImageFilterMode::Linear, + ..default() + }); + image +} + +fn mesh_of(b: &TriangleBatch) -> Option { + if b.indices.is_empty() { + return None; + } + let mut mesh = Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::default(), + ); + mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, b.positions.clone()); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, b.uvs.clone()); + // No normals and no colours: lighting is the game's own per-vertex light + // array, which is parsed but not yet reproduced. Inventing normals would + // make unlit terrain look lit and hide that gap. + mesh.insert_indices(Indices::U32(b.indices.clone())); + Some(mesh) +} + +fn orbit( + keys: Res>, + time: Res