Files
punktfunk/crates/pf-presenter/src/vk.rs
T
enricobuehler f77eec1299
android / android (push) Failing after 45s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 56s
decky / build-publish (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
arch / build-publish (push) Failing after 4m15s
ci / rust (push) Failing after 3m59s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 59s
deb / build-publish (push) Failing after 4m7s
ci / bench (push) Successful in 5m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 3m46s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 3m58s
flatpak / build-publish (push) Failing after 8m3s
docker / deploy-docs (push) Successful in 22s
windows-host / package (push) Successful in 14m21s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m31s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m35s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m57s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m46s
apple / swift (push) Successful in 4m59s
apple / screenshots (push) Successful in 21m40s
feat(client): PyroWave planar present path + Linux NVENC match-arm fix (Phase 2b, part 2)
The arch package job (--features nvenc) tripped the same class of
Codec::PyroWave non-exhaustive matches as windows-host had, in
nvenc_cuda.rs (6 sites) — dispatch-guarded unreachable!() arms, plus
the vk_util-extraction leftover unused imports in vulkan_video.rs.
All Linux host feature combos (none / pyrowave / nvenc,vulkan-encode /
all three) now compile clean on .21.

Presenter: planar_csc.frag (+ committed .spv) — the 3-plane variant of
nv12_csc.frag (separate Cb/Cr R8 planes, same push-constant CSC-row
contract, siting correction self-disables at full-res chroma).
CscPass grows a shared builder + new_planar()/bind_planes_planar()
(GENERAL-layout descriptors — pyrowave planes stay GENERAL); the Vk
presenter builds the planar pass when the device passed the pyrowave
probe, FrameInput::PyroWave rides present_frame (no acquire barrier
needed: the decoder fence-completed and barriered the planes on the
same queue), and run.rs presents it with no demote rung (only device
loss ends the session).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 01:42:15 +02:00

2514 lines
105 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<vk::ImageView>,
framebuffers: Vec<vk::Framebuffer>,
}
impl OverlayPipe {
fn new(device: &ash::Device, format: vk::Format) -> Result<OverlayPipe> {
// 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<vk::ImageView>, Vec<vk::Framebuffer>) {
(
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<HwCtx>,
/// D3D11 shared-texture import — `None` when the device lacks the win32 external
/// memory / keyed-mutex extensions.
#[cfg(windows)]
hw_win: Option<HwCtxWin>,
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<CscPass>,
/// FFmpeg Vulkan Video decode handles — `None` when the stack can't do it.
video_export: Option<pf_client_core::video::VulkanDecodeDevice>,
/// 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<Retired>,
/// 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<pf_client_core::video::QueueLock>,
format: vk::SurfaceFormatKHR,
/// The surface's HDR10/ST.2084 pairing, when the stack offers one.
hdr10_format: Option<vk::SurfaceFormatKHR>,
/// 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<ash::ext::hdr_metadata::Device>,
/// 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<punktfunk_core::quic::HdrMeta>,
/// The video image / CSC attachment format for the current mode.
video_format: vk::Format,
present_mode: vk::PresentModeKHR,
swapchain: vk::SwapchainKHR,
images: Vec<vk::Image>,
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<vk::Semaphore>,
acquire_sem: vk::Semaphore,
fence: vk::Fence,
cmd_pool: vk::CommandPool,
cmd_buf: vk::CommandBuffer,
staging: Option<Staging>,
video: Option<VideoImage>,
/// 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<Presenter> {
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<String> = 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<CString> = 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<vk::QueueFamilyVideoPropertiesKHR> =
vec![vk::QueueFamilyVideoPropertiesKHR::default(); n];
let mut props: Vec<vk::QueueFamilyProperties2> = 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<vk::QueueFlags> = 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<CString> =
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<pf_client_core::video::VulkanDecodeDevice> {
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<bool> {
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<HwFrame> = None;
#[cfg(windows)]
let mut win_frame: Option<crate::d3d11::HwFrame> = None;
let mut vk_frame: Option<(VkVideoFrame, [vk::ImageView; 2])> = None;
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
let mut pyro_frame: Option<pf_client_core::video_pyrowave::PyroWavePlanarFrame> = 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(|v| vk::ImageView::from_raw(v)));
}
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<VkFrameSync> = 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::<f32>().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::<u8>(), 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::<u8>(), 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<vk::DeviceMemory> {
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=<index>` 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<usize> = std::env::var("PUNKTFUNK_VK_DEVICE")
.ok()
.and_then(|v| v.parse().ok());
let mut candidates: Vec<vk::PhysicalDevice> = 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<vk::SurfaceFormatKHR>)> {
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<vk::PresentModeKHR> {
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::<f32>(), 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));
}
}