From 2a793f1854231be6da930a3692dce691a6426211 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 15:35:00 +0400 Subject: [PATCH] feat(render): apply recovered TMA orientation --- .../fparkan-render-vulkan/src/asset_mesh.rs | 63 ++++++++++++++++--- adapters/fparkan-render-vulkan/src/ffi.rs | 5 +- apps/fparkan-game/src/main.rs | 20 +++--- crates/fparkan-render/src/lib.rs | 39 ++++++++++++ docs/tomes/05-render.md | 33 ++++++---- 5 files changed, 130 insertions(+), 30 deletions(-) diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index d74e79c..d92f76d 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -2,7 +2,7 @@ use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex}; use fparkan_msh::ModelAsset; -use fparkan_render::LegacyPipelineState; +use fparkan_render::{LegacyIron3dEulerTransform, LegacyPipelineState}; use fparkan_terrain_format::LandMeshDocument; /// Error returned when a validated MSH cannot enter the current static GPU path. @@ -185,8 +185,36 @@ pub fn project_msh_to_static_mesh_in_world_space( translation: [f32; 3], scale: [f32; 3], ) -> Result { - if !translation + project_msh_to_static_mesh_in_world_space_with_transform( + model, + LegacyIron3dEulerTransform { + translation, + orientation_radians: [0.0; 3], + }, + scale, + ) +} + +/// Projects MSH geometry using the recovered `Iron3D` placement transform. +/// +/// This preserves source-world coordinates and applies the proven +/// `Rz(z) * Ry(y) * Rx(x)` rotation after local component-wise scale. It is +/// intended for the opt-in legacy-camera static preview, not yet for animated +/// or gameplay-owned transforms. +/// +/// # Errors +/// +/// Returns [`VulkanAssetMeshError`] when geometry or transform values cannot +/// be represented by the static Vulkan input contract. +pub fn project_msh_to_static_mesh_in_world_space_with_transform( + model: &ModelAsset, + transform: LegacyIron3dEulerTransform, + scale: [f32; 3], +) -> Result { + if !transform + .translation .iter() + .chain(transform.orientation_radians.iter()) .chain(scale.iter()) .all(|value| value.is_finite()) { @@ -201,11 +229,9 @@ pub fn project_msh_to_static_mesh_in_world_space( if !position.iter().all(|value| value.is_finite()) { return Err(VulkanAssetMeshError::NonFinitePosition); } - let position = [ - position[0] * scale[0] + translation[0], - position[1] * scale[1] + translation[1], - position[2] * scale[2] + translation[2], - ]; + let position = transform + .try_transform_scaled_point(*position, scale) + .ok_or(VulkanAssetMeshError::NonFinitePosition)?; Ok(VulkanStaticVertex { position, color: [0.82, 0.72, 0.31], @@ -552,6 +578,29 @@ mod tests { assert_eq!(mesh.vertices[0].uv, [1.0, -0.5]); } + #[test] + fn world_space_msh_applies_recovered_iron3d_orientation_after_scale() { + let source = model( + vec![[2.0, 3.0, 4.0], [0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + vec![0, 1, 2], + vec![batch(0, 3, 0)], + ); + + let mesh = project_msh_to_static_mesh_in_world_space_with_transform( + &source, + LegacyIron3dEulerTransform { + translation: [10.0, 20.0, 30.0], + orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2], + }, + [2.0, 1.0, 0.5], + ) + .expect("finite recovered transform"); + + assert_eq!(mesh.vertices[0].position, [7.0, 24.0, 32.0]); + assert_eq!(mesh.vertices[1].position, [10.0, 20.0, 30.0]); + assert_eq!(mesh.vertices[2].position, [10.0, 22.0, 30.0]); + } + #[test] fn retains_each_source_batch_material_selector() { let mut second = batch(3, 3, 0); diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs index 4d2b63e..4c4bfe6 100644 --- a/adapters/fparkan-render-vulkan/src/ffi.rs +++ b/adapters/fparkan-render-vulkan/src/ffi.rs @@ -42,8 +42,9 @@ mod validation; pub use self::asset_mesh::{ project_land_msh_to_static_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_xy_frame, - VulkanAssetMeshError, VulkanStaticXyFrame, + project_msh_to_static_mesh_in_world_space, + project_msh_to_static_mesh_in_world_space_with_transform, + project_msh_to_static_mesh_in_xy_frame, VulkanAssetMeshError, VulkanStaticXyFrame, }; pub use self::capabilities::{ probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request, diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 850b509..1d9fecd 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -26,15 +26,16 @@ use fparkan_platform::WindowPort; use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan}; use fparkan_render::{ build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, - LegacyD3d7Projection, RawCameraTransform, RenderBackend, RenderCommand, RenderCommandList, - RenderPhase, RenderProfile, RenderSnapshot, RenderSnapshotDraw, + LegacyD3d7Projection, LegacyIron3dEulerTransform, RawCameraTransform, RenderBackend, + RenderCommand, RenderCommandList, RenderPhase, RenderProfile, RenderSnapshot, + RenderSnapshotDraw, }; use fparkan_render_vulkan::{ project_land_msh_to_static_mesh_in_world_space, project_land_msh_to_static_mesh_in_xy_frame, - project_msh_to_static_mesh_in_world_space, project_msh_to_static_mesh_in_xy_frame, - VulkanPlanningBackend, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, - VulkanSmokeRendererCreateInfo, VulkanStaticCamera, VulkanStaticMaterial, VulkanStaticMesh, - VulkanStaticTexture, VulkanStaticXyFrame, + project_msh_to_static_mesh_in_world_space_with_transform, + project_msh_to_static_mesh_in_xy_frame, VulkanPlanningBackend, VulkanSmokeFrameOutcome, + VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticCamera, VulkanStaticMaterial, + VulkanStaticMesh, VulkanStaticTexture, VulkanStaticXyFrame, }; use fparkan_runtime::{ create, frame, load_mission, load_mission_static_preview, load_mission_static_preview_roots, @@ -285,9 +286,12 @@ fn static_preview_mesh_and_materials( format!("static preview visual {visual_id:?} references unknown model {model_id:?}") })?; let component = if legacy_camera.is_some() { - project_msh_to_static_mesh_in_world_space( + project_msh_to_static_mesh_in_world_space_with_transform( &model.validated, - root.position, + LegacyIron3dEulerTransform { + translation: root.position, + orientation_radians: root.orientation_raw, + }, root.scale, ) } else { diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs index c181cf9..89c29a6 100644 --- a/crates/fparkan-render/src/lib.rs +++ b/crates/fparkan-render/src/lib.rs @@ -304,6 +304,37 @@ impl LegacyIron3dEulerTransform { .all(|value| value.is_finite()) .then_some(matrix) } + + /// Applies the recovered rotation after a component-wise local scale. + /// + /// This is the affine `R * (scale * point) + translation` convention used + /// by the static source-world bridge. The original dynamic transform path + /// needs its own capture evidence before it may replace this static path. + #[must_use] + pub fn try_transform_scaled_point(self, point: [f32; 3], scale: [f32; 3]) -> Option<[f32; 3]> { + if !point + .iter() + .chain(scale.iter()) + .all(|value| value.is_finite()) + { + return None; + } + let matrix = self.try_row_major()?; + let scaled = [ + point[0] * scale[0], + point[1] * scale[1], + point[2] * scale[2], + ]; + let transformed = [ + matrix[0] * scaled[0] + matrix[1] * scaled[1] + matrix[2] * scaled[2] + matrix[3], + matrix[4] * scaled[0] + matrix[5] * scaled[1] + matrix[6] * scaled[2] + matrix[7], + matrix[8] * scaled[0] + matrix[9] * scaled[1] + matrix[10] * scaled[2] + matrix[11], + ]; + transformed + .iter() + .all(|value| value.is_finite()) + .then_some(transformed) + } } /// Raw camera state observed through the original Terrain camera interface. @@ -1210,6 +1241,14 @@ mod tests { assert_eq!(matrix[7], 717.433); assert_eq!(matrix[11], 3.040_938_9); assert_eq!(matrix[15], 1.0); + assert_eq!( + LegacyIron3dEulerTransform { + translation: [10.0, 20.0, 30.0], + orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2], + } + .try_transform_scaled_point([2.0, 3.0, 4.0], [2.0, 1.0, 0.5]), + Some([7.0, 24.0, 32.0]) + ); assert_eq!( LegacyIron3dEulerTransform { translation: [0.0, 0.0, 0.0], diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 48b93e3..0043722 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1437,16 +1437,20 @@ on its XY diagnostic projection until a runtime supplies a real legacy camera; selecting source-world coordinates with identity camera would be a misleading empty/off-screen viewer rather than a compatibility result. -Static analysis of GOG `iron3d.dll` now recovers an exact adjacent placement -primitive: RVA `0x36610` evaluates a row-major affine -`Rz(z) * Ry(y) * Rx(x)` and writes translation in the final column. The -backend-neutral `LegacyIron3dEulerTransform` preserves that finite portable -formula with a golden yaw-vector test. This is deliberately not wired to TMA: -AutoDemo records strongly suggest the candidate `(0, 0, z)` radians (all eight -objects have zero first two values and `z` in approximately `[-pi, pi]`), but -the current static trace has not proved that those raw record fields reach this -builder unchanged for every mission object category. The remaining link needs -a direct loader-to-builder dataflow or a dynamic placement capture. +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 +translation in the final column. The backend-neutral +`LegacyIron3dEulerTransform` preserves that finite portable formula with golden +yaw and scaled-point tests. A read-only elevated AutoDemo scan closes the +previous TMA binding gap for static placement: it finds the exact raw mission +records (position, `(0, 0, z)`, scale) and live object blocks at the same +world positions. For example, `z = 1.6262363` produces the observed pair +`-sin(z) = -0.99846`, `cos(z) = -0.05541`; matching pairs occur for the other +observed objects. The opt-in captured-camera path therefore applies +`R * (scale * local_position) + translation` before Vulkan upload. It remains +a static-placement contract: dynamic/animated transforms, physics ownership, +terrain material selection, lighting and gameplay parity are still separate +evidence tasks. `VulkanSmokeRenderer::set_camera` now accepts a new finite camera between frame submissions and uses it for the next command buffer without rebuilding the @@ -1494,9 +1498,12 @@ indices remain decoded in their original 16-bit representations and are widened only at the static-render bridge. A regression test crosses the former boundary. The full captured-camera AutoDemo preview completed at 13.369 seconds and rendered three 1280×720 native frames: 8 mission objects, 66 mesh components, -67 material descriptors, and zero Vulkan validation warnings or errors. It is -still a static source-world preview: raw mission orientation, terrain material -selection, lighting and animation have not yet been claimed as parity. +67 material descriptors, and zero Vulkan validation warnings or errors. A later +orientation-enabled run rendered the same full scene for three 1280×720 frames +with `validation_warnings=0`, `validation_errors=0` and readback hash +`6406107678925513509`. It is still a static source-world preview: terrain +material selection, lighting, animation, physics and gameplay behavior have +not yet been claimed as parity. A fresh no-input launch of the canonical `iron_3d.exe` did create a responsive window titled `Parkan. Железная Стратегия`. A read-only probe then requested