From 0f8b14abbf73a43402644c9acd0750bf25a79e98 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sun, 1 Nov 2026 07:49:51 +0400 Subject: [PATCH] fix(render): use native shifted water reflection camera --- README.md | 5 +- .../shaders/triangle.frag | 15 ++- .../shaders/triangle.frag.spv | Bin 14512 -> 14716 bytes .../fparkan-render-vulkan/src/ffi/smoke.rs | 28 ++++- crates/fparkan-render/src/water.rs | 108 ++++++++++++++++++ docs/appendices/knowledge-boundaries.md | 2 +- docs/tomes/05-render.md | 35 ++++-- 7 files changed, 168 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 1aac809..4e16908 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ Рельеф переключает исходные LOD0/LOD1 по ячейкам относительно текущей камеры; под основаниями строений сохраняется подробная сетка. Горизонтальная вода использует плоское отражение ближайшей активной высоты; -волны используют карту Du/Dv и часы оригинала, а shifted-камера отражения -пока заменена обычным зеркалом. +волны используют карту Du/Dv и часы оригинала. Камера отражения смотрит вверх +из отражённой точки наблюдения через прямоугольник водоёма; глубина и часть +параметров отражённого вида пока заимствуются из основного прохода. ## Запуск diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag b/adapters/fparkan-render-vulkan/shaders/triangle.frag index 5d10152..2c71278 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.frag +++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag @@ -82,12 +82,15 @@ void main() { vec2 bump_uv = plane_uv * frame.embm_wave.y + frame.embm_wave.x; vec2 duv = max(vec2(-1.0), (texture(embm_bump, bump_uv).rg * 255.0 - 128.0) / 127.0); vec2 displacement = duv * frame.embm_wave.zw; - // shortcut: convert native map-UV displacement to the current mirror projection; - // replace this projection when accepting the original shifted reflection camera. - vec3 displaced_world = in_world_position - vec3(displacement.yx * span, 0.0); - vec4 displaced_clip = frame.clip_from_world * vec4(displaced_world, 1.0); - if (displaced_clip.w > 0.000001) { - reflection_uv = displaced_clip.xy / displaced_clip.w * 0.5 + 0.5; + if ((mode & 128) != 0) { + reflection_uv = plane_uv + displacement; + } else { + // shortcut: non-perspective API cameras retain the projective mirror fallback. + vec3 displaced_world = in_world_position - vec3(displacement.yx * span, 0.0); + vec4 displaced_clip = frame.clip_from_world * vec4(displaced_world, 1.0); + if (displaced_clip.w > 0.000001) { + reflection_uv = displaced_clip.xy / displaced_clip.w * 0.5 + 0.5; + } } vec3 rgb = texture(reflection_color, reflection_uv).rgb * in_base_diffuse + in_base_specular; out_color = vec4(mix(rgb * in_color, frame.fog_color.rgb, in_fog_factor), material.diffuse_alpha); diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv index de3ace671d3c127e33451491bd430393f263b243..6deef9d51afc9622557cc104e0f9c6e88c5c2720 100644 GIT binary patch literal 14716 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 literal 14512 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 Xr?$9ew6!5}J{NJ^@p9hr2O|FqDiS7M diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index bd745ad..ef19ee8 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -373,7 +373,7 @@ impl VulkanSmokeRenderer { plane_heights.sort_unstable(); plane_heights.dedup(); if !plane_heights.is_empty() { - eprintln!("render_coverage planar_reflection_shifted_camera_unsupported={} (native bump map and clock; using a projective mirror until shifted-camera mapping is implemented)", plane_heights.len()); + eprintln!("render_coverage planar_reflection_render_context_approx={} (native shifted window and bump UV; main-view depth range, sky/fog/FX and swapchain-sized target remain approximations; non-perspective cameras use mirror fallback)", plane_heights.len()); eprintln!("render_coverage planar_reflection_other_planes_unsupported={} (nearest active plane is reflected)", plane_heights.len().saturating_sub(1)); } if unsupported_nonplanar != 0 { @@ -1564,11 +1564,18 @@ impl VulkanSmokeRenderer { .active_reflection_plane() .map(|height| { let mut frame = self.frame_uniforms; - frame.clip_from_world = + frame.clip_from_world = fparkan_render::water::legacy_embm_reflection_clip( + frame.clip_from_world, + frame.camera_position, + height, + frame.embm_bounds, + ) + .or_else(|| { fparkan_render::reflect_clip_from_world_about_z(frame.clip_from_world, height) - .ok_or(VulkanSmokeRendererError::InvalidStaticCamera { - context: "non-finite reflected clip transform", - })?; + }) + .ok_or(VulkanSmokeRendererError::InvalidStaticCamera { + context: "non-finite reflected clip transform", + })?; frame.camera_position[2] = 2.0 * height - frame.camera_position[2]; if !frame.is_finite() { return Err(VulkanSmokeRendererError::InvalidStaticCamera { @@ -1819,6 +1826,15 @@ impl VulkanSmokeRenderer { self.draw_order_dirty = false; } let reflection_plane = self.active_reflection_plane(); + let shifted_reflection = reflection_plane.is_some_and(|height| { + fparkan_render::water::legacy_embm_reflection_clip( + self.frame_uniforms.clip_from_world, + self.frame_uniforms.camera_position, + height, + self.frame_uniforms.embm_bounds, + ) + .is_some() + }); let device = self.device_ref()?; let swapchain = self.swapchain_ref()?; let resources = self.resources_ref()?; @@ -2053,7 +2069,7 @@ impl VulkanSmokeRenderer { && reflection_plane.is_some() && self.reflection_planes[range_index] == reflection_plane { - 64 + 64 | if shifted_reflection { 128 } else { 0 } } else { 0 }) as f32; diff --git a/crates/fparkan-render/src/water.rs b/crates/fparkan-render/src/water.rs index 0c7ad2a..4f161cc 100644 --- a/crates/fparkan-render/src/water.rs +++ b/crates/fparkan-render/src/water.rs @@ -51,10 +51,118 @@ pub fn legacy_embm_uv(world_xy: [f32; 2], bounds: [f32; 4]) -> Option<[f32; 2]> ]) } +/// The EMBM camera looks up from the mirrored eye, with its window on the water rectangle. +/// Terrain.dll+4EFF0 supplies `[min_y-eye_y, min_x-eye_x, span_y, span_x, eye_z-height]`. +/// The ordinary perspective's depth mapping is retained; orthographic inputs use the caller's fallback. +#[must_use] +pub fn legacy_embm_reflection_clip( + main_clip: [f32; 16], + eye: [f32; 3], + height: f32, + bounds: [f32; 4], +) -> Option<[f32; 16]> { + let span = [bounds[2] - bounds[0], bounds[3] - bounds[1]]; + let distance = eye[2] - height; + if !main_clip + .into_iter() + .chain(eye) + .chain([height]) + .chain(bounds) + .chain(span) + .all(f32::is_finite) + || distance <= 0.0 + || span.into_iter().any(|v| v <= 0.0) + { + return None; + } + let w = [main_clip[3], main_clip[7], main_clip[11]]; + let squared = w.into_iter().map(|v| v * v).sum::(); + if !squared.is_finite() || squared <= f32::EPSILON { + return None; + } + let depth_a = (main_clip[2] * w[0] + main_clip[6] * w[1] + main_clip[10] * w[2]) / squared; + // shortcut: retain the main depth range until the native shifted depth matrix is verified. + let depth_b = (main_clip[14] - depth_a * main_clip[15]) / squared.sqrt(); + if !depth_a.is_finite() || !depth_b.is_finite() || depth_a < 1.0 || depth_b >= 0.0 { + return None; + } + let z = 2.0 * height - eye[2]; + let x_scale = -2.0 * distance / span[1]; + let y_scale = -2.0 * distance / span[0]; + let x_shift = 1.0 + 2.0 * (bounds[1] - eye[1]) / span[1]; + let y_shift = 1.0 + 2.0 * (bounds[0] - eye[0]) / span[0]; + let matrix = [ + 0.0, + y_scale, + 0.0, + 0.0, + x_scale, + 0.0, + 0.0, + 0.0, + x_shift, + y_shift, + depth_a, + 1.0, + -x_scale * eye[1] - x_shift * z, + -y_scale * eye[0] - y_shift * z, + depth_b - depth_a * z, + -z, + ]; + matrix.into_iter().all(f32::is_finite).then_some(matrix) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn shifted_camera_maps_native_water_corners_and_retains_perspective_depth() { + let projection = crate::LegacyD3d7Projection { + viewport: [0, 0, 1280, 1024], + near_plane: 0.5, + far_plane: 700.0, + field_of_view_radians: 1.3, + } + .try_direct3d7_projection_row_major() + .unwrap(); + let eye = [1200.0, 800.0, 185.0]; + let mut main = [0.0; 16]; + main[2] = projection[10]; + main[3] = projection[11]; + main[14] = -eye[0] * projection[10] + projection[14]; + main[15] = -eye[0] * projection[11]; + let bounds = [1000.0, 500.0, 1500.0, 1200.0]; + let matrix = legacy_embm_reflection_clip(main, eye, 150.0, bounds).unwrap(); + let project = |point: [f32; 3]| -> [f32; 4] { + std::array::from_fn(|column| { + point[0] * matrix[column] + + point[1] * matrix[4 + column] + + point[2] * matrix[8 + column] + + matrix[12 + column] + }) + }; + for point in [ + [1000.0, 500.0, 150.0], + [1500.0, 1200.0, 150.0], + [1125.0, 1025.0, 150.0], + ] { + let clip = project(point); + let expected = legacy_embm_uv([point[0], point[1]], bounds).unwrap(); + for axis in 0..2 { + assert!((clip[axis] / clip[3] * 0.5 + 0.5 - expected[axis]).abs() < 0.00001); + } + assert_eq!(clip[3], 35.0); + } + let near = project([1200.0, 800.0, 115.5]); + assert!((near[2] / near[3]).abs() < 0.001); + let far = project([1200.0, 800.0, 815.0]); + assert!((far[2] / far[3] - 1.0).abs() < 0.00001); + assert!(legacy_embm_reflection_clip([0.0; 16], eye, 150.0, bounds).is_none()); + assert!(legacy_embm_reflection_clip(main, eye, 185.0, bounds).is_none()); + assert!(legacy_embm_reflection_clip(main, eye, 150.0, [1.0; 4]).is_none()); + } + #[test] fn wave_bytes_match_native_generation_and_clock_wraps_without_float_time_loss() { let texels = legacy_embm_bump_map(); diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index d632bf3..7b71e9e 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -76,7 +76,7 @@ MSH-запрос со свободной preview-камерой; это не п среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state log на минимальных сценах с одним типом материала. -Горизонтальные грани Land.msh с флагом `0x20000` используют Vulkan-отражение и исходную карту волн Du/Dv; перенос её смещений в обычное зеркало приближённый: `planar_reflection_shifted_camera_unsupported`. +Горизонтальные грани Land.msh с флагом `0x20000` используют исходную карту Du/Dv и окно shifted-камеры, проверенное исполнением Terrain.dll. Диапазон глубины, небо/туман/FX основного вида и размер target остаются приближениями: `planar_reflection_render_context_approx`; неперспективная камера API использует обычное зеркало. Raw queue clock задаёт фазу волн отдельно от времени суток и sky elapsed; старые захваты без него считаются в `captured_water_phase_unverified`. Один target отражает ближайшую активную высоту воды; остальные высоты и неплоские грани считаются в `planar_reflection_other_planes_unsupported` и `planar_reflection_nonplanar_unsupported` и сохраняют обычный terrain shader. `terrain_face8_pipeline_unverified` теперь считает только исходные грани с `0x8` без `0x20000`; сама семантика оставшихся специальных проходов ещё не установлена. diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index a7a1396..53667c8 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1147,13 +1147,27 @@ lightmap state передаются в `VulkanStaticMaterial`, а shader пос В [эталонной сборке](../reference/original-binaries.md) выбор грани находится в Terrain.dll RVA `0x28A50`, а подготовка EMBM-пакета — в RVA `0x1B540`. -Первый Vulkan-проход строит плоское зеркало. Для горизонтальной воды на -высоте `h` отражённая координата точки равна `z′ = 2h − z`. Это преобразование -применяется перед обычной проекцией. Оно меняет порядок вершин треугольника, -поэтому проход меняет сторону отсечения и отбрасывает геометрию ниже воды. +Для горизонтальной воды на высоте `h` камера отражения находится в точке +`[eye_x, eye_y, 2h − eye_z]`. Но она не повторяет наклон основной камеры: +смотрит вертикально вверх, а её окно лежит на прямоугольнике водоёма. Поэтому +текстура отражения привязана к местности, а не к экрану игрока. В плоскости +воды координаты изображения равны `U = 1 − Ynorm`, `V = 1 − Xnorm`. + +Terrain.dll RVA `0x4EFF0` передаёт shifted-проекции пять значений: +`[min_y − eye_y, min_x − eye_x, span_y, span_x, eye_z − h]`. Например, +для наблюдателя `[1200, 800, 185]`, воды `150` и границ +`[1000, 500, 1500, 1200]` это `[-300, -200, 700, 500, 35]`. +Матрица камеры и эти аргументы проверены исполнением оригинальной функции +при двух положениях и разных поворотах основной камеры. Оракул подменяет +ввод-вывод очереди, ландшафта и камеры; сам renderer и матрица глубины в эту +проверку не входят. Vulkan-проекция сохраняет near/far основного вида; +Rust-тест отдельно проверяет углы водоёма и границы глубины. + +Отражённый вид меняет сторону отсечения и отбрасывает геометрию ниже воды. Небо и оставшаяся сцена попадают в отдельную цветовую текстуру с собственной -глубиной. В основном проходе водные треугольники читают её в экранной -координате: точки самой плоскости не сдвигаются при отражении. +глубиной. В основном проходе водные треугольники читают её по координатам +водоёма с добавленным смещением волн. Для неперспективной камеры API остаётся +обычное зеркало с экранными координатами. Цветовой target переходит из attachment layout в shader-read layout после записи. Следующий зеркальный проход ждёт предыдущего чтения этой текстуры; @@ -1182,10 +1196,11 @@ lightmap state передаются в `VulkanStaticMaterial`, а shader пос В Vulkan карта хранится в двух каналах несжатой текстуры и возвращается к знаковым значениям перед вычислением смещения. -Обычное зеркало ещё не повторяет shifted-камеру оригинала: смещения Du/Dv -сейчас переводятся из координат водоёма в текущую зеркальную проекцию. -Это приближение учитывается в `planar_reflection_shifted_camera_unsupported`; -совпадение генератора волн не доказывает совпадения итогового отражения. +Матрица положения камеры, её окно и координаты волн восстановлены, но весь +контекст отражённого вида ещё не повторён. Диапазон глубины, небо, туман и FX +заимствуются из основного вида, а размер target совпадает со swapchain. +Эти ограничения учитываются в `planar_reflection_render_context_approx`; +совпадение генератора волн и окна не доказывает совпадения итогового отражения. Один target обслуживает ближайшую активную высоту воды; другие высоты и негоризонтальные поверхности явно считаются как неподдержанные. Это промежуточный водный проход, а не полная