From fea984ddf51ba1e56ce800b9f370ac4208dcfcab Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 15:50:52 +0400 Subject: [PATCH] feat(render): diagnose captured camera clipping --- .../fparkan-render-vulkan/src/asset_mesh.rs | 58 ++++++++++++ adapters/fparkan-render-vulkan/src/ffi.rs | 6 +- apps/fparkan-game/src/main.rs | 89 ++++++++++++++++++- docs/tomes/05-render.md | 24 +++++ 4 files changed, 170 insertions(+), 7 deletions(-) diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index d92f76d..413eba6 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -5,6 +5,9 @@ use fparkan_msh::ModelAsset; use fparkan_render::{LegacyIron3dEulerTransform, LegacyPipelineState}; use fparkan_terrain_format::LandMeshDocument; +/// Legacy `Land.msh` stored-height to mission-world-height scale. +const LEGACY_LAND_HEIGHT_SCALE: f32 = 1.0 / 32.0; + /// Error returned when a validated MSH cannot enter the current static GPU path. #[derive(Clone, Debug, Eq, PartialEq)] pub enum VulkanAssetMeshError { @@ -383,6 +386,31 @@ pub fn project_land_msh_to_static_mesh_in_world_space( }) } +/// Projects terrain into the mission space consumed by the legacy D3D7 camera. +/// +/// `Land.msh` stores horizontal coordinates directly, while its height stream +/// uses 1/32 units. The scale is evidenced by the GOG `AutoDemo` map: raw terrain +/// heights `95.29412..288.23532` become `2.978..9.007`, matching its placed +/// object heights and the live Ngi32 camera's world-space Z coordinate. +/// +/// This conversion is intentionally confined to the captured legacy-camera +/// path. [`project_land_msh_to_static_mesh_in_world_space`] remains the raw +/// source-coordinate bridge for format inspection. +/// +/// # Errors +/// +/// Returns [`VulkanAssetMeshError`] when the terrain cannot enter the static +/// Vulkan input contract. +pub fn project_land_msh_to_static_mesh_in_legacy_world_space( + terrain: &LandMeshDocument, +) -> Result { + let mut mesh = project_land_msh_to_static_mesh_in_world_space(terrain)?; + for vertex in &mut mesh.vertices { + vertex.position[2] *= LEGACY_LAND_HEIGHT_SCALE; + } + Ok(mesh) +} + fn static_terrain_indices(terrain: &LandMeshDocument) -> Result, VulkanAssetMeshError> { let mut indices = Vec::with_capacity( terrain @@ -699,6 +727,36 @@ mod tests { assert_eq!(mesh.vertices[0].uv, [1.0, -0.5]); } + #[test] + fn legacy_world_space_terrain_scales_only_stored_height() { + let terrain = LandMeshDocument { + streams: Vec::new(), + nodes_raw: Vec::new(), + slots: TerrainSlotTable { + header_raw: Vec::new(), + slots_raw: Vec::new(), + }, + positions: vec![ + [100.0, 200.0, 96.0], + [300.0, 400.0, 288.0], + [500.0, 600.0, 192.0], + ], + normals: Vec::new(), + uv0: vec![[0, 0]; 3], + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: vec![terrain_face([0, 1, 2])], + }; + + let mesh = project_land_msh_to_static_mesh_in_legacy_world_space(&terrain) + .expect("representable terrain"); + + assert_eq!(mesh.vertices[0].position, [100.0, 200.0, 3.0]); + assert_eq!(mesh.vertices[1].position, [300.0, 400.0, 9.0]); + assert_eq!(mesh.vertices[2].position, [500.0, 600.0, 6.0]); + } + 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 4c4bfe6..ab170ea 100644 --- a/adapters/fparkan-render-vulkan/src/ffi.rs +++ b/adapters/fparkan-render-vulkan/src/ffi.rs @@ -40,9 +40,9 @@ mod swapchain_resources; 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_land_msh_to_static_mesh, project_land_msh_to_static_mesh_in_legacy_world_space, + 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_world_space_with_transform, project_msh_to_static_mesh_in_xy_frame, VulkanAssetMeshError, VulkanStaticXyFrame, }; diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 1d9fecd..32e7e44 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -31,7 +31,8 @@ use fparkan_render::{ RenderSnapshotDraw, }; use fparkan_render_vulkan::{ - project_land_msh_to_static_mesh_in_world_space, project_land_msh_to_static_mesh_in_xy_frame, + project_land_msh_to_static_mesh_in_legacy_world_space, + project_land_msh_to_static_mesh_in_xy_frame, project_msh_to_static_mesh_in_world_space_with_transform, project_msh_to_static_mesh_in_xy_frame, VulkanPlanningBackend, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticCamera, VulkanStaticMaterial, @@ -226,6 +227,7 @@ struct StaticPreviewScene { materials: Vec, camera: VulkanStaticCamera, camera_mode: &'static str, + clip_visible_vertices: usize, mesh_components: usize, terrain_components: usize, } @@ -264,7 +266,7 @@ fn static_preview_mesh_and_materials( }, }]; let terrain_component = if legacy_camera.is_some() { - project_land_msh_to_static_mesh_in_world_space(terrain_mesh) + project_land_msh_to_static_mesh_in_legacy_world_space(terrain_mesh) } else { let frame = xy_frame.ok_or_else(|| "missing diagnostic XY frame".to_string())?; project_land_msh_to_static_mesh_in_xy_frame(terrain_mesh, frame) @@ -313,10 +315,12 @@ fn static_preview_mesh_and_materials( if mesh_components == 0 { return Err("selected static preview roots have no mesh-backed visual".to_string()); } + let camera = legacy_camera.unwrap_or_default(); Ok(StaticPreviewScene { + clip_visible_vertices: static_preview_clip_visible_vertices(&mesh, camera), mesh, materials, - camera: legacy_camera.unwrap_or_default(), + camera, camera_mode: if legacy_camera.is_some() { "legacy-d3d7-capture" } else { @@ -327,6 +331,46 @@ fn static_preview_mesh_and_materials( }) } +/// Counts source vertices inside the Vulkan clip volume for a static preview. +/// +/// The static vertex shader receives row-major D3D7 matrix data as a GLSL +/// column-major `mat4`; its multiplication is consequently equivalent to this +/// row-vector calculation. This remains CPU-only diagnostic evidence and does +/// not change the renderer's clipping rules. +fn static_preview_clip_visible_vertices( + mesh: &VulkanStaticMesh, + camera: VulkanStaticCamera, +) -> usize { + mesh.vertices + .iter() + .filter(|vertex| { + let [x, y, z] = vertex.position; + let matrix = camera.clip_from_world; + let clip_x = x.mul_add( + matrix[0], + y.mul_add(matrix[4], z.mul_add(matrix[8], matrix[12])), + ); + let clip_y = x.mul_add( + matrix[1], + y.mul_add(matrix[5], z.mul_add(matrix[9], matrix[13])), + ); + let clip_z = x.mul_add( + matrix[2], + y.mul_add(matrix[6], z.mul_add(matrix[10], matrix[14])), + ); + let clip_w = x.mul_add( + matrix[3], + y.mul_add(matrix[7], z.mul_add(matrix[11], matrix[15])), + ); + clip_w.is_finite() + && clip_w > 0.0 + && (-clip_w..=clip_w).contains(&clip_x) + && (-clip_w..=clip_w).contains(&clip_y) + && (0.0..=clip_w).contains(&clip_z) + }) + .count() +} + fn static_preview_xy_frame( assets: &MissionAssets, terrain: &TerrainWorld, @@ -558,6 +602,7 @@ struct StaticVulkanApp { materials: Vec, camera: VulkanStaticCamera, camera_mode: &'static str, + clip_visible_vertices: usize, mesh_components: usize, terrain_components: usize, preview_roots: usize, @@ -585,6 +630,7 @@ impl StaticVulkanApp { materials: preview.materials, camera: preview.camera, camera_mode: preview.camera_mode, + clip_visible_vertices: preview.clip_visible_vertices, mesh_components: preview.mesh_components, terrain_components: preview.terrain_components, preview_roots, @@ -630,7 +676,7 @@ impl StaticVulkanApp { return; } self.output = Some(format!( - "{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"camera_mode\":{},\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"preview_roots\":{},\"mesh_components\":{},\"terrain_components\":{},\"material_descriptors\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_bytes\":{},\"readback_hash\":{}}}", + "{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"camera_mode\":{},\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"preview_roots\":{},\"mesh_components\":{},\"terrain_components\":{},\"clip_visible_vertices\":{},\"material_descriptors\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_bytes\":{},\"readback_hash\":{}}}", json_string(self.camera_mode), json_string(&self.mission), self.object_count, @@ -638,6 +684,7 @@ impl StaticVulkanApp { self.preview_roots, self.mesh_components, self.terrain_components, + self.clip_visible_vertices, self.materials.len(), report.renderer_report.swapchain_extent.0, report.renderer_report.swapchain_extent.1, @@ -1061,6 +1108,40 @@ mod tests { values.iter().map(|value| (*value).to_string()).collect() } + #[test] + fn static_preview_clip_diagnostic_matches_vulkan_clip_bounds() { + let mesh = VulkanStaticMesh { + vertices: vec![ + fparkan_render_vulkan::VulkanStaticVertex { + position: [0.0, 0.0, 0.0], + color: [0.0; 3], + uv: [0.0; 2], + }, + fparkan_render_vulkan::VulkanStaticVertex { + position: [1.0, -1.0, 1.0], + color: [0.0; 3], + uv: [0.0; 2], + }, + fparkan_render_vulkan::VulkanStaticVertex { + position: [1.01, 0.0, 0.0], + color: [0.0; 3], + uv: [0.0; 2], + }, + fparkan_render_vulkan::VulkanStaticVertex { + position: [0.0, 0.0, -0.01], + color: [0.0; 3], + uv: [0.0; 2], + }, + ], + indices: Vec::new(), + draw_ranges: Vec::new(), + }; + assert_eq!( + static_preview_clip_visible_vertices(&mesh, VulkanStaticCamera::default()), + 2 + ); + } + #[test] fn parses_required_args() { assert_eq!( diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 0043722..07add33 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1561,3 +1561,27 @@ hashes промежуточных buffers. Без такой трассиров Связанные справочные страницы с таблицами форматов: [MSH](../reference/msh.md), [materials](../reference/materials.md), [Texm](../reference/texm.md) и [render frame](../reference/render-frame.md). + +### Live AutoDemo: видимый результат пока не достигнут + +Read-only `PrintWindow` capture работающего GOG AutoDemo даёт полноценный +оригинальный кадр (terrain, варбот и близкая geometry), поэтому теперь есть +безвводный source для будущего visual comparison. Текущий полный static-Vulkan +preview нельзя описывать как видимую сцену: хотя он загружает 8 TMA objects, +66 MSH components и 67 descriptors, его изображение состоит только из clear +color. Validation остаётся `0/0`, но это доказывает корректность GPU lifecycle, +а не совпадение с оригинальным renderer. + +Новая CPU-диагностика применяет к тем же source vertices ту же row-major D3D7 +matrix, которую GLSL получает как column-major push constant. Для свежего +captured Ngi32 camera (`viewport 1024x768`, near `5`, far `700`, FOV `1.3`) +она насчитала `clip_visible_vertices=0`. Следовательно, current empty frame не +объясняется texture selection, face culling (baseline state already disables +it) или Vulkan validation: следующий исследовательский шаг — восстановить +точный live camera transform/clip-space boundary. + +Параллельно bridge для legacy-camera path теперь переводит только высоту +`Land.msh` в world units с масштабом `1/32`: raw AutoDemo heights +`95.29412..288.23532` становятся `2.978..9.007`, что согласуется с TMA Z и +live camera height. Это доказанное coordinate conversion, но оно само по себе +не сделало вершины видимыми и не является заявлением о pixel parity.