From dfa2db65cc7b052d91c30ce179df8bc07af0cb1b Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sun, 1 Nov 2026 05:31:43 +0400 Subject: [PATCH] feat(render): add planar water reflections --- README.md | 2 + .../shaders/triangle.frag | 16 +- .../shaders/triangle.frag.spv | Bin 10632 -> 12292 bytes .../shaders/triangle.vert | 2 + .../shaders/triangle.vert.spv | Bin 20196 -> 20300 bytes .../fparkan-render-vulkan/src/asset_mesh.rs | 27 + adapters/fparkan-render-vulkan/src/ffi.rs | 1 + .../src/ffi/reflection.rs | 348 ++++++++++++ .../src/ffi/resources.rs | 8 +- .../fparkan-render-vulkan/src/ffi/smoke.rs | 511 ++++++++++++------ .../src/ffi/smoke_types.rs | 11 + .../src/ffi/swapchain_resources.rs | 69 ++- apps/fparkan-game/src/main.rs | 24 +- apps/fparkan-game/src/terrain_lod.rs | 1 + crates/fparkan-render/src/lib.rs | 40 ++ docs/appendices/knowledge-boundaries.md | 6 +- docs/tomes/05-render.md | 30 + 17 files changed, 905 insertions(+), 191 deletions(-) create mode 100644 adapters/fparkan-render-vulkan/src/ffi/reflection.rs diff --git a/README.md b/README.md index dea37bc..eaf7636 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ позе. Рельеф переключает исходные LOD0/LOD1 по ячейкам относительно текущей камеры; под основаниями строений сохраняется подробная сетка. +Горизонтальная вода использует плоское отражение ближайшей активной высоты; +волны EMBM и shifted-камера оригинала пока не воспроизводятся. ## Запуск diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag b/adapters/fparkan-render-vulkan/shaders/triangle.frag index 6a6e752..ec0acdb 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.frag +++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag @@ -9,12 +9,14 @@ 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 = 0) out vec4 out_color; layout(set = 0, binding = 0) uniform sampler2D base_color; 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(std140, set = 0, binding = 4) uniform FrameConstants { mat4 clip_from_world; @@ -66,6 +68,19 @@ layout(push_constant) uniform MaterialConstants { } material; void main() { + int mode = int(round(material.material_mode)); + if ((mode & 32) != 0 && (mode & 4) == 0 && in_world_z < frame.camera_position.w) { + discard; + } + if ((mode & 64) != 0) { + vec2 reflection_uv = gl_FragCoord.xy / vec2(textureSize(reflection_color, 0)); + 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) { + discard; + } + return; + } vec2 base_uv = vec2(material.base_page_x, material.base_page_y) + in_uv * vec2(material.base_page_w, material.base_page_h); vec2 detail_uv = vec2(material.detail_page_x, material.detail_page_y) @@ -78,7 +93,6 @@ void main() { vec4 detail = texture(base_detail, detail_uv); vec4 overlay = texture(overlay_color, overlay_uv); vec4 overlay_detail_sample = texture(overlay_detail, overlay_detail_uv); - int mode = int(round(material.material_mode)); int combiner_mode = mode & 3; // Native sky mode 4 combines nebula and stars as diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv index 7b7c3da115cd10e93af5e22f0f5962ab72c73075..84c8a0c1f2c312b7d77f48b3c7c9eae1c9017b8f 100644 GIT binary patch literal 12292 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 KUd}syHu66VBYJND diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert b/adapters/fparkan-render-vulkan/shaders/triangle.vert index 078a0d1..889a379 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.vert +++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert @@ -19,6 +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(std140, set = 0, binding = 4) uniform FrameConstants { mat4 clip_from_world; @@ -241,6 +242,7 @@ LightingTerms native_material_lighting( } void main() { + out_world_z = in_position.z; 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 index 4fc9a1b5a2843a9d466379f569fb8f1d0c7fd504..ff28c23a8e3752ecd89a47c0f718b77a5b06906a 100644 GIT binary patch delta 5539 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 fparkan_render::LegacyPipelineState { + use fparkan_render::LegacyCullMode; + state.cull = match state.cull { + LegacyCullMode::Disabled => LegacyCullMode::Disabled, + LegacyCullMode::BackFace => LegacyCullMode::FrontFace, + LegacyCullMode::FrontFace => LegacyCullMode::BackFace, + }; + state +} + +pub(super) fn mesh_reflection_planes(mesh: &super::VulkanStaticMesh) -> Vec> { + mesh.draw_ranges + .iter() + .map(|range| { + if !range.terrain_reflection || range.index_count == 0 { + return None; + } + let indices = &mesh.indices[range.first_index as usize + ..range.first_index as usize + range.index_count as usize]; + let height = mesh.vertices[indices[0] as usize].position[2]; + indices + .iter() + .all(|&index| (mesh.vertices[index as usize].position[2] - height).abs() <= 1.0e-4) + .then_some(height) + }) + .collect() +} + +/// 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, + pub sets: [vk::DescriptorSet; 2], + dummy: Option, + pub color: Option, + depth: Option, + pub framebuffer: vk::Framebuffer, +} + +impl ReflectionResources { + pub fn new( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + command_pool: vk::CommandPool, + ) -> Result { + let mut owned = Self { + device: device.device().clone(), + layout: vk::DescriptorSetLayout::null(), + pool: vk::DescriptorPool::null(), + sampler: vk::Sampler::null(), + sets: [vk::DescriptorSet::null(); 2], + dummy: 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)); + // SAFETY: The device is live; owned rolls back every handle on later failure. + owned.layout = unsafe { + owned + .device + .create_descriptor_set_layout(&layout_info, None) + } + .map_err(|result| VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateDescriptorSetLayout(reflection)", + result, + })?; + let size = vk::DescriptorPoolSize::default() + .ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .descriptor_count(2); + let pool_info = vk::DescriptorPoolCreateInfo::default() + .max_sets(2) + .pool_sizes(std::slice::from_ref(&size)); + // SAFETY: The pool owns only the two reflection descriptor sets below. + owned.pool = + unsafe { owned.device.create_descriptor_pool(&pool_info, None) }.map_err(|result| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateDescriptorPool(reflection)", + result, + } + })?; + let layouts = [owned.layout; 2]; + let allocation = vk::DescriptorSetAllocateInfo::default() + .descriptor_pool(owned.pool) + .set_layouts(&layouts); + // SAFETY: The pool has capacity for these two live layouts. + let sets = + unsafe { owned.device.allocate_descriptor_sets(&allocation) }.map_err(|result| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkAllocateDescriptorSets(reflection)", + result, + } + })?; + owned.sets.copy_from_slice(&sets); + let sampler = vk::SamplerCreateInfo::default() + .mag_filter(vk::Filter::LINEAR) + .min_filter(vk::Filter::LINEAR) + .address_mode_u(vk::SamplerAddressMode::CLAMP_TO_EDGE) + .address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE) + .address_mode_w(vk::SamplerAddressMode::CLAMP_TO_EDGE); + // SAFETY: No optional device feature is used by this sampler. + owned.sampler = + unsafe { owned.device.create_sampler(&sampler, None) }.map_err(|result| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateSampler(reflection)", + result, + } + })?; + // Material images can be replaced by animation, so the dummy has its own lifetime. + owned.dummy = Some(super::create_static_texture_image( + instance, + device, + command_pool, + &super::VulkanStaticTexture { + width: 1, + height: 1, + rgba8: vec![255; 4], + mip_levels: Vec::new(), + }, + )?); + for set in owned.sets { + owned.write_image(set, owned.dummy.as_ref().expect("dummy image exists").view); + } + Ok(owned) + } + + pub fn create_target( + &mut self, + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + extent: (u32, u32), + format: i32, + depth_request: fparkan_platform::DepthStencilSupport, + render_pass: vk::RenderPass, + ) -> Result<(), VulkanSmokeRendererError> { + let info = vk::ImageCreateInfo::default() + .image_type(vk::ImageType::TYPE_2D) + .format(vk::Format::from_raw(format)) + .extent(vk::Extent3D { + width: extent.0, + height: extent.1, + depth: 1, + }) + .mip_levels(1) + .array_layers(1) + .samples(vk::SampleCountFlags::TYPE_1) + .tiling(vk::ImageTiling::OPTIMAL) + .usage(vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::SAMPLED) + .sharing_mode(vk::SharingMode::EXCLUSIVE) + .initial_layout(vk::ImageLayout::UNDEFINED); + // SAFETY: This target uses the validated non-zero swapchain extent and format. + let image = unsafe { self.device.create_image(&info, None) }.map_err(|result| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateImage(reflection)", + result, + } + })?; + self.color = Some(VulkanAllocatedImage { + image, + memory: vk::DeviceMemory::null(), + view: vk::ImageView::null(), + width: extent.0, + height: extent.1, + mip_levels: 1, + }); + // SAFETY: The image belongs to this device and has not been bound yet. + let requirements = unsafe { self.device.get_image_memory_requirements(image) }; + let memory_type_index = super::resources::find_memory_type( + instance, + device.physical_device(), + requirements.memory_type_bits, + vk::MemoryPropertyFlags::DEVICE_LOCAL, + ) + .ok_or(VulkanSmokeRendererError::MissingMemoryType { + context: "reflection target", + })?; + let allocation = vk::MemoryAllocateInfo::default() + .allocation_size(requirements.size) + .memory_type_index(memory_type_index); + // SAFETY: The allocation follows this image's memory requirements. + let memory = + unsafe { self.device.allocate_memory(&allocation, None) }.map_err(|result| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkAllocateMemory(reflection)", + result, + } + })?; + self.color.as_mut().expect("color image exists").memory = memory; + // SAFETY: The allocation is compatible with the image and offset zero. + unsafe { self.device.bind_image_memory(image, memory, 0) }.map_err(|result| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkBindImageMemory(reflection)", + result, + } + })?; + let view_info = vk::ImageViewCreateInfo::default() + .image(image) + .view_type(vk::ImageViewType::TYPE_2D) + .format(vk::Format::from_raw(format)) + .subresource_range(super::color_subresource_range(1)); + // SAFETY: The image is bound and the view covers its single color subresource. + let view = + unsafe { self.device.create_image_view(&view_info, None) }.map_err(|result| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateImageView(reflection)", + result, + } + })?; + self.color.as_mut().expect("color image exists").view = view; + self.depth = Some(super::create_depth_attachment( + instance, + device, + extent, + depth_request, + )?); + let attachments = [view, self.depth.as_ref().expect("depth exists").image.view]; + let info = vk::FramebufferCreateInfo::default() + .render_pass(render_pass) + .attachments(&attachments) + .width(extent.0) + .height(extent.1) + .layers(1); + // SAFETY: The target formats and extent match the shared render pass. + self.framebuffer = + unsafe { self.device.create_framebuffer(&info, None) }.map_err(|result| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateFramebuffer(reflection)", + result, + } + })?; + self.write_image(self.sets[1], view); + Ok(()) + } + + fn write_image(&self, set: vk::DescriptorSet, view: vk::ImageView) { + let image = vk::DescriptorImageInfo::default() + .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)); + // SAFETY: Sets are initialized before submission; the dummy remains valid for every pass. + unsafe { self.device.update_descriptor_sets(&[write], &[]) }; + } +} + +impl Drop for ReflectionResources { + fn drop(&mut self) { + // SAFETY: The parent waits for submissions and drops this owner before the render pass/device. + // Null handles also cover partial construction, including failures before memory binding. + unsafe { + 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_descriptor_set_layout(self.layout, None); + for image in self + .color + .iter() + .chain(self.depth.iter().map(|depth| &depth.image)) + .chain(self.dummy.iter()) + { + self.device.destroy_image_view(image.view, None); + self.device.destroy_image(image.image, None); + self.device.free_memory(image.memory, None); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex}; + + #[test] + fn reflection_requires_a_flat_flagged_surface_and_reverses_only_culling() { + let mut mesh = VulkanStaticMesh { + vertices: vec![ + VulkanStaticVertex { + position: [0.0, 0.0, 150.0], + color: [1.0; 3], + normal: [0.0, 0.0, 1.0], + uv: [0.0; 2], + detail_uv: [0.0; 2], + overlay_alpha: 0.0, + }; + 3 + ], + indices: vec![0, 1, 2], + draw_ranges: vec![VulkanStaticDrawRange { + first_index: 0, + index_count: 3, + material_index: 0, + lightmap_index: 255, + batch_flags: 0, + terrain_base_only: false, + terrain_reflection: true, + pipeline_state: fparkan_render::LegacyPipelineState::default(), + alpha_test_reference: 1, + }], + }; + 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]); + for cull in [ + fparkan_render::LegacyCullMode::Disabled, + fparkan_render::LegacyCullMode::BackFace, + fparkan_render::LegacyCullMode::FrontFace, + ] { + let mut state = mesh.draw_ranges[0].pipeline_state; + state.cull = cull; + state.alpha_test = true; + state.depth_bias = fparkan_render::LegacyDepthBias::ProjectedDecal; + assert_eq!( + reflected_pipeline_state(reflected_pipeline_state(state)), + state + ); + let reflected = reflected_pipeline_state(state); + assert_eq!(reflected.depth, state.depth); + assert_eq!(reflected.blend, state.blend); + assert_eq!(reflected.alpha_test, state.alpha_test); + } + } +} diff --git a/adapters/fparkan-render-vulkan/src/ffi/resources.rs b/adapters/fparkan-render-vulkan/src/ffi/resources.rs index fe71d12..498a472 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/resources.rs @@ -528,7 +528,7 @@ pub(super) fn create_static_texture_image( }) } -/// Allocates two aligned camera slots used by the dynamic frame UBO. +/// Allocates a main and reflected view slot for each in-flight frame. pub(super) fn create_frame_uniform_buffer( instance: &VulkanInstanceProbe, device: &VulkanLogicalDeviceProbe, @@ -545,7 +545,7 @@ pub(super) fn create_frame_uniform_buffer( )? / alignment * alignment; let size = stride - .checked_mul(super::VULKAN_FRAME_SLOT_COUNT as u64) + .checked_mul(2 * super::VULKAN_FRAME_SLOT_COUNT as u64) .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { context: "frame uniform buffer size overflow", })?; @@ -888,7 +888,7 @@ pub(super) fn write_frame_uniform( .ok_or(VulkanSmokeRendererError::InvariantViolation { context: "frame uniform slot offset", })?; - // SAFETY: The offset is one of the two allocated aligned slots and the + // SAFETY: The caller supplies an allocated, fence-safe main or reflection slot; the // mapped range is exactly one complete frame block. let mapped = unsafe { device.device().map_memory( @@ -1385,7 +1385,7 @@ fn create_host_visible_buffer( Ok(VulkanAllocatedBuffer { buffer, memory }) } -fn find_memory_type( +pub(super) fn find_memory_type( instance: &VulkanInstanceProbe, physical_device: vk::PhysicalDevice, type_bits: u32, diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index 1b50914..5968a47 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -357,6 +357,28 @@ impl VulkanSmokeRenderer { .map(|material| material.material_index) .collect() }; + let reflection_planes = super::reflection::mesh_reflection_planes(&create_info.mesh); + let unsupported_nonplanar = create_info + .mesh + .draw_ranges + .iter() + .zip(&reflection_planes) + .filter(|(range, plane)| range.terrain_reflection && plane.is_none()) + .count(); + let mut plane_heights = reflection_planes + .iter() + .flatten() + .map(|z| z.to_bits()) + .collect::>(); + 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_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 renderer = Self { instance: Some(instance), validation, @@ -379,6 +401,7 @@ impl VulkanSmokeRenderer { .map(|range| range.index_count) .collect(), draw_ranges: create_info.mesh.draw_ranges.clone(), + reflection_planes, draw_texture_indices, draw_order: (0..create_info.mesh.draw_ranges.len()).collect(), draw_range_sort_keys: vec![None; create_info.mesh.draw_ranges.len()], @@ -521,6 +544,22 @@ impl VulkanSmokeRenderer { self.frame_uniforms } + fn active_reflection_plane(&self) -> Option { + // shortcut: one target serves the nearest active water height; add per-plane targets + // when accepting maps whose visible water surfaces have different heights. + self.draw_ranges + .iter() + .zip(&self.reflection_planes) + .filter(|(range, _)| range.index_count != 0) + .filter_map(|(_, plane)| *plane) + .filter(|&z| z < self.frame_uniforms.camera_position[2]) + .min_by(|a, b| { + (self.frame_uniforms.camera_position[2] - a) + .abs() + .total_cmp(&(self.frame_uniforms.camera_position[2] - b).abs()) + }) + } + /// Replaces the camera for subsequent frames without recreating GPU resources. /// /// # Errors @@ -1516,6 +1555,27 @@ impl VulkanSmokeRenderer { } self.images_in_flight[image_index_usize] = in_flight_fence; let frame_bytes = self.frame_uniforms.to_ne_bytes(); + let reflection_bytes = self + .active_reflection_plane() + .map(|height| { + let mut frame = self.frame_uniforms; + frame.clip_from_world = + fparkan_render::reflect_clip_from_world_about_z(frame.clip_from_world, height) + .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 { + context: "non-finite reflected camera position", + }); + } + let mut bytes = frame.to_ne_bytes(); + let length = bytes.len(); + bytes[length - 4..].copy_from_slice(&height.to_ne_bytes()); + Ok(bytes) + }) + .transpose()?; { let explicit_point_lights = self.point_lights_explicit; let legacy_point_light = self.frame_uniforms.point_light; @@ -1541,9 +1601,18 @@ impl VulkanSmokeRenderer { device, &resources.frame_uniform_buffer, resources.frame_uniform_stride, - self.current_frame, + self.current_frame * 2, &frame_bytes, )?; + if let Some(bytes) = &reflection_bytes { + write_frame_uniform( + device, + &resources.frame_uniform_buffer, + resources.frame_uniform_stride, + self.current_frame * 2 + 1, + bytes, + )?; + } write_point_light_buffer( device, &resources.point_light_buffer, @@ -1686,6 +1755,9 @@ impl VulkanSmokeRenderer { self.textures_ref()?, &self.texture_slot_indices, &self.draw_range_base_pipeline_states, + self.draw_ranges + .iter() + .any(|range| range.terrain_reflection), self.depth_request, reuse_command_pool, )?, @@ -1741,6 +1813,7 @@ impl VulkanSmokeRenderer { self.rebuild_draw_order(); self.draw_order_dirty = false; } + let reflection_plane = self.active_reflection_plane(); let device = self.device_ref()?; let swapchain = self.swapchain_ref()?; let resources = self.resources_ref()?; @@ -1787,185 +1860,269 @@ impl VulkanSmokeRenderer { height: swapchain.report.plan.extent.1, }, }; - let render_pass_info = vk::RenderPassBeginInfo::default() - .render_pass(resources.render_pass) - .framebuffer(resources.framebuffers[image_index]) - .render_area(render_area) - .clear_values(&clear_values); - // SAFETY: All commands target live frame resources owned by this renderer. - unsafe { - device.device().cmd_begin_render_pass( - command_buffer, - &render_pass_info, - vk::SubpassContents::INLINE, - ); - let vertex_buffers = [self.vertex_buffer_ref()?.buffer]; - let offsets = [0_u64]; - device - .device() - .cmd_bind_vertex_buffers(command_buffer, 0, &vertex_buffers, &offsets); - device.device().cmd_bind_index_buffer( - command_buffer, - self.index_buffer_ref()?.buffer, - 0, - vk::IndexType::UINT32, - ); - let mut viewport_depth = None; - for &range_index in &self.draw_order { - let range = self.draw_ranges.get(range_index).ok_or( - VulkanSmokeRendererError::InvariantViolation { - context: "stable draw order range handle", - }, - )?; - let texture_index = *self.draw_texture_indices.get(range_index).ok_or( - VulkanSmokeRendererError::InvariantViolation { - context: "stable draw texture range handle", - }, - )?; - let pipeline = resources.pipelines.get(&range.pipeline_key()).ok_or( - VulkanSmokeRendererError::InvariantViolation { - context: "static draw pipeline variant", - }, - )?; - device.device().cmd_bind_pipeline( + for reflected in [true, false] { + if reflected && reflection_plane.is_none() { + continue; + } + let framebuffer = if reflected { + resources.reflection.framebuffer + } else { + resources.framebuffers[image_index] + }; + let render_pass_info = vk::RenderPassBeginInfo::default() + .render_pass(resources.render_pass) + .framebuffer(framebuffer) + .render_area(render_area) + .clear_values(&clear_values); + // SAFETY: All commands target live frame resources owned by this renderer. + unsafe { + device.device().cmd_begin_render_pass( command_buffer, - vk::PipelineBindPoint::GRAPHICS, - *pipeline, + &render_pass_info, + vk::SubpassContents::INLINE, + ); + let vertex_buffers = [self.vertex_buffer_ref()?.buffer]; + let offsets = [0_u64]; + device.device().cmd_bind_vertex_buffers( + command_buffer, + 0, + &vertex_buffers, + &offsets, + ); + device.device().cmd_bind_index_buffer( + command_buffer, + self.index_buffer_ref()?.buffer, + 0, + vk::IndexType::UINT32, ); - let frame_offset = u32::try_from( - resources - .frame_uniform_stride - .saturating_mul(u64::try_from(self.current_frame).unwrap_or(u64::MAX)), - ) - .unwrap_or(u32::MAX); - let material_offset = u32::try_from( - resources - .material_specular_stride - .saturating_mul(u64::try_from(texture_index).unwrap_or(u64::MAX)), - ) - .unwrap_or(u32::MAX); - let point_light_offset = u32::try_from( - resources - .point_light_stride - .saturating_mul(u64::try_from(self.current_frame).unwrap_or(u64::MAX)), - ) - .unwrap_or(u32::MAX); - let descriptor_set = resources.descriptor_sets.get(texture_index).ok_or( - VulkanSmokeRendererError::InvariantViolation { - context: "static material descriptor set", - }, - )?; device.device().cmd_bind_descriptor_sets( command_buffer, vk::PipelineBindPoint::GRAPHICS, resources.pipeline_layout, - 0, - std::slice::from_ref(descriptor_set), - &[frame_offset, material_offset, point_light_offset], - ); - let alpha_cutoff = range.alpha_test_cutoff().to_ne_bytes(); - device.device().cmd_push_constants( - command_buffer, - resources.pipeline_layout, - vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT, - 0, - &alpha_cutoff, - ); - let material_alpha = self.material_alphas.get(texture_index).ok_or( - VulkanSmokeRendererError::InvariantViolation { - context: "static material alpha", - }, - )?; - let diffuse_alpha_override = self - .draw_range_diffuse_alpha_overrides - .get(range_index) - .ok_or(VulkanSmokeRendererError::InvariantViolation { - context: "draw-range diffuse-alpha override", - })?; - let material_alphas = - draw_material_alphas(*material_alpha, *diffuse_alpha_override) - .map(f32::to_ne_bytes) - .concat(); - device.device().cmd_push_constants( - command_buffer, - resources.pipeline_layout, - vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT, - 4, - &material_alphas, - ); - let material_lighting = self.material_lighting.get(texture_index).ok_or( - VulkanSmokeRendererError::InvariantViolation { - context: "static material lighting", - }, - )?; - let material_lighting = material_lighting - .iter() - .flatten() - .flat_map(|value| value.to_ne_bytes()) - .collect::>(); - device.device().cmd_push_constants( - command_buffer, - resources.pipeline_layout, - vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT, - 12, - &material_lighting, - ); - let material_uv_transforms = self.material_uv_transforms.get(texture_index).ok_or( - VulkanSmokeRendererError::InvariantViolation { - context: "static material UV transforms", - }, - )?; - let material_uv_transforms = material_uv_transforms - .iter() - .flatten() - .flat_map(|value| value.to_ne_bytes()) - .collect::>(); - device.device().cmd_push_constants( - command_buffer, - resources.pipeline_layout, - vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT, - 60, - &material_uv_transforms, - ); - let material_mode = self.material_unlit.get(texture_index).ok_or( - VulkanSmokeRendererError::InvariantViolation { - context: "static material unlit flag", - }, - )?; - let material_mode = if range.terrain_base_only { - ((*material_mode).round() as i32 | 16) as f32 - } else { - *material_mode - }; - let viewport = super::swapchain_resources::draw_viewport( - swapchain.report.plan.extent, - material_mode, - ); - let next_viewport_depth = [viewport.min_depth, viewport.max_depth]; - if viewport_depth != Some(next_viewport_depth) { - device - .device() - .cmd_set_viewport(command_buffer, 0, &[viewport]); - viewport_depth = Some(next_viewport_depth); - } - let material_mode = [material_mode.to_ne_bytes()]; - device.device().cmd_push_constants( - command_buffer, - resources.pipeline_layout, - vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT, - 124, - &material_mode.concat(), - ); - device.device().cmd_draw_indexed( - command_buffer, - range.index_count, 1, - range.first_index, - 0, - 0, + &[resources.reflection.sets + [usize::from(!reflected && reflection_plane.is_some())]], + &[], ); + let mut viewport_depth = None; + for &range_index in &self.draw_order { + let range = self.draw_ranges.get(range_index).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "stable draw order range handle", + }, + )?; + let texture_index = *self.draw_texture_indices.get(range_index).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "stable draw texture range handle", + }, + )?; + let mode = self.material_unlit[texture_index].round() as i32; + if reflected && (range.terrain_reflection || mode & 8 != 0) { + continue; + } + let state = if reflected { + super::reflection::reflected_pipeline_state(range.pipeline_state) + } else { + range.pipeline_state + }; + let pipeline = resources.pipelines.get(&state.into()).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "static draw pipeline variant", + }, + )?; + device.device().cmd_bind_pipeline( + command_buffer, + vk::PipelineBindPoint::GRAPHICS, + *pipeline, + ); + let frame_offset = u32::try_from( + resources.frame_uniform_stride.saturating_mul( + u64::try_from(self.current_frame * 2 + usize::from(reflected)) + .unwrap_or(u64::MAX), + ), + ) + .unwrap_or(u32::MAX); + let material_offset = u32::try_from( + resources + .material_specular_stride + .saturating_mul(u64::try_from(texture_index).unwrap_or(u64::MAX)), + ) + .unwrap_or(u32::MAX); + let point_light_offset = u32::try_from( + resources + .point_light_stride + .saturating_mul(u64::try_from(self.current_frame).unwrap_or(u64::MAX)), + ) + .unwrap_or(u32::MAX); + let descriptor_set = resources.descriptor_sets.get(texture_index).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "static material descriptor set", + }, + )?; + device.device().cmd_bind_descriptor_sets( + command_buffer, + vk::PipelineBindPoint::GRAPHICS, + resources.pipeline_layout, + 0, + std::slice::from_ref(descriptor_set), + &[frame_offset, material_offset, point_light_offset], + ); + let alpha_cutoff = range.alpha_test_cutoff().to_ne_bytes(); + device.device().cmd_push_constants( + command_buffer, + resources.pipeline_layout, + vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT, + 0, + &alpha_cutoff, + ); + let material_alpha = self.material_alphas.get(texture_index).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "static material alpha", + }, + )?; + let diffuse_alpha_override = self + .draw_range_diffuse_alpha_overrides + .get(range_index) + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "draw-range diffuse-alpha override", + })?; + let material_alphas = + draw_material_alphas(*material_alpha, *diffuse_alpha_override) + .map(f32::to_ne_bytes) + .concat(); + device.device().cmd_push_constants( + command_buffer, + resources.pipeline_layout, + vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT, + 4, + &material_alphas, + ); + let material_lighting = self.material_lighting.get(texture_index).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "static material lighting", + }, + )?; + let material_lighting = material_lighting + .iter() + .flatten() + .flat_map(|value| value.to_ne_bytes()) + .collect::>(); + device.device().cmd_push_constants( + command_buffer, + resources.pipeline_layout, + vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT, + 12, + &material_lighting, + ); + let material_uv_transforms = self + .material_uv_transforms + .get(texture_index) + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "static material UV transforms", + })?; + let material_uv_transforms = material_uv_transforms + .iter() + .flatten() + .flat_map(|value| value.to_ne_bytes()) + .collect::>(); + device.device().cmd_push_constants( + command_buffer, + resources.pipeline_layout, + vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT, + 60, + &material_uv_transforms, + ); + let material_mode = self.material_unlit.get(texture_index).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "static material unlit flag", + }, + )?; + let material_mode = if range.terrain_base_only { + ((*material_mode).round() as i32 | 16) as f32 + } else { + *material_mode + }; + let material_mode = (material_mode.round() as i32 + | if reflected { 32 } else { 0 } + | if !reflected + && reflection_plane.is_some() + && self.reflection_planes[range_index] == reflection_plane + { + 64 + } else { + 0 + }) as f32; + let viewport = super::swapchain_resources::draw_viewport( + swapchain.report.plan.extent, + material_mode, + ); + let next_viewport_depth = [viewport.min_depth, viewport.max_depth]; + if viewport_depth != Some(next_viewport_depth) { + device + .device() + .cmd_set_viewport(command_buffer, 0, &[viewport]); + viewport_depth = Some(next_viewport_depth); + } + let material_mode = [material_mode.to_ne_bytes()]; + device.device().cmd_push_constants( + command_buffer, + resources.pipeline_layout, + vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT, + 124, + &material_mode.concat(), + ); + device.device().cmd_draw_indexed( + command_buffer, + range.index_count, + 1, + range.first_index, + 0, + 0, + ); + } + device.device().cmd_end_render_pass(command_buffer); + if reflected { + let image = resources.reflection.color.as_ref().ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "reflection target", + }, + )?; + let barrier = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL) + .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) + .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE) + .dst_access_mask(vk::AccessFlags::SHADER_READ) + .image(image.image) + .subresource_range(super::color_subresource_range(1)); + device.device().cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT, + vk::PipelineStageFlags::FRAGMENT_SHADER, + vk::DependencyFlags::empty(), + &[], + &[], + &[barrier], + ); + } } - device.device().cmd_end_render_pass(command_buffer); + } + // SAFETY: The main pass has completed its writes to this acquired swapchain image. + unsafe { + let barrier = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL) + .new_layout(vk::ImageLayout::PRESENT_SRC_KHR) + .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE) + .image(resources.images[image_index]) + .subresource_range(super::color_subresource_range(1)); + device.device().cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT, + vk::PipelineStageFlags::BOTTOM_OF_PIPE, + vk::DependencyFlags::empty(), + &[], + &[], + &[barrier], + ); if self.readback_enabled { if let (Some(image), Some(readback)) = ( resources.images.get(image_index), @@ -2028,7 +2185,7 @@ impl VulkanSmokeRenderer { } } - // SAFETY: The render pass owns the attachment layout transitions for this clear-and-present path. + // SAFETY: Every render pass and attachment transition has been recorded in this buffer. unsafe { device.device().end_command_buffer(command_buffer) }.map_err(|error| { VulkanSmokeRendererError::VulkanOperation { context: "vkEndCommandBuffer", @@ -2720,6 +2877,7 @@ mod tests { batch_flags: 0x108, pipeline_state: base_state, terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 17, }, VulkanStaticDrawRange { @@ -2730,6 +2888,7 @@ mod tests { batch_flags: 0, pipeline_state: LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }, ]; @@ -2886,6 +3045,7 @@ mod tests { batch_flags: 0, pipeline_state: LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }]; let mut capacities = [6]; @@ -3072,6 +3232,7 @@ mod tests { batch_flags: 0, pipeline_state: fparkan_render::LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }) .collect::>(); @@ -3096,6 +3257,7 @@ mod tests { batch_flags: 0, pipeline_state: fparkan_render::LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }, super::super::VulkanStaticDrawRange { @@ -3106,6 +3268,7 @@ mod tests { batch_flags: 0, pipeline_state: fparkan_render::LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }, super::super::VulkanStaticDrawRange { @@ -3116,6 +3279,7 @@ mod tests { batch_flags: 0, pipeline_state: fparkan_render::LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }, ]; @@ -3140,6 +3304,7 @@ mod tests { batch_flags: 0, pipeline_state: fparkan_render::LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }) .collect::>(); @@ -3233,6 +3398,7 @@ mod tests { batch_flags: 0, pipeline_state: LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }) .collect::>(); @@ -3291,6 +3457,7 @@ mod tests { batch_flags: 0, pipeline_state: LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }) .collect::>(); diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index 5d6f12f..bc8aedd 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -437,6 +437,8 @@ pub struct VulkanStaticDrawRange { pub batch_flags: u16, /// Terrain ranges that use the native lit base-only path (face flag 0x400 clear). pub terrain_base_only: bool, + /// Terrain face flag 0x20000 requests a water reflection surface. + pub terrain_reflection: bool, /// Backend-neutral fixed-function state for this source range. pub pipeline_state: LegacyPipelineState, /// Alpha-test reference in legacy 0..=255 units; dynamic material data. @@ -771,6 +773,7 @@ impl VulkanStaticMesh { batch_flags: 0, pipeline_state: LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }], } @@ -1032,6 +1035,7 @@ mod static_mesh_tests { batch_flags: 0, pipeline_state: LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }], }; @@ -1063,6 +1067,7 @@ mod static_mesh_tests { batch_flags: 0, pipeline_state: LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }, VulkanStaticDrawRange { @@ -1073,6 +1078,7 @@ mod static_mesh_tests { batch_flags: 0, pipeline_state: LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }, ]; @@ -1191,6 +1197,7 @@ mod static_mesh_tests { let base = VulkanStaticMesh::smoke_triangle().draw_ranges[0]; let disabled = VulkanStaticDrawRange { terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 200, ..base }; @@ -1200,6 +1207,7 @@ mod static_mesh_tests { ..LegacyPipelineState::default() }, terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 128, ..base }; @@ -1211,6 +1219,7 @@ mod static_mesh_tests { ..LegacyPipelineState::default() }, terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, ..base }; @@ -1221,6 +1230,7 @@ mod static_mesh_tests { ..LegacyPipelineState::default() }, terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE, ..base }; @@ -1501,6 +1511,7 @@ pub struct VulkanSmokeRenderer { pub(super) index_capacity: usize, pub(super) draw_range_capacities: Vec, pub(super) draw_ranges: Vec, + pub(super) reflection_planes: Vec>, pub(super) draw_texture_indices: Vec, /// Stable range handles used when recording the native pass order. pub(super) draw_order: Vec, diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs index a44f79d..4365cd2 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs @@ -18,6 +18,7 @@ use super::{ }; pub(super) struct VulkanSwapchainResources { + pub(super) reflection: super::reflection::ReflectionResources, pub(super) image_views: Vec, pub(super) images: Vec, pub(super) readback_buffers: Vec, @@ -146,6 +147,7 @@ pub(super) fn ensure_point_light_capacity( } struct PartialSwapchainResources { + reflection: Option, image_views: Vec, readback_buffers: Vec, depth_attachments: Vec, @@ -180,6 +182,7 @@ pub(super) fn create_swapchain_resources( textures: &[VulkanAllocatedImage], texture_slot_indices: &[usize], base_pipeline_states: &[LegacyPipelineState], + reflection_enabled: bool, depth_request: DepthStencilSupport, reuse_command_pool: bool, ) -> Result { @@ -194,6 +197,7 @@ pub(super) fn create_swapchain_resources( result: error, })?; let mut partial = PartialSwapchainResources { + reflection: None, image_views: create_swapchain_image_views( device, &images, @@ -245,6 +249,14 @@ 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); + } + }; let material_count = texture_slot_indices.len() / 4; let (material_specular_buffer, material_specular_stride) = match create_material_specular_buffer(instance, device, material_count) { @@ -288,6 +300,12 @@ pub(super) fn create_swapchain_resources( textures, texture_slot_indices, base_pipeline_states, + partial + .reflection + .as_ref() + .expect("reflection descriptors exist") + .layout, + reflection_enabled, depth_request, partial .frame_uniform_buffer @@ -319,6 +337,24 @@ pub(super) fn create_swapchain_resources( partial.descriptor_pool = Some(descriptor_pool); partial.sampler = Some(sampler); partial.pipelines = pipelines; + if reflection_enabled { + if let Err(error) = partial + .reflection + .as_mut() + .expect("reflection descriptors exist") + .create_target( + instance, + device, + swapchain.report.plan.extent, + swapchain.report.plan.format.format, + depth_request, + render_pass, + ) + { + destroy_partial_swapchain_resources(device, command_pool, partial); + return Err(error); + } + } for _ in 1..images.len() { match create_depth_attachment( instance, @@ -394,6 +430,10 @@ pub(super) fn create_swapchain_resources( }); } Ok(VulkanSwapchainResources { + reflection: partial + .reflection + .take() + .expect("reflection descriptors exist"), image_views: partial.image_views, images, readback_buffers: partial.readback_buffers, @@ -457,6 +497,8 @@ fn create_swapchain_pipeline_bundle( textures: &[VulkanAllocatedImage], texture_slot_indices: &[usize], base_pipeline_states: &[LegacyPipelineState], + reflection_layout: vk::DescriptorSetLayout, + reflection_enabled: bool, depth_request: DepthStencilSupport, frame_uniform_buffer: &VulkanAllocatedBuffer, material_specular_buffer: &VulkanAllocatedBuffer, @@ -498,8 +540,8 @@ fn create_swapchain_pipeline_bundle( unsafe { device.device().destroy_render_pass(render_pass, None) }; destroy_depth_attachment(device, &depth_attachment); })?; - let pipeline_layout = - create_pipeline_layout(device, descriptor_set_layout).inspect_err(|_| { + let pipeline_layout = create_pipeline_layout(device, descriptor_set_layout, reflection_layout) + .inspect_err(|_| { // SAFETY: The descriptor resources and render pass were created above and are rolled back on this device. unsafe { device.device().destroy_sampler(sampler, None); @@ -520,6 +562,7 @@ fn create_swapchain_pipeline_bundle( pipeline_layout, extent, base_pipeline_states, + reflection_enabled, ) .inspect_err(|_| { // SAFETY: These objects were created above on this live logical device and are destroyed on setup failure. @@ -636,7 +679,7 @@ fn create_render_pass( .load_op(vk::AttachmentLoadOp::CLEAR) .store_op(vk::AttachmentStoreOp::STORE) .initial_layout(vk::ImageLayout::UNDEFINED) - .final_layout(vk::ImageLayout::PRESENT_SRC_KHR); + .final_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL); let color_attachment_ref = vk::AttachmentReference::default() .attachment(0) .layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL); @@ -661,7 +704,8 @@ fn create_render_pass( .src_subpass(vk::SUBPASS_EXTERNAL) .dst_subpass(0) .src_stage_mask( - vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT + vk::PipelineStageFlags::FRAGMENT_SHADER + | vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT | vk::PipelineStageFlags::EARLY_FRAGMENT_TESTS | vk::PipelineStageFlags::LATE_FRAGMENT_TESTS, ) @@ -670,7 +714,11 @@ fn create_render_pass( | vk::PipelineStageFlags::EARLY_FRAGMENT_TESTS | vk::PipelineStageFlags::LATE_FRAGMENT_TESTS, ) - .src_access_mask(vk::AccessFlags::empty()) + .src_access_mask( + vk::AccessFlags::SHADER_READ + | vk::AccessFlags::COLOR_ATTACHMENT_WRITE + | vk::AccessFlags::DEPTH_STENCIL_ATTACHMENT_WRITE, + ) .dst_access_mask( vk::AccessFlags::COLOR_ATTACHMENT_WRITE | vk::AccessFlags::DEPTH_STENCIL_ATTACHMENT_WRITE, @@ -694,8 +742,9 @@ fn create_render_pass( fn create_pipeline_layout( device: &VulkanLogicalDeviceProbe, descriptor_set_layout: vk::DescriptorSetLayout, + reflection_layout: vk::DescriptorSetLayout, ) -> Result { - let set_layouts = [descriptor_set_layout]; + let set_layouts = [descriptor_set_layout, reflection_layout]; // Material data occupies one 128-byte push-constant allocation shared by // both stages; the vertex stage also reads its specular pair from the // dynamic storage buffer at descriptor binding 5. Camera data is supplied @@ -960,12 +1009,18 @@ fn create_graphics_pipeline_cache( pipeline_layout: vk::PipelineLayout, extent: (u32, u32), base_pipeline_states: &[LegacyPipelineState], + reflection_enabled: bool, ) -> Result, VulkanSmokeRendererError> { let mut pipelines = BTreeMap::new(); for state in base_pipeline_states .iter() .copied() .flat_map(super::smoke::pipeline_variants) + .flat_map(|state| { + std::iter::once(state).chain( + reflection_enabled.then_some(super::reflection::reflected_pipeline_state(state)), + ) + }) { let key = PipelineKey::from(state); if pipelines.contains_key(&key) { @@ -1238,6 +1293,7 @@ pub(super) fn destroy_swapchain_resources( command_pool: vk::CommandPool, resources: VulkanSwapchainResources, ) { + drop(resources.reflection); // SAFETY: All handles belong to this live logical device and are destroyed during renderer teardown/recreation. unsafe { if !resources.command_buffers.is_empty() { @@ -1303,6 +1359,7 @@ fn destroy_partial_swapchain_resources( command_pool: vk::CommandPool, partial: PartialSwapchainResources, ) { + drop(partial.reflection); // SAFETY: All handles in the partial bundle belong to this live logical device and are destroyed on setup failure. unsafe { if !partial.command_buffers.is_empty() { diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 3432bda..920468f 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -2760,6 +2760,7 @@ fn append_environment_range( batch_flags: 0, pipeline_state, terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE, }); Ok(EnvironmentGpuRange { @@ -7606,7 +7607,7 @@ fn project_basement_foundation( let mut shadow_receivers = Vec::with_capacity(foundation.fragments.len()); for (fragment_index, fragment) in foundation.fragments.iter().enumerate() { - let (material_index, base_only, vertices) = match fragment.source_face { + let (material_index, base_only, reflection, vertices) = match fragment.source_face { Some(face_index) => { let face = terrain.faces.get(face_index).ok_or_else(|| { format!("foundation fragment references unknown terrain face {face_index}") @@ -7654,6 +7655,7 @@ fn project_basement_foundation( ( face.material_tag, fragment.surface_mask.0 & 0x400 == 0, + fragment.surface_mask.0 & 0x20000 != 0, vertices, ) } @@ -7671,7 +7673,7 @@ fn project_basement_foundation( fragment_uv0_words, fragment_normals, ); - (0xff00, true, vertices) + (0xff00, true, false, vertices) } }; @@ -7684,7 +7686,13 @@ fn project_basement_foundation( mesh.indices.push(index); } if let Some(range) = mesh.draw_ranges.last_mut().filter(|range| { - range.material_index == material_index && range.terrain_base_only == base_only + range.material_index == material_index + && range.terrain_base_only == base_only + && range.terrain_reflection == reflection + && (!reflection + || vertices[0].position[2] + == mesh.vertices[mesh.indices[range.first_index as usize] as usize] + .position[2]) }) { range.index_count = range .index_count @@ -7699,6 +7707,7 @@ fn project_basement_foundation( batch_flags: 0, pipeline_state, terrain_base_only: base_only, + terrain_reflection: reflection, alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE, }); } @@ -8004,9 +8013,9 @@ fn static_preview_mesh_and_materials( let unverified_special_faces = terrain_mesh .faces .iter() - .filter(|face| face.flags.0 & 8 != 0) + .filter(|face| face.flags.0 & 8 != 0 && face.flags.0 & 0x20000 == 0) .count(); - eprintln!("render_coverage terrain_face8_pipeline_unverified={unverified_special_faces} (source faces across LODs use the ordinary terrain shader; native special pass and reflection/EMBM mapping pending)"); + eprintln!("render_coverage terrain_face8_pipeline_unverified={unverified_special_faces} (non-reflective source faces across LODs; native special-pass mapping pending)"); let footprint_sources = mission_basement_footprints(assets, roots)?; let foundation = if footprint_sources.is_empty() { None @@ -11208,6 +11217,7 @@ mod tests { batch_flags, pipeline_state: LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE, }; let mesh = VulkanStaticMesh { @@ -12467,6 +12477,7 @@ mod tests { LegacyDepthMode::TestReadOnly, ), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }; let mut mesh = VulkanStaticMesh { @@ -13149,6 +13160,7 @@ mod tests { LegacyDepthMode::TestReadOnly, ), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }; let mut mesh = VulkanStaticMesh { @@ -13210,6 +13222,7 @@ mod tests { batch_flags, pipeline_state: world_pipeline_state(blend, LegacyDepthMode::TestReadOnly), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }; @@ -13673,6 +13686,7 @@ mod tests { lightmap_index: u8::MAX, batch_flags: 0, terrain_base_only: false, + terrain_reflection: false, pipeline_state: world_pipeline_state( LegacyBlendMode::Opaque, LegacyDepthMode::TestReadOnly, diff --git a/apps/fparkan-game/src/terrain_lod.rs b/apps/fparkan-game/src/terrain_lod.rs index 599178c..190f660 100644 --- a/apps/fparkan-game/src/terrain_lod.rs +++ b/apps/fparkan-game/src/terrain_lod.rs @@ -239,6 +239,7 @@ mod tests { lightmap_index: u8::MAX, batch_flags: 0, terrain_base_only: true, + terrain_reflection: false, pipeline_state: super::super::world_pipeline_state( fparkan_render::LegacyBlendMode::Opaque, fparkan_render::LegacyDepthMode::TestWrite, diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs index ad96204..2ad4789 100644 --- a/crates/fparkan-render/src/lib.rs +++ b/crates/fparkan-render/src/lib.rs @@ -349,6 +349,23 @@ pub enum LegacyDepthMode { TestReadOnly, } +/// Reflects world Z before a row-vector clip transform, preserving points on the plane. +#[must_use] +pub fn reflect_clip_from_world_about_z(matrix: [f32; 16], height: f32) -> Option<[f32; 16]> { + if !height.is_finite() || !matrix.iter().all(|value| value.is_finite()) { + return None; + } + let mut reflected = matrix; + for column in 0..4 { + reflected[8 + column] = -matrix[8 + column]; + reflected[12 + column] = matrix[12 + column] + 2.0 * height * matrix[8 + column]; + } + reflected + .iter() + .all(|value| value.is_finite()) + .then_some(reflected) +} + /// Triangle culling behaviour represented without a graphics API type. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum LegacyCullMode { @@ -556,6 +573,29 @@ mod tests { result } + #[test] + fn water_reflection_preserves_the_plane_and_reverses_height_before_projection() { + let matrix = [ + 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, + ]; + let reflected = reflect_clip_from_world_about_z(matrix, 150.0).unwrap(); + let project = |m: [f32; 16], p: [f32; 4]| -> [f32; 4] { + std::array::from_fn(|column| (0..4).map(|row| p[row] * m[row * 4 + column]).sum()) + }; + for z in [125.0, 150.0, 185.0] { + assert_eq!( + project(reflected, [7.0, 11.0, z, 1.0]), + project(matrix, [7.0, 11.0, 300.0 - z, 1.0]) + ); + } + assert_eq!( + reflect_clip_from_world_about_z(reflected, 150.0), + Some(matrix) + ); + assert!(reflect_clip_from_world_about_z(matrix, f32::NAN).is_none()); + assert!(reflect_clip_from_world_about_z([f32::MAX; 16], f32::MAX).is_none()); + } + fn assert_matrix_approximately_identity(matrix: [f32; 16]) { for (index, value) in matrix.into_iter().enumerate() { let expected = if index / 4 == index % 4 { 1.0 } else { 0.0 }; diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index 70f6259..a48bec5 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -73,9 +73,9 @@ MSH-запрос со свободной preview-камерой; это не п среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state log на минимальных сценах с одним типом материала. -Полигоны Land.msh с флагом `0x8` пока используют обычный terrain shader; их исходные записи во всех LOD считает `terrain_face8_pipeline_unverified`. -На водоёме «Конструктора» берег близок к оригиналу, а поверхность воды слишком светлая; точные special-pass, UV и combiner остаются открытыми. -В GOG Terrain.dll подтверждена отдельная камера `REFLECTION`/`REFLECTION_SHIFTED` (инициализация RVA `0x13CF0`, render RVA `0x4EFF0`); Vulkan-проход отражения пока отсутствует. +Горизонтальные грани Land.msh с флагом `0x20000` используют плоское Vulkan-отражение; shifted-камера, волны и native EMBM combiner остаются заглушкой `planar_reflection_embm_unsupported`. +Один target отражает ближайшую активную высоту воды; остальные высоты и неплоские грани считаются в `planar_reflection_other_planes_unsupported` и `planar_reflection_nonplanar_unsupported` и сохраняют обычный terrain shader. +`terrain_face8_pipeline_unverified` теперь считает только исходные грани с `0x8` без `0x20000`; сама семантика оставшихся специальных проходов ещё не установлена. ## FXID field-level semantics diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index cfd352a..e374ef0 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1138,6 +1138,36 @@ lightmap state передаются в `VulkanStaticMaterial`, а shader пос сэмплирует эти четыре стадии. Animation clock обновляет выбранные фазы и коэффициенты без пересборки terrain mesh, сохраняя native type-14 overlay mask. +### Отражение на воде + +Поверхность воды нельзя рисовать тем же умножением текстур, что и землю. +В проходе ландшафта GOG Terrain.dll флаг грани `0x20000` участвует в выборе +отражения; при включённом EMBM живой водный кадр использует texture mode 10. +Оригинал создаёт для этого отдельную камеру `REFLECTION_SHIFTED`. +В [эталонной сборке](../reference/original-binaries.md) выбор грани находится +в Terrain.dll RVA `0x28A50`, а подготовка EMBM-пакета — в RVA `0x1B540`. + +Первый Vulkan-проход строит плоское зеркало. Для горизонтальной воды на +высоте `h` отражённая координата точки равна `z′ = 2h − z`. Это преобразование +применяется перед обычной проекцией. Оно меняет порядок вершин треугольника, +поэтому проход меняет сторону отсечения и отбрасывает геометрию ниже воды. +Небо и оставшаяся сцена попадают в отдельную цветовую текстуру с собственной +глубиной. В основном проходе водные треугольники читают её в экранной +координате: точки самой плоскости не сдвигаются при отражении. + +Цветовой target переходит из attachment layout в shader-read layout после +записи. Следующий зеркальный проход ждёт предыдущего чтения этой текстуры; +основной и отражённый виды используют разные слоты uniform buffer. Во время +записи отражения привязан отдельный нейтральный descriptor, чтобы проход +не читал собственное вложение. + +Такое зеркало ещё не повторяет shifted-камеру и EMBM оригинала: искажение +отражения волнами пока отсутствует и учитывается в +`planar_reflection_embm_unsupported`. Один target обслуживает ближайшую +активную высоту воды; другие высоты и негоризонтальные поверхности явно +считаются как неподдержанные. Это промежуточный водный проход, а не полная +приёмка воды или лавы. + ## Реализация Vulkan `LegacyPipelineState` хранит blend, depth, cull и alpha-test. Из этих значений