aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-07-18 14:50:52 +0300
committerValentin Popov <valentin@popov.link>2026-07-18 14:50:52 +0300
commitfea984ddf51ba1e56ce800b9f370ac4208dcfcab (patch)
tree96345c489f8c8d80ab0cbeb9d5dcf054217d6859
parent2a793f1854231be6da930a3692dce691a6426211 (diff)
downloadfparkan-fea984ddf51ba1e56ce800b9f370ac4208dcfcab.tar.xz
fparkan-fea984ddf51ba1e56ce800b9f370ac4208dcfcab.zip
feat(render): diagnose captured camera clipping
-rw-r--r--adapters/fparkan-render-vulkan/src/asset_mesh.rs58
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi.rs6
-rw-r--r--apps/fparkan-game/src/main.rs89
-rw-r--r--docs/tomes/05-render.md24
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<VulkanStaticMesh, VulkanAssetMeshError> {
+ 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<Vec<u32>, 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<VulkanStaticMaterial>,
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<VulkanStaticMaterial>,
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,
@@ -1062,6 +1109,40 @@ mod tests {
}
#[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!(
Args::parse(&strings(&[
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.