From b24594c6a20e43b8080a368ad9a20708caddcde6 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 09:08:07 +0400 Subject: [PATCH] feat(vulkan): export readback artifacts --- adapters/fparkan-render-vulkan/src/ffi.rs | 10 ++-- .../fparkan-render-vulkan/src/ffi/smoke.rs | 46 ++++++++++++------- .../src/ffi/smoke_types.rs | 15 ++++++ apps/fparkan-vulkan-smoke/src/main.rs | 36 +++++++++++++-- docs/tomes/05-render.md | 6 +++ 5 files changed, 87 insertions(+), 26 deletions(-) diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs index eef9fc1..7b53c92 100644 --- a/adapters/fparkan-render-vulkan/src/ffi.rs +++ b/adapters/fparkan-render-vulkan/src/ffi.rs @@ -66,11 +66,11 @@ pub use self::runtime::{ VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanLogicalDeviceReport, }; pub use self::smoke_types::{ - VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot, VulkanSmokeFrameOutcome, - VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererError, - VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanStaticDrawRange, - VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, VulkanStaticVertex, - VulkanValidationReport, + VulkanReadbackArtifact, VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot, + VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, + VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport, + VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, + VulkanStaticVertex, VulkanValidationReport, }; #[cfg(test)] use self::surface::extension_name; diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index eee2f06..5ddfae6 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -10,10 +10,11 @@ use super::{ 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, readback_buffer_bytes, VulkanAllocatedBuffer, - VulkanInstanceConfig, VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeFrameOutcome, - VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererError, - VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanSurfaceProbe, VulkanSwapchainProbe, - VulkanSwapchainResources, VulkanValidationMessenger, VulkanValidationReport, + VulkanInstanceConfig, VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanReadbackArtifact, + 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}; @@ -845,10 +846,18 @@ impl VulkanSmokeRenderer { } else { None }; - if let Some((byte_count, hash)) = completed_readback { - self.report.readback_byte_count = byte_count; - self.report.readback_fnv1a64 = hash; - } + let readback_artifact = completed_readback.map(|bytes| { + self.report.readback_byte_count = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + self.report.readback_fnv1a64 = fnv1a64(&bytes); + VulkanReadbackArtifact { + format: self + .swapchain_ref() + .map_or(0, |swapchain| swapchain.report.plan.format.format), + width: self.report.swapchain_extent.0, + height: self.report.swapchain_extent.1, + bytes, + } + }); self.destroy_device_owned_resources(); let validation = take_runtime_children_with_validation_snapshot( &mut self.surface, @@ -862,6 +871,7 @@ impl VulkanSmokeRenderer { self.instance.take(); Ok(VulkanSmokeShutdownReport { renderer_report: self.report.clone(), + readback_artifact, swapchain_recreate_count: self.swapchain_recreate_count, validation, }) @@ -870,7 +880,7 @@ impl VulkanSmokeRenderer { fn completed_readback( &self, device: &VulkanLogicalDeviceProbe, - ) -> Result, VulkanSmokeRendererError> { + ) -> Result>, VulkanSmokeRendererError> { let Some(resources) = self.swapchain_resources.as_ref() else { return Ok(None); }; @@ -888,17 +898,13 @@ impl VulkanSmokeRenderer { if resources.readback_buffers.is_empty() { return Ok(None); } - let mut hash = 0xcbf2_9ce4_8422_2325_u64; - let mut byte_count = 0_u64; + let mut artifact = + Vec::with_capacity(byte_len.saturating_mul(resources.readback_buffers.len())); 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)); + artifact.extend_from_slice(&bytes); } - Ok(Some((byte_count, hash))) + Ok(Some(artifact)) } fn teardown(&mut self) { @@ -919,6 +925,12 @@ impl VulkanSmokeRenderer { } } +fn fnv1a64(bytes: &[u8]) -> u64 { + bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) + }) +} + impl Drop for VulkanSmokeRenderer { fn drop(&mut self) { self.teardown(); diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index ce691b8..60f86e2 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -478,11 +478,26 @@ pub struct VulkanValidationReport { pub vuids: Vec, } +/// CPU-owned copy of the final completed swapchain readback buffers. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VulkanReadbackArtifact { + /// Vulkan swapchain format as a raw enum value. + pub format: i32, + /// Width of each image in pixels. + pub width: u32, + /// Height of each image in pixels. + pub height: u32, + /// Concatenated raw four-byte-per-pixel images in swapchain order. + pub bytes: Vec, +} + /// Final smoke renderer shutdown evidence captured after explicit teardown. #[derive(Clone, Debug, Eq, PartialEq)] pub struct VulkanSmokeShutdownReport { /// Stable renderer bootstrap and swapchain report. pub renderer_report: VulkanSmokeRendererReport, + /// Optional raw GPU readback retained after synchronization and before teardown. + pub readback_artifact: Option, /// Measured swapchain recreation count for the completed smoke loop. pub swapchain_recreate_count: u32, /// Final validation snapshot captured before the debug messenger is destroyed. diff --git a/apps/fparkan-vulkan-smoke/src/main.rs b/apps/fparkan-vulkan-smoke/src/main.rs index a3f7226..0fbd843 100644 --- a/apps/fparkan-vulkan-smoke/src/main.rs +++ b/apps/fparkan-vulkan-smoke/src/main.rs @@ -14,10 +14,10 @@ use fparkan_platform::RenderRequest; use fparkan_platform_winit::{window_native_handles, WinitWindowPlan}; use fparkan_render_vulkan::{ - project_land_msh_to_static_mesh, project_msh_to_static_mesh, VulkanSmokeBootstrapProgress, - VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, - VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanStaticMaterial, VulkanStaticMesh, - VulkanStaticTexture, VulkanValidationReport, + project_land_msh_to_static_mesh, project_msh_to_static_mesh, VulkanReadbackArtifact, + VulkanSmokeBootstrapProgress, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, + VulkanSmokeRendererCreateInfo, VulkanSmokeRendererReport, VulkanSmokeShutdownReport, + VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, VulkanValidationReport, }; use serde::Serialize; use std::path::PathBuf; @@ -557,6 +557,7 @@ fn drop_renderer_before_window( #[derive(Clone, Debug)] struct RendererSnapshot { report: VulkanSmokeRendererReport, + readback_artifact: Option, swapchain_recreate_count: u32, validation: VulkanValidationReport, } @@ -565,6 +566,7 @@ impl From for RendererSnapshot { fn from(report: VulkanSmokeShutdownReport) -> Self { Self { report: report.renderer_report, + readback_artifact: report.readback_artifact, swapchain_recreate_count: report.swapchain_recreate_count, validation: report.validation, } @@ -627,9 +629,24 @@ impl SmokeApp { .map_err(|err| format!("{}: {err}", self.options.out.display())) } + fn write_readback_artifact(&self, artifact: &VulkanReadbackArtifact) -> Result<(), String> { + let stem = self + .options + .out + .file_stem() + .and_then(std::ffi::OsStr::to_str) + .unwrap_or("smoke"); + let path = self + .options + .out + .with_file_name(format!("{stem}.readback-b8g8r8a8.raw")); + std::fs::write(&path, &artifact.bytes).map_err(|err| format!("{}: {err}", path.display())) + } + fn live_renderer_snapshot(&self) -> Option { self.renderer.as_ref().map(|renderer| RendererSnapshot { report: renderer.report().clone(), + readback_artifact: None, swapchain_recreate_count: renderer.swapchain_recreate_count(), validation: renderer.validation_report(), }) @@ -866,6 +883,17 @@ impl SmokeApp { return; } self.final_renderer = Some(final_renderer); + if let Some(artifact) = self + .final_renderer + .as_ref() + .and_then(|snapshot| snapshot.readback_artifact.as_ref()) + { + if let Err(err) = self.write_readback_artifact(artifact) { + self.error = Some(err); + event_loop.exit(); + return; + } + } let report = match self.render_report("passed", None) { Ok(report) => report, Err(err) => { diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 3e0a149..6bc1915 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -895,6 +895,12 @@ static viewer. Он ещё не захватывает original DirectDraw frame fixed original camera и не сравнивает два изображения, поэтому pixel-parity acceptance остаётся blocked. +Smoke также сохраняет raw artifact рядом с JSON: `.readback-b8g8r8a8.raw`. +Это concatenated current-swapchain images in Vulkan order, каждый с dimensions +из JSON и четырьмя bytes per pixel; format намеренно указан в имени файла, +потому что bytes не перекодируются. GOG proof produced a 4,147,200-byte file +for two 960x540 images. Артефакт остаётся локальным output и не попадает в Git. + `Land.msh` использует отдельный geometry-only bridge: validated `TerrainFace28` сохраняет source triangle order, а его positions и packed UV0 попадают в тот же static vertex/index upload path. Для текущего диагностического viewer XZ bounds