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:
@@ -131,6 +131,23 @@ pub struct PakDetail {
|
||||
pub fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// One decoded cubemap face, registered as an egui image.
|
||||
pub struct FaceTex {
|
||||
pub label: &'static str,
|
||||
pub handle: Handle<Image>,
|
||||
pub egui_id: egui::TextureId,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// The currently-open world cubemap (`TXCM`), shown as a labelled 6-face grid.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct SkyboxPreview {
|
||||
pub faces: Vec<FaceTex>,
|
||||
/// Header summary (dimensions / format).
|
||||
pub info: String,
|
||||
}
|
||||
|
||||
/// The currently-open IPFB data pack, shown as a master-detail browser.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct PakView {
|
||||
@@ -237,7 +254,8 @@ impl Plugin for IsoLoaderPlugin {
|
||||
// Registered unconditionally (even on wasm, where the load systems
|
||||
// below are cfg'd out) so the UI can always read them.
|
||||
.init_resource::<TextPreview>()
|
||||
.init_resource::<PakView>();
|
||||
.init_resource::<PakView>()
|
||||
.init_resource::<SkyboxPreview>();
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -621,14 +639,43 @@ fn poll_loader_channel(
|
||||
}
|
||||
}
|
||||
|
||||
/// Free the current texture preview's GPU image + egui slot and reset it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn free_texture(
|
||||
preview: &mut TexturePreview,
|
||||
images: &mut Assets<Image>,
|
||||
contexts: &mut bevy_egui::EguiContexts<'_, '_>,
|
||||
) {
|
||||
if let Some(ref handle) = preview.handle {
|
||||
contexts.remove_image(handle);
|
||||
images.remove(handle.id());
|
||||
}
|
||||
*preview = TexturePreview::default();
|
||||
}
|
||||
|
||||
/// Free the current skybox preview's per-face GPU images + egui slots and reset it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn free_skybox(
|
||||
skybox: &mut SkyboxPreview,
|
||||
images: &mut Assets<Image>,
|
||||
contexts: &mut bevy_egui::EguiContexts<'_, '_>,
|
||||
) {
|
||||
for f in &skybox.faces {
|
||||
contexts.remove_image(&f.handle);
|
||||
images.remove(f.handle.id());
|
||||
}
|
||||
*skybox = SkyboxPreview::default();
|
||||
}
|
||||
|
||||
/// Converts pending raw bytes into a Bevy `Image`, registers it with egui,
|
||||
/// and populates `TexturePreview` / `FileInfo`.
|
||||
/// and populates `TexturePreview` / `SkyboxPreview` / `FileInfo`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn apply_loaded_texture(
|
||||
mut pending: ResMut<PendingFileBytes>,
|
||||
mut preview: ResMut<TexturePreview>,
|
||||
mut text_preview: ResMut<TextPreview>,
|
||||
mut pak_view: ResMut<PakView>,
|
||||
mut skybox: ResMut<SkyboxPreview>,
|
||||
mut file_info: ResMut<FileInfo>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
mut contexts: bevy_egui::EguiContexts,
|
||||
@@ -643,13 +690,10 @@ fn apply_loaded_texture(
|
||||
|
||||
let fmt = sylpheed_formats::vfs::identify_format(&bytes);
|
||||
|
||||
// Always free the previous texture first (GPU memory + egui slot), and clear
|
||||
// the other previews so exactly one viewer is active per selection.
|
||||
if let Some(ref handle) = preview.handle {
|
||||
contexts.remove_image(handle);
|
||||
images.remove(handle.id());
|
||||
}
|
||||
*preview = TexturePreview::default();
|
||||
// Always free the previous previews (GPU memory + egui slots) so exactly one
|
||||
// viewer is active per selection.
|
||||
free_texture(&mut preview, &mut images, &mut contexts);
|
||||
free_skybox(&mut skybox, &mut images, &mut contexts);
|
||||
*text_preview = TextPreview::default();
|
||||
*pak_view = PakView::default();
|
||||
|
||||
@@ -685,6 +729,48 @@ fn apply_loaded_texture(
|
||||
return; // Not a texture — FileInfo is sufficient
|
||||
}
|
||||
|
||||
// World cubemap (`TXCM`) → decode all 6 faces and show a labelled grid.
|
||||
// Returns `None` for ordinary 2D textures, which fall through below.
|
||||
match sylpheed_formats::texture::X360Texture::cube_faces_from_xpr2(&bytes) {
|
||||
Ok(Some(cube)) => {
|
||||
use sylpheed_formats::texture::{Cubemap, X360Texture};
|
||||
for (i, face) in cube.faces.iter().enumerate() {
|
||||
let face_tex = X360Texture {
|
||||
width: cube.width,
|
||||
height: cube.height,
|
||||
format: cube.format,
|
||||
mip_levels: 1,
|
||||
is_cubemap: false,
|
||||
data: face.clone(),
|
||||
};
|
||||
if let Ok(img) = crate::asset_loader::x360_texture_to_bevy_image(face_tex) {
|
||||
let handle = images.add(img);
|
||||
let egui_id = contexts.add_image(handle.clone_weak());
|
||||
skybox.faces.push(FaceTex {
|
||||
label: Cubemap::face_label(i),
|
||||
handle,
|
||||
egui_id,
|
||||
width: cube.width,
|
||||
height: cube.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
skybox.info = format!(
|
||||
"Cubemap {}×{} {:?} · {} faces",
|
||||
cube.width,
|
||||
cube.height,
|
||||
cube.format,
|
||||
skybox.faces.len()
|
||||
);
|
||||
return;
|
||||
}
|
||||
Ok(None) => {} // ordinary 2D texture
|
||||
Err(e) => {
|
||||
preview.format_info = format!("Cubemap parse failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let tex = match sylpheed_formats::X360Texture::from_xpr2(&bytes) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
@@ -724,6 +810,7 @@ fn apply_pak(
|
||||
mut pak_view: ResMut<PakView>,
|
||||
mut preview: ResMut<TexturePreview>,
|
||||
mut text_preview: ResMut<TextPreview>,
|
||||
mut skybox: ResMut<SkyboxPreview>,
|
||||
mut file_info: ResMut<FileInfo>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
mut contexts: bevy_egui::EguiContexts,
|
||||
@@ -733,13 +820,10 @@ fn apply_pak(
|
||||
}
|
||||
pending.ready = false;
|
||||
|
||||
// Free any previous texture + clear the other previews (mirrors the texture
|
||||
// path) so exactly one viewer is active.
|
||||
if let Some(ref handle) = preview.handle {
|
||||
contexts.remove_image(handle);
|
||||
images.remove(handle.id());
|
||||
}
|
||||
*preview = TexturePreview::default();
|
||||
// Free any previous texture/skybox + clear the other previews (mirrors the
|
||||
// texture path) so exactly one viewer is active.
|
||||
free_texture(&mut preview, &mut images, &mut contexts);
|
||||
free_skybox(&mut skybox, &mut images, &mut contexts);
|
||||
*text_preview = TextPreview::default();
|
||||
|
||||
file_info.name = pending.name.clone();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user