aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi.rs7
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/resources.rs43
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke.rs120
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs6
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs38
-rw-r--r--apps/fparkan-vulkan-smoke/src/main.rs20
-rw-r--r--docs/tomes/05-render.md20
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<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
+ 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<Vec<u8>, 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::<u8>(), 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<VulkanSmokeShutdownReport, VulkanSmokeRendererError> {
- 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<Option<(u64, u64)>, 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<vk::ImageView>,
+ pub(super) images: Vec<vk::Image>,
+ pub(super) readback_buffers: Vec<VulkanAllocatedBuffer>,
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<vk::ImageView>,
+ readback_buffers: Vec<VulkanAllocatedBuffer>,
depth_attachment: Option<VulkanDepthAttachment>,
render_pass: Option<vk::RenderPass>,
pipeline_layout: Option<vk::PipelineLayout>,
@@ -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