From d23339a3aa83b6271d9b47811fe23460eba50d5f Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 12 Jul 2026 20:56:45 +0200 Subject: [PATCH] feat(viewer): stage thumbnail grid, per-model albedo, camera zoom fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/sylpheed-viewer/src/camera.rs | 26 ++- crates/sylpheed-viewer/src/iso_loader.rs | 272 +++++++++++++++++++++-- docs/HANDOFF-stage-mesh-2026-07-12.md | 94 ++++++++ 3 files changed, 375 insertions(+), 17 deletions(-) create mode 100644 docs/HANDOFF-stage-mesh-2026-07-12.md diff --git a/crates/sylpheed-viewer/src/camera.rs b/crates/sylpheed-viewer/src/camera.rs index 370a397..121afe7 100644 --- a/crates/sylpheed-viewer/src/camera.rs +++ b/crates/sylpheed-viewer/src/camera.rs @@ -32,6 +32,10 @@ pub struct OrbitCamera { pub orbit_sensitivity: f32, pub zoom_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 { @@ -44,6 +48,8 @@ impl Default for OrbitCamera { orbit_sensitivity: 0.005, zoom_sensitivity: 0.3, pan_sensitivity: 0.003, + min_radius: 0.05, + max_radius: 500.0, } } } @@ -54,19 +60,26 @@ fn spawn_camera(mut commands: Commands) { commands.spawn(( 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, orbit, )); } fn orbit_camera( - mut query: Query<(&mut OrbitCamera, &mut Transform)>, + mut query: Query<(&mut OrbitCamera, &mut Transform, &mut Projection)>, mouse_buttons: Res>, keys: Res>, mut mouse_motion: EventReader, mut scroll: EventReader, ) { - 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; for ev in mouse_motion.read() { @@ -99,13 +112,20 @@ fn orbit_camera( // Zoom (scroll) 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) if keys.just_pressed(KeyCode::KeyR) { *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); } diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index 9223121..b970cf8 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -1556,6 +1556,8 @@ fn spawn_model_preview( if let Ok(mut cam) = orbit.get_single_mut() { cam.focus = center; 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; @@ -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, + materials: &mut Assets, + images: &mut Assets, + 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> = + std::collections::HashMap::new(); + let material_for = |model_name: &str, + materials: &mut Assets, + images: &mut Assets, + cache: &mut std::collections::HashMap>| + -> Handle { + // 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. #[cfg(not(target_arch = "wasm32"))] 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 // with their albedo (`_col`) map. Complex multi-stream body meshes decline // cleanly (Err) and fall through to the 2D texture preview below. - if let Ok(model) = sylpheed_formats::mesh::Xbg7Model::from_xpr2(&bytes) { - if !model.meshes.is_empty() { - spawn_model_preview( - &model, - &bytes, - &mut commands, - &mut meshes, - &mut materials, - &mut images, - &mut model_preview, - &mut orbit, - ); - return; - } + // A container may decode as a single model (weapons / props, via the + // single-block layout) OR as a stage — a collection of many enemy / prop + // sub-models. A stage's *first* XBG7 is often a tiny dummy "root" node that + // the single-model path decodes into a degenerate cube, so we can't just try + // it first and stop. Decode both and keep whichever yields more geometry. + use sylpheed_formats::mesh::Xbg7Model; + let single = Xbg7Model::from_xpr2(&bytes) + .ok() + .filter(|m| !m.meshes.is_empty()); + let single_verts = single.as_ref().map(|m| m.totals().0).unwrap_or(0); + + let stage = Xbg7Model::stage_models(&bytes); + 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. diff --git a/docs/HANDOFF-stage-mesh-2026-07-12.md b/docs/HANDOFF-stage-mesh-2026-07-12.md new file mode 100644 index 0000000..49d1f55 --- /dev/null +++ b/docs/HANDOFF-stage-mesh-2026-07-12.md @@ -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 ` 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.