feat(render): apply recovered TMA orientation

This commit is contained in:
2026-07-18 15:35:00 +04:00
parent 468ecc4e32
commit 2a793f1854
5 changed files with 130 additions and 30 deletions
@@ -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<VulkanStaticMesh, VulkanAssetMeshError> {
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<VulkanStaticMesh, VulkanAssetMeshError> {
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);
+3 -2
View File
@@ -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,
+12 -8
View File
@@ -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 {
+39
View File
@@ -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],
+20 -13
View File
@@ -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