feat(clients/windows): port the Vulkan session client to Windows — session-always
The punktfunk-session Vulkan client (clients/linux-session, now clients/session) builds and runs on Windows; the WinUI shell spawns it for every stream. Verified live: 10-bit HEVC via Vulkan Video on both AMD (iGPU) and NVIDIA, 5120x1440 at 130 fps / 8 ms end-to-end on the RTX 4090. - pf-ffvk: Windows bindgen branch (FFMPEG_DIR + PF_FFVK_VULKAN_INCLUDE, no pkg-config); provisioning fetches Vulkan-Headers (pinned v1.4.309). - pf-client-core: builds on Windows — WASAPI audio (audio_wasapi.rs, cfg-swapped via #[path], same surface as the PipeWire twin), VAAPI/dmabuf gated inline (chain = vulkan -> software), trust reads the WinUI shell's %APPDATA% stores (parity tests pin both serialized shapes), Settings gains adapter/hdr_enabled (serde-defaulted; Linux stores unaffected). - pf-presenter: builds on Windows — dmabuf module Linux-gated; SDL keyboard grab while captured (Alt+Tab/Win reach the host); pick_device ranks discrete over integrated (device 0 was the iGPU on hybrid boxes — the silent footgun) and honors PUNKTFUNK_VK_ADAPTER (the Settings GPU pick, exported by the session). - run loop: block in one SDL wait woken by input AND decoded frames (a per- session forwarder pushes a FrameWake user event) instead of a 1 ms poll — measured 111%% -> 5%% of a core (NVIDIA), 86%% -> 3.5%% (AMD), stats unchanged. The pump's decode-fence wait became once-per-window sampling (no per-frame pipeline stall; the stat now shows true backlog). - pf-console-ui: builds on Windows (skia-safe msvc prebuilts); font lookup falls through fontconfig aliases to concrete DirectWrite families (Consolas/Segoe UI) — browse/coverflow works, verified against a live host. - WinUI shell: session-always via new src/spawn.rs (GTK spawn.rs port — CREATE_NO_WINDOW, stdout contract, kill handle); the Stream screen is a status card (chips + stage lines from the child's stats). The legacy in-process D3D11VA path stays behind Settings "Streaming engine" / PUNKTFUNK_BUILTIN_ STREAM=1 as the A/B baseline until Phase 8 deletes it. SessionParams.video_caps makes the HDR toggle real. - clients/linux-session renamed to clients/session (builds for both OSes). - CI/MSIX: both workflows build/test both bins with widened path filters; the MSIX ships punktfunk-session.exe. ARM64 session builds --no-default-features (rust-skia has no aarch64-pc-windows-msvc prebuilts; flip when it does). A/B on this box (5120x1440 HEVC vs home-worker-5): NVIDIA Vulkan 130 fps / 8 ms e2e / 1.6 ms decode — clearly better than the built-in path. The AMD iGPU VCN saturates at ~52 fps where its own D3D11VA does ~70 — Adrenalin Vulkan decode is slower on APU silicon; discrete RDNA validation gates Phase 8. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,19 +8,28 @@ license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
# Same Linux gating as the rest of the client stack.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Same Linux+Windows gating as the rest of the client stack (dmabuf import is the one
|
||||
# Linux-only module — see lib.rs).
|
||||
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
|
||||
pf-client-core = { path = "../pf-client-core" }
|
||||
# AVVkFrame access (Vulkan Video frames: live sync state under the frames lock).
|
||||
pf-ffvk = { path = "../pf-ffvk" }
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
|
||||
# `loaded` dlopens libvulkan at runtime (no link-time dependency — GPU-less boxes still
|
||||
# start and fail into a clean error). `ash` on sdl3 types SDL_Vulkan_CreateSurface with
|
||||
# ash 0.38 handles so the surface hands over without transmutes.
|
||||
# start and fail into a clean error; on Windows vulkan-1.dll is a GPU-driver component).
|
||||
# `ash` on sdl3 types SDL_Vulkan_CreateSurface with ash 0.38 handles so the surface
|
||||
# hands over without transmutes.
|
||||
ash = { version = "0.38", features = ["loaded"] }
|
||||
sdl3 = { version = "0.18", features = ["hidapi", "ash"] }
|
||||
|
||||
async-channel = "2"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
|
||||
# Linux links the system SDL3; Windows builds it from source (same choice as the rest
|
||||
# of the workspace's Windows SDL consumers).
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
sdl3 = { version = "0.18", features = ["hidapi", "ash"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
sdl3 = { version = "0.18", features = ["hidapi", "ash", "build-from-source"] }
|
||||
|
||||
@@ -155,7 +155,9 @@ pub fn mouse_button_to_gs(b: sdl3::mouse::MouseButton) -> Option<u32> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
// Linux-only: the reference table it cross-checks (pf_client_core::keymap, evdev-keyed)
|
||||
// only exists there. The SDL table under test is itself cross-platform.
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pf_client_core::keymap::evdev_to_vk;
|
||||
|
||||
@@ -3,26 +3,30 @@
|
||||
//! decoded frames, captures input on the `ui_stream` state-machine contract, and reports
|
||||
//! the unified stats window on stdout. No UI toolkit anywhere in the dependency tree.
|
||||
//!
|
||||
//! Two frame paths: software (`CpuFrame` RGBA staging upload) and hardware (the
|
||||
//! decoder's NV12 dmabuf imported per-plane into Vulkan + the CICP-driven CSC pass —
|
||||
//! `dmabuf.rs`/`csc.rs`), both composited by a letterboxed blit. Devices without the
|
||||
//! import extensions, and any import/present failure streak, demote the decoder to
|
||||
//! software via the session pump's `force_software` contract, same as the GTK presenter.
|
||||
//! Three frame paths: software (`CpuFrame` RGBA staging upload), Vulkan Video (the
|
||||
//! decoder's VkImage on THIS device — plane views + the CICP-driven CSC pass), and on
|
||||
//! Linux additionally VAAPI hardware (NV12 dmabuf imported per-plane — `dmabuf.rs`),
|
||||
//! all composited by a letterboxed blit. Devices without the import extensions, and any
|
||||
//! import/present failure streak, demote the decoder to software via the session pump's
|
||||
//! `force_software` contract, same as the GTK presenter.
|
||||
//!
|
||||
//! Builds on Linux AND Windows; `dmabuf` is the one Linux-only module (DRM-PRIME does
|
||||
//! not exist on Windows — the decode chain there is Vulkan → software).
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod csc;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod dmabuf;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod input;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod keymap_sdl;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod overlay;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod run;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod vk;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub use run::{run_browse, run_session, ActionOutcome, Outcome, SessionOpts};
|
||||
|
||||
@@ -124,10 +124,18 @@ enum ModeCtl<'a> {
|
||||
Browse(OnAction<'a>),
|
||||
}
|
||||
|
||||
/// The custom SDL event a decoded frame's arrival pushes (see [`StreamState::new`]):
|
||||
/// pure wake-up — the loop drains the frame channel regardless of why it woke.
|
||||
struct FrameWake;
|
||||
|
||||
/// Everything one stream session accumulates — created at session start, dropped at
|
||||
/// session end (browse mode cycles through several per process lifetime).
|
||||
struct StreamState {
|
||||
handle: SessionHandle,
|
||||
/// Decoded frames, re-queued by the wake forwarder (newest-wins, like the pump's
|
||||
/// own queue). The loop drains THIS, never `handle.frames` — the forwarder is that
|
||||
/// channel's one consumer.
|
||||
frames: async_channel::Receiver<DecodedFrame>,
|
||||
connector: Option<Arc<NativeClient>>,
|
||||
capture: Option<Capture>,
|
||||
force_software: Arc<AtomicBool>,
|
||||
@@ -150,9 +158,31 @@ struct StreamState {
|
||||
}
|
||||
|
||||
impl StreamState {
|
||||
fn new(params: SessionParams, force_software: Arc<AtomicBool>) -> StreamState {
|
||||
/// `wake`: pushes a [`FrameWake`] SDL event as each decoded frame lands, via a tiny
|
||||
/// forwarder thread that owns the pump's frame channel. This is what lets the run
|
||||
/// loop BLOCK in `wait_event_timeout` (instead of a 1 ms poll — measured as a full
|
||||
/// core burned at any frame rate) yet still present a frame the instant it arrives:
|
||||
/// input events and frames both wake the same wait. The forwarder exits when the
|
||||
/// pump drops its sender (session end/shutdown).
|
||||
fn new(
|
||||
params: SessionParams,
|
||||
force_software: Arc<AtomicBool>,
|
||||
wake: sdl3::event::EventSender,
|
||||
) -> StreamState {
|
||||
let handle = session::start(params);
|
||||
let (wake_tx, wake_rx) = async_channel::bounded(2);
|
||||
let pump_rx = handle.frames.clone();
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("pf-frame-wake".into())
|
||||
.spawn(move || {
|
||||
while let Ok(f) = pump_rx.recv_blocking() {
|
||||
let _ = wake_tx.force_send(f); // newest wins, like the pump's queue
|
||||
let _ = wake.push_custom_event(FrameWake);
|
||||
}
|
||||
});
|
||||
StreamState {
|
||||
handle: session::start(params),
|
||||
handle,
|
||||
frames: wake_rx,
|
||||
connector: None,
|
||||
capture: None,
|
||||
force_software,
|
||||
@@ -199,6 +229,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||
let sdl = sdl3::init().context("SDL init")?;
|
||||
let video = sdl.video().context("SDL video")?;
|
||||
let events = sdl.event().context("SDL events")?;
|
||||
events
|
||||
.register_custom_event::<FrameWake>()
|
||||
.map_err(|e| anyhow::anyhow!("register FrameWake event: {e}"))?;
|
||||
let mut window = {
|
||||
let mut b = video.window(&opts.window_title, 1280, 720);
|
||||
b.position_centered().resizable().vulkan();
|
||||
@@ -262,7 +296,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
force_software.clone(),
|
||||
presenter.vulkan_decode(),
|
||||
);
|
||||
Some(StreamState::new(params, force_software))
|
||||
Some(StreamState::new(
|
||||
params,
|
||||
force_software,
|
||||
events.event_sender(),
|
||||
))
|
||||
}
|
||||
ModeCtl::Browse(_) => None,
|
||||
};
|
||||
@@ -278,11 +316,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
|
||||
let outcome = 'main: loop {
|
||||
// --- SDL events (input, window, gamepads) ---------------------------------------
|
||||
// Block briefly in SDL's own wait so idle costs nothing; while streaming, frames
|
||||
// arrive on the channel below and 1 ms bounds the added present latency. In
|
||||
// browse-idle the per-iteration FIFO present vsync-throttles the loop anyway.
|
||||
let streaming = stream.as_ref().is_some_and(|s| s.connector.is_some());
|
||||
let timeout = Duration::from_millis(if streaming { 1 } else { 5 });
|
||||
// Block in SDL's own wait: input/window events AND decoded frames (the wake
|
||||
// forwarder's FrameWake) all land in this one queue, so the loop wakes exactly
|
||||
// when there is work — a short-timeout poll here burned a full core (measured;
|
||||
// the timeout only bounds stop-flag/pump-tick latency now). In browse-idle the
|
||||
// per-iteration FIFO present vsync-throttles the loop anyway.
|
||||
let timeout = Duration::from_millis(15);
|
||||
let first = event_pump.wait_event_timeout(timeout);
|
||||
let mut queued: Vec<Event> = Vec::new();
|
||||
if let Some(e) = first {
|
||||
@@ -311,8 +350,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
WindowEvent::FocusLost => {
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if cap.release(false) {
|
||||
mouse.set_relative_mouse_mode(&window, false);
|
||||
mouse.show_cursor(true);
|
||||
apply_capture(&mut window, &mouse, false);
|
||||
tracing::info!("focus lost — input released");
|
||||
}
|
||||
}
|
||||
@@ -323,8 +361,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if cap.should_reengage() {
|
||||
cap.engage();
|
||||
mouse.set_relative_mouse_mode(&window, true);
|
||||
mouse.show_cursor(false);
|
||||
apply_capture(&mut window, &mouse, true);
|
||||
tracing::info!("focus gained — input recaptured");
|
||||
}
|
||||
}
|
||||
@@ -352,12 +389,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if cap.captured() {
|
||||
cap.release(true);
|
||||
mouse.set_relative_mouse_mode(&window, false);
|
||||
mouse.show_cursor(true);
|
||||
apply_capture(&mut window, &mouse, false);
|
||||
} else {
|
||||
cap.engage();
|
||||
mouse.set_relative_mouse_mode(&window, true);
|
||||
mouse.show_cursor(false);
|
||||
apply_capture(&mut window, &mouse, true);
|
||||
}
|
||||
tracing::info!(captured = cap.captured(), "chord: release/engage");
|
||||
}
|
||||
@@ -367,8 +402,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if let Some(st) = &mut stream {
|
||||
tracing::info!("chord: disconnect");
|
||||
st.request_quit();
|
||||
mouse.set_relative_mouse_mode(&window, false);
|
||||
mouse.show_cursor(true);
|
||||
apply_capture(&mut window, &mouse, false);
|
||||
// The pump emits Ended(None); the end path routes per mode.
|
||||
}
|
||||
continue;
|
||||
@@ -410,8 +444,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if !cap.captured() {
|
||||
// The engaging click is suppressed toward the host.
|
||||
cap.engage();
|
||||
mouse.set_relative_mouse_mode(&window, true);
|
||||
mouse.show_cursor(false);
|
||||
apply_capture(&mut window, &mouse, true);
|
||||
} else {
|
||||
cap.on_button_down(mouse_btn);
|
||||
}
|
||||
@@ -427,6 +460,9 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
cap.on_wheel(x, y);
|
||||
}
|
||||
}
|
||||
// The wake forwarder's FrameWake (and any other user event): pure
|
||||
// wake-up — the frame drain below runs this iteration either way.
|
||||
Event::User { .. } => {}
|
||||
// Everything else (gamepad add/remove/button/axis/touchpad/sensor…) is
|
||||
// the pumped gamepad worker's — it ignores what it doesn't know.
|
||||
other => pump.handle_event(other),
|
||||
@@ -445,8 +481,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
while escape_rx.try_recv().is_ok() {
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if cap.release(true) {
|
||||
mouse.set_relative_mouse_mode(&window, false);
|
||||
mouse.show_cursor(true);
|
||||
apply_capture(&mut window, &mouse, false);
|
||||
}
|
||||
}
|
||||
if fullscreen && !opts.fullscreen {
|
||||
@@ -459,8 +494,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if let Some(st) = &mut stream {
|
||||
tracing::info!("controller chord: disconnect");
|
||||
st.request_quit();
|
||||
mouse.set_relative_mouse_mode(&window, false);
|
||||
mouse.show_cursor(true);
|
||||
apply_capture(&mut window, &mouse, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,7 +520,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
) {
|
||||
ActionOutcome::Handled => {}
|
||||
ActionOutcome::Start(params) => {
|
||||
stream = Some(StreamState::new(*params, force_software));
|
||||
stream = Some(StreamState::new(
|
||||
*params,
|
||||
force_software,
|
||||
events.event_sender(),
|
||||
));
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
o.session_phase(SessionPhase::Connecting);
|
||||
}
|
||||
@@ -518,8 +556,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
st.clock_offset_ns = c.clock_offset_ns;
|
||||
let mut cap = Capture::new(c.clone());
|
||||
cap.engage(); // capture engages when the stream starts (ui_stream parity)
|
||||
mouse.set_relative_mouse_mode(&window, true);
|
||||
mouse.show_cursor(false);
|
||||
apply_capture(&mut window, &mouse, true);
|
||||
st.capture = Some(cap);
|
||||
st.connector = Some(c);
|
||||
if let Some(f) = opts.on_connected.as_mut() {
|
||||
@@ -556,8 +593,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if let Some(st) = stream.take() {
|
||||
st.shutdown();
|
||||
}
|
||||
mouse.set_relative_mouse_mode(&window, false);
|
||||
mouse.show_cursor(true);
|
||||
apply_capture(&mut window, &mouse, false);
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
o.session_phase(SessionPhase::Failed(&msg));
|
||||
}
|
||||
@@ -569,8 +605,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if let Some(cap) = &mut st.capture {
|
||||
cap.release(true);
|
||||
}
|
||||
mouse.set_relative_mouse_mode(&window, false);
|
||||
mouse.show_cursor(true);
|
||||
apply_capture(&mut window, &mouse, false);
|
||||
match &mode {
|
||||
ModeCtl::Single(_) => break 'main Some(Outcome::Ended(reason)),
|
||||
ModeCtl::Browse(_) => {
|
||||
@@ -641,7 +676,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
}
|
||||
let mut newest: Option<DecodedFrame> = None;
|
||||
while let Ok(f) = st.handle.frames.try_recv() {
|
||||
while let Ok(f) = st.frames.try_recv() {
|
||||
newest = Some(f);
|
||||
}
|
||||
if let Some(f) = newest {
|
||||
@@ -655,6 +690,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
st.hdr = c.color.is_pq();
|
||||
presenter.present(&window, FrameInput::Cpu(&c), overlay_frame.as_ref())?
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
DecodedImage::Dmabuf(d)
|
||||
if presenter.supports_dmabuf() && !st.dmabuf_demoted =>
|
||||
{
|
||||
@@ -685,6 +721,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
DecodedImage::Dmabuf(_) => {
|
||||
// No import extensions on this device (or already demoted) — the
|
||||
// pump rebuilds the decoder as software; frames flow again soon.
|
||||
@@ -782,6 +819,19 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Apply the capture state to the window: pointer lock (relative mouse + hidden cursor)
|
||||
/// and — on Windows — a keyboard grab, so system chords (Alt+Tab, the Windows key) reach
|
||||
/// the host while captured instead of the local shell. SDL implements the grab there
|
||||
/// with a low-level keyboard hook, the same mechanism the WinUI shell's in-process
|
||||
/// client used its own WH_KEYBOARD_LL hooks for. Not engaged on Linux: the compositor
|
||||
/// shortcut-inhibit story stays the shells' concern (Settings.inhibit_shortcuts).
|
||||
fn apply_capture(window: &mut sdl3::video::Window, mouse: &sdl3::mouse::MouseUtil, on: bool) {
|
||||
mouse.set_relative_mouse_mode(window, on);
|
||||
mouse.show_cursor(!on);
|
||||
#[cfg(windows)]
|
||||
window.set_keyboard_grab(on);
|
||||
}
|
||||
|
||||
/// The presenter's share of the unified stats window — folded into each printed line.
|
||||
#[derive(Default)]
|
||||
struct PresentedWindow {
|
||||
|
||||
@@ -17,12 +17,15 @@
|
||||
//! (expose/resize redraws).
|
||||
|
||||
use crate::csc::{build_fullscreen_pipeline, csc_rows, CscPass};
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::dmabuf::{self, HwFrame};
|
||||
use crate::overlay::{OverlayFrame, SharedDevice};
|
||||
use anyhow::{anyhow, bail, Context as _, Result};
|
||||
use ash::vk;
|
||||
use ash::vk::Handle as _;
|
||||
use pf_client_core::video::{CpuFrame, DmabufFrame, VkVideoFrame};
|
||||
#[cfg(target_os = "linux")]
|
||||
use pf_client_core::video::DmabufFrame;
|
||||
use pf_client_core::video::{CpuFrame, VkVideoFrame};
|
||||
use std::ffi::CString;
|
||||
|
||||
/// One presenter iteration's video input.
|
||||
@@ -30,12 +33,14 @@ 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),
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
@@ -44,6 +49,7 @@ struct HwCtx {
|
||||
/// 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),
|
||||
Vk {
|
||||
frame: VkVideoFrame,
|
||||
@@ -54,6 +60,7 @@ enum Retired {
|
||||
impl Retired {
|
||||
fn destroy(self, device: &ash::Device) {
|
||||
match self {
|
||||
#[cfg(target_os = "linux")]
|
||||
Retired::Dmabuf(f) => f.destroy(device),
|
||||
Retired::Vk { frame, views } => {
|
||||
unsafe {
|
||||
@@ -324,6 +331,7 @@ pub struct Presenter {
|
||||
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>,
|
||||
csc: CscPass,
|
||||
/// FFmpeg Vulkan Video decode handles — `None` when the stack can't do it.
|
||||
@@ -423,15 +431,18 @@ impl Presenter {
|
||||
}
|
||||
|
||||
// The dmabuf import set is optional: enabled when the device offers all four,
|
||||
// else that path is off (`supports_dmabuf() == false`).
|
||||
// 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 {
|
||||
@@ -577,6 +588,7 @@ impl Presenter {
|
||||
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),
|
||||
@@ -593,6 +605,7 @@ impl Presenter {
|
||||
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)));
|
||||
@@ -670,6 +683,7 @@ impl Presenter {
|
||||
swap_d,
|
||||
queue,
|
||||
qfi,
|
||||
#[cfg(target_os = "linux")]
|
||||
hw,
|
||||
csc,
|
||||
video_export,
|
||||
@@ -862,6 +876,7 @@ 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()
|
||||
}
|
||||
@@ -962,6 +977,7 @@ impl Presenter {
|
||||
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()),
|
||||
};
|
||||
@@ -974,11 +990,13 @@ impl Presenter {
|
||||
// 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;
|
||||
let mut vk_frame: Option<(VkVideoFrame, [vk::ImageView; 2])> = 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
|
||||
@@ -1015,6 +1033,7 @@ impl Presenter {
|
||||
if let Some(f) = cpu_frame {
|
||||
self.stage_frame(f)?;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(f) = &hw_frame {
|
||||
if self
|
||||
.video
|
||||
@@ -1063,6 +1082,7 @@ impl Presenter {
|
||||
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);
|
||||
}
|
||||
@@ -1083,6 +1103,7 @@ impl Presenter {
|
||||
// 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);
|
||||
@@ -1328,12 +1349,15 @@ impl Presenter {
|
||||
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).
|
||||
self.retired_hw = hw_frame.take().map(Retired::Dmabuf).or_else(|| {
|
||||
vk_frame
|
||||
.take()
|
||||
.map(|(frame, views)| Retired::Vk { frame, views })
|
||||
});
|
||||
// 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));
|
||||
}
|
||||
|
||||
let swapchains = [self.swapchain];
|
||||
let indices = [index];
|
||||
@@ -1688,6 +1712,7 @@ impl Drop for Presenter {
|
||||
let device = self.device.clone();
|
||||
g.destroy(&device, &self.swap_d);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
self.hw.take();
|
||||
self.csc.destroy(&self.device);
|
||||
self.overlay_pipe.destroy(&self.device);
|
||||
@@ -1720,10 +1745,43 @@ fn pick_device(
|
||||
let forced: Option<usize> = std::env::var("PUNKTFUNK_VK_DEVICE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok());
|
||||
let candidates: Vec<vk::PhysicalDevice> = match forced {
|
||||
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() {
|
||||
@@ -1855,6 +1913,9 @@ struct VkFrameSync {
|
||||
|
||||
/// 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) =
|
||||
@@ -1944,6 +2005,7 @@ fn vkframe_acquire_barrier(
|
||||
/// Acquire a dmabuf plane image from its foreign owner (the VAAPI decoder): queue-family
|
||||
/// transfer FOREIGN → ours, UNDEFINED → SHADER_READ_ONLY (content is preserved across
|
||||
/// the transfer regardless of the UNDEFINED old-layout, per the external-memory rules).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn foreign_acquire_barrier(
|
||||
device: &ash::Device,
|
||||
cmd: vk::CommandBuffer,
|
||||
|
||||
Reference in New Issue
Block a user