feat(render): apply placement in xy preview

This commit is contained in:
2026-07-18 18:19:55 +04:00
parent cc7a1a0faa
commit 4d024223bb
5 changed files with 102 additions and 32 deletions
@@ -174,6 +174,38 @@ pub fn project_msh_to_static_mesh_in_xy_frame(
}) })
} }
/// Projects a static MSH preview through the recovered placement transform and
/// then into a shared diagnostic XY frame.
///
/// This is the diagnostic-camera counterpart of
/// [`project_msh_to_static_mesh_in_world_space_with_node_fallback_poses`]. It
/// deliberately keeps the diagnostic camera itself separate from the original
/// D3D7 camera, while ensuring that mission placement uses the same proven
/// `Rz(z) * Ry(y) * Rx(x)` convention in both preview paths.
///
/// # Errors
///
/// Returns [`VulkanAssetMeshError`] when the model 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_fallback_poses(
model: &ModelAsset,
frame: VulkanStaticXyFrame,
transform: LegacyIron3dEulerTransform,
scale: [f32; 3],
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
let mut mesh = project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
model, transform, scale,
)?;
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
@@ -906,6 +938,32 @@ 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 xy_preview_applies_recovered_iron3d_orientation_before_framing() {
let source = model(
vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]],
vec![0, 1, 2],
vec![batch(0, 3, 0)],
);
let frame =
VulkanStaticXyFrame::from_bounds(0.0, 4.0, 0.0, 4.0).expect("valid diagnostic frame");
let mesh = project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses(
&source,
frame,
LegacyIron3dEulerTransform {
translation: [2.0, 1.0, 0.0],
orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2],
},
[1.0; 3],
)
.expect("diagnostic static mesh");
assert!(mesh.vertices[0].position[0].abs() < 0.0001);
assert!(mesh.vertices[0].position[1].abs() < 0.0001);
assert_eq!(mesh.vertices[0].position[2], 0.0);
}
#[test] #[test]
fn world_space_msh_applies_each_node_fallback_pose_before_root_transform() { fn world_space_msh_applies_each_node_fallback_pose_before_root_transform() {
let mut nodes = vec![0_u8; 76]; let mut nodes = vec![0_u8; 76];
+3 -1
View File
@@ -45,7 +45,9 @@ pub use self::asset_mesh::{
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_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,
project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses, VulkanAssetMeshError,
VulkanStaticXyFrame,
}; };
pub use self::capabilities::{ pub use self::capabilities::{
probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request, probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request,
+18 -14
View File
@@ -35,9 +35,10 @@ 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, VulkanPlanningBackend, VulkanSmokeFrameOutcome, project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses, VulkanPlanningBackend,
VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticCamera, VulkanStaticMaterial, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
VulkanStaticMesh, VulkanStaticTexture, VulkanStaticXyFrame, VulkanStaticCamera, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture,
VulkanStaticXyFrame,
}; };
use fparkan_runtime::{ use fparkan_runtime::{
create, frame, load_mission, load_mission_static_preview, load_mission_static_preview_roots, create, frame, load_mission, load_mission_static_preview, load_mission_static_preview_roots,
@@ -310,10 +311,13 @@ fn static_preview_mesh_and_materials(
) )
} 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( project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses(
&model.validated, &model.validated,
frame, frame,
root.position, LegacyIron3dEulerTransform {
translation: root.position,
orientation_radians: root.orientation_raw,
},
root.scale, root.scale,
) )
} }
@@ -422,16 +426,16 @@ fn static_preview_xy_frame(
format!("static preview visual {visual_id:?} references unknown model {model_id:?}") format!("static preview visual {visual_id:?} references unknown model {model_id:?}")
})?; })?;
for position in &model.validated.positions { for position in &model.validated.positions {
let position = LegacyIron3dEulerTransform {
translation: root.position,
orientation_radians: root.orientation_raw,
}
.try_transform_scaled_point(*position, root.scale)
.ok_or_else(|| {
"static preview contains a non-finite transformed XY position".to_string()
})?;
extend_static_preview_xy_bounds( extend_static_preview_xy_bounds(
[ position, &mut min_x, &mut max_x, &mut min_y, &mut max_y,
position[0] * root.scale[0] + root.position[0],
position[1] * root.scale[1] + root.position[1],
position[2] * root.scale[2] + root.position[2],
],
&mut min_x,
&mut max_x,
&mut min_y,
&mut max_y,
)?; )?;
} }
} }
+5 -4
View File
@@ -13,7 +13,7 @@
| `RecordingBackend` | No | No | Optional CPU-side IDs only | `covered-planning` | Stable command capture for backend-neutral tests | Native window, Vulkan, GPU resource lifetime, pixels | | `RecordingBackend` | No | No | Optional CPU-side IDs only | `covered-planning` | Stable command capture for backend-neutral tests | Native window, Vulkan, GPU resource lifetime, pixels |
| `NullBackend` | No | No | Optional CPU-side IDs only | Usually `covered` for validation-only rows | Command stream framing and bounds validation | Capture stability, GPU execution, pixels | | `NullBackend` | No | No | Optional CPU-side IDs only | Usually `covered` for validation-only rows | Command stream framing and bounds validation | Capture stability, GPU execution, pixels |
| `VulkanAssetRenderer` | Yes | Yes | Yes | `covered-gpu` | Static original asset rendering: MSH/Texm/WEAR/MAT0/terrain through Vulkan | Animation/FX parity unless explicitly wired | | `VulkanAssetRenderer` | Yes | Yes | Yes | `covered-gpu` | Static original asset rendering: MSH/Texm/WEAR/MAT0/terrain through Vulkan | Animation/FX parity unless explicitly wired |
| `fparkan-game --backend static-vulkan` | Yes, GOG `Autodemo.00` | Yes, full static AutoDemo preview | Eight mission objects, 66 MSH components, original diffuse TEXM and Land2 terrain base layer | `covered-gpu` for the bounded static-scene bridge | Captured legacy camera, placed translation/scale/Z Euler transform, fallback node hierarchy, source-world geometry, 32-bit GPU indices, diffuse descriptors, terrain base draws and synchronized teardown telemetry | Material phases, Land1 blend/microtexture/lightmaps, animated keys, FX, camera control and gameplay parity | | `fparkan-game --backend static-vulkan` | Yes, GOG `Autodemo.00` | Yes, full static AutoDemo preview | Eight mission objects, 66 MSH components, original diffuse TEXM and Land2 terrain base layer | `covered-gpu` for the bounded static-scene bridge | Diagnostic XY framing with recovered translation/scale/Z Euler placement, fallback node hierarchy, optional captured legacy camera, 32-bit GPU indices, diffuse descriptors, terrain base draws and synchronized teardown telemetry | Material phases, Land1 blend/microtexture/lightmaps, animated keys, FX, camera control and gameplay parity |
| Future rendered `fparkan-game` mode | Yes | Yes | Yes | `covered-gpu` plus original-evidence IDs | Mission-driven render snapshot execution and pixel capture | Original-runtime parity for animation/FX/x87 without dedicated captures | | Future rendered `fparkan-game` mode | Yes | Yes | Yes | `covered-gpu` plus original-evidence IDs | Mission-driven render snapshot execution and pixel capture | Original-runtime parity for animation/FX/x87 without dedicated captures |
## Rules ## Rules
@@ -36,10 +36,11 @@
synthetic window descriptor и `VulkanPlanningBackend`. Opt-in `--backend static-vulkan` synthetic window descriptor и `VulkanPlanningBackend`. Opt-in `--backend static-vulkan`
создаёт native `winit` window. Для GOG `MISSIONS/Autodemo.00/data.tma` он уже создаёт native `winit` window. Для GOG `MISSIONS/Autodemo.00/data.tma` он уже
рендерит весь статический набор из восьми mission objects и 66 MSH components с рендерит весь статический набор из восьми mission objects и 66 MSH components с
captured legacy camera, доказанным `Rz * Ry * Rx` placement transform, diagnostic XY camera (or an optional captured legacy camera), доказанным `Rz * Ry * Rx` placement transform,
fallback node hierarchy и Land2 base terrain. Последний validation-clean fallback node hierarchy и Land2 base terrain. Последний validation-clean
трёхкадровый запуск имел 67 material descriptors, `clip_visible_vertices=3415` текущий трёхкадровый запуск после применения placement transform в XY path
и readback hash `1275533143935640133`. Это `covered-gpu` для ограниченного имел 71 material descriptor, `clip_visible_vertices=75543` и readback hash
`5444013368935681345`. Это `covered-gpu` для ограниченного
static-scene bridge, но не pixel parity и не доказательство material phase, static-scene bridge, но не pixel parity и не доказательство material phase,
Land1 blend, microtexture, lightmap, dynamic animation, FX, live camera Land1 blend, microtexture, lightmap, dynamic animation, FX, live camera
selection или gameplay rendering. selection или gameplay rendering.
+18 -13
View File
@@ -985,8 +985,11 @@ The bounded native preview now retains the already validated `Land.msh` inside
runtime boundary instead of decoding archive formats a second time. It merges runtime boundary instead of decoding archive formats a second time. It merges
one terrain component with the first TMA root's 14 reachable MSH components in one terrain component with the first TMA root's 14 reachable MSH components in
one explicit top-down XY frame. The frame is computed from terrain positions one explicit top-down XY frame. The frame is computed from terrain positions
and the root models after applying only the decoded TMA `position` and `scale`. and the root models after applying decoded TMA placement: non-uniform `scale`,
Raw TMA orientation is deliberately excluded: its convention is not proven. then the recovered `Rz * Ry * Rx` orientation, then `position`. The XY camera
itself remains diagnostic; applying the established placement transform here
prevents differently oriented mission objects from being framed or rasterized
at a false location.
Terrain is assigned an explicit 1x1 white diagnostic texture, rather than a Terrain is assigned an explicit 1x1 white diagnostic texture, rather than a
guessed terrain material. `Land.msh` slot selection, `material_tag`, masks, guessed terrain material. `Land.msh` slot selection, `material_tag`, masks,
@@ -1004,10 +1007,11 @@ geometry and descriptor set changed; it is not an original-frame comparison.
### Multiple static-preview roots ### Multiple static-preview roots
The native bridge now combines every mesh-backed visual of each selected root, The native bridge now combines every mesh-backed visual of each selected root,
using that root's preserved TMA `position` and `scale` in the shared diagnostic using that root's recovered static TMA placement (`Rz * Ry * Rx` after scale)
XY frame. It reports the actual selected root count as `preview_roots`, rather in the shared diagnostic XY frame. It reports the actual selected root count as
than implying that all mission objects are rendered. Raw orientation, original `preview_roots`, rather than implying that all mission objects are rendered.
camera/frustum and visibility remain unresolved. The original camera/frustum, dynamic orientation ownership and visibility
remain unresolved.
Fresh canonical GOG `Autodemo.00` evidence with `--preview-roots 2` completed Fresh canonical GOG `Autodemo.00` evidence with `--preview-roots 2` completed
in 59.7 seconds: 25 submitted MSH components, one terrain component and 26 in 59.7 seconds: 25 submitted MSH components, one terrain component and 26
@@ -1430,13 +1434,14 @@ runtime wiring, not a claim of scene parity.
The geometry side of that handoff is also explicit. The static mesh adapter now The geometry side of that handoff is also explicit. The static mesh adapter now
has source-world projections for `Land.msh` and MSH: terrain positions retain has source-world projections for `Land.msh` and MSH: terrain positions retain
all three decoded coordinates; model positions retain Z and apply only the all three decoded coordinates; model positions retain Z and apply the recovered
already decoded mission translation and scale. These APIs preserve triangle static mission placement. The diagnostic XY counterpart uses that same placement
order, batch ranges and packed UV decoding, but do not interpret the still before normalizing X/Y, so it no longer silently omits authored object rotation.
unproven raw object orientation. The current game preview deliberately stays These APIs preserve triangle order, batch ranges and packed UV decoding. The
on its XY diagnostic projection until a runtime supplies a real legacy camera; current game preview deliberately stays on its XY diagnostic projection until a
selecting source-world coordinates with identity camera would be a misleading runtime supplies a real legacy camera; selecting source-world coordinates with
empty/off-screen viewer rather than a compatibility result. identity camera would be a misleading empty/off-screen viewer rather than a
compatibility result.
Static analysis of GOG `iron3d.dll` recovers the placement primitive at RVA Static analysis of GOG `iron3d.dll` recovers the placement primitive at RVA
`0x36610`: it evaluates a row-major affine `Rz(z) * Ry(y) * Rx(x)` and writes `0x36610`: it evaluates a row-major affine `Rz(z) * Ry(y) * Rx(x)` and writes