From d98e033f4fbd8d99f30e19e5471107eb954bab9a Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 14:56:19 +0400 Subject: [PATCH] feat(render): retain source world geometry --- .../fparkan-render-vulkan/src/asset_mesh.rs | 190 +++++++++++++++++- adapters/fparkan-render-vulkan/src/ffi.rs | 7 +- docs/tomes/05-render.md | 10 + 3 files changed, 194 insertions(+), 13 deletions(-) diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index fa7c28e..7a2f8ba 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -169,6 +169,64 @@ pub fn project_msh_to_static_mesh_in_xy_frame( }) } +/// 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 +/// or discard Z. It retains the decoded translation/scale for the recovered +/// legacy camera path while intentionally leaving raw object orientation +/// uninterpreted until its original convention is established. +/// +/// # Errors +/// +/// Returns [`VulkanAssetMeshError`] when geometry, transform values or the +/// static Vulkan input contract cannot represent the source model. +pub fn project_msh_to_static_mesh_in_world_space( + model: &ModelAsset, + translation: [f32; 3], + scale: [f32; 3], +) -> Result { + if !translation + .iter() + .chain(scale.iter()) + .all(|value| value.is_finite()) + { + return Err(VulkanAssetMeshError::NonFinitePosition); + } + let (indices, draw_ranges) = static_model_indices_and_ranges(model)?; + let vertices = model + .positions + .iter() + .enumerate() + .map(|(index, position)| { + 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], + ]; + Ok(VulkanStaticVertex { + position, + color: [0.82, 0.72, 0.31], + uv: model + .uv0 + .as_ref() + .and_then(|uv0| uv0.get(index)) + .map_or([0.0, 0.0], |uv| { + [f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0] + }), + }) + }) + .collect::, _>>()?; + + Ok(VulkanStaticMesh { + vertices, + indices, + draw_ranges, + }) +} + /// Projects validated `Land.msh` terrain geometry into the static Vulkan path. /// /// This bridge preserves the source triangle order from `TerrainFace28` and @@ -255,19 +313,82 @@ pub fn project_land_msh_to_static_mesh_in_xy_frame( Ok(VulkanStaticMesh { vertices, indices, - draw_ranges: vec![VulkanStaticDrawRange { - first_index: 0, - index_count: u32::try_from(terrain.faces.len()) - .map_err(|_| VulkanAssetMeshError::IndexOutOfRange)? - .checked_mul(3) - .ok_or(VulkanAssetMeshError::IndexOutOfRange)?, - material_index: 0, - pipeline_state: LegacyPipelineState::default(), - alpha_test_reference: 0, - }], + draw_ranges: static_terrain_draw_ranges(terrain)?, }) } +/// Projects `Land.msh` terrain with its decoded source coordinates intact. +/// +/// This is the terrain counterpart of +/// [`project_msh_to_static_mesh_in_world_space`]. It has no viewer transform: +/// camera framing and any coordinate conversion are owned by the recovered +/// legacy camera adapter. +/// +/// # Errors +/// +/// Returns [`VulkanAssetMeshError`] when terrain geometry or the static Vulkan +/// input contract cannot represent the source mesh. +pub fn project_land_msh_to_static_mesh_in_world_space( + terrain: &LandMeshDocument, +) -> Result { + let indices = static_terrain_indices(terrain)?; + let vertices = terrain + .positions + .iter() + .enumerate() + .map(|(index, position)| { + if !position.iter().all(|value| value.is_finite()) { + return Err(VulkanAssetMeshError::NonFinitePosition); + } + Ok(VulkanStaticVertex { + position: *position, + color: [0.31, 0.58, 0.27], + uv: terrain.uv0.get(index).map_or([0.0, 0.0], |uv| { + [f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0] + }), + }) + }) + .collect::, _>>()?; + + Ok(VulkanStaticMesh { + vertices, + indices, + draw_ranges: static_terrain_draw_ranges(terrain)?, + }) +} + +fn static_terrain_indices(terrain: &LandMeshDocument) -> Result, VulkanAssetMeshError> { + let mut indices = Vec::with_capacity( + terrain + .faces + .len() + .checked_mul(3) + .ok_or(VulkanAssetMeshError::IndexOutOfRange)?, + ); + for face in &terrain.faces { + indices.extend(face.vertices); + } + if indices.is_empty() { + return Err(VulkanAssetMeshError::EmptyGeometry); + } + Ok(indices) +} + +fn static_terrain_draw_ranges( + terrain: &LandMeshDocument, +) -> Result, VulkanAssetMeshError> { + Ok(vec![VulkanStaticDrawRange { + first_index: 0, + index_count: u32::try_from(terrain.faces.len()) + .map_err(|_| VulkanAssetMeshError::IndexOutOfRange)? + .checked_mul(3) + .ok_or(VulkanAssetMeshError::IndexOutOfRange)?, + material_index: 0, + pipeline_state: LegacyPipelineState::default(), + alpha_test_reference: 0, + }]) +} + fn static_model_indices_and_ranges( model: &ModelAsset, ) -> Result<(Vec, Vec), VulkanAssetMeshError> { @@ -409,6 +530,28 @@ mod tests { assert_eq!(mesh.vertices[2].uv, [-1.0, 0.5]); } + #[test] + fn world_space_msh_preserves_z_and_applies_only_decoded_translation_scale() { + let mut source = model( + vec![[2.0, -3.0, 4.0], [0.0, 1.0, 2.0], [-1.0, 2.0, 3.0]], + vec![0, 1, 2], + vec![batch(0, 3, 0)], + ); + source.uv0 = Some(vec![[1024, -512], [0, 2048], [-1024, 512]]); + + let mesh = project_msh_to_static_mesh_in_world_space( + &source, + [100.0, 200.0, 300.0], + [2.0, -1.0, 0.5], + ) + .expect("representable source-space MSH"); + + assert_eq!(mesh.indices, vec![0, 1, 2]); + assert_eq!(mesh.vertices[0].position, [104.0, 203.0, 302.0]); + assert_eq!(mesh.vertices[1].position, [100.0, 199.0, 301.0]); + assert_eq!(mesh.vertices[0].uv, [1.0, -0.5]); + } + #[test] fn retains_each_source_batch_material_selector() { let mut second = batch(3, 3, 0); @@ -480,6 +623,33 @@ mod tests { assert_eq!(mesh.vertices[2].uv, [-1.0, 0.5]); } + #[test] + fn world_space_terrain_preserves_source_coordinates_and_faces() { + let terrain = LandMeshDocument { + streams: Vec::new(), + nodes_raw: Vec::new(), + slots: TerrainSlotTable { + header_raw: Vec::new(), + slots_raw: Vec::new(), + }, + positions: vec![[10.0, 20.0, 30.0], [40.0, 50.0, 60.0], [70.0, 80.0, 90.0]], + normals: Vec::new(), + uv0: vec![[1024, -512], [0, 2048], [-1024, 512]], + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: vec![terrain_face([2, 0, 1])], + }; + + let mesh = project_land_msh_to_static_mesh_in_world_space(&terrain) + .expect("representable source-space terrain"); + + assert_eq!(mesh.indices, vec![2, 0, 1]); + assert_eq!(mesh.vertices[0].position, [10.0, 20.0, 30.0]); + assert_eq!(mesh.vertices[2].position, [70.0, 80.0, 90.0]); + assert_eq!(mesh.vertices[0].uv, [1.0, -0.5]); + } + fn terrain_face(vertices: [u16; 3]) -> TerrainFace28 { TerrainFace28 { flags: FullSurfaceMask(0), diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs index 056afcc..4d2b63e 100644 --- a/adapters/fparkan-render-vulkan/src/ffi.rs +++ b/adapters/fparkan-render-vulkan/src/ffi.rs @@ -40,9 +40,10 @@ mod swapchain_resources; mod validation; pub use self::asset_mesh::{ - project_land_msh_to_static_mesh, project_land_msh_to_static_mesh_in_xy_frame, - project_msh_to_static_mesh, project_msh_to_static_mesh_in_xy_frame, VulkanAssetMeshError, - VulkanStaticXyFrame, + 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, }; pub use self::capabilities::{ probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request, diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 47d0fc8..19f1f92 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1427,6 +1427,16 @@ identity camera deliberately preserves the old XY diagnostic preview; feeding live terrain camera/projection values into mission rendering remains later runtime wiring, not a claim of scene parity. +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 +all three decoded coordinates; model positions retain Z and apply only the +already decoded mission translation and scale. These APIs preserve triangle +order, batch ranges and packed UV decoding, but do not interpret the still +unproven raw object orientation. The current game preview deliberately stays +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. + A fresh no-input launch of the canonical `iron_3d.exe` did create a responsive window titled `Parkan. Железная Стратегия`. A read-only probe then requested `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` and attempted to read the known