diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-23 21:53:54 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-23 21:53:54 +0300 |
| commit | f5fae8e84a346d4322bad06647315265eebfa21f (patch) | |
| tree | 2c375fc848cf3be7db6358459900ccb0ce070589 | |
| parent | a0a4089e4b75296e43a89f9a9ca2592f7fc2f68f (diff) | |
| download | fparkan-f5fae8e84a346d4322bad06647315265eebfa21f.tar.xz fparkan-f5fae8e84a346d4322bad06647315265eebfa21f.zip | |
feat: add Vulkan surface bootstrap boundary
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/lib.rs | 192 | ||||
| -rw-r--r-- | fixtures/acceptance/coverage.tsv | 3 | ||||
| -rw-r--r-- | fixtures/acceptance/stage_0_2_roadmap.md | 3 |
3 files changed, 194 insertions, 4 deletions
diff --git a/adapters/fparkan-render-vulkan/src/lib.rs b/adapters/fparkan-render-vulkan/src/lib.rs index 1db4f68..60e4f54 100644 --- a/adapters/fparkan-render-vulkan/src/lib.rs +++ b/adapters/fparkan-render-vulkan/src/lib.rs @@ -27,8 +27,8 @@ //! //! This crate is the declared low-level Vulkan boundary. -use ash::vk; -use fparkan_platform::RenderRequest; +use ash::{khr::surface, vk}; +use fparkan_platform::{NativeWindowHandles, RenderRequest}; use fparkan_render::{ canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError, }; @@ -83,7 +83,7 @@ pub struct VulkanInstancePlan { /// Created Vulkan instance probe. pub struct VulkanInstanceProbe { - _entry: ash::Entry, + entry: ash::Entry, instance: ash::Instance, /// Deterministic instance creation report. pub report: VulkanInstancePlan, @@ -96,6 +96,157 @@ impl Drop for VulkanInstanceProbe { } } +/// Deterministic Vulkan surface creation plan. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VulkanSurfacePlan { + /// Report schema version. + pub schema: u32, + /// Instance extensions required by the native display backend. + pub required_instance_extensions: Vec<String>, +} + +/// Vulkan surface bootstrap error. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VulkanSurfaceError { + /// No native raw window/display handles were available. + MissingNativeHandles, + /// Required platform surface extensions could not be enumerated. + RequiredExtensionsFailed { + /// Vulkan result. + result: String, + }, + /// A required extension pointer was not valid UTF-8. + InvalidExtensionName, + /// Surface creation failed. + CreateFailed { + /// Vulkan result. + result: String, + }, +} + +impl std::fmt::Display for VulkanSurfaceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingNativeHandles => { + write!( + f, + "native window/display handles are required for Vulkan surface creation" + ) + } + Self::RequiredExtensionsFailed { result } => write!( + f, + "failed to enumerate required Vulkan surface extensions: {result}" + ), + Self::InvalidExtensionName => { + write!(f, "Vulkan surface extension name is not valid UTF-8") + } + Self::CreateFailed { result } => write!(f, "Vulkan surface creation failed: {result}"), + } + } +} + +impl std::error::Error for VulkanSurfaceError {} + +/// Created Vulkan surface probe. +pub struct VulkanSurfaceProbe { + loader: surface::Instance, + surface: vk::SurfaceKHR, + /// Deterministic surface creation report. + pub report: VulkanSurfacePlan, +} + +impl Drop for VulkanSurfaceProbe { + fn drop(&mut self) { + // SAFETY: The `SurfaceKHR` was created by this probe and is destroyed once during drop. + unsafe { self.loader.destroy_surface(self.surface, None) }; + } +} + +/// Builds a deterministic Vulkan surface plan from native window handles. +/// +/// # Errors +/// +/// Returns [`VulkanSurfaceError`] when no native handles exist or the platform +/// display backend has no Vulkan surface extension mapping. +pub fn plan_vulkan_surface( + handles: Option<NativeWindowHandles>, +) -> Result<VulkanSurfacePlan, VulkanSurfaceError> { + let handles = handles.ok_or(VulkanSurfaceError::MissingNativeHandles)?; + let required = ash_window::enumerate_required_extensions(handles.display).map_err(|error| { + VulkanSurfaceError::RequiredExtensionsFailed { + result: format!("{error:?}"), + } + })?; + let mut required_instance_extensions = Vec::with_capacity(required.len()); + for extension in required { + let name = extension_name(*extension)?; + required_instance_extensions.push(name); + } + required_instance_extensions.sort(); + required_instance_extensions.dedup(); + Ok(VulkanSurfacePlan { + schema: 1, + required_instance_extensions, + }) +} + +/// Creates a Vulkan surface probe from native window handles. +/// +/// # Errors +/// +/// Returns [`VulkanSurfaceError`] when handles are missing, required extensions +/// cannot be planned, or `vkCreate*SurfaceKHR` fails. +pub fn create_vulkan_surface_probe( + instance: &VulkanInstanceProbe, + handles: Option<NativeWindowHandles>, +) -> Result<VulkanSurfaceProbe, VulkanSurfaceError> { + let handles = handles.ok_or(VulkanSurfaceError::MissingNativeHandles)?; + let report = plan_vulkan_surface(Some(handles))?; + // SAFETY: The platform handles are only used to create a child surface owned by this probe. + let surface = unsafe { + ash_window::create_surface( + &instance.entry, + &instance.instance, + handles.display, + handles.window, + None, + ) + } + .map_err(|error| VulkanSurfaceError::CreateFailed { + result: format!("{error:?}"), + })?; + Ok(VulkanSurfaceProbe { + loader: surface::Instance::new(&instance.entry, &instance.instance), + surface, + report, + }) +} + +/// Renders a deterministic JSON Vulkan surface plan. +#[must_use] +pub fn render_surface_plan_json(plan: &VulkanSurfacePlan) -> String { + let mut out = String::new(); + out.push_str("{\"schema\":"); + out.push_str(&plan.schema.to_string()); + out.push_str(",\"required_instance_extensions\":["); + for (index, extension) in plan.required_instance_extensions.iter().enumerate() { + if index > 0 { + out.push(','); + } + push_json_string(&mut out, extension); + } + out.push_str("]}"); + out +} + +fn extension_name(extension: *const c_char) -> Result<String, VulkanSurfaceError> { + // SAFETY: `ash-window` returns extension pointers to static NUL-terminated Vulkan names. + let name = unsafe { CStr::from_ptr(extension) }; + name.to_str() + .map(str::to_string) + .map_err(|_| VulkanSurfaceError::InvalidExtensionName) +} + /// Vulkan instance bootstrap error. #[derive(Clone, Debug, Eq, PartialEq)] pub enum VulkanInstanceError { @@ -198,7 +349,7 @@ pub fn create_vulkan_instance_probe( } })?; Ok(VulkanInstanceProbe { - _entry: entry, + entry, instance, report: plan, }) @@ -1004,6 +1155,39 @@ mod tests { ); } + #[test] + fn surface_plan_requires_native_handles() { + assert_eq!( + plan_vulkan_surface(None), + Err(VulkanSurfaceError::MissingNativeHandles) + ); + assert_eq!( + VulkanSurfaceError::MissingNativeHandles.to_string(), + "native window/display handles are required for Vulkan surface creation" + ); + } + + #[test] + fn surface_plan_json_is_stable() { + assert_eq!( + render_surface_plan_json(&VulkanSurfacePlan { + schema: 1, + required_instance_extensions: vec![ + "VK_KHR_surface".to_string(), + "VK_EXT_metal_surface".to_string(), + ], + }), + "{\"schema\":1,\"required_instance_extensions\":[\"VK_KHR_surface\",\"VK_EXT_metal_surface\"]}" + ); + } + + #[test] + fn static_surface_extension_name_is_decoded() { + let name = extension_name(ash::khr::surface::NAME.as_ptr()).expect("extension name"); + + assert_eq!(name, "VK_KHR_surface"); + } + fn device( name: &str, device_type: VulkanDeviceType, diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv index 048a654..7b24c69 100644 --- a/fixtures/acceptance/coverage.tsv +++ b/fixtures/acceptance/coverage.tsv @@ -33,6 +33,9 @@ S0-VK-007 covered cargo xtask policy S0-VK-008 covered cargo test -p fparkan-render-vulkan --offline instance_plan_is_sorted_deduplicated_and_portability_aware S0-VK-009 covered cargo test -p fparkan-render-vulkan --offline instance_plan_adds_portability_extension_when_requested S0-VK-010 covered cargo test -p fparkan-render-vulkan --offline invalid_instance_extension_name_is_reported_before_loader_use +S0-VK-011 covered cargo test -p fparkan-render-vulkan --offline surface_plan_requires_native_handles +S0-VK-012 covered cargo test -p fparkan-render-vulkan --offline surface_plan_json_is_stable +S0-VK-013 covered cargo test -p fparkan-render-vulkan --offline static_surface_extension_name_is_decoded S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates diff --git a/fixtures/acceptance/stage_0_2_roadmap.md b/fixtures/acceptance/stage_0_2_roadmap.md index 66d8cdd..e203bad 100644 --- a/fixtures/acceptance/stage_0_2_roadmap.md +++ b/fixtures/acceptance/stage_0_2_roadmap.md @@ -33,6 +33,9 @@ `S0-VK-008` `S0-VK-009` `S0-VK-010` +`S0-VK-011` +`S0-VK-012` +`S0-VK-013` `S0-LIMIT-001` `S0-LIMIT-002` `L1-P1-NRES-001` |
