diff options
Diffstat (limited to 'adapters/fparkan-render-vulkan/src/policy.rs')
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/policy.rs | 712 |
1 files changed, 712 insertions, 0 deletions
diff --git a/adapters/fparkan-render-vulkan/src/policy.rs b/adapters/fparkan-render-vulkan/src/policy.rs new file mode 100644 index 0000000..9e77e57 --- /dev/null +++ b/adapters/fparkan-render-vulkan/src/policy.rs @@ -0,0 +1,712 @@ +use ash::vk; +use fparkan_render::{validate_command_list, RenderCommand, RenderCommandList, RenderError}; +use serde::Serialize; + +const MIN_VULKAN_API_VERSION: u32 = vk::API_VERSION_1_1; +pub(crate) const KHR_SWAPCHAIN_EXTENSION: &str = "VK_KHR_swapchain"; +pub(crate) const KHR_PORTABILITY_SUBSET_EXTENSION: &str = "VK_KHR_portability_subset"; + +/// 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, +} + +/// Surface capabilities needed by the Stage 0 swapchain policy. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct VulkanSwapchainSurfaceCapabilities { + /// Current surface extent, when dictated by the platform. + pub current_extent: Option<(u32, u32)>, + /// Minimum supported swapchain extent. + pub min_extent: (u32, u32), + /// Maximum supported swapchain extent. + pub max_extent: (u32, u32), + /// Minimum supported image count. + pub min_image_count: u32, + /// Maximum supported image count, or 0 when unbounded. + pub max_image_count: u32, + /// Supported swapchain image-usage flags as raw Vulkan bits. + pub supported_usage_flags: u32, +} + +/// Deterministic swapchain planning input. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VulkanSwapchainRequest { + /// Requested drawable extent. + pub drawable_extent: (u32, u32), + /// Available surface formats. + pub formats: Vec<VulkanSurfaceFormat>, + /// Available present modes as raw Vulkan values. + pub present_modes: Vec<i32>, + /// Surface capabilities. + pub capabilities: VulkanSwapchainSurfaceCapabilities, + /// Preferred present mode. + pub preferred_present_mode: i32, +} + +/// Deterministic swapchain plan. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VulkanSwapchainPlan { + /// Report schema version. + pub schema: u32, + /// Selected swapchain extent. + pub extent: (u32, u32), + /// Selected surface format. + pub format: VulkanSurfaceFormat, + /// Selected present mode raw Vulkan value. + pub present_mode: i32, + /// Selected image count. + pub image_count: u32, +} + +/// Swapchain planning error. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VulkanSwapchainError { + /// No surface format was available. + MissingSurfaceFormat, + /// No present mode was available. + MissingPresentMode, + /// Requested or current extent is empty. + EmptyExtent, +} + +impl std::fmt::Display for VulkanSwapchainError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingSurfaceFormat => write!(f, "Vulkan swapchain has no surface format"), + Self::MissingPresentMode => write!(f, "Vulkan swapchain has no present mode"), + Self::EmptyExtent => write!(f, "Vulkan swapchain extent must be non-zero"), + } + } +} + +impl std::error::Error for VulkanSwapchainError {} + +/// Swapchain recreation reason. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VulkanSwapchainRecreationReason { + /// Drawable extent changed. + Resize, + /// Vulkan reported `VK_ERROR_OUT_OF_DATE_KHR`. + OutOfDate, + /// Vulkan reported `VK_SUBOPTIMAL_KHR`. + Suboptimal, +} + +/// Deterministic swapchain recreation report. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VulkanSwapchainRecreationReport { + /// Report schema version. + pub schema: u32, + /// Recreation reason. + pub reason: VulkanSwapchainRecreationReason, + /// Previous extent. + pub previous_extent: (u32, u32), + /// Next extent. + pub next_extent: (u32, u32), +} + +/// Deterministic frame submission plan for command buffers and sync objects. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct VulkanFrameSubmissionPlan { + /// Report schema version. + pub schema: u32, + /// Frames allowed in flight. + pub frames_in_flight: u32, + /// Swapchain-backed primary command buffers. + pub command_buffers: u32, + /// Binary semaphores allocated per frame. + pub semaphores_per_frame: u32, + /// Fences allocated per frame. + pub fences_per_frame: u32, + /// Draw commands encoded into the frame. + pub draw_count: u32, + /// Total indexed vertices submitted by draw commands. + pub indexed_vertex_count: u32, +} + +/// 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>, + /// Present modes accepted by the target surface. + pub present_modes: Vec<i32>, + /// Surface capabilities accepted by the target surface. + pub surface_capabilities: VulkanSwapchainSurfaceCapabilities, +} + +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, + }, + /// No present mode is available for the target surface. + MissingPresentMode { + /// Device name that failed validation. + device: String, + }, + /// Swapchain images cannot be used as color attachments. + MissingColorAttachmentUsage { + /// 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") + } + Self::MissingPresentMode { device } => { + write!(f, "Vulkan device {device} has no supported present mode") + } + Self::MissingColorAttachmentUsage { device } => write!( + f, + "Vulkan device {device} surface does not support COLOR_ATTACHMENT usage" + ), + } + } +} + +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; + let mut last_error = None; + for device in devices { + let report = match validate_device(device) { + Ok(report) => report, + Err(err) => { + last_error = Some(err); + continue; + } + }; + match &best { + Some(existing) if compare_reports(&report, existing) != std::cmp::Ordering::Greater => { + } + _ => best = Some(report), + } + } + best.ok_or_else(|| last_error.unwrap_or(VulkanCapabilityError::NoPhysicalDevice)) +} + +/// Builds a deterministic swapchain plan from surface capabilities. +/// +/// # Errors +/// +/// Returns [`VulkanSwapchainError`] when formats, present modes or extent are +/// unusable. +pub fn plan_vulkan_swapchain( + request: &VulkanSwapchainRequest, +) -> Result<VulkanSwapchainPlan, VulkanSwapchainError> { + let format = select_surface_format(&request.formats)?; + let present_mode = select_present_mode(&request.present_modes, request.preferred_present_mode)?; + let extent = select_swapchain_extent(request)?; + if extent.0 == 0 || extent.1 == 0 { + return Err(VulkanSwapchainError::EmptyExtent); + } + Ok(VulkanSwapchainPlan { + schema: 1, + extent, + format, + present_mode, + image_count: select_image_count(request.capabilities), + }) +} + +/// Builds a deterministic swapchain recreation report. +#[must_use] +pub const fn swapchain_recreation_report( + reason: VulkanSwapchainRecreationReason, + previous_extent: (u32, u32), + next_extent: (u32, u32), +) -> VulkanSwapchainRecreationReport { + VulkanSwapchainRecreationReport { + schema: 1, + reason, + previous_extent, + next_extent, + } +} + +/// Builds a deterministic frame submission plan for a validated command list. +/// +/// Stage 0 keeps this as a pure planning boundary so command-pool, command-buffer +/// and synchronization policy can be tested without requiring a native surface. +/// +/// # Errors +/// +/// Returns [`RenderError`] when the command list has invalid frame framing, +/// ordering, draw ranges, mesh bounds, or non-finite transforms. +pub fn plan_vulkan_frame_submission( + swapchain: &VulkanSwapchainPlan, + commands: &RenderCommandList, +) -> Result<VulkanFrameSubmissionPlan, RenderError> { + validate_command_list(commands)?; + let mut draw_count = 0_u32; + let mut indexed_vertex_count = 0_u32; + for command in &commands.commands { + if let RenderCommand::Draw(draw) = command { + draw_count = draw_count.saturating_add(1); + indexed_vertex_count = indexed_vertex_count.saturating_add(draw.range.count); + } + } + Ok(VulkanFrameSubmissionPlan { + schema: 1, + frames_in_flight: swapchain.image_count.clamp(1, 2), + command_buffers: swapchain.image_count, + semaphores_per_frame: 2, + fences_per_frame: 1, + draw_count, + indexed_vertex_count, + }) +} + +/// Renders a deterministic JSON capability report. +#[must_use] +pub fn render_capability_report_json(report: &VulkanCapabilityReport) -> String { + #[derive(Serialize)] + struct CapabilityReportJson<'a> { + schema: u32, + vulkan_api: String, + device_name: &'a str, + score: i32, + graphics_queue_family: u32, + present_queue_family: u32, + portability_subset: bool, + enabled_extensions: &'a [String], + } + + serialize_json_or_fallback( + &CapabilityReportJson { + schema: report.schema, + vulkan_api: format_api_version(report.vulkan_api_version), + device_name: &report.device_name, + score: report.score, + graphics_queue_family: report.graphics_queue_family, + present_queue_family: report.present_queue_family, + portability_subset: report.portability_subset, + enabled_extensions: &report.enabled_extensions, + }, + "{\"schema\":0,\"vulkan_api\":\"0.0.0\",\"device_name\":\"unknown\",\"score\":0,\"graphics_queue_family\":0,\"present_queue_family\":0,\"portability_subset\":false,\"enabled_extensions\":[]}", + ) +} + +/// Renders a deterministic JSON swapchain plan. +#[must_use] +pub fn render_swapchain_plan_json(plan: &VulkanSwapchainPlan) -> String { + #[derive(Serialize)] + struct SwapchainPlanJson { + schema: u32, + extent: [u32; 2], + format: i32, + color_space: i32, + present_mode: i32, + image_count: u32, + } + + serialize_json_or_fallback( + &SwapchainPlanJson { + schema: plan.schema, + extent: [plan.extent.0, plan.extent.1], + format: plan.format.format, + color_space: plan.format.color_space, + present_mode: plan.present_mode, + image_count: plan.image_count, + }, + "{\"schema\":0,\"extent\":[0,0],\"format\":0,\"color_space\":0,\"present_mode\":0,\"image_count\":0}", + ) +} + +/// Renders a deterministic JSON swapchain recreation report. +#[must_use] +pub fn render_swapchain_recreation_report_json(report: &VulkanSwapchainRecreationReport) -> String { + #[derive(Serialize)] + struct SwapchainRecreationReportJson<'a> { + schema: u32, + reason: &'a str, + previous_extent: [u32; 2], + next_extent: [u32; 2], + } + + serialize_json_or_fallback( + &SwapchainRecreationReportJson { + schema: report.schema, + reason: match report.reason { + VulkanSwapchainRecreationReason::Resize => "resize", + VulkanSwapchainRecreationReason::OutOfDate => "out_of_date", + VulkanSwapchainRecreationReason::Suboptimal => "suboptimal", + }, + previous_extent: [report.previous_extent.0, report.previous_extent.1], + next_extent: [report.next_extent.0, report.next_extent.1], + }, + "{\"schema\":0,\"reason\":\"unknown\",\"previous_extent\":[0,0],\"next_extent\":[0,0]}", + ) +} + +/// Renders a deterministic JSON frame submission plan. +#[must_use] +pub fn render_frame_submission_plan_json(plan: &VulkanFrameSubmissionPlan) -> String { + serialize_json_or_fallback( + plan, + "{\"schema\":0,\"frames_in_flight\":0,\"command_buffers\":0,\"semaphores_per_frame\":0,\"fences_per_frame\":0,\"draw_count\":0,\"indexed_vertex_count\":0}", + ) +} + +pub(crate) fn select_composite_alpha( + supported: vk::CompositeAlphaFlagsKHR, +) -> vk::CompositeAlphaFlagsKHR { + if supported.contains(vk::CompositeAlphaFlagsKHR::OPAQUE) { + vk::CompositeAlphaFlagsKHR::OPAQUE + } else if supported.contains(vk::CompositeAlphaFlagsKHR::PRE_MULTIPLIED) { + vk::CompositeAlphaFlagsKHR::PRE_MULTIPLIED + } else if supported.contains(vk::CompositeAlphaFlagsKHR::POST_MULTIPLIED) { + vk::CompositeAlphaFlagsKHR::POST_MULTIPLIED + } else { + vk::CompositeAlphaFlagsKHR::INHERIT + } +} + +pub(crate) fn serialize_json_or_fallback<T: Serialize>(value: &T, fallback: &str) -> String { + match serde_json::to_string(value) { + Ok(json) => json, + Err(_) => fallback.to_string(), + } +} + +pub(crate) fn format_api_version(version: u32) -> String { + format!( + "{}.{}.{}", + vk::api_version_major(version), + vk::api_version_minor(version), + vk::api_version_patch(version) + ) +} + +fn select_surface_format( + formats: &[VulkanSurfaceFormat], +) -> Result<VulkanSurfaceFormat, VulkanSwapchainError> { + if let Some(format) = undefined_surface_format_override(formats) { + return Ok(format); + } + formats + .iter() + .copied() + .find(|format| { + format.format == vk::Format::B8G8R8A8_SRGB.as_raw() + && format.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw() + }) + .or_else(|| formats.first().copied()) + .ok_or(VulkanSwapchainError::MissingSurfaceFormat) +} + +fn undefined_surface_format_override( + formats: &[VulkanSurfaceFormat], +) -> Option<VulkanSurfaceFormat> { + match formats { + [format] if format.format == vk::Format::UNDEFINED.as_raw() => Some(VulkanSurfaceFormat { + format: vk::Format::B8G8R8A8_SRGB.as_raw(), + color_space: format.color_space, + }), + _ => None, + } +} + +fn select_present_mode(present_modes: &[i32], preferred: i32) -> Result<i32, VulkanSwapchainError> { + if present_modes.contains(&preferred) { + Ok(preferred) + } else if present_modes.contains(&vk::PresentModeKHR::FIFO.as_raw()) { + Ok(vk::PresentModeKHR::FIFO.as_raw()) + } else { + present_modes + .first() + .copied() + .ok_or(VulkanSwapchainError::MissingPresentMode) + } +} + +fn select_swapchain_extent( + request: &VulkanSwapchainRequest, +) -> Result<(u32, u32), VulkanSwapchainError> { + if let Some(extent) = request.capabilities.current_extent { + return if extent.0 == 0 || extent.1 == 0 { + Err(VulkanSwapchainError::EmptyExtent) + } else { + Ok(extent) + }; + } + let width = request.drawable_extent.0.clamp( + request.capabilities.min_extent.0, + request.capabilities.max_extent.0, + ); + let height = request.drawable_extent.1.clamp( + request.capabilities.min_extent.1, + request.capabilities.max_extent.1, + ); + Ok((width, height)) +} + +fn select_image_count(capabilities: VulkanSwapchainSurfaceCapabilities) -> u32 { + let requested = capabilities.min_image_count.saturating_add(1).max(2); + if capabilities.max_image_count == 0 { + requested + } else { + requested.min(capabilities.max_image_count) + } +} + +pub(crate) 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 !supports_surface_formats(device) { + return Err(VulkanCapabilityError::MissingSurfaceFormat { + device: device.name.clone(), + }); + } + if device.present_modes.is_empty() { + return Err(VulkanCapabilityError::MissingPresentMode { + device: device.name.clone(), + }); + } + if !supports_color_attachment_usage(device.surface_capabilities) { + return Err(VulkanCapabilityError::MissingColorAttachmentUsage { + device: device.name.clone(), + }); + } + let (graphics_queue_family, present_queue_family) = select_queue_families(device)?; + + 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 select_queue_families( + device: &VulkanPhysicalDeviceRecord, +) -> Result<(u32, u32), VulkanCapabilityError> { + if let Some(unified) = device + .queue_families + .iter() + .filter(|family| family.graphics && family.present) + .min_by_key(|family| family.index) + { + return Ok((unified.index, unified.index)); + } + + let graphics_queue_family = device + .queue_families + .iter() + .filter(|family| family.graphics) + .min_by_key(|family| family.index) + .ok_or_else(|| VulkanCapabilityError::NoGraphicsQueue { + device: device.name.clone(), + })? + .index; + let present_queue_family = device + .queue_families + .iter() + .filter(|family| family.present) + .min_by_key(|family| family.index) + .ok_or_else(|| VulkanCapabilityError::NoPresentQueue { + device: device.name.clone(), + })? + .index; + Ok((graphics_queue_family, present_queue_family)) +} + +fn supports_surface_formats(device: &VulkanPhysicalDeviceRecord) -> bool { + !device.surface_formats.is_empty() +} + +fn supports_color_attachment_usage(capabilities: VulkanSwapchainSurfaceCapabilities) -> bool { + capabilities.supported_usage_flags & vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw() != 0 +} + +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) +} + +pub(crate) fn compare_reports( + left: &VulkanCapabilityReport, + right: &VulkanCapabilityReport, +) -> std::cmp::Ordering { + left.score + .cmp(&right.score) + .then_with(|| right.device_name.cmp(&left.device_name)) +} |
