feat(render): apply static node fallback poses
This commit is contained in:
Generated
+1
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`:
|
||||||
|
|||||||
@@ -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 и
|
||||||
|
|||||||
Reference in New Issue
Block a user