feat(render): select static MAT0 material phases

This commit is contained in:
2026-07-18 19:22:58 +04:00
parent a339a3a635
commit 9788749650
4 changed files with 95 additions and 10 deletions
Generated
+1
View File
@@ -448,6 +448,7 @@ version = "0.1.0"
dependencies = [
"fparkan-assets",
"fparkan-inspection",
"fparkan-path",
"fparkan-platform",
"fparkan-platform-winit",
"fparkan-render",
+1
View File
@@ -8,6 +8,7 @@ repository.workspace = true
[dependencies]
fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" }
fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" }
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" }
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" }
+77 -10
View File
@@ -23,6 +23,7 @@
use fparkan_assets::{PreparedTextureUsage, PreparedVisual};
use fparkan_inspection::load_standalone_wear_material_texture_mip0_rgba8_from_root;
use fparkan_path::ResourceName;
use fparkan_platform::WindowPort;
use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan};
use fparkan_render::{
@@ -113,6 +114,7 @@ fn run(args: &[String]) -> Result<String, String> {
roots,
camera,
args.static_animation_frame,
args.static_material_phase,
&args.root,
&loaded.land_msh_path,
)?;
@@ -245,6 +247,7 @@ struct StaticPreviewScene {
mesh_components: usize,
terrain_components: usize,
animation_frame: Option<u16>,
material_phase: Option<u16>,
}
/// Projects the mission terrain plus every MSH component of the explicitly
@@ -256,13 +259,14 @@ struct StaticPreviewScene {
/// retained on draw ranges but awaits a recovered blend equation; orientation,
/// later material phases, animation, lightmaps and gameplay visibility remain
/// outside this bridge.
#[allow(clippy::too_many_lines)]
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn static_preview_mesh_and_materials(
assets: &MissionAssets,
terrain: &TerrainWorld,
roots: &[MissionObjectDraft],
legacy_camera: Option<VulkanStaticCamera>,
static_animation_frame: Option<u16>,
static_material_phase: Option<u16>,
root: &std::path::Path,
land_msh_path: &str,
) -> Result<StaticPreviewScene, String> {
@@ -346,8 +350,13 @@ fn static_preview_mesh_and_materials(
}
}
.map_err(|err| format!("project mission MSH for Vulkan: {err}"))?;
let selector_remap =
static_preview_component_materials(assets, visual, &component, &mut materials)?;
let selector_remap = static_preview_component_materials(
assets,
visual,
&component,
static_material_phase,
&mut materials,
)?;
append_static_preview_component(&mut mesh, component, &selector_remap)?;
mesh_components += 1;
}
@@ -369,6 +378,7 @@ fn static_preview_mesh_and_materials(
mesh_components,
terrain_components: 1,
animation_frame: static_animation_frame,
material_phase: static_material_phase,
})
}
@@ -539,6 +549,7 @@ fn static_preview_component_materials(
assets: &MissionAssets,
visual: &PreparedVisual,
mesh: &VulkanStaticMesh,
static_material_phase: Option<u16>,
materials: &mut Vec<VulkanStaticMaterial>,
) -> Result<Vec<(u16, u16)>, String> {
let mut source_selectors = mesh
@@ -562,15 +573,21 @@ fn static_preview_component_materials(
let material = assets.material_by_id(*material_id).ok_or_else(|| {
format!("static preview prepared material {source_selector} is unavailable")
})?;
let texture_name = material.texture_requests.first().ok_or_else(|| {
format!("static preview material {source_selector} has no MAT0 diffuse texture")
})?;
let texture_name = static_material_phase
.and_then(|phase_index| {
let phase = material.mat0.phases.get(usize::from(phase_index))?;
let length = phase.texture_raw.iter().position(|byte| *byte == 0).unwrap_or(phase.texture_raw.len());
(length != 0).then(|| ResourceName(phase.texture_raw[..length].to_vec()))
})
.or_else(|| material.texture_requests.first().cloned())
.ok_or_else(|| {
format!("static preview material {source_selector} has no MAT0 diffuse texture")
})?;
let texture = assets
.textures
.iter()
.find(|texture| {
texture.usage == PreparedTextureUsage::Diffuse
&& texture.source.name == *texture_name
texture.usage == PreparedTextureUsage::Diffuse && texture.source.name == texture_name
})
.ok_or_else(|| {
format!(
@@ -703,6 +720,7 @@ struct StaticVulkanApp {
mesh_components: usize,
terrain_components: usize,
animation_frame: Option<u16>,
material_phase: Option<u16>,
preview_roots: usize,
target_frames: u64,
mission: String,
@@ -734,6 +752,7 @@ impl StaticVulkanApp {
mesh_components: preview.mesh_components,
terrain_components: preview.terrain_components,
animation_frame: preview.animation_frame,
material_phase: preview.material_phase,
preview_roots,
target_frames,
mission: mission.to_string(),
@@ -802,13 +821,14 @@ impl StaticVulkanApp {
(None, _) => None,
};
self.output = Some(format!(
"{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"camera_mode\":{},\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"preview_roots\":{},\"animation_frame\":{},\"mesh_components\":{},\"terrain_components\":{},\"clip_visible_vertices\":{},\"material_descriptors\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_format\":{},\"readback_bytes\":{},\"readback_hash\":{},\"readback_path\":{}}}",
"{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"camera_mode\":{},\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"preview_roots\":{},\"animation_frame\":{},\"material_phase\":{},\"mesh_components\":{},\"terrain_components\":{},\"clip_visible_vertices\":{},\"material_descriptors\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_format\":{},\"readback_bytes\":{},\"readback_hash\":{},\"readback_path\":{}}}",
json_string(self.camera_mode),
json_string(&self.mission),
self.object_count,
self.frames_presented,
self.preview_roots,
self.animation_frame.map_or_else(|| "null".to_string(), |frame| frame.to_string()),
self.material_phase.map_or_else(|| "null".to_string(), |phase| phase.to_string()),
self.mesh_components,
self.terrain_components,
self.clip_visible_vertices,
@@ -1063,6 +1083,7 @@ struct Args {
preview_roots: NonZeroUsize,
legacy_camera_capture: Option<PathBuf>,
static_animation_frame: Option<u16>,
static_material_phase: Option<u16>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -1087,6 +1108,7 @@ impl Args {
let mut preview_roots = NonZeroUsize::MAX;
let mut legacy_camera_capture = None;
let mut static_animation_frame = None;
let mut static_material_phase = None;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
@@ -1159,6 +1181,16 @@ impl Args {
})?,
);
}
"--static-material-phase" => {
static_material_phase = Some(
iter.next()
.ok_or_else(|| "--static-material-phase requires a value".to_string())?
.parse()
.map_err(|_| {
"--static-material-phase must be a u16 integer".to_string()
})?,
);
}
_ => return Err(usage()),
}
}
@@ -1176,6 +1208,9 @@ impl Args {
if static_animation_frame.is_some() && backend != RenderBackendMode::StaticVulkan {
return Err("--static-animation-frame requires --backend static-vulkan".to_string());
}
if static_material_phase.is_some() && backend != RenderBackendMode::StaticVulkan {
return Err("--static-material-phase requires --backend static-vulkan".to_string());
}
Ok(Self {
root,
mission,
@@ -1186,6 +1221,7 @@ impl Args {
preview_roots,
legacy_camera_capture,
static_animation_frame,
static_material_phase,
})
}
}
@@ -1257,7 +1293,7 @@ fn json_hash(hash: &[u8; 32]) -> String {
}
fn usage() -> String {
"usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--backend <planning|static-vulkan>] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--readback-out <path>] [--load-progress <path>]".to_string()
"usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--backend <planning|static-vulkan>] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>] [--load-progress <path>]".to_string()
}
#[cfg(test)]
@@ -1326,6 +1362,7 @@ mod tests {
preview_roots: NonZeroUsize::MAX,
legacy_camera_capture: None,
static_animation_frame: None,
static_material_phase: None,
})
);
}
@@ -1351,6 +1388,7 @@ mod tests {
preview_roots: NonZeroUsize::MAX,
legacy_camera_capture: None,
static_animation_frame: None,
static_material_phase: None,
})
);
}
@@ -1430,6 +1468,34 @@ mod tests {
assert_eq!(parsed.static_animation_frame, Some(12));
}
#[test]
fn static_material_phase_requires_static_vulkan_and_is_retained() {
let common = [
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--static-material-phase",
"2",
];
assert_eq!(
Args::parse(&strings(&common)),
Err("--static-material-phase requires --backend static-vulkan".to_string())
);
let parsed = Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--backend",
"static-vulkan",
"--static-material-phase",
"2",
]))
.expect("valid static material phase");
assert_eq!(parsed.static_material_phase, Some(2));
}
#[test]
fn rejects_zero_static_preview_root_count() {
let error = Args::parse(&strings(&[
@@ -1467,6 +1533,7 @@ mod tests {
preview_roots: NonZeroUsize::MAX,
legacy_camera_capture: None,
static_animation_frame: None,
static_material_phase: None,
})
);
}
+16
View File
@@ -1895,3 +1895,19 @@ the licensed GOG `MISSIONS/Autodemo.00/data.tma` run with all eight roots,
`--static-animation-frame 1`, two frames and format `50` completed with 66
mesh components, 75,543 clip-visible vertices, a 1280×720 / 3,686,400-byte
readback hash `4638497144561211935`, and validation warnings/errors `0/0`.
### Explicit MAT0 phase preview
`fparkan-game --backend static-vulkan --static-material-phase <u16>` now
selects the exact decoded MAT0 texture for that phase of each MSH material.
The override is deliberately per material: when the requested phase is absent
or intentionally untextured, that material retains its existing first prepared
diffuse request, rather than fabricating a texture or aborting the whole
mission. Terrain remains on its separately proven Land2 base path. The JSON
report records `material_phase` so the artifact's selector is explicit. This
is an asset-viewer control, not a claim about the original material clock,
phase mode, interpolation, random offset, lighting or Land1 composition.
On 2026-07-18 the licensed GOG AutoDemo run with all eight roots, phase `1`,
two frames and format `50` completed validation-clean with 71 descriptors and
a 1280×720 / 3,686,400-byte readback hash `3152437928708207395`.