From f9a13298c0d5ef9bc0df968d178983506c24c0a6 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 09:05:20 +0400 Subject: [PATCH] feat(vulkan): capture swapchain pixels --- adapters/fparkan-render-vulkan/src/ffi.rs | 7 +- .../src/ffi/resources.rs | 43 +++++++ .../fparkan-render-vulkan/src/ffi/smoke.rs | 120 +++++++++++++++++- .../src/ffi/smoke_types.rs | 6 + .../src/ffi/swapchain_resources.rs | 38 ++++++ apps/fparkan-vulkan-smoke/src/main.rs | 20 +++ docs/tomes/05-render.md | 20 +++ 7 files changed, 245 insertions(+), 9 deletions(-) diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs index 610401a..eef9fc1 100644 --- a/adapters/fparkan-render-vulkan/src/ffi.rs +++ b/adapters/fparkan-render-vulkan/src/ffi.rs @@ -56,9 +56,10 @@ pub use self::instance::{ use self::instance::{cstring_vec, ensure_instance_extensions_available}; use self::resources::{ color_subresource_range, create_command_pool, create_depth_attachment, create_frame_sync, - create_static_mesh_index_buffer, create_static_mesh_vertex_buffer, create_static_texture_image, - destroy_allocated_buffer, destroy_allocated_image, destroy_depth_attachment, - VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanDepthAttachment, VulkanFrameSync, + create_readback_buffer, create_static_mesh_index_buffer, create_static_mesh_vertex_buffer, + create_static_texture_image, destroy_allocated_buffer, destroy_allocated_image, + destroy_depth_attachment, readback_buffer_bytes, VulkanAllocatedBuffer, VulkanAllocatedImage, + VulkanDepthAttachment, VulkanFrameSync, }; pub use self::runtime::{ create_vulkan_logical_device_probe, create_vulkan_logical_device_probe_for_request, diff --git a/adapters/fparkan-render-vulkan/src/ffi/resources.rs b/adapters/fparkan-render-vulkan/src/ffi/resources.rs index 2318b3b..6f2005e 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/resources.rs @@ -698,6 +698,49 @@ pub(super) fn destroy_allocated_buffer( } } +/// Allocates a host-coherent transfer destination for a swapchain image copy. +pub(super) fn create_readback_buffer( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + byte_len: usize, +) -> Result { + create_host_visible_buffer( + instance, + device, + &vec![0; byte_len], + vk::BufferUsageFlags::TRANSFER_DST, + "vkCreateBuffer(readback)", + ) +} + +/// Copies a completed host-coherent readback allocation into CPU-owned bytes. +pub(super) fn readback_buffer_bytes( + device: &VulkanLogicalDeviceProbe, + buffer: &VulkanAllocatedBuffer, + byte_len: usize, +) -> Result, VulkanSmokeRendererError> { + // SAFETY: The caller waits for device idle before mapping this host-visible allocation. + let mapped = unsafe { + device.device().map_memory( + buffer.memory, + 0, + u64::try_from(byte_len).unwrap_or(u64::MAX), + vk::MemoryMapFlags::empty(), + ) + } + .map_err(|result| VulkanSmokeRendererError::VulkanOperation { + context: "vkMapMemory(readback)", + result, + })?; + let mut bytes = vec![0; byte_len]; + // SAFETY: Both ranges contain exactly byte_len initialized bytes and do not overlap. + unsafe { + std::ptr::copy_nonoverlapping(mapped.cast::(), bytes.as_mut_ptr(), byte_len); + device.device().unmap_memory(buffer.memory); + } + Ok(bytes) +} + pub(super) fn color_subresource_range() -> vk::ImageSubresourceRange { vk::ImageSubresourceRange::default() .aspect_mask(vk::ImageAspectFlags::COLOR) diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index 0d5e6b4..eee2f06 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -9,11 +9,11 @@ use super::{ create_validation_messenger, create_vulkan_instance_probe, create_vulkan_logical_device_probe_for_request, create_vulkan_surface_probe, create_vulkan_swapchain_probe_for_extent, destroy_allocated_buffer, destroy_allocated_image, - destroy_swapchain_resources, plan_vulkan_surface, VulkanAllocatedBuffer, VulkanInstanceConfig, - VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, - VulkanSmokeRendererCreateInfo, VulkanSmokeRendererError, VulkanSmokeRendererReport, - VulkanSmokeShutdownReport, VulkanSurfaceProbe, VulkanSwapchainProbe, VulkanSwapchainResources, - VulkanValidationMessenger, VulkanValidationReport, + destroy_swapchain_resources, plan_vulkan_surface, readback_buffer_bytes, VulkanAllocatedBuffer, + VulkanInstanceConfig, VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeFrameOutcome, + VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererError, + VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanSurfaceProbe, VulkanSwapchainProbe, + VulkanSwapchainResources, VulkanValidationMessenger, VulkanValidationReport, }; use crate::policy::KHR_PORTABILITY_SUBSET_EXTENSION; use crate::shader_manifest::{triangle_shader_manifest, validate_shader_manifest}; @@ -242,6 +242,9 @@ impl VulkanSmokeRenderer { swapchain_extent: (0, 0), swapchain_image_count: 0, swapchain_image_usage: 0, + readback_copy_count: 0, + readback_byte_count: 0, + readback_fnv1a64: 0, }, }; renderer.rebuild_swapchain_resources(false)?; @@ -270,6 +273,9 @@ impl VulkanSmokeRenderer { swapchain_extent: swapchain_ref.report.plan.extent, swapchain_image_count: swapchain_ref.report.image_count, swapchain_image_usage: swapchain_ref.report.plan.image_usage, + readback_copy_count: 0, + readback_byte_count: 0, + readback_fnv1a64: 0, }; Ok(renderer) } @@ -476,6 +482,9 @@ impl VulkanSmokeRenderer { context: "vkQueueSubmit", result: error, })?; + if !self.resources_ref()?.readback_buffers.is_empty() { + self.report.readback_copy_count = self.report.readback_copy_count.saturating_add(1); + } let present_wait = [render_finished]; let swapchains = [self.swapchain_ref()?.swapchain()]; @@ -700,6 +709,64 @@ impl VulkanSmokeRenderer { ); } device.device().cmd_end_render_pass(command_buffer); + if let (Some(image), Some(readback)) = ( + resources.images.get(image_index), + resources.readback_buffers.get(image_index), + ) { + let range = super::color_subresource_range(); + let to_transfer = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::PRESENT_SRC_KHR) + .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) + .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE) + .dst_access_mask(vk::AccessFlags::TRANSFER_READ) + .image(*image) + .subresource_range(range); + let back_to_present = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) + .new_layout(vk::ImageLayout::PRESENT_SRC_KHR) + .src_access_mask(vk::AccessFlags::TRANSFER_READ) + .dst_access_mask(vk::AccessFlags::empty()) + .image(*image) + .subresource_range(range); + let region = vk::BufferImageCopy::default() + .image_subresource( + vk::ImageSubresourceLayers::default() + .aspect_mask(vk::ImageAspectFlags::COLOR) + .mip_level(0) + .base_array_layer(0) + .layer_count(1), + ) + .image_extent(vk::Extent3D { + width: swapchain.report.plan.extent.0, + height: swapchain.report.plan.extent.1, + depth: 1, + }); + device.device().cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT, + vk::PipelineStageFlags::TRANSFER, + vk::DependencyFlags::empty(), + &[], + &[], + &[to_transfer], + ); + device.device().cmd_copy_image_to_buffer( + command_buffer, + *image, + vk::ImageLayout::TRANSFER_SRC_OPTIMAL, + readback.buffer, + &[region], + ); + device.device().cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::TRANSFER, + vk::PipelineStageFlags::BOTTOM_OF_PIPE, + vk::DependencyFlags::empty(), + &[], + &[], + &[back_to_present], + ); + } } // SAFETY: The render pass owns the attachment layout transitions for this clear-and-present path. @@ -766,7 +833,7 @@ impl VulkanSmokeRenderer { } fn shutdown_inner(&mut self) -> Result { - if let Some(device) = self.device.as_ref() { + let completed_readback = if let Some(device) = self.device.as_ref() { // SAFETY: The logical device remains live until teardown finishes and idling prevents in-flight work from touching swapchain, buffers, sync objects or the command pool after destruction starts. unsafe { device.device().device_wait_idle() }.map_err(|error| { VulkanSmokeRendererError::VulkanOperation { @@ -774,6 +841,13 @@ impl VulkanSmokeRenderer { result: error, } })?; + self.completed_readback(device)? + } else { + None + }; + if let Some((byte_count, hash)) = completed_readback { + self.report.readback_byte_count = byte_count; + self.report.readback_fnv1a64 = hash; } self.destroy_device_owned_resources(); let validation = take_runtime_children_with_validation_snapshot( @@ -793,6 +867,40 @@ impl VulkanSmokeRenderer { }) } + fn completed_readback( + &self, + device: &VulkanLogicalDeviceProbe, + ) -> Result, VulkanSmokeRendererError> { + let Some(resources) = self.swapchain_resources.as_ref() else { + return Ok(None); + }; + let byte_len = usize::try_from(self.report.swapchain_extent.0) + .ok() + .and_then(|width| { + usize::try_from(self.report.swapchain_extent.1) + .ok() + .and_then(|height| width.checked_mul(height)) + }) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "completed readback byte length", + })?; + if resources.readback_buffers.is_empty() { + return Ok(None); + } + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + let mut byte_count = 0_u64; + for buffer in &resources.readback_buffers { + let bytes = readback_buffer_bytes(device, buffer, byte_len)?; + for byte in bytes { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + byte_count = byte_count.saturating_add(u64::try_from(byte_len).unwrap_or(u64::MAX)); + } + Ok(Some((byte_count, hash))) + } + fn teardown(&mut self) { if let Some(device) = self.device.as_ref() { // SAFETY: The logical device remains live until teardown finishes and idling prevents in-flight work from touching swapchain, buffers, sync objects or the command pool after destruction starts. diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index cbf978a..ce691b8 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -459,6 +459,12 @@ pub struct VulkanSmokeRendererReport { pub swapchain_image_count: u32, /// Current swapchain image-usage flags as raw Vulkan bits. pub swapchain_image_usage: u32, + /// Number of submitted swapchain image-to-buffer copy commands. + pub readback_copy_count: u64, + /// Byte length of the final completed readback artifact. + pub readback_byte_count: u64, + /// Stable FNV-1a hash of the final completed readback artifact. + pub readback_fnv1a64: u64, } /// Measured validation counters from the live smoke loop. diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs index 529214d..6d87687 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs @@ -16,6 +16,8 @@ use super::{ pub(super) struct VulkanSwapchainResources { pub(super) image_views: Vec, + pub(super) images: Vec, + pub(super) readback_buffers: Vec, pub(super) depth_attachment: VulkanDepthAttachment, pub(super) render_pass: vk::RenderPass, pub(super) pipeline_layout: vk::PipelineLayout, @@ -31,6 +33,7 @@ pub(super) struct VulkanSwapchainResources { struct PartialSwapchainResources { image_views: Vec, + readback_buffers: Vec, depth_attachment: Option, render_pass: Option, pipeline_layout: Option, @@ -71,6 +74,7 @@ pub(super) fn create_swapchain_resources( &images, swapchain.report.plan.format.format, )?, + readback_buffers: Vec::new(), depth_attachment: None, render_pass: None, pipeline_layout: None, @@ -127,6 +131,30 @@ pub(super) fn create_swapchain_resources( } }; partial.framebuffers = framebuffers; + if vk::ImageUsageFlags::from_raw(swapchain.report.plan.image_usage) + .contains(vk::ImageUsageFlags::TRANSFER_SRC) + { + let byte_len = usize::try_from(swapchain.report.plan.extent.0) + .ok() + .and_then(|width| { + usize::try_from(swapchain.report.plan.extent.1) + .ok() + .and_then(|height| width.checked_mul(height)) + }) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "swapchain readback byte length", + })?; + for _ in &images { + match super::create_readback_buffer(instance, device, byte_len) { + Ok(buffer) => partial.readback_buffers.push(buffer), + Err(error) => { + destroy_partial_swapchain_resources(device, command_pool, partial); + return Err(error); + } + } + } + } reset_reusable_command_pool(device, command_pool, reuse_command_pool)?; let command_buffers = match allocate_command_buffers( device, @@ -150,6 +178,8 @@ pub(super) fn create_swapchain_resources( })?; Ok(VulkanSwapchainResources { image_views: partial.image_views, + images, + readback_buffers: partial.readback_buffers, depth_attachment, render_pass, pipeline_layout, @@ -795,6 +825,10 @@ pub(super) fn destroy_swapchain_resources( for image_view in resources.image_views { device.device().destroy_image_view(image_view, None); } + for buffer in resources.readback_buffers { + device.device().destroy_buffer(buffer.buffer, None); + device.device().free_memory(buffer.memory, None); + } } } @@ -843,5 +877,9 @@ fn destroy_partial_swapchain_resources( for image_view in partial.image_views { device.device().destroy_image_view(image_view, None); } + for buffer in partial.readback_buffers { + device.device().destroy_buffer(buffer.buffer, None); + device.device().free_memory(buffer.memory, None); + } } } diff --git a/apps/fparkan-vulkan-smoke/src/main.rs b/apps/fparkan-vulkan-smoke/src/main.rs index 24e4dd0..a3f7226 100644 --- a/apps/fparkan-vulkan-smoke/src/main.rs +++ b/apps/fparkan-vulkan-smoke/src/main.rs @@ -781,6 +781,15 @@ impl SmokeApp { vulkan_swapchain_image_usage: renderer .as_ref() .map_or(0, |snapshot| snapshot.report.swapchain_image_usage), + vulkan_readback_copy_count: renderer + .as_ref() + .map_or(0, |snapshot| snapshot.report.readback_copy_count), + vulkan_readback_byte_count: renderer + .as_ref() + .map_or(0, |snapshot| snapshot.report.readback_byte_count), + vulkan_readback_fnv1a64: renderer + .as_ref() + .map_or(0, |snapshot| snapshot.report.readback_fnv1a64), vulkan_portability_enumeration: renderer .as_ref() .is_some_and(|snapshot| snapshot.report.portability_enumeration), @@ -984,6 +993,9 @@ fn render_timeout_failure_report( vulkan_swapchain_height: 0, vulkan_swapchain_image_count: 0, vulkan_swapchain_image_usage: 0, + vulkan_readback_copy_count: 0, + vulkan_readback_byte_count: 0, + vulkan_readback_fnv1a64: 0, vulkan_portability_enumeration: false, vulkan_portability_subset_enabled: false, }; @@ -1205,6 +1217,9 @@ struct SmokeReport<'a> { vulkan_swapchain_height: u32, vulkan_swapchain_image_count: u32, vulkan_swapchain_image_usage: u32, + vulkan_readback_copy_count: u64, + vulkan_readback_byte_count: u64, + vulkan_readback_fnv1a64: u64, vulkan_portability_enumeration: bool, vulkan_portability_subset_enabled: bool, } @@ -1709,6 +1724,9 @@ mod tests { vulkan_swapchain_height: 540, vulkan_swapchain_image_count: 3, vulkan_swapchain_image_usage: 17, + vulkan_readback_copy_count: 300, + vulkan_readback_byte_count: 4_147_200, + vulkan_readback_fnv1a64: 0x1234_5678_90ab_cdef, vulkan_portability_enumeration: false, vulkan_portability_subset_enabled: false, }) @@ -1721,6 +1739,8 @@ mod tests { assert!(json.contains("\"mesh_source\": \"original-msh\"")); assert!(json.contains("\"mesh_draw_count\": 1")); assert!(json.contains("\"texture_source\": \"original-texm\"")); + assert!(json.contains("\"vulkan_readback_copy_count\": 300")); + assert!(json.contains("\"vulkan_readback_byte_count\": 4147200")); } #[test] diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index e2f3b88..3e0a149 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -875,6 +875,26 @@ events, 2 swapchain recreations, Vulkan validation warnings/errors `0/0`. Следующий шаг должен выполнить явный image-to-buffer copy и сравнить полученные bytes с зафиксированным reference capture. +### First synchronized Vulkan pixel-readback artifact + +Stage 3 static viewer теперь выполняет фактический readback для surface с +`TRANSFER_SRC`: на каждый swapchain image создаётся host-visible coherent +`TRANSFER_DST` buffer. После render pass command buffer переводит image из +`PRESENT_SRC_KHR` в `TRANSFER_SRC_OPTIMAL`, выполняет `vkCmdCopyImageToBuffer` +и возвращает image в `PRESENT_SRC_KHR`. CPU отображает memory только после +`vkDeviceWaitIdle` during shutdown; это исключает чтение GPU work in flight. +Smoke JSON фиксирует число записанных copy-команд, final byte count и FNV-1a-64 +hash всех current-swapchain readback buffers, не сохраняя игровые pixels в +репозитории. + +На GOG `MTCHECK.MSH`/`DEFAULT.0` AMD Radeon Pro WX 3200 Series выполнил 300 +copy-команд, final artifact 4,147,200 bytes и hash `2184179010340020629` при +validation warnings/errors `0/0`. Повторный идентичный запуск дал тот же +размер и hash. Это доказывает Vulkan copy/readback path только для нашего +static viewer. Он ещё не захватывает original DirectDraw frame, не задаёт +fixed original camera и не сравнивает два изображения, поэтому pixel-parity +acceptance остаётся blocked. + `Land.msh` использует отдельный geometry-only bridge: validated `TerrainFace28` сохраняет source triangle order, а его positions и packed UV0 попадают в тот же static vertex/index upload path. Для текущего диагностического viewer XZ bounds