feat(render): apply static node fallback poses

This commit is contained in:
2026-07-18 16:14:58 +04:00
parent cfa6d27032
commit 858dcd0d1d
7 changed files with 286 additions and 4 deletions
Generated
+1
View File
@@ -566,6 +566,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"ash", "ash",
"ash-window", "ash-window",
"fparkan-animation",
"fparkan-binary", "fparkan-binary",
"fparkan-msh", "fparkan-msh",
"fparkan-platform", "fparkan-platform",
@@ -17,6 +17,9 @@ 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"
@@ -1,7 +1,9 @@
//! Format-to-GPU geometry bridge for the initial static asset renderer. //! Format-to-GPU geometry bridge for the initial static asset renderer.
use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex}; use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex};
use fparkan_msh::{selected_slot, Group, Lod, ModelAsset, NodeId}; use fparkan_msh::{
draw_batches, node38_fallback_pose, 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;
@@ -256,6 +258,167 @@ pub fn project_msh_to_static_mesh_in_world_space_with_transform(
}) })
} }
/// Projects a standard `Node38` model using each selected node's decoded
/// fallback pose before its recovered `Iron3D` placement transform.
///
/// This is deliberately not a skeleton evaluator: the source field at
/// `Node38 + 2` is still an unassigned link, so no parent transform is
/// inferred here. Each node's source fallback pose is nevertheless enough to
/// keep independently stored static components at their authored local pose.
/// Models without complete standard-node pose data retain the established
/// unposed static 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_fallback_poses(
model: &ModelAsset,
transform: LegacyIron3dEulerTransform,
scale: [f32; 3],
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
if !transform
.translation
.iter()
.chain(transform.orientation_radians.iter())
.chain(scale.iter())
.all(|value| value.is_finite())
{
return Err(VulkanAssetMeshError::NonFinitePosition);
}
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 mut vertices = Vec::new();
let mut indices = Vec::new();
let mut draw_ranges = Vec::new();
for node_index in 0..model.node_count {
let node =
NodeId(u32::try_from(node_index).map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?);
let Some(slot) = selected_slot(model, node, Lod(0), Group(0)) else {
continue;
};
let Some(pose) = node38_fallback_pose(model, node) else {
return project_msh_to_static_mesh_in_world_space_with_transform(
model, transform, scale,
);
};
if !pose
.translation
.iter()
.chain(pose.rotation.iter())
.all(|value| value.is_finite())
{
return Err(VulkanAssetMeshError::NonFinitePosition);
}
for batch in draw_batches(model, slot).map_err(|_| VulkanAssetMeshError::IndexOutOfRange)? {
append_node_fallback_batch(
model,
batch,
pose.translation,
pose.rotation,
transform,
scale,
&mut vertices,
&mut indices,
&mut draw_ranges,
)?;
}
}
if indices.is_empty() {
return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale);
}
Ok(VulkanStaticMesh {
vertices,
indices,
draw_ranges,
})
}
#[allow(clippy::too_many_arguments)]
fn append_node_fallback_batch(
model: &ModelAsset,
batch: &fparkan_msh::Batch,
translation: [f32; 3],
rotation: [f32; 4],
transform: LegacyIron3dEulerTransform,
scale: [f32; 3],
vertices: &mut Vec<VulkanStaticVertex>,
indices: &mut Vec<u32>,
draw_ranges: &mut Vec<VulkanStaticDrawRange>,
) -> Result<(), VulkanAssetMeshError> {
let first_index =
u32::try_from(indices.len()).map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?;
let start =
usize::try_from(batch.index_start).map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?;
let end = start
.checked_add(usize::from(batch.index_count))
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?;
for &raw_index in model
.indices
.get(start..end)
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?
{
let source_index = batch
.base_vertex
.checked_add(u32::from(raw_index))
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?;
let source_index =
usize::try_from(source_index).map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?;
let position = *model
.positions
.get(source_index)
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?;
if !position.iter().all(|value| value.is_finite()) {
return Err(VulkanAssetMeshError::NonFinitePosition);
}
let local_position = rotate_by_quaternion(position, rotation);
let local_position = [
local_position[0] + translation[0],
local_position[1] + translation[1],
local_position[2] + translation[2],
];
let position = transform
.try_transform_scaled_point(local_position, scale)
.ok_or(VulkanAssetMeshError::NonFinitePosition)?;
let vertex_index =
u32::try_from(vertices.len()).map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?;
vertices.push(VulkanStaticVertex {
position,
color: [0.82, 0.72, 0.31],
uv: model
.uv0
.as_ref()
.and_then(|uv0| uv0.get(source_index))
.map_or([0.0, 0.0], |uv| {
[f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0]
}),
});
indices.push(vertex_index);
}
draw_ranges.push(VulkanStaticDrawRange {
first_index,
index_count: u32::from(batch.index_count),
material_index: batch.material_index,
pipeline_state: LegacyPipelineState::default(),
alpha_test_reference: 0,
});
Ok(())
}
fn rotate_by_quaternion(position: [f32; 3], rotation: [f32; 4]) -> [f32; 3] {
let [x, y, z, w] = rotation;
let tx = 2.0 * (y * position[2] - z * position[1]);
let ty = 2.0 * (z * position[0] - x * position[2]);
let tz = 2.0 * (x * position[1] - y * position[0]);
[
position[0] + w * tx + (y * tz - z * ty),
position[1] + w * ty + (z * tx - x * tz),
position[2] + w * tz + (x * ty - y * tx),
]
}
/// Projects validated `Land.msh` terrain geometry into the static Vulkan path. /// Projects validated `Land.msh` terrain geometry into the static Vulkan path.
/// ///
/// This bridge preserves the source triangle order from `TerrainFace28` and /// This bridge preserves the source triangle order from `TerrainFace28` and
@@ -551,7 +714,8 @@ fn static_mesh_xy_bounds(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use fparkan_msh::{Batch, ModelAsset, Slot}; use fparkan_animation::{AnimKey24, AnimationTime, Pose};
use fparkan_msh::{Batch, ModelAnimation, ModelAsset, Slot};
use fparkan_terrain_format::{FullSurfaceMask, TerrainFace28, TerrainSlotTable}; use fparkan_terrain_format::{FullSurfaceMask, TerrainFace28, TerrainSlotTable};
fn model(positions: Vec<[f32; 3]>, indices: Vec<u16>, batches: Vec<Batch>) -> ModelAsset { fn model(positions: Vec<[f32; 3]>, indices: Vec<u16>, batches: Vec<Batch>) -> ModelAsset {
@@ -726,6 +890,96 @@ mod tests {
assert_eq!(mesh.vertices[2].position, [10.0, 22.0, 30.0]); assert_eq!(mesh.vertices[2].position, [10.0, 22.0, 30.0]);
} }
#[test]
fn world_space_msh_applies_each_node_fallback_pose_before_root_transform() {
let mut nodes = vec![0_u8; 76];
nodes[8..10].copy_from_slice(&0_u16.to_le_bytes());
nodes[38 + 6..38 + 8].copy_from_slice(&1_u16.to_le_bytes());
nodes[38 + 8..38 + 10].copy_from_slice(&1_u16.to_le_bytes());
let source = ModelAsset {
node_stride: 38,
node_count: 2,
nodes_raw: nodes,
slots: vec![
Slot {
tri_start: 0,
tri_count: 0,
batch_start: 0,
batch_count: 1,
aabb_min: [0.0; 3],
aabb_max: [1.0; 3],
sphere_center: [0.0; 3],
sphere_radius: 1.0,
opaque: [0; 5],
},
Slot {
tri_start: 0,
tri_count: 0,
batch_start: 1,
batch_count: 1,
aabb_min: [0.0; 3],
aabb_max: [1.0; 3],
sphere_center: [0.0; 3],
sphere_radius: 1.0,
opaque: [0; 5],
},
],
positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
normals: None,
uv0: None,
indices: vec![0, 1, 2, 0, 1, 2],
batches: vec![batch(0, 3, 0), batch(3, 3, 0)],
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
AnimKey24 {
time: AnimationTime(0.0),
pose: Pose::default(),
},
AnimKey24 {
time: AnimationTime(0.0),
pose: Pose {
translation: [10.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
],
frame_map: Vec::new(),
frame_count: 0,
}),
};
let mesh = project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
&source,
LegacyIron3dEulerTransform {
translation: [100.0, 0.0, 0.0],
orientation_radians: [0.0; 3],
},
[1.0; 3],
)
.expect("fallback-pose static mesh");
assert_eq!(mesh.vertices[0].position, [101.0, 0.0, 0.0]);
assert_eq!(mesh.vertices[3].position, [111.0, 0.0, 0.0]);
assert_eq!(mesh.indices, vec![0, 1, 2, 3, 4, 5]);
}
#[test]
fn rotates_point_by_unit_quaternion() {
let rotated = rotate_by_quaternion(
[1.0, 0.0, 0.0],
[
0.0,
0.0,
std::f32::consts::FRAC_1_SQRT_2,
std::f32::consts::FRAC_1_SQRT_2,
],
);
assert!((rotated[0]).abs() < 0.0001);
assert!((rotated[1] - 1.0).abs() < 0.0001);
assert!((rotated[2]).abs() < 0.0001);
}
#[test] #[test]
fn retains_each_source_batch_material_selector() { fn retains_each_source_batch_material_selector() {
let mut second = batch(3, 3, 0); let mut second = batch(3, 3, 0);
@@ -43,6 +43,7 @@ pub use self::asset_mesh::{
project_land_msh_to_static_mesh, project_land_msh_to_static_mesh_in_legacy_world_space, project_land_msh_to_static_mesh, project_land_msh_to_static_mesh_in_legacy_world_space,
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_transform, project_msh_to_static_mesh_in_world_space_with_transform,
project_msh_to_static_mesh_in_xy_frame, VulkanAssetMeshError, VulkanStaticXyFrame, project_msh_to_static_mesh_in_xy_frame, VulkanAssetMeshError, VulkanStaticXyFrame,
}; };
+2 -2
View File
@@ -33,7 +33,7 @@ use fparkan_render::{
use fparkan_render_vulkan::{ 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_transform, project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
project_msh_to_static_mesh_in_xy_frame, VulkanPlanningBackend, VulkanSmokeFrameOutcome, project_msh_to_static_mesh_in_xy_frame, VulkanPlanningBackend, VulkanSmokeFrameOutcome,
VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticCamera, VulkanStaticMaterial, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticCamera, VulkanStaticMaterial,
VulkanStaticMesh, VulkanStaticTexture, VulkanStaticXyFrame, VulkanStaticMesh, VulkanStaticTexture, VulkanStaticXyFrame,
@@ -288,7 +288,7 @@ fn static_preview_mesh_and_materials(
format!("static preview visual {visual_id:?} references unknown model {model_id:?}") format!("static preview visual {visual_id:?} references unknown model {model_id:?}")
})?; })?;
let component = if legacy_camera.is_some() { let component = if legacy_camera.is_some() {
project_msh_to_static_mesh_in_world_space_with_transform( project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
&model.validated, &model.validated,
LegacyIron3dEulerTransform { LegacyIron3dEulerTransform {
translation: root.position, translation: root.position,
+9
View File
@@ -49,6 +49,15 @@ Validated `ModelAsset` также сохраняет decoded type 8 keys и type
field, пока runtime evidence не подтверждает, является ли оно parent index или field, пока runtime evidence не подтверждает, является ли оно parent index или
другой link semantic. другой link semantic.
В legacy-camera static preview стандартный узел уже получает свой fallback pose
до внешнего TMA/Iron3D transform: quaternion поворачивает local vertex, затем
добавляется node translation, после чего применяется `Rz * Ry * Rx`, scale и
mission translation. Геометрия намеренно дублируется на draw-range узла, потому
что один source vertex может быть нарисован разными node poses. Это local-pose
assembly, а не skeleton: `parent_or_link` по-прежнему не используется, поэтому
вложенные parent transforms и dynamic frame-map sampling остаются отдельной
задачей runtime recovery.
## Slot and batch ## Slot and batch
Type 2 содержит header `0x8C`, затем `Slot68`: Type 2 содержит header `0x8C`, затем `Slot68`:
+14
View File
@@ -1607,6 +1607,20 @@ animated models, 3,469/5,233 node samples и утверждённые captures.
доказывает production handoff animation data, не семантику parent links и не доказывает production handoff animation data, не семантику parent links и не
готовую model assembly. готовую model assembly.
Следующий узкий шаг применяет этот уже декодированный `fallback_key` в
legacy-camera static preview. Для каждого выбранного `Node38` bridge поворачивает
вершины normalized quaternion, прибавляет local translation pose и только затем
применяет доказанный TMA `Rz * Ry * Rx`, scale и mission translation. Вершины
намеренно разворачиваются per node/draw range, чтобы один source vertex мог
получить разные poses. `parent_or_link_raw` не читается как parent, frame map не
семплируется, поэтому это не заявляет восстановленную hierarchy или animation.
На той же GOG `MISSIONS/Autodemo.00/data.tma` и offline Ngi32 camera capture
full preview из 8 objects / 66 mesh components / 67 descriptors завершился за
один native Vulkan run с `clip_visible_vertices=2584`, `validation_warnings=0`,
`validation_errors=0` и readback hash `5769837558651835265`. Рост с 938
видимых вершин подтверждает, что local source poses реально вошли в geometry
path; он не является оценкой сходства с original frame.
Параллельно bridge для legacy-camera path теперь переводит только высоту Параллельно bridge для legacy-camera path теперь переводит только высоту
`Land.msh` в world units с масштабом `1/32`: raw AutoDemo heights `Land.msh` в world units с масштабом `1/32`: raw AutoDemo heights
`95.29412..288.23532` становятся `2.978..9.007`, что согласуется с TMA Z и `95.29412..288.23532` становятся `2.978..9.007`, что согласуется с TMA Z и