aboutsummaryrefslogtreecommitdiff
path: root/adapters/fparkan-render-vulkan/src
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-23 21:40:01 +0300
committerValentin Popov <valentin@popov.link>2026-06-23 21:40:01 +0300
commit69c032accab677b53f3dff61f3afb870c2e8e0a8 (patch)
tree626bd9204ae2ade533a1e1828093a676a490610f /adapters/fparkan-render-vulkan/src
parent9cc24e715db81edbe21c0d04aadd00f11dddecb8 (diff)
downloadfparkan-69c032accab677b53f3dff61f3afb870c2e8e0a8.tar.xz
fparkan-69c032accab677b53f3dff61f3afb870c2e8e0a8.zip
feat: add Vulkan capability selection boundary
Diffstat (limited to 'adapters/fparkan-render-vulkan/src')
-rw-r--r--adapters/fparkan-render-vulkan/src/lib.rs477
1 files changed, 477 insertions, 0 deletions
diff --git a/adapters/fparkan-render-vulkan/src/lib.rs b/adapters/fparkan-render-vulkan/src/lib.rs
index 6cae797..75447e8 100644
--- a/adapters/fparkan-render-vulkan/src/lib.rs
+++ b/adapters/fparkan-render-vulkan/src/lib.rs
@@ -27,12 +27,18 @@
//!
//! This crate is the declared low-level Vulkan boundary.
+use ash::vk;
use fparkan_platform::RenderRequest;
use fparkan_render::{
canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError,
};
use std::time::{SystemTime, UNIX_EPOCH};
+/// Minimum Vulkan API version accepted by the Stage 0 backend.
+pub const MIN_VULKAN_API_VERSION: u32 = vk::API_VERSION_1_1;
+const KHR_SWAPCHAIN_EXTENSION: &str = "VK_KHR_swapchain";
+const KHR_PORTABILITY_SUBSET_EXTENSION: &str = "VK_KHR_portability_subset";
+
/// Vulkan backend migration readiness.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VulkanBackendState {
@@ -50,6 +56,330 @@ impl Default for VulkanBackendState {
}
}
+/// Synthetic physical-device type used by deterministic capability scoring.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum VulkanDeviceType {
+ /// Discrete GPU.
+ DiscreteGpu,
+ /// Integrated GPU.
+ IntegratedGpu,
+ /// CPU or software Vulkan implementation.
+ Cpu,
+ /// Other or unknown implementation.
+ Other,
+}
+
+impl VulkanDeviceType {
+ const fn score_bonus(self) -> i32 {
+ match self {
+ Self::DiscreteGpu => 1_000,
+ Self::IntegratedGpu => 700,
+ Self::Cpu => 100,
+ Self::Other => 10,
+ }
+ }
+}
+
+/// Queue-family capabilities needed by the Stage 0 renderer.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct VulkanQueueFamily {
+ /// Stable queue-family index.
+ pub index: u32,
+ /// Whether the family supports graphics commands.
+ pub graphics: bool,
+ /// Whether the family supports presentation for the target surface.
+ pub present: bool,
+}
+
+/// Surface format capability needed by the Stage 0 swapchain policy.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct VulkanSurfaceFormat {
+ /// Vulkan format numeric value.
+ pub format: i32,
+ /// Vulkan color-space numeric value.
+ pub color_space: i32,
+}
+
+/// Synthetic physical-device capabilities used by negative tests and reports.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct VulkanPhysicalDeviceRecord {
+ /// Human-readable device name.
+ pub name: String,
+ /// Reported Vulkan API version.
+ pub api_version: u32,
+ /// Device class.
+ pub device_type: VulkanDeviceType,
+ /// Supported device-extension names.
+ pub extensions: Vec<String>,
+ /// Queue-family capabilities.
+ pub queue_families: Vec<VulkanQueueFamily>,
+ /// Surface formats accepted by the target surface.
+ pub surface_formats: Vec<VulkanSurfaceFormat>,
+}
+
+impl VulkanPhysicalDeviceRecord {
+ /// Returns whether the device supports an extension name.
+ #[must_use]
+ pub fn supports_extension(&self, extension: &str) -> bool {
+ self.extensions
+ .iter()
+ .any(|candidate| candidate == extension)
+ }
+}
+
+/// Selected device and queue capability report.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct VulkanCapabilityReport {
+ /// Report schema version.
+ pub schema: u32,
+ /// Selected device name.
+ pub device_name: String,
+ /// Selected Vulkan API version.
+ pub vulkan_api_version: u32,
+ /// Deterministic score used for device selection.
+ pub score: i32,
+ /// Graphics queue family index.
+ pub graphics_queue_family: u32,
+ /// Present queue family index.
+ pub present_queue_family: u32,
+ /// Whether portability subset is enabled for the selected device.
+ pub portability_subset: bool,
+ /// Enabled device extensions.
+ pub enabled_extensions: Vec<String>,
+}
+
+/// Vulkan capability selection error.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum VulkanCapabilityError {
+ /// No physical devices were available.
+ NoPhysicalDevice,
+ /// Device API version is lower than the Stage 0 minimum.
+ ApiVersionTooLow {
+ /// Required Vulkan API version.
+ required: u32,
+ /// Reported Vulkan API version.
+ found: u32,
+ },
+ /// Required graphics queue is unavailable.
+ NoGraphicsQueue {
+ /// Device name that failed validation.
+ device: String,
+ },
+ /// Required present queue is unavailable.
+ NoPresentQueue {
+ /// Device name that failed validation.
+ device: String,
+ },
+ /// Swapchain device extension is unavailable.
+ MissingSwapchainExtension {
+ /// Device name that failed validation.
+ device: String,
+ },
+ /// No compatible surface format exists.
+ MissingSurfaceFormat {
+ /// Device name that failed validation.
+ device: String,
+ },
+}
+
+impl std::fmt::Display for VulkanCapabilityError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::NoPhysicalDevice => write!(f, "no Vulkan physical device available"),
+ Self::ApiVersionTooLow { required, found } => write!(
+ f,
+ "Vulkan API version too low: required {}, found {}",
+ format_api_version(*required),
+ format_api_version(*found)
+ ),
+ Self::NoGraphicsQueue { device } => {
+ write!(f, "Vulkan device {device} has no graphics queue")
+ }
+ Self::NoPresentQueue { device } => {
+ write!(f, "Vulkan device {device} has no present queue")
+ }
+ Self::MissingSwapchainExtension { device } => {
+ write!(f, "Vulkan device {device} lacks {KHR_SWAPCHAIN_EXTENSION}")
+ }
+ Self::MissingSurfaceFormat { device } => {
+ write!(f, "Vulkan device {device} has no compatible surface format")
+ }
+ }
+ }
+}
+
+impl std::error::Error for VulkanCapabilityError {}
+
+/// Selects a Vulkan physical device using deterministic Stage 0 policy.
+///
+/// # Errors
+///
+/// Returns [`VulkanCapabilityError`] when no candidate satisfies the minimum
+/// API version, queue, swapchain-extension and surface-format requirements.
+pub fn select_physical_device(
+ devices: &[VulkanPhysicalDeviceRecord],
+) -> Result<VulkanCapabilityReport, VulkanCapabilityError> {
+ if devices.is_empty() {
+ return Err(VulkanCapabilityError::NoPhysicalDevice);
+ }
+
+ let mut best = None;
+ for device in devices {
+ let report = validate_device(device)?;
+ match &best {
+ Some(existing) if compare_reports(&report, existing) != std::cmp::Ordering::Greater => {
+ }
+ _ => best = Some(report),
+ }
+ }
+ best.ok_or(VulkanCapabilityError::NoPhysicalDevice)
+}
+
+fn validate_device(
+ device: &VulkanPhysicalDeviceRecord,
+) -> Result<VulkanCapabilityReport, VulkanCapabilityError> {
+ if device.api_version < MIN_VULKAN_API_VERSION {
+ return Err(VulkanCapabilityError::ApiVersionTooLow {
+ required: MIN_VULKAN_API_VERSION,
+ found: device.api_version,
+ });
+ }
+ if !device.supports_extension(KHR_SWAPCHAIN_EXTENSION) {
+ return Err(VulkanCapabilityError::MissingSwapchainExtension {
+ device: device.name.clone(),
+ });
+ }
+ if device.surface_formats.is_empty() {
+ return Err(VulkanCapabilityError::MissingSurfaceFormat {
+ device: device.name.clone(),
+ });
+ }
+ let graphics_queue_family = device
+ .queue_families
+ .iter()
+ .find(|family| family.graphics)
+ .ok_or_else(|| VulkanCapabilityError::NoGraphicsQueue {
+ device: device.name.clone(),
+ })?
+ .index;
+ let present_queue_family = device
+ .queue_families
+ .iter()
+ .find(|family| family.present)
+ .ok_or_else(|| VulkanCapabilityError::NoPresentQueue {
+ device: device.name.clone(),
+ })?
+ .index;
+
+ let portability_subset = device.supports_extension(KHR_PORTABILITY_SUBSET_EXTENSION);
+ let mut enabled_extensions = vec![KHR_SWAPCHAIN_EXTENSION.to_string()];
+ if portability_subset {
+ enabled_extensions.push(KHR_PORTABILITY_SUBSET_EXTENSION.to_string());
+ }
+
+ Ok(VulkanCapabilityReport {
+ schema: 1,
+ device_name: device.name.clone(),
+ vulkan_api_version: device.api_version,
+ score: score_device(device, graphics_queue_family, present_queue_family),
+ graphics_queue_family,
+ present_queue_family,
+ portability_subset,
+ enabled_extensions,
+ })
+}
+
+fn score_device(
+ device: &VulkanPhysicalDeviceRecord,
+ graphics_queue_family: u32,
+ present_queue_family: u32,
+) -> i32 {
+ let unified_queue_bonus = if graphics_queue_family == present_queue_family {
+ 100
+ } else {
+ 0
+ };
+ let portability_penalty = if device.supports_extension(KHR_PORTABILITY_SUBSET_EXTENSION) {
+ -50
+ } else {
+ 0
+ };
+ device.device_type.score_bonus()
+ + unified_queue_bonus
+ + portability_penalty
+ + i32::try_from(device.surface_formats.len()).unwrap_or(i32::MAX)
+}
+
+fn compare_reports(
+ left: &VulkanCapabilityReport,
+ right: &VulkanCapabilityReport,
+) -> std::cmp::Ordering {
+ left.score
+ .cmp(&right.score)
+ .then_with(|| right.device_name.cmp(&left.device_name))
+}
+
+/// Renders a deterministic JSON capability report.
+#[must_use]
+pub fn render_capability_report_json(report: &VulkanCapabilityReport) -> String {
+ let mut out = String::new();
+ out.push_str("{\"schema\":");
+ out.push_str(&report.schema.to_string());
+ out.push_str(",\"vulkan_api\":\"");
+ out.push_str(&format_api_version(report.vulkan_api_version));
+ out.push_str("\",\"device_name\":");
+ push_json_string(&mut out, &report.device_name);
+ out.push_str(",\"score\":");
+ out.push_str(&report.score.to_string());
+ out.push_str(",\"graphics_queue_family\":");
+ out.push_str(&report.graphics_queue_family.to_string());
+ out.push_str(",\"present_queue_family\":");
+ out.push_str(&report.present_queue_family.to_string());
+ out.push_str(",\"portability_subset\":");
+ out.push_str(if report.portability_subset {
+ "true"
+ } else {
+ "false"
+ });
+ out.push_str(",\"enabled_extensions\":[");
+ for (index, extension) in report.enabled_extensions.iter().enumerate() {
+ if index > 0 {
+ out.push(',');
+ }
+ push_json_string(&mut out, extension);
+ }
+ out.push_str("]}");
+ out
+}
+
+fn format_api_version(version: u32) -> String {
+ format!(
+ "{}.{}.{}",
+ vk::api_version_major(version),
+ vk::api_version_minor(version),
+ vk::api_version_patch(version)
+ )
+}
+
+fn push_json_string(out: &mut String, value: &str) {
+ out.push('"');
+ for ch in value.chars() {
+ match ch {
+ '"' => out.push_str("\\\""),
+ '\\' => out.push_str("\\\\"),
+ '\n' => out.push_str("\\n"),
+ '\r' => out.push_str("\\r"),
+ '\t' => out.push_str("\\t"),
+ c if c.is_control() => {
+ use std::fmt::Write as _;
+ let _ = write!(out, "\\u{:04x}", c as u32);
+ }
+ c => out.push(c),
+ }
+ }
+ out.push('"');
+}
+
/// Diagnostics for Vulkan backend setup and frame progression.
#[derive(Clone, Debug, PartialEq)]
pub struct VulkanBackendReport {
@@ -194,4 +524,151 @@ mod tests {
assert!(backend.report().last_capture_size > 0);
Ok(())
}
+
+ #[test]
+ fn device_scoring_is_deterministic_and_prefers_discrete_unified_queue() {
+ let devices = vec![
+ device("SwiftShader", VulkanDeviceType::Cpu, 0, true, false),
+ device("Discrete", VulkanDeviceType::DiscreteGpu, 1, true, false),
+ device(
+ "Integrated",
+ VulkanDeviceType::IntegratedGpu,
+ 2,
+ true,
+ false,
+ ),
+ ];
+
+ let report = select_physical_device(&devices).expect("selected device");
+
+ assert_eq!(report.device_name, "Discrete");
+ assert_eq!(report.graphics_queue_family, 1);
+ assert_eq!(report.present_queue_family, 1);
+ assert!(!report.portability_subset);
+ assert_eq!(report.enabled_extensions, vec![KHR_SWAPCHAIN_EXTENSION]);
+ }
+
+ #[test]
+ fn portability_subset_is_reported_and_enabled_when_exposed() {
+ let report = select_physical_device(&[device(
+ "MoltenVK",
+ VulkanDeviceType::IntegratedGpu,
+ 0,
+ true,
+ true,
+ )])
+ .expect("selected device");
+
+ assert!(report.portability_subset);
+ assert_eq!(
+ report.enabled_extensions,
+ vec![
+ KHR_SWAPCHAIN_EXTENSION.to_string(),
+ KHR_PORTABILITY_SUBSET_EXTENSION.to_string()
+ ]
+ );
+ }
+
+ #[test]
+ fn missing_loader_candidates_are_reported() {
+ assert_eq!(
+ select_physical_device(&[]),
+ Err(VulkanCapabilityError::NoPhysicalDevice)
+ );
+ }
+
+ #[test]
+ fn rejects_low_api_version() {
+ let mut candidate = device("Old GPU", VulkanDeviceType::DiscreteGpu, 0, true, false);
+ candidate.api_version = vk::API_VERSION_1_0;
+
+ assert!(matches!(
+ select_physical_device(&[candidate]),
+ Err(VulkanCapabilityError::ApiVersionTooLow { .. })
+ ));
+ }
+
+ #[test]
+ fn rejects_missing_graphics_present_swapchain_and_format() {
+ let mut no_graphics = device("No graphics", VulkanDeviceType::DiscreteGpu, 0, true, false);
+ no_graphics.queue_families[0].graphics = false;
+ assert!(matches!(
+ select_physical_device(&[no_graphics]),
+ Err(VulkanCapabilityError::NoGraphicsQueue { .. })
+ ));
+
+ let mut no_present = device("No present", VulkanDeviceType::DiscreteGpu, 0, true, false);
+ no_present.queue_families[0].present = false;
+ assert!(matches!(
+ select_physical_device(&[no_present]),
+ Err(VulkanCapabilityError::NoPresentQueue { .. })
+ ));
+
+ let no_swapchain = device(
+ "No swapchain",
+ VulkanDeviceType::DiscreteGpu,
+ 0,
+ false,
+ false,
+ );
+ assert!(matches!(
+ select_physical_device(&[no_swapchain]),
+ Err(VulkanCapabilityError::MissingSwapchainExtension { .. })
+ ));
+
+ let mut no_format = device("No format", VulkanDeviceType::DiscreteGpu, 0, true, false);
+ no_format.surface_formats.clear();
+ assert!(matches!(
+ select_physical_device(&[no_format]),
+ Err(VulkanCapabilityError::MissingSurfaceFormat { .. })
+ ));
+ }
+
+ #[test]
+ fn capability_report_json_is_stable() {
+ let report = select_physical_device(&[device(
+ "GPU \"A\"",
+ VulkanDeviceType::DiscreteGpu,
+ 3,
+ true,
+ false,
+ )])
+ .expect("selected device");
+
+ assert_eq!(
+ render_capability_report_json(&report),
+ "{\"schema\":1,\"vulkan_api\":\"1.1.0\",\"device_name\":\"GPU \\\"A\\\"\",\"score\":1101,\"graphics_queue_family\":3,\"present_queue_family\":3,\"portability_subset\":false,\"enabled_extensions\":[\"VK_KHR_swapchain\"]}"
+ );
+ }
+
+ fn device(
+ name: &str,
+ device_type: VulkanDeviceType,
+ queue_index: u32,
+ swapchain: bool,
+ portability_subset: bool,
+ ) -> VulkanPhysicalDeviceRecord {
+ let mut extensions = Vec::new();
+ if swapchain {
+ extensions.push(KHR_SWAPCHAIN_EXTENSION.to_string());
+ }
+ if portability_subset {
+ extensions.push(KHR_PORTABILITY_SUBSET_EXTENSION.to_string());
+ }
+ VulkanPhysicalDeviceRecord {
+ name: name.to_string(),
+ api_version: MIN_VULKAN_API_VERSION,
+ device_type,
+ extensions,
+ queue_families: vec![VulkanQueueFamily {
+ index: queue_index,
+ graphics: true,
+ present: true,
+ }],
+ surface_formats: vec![VulkanSurfaceFormat {
+ format: vk::Format::B8G8R8A8_SRGB.as_raw(),
+ color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
+ }],
+ }
+ }
}