feat(viewer): world cubemap (skybox) viewer — 6-face grid

TXCM XPR2 resources are the game's world skyboxes (BG_Acheron, BG_Hargenteen,
…). The viewer showed only face 0 as a flat 2D image; now it decodes all 6 faces
and shows a labelled grid.

Formats (Bevy-free): X360Texture::cube_faces_from_xpr2() returns a Cubemap with 6
faces in D3D9 order (+X −X +Y −Y +Z −Z), or None for ordinary 2D textures. Face
layout derived from xenia's GetGuestTextureLayout — 6 back-to-back independently-
tiled surfaces, per-face stride = tiled-surface-size aligned to the 4 KiB
subresource boundary (kTextureSubresourceAlignmentBytes). Verified against real
BG_Acheron: data_size == 6 × 0x400000, and all 6 faces decode cleanly (own
Python decode + a disc test asserting 6×4 MiB faces and face 0 == the validated
from_xpr2 green-planet decode). Extracted a shared decode_surface() helper so the
2D and cube paths are byte-identical; from_xpr2 output unchanged (re-verified).

Viewer: new SkyboxPreview resource (6 egui face textures, reusing the existing
per-format x360_texture_to_bevy_image); populated in apply_loaded_texture's TXCM
branch; freed/reset alongside the other previews (factored free_texture/
free_skybox helpers). Central panel gains a skybox branch rendering a 3-column
labelled face grid.

DEFERRED (per "do not guess, else defer"): the interactive 3D skybox. Face data +
D3D9 order are validated, but wgpu cube-sampling handedness can't be confirmed
without eyeballing the GUI — a wrong-oriented skybox is worse than the correct
labelled grid. The grid is the reliable deliverable; the 3D look-around is a
follow-up once orientation is visually confirmed.

(Background agent did the investigation/validation but was blocked from writing
files; implemented here in the main tree from its findings, independently
re-verified.)

23 formats tests + disc cubemap test pass; viewer/CLI build; face-0 export
re-verified as the green planet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-11 13:38:07 +02:00
parent 765e4a573b
commit 589659bec6
4 changed files with 326 additions and 71 deletions

View File

@@ -10,8 +10,8 @@ use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts};
use crate::iso_loader::{
FileInfo, FileSelected, IsoState, PakView, RequestOpenDir, RequestOpenIso, TextPreview,
TexturePreview, IsoLoaderSystemSet,
FileInfo, FileSelected, IsoState, PakView, RequestOpenDir, RequestOpenIso, SkyboxPreview,
TextPreview, TexturePreview, IsoLoaderSystemSet,
};
use crate::ViewerState;
@@ -131,6 +131,7 @@ fn draw_viewer_ui(
preview: Res<TexturePreview>,
text_preview: Res<TextPreview>,
mut pak_view: ResMut<PakView>,
skybox: Res<SkyboxPreview>,
file_info: Res<FileInfo>,
mut open_iso_events: EventWriter<RequestOpenIso>,
mut open_dir_events: EventWriter<RequestOpenDir>,
@@ -265,6 +266,8 @@ fn draw_viewer_ui(
ui.label(format!("Loading {name}"));
});
});
} else if !skybox.faces.is_empty() {
draw_skybox_grid(ui, &skybox);
} else if pak_view.loaded {
draw_pak_browser(ui, &mut pak_view);
} else if let Some(text) = &text_preview.content {
@@ -485,3 +488,40 @@ fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) {
pak.selected = new_selection;
}
}
// ── World cubemap (skybox) viewer ─────────────────────────────────────────────
/// Show a world cubemap's 6 faces in a labelled grid (D3D9 order). A true
/// interactive skybox is a follow-up — the face data + order are validated, but
/// the wgpu cube-sampling handedness needs visual confirmation first.
fn draw_skybox_grid(ui: &mut egui::Ui, skybox: &SkyboxPreview) {
ui.horizontal(|ui| {
ui.heading("World cubemap");
ui.separator();
ui.label(&skybox.info);
});
ui.label("6 cube faces, D3D9 order (+X X +Y Y +Z Z).");
ui.separator();
egui::ScrollArea::both().show(ui, |ui| {
const CELL: f32 = 240.0;
egui::Grid::new("cube_faces")
.num_columns(3)
.spacing([10.0, 10.0])
.show(ui, |ui| {
for (i, face) in skybox.faces.iter().enumerate() {
ui.vertical(|ui| {
ui.strong(face.label);
let aspect = face.height as f32 / face.width.max(1) as f32;
ui.add(egui::Image::new(egui::load::SizedTexture::new(
face.egui_id,
[CELL, CELL * aspect],
)));
});
if (i + 1) % 3 == 0 {
ui.end_row();
}
}
});
});
}