refactor(presenter/W8): split vk.rs into vk/ directory module
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) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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<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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<vk::ImageView>,
|
||||||
|
framebuffers: Vec<vk::Framebuffer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
/// 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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).
|
||||||
|
pub(super) 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.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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(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<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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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).
|
||||||
|
pub(super) 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
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user