diff options
| author | Valentin Popov <valentin@popov.link> | 2026-11-01 05:12:44 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-11-01 05:12:44 +0300 |
| commit | 05572c7bf446572264d2cf97c531be6c3bfaaf30 (patch) | |
| tree | 9aa68d9c49fe525f4e632b960653cc0082936e95 | |
| parent | dfa2db65cc7b052d91c30ce179df8bc07af0cb1b (diff) | |
| download | fparkan-05572c7bf446572264d2cf97c531be6c3bfaaf30.tar.xz fparkan-05572c7bf446572264d2cf97c531be6c3bfaaf30.zip | |
feat(render): animate water with native EMBM waves
| -rw-r--r-- | README.md | 3 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/shaders/triangle.frag | 19 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/shaders/triangle.frag.spv | bin | 12292 -> 14512 bytes | |||
| -rw-r--r-- | adapters/fparkan-render-vulkan/shaders/triangle.vert | 6 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/shaders/triangle.vert.spv | bin | 20300 -> 20384 bytes | |||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/reflection.rs | 129 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/smoke.rs | 13 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs | 47 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs | 20 | ||||
| -rw-r--r-- | apps/fparkan-game/src/camera_capture.rs | 1 | ||||
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 52 | ||||
| -rw-r--r-- | crates/fparkan-render/src/lib.rs | 2 | ||||
| -rw-r--r-- | crates/fparkan-render/src/water.rs | 108 | ||||
| -rw-r--r-- | docs/appendices/knowledge-boundaries.md | 3 | ||||
| -rw-r--r-- | docs/tomes/05-render.md | 29 |
15 files changed, 390 insertions, 42 deletions
@@ -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 Binary files differindex 84c8a0c..de3ace6 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv +++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert b/adapters/fparkan-render-vulkan/shaders/triangle.vert index 889a379..8e04536 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.vert +++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert @@ -19,7 +19,7 @@ layout(location = 5) out vec3 out_base_diffuse; layout(location = 6) out vec3 out_base_specular; layout(location = 7) out vec3 out_overlay_diffuse; layout(location = 8) out vec3 out_overlay_specular; -layout(location = 9) out float out_world_z; +layout(location = 9) out vec3 out_world_position; layout(std140, set = 0, binding = 4) uniform FrameConstants { mat4 clip_from_world; @@ -33,6 +33,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(std430, set = 0, binding = 5) readonly buffer MaterialSpecular { @@ -242,7 +244,7 @@ LightingTerms native_material_lighting( } void main() { - out_world_z = in_position.z; + out_world_position = in_position; out_color = in_color; out_uv = in_uv; out_detail_uv = in_detail_uv; diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv Binary files differindex ff28c23..439e2a0 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv +++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv 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<Opti .collect() } +pub(super) fn mesh_reflection_bounds(mesh: &super::VulkanStaticMesh) -> 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<VulkanAllocatedImage>, + bump: Option<VulkanAllocatedImage>, pub color: Option<VulkanAllocatedImage>, depth: Option<VulkanDepthAttachment>, pub framebuffer: vk::Framebuffer, @@ -54,25 +82,31 @@ impl ReflectionResources { instance: &VulkanInstanceProbe, device: &VulkanLogicalDeviceProbe, command_pool: vk::CommandPool, + enabled: bool, ) -> Result<Self, VulkanSmokeRendererError> { 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::<f32>(); @@ -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<u32>, } #[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<u32>, mesh: VulkanStaticMesh, dynamic_vertex_start: usize, terrain_lod: Option<terrain_lod::TerrainLodDraws>, @@ -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<u32>, mesh: VulkanStaticMesh, dynamic_vertex_start: usize, terrain_lod: Option<terrain_lod::TerrainLodDraws>, @@ -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 обслуживает ближайшую активную высоту воды; другие высоты и негоризонтальные поверхности явно считаются как неподдержанные. Это промежуточный водный проход, а не полная приёмка воды или лавы. |
