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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user