aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-07-18 14:35:00 +0300
committerValentin Popov <valentin@popov.link>2026-07-18 14:35:00 +0300
commit2a793f1854231be6da930a3692dce691a6426211 (patch)
treefb0cefbdf2d8e2e3dbb883f8fd696b4d25c86a80
parent468ecc4e325178c116f0de25f47736eaec2a1e15 (diff)
downloadfparkan-2a793f1854231be6da930a3692dce691a6426211.tar.xz
fparkan-2a793f1854231be6da930a3692dce691a6426211.zip
feat(render): apply recovered TMA orientation
-rw-r--r--adapters/fparkan-render-vulkan/src/asset_mesh.rs63
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi.rs5
-rw-r--r--apps/fparkan-game/src/main.rs20
-rw-r--r--crates/fparkan-render/src/lib.rs39
-rw-r--r--docs/tomes/05-render.md33
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<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],
@@ -553,6 +579,29 @@ mod tests {
}
#[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);
second.material_index = 7;
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.
@@ -1212,6 +1243,14 @@ mod tests {
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],
orientation_radians: [f32::NAN, 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