diff options
| author | Valentin Popov <valentin@popov.link> | 2026-11-01 11:30:17 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-11-01 11:30:17 +0300 |
| commit | 923ef2aad6b7284d745756d938b90965bb92a110 (patch) | |
| tree | 2f84de40eaae84c148c1c601f759740f07755ab4 | |
| parent | 9533c1a927ec26aced3a9b80ce385c0774cbadbe (diff) | |
| download | fparkan-923ef2aad6b7284d745756d938b90965bb92a110.tar.xz fparkan-923ef2aad6b7284d745756d938b90965bb92a110.zip | |
fix(render): match native packet distance approximation
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi.rs | 2 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/packet_distance.rs | 52 | ||||
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 120 | ||||
| -rw-r--r-- | crates/fparkan-render/src/lib.rs | 5 | ||||
| -rw-r--r-- | docs/appendices/knowledge-boundaries.md | 7 | ||||
| -rw-r--r-- | docs/reference/render-frame.md | 30 | ||||
| -rw-r--r-- | docs/tomes/05-render.md | 12 |
7 files changed, 188 insertions, 40 deletions
diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs index 030b8ac..04be2d3 100644 --- a/adapters/fparkan-render-vulkan/src/ffi.rs +++ b/adapters/fparkan-render-vulkan/src/ffi.rs @@ -11,6 +11,7 @@ mod asset_mesh; mod capabilities; mod instance; +mod packet_distance; mod reflection; mod resources; mod runtime; @@ -50,6 +51,7 @@ pub use self::instance::{ }; #[cfg(test)] use self::instance::{cstring_vec, ensure_instance_extensions_available}; +pub use self::packet_distance::legacy_packet_distance_squared; use self::resources::{ color_subresource_range, create_command_pool, create_depth_attachment, create_frame_sync, create_frame_uniform_buffer, create_index_buffer, create_material_specular_buffer, diff --git a/adapters/fparkan-render-vulkan/src/ffi/packet_distance.rs b/adapters/fparkan-render-vulkan/src/ffi/packet_distance.rs new file mode 100644 index 0000000..b6b8938 --- /dev/null +++ b/adapters/fparkan-render-vulkan/src/ffi/packet_distance.rs @@ -0,0 +1,52 @@ +#![allow(unsafe_code)] +//! The native CPU instruction used for render-packet distance keys. + +/// Reproduces Ngi32's squared length multiplied by `RSQRTSS` on x86-64. +/// Other architectures retain the portable distance and report that boundary. +#[must_use] +pub fn legacy_packet_distance_squared(squared: f32) -> Option<f32> { + if !squared.is_finite() || squared < 0.0 { + return None; + } + if squared == 0.0 { + static NOTICE: std::sync::Once = std::sync::Once::new(); + NOTICE.call_once(|| { + eprintln!("render_coverage packet_zero_distance_fallback=1 (stable zero instead of native 0 * infinity)"); + }); + return Some(0.0); + } + #[cfg(target_arch = "x86_64")] + let distance = { + use core::arch::x86_64::{_mm_cvtss_f32, _mm_rsqrt_ss, _mm_set_ss}; + // SAFETY: SSE is part of the x86-64 baseline; these intrinsics only + // operate on register values and do not access memory. + let inverse = unsafe { _mm_cvtss_f32(_mm_rsqrt_ss(_mm_set_ss(squared))) }; + squared * inverse + }; + #[cfg(not(target_arch = "x86_64"))] + let distance = { + static NOTICE: std::sync::Once = std::sync::Once::new(); + NOTICE.call_once(|| { + eprintln!("render_coverage packet_distance_native_rsqrt_unsupported=1 (using portable square root)"); + }); + squared.sqrt() + }; + distance.is_finite().then_some(distance) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn packet_distance_preserves_the_native_approximation_error_bound() { + for squared in [0.000_001_f32, 1.0, 9.0, 11_393.854, 1_000_000.0] { + let distance = legacy_packet_distance_squared(squared).unwrap(); + assert!((distance / squared.sqrt() - 1.0).abs() < 0.000_3); + } + assert_eq!(legacy_packet_distance_squared(0.0), Some(0.0)); + for invalid in [-1.0, f32::NAN, f32::INFINITY] { + assert_eq!(legacy_packet_distance_squared(invalid), None); + } + } +} diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index ca8742d..dc996c9 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -61,8 +61,9 @@ use fparkan_render::{ RawCameraTransform, }; use fparkan_render_vulkan::{ - node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root, - node38_pose_relative_to_root_from_hierarchy, project_land_msh_to_static_mesh_in_world_space, + legacy_packet_distance_squared, node38_pose, node38_pose_from_hierarchy, + node38_pose_relative_to_root, node38_pose_relative_to_root_from_hierarchy, + project_land_msh_to_static_mesh_in_world_space, project_msh_to_static_mesh_in_world_space_with_node_fallback_poses, project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount, project_msh_to_static_mesh_in_world_space_with_node_pose_buffer, @@ -1373,7 +1374,7 @@ fn native_msh_diffuse_alpha_override( let distance = if range.batch_flags & 0x0050 == 0x0040 { 0.0 } else { - // shortcut: use the first indexed vertex; replace with native CPU clipping selection when clipped-portal captures are available. + // The native alpha callback reads the input packet before renderer clipping. first_indexed_vertex_distance(mesh, range_index, eye_world)? }; legacy_msh_portal_alpha(range.batch_flags, distance, horizontal_fov) @@ -3978,10 +3979,7 @@ fn first_indexed_vertex_distance( .get(vertex_index) .ok_or_else(|| "world draw range vertex index is outside the vertex buffer".to_string())?; let delta = sub3(vertex.position, camera_position); - let distance = dot3(delta, delta).sqrt(); - distance - .is_finite() - .then_some(distance) + legacy_packet_distance_squared(dot3(delta, delta)) .ok_or_else(|| "world draw range sort distance is non-finite".to_string()) } @@ -9601,17 +9599,6 @@ impl StaticVulkanApp { horizontal_fov(&self.view_camera, aspect), )?; } - let approximate_portals = self - .world_range_indices - .iter() - .filter(|&&index| { - let flags = self.mesh.draw_ranges[index].batch_flags; - flags & 8 != 0 - && flags & 0x50 != 0x40 - && !self.type16_present_ranges.contains(&index) - }) - .count(); - eprintln!("render_coverage portal_fade_packet_selection_approx={approximate_portals} (first indexed vertex; native CPU clipping selection pending)"); eprintln!("render_coverage msh_type16_colors_unsupported={} (authored vertex colors are not decoded; using white)", self.type16_present_ranges.len()); let randomized_lights = self .control_point_light_slots @@ -13355,7 +13342,8 @@ mod tests { let alpha = native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -25.0], 1.04) .unwrap() .unwrap(); - assert!((alpha - 0.5).abs() < 0.000_001); + // Native RSQRTSS perturbs the distance before the extra square root. + assert!((alpha - 0.5).abs() < 0.000_3); assert_eq!( native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -60.0], 1.04), Ok(Some(1.0)) @@ -15360,7 +15348,97 @@ mod tests { let mesh = VulkanStaticMesh::smoke_triangle(); let distance = first_indexed_vertex_distance(&mesh, 0, [0.0, -0.55, -3.0]) .expect("first indexed vertex distance"); - assert!((distance - 3.0).abs() < 1.0e-6); + assert!((distance - 3.0).abs() < 1.0e-3); + } + + #[test] + #[ignore = "requires the original GOG installation and native SSE reference"] + fn gog_portal_alpha_keeps_the_reference_vertex_outside_the_viewport() { + let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT") + .expect("set FPARKAN_CORPUS_PART1_ROOT to the original GOG installation"); + let mut engine = + create(EngineServices::new(Arc::new(DirectoryVfs::new(root)))).expect("engine"); + load_mission_static_preview_roots( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + NonZeroUsize::new(usize::MAX).unwrap(), + ) + .expect("original AutoDemo"); + let assets = loaded_mission_assets(&engine).expect("mission assets"); + let root = &loaded_mission_object_drafts(&engine).expect("mission objects")[4]; + let visual = assets + .visual_by_id(assets.object_visuals[4][0]) + .expect("factory visual"); + let model = assets.model_by_id(visual.model_id.unwrap()).unwrap(); + let mesh = project_msh_to_static_mesh_in_world_space_with_node_sampled_poses( + &model.validated, + LegacyIron3dEulerTransform { + translation: root.position, + orientation_radians: root.orientation_raw, + }, + root.scale, + 0, + ) + .expect("factory mesh"); + // GOG capture: yaw/roll expose only part of the window; alpha was read + // after Terrain's callback stored it, before the renderer consumed it. + let transform = RawCameraTransform { + words: [ + 3_204_541_090, + 3_209_392_467, + 1_051_461_443, + 1_135_728_591, + 1_063_051_501, + 3_203_294_891, + 1_045_010_100, + 1_143_538_729, + 0, + 1_053_254_103, + 1_064_028_839, + 1_093_589_271, + 0, + 0, + 0, + 1_065_353_216, + ], + }; + let camera = VulkanStaticCamera::from_legacy_d3d7( + transform, + LegacyD3d7Projection { + viewport: [0, 0, 800, 600], + near_plane: 0.5, + far_plane: 700.0, + field_of_view_radians: 1.04, + }, + ) + .expect("native edge camera"); + let range = mesh.draw_ranges[33]; + assert_eq!(range.batch_flags, 0x0108); + let clip_vertex = |offset: usize| -> [f32; 4] { + let index = mesh.indices[range.first_index as usize + offset] as usize; + let [x, y, z] = mesh.vertices[index].position; + let point = [x, y, z, 1.0]; + std::array::from_fn(|column| { + (0..4) + .map(|row| point[row] * camera.clip_from_world[row * 4 + column]) + .sum() + }) + }; + let outside = clip_vertex(0); + let inside = clip_vertex(1); + assert!(outside[0] > outside[3]); + assert!(inside[0].abs() < inside[3]); + let distance = first_indexed_vertex_distance(&mesh, 33, transform.translation()) + .expect("portal reference distance"); + assert!((distance - f32::from_bits(0x42D5_7EE4)).abs() < 2.0e-5); + let alpha = + native_msh_diffuse_alpha_override(&mesh, 33, false, transform.translation(), 1.04) + .expect("portal alpha") + .expect("portal override"); + assert!((alpha - f32::from_bits(0x3F05_190C)).abs() < 1.0e-6); } #[test] @@ -16515,7 +16593,7 @@ mod tests { let expected = dot3(expected, expected).sqrt(); let actual = first_indexed_vertex_distance(&mesh, range.range_index, camera) .expect("native Op9 primitive distance key"); - assert!((actual - expected).abs() < 1e-5); + assert!((actual - expected).abs() < expected * 0.000_3 + 1e-5); } for selector in [1, 2] { diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs index db0171f..80193c7 100644 --- a/crates/fparkan-render/src/lib.rs +++ b/crates/fparkan-render/src/lib.rs @@ -382,8 +382,9 @@ pub enum LegacyCullMode { /// CPU diffuse alpha for a colourless MSH batch carrying flag `0x0008`. /// -/// `packet_distance` is the distance to the packet's first accepted vertex, -/// after its object transform. Flag `0x0010` takes precedence over `0x0040`. +/// `packet_distance` is the native distance to the input packet's first indexed +/// vertex after its object transform, even when that vertex is outside the +/// viewport. Flag `0x0010` takes precedence over `0x0040`. /// Returns `None` for invalid distance or projection inputs. Callers must keep /// the material alpha for batches with a type-16 colour stream or without `0x0008`. #[must_use] diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index a60fa68..04dc822 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -50,9 +50,10 @@ MSH-запрос со свободной preview-камерой; это не пÐ стратегической и следящей камерами не подтверждено ([MSH](../reference/msh.md)). -Кривые alpha и метрика расстояния порталов реализованы для MSH без type 16 -([описание кадра](../reference/render-frame.md#прозрачность-порталов-msh)). Native CPU clipping может выбрать другую первую вершину; -это приближение явно считается как `portal_fade_packet_selection_approx`. +Кривые alpha и опорная вершина порталов сверены для MSH без type 16 +([описание кадра](../reference/render-frame.md#прозрачность-порталов-msh)). Native `RSQRTSS` +воспроизводится на x86-64; другие архитектуры и нулевая дистанция используют +явные `packet_distance_native_rsqrt_unsupported` / `packet_zero_distance_fallback`. Данные type 16 ещё не декодированы и считаются в `msh_type16_colors_unsupported`; в статических сценах 29 миссий GOG (1736 загрузок моделей) этот поток отсутствует. diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md index 7adc4e6..ebe3971 100644 --- a/docs/reference/render-frame.md +++ b/docs/reference/render-frame.md @@ -68,13 +68,24 @@ RVA `0x1B0F0`, заменяет diffuse alpha пакета без type 16 с bat Скаляр камеры в шестом float getter `0x4F5F0` и поле CShade `+0xBB4` проверены в работающем оригинале: оба содержат текущий горизонтальный FOV. -`legacy_msh_portal_alpha` хранит кривую в `fparkan-render`. Приложение пока -берёт первую индексированную вершину своего диапазона и обычный `sqrt`, -поэтому выбор вершины после native CPU clipping и точность `RSQRTSS` не -воспроизводятся. Счётчик `render_coverage portal_fade_packet_selection_approx` -показывает число таких диапазонов. Поток type 16 сохраняет прежний отдельный -путь и ещё не декодирован. Это ограничивает приёмку на частично отсечённых -порталах и не позволяет считать интерьер принятой эталонной сценой. +`legacy_msh_portal_alpha` хранит кривую в `fparkan-render`. Её опорная вершина +принадлежит входному пакету: выбирать вместо неё первую видимую точку нельзя. +В живом краевом ракурсе AutoDemo первая вершина окна находится за правой +границей viewport (`NDC x ≈ 1,048`), а следующая — внутри (`x ≈ 0,815`). +Оригинал всё равно использует первую: расстояние `106,74783` даёт alpha +`0,51991343`. Выбор следующей изменил бы alpha примерно на `0,026`. +Передача пакета renderer-у происходит после записи этой alpha. + +Адаптер изолирует инструкцию `RSQRTSS`; на x86-64 расстояние и alpha этого +ракурса совпадают с оригиналом побитно. Обычный `sqrt` давал alpha +`0,5196797`: при упаковке прозрачности этого достаточно для отличия отдельных +пикселей. Прежнее предположение о выборе вершины после clipping и связанный +счётчик `portal_fade_packet_selection_approx` удалены. На других архитектурах +сохраняется переносимый корень с явным `packet_distance_native_rsqrt_unsupported`; +совпадение камеры с вершиной использует нулевую дистанцию и +`packet_zero_distance_fallback` вместо native `0 × infinity`. +Поток type 16 ещё не декодирован. Проверка краевого окна не закрывает остальные +внутренние ракурсы завода. ## Дистанционная кривая тумана @@ -151,9 +162,8 @@ raw-флаге `Batch20` `0x0008` получает переопределениÐ материал; alpha `0` временно задаёт число индексов `0`, а при очистке переопределения исходное число индексов восстанавливается. -Fade обновляется с камерой каждый кадр. Открыт выбор первой принятой вершины -после native CPU clipping: приложение пока берёт первую индексированную -вершину диапазона и считает это приближение в `render_coverage`. +Fade обновляется с камерой каждый кадр и сохраняет исходную опорную вершину +диапазона, в том числе когда часть окна выходит за границу экрана. В превью с фиксированной камерой диапазоны туманности и звёзд, купола и облаков явно помечаются как прозрачный фон. Непрозрачные диапазоны мира остаются на diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 4aba919..266cb47 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -510,8 +510,11 @@ Material manager кэширует разобранный MAT0 и texture handles Для MSH без потока цветов type 16 правило включает флаг batch `0x0008`. В обычной ветви при горизонтальном FOV `1.3` поверхность прозрачна до расстояния 75 и непрозрачна после 95; между ними alpha растёт линейно. При другом FOV обе -границы умножаются на `1.3 / FOV`. Расстояние берётся до первой вершины первого -принятого треугольника, а не до центра здания. +границы умножаются на `1.3 / FOV`. Расстояние берётся до первой индексированной +вершины входного пакета, а не до центра здания. Эта точка может находиться за +экраном: видимая часть окна всё равно сохраняет её прозрачность. Если при повороте +камеры выбирать новую видимую точку, окно начнёт менять прозрачность без движения +игрока. Флаг `0x0010` выбирает другую кривую: сравнивается квадратный корень уже вычисленного расстояния, границы равны `2.6 / FOV` и `7.7999997 / FOV`. @@ -521,8 +524,9 @@ Material manager кэширует разобранный MAT0 и texture handles FParkan пересчитывает эту alpha при каждом обновлении камеры, после анимации модели. Нулевое значение временно выключает диапазон индексов; при удалении -камеры диапазон восстанавливается. Подробности оригинального вычисления и -пока приближённого выбора вершины приведены в [описании кадра](../reference/render-frame.md#прозрачность-порталов-msh). +камеры диапазон восстанавливается. Расстояние использует тот же приближённый +обратный корень, что и оригинал: округление влияет на записанную alpha. +Подробности вычисления приведены в [описании кадра](../reference/render-frame.md#прозрачность-порталов-msh). ## Texm: текстуры, mip-уровни и атласы |
