Render a level: first drawable frame, and the origin bug it exposed

Adds gc-engine's binary and a level.rs that joins the format layer to the
renderer: LOD selection and morphing per patch, then triangles grouped by
texture so each texture is one draw rather than one per triangle.

The PS2 world is Z-up at 1024 raw units per world unit, so the conversion is
(x, y, z) -> (x, z, -y). The sign flip on the third axis turns the PS2's
right-handed frame into Bevy's left-handed one rather than negating a
determinant, which is what stops the level rendering mirrored.

Morphing and collapse are kept distinct. Morphing blends a vertex toward the
midpoint of its two parents across its tier's band; collapse replaces the
vertex outright, position and UV, once it is past the far edge. Morphing is a
blend, collapse is a cut, and conflating them makes distant terrain
shimmer. Blending has to happen in raw units before the axis swap: mixing a
converted parent with an unconverted child scales the blend by 1024.

Level 0 renders as 26,758 textured triangles over 92 textures, correct UVs,
with LOD selection live (26 patches at LOD 0, 650 at LOD 2 from the start
camera). GC_ENGINE_SHOT writes one frame as a PPM and exits.

Rendering immediately exposed a bug no test could. Positions are i16 offsets
from a per-patch origin, and the obvious rule -- the last non-index STROW row --
is wrong on RAC2, which also uses STROW for per-vertex light records that come
*after* the origin. Taking the last one yields [186, 186, 186, 186] and
collapses the whole level onto the world origin. The entire level then fit in
a 63-unit box and every test still passed: vert_count matched, tri_count
matched, every parent and triangle index resolved, because all of those are
relative to the same broken origin. Only a screenshot showed it. The rule that
works is the STROW immediately preceding the first V3_16 position unpack; with
it, level 0 spans x 86..444, y 46..211, z -477..40, matching the patch
bounding spheres.

That is worth more than the renderer: a parser can be completely
self-consistent and still describe the wrong thing, and where a value is
absolute rather than relative it has to be checked against something
independent.

The frame is flat and dark because there are no normals and no lighting yet.
That is the next task -- the per-vertex light records -- not something to
paper over with an exposure fudge.
This commit is contained in:
2026-10-10 02:58:43 +02:00
parent 71a3185fa5
commit 192dcde247
7 changed files with 1019 additions and 12 deletions
+4
View File
@@ -13,3 +13,7 @@ thiserror.workspace = true
gc-formats.workspace = true gc-formats.workspace = true
gc-game.workspace = true gc-game.workspace = true
bevy.workspace = true bevy.workspace = true
[[bin]]
name = "gc-engine"
path = "src/main.rs"
+501
View File
@@ -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<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,
}
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<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],
}
/// 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],
}
/// 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<LevelGeometry> {
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(),
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
);
}
}
+2
View File
@@ -33,3 +33,5 @@
//! comparing frames. Without that loop, "looks close" and "is correct" drift //! comparing frames. Without that loop, "looks close" and "is correct" drift
//! apart, and this game's difficulty and movement make that drift very //! apart, and this game's difficulty and movement make that drift very
//! noticeable. //! noticeable.
pub mod level;
+456
View File
@@ -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 -- <unpacked-dir> [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<String> = std::env::args().skip(1).collect();
let (dir, level) = match args.len() {
0 => {
println!("usage: gc-engine <unpacked-dir> [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::<Orbit>();
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<Shot>,
mut frame: Local<u32>,
mut done: Local<bool>,
) {
*frame += 1;
if *done || *frame < shot.frames {
return;
}
*done = true;
commands.spawn(Screenshot::primary_window());
}
fn save_shot(
captured: Res<CapturedScreenshots>,
shot: Res<Shot>,
mut exit: MessageWriter<AppExit>,
) {
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<Focus>, orbit: Res<Orbit>) {
// 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<Assets<Image>>,
meshes: ResMut<Assets<Mesh>>,
materials: ResMut<Assets<StandardMaterial>>,
files: Res<LoadedFiles>,
focus: Res<Focus>,
) {
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<ButtonInput<KeyCode>>,
mut commands: Commands,
images: ResMut<Assets<Image>>,
meshes: ResMut<Assets<Mesh>>,
materials: ResMut<Assets<StandardMaterial>>,
level: Res<Level>,
dir: Res<LoadedDir>,
) {
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<u8>,
core_index: Vec<u8>,
gs_ram: Vec<u8>,
}
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<LevelGeometry> {
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<Assets<Image>>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
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<Mesh> {
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<ButtonInput<KeyCode>>,
time: Res<Time>,
mut cam: Query<&mut Transform, With<Camera3d>>,
mut o: ResMut<Orbit>,
focus: Res<Focus>,
) {
let dt = time.delta_secs();
let mut changed = false;
if keys.pressed(KeyCode::KeyA) {
o.azimuth += dt;
changed = true;
}
if keys.pressed(KeyCode::KeyD) {
o.azimuth -= dt;
changed = true;
}
if keys.pressed(KeyCode::KeyW) {
o.distance = (o.distance - dt * 150.0).max(5.0);
changed = true;
}
if keys.pressed(KeyCode::KeyS) {
o.distance += dt * 150.0;
changed = true;
}
if keys.pressed(KeyCode::KeyQ) {
o.height += dt * 40.0;
changed = true;
}
if keys.pressed(KeyCode::KeyE) {
o.height -= dt * 40.0;
changed = true;
}
if keys.pressed(KeyCode::ArrowUp) {
o.pitch = (o.pitch + dt * 0.8).clamp(-1.5, 1.5);
changed = true;
}
if keys.pressed(KeyCode::ArrowDown) {
o.pitch = (o.pitch - dt * 0.8).clamp(-1.5, 1.5);
changed = true;
}
if !changed {
return;
}
let (sin_p, cos_p) = o.pitch.sin_cos();
let (sin_a, cos_a) = o.azimuth.sin_cos();
let offset = Vec3::new(
cos_p * cos_a * o.distance,
sin_p * o.distance,
cos_p * sin_a * o.distance,
);
// Orbit the middle of the terrain rather than the world origin: level
// geometry is absolutely positioned and nowhere near the origin.
let target = Vec3::from_array(focus.0);
for mut t in &mut cam {
*t = Transform::from_translation(target + offset + Vec3::new(0.0, o.height, 0.0))
.looking_at(target, Vec3::Y);
}
}
+15 -10
View File
@@ -723,6 +723,7 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
let mut vu = TfragVuHeader::default(); let mut vu = TfragVuHeader::default();
let mut have_vu = false; let mut have_vu = false;
let mut have_origin = false; let mut have_origin = false;
let mut last_strow: Option<[i32; 4]> = None;
let mut strips: Vec<TfragStrip> = Vec::new(); let mut strips: Vec<TfragStrip> = Vec::new();
let mut indices: Vec<u8> = Vec::new(); let mut indices: Vec<u8> = Vec::new();
@@ -750,16 +751,10 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
let payload = &block[pos + 4..pos + v.packet_size()]; let payload = &block[pos + 4..pos + v.packet_size()];
if v.cmd == STROW { if v.cmd == STROW {
// The origin row is the last STROW that is not the vertex-info // Remember the row; the origin is the one that immediately
// row, and it precedes every position unpack. // precedes the first position unpack.
if payload.len() >= 16 if payload.len() >= 16 {
&& u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]) last_strow = read_i32x4(payload, 0);
!= 0x4500_0000
{
for (i, slot) in g.origin.iter_mut().enumerate() {
*slot = i32::from_le_bytes(payload[i * 4..i * 4 + 4].try_into().unwrap());
}
have_origin = true;
} }
} else if v.is_unpack() { } else if v.is_unpack() {
let addr = v.vu_address(); let addr = v.vu_address();
@@ -781,6 +776,16 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
} }
} }
(2, 1) => { (2, 1) => {
// The first position unpack is preceded by the origin
// row. Taking the *last* STROW instead picks up the
// per-vertex light rows, which RAC2 also uses STROW
// for, and collapses the whole level onto the origin.
if !have_origin {
if let Some(row) = last_strow {
g.origin = row;
have_origin = true;
}
}
let tier = if addr == vu.positions_common_addr { let tier = if addr == vu.positions_common_addr {
0 0
} else if g.positions_tier[1] == 0 } else if g.positions_tier[1] == 0
+28
View File
@@ -488,6 +488,34 @@ never claims a parent.
Ruled out along the way: address-only classification (addresses collide), and Ruled out along the way: address-only classification (addresses collide), and
one parent array entry per unpack element (wrong unit). one parent array entry per unpack element (wrong unit).
### The origin, and why no parser test could catch it
Positions are `i16`, so a single patch spans at most +-32 world units around
its own origin. The origin comes from an STROW row, and the obvious rule --
the last non-index STROW in the list -- is **wrong on RAC2**. RAC2 also uses
STROW for the per-vertex light records, and those rows come *after* the origin,
so taking the last one yields `[186, 186, 186, 186]` and every patch collapses
onto the world origin.
Nothing in the test suite noticed, and that is the point worth recording:
* `vert_count` still matched, because it counts positions, not places.
* `tri_count` still matched, because the topology never changed.
* Every parent, morph target and triangle index still resolved, because those
are all relative to the same broken origin.
The entire level fit in a 63 x 62 x 62 unit box centred on the origin, and all
of it was self-consistent. Only rendering exposed it, and only because the
screenshot put the camera where the bounding spheres said the terrain was.
The rule that works: **the origin is the STROW immediately preceding the first
V3_16 position unpack** in the common list. With that, level 0's vertices span
x 86..444, y 46..211, z -477..40, which matches the patch bounding spheres.
Lesson for the rest of the project: a parser can be entirely self-consistent
and still describe the wrong thing. Where a value is absolute rather than
relative, check it against an independent source -- here, the bounding spheres.
### Still open ### Still open
The GS register values the level load builds from the texture entry (TBP0/TBP1 The GS register values the level load builds from the texture entry (TBP0/TBP1
+13 -2
View File
@@ -20,8 +20,19 @@ early, while they are still cheap to retire.
header, and the `data` extent is parsed as a container of byte ranges. The header, and the `data` extent is parsed as a container of byte ranges. The
geometry decompresses: level 0 yields 16,969,296 bytes of level content geometry decompresses: level 0 yields 16,969,296 bytes of level content
from a 9.7 MB stream. from a 9.7 MB stream.
- [ ] Next: the first drawable frame — build the GS register set and a - [x] **First drawable frame.** `gc-engine` loads a level, applies LOD selection
Bevy fly-camera over level 0's tfrags. and morphing for the camera, groups triangles by texture and draws them
under a Bevy fly-camera. Level 0 renders as 26,758 textured triangles
across 92 textures with correct UVs. Set `GC_ENGINE_SHOT=<path.ppm>` to
capture a frame and exit.
- [ ] **The origin bug this exposed.** Positions are i16 offsets from a
per-patch origin, so taking the wrong STROW collapsed the whole level to
a +-32 unit blob at the world origin while every parser test still
passed: the vertex count matched, the triangle count matched, only the
*place* was wrong. Fixed by taking the STROW immediately preceding the
first position unpack rather than the last one.
- [ ] Next: the per-vertex light records, which is why the first frame is flat
and dark. Then the GS register set and the guard-band clip test.
The disc being the exact build the reference projects pin is the single The disc being the exact build the reference projects pin is the single
biggest favourable fact so far: no disc conversion, no regional timing work, biggest favourable fact so far: no disc conversion, no regional timing work,