feat: add Vulkan surface bootstrap boundary

This commit is contained in:
2026-06-23 22:53:54 +04:00
parent a0a4089e4b
commit f5fae8e84a
3 changed files with 194 additions and 4 deletions
+188 -4
View File
@@ -27,8 +27,8 @@
//! //!
//! This crate is the declared low-level Vulkan boundary. //! This crate is the declared low-level Vulkan boundary.
use ash::vk; use ash::{khr::surface, vk};
use fparkan_platform::RenderRequest; use fparkan_platform::{NativeWindowHandles, RenderRequest};
use fparkan_render::{ use fparkan_render::{
canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError, canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError,
}; };
@@ -83,7 +83,7 @@ pub struct VulkanInstancePlan {
/// Created Vulkan instance probe. /// Created Vulkan instance probe.
pub struct VulkanInstanceProbe { pub struct VulkanInstanceProbe {
_entry: ash::Entry, entry: ash::Entry,
instance: ash::Instance, instance: ash::Instance,
/// Deterministic instance creation report. /// Deterministic instance creation report.
pub report: VulkanInstancePlan, 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. /// Vulkan instance bootstrap error.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanInstanceError { pub enum VulkanInstanceError {
@@ -198,7 +349,7 @@ pub fn create_vulkan_instance_probe(
} }
})?; })?;
Ok(VulkanInstanceProbe { Ok(VulkanInstanceProbe {
_entry: entry, entry,
instance, instance,
report: plan, 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( fn device(
name: &str, name: &str,
device_type: VulkanDeviceType, device_type: VulkanDeviceType,
+3
View File
@@ -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-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-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-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-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 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 L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
1 # Acceptance coverage manifest.
33 S0-VK-008
34 S0-VK-009
35 S0-VK-010
36 S0-VK-011
37 S0-VK-012
38 S0-VK-013
39 S0-LIMIT-001
40 S0-LIMIT-002
41 L1-P1-NRES-001
+3
View File
@@ -33,6 +33,9 @@
`S0-VK-008` `S0-VK-008`
`S0-VK-009` `S0-VK-009`
`S0-VK-010` `S0-VK-010`
`S0-VK-011`
`S0-VK-012`
`S0-VK-013`
`S0-LIMIT-001` `S0-LIMIT-001`
`S0-LIMIT-002` `S0-LIMIT-002`
`L1-P1-NRES-001` `L1-P1-NRES-001`