feat(render): preview explicit animation frames
This commit is contained in:
@@ -9,6 +9,7 @@ build = "build.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
ash = "0.38"
|
ash = "0.38"
|
||||||
ash-window = "0.13"
|
ash-window = "0.13"
|
||||||
|
fparkan-animation = { path = "../../crates/fparkan-animation", version = "0.1.0" }
|
||||||
fparkan-binary = { path = "../../crates/fparkan-binary", version = "0.1.0" }
|
fparkan-binary = { path = "../../crates/fparkan-binary", version = "0.1.0" }
|
||||||
fparkan-msh = { path = "../../crates/fparkan-msh", version = "0.1.0" }
|
fparkan-msh = { path = "../../crates/fparkan-msh", version = "0.1.0" }
|
||||||
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
|
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
|
||||||
@@ -17,9 +18,6 @@ fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format", version
|
|||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
fparkan-animation = { path = "../../crates/fparkan-animation", version = "0.1.0" }
|
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "allow"
|
unsafe_code = "allow"
|
||||||
missing_docs = "warn"
|
missing_docs = "warn"
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex};
|
use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex};
|
||||||
use fparkan_msh::{
|
use fparkan_msh::{
|
||||||
draw_batches, node38_fallback_hierarchy, selected_slot, Group, Lod, ModelAsset, NodeId,
|
draw_batches, node38_fallback_hierarchy, node38_sampled_hierarchy, selected_slot, Group, Lod,
|
||||||
|
ModelAsset, NodeId,
|
||||||
};
|
};
|
||||||
use fparkan_render::{LegacyIron3dEulerTransform, LegacyPipelineState};
|
use fparkan_render::{LegacyIron3dEulerTransform, LegacyPipelineState};
|
||||||
use fparkan_terrain_format::LandMeshDocument;
|
use fparkan_terrain_format::LandMeshDocument;
|
||||||
@@ -206,6 +207,39 @@ pub fn project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses(
|
|||||||
Ok(mesh)
|
Ok(mesh)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Projects a standard `Node38` model at one explicit portable-reference
|
||||||
|
/// animation frame into a diagnostic XY frame.
|
||||||
|
///
|
||||||
|
/// This is the diagnostic-camera counterpart of
|
||||||
|
/// [`project_msh_to_static_mesh_in_world_space_with_node_sampled_poses`].
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`VulkanAssetMeshError`] when the source cannot be represented by
|
||||||
|
/// the static bridge or a transformed coordinate is non-finite.
|
||||||
|
pub fn project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses(
|
||||||
|
model: &ModelAsset,
|
||||||
|
frame: VulkanStaticXyFrame,
|
||||||
|
transform: LegacyIron3dEulerTransform,
|
||||||
|
scale: [f32; 3],
|
||||||
|
animation_frame: u16,
|
||||||
|
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
|
||||||
|
let mut mesh = project_msh_to_static_mesh_in_world_space_with_node_sampled_poses(
|
||||||
|
model,
|
||||||
|
transform,
|
||||||
|
scale,
|
||||||
|
animation_frame,
|
||||||
|
)?;
|
||||||
|
for vertex in &mut mesh.vertices {
|
||||||
|
if !vertex.position.iter().all(|value| value.is_finite()) {
|
||||||
|
return Err(VulkanAssetMeshError::NonFinitePosition);
|
||||||
|
}
|
||||||
|
let projected = frame.project(vertex.position);
|
||||||
|
vertex.position = [projected[0], projected[1], 0.0];
|
||||||
|
}
|
||||||
|
Ok(mesh)
|
||||||
|
}
|
||||||
|
|
||||||
/// Projects MSH geometry into source world coordinates with known translation and scale.
|
/// Projects MSH geometry into source world coordinates with known translation and scale.
|
||||||
///
|
///
|
||||||
/// Unlike [`project_msh_to_static_mesh_in_xy_frame`], this does not normalize
|
/// Unlike [`project_msh_to_static_mesh_in_xy_frame`], this does not normalize
|
||||||
@@ -307,6 +341,45 @@ pub fn project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
|
|||||||
model: &ModelAsset,
|
model: &ModelAsset,
|
||||||
transform: LegacyIron3dEulerTransform,
|
transform: LegacyIron3dEulerTransform,
|
||||||
scale: [f32; 3],
|
scale: [f32; 3],
|
||||||
|
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
|
||||||
|
project_msh_to_static_mesh_in_world_space_with_node_poses(
|
||||||
|
model,
|
||||||
|
transform,
|
||||||
|
scale,
|
||||||
|
node38_fallback_hierarchy(model),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Projects a standard `Node38` model at one explicit portable-reference
|
||||||
|
/// animation frame.
|
||||||
|
///
|
||||||
|
/// This uses decoded type-8 keys and the type-19 map but deliberately does
|
||||||
|
/// not infer the original engine's animation clock or x87 profile. Models
|
||||||
|
/// without a complete sampled hierarchy retain the established unposed bridge.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`VulkanAssetMeshError`] when source geometry or transforms cannot
|
||||||
|
/// be represented by the current static Vulkan input contract.
|
||||||
|
pub fn project_msh_to_static_mesh_in_world_space_with_node_sampled_poses(
|
||||||
|
model: &ModelAsset,
|
||||||
|
transform: LegacyIron3dEulerTransform,
|
||||||
|
scale: [f32; 3],
|
||||||
|
animation_frame: u16,
|
||||||
|
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
|
||||||
|
project_msh_to_static_mesh_in_world_space_with_node_poses(
|
||||||
|
model,
|
||||||
|
transform,
|
||||||
|
scale,
|
||||||
|
node38_sampled_hierarchy(model, animation_frame),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_msh_to_static_mesh_in_world_space_with_node_poses(
|
||||||
|
model: &ModelAsset,
|
||||||
|
transform: LegacyIron3dEulerTransform,
|
||||||
|
scale: [f32; 3],
|
||||||
|
hierarchy: Option<fparkan_animation::NodePoseBuffer>,
|
||||||
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
|
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
|
||||||
if !transform
|
if !transform
|
||||||
.translation
|
.translation
|
||||||
@@ -320,7 +393,7 @@ pub fn project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
|
|||||||
if model.node_stride != 38 || model.node_count == 0 || model.animation.is_none() {
|
if model.node_stride != 38 || model.node_count == 0 || model.animation.is_none() {
|
||||||
return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale);
|
return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale);
|
||||||
}
|
}
|
||||||
let Some(hierarchy) = node38_fallback_hierarchy(model) else {
|
let Some(hierarchy) = hierarchy else {
|
||||||
return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale);
|
return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -44,9 +44,11 @@ pub use self::asset_mesh::{
|
|||||||
project_land_msh_to_static_mesh_in_world_space, project_land_msh_to_static_mesh_in_xy_frame,
|
project_land_msh_to_static_mesh_in_world_space, project_land_msh_to_static_mesh_in_xy_frame,
|
||||||
project_msh_to_static_mesh, project_msh_to_static_mesh_in_world_space,
|
project_msh_to_static_mesh, project_msh_to_static_mesh_in_world_space,
|
||||||
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
|
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
|
||||||
|
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses,
|
||||||
project_msh_to_static_mesh_in_world_space_with_transform,
|
project_msh_to_static_mesh_in_world_space_with_transform,
|
||||||
project_msh_to_static_mesh_in_xy_frame,
|
project_msh_to_static_mesh_in_xy_frame,
|
||||||
project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses, VulkanAssetMeshError,
|
project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses,
|
||||||
|
project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanAssetMeshError,
|
||||||
VulkanStaticXyFrame,
|
VulkanStaticXyFrame,
|
||||||
};
|
};
|
||||||
pub use self::capabilities::{
|
pub use self::capabilities::{
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ use fparkan_render_vulkan::{
|
|||||||
project_land_msh_to_static_mesh_in_legacy_world_space,
|
project_land_msh_to_static_mesh_in_legacy_world_space,
|
||||||
project_land_msh_to_static_mesh_in_xy_frame,
|
project_land_msh_to_static_mesh_in_xy_frame,
|
||||||
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
|
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
|
||||||
project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses, VulkanPlanningBackend,
|
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses,
|
||||||
|
project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses,
|
||||||
|
project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanPlanningBackend,
|
||||||
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
|
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
|
||||||
VulkanStaticCamera, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture,
|
VulkanStaticCamera, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture,
|
||||||
VulkanStaticXyFrame,
|
VulkanStaticXyFrame,
|
||||||
@@ -110,6 +112,7 @@ fn run(args: &[String]) -> Result<String, String> {
|
|||||||
terrain,
|
terrain,
|
||||||
roots,
|
roots,
|
||||||
camera,
|
camera,
|
||||||
|
args.static_animation_frame,
|
||||||
&args.root,
|
&args.root,
|
||||||
&loaded.land_msh_path,
|
&loaded.land_msh_path,
|
||||||
)?;
|
)?;
|
||||||
@@ -241,6 +244,7 @@ struct StaticPreviewScene {
|
|||||||
clip_visible_vertices: usize,
|
clip_visible_vertices: usize,
|
||||||
mesh_components: usize,
|
mesh_components: usize,
|
||||||
terrain_components: usize,
|
terrain_components: usize,
|
||||||
|
animation_frame: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Projects the mission terrain plus every MSH component of the explicitly
|
/// Projects the mission terrain plus every MSH component of the explicitly
|
||||||
@@ -252,11 +256,13 @@ struct StaticPreviewScene {
|
|||||||
/// retained on draw ranges but awaits a recovered blend equation; orientation,
|
/// retained on draw ranges but awaits a recovered blend equation; orientation,
|
||||||
/// later material phases, animation, lightmaps and gameplay visibility remain
|
/// later material phases, animation, lightmaps and gameplay visibility remain
|
||||||
/// outside this bridge.
|
/// outside this bridge.
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
fn static_preview_mesh_and_materials(
|
fn static_preview_mesh_and_materials(
|
||||||
assets: &MissionAssets,
|
assets: &MissionAssets,
|
||||||
terrain: &TerrainWorld,
|
terrain: &TerrainWorld,
|
||||||
roots: &[MissionObjectDraft],
|
roots: &[MissionObjectDraft],
|
||||||
legacy_camera: Option<VulkanStaticCamera>,
|
legacy_camera: Option<VulkanStaticCamera>,
|
||||||
|
static_animation_frame: Option<u16>,
|
||||||
root: &std::path::Path,
|
root: &std::path::Path,
|
||||||
land_msh_path: &str,
|
land_msh_path: &str,
|
||||||
) -> Result<StaticPreviewScene, String> {
|
) -> Result<StaticPreviewScene, String> {
|
||||||
@@ -301,26 +307,43 @@ fn static_preview_mesh_and_materials(
|
|||||||
let model = assets.model_by_id(model_id).ok_or_else(|| {
|
let model = assets.model_by_id(model_id).ok_or_else(|| {
|
||||||
format!("static preview visual {visual_id:?} references unknown model {model_id:?}")
|
format!("static preview visual {visual_id:?} references unknown model {model_id:?}")
|
||||||
})?;
|
})?;
|
||||||
|
let transform = LegacyIron3dEulerTransform {
|
||||||
|
translation: root.position,
|
||||||
|
orientation_radians: root.orientation_raw,
|
||||||
|
};
|
||||||
let component = if legacy_camera.is_some() {
|
let component = if legacy_camera.is_some() {
|
||||||
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
|
if let Some(frame) = static_animation_frame {
|
||||||
&model.validated,
|
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses(
|
||||||
LegacyIron3dEulerTransform {
|
&model.validated,
|
||||||
translation: root.position,
|
transform,
|
||||||
orientation_radians: root.orientation_raw,
|
root.scale,
|
||||||
},
|
frame,
|
||||||
root.scale,
|
)
|
||||||
)
|
} else {
|
||||||
|
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
|
||||||
|
&model.validated,
|
||||||
|
transform,
|
||||||
|
root.scale,
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let frame = xy_frame.ok_or_else(|| "missing diagnostic XY frame".to_string())?;
|
let frame = xy_frame.ok_or_else(|| "missing diagnostic XY frame".to_string())?;
|
||||||
project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses(
|
if let Some(animation_frame) = static_animation_frame {
|
||||||
&model.validated,
|
project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses(
|
||||||
frame,
|
&model.validated,
|
||||||
LegacyIron3dEulerTransform {
|
frame,
|
||||||
translation: root.position,
|
transform,
|
||||||
orientation_radians: root.orientation_raw,
|
root.scale,
|
||||||
},
|
animation_frame,
|
||||||
root.scale,
|
)
|
||||||
)
|
} else {
|
||||||
|
project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses(
|
||||||
|
&model.validated,
|
||||||
|
frame,
|
||||||
|
transform,
|
||||||
|
root.scale,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.map_err(|err| format!("project mission MSH for Vulkan: {err}"))?;
|
.map_err(|err| format!("project mission MSH for Vulkan: {err}"))?;
|
||||||
let selector_remap =
|
let selector_remap =
|
||||||
@@ -345,6 +368,7 @@ fn static_preview_mesh_and_materials(
|
|||||||
},
|
},
|
||||||
mesh_components,
|
mesh_components,
|
||||||
terrain_components: 1,
|
terrain_components: 1,
|
||||||
|
animation_frame: static_animation_frame,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -678,6 +702,7 @@ struct StaticVulkanApp {
|
|||||||
clip_visible_vertices: usize,
|
clip_visible_vertices: usize,
|
||||||
mesh_components: usize,
|
mesh_components: usize,
|
||||||
terrain_components: usize,
|
terrain_components: usize,
|
||||||
|
animation_frame: Option<u16>,
|
||||||
preview_roots: usize,
|
preview_roots: usize,
|
||||||
target_frames: u64,
|
target_frames: u64,
|
||||||
mission: String,
|
mission: String,
|
||||||
@@ -708,6 +733,7 @@ impl StaticVulkanApp {
|
|||||||
clip_visible_vertices: preview.clip_visible_vertices,
|
clip_visible_vertices: preview.clip_visible_vertices,
|
||||||
mesh_components: preview.mesh_components,
|
mesh_components: preview.mesh_components,
|
||||||
terrain_components: preview.terrain_components,
|
terrain_components: preview.terrain_components,
|
||||||
|
animation_frame: preview.animation_frame,
|
||||||
preview_roots,
|
preview_roots,
|
||||||
target_frames,
|
target_frames,
|
||||||
mission: mission.to_string(),
|
mission: mission.to_string(),
|
||||||
@@ -776,12 +802,13 @@ impl StaticVulkanApp {
|
|||||||
(None, _) => None,
|
(None, _) => None,
|
||||||
};
|
};
|
||||||
self.output = Some(format!(
|
self.output = Some(format!(
|
||||||
"{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"camera_mode\":{},\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"preview_roots\":{},\"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\":{},\"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.camera_mode),
|
||||||
json_string(&self.mission),
|
json_string(&self.mission),
|
||||||
self.object_count,
|
self.object_count,
|
||||||
self.frames_presented,
|
self.frames_presented,
|
||||||
self.preview_roots,
|
self.preview_roots,
|
||||||
|
self.animation_frame.map_or_else(|| "null".to_string(), |frame| frame.to_string()),
|
||||||
self.mesh_components,
|
self.mesh_components,
|
||||||
self.terrain_components,
|
self.terrain_components,
|
||||||
self.clip_visible_vertices,
|
self.clip_visible_vertices,
|
||||||
@@ -1035,6 +1062,7 @@ struct Args {
|
|||||||
readback_out: Option<PathBuf>,
|
readback_out: Option<PathBuf>,
|
||||||
preview_roots: NonZeroUsize,
|
preview_roots: NonZeroUsize,
|
||||||
legacy_camera_capture: Option<PathBuf>,
|
legacy_camera_capture: Option<PathBuf>,
|
||||||
|
static_animation_frame: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -1044,6 +1072,7 @@ enum RenderBackendMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Args {
|
impl Args {
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
fn parse(args: &[String]) -> Result<Self, String> {
|
fn parse(args: &[String]) -> Result<Self, String> {
|
||||||
let mut root = None;
|
let mut root = None;
|
||||||
let mut mission = None;
|
let mut mission = None;
|
||||||
@@ -1057,6 +1086,7 @@ impl Args {
|
|||||||
// remains available for bounded diagnostic work.
|
// remains available for bounded diagnostic work.
|
||||||
let mut preview_roots = NonZeroUsize::MAX;
|
let mut preview_roots = NonZeroUsize::MAX;
|
||||||
let mut legacy_camera_capture = None;
|
let mut legacy_camera_capture = None;
|
||||||
|
let mut static_animation_frame = None;
|
||||||
let mut iter = args.iter();
|
let mut iter = args.iter();
|
||||||
while let Some(arg) = iter.next() {
|
while let Some(arg) = iter.next() {
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
@@ -1119,6 +1149,16 @@ impl Args {
|
|||||||
"--legacy-camera-capture requires a path".to_string()
|
"--legacy-camera-capture requires a path".to_string()
|
||||||
})?);
|
})?);
|
||||||
}
|
}
|
||||||
|
"--static-animation-frame" => {
|
||||||
|
static_animation_frame = Some(
|
||||||
|
iter.next()
|
||||||
|
.ok_or_else(|| "--static-animation-frame requires a value".to_string())?
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| {
|
||||||
|
"--static-animation-frame must be a u16 integer".to_string()
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
}
|
||||||
_ => return Err(usage()),
|
_ => return Err(usage()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1133,6 +1173,9 @@ impl Args {
|
|||||||
if readback_out.is_some() && backend != RenderBackendMode::StaticVulkan {
|
if readback_out.is_some() && backend != RenderBackendMode::StaticVulkan {
|
||||||
return Err("--readback-out requires --backend static-vulkan".to_string());
|
return Err("--readback-out requires --backend static-vulkan".to_string());
|
||||||
}
|
}
|
||||||
|
if static_animation_frame.is_some() && backend != RenderBackendMode::StaticVulkan {
|
||||||
|
return Err("--static-animation-frame requires --backend static-vulkan".to_string());
|
||||||
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
root,
|
root,
|
||||||
mission,
|
mission,
|
||||||
@@ -1142,6 +1185,7 @@ impl Args {
|
|||||||
readback_out,
|
readback_out,
|
||||||
preview_roots,
|
preview_roots,
|
||||||
legacy_camera_capture,
|
legacy_camera_capture,
|
||||||
|
static_animation_frame,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1213,7 +1257,7 @@ fn json_hash(hash: &[u8; 32]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn usage() -> 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>] [--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>] [--readback-out <path>] [--load-progress <path>]".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1281,6 +1325,7 @@ mod tests {
|
|||||||
readback_out: None,
|
readback_out: None,
|
||||||
preview_roots: NonZeroUsize::MAX,
|
preview_roots: NonZeroUsize::MAX,
|
||||||
legacy_camera_capture: None,
|
legacy_camera_capture: None,
|
||||||
|
static_animation_frame: None,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1305,6 +1350,7 @@ mod tests {
|
|||||||
readback_out: None,
|
readback_out: None,
|
||||||
preview_roots: NonZeroUsize::MAX,
|
preview_roots: NonZeroUsize::MAX,
|
||||||
legacy_camera_capture: None,
|
legacy_camera_capture: None,
|
||||||
|
static_animation_frame: None,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1356,6 +1402,34 @@ mod tests {
|
|||||||
assert_eq!(parsed.readback_out, Some(PathBuf::from("target/frame.raw")));
|
assert_eq!(parsed.readback_out, Some(PathBuf::from("target/frame.raw")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn static_animation_frame_requires_static_vulkan_and_is_retained() {
|
||||||
|
let common = [
|
||||||
|
"--root",
|
||||||
|
"testdata/IS",
|
||||||
|
"--mission",
|
||||||
|
"MISSIONS/Autodemo.00/data.tma",
|
||||||
|
"--static-animation-frame",
|
||||||
|
"12",
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
Args::parse(&strings(&common)),
|
||||||
|
Err("--static-animation-frame requires --backend static-vulkan".to_string())
|
||||||
|
);
|
||||||
|
let parsed = Args::parse(&strings(&[
|
||||||
|
"--root",
|
||||||
|
"testdata/IS",
|
||||||
|
"--mission",
|
||||||
|
"MISSIONS/Autodemo.00/data.tma",
|
||||||
|
"--backend",
|
||||||
|
"static-vulkan",
|
||||||
|
"--static-animation-frame",
|
||||||
|
"12",
|
||||||
|
]))
|
||||||
|
.expect("valid static animation frame");
|
||||||
|
assert_eq!(parsed.static_animation_frame, Some(12));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_zero_static_preview_root_count() {
|
fn rejects_zero_static_preview_root_count() {
|
||||||
let error = Args::parse(&strings(&[
|
let error = Args::parse(&strings(&[
|
||||||
@@ -1392,6 +1466,7 @@ mod tests {
|
|||||||
readback_out: None,
|
readback_out: None,
|
||||||
preview_roots: NonZeroUsize::MAX,
|
preview_roots: NonZeroUsize::MAX,
|
||||||
legacy_camera_capture: None,
|
legacy_camera_capture: None,
|
||||||
|
static_animation_frame: None,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,10 @@
|
|||||||
//! Stage-3 MSH asset contract.
|
//! Stage-3 MSH asset contract.
|
||||||
|
|
||||||
use encoding_rs::WINDOWS_1251;
|
use encoding_rs::WINDOWS_1251;
|
||||||
use fparkan_animation::{evaluate_hierarchy, AnimKey24, NodePoseBuffer, ParentIndex, Pose};
|
use fparkan_animation::{
|
||||||
|
evaluate_hierarchy, AnimKey24, AnimationTime, NodePoseBuffer, ParentIndex, Pose, TimedPoseKey,
|
||||||
|
TimedPoseTrack,
|
||||||
|
};
|
||||||
use fparkan_nres::{EntryMeta, NresDocument, NresError};
|
use fparkan_nres::{EntryMeta, NresDocument, NresError};
|
||||||
|
|
||||||
/// Node table stream.
|
/// Node table stream.
|
||||||
@@ -478,6 +481,88 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
|
|||||||
evaluate_hierarchy(&parents, &poses).ok()
|
evaluate_hierarchy(&parents, &poses).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Evaluates the portable-reference pose hierarchy of a standard `Node38`
|
||||||
|
/// model at one explicitly supplied logical frame.
|
||||||
|
///
|
||||||
|
/// A node without a usable type-19 map, or a requested frame outside the
|
||||||
|
/// declared map length, retains its exact fallback key. Mapped keys are
|
||||||
|
/// sampled against their immediate successor using the decoded key times.
|
||||||
|
/// This is an offline asset-sampling contract; it does not claim ownership of
|
||||||
|
/// the original runtime's animation clock or x87 numeric profile.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the model lacks a complete standard-node animation
|
||||||
|
/// representation or the map cannot be safely resolved.
|
||||||
|
#[must_use]
|
||||||
|
pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePoseBuffer> {
|
||||||
|
if model.node_stride != 38 || model.node_count == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let animation = model.animation.as_ref()?;
|
||||||
|
let mut parents = Vec::with_capacity(model.node_count);
|
||||||
|
let mut poses = Vec::with_capacity(model.node_count);
|
||||||
|
for index in 0..model.node_count {
|
||||||
|
let node = NodeId(u32::try_from(index).ok()?);
|
||||||
|
let metadata = node38_metadata(model, node)?;
|
||||||
|
let parent = if metadata.parent_or_link_raw == u16::MAX {
|
||||||
|
ParentIndex(None)
|
||||||
|
} else {
|
||||||
|
let parent = usize::from(metadata.parent_or_link_raw);
|
||||||
|
(parent < index).then_some(ParentIndex(Some(metadata.parent_or_link_raw)))?
|
||||||
|
};
|
||||||
|
let fallback_index = usize::from(metadata.fallback_key);
|
||||||
|
let _fallback = animation.keys.get(fallback_index)?;
|
||||||
|
let key_index =
|
||||||
|
if metadata.anim_map_start == u16::MAX || u32::from(frame) >= animation.frame_count {
|
||||||
|
fallback_index
|
||||||
|
} else {
|
||||||
|
let mapped_index =
|
||||||
|
usize::from(*animation.frame_map.get(
|
||||||
|
usize::from(metadata.anim_map_start).checked_add(usize::from(frame))?,
|
||||||
|
)?);
|
||||||
|
if mapped_index < fallback_index {
|
||||||
|
mapped_index
|
||||||
|
} else {
|
||||||
|
fallback_index
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let pose = sample_node38_key_pair(&animation.keys, key_index, fallback_index, frame)?;
|
||||||
|
parents.push(parent);
|
||||||
|
poses.push(pose);
|
||||||
|
}
|
||||||
|
evaluate_hierarchy(&parents, &poses).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_node38_key_pair(
|
||||||
|
keys: &[AnimKey24],
|
||||||
|
key_index: usize,
|
||||||
|
fallback_index: usize,
|
||||||
|
frame: u16,
|
||||||
|
) -> Option<Pose> {
|
||||||
|
let key = *keys.get(key_index)?;
|
||||||
|
if key_index == fallback_index {
|
||||||
|
return Some(key.sampling_pose());
|
||||||
|
}
|
||||||
|
let next = *keys.get(key_index.checked_add(1)?)?;
|
||||||
|
if next.time.0 <= key.time.0 {
|
||||||
|
return Some(key.sampling_pose());
|
||||||
|
}
|
||||||
|
let track = TimedPoseTrack::new(
|
||||||
|
key.sampling_pose(),
|
||||||
|
vec![
|
||||||
|
TimedPoseKey {
|
||||||
|
time: key.time,
|
||||||
|
pose: key.sampling_pose(),
|
||||||
|
},
|
||||||
|
TimedPoseKey {
|
||||||
|
time: next.time,
|
||||||
|
pose: next.sampling_pose(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
track.sample(AnimationTime(f32::from(frame))).ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns draw batches for a validated slot.
|
/// Returns draw batches for a validated slot.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@@ -1273,6 +1358,61 @@ mod tests {
|
|||||||
assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001);
|
assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_nodes_sample_mapped_key_before_fallback_key() {
|
||||||
|
let mut node = node38([u16::MAX; 15]);
|
||||||
|
node[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||||
|
node[4..6].copy_from_slice(&0_u16.to_le_bytes());
|
||||||
|
node[6..8].copy_from_slice(&2_u16.to_le_bytes());
|
||||||
|
let model = ModelAsset {
|
||||||
|
node_stride: 38,
|
||||||
|
node_count: 1,
|
||||||
|
nodes_raw: node,
|
||||||
|
slots: Vec::new(),
|
||||||
|
positions: Vec::new(),
|
||||||
|
normals: None,
|
||||||
|
uv0: None,
|
||||||
|
indices: Vec::new(),
|
||||||
|
batches: Vec::new(),
|
||||||
|
node_names: None,
|
||||||
|
animation: Some(ModelAnimation {
|
||||||
|
keys: vec![
|
||||||
|
AnimKey24 {
|
||||||
|
time: AnimationTime(0.0),
|
||||||
|
pose: Pose {
|
||||||
|
translation: [4.0, 0.0, 0.0],
|
||||||
|
rotation: [0.0, 0.0, 0.0, 1.0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AnimKey24 {
|
||||||
|
time: AnimationTime(2.0),
|
||||||
|
pose: Pose {
|
||||||
|
translation: [8.0, 0.0, 0.0],
|
||||||
|
rotation: [0.0, 0.0, 0.0, 1.0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AnimKey24 {
|
||||||
|
time: AnimationTime(9.0),
|
||||||
|
pose: Pose::default(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
frame_map: vec![0, 0, 0],
|
||||||
|
frame_count: 3,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let pose = node38_sampled_hierarchy(&model, 1)
|
||||||
|
.expect("mapped hierarchy")
|
||||||
|
.poses[0];
|
||||||
|
assert!((pose.translation[0] - 6.0).abs() < f32::EPSILON);
|
||||||
|
assert_eq!(
|
||||||
|
node38_sampled_hierarchy(&model, 9)
|
||||||
|
.expect("fallback hierarchy")
|
||||||
|
.poses[0],
|
||||||
|
Pose::default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn type2_header_and_slot_tail_framing_are_exact() {
|
fn type2_header_and_slot_tail_framing_are_exact() {
|
||||||
let too_small = decode_nested(&build_nres(&[
|
let too_small = decode_nested(&build_nres(&[
|
||||||
|
|||||||
@@ -1841,3 +1841,25 @@ path; он не является оценкой сходства с original fra
|
|||||||
`95.29412..288.23532` становятся `2.978..9.007`, что согласуется с TMA Z и
|
`95.29412..288.23532` становятся `2.978..9.007`, что согласуется с TMA Z и
|
||||||
live camera height. Это доказанное coordinate conversion, но оно само по себе
|
live camera height. Это доказанное coordinate conversion, но оно само по себе
|
||||||
не сделало вершины видимыми и не является заявлением о pixel parity.
|
не сделало вершины видимыми и не является заявлением о pixel parity.
|
||||||
|
|
||||||
|
### Explicit offline Node38 animation-frame preview
|
||||||
|
|
||||||
|
The static Vulkan path now accepts `--static-animation-frame <u16>`. For each
|
||||||
|
standard `Node38` model it resolves the already-decoded type-19 map at that
|
||||||
|
logical frame, selects its mapped type-8 key when it is valid before the
|
||||||
|
node's fallback key, samples the selected key and its immediate successor with
|
||||||
|
the portable reference sampler, then composes the resulting parent hierarchy.
|
||||||
|
Nodes with no usable map, or a requested frame outside the declared map,
|
||||||
|
retain their exact fallback pose. An invalid/incomplete hierarchy deliberately
|
||||||
|
falls back to the existing unposed static geometry path instead of guessing a
|
||||||
|
runtime state.
|
||||||
|
|
||||||
|
This option is a reproducible asset-viewer probe, not a gameplay clock: it
|
||||||
|
does not identify the original animation controller, x87 rounding profile,
|
||||||
|
clip selection, LOD/group controller, blending or FX timing. The JSON report
|
||||||
|
contains `animation_frame` (`null` for the established fallback preview) so
|
||||||
|
readbacks from the two modes cannot be mistaken for each other. On 2026-07-18,
|
||||||
|
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`.
|
||||||
|
|||||||
Reference in New Issue
Block a user