Sets the terrain material's alpha mode, which is the first draw-path change the
GS work has actually produced.
The material used `..default()`, which is AlphaMode::Opaque: every texel drawn
fully solid regardless of what its alpha said. Given 0.98% of texels on the disc
are fully clear, that means roughly 227,000 texels that should have shown
background are drawn as opaque geometry.
The choice is Blend, not Mask, and the reason is the histogram from 25d73b4:
- Mask(f32) is a *quantising* cutout. Below the threshold -> transparent, at or
above it -> fully opaque. Feeding it the 1.08% partial texels would flatten
every one of them to solid, discarding exactly the soft edges they exist to
produce. 126 distinct values become two.
- Blend handles all three populations at once. A clear texel blends to invisible,
a partial one stays smooth, an opaque one stays opaque.
Which retires the conclusion I gave two commits ago. I said cutout-first, on the
reasoning that the clear texels need discarding and a partial texel drawn opaque
is a failure nobody notices. But Blend does not need a cutout to hide those
texels -- a zero-alpha fragment is invisible by definition. The cutout was
solving a problem Blend solves for free, so the ordering was moot before it was
tested. Three commits on the alpha path and the second conclusion has now also
fallen; the surviving facts are the histogram and the rule that 0x80 is opaque.
One real fidelity gap, recorded rather than papered over: the GS can alpha-test
and blend the same primitive at once. bevy's AlphaMode is a single enum where
Mask and Blend are mutually exclusive, so it cannot express that. Where the
original combines them, this port has to pick one, and the choice will not be
free. That belongs in the Phase 5 frame-for-frame gate rather than being treated
as settled here.
Clippy clean, builds. No test added: this is a material parameter with no
headless assertion available, and asserting a bevy enum value would be a test of
the bevy API rather than of anything in this project.
494 lines
16 KiB
Rust
494 lines
16 KiB
Rust
//! 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::{ImageAddressMode, ImageFilterMode, ImageSampler, ImageSamplerDescriptor};
|
|
use bevy::material::AlphaMode;
|
|
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 overlay = std::fs::read(level_dir.join("overlay.bin")).ok();
|
|
let gameplay = std::fs::read(level_dir.join("gameplay.bin")).ok();
|
|
let src = LevelSource {
|
|
core_data: &core_data,
|
|
core_index: &core_index,
|
|
gs_ram: &gs_ram,
|
|
overlay: overlay.as_deref(),
|
|
gameplay: gameplay.as_deref(),
|
|
};
|
|
let (centre, _radius) = level_bounds(&src)?;
|
|
Ok((
|
|
LevelFiles {
|
|
core_data,
|
|
core_index,
|
|
gs_ram,
|
|
overlay,
|
|
gameplay,
|
|
},
|
|
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>,
|
|
overlay: Option<Vec<u8>>,
|
|
gameplay: Option<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,
|
|
overlay: self.overlay.as_deref(),
|
|
gameplay: self.gameplay.as_deref(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
// Blend, not Mask. The disc's alpha is 97.94% opaque, 0.98% clear and
|
|
// 1.08% partial across 126 values, and Blend handles all three: a
|
|
// clear texel blends to invisible, a partial one stays smooth. Mask
|
|
// would flatten those 1.08% to fully opaque, discarding exactly the
|
|
// soft edges they exist to produce.
|
|
alpha_mode: AlphaMode::Blend,
|
|
..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");
|
|
}
|
|
// Say so when the level is not lit. Falling back silently to the tfrag's
|
|
// stored colours produces a plausible but wrong picture.
|
|
if s.lit_vertices < s.vertices {
|
|
eprintln!(
|
|
" lighting: {} of {} vertices shaded by LightTfrags; the rest fall back to the \
|
|
tfrag's stored colours (no usable light bank)",
|
|
s.lit_vertices, s.vertices
|
|
);
|
|
}
|
|
}
|
|
|
|
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(),
|
|
);
|
|
// The GS clamps; bevy's default is Repeat. This was `..default()` with a
|
|
// comment claiming the sampler clamped, which it did not -- the default is
|
|
// ImageAddressMode::Repeat. A tile edge therefore wrapped onto its opposite
|
|
// edge instead of repeating the border texel, which is the seam you see when
|
|
// a patch runs off its texture. See gc_formats::gs::clamp01.
|
|
//
|
|
// Only mip level 0 is decoded, so there is no chain to interpolate: the
|
|
// mipmap filter is Nearest because it samples that single level either way,
|
|
// and gs::mip_level would return 0 for every patch until levels are decoded.
|
|
image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
|
|
label: Some("tfrag texture".into()),
|
|
address_mode_u: ImageAddressMode::ClampToEdge,
|
|
address_mode_v: ImageAddressMode::ClampToEdge,
|
|
mipmap_filter: ImageFilterMode::Nearest,
|
|
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());
|
|
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, b.colors.clone());
|
|
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, b.normals.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);
|
|
}
|
|
}
|