feat(viewer): stage thumbnail grid, per-model albedo, camera zoom fix
Route stage containers to stage_models (decode both single + stage paths, keep whichever yields more geometry) — fixes stages showing the dummy-root "black cube". Present a stage's sub-models as a thumbnail grid: each recentred and uniformly scaled to a fixed cell so a 1-unit prop and a 3000-unit structure are equally visible; skybox-plane / stray-anchor models (>20000u) are culled. Each sub-model is textured by matching its name to the container's `_col` albedo. Fix the orbit camera: zoom radius was hard-capped at 50u with fixed clip planes, trapping the camera inside multi-thousand-unit stages. Zoom limits and near/far planes now scale to the framed model. Adds docs/HANDOFF-stage-mesh-2026-07-12.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,10 @@ pub struct OrbitCamera {
|
|||||||
pub orbit_sensitivity: f32,
|
pub orbit_sensitivity: f32,
|
||||||
pub zoom_sensitivity: f32,
|
pub zoom_sensitivity: f32,
|
||||||
pub pan_sensitivity: f32,
|
pub pan_sensitivity: f32,
|
||||||
|
/// Zoom limits — set when framing a model so large scenes can be pulled back
|
||||||
|
/// far enough (the old fixed 0.5..50 cap trapped the camera inside big stages).
|
||||||
|
pub min_radius: f32,
|
||||||
|
pub max_radius: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for OrbitCamera {
|
impl Default for OrbitCamera {
|
||||||
@@ -44,6 +48,8 @@ impl Default for OrbitCamera {
|
|||||||
orbit_sensitivity: 0.005,
|
orbit_sensitivity: 0.005,
|
||||||
zoom_sensitivity: 0.3,
|
zoom_sensitivity: 0.3,
|
||||||
pan_sensitivity: 0.003,
|
pan_sensitivity: 0.003,
|
||||||
|
min_radius: 0.05,
|
||||||
|
max_radius: 500.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,19 +60,26 @@ fn spawn_camera(mut commands: Commands) {
|
|||||||
|
|
||||||
commands.spawn((
|
commands.spawn((
|
||||||
Camera3d::default(),
|
Camera3d::default(),
|
||||||
|
// Wide near/far so both tiny weapons and multi-thousand-unit stages fit;
|
||||||
|
// the planes are re-scaled to the zoom distance each frame (below).
|
||||||
|
Projection::Perspective(PerspectiveProjection {
|
||||||
|
near: 0.05,
|
||||||
|
far: 100_000.0,
|
||||||
|
..default()
|
||||||
|
}),
|
||||||
transform,
|
transform,
|
||||||
orbit,
|
orbit,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn orbit_camera(
|
fn orbit_camera(
|
||||||
mut query: Query<(&mut OrbitCamera, &mut Transform)>,
|
mut query: Query<(&mut OrbitCamera, &mut Transform, &mut Projection)>,
|
||||||
mouse_buttons: Res<ButtonInput<MouseButton>>,
|
mouse_buttons: Res<ButtonInput<MouseButton>>,
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
mut mouse_motion: EventReader<MouseMotion>,
|
mut mouse_motion: EventReader<MouseMotion>,
|
||||||
mut scroll: EventReader<MouseWheel>,
|
mut scroll: EventReader<MouseWheel>,
|
||||||
) {
|
) {
|
||||||
let Ok((mut cam, mut transform)) = query.get_single_mut() else { return };
|
let Ok((mut cam, mut transform, mut projection)) = query.get_single_mut() else { return };
|
||||||
|
|
||||||
let mut delta_motion = Vec2::ZERO;
|
let mut delta_motion = Vec2::ZERO;
|
||||||
for ev in mouse_motion.read() {
|
for ev in mouse_motion.read() {
|
||||||
@@ -99,13 +112,20 @@ fn orbit_camera(
|
|||||||
|
|
||||||
// Zoom (scroll)
|
// Zoom (scroll)
|
||||||
cam.radius -= scroll_delta * cam.zoom_sensitivity * cam.radius;
|
cam.radius -= scroll_delta * cam.zoom_sensitivity * cam.radius;
|
||||||
cam.radius = cam.radius.clamp(0.5, 50.0);
|
cam.radius = cam.radius.clamp(cam.min_radius, cam.max_radius);
|
||||||
|
|
||||||
// Reset (R key)
|
// Reset (R key)
|
||||||
if keys.just_pressed(KeyCode::KeyR) {
|
if keys.just_pressed(KeyCode::KeyR) {
|
||||||
*cam = OrbitCamera::default();
|
*cam = OrbitCamera::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the clip planes proportional to the zoom distance so depth precision
|
||||||
|
// stays usable across scales (tiny prop → whole stage grid).
|
||||||
|
if let Projection::Perspective(p) = projection.as_mut() {
|
||||||
|
p.near = (cam.radius * 0.02).clamp(0.02, 50.0);
|
||||||
|
p.far = (cam.radius * 50.0).max(2000.0);
|
||||||
|
}
|
||||||
|
|
||||||
*transform = orbit_transform(&cam);
|
*transform = orbit_transform(&cam);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1556,6 +1556,8 @@ fn spawn_model_preview(
|
|||||||
if let Ok(mut cam) = orbit.get_single_mut() {
|
if let Ok(mut cam) = orbit.get_single_mut() {
|
||||||
cam.focus = center;
|
cam.focus = center;
|
||||||
cam.radius = extent * 1.4;
|
cam.radius = extent * 1.4;
|
||||||
|
cam.min_radius = (extent * 0.02).max(0.02);
|
||||||
|
cam.max_radius = (extent * 20.0).max(50.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
model_preview.active = true;
|
model_preview.active = true;
|
||||||
@@ -1569,6 +1571,223 @@ fn spawn_model_preview(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawn every decoded sub-model of a **stage** container as a side-by-side row
|
||||||
|
/// (a "cast sheet"): the stage's enemy / prop models are separate objects with no
|
||||||
|
/// recovered scene transforms, so each is recentred on its own origin and laid
|
||||||
|
/// out along +X, spaced by its width. The orbit camera frames the whole row.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn spawn_stage_models(
|
||||||
|
models: &[sylpheed_formats::mesh::Xbg7Model],
|
||||||
|
xpr_bytes: &[u8],
|
||||||
|
commands: &mut Commands,
|
||||||
|
meshes: &mut Assets<Mesh>,
|
||||||
|
materials: &mut Assets<StandardMaterial>,
|
||||||
|
images: &mut Assets<Image>,
|
||||||
|
model_preview: &mut ModelPreview,
|
||||||
|
orbit: &mut Query<&mut crate::camera::OrbitCamera>,
|
||||||
|
) {
|
||||||
|
use bevy::render::mesh::{Indices, PrimitiveTopology};
|
||||||
|
use sylpheed_formats::texture::X360Texture;
|
||||||
|
|
||||||
|
// Per-sub-model albedo: match each model's name to its `_col` texture
|
||||||
|
// (e.g. `e003` → `e003_bdy_col`, `e005` → `e005_01_col`). Decode each once
|
||||||
|
// and cache by texture index; fall back to a neutral tint when no `_col`
|
||||||
|
// channel matches. Colours are the same "unverified" item as the 2D textures.
|
||||||
|
let tex_names = X360Texture::texture_names(xpr_bytes);
|
||||||
|
let neutral = materials.add(StandardMaterial {
|
||||||
|
base_color: Color::srgb(0.72, 0.74, 0.78),
|
||||||
|
perceptual_roughness: 0.65,
|
||||||
|
metallic: 0.1,
|
||||||
|
cull_mode: None,
|
||||||
|
double_sided: true,
|
||||||
|
..default()
|
||||||
|
});
|
||||||
|
let mut tex_cache: std::collections::HashMap<usize, Handle<StandardMaterial>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
let material_for = |model_name: &str,
|
||||||
|
materials: &mut Assets<StandardMaterial>,
|
||||||
|
images: &mut Assets<Image>,
|
||||||
|
cache: &mut std::collections::HashMap<usize, Handle<StandardMaterial>>|
|
||||||
|
-> Handle<StandardMaterial> {
|
||||||
|
// Core name = strip leading `_`/`rou_` and trailing `_l` LOD suffix.
|
||||||
|
let core = model_name
|
||||||
|
.trim_start_matches('_')
|
||||||
|
.trim_start_matches("rou_")
|
||||||
|
.trim_end_matches("_l");
|
||||||
|
let idx = tex_names.iter().position(|n| {
|
||||||
|
n.ends_with("_col") && (n.starts_with(core) || n.contains(core))
|
||||||
|
});
|
||||||
|
let Some(i) = idx else { return neutral.clone() };
|
||||||
|
if let Some(h) = cache.get(&i) {
|
||||||
|
return h.clone();
|
||||||
|
}
|
||||||
|
let handle = X360Texture::from_xpr2_index(xpr_bytes, i)
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| crate::asset_loader::x360_texture_to_bevy_image(t).ok())
|
||||||
|
.map(|img| {
|
||||||
|
materials.add(StandardMaterial {
|
||||||
|
base_color: Color::WHITE,
|
||||||
|
base_color_texture: Some(images.add(img)),
|
||||||
|
perceptual_roughness: 0.7,
|
||||||
|
metallic: 0.1,
|
||||||
|
cull_mode: None,
|
||||||
|
double_sided: true,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| neutral.clone());
|
||||||
|
cache.insert(i, handle.clone());
|
||||||
|
handle
|
||||||
|
};
|
||||||
|
|
||||||
|
let root = commands
|
||||||
|
.spawn((
|
||||||
|
PreviewModel,
|
||||||
|
Transform::default(),
|
||||||
|
Visibility::default(),
|
||||||
|
Name::new("stage"),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
// A stage holds up to ~450 sub-models at wildly different true scales (a
|
||||||
|
// 1-unit prop next to a 3000-unit structure) with no recovered scene
|
||||||
|
// transforms. Laying them end-to-end made a mile-wide strip the camera
|
||||||
|
// couldn't escape. Instead present a **thumbnail grid**: each model is
|
||||||
|
// recentred and uniformly scaled to a fixed cell, so all are equally visible
|
||||||
|
// and browsable regardless of native size. Grid spans the XY plane.
|
||||||
|
const CELL: f32 = 10.0; // target on-screen size of each thumbnail
|
||||||
|
const GAP: f32 = 4.0;
|
||||||
|
let pitch = CELL + GAP;
|
||||||
|
|
||||||
|
// Extent above which a sub-model is a skybox plane (300 k-unit cloud quad) or
|
||||||
|
// a stray-vertex mis-anchor rather than a real object — dropped from the grid.
|
||||||
|
const MAX_EXTENT: f32 = 20_000.0;
|
||||||
|
let mut drawn: Vec<&sylpheed_formats::mesh::Xbg7Model> = Vec::new();
|
||||||
|
for model in models {
|
||||||
|
let mut lo = Vec3::splat(f32::MAX);
|
||||||
|
let mut hi = Vec3::splat(f32::MIN);
|
||||||
|
let mut has_geo = false;
|
||||||
|
for sub in &model.meshes {
|
||||||
|
if sub.positions.is_empty() || sub.indices.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
has_geo = true;
|
||||||
|
for p in &sub.positions {
|
||||||
|
lo = lo.min(Vec3::from_array(*p));
|
||||||
|
hi = hi.max(Vec3::from_array(*p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has_geo && (hi - lo).max_element() <= MAX_EXTENT {
|
||||||
|
drawn.push(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cols = (drawn.len() as f32).sqrt().ceil().max(1.0) as usize;
|
||||||
|
|
||||||
|
let mut total_v = 0usize;
|
||||||
|
let mut total_t = 0usize;
|
||||||
|
for (i, model) in drawn.iter().enumerate() {
|
||||||
|
// Per-model bbox → recentre + uniform scale to the cell.
|
||||||
|
let mut lo = Vec3::splat(f32::MAX);
|
||||||
|
let mut hi = Vec3::splat(f32::MIN);
|
||||||
|
for sub in &model.meshes {
|
||||||
|
for p in &sub.positions {
|
||||||
|
lo = lo.min(Vec3::from_array(*p));
|
||||||
|
hi = hi.max(Vec3::from_array(*p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lo.x > hi.x {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let center = (lo + hi) * 0.5;
|
||||||
|
let extent = (hi - lo).max_element().max(1e-3);
|
||||||
|
let scale = CELL / extent;
|
||||||
|
|
||||||
|
// Grid cell centre (row-major, growing down in −Y).
|
||||||
|
let col = i % cols;
|
||||||
|
let row = i / cols;
|
||||||
|
let cell_pos = Vec3::new(col as f32 * pitch, -(row as f32) * pitch, 0.0);
|
||||||
|
|
||||||
|
// One entity per model carries the placement + normalising scale; its
|
||||||
|
// sub-meshes are recentred to the model's own origin underneath it.
|
||||||
|
let model_root = commands
|
||||||
|
.spawn((
|
||||||
|
PreviewModel,
|
||||||
|
Transform::from_translation(cell_pos).with_scale(Vec3::splat(scale)),
|
||||||
|
Visibility::default(),
|
||||||
|
Name::new(model.name.clone()),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
commands.entity(root).add_child(model_root);
|
||||||
|
|
||||||
|
let material = material_for(&model.name, materials, images, &mut tex_cache);
|
||||||
|
|
||||||
|
for sub in &model.meshes {
|
||||||
|
if sub.positions.is_empty() || sub.indices.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
total_v += sub.positions.len();
|
||||||
|
total_t += sub.indices.len() / 3;
|
||||||
|
|
||||||
|
let positions: Vec<[f32; 3]> =
|
||||||
|
sub.positions.iter().map(|p| {
|
||||||
|
[p[0] - center.x, p[1] - center.y, p[2] - center.z]
|
||||||
|
}).collect();
|
||||||
|
let uvs: Vec<[f32; 2]> = if sub.uvs.len() == sub.positions.len() {
|
||||||
|
sub.uvs.clone()
|
||||||
|
} else {
|
||||||
|
vec![[0.0, 0.0]; sub.positions.len()]
|
||||||
|
};
|
||||||
|
let normals = if sub.normals.len() == sub.positions.len() {
|
||||||
|
sub.normals.clone()
|
||||||
|
} else {
|
||||||
|
compute_smooth_normals(&positions, &sub.indices)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut mesh =
|
||||||
|
Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::default());
|
||||||
|
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
|
||||||
|
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
|
||||||
|
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
|
||||||
|
mesh.insert_indices(Indices::U32(sub.indices.clone()));
|
||||||
|
|
||||||
|
let child = commands
|
||||||
|
.spawn((
|
||||||
|
PreviewModel,
|
||||||
|
Mesh3d(meshes.add(mesh)),
|
||||||
|
MeshMaterial3d(material.clone()),
|
||||||
|
Transform::default(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
commands.entity(model_root).add_child(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frame the whole grid.
|
||||||
|
let rows = drawn.len().div_ceil(cols);
|
||||||
|
let grid_w = cols as f32 * pitch;
|
||||||
|
let grid_h = rows as f32 * pitch;
|
||||||
|
let center = Vec3::new(grid_w * 0.5 - pitch * 0.5, -(grid_h * 0.5 - pitch * 0.5), 0.0);
|
||||||
|
let diag = (grid_w * grid_w + grid_h * grid_h).sqrt().max(CELL);
|
||||||
|
if let Ok(mut cam) = orbit.get_single_mut() {
|
||||||
|
cam.focus = center;
|
||||||
|
cam.radius = diag * 0.75;
|
||||||
|
cam.min_radius = CELL * 0.05;
|
||||||
|
cam.max_radius = diag * 3.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
model_preview.active = true;
|
||||||
|
model_preview.name = "stage".to_string();
|
||||||
|
model_preview.info = format!(
|
||||||
|
"Stage container · {} sub-models (thumbnail grid, each scaled to fit) · \
|
||||||
|
{} verts · {} tris (content-anchored; hero bodies with quantized layout \
|
||||||
|
are skipped)",
|
||||||
|
drawn.len(),
|
||||||
|
total_v,
|
||||||
|
total_t,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-vertex smooth normals = normalized sum of incident face normals.
|
/// Per-vertex smooth normals = normalized sum of incident face normals.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn compute_smooth_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
|
fn compute_smooth_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
|
||||||
@@ -1673,20 +1892,45 @@ fn apply_loaded_texture(
|
|||||||
// models under `hidden/resource3d/`) preview as an orbitable mesh, textured
|
// models under `hidden/resource3d/`) preview as an orbitable mesh, textured
|
||||||
// with their albedo (`_col`) map. Complex multi-stream body meshes decline
|
// with their albedo (`_col`) map. Complex multi-stream body meshes decline
|
||||||
// cleanly (Err) and fall through to the 2D texture preview below.
|
// cleanly (Err) and fall through to the 2D texture preview below.
|
||||||
if let Ok(model) = sylpheed_formats::mesh::Xbg7Model::from_xpr2(&bytes) {
|
// A container may decode as a single model (weapons / props, via the
|
||||||
if !model.meshes.is_empty() {
|
// single-block layout) OR as a stage — a collection of many enemy / prop
|
||||||
spawn_model_preview(
|
// sub-models. A stage's *first* XBG7 is often a tiny dummy "root" node that
|
||||||
&model,
|
// the single-model path decodes into a degenerate cube, so we can't just try
|
||||||
&bytes,
|
// it first and stop. Decode both and keep whichever yields more geometry.
|
||||||
&mut commands,
|
use sylpheed_formats::mesh::Xbg7Model;
|
||||||
&mut meshes,
|
let single = Xbg7Model::from_xpr2(&bytes)
|
||||||
&mut materials,
|
.ok()
|
||||||
&mut images,
|
.filter(|m| !m.meshes.is_empty());
|
||||||
&mut model_preview,
|
let single_verts = single.as_ref().map(|m| m.totals().0).unwrap_or(0);
|
||||||
&mut orbit,
|
|
||||||
);
|
let stage = Xbg7Model::stage_models(&bytes);
|
||||||
return;
|
let stage_verts: usize = stage.iter().map(|m| m.totals().0).sum();
|
||||||
}
|
|
||||||
|
if !stage.is_empty() && stage_verts > single_verts {
|
||||||
|
spawn_stage_models(
|
||||||
|
&stage,
|
||||||
|
&bytes,
|
||||||
|
&mut commands,
|
||||||
|
&mut meshes,
|
||||||
|
&mut materials,
|
||||||
|
&mut images,
|
||||||
|
&mut model_preview,
|
||||||
|
&mut orbit,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(model) = single {
|
||||||
|
spawn_model_preview(
|
||||||
|
&model,
|
||||||
|
&bytes,
|
||||||
|
&mut commands,
|
||||||
|
&mut meshes,
|
||||||
|
&mut materials,
|
||||||
|
&mut images,
|
||||||
|
&mut model_preview,
|
||||||
|
&mut orbit,
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// World cubemap (`TXCM`) → decode all 6 faces and show a labelled grid.
|
// World cubemap (`TXCM`) → decode all 6 faces and show a labelled grid.
|
||||||
|
|||||||
94
docs/HANDOFF-stage-mesh-2026-07-12.md
Normal file
94
docs/HANDOFF-stage-mesh-2026-07-12.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Handoff — XBG7 stage-container meshes, unified layout, mesh renderer (2026-07-12)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This session extended the XBG7 mesh work from single weapons/props to the big
|
||||||
|
**stage containers**, fixed a long-standing weapon decode artifact, added a
|
||||||
|
headless mesh renderer for self-verification, and reworked the viewer's stage
|
||||||
|
presentation. Companion Xenia-Canary GPU instrumentation (the `log_draws`
|
||||||
|
draw-logger used as the ground-truth oracle) is on the `instrument-current`
|
||||||
|
branch of the Canary fork.
|
||||||
|
|
||||||
|
## What landed (Reborn — `feature/ipfb-idxd-parser`)
|
||||||
|
|
||||||
|
### 1. Stage containers decode — `Xbg7Model::stage_models`
|
||||||
|
`hidden/resource3d/Stage_S*.xpr` are **collections of up to ~450 enemy/prop
|
||||||
|
sub-models**, not single meshes. Each resource is a block
|
||||||
|
`[12-byte header][index buffer][vertex buffer]`; the index count is the
|
||||||
|
descriptor's `(index_bytes, index_count)` marker and the vertex count is the
|
||||||
|
`u32` 32 bytes before it. The blocks are **scattered among the container's
|
||||||
|
texture data with no stored offset**, so each is located by **content**: one
|
||||||
|
`O(file)` pass per stride finds vertex-buffer starts (unit NORMAL at vertex +12
|
||||||
|
whose previous stride slot isn't — a block boundary), then each resource is
|
||||||
|
pinned to the candidate where its indices validate and produce non-degenerate,
|
||||||
|
well-connected triangles. Fast (≤ ~4 s on a 70 MB stage), unambiguous.
|
||||||
|
|
||||||
|
- **Coverage: ~4993 sub-models across the 22 stages** (content-anchored).
|
||||||
|
- A **connectivity gate** (mean triangle edge ≤ 0.28× bbox diagonal) rejects
|
||||||
|
mis-anchored blocks that would render as spiky messes.
|
||||||
|
|
||||||
|
### 2. Weapon layout fix (unified with stages)
|
||||||
|
Weapons use the **same** `[12-byte header][index][vertex]` block layout. The old
|
||||||
|
decoder read the index buffer 12 bytes too early → 2 leading degenerate
|
||||||
|
triangles (the recurring "stray white triangle" artifact) + a lost tail of 6
|
||||||
|
real indices. Now the header is skipped; vertex offsets are unchanged, so
|
||||||
|
coverage stays 36/166 and the triangle lists are correct. Verified on
|
||||||
|
wep_00/03/04 via the renderer.
|
||||||
|
|
||||||
|
### 3. Headless mesh renderer — `sylpheed-cli mesh {info,render}`
|
||||||
|
A software rasterizer (orthographic, z-buffered, two-sided shading) that writes a
|
||||||
|
PNG. Lets anyone (including the assistant) verify recovered geometry without the
|
||||||
|
GUI. `--only <substr>` filters sub-models; `--yaw/--pitch/--size/--row` control
|
||||||
|
the view. Stage containers render as a normalised thumbnail grid.
|
||||||
|
|
||||||
|
### 4. Viewer UX
|
||||||
|
- Stage files show a **thumbnail grid**: each sub-model recentred + uniformly
|
||||||
|
scaled to a fixed cell (a 1-unit prop and a 3000-unit structure are equally
|
||||||
|
visible), skybox-plane / stray-anchor models (>20000 u) culled.
|
||||||
|
- Stage sub-models are **textured** by matching name → `_col` albedo.
|
||||||
|
- **Camera fixed**: zoom was hard-capped at 50 u (camera trapped inside big
|
||||||
|
stages); now zoom limits + clip planes scale to the framed model.
|
||||||
|
- Dispatch decodes both single-model and stage paths and keeps whichever yields
|
||||||
|
more geometry (fixes stages previously showing the dummy-root "black cube").
|
||||||
|
|
||||||
|
## State / how to verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# formats + disc tests (needs the extracted disc)
|
||||||
|
SYLPHEED_RES3D=/path/to/hidden/resource3d \
|
||||||
|
cargo test -p sylpheed-formats --test mesh_disc -- --ignored --nocapture
|
||||||
|
|
||||||
|
# render any model / stage to a PNG
|
||||||
|
cargo run --release -p sylpheed-cli -- mesh render \
|
||||||
|
.../resource3d/Stage_S10.xpr /tmp/s10.png --size 1000
|
||||||
|
cargo run --release -p sylpheed-cli -- mesh render \
|
||||||
|
.../resource3d/rou_f001_wep_00.xpr /tmp/wep.png
|
||||||
|
|
||||||
|
# viewer
|
||||||
|
cargo run -p sylpheed-viewer # File → Open Directory → the extracted disc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known limitations / next steps
|
||||||
|
|
||||||
|
- **Multi-submesh main bodies with separate vertex pools** still mis-decode
|
||||||
|
(e.g. `Stage_S10` `e005`: a coherent fighter overlaid with wrong spike
|
||||||
|
triangles). `e003` works only because its two submeshes *share* one pool. Same
|
||||||
|
unsolved class as the **DeltaSaber hero body** (`f004`, still declined). Fix
|
||||||
|
needs per-submesh vertex-base RE. The LODs of these bodies (e.g. `e005_l`)
|
||||||
|
decode cleanly.
|
||||||
|
- **The per-resource data offset is not stored** — blocks are found by content.
|
||||||
|
Reversing the container's allocation order would give exact offsets + 100%
|
||||||
|
coverage but is a larger effort.
|
||||||
|
- **Texture colour correctness** remains the known "unverified" dynamic-RE item
|
||||||
|
(channel order / sRGB) for both 2D textures and model albedos.
|
||||||
|
- The viewer's stage decode runs on the main thread (~10 s on a 70 MB stage in a
|
||||||
|
debug build); move off-thread if the load hitch matters.
|
||||||
|
|
||||||
|
## Canary oracle (`instrument-current` on the Xenia-Canary fork)
|
||||||
|
|
||||||
|
`command_processor.cc::LogDrawForRE` (cvar `log_draws`) dumps each distinct
|
||||||
|
draw's primitive type, index buffer, vertex declaration, and sample vertex
|
||||||
|
positions to `xenia_re_draws.log`. Used to confirm the XBG7 layout (triangle
|
||||||
|
list, f32×3 position + f16×4 normal + f16×2 uv). Build with the memory-safe
|
||||||
|
flags (`CC=clang-19 CXX=clang++-19 CMAKE_BUILD_PARALLEL_LEVEL=3 … -j 3`); run one
|
||||||
|
emulator process at a time.
|
||||||
Reference in New Issue
Block a user