From 61118cbdd4cf593f799b772c44ed3fd261619bad Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 17 Jul 2026 14:39:36 +0200 Subject: [PATCH] refactor(presenter/W8): split vk.rs into vk/ directory module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break the 2513-line pf-presenter/src/vk.rs into a vk/ directory module (mod.rs + 6 concern submodules), keeping ALL type definitions in vk/mod.rs so every submodule (a descendant of vk) sees the Presenter/OverlayPipe/etc. private fields with no field bumps: - vk/setup.rs : Presenter::new + device/format/present-mode selection - vk/present.rs : the per-frame present path (present + CSC record + AVVkFrame sync) — HOT PATH, moved whole - vk/reconfig.rs : swapchain recreate/resize + HDR reconfiguration - vk/resources.rs : video-image/staging (re)build + Retired-frame destruction - vk/overlay_pipe.rs: the presenter-side overlay composite pipeline - vk/gpu.rs : memory allocation, image barriers, geometry helpers (+ tests) vk/mod.rs keeps FrameInput/Presenter/OverlayPipe/VideoImage/Staging/Retired/HwCtx* + the public accessors + Drop. Methods/free-fns a sibling submodule calls became pub(super) (~18); zero field bumps, zero re-exports (Presenter/FrameInput never leave mod.rs). lib.rs unchanged (`pub mod vk;` resolves to vk/mod.rs). The moved overlay shader include_bytes! gained one `../` for the deeper dir. Pure move; no behavior change; the hot present path keeps only static pub(super) calls (inlinable). Verified both platforms: Linux (home-worker-5) clippy -p pf-presenter (--all-targets -D warnings) + test; Windows (winbox, ASCII CARGO_HOME) clippy. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/pf-presenter/src/vk.rs | 2513 -------------------- crates/pf-presenter/src/vk/gpu.rs | 257 ++ crates/pf-presenter/src/vk/mod.rs | 289 +++ crates/pf-presenter/src/vk/overlay_pipe.rs | 184 ++ crates/pf-presenter/src/vk/present.rs | 866 +++++++ crates/pf-presenter/src/vk/reconfig.rs | 233 ++ crates/pf-presenter/src/vk/resources.rs | 169 ++ crates/pf-presenter/src/vk/setup.rs | 579 +++++ 8 files changed, 2577 insertions(+), 2513 deletions(-) delete mode 100644 crates/pf-presenter/src/vk.rs create mode 100644 crates/pf-presenter/src/vk/gpu.rs create mode 100644 crates/pf-presenter/src/vk/mod.rs create mode 100644 crates/pf-presenter/src/vk/overlay_pipe.rs create mode 100644 crates/pf-presenter/src/vk/present.rs create mode 100644 crates/pf-presenter/src/vk/reconfig.rs create mode 100644 crates/pf-presenter/src/vk/resources.rs create mode 100644 crates/pf-presenter/src/vk/setup.rs diff --git a/crates/pf-presenter/src/vk.rs b/crates/pf-presenter/src/vk.rs deleted file mode 100644 index abbb8dc5..00000000 --- a/crates/pf-presenter/src/vk.rs +++ /dev/null @@ -1,2513 +0,0 @@ -//! The Vulkan presenter: swapchain + two frame paths into one device-local RGBA video -//! image, then a letterboxed `vkCmdBlitImage` composite. -//! -//! * **Software** (`FrameInput::Cpu`): staging upload + `copy_buffer_to_image` (row -//! stride via `buffer_row_length`) — transfer-only, runs on every GPU. -//! * **Hardware** (`FrameInput::Dmabuf`): the decoder's NV12 dmabuf imported per-plane -//! (`dmabuf.rs`) and converted by the CSC render pass (`csc.rs`) — zero-copy, gated on -//! the four import extensions at device creation; boxes without them (NVIDIA -//! proprietary by design) report `supports_dmabuf() == false` and the caller keeps the -//! decoder on software. -//! -//! Pacing: one frame in flight (the submit fence is waited before each record), MAILBOX -//! when available, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE=fifo|mailbox|immediate` -//! overrides — see `pick_present_mode` for why an arrival-paced presenter must not -//! block in FIFO's present queue). Present is arrival-paced by the caller: a frame -//! input on each decoded frame, `FrameInput::Redraw` re-blits the retained video image -//! (expose/resize redraws). - -use crate::csc::{build_fullscreen_pipeline, csc_rows, CscPass}; -#[cfg(target_os = "linux")] -use crate::dmabuf::{self, HwFrame}; -use crate::overlay::{OverlayFrame, SharedDevice}; -use anyhow::{anyhow, bail, Context as _, Result}; -use ash::vk; -use ash::vk::Handle as _; -#[cfg(target_os = "linux")] -use pf_client_core::video::DmabufFrame; -use pf_client_core::video::{CpuFrame, VkVideoFrame}; -use std::ffi::CString; - -/// One presenter iteration's video input. -pub enum FrameInput<'a> { - /// No new frame — re-composite the retained video image (expose/resize). - Redraw, - Cpu(&'a CpuFrame), - #[cfg(target_os = "linux")] - Dmabuf(DmabufFrame), - /// FFmpeg Vulkan Video output — a VkImage already on THIS device (zero copy). - VkFrame(VkVideoFrame), - /// D3D11VA hand-off — a shareable NT-handle texture to import (`d3d11.rs`). - #[cfg(windows)] - D3d11(pf_client_core::video::D3d11Frame), - /// PyroWave planar output — three R8 plane views already on THIS device, decode - /// fence-complete, GENERAL layout (`pf_client_core::video_pyrowave`). - #[cfg(all(target_os = "linux", feature = "pyrowave"))] - PyroWave(pf_client_core::video_pyrowave::PyroWavePlanarFrame), -} - -/// The dmabuf/CSC machinery, present only when the device carries the import extensions. -#[cfg(target_os = "linux")] -struct HwCtx { - ext_mem_fd: ash::khr::external_memory_fd::Device, -} - -/// The D3D11 shared-texture import machinery, present only when the device carries -/// `VK_KHR_external_memory_win32` + `VK_KHR_win32_keyed_mutex`. -#[cfg(windows)] -struct HwCtxWin { - ext_mem_win32: ash::khr::external_memory_win32::Device, -} - -/// A submitted hardware frame parked until the in-flight fence proves the GPU reads -/// done: imported dmabuf planes, or a Vulkan-Video frame (FFmpeg's image — we own only -/// the plane views; dropping the frame's guard releases the AVFrame back to the pool). -enum Retired { - #[cfg(target_os = "linux")] - Dmabuf(HwFrame), - #[cfg(windows)] - D3d11(crate::d3d11::HwFrame), - Vk { - frame: VkVideoFrame, - views: [vk::ImageView; 2], - }, -} - -impl Retired { - fn destroy(self, device: &ash::Device) { - match self { - #[cfg(target_os = "linux")] - Retired::Dmabuf(f) => f.destroy(device), - #[cfg(windows)] - Retired::D3d11(f) => f.destroy(device), - Retired::Vk { frame, views } => { - unsafe { - for v in views { - device.destroy_image_view(v, None); - } - } - drop(frame); // guard drops here — AVFrame (and the VkImage) released - } - } - } -} - -/// The overlay composite: one premultiplied-alpha quad blended over the swapchain image -/// after the video blit (the §6.1 contract's presenter half). Always built — it has no -/// Skia dependency and costs nothing while no overlay frame arrives (the render pass -/// isn't even recorded). -struct OverlayPipe { - render_pass: vk::RenderPass, - set_layout: vk::DescriptorSetLayout, - pipeline_layout: vk::PipelineLayout, - pipeline: vk::Pipeline, - desc_pool: vk::DescriptorPool, - desc_set: vk::DescriptorSet, - sampler: vk::Sampler, - /// Per-swapchain-image render targets, rebuilt with the swapchain. - views: Vec, - framebuffers: Vec, -} - -impl OverlayPipe { - fn new(device: &ash::Device, format: vk::Format) -> Result { - // LOAD the blitted video, blend the overlay, end PRESENT-ready — this pass owns - // the swapchain image's final transition on overlay frames. - let attachment = [vk::AttachmentDescription::default() - .format(format) - .samples(vk::SampleCountFlags::TYPE_1) - .load_op(vk::AttachmentLoadOp::LOAD) - .store_op(vk::AttachmentStoreOp::STORE) - .initial_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL) - .final_layout(vk::ImageLayout::PRESENT_SRC_KHR)]; - let color_ref = [vk::AttachmentReference::default() - .attachment(0) - .layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)]; - let subpass = [vk::SubpassDescription::default() - .pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS) - .color_attachments(&color_ref)]; - let deps = [vk::SubpassDependency::default() - .src_subpass(vk::SUBPASS_EXTERNAL) - .dst_subpass(0) - .src_stage_mask(vk::PipelineStageFlags::ALL_COMMANDS) - .src_access_mask(vk::AccessFlags::MEMORY_WRITE) - .dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT) - .dst_access_mask( - vk::AccessFlags::COLOR_ATTACHMENT_READ | vk::AccessFlags::COLOR_ATTACHMENT_WRITE, - )]; - let render_pass = unsafe { - device.create_render_pass( - &vk::RenderPassCreateInfo::default() - .attachments(&attachment) - .subpasses(&subpass) - .dependencies(&deps), - None, - ) - } - .context("overlay render pass")?; - - let sampler = unsafe { - device.create_sampler( - &vk::SamplerCreateInfo::default() - .mag_filter(vk::Filter::LINEAR) - .min_filter(vk::Filter::LINEAR) - .address_mode_u(vk::SamplerAddressMode::CLAMP_TO_EDGE) - .address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE) - .address_mode_w(vk::SamplerAddressMode::CLAMP_TO_EDGE), - None, - ) - }?; - let samplers = [sampler]; - let bindings = [vk::DescriptorSetLayoutBinding::default() - .binding(0) - .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) - .descriptor_count(1) - .stage_flags(vk::ShaderStageFlags::FRAGMENT) - .immutable_samplers(&samplers)]; - let set_layout = unsafe { - device.create_descriptor_set_layout( - &vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings), - None, - ) - }?; - let set_layouts = [set_layout]; - let pipeline_layout = unsafe { - device.create_pipeline_layout( - &vk::PipelineLayoutCreateInfo::default().set_layouts(&set_layouts), - None, - ) - }?; - let pool_sizes = [vk::DescriptorPoolSize::default() - .ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) - .descriptor_count(1)]; - let desc_pool = unsafe { - device.create_descriptor_pool( - &vk::DescriptorPoolCreateInfo::default() - .max_sets(1) - .pool_sizes(&pool_sizes), - None, - ) - }?; - let desc_set = unsafe { - device.allocate_descriptor_sets( - &vk::DescriptorSetAllocateInfo::default() - .descriptor_pool(desc_pool) - .set_layouts(&set_layouts), - ) - }?[0]; - let pipeline = build_fullscreen_pipeline( - device, - render_pass, - pipeline_layout, - include_bytes!("../shaders/overlay.frag.spv"), - true, // premultiplied blend over the video - )?; - Ok(OverlayPipe { - render_pass, - set_layout, - pipeline_layout, - pipeline, - desc_pool, - desc_set, - sampler, - views: Vec::new(), - framebuffers: Vec::new(), - }) - } - - /// Detach the current per-swapchain-image targets (for deferred destruction). - fn take_targets(&mut self) -> (Vec, Vec) { - ( - std::mem::take(&mut self.views), - std::mem::take(&mut self.framebuffers), - ) - } - - /// Rebuild the per-swapchain-image views + framebuffers (swapchain recreation). - /// The caller has already taken the old targets for deferred destruction. - fn rebuild_targets( - &mut self, - device: &ash::Device, - images: &[vk::Image], - format: vk::Format, - extent: vk::Extent2D, - ) -> Result<()> { - self.destroy_targets(device); // no-op after take_targets; safety net otherwise - for &image in images { - let view = unsafe { - device.create_image_view( - &vk::ImageViewCreateInfo::default() - .image(image) - .view_type(vk::ImageViewType::TYPE_2D) - .format(format) - .subresource_range(subresource_range()), - None, - ) - }?; - self.views.push(view); - let attachments = [view]; - let fb = unsafe { - device.create_framebuffer( - &vk::FramebufferCreateInfo::default() - .render_pass(self.render_pass) - .attachments(&attachments) - .width(extent.width) - .height(extent.height) - .layers(1), - None, - ) - }?; - self.framebuffers.push(fb); - } - Ok(()) - } - - fn destroy_targets(&mut self, device: &ash::Device) { - unsafe { - for fb in self.framebuffers.drain(..) { - device.destroy_framebuffer(fb, None); - } - for v in self.views.drain(..) { - device.destroy_image_view(v, None); - } - } - } - - fn destroy(&mut self, device: &ash::Device) { - self.destroy_targets(device); - unsafe { - device.destroy_pipeline(self.pipeline, None); - device.destroy_pipeline_layout(self.pipeline_layout, None); - device.destroy_descriptor_pool(self.desc_pool, None); - device.destroy_descriptor_set_layout(self.set_layout, None); - device.destroy_sampler(self.sampler, None); - device.destroy_render_pass(self.render_pass, None); - } - } -} - -/// The one video image (device-local RGBA the size of the decoded stream) + its staging. -/// `view`/`framebuffer` exist only on hw-capable devices (the CSC pass renders into it). -struct VideoImage { - image: vk::Image, - memory: vk::DeviceMemory, - view: vk::ImageView, - framebuffer: vk::Framebuffer, - width: u32, - height: u32, -} - -struct Staging { - buffer: vk::Buffer, - memory: vk::DeviceMemory, - ptr: *mut u8, - capacity: usize, -} - -pub struct Presenter { - // Field order = drop order documentation only; teardown is explicit in `Drop`. - entry: ash::Entry, - instance: ash::Instance, - surface_i: ash::khr::surface::Instance, - surface: vk::SurfaceKHR, - pdev: vk::PhysicalDevice, - mem_props: vk::PhysicalDeviceMemoryProperties, - device: ash::Device, - swap_d: ash::khr::swapchain::Device, - queue: vk::Queue, - qfi: u32, - /// Dmabuf import — `None` when the device lacks the import extensions (the CSC - /// pass itself is unconditional: Vulkan-Video frames need it everywhere). - #[cfg(target_os = "linux")] - hw: Option, - /// D3D11 shared-texture import — `None` when the device lacks the win32 external - /// memory / keyed-mutex extensions. - #[cfg(windows)] - hw_win: Option, - csc: CscPass, - /// The planar (3-plane) CSC variant for PyroWave frames; built only when the device - /// passed the pyrowave probe. - #[cfg(all(target_os = "linux", feature = "pyrowave"))] - csc_planar: Option, - /// FFmpeg Vulkan Video decode handles — `None` when the stack can't do it. - video_export: Option, - /// The console-UI composite quad (§6.1's presenter half). - overlay_pipe: OverlayPipe, - /// The submitted hardware frame (dmabuf plane images + guard, or a Vulkan-Video - /// frame + our plane views): its GPU reads end with the in-flight fence, so it's - /// destroyed right after the next fence wait. - retired_hw: Option, - /// External-sync lock over this device's queues, shared with FFmpeg (via - /// [`pf_client_core::video::VulkanDecodeDevice::queue_lock`] → its - /// `lock_queue`/`unlock_queue` callbacks) and the Skia overlay: FFmpeg preps on the - /// SAME graphics queue from the pump thread, so every `vkQueueSubmit`/ - /// `vkQueuePresentKHR`/`vkQueueWaitIdle`/`vkDeviceWaitIdle` here must hold it — - /// the unsynchronized overlap was an intermittent `VK_ERROR_DEVICE_LOST`. - queue_lock: std::sync::Arc, - format: vk::SurfaceFormatKHR, - /// The surface's HDR10/ST.2084 pairing, when the stack offers one. - hdr10_format: Option, - /// PQ frames are on screen and the swapchain is in HDR10 mode. - hdr_active: bool, - /// One-shot latch: a PQ frame arrived but the surface offers no HDR10 colorspace, so the - /// CSC pass silently tone-maps to SDR. Warned once — the single most useful signal for - /// diagnosing "HDR isn't advertised" (e.g. gamescope's WSI layer invisible in a flatpak - /// sandbox) vs. the host simply not sending PQ. - hdr_downgrade_warned: bool, - /// `VK_EXT_hdr_metadata` device fns when the driver offers them (gamescope/KDE do). - hdr_metadata_d: Option, - /// The host's latest ST.2086/CLL metadata (the 0xCE plane) — pushed to the - /// swapchain whenever HDR10 mode is live; `None` until the first datagram lands - /// (a generic HDR10 baseline is pushed meanwhile). - hdr_meta: Option, - /// The video image / CSC attachment format for the current mode. - video_format: vk::Format, - present_mode: vk::PresentModeKHR, - swapchain: vk::SwapchainKHR, - images: Vec, - extent: vk::Extent2D, - /// Per-swapchain-image render-finished semaphores (present consumes them on the - /// image's schedule — one shared semaphore could be re-submitted while a previous - /// present still holds it). - render_sems: Vec, - acquire_sem: vk::Semaphore, - fence: vk::Fence, - cmd_pool: vk::CommandPool, - cmd_buf: vk::CommandBuffer, - staging: Option, - video: Option, - /// The submit fence has a submission pending (wait before recording again — also - /// what makes the single staging buffer safe to overwrite). - submitted: bool, -} - -impl Presenter { - /// Bring up instance → surface → device → swapchain over an SDL window. - /// `instance_extensions` comes from `VideoSubsystem::vulkan_instance_extensions()`. - pub fn new(window: &sdl3::video::Window, instance_extensions: &[String]) -> Result { - let entry = unsafe { ash::Entry::load() }.context("libvulkan not loadable")?; - - let app_name = CString::new("punktfunk-session").unwrap(); - // 1.3: FFmpeg's Vulkan hwcontext requires an instance of at least 1.3 (any - // current loader accepts it regardless of device support; device-level gating - // happens below). - let app_info = vk::ApplicationInfo::default() - .application_name(&app_name) - .api_version(vk::API_VERSION_1_3); - // HDR10 presentation needs the extended colorspaces at the INSTANCE level. - let mut instance_extensions: Vec = instance_extensions.to_vec(); - let inst_available = - unsafe { entry.enumerate_instance_extension_properties(None) }.unwrap_or_default(); - let has_colorspace_ext = inst_available - .iter() - .any(|e| e.extension_name_as_c_str() == Ok(c"VK_EXT_swapchain_colorspace")); - if has_colorspace_ext { - instance_extensions.push("VK_EXT_swapchain_colorspace".into()); - } - let ext_cstrings: Vec = instance_extensions - .iter() - .map(|e| CString::new(e.as_str()).unwrap()) - .collect(); - let ext_ptrs: Vec<*const i8> = ext_cstrings.iter().map(|e| e.as_ptr()).collect(); - let instance = unsafe { - entry.create_instance( - &vk::InstanceCreateInfo::default() - .application_info(&app_info) - .enabled_extension_names(&ext_ptrs), - None, - ) - } - .context("vkCreateInstance")?; - let surface_i = ash::khr::surface::Instance::new(&entry, &instance); - - let surface = unsafe { window.vulkan_create_surface(instance.handle()) } - .map_err(|e| anyhow!("SDL_Vulkan_CreateSurface: {e}"))?; - - let (pdev, qfi) = pick_device(&instance, &surface_i, surface)?; - let mem_props = unsafe { instance.get_physical_device_memory_properties(pdev) }; - { - let props = unsafe { instance.get_physical_device_properties(pdev) }; - let name = props - .device_name_as_c_str() - .map(|c| c.to_string_lossy().into_owned()) - .unwrap_or_default(); - tracing::info!(device = %name, queue_family = qfi, "vulkan device"); - } - - // The dmabuf import set is optional: enabled when the device offers all four, - // else that path is off (`supports_dmabuf() == false`). Windows has no - // dmabuf/DRM-PRIME — the whole import path is compiled out there. - let available = unsafe { instance.enumerate_device_extension_properties(pdev) }?; - let has = |name: &std::ffi::CStr| { - available - .iter() - .any(|e| e.extension_name_as_c_str() == Ok(name)) - }; - #[cfg(target_os = "linux")] - let hw_capable = dmabuf::DEVICE_EXTENSIONS.iter().all(|n| has(n)); - let mut dev_exts = vec![ash::khr::swapchain::NAME.as_ptr()]; - #[cfg(target_os = "linux")] - if hw_capable { - dev_exts.extend(dmabuf::DEVICE_EXTENSIONS.iter().map(|n| n.as_ptr())); - } else { - tracing::info!( - "device lacks the dmabuf import extensions — VAAPI hardware frames \ - unavailable" - ); - } - // D3D11 shared-texture import (the D3D11VA decode hand-off) — optional exactly - // like the dmabuf set; a device without it keeps Vulkan-Video/software decode. - // Extensions alone aren't the whole gate: the driver must also report the - // multiplanar NV12 image as IMPORTABLE from a D3D11 texture handle - // (vkGetPhysicalDeviceImageFormatProperties2 — creating an unsupported external - // image is UB, observed as VK_ERROR_DEVICE_LOST at the first submits on NVIDIA). - #[cfg(windows)] - let win_capable = crate::d3d11::DEVICE_EXTENSIONS.iter().all(|n| has(n)) - && crate::d3d11::import_supported(&instance, pdev); - #[cfg(windows)] - if win_capable { - dev_exts.extend(crate::d3d11::DEVICE_EXTENSIONS.iter().map(|n| n.as_ptr())); - } else { - tracing::info!( - "device lacks the win32 external-memory/keyed-mutex extensions — D3D11VA \ - hardware frames unavailable" - ); - } - // The adapter LUID (for the D3D11VA backend to create its decode device on the - // SAME adapter). Core 1.1 query; valid on effectively every Windows driver. - let mut id_props = vk::PhysicalDeviceIDProperties::default(); - let mut props2 = vk::PhysicalDeviceProperties2::default().push_next(&mut id_props); - unsafe { instance.get_physical_device_properties2(pdev, &mut props2) }; - let adapter_luid: Option<[u8; 8]> = - (id_props.device_luid_valid == vk::TRUE).then_some(id_props.device_luid); - // Static HDR metadata (ST.2086 mastering + CLL) to the presentation engine. - // Compositors key their "this app is HDR" signaling on the client pushing - // metadata via vkSetHdrMetadataEXT in addition to picking the HDR10 colorspace - // (gamescope's SteamOS HDR badge and per-app tone-map targets among them) — - // the colorspace alone leaves the app looking SDR to the shell. - let has_hdr_metadata = has(ash::ext::hdr_metadata::NAME); - if has_hdr_metadata { - dev_exts.push(ash::ext::hdr_metadata::NAME.as_ptr()); - } - - // --- Vulkan Video decode (the FFmpeg-on-our-device path) --------------------- - // Probed, never required: a capable stack gets the video extensions, a second - // (decode) queue, and the features FFmpeg's decoder needs; anything less means - // `vulkan_decode() == None` and the decoder chain falls back (VAAPI/software). - let dev_props = unsafe { instance.get_physical_device_properties(pdev) }; - let dev_is_13 = vk::api_version_major(dev_props.api_version) > 1 - || vk::api_version_minor(dev_props.api_version) >= 3; - let mut have_f11 = vk::PhysicalDeviceVulkan11Features::default(); - let mut have_f12 = vk::PhysicalDeviceVulkan12Features::default(); - let mut have_f13 = vk::PhysicalDeviceVulkan13Features::default(); - let mut have_f2 = vk::PhysicalDeviceFeatures2::default() - .push_next(&mut have_f11) - .push_next(&mut have_f12) - .push_next(&mut have_f13); - unsafe { instance.get_physical_device_features2(pdev, &mut have_f2) }; - // Copy the one base-features fact out NOW: `have_f2` mutably borrows the 11/12/13 - // structs through its pNext chain, so any later use of it would pin those borrows. - let have_shader_int16 = have_f2.features.shader_int16; - let features_ok = have_f11.sampler_ycbcr_conversion == vk::TRUE - && have_f12.timeline_semaphore == vk::TRUE - && have_f13.synchronization2 == vk::TRUE; - // PyroWave decode (the wired-LAN wavelet codec, design/pyrowave-codec-plan.md §4.5): - // plain Vulkan-1.3 compute on THIS device — no video extensions. Probed alongside so a - // capable device gets the features enabled below and advertises the codec; anything - // less simply never sets the CODEC_PYROWAVE bit. - let pyrowave_ok = dev_is_13 - && have_shader_int16 == vk::TRUE - && have_f12.storage_buffer8_bit_access == vk::TRUE - && have_f12.timeline_semaphore == vk::TRUE - && have_f13.subgroup_size_control == vk::TRUE - && have_f13.compute_full_subgroups == vk::TRUE - && have_f13.synchronization2 == vk::TRUE; - - // The decode queue family + which codec operations it can run. - let decode_family: Option<(u32, vk::VideoCodecOperationFlagsKHR)> = { - let n = unsafe { instance.get_physical_device_queue_family_properties2_len(pdev) }; - let mut video: Vec = - vec![vk::QueueFamilyVideoPropertiesKHR::default(); n]; - let mut props: Vec = video - .iter_mut() - .map(|v| vk::QueueFamilyProperties2::default().push_next(v)) - .collect(); - unsafe { instance.get_physical_device_queue_family_properties2(pdev, &mut props) }; - // `props` mutably borrows `video` (push_next); copy the flags out, then - // read the driver-filled video properties directly. - let flags: Vec = props - .iter() - .map(|p| p.queue_family_properties.queue_flags) - .collect(); - drop(props); - flags - .iter() - .zip(&video) - .enumerate() - .find(|(_, (f, _))| f.contains(vk::QueueFlags::VIDEO_DECODE_KHR)) - .map(|(i, (_, v))| (i as u32, v.video_codec_operations)) - }; - - const VIDEO_BASE: [&std::ffi::CStr; 2] = [ - ash::khr::video_queue::NAME, - ash::khr::video_decode_queue::NAME, - ]; - const VIDEO_CODECS: [&std::ffi::CStr; 3] = [ - ash::khr::video_decode_h264::NAME, - ash::khr::video_decode_h265::NAME, - c"VK_KHR_video_decode_av1", - ]; - let codec_exts: Vec<&std::ffi::CStr> = - VIDEO_CODECS.into_iter().filter(|n| has(n)).collect(); - let video_ok = dev_is_13 - && features_ok - && decode_family.is_some() - && VIDEO_BASE.iter().all(|n| has(n)) - && !codec_exts.is_empty(); - - let (decode_qf, decode_caps) = decode_family.unwrap_or((qfi, Default::default())); - let mut video_ext_names: Vec<&std::ffi::CStr> = Vec::new(); - if video_ok { - video_ext_names.extend(VIDEO_BASE); - video_ext_names.extend(&codec_exts); - // Optional decoder niceties FFmpeg uses when present. - for opt in [c"VK_KHR_video_maintenance1", c"VK_KHR_video_maintenance2"] { - if has(opt) { - video_ext_names.push(opt); - } - } - dev_exts.extend(video_ext_names.iter().map(|n| n.as_ptr())); - tracing::info!( - decode_qf, - caps = ?decode_caps, - exts = ?video_ext_names, - "Vulkan Video decode available on this device" - ); - } else { - tracing::info!( - dev_is_13, - features_ok, - decode_family = decode_family.is_some(), - "Vulkan Video decode unavailable — decoder falls back (VAAPI/software)" - ); - } - - // Enable only the features the video path needs, and only where supported - // (harmless when the path is off; reported to FFmpeg via device_features). - let mut en_f11 = vk::PhysicalDeviceVulkan11Features::default() - .sampler_ycbcr_conversion(have_f11.sampler_ycbcr_conversion == vk::TRUE); - let mut en_f12 = vk::PhysicalDeviceVulkan12Features::default() - .timeline_semaphore(have_f12.timeline_semaphore == vk::TRUE) - .storage_buffer8_bit_access(pyrowave_ok) - .shader_float16(pyrowave_ok && have_f12.shader_float16 == vk::TRUE); - let mut en_f13 = vk::PhysicalDeviceVulkan13Features::default() - .synchronization2(have_f13.synchronization2 == vk::TRUE) - .subgroup_size_control(pyrowave_ok) - .compute_full_subgroups(pyrowave_ok); - let mut en_f2 = vk::PhysicalDeviceFeatures2::default() - .push_next(&mut en_f11) - .push_next(&mut en_f12) - .push_next(&mut en_f13); - en_f2.features.shader_int16 = if pyrowave_ok { vk::TRUE } else { vk::FALSE }; - - let priorities = [1.0f32]; - let mut queue_info = vec![vk::DeviceQueueCreateInfo::default() - .queue_family_index(qfi) - .queue_priorities(&priorities)]; - if video_ok && decode_qf != qfi { - queue_info.push( - vk::DeviceQueueCreateInfo::default() - .queue_family_index(decode_qf) - .queue_priorities(&priorities), - ); - } - let device = unsafe { - instance.create_device( - pdev, - &vk::DeviceCreateInfo::default() - .queue_create_infos(&queue_info) - .enabled_extension_names(&dev_exts) - .push_next(&mut en_f2), - None, - ) - } - .context("vkCreateDevice")?; - let swap_d = ash::khr::swapchain::Device::new(&instance, &device); - let hdr_metadata_d = - has_hdr_metadata.then(|| ash::ext::hdr_metadata::Device::new(&instance, &device)); - let queue = unsafe { device.get_device_queue(qfi, 0) }; - #[cfg(target_os = "linux")] - let hw = if hw_capable { - Some(HwCtx { - ext_mem_fd: ash::khr::external_memory_fd::Device::new(&instance, &device), - }) - } else { - None - }; - #[cfg(windows)] - let hw_win = win_capable.then(|| HwCtxWin { - ext_mem_win32: ash::khr::external_memory_win32::Device::new(&instance, &device), - }); - let csc = CscPass::new(&device, vk::Format::R8G8B8A8_UNORM)?; - // PyroWave is 8-bit SDR only, so the planar pass never needs the HDR10 rebuild. - #[cfg(all(target_os = "linux", feature = "pyrowave"))] - let csc_planar = if pyrowave_ok { - Some(CscPass::new_planar(&device, vk::Format::R8G8B8A8_UNORM)?) - } else { - None - }; - - // The exported handle bundle: FFmpeg Vulkan Video handles when the device can - // decode, AND (Windows) the D3D11-interop facts — so it's built whenever EITHER - // consumer needs it; `video_decode`/`d3d11_import` tell the decoder chain which - // paths are real. Extension lists must mirror creation exactly — FFmpeg keys its - // code paths off the strings. - // One lock per device for queue external sync (FFmpeg + Skia + this presenter - // all funnel their queue calls through it — see the `queue_lock` field docs). - let queue_lock = std::sync::Arc::new(pf_client_core::video::QueueLock::new()); - #[cfg(windows)] - let export_worthy = video_ok || win_capable || pyrowave_ok; - #[cfg(not(windows))] - let export_worthy = video_ok || pyrowave_ok; - let video_export = if export_worthy { - let qf_props = unsafe { instance.get_physical_device_queue_family_properties(pdev) }; - let mut device_extensions: Vec = - vec![CString::from(ash::khr::swapchain::NAME)]; - #[cfg(target_os = "linux")] - if hw_capable { - device_extensions - .extend(dmabuf::DEVICE_EXTENSIONS.iter().map(|n| CString::from(*n))); - } - #[cfg(windows)] - if win_capable { - device_extensions.extend( - crate::d3d11::DEVICE_EXTENSIONS - .iter() - .map(|n| CString::from(*n)), - ); - } - if has_hdr_metadata { - device_extensions.push(CString::from(ash::ext::hdr_metadata::NAME)); - } - device_extensions.extend(video_ext_names.iter().map(|n| CString::from(*n))); - Some(pf_client_core::video::VulkanDecodeDevice { - get_instance_proc_addr: entry.static_fn().get_instance_proc_addr as usize, - instance: instance.handle().as_raw() as usize, - physical_device: pdev.as_raw() as usize, - device: device.handle().as_raw() as usize, - vendor_id: dev_props.vendor_id, - device_name: dev_props - .device_name_as_c_str() - .map(|c| c.to_string_lossy().into_owned()) - .unwrap_or_default(), - graphics_qf: qfi, - graphics_queue_flags: qf_props[qfi as usize].queue_flags.as_raw(), - decode_qf, - decode_video_caps: decode_caps.as_raw(), - instance_extensions: instance_extensions - .iter() - .map(|e| CString::new(e.as_str()).unwrap()) - .collect(), - device_extensions, - f_sampler_ycbcr: have_f11.sampler_ycbcr_conversion == vk::TRUE, - f_timeline_semaphore: have_f12.timeline_semaphore == vk::TRUE, - f_synchronization2: have_f13.synchronization2 == vk::TRUE, - f_shader_int16: pyrowave_ok, - f_storage_buffer8: pyrowave_ok, - f_subgroup_size_control: pyrowave_ok, - f_compute_full_subgroups: pyrowave_ok, - f_shader_float16: pyrowave_ok && have_f12.shader_float16 == vk::TRUE, - api_version: dev_props.api_version, - queue_families: queue_info.iter().map(|q| q.queue_family_index).collect(), - pyrowave_decode: pyrowave_ok, - video_decode: video_ok, - #[cfg(windows)] - d3d11_import: win_capable, - #[cfg(not(windows))] - d3d11_import: false, - adapter_luid, - queue_lock: queue_lock.clone(), - }) - } else { - None - }; - - let (format, hdr10_format) = pick_formats(&surface_i, pdev, surface, has_colorspace_ext)?; - let present_mode = pick_present_mode(&surface_i, pdev, surface)?; - tracing::info!( - ?format, - ?hdr10_format, - ?present_mode, - hdr_metadata = has_hdr_metadata, - "swapchain config" - ); - let overlay_pipe = OverlayPipe::new(&device, format.format)?; - - let cmd_pool = unsafe { - device.create_command_pool( - &vk::CommandPoolCreateInfo::default() - .flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER) - .queue_family_index(qfi), - None, - ) - }?; - let cmd_buf = unsafe { - device.allocate_command_buffers( - &vk::CommandBufferAllocateInfo::default() - .command_pool(cmd_pool) - .level(vk::CommandBufferLevel::PRIMARY) - .command_buffer_count(1), - ) - }?[0]; - let acquire_sem = - unsafe { device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None) }?; - let fence = unsafe { - device.create_fence( - &vk::FenceCreateInfo::default().flags(vk::FenceCreateFlags::SIGNALED), - None, - ) - }?; - - let mut p = Presenter { - entry, - instance, - surface_i, - surface, - pdev, - mem_props, - device, - swap_d, - queue, - qfi, - #[cfg(target_os = "linux")] - hw, - #[cfg(windows)] - hw_win, - csc, - #[cfg(all(target_os = "linux", feature = "pyrowave"))] - csc_planar, - video_export, - overlay_pipe, - retired_hw: None, - queue_lock, - format, - hdr10_format, - hdr_active: false, - hdr_downgrade_warned: false, - hdr_metadata_d, - hdr_meta: None, - video_format: vk::Format::R8G8B8A8_UNORM, - present_mode, - swapchain: vk::SwapchainKHR::null(), - images: Vec::new(), - extent: vk::Extent2D::default(), - render_sems: Vec::new(), - acquire_sem, - fence, - cmd_pool, - cmd_buf, - staging: None, - video: None, - submitted: false, - }; - p.recreate_swapchain(window)?; - Ok(p) - } - - /// Wait the in-flight fence: OUR command buffers are done (staging, video image, - /// old-swapchain images). Deliberately NOT `vkDeviceWaitIdle` — the pump thread - /// submits FFmpeg's Vulkan decode work concurrently, and wait-idle's external-sync - /// rule over every device queue would race it (observed as a resize crash). - fn quiesce_own(&mut self) -> Result<()> { - unsafe { - if self.submitted { - self.device.wait_for_fences(&[self.fence], true, u64::MAX)?; - self.submitted = false; - } - } - Ok(()) - } - - /// (Re)build the swapchain for the window's current pixel size. Also the resize path. - pub fn recreate_swapchain(&mut self, window: &sdl3::video::Window) -> Result<()> { - self.quiesce_own()?; - // Drain the queue before touching presentation objects: after this, every prior - // present's semaphore-wait operation has completed, so the OLD swapchain and its - // render semaphores are safe to destroy immediately below. (The previous scheme - // parked them and destroyed after one fence cycle — but the fence proves only - // OUR submit, not the presentation engine's semaphore consumption: - // VUID-vkDestroySemaphore-05149 / VUID-vkDestroySwapchainKHR-01282 on every - // recreate, and destroy-in-use is exactly the kind of misuse that turns into an - // intermittent VK_ERROR_DEVICE_LOST.) Safe against the pump's FFmpeg submits — - // both sides hold the shared queue lock — and cheap: a recreate already stalls - // the stream for a frame, and only happens on resize/HDR-flip/OUT_OF_DATE. - { - let _q = self.queue_lock.guard(); - unsafe { self.device.queue_wait_idle(self.queue) } - .context("vkQueueWaitIdle (swapchain recreate)")?; - } - - let caps = unsafe { - self.surface_i - .get_physical_device_surface_capabilities(self.pdev, self.surface) - }?; - let (pw, ph) = window.size_in_pixels(); - let extent = if caps.current_extent.width != u32::MAX { - caps.current_extent - } else { - vk::Extent2D { - width: pw.clamp(caps.min_image_extent.width, caps.max_image_extent.width), - height: ph.clamp(caps.min_image_extent.height, caps.max_image_extent.height), - } - }; - if extent.width == 0 || extent.height == 0 { - // Minimized — keep the old swapchain; presents will report OUT_OF_DATE and - // land back here once the window has a size again. - return Ok(()); - } - let mut min_images = caps.min_image_count + 1; - if caps.max_image_count > 0 { - min_images = min_images.min(caps.max_image_count); - } - - let old = self.swapchain; - let info = vk::SwapchainCreateInfoKHR::default() - .surface(self.surface) - .min_image_count(min_images) - .image_format(self.format.format) - .image_color_space(self.format.color_space) - .image_extent(extent) - .image_array_layers(1) - // TRANSFER_DST is the whole phase-1 pipeline (clear + blit); COLOR_ATTACHMENT - // keeps the phase-2 render pass from forcing a swapchain rebuild contract change. - .image_usage(vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::TRANSFER_DST) - .image_sharing_mode(vk::SharingMode::EXCLUSIVE) - .pre_transform(caps.current_transform) - .composite_alpha(vk::CompositeAlphaFlagsKHR::OPAQUE) - .present_mode(self.present_mode) - .clipped(true) - .old_swapchain(old); - let swapchain = - unsafe { self.swap_d.create_swapchain(&info, None) }.context("vkCreateSwapchainKHR")?; - // The old swapchain and everything tied to its images dies NOW: the fence - // quiesce covered our own command buffers, the queue drain above covered the - // presentation engine's semaphore waits — nothing can still reference them. - let (overlay_views, overlay_framebuffers) = self.overlay_pipe.take_targets(); - unsafe { - for fb in overlay_framebuffers { - self.device.destroy_framebuffer(fb, None); - } - for v in overlay_views { - self.device.destroy_image_view(v, None); - } - for s in self.render_sems.drain(..) { - self.device.destroy_semaphore(s, None); - } - if old != vk::SwapchainKHR::null() { - self.swap_d.destroy_swapchain(old, None); - } - } - self.swapchain = swapchain; - self.images = unsafe { self.swap_d.get_swapchain_images(swapchain) }?; - self.extent = extent; - self.overlay_pipe.rebuild_targets( - &self.device, - &self.images, - self.format.format, - extent, - )?; - - for _ in 0..self.images.len() { - self.render_sems.push(unsafe { - self.device - .create_semaphore(&vk::SemaphoreCreateInfo::default(), None) - }?); - } - tracing::debug!( - width = extent.width, - height = extent.height, - images = self.images.len(), - "swapchain (re)created" - ); - // HDR metadata is per-swapchain state: a rebuilt HDR10 swapchain needs it pushed - // again (this also covers set_hdr_mode's entry into HDR10, which lands here). - if self.hdr_active { - self.apply_hdr_metadata(); - } - Ok(()) - } - - /// Whether the swapchain is actually in HDR10/PQ mode — as opposed to a PQ stream - /// being tone-mapped onto an SDR surface. This, not the stream's own signaling, is - /// what user-facing "HDR" indicators should report. - pub fn hdr_active(&self) -> bool { - self.hdr_active - } - - /// Record the host's ST.2086 mastering + content-light metadata (the 0xCE plane), - /// pushing it to the swapchain immediately when HDR10 mode is live. Cheap and - /// idempotent per distinct value — callers just drain the plane into it. - pub fn set_hdr_metadata(&mut self, meta: punktfunk_core::quic::HdrMeta) { - if self.hdr_meta == Some(meta) { - return; - } - self.hdr_meta = Some(meta); - if self.hdr_active { - self.apply_hdr_metadata(); - } - } - - /// Push the current metadata (the host's, or a generic HDR10 baseline until 0xCE - /// arrives) to the presentation engine via `vkSetHdrMetadataEXT`. Compositors gate - /// their HDR-app signaling on this — picking the HDR10 colorspace alone leaves - /// gamescope treating the app as SDR (no SteamOS HDR badge, no per-app tone-map - /// target). No-op where the driver lacks the extension. - fn apply_hdr_metadata(&self) { - let Some(ext) = &self.hdr_metadata_d else { - return; - }; - // Same generic baseline as the Windows presenter: BT.2020 primaries + D65 - // white, 1000-nit mastering display, MaxCLL 1000 / MaxFALL 400. - let m = self.hdr_meta.unwrap_or(punktfunk_core::quic::HdrMeta { - display_primaries: [[8500, 39850], [6550, 2300], [35400, 14600]], - white_point: [15635, 16450], - max_display_mastering_luminance: 10_000_000, - min_display_mastering_luminance: 1, - max_cll: 1000, - max_fall: 400, - }); - // Protocol fields are HDR10 SEI fixed-point (chromaticity 1/50000, luminance - // 0.0001 cd/m², primaries in ST.2086 G,B,R order); Vulkan wants floats in - // 0..1 chromaticity and whole nits, primaries named R/G/B. - let xy = |p: [u16; 2]| vk::XYColorEXT { - x: p[0] as f32 / 50_000.0, - y: p[1] as f32 / 50_000.0, - }; - let [g, b, r] = m.display_primaries; - let md = vk::HdrMetadataEXT::default() - .display_primary_red(xy(r)) - .display_primary_green(xy(g)) - .display_primary_blue(xy(b)) - .white_point(xy(m.white_point)) - .max_luminance(m.max_display_mastering_luminance as f32 / 10_000.0) - .min_luminance(m.min_display_mastering_luminance as f32 / 10_000.0) - .max_content_light_level(m.max_cll as f32) - .max_frame_average_light_level(m.max_fall as f32); - unsafe { ext.set_hdr_metadata(&[self.swapchain], &[md]) }; - tracing::debug!(from_host = self.hdr_meta.is_some(), "HDR metadata pushed"); - } - - /// Whether the hardware (dmabuf) path exists on this device — callers keep the - /// decoder on software when it doesn't. - #[cfg(target_os = "linux")] - pub fn supports_dmabuf(&self) -> bool { - self.hw.is_some() - } - - /// Whether the D3D11 shared-texture path exists on this device — callers keep the - /// decoder on software when it doesn't. - #[cfg(windows)] - pub fn supports_d3d11(&self) -> bool { - self.hw_win.is_some() - } - - /// The FFmpeg Vulkan Video decode handle bundle — `None` when this stack can't - /// (device < 1.3, missing video extensions/queue/features). The decoder chain - /// falls back to VAAPI/software then. - pub fn vulkan_decode(&self) -> Option { - self.video_export.clone() - } - - /// Full device idle — TEARDOWN ONLY, and only after the session pump thread has - /// been joined (it submits FFmpeg decode work; wait-idle's external-sync rule - /// covers every queue on the device). Mid-session code uses the fence quiesce. - /// The queue lock is held as cheap insurance against a straggling submitter. - pub fn wait_idle(&self) { - let _q = self.queue_lock.guard(); - unsafe { self.device.device_wait_idle() }.ok(); - } - - /// The device handles the console-UI overlay renders on (§6.1). Valid for the - /// presenter's lifetime; the run loop drops the overlay first. - pub fn shared_device(&self) -> SharedDevice { - SharedDevice { - entry: self.entry.clone(), - instance: self.instance.clone(), - physical_device: self.pdev, - device: self.device.clone(), - queue: self.queue, - queue_family_index: self.qfi, - queue_lock: self.queue_lock.clone(), - } - } - - /// Flip the presenter between SDR and HDR10 output (stream SDR↔PQ, in-band). A - /// fence quiesce, then everything format-bound is rebuilt: the CSC pass + video - /// image (10-bit intermediate — PQ in 8 bits bands visibly), the overlay pipe, and - /// the swapchain (old one parked per the deferred-destroy rules). - fn set_hdr_mode(&mut self, window: &sdl3::video::Window, on: bool) -> Result<()> { - let target = if on { - self.hdr10_format.expect("caller checked availability") - } else { - // Recompute the SDR pick? It never changed — the sdr format is immutable. - // (self.format currently holds the HDR pairing.) - pick_formats(&self.surface_i, self.pdev, self.surface, false)?.0 - }; - tracing::info!(hdr = on, format = ?target, "switching presentation mode"); - self.quiesce_own()?; - self.video_format = if on { - vk::Format::A2B10G10R10_UNORM_PACK32 - } else { - vk::Format::R8G8B8A8_UNORM - }; - self.csc.destroy(&self.device); // fence-safe: only our cmd bufs reference it - #[cfg(all(target_os = "linux", feature = "pyrowave"))] - if let Some(p) = &self.csc_planar { - p.destroy(&self.device); - } - self.csc = CscPass::new(&self.device, self.video_format)?; - if let Some(v) = self.video.take() { - unsafe { - self.device.destroy_framebuffer(v.framebuffer, None); - self.device.destroy_image_view(v.view, None); - self.device.destroy_image(v.image, None); - self.device.free_memory(v.memory, None); - } - } - // New overlay pipe against the new swapchain format. The old one's targets - // (views/framebuffers over the current swapchain's images) are only ever - // referenced by our own command buffers — the fence quiesce above makes them - // safe to destroy right here; the swapchain itself rides the recreate below. - let mut old_pipe = std::mem::replace( - &mut self.overlay_pipe, - OverlayPipe::new(&self.device, target.format)?, - ); - let (overlay_views, overlay_framebuffers) = old_pipe.take_targets(); - unsafe { - for fb in overlay_framebuffers { - self.device.destroy_framebuffer(fb, None); - } - for v in overlay_views { - self.device.destroy_image_view(v, None); - } - } - old_pipe.destroy(&self.device); - self.format = target; - self.hdr_active = on; - self.recreate_swapchain(window) - } - - /// Present one frame: route `input` into the video image (staging upload or dmabuf - /// import + CSC pass; `Redraw` re-blits what's retained), clear, letterbox-blit, - /// blend the console-UI `overlay` quad if one arrived, present. Returns false when - /// the swapchain was out of date — the caller recreates (with current window state) - /// and may retry. - pub fn present( - &mut self, - window: &sdl3::video::Window, - input: FrameInput, - overlay: Option<&OverlayFrame>, - ) -> Result { - if self.extent.width == 0 || self.extent.height == 0 { - return Ok(true); // minimized — nothing to do - } - // SDR↔HDR follows the FRAMES' own signaling (the host flips PQ in-band): - // switch modes before anything touches this frame. Only where the surface - // offers HDR10 — otherwise PQ stays on the SDR swapchain and the CSC shader - // tonemaps (mode 1). - let frame_pq = match &input { - FrameInput::Redraw => None, - FrameInput::Cpu(f) => Some(f.color.is_pq()), - #[cfg(target_os = "linux")] - FrameInput::Dmabuf(d) => Some(d.color.is_pq()), - FrameInput::VkFrame(v) => Some(v.color.is_pq()), - #[cfg(windows)] - FrameInput::D3d11(d) => Some(d.color.is_pq()), - #[cfg(all(target_os = "linux", feature = "pyrowave"))] - FrameInput::PyroWave(f) => Some(f.color.is_pq()), // always SDR today - }; - if let Some(pq) = frame_pq { - // A PQ stream we can only tone-map (no HDR10 surface) is the silent failure behind - // "HDR isn't advertised": the compositor never sees an HDR-committing app. Say so - // once — its presence proves PQ IS arriving and the surface/compositor is the - // blocker (on the Deck: gamescope's WSI layer not visible in the flatpak sandbox); - // its absence, with a plain SDR stream, points back at the host not sending PQ. - if pq && self.hdr10_format.is_none() && !self.hdr_downgrade_warned { - self.hdr_downgrade_warned = true; - tracing::warn!( - "PQ (HDR10) stream tone-mapped to SDR — the surface offers no HDR10 \ - colorspace, so no HDR is committed to the compositor. Under gamescope this \ - usually means the gamescope Vulkan WSI layer is not visible in the sandbox." - ); - } - let want = pq && self.hdr10_format.is_some(); - if want != self.hdr_active { - self.set_hdr_mode(window, want)?; - } - } - // Hardware frames prepare before anything touches the queue: an import/view the - // driver rejects must fail out here, before this present consumed the acquire - // semaphore. - #[cfg(target_os = "linux")] - let mut hw_frame: Option = None; - #[cfg(windows)] - let mut win_frame: Option = None; - let mut vk_frame: Option<(VkVideoFrame, [vk::ImageView; 2])> = None; - #[cfg(all(target_os = "linux", feature = "pyrowave"))] - let mut pyro_frame: Option = None; - let cpu_frame = match input { - FrameInput::Redraw => None, - FrameInput::Cpu(f) => Some(f), - #[cfg(target_os = "linux")] - FrameInput::Dmabuf(d) => { - let hw = self - .hw - .as_ref() - .context("hardware frame without dmabuf support")?; - hw_frame = Some(dmabuf::import(&self.device, &hw.ext_mem_fd, d)?); - None - } - #[cfg(windows)] - FrameInput::D3d11(d) => { - let hw = self - .hw_win - .as_ref() - .context("D3D11 frame without win32 import support")?; - win_frame = Some(crate::d3d11::import(&self.device, &hw.ext_mem_win32, &d)?); - None - } - FrameInput::VkFrame(v) => { - let views = self.vkframe_plane_views(&v)?; - vk_frame = Some((v, views)); - None - } - #[cfg(all(target_os = "linux", feature = "pyrowave"))] - FrameInput::PyroWave(f) => { - pyro_frame = Some(f); - None - } - }; - - // One frame in flight: the fence covers the command buffer, the staging buffer - // AND the previously submitted hw frame — waiting makes all three reusable. - unsafe { - if self.submitted { - self.device.wait_for_fences(&[self.fence], true, u64::MAX)?; - self.submitted = false; - } - self.device.reset_fences(&[self.fence])?; - } - if let Some(old) = self.retired_hw.take() { - old.destroy(&self.device); - } - - if let Some(f) = cpu_frame { - self.stage_frame(f)?; - } - #[cfg(target_os = "linux")] - if let Some(f) = &hw_frame { - if self - .video - .as_ref() - .is_none_or(|v| v.width != f.width || v.height != f.height) - { - self.rebuild_video_image(f.width, f.height)?; - tracing::info!(width = f.width, height = f.height, "video image (re)built"); - } - // Safe while nothing in flight references the set — the fence wait above. - self.csc - .bind_planes(&self.device, f.luma_view, f.chroma_view); - } - #[cfg(windows)] - if let Some(f) = &win_frame { - if self - .video - .as_ref() - .is_none_or(|v| v.width != f.width || v.height != f.height) - { - self.rebuild_video_image(f.width, f.height)?; - tracing::info!(width = f.width, height = f.height, "video image (re)built"); - } - } - if let Some((f, views)) = &vk_frame { - if self - .video - .as_ref() - .is_none_or(|v| v.width != f.width || v.height != f.height) - { - self.rebuild_video_image(f.width, f.height)?; - tracing::info!(width = f.width, height = f.height, "video image (re)built"); - } - self.csc.bind_planes(&self.device, views[0], views[1]); - } - #[cfg(all(target_os = "linux", feature = "pyrowave"))] - if let Some(f) = &pyro_frame { - if self - .video - .as_ref() - .is_none_or(|v| v.width != f.width || v.height != f.height) - { - self.rebuild_video_image(f.width, f.height)?; - tracing::info!(width = f.width, height = f.height, "video image (re)built"); - } - let planar = self - .csc_planar - .as_ref() - .context("PyroWave frame but the device failed the pyrowave probe")?; - planar.bind_planes_planar(&self.device, f.views.map(vk::ImageView::from_raw)); - } - if let Some(o) = overlay { - // Point the composite at this overlay image (same fence-wait safety). - let infos = [vk::DescriptorImageInfo::default() - .image_view(o.view) - .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)]; - let writes = [vk::WriteDescriptorSet::default() - .dst_set(self.overlay_pipe.desc_set) - .dst_binding(0) - .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) - .image_info(&infos)]; - unsafe { self.device.update_descriptor_sets(&writes, &[]) }; - } - - let (index, _suboptimal) = match unsafe { - self.swap_d.acquire_next_image( - self.swapchain, - u64::MAX, - self.acquire_sem, - vk::Fence::null(), - ) - } { - Ok(r) => r, - Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => { - // Never submitted — the import (if any) dies here, GPU never saw it. - #[cfg(target_os = "linux")] - if let Some(f) = hw_frame { - f.destroy(&self.device); - } - #[cfg(windows)] - if let Some(f) = win_frame { - f.destroy(&self.device); - } - self.recreate_swapchain(window)?; - return Ok(false); - } - Err(e) => return Err(e).context("vkAcquireNextImageKHR"), - }; - let swap_image = self.images[index as usize]; - - unsafe { - self.device.begin_command_buffer( - self.cmd_buf, - &vk::CommandBufferBeginInfo::default() - .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT), - )?; - - // Dmabuf frame: acquire the foreign planes, then the CSC pass renders - // NV12→RGBA into the video image (render pass ends it in TRANSFER_SRC for - // the blit below). - #[cfg(target_os = "linux")] - if let (Some(f), Some(v)) = (&hw_frame, &self.video) { - for view_image in [f.luma_image(), f.chroma_image()] { - foreign_acquire_barrier(&self.device, self.cmd_buf, view_image, self.qfi); - } - let extent = vk::Extent2D { - width: v.width, - height: v.height, - }; - let ten_bit = f.is_p010(); - self.record_csc( - v.framebuffer, - extent, - f.color, - if ten_bit { 10 } else { 8 }, - ten_bit, - ); - } - - // D3D11 frame: acquire the imported BGRA texture from the external "queue - // family" (the keyed mutex on the submit is the actual cross-API sync) and - // blit it into the video image — the frame arrives as ready sRGB from the - // decoder's VideoProcessor, so there is no CSC pass; the blit converts the - // BGRA→RGBA component order. Same layout dance as the CPU staging path. - #[cfg(windows)] - if let (Some(f), Some(v)) = (&win_frame, &self.video) { - external_acquire_barrier(&self.device, self.cmd_buf, f.image(), self.qfi); - barrier( - &self.device, - self.cmd_buf, - v.image, - vk::ImageLayout::UNDEFINED, - vk::ImageLayout::TRANSFER_DST_OPTIMAL, - ); - let extent = vk::Offset3D { - x: v.width as i32, - y: v.height as i32, - z: 1, - }; - let blit = vk::ImageBlit::default() - .src_subresource(subresource_layers()) - .src_offsets([vk::Offset3D::default(), extent]) - .dst_subresource(subresource_layers()) - .dst_offsets([vk::Offset3D::default(), extent]); - self.device.cmd_blit_image( - self.cmd_buf, - f.image(), - vk::ImageLayout::TRANSFER_SRC_OPTIMAL, - v.image, - vk::ImageLayout::TRANSFER_DST_OPTIMAL, - &[blit], - vk::Filter::NEAREST, // 1:1 — the composite blit below does the scaling - ); - barrier( - &self.device, - self.cmd_buf, - v.image, - vk::ImageLayout::TRANSFER_DST_OPTIMAL, - vk::ImageLayout::TRANSFER_SRC_OPTIMAL, - ); - } - - // Vulkan-Video frame: the decoded image is already on THIS device. Read the - // live sync state under the frames lock (held through submission — the - // AVVulkanFramesContext contract), acquire from the decode queue family, - // then the same CSC pass. - let mut vk_sync: Option = None; - if let (Some((f, _)), Some(v)) = (&vk_frame, &self.video) { - let sync = lock_vkframe(f); - vkframe_acquire_barrier( - &self.device, - self.cmd_buf, - vk::Image::from_raw(sync.image), - vk::ImageLayout::from_raw(sync.layout), - sync.queue_family, - self.qfi, - ); - let extent = vk::Extent2D { - width: v.width, - height: v.height, - }; - let ten_bit = - f.vk_format == vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16.as_raw(); - self.record_csc( - v.framebuffer, - extent, - f.color, - if ten_bit { 10 } else { 8 }, - ten_bit, - ); - vk_sync = Some(sync); - } - - // PyroWave frame: the planes are already on THIS device, decode - // fence-complete and barriered to fragment sampling (GENERAL) by the - // decoder — no acquire needed, just the planar CSC pass. - #[cfg(all(target_os = "linux", feature = "pyrowave"))] - if let (Some(f), Some(v)) = (&pyro_frame, &self.video) { - let extent = vk::Extent2D { - width: v.width, - height: v.height, - }; - self.record_csc_planar(v.framebuffer, extent, f.color); - } - - // New frame: staging → video image (stride carried by buffer_row_length). - if let (Some(f), Some(v), Some(s)) = (cpu_frame, &self.video, &self.staging) { - barrier( - &self.device, - self.cmd_buf, - v.image, - vk::ImageLayout::UNDEFINED, - vk::ImageLayout::TRANSFER_DST_OPTIMAL, - ); - let region = vk::BufferImageCopy::default() - .buffer_row_length((f.stride / 4) as u32) - .image_subresource(subresource_layers()) - .image_extent(vk::Extent3D { - width: v.width, - height: v.height, - depth: 1, - }); - self.device.cmd_copy_buffer_to_image( - self.cmd_buf, - s.buffer, - v.image, - vk::ImageLayout::TRANSFER_DST_OPTIMAL, - &[region], - ); - barrier( - &self.device, - self.cmd_buf, - v.image, - vk::ImageLayout::TRANSFER_DST_OPTIMAL, - vk::ImageLayout::TRANSFER_SRC_OPTIMAL, - ); - } - - // Swapchain image: discard old content, clear to black (the letterbox bars), - // blit the video in, hand to present. - barrier( - &self.device, - self.cmd_buf, - swap_image, - vk::ImageLayout::UNDEFINED, - vk::ImageLayout::TRANSFER_DST_OPTIMAL, - ); - self.device.cmd_clear_color_image( - self.cmd_buf, - swap_image, - vk::ImageLayout::TRANSFER_DST_OPTIMAL, - &vk::ClearColorValue { - float32: [0.0, 0.0, 0.0, 1.0], - }, - &[subresource_range()], - ); - if let Some(v) = &self.video { - let (dst0, dst1) = letterbox(self.extent, v.width, v.height); - let blit = vk::ImageBlit::default() - .src_subresource(subresource_layers()) - .src_offsets([ - vk::Offset3D { x: 0, y: 0, z: 0 }, - vk::Offset3D { - x: v.width as i32, - y: v.height as i32, - z: 1, - }, - ]) - .dst_subresource(subresource_layers()) - .dst_offsets([dst0, dst1]); - self.device.cmd_blit_image( - self.cmd_buf, - v.image, - vk::ImageLayout::TRANSFER_SRC_OPTIMAL, - swap_image, - vk::ImageLayout::TRANSFER_DST_OPTIMAL, - &[blit], - vk::Filter::LINEAR, - ); - } - if let Some(o) = overlay { - // Cross-submit visibility for the overlay image (Skia flushed it on this - // queue): same-layout barrier = execution + memory dependency only. - barrier( - &self.device, - self.cmd_buf, - o.image, - vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL, - vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL, - ); - barrier( - &self.device, - self.cmd_buf, - swap_image, - vk::ImageLayout::TRANSFER_DST_OPTIMAL, - vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL, - ); - // The composite pass blends the quad and ends the image PRESENT-ready. - self.device.cmd_begin_render_pass( - self.cmd_buf, - &vk::RenderPassBeginInfo::default() - .render_pass(self.overlay_pipe.render_pass) - .framebuffer(self.overlay_pipe.framebuffers[index as usize]) - .render_area(vk::Rect2D { - offset: vk::Offset2D { x: 0, y: 0 }, - extent: self.extent, - }), - vk::SubpassContents::INLINE, - ); - self.device.cmd_bind_pipeline( - self.cmd_buf, - vk::PipelineBindPoint::GRAPHICS, - self.overlay_pipe.pipeline, - ); - self.device.cmd_set_viewport( - self.cmd_buf, - 0, - &[vk::Viewport { - x: 0.0, - y: 0.0, - width: self.extent.width as f32, - height: self.extent.height as f32, - min_depth: 0.0, - max_depth: 1.0, - }], - ); - self.device.cmd_set_scissor( - self.cmd_buf, - 0, - &[vk::Rect2D { - offset: vk::Offset2D { x: 0, y: 0 }, - extent: self.extent, - }], - ); - self.device.cmd_bind_descriptor_sets( - self.cmd_buf, - vk::PipelineBindPoint::GRAPHICS, - self.overlay_pipe.pipeline_layout, - 0, - &[self.overlay_pipe.desc_set], - &[], - ); - self.device.cmd_draw(self.cmd_buf, 3, 1, 0, 0); - self.device.cmd_end_render_pass(self.cmd_buf); - } else { - barrier( - &self.device, - self.cmd_buf, - swap_image, - vk::ImageLayout::TRANSFER_DST_OPTIMAL, - vk::ImageLayout::PRESENT_SRC_KHR, - ); - } - self.device.end_command_buffer(self.cmd_buf)?; - - let render_sem = self.render_sems[index as usize]; - let cmd_bufs = [self.cmd_buf]; - let mut wait_sems = vec![self.acquire_sem]; - let mut wait_stages = vec![vk::PipelineStageFlags::TRANSFER]; - let mut signal_sems = vec![render_sem]; - // The Vulkan-Video frame's timeline semaphore: wait for the decoder's value, - // signal value+1 when our reads are done (FFmpeg's per-submission contract). - let mut wait_values = vec![0u64]; - let mut signal_values = vec![0u64]; - if let Some(sync) = &vk_sync { - let sem = vk::Semaphore::from_raw(sync.semaphore); - wait_sems.push(sem); - wait_stages.push(vk::PipelineStageFlags::FRAGMENT_SHADER); - wait_values.push(sync.sem_value); - signal_sems.push(sem); - signal_values.push(sync.sem_value + 1); - } - let mut timeline = vk::TimelineSemaphoreSubmitInfo::default() - .wait_semaphore_values(&wait_values) - .signal_semaphore_values(&signal_values); - let mut submit = vk::SubmitInfo::default() - .wait_semaphores(&wait_sems) - .wait_dst_stage_mask(&wait_stages) - .command_buffers(&cmd_bufs) - .signal_semaphores(&signal_sems); - if vk_sync.is_some() { - submit = submit.push_next(&mut timeline); - } - // D3D11 frame: bracket the submit in the shared texture's keyed mutex, key 0 - // both ways (the decode side copies under acquire(0)/release(0) too) — the - // GPU-side acquire is what orders our sampling after the decoder's copy, and - // our completion release is what unblocks the ring slot's reuse. - #[cfg(windows)] - let keyed_mem; - #[cfg(windows)] - let keyed_keys = [0u64]; - #[cfg(windows)] - let keyed_timeouts = [2000u32]; - #[cfg(windows)] - let mut keyed_info; - #[cfg(windows)] - if let Some(f) = &win_frame { - // Bisect knob: PUNKTFUNK_D3D11_NO_MUTEX=1 skips the acquire/release pair - // (torn frames possible — debugging only). - if std::env::var_os("PUNKTFUNK_D3D11_NO_MUTEX").is_none() { - keyed_mem = [f.memory()]; - keyed_info = vk::Win32KeyedMutexAcquireReleaseInfoKHR::default() - .acquire_syncs(&keyed_mem) - .acquire_keys(&keyed_keys) - .acquire_timeouts(&keyed_timeouts) - .release_syncs(&keyed_mem) - .release_keys(&keyed_keys); - submit = submit.push_next(&mut keyed_info); - } - } - let submitted = { - // Queue external sync vs the pump's FFmpeg submits (see `queue_lock`). - let _q = self.queue_lock.guard(); - self.device.queue_submit(self.queue, &[submit], self.fence) - }; - // Write the new sync state back and release the frames lock REGARDLESS of - // the submit outcome (an abandoned lock would wedge the decoder). - if let Some(sync) = vk_sync.take() { - let ok = submitted.is_ok(); - unlock_vkframe( - vk_frame - .as_ref() - .map(|(f, _)| f) - .expect("vk_sync implies vk_frame"), - &sync, - ok, - self.qfi, - ); - } - submitted?; - self.submitted = true; - // The hw frame is on the GPU now — park it until the fence proves the reads - // done (destroyed at the next present's fence wait, or in Drop). At most one - // of hw_frame/vk_frame is set (they route from the same `input`). - self.retired_hw = vk_frame - .take() - .map(|(frame, views)| Retired::Vk { frame, views }); - #[cfg(target_os = "linux")] - if let Some(f) = hw_frame.take() { - self.retired_hw = Some(Retired::Dmabuf(f)); - } - #[cfg(windows)] - if let Some(f) = win_frame.take() { - self.retired_hw = Some(Retired::D3d11(f)); - } - - let swapchains = [self.swapchain]; - let indices = [index]; - let present_sems = [render_sem]; - // Same queue external-sync rule as the submit above. Scoped tightly: the - // OUT_OF_DATE arm re-enters the lock via recreate_swapchain's queue drain. - let present_res = { - let _q = self.queue_lock.guard(); - self.swap_d.queue_present( - self.queue, - &vk::PresentInfoKHR::default() - .wait_semaphores(&present_sems) - .swapchains(&swapchains) - .image_indices(&indices), - ) - }; - match present_res { - Ok(_) => Ok(true), - Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => { - self.recreate_swapchain(window)?; - Ok(false) - } - Err(e) => Err(e).context("vkQueuePresentKHR"), - } - } - } - - /// Record the NV12→RGBA CSC pass into the video image (framebuffer): fullscreen - /// triangle, CICP-driven push-constant rows. Shared by the dmabuf and Vulkan-Video - /// paths — only the plane views bound beforehand differ. - /// - /// # Safety - /// `self.cmd_buf` must be in the recording state; the CSC descriptor set must point - /// at live plane views. - unsafe fn record_csc( - &self, - framebuffer: vk::Framebuffer, - extent: vk::Extent2D, - color: pf_client_core::video::ColorDesc, - depth: u8, - msb_packed: bool, - ) { - unsafe { - self.device.cmd_begin_render_pass( - self.cmd_buf, - &vk::RenderPassBeginInfo::default() - .render_pass(self.csc.render_pass) - .framebuffer(framebuffer) - .render_area(vk::Rect2D { - offset: vk::Offset2D { x: 0, y: 0 }, - extent, - }), - vk::SubpassContents::INLINE, - ); - self.device.cmd_bind_pipeline( - self.cmd_buf, - vk::PipelineBindPoint::GRAPHICS, - self.csc.pipeline, - ); - self.device.cmd_set_viewport( - self.cmd_buf, - 0, - &[vk::Viewport { - x: 0.0, - y: 0.0, - width: extent.width as f32, - height: extent.height as f32, - min_depth: 0.0, - max_depth: 1.0, - }], - ); - self.device.cmd_set_scissor( - self.cmd_buf, - 0, - &[vk::Rect2D { - offset: vk::Offset2D { x: 0, y: 0 }, - extent, - }], - ); - self.device.cmd_bind_descriptor_sets( - self.cmd_buf, - vk::PipelineBindPoint::GRAPHICS, - self.csc.pipeline_layout, - 0, - &[self.csc.desc_set], - &[], - ); - let rows = csc_rows(color, depth, msb_packed); - // Mode 1 = PQ→SDR tonemap (a PQ stream without an HDR10 surface); mode 0 - // passes the transfer through (SDR as-is, or PQ onto the HDR10 swapchain). - let mode = if color.is_pq() && !self.hdr_active { - 1.0f32 - } else { - 0.0 - }; - let peak = std::env::var("PUNKTFUNK_TONEMAP_PEAK") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(4.9); // ≈1000 nits over the 203-nit reference - let mut pc = [0f32; 16]; - pc[..12].copy_from_slice(bytemuck_rows(&rows)); - pc[12] = mode; - pc[13] = peak; - let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::(), 64); - self.device.cmd_push_constants( - self.cmd_buf, - self.csc.pipeline_layout, - vk::ShaderStageFlags::FRAGMENT, - 0, - bytes, - ); - self.device.cmd_draw(self.cmd_buf, 3, 1, 0, 0); - self.device.cmd_end_render_pass(self.cmd_buf); - } - } - - /// [`record_csc`] over the planar (PyroWave) pass — always 8-bit, no MSB packing. - #[cfg(all(target_os = "linux", feature = "pyrowave"))] - unsafe fn record_csc_planar( - &self, - framebuffer: vk::Framebuffer, - extent: vk::Extent2D, - color: pf_client_core::video::ColorDesc, - ) { - // The planar pass exists whenever a PyroWave frame reached us (checked at bind). - let Some(planar) = self.csc_planar.as_ref() else { - return; - }; - unsafe { - self.device.cmd_begin_render_pass( - self.cmd_buf, - &vk::RenderPassBeginInfo::default() - .render_pass(planar.render_pass) - .framebuffer(framebuffer) - .render_area(vk::Rect2D { - offset: vk::Offset2D { x: 0, y: 0 }, - extent, - }), - vk::SubpassContents::INLINE, - ); - self.device.cmd_bind_pipeline( - self.cmd_buf, - vk::PipelineBindPoint::GRAPHICS, - planar.pipeline, - ); - self.device.cmd_set_viewport( - self.cmd_buf, - 0, - &[vk::Viewport { - x: 0.0, - y: 0.0, - width: extent.width as f32, - height: extent.height as f32, - min_depth: 0.0, - max_depth: 1.0, - }], - ); - self.device.cmd_set_scissor( - self.cmd_buf, - 0, - &[vk::Rect2D { - offset: vk::Offset2D { x: 0, y: 0 }, - extent, - }], - ); - self.device.cmd_bind_descriptor_sets( - self.cmd_buf, - vk::PipelineBindPoint::GRAPHICS, - planar.pipeline_layout, - 0, - &[planar.desc_set], - &[], - ); - let rows = csc_rows(color, 8, false); - let mut pc = [0f32; 16]; - pc[..12].copy_from_slice(bytemuck_rows(&rows)); - pc[12] = 0.0; // SDR passthrough — PyroWave has no PQ path - pc[13] = 0.0; - let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::(), 64); - self.device.cmd_push_constants( - self.cmd_buf, - planar.pipeline_layout, - vk::ShaderStageFlags::FRAGMENT, - 0, - bytes, - ); - self.device.cmd_draw(self.cmd_buf, 3, 1, 0, 0); - self.device.cmd_end_render_pass(self.cmd_buf); - } - } - - /// Per-plane views over a Vulkan-Video frame's multiplanar image — the CSC pass's - /// exact sampling contract (the frames pool was created MUTABLE_FORMAT for this). - /// 8-bit NV12 (R8 + R8G8) and 10-bit P010/X6 (R10X6 + R10X6G10X6). - fn vkframe_plane_views(&self, f: &VkVideoFrame) -> Result<[vk::ImageView; 2]> { - let (luma_fmt, chroma_fmt) = if f.vk_format == vk::Format::G8_B8R8_2PLANE_420_UNORM.as_raw() - { - (vk::Format::R8_UNORM, vk::Format::R8G8_UNORM) - } else if f.vk_format == vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16.as_raw() { - ( - vk::Format::R10X6_UNORM_PACK16, - vk::Format::R10X6G10X6_UNORM_2PACK16, - ) - } else { - bail!( - "Vulkan-Video pool format {} unsupported (expected 2-plane 4:2:0, 8/10-bit)", - f.vk_format - ); - }; - // img[0] is creation-constant (only the sync fields need the frames lock). - let image = - vk::Image::from_raw( - unsafe { (*(f.vkframe as *const pf_ffvk::AVVkFrame)).img[0] } as u64, - ); - let make = |aspect: vk::ImageAspectFlags, format: vk::Format| { - unsafe { - self.device.create_image_view( - &vk::ImageViewCreateInfo::default() - .image(image) - .view_type(vk::ImageViewType::TYPE_2D) - .format(format) - .subresource_range( - vk::ImageSubresourceRange::default() - .aspect_mask(aspect) - .level_count(1) - .layer_count(1), - ), - None, - ) - } - .context("vk-frame plane view") - }; - let luma = make(vk::ImageAspectFlags::PLANE_0, luma_fmt)?; - let chroma = match make(vk::ImageAspectFlags::PLANE_1, chroma_fmt) { - Ok(v) => v, - Err(e) => { - unsafe { self.device.destroy_image_view(luma, None) }; - return Err(e); - } - }; - Ok([luma, chroma]) - } - - /// Copy the frame's RGBA into the staging buffer and (re)build the video image on a - /// stream-size change. Rows keep their stride — `buffer_row_length` unpacks it. - fn stage_frame(&mut self, f: &CpuFrame) -> Result<()> { - anyhow::ensure!( - f.stride % 4 == 0 && f.stride >= f.width as usize * 4, - "unexpected RGBA stride {} for width {}", - f.stride, - f.width - ); - if self - .video - .as_ref() - .is_none_or(|v| v.width != f.width || v.height != f.height) - { - self.rebuild_video_image(f.width, f.height)?; - tracing::info!(width = f.width, height = f.height, "video image (re)built"); - } - let needed = f.stride * f.height as usize; - if self.staging.as_ref().is_none_or(|s| s.capacity < needed) { - self.rebuild_staging(needed)?; - } - let s = self.staging.as_ref().unwrap(); - let n = f.rgba.len().min(needed); - unsafe { std::ptr::copy_nonoverlapping(f.rgba.as_ptr(), s.ptr, n) }; - Ok(()) - } - - fn rebuild_video_image(&mut self, width: u32, height: u32) -> Result<()> { - // Fence-quiesce: the old image is only ever referenced by OUR command buffers. - self.quiesce_own()?; - if let Some(v) = self.video.take() { - unsafe { - if v.framebuffer != vk::Framebuffer::null() { - self.device.destroy_framebuffer(v.framebuffer, None); - } - if v.view != vk::ImageView::null() { - self.device.destroy_image_view(v.view, None); - } - self.device.destroy_image(v.image, None); - self.device.free_memory(v.memory, None); - } - } - // COLOR_ATTACHMENT is the CSC pass's render target; harmless where hw is absent. - let image = unsafe { - self.device.create_image( - &vk::ImageCreateInfo::default() - .image_type(vk::ImageType::TYPE_2D) - .format(self.video_format) - .extent(vk::Extent3D { - width, - height, - depth: 1, - }) - .mip_levels(1) - .array_layers(1) - .samples(vk::SampleCountFlags::TYPE_1) - .tiling(vk::ImageTiling::OPTIMAL) - .usage( - vk::ImageUsageFlags::TRANSFER_DST - | vk::ImageUsageFlags::TRANSFER_SRC - | vk::ImageUsageFlags::COLOR_ATTACHMENT, - ) - .initial_layout(vk::ImageLayout::UNDEFINED), - None, - ) - }?; - let reqs = unsafe { self.device.get_image_memory_requirements(image) }; - let memory = self.allocate(reqs, vk::MemoryPropertyFlags::DEVICE_LOCAL)?; - unsafe { self.device.bind_image_memory(image, memory, 0) }?; - // The CSC pass renders into it — view + framebuffer, unconditional (Vulkan-Video - // frames need the pass on every device, dmabuf-capable or not). - let view = unsafe { - self.device.create_image_view( - &vk::ImageViewCreateInfo::default() - .image(image) - .view_type(vk::ImageViewType::TYPE_2D) - .format(self.video_format) - .subresource_range(subresource_range()), - None, - ) - }?; - let attachments = [view]; - let framebuffer = unsafe { - self.device.create_framebuffer( - &vk::FramebufferCreateInfo::default() - .render_pass(self.csc.render_pass) - .attachments(&attachments) - .width(width) - .height(height) - .layers(1), - None, - ) - }?; - self.video = Some(VideoImage { - image, - memory, - view, - framebuffer, - width, - height, - }); - Ok(()) - } - - fn rebuild_staging(&mut self, capacity: usize) -> Result<()> { - self.quiesce_own()?; - if let Some(s) = self.staging.take() { - unsafe { - self.device.unmap_memory(s.memory); - self.device.destroy_buffer(s.buffer, None); - self.device.free_memory(s.memory, None); - } - } - let buffer = unsafe { - self.device.create_buffer( - &vk::BufferCreateInfo::default() - .size(capacity as u64) - .usage(vk::BufferUsageFlags::TRANSFER_SRC) - .sharing_mode(vk::SharingMode::EXCLUSIVE), - None, - ) - }?; - let reqs = unsafe { self.device.get_buffer_memory_requirements(buffer) }; - let memory = self.allocate( - reqs, - vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT, - )?; - unsafe { self.device.bind_buffer_memory(buffer, memory, 0) }?; - let ptr = unsafe { - self.device - .map_memory(memory, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty()) - }? as *mut u8; - self.staging = Some(Staging { - buffer, - memory, - ptr, - capacity, - }); - Ok(()) - } - - fn allocate( - &self, - reqs: vk::MemoryRequirements, - flags: vk::MemoryPropertyFlags, - ) -> Result { - let type_index = (0..self.mem_props.memory_type_count) - .find(|&i| { - reqs.memory_type_bits & (1 << i) != 0 - && self.mem_props.memory_types[i as usize] - .property_flags - .contains(flags) - }) - .with_context(|| format!("no memory type for {flags:?}"))?; - unsafe { - self.device.allocate_memory( - &vk::MemoryAllocateInfo::default() - .allocation_size(reqs.size) - .memory_type_index(type_index), - None, - ) - } - .context("vkAllocateMemory") - } -} - -impl Drop for Presenter { - fn drop(&mut self) { - unsafe { - { - // Insurance against a straggling submitter (the run loop joins the - // pump before dropping us, so this is normally uncontended). - let _q = self.queue_lock.guard(); - self.device.device_wait_idle().ok(); - } - if let Some(f) = self.retired_hw.take() { - f.destroy(&self.device); // idle above — the GPU reads are done - } - if let Some(s) = self.staging.take() { - self.device.unmap_memory(s.memory); - self.device.destroy_buffer(s.buffer, None); - self.device.free_memory(s.memory, None); - } - if let Some(v) = self.video.take() { - if v.framebuffer != vk::Framebuffer::null() { - self.device.destroy_framebuffer(v.framebuffer, None); - } - if v.view != vk::ImageView::null() { - self.device.destroy_image_view(v.view, None); - } - self.device.destroy_image(v.image, None); - self.device.free_memory(v.memory, None); - } - #[cfg(target_os = "linux")] - self.hw.take(); - self.csc.destroy(&self.device); - #[cfg(all(target_os = "linux", feature = "pyrowave"))] - if let Some(p) = &self.csc_planar { - p.destroy(&self.device); - } - self.overlay_pipe.destroy(&self.device); - for s in self.render_sems.drain(..) { - self.device.destroy_semaphore(s, None); - } - self.device.destroy_semaphore(self.acquire_sem, None); - self.device.destroy_fence(self.fence, None); - self.device.destroy_command_pool(self.cmd_pool, None); - if self.swapchain != vk::SwapchainKHR::null() { - self.swap_d.destroy_swapchain(self.swapchain, None); - } - self.device.destroy_device(None); - self.surface_i.destroy_surface(self.surface, None); - self.instance.destroy_instance(None); - } - // `entry` (the libvulkan handle) drops last, after every vk call is done. - let _ = &self.entry; - } -} - -/// First physical device with a queue family that does graphics + present here; -/// `PUNKTFUNK_VK_DEVICE=` overrides on multi-GPU boxes. -fn pick_device( - instance: &ash::Instance, - surface_i: &ash::khr::surface::Instance, - surface: vk::SurfaceKHR, -) -> Result<(vk::PhysicalDevice, u32)> { - let devices = unsafe { instance.enumerate_physical_devices() }?; - let forced: Option = std::env::var("PUNKTFUNK_VK_DEVICE") - .ok() - .and_then(|v| v.parse().ok()); - let mut candidates: Vec = match forced { - Some(i) => devices.get(i).copied().into_iter().collect(), - None => devices, - }; - // Rank the candidates (stable sort; the index override wins outright): - // 1. The Settings GPU pick — `PUNKTFUNK_VK_ADAPTER` carries the adapter's marketing - // name (the WinUI shell's picker stores DXGI's, which matches Vulkan's for the - // same GPU): exact match, then substring, plain order when nothing matches - // (eGPU unplugged, stale setting). - // 2. Discrete over integrated: enumeration order puts the iGPU FIRST on some - // hybrids (observed: Ryzen iGPU ahead of an RTX dGPU), and the iGPU's video - // engine is the far weaker decoder — first-enumerated was a silent footgun. - if forced.is_none() { - let want = std::env::var("PUNKTFUNK_VK_ADAPTER") - .ok() - .map(|w| w.trim().to_lowercase()) - .filter(|w| !w.is_empty()); - candidates.sort_by_key(|d| { - let props = unsafe { instance.get_physical_device_properties(*d) }; - let name = props - .device_name_as_c_str() - .map(|c| c.to_string_lossy().to_lowercase()) - .unwrap_or_default(); - let name_rank = match &want { - Some(w) if name == *w => 0, - Some(w) if name.contains(w.as_str()) || w.contains(&name) => 1, - Some(_) => 2, - None => 0, - }; - let type_rank = match props.device_type { - vk::PhysicalDeviceType::DISCRETE_GPU => 0, - vk::PhysicalDeviceType::INTEGRATED_GPU => 1, - _ => 2, - }; - (name_rank, type_rank) - }); - } - for pdev in candidates { - let families = unsafe { instance.get_physical_device_queue_family_properties(pdev) }; - for (i, f) in families.iter().enumerate() { - let graphics = f.queue_flags.contains(vk::QueueFlags::GRAPHICS); - let present = - unsafe { surface_i.get_physical_device_surface_support(pdev, i as u32, surface) } - .unwrap_or(false); - if graphics && present { - return Ok((pdev, i as u32)); - } - } - } - bail!("no Vulkan device with a graphics+present queue family") -} - -/// SDR: prefer BGRA8 UNORM (the near-universal presentable format); RGBA8 second; else -/// whatever the surface offers first. UNORM (not SRGB) — the decoded RGBA is already -/// display-referred, the blit must not re-encode it. HDR: a 10-bit UNORM format paired -/// with the HDR10/ST.2084 colorspace, when the instance ext + surface offer one (KDE/ -/// gamescope with HDR enabled; absent elsewhere → the shader tonemaps instead). -fn pick_formats( - surface_i: &ash::khr::surface::Instance, - pdev: vk::PhysicalDevice, - surface: vk::SurfaceKHR, - colorspace_ext: bool, -) -> Result<(vk::SurfaceFormatKHR, Option)> { - let formats = unsafe { surface_i.get_physical_device_surface_formats(pdev, surface) }?; - let mut sdr = None; - for want in [vk::Format::B8G8R8A8_UNORM, vk::Format::R8G8B8A8_UNORM] { - if let Some(f) = formats - .iter() - .find(|f| f.format == want && f.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR) - { - sdr = Some(*f); - break; - } - } - let sdr = sdr - .or_else(|| formats.first().copied()) - .ok_or_else(|| anyhow!("surface offers no formats"))?; - let hdr10 = colorspace_ext - .then(|| { - formats - .iter() - .find(|f| { - f.color_space == vk::ColorSpaceKHR::HDR10_ST2084_EXT - && matches!( - f.format, - vk::Format::A2B10G10R10_UNORM_PACK32 - | vk::Format::A2R10G10B10_UNORM_PACK32 - ) - }) - .copied() - }) - .flatten(); - Ok((sdr, hdr10)) -} - -/// MAILBOX when the surface offers it, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE= -/// fifo|mailbox|immediate` overrides). Both are tear-free, but an arrival-paced -/// presenter must not block in FIFO's present queue: when the compositor holds images -/// for a vblank pass (gamescope's composite path) or arrival cadence drifts against -/// refresh, `acquire_next_image` stalls most of a refresh — a standing 11-13 ms added -/// to every frame at 60 Hz. MAILBOX never queues more than the newest frame, so the -/// pipeline stays at decode latency and a late frame is replaced, not waited for. -fn pick_present_mode( - surface_i: &ash::khr::surface::Instance, - pdev: vk::PhysicalDevice, - surface: vk::SurfaceKHR, -) -> Result { - let modes = unsafe { surface_i.get_physical_device_surface_present_modes(pdev, surface) }?; - let want = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() { - Some("fifo") => vk::PresentModeKHR::FIFO, - Some("immediate") => vk::PresentModeKHR::IMMEDIATE, - _ => vk::PresentModeKHR::MAILBOX, - }; - Ok(if modes.contains(&want) { - want - } else { - vk::PresentModeKHR::FIFO // always available per spec - }) -} - -/// Flatten the 3×vec4 rows for the push-constant block. -fn bytemuck_rows(rows: &[[f32; 4]; 3]) -> &[f32] { - // SAFETY: [[f32;4];3] is 12 contiguous f32s. - unsafe { std::slice::from_raw_parts(rows.as_ptr().cast::(), 12) } -} - -/// The Contain-fit letterbox: video (vw×vh) into the swapchain extent, centered. -fn letterbox(extent: vk::Extent2D, vw: u32, vh: u32) -> (vk::Offset3D, vk::Offset3D) { - let (ew, eh) = (f64::from(extent.width), f64::from(extent.height)); - let scale = (ew / f64::from(vw.max(1))).min(eh / f64::from(vh.max(1))); - let dw = (f64::from(vw) * scale).round(); - let dh = (f64::from(vh) * scale).round(); - let ox = ((ew - dw) / 2.0).floor() as i32; - let oy = ((eh - dh) / 2.0).floor() as i32; - ( - vk::Offset3D { x: ox, y: oy, z: 0 }, - vk::Offset3D { - x: (ox + dw as i32).min(extent.width as i32), - y: (oy + dh as i32).min(extent.height as i32), - z: 1, - }, - ) -} - -fn subresource_layers() -> vk::ImageSubresourceLayers { - vk::ImageSubresourceLayers::default() - .aspect_mask(vk::ImageAspectFlags::COLOR) - .layer_count(1) -} - -fn subresource_range() -> vk::ImageSubresourceRange { - vk::ImageSubresourceRange::default() - .aspect_mask(vk::ImageAspectFlags::COLOR) - .level_count(1) - .layer_count(1) -} - -/// The live sync state of an `AVVkFrame`, snapshotted under the frames lock. -struct VkFrameSync { - image: u64, - semaphore: u64, - sem_value: u64, - layout: i32, - queue_family: u32, -} - -/// Lock the frame and read its live sync state (the presenter's submit must wait -/// `sem_value` and signal `sem_value + 1`). The lock is held until [`unlock_vkframe`]. -// bindgen's enum repr is target-dependent (u32 Linux/clang, i32 MSVC) — the layout cast -// is required on one platform and a no-op on the other. -#[allow(clippy::unnecessary_cast)] -fn lock_vkframe(f: &VkVideoFrame) -> VkFrameSync { - unsafe { - let lock: unsafe extern "C" fn(*mut pf_ffvk::AVHWFramesContext, *mut pf_ffvk::AVVkFrame) = - std::mem::transmute(f.lock_frame); - let fc = f.frames_ctx as *mut pf_ffvk::AVHWFramesContext; - let vkf = f.vkframe as *mut pf_ffvk::AVVkFrame; - lock(fc, vkf); - VkFrameSync { - image: (*vkf).img[0] as u64, - semaphore: (*vkf).sem[0] as u64, - sem_value: (*vkf).sem_value[0], - layout: (*vkf).layout[0] as i32, - queue_family: (*vkf).queue_family[0], - } - } -} - -/// Write the post-submission state back (FFmpeg waits these on its next use of the -/// frame) and release the lock. On a failed submit only the lock is released. -fn unlock_vkframe(f: &VkVideoFrame, sync: &VkFrameSync, submitted: bool, graphics_qf: u32) { - unsafe { - let vkf = f.vkframe as *mut pf_ffvk::AVVkFrame; - if submitted { - (*vkf).sem_value[0] = sync.sem_value + 1; - (*vkf).layout[0] = - vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL.as_raw() as pf_ffvk::VkImageLayout; - if sync.queue_family != vk::QUEUE_FAMILY_IGNORED { - (*vkf).queue_family[0] = graphics_qf; - } - } - let unlock: unsafe extern "C" fn(*mut pf_ffvk::AVHWFramesContext, *mut pf_ffvk::AVVkFrame) = - std::mem::transmute(f.unlock_frame); - unlock(f.frames_ctx as *mut pf_ffvk::AVHWFramesContext, vkf); - } -} - -/// Acquire a Vulkan-Video frame's image from the decode queue family (EXCLUSIVE -/// sharing) and transition it for sampling. `src_qf == dst_qf` (or IGNORED/CONCURRENT) -/// degrades to a plain layout transition. The matching decode-side acquire happens in -/// FFmpeg, keyed off the queue_family we write back after submission. -/// -/// `srcStage` is FRAGMENT_SHADER — NOT TOP_OF_PIPE — deliberately: the submit waits the -/// frame's decode-complete timeline semaphore with `wait_dst_stage_mask = -/// FRAGMENT_SHADER`, and a semaphore wait only orders operations whose first sync scope -/// INTERSECTS that mask (the dependency-chain rule). With TOP_OF_PIPE the barrier's -/// layout transition (VIDEO_DECODE_DST/DPB → SHADER_READ_ONLY) formed no chain with the -/// wait and could execute while the decode queue was still writing the image. On RADV -/// that transition physically touches the image (metadata/decompression), so the race -/// showed as green/yellow block corruption exactly at freshly-decoded (damaged) regions -/// — the Steam Deck cursor-trail artifact. NVIDIA treats the transition as a no-op, -/// which is why the same code looked clean there. -fn vkframe_acquire_barrier( - device: &ash::Device, - cmd: vk::CommandBuffer, - image: vk::Image, - old_layout: vk::ImageLayout, - src_qf: u32, - dst_qf: u32, -) { - let (src, dst) = if src_qf == dst_qf || src_qf == vk::QUEUE_FAMILY_IGNORED { - (vk::QUEUE_FAMILY_IGNORED, vk::QUEUE_FAMILY_IGNORED) - } else { - (src_qf, dst_qf) - }; - let b = vk::ImageMemoryBarrier::default() - .src_access_mask(vk::AccessFlags::empty()) - .dst_access_mask(vk::AccessFlags::SHADER_READ) - .old_layout(old_layout) - .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) - .src_queue_family_index(src) - .dst_queue_family_index(dst) - .image(image) - .subresource_range(subresource_range()); - unsafe { - device.cmd_pipeline_barrier( - cmd, - vk::PipelineStageFlags::FRAGMENT_SHADER, - vk::PipelineStageFlags::FRAGMENT_SHADER, - vk::DependencyFlags::empty(), - &[], - &[], - &[b], - ); - } -} - -/// Acquire an imported D3D11 texture from the EXTERNAL queue family as a copy source. -/// The keyed mutex on the submit is the actual cross-API ordering; per the -/// external-memory rules an UNDEFINED-old-layout transition on externally-bound memory -/// preserves the contents (unlike ordinary images), so this is purely the -/// layout/ownership hop. -#[cfg(windows)] -fn external_acquire_barrier( - device: &ash::Device, - cmd: vk::CommandBuffer, - image: vk::Image, - qfi: u32, -) { - let b = vk::ImageMemoryBarrier::default() - .src_access_mask(vk::AccessFlags::empty()) - .dst_access_mask(vk::AccessFlags::TRANSFER_READ) - .old_layout(vk::ImageLayout::UNDEFINED) - .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) - .src_queue_family_index(vk::QUEUE_FAMILY_EXTERNAL) - .dst_queue_family_index(qfi) - .image(image) - .subresource_range(subresource_range()); - unsafe { - device.cmd_pipeline_barrier( - cmd, - vk::PipelineStageFlags::TOP_OF_PIPE, - vk::PipelineStageFlags::TRANSFER, - vk::DependencyFlags::empty(), - &[], - &[], - &[b], - ); - } -} - -/// Acquire a dmabuf plane image from its foreign owner (the VAAPI decoder): queue-family -/// transfer FOREIGN → ours, UNDEFINED → SHADER_READ_ONLY (content is preserved across -/// the transfer regardless of the UNDEFINED old-layout, per the external-memory rules). -#[cfg(target_os = "linux")] -fn foreign_acquire_barrier( - device: &ash::Device, - cmd: vk::CommandBuffer, - image: vk::Image, - qfi: u32, -) { - let b = vk::ImageMemoryBarrier::default() - .src_access_mask(vk::AccessFlags::empty()) - .dst_access_mask(vk::AccessFlags::SHADER_READ) - .old_layout(vk::ImageLayout::UNDEFINED) - .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) - .src_queue_family_index(vk::QUEUE_FAMILY_FOREIGN_EXT) - .dst_queue_family_index(qfi) - .image(image) - .subresource_range(subresource_range()); - unsafe { - device.cmd_pipeline_barrier( - cmd, - vk::PipelineStageFlags::TOP_OF_PIPE, - vk::PipelineStageFlags::FRAGMENT_SHADER, - vk::DependencyFlags::empty(), - &[], - &[], - &[b], - ); - } -} - -/// A full-subresource layout transition with the conservative ALL_COMMANDS/TRANSFER -/// scopes this transfer-only pipeline needs (per-frame granularity, not per-stage). -fn barrier( - device: &ash::Device, - cmd: vk::CommandBuffer, - image: vk::Image, - from: vk::ImageLayout, - to: vk::ImageLayout, -) { - let b = vk::ImageMemoryBarrier::default() - .src_access_mask(vk::AccessFlags::MEMORY_WRITE) - .dst_access_mask(vk::AccessFlags::MEMORY_READ | vk::AccessFlags::MEMORY_WRITE) - .old_layout(from) - .new_layout(to) - .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) - .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) - .image(image) - .subresource_range(subresource_range()); - unsafe { - device.cmd_pipeline_barrier( - cmd, - vk::PipelineStageFlags::ALL_COMMANDS, - vk::PipelineStageFlags::ALL_COMMANDS, - vk::DependencyFlags::empty(), - &[], - &[], - &[b], - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn letterbox_pillarboxes_a_wide_window() { - // 16:10 video in a 21:9-ish window: full height, centered horizontally. - let (a, b) = letterbox( - vk::Extent2D { - width: 3440, - height: 1440, - }, - 1280, - 800, - ); - assert_eq!((a.y, b.y), (0, 1440)); - assert_eq!(b.x - a.x, 2304); // 1280 * (1440/800) - assert_eq!(a.x, (3440 - 2304) / 2); - } - - #[test] - fn letterbox_matches_exact_fit() { - let (a, b) = letterbox( - vk::Extent2D { - width: 1280, - height: 800, - }, - 1280, - 800, - ); - assert_eq!((a.x, a.y), (0, 0)); - assert_eq!((b.x, b.y), (1280, 800)); - } -} diff --git a/crates/pf-presenter/src/vk/gpu.rs b/crates/pf-presenter/src/vk/gpu.rs new file mode 100644 index 00000000..f08b63cc --- /dev/null +++ b/crates/pf-presenter/src/vk/gpu.rs @@ -0,0 +1,257 @@ +//! Low-level GPU helpers: memory allocation, image barriers, AVVkFrame sync, geometry. + +use super::Presenter; +use anyhow::{Context as _, Result}; +use ash::vk; + +impl Presenter { + /// Wait the in-flight fence: OUR command buffers are done (staging, video image, + /// old-swapchain images). Deliberately NOT `vkDeviceWaitIdle` — the pump thread + /// submits FFmpeg's Vulkan decode work concurrently, and wait-idle's external-sync + /// rule over every device queue would race it (observed as a resize crash). + pub(super) fn quiesce_own(&mut self) -> Result<()> { + unsafe { + if self.submitted { + self.device.wait_for_fences(&[self.fence], true, u64::MAX)?; + self.submitted = false; + } + } + Ok(()) + } + pub(super) fn allocate( + &self, + reqs: vk::MemoryRequirements, + flags: vk::MemoryPropertyFlags, + ) -> Result { + let type_index = (0..self.mem_props.memory_type_count) + .find(|&i| { + reqs.memory_type_bits & (1 << i) != 0 + && self.mem_props.memory_types[i as usize] + .property_flags + .contains(flags) + }) + .with_context(|| format!("no memory type for {flags:?}"))?; + unsafe { + self.device.allocate_memory( + &vk::MemoryAllocateInfo::default() + .allocation_size(reqs.size) + .memory_type_index(type_index), + None, + ) + } + .context("vkAllocateMemory") + } +} + +/// The Contain-fit letterbox: video (vw×vh) into the swapchain extent, centered. +pub(super) fn letterbox(extent: vk::Extent2D, vw: u32, vh: u32) -> (vk::Offset3D, vk::Offset3D) { + let (ew, eh) = (f64::from(extent.width), f64::from(extent.height)); + let scale = (ew / f64::from(vw.max(1))).min(eh / f64::from(vh.max(1))); + let dw = (f64::from(vw) * scale).round(); + let dh = (f64::from(vh) * scale).round(); + let ox = ((ew - dw) / 2.0).floor() as i32; + let oy = ((eh - dh) / 2.0).floor() as i32; + ( + vk::Offset3D { x: ox, y: oy, z: 0 }, + vk::Offset3D { + x: (ox + dw as i32).min(extent.width as i32), + y: (oy + dh as i32).min(extent.height as i32), + z: 1, + }, + ) +} + +pub(super) fn subresource_layers() -> vk::ImageSubresourceLayers { + vk::ImageSubresourceLayers::default() + .aspect_mask(vk::ImageAspectFlags::COLOR) + .layer_count(1) +} + +pub(super) fn subresource_range() -> vk::ImageSubresourceRange { + vk::ImageSubresourceRange::default() + .aspect_mask(vk::ImageAspectFlags::COLOR) + .level_count(1) + .layer_count(1) +} + +/// Acquire a Vulkan-Video frame's image from the decode queue family (EXCLUSIVE +/// sharing) and transition it for sampling. `src_qf == dst_qf` (or IGNORED/CONCURRENT) +/// degrades to a plain layout transition. The matching decode-side acquire happens in +/// FFmpeg, keyed off the queue_family we write back after submission. +/// +/// `srcStage` is FRAGMENT_SHADER — NOT TOP_OF_PIPE — deliberately: the submit waits the +/// frame's decode-complete timeline semaphore with `wait_dst_stage_mask = +/// FRAGMENT_SHADER`, and a semaphore wait only orders operations whose first sync scope +/// INTERSECTS that mask (the dependency-chain rule). With TOP_OF_PIPE the barrier's +/// layout transition (VIDEO_DECODE_DST/DPB → SHADER_READ_ONLY) formed no chain with the +/// wait and could execute while the decode queue was still writing the image. On RADV +/// that transition physically touches the image (metadata/decompression), so the race +/// showed as green/yellow block corruption exactly at freshly-decoded (damaged) regions +/// — the Steam Deck cursor-trail artifact. NVIDIA treats the transition as a no-op, +/// which is why the same code looked clean there. +pub(super) fn vkframe_acquire_barrier( + device: &ash::Device, + cmd: vk::CommandBuffer, + image: vk::Image, + old_layout: vk::ImageLayout, + src_qf: u32, + dst_qf: u32, +) { + let (src, dst) = if src_qf == dst_qf || src_qf == vk::QUEUE_FAMILY_IGNORED { + (vk::QUEUE_FAMILY_IGNORED, vk::QUEUE_FAMILY_IGNORED) + } else { + (src_qf, dst_qf) + }; + let b = vk::ImageMemoryBarrier::default() + .src_access_mask(vk::AccessFlags::empty()) + .dst_access_mask(vk::AccessFlags::SHADER_READ) + .old_layout(old_layout) + .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) + .src_queue_family_index(src) + .dst_queue_family_index(dst) + .image(image) + .subresource_range(subresource_range()); + unsafe { + device.cmd_pipeline_barrier( + cmd, + vk::PipelineStageFlags::FRAGMENT_SHADER, + vk::PipelineStageFlags::FRAGMENT_SHADER, + vk::DependencyFlags::empty(), + &[], + &[], + &[b], + ); + } +} + +/// Acquire an imported D3D11 texture from the EXTERNAL queue family as a copy source. +/// The keyed mutex on the submit is the actual cross-API ordering; per the +/// external-memory rules an UNDEFINED-old-layout transition on externally-bound memory +/// preserves the contents (unlike ordinary images), so this is purely the +/// layout/ownership hop. +#[cfg(windows)] +pub(super) fn external_acquire_barrier( + device: &ash::Device, + cmd: vk::CommandBuffer, + image: vk::Image, + qfi: u32, +) { + let b = vk::ImageMemoryBarrier::default() + .src_access_mask(vk::AccessFlags::empty()) + .dst_access_mask(vk::AccessFlags::TRANSFER_READ) + .old_layout(vk::ImageLayout::UNDEFINED) + .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) + .src_queue_family_index(vk::QUEUE_FAMILY_EXTERNAL) + .dst_queue_family_index(qfi) + .image(image) + .subresource_range(subresource_range()); + unsafe { + device.cmd_pipeline_barrier( + cmd, + vk::PipelineStageFlags::TOP_OF_PIPE, + vk::PipelineStageFlags::TRANSFER, + vk::DependencyFlags::empty(), + &[], + &[], + &[b], + ); + } +} + +/// Acquire a dmabuf plane image from its foreign owner (the VAAPI decoder): queue-family +/// transfer FOREIGN → ours, UNDEFINED → SHADER_READ_ONLY (content is preserved across +/// the transfer regardless of the UNDEFINED old-layout, per the external-memory rules). +#[cfg(target_os = "linux")] +pub(super) fn foreign_acquire_barrier( + device: &ash::Device, + cmd: vk::CommandBuffer, + image: vk::Image, + qfi: u32, +) { + let b = vk::ImageMemoryBarrier::default() + .src_access_mask(vk::AccessFlags::empty()) + .dst_access_mask(vk::AccessFlags::SHADER_READ) + .old_layout(vk::ImageLayout::UNDEFINED) + .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) + .src_queue_family_index(vk::QUEUE_FAMILY_FOREIGN_EXT) + .dst_queue_family_index(qfi) + .image(image) + .subresource_range(subresource_range()); + unsafe { + device.cmd_pipeline_barrier( + cmd, + vk::PipelineStageFlags::TOP_OF_PIPE, + vk::PipelineStageFlags::FRAGMENT_SHADER, + vk::DependencyFlags::empty(), + &[], + &[], + &[b], + ); + } +} + +/// A full-subresource layout transition with the conservative ALL_COMMANDS/TRANSFER +/// scopes this transfer-only pipeline needs (per-frame granularity, not per-stage). +pub(super) fn barrier( + device: &ash::Device, + cmd: vk::CommandBuffer, + image: vk::Image, + from: vk::ImageLayout, + to: vk::ImageLayout, +) { + let b = vk::ImageMemoryBarrier::default() + .src_access_mask(vk::AccessFlags::MEMORY_WRITE) + .dst_access_mask(vk::AccessFlags::MEMORY_READ | vk::AccessFlags::MEMORY_WRITE) + .old_layout(from) + .new_layout(to) + .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .image(image) + .subresource_range(subresource_range()); + unsafe { + device.cmd_pipeline_barrier( + cmd, + vk::PipelineStageFlags::ALL_COMMANDS, + vk::PipelineStageFlags::ALL_COMMANDS, + vk::DependencyFlags::empty(), + &[], + &[], + &[b], + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn letterbox_pillarboxes_a_wide_window() { + // 16:10 video in a 21:9-ish window: full height, centered horizontally. + let (a, b) = letterbox( + vk::Extent2D { + width: 3440, + height: 1440, + }, + 1280, + 800, + ); + assert_eq!((a.y, b.y), (0, 1440)); + assert_eq!(b.x - a.x, 2304); // 1280 * (1440/800) + assert_eq!(a.x, (3440 - 2304) / 2); + } + + #[test] + fn letterbox_matches_exact_fit() { + let (a, b) = letterbox( + vk::Extent2D { + width: 1280, + height: 800, + }, + 1280, + 800, + ); + assert_eq!((a.x, a.y), (0, 0)); + assert_eq!((b.x, b.y), (1280, 800)); + } +} diff --git a/crates/pf-presenter/src/vk/mod.rs b/crates/pf-presenter/src/vk/mod.rs new file mode 100644 index 00000000..c5a05890 --- /dev/null +++ b/crates/pf-presenter/src/vk/mod.rs @@ -0,0 +1,289 @@ +//! The Vulkan presenter: swapchain + two frame paths into one device-local RGBA video +//! image, then a letterboxed `vkCmdBlitImage` composite. +//! +//! * **Software** (`FrameInput::Cpu`): staging upload + `copy_buffer_to_image` (row +//! stride via `buffer_row_length`) — transfer-only, runs on every GPU. +//! * **Hardware** (`FrameInput::Dmabuf`): the decoder's NV12 dmabuf imported per-plane +//! (`dmabuf.rs`) and converted by the CSC render pass (`csc.rs`) — zero-copy, gated on +//! the four import extensions at device creation; boxes without them (NVIDIA +//! proprietary by design) report `supports_dmabuf() == false` and the caller keeps the +//! decoder on software. +//! +//! Pacing: one frame in flight (the submit fence is waited before each record), MAILBOX +//! when available, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE=fifo|mailbox|immediate` +//! overrides — see `pick_present_mode` for why an arrival-paced presenter must not +//! block in FIFO's present queue). Present is arrival-paced by the caller: a frame +//! input on each decoded frame, `FrameInput::Redraw` re-blits the retained video image +//! (expose/resize redraws). + +use crate::csc::CscPass; +#[cfg(target_os = "linux")] +use crate::dmabuf::HwFrame; +use crate::overlay::SharedDevice; +use ash::vk; +#[cfg(target_os = "linux")] +use pf_client_core::video::DmabufFrame; +use pf_client_core::video::{CpuFrame, VkVideoFrame}; + +mod gpu; +mod overlay_pipe; +mod present; +mod reconfig; +mod resources; +mod setup; + +/// One presenter iteration's video input. +pub enum FrameInput<'a> { + /// No new frame — re-composite the retained video image (expose/resize). + Redraw, + Cpu(&'a CpuFrame), + #[cfg(target_os = "linux")] + Dmabuf(DmabufFrame), + /// FFmpeg Vulkan Video output — a VkImage already on THIS device (zero copy). + VkFrame(VkVideoFrame), + /// D3D11VA hand-off — a shareable NT-handle texture to import (`d3d11.rs`). + #[cfg(windows)] + D3d11(pf_client_core::video::D3d11Frame), + /// PyroWave planar output — three R8 plane views already on THIS device, decode + /// fence-complete, GENERAL layout (`pf_client_core::video_pyrowave`). + #[cfg(all(target_os = "linux", feature = "pyrowave"))] + PyroWave(pf_client_core::video_pyrowave::PyroWavePlanarFrame), +} + +/// The dmabuf/CSC machinery, present only when the device carries the import extensions. +#[cfg(target_os = "linux")] +struct HwCtx { + ext_mem_fd: ash::khr::external_memory_fd::Device, +} + +/// The D3D11 shared-texture import machinery, present only when the device carries +/// `VK_KHR_external_memory_win32` + `VK_KHR_win32_keyed_mutex`. +#[cfg(windows)] +struct HwCtxWin { + ext_mem_win32: ash::khr::external_memory_win32::Device, +} + +/// A submitted hardware frame parked until the in-flight fence proves the GPU reads +/// done: imported dmabuf planes, or a Vulkan-Video frame (FFmpeg's image — we own only +/// the plane views; dropping the frame's guard releases the AVFrame back to the pool). +enum Retired { + #[cfg(target_os = "linux")] + Dmabuf(HwFrame), + #[cfg(windows)] + D3d11(crate::d3d11::HwFrame), + Vk { + frame: VkVideoFrame, + views: [vk::ImageView; 2], + }, +} + +/// The overlay composite: one premultiplied-alpha quad blended over the swapchain image +/// after the video blit (the §6.1 contract's presenter half). Always built — it has no +/// Skia dependency and costs nothing while no overlay frame arrives (the render pass +/// isn't even recorded). +struct OverlayPipe { + render_pass: vk::RenderPass, + set_layout: vk::DescriptorSetLayout, + pipeline_layout: vk::PipelineLayout, + pipeline: vk::Pipeline, + desc_pool: vk::DescriptorPool, + desc_set: vk::DescriptorSet, + sampler: vk::Sampler, + /// Per-swapchain-image render targets, rebuilt with the swapchain. + views: Vec, + framebuffers: Vec, +} + +/// The one video image (device-local RGBA the size of the decoded stream) + its staging. +/// `view`/`framebuffer` exist only on hw-capable devices (the CSC pass renders into it). +struct VideoImage { + image: vk::Image, + memory: vk::DeviceMemory, + view: vk::ImageView, + framebuffer: vk::Framebuffer, + width: u32, + height: u32, +} + +struct Staging { + buffer: vk::Buffer, + memory: vk::DeviceMemory, + ptr: *mut u8, + capacity: usize, +} + +pub struct Presenter { + // Field order = drop order documentation only; teardown is explicit in `Drop`. + entry: ash::Entry, + instance: ash::Instance, + surface_i: ash::khr::surface::Instance, + surface: vk::SurfaceKHR, + pdev: vk::PhysicalDevice, + mem_props: vk::PhysicalDeviceMemoryProperties, + device: ash::Device, + swap_d: ash::khr::swapchain::Device, + queue: vk::Queue, + qfi: u32, + /// Dmabuf import — `None` when the device lacks the import extensions (the CSC + /// pass itself is unconditional: Vulkan-Video frames need it everywhere). + #[cfg(target_os = "linux")] + hw: Option, + /// D3D11 shared-texture import — `None` when the device lacks the win32 external + /// memory / keyed-mutex extensions. + #[cfg(windows)] + hw_win: Option, + csc: CscPass, + /// The planar (3-plane) CSC variant for PyroWave frames; built only when the device + /// passed the pyrowave probe. + #[cfg(all(target_os = "linux", feature = "pyrowave"))] + csc_planar: Option, + /// FFmpeg Vulkan Video decode handles — `None` when the stack can't do it. + video_export: Option, + /// The console-UI composite quad (§6.1's presenter half). + overlay_pipe: OverlayPipe, + /// The submitted hardware frame (dmabuf plane images + guard, or a Vulkan-Video + /// frame + our plane views): its GPU reads end with the in-flight fence, so it's + /// destroyed right after the next fence wait. + retired_hw: Option, + /// External-sync lock over this device's queues, shared with FFmpeg (via + /// [`pf_client_core::video::VulkanDecodeDevice::queue_lock`] → its + /// `lock_queue`/`unlock_queue` callbacks) and the Skia overlay: FFmpeg preps on the + /// SAME graphics queue from the pump thread, so every `vkQueueSubmit`/ + /// `vkQueuePresentKHR`/`vkQueueWaitIdle`/`vkDeviceWaitIdle` here must hold it — + /// the unsynchronized overlap was an intermittent `VK_ERROR_DEVICE_LOST`. + queue_lock: std::sync::Arc, + format: vk::SurfaceFormatKHR, + /// The surface's HDR10/ST.2084 pairing, when the stack offers one. + hdr10_format: Option, + /// PQ frames are on screen and the swapchain is in HDR10 mode. + hdr_active: bool, + /// One-shot latch: a PQ frame arrived but the surface offers no HDR10 colorspace, so the + /// CSC pass silently tone-maps to SDR. Warned once — the single most useful signal for + /// diagnosing "HDR isn't advertised" (e.g. gamescope's WSI layer invisible in a flatpak + /// sandbox) vs. the host simply not sending PQ. + hdr_downgrade_warned: bool, + /// `VK_EXT_hdr_metadata` device fns when the driver offers them (gamescope/KDE do). + hdr_metadata_d: Option, + /// The host's latest ST.2086/CLL metadata (the 0xCE plane) — pushed to the + /// swapchain whenever HDR10 mode is live; `None` until the first datagram lands + /// (a generic HDR10 baseline is pushed meanwhile). + hdr_meta: Option, + /// The video image / CSC attachment format for the current mode. + video_format: vk::Format, + present_mode: vk::PresentModeKHR, + swapchain: vk::SwapchainKHR, + images: Vec, + extent: vk::Extent2D, + /// Per-swapchain-image render-finished semaphores (present consumes them on the + /// image's schedule — one shared semaphore could be re-submitted while a previous + /// present still holds it). + render_sems: Vec, + acquire_sem: vk::Semaphore, + fence: vk::Fence, + cmd_pool: vk::CommandPool, + cmd_buf: vk::CommandBuffer, + staging: Option, + video: Option, + /// The submit fence has a submission pending (wait before recording again — also + /// what makes the single staging buffer safe to overwrite). + submitted: bool, +} + +impl Presenter { + /// Whether the hardware (dmabuf) path exists on this device — callers keep the + /// decoder on software when it doesn't. + #[cfg(target_os = "linux")] + pub fn supports_dmabuf(&self) -> bool { + self.hw.is_some() + } + + /// Whether the D3D11 shared-texture path exists on this device — callers keep the + /// decoder on software when it doesn't. + #[cfg(windows)] + pub fn supports_d3d11(&self) -> bool { + self.hw_win.is_some() + } + + /// The FFmpeg Vulkan Video decode handle bundle — `None` when this stack can't + /// (device < 1.3, missing video extensions/queue/features). The decoder chain + /// falls back to VAAPI/software then. + pub fn vulkan_decode(&self) -> Option { + self.video_export.clone() + } + + /// Full device idle — TEARDOWN ONLY, and only after the session pump thread has + /// been joined (it submits FFmpeg decode work; wait-idle's external-sync rule + /// covers every queue on the device). Mid-session code uses the fence quiesce. + /// The queue lock is held as cheap insurance against a straggling submitter. + pub fn wait_idle(&self) { + let _q = self.queue_lock.guard(); + unsafe { self.device.device_wait_idle() }.ok(); + } + + /// The device handles the console-UI overlay renders on (§6.1). Valid for the + /// presenter's lifetime; the run loop drops the overlay first. + pub fn shared_device(&self) -> SharedDevice { + SharedDevice { + entry: self.entry.clone(), + instance: self.instance.clone(), + physical_device: self.pdev, + device: self.device.clone(), + queue: self.queue, + queue_family_index: self.qfi, + queue_lock: self.queue_lock.clone(), + } + } +} + +impl Drop for Presenter { + fn drop(&mut self) { + unsafe { + { + // Insurance against a straggling submitter (the run loop joins the + // pump before dropping us, so this is normally uncontended). + let _q = self.queue_lock.guard(); + self.device.device_wait_idle().ok(); + } + if let Some(f) = self.retired_hw.take() { + f.destroy(&self.device); // idle above — the GPU reads are done + } + if let Some(s) = self.staging.take() { + self.device.unmap_memory(s.memory); + self.device.destroy_buffer(s.buffer, None); + self.device.free_memory(s.memory, None); + } + if let Some(v) = self.video.take() { + if v.framebuffer != vk::Framebuffer::null() { + self.device.destroy_framebuffer(v.framebuffer, None); + } + if v.view != vk::ImageView::null() { + self.device.destroy_image_view(v.view, None); + } + self.device.destroy_image(v.image, None); + self.device.free_memory(v.memory, None); + } + #[cfg(target_os = "linux")] + self.hw.take(); + self.csc.destroy(&self.device); + #[cfg(all(target_os = "linux", feature = "pyrowave"))] + if let Some(p) = &self.csc_planar { + p.destroy(&self.device); + } + self.overlay_pipe.destroy(&self.device); + for s in self.render_sems.drain(..) { + self.device.destroy_semaphore(s, None); + } + self.device.destroy_semaphore(self.acquire_sem, None); + self.device.destroy_fence(self.fence, None); + self.device.destroy_command_pool(self.cmd_pool, None); + if self.swapchain != vk::SwapchainKHR::null() { + self.swap_d.destroy_swapchain(self.swapchain, None); + } + self.device.destroy_device(None); + self.surface_i.destroy_surface(self.surface, None); + self.instance.destroy_instance(None); + } + // `entry` (the libvulkan handle) drops last, after every vk call is done. + let _ = &self.entry; + } +} diff --git a/crates/pf-presenter/src/vk/overlay_pipe.rs b/crates/pf-presenter/src/vk/overlay_pipe.rs new file mode 100644 index 00000000..e6fc064a --- /dev/null +++ b/crates/pf-presenter/src/vk/overlay_pipe.rs @@ -0,0 +1,184 @@ +//! The presenter-side overlay composite pipeline (premultiplied-alpha quad over the swapchain). + +use super::gpu::subresource_range; +use super::OverlayPipe; +use crate::csc::build_fullscreen_pipeline; +use anyhow::{Context as _, Result}; +use ash::vk; + +impl OverlayPipe { + pub(super) fn new(device: &ash::Device, format: vk::Format) -> Result { + // LOAD the blitted video, blend the overlay, end PRESENT-ready — this pass owns + // the swapchain image's final transition on overlay frames. + let attachment = [vk::AttachmentDescription::default() + .format(format) + .samples(vk::SampleCountFlags::TYPE_1) + .load_op(vk::AttachmentLoadOp::LOAD) + .store_op(vk::AttachmentStoreOp::STORE) + .initial_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL) + .final_layout(vk::ImageLayout::PRESENT_SRC_KHR)]; + let color_ref = [vk::AttachmentReference::default() + .attachment(0) + .layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)]; + let subpass = [vk::SubpassDescription::default() + .pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS) + .color_attachments(&color_ref)]; + let deps = [vk::SubpassDependency::default() + .src_subpass(vk::SUBPASS_EXTERNAL) + .dst_subpass(0) + .src_stage_mask(vk::PipelineStageFlags::ALL_COMMANDS) + .src_access_mask(vk::AccessFlags::MEMORY_WRITE) + .dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT) + .dst_access_mask( + vk::AccessFlags::COLOR_ATTACHMENT_READ | vk::AccessFlags::COLOR_ATTACHMENT_WRITE, + )]; + let render_pass = unsafe { + device.create_render_pass( + &vk::RenderPassCreateInfo::default() + .attachments(&attachment) + .subpasses(&subpass) + .dependencies(&deps), + None, + ) + } + .context("overlay render pass")?; + + let sampler = unsafe { + device.create_sampler( + &vk::SamplerCreateInfo::default() + .mag_filter(vk::Filter::LINEAR) + .min_filter(vk::Filter::LINEAR) + .address_mode_u(vk::SamplerAddressMode::CLAMP_TO_EDGE) + .address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE) + .address_mode_w(vk::SamplerAddressMode::CLAMP_TO_EDGE), + None, + ) + }?; + let samplers = [sampler]; + let bindings = [vk::DescriptorSetLayoutBinding::default() + .binding(0) + .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .descriptor_count(1) + .stage_flags(vk::ShaderStageFlags::FRAGMENT) + .immutable_samplers(&samplers)]; + let set_layout = unsafe { + device.create_descriptor_set_layout( + &vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings), + None, + ) + }?; + let set_layouts = [set_layout]; + let pipeline_layout = unsafe { + device.create_pipeline_layout( + &vk::PipelineLayoutCreateInfo::default().set_layouts(&set_layouts), + None, + ) + }?; + let pool_sizes = [vk::DescriptorPoolSize::default() + .ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .descriptor_count(1)]; + let desc_pool = unsafe { + device.create_descriptor_pool( + &vk::DescriptorPoolCreateInfo::default() + .max_sets(1) + .pool_sizes(&pool_sizes), + None, + ) + }?; + let desc_set = unsafe { + device.allocate_descriptor_sets( + &vk::DescriptorSetAllocateInfo::default() + .descriptor_pool(desc_pool) + .set_layouts(&set_layouts), + ) + }?[0]; + let pipeline = build_fullscreen_pipeline( + device, + render_pass, + pipeline_layout, + include_bytes!("../../shaders/overlay.frag.spv"), + true, // premultiplied blend over the video + )?; + Ok(OverlayPipe { + render_pass, + set_layout, + pipeline_layout, + pipeline, + desc_pool, + desc_set, + sampler, + views: Vec::new(), + framebuffers: Vec::new(), + }) + } + + /// Detach the current per-swapchain-image targets (for deferred destruction). + pub(super) fn take_targets(&mut self) -> (Vec, Vec) { + ( + std::mem::take(&mut self.views), + std::mem::take(&mut self.framebuffers), + ) + } + + /// Rebuild the per-swapchain-image views + framebuffers (swapchain recreation). + /// The caller has already taken the old targets for deferred destruction. + pub(super) fn rebuild_targets( + &mut self, + device: &ash::Device, + images: &[vk::Image], + format: vk::Format, + extent: vk::Extent2D, + ) -> Result<()> { + self.destroy_targets(device); // no-op after take_targets; safety net otherwise + for &image in images { + let view = unsafe { + device.create_image_view( + &vk::ImageViewCreateInfo::default() + .image(image) + .view_type(vk::ImageViewType::TYPE_2D) + .format(format) + .subresource_range(subresource_range()), + None, + ) + }?; + self.views.push(view); + let attachments = [view]; + let fb = unsafe { + device.create_framebuffer( + &vk::FramebufferCreateInfo::default() + .render_pass(self.render_pass) + .attachments(&attachments) + .width(extent.width) + .height(extent.height) + .layers(1), + None, + ) + }?; + self.framebuffers.push(fb); + } + Ok(()) + } + + fn destroy_targets(&mut self, device: &ash::Device) { + unsafe { + for fb in self.framebuffers.drain(..) { + device.destroy_framebuffer(fb, None); + } + for v in self.views.drain(..) { + device.destroy_image_view(v, None); + } + } + } + + pub(super) fn destroy(&mut self, device: &ash::Device) { + self.destroy_targets(device); + unsafe { + device.destroy_pipeline(self.pipeline, None); + device.destroy_pipeline_layout(self.pipeline_layout, None); + device.destroy_descriptor_pool(self.desc_pool, None); + device.destroy_descriptor_set_layout(self.set_layout, None); + device.destroy_sampler(self.sampler, None); + device.destroy_render_pass(self.render_pass, None); + } + } +} diff --git a/crates/pf-presenter/src/vk/present.rs b/crates/pf-presenter/src/vk/present.rs new file mode 100644 index 00000000..5b54f737 --- /dev/null +++ b/crates/pf-presenter/src/vk/present.rs @@ -0,0 +1,866 @@ +//! The per-frame present path (route input → video image → CSC → blit → present). HOT PATH. + +use super::gpu::*; +use super::{FrameInput, Presenter, Retired}; +use crate::csc::csc_rows; +#[cfg(target_os = "linux")] +use crate::dmabuf::{self, HwFrame}; +use crate::overlay::OverlayFrame; +use anyhow::{bail, Context as _, Result}; +use ash::vk; +use ash::vk::Handle as _; +use pf_client_core::video::VkVideoFrame; + +impl Presenter { + /// Present one frame: route `input` into the video image (staging upload or dmabuf + /// import + CSC pass; `Redraw` re-blits what's retained), clear, letterbox-blit, + /// blend the console-UI `overlay` quad if one arrived, present. Returns false when + /// the swapchain was out of date — the caller recreates (with current window state) + /// and may retry. + pub fn present( + &mut self, + window: &sdl3::video::Window, + input: FrameInput, + overlay: Option<&OverlayFrame>, + ) -> Result { + if self.extent.width == 0 || self.extent.height == 0 { + return Ok(true); // minimized — nothing to do + } + // SDR↔HDR follows the FRAMES' own signaling (the host flips PQ in-band): + // switch modes before anything touches this frame. Only where the surface + // offers HDR10 — otherwise PQ stays on the SDR swapchain and the CSC shader + // tonemaps (mode 1). + let frame_pq = match &input { + FrameInput::Redraw => None, + FrameInput::Cpu(f) => Some(f.color.is_pq()), + #[cfg(target_os = "linux")] + FrameInput::Dmabuf(d) => Some(d.color.is_pq()), + FrameInput::VkFrame(v) => Some(v.color.is_pq()), + #[cfg(windows)] + FrameInput::D3d11(d) => Some(d.color.is_pq()), + #[cfg(all(target_os = "linux", feature = "pyrowave"))] + FrameInput::PyroWave(f) => Some(f.color.is_pq()), // always SDR today + }; + if let Some(pq) = frame_pq { + // A PQ stream we can only tone-map (no HDR10 surface) is the silent failure behind + // "HDR isn't advertised": the compositor never sees an HDR-committing app. Say so + // once — its presence proves PQ IS arriving and the surface/compositor is the + // blocker (on the Deck: gamescope's WSI layer not visible in the flatpak sandbox); + // its absence, with a plain SDR stream, points back at the host not sending PQ. + if pq && self.hdr10_format.is_none() && !self.hdr_downgrade_warned { + self.hdr_downgrade_warned = true; + tracing::warn!( + "PQ (HDR10) stream tone-mapped to SDR — the surface offers no HDR10 \ + colorspace, so no HDR is committed to the compositor. Under gamescope this \ + usually means the gamescope Vulkan WSI layer is not visible in the sandbox." + ); + } + let want = pq && self.hdr10_format.is_some(); + if want != self.hdr_active { + self.set_hdr_mode(window, want)?; + } + } + // Hardware frames prepare before anything touches the queue: an import/view the + // driver rejects must fail out here, before this present consumed the acquire + // semaphore. + #[cfg(target_os = "linux")] + let mut hw_frame: Option = None; + #[cfg(windows)] + let mut win_frame: Option = None; + let mut vk_frame: Option<(VkVideoFrame, [vk::ImageView; 2])> = None; + #[cfg(all(target_os = "linux", feature = "pyrowave"))] + let mut pyro_frame: Option = None; + let cpu_frame = match input { + FrameInput::Redraw => None, + FrameInput::Cpu(f) => Some(f), + #[cfg(target_os = "linux")] + FrameInput::Dmabuf(d) => { + let hw = self + .hw + .as_ref() + .context("hardware frame without dmabuf support")?; + hw_frame = Some(dmabuf::import(&self.device, &hw.ext_mem_fd, d)?); + None + } + #[cfg(windows)] + FrameInput::D3d11(d) => { + let hw = self + .hw_win + .as_ref() + .context("D3D11 frame without win32 import support")?; + win_frame = Some(crate::d3d11::import(&self.device, &hw.ext_mem_win32, &d)?); + None + } + FrameInput::VkFrame(v) => { + let views = self.vkframe_plane_views(&v)?; + vk_frame = Some((v, views)); + None + } + #[cfg(all(target_os = "linux", feature = "pyrowave"))] + FrameInput::PyroWave(f) => { + pyro_frame = Some(f); + None + } + }; + + // One frame in flight: the fence covers the command buffer, the staging buffer + // AND the previously submitted hw frame — waiting makes all three reusable. + unsafe { + if self.submitted { + self.device.wait_for_fences(&[self.fence], true, u64::MAX)?; + self.submitted = false; + } + self.device.reset_fences(&[self.fence])?; + } + if let Some(old) = self.retired_hw.take() { + old.destroy(&self.device); + } + + if let Some(f) = cpu_frame { + self.stage_frame(f)?; + } + #[cfg(target_os = "linux")] + if let Some(f) = &hw_frame { + if self + .video + .as_ref() + .is_none_or(|v| v.width != f.width || v.height != f.height) + { + self.rebuild_video_image(f.width, f.height)?; + tracing::info!(width = f.width, height = f.height, "video image (re)built"); + } + // Safe while nothing in flight references the set — the fence wait above. + self.csc + .bind_planes(&self.device, f.luma_view, f.chroma_view); + } + #[cfg(windows)] + if let Some(f) = &win_frame { + if self + .video + .as_ref() + .is_none_or(|v| v.width != f.width || v.height != f.height) + { + self.rebuild_video_image(f.width, f.height)?; + tracing::info!(width = f.width, height = f.height, "video image (re)built"); + } + } + if let Some((f, views)) = &vk_frame { + if self + .video + .as_ref() + .is_none_or(|v| v.width != f.width || v.height != f.height) + { + self.rebuild_video_image(f.width, f.height)?; + tracing::info!(width = f.width, height = f.height, "video image (re)built"); + } + self.csc.bind_planes(&self.device, views[0], views[1]); + } + #[cfg(all(target_os = "linux", feature = "pyrowave"))] + if let Some(f) = &pyro_frame { + if self + .video + .as_ref() + .is_none_or(|v| v.width != f.width || v.height != f.height) + { + self.rebuild_video_image(f.width, f.height)?; + tracing::info!(width = f.width, height = f.height, "video image (re)built"); + } + let planar = self + .csc_planar + .as_ref() + .context("PyroWave frame but the device failed the pyrowave probe")?; + planar.bind_planes_planar(&self.device, f.views.map(vk::ImageView::from_raw)); + } + if let Some(o) = overlay { + // Point the composite at this overlay image (same fence-wait safety). + let infos = [vk::DescriptorImageInfo::default() + .image_view(o.view) + .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)]; + let writes = [vk::WriteDescriptorSet::default() + .dst_set(self.overlay_pipe.desc_set) + .dst_binding(0) + .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .image_info(&infos)]; + unsafe { self.device.update_descriptor_sets(&writes, &[]) }; + } + + let (index, _suboptimal) = match unsafe { + self.swap_d.acquire_next_image( + self.swapchain, + u64::MAX, + self.acquire_sem, + vk::Fence::null(), + ) + } { + Ok(r) => r, + Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => { + // Never submitted — the import (if any) dies here, GPU never saw it. + #[cfg(target_os = "linux")] + if let Some(f) = hw_frame { + f.destroy(&self.device); + } + #[cfg(windows)] + if let Some(f) = win_frame { + f.destroy(&self.device); + } + self.recreate_swapchain(window)?; + return Ok(false); + } + Err(e) => return Err(e).context("vkAcquireNextImageKHR"), + }; + let swap_image = self.images[index as usize]; + + unsafe { + self.device.begin_command_buffer( + self.cmd_buf, + &vk::CommandBufferBeginInfo::default() + .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT), + )?; + + // Dmabuf frame: acquire the foreign planes, then the CSC pass renders + // NV12→RGBA into the video image (render pass ends it in TRANSFER_SRC for + // the blit below). + #[cfg(target_os = "linux")] + if let (Some(f), Some(v)) = (&hw_frame, &self.video) { + for view_image in [f.luma_image(), f.chroma_image()] { + foreign_acquire_barrier(&self.device, self.cmd_buf, view_image, self.qfi); + } + let extent = vk::Extent2D { + width: v.width, + height: v.height, + }; + let ten_bit = f.is_p010(); + self.record_csc( + v.framebuffer, + extent, + f.color, + if ten_bit { 10 } else { 8 }, + ten_bit, + ); + } + + // D3D11 frame: acquire the imported BGRA texture from the external "queue + // family" (the keyed mutex on the submit is the actual cross-API sync) and + // blit it into the video image — the frame arrives as ready sRGB from the + // decoder's VideoProcessor, so there is no CSC pass; the blit converts the + // BGRA→RGBA component order. Same layout dance as the CPU staging path. + #[cfg(windows)] + if let (Some(f), Some(v)) = (&win_frame, &self.video) { + external_acquire_barrier(&self.device, self.cmd_buf, f.image(), self.qfi); + barrier( + &self.device, + self.cmd_buf, + v.image, + vk::ImageLayout::UNDEFINED, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + ); + let extent = vk::Offset3D { + x: v.width as i32, + y: v.height as i32, + z: 1, + }; + let blit = vk::ImageBlit::default() + .src_subresource(subresource_layers()) + .src_offsets([vk::Offset3D::default(), extent]) + .dst_subresource(subresource_layers()) + .dst_offsets([vk::Offset3D::default(), extent]); + self.device.cmd_blit_image( + self.cmd_buf, + f.image(), + vk::ImageLayout::TRANSFER_SRC_OPTIMAL, + v.image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + &[blit], + vk::Filter::NEAREST, // 1:1 — the composite blit below does the scaling + ); + barrier( + &self.device, + self.cmd_buf, + v.image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + vk::ImageLayout::TRANSFER_SRC_OPTIMAL, + ); + } + + // Vulkan-Video frame: the decoded image is already on THIS device. Read the + // live sync state under the frames lock (held through submission — the + // AVVulkanFramesContext contract), acquire from the decode queue family, + // then the same CSC pass. + let mut vk_sync: Option = None; + if let (Some((f, _)), Some(v)) = (&vk_frame, &self.video) { + let sync = lock_vkframe(f); + vkframe_acquire_barrier( + &self.device, + self.cmd_buf, + vk::Image::from_raw(sync.image), + vk::ImageLayout::from_raw(sync.layout), + sync.queue_family, + self.qfi, + ); + let extent = vk::Extent2D { + width: v.width, + height: v.height, + }; + let ten_bit = + f.vk_format == vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16.as_raw(); + self.record_csc( + v.framebuffer, + extent, + f.color, + if ten_bit { 10 } else { 8 }, + ten_bit, + ); + vk_sync = Some(sync); + } + + // PyroWave frame: the planes are already on THIS device, decode + // fence-complete and barriered to fragment sampling (GENERAL) by the + // decoder — no acquire needed, just the planar CSC pass. + #[cfg(all(target_os = "linux", feature = "pyrowave"))] + if let (Some(f), Some(v)) = (&pyro_frame, &self.video) { + let extent = vk::Extent2D { + width: v.width, + height: v.height, + }; + self.record_csc_planar(v.framebuffer, extent, f.color); + } + + // New frame: staging → video image (stride carried by buffer_row_length). + if let (Some(f), Some(v), Some(s)) = (cpu_frame, &self.video, &self.staging) { + barrier( + &self.device, + self.cmd_buf, + v.image, + vk::ImageLayout::UNDEFINED, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + ); + let region = vk::BufferImageCopy::default() + .buffer_row_length((f.stride / 4) as u32) + .image_subresource(subresource_layers()) + .image_extent(vk::Extent3D { + width: v.width, + height: v.height, + depth: 1, + }); + self.device.cmd_copy_buffer_to_image( + self.cmd_buf, + s.buffer, + v.image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + &[region], + ); + barrier( + &self.device, + self.cmd_buf, + v.image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + vk::ImageLayout::TRANSFER_SRC_OPTIMAL, + ); + } + + // Swapchain image: discard old content, clear to black (the letterbox bars), + // blit the video in, hand to present. + barrier( + &self.device, + self.cmd_buf, + swap_image, + vk::ImageLayout::UNDEFINED, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + ); + self.device.cmd_clear_color_image( + self.cmd_buf, + swap_image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + &vk::ClearColorValue { + float32: [0.0, 0.0, 0.0, 1.0], + }, + &[subresource_range()], + ); + if let Some(v) = &self.video { + let (dst0, dst1) = letterbox(self.extent, v.width, v.height); + let blit = vk::ImageBlit::default() + .src_subresource(subresource_layers()) + .src_offsets([ + vk::Offset3D { x: 0, y: 0, z: 0 }, + vk::Offset3D { + x: v.width as i32, + y: v.height as i32, + z: 1, + }, + ]) + .dst_subresource(subresource_layers()) + .dst_offsets([dst0, dst1]); + self.device.cmd_blit_image( + self.cmd_buf, + v.image, + vk::ImageLayout::TRANSFER_SRC_OPTIMAL, + swap_image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + &[blit], + vk::Filter::LINEAR, + ); + } + if let Some(o) = overlay { + // Cross-submit visibility for the overlay image (Skia flushed it on this + // queue): same-layout barrier = execution + memory dependency only. + barrier( + &self.device, + self.cmd_buf, + o.image, + vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL, + vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL, + ); + barrier( + &self.device, + self.cmd_buf, + swap_image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL, + ); + // The composite pass blends the quad and ends the image PRESENT-ready. + self.device.cmd_begin_render_pass( + self.cmd_buf, + &vk::RenderPassBeginInfo::default() + .render_pass(self.overlay_pipe.render_pass) + .framebuffer(self.overlay_pipe.framebuffers[index as usize]) + .render_area(vk::Rect2D { + offset: vk::Offset2D { x: 0, y: 0 }, + extent: self.extent, + }), + vk::SubpassContents::INLINE, + ); + self.device.cmd_bind_pipeline( + self.cmd_buf, + vk::PipelineBindPoint::GRAPHICS, + self.overlay_pipe.pipeline, + ); + self.device.cmd_set_viewport( + self.cmd_buf, + 0, + &[vk::Viewport { + x: 0.0, + y: 0.0, + width: self.extent.width as f32, + height: self.extent.height as f32, + min_depth: 0.0, + max_depth: 1.0, + }], + ); + self.device.cmd_set_scissor( + self.cmd_buf, + 0, + &[vk::Rect2D { + offset: vk::Offset2D { x: 0, y: 0 }, + extent: self.extent, + }], + ); + self.device.cmd_bind_descriptor_sets( + self.cmd_buf, + vk::PipelineBindPoint::GRAPHICS, + self.overlay_pipe.pipeline_layout, + 0, + &[self.overlay_pipe.desc_set], + &[], + ); + self.device.cmd_draw(self.cmd_buf, 3, 1, 0, 0); + self.device.cmd_end_render_pass(self.cmd_buf); + } else { + barrier( + &self.device, + self.cmd_buf, + swap_image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + vk::ImageLayout::PRESENT_SRC_KHR, + ); + } + self.device.end_command_buffer(self.cmd_buf)?; + + let render_sem = self.render_sems[index as usize]; + let cmd_bufs = [self.cmd_buf]; + let mut wait_sems = vec![self.acquire_sem]; + let mut wait_stages = vec![vk::PipelineStageFlags::TRANSFER]; + let mut signal_sems = vec![render_sem]; + // The Vulkan-Video frame's timeline semaphore: wait for the decoder's value, + // signal value+1 when our reads are done (FFmpeg's per-submission contract). + let mut wait_values = vec![0u64]; + let mut signal_values = vec![0u64]; + if let Some(sync) = &vk_sync { + let sem = vk::Semaphore::from_raw(sync.semaphore); + wait_sems.push(sem); + wait_stages.push(vk::PipelineStageFlags::FRAGMENT_SHADER); + wait_values.push(sync.sem_value); + signal_sems.push(sem); + signal_values.push(sync.sem_value + 1); + } + let mut timeline = vk::TimelineSemaphoreSubmitInfo::default() + .wait_semaphore_values(&wait_values) + .signal_semaphore_values(&signal_values); + let mut submit = vk::SubmitInfo::default() + .wait_semaphores(&wait_sems) + .wait_dst_stage_mask(&wait_stages) + .command_buffers(&cmd_bufs) + .signal_semaphores(&signal_sems); + if vk_sync.is_some() { + submit = submit.push_next(&mut timeline); + } + // D3D11 frame: bracket the submit in the shared texture's keyed mutex, key 0 + // both ways (the decode side copies under acquire(0)/release(0) too) — the + // GPU-side acquire is what orders our sampling after the decoder's copy, and + // our completion release is what unblocks the ring slot's reuse. + #[cfg(windows)] + let keyed_mem; + #[cfg(windows)] + let keyed_keys = [0u64]; + #[cfg(windows)] + let keyed_timeouts = [2000u32]; + #[cfg(windows)] + let mut keyed_info; + #[cfg(windows)] + if let Some(f) = &win_frame { + // Bisect knob: PUNKTFUNK_D3D11_NO_MUTEX=1 skips the acquire/release pair + // (torn frames possible — debugging only). + if std::env::var_os("PUNKTFUNK_D3D11_NO_MUTEX").is_none() { + keyed_mem = [f.memory()]; + keyed_info = vk::Win32KeyedMutexAcquireReleaseInfoKHR::default() + .acquire_syncs(&keyed_mem) + .acquire_keys(&keyed_keys) + .acquire_timeouts(&keyed_timeouts) + .release_syncs(&keyed_mem) + .release_keys(&keyed_keys); + submit = submit.push_next(&mut keyed_info); + } + } + let submitted = { + // Queue external sync vs the pump's FFmpeg submits (see `queue_lock`). + let _q = self.queue_lock.guard(); + self.device.queue_submit(self.queue, &[submit], self.fence) + }; + // Write the new sync state back and release the frames lock REGARDLESS of + // the submit outcome (an abandoned lock would wedge the decoder). + if let Some(sync) = vk_sync.take() { + let ok = submitted.is_ok(); + unlock_vkframe( + vk_frame + .as_ref() + .map(|(f, _)| f) + .expect("vk_sync implies vk_frame"), + &sync, + ok, + self.qfi, + ); + } + submitted?; + self.submitted = true; + // The hw frame is on the GPU now — park it until the fence proves the reads + // done (destroyed at the next present's fence wait, or in Drop). At most one + // of hw_frame/vk_frame is set (they route from the same `input`). + self.retired_hw = vk_frame + .take() + .map(|(frame, views)| Retired::Vk { frame, views }); + #[cfg(target_os = "linux")] + if let Some(f) = hw_frame.take() { + self.retired_hw = Some(Retired::Dmabuf(f)); + } + #[cfg(windows)] + if let Some(f) = win_frame.take() { + self.retired_hw = Some(Retired::D3d11(f)); + } + + let swapchains = [self.swapchain]; + let indices = [index]; + let present_sems = [render_sem]; + // Same queue external-sync rule as the submit above. Scoped tightly: the + // OUT_OF_DATE arm re-enters the lock via recreate_swapchain's queue drain. + let present_res = { + let _q = self.queue_lock.guard(); + self.swap_d.queue_present( + self.queue, + &vk::PresentInfoKHR::default() + .wait_semaphores(&present_sems) + .swapchains(&swapchains) + .image_indices(&indices), + ) + }; + match present_res { + Ok(_) => Ok(true), + Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => { + self.recreate_swapchain(window)?; + Ok(false) + } + Err(e) => Err(e).context("vkQueuePresentKHR"), + } + } + } + + /// Record the NV12→RGBA CSC pass into the video image (framebuffer): fullscreen + /// triangle, CICP-driven push-constant rows. Shared by the dmabuf and Vulkan-Video + /// paths — only the plane views bound beforehand differ. + /// + /// # Safety + /// `self.cmd_buf` must be in the recording state; the CSC descriptor set must point + /// at live plane views. + unsafe fn record_csc( + &self, + framebuffer: vk::Framebuffer, + extent: vk::Extent2D, + color: pf_client_core::video::ColorDesc, + depth: u8, + msb_packed: bool, + ) { + unsafe { + self.device.cmd_begin_render_pass( + self.cmd_buf, + &vk::RenderPassBeginInfo::default() + .render_pass(self.csc.render_pass) + .framebuffer(framebuffer) + .render_area(vk::Rect2D { + offset: vk::Offset2D { x: 0, y: 0 }, + extent, + }), + vk::SubpassContents::INLINE, + ); + self.device.cmd_bind_pipeline( + self.cmd_buf, + vk::PipelineBindPoint::GRAPHICS, + self.csc.pipeline, + ); + self.device.cmd_set_viewport( + self.cmd_buf, + 0, + &[vk::Viewport { + x: 0.0, + y: 0.0, + width: extent.width as f32, + height: extent.height as f32, + min_depth: 0.0, + max_depth: 1.0, + }], + ); + self.device.cmd_set_scissor( + self.cmd_buf, + 0, + &[vk::Rect2D { + offset: vk::Offset2D { x: 0, y: 0 }, + extent, + }], + ); + self.device.cmd_bind_descriptor_sets( + self.cmd_buf, + vk::PipelineBindPoint::GRAPHICS, + self.csc.pipeline_layout, + 0, + &[self.csc.desc_set], + &[], + ); + let rows = csc_rows(color, depth, msb_packed); + // Mode 1 = PQ→SDR tonemap (a PQ stream without an HDR10 surface); mode 0 + // passes the transfer through (SDR as-is, or PQ onto the HDR10 swapchain). + let mode = if color.is_pq() && !self.hdr_active { + 1.0f32 + } else { + 0.0 + }; + let peak = std::env::var("PUNKTFUNK_TONEMAP_PEAK") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(4.9); // ≈1000 nits over the 203-nit reference + let mut pc = [0f32; 16]; + pc[..12].copy_from_slice(bytemuck_rows(&rows)); + pc[12] = mode; + pc[13] = peak; + let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::(), 64); + self.device.cmd_push_constants( + self.cmd_buf, + self.csc.pipeline_layout, + vk::ShaderStageFlags::FRAGMENT, + 0, + bytes, + ); + self.device.cmd_draw(self.cmd_buf, 3, 1, 0, 0); + self.device.cmd_end_render_pass(self.cmd_buf); + } + } + + /// [`record_csc`] over the planar (PyroWave) pass — always 8-bit, no MSB packing. + #[cfg(all(target_os = "linux", feature = "pyrowave"))] + unsafe fn record_csc_planar( + &self, + framebuffer: vk::Framebuffer, + extent: vk::Extent2D, + color: pf_client_core::video::ColorDesc, + ) { + // The planar pass exists whenever a PyroWave frame reached us (checked at bind). + let Some(planar) = self.csc_planar.as_ref() else { + return; + }; + unsafe { + self.device.cmd_begin_render_pass( + self.cmd_buf, + &vk::RenderPassBeginInfo::default() + .render_pass(planar.render_pass) + .framebuffer(framebuffer) + .render_area(vk::Rect2D { + offset: vk::Offset2D { x: 0, y: 0 }, + extent, + }), + vk::SubpassContents::INLINE, + ); + self.device.cmd_bind_pipeline( + self.cmd_buf, + vk::PipelineBindPoint::GRAPHICS, + planar.pipeline, + ); + self.device.cmd_set_viewport( + self.cmd_buf, + 0, + &[vk::Viewport { + x: 0.0, + y: 0.0, + width: extent.width as f32, + height: extent.height as f32, + min_depth: 0.0, + max_depth: 1.0, + }], + ); + self.device.cmd_set_scissor( + self.cmd_buf, + 0, + &[vk::Rect2D { + offset: vk::Offset2D { x: 0, y: 0 }, + extent, + }], + ); + self.device.cmd_bind_descriptor_sets( + self.cmd_buf, + vk::PipelineBindPoint::GRAPHICS, + planar.pipeline_layout, + 0, + &[planar.desc_set], + &[], + ); + let rows = csc_rows(color, 8, false); + let mut pc = [0f32; 16]; + pc[..12].copy_from_slice(bytemuck_rows(&rows)); + pc[12] = 0.0; // SDR passthrough — PyroWave has no PQ path + pc[13] = 0.0; + let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::(), 64); + self.device.cmd_push_constants( + self.cmd_buf, + planar.pipeline_layout, + vk::ShaderStageFlags::FRAGMENT, + 0, + bytes, + ); + self.device.cmd_draw(self.cmd_buf, 3, 1, 0, 0); + self.device.cmd_end_render_pass(self.cmd_buf); + } + } + + /// Per-plane views over a Vulkan-Video frame's multiplanar image — the CSC pass's + /// exact sampling contract (the frames pool was created MUTABLE_FORMAT for this). + /// 8-bit NV12 (R8 + R8G8) and 10-bit P010/X6 (R10X6 + R10X6G10X6). + fn vkframe_plane_views(&self, f: &VkVideoFrame) -> Result<[vk::ImageView; 2]> { + let (luma_fmt, chroma_fmt) = if f.vk_format == vk::Format::G8_B8R8_2PLANE_420_UNORM.as_raw() + { + (vk::Format::R8_UNORM, vk::Format::R8G8_UNORM) + } else if f.vk_format == vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16.as_raw() { + ( + vk::Format::R10X6_UNORM_PACK16, + vk::Format::R10X6G10X6_UNORM_2PACK16, + ) + } else { + bail!( + "Vulkan-Video pool format {} unsupported (expected 2-plane 4:2:0, 8/10-bit)", + f.vk_format + ); + }; + // img[0] is creation-constant (only the sync fields need the frames lock). + let image = + vk::Image::from_raw( + unsafe { (*(f.vkframe as *const pf_ffvk::AVVkFrame)).img[0] } as u64, + ); + let make = |aspect: vk::ImageAspectFlags, format: vk::Format| { + unsafe { + self.device.create_image_view( + &vk::ImageViewCreateInfo::default() + .image(image) + .view_type(vk::ImageViewType::TYPE_2D) + .format(format) + .subresource_range( + vk::ImageSubresourceRange::default() + .aspect_mask(aspect) + .level_count(1) + .layer_count(1), + ), + None, + ) + } + .context("vk-frame plane view") + }; + let luma = make(vk::ImageAspectFlags::PLANE_0, luma_fmt)?; + let chroma = match make(vk::ImageAspectFlags::PLANE_1, chroma_fmt) { + Ok(v) => v, + Err(e) => { + unsafe { self.device.destroy_image_view(luma, None) }; + return Err(e); + } + }; + Ok([luma, chroma]) + } +} + +/// Flatten the 3×vec4 rows for the push-constant block. +fn bytemuck_rows(rows: &[[f32; 4]; 3]) -> &[f32] { + // SAFETY: [[f32;4];3] is 12 contiguous f32s. + unsafe { std::slice::from_raw_parts(rows.as_ptr().cast::(), 12) } +} + +/// The live sync state of an `AVVkFrame`, snapshotted under the frames lock. +struct VkFrameSync { + image: u64, + semaphore: u64, + sem_value: u64, + layout: i32, + queue_family: u32, +} + +/// Lock the frame and read its live sync state (the presenter's submit must wait +/// `sem_value` and signal `sem_value + 1`). The lock is held until [`unlock_vkframe`]. +// bindgen's enum repr is target-dependent (u32 Linux/clang, i32 MSVC) — the layout cast +// is required on one platform and a no-op on the other. +#[allow(clippy::unnecessary_cast)] +fn lock_vkframe(f: &VkVideoFrame) -> VkFrameSync { + unsafe { + let lock: unsafe extern "C" fn(*mut pf_ffvk::AVHWFramesContext, *mut pf_ffvk::AVVkFrame) = + std::mem::transmute(f.lock_frame); + let fc = f.frames_ctx as *mut pf_ffvk::AVHWFramesContext; + let vkf = f.vkframe as *mut pf_ffvk::AVVkFrame; + lock(fc, vkf); + VkFrameSync { + image: (*vkf).img[0] as u64, + semaphore: (*vkf).sem[0] as u64, + sem_value: (*vkf).sem_value[0], + layout: (*vkf).layout[0] as i32, + queue_family: (*vkf).queue_family[0], + } + } +} + +/// Write the post-submission state back (FFmpeg waits these on its next use of the +/// frame) and release the lock. On a failed submit only the lock is released. +fn unlock_vkframe(f: &VkVideoFrame, sync: &VkFrameSync, submitted: bool, graphics_qf: u32) { + unsafe { + let vkf = f.vkframe as *mut pf_ffvk::AVVkFrame; + if submitted { + (*vkf).sem_value[0] = sync.sem_value + 1; + (*vkf).layout[0] = + vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL.as_raw() as pf_ffvk::VkImageLayout; + if sync.queue_family != vk::QUEUE_FAMILY_IGNORED { + (*vkf).queue_family[0] = graphics_qf; + } + } + let unlock: unsafe extern "C" fn(*mut pf_ffvk::AVHWFramesContext, *mut pf_ffvk::AVVkFrame) = + std::mem::transmute(f.unlock_frame); + unlock(f.frames_ctx as *mut pf_ffvk::AVHWFramesContext, vkf); + } +} diff --git a/crates/pf-presenter/src/vk/reconfig.rs b/crates/pf-presenter/src/vk/reconfig.rs new file mode 100644 index 00000000..5b587ac6 --- /dev/null +++ b/crates/pf-presenter/src/vk/reconfig.rs @@ -0,0 +1,233 @@ +//! Swapchain recreate / resize / HDR reconfiguration. + +use super::setup::pick_formats; +use super::{OverlayPipe, Presenter}; +use crate::csc::CscPass; +use anyhow::{Context as _, Result}; +use ash::vk; + +impl Presenter { + /// (Re)build the swapchain for the window's current pixel size. Also the resize path. + pub fn recreate_swapchain(&mut self, window: &sdl3::video::Window) -> Result<()> { + self.quiesce_own()?; + // Drain the queue before touching presentation objects: after this, every prior + // present's semaphore-wait operation has completed, so the OLD swapchain and its + // render semaphores are safe to destroy immediately below. (The previous scheme + // parked them and destroyed after one fence cycle — but the fence proves only + // OUR submit, not the presentation engine's semaphore consumption: + // VUID-vkDestroySemaphore-05149 / VUID-vkDestroySwapchainKHR-01282 on every + // recreate, and destroy-in-use is exactly the kind of misuse that turns into an + // intermittent VK_ERROR_DEVICE_LOST.) Safe against the pump's FFmpeg submits — + // both sides hold the shared queue lock — and cheap: a recreate already stalls + // the stream for a frame, and only happens on resize/HDR-flip/OUT_OF_DATE. + { + let _q = self.queue_lock.guard(); + unsafe { self.device.queue_wait_idle(self.queue) } + .context("vkQueueWaitIdle (swapchain recreate)")?; + } + + let caps = unsafe { + self.surface_i + .get_physical_device_surface_capabilities(self.pdev, self.surface) + }?; + let (pw, ph) = window.size_in_pixels(); + let extent = if caps.current_extent.width != u32::MAX { + caps.current_extent + } else { + vk::Extent2D { + width: pw.clamp(caps.min_image_extent.width, caps.max_image_extent.width), + height: ph.clamp(caps.min_image_extent.height, caps.max_image_extent.height), + } + }; + if extent.width == 0 || extent.height == 0 { + // Minimized — keep the old swapchain; presents will report OUT_OF_DATE and + // land back here once the window has a size again. + return Ok(()); + } + let mut min_images = caps.min_image_count + 1; + if caps.max_image_count > 0 { + min_images = min_images.min(caps.max_image_count); + } + + let old = self.swapchain; + let info = vk::SwapchainCreateInfoKHR::default() + .surface(self.surface) + .min_image_count(min_images) + .image_format(self.format.format) + .image_color_space(self.format.color_space) + .image_extent(extent) + .image_array_layers(1) + // TRANSFER_DST is the whole phase-1 pipeline (clear + blit); COLOR_ATTACHMENT + // keeps the phase-2 render pass from forcing a swapchain rebuild contract change. + .image_usage(vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::TRANSFER_DST) + .image_sharing_mode(vk::SharingMode::EXCLUSIVE) + .pre_transform(caps.current_transform) + .composite_alpha(vk::CompositeAlphaFlagsKHR::OPAQUE) + .present_mode(self.present_mode) + .clipped(true) + .old_swapchain(old); + let swapchain = + unsafe { self.swap_d.create_swapchain(&info, None) }.context("vkCreateSwapchainKHR")?; + // The old swapchain and everything tied to its images dies NOW: the fence + // quiesce covered our own command buffers, the queue drain above covered the + // presentation engine's semaphore waits — nothing can still reference them. + let (overlay_views, overlay_framebuffers) = self.overlay_pipe.take_targets(); + unsafe { + for fb in overlay_framebuffers { + self.device.destroy_framebuffer(fb, None); + } + for v in overlay_views { + self.device.destroy_image_view(v, None); + } + for s in self.render_sems.drain(..) { + self.device.destroy_semaphore(s, None); + } + if old != vk::SwapchainKHR::null() { + self.swap_d.destroy_swapchain(old, None); + } + } + self.swapchain = swapchain; + self.images = unsafe { self.swap_d.get_swapchain_images(swapchain) }?; + self.extent = extent; + self.overlay_pipe.rebuild_targets( + &self.device, + &self.images, + self.format.format, + extent, + )?; + + for _ in 0..self.images.len() { + self.render_sems.push(unsafe { + self.device + .create_semaphore(&vk::SemaphoreCreateInfo::default(), None) + }?); + } + tracing::debug!( + width = extent.width, + height = extent.height, + images = self.images.len(), + "swapchain (re)created" + ); + // HDR metadata is per-swapchain state: a rebuilt HDR10 swapchain needs it pushed + // again (this also covers set_hdr_mode's entry into HDR10, which lands here). + if self.hdr_active { + self.apply_hdr_metadata(); + } + Ok(()) + } + + /// Whether the swapchain is actually in HDR10/PQ mode — as opposed to a PQ stream + /// being tone-mapped onto an SDR surface. This, not the stream's own signaling, is + /// what user-facing "HDR" indicators should report. + pub fn hdr_active(&self) -> bool { + self.hdr_active + } + + /// Record the host's ST.2086 mastering + content-light metadata (the 0xCE plane), + /// pushing it to the swapchain immediately when HDR10 mode is live. Cheap and + /// idempotent per distinct value — callers just drain the plane into it. + pub fn set_hdr_metadata(&mut self, meta: punktfunk_core::quic::HdrMeta) { + if self.hdr_meta == Some(meta) { + return; + } + self.hdr_meta = Some(meta); + if self.hdr_active { + self.apply_hdr_metadata(); + } + } + + /// Push the current metadata (the host's, or a generic HDR10 baseline until 0xCE + /// arrives) to the presentation engine via `vkSetHdrMetadataEXT`. Compositors gate + /// their HDR-app signaling on this — picking the HDR10 colorspace alone leaves + /// gamescope treating the app as SDR (no SteamOS HDR badge, no per-app tone-map + /// target). No-op where the driver lacks the extension. + fn apply_hdr_metadata(&self) { + let Some(ext) = &self.hdr_metadata_d else { + return; + }; + // Same generic baseline as the Windows presenter: BT.2020 primaries + D65 + // white, 1000-nit mastering display, MaxCLL 1000 / MaxFALL 400. + let m = self.hdr_meta.unwrap_or(punktfunk_core::quic::HdrMeta { + display_primaries: [[8500, 39850], [6550, 2300], [35400, 14600]], + white_point: [15635, 16450], + max_display_mastering_luminance: 10_000_000, + min_display_mastering_luminance: 1, + max_cll: 1000, + max_fall: 400, + }); + // Protocol fields are HDR10 SEI fixed-point (chromaticity 1/50000, luminance + // 0.0001 cd/m², primaries in ST.2086 G,B,R order); Vulkan wants floats in + // 0..1 chromaticity and whole nits, primaries named R/G/B. + let xy = |p: [u16; 2]| vk::XYColorEXT { + x: p[0] as f32 / 50_000.0, + y: p[1] as f32 / 50_000.0, + }; + let [g, b, r] = m.display_primaries; + let md = vk::HdrMetadataEXT::default() + .display_primary_red(xy(r)) + .display_primary_green(xy(g)) + .display_primary_blue(xy(b)) + .white_point(xy(m.white_point)) + .max_luminance(m.max_display_mastering_luminance as f32 / 10_000.0) + .min_luminance(m.min_display_mastering_luminance as f32 / 10_000.0) + .max_content_light_level(m.max_cll as f32) + .max_frame_average_light_level(m.max_fall as f32); + unsafe { ext.set_hdr_metadata(&[self.swapchain], &[md]) }; + tracing::debug!(from_host = self.hdr_meta.is_some(), "HDR metadata pushed"); + } + /// Flip the presenter between SDR and HDR10 output (stream SDR↔PQ, in-band). A + /// fence quiesce, then everything format-bound is rebuilt: the CSC pass + video + /// image (10-bit intermediate — PQ in 8 bits bands visibly), the overlay pipe, and + /// the swapchain (old one parked per the deferred-destroy rules). + pub(super) fn set_hdr_mode(&mut self, window: &sdl3::video::Window, on: bool) -> Result<()> { + let target = if on { + self.hdr10_format.expect("caller checked availability") + } else { + // Recompute the SDR pick? It never changed — the sdr format is immutable. + // (self.format currently holds the HDR pairing.) + pick_formats(&self.surface_i, self.pdev, self.surface, false)?.0 + }; + tracing::info!(hdr = on, format = ?target, "switching presentation mode"); + self.quiesce_own()?; + self.video_format = if on { + vk::Format::A2B10G10R10_UNORM_PACK32 + } else { + vk::Format::R8G8B8A8_UNORM + }; + self.csc.destroy(&self.device); // fence-safe: only our cmd bufs reference it + #[cfg(all(target_os = "linux", feature = "pyrowave"))] + if let Some(p) = &self.csc_planar { + p.destroy(&self.device); + } + self.csc = CscPass::new(&self.device, self.video_format)?; + if let Some(v) = self.video.take() { + unsafe { + self.device.destroy_framebuffer(v.framebuffer, None); + self.device.destroy_image_view(v.view, None); + self.device.destroy_image(v.image, None); + self.device.free_memory(v.memory, None); + } + } + // New overlay pipe against the new swapchain format. The old one's targets + // (views/framebuffers over the current swapchain's images) are only ever + // referenced by our own command buffers — the fence quiesce above makes them + // safe to destroy right here; the swapchain itself rides the recreate below. + let mut old_pipe = std::mem::replace( + &mut self.overlay_pipe, + OverlayPipe::new(&self.device, target.format)?, + ); + let (overlay_views, overlay_framebuffers) = old_pipe.take_targets(); + unsafe { + for fb in overlay_framebuffers { + self.device.destroy_framebuffer(fb, None); + } + for v in overlay_views { + self.device.destroy_image_view(v, None); + } + } + old_pipe.destroy(&self.device); + self.format = target; + self.hdr_active = on; + self.recreate_swapchain(window) + } +} diff --git a/crates/pf-presenter/src/vk/resources.rs b/crates/pf-presenter/src/vk/resources.rs new file mode 100644 index 00000000..e45281c3 --- /dev/null +++ b/crates/pf-presenter/src/vk/resources.rs @@ -0,0 +1,169 @@ +//! Video-image / staging-buffer (re)build + retired-frame destruction. + +use super::gpu::subresource_range; +use super::{Presenter, Retired, Staging, VideoImage}; +use anyhow::Result; +use ash::vk; +use pf_client_core::video::CpuFrame; + +impl Retired { + pub(super) fn destroy(self, device: &ash::Device) { + match self { + #[cfg(target_os = "linux")] + Retired::Dmabuf(f) => f.destroy(device), + #[cfg(windows)] + Retired::D3d11(f) => f.destroy(device), + Retired::Vk { frame, views } => { + unsafe { + for v in views { + device.destroy_image_view(v, None); + } + } + drop(frame); // guard drops here — AVFrame (and the VkImage) released + } + } + } +} + +impl Presenter { + /// Copy the frame's RGBA into the staging buffer and (re)build the video image on a + /// stream-size change. Rows keep their stride — `buffer_row_length` unpacks it. + pub(super) fn stage_frame(&mut self, f: &CpuFrame) -> Result<()> { + anyhow::ensure!( + f.stride % 4 == 0 && f.stride >= f.width as usize * 4, + "unexpected RGBA stride {} for width {}", + f.stride, + f.width + ); + if self + .video + .as_ref() + .is_none_or(|v| v.width != f.width || v.height != f.height) + { + self.rebuild_video_image(f.width, f.height)?; + tracing::info!(width = f.width, height = f.height, "video image (re)built"); + } + let needed = f.stride * f.height as usize; + if self.staging.as_ref().is_none_or(|s| s.capacity < needed) { + self.rebuild_staging(needed)?; + } + let s = self.staging.as_ref().unwrap(); + let n = f.rgba.len().min(needed); + unsafe { std::ptr::copy_nonoverlapping(f.rgba.as_ptr(), s.ptr, n) }; + Ok(()) + } + + pub(super) fn rebuild_video_image(&mut self, width: u32, height: u32) -> Result<()> { + // Fence-quiesce: the old image is only ever referenced by OUR command buffers. + self.quiesce_own()?; + if let Some(v) = self.video.take() { + unsafe { + if v.framebuffer != vk::Framebuffer::null() { + self.device.destroy_framebuffer(v.framebuffer, None); + } + if v.view != vk::ImageView::null() { + self.device.destroy_image_view(v.view, None); + } + self.device.destroy_image(v.image, None); + self.device.free_memory(v.memory, None); + } + } + // COLOR_ATTACHMENT is the CSC pass's render target; harmless where hw is absent. + let image = unsafe { + self.device.create_image( + &vk::ImageCreateInfo::default() + .image_type(vk::ImageType::TYPE_2D) + .format(self.video_format) + .extent(vk::Extent3D { + width, + height, + depth: 1, + }) + .mip_levels(1) + .array_layers(1) + .samples(vk::SampleCountFlags::TYPE_1) + .tiling(vk::ImageTiling::OPTIMAL) + .usage( + vk::ImageUsageFlags::TRANSFER_DST + | vk::ImageUsageFlags::TRANSFER_SRC + | vk::ImageUsageFlags::COLOR_ATTACHMENT, + ) + .initial_layout(vk::ImageLayout::UNDEFINED), + None, + ) + }?; + let reqs = unsafe { self.device.get_image_memory_requirements(image) }; + let memory = self.allocate(reqs, vk::MemoryPropertyFlags::DEVICE_LOCAL)?; + unsafe { self.device.bind_image_memory(image, memory, 0) }?; + // The CSC pass renders into it — view + framebuffer, unconditional (Vulkan-Video + // frames need the pass on every device, dmabuf-capable or not). + let view = unsafe { + self.device.create_image_view( + &vk::ImageViewCreateInfo::default() + .image(image) + .view_type(vk::ImageViewType::TYPE_2D) + .format(self.video_format) + .subresource_range(subresource_range()), + None, + ) + }?; + let attachments = [view]; + let framebuffer = unsafe { + self.device.create_framebuffer( + &vk::FramebufferCreateInfo::default() + .render_pass(self.csc.render_pass) + .attachments(&attachments) + .width(width) + .height(height) + .layers(1), + None, + ) + }?; + self.video = Some(VideoImage { + image, + memory, + view, + framebuffer, + width, + height, + }); + Ok(()) + } + + fn rebuild_staging(&mut self, capacity: usize) -> Result<()> { + self.quiesce_own()?; + if let Some(s) = self.staging.take() { + unsafe { + self.device.unmap_memory(s.memory); + self.device.destroy_buffer(s.buffer, None); + self.device.free_memory(s.memory, None); + } + } + let buffer = unsafe { + self.device.create_buffer( + &vk::BufferCreateInfo::default() + .size(capacity as u64) + .usage(vk::BufferUsageFlags::TRANSFER_SRC) + .sharing_mode(vk::SharingMode::EXCLUSIVE), + None, + ) + }?; + let reqs = unsafe { self.device.get_buffer_memory_requirements(buffer) }; + let memory = self.allocate( + reqs, + vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT, + )?; + unsafe { self.device.bind_buffer_memory(buffer, memory, 0) }?; + let ptr = unsafe { + self.device + .map_memory(memory, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty()) + }? as *mut u8; + self.staging = Some(Staging { + buffer, + memory, + ptr, + capacity, + }); + Ok(()) + } +} diff --git a/crates/pf-presenter/src/vk/setup.rs b/crates/pf-presenter/src/vk/setup.rs new file mode 100644 index 00000000..536bb51b --- /dev/null +++ b/crates/pf-presenter/src/vk/setup.rs @@ -0,0 +1,579 @@ +//! Presenter bring-up: instance → surface → device → swapchain (init-time construction). + +#[cfg(target_os = "linux")] +use super::HwCtx; +#[cfg(windows)] +use super::HwCtxWin; +use super::{OverlayPipe, Presenter}; +use crate::csc::CscPass; +#[cfg(target_os = "linux")] +use crate::dmabuf; +use anyhow::{anyhow, bail, Context as _, Result}; +use ash::vk; +use ash::vk::Handle as _; +use std::ffi::CString; + +impl Presenter { + /// Bring up instance → surface → device → swapchain over an SDL window. + /// `instance_extensions` comes from `VideoSubsystem::vulkan_instance_extensions()`. + pub fn new(window: &sdl3::video::Window, instance_extensions: &[String]) -> Result { + let entry = unsafe { ash::Entry::load() }.context("libvulkan not loadable")?; + + let app_name = CString::new("punktfunk-session").unwrap(); + // 1.3: FFmpeg's Vulkan hwcontext requires an instance of at least 1.3 (any + // current loader accepts it regardless of device support; device-level gating + // happens below). + let app_info = vk::ApplicationInfo::default() + .application_name(&app_name) + .api_version(vk::API_VERSION_1_3); + // HDR10 presentation needs the extended colorspaces at the INSTANCE level. + let mut instance_extensions: Vec = instance_extensions.to_vec(); + let inst_available = + unsafe { entry.enumerate_instance_extension_properties(None) }.unwrap_or_default(); + let has_colorspace_ext = inst_available + .iter() + .any(|e| e.extension_name_as_c_str() == Ok(c"VK_EXT_swapchain_colorspace")); + if has_colorspace_ext { + instance_extensions.push("VK_EXT_swapchain_colorspace".into()); + } + let ext_cstrings: Vec = instance_extensions + .iter() + .map(|e| CString::new(e.as_str()).unwrap()) + .collect(); + let ext_ptrs: Vec<*const i8> = ext_cstrings.iter().map(|e| e.as_ptr()).collect(); + let instance = unsafe { + entry.create_instance( + &vk::InstanceCreateInfo::default() + .application_info(&app_info) + .enabled_extension_names(&ext_ptrs), + None, + ) + } + .context("vkCreateInstance")?; + let surface_i = ash::khr::surface::Instance::new(&entry, &instance); + + let surface = unsafe { window.vulkan_create_surface(instance.handle()) } + .map_err(|e| anyhow!("SDL_Vulkan_CreateSurface: {e}"))?; + + let (pdev, qfi) = pick_device(&instance, &surface_i, surface)?; + let mem_props = unsafe { instance.get_physical_device_memory_properties(pdev) }; + { + let props = unsafe { instance.get_physical_device_properties(pdev) }; + let name = props + .device_name_as_c_str() + .map(|c| c.to_string_lossy().into_owned()) + .unwrap_or_default(); + tracing::info!(device = %name, queue_family = qfi, "vulkan device"); + } + + // The dmabuf import set is optional: enabled when the device offers all four, + // else that path is off (`supports_dmabuf() == false`). Windows has no + // dmabuf/DRM-PRIME — the whole import path is compiled out there. + let available = unsafe { instance.enumerate_device_extension_properties(pdev) }?; + let has = |name: &std::ffi::CStr| { + available + .iter() + .any(|e| e.extension_name_as_c_str() == Ok(name)) + }; + #[cfg(target_os = "linux")] + let hw_capable = dmabuf::DEVICE_EXTENSIONS.iter().all(|n| has(n)); + let mut dev_exts = vec![ash::khr::swapchain::NAME.as_ptr()]; + #[cfg(target_os = "linux")] + if hw_capable { + dev_exts.extend(dmabuf::DEVICE_EXTENSIONS.iter().map(|n| n.as_ptr())); + } else { + tracing::info!( + "device lacks the dmabuf import extensions — VAAPI hardware frames \ + unavailable" + ); + } + // D3D11 shared-texture import (the D3D11VA decode hand-off) — optional exactly + // like the dmabuf set; a device without it keeps Vulkan-Video/software decode. + // Extensions alone aren't the whole gate: the driver must also report the + // multiplanar NV12 image as IMPORTABLE from a D3D11 texture handle + // (vkGetPhysicalDeviceImageFormatProperties2 — creating an unsupported external + // image is UB, observed as VK_ERROR_DEVICE_LOST at the first submits on NVIDIA). + #[cfg(windows)] + let win_capable = crate::d3d11::DEVICE_EXTENSIONS.iter().all(|n| has(n)) + && crate::d3d11::import_supported(&instance, pdev); + #[cfg(windows)] + if win_capable { + dev_exts.extend(crate::d3d11::DEVICE_EXTENSIONS.iter().map(|n| n.as_ptr())); + } else { + tracing::info!( + "device lacks the win32 external-memory/keyed-mutex extensions — D3D11VA \ + hardware frames unavailable" + ); + } + // The adapter LUID (for the D3D11VA backend to create its decode device on the + // SAME adapter). Core 1.1 query; valid on effectively every Windows driver. + let mut id_props = vk::PhysicalDeviceIDProperties::default(); + let mut props2 = vk::PhysicalDeviceProperties2::default().push_next(&mut id_props); + unsafe { instance.get_physical_device_properties2(pdev, &mut props2) }; + let adapter_luid: Option<[u8; 8]> = + (id_props.device_luid_valid == vk::TRUE).then_some(id_props.device_luid); + // Static HDR metadata (ST.2086 mastering + CLL) to the presentation engine. + // Compositors key their "this app is HDR" signaling on the client pushing + // metadata via vkSetHdrMetadataEXT in addition to picking the HDR10 colorspace + // (gamescope's SteamOS HDR badge and per-app tone-map targets among them) — + // the colorspace alone leaves the app looking SDR to the shell. + let has_hdr_metadata = has(ash::ext::hdr_metadata::NAME); + if has_hdr_metadata { + dev_exts.push(ash::ext::hdr_metadata::NAME.as_ptr()); + } + + // --- Vulkan Video decode (the FFmpeg-on-our-device path) --------------------- + // Probed, never required: a capable stack gets the video extensions, a second + // (decode) queue, and the features FFmpeg's decoder needs; anything less means + // `vulkan_decode() == None` and the decoder chain falls back (VAAPI/software). + let dev_props = unsafe { instance.get_physical_device_properties(pdev) }; + let dev_is_13 = vk::api_version_major(dev_props.api_version) > 1 + || vk::api_version_minor(dev_props.api_version) >= 3; + let mut have_f11 = vk::PhysicalDeviceVulkan11Features::default(); + let mut have_f12 = vk::PhysicalDeviceVulkan12Features::default(); + let mut have_f13 = vk::PhysicalDeviceVulkan13Features::default(); + let mut have_f2 = vk::PhysicalDeviceFeatures2::default() + .push_next(&mut have_f11) + .push_next(&mut have_f12) + .push_next(&mut have_f13); + unsafe { instance.get_physical_device_features2(pdev, &mut have_f2) }; + // Copy the one base-features fact out NOW: `have_f2` mutably borrows the 11/12/13 + // structs through its pNext chain, so any later use of it would pin those borrows. + let have_shader_int16 = have_f2.features.shader_int16; + let features_ok = have_f11.sampler_ycbcr_conversion == vk::TRUE + && have_f12.timeline_semaphore == vk::TRUE + && have_f13.synchronization2 == vk::TRUE; + // PyroWave decode (the wired-LAN wavelet codec, design/pyrowave-codec-plan.md §4.5): + // plain Vulkan-1.3 compute on THIS device — no video extensions. Probed alongside so a + // capable device gets the features enabled below and advertises the codec; anything + // less simply never sets the CODEC_PYROWAVE bit. + let pyrowave_ok = dev_is_13 + && have_shader_int16 == vk::TRUE + && have_f12.storage_buffer8_bit_access == vk::TRUE + && have_f12.timeline_semaphore == vk::TRUE + && have_f13.subgroup_size_control == vk::TRUE + && have_f13.compute_full_subgroups == vk::TRUE + && have_f13.synchronization2 == vk::TRUE; + + // The decode queue family + which codec operations it can run. + let decode_family: Option<(u32, vk::VideoCodecOperationFlagsKHR)> = { + let n = unsafe { instance.get_physical_device_queue_family_properties2_len(pdev) }; + let mut video: Vec = + vec![vk::QueueFamilyVideoPropertiesKHR::default(); n]; + let mut props: Vec = video + .iter_mut() + .map(|v| vk::QueueFamilyProperties2::default().push_next(v)) + .collect(); + unsafe { instance.get_physical_device_queue_family_properties2(pdev, &mut props) }; + // `props` mutably borrows `video` (push_next); copy the flags out, then + // read the driver-filled video properties directly. + let flags: Vec = props + .iter() + .map(|p| p.queue_family_properties.queue_flags) + .collect(); + drop(props); + flags + .iter() + .zip(&video) + .enumerate() + .find(|(_, (f, _))| f.contains(vk::QueueFlags::VIDEO_DECODE_KHR)) + .map(|(i, (_, v))| (i as u32, v.video_codec_operations)) + }; + + const VIDEO_BASE: [&std::ffi::CStr; 2] = [ + ash::khr::video_queue::NAME, + ash::khr::video_decode_queue::NAME, + ]; + const VIDEO_CODECS: [&std::ffi::CStr; 3] = [ + ash::khr::video_decode_h264::NAME, + ash::khr::video_decode_h265::NAME, + c"VK_KHR_video_decode_av1", + ]; + let codec_exts: Vec<&std::ffi::CStr> = + VIDEO_CODECS.into_iter().filter(|n| has(n)).collect(); + let video_ok = dev_is_13 + && features_ok + && decode_family.is_some() + && VIDEO_BASE.iter().all(|n| has(n)) + && !codec_exts.is_empty(); + + let (decode_qf, decode_caps) = decode_family.unwrap_or((qfi, Default::default())); + let mut video_ext_names: Vec<&std::ffi::CStr> = Vec::new(); + if video_ok { + video_ext_names.extend(VIDEO_BASE); + video_ext_names.extend(&codec_exts); + // Optional decoder niceties FFmpeg uses when present. + for opt in [c"VK_KHR_video_maintenance1", c"VK_KHR_video_maintenance2"] { + if has(opt) { + video_ext_names.push(opt); + } + } + dev_exts.extend(video_ext_names.iter().map(|n| n.as_ptr())); + tracing::info!( + decode_qf, + caps = ?decode_caps, + exts = ?video_ext_names, + "Vulkan Video decode available on this device" + ); + } else { + tracing::info!( + dev_is_13, + features_ok, + decode_family = decode_family.is_some(), + "Vulkan Video decode unavailable — decoder falls back (VAAPI/software)" + ); + } + + // Enable only the features the video path needs, and only where supported + // (harmless when the path is off; reported to FFmpeg via device_features). + let mut en_f11 = vk::PhysicalDeviceVulkan11Features::default() + .sampler_ycbcr_conversion(have_f11.sampler_ycbcr_conversion == vk::TRUE); + let mut en_f12 = vk::PhysicalDeviceVulkan12Features::default() + .timeline_semaphore(have_f12.timeline_semaphore == vk::TRUE) + .storage_buffer8_bit_access(pyrowave_ok) + .shader_float16(pyrowave_ok && have_f12.shader_float16 == vk::TRUE); + let mut en_f13 = vk::PhysicalDeviceVulkan13Features::default() + .synchronization2(have_f13.synchronization2 == vk::TRUE) + .subgroup_size_control(pyrowave_ok) + .compute_full_subgroups(pyrowave_ok); + let mut en_f2 = vk::PhysicalDeviceFeatures2::default() + .push_next(&mut en_f11) + .push_next(&mut en_f12) + .push_next(&mut en_f13); + en_f2.features.shader_int16 = if pyrowave_ok { vk::TRUE } else { vk::FALSE }; + + let priorities = [1.0f32]; + let mut queue_info = vec![vk::DeviceQueueCreateInfo::default() + .queue_family_index(qfi) + .queue_priorities(&priorities)]; + if video_ok && decode_qf != qfi { + queue_info.push( + vk::DeviceQueueCreateInfo::default() + .queue_family_index(decode_qf) + .queue_priorities(&priorities), + ); + } + let device = unsafe { + instance.create_device( + pdev, + &vk::DeviceCreateInfo::default() + .queue_create_infos(&queue_info) + .enabled_extension_names(&dev_exts) + .push_next(&mut en_f2), + None, + ) + } + .context("vkCreateDevice")?; + let swap_d = ash::khr::swapchain::Device::new(&instance, &device); + let hdr_metadata_d = + has_hdr_metadata.then(|| ash::ext::hdr_metadata::Device::new(&instance, &device)); + let queue = unsafe { device.get_device_queue(qfi, 0) }; + #[cfg(target_os = "linux")] + let hw = if hw_capable { + Some(HwCtx { + ext_mem_fd: ash::khr::external_memory_fd::Device::new(&instance, &device), + }) + } else { + None + }; + #[cfg(windows)] + let hw_win = win_capable.then(|| HwCtxWin { + ext_mem_win32: ash::khr::external_memory_win32::Device::new(&instance, &device), + }); + let csc = CscPass::new(&device, vk::Format::R8G8B8A8_UNORM)?; + // PyroWave is 8-bit SDR only, so the planar pass never needs the HDR10 rebuild. + #[cfg(all(target_os = "linux", feature = "pyrowave"))] + let csc_planar = if pyrowave_ok { + Some(CscPass::new_planar(&device, vk::Format::R8G8B8A8_UNORM)?) + } else { + None + }; + + // The exported handle bundle: FFmpeg Vulkan Video handles when the device can + // decode, AND (Windows) the D3D11-interop facts — so it's built whenever EITHER + // consumer needs it; `video_decode`/`d3d11_import` tell the decoder chain which + // paths are real. Extension lists must mirror creation exactly — FFmpeg keys its + // code paths off the strings. + // One lock per device for queue external sync (FFmpeg + Skia + this presenter + // all funnel their queue calls through it — see the `queue_lock` field docs). + let queue_lock = std::sync::Arc::new(pf_client_core::video::QueueLock::new()); + #[cfg(windows)] + let export_worthy = video_ok || win_capable || pyrowave_ok; + #[cfg(not(windows))] + let export_worthy = video_ok || pyrowave_ok; + let video_export = if export_worthy { + let qf_props = unsafe { instance.get_physical_device_queue_family_properties(pdev) }; + let mut device_extensions: Vec = + vec![CString::from(ash::khr::swapchain::NAME)]; + #[cfg(target_os = "linux")] + if hw_capable { + device_extensions + .extend(dmabuf::DEVICE_EXTENSIONS.iter().map(|n| CString::from(*n))); + } + #[cfg(windows)] + if win_capable { + device_extensions.extend( + crate::d3d11::DEVICE_EXTENSIONS + .iter() + .map(|n| CString::from(*n)), + ); + } + if has_hdr_metadata { + device_extensions.push(CString::from(ash::ext::hdr_metadata::NAME)); + } + device_extensions.extend(video_ext_names.iter().map(|n| CString::from(*n))); + Some(pf_client_core::video::VulkanDecodeDevice { + get_instance_proc_addr: entry.static_fn().get_instance_proc_addr as usize, + instance: instance.handle().as_raw() as usize, + physical_device: pdev.as_raw() as usize, + device: device.handle().as_raw() as usize, + vendor_id: dev_props.vendor_id, + device_name: dev_props + .device_name_as_c_str() + .map(|c| c.to_string_lossy().into_owned()) + .unwrap_or_default(), + graphics_qf: qfi, + graphics_queue_flags: qf_props[qfi as usize].queue_flags.as_raw(), + decode_qf, + decode_video_caps: decode_caps.as_raw(), + instance_extensions: instance_extensions + .iter() + .map(|e| CString::new(e.as_str()).unwrap()) + .collect(), + device_extensions, + f_sampler_ycbcr: have_f11.sampler_ycbcr_conversion == vk::TRUE, + f_timeline_semaphore: have_f12.timeline_semaphore == vk::TRUE, + f_synchronization2: have_f13.synchronization2 == vk::TRUE, + f_shader_int16: pyrowave_ok, + f_storage_buffer8: pyrowave_ok, + f_subgroup_size_control: pyrowave_ok, + f_compute_full_subgroups: pyrowave_ok, + f_shader_float16: pyrowave_ok && have_f12.shader_float16 == vk::TRUE, + api_version: dev_props.api_version, + queue_families: queue_info.iter().map(|q| q.queue_family_index).collect(), + pyrowave_decode: pyrowave_ok, + video_decode: video_ok, + #[cfg(windows)] + d3d11_import: win_capable, + #[cfg(not(windows))] + d3d11_import: false, + adapter_luid, + queue_lock: queue_lock.clone(), + }) + } else { + None + }; + + let (format, hdr10_format) = pick_formats(&surface_i, pdev, surface, has_colorspace_ext)?; + let present_mode = pick_present_mode(&surface_i, pdev, surface)?; + tracing::info!( + ?format, + ?hdr10_format, + ?present_mode, + hdr_metadata = has_hdr_metadata, + "swapchain config" + ); + let overlay_pipe = OverlayPipe::new(&device, format.format)?; + + let cmd_pool = unsafe { + device.create_command_pool( + &vk::CommandPoolCreateInfo::default() + .flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER) + .queue_family_index(qfi), + None, + ) + }?; + let cmd_buf = unsafe { + device.allocate_command_buffers( + &vk::CommandBufferAllocateInfo::default() + .command_pool(cmd_pool) + .level(vk::CommandBufferLevel::PRIMARY) + .command_buffer_count(1), + ) + }?[0]; + let acquire_sem = + unsafe { device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None) }?; + let fence = unsafe { + device.create_fence( + &vk::FenceCreateInfo::default().flags(vk::FenceCreateFlags::SIGNALED), + None, + ) + }?; + + let mut p = Presenter { + entry, + instance, + surface_i, + surface, + pdev, + mem_props, + device, + swap_d, + queue, + qfi, + #[cfg(target_os = "linux")] + hw, + #[cfg(windows)] + hw_win, + csc, + #[cfg(all(target_os = "linux", feature = "pyrowave"))] + csc_planar, + video_export, + overlay_pipe, + retired_hw: None, + queue_lock, + format, + hdr10_format, + hdr_active: false, + hdr_downgrade_warned: false, + hdr_metadata_d, + hdr_meta: None, + video_format: vk::Format::R8G8B8A8_UNORM, + present_mode, + swapchain: vk::SwapchainKHR::null(), + images: Vec::new(), + extent: vk::Extent2D::default(), + render_sems: Vec::new(), + acquire_sem, + fence, + cmd_pool, + cmd_buf, + staging: None, + video: None, + submitted: false, + }; + p.recreate_swapchain(window)?; + Ok(p) + } +} + +/// First physical device with a queue family that does graphics + present here; +/// `PUNKTFUNK_VK_DEVICE=` overrides on multi-GPU boxes. +fn pick_device( + instance: &ash::Instance, + surface_i: &ash::khr::surface::Instance, + surface: vk::SurfaceKHR, +) -> Result<(vk::PhysicalDevice, u32)> { + let devices = unsafe { instance.enumerate_physical_devices() }?; + let forced: Option = std::env::var("PUNKTFUNK_VK_DEVICE") + .ok() + .and_then(|v| v.parse().ok()); + let mut candidates: Vec = match forced { + Some(i) => devices.get(i).copied().into_iter().collect(), + None => devices, + }; + // Rank the candidates (stable sort; the index override wins outright): + // 1. The Settings GPU pick — `PUNKTFUNK_VK_ADAPTER` carries the adapter's marketing + // name (the WinUI shell's picker stores DXGI's, which matches Vulkan's for the + // same GPU): exact match, then substring, plain order when nothing matches + // (eGPU unplugged, stale setting). + // 2. Discrete over integrated: enumeration order puts the iGPU FIRST on some + // hybrids (observed: Ryzen iGPU ahead of an RTX dGPU), and the iGPU's video + // engine is the far weaker decoder — first-enumerated was a silent footgun. + if forced.is_none() { + let want = std::env::var("PUNKTFUNK_VK_ADAPTER") + .ok() + .map(|w| w.trim().to_lowercase()) + .filter(|w| !w.is_empty()); + candidates.sort_by_key(|d| { + let props = unsafe { instance.get_physical_device_properties(*d) }; + let name = props + .device_name_as_c_str() + .map(|c| c.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + let name_rank = match &want { + Some(w) if name == *w => 0, + Some(w) if name.contains(w.as_str()) || w.contains(&name) => 1, + Some(_) => 2, + None => 0, + }; + let type_rank = match props.device_type { + vk::PhysicalDeviceType::DISCRETE_GPU => 0, + vk::PhysicalDeviceType::INTEGRATED_GPU => 1, + _ => 2, + }; + (name_rank, type_rank) + }); + } + for pdev in candidates { + let families = unsafe { instance.get_physical_device_queue_family_properties(pdev) }; + for (i, f) in families.iter().enumerate() { + let graphics = f.queue_flags.contains(vk::QueueFlags::GRAPHICS); + let present = + unsafe { surface_i.get_physical_device_surface_support(pdev, i as u32, surface) } + .unwrap_or(false); + if graphics && present { + return Ok((pdev, i as u32)); + } + } + } + bail!("no Vulkan device with a graphics+present queue family") +} + +/// SDR: prefer BGRA8 UNORM (the near-universal presentable format); RGBA8 second; else +/// whatever the surface offers first. UNORM (not SRGB) — the decoded RGBA is already +/// display-referred, the blit must not re-encode it. HDR: a 10-bit UNORM format paired +/// with the HDR10/ST.2084 colorspace, when the instance ext + surface offer one (KDE/ +/// gamescope with HDR enabled; absent elsewhere → the shader tonemaps instead). +pub(super) fn pick_formats( + surface_i: &ash::khr::surface::Instance, + pdev: vk::PhysicalDevice, + surface: vk::SurfaceKHR, + colorspace_ext: bool, +) -> Result<(vk::SurfaceFormatKHR, Option)> { + let formats = unsafe { surface_i.get_physical_device_surface_formats(pdev, surface) }?; + let mut sdr = None; + for want in [vk::Format::B8G8R8A8_UNORM, vk::Format::R8G8B8A8_UNORM] { + if let Some(f) = formats + .iter() + .find(|f| f.format == want && f.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR) + { + sdr = Some(*f); + break; + } + } + let sdr = sdr + .or_else(|| formats.first().copied()) + .ok_or_else(|| anyhow!("surface offers no formats"))?; + let hdr10 = colorspace_ext + .then(|| { + formats + .iter() + .find(|f| { + f.color_space == vk::ColorSpaceKHR::HDR10_ST2084_EXT + && matches!( + f.format, + vk::Format::A2B10G10R10_UNORM_PACK32 + | vk::Format::A2R10G10B10_UNORM_PACK32 + ) + }) + .copied() + }) + .flatten(); + Ok((sdr, hdr10)) +} + +/// MAILBOX when the surface offers it, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE= +/// fifo|mailbox|immediate` overrides). Both are tear-free, but an arrival-paced +/// presenter must not block in FIFO's present queue: when the compositor holds images +/// for a vblank pass (gamescope's composite path) or arrival cadence drifts against +/// refresh, `acquire_next_image` stalls most of a refresh — a standing 11-13 ms added +/// to every frame at 60 Hz. MAILBOX never queues more than the newest frame, so the +/// pipeline stays at decode latency and a late frame is replaced, not waited for. +fn pick_present_mode( + surface_i: &ash::khr::surface::Instance, + pdev: vk::PhysicalDevice, + surface: vk::SurfaceKHR, +) -> Result { + let modes = unsafe { surface_i.get_physical_device_surface_present_modes(pdev, surface) }?; + let want = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() { + Some("fifo") => vk::PresentModeKHR::FIFO, + Some("immediate") => vk::PresentModeKHR::IMMEDIATE, + _ => vk::PresentModeKHR::MAILBOX, + }; + Ok(if modes.contains(&want) { + want + } else { + vk::PresentModeKHR::FIFO // always available per spec + }) +}