aboutsummaryrefslogtreecommitdiff
path: root/adapters/fparkan-render-vulkan/src
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-07-18 08:05:20 +0300
committerValentin Popov <valentin@popov.link>2026-07-18 08:05:20 +0300
commitf9a13298c0d5ef9bc0df968d178983506c24c0a6 (patch)
tree48a5e08d3f3dc614d9345fd937c6601eb8005518 /adapters/fparkan-render-vulkan/src
parent9f6b21638d396783809bbd941d4cf0d548122995 (diff)
downloadfparkan-f9a13298c0d5ef9bc0df968d178983506c24c0a6.tar.xz
fparkan-f9a13298c0d5ef9bc0df968d178983506c24c0a6.zip
feat(vulkan): capture swapchain pixels
Diffstat (limited to 'adapters/fparkan-render-vulkan/src')
-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
5 files changed, 205 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);
+ }
}
}