From 05572c7bf446572264d2cf97c531be6c3bfaaf30 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sun, 1 Nov 2026 06:12:44 +0400 Subject: [PATCH] feat(render): animate water with native EMBM waves --- README.md | 3 +- .../shaders/triangle.frag | 19 ++- .../shaders/triangle.frag.spv | Bin 12292 -> 14512 bytes .../shaders/triangle.vert | 6 +- .../shaders/triangle.vert.spv | Bin 20300 -> 20384 bytes .../src/ffi/reflection.rs | 129 ++++++++++++++++-- .../fparkan-render-vulkan/src/ffi/smoke.rs | 13 +- .../src/ffi/smoke_types.rs | 47 ++++++- .../src/ffi/swapchain_resources.rs | 20 +-- apps/fparkan-game/src/camera_capture.rs | 1 + apps/fparkan-game/src/main.rs | 52 ++++++- crates/fparkan-render/src/lib.rs | 2 + crates/fparkan-render/src/water.rs | 108 +++++++++++++++ docs/appendices/knowledge-boundaries.md | 3 +- docs/tomes/05-render.md | 29 +++- 15 files changed, 390 insertions(+), 42 deletions(-) create mode 100644 crates/fparkan-render/src/water.rs diff --git a/README.md b/README.md index eaf7636..1aac809 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ Рельеф переключает исходные LOD0/LOD1 по ячейкам относительно текущей камеры; под основаниями строений сохраняется подробная сетка. Горизонтальная вода использует плоское отражение ближайшей активной высоты; -волны EMBM и shifted-камера оригинала пока не воспроизводятся. +волны используют карту Du/Dv и часы оригинала, а shifted-камера отражения +пока заменена обычным зеркалом. ## Запуск diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag b/adapters/fparkan-render-vulkan/shaders/triangle.frag index ec0acdb..5d10152 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.frag +++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag @@ -9,7 +9,7 @@ layout(location = 5) in vec3 in_base_diffuse; layout(location = 6) in vec3 in_base_specular; layout(location = 7) in vec3 in_overlay_diffuse; layout(location = 8) in vec3 in_overlay_specular; -layout(location = 9) in float in_world_z; +layout(location = 9) in vec3 in_world_position; layout(location = 0) out vec4 out_color; layout(set = 0, binding = 0) uniform sampler2D base_color; @@ -17,6 +17,7 @@ layout(set = 0, binding = 1) uniform sampler2D base_detail; layout(set = 0, binding = 2) uniform sampler2D overlay_color; layout(set = 0, binding = 3) uniform sampler2D overlay_detail; layout(set = 1, binding = 0) uniform sampler2D reflection_color; +layout(set = 1, binding = 1) uniform sampler2D embm_bump; layout(std140, set = 0, binding = 4) uniform FrameConstants { mat4 clip_from_world; @@ -30,6 +31,8 @@ layout(std140, set = 0, binding = 4) uniform FrameConstants { vec4 fog_color; vec4 fog_distances; vec4 camera_position; + vec4 embm_bounds; + vec4 embm_wave; } frame; layout(push_constant) uniform MaterialConstants { @@ -69,11 +72,23 @@ layout(push_constant) uniform MaterialConstants { void main() { int mode = int(round(material.material_mode)); - if ((mode & 32) != 0 && (mode & 4) == 0 && in_world_z < frame.camera_position.w) { + if ((mode & 32) != 0 && (mode & 4) == 0 && in_world_position.z < frame.camera_position.w) { discard; } if ((mode & 64) != 0) { vec2 reflection_uv = gl_FragCoord.xy / vec2(textureSize(reflection_color, 0)); + vec2 span = frame.embm_bounds.zw - frame.embm_bounds.xy; + vec2 plane_uv = vec2(1.0) - (in_world_position.yx - frame.embm_bounds.yx) / span.yx; + 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; + } 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); if (out_color.a < material.alpha_cutoff) { diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv index 84c8a0c1f2c312b7d77f48b3c7c9eae1c9017b8f..de3ace671d3c127e33451491bd430393f263b243 100644 GIT binary patch literal 14512 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 Xr?$9ew6!5}J{NJ^@p9hr2O|FqDiS7M literal 12292 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 HAFlr&ARtu# delta 5562 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 diff --git a/adapters/fparkan-render-vulkan/src/ffi/reflection.rs b/adapters/fparkan-render-vulkan/src/ffi/reflection.rs index eb4025e..62a0d1e 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/reflection.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/reflection.rs @@ -36,14 +36,42 @@ pub(super) fn mesh_reflection_planes(mesh: &super::VulkanStaticMesh) -> Vec Option<[f32; 4]> { + let mut bounds = [ + f32::INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::NEG_INFINITY, + ]; + for range in mesh + .draw_ranges + .iter() + .filter(|range| range.terrain_reflection) + { + for &index in &mesh.indices + [range.first_index as usize..range.first_index as usize + range.index_count as usize] + { + let position = mesh.vertices[index as usize].position; + bounds[0] = bounds[0].min(position[0]); + bounds[1] = bounds[1].min(position[1]); + bounds[2] = bounds[2].max(position[0]); + bounds[3] = bounds[3].max(position[1]); + } + } + (bounds.iter().all(|v| v.is_finite()) && bounds[2] > bounds[0] && bounds[3] > bounds[1]) + .then_some(bounds) +} + /// Owned before pipeline creation; a dummy set prevents attachment feedback in the mirror pass. pub(super) struct ReflectionResources { device: ash::Device, pub layout: vk::DescriptorSetLayout, pool: vk::DescriptorPool, sampler: vk::Sampler, + bump_sampler: vk::Sampler, pub sets: [vk::DescriptorSet; 2], dummy: Option, + bump: Option, pub color: Option, depth: Option, pub framebuffer: vk::Framebuffer, @@ -54,25 +82,31 @@ impl ReflectionResources { instance: &VulkanInstanceProbe, device: &VulkanLogicalDeviceProbe, command_pool: vk::CommandPool, + enabled: bool, ) -> Result { let mut owned = Self { device: device.device().clone(), layout: vk::DescriptorSetLayout::null(), pool: vk::DescriptorPool::null(), sampler: vk::Sampler::null(), + bump_sampler: vk::Sampler::null(), sets: [vk::DescriptorSet::null(); 2], dummy: None, + bump: None, color: None, depth: None, framebuffer: vk::Framebuffer::null(), }; - let binding = vk::DescriptorSetLayoutBinding::default() - .binding(0) - .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) - .descriptor_count(1) - .stage_flags(vk::ShaderStageFlags::FRAGMENT); - let layout_info = - vk::DescriptorSetLayoutCreateInfo::default().bindings(std::slice::from_ref(&binding)); + let bindings: Vec<_> = (0..2) + .map(|binding| { + vk::DescriptorSetLayoutBinding::default() + .binding(binding) + .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .descriptor_count(1) + .stage_flags(vk::ShaderStageFlags::FRAGMENT) + }) + .collect(); + let layout_info = vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings); // SAFETY: The device is live; owned rolls back every handle on later failure. owned.layout = unsafe { owned @@ -85,7 +119,7 @@ impl ReflectionResources { })?; let size = vk::DescriptorPoolSize::default() .ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) - .descriptor_count(2); + .descriptor_count(4); let pool_info = vk::DescriptorPoolCreateInfo::default() .max_sets(2) .pool_sizes(std::slice::from_ref(&size)); @@ -136,6 +170,42 @@ impl ReflectionResources { mip_levels: Vec::new(), }, )?); + if enabled { + let sampler = sampler + .address_mode_u(vk::SamplerAddressMode::REPEAT) + .address_mode_v(vk::SamplerAddressMode::REPEAT) + .address_mode_w(vk::SamplerAddressMode::REPEAT); + // SAFETY: The immutable bump map uses supported linear filtering and a single mip level. + owned.bump_sampler = + unsafe { owned.device.create_sampler(&sampler, None) }.map_err(|result| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateSampler(EMBM)", + result, + } + })?; + let rgba8 = fparkan_render::water::legacy_embm_bump_map() + .into_iter() + .flat_map(|duv| { + [ + (i16::from(duv[0]) + 128) as u8, + (i16::from(duv[1]) + 128) as u8, + 0, + 255, + ] + }) + .collect(); + owned.bump = Some(super::create_static_texture_image( + instance, + device, + command_pool, + &super::VulkanStaticTexture { + width: fparkan_render::water::EMBM_BUMP_SIZE, + height: fparkan_render::water::EMBM_BUMP_SIZE, + rgba8, + mip_levels: Vec::new(), + }, + )?); + } for set in owned.sets { owned.write_image(set, owned.dummy.as_ref().expect("dummy image exists").view); } @@ -255,13 +325,35 @@ impl ReflectionResources { .sampler(self.sampler) .image_view(view) .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL); - let write = vk::WriteDescriptorSet::default() - .dst_set(set) - .dst_binding(0) - .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) - .image_info(std::slice::from_ref(&image)); + let bump = self + .bump + .as_ref() + .or(self.dummy.as_ref()) + .expect("dummy image exists"); + let images = [ + image, + vk::DescriptorImageInfo::default() + .sampler(if self.bump.is_some() { + self.bump_sampler + } else { + self.sampler + }) + .image_view(bump.view) + .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL), + ]; + let writes: Vec<_> = images + .iter() + .enumerate() + .map(|(binding, image)| { + vk::WriteDescriptorSet::default() + .dst_set(set) + .dst_binding(binding as u32) + .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .image_info(std::slice::from_ref(image)) + }) + .collect(); // SAFETY: Sets are initialized before submission; the dummy remains valid for every pass. - unsafe { self.device.update_descriptor_sets(&[write], &[]) }; + unsafe { self.device.update_descriptor_sets(&writes, &[]) }; } } @@ -273,12 +365,14 @@ impl Drop for ReflectionResources { self.device.destroy_framebuffer(self.framebuffer, None); self.device.destroy_descriptor_pool(self.pool, None); self.device.destroy_sampler(self.sampler, None); + self.device.destroy_sampler(self.bump_sampler, None); self.device.destroy_descriptor_set_layout(self.layout, None); for image in self .color .iter() .chain(self.depth.iter().map(|depth| &depth.image)) .chain(self.dummy.iter()) + .chain(self.bump.iter()) { self.device.destroy_image_view(image.view, None); self.device.destroy_image(image.image, None); @@ -320,12 +414,19 @@ mod tests { alpha_test_reference: 1, }], }; + mesh.vertices[1].position = [10.0, 0.0, 150.0]; + mesh.vertices[2].position = [5.0, 20.0, 150.0]; + let mut unused = mesh.vertices[0]; + unused.position = [-10_000.0, -10_000.0, 150.0]; + mesh.vertices.push(unused); + assert_eq!(mesh_reflection_bounds(&mesh), Some([0.0, 0.0, 10.0, 20.0])); assert_eq!(mesh_reflection_planes(&mesh), vec![Some(150.0)]); mesh.vertices[2].position[2] = 151.0; assert_eq!(mesh_reflection_planes(&mesh), vec![None]); mesh.vertices[2].position[2] = 150.0; mesh.draw_ranges[0].terrain_reflection = false; assert_eq!(mesh_reflection_planes(&mesh), vec![None]); + assert_eq!(mesh_reflection_bounds(&mesh), None); for cull in [ fparkan_render::LegacyCullMode::Disabled, fparkan_render::LegacyCullMode::BackFace, diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index 5968a47..bd745ad 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -373,12 +373,17 @@ impl VulkanSmokeRenderer { plane_heights.sort_unstable(); plane_heights.dedup(); if !plane_heights.is_empty() { - eprintln!("render_coverage planar_reflection_embm_unsupported={} (flat reflection; native shifted camera and wave distortion pending)", plane_heights.len()); + 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_other_planes_unsupported={} (nearest active plane is reflected)", plane_heights.len().saturating_sub(1)); } if unsupported_nonplanar != 0 { eprintln!("render_coverage planar_reflection_nonplanar_unsupported={unsupported_nonplanar} (ordinary terrain fallback)"); } + let mut frame_uniforms = super::VulkanFrameUniforms::from_camera(create_info.camera); + if let Some(bounds) = super::reflection::mesh_reflection_bounds(&create_info.mesh) { + frame_uniforms.embm_bounds = bounds; + eprintln!("render_reflection embm_bounds={bounds:?}"); + } let mut renderer = Self { instance: Some(instance), validation, @@ -424,7 +429,7 @@ impl VulkanSmokeRenderer { material_specular_dirty: true, readback_enabled: false, camera: create_info.camera, - frame_uniforms: super::VulkanFrameUniforms::from_camera(create_info.camera), + frame_uniforms, point_lights: Vec::new(), point_lights_explicit: false, frame_sync: Vec::new(), @@ -1571,8 +1576,8 @@ impl VulkanSmokeRenderer { }); } let mut bytes = frame.to_ne_bytes(); - let length = bytes.len(); - bytes[length - 4..].copy_from_slice(&height.to_ne_bytes()); + let offset = super::smoke_types::REFLECTION_CLIP_HEIGHT_OFFSET; + bytes[offset..offset + 4].copy_from_slice(&height.to_ne_bytes()); Ok(bytes) }) .transpose()?; diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index bc8aedd..90fd7b4 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -192,7 +192,8 @@ impl Default for VulkanPointLight { /// every device. Four directional lights and one point light fit in a small /// dynamic UBO while the push-constant range remains available for material /// state. -pub const VULKAN_FRAME_UNIFORM_BYTES: usize = 368; +pub const VULKAN_FRAME_UNIFORM_BYTES: usize = 400; +pub(super) const REFLECTION_CLIP_HEIGHT_OFFSET: usize = 364; /// Number of CPU/GPU frame slots shared by the swapchain renderer. pub const VULKAN_FRAME_SLOT_COUNT: usize = 2; @@ -235,6 +236,10 @@ pub struct VulkanFrameUniforms { pub fog_end: f32, /// World-space camera position used for fog distance. pub camera_position: [f32; 3], + /// XY bounds of the original reflective faces: min X/Y, max X/Y. + pub embm_bounds: [f32; 4], + /// Native wave scroll fraction in the ten-second cycle. + pub embm_phase: f32, } impl VulkanFrameUniforms { @@ -258,6 +263,8 @@ impl VulkanFrameUniforms { fog_start: f32::MAX, fog_end: f32::MAX, camera_position: [0.0; 3], + embm_bounds: [0.0, 0.0, 1.0, 1.0], + embm_phase: 0.0, } } @@ -283,7 +290,14 @@ impl VulkanFrameUniforms { .chain(self.fog_color) .chain([self.fog_start, self.fog_end]) .chain(self.camera_position) + .chain(self.embm_bounds) + .chain([self.embm_phase]) .all(f32::is_finite) + && self.embm_bounds[2] > self.embm_bounds[0] + && self.embm_bounds[3] > self.embm_bounds[1] + && (self.embm_bounds[2] - self.embm_bounds[0]).is_finite() + && (self.embm_bounds[3] - self.embm_bounds[1]).is_finite() + && (0.0..1.0).contains(&self.embm_phase) && (self.fog_end <= self.fog_start || ((self.fog_start * self.fog_start).is_finite() && (self.fog_end * self.fog_end).is_finite())) @@ -364,6 +378,13 @@ impl VulkanFrameUniforms { self.camera_position[2], 0.0, ]); + values[92..96].copy_from_slice(&self.embm_bounds); + values[96..100].copy_from_slice(&[ + self.embm_phase, + fparkan_render::water::EMBM_BUMP_TILE, + fparkan_render::water::EMBM_COEFFICIENTS[0], + fparkan_render::water::EMBM_COEFFICIENTS[1], + ]); let mut bytes = [0_u8; VULKAN_FRAME_UNIFORM_BYTES]; for (index, value) in values.into_iter().enumerate() { let offset = index * std::mem::size_of::(); @@ -862,6 +883,8 @@ mod static_mesh_tests { fog_start: 13.0, fog_end: 14.0, camera_position: [15.0, 16.0, 17.0], + embm_bounds: [100.0, 200.0, 300.0, 600.0], + embm_phase: 0.25, }; let bytes = frame.to_ne_bytes(); assert_eq!(bytes.len(), VULKAN_FRAME_UNIFORM_BYTES); @@ -880,6 +903,28 @@ mod static_mesh_tests { assert_eq!(read(320), 10.0); assert_eq!(read(336), 13.0); assert_eq!(read(352), 15.0); + assert_eq!(read(368), 100.0); + assert_eq!(read(380), 600.0); + assert_eq!(read(384), 0.25); + assert_eq!(read(388), 100.0); + assert_eq!(read(392), 0.01); + } + + #[test] + fn embm_uniforms_reject_invalid_bounds_and_phase() { + for (bounds, phase) in [ + ([0.0; 4], 0.0), + ([-f32::MAX, 0.0, f32::MAX, 1.0], 0.0), + ([0.0, 0.0, 1.0, 1.0], f32::NAN), + ([0.0, 0.0, 1.0, 1.0], 1.0), + ] { + assert!(!VulkanFrameUniforms { + embm_bounds: bounds, + embm_phase: phase, + ..Default::default() + } + .is_finite()); + } } #[test] diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs index 4365cd2..af20dfa 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs @@ -249,14 +249,18 @@ pub(super) fn create_swapchain_resources( }; partial.frame_uniform_stride = frame_uniform_stride; partial.frame_uniform_buffer = Some(frame_uniform_buffer); - partial.reflection = - match super::reflection::ReflectionResources::new(instance, device, command_pool) { - Ok(reflection) => Some(reflection), - Err(error) => { - destroy_partial_swapchain_resources(device, command_pool, partial); - return Err(error); - } - }; + partial.reflection = match super::reflection::ReflectionResources::new( + instance, + device, + command_pool, + reflection_enabled, + ) { + Ok(reflection) => Some(reflection), + Err(error) => { + destroy_partial_swapchain_resources(device, command_pool, partial); + return Err(error); + } + }; let material_count = texture_slot_indices.len() / 4; let (material_specular_buffer, material_specular_stride) = match create_material_specular_buffer(instance, device, material_count) { diff --git a/apps/fparkan-game/src/camera_capture.rs b/apps/fparkan-game/src/camera_capture.rs index bc16807..dccb738 100644 --- a/apps/fparkan-game/src/camera_capture.rs +++ b/apps/fparkan-game/src/camera_capture.rs @@ -23,6 +23,7 @@ pub(super) struct LegacyCameraCapture { pub(super) struct SkyUvClock { pub(super) verified: bool, pub(super) elapsed_milliseconds: u32, + pub(super) raw_clock: Option, } #[derive(Clone, Debug, Deserialize, PartialEq)] diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 920468f..5494d65 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -4638,6 +4638,7 @@ fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] { } struct StaticPreviewScene { + fixed_water_clock_ms: Option, mesh: VulkanStaticMesh, dynamic_vertex_start: usize, terrain_lod: Option, @@ -8554,7 +8555,23 @@ fn static_preview_mesh_and_materials( }; let camera = view_camera.vulkan_camera(); let shadow_material_index = append_shadow_material(&mut materials)?; + let fixed_water_clock_ms = legacy_camera.as_ref().and_then(|capture| { + capture + .sky_uv_clock + .as_ref() + .and_then(|clock| clock.raw_clock) + }); + if legacy_camera.is_some() + && fixed_water_clock_ms.is_none() + && mesh + .draw_ranges + .iter() + .any(|range| range.terrain_reflection) + { + eprintln!("render_coverage captured_water_phase_unverified=1 (capture has no verified raw queue clock; using replay elapsed time)"); + } Ok(StaticPreviewScene { + fixed_water_clock_ms, mesh, dynamic_vertex_start, terrain_lod, @@ -9369,6 +9386,7 @@ fn run_static_vulkan_mode( } struct StaticVulkanApp { + fixed_water_clock_ms: Option, mesh: VulkanStaticMesh, dynamic_vertex_start: usize, terrain_lod: Option, @@ -9497,6 +9515,7 @@ impl StaticVulkanApp { dynamic_shadow_casters: preview.dynamic_shadow_casters, sun_occlusion_range_indices: preview.sun_occlusion_range_indices, terrain: preview.terrain, + fixed_water_clock_ms: preview.fixed_water_clock_ms, world_range_indices, world_range_index_counts, camera: preview.camera, @@ -10333,6 +10352,21 @@ impl StaticVulkanApp { &mut self.material_animations, renderer, )?; + let water_clock_ms = self + .fixed_water_clock_ms + .map_or(frame.clock.time_ms, |captured| { + if self.target_frames == 0 { + captured.wrapping_add(frame.clock.time_ms) + } else { + captured + } + }); + let mut uniforms = renderer.frame_uniforms(); + uniforms.camera_position = frame.camera.position; + uniforms.embm_phase = fparkan_render::water::legacy_embm_phase(water_clock_ms); + renderer + .set_frame_uniforms(uniforms) + .map_err(|error| format!("update water clock uniforms: {error}"))?; for update in &frame.effect_updates { renderer .set_draw_range_index_count(update.range_index, update.index_count) @@ -13664,6 +13698,7 @@ mod tests { }; let view_camera = free_camera.preview_frame(16.0 / 9.0); let preview = StaticPreviewScene { + fixed_water_clock_ms: None, terrain_lod: None, dynamic_vertex_start: 3, mesh: VulkanStaticMesh { @@ -15730,13 +15765,18 @@ mod tests { let mut value: serde_json::Value = serde_json::from_slice(include_bytes!("../testdata/legacy-camera-parented.json")) .unwrap(); - value["sky_uv_clock"] = - serde_json::json!({"verified": true, "elapsed_milliseconds": 4051271258_u32}); + value["sky_uv_clock"] = serde_json::json!({"verified": true, "elapsed_milliseconds": 4051271258_u32, "raw_clock": 272342176_u32}); let capture = parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).unwrap(); - assert_eq!( - capture.sky_uv_clock.unwrap().elapsed_milliseconds, - 4051271258 - ); + let clock = capture.sky_uv_clock.unwrap(); + assert_eq!(clock.elapsed_milliseconds, 4051271258); + assert_eq!(clock.raw_clock, Some(272342176)); + value["sky_uv_clock"] + .as_object_mut() + .unwrap() + .remove("raw_clock"); + let old_capture = + parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).unwrap(); + assert_eq!(old_capture.sky_uv_clock.unwrap().raw_clock, None); value["sky_uv_clock"]["verified"] = serde_json::json!(false); assert!(parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).is_err()); } diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs index 2ad4789..db0171f 100644 --- a/crates/fparkan-render/src/lib.rs +++ b/crates/fparkan-render/src/lib.rs @@ -1,6 +1,8 @@ #![forbid(unsafe_code)] //! Camera mathematics and graphics pipeline state. +pub mod water; + /// A 64-byte transform block returned by the original Terrain camera ABI. /// /// The original uses two selector-dependent transform pointers. Their exact diff --git a/crates/fparkan-render/src/water.rs b/crates/fparkan-render/src/water.rs new file mode 100644 index 0000000..0c7ad2a --- /dev/null +++ b/crates/fparkan-render/src/water.rs @@ -0,0 +1,108 @@ +//! The GOG Terrain EMBM wave map and its world-space animation clock. + +/// Side length of the native signed Du/Dv texture. +pub const EMBM_BUMP_SIZE: u32 = 128; +/// Native default wave tiling across the bounding rectangle of reflective faces. +pub const EMBM_BUMP_TILE: f32 = 100.0; +/// Native diagonal bump matrix, without luminance modulation. +pub const EMBM_COEFFICIENTS: [f32; 2] = [0.01, 0.01]; + +/// Generates the signed bytes written by Terrain.dll RVA 0x2BF50 with CW 0x007F. +#[must_use] +pub fn legacy_embm_bump_map() -> Vec<[i8; 2]> { + let mut texels = Vec::with_capacity((EMBM_BUMP_SIZE * EMBM_BUMP_SIZE) as usize); + for y in 0..EMBM_BUMP_SIZE { + for x in 0..EMBM_BUMP_SIZE { + // The native constant is slightly smaller than mathematical 4*pi. + let angle = ((y as f32 / EMBM_BUMP_SIZE as f32 - 0.5) + + (x as f32 / EMBM_BUMP_SIZE as f32 - 0.5)) + * f32::from_bits(0x4149_0fd0); + texels.push([ + (angle.cos() * 64.0).round_ties_even() as i8, + (angle.sin() * 64.0).round_ties_even() as i8, + ]); + } + } + texels +} + +/// Queue clock modulo the native ten-second wave scroll period. +#[must_use] +pub fn legacy_embm_phase(clock_ms: u32) -> f32 { + (clock_ms % 10_000) as f32 / 10_000.0 +} + +/// Native reflection coordinates: U reverses world Y and V reverses world X. +#[must_use] +pub fn legacy_embm_uv(world_xy: [f32; 2], bounds: [f32; 4]) -> Option<[f32; 2]> { + let span = [bounds[2] - bounds[0], bounds[3] - bounds[1]]; + if !world_xy + .into_iter() + .chain(bounds) + .chain(span) + .all(f32::is_finite) + || span.iter().any(|&value| value <= 0.0) + { + return None; + } + Some([ + 1.0 - (world_xy[1] - bounds[1]) / span[1], + 1.0 - (world_xy[0] - bounds[0]) / span[0], + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wave_bytes_match_native_generation_and_clock_wraps_without_float_time_loss() { + let texels = legacy_embm_bump_map(); + assert_eq!(texels.len(), 128 * 128); + assert_eq!( + &texels[..16], + &[ + [64, 0], + [64, 6], + [63, 12], + [61, 19], + [59, 24], + [56, 30], + [53, 36], + [49, 41], + [45, 45], + [41, 49], + [36, 53], + [30, 56], + [24, 59], + [19, 61], + [12, 63], + [6, 64], + ] + ); + assert_eq!(texels[16], [0, 64]); + assert_eq!(texels[32], [-64, 0]); + assert_eq!(texels[48], [0, -64]); + let native_hash = texels + .iter() + .flatten() + .fold(0xcbf29ce484222325u64, |hash, &byte| { + (hash ^ u64::from(byte as u8)).wrapping_mul(0x100000001b3) + }); + assert_eq!(native_hash, 0x0b92dc6a9e482625); + assert_eq!(legacy_embm_phase(0), 0.0); + assert_eq!(legacy_embm_phase(5_000), 0.5); + assert_eq!(legacy_embm_phase(10_000), 0.0); + assert_eq!(legacy_embm_phase(u32::MAX), 0.7295); + } + + #[test] + fn mapping_uses_the_reflective_rectangle_and_keeps_axis_order() { + let bounds = [100.0, 200.0, 300.0, 600.0]; + assert_eq!(legacy_embm_uv([100.0, 200.0], bounds), Some([1.0, 1.0])); + assert_eq!(legacy_embm_uv([300.0, 600.0], bounds), Some([0.0, 0.0])); + assert_eq!(legacy_embm_uv([150.0, 500.0], bounds), Some([0.25, 0.75])); + assert_eq!(legacy_embm_uv([0.0; 2], [1.0; 4]), None); + assert_eq!(legacy_embm_uv([f32::NAN, 0.0], bounds), None); + } +} diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index a48bec5..1af4ad5 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -73,7 +73,8 @@ MSH-запрос со свободной preview-камерой; это не п среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state log на минимальных сценах с одним типом материала. -Горизонтальные грани Land.msh с флагом `0x20000` используют плоское Vulkan-отражение; shifted-камера, волны и native EMBM combiner остаются заглушкой `planar_reflection_embm_unsupported`. +Горизонтальные грани Land.msh с флагом `0x20000` используют Vulkan-отражение и исходную карту волн Du/Dv; перенос её смещений в обычное зеркало приближённый: `planar_reflection_shifted_camera_unsupported`. +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 e374ef0..a7a1396 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1161,9 +1161,32 @@ lightmap state передаются в `VulkanStaticMaterial`, а shader пос записи отражения привязан отдельный нейтральный descriptor, чтобы проход не читал собственное вложение. -Такое зеркало ещё не повторяет shifted-камеру и EMBM оригинала: искажение -отражения волнами пока отсутствует и учитывается в -`planar_reflection_embm_unsupported`. Один target обслуживает ближайшую +Волны не требуют готовой текстуры из ресурсов: Terrain.dll RVA `0x2BF50` +создаёт карту 128×128 со знаковыми парами Du/Dv. Для нормированных координат +текселя `x` и `y` угол равен `(x + y − 1) × 12,566360473632812`, а компоненты — +округлённые `64 cos(угол)` и `64 sin(угол)`. Все 32 768 байт Rust-генератора +сверены с исполнением этой функции DLL при игровом x87 CW `0x007F`. + +Рябь привязана к водоёму, поэтому не плывёт вслед за экраном. Конструктор +ландшафта собирает прямоугольник всех отражающих граней; U меняется против +мировой Y, V — против X. Эти координаты повторяются сто раз, к обеим прибавляется +`(queue_clock % 10000) / 10000`. Это самостоятельный clock: время суток и +относительный clock неба не задают фазу волн. Захват хранит исходный +`sky_uv_clock.raw_clock`; ограниченный replay фиксирует его, интерактивный +продолжает с этого значения. Старые захваты без raw clock явно считаются в +`captured_water_phase_unverified`. + +Карта Du/Dv смещает координаты следующей текстуры с диагональными коэффициентами +`0,01`, без изменения яркости. Это правило +[Direct3D BUMPENVMAP](https://learn.microsoft.com/en-us/windows/win32/direct3d9/bump-mapping-formulas). +В Vulkan карта хранится в двух каналах несжатой текстуры и возвращается к +знаковым значениям перед вычислением смещения. + +Обычное зеркало ещё не повторяет shifted-камеру оригинала: смещения Du/Dv +сейчас переводятся из координат водоёма в текущую зеркальную проекцию. +Это приближение учитывается в `planar_reflection_shifted_camera_unsupported`; +совпадение генератора волн не доказывает совпадения итогового отражения. +Один target обслуживает ближайшую активную высоту воды; другие высоты и негоризонтальные поверхности явно считаются как неподдержанные. Это промежуточный водный проход, а не полная приёмка воды или лавы.