[formats] XBG7 meshes: index buffers are triangle STRIPS, not lists
The weapon/prop models (rou_fxxx_wep_nn.xpr) rendered with triangular holes in the hull and misplaced/extra spikes. Root cause: XBG7 sub-mesh index buffers are triangle STRIPS, but the decoder read them as triangle LISTS — a strip of N indices is N-2 triangles, a list is only N/3, so ~2/3 of every hull was missing (the holes) and each list-triple of strip data spanned unrelated vertices (the spikes). The tell: triangles-per-vertex was 0.5-1.0 with 0 unreferenced vertices (a closed surface needs ~2.0). - mesh.rs: expand_triangle_strip() converts strip -> list with alternating winding, skipping degenerate triangles (repeated index = strip restart). Applied in both from_xpr2 (weapons) and read_pool_mesh (stage sub-models). All 71 weapon submeshes now have healthy ratios; hulls fill in (wep_03 cannon, wep_04 pod, wep_11, wep_37 verified coherent). - Module doc updated (strip, not list). - sylpheed-cli: `mesh info` reports per-submesh degenerate/unref/spanning-triangle counts; `mesh render` gains --dist (camera zoom). XMESHDBG env dumps the descriptor index markers. Known residual: an index buffer may concatenate several strips with no degenerate bridge, leaving ~2 spanning "spike" triangles at each restart (index jump to a new vertex region) — <1% of tris on most models. Decoding the restart mechanism is a follow-up (a blanket spanning-filter is unsafe: clean models have legit elongated tip triangles). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -157,6 +157,9 @@ enum MeshCommands {
|
|||||||
/// Camera pitch in degrees
|
/// Camera pitch in degrees
|
||||||
#[arg(long, default_value_t = 22.0)]
|
#[arg(long, default_value_t = 22.0)]
|
||||||
pitch: f32,
|
pitch: f32,
|
||||||
|
/// Camera distance multiplier (1.0 = framed; <1 zooms in, >1 out)
|
||||||
|
#[arg(long, default_value_t = 1.0)]
|
||||||
|
dist: f32,
|
||||||
/// Force the stage grid layout even for single models
|
/// Force the stage grid layout even for single models
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
row: bool,
|
row: bool,
|
||||||
@@ -205,8 +208,8 @@ async fn main() -> Result<()> {
|
|||||||
},
|
},
|
||||||
Commands::Mesh { cmd } => match cmd {
|
Commands::Mesh { cmd } => match cmd {
|
||||||
MeshCommands::Info { file } => cmd_mesh_info(&file),
|
MeshCommands::Info { file } => cmd_mesh_info(&file),
|
||||||
MeshCommands::Render { file, output, size, yaw, pitch, row, only } => {
|
MeshCommands::Render { file, output, size, yaw, pitch, dist, row, only } => {
|
||||||
cmd_mesh_render(&file, &output, size, yaw, pitch, row, only)
|
cmd_mesh_render(&file, &output, size, yaw, pitch, dist, row, only)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Commands::Pak { cmd } => match cmd {
|
Commands::Pak { cmd } => match cmd {
|
||||||
@@ -466,6 +469,48 @@ fn cmd_mesh_info(file: &Path) -> Result<()> {
|
|||||||
hi[1] - lo[1],
|
hi[1] - lo[1],
|
||||||
hi[2] - lo[2]
|
hi[2] - lo[2]
|
||||||
);
|
);
|
||||||
|
// Per-sub-mesh integrity diagnostics: degenerate triangles (a zero-area
|
||||||
|
// "hole"), vertices referenced by no triangle (dropped geometry), and the
|
||||||
|
// referenced index range vs the vertex count (short/over reads).
|
||||||
|
for (si, sub) in m.meshes.iter().enumerate() {
|
||||||
|
let nv = sub.positions.len();
|
||||||
|
let mut referenced = vec![false; nv];
|
||||||
|
let (mut degen, mut oob, mut imax) = (0usize, 0usize, 0u32);
|
||||||
|
for tri in sub.indices.chunks_exact(3) {
|
||||||
|
let (a, b, c) = (tri[0], tri[1], tri[2]);
|
||||||
|
imax = imax.max(a).max(b).max(c);
|
||||||
|
if a == b || b == c || a == c {
|
||||||
|
degen += 1;
|
||||||
|
}
|
||||||
|
for &i in tri {
|
||||||
|
if (i as usize) < nv {
|
||||||
|
referenced[i as usize] = true;
|
||||||
|
} else {
|
||||||
|
oob += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let unref = referenced.iter().filter(|&&r| !r).count();
|
||||||
|
// Spanning triangles: longest edge ≫ the median (strip-junction spikes).
|
||||||
|
let edge = |a: u32, b: u32| {
|
||||||
|
let (p, q) = (sub.positions[a as usize], sub.positions[b as usize]);
|
||||||
|
((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt()
|
||||||
|
};
|
||||||
|
let mut maxedges: Vec<f32> = sub
|
||||||
|
.indices
|
||||||
|
.chunks_exact(3)
|
||||||
|
.map(|t| edge(t[0], t[1]).max(edge(t[1], t[2])).max(edge(t[0], t[2])))
|
||||||
|
.collect();
|
||||||
|
maxedges.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
let median = maxedges.get(maxedges.len() / 2).copied().unwrap_or(1.0).max(1e-6);
|
||||||
|
let spanning = maxedges.iter().filter(|&&e| e > 6.0 * median).count();
|
||||||
|
println!(
|
||||||
|
" sub{si}: {nv} v, {} tris | degenerate {degen}, unref-verts {unref}, spanning>6×med {spanning}, idx_max {imax}/{}{}",
|
||||||
|
sub.indices.len() / 3,
|
||||||
|
nv.saturating_sub(1),
|
||||||
|
if oob > 0 { format!(", OOB {oob}") } else { String::new() },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
println!(
|
println!(
|
||||||
" {} {} sub-models · {} verts · {} tris",
|
" {} {} sub-models · {} verts · {} tris",
|
||||||
@@ -484,6 +529,7 @@ fn cmd_mesh_render(
|
|||||||
size: u32,
|
size: u32,
|
||||||
yaw: f32,
|
yaw: f32,
|
||||||
pitch: f32,
|
pitch: f32,
|
||||||
|
dist: f32,
|
||||||
force_row: bool,
|
force_row: bool,
|
||||||
only: Option<String>,
|
only: Option<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
@@ -535,20 +581,22 @@ fn cmd_mesh_render(
|
|||||||
(1.0, [0.0, 0.0, 0.0])
|
(1.0, [0.0, 0.0, 0.0])
|
||||||
};
|
};
|
||||||
for sub in &m.meshes {
|
for sub in &m.meshes {
|
||||||
|
let f = |i: usize| {
|
||||||
|
let p = sub.positions[i];
|
||||||
|
[
|
||||||
|
(p[0] - center[0]) * scale + cell[0],
|
||||||
|
(p[1] - center[1]) * scale + cell[1],
|
||||||
|
(p[2] - center[2]) * scale + cell[2],
|
||||||
|
]
|
||||||
|
};
|
||||||
|
// Sub-mesh indices are a triangle list (the decoder has already
|
||||||
|
// expanded the file's triangle strips).
|
||||||
|
let n = sub.positions.len();
|
||||||
for tri in sub.indices.chunks_exact(3) {
|
for tri in sub.indices.chunks_exact(3) {
|
||||||
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
|
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
|
||||||
if a >= sub.positions.len() || b >= sub.positions.len() || c >= sub.positions.len() {
|
if a < n && b < n && c < n {
|
||||||
continue;
|
tris.push([f(a), f(b), f(c)]);
|
||||||
}
|
}
|
||||||
let f = |i: usize| {
|
|
||||||
let p = sub.positions[i];
|
|
||||||
[
|
|
||||||
(p[0] - center[0]) * scale + cell[0],
|
|
||||||
(p[1] - center[1]) * scale + cell[1],
|
|
||||||
(p[2] - center[2]) * scale + cell[2],
|
|
||||||
]
|
|
||||||
};
|
|
||||||
tris.push([f(a), f(b), f(c)]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -556,7 +604,7 @@ fn cmd_mesh_render(
|
|||||||
anyhow::bail!("no triangles to render");
|
anyhow::bail!("no triangles to render");
|
||||||
}
|
}
|
||||||
|
|
||||||
let rgba = rasterize(&tris, size, yaw, pitch);
|
let rgba = rasterize(&tris, size, yaw, pitch, dist);
|
||||||
image::save_buffer(output, &rgba, size, size, image::ExtendedColorType::Rgba8)
|
image::save_buffer(output, &rgba, size, size, image::ExtendedColorType::Rgba8)
|
||||||
.with_context(|| format!("writing PNG {}", output.display()))?;
|
.with_context(|| format!("writing PNG {}", output.display()))?;
|
||||||
println!(
|
println!(
|
||||||
@@ -575,7 +623,7 @@ fn cmd_mesh_render(
|
|||||||
/// Minimal software rasterizer: orthographic, z-buffered, two-sided Lambert +
|
/// Minimal software rasterizer: orthographic, z-buffered, two-sided Lambert +
|
||||||
/// headlight shading over a flat grey material on a dark background. Enough to
|
/// headlight shading over a flat grey material on a dark background. Enough to
|
||||||
/// judge whether recovered geometry is coherent.
|
/// judge whether recovered geometry is coherent.
|
||||||
fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32) -> Vec<u8> {
|
fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32, dist: f32) -> Vec<u8> {
|
||||||
let n = size as usize;
|
let n = size as usize;
|
||||||
let (yaw, pitch) = (yaw_deg.to_radians(), pitch_deg.to_radians());
|
let (yaw, pitch) = (yaw_deg.to_radians(), pitch_deg.to_radians());
|
||||||
let (cy, sy) = (yaw.cos(), yaw.sin());
|
let (cy, sy) = (yaw.cos(), yaw.sin());
|
||||||
@@ -602,7 +650,7 @@ fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let span = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1e-3);
|
let span = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1e-3);
|
||||||
let scale = (n as f32) * 0.9 / span;
|
let scale = (n as f32) * 0.9 / (span * dist.max(1e-3));
|
||||||
let cx = (lo[0] + hi[0]) * 0.5;
|
let cx = (lo[0] + hi[0]) * 0.5;
|
||||||
let cyv = (lo[1] + hi[1]) * 0.5;
|
let cyv = (lo[1] + hi[1]) * 0.5;
|
||||||
let to_screen = |q: [f32; 3]| -> (f32, f32, f32) {
|
let to_screen = |q: [f32; 3]| -> (f32, f32, f32) {
|
||||||
|
|||||||
@@ -21,12 +21,20 @@
|
|||||||
//! the data section is a straight sequence of sub-meshes, each:
|
//! the data section is a straight sequence of sub-meshes, each:
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! [ index buffer : idx_count × u16 big-endian ] triangle list
|
//! [ index buffer : idx_count × u16 big-endian ] triangle STRIP
|
||||||
//! [ 12-byte vertex-buffer header (contents undecoded) ]
|
//! [ 12-byte vertex-buffer header (contents undecoded) ]
|
||||||
//! [ vertex buffer : vtx_count × stride bytes ] (declaration-driven)
|
//! [ vertex buffer : vtx_count × stride bytes ] (declaration-driven)
|
||||||
//! (pad to 16 bytes → next sub-mesh)
|
//! (pad to 16 bytes → next sub-mesh)
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
|
//! The index buffer is a triangle **strip** (a strip of N indices is N-2
|
||||||
|
//! triangles with alternating winding), NOT a list — reading it as a list
|
||||||
|
//! dropped ~⅔ of every hull (the "triangular holes") and produced spanning
|
||||||
|
//! spikes. `expand_triangle_strip` converts it, skipping degenerate triangles.
|
||||||
|
//! Residual: an index buffer may concatenate several strips with no degenerate
|
||||||
|
//! bridge, leaving ~2 spanning triangles at each restart (an index jump to a new
|
||||||
|
//! vertex region) — a small known artifact on some models.
|
||||||
|
//!
|
||||||
//! The vertex layout is **not fixed** — it comes from a **vertex declaration**
|
//! The vertex layout is **not fixed** — it comes from a **vertex declaration**
|
||||||
//! in the descriptor: a table of `{offset, format-code, usage}` triples (usage
|
//! in the descriptor: a table of `{offset, format-code, usage}` triples (usage
|
||||||
//! `0x00` POSITION `f32×3`, `0x03` NORMAL `f16×4`, `0x05` TEXCOORD `f16×2`).
|
//! `0x00` POSITION `f32×3`, `0x03` NORMAL `f16×4`, `0x05` TEXCOORD `f16×2`).
|
||||||
@@ -145,6 +153,19 @@ impl Xbg7Model {
|
|||||||
return Err(MeshError::UnsupportedLayout);
|
return Err(MeshError::UnsupportedLayout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if std::env::var("XMESHDBG").is_ok() {
|
||||||
|
let d = &bytes[desc..desc_end];
|
||||||
|
eprintln!("[{name}] stride={} records={records:?}", decl.stride);
|
||||||
|
let mut rel = 0usize;
|
||||||
|
while rel + 8 <= d.len() {
|
||||||
|
let (a, c) = (be32(d, rel), be32(d, rel + 4));
|
||||||
|
if c >= 3 && c % 3 == 0 && c < 400_000 && a == c * 2 {
|
||||||
|
eprintln!(" idx-marker @0x{rel:03x}: idx_count={c}");
|
||||||
|
}
|
||||||
|
rel += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Carve the data section sequentially.
|
// Carve the data section sequentially.
|
||||||
let base = header.header_size as usize;
|
let base = header.header_size as usize;
|
||||||
let mut off = 0usize; // relative to `base`
|
let mut off = 0usize; // relative to `base`
|
||||||
@@ -166,15 +187,21 @@ impl Xbg7Model {
|
|||||||
return Err(MeshError::UnsupportedLayout);
|
return Err(MeshError::UnsupportedLayout);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Indices (u16 BE), validated against the sub-mesh vertex count.
|
// The index buffer is a triangle STRIP (u16 BE), validated against the
|
||||||
let mut indices = Vec::with_capacity(idx_count);
|
// sub-mesh vertex count. Reading it as a triangle *list* dropped ~⅔ of
|
||||||
|
// the surface (the classic "triangular holes in the hull") and produced
|
||||||
|
// spanning spikes; a strip of N indices is N-2 triangles with
|
||||||
|
// alternating winding. Expand to a triangle list here, skipping
|
||||||
|
// degenerate triangles (repeated index = strip restart / zero area).
|
||||||
|
let mut strip = Vec::with_capacity(idx_count);
|
||||||
for k in 0..idx_count {
|
for k in 0..idx_count {
|
||||||
let i = be16(bytes, ib + k * 2) as u32;
|
let i = be16(bytes, ib + k * 2) as u32;
|
||||||
if i >= vtx_count as u32 {
|
if i >= vtx_count as u32 {
|
||||||
return Err(MeshError::UnsupportedLayout);
|
return Err(MeshError::UnsupportedLayout);
|
||||||
}
|
}
|
||||||
indices.push(i);
|
strip.push(i);
|
||||||
}
|
}
|
||||||
|
let indices = expand_triangle_strip(&strip);
|
||||||
|
|
||||||
// Vertices per the declaration. The .xpr stores each element in
|
// Vertices per the declaration. The .xpr stores each element in
|
||||||
// naive big-endian component order (f32 / f16 read at consecutive
|
// naive big-endian component order (f32 / f16 read at consecutive
|
||||||
@@ -531,9 +558,10 @@ fn read_pool_mesh(
|
|||||||
decl: &VertexDecl,
|
decl: &VertexDecl,
|
||||||
) -> GameMesh {
|
) -> GameMesh {
|
||||||
let stride = decl.stride;
|
let stride = decl.stride;
|
||||||
let indices: Vec<u32> = (0..index_count)
|
let strip: Vec<u32> = (0..index_count)
|
||||||
.map(|k| be16(bytes, ib + k * 2) as u32)
|
.map(|k| be16(bytes, ib + k * 2) as u32)
|
||||||
.collect();
|
.collect();
|
||||||
|
let indices = expand_triangle_strip(&strip);
|
||||||
|
|
||||||
let mut positions = Vec::with_capacity(vtx_count);
|
let mut positions = Vec::with_capacity(vtx_count);
|
||||||
let mut normals = Vec::with_capacity(vtx_count);
|
let mut normals = Vec::with_capacity(vtx_count);
|
||||||
@@ -713,6 +741,25 @@ fn submesh_records(desc: &[u8]) -> Vec<(usize, usize)> {
|
|||||||
fn align16(x: usize) -> usize {
|
fn align16(x: usize) -> usize {
|
||||||
(x + 15) & !15
|
(x + 15) & !15
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Expand a triangle-**strip** index buffer into a triangle **list**, dropping
|
||||||
|
/// degenerate triangles (a repeated index marks a strip restart / zero-area
|
||||||
|
/// triangle). XBG7 sub-mesh index buffers are strips: reading them as lists
|
||||||
|
/// dropped ~⅔ of every hull (triangular holes) and produced spanning spikes.
|
||||||
|
fn expand_triangle_strip(strip: &[u32]) -> Vec<u32> {
|
||||||
|
let mut out = Vec::with_capacity(strip.len().saturating_sub(2) * 3);
|
||||||
|
for w in 0..strip.len().saturating_sub(2) {
|
||||||
|
let (a, b, c) = if w % 2 == 0 {
|
||||||
|
(strip[w], strip[w + 1], strip[w + 2])
|
||||||
|
} else {
|
||||||
|
(strip[w + 1], strip[w], strip[w + 2])
|
||||||
|
};
|
||||||
|
if a != b && b != c && a != c {
|
||||||
|
out.extend_from_slice(&[a, b, c]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
#[inline]
|
#[inline]
|
||||||
fn be16(b: &[u8], o: usize) -> u16 {
|
fn be16(b: &[u8], o: usize) -> u16 {
|
||||||
u16::from_be_bytes([b[o], b[o + 1]])
|
u16::from_be_bytes([b[o], b[o + 1]])
|
||||||
|
|||||||
Reference in New Issue
Block a user