feat(render): preview explicit animation frames

This commit is contained in:
2026-07-18 19:08:49 +04:00
parent 3b6dd392a7
commit 5c1ccae393
6 changed files with 337 additions and 27 deletions
+1 -3
View File
@@ -9,6 +9,7 @@ build = "build.rs"
[dependencies]
ash = "0.38"
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-msh = { path = "../../crates/fparkan-msh", 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_json = "1.0"
[dev-dependencies]
fparkan-animation = { path = "../../crates/fparkan-animation", version = "0.1.0" }
[lints.rust]
unsafe_code = "allow"
missing_docs = "warn"
@@ -2,7 +2,8 @@
use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex};
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_terrain_format::LandMeshDocument;
@@ -206,6 +207,39 @@ pub fn project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses(
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.
///
/// 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,
transform: LegacyIron3dEulerTransform,
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> {
if !transform
.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() {
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);
};
+3 -1
View File
@@ -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_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_sampled_poses,
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_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,
};
pub use self::capabilities::{
+95 -20
View File
@@ -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_xy_frame,
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,
VulkanStaticCamera, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture,
VulkanStaticXyFrame,
@@ -110,6 +112,7 @@ fn run(args: &[String]) -> Result<String, String> {
terrain,
roots,
camera,
args.static_animation_frame,
&args.root,
&loaded.land_msh_path,
)?;
@@ -241,6 +244,7 @@ struct StaticPreviewScene {
clip_visible_vertices: usize,
mesh_components: usize,
terrain_components: usize,
animation_frame: Option<u16>,
}
/// 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,
/// later material phases, animation, lightmaps and gameplay visibility remain
/// outside this bridge.
#[allow(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>,
root: &std::path::Path,
land_msh_path: &str,
) -> Result<StaticPreviewScene, String> {
@@ -301,26 +307,43 @@ fn static_preview_mesh_and_materials(
let model = assets.model_by_id(model_id).ok_or_else(|| {
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() {
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
&model.validated,
LegacyIron3dEulerTransform {
translation: root.position,
orientation_radians: root.orientation_raw,
},
root.scale,
)
if let Some(frame) = static_animation_frame {
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses(
&model.validated,
transform,
root.scale,
frame,
)
} else {
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
&model.validated,
transform,
root.scale,
)
}
} else {
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(
&model.validated,
frame,
LegacyIron3dEulerTransform {
translation: root.position,
orientation_radians: root.orientation_raw,
},
root.scale,
)
if let Some(animation_frame) = static_animation_frame {
project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses(
&model.validated,
frame,
transform,
root.scale,
animation_frame,
)
} 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}"))?;
let selector_remap =
@@ -345,6 +368,7 @@ fn static_preview_mesh_and_materials(
},
mesh_components,
terrain_components: 1,
animation_frame: static_animation_frame,
})
}
@@ -678,6 +702,7 @@ struct StaticVulkanApp {
clip_visible_vertices: usize,
mesh_components: usize,
terrain_components: usize,
animation_frame: Option<u16>,
preview_roots: usize,
target_frames: u64,
mission: String,
@@ -708,6 +733,7 @@ impl StaticVulkanApp {
clip_visible_vertices: preview.clip_visible_vertices,
mesh_components: preview.mesh_components,
terrain_components: preview.terrain_components,
animation_frame: preview.animation_frame,
preview_roots,
target_frames,
mission: mission.to_string(),
@@ -776,12 +802,13 @@ 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\":{},\"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.mission),
self.object_count,
self.frames_presented,
self.preview_roots,
self.animation_frame.map_or_else(|| "null".to_string(), |frame| frame.to_string()),
self.mesh_components,
self.terrain_components,
self.clip_visible_vertices,
@@ -1035,6 +1062,7 @@ struct Args {
readback_out: Option<PathBuf>,
preview_roots: NonZeroUsize,
legacy_camera_capture: Option<PathBuf>,
static_animation_frame: Option<u16>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -1044,6 +1072,7 @@ enum RenderBackendMode {
}
impl Args {
#[allow(clippy::too_many_lines)]
fn parse(args: &[String]) -> Result<Self, String> {
let mut root = None;
let mut mission = None;
@@ -1057,6 +1086,7 @@ impl Args {
// remains available for bounded diagnostic work.
let mut preview_roots = NonZeroUsize::MAX;
let mut legacy_camera_capture = None;
let mut static_animation_frame = None;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
@@ -1119,6 +1149,16 @@ impl Args {
"--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()),
}
}
@@ -1133,6 +1173,9 @@ impl Args {
if readback_out.is_some() && backend != RenderBackendMode::StaticVulkan {
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 {
root,
mission,
@@ -1142,6 +1185,7 @@ impl Args {
readback_out,
preview_roots,
legacy_camera_capture,
static_animation_frame,
})
}
}
@@ -1213,7 +1257,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>] [--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)]
@@ -1281,6 +1325,7 @@ mod tests {
readback_out: None,
preview_roots: NonZeroUsize::MAX,
legacy_camera_capture: None,
static_animation_frame: None,
})
);
}
@@ -1305,6 +1350,7 @@ mod tests {
readback_out: None,
preview_roots: NonZeroUsize::MAX,
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")));
}
#[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]
fn rejects_zero_static_preview_root_count() {
let error = Args::parse(&strings(&[
@@ -1392,6 +1466,7 @@ mod tests {
readback_out: None,
preview_roots: NonZeroUsize::MAX,
legacy_camera_capture: None,
static_animation_frame: None,
})
);
}
+141 -1
View File
@@ -21,7 +21,10 @@
//! Stage-3 MSH asset contract.
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};
/// Node table stream.
@@ -478,6 +481,88 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
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.
///
/// # Errors
@@ -1273,6 +1358,61 @@ mod tests {
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]
fn type2_header_and_slot_tail_framing_are_exact() {
let too_small = decode_nested(&build_nres(&[
+22
View File
@@ -1841,3 +1841,25 @@ path; он не является оценкой сходства с original fra
`95.29412..288.23532` становятся `2.978..9.007`, что согласуется с TMA Z и
live camera height. Это доказанное coordinate conversion, но оно само по себе
не сделало вершины видимыми и не является заявлением о 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`.