From a69a83b545669c0af9e6a50c5294a4d0426666af Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 9 Jul 2026 10:57:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(clients/windows):=20D3D11VA=20hardware=20d?= =?UTF-8?q?ecode=20in=20the=20session=20client=20=E2=80=94=20Vulkan=20chai?= =?UTF-8?q?n=20becomes=20vulkan=20=E2=86=92=20d3d11va=20=E2=86=92=20softwa?= =?UTF-8?q?re?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vendor-agnostic DXVA path for GPUs without Vulkan Video (Intel's Windows driver foremost, which previously landed on CPU decode). Ported from the retired WinUI presenter's decoder with its Intel-safe discipline intact (decode pool stays libavcodec-derived — a hand-built pool broke Intel at the first SubmitDecoderBuffers), on a decode device LUID-matched to the presenter's adapter. Hand-off is a ring of shareable BGRA8 textures (SHARED_NTHANDLE | SHARED_KEYEDMUTEX) filled by the fixed-function ID3D11VideoProcessor (NV12/P010 → BGRA8, colour spaces from the per-frame CICP; PQ is tone-mapped to SDR by the processor — HDR-first boxes take Vulkan Video). BGRA is deliberate: importing a multiplanar NV12 D3D11 texture device-losts on NVIDIA however it is consumed (plane-view sampling and DMA copy both validation-clean, both TDR — bisected), while single-plane RGBA D3D11↔Vulkan interop is the path Chromium/ANGLE exercise on every driver. The presenter imports a slot's NT handle per frame (VK_KHR_external_memory_win32, gated on the spec-required external-format probe) and blits it into the video image — no CSC pass; the DXGI keyed mutex (key 0 both sides, drop-tolerant) is the cross-API lock and visibility barrier. Verified live vs a real host at 5120x1440@240 HEVC on an RTX 4090: 240 fps, e2e 2.7/3.0 ms p50/p95 under the Khronos validation layer — parity with Vulkan Video (2.6 ms); auto still resolves vulkan on NVIDIA. PUNKTFUNK_DECODER=d3d11va forces it; import/present failures demote to software on the existing streak contract. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + crates/pf-client-core/Cargo.toml | 13 + crates/pf-client-core/src/lib.rs | 2 + crates/pf-client-core/src/session.rs | 4 + crates/pf-client-core/src/video.rs | 75 ++- crates/pf-client-core/src/video_d3d11.rs | 698 +++++++++++++++++++++++ crates/pf-presenter/src/d3d11.rs | 182 ++++++ crates/pf-presenter/src/lib.rs | 7 +- crates/pf-presenter/src/run.rs | 41 ++ crates/pf-presenter/src/vk.rs | 224 +++++++- 10 files changed, 1231 insertions(+), 16 deletions(-) create mode 100644 crates/pf-client-core/src/video_d3d11.rs create mode 100644 crates/pf-presenter/src/d3d11.rs diff --git a/Cargo.lock b/Cargo.lock index 5fee7430..87a8d017 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2784,6 +2784,7 @@ dependencies = [ "tracing", "ureq", "wasapi", + "windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", ] [[package]] diff --git a/crates/pf-client-core/Cargo.toml b/crates/pf-client-core/Cargo.toml index acd974b5..f452ad53 100644 --- a/crates/pf-client-core/Cargo.toml +++ b/crates/pf-client-core/Cargo.toml @@ -44,3 +44,16 @@ sdl3 = { version = "0.18", features = ["hidapi"] } [target.'cfg(windows)'.dependencies] wasapi = "0.23" sdl3 = { version = "0.18", features = ["hidapi", "build-from-source"] } +# D3D11VA decode (video_d3d11.rs): device/adapter selection, DXVA probes, and the shared +# NT-handle hand-off ring. Same pinned rev as clients/windows so the workspace builds ONE +# windows-rs. +windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f", features = [ + "Win32_Foundation", + "Win32_Graphics_Direct3D", + "Win32_Graphics_Direct3D11", + "Win32_Graphics_Dxgi", + "Win32_Graphics_Dxgi_Common", + # IDXGIResource1::CreateSharedHandle takes an optional SECURITY_ATTRIBUTES — the + # method itself is feature-gated behind this. + "Win32_Security", +] } diff --git a/crates/pf-client-core/src/lib.rs b/crates/pf-client-core/src/lib.rs index 7174d442..292eb16c 100644 --- a/crates/pf-client-core/src/lib.rs +++ b/crates/pf-client-core/src/lib.rs @@ -33,5 +33,7 @@ pub mod session; pub mod trust; #[cfg(any(target_os = "linux", windows))] pub mod video; +#[cfg(windows)] +pub mod video_d3d11; pub mod wol; diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index d908c54f..367267b3 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -333,6 +333,8 @@ fn pump( #[cfg(target_os = "linux")] DecodedImage::Dmabuf(_) => "vaapi", DecodedImage::VkFrame(_) => "vulkan", + #[cfg(windows)] + DecodedImage::D3d11(_) => "d3d11va", }; if total_frames == 1 { let (w, h, path) = match &image { @@ -340,6 +342,8 @@ fn pump( #[cfg(target_os = "linux")] DecodedImage::Dmabuf(d) => (d.width, d.height, "vaapi-dmabuf"), DecodedImage::VkFrame(v) => (v.width, v.height, "vulkan-video"), + #[cfg(windows)] + DecodedImage::D3d11(d) => (d.width, d.height, "d3d11va"), }; tracing::info!(width = w, height = h, path, "first frame decoded"); } diff --git a/crates/pf-client-core/src/video.rs b/crates/pf-client-core/src/video.rs index fc658b6a..28b6d2c3 100644 --- a/crates/pf-client-core/src/video.rs +++ b/crates/pf-client-core/src/video.rs @@ -19,9 +19,10 @@ //! Both run `AV_CODEC_FLAG_LOW_DELAY`; the host encodes zero-reorder streams (no //! B-frames, in-band parameter sets on every IDR), so decode is strictly one-in/one-out. //! -//! On Windows the VAAPI/dmabuf backend does not exist (DRM-PRIME is a Linux concept), so -//! the chain is Vulkan → software — Intel (no Vulkan Video in its Windows driver) lands -//! on software. Everything dmabuf-shaped is `cfg(target_os = "linux")`-gated inline. +//! On Windows the VAAPI/dmabuf backend does not exist (DRM-PRIME is a Linux concept); the +//! chain there is Vulkan → **D3D11VA** (`crate::video_d3d11` — the vendor-agnostic DXVA +//! path, which is how Intel's Windows driver gets hardware decode without Vulkan Video) +//! → software. Everything dmabuf-shaped is `cfg(target_os = "linux")`-gated inline. // bindgen's C-enum repr is target-dependent (u32 on Linux/clang, i32 on MSVC), so the // pf-ffvk Vulkan flag/enum casts below are required on one platform and no-ops on the @@ -50,12 +51,21 @@ pub struct DecodedFrame { pub image: DecodedImage, } +/// Re-exported so consumers (the presenter) name every frame type through `video::`. +#[cfg(windows)] +pub use crate::video_d3d11::D3d11Frame; + pub enum DecodedImage { Cpu(CpuFrame), #[cfg(target_os = "linux")] Dmabuf(DmabufFrame), /// FFmpeg Vulkan Video output: a VkImage already on the PRESENTER's device. VkFrame(VkVideoFrame), + /// D3D11VA output copied into a shareable NT-handle texture the presenter imports + /// (`VK_KHR_external_memory_win32`) — the DXVA path for GPUs without Vulkan Video + /// (Intel's Windows driver foremost). See `crate::video_d3d11`. + #[cfg(windows)] + D3d11(crate::video_d3d11::D3d11Frame), } /// One Vulkan-decoded frame. The image lives on the presenter's own VkDevice (the @@ -113,7 +123,7 @@ impl ColorDesc { /// /// # Safety /// `frame` must point to a valid `AVFrame` (alive for the duration of the call). - unsafe fn from_raw(frame: *const ffmpeg::ffi::AVFrame) -> ColorDesc { + pub(crate) unsafe fn from_raw(frame: *const ffmpeg::ffi::AVFrame) -> ColorDesc { // SAFETY: caller guarantees a live AVFrame; these are plain enum field reads. unsafe { ColorDesc { @@ -185,6 +195,8 @@ enum Backend { Vulkan(VulkanDecoder), #[cfg(target_os = "linux")] Vaapi(VaapiDecoder), + #[cfg(windows)] + D3d11va(crate::video_d3d11::D3d11vaDecoder), Software(SoftwareDecoder), } @@ -254,8 +266,8 @@ fn quiet_ffmpeg_log() { impl Decoder { /// `codec_id` is the codec the host resolved in the Welcome (never assume HEVC). - /// `pref` is the Settings "Video decoder" value (`auto`/`vulkan`/`vaapi`/`software`; - /// `hardware` — the WinUI shell's stored value from its D3D11VA era — reads as auto). + /// `pref` is the Settings "Video decoder" value (`auto`/`vulkan`/`vaapi`/`d3d11va`/ + /// `software`; `hardware` — the WinUI shell's stored value — reads as auto). /// `vk` is the presenter's shared Vulkan device when its stack can run FFmpeg's /// Vulkan Video decoder — decode lands as VkImages the presenter samples directly. /// Precedence: the `PUNKTFUNK_DECODER` env override wins (support/debug escape @@ -281,7 +293,10 @@ impl Decoder { }) }; if matches!(choice.as_str(), "auto" | "" | "vulkan" | "hardware") { - match vk { + // `video_decode` gates the Vulkan Video attempt: the presenter now exports its + // handle bundle even when the device has no decode queue (Windows D3D11 interop + // rides the same struct), so presence alone no longer implies a usable decoder. + match vk.filter(|v| v.video_decode) { Some(vk) => match VulkanDecoder::new(codec_id, vk) { Ok(v) => { tracing::info!( @@ -326,6 +341,37 @@ impl Decoder { } } } + // Windows: D3D11VA is the vendor-agnostic DXVA fallback when Vulkan Video isn't + // available (Intel's Windows driver foremost) — gated on the presenter having the + // win32 external-memory import path, else its frames could never reach the screen. + #[cfg(windows)] + if choice != "software" && choice != "vulkan" { + match vk.filter(|v| v.d3d11_import) { + Some(v) => { + match crate::video_d3d11::D3d11vaDecoder::new(codec_id, v.adapter_luid) { + Ok(d) => { + tracing::info!( + ?codec_id, + "D3D11VA hardware decode active (shared-texture hand-off)" + ); + return done(Backend::D3d11va(d)); + } + Err(e) => { + if choice == "d3d11va" { + return Err(e.context("PUNKTFUNK_DECODER=d3d11va but it failed")); + } + tracing::info!(reason = %format!("{e:#}"), + "D3D11VA unavailable — software decode"); + } + } + } + None if choice == "d3d11va" => bail!( + "PUNKTFUNK_DECODER=d3d11va but the presenter's device lacks the win32 \ + external-memory import extensions — see the presenter log" + ), + None => {} + } + } if choice == "software" { // Say WHY hardware wasn't even attempted — a stored "software" preference // (or the env override) silently skipping vulkan/vaapi has burned real @@ -380,6 +426,8 @@ impl Decoder { Backend::Vulkan(v) => v.decode(au).map(|f| f.map(DecodedImage::VkFrame)), #[cfg(target_os = "linux")] Backend::Vaapi(v) => v.decode(au).map(|f| f.map(DecodedImage::Dmabuf)), + #[cfg(windows)] + Backend::D3d11va(d) => d.decode(au).map(|f| f.map(DecodedImage::D3d11)), Backend::Software(s) => return Ok(s.decode(au)?.map(DecodedImage::Cpu)), }; match result { @@ -390,6 +438,8 @@ impl Decoder { Err(e) => { let which = match self.backend { Backend::Vulkan(_) => "Vulkan Video", + #[cfg(windows)] + Backend::D3d11va(_) => "D3D11VA", _ => "VAAPI", }; self.vaapi_fails += 1; @@ -778,6 +828,17 @@ pub struct VulkanDecodeDevice { pub f_sampler_ycbcr: bool, pub f_timeline_semaphore: bool, pub f_synchronization2: bool, + /// Vulkan Video decode is actually usable on this device (decode queue + extensions + + /// features). The bundle now exists even without it — Windows D3D11 interop rides the + /// same struct — so consumers gate the FFmpeg-Vulkan decoder on THIS, not on `Some`. + pub video_decode: bool, + /// The presenter enabled `VK_KHR_external_memory_win32` + `VK_KHR_win32_keyed_mutex`: + /// D3D11 shared-texture frames can reach the screen. Always `false` off Windows. + pub d3d11_import: bool, + /// `VkPhysicalDeviceIDProperties::deviceLUID` when the driver reports one — the D3D11VA + /// backend creates its decode device on the SAME adapter so shared textures never cross + /// GPUs. `None` when not reported (or off Windows, where it's unused). + pub adapter_luid: Option<[u8; 8]>, } /// `fourcc(a,b,c,d)` — the DRM FourCC packing (little-endian, `a | b<<8 | c<<16 | d<<24`). diff --git a/crates/pf-client-core/src/video_d3d11.rs b/crates/pf-client-core/src/video_d3d11.rs new file mode 100644 index 00000000..cb4f51d6 --- /dev/null +++ b/crates/pf-client-core/src/video_d3d11.rs @@ -0,0 +1,698 @@ +//! D3D11VA hardware decode (Windows) for the Vulkan presenter — the vendor-agnostic DXVA +//! path that covers what Vulkan Video can't (Intel's Windows driver foremost, which has no +//! video-decode queue and previously landed on CPU decode). +//! +//! Ported from the retired in-process WinUI presenter's decoder (`clients/windows/src/video.rs`) +//! with one structural change: that presenter sampled D3D11 textures directly, while ours draws +//! with Vulkan. Bridging rules, all learned the hard way there: +//! +//! * The **decode pool stays libavcodec-derived** (`get_format` sets no frames context): a +//! hand-built pool validated on NVIDIA was rejected by Intel at the first +//! `SubmitDecoderBuffers` — and Intel is the GPU this backend exists for. That also means the +//! decode surfaces carry no share flags, so they can't be imported into Vulkan directly. +//! * Each decoded slice goes through the fixed-function **`ID3D11VideoProcessor`** +//! (`VideoProcessorBlt`, NV12/P010 → BGRA8 — the conversion every Windows video player +//! exercises on every vendor) into a small ring of **shareable RGBA textures** created with +//! `SHARED_NTHANDLE | SHARED_KEYEDMUTEX`. Single-plane RGBA is deliberate: the presenter's +//! Vulkan import of a *multiplanar* NV12 D3D11 texture device-losts on NVIDIA no matter how +//! it's consumed (plane-view sampling, DMA copy — all validation-clean, all TDR; bisected +//! 2026-07-09), while RGBA D3D11↔Vulkan interop is the path Chromium/ANGLE ship everywhere. +//! The presenter imports a ring slot's NT handle per frame (`pf-presenter/src/d3d11.rs`, +//! `VK_KHR_external_memory_win32`) and blits it straight into its video image — the frames +//! arrive as ready sRGB, no CSC pass. +//! * Cross-API exclusion + write→read visibility ride the slot's keyed mutex +//! (`VK_KHR_win32_keyed_mutex`); both sides take and release it with **key 0**: a frame the +//! presenter drops (arrival-paced, newest wins) is simply never acquired, which a +//! key-ping-pong protocol would deadlock on. +//! * An HDR (PQ/BT.2020) stream is tone-mapped to SDR by the video processor (input colour +//! space `G2084_P2020`, output sRGB): correct picture, no HDR presentation on this backend — +//! its targets (Intel iGPU laptops) are SDR panels; HDR-first boxes take Vulkan Video. +//! +//! The decode device is created on the **presenter's adapter** (matched by the Vulkan device's +//! LUID) so the shared textures never cross GPUs on a multi-adapter box. + +use crate::video::ColorDesc; +use anyhow::{anyhow, bail, Context as _, Result}; +use ffmpeg_next as ffmpeg; +use std::ffi::c_void; +use std::ptr; +use windows::core::{Interface, GUID}; +use windows::Win32::Foundation::HANDLE; +use windows::Win32::Graphics::Direct3D::{D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_11_1}; +use windows::Win32::Graphics::Direct3D11::{ + D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Multithread, ID3D11Texture2D, + ID3D11VideoContext1, ID3D11VideoDevice, ID3D11VideoProcessor, + ID3D11VideoProcessorEnumerator, ID3D11VideoProcessorOutputView, D3D11_BIND_RENDER_TARGET, + D3D11_BIND_SHADER_RESOURCE, D3D11_CREATE_DEVICE_BGRA_SUPPORT, + D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX, + D3D11_RESOURCE_MISC_SHARED_NTHANDLE, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, + D3D11_USAGE_DEFAULT, D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE, + D3D11_VIDEO_PROCESSOR_CONTENT_DESC, D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC, + D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC, D3D11_VIDEO_PROCESSOR_STREAM, + D3D11_VIDEO_USAGE_PLAYBACK_NORMAL, D3D11_VPIV_DIMENSION_TEXTURE2D, + D3D11_VPOV_DIMENSION_TEXTURE2D, +}; +use windows::Win32::Graphics::Dxgi::Common::{ + DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709, + DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020, + DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, + DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_RATIONAL, DXGI_SAMPLE_DESC, +}; +use windows::Win32::Graphics::Dxgi::{ + CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, IDXGIKeyedMutex, IDXGIResource1, + DXGI_ADAPTER_FLAG_SOFTWARE, DXGI_SHARED_RESOURCE_READ, DXGI_SHARED_RESOURCE_WRITE, +}; + +/// Ring of shareable hand-off textures. Bounds how many decoded-but-unpresented frames can +/// exist without a slot being rewritten under an in-flight older frame: the pump's decoded +/// channel holds 2 and the presenter drains to newest with one frame in flight, so 3 are ever +/// outstanding — 6 leaves margin without meaningful VRAM cost. +const RING_SLOTS: usize = 6; + +/// Keyed-mutex acquire budget (ms) on the DECODE side. The presenter holds a slot only for one +/// submit's GPU lifetime; multiple seconds means the render thread died — surface an error +/// (which demotes to software) instead of wedging the decode loop. +const ACQUIRE_TIMEOUT_MS: u32 = 2000; + +/// Probe pool size — mirrors what libavcodec sizes for a worst-case DPB (legacy value). +const DECODE_POOL_SIZE: i32 = 12; + +/// `D3D11_BIND_DECODER` — the decode pool's ONLY bind flag (see `get_format_d3d11`). +const BIND_DECODER: u32 = 0x200; + +// DXVA decode-profile GUIDs (`dxva.h`), defined locally so no extra windows-rs feature or +// metadata surface is pulled in for four constants. +const PROFILE_H264_VLD_NOFGT: GUID = GUID::from_u128(0x1b81be68_a0c7_11d3_b984_00c04f2e73c5); +const PROFILE_HEVC_VLD_MAIN: GUID = GUID::from_u128(0x5b11d51b_2f4c_4452_bcc3_09f2a1160cc0); +const PROFILE_HEVC_VLD_MAIN10: GUID = GUID::from_u128(0x107af0e0_ef1a_4d19_aba8_67a163073d13); +const PROFILE_AV1_VLD_PROFILE0: GUID = GUID::from_u128(0xb8be4ccb_cf53_46ba_8d59_d6b8a6da5d2a); + +/// One decoded frame, parked in a ring slot the presenter imports by NT handle. Plain POD — +/// the ring (and its handles) belong to the decoder and outlive every in-flight frame; the +/// presenter must NOT close the handle. Cross-API exclusion + visibility ride the slot's +/// keyed mutex (key 0 on both sides), not this struct. +pub struct D3d11Frame { + pub width: u32, + pub height: u32, + /// What the ring slot actually CONTAINS after the video processor's conversion: sRGB + /// BT.709 full-range RGB — regardless of the stream's own CICP (a PQ stream was + /// tone-mapped). The presenter keys SDR/HDR handling off this, so it always reads SDR. + pub color: ColorDesc, + /// The ring slot's NT shared handle (`IDXGIResource1::CreateSharedHandle`), stable for the + /// ring's lifetime. Raw `isize` so the frame crosses the pump→presenter channel. + pub handle: isize, + /// Ring generation — bumped when the ring is rebuilt (stream size change), so a + /// presenter-side import cache could never alias a stale handle. Informational today + /// (the presenter imports per frame). + pub generation: u32, +} + +// --- FFmpeg hwcontext_d3d11va ABI (repr(C) mirrors, same as the legacy decoder) -------------- + +/// `hwcontext_d3d11va.h` — `AVHWDeviceContext::hwctx` for D3D11VA. FFmpeg installs the +/// `ID3D11Multithread` default lock + multithread protection during init, which is what lets +/// the presenter-side device share textures with the decode thread safely. +#[repr(C)] +struct AVD3D11VADeviceContext { + device: *mut c_void, // ID3D11Device* + device_context: *mut c_void, // ID3D11DeviceContext* + video_device: *mut c_void, // ID3D11VideoDevice* + video_context: *mut c_void, // ID3D11VideoContext* + lock: *mut c_void, // void (*)(void*) + unlock: *mut c_void, // void (*)(void*) + lock_ctx: *mut c_void, +} + +/// `hwcontext_d3d11va.h` — `AVHWFramesContext::hwctx`. A user-built frames context gets NO +/// default bind flags (BindFlags 0 → `CreateTexture2D` E_INVALIDARG); only the probe below +/// builds one, and it sets `BIND_DECODER` exactly like libavcodec's own path. +#[repr(C)] +struct AVD3D11VAFramesContext { + texture: *mut c_void, // ID3D11Texture2D* (null → FFmpeg allocates the pool) + bind_flags: u32, // UINT BindFlags + misc_flags: u32, // UINT MiscFlags + texture_infos: *mut c_void, // AVD3D11FrameDescriptor* (FFmpeg-managed) +} + +fn averr(what: &str, code: i32) -> anyhow::Error { + anyhow!("{what}: {}", ffmpeg::Error::from(code)) +} + +/// libavcodec's `get_format` callback: pick the D3D11 hw surface format and nothing else. +/// Deliberately does NOT build a frames context — with `hw_device_ctx` set and `hw_frames_ctx` +/// left null, libavcodec derives the decode pool itself (`ff_decode_get_hw_frames_ctx`), +/// applying every vendor quirk: DXVA surface alignment (128 for HEVC/AV1), DPB-based pool +/// sizing, and the decoder-only `D3D11_BIND_DECODER` flags. A hand-built context validated on +/// NVIDIA was rejected by Intel at the first `SubmitDecoderBuffers` (E_INVALIDARG) — the +/// vendor-proof path is the one the ffmpeg CLI/mpv ship. +unsafe extern "C" fn get_format_d3d11( + avctx: *mut ffmpeg::ffi::AVCodecContext, + mut list: *const ffmpeg::ffi::AVPixelFormat, +) -> ffmpeg::ffi::AVPixelFormat { + use ffmpeg::ffi::*; + unsafe { + if (*avctx).hw_device_ctx.is_null() { + return AVPixelFormat::AV_PIX_FMT_NONE; + } + while *list != AVPixelFormat::AV_PIX_FMT_NONE { + if *list == AVPixelFormat::AV_PIX_FMT_D3D11 { + return AVPixelFormat::AV_PIX_FMT_D3D11; + } + list = list.add(1); + } + AVPixelFormat::AV_PIX_FMT_NONE + } +} + +/// Does the adapter expose a DXVA decode profile for `codec_id`? Checked before building the +/// FFmpeg hwdevice because hwaccel selection (`get_format`) only runs on the FIRST access +/// unit — an unsupported profile would otherwise burn the opening IDR and recover through the +/// mid-stream demotion path instead of committing to software up front. +fn decode_profile_supported(device: &ID3D11Device, codec_id: ffmpeg::codec::Id) -> Result<()> { + let video: ID3D11VideoDevice = device + .cast() + .context("device lacks ID3D11VideoDevice (created without VIDEO_SUPPORT)")?; + let profiles: Vec = unsafe { + let n = video.GetVideoDecoderProfileCount(); + (0..n) + .filter_map(|i| video.GetVideoDecoderProfile(i).ok()) + .collect() + }; + let (wanted, format, name): (GUID, DXGI_FORMAT, &str) = match codec_id { + ffmpeg::codec::Id::H264 => (PROFILE_H264_VLD_NOFGT, DXGI_FORMAT_NV12, "H.264 VLD NoFGT"), + ffmpeg::codec::Id::HEVC => (PROFILE_HEVC_VLD_MAIN, DXGI_FORMAT_NV12, "HEVC Main"), + ffmpeg::codec::Id::AV1 => (PROFILE_AV1_VLD_PROFILE0, DXGI_FORMAT_NV12, "AV1 Profile 0"), + other => bail!("no DXVA profile known for {other:?}"), + }; + let ok = profiles.contains(&wanted) + && unsafe { video.CheckVideoDecoderFormat(&wanted, format) } + .map(|b| b.as_bool()) + .unwrap_or(false); + if !ok { + bail!("adapter exposes no {name} decode profile"); + } + // 10-bit (a mid-session HDR upgrade needs Main10): informational — if it's missing, the + // decode error → software demotion + keyframe re-request path covers the switch. + if codec_id == ffmpeg::codec::Id::HEVC { + let main10 = profiles.contains(&PROFILE_HEVC_VLD_MAIN10) + && unsafe { video.CheckVideoDecoderFormat(&PROFILE_HEVC_VLD_MAIN10, DXGI_FORMAT_P010) } + .map(|b| b.as_bool()) + .unwrap_or(false); + tracing::info!(main10, "HEVC Main10 (10-bit/HDR) decode profile"); + } + Ok(()) +} + +/// Predict whether D3D11VA decode will work by doing EXACTLY what the decoder's `get_format` +/// leads to — allocate an `AVHWFramesContext` (decoder-only pool) and initialize it, which +/// creates the real NV12 decode surface array. On a GPU/driver that can't create the pool this +/// fails here, up front, so the session commits to software from the first frame (a clean, +/// gap-free stream) instead of dying mid-stream on the opening IDR. +unsafe fn d3d11va_decode_supported(hw_device: *mut ffmpeg::ffi::AVBufferRef) -> bool { + use ffmpeg::ffi::*; + unsafe { + let frames_ref = av_hwframe_ctx_alloc(hw_device); + if frames_ref.is_null() { + return false; + } + let frames = (*frames_ref).data as *mut AVHWFramesContext; + (*frames).format = AVPixelFormat::AV_PIX_FMT_D3D11; + (*frames).sw_format = AVPixelFormat::AV_PIX_FMT_NV12; + (*frames).width = 1920; + (*frames).height = 1152; // 128-aligned 1080p surface (the HEVC DXVA alignment) + (*frames).initial_pool_size = DECODE_POOL_SIZE; + let fhw = (*frames).hwctx as *mut AVD3D11VAFramesContext; + (*fhw).bind_flags = BIND_DECODER; + let r = av_hwframe_ctx_init(frames_ref); + let mut fr = frames_ref; + av_buffer_unref(&mut fr); + r >= 0 + } +} + +/// Create the decode device on the presenter's adapter. `luid` is the Vulkan device's +/// `VkPhysicalDeviceIDProperties::deviceLUID` (little-endian LowPart‖HighPart) — matching it +/// keeps the shared textures on one GPU. `None`/no match falls back to the first hardware +/// adapter (single-GPU boxes; a WARP-only box fails out to software decode). +fn create_device(luid: Option<[u8; 8]>) -> Result<(ID3D11Device, ID3D11DeviceContext)> { + let factory: IDXGIFactory1 = unsafe { CreateDXGIFactory1() }.context("CreateDXGIFactory1")?; + let mut chosen: Option = None; + let mut fallback: Option = None; + for i in 0.. { + let Ok(adapter) = (unsafe { factory.EnumAdapters1(i) }) else { + break; + }; + let Ok(desc) = (unsafe { adapter.GetDesc1() }) else { + continue; + }; + if desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32 != 0 { + continue; // WARP can't hardware-decode; software decode covers that box anyway + } + if fallback.is_none() { + fallback = Some(adapter.clone()); + } + if let Some(want) = luid { + let mut have = [0u8; 8]; + have[..4].copy_from_slice(&desc.AdapterLuid.LowPart.to_le_bytes()); + have[4..].copy_from_slice(&desc.AdapterLuid.HighPart.to_le_bytes()); + if have == want { + chosen = Some(adapter); + break; + } + } + } + if chosen.is_none() && luid.is_some() && fallback.is_some() { + tracing::warn!("no DXGI adapter matches the Vulkan device LUID — using the first hardware adapter"); + } + let adapter = chosen + .or(fallback) + .ok_or_else(|| anyhow!("no hardware DXGI adapter"))?; + let mut device = None; + let mut context = None; + unsafe { + D3D11CreateDevice( + &adapter, + windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN, + None, + D3D11_CREATE_DEVICE_VIDEO_SUPPORT | D3D11_CREATE_DEVICE_BGRA_SUPPORT, + Some(&[D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0]), + D3D11_SDK_VERSION, + Some(&mut device), + None, + Some(&mut context), + ) + } + .context("D3D11CreateDevice")?; + let device = device.ok_or_else(|| anyhow!("D3D11CreateDevice returned no device"))?; + let context = context.ok_or_else(|| anyhow!("D3D11CreateDevice returned no context"))?; + // The decode (FFmpeg video context) and our copy (immediate context) run on the decode + // thread, but FFmpeg's own workers touch the device too — same protection the legacy + // shared device enabled (FFmpeg would install it during hwdevice init anyway; explicit + // keeps the invariant obvious). + if let Ok(mt) = device.cast::() { + // Returns the PREVIOUS protection state — nothing to act on. + let _ = unsafe { mt.SetMultithreadProtected(true) }; + } + Ok((device, context)) +} + +/// One shareable ring slot: the NV12/P010 texture, its keyed mutex, and the NT handle the +/// presenter imports. Handle closed on drop (the presenter never owns it). +struct Slot { + /// The shared texture itself — everything below views into it; kept for its lifetime. + _tex: ID3D11Texture2D, + mutex: IDXGIKeyedMutex, + handle: HANDLE, + /// The video processor's render target over the texture — `VideoProcessorBlt`'s target. + out_view: ID3D11VideoProcessorOutputView, +} + +impl Drop for Slot { + fn drop(&mut self) { + unsafe { + let _ = windows::Win32::Foundation::CloseHandle(self.handle); + } + } +} + +/// The hand-off ring + the video processor that fills it (both sized to the stream, so a +/// mid-stream `Reconfigure` rebuilds the whole bundle). See the module docs. +struct SharedRing { + slots: Vec, + vp: ID3D11VideoProcessor, + enumerator: ID3D11VideoProcessorEnumerator, + width: u32, + height: u32, + next: usize, + generation: u32, +} + +impl SharedRing { + fn build( + device: &ID3D11Device, + video_device: &ID3D11VideoDevice, + width: u32, + height: u32, + generation: u32, + ) -> Result { + // The video processor: NV12/P010 in, BGRA8 out, 1:1 (no scaling — the Vulkan side + // scales at composite time like every other path). Frame rates are advisory. + let content = D3D11_VIDEO_PROCESSOR_CONTENT_DESC { + InputFrameFormat: D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE, + InputFrameRate: DXGI_RATIONAL { + Numerator: 60, + Denominator: 1, + }, + InputWidth: width, + InputHeight: height, + OutputFrameRate: DXGI_RATIONAL { + Numerator: 60, + Denominator: 1, + }, + OutputWidth: width, + OutputHeight: height, + Usage: D3D11_VIDEO_USAGE_PLAYBACK_NORMAL, + }; + let enumerator = unsafe { video_device.CreateVideoProcessorEnumerator(&content) } + .context("CreateVideoProcessorEnumerator")?; + let vp = unsafe { video_device.CreateVideoProcessor(&enumerator, 0) } + .context("CreateVideoProcessor")?; + + let desc = D3D11_TEXTURE2D_DESC { + Width: width, + Height: height, + MipLevels: 1, + ArraySize: 1, + // Single-plane BGRA8: the ONLY hand-off format whose Vulkan import is a + // universally exercised driver path (see the module docs — NV12 import TDRs + // on NVIDIA despite being advertised). + Format: DXGI_FORMAT_B8G8R8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + Usage: D3D11_USAGE_DEFAULT, + // RENDER_TARGET: the video processor's output view renders into it. + BindFlags: (D3D11_BIND_SHADER_RESOURCE.0 | D3D11_BIND_RENDER_TARGET.0) as u32, + CPUAccessFlags: 0, + MiscFlags: (D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0 + | D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX.0) as u32, + }; + let mut slots = Vec::with_capacity(RING_SLOTS); + for _ in 0..RING_SLOTS { + let mut tex = None; + unsafe { device.CreateTexture2D(&desc, None, Some(&mut tex)) } + .context("create shared hand-off texture")?; + let tex: ID3D11Texture2D = tex.expect("CreateTexture2D succeeded"); + let mutex: IDXGIKeyedMutex = tex.cast().context("shared texture lacks IDXGIKeyedMutex")?; + let resource: IDXGIResource1 = tex.cast().context("shared texture lacks IDXGIResource1")?; + let handle = unsafe { + resource.CreateSharedHandle( + None, + (DXGI_SHARED_RESOURCE_READ | DXGI_SHARED_RESOURCE_WRITE).0, + None, + ) + } + .context("CreateSharedHandle")?; + let mut ov_desc = D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC::default(); + ov_desc.ViewDimension = D3D11_VPOV_DIMENSION_TEXTURE2D; + ov_desc.Anonymous.Texture2D.MipSlice = 0; + let mut out_view = None; + unsafe { + video_device.CreateVideoProcessorOutputView( + &tex, + &enumerator, + &ov_desc, + Some(&mut out_view), + ) + } + .context("CreateVideoProcessorOutputView")?; + let out_view = out_view.expect("output view created"); + slots.push(Slot { + _tex: tex, + mutex, + handle, + out_view, + }); + } + tracing::info!(width, height, slots = RING_SLOTS, generation, + "D3D11 shared hand-off ring built (VideoProcessor → BGRA8)"); + Ok(SharedRing { + slots, + vp, + enumerator, + width, + height, + next: 0, + generation, + }) + } +} + +pub(crate) struct D3d11vaDecoder { + ctx: *mut ffmpeg::ffi::AVCodecContext, + hw_device: *mut ffmpeg::ffi::AVBufferRef, + packet: *mut ffmpeg::ffi::AVPacket, + frame: *mut ffmpeg::ffi::AVFrame, + device: ID3D11Device, + context: ID3D11DeviceContext, + /// Creates the per-ring video processor + views. + video_device: ID3D11VideoDevice, + /// Runs the per-frame `VideoProcessorBlt`; the `1` interface for the DXGI colour-space + /// setters (Win10 1703+, universally present — init fails to software without it). + video_context1: ID3D11VideoContext1, + ring: Option, +} + +// Single-owner pointers + COM interfaces, only touched from the session pump thread (the +// decode loop); the presenter reaches the shared textures exclusively via their NT handles. +unsafe impl Send for D3d11vaDecoder {} + +impl D3d11vaDecoder { + pub(crate) fn new(codec_id: ffmpeg::codec::Id, luid: Option<[u8; 8]>) -> Result { + use ffmpeg::ffi; + let (device, context) = create_device(luid)?; + // The adapter must expose the codec's DXVA profile — checked here, not at the first AU. + decode_profile_supported(&device, codec_id)?; + // The hand-off converter's interfaces, up front (their absence must route to software + // decode NOW, not burn the opening IDR). + let video_device: ID3D11VideoDevice = device + .cast() + .context("device lacks ID3D11VideoDevice (created without VIDEO_SUPPORT)")?; + let video_context1: ID3D11VideoContext1 = context + .cast() + .context("context lacks ID3D11VideoContext1 (pre-1703 Windows?)")?; + unsafe { + let hw_device = + ffi::av_hwdevice_ctx_alloc(ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA); + if hw_device.is_null() { + bail!("av_hwdevice_ctx_alloc(D3D11VA) failed"); + } + let devctx = (*hw_device).data as *mut ffi::AVHWDeviceContext; + let d3dctx = (*devctx).hwctx as *mut AVD3D11VADeviceContext; + // Hand FFmpeg an owned ref to the device + immediate context (it Releases them when + // the hwdevice ctx is freed). `into_raw()` transfers a +1 ref without releasing. + (*d3dctx).device = device.clone().into_raw(); + (*d3dctx).device_context = context.clone().into_raw(); + // lock left null → FFmpeg installs the ID3D11Multithread default lock in init. + let r = ffi::av_hwdevice_ctx_init(hw_device); + if r < 0 { + let mut hw = hw_device; + ffi::av_buffer_unref(&mut hw); + bail!("av_hwdevice_ctx_init: {}", ffmpeg::Error::from(r)); + } + // Up-front viability probe (see `d3d11va_decode_supported`). + if !d3d11va_decode_supported(hw_device) { + let mut hw = hw_device; + ffi::av_buffer_unref(&mut hw); + bail!("GPU can't create the D3D11VA decode surface pool"); + } + let codec = ffi::avcodec_find_decoder(codec_id.into()); + if codec.is_null() { + let mut hw = hw_device; + ffi::av_buffer_unref(&mut hw); + bail!("no {codec_id:?} decoder"); + } + let ctx = ffi::avcodec_alloc_context3(codec); + (*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device); + (*ctx).get_format = Some(get_format_d3d11); + (*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32; + (*ctx).thread_count = 1; // hwaccel: threads only add latency + // On top of the DPB-based pool libavcodec sizes: margin for the frames briefly held + // between decode and the ring copy (the copy runs immediately, so this is small). + (*ctx).extra_hw_frames = 4; + let r = ffi::avcodec_open2(ctx, codec, ptr::null_mut()); + if r < 0 { + let mut ctx = ctx; + ffi::avcodec_free_context(&mut ctx); + let mut hw = hw_device; + ffi::av_buffer_unref(&mut hw); + bail!("avcodec_open2 (D3D11VA): {}", ffmpeg::Error::from(r)); + } + Ok(D3d11vaDecoder { + ctx, + hw_device, + packet: ffi::av_packet_alloc(), + frame: ffi::av_frame_alloc(), + device, + context, + video_device, + video_context1, + ring: None, + }) + } + } + + pub(crate) fn decode(&mut self, au: &[u8]) -> Result> { + use ffmpeg::ffi; + unsafe { + let r = ffi::av_new_packet(self.packet, au.len() as i32); + if r < 0 { + return Err(averr("av_new_packet", r)); + } + ptr::copy_nonoverlapping(au.as_ptr(), (*self.packet).data, au.len()); + let r = ffi::avcodec_send_packet(self.ctx, self.packet); + ffi::av_packet_unref(self.packet); + if r < 0 { + return Err(averr("send_packet", r)); + } + let mut out = None; + loop { + let r = ffi::avcodec_receive_frame(self.ctx, self.frame); + if r == ffmpeg::ffi::AVERROR(ffmpeg::ffi::EAGAIN) { + break; + } + if r < 0 { + return Err(averr("receive_frame", r)); + } + let lifted = self.lift(); + // The decode surface goes back to the pool NOW — the ring copy (queued ahead + // of any later decoder write on the same immediate context) already owns the + // pixels. No cross-thread AVFrame guard exists in this backend at all. + ffi::av_frame_unref(self.frame); + out = Some(lifted?); // newest wins (one-in/one-out streams make this moot) + } + Ok(out) + } + } + + /// Convert the decoded slice into the next ring slot (`VideoProcessorBlt`, NV12/P010 → + /// BGRA8) under its keyed mutex and describe the hand-off. The mutex acquire also + /// back-pressures against the presenter still reading this slot (only possible if the + /// stream runs `RING_SLOTS` ahead of present). + unsafe fn lift(&mut self) -> Result { + use ffmpeg::ffi; + unsafe { + if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 { + bail!("decoder returned a software frame (no D3D11 surface)"); + } + let width = (*self.frame).width as u32; + let height = (*self.frame).height as u32; + let color = ColorDesc::from_raw(self.frame); + // AddRef'd locals so the mutable `ring` borrow below doesn't lock all of `self`. + let video_device = self.video_device.clone(); + let video_context1 = self.video_context1.clone(); + let context = self.context.clone(); + // (Re)build the ring + video processor on first use or a stream size change (the + // hand-off is BGRA8 regardless of the stream's bit depth, so depth never rebuilds). + let rebuild = self + .ring + .as_ref() + .is_none_or(|r| r.width != width || r.height != height); + if rebuild { + let generation = self.ring.as_ref().map_or(0, |r| r.generation + 1); + self.ring = Some(SharedRing::build( + &self.device, + &video_device, + width, + height, + generation, + )?); + } + let ring = self.ring.as_mut().expect("ring built above"); + let slot_idx = ring.next; + ring.next = (ring.next + 1) % ring.slots.len(); + let slot = &ring.slots[slot_idx]; + + let raw = (*self.frame).data[0] as *mut c_void; + let src: ID3D11Texture2D = ID3D11Texture2D::from_raw_borrowed(&raw) + .ok_or_else(|| anyhow!("null D3D11 texture on decoded frame"))? + .clone(); + let index = (*self.frame).data[1] as usize as u32; + + // Input view over THIS slice of the decode array (cheap per-frame object). + let mut iv_desc = D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC::default(); + iv_desc.FourCC = 0; // surface format speaks for itself + iv_desc.ViewDimension = D3D11_VPIV_DIMENSION_TEXTURE2D; + iv_desc.Anonymous.Texture2D.MipSlice = 0; + iv_desc.Anonymous.Texture2D.ArraySlice = index; + let mut in_view = None; + video_device + .CreateVideoProcessorInputView(&src, &ring.enumerator, &iv_desc, Some(&mut in_view)) + .context("CreateVideoProcessorInputView")?; + let in_view = in_view.expect("input view created"); + + // Colour spaces per frame (the host flips PQ in-band): YCbCr in, sRGB out — a PQ + // stream is tone-mapped to SDR by the processor (module docs). CICP → DXGI enums. + let in_cs = match (color.transfer, color.matrix, color.full_range) { + (16, _, _) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, + (_, 9, _) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020, + (_, _, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709, + _ => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, + }; + video_context1.VideoProcessorSetStreamColorSpace1(&ring.vp, 0, in_cs); + video_context1 + .VideoProcessorSetOutputColorSpace1(&ring.vp, DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709); + + let stream = D3D11_VIDEO_PROCESSOR_STREAM { + Enable: true.into(), + OutputIndex: 0, + InputFrameOrField: 0, + PastFrames: 0, + FutureFrames: 0, + ppPastSurfaces: ptr::null_mut(), + pInputSurface: std::mem::ManuallyDrop::new(Some(in_view)), + ppFutureSurfaces: ptr::null_mut(), + ppPastSurfacesRight: ptr::null_mut(), + pInputSurfaceRight: std::mem::ManuallyDrop::new(None), + ppFutureSurfacesRight: ptr::null_mut(), + }; + let handle = slot.handle.0 as isize; + let generation = ring.generation; + let mut streams = [stream]; + slot.mutex + .AcquireSync(0, ACQUIRE_TIMEOUT_MS) + .ok() + .context("keyed-mutex acquire (decode side) timed out")?; + let blt = video_context1.VideoProcessorBlt(&ring.vp, &slot.out_view, 0, &streams); + // Balance the ManuallyDrop refs the stream struct carried BEFORE error-checking. + std::mem::ManuallyDrop::drop(&mut streams[0].pInputSurface); + std::mem::ManuallyDrop::drop(&mut streams[0].pInputSurfaceRight); + let release = slot.mutex.ReleaseSync(0); + blt.ok().context("VideoProcessorBlt")?; + release.ok().context("keyed-mutex release")?; + // Get the conversion moving now — the presenter's GPU-side acquire waits on its + // completion, and an unflushed deferred batch would add a driver-decided delay. + context.Flush(); + + log_layout_once(width, height, index, color.is_pq()); + Ok(D3d11Frame { + width, + height, + // What the slot now CONTAINS: sRGB BT.709 full-range RGB (PQ was tone-mapped). + color: ColorDesc { + primaries: 1, + transfer: 13, // sRGB (H.273) + matrix: 0, // identity — RGB + full_range: true, + }, + handle, + generation, + }) + } + } +} + +impl Drop for D3d11vaDecoder { + fn drop(&mut self) { + use ffmpeg::ffi; + unsafe { + ffi::av_packet_free(&mut self.packet); + ffi::av_frame_free(&mut self.frame); + ffi::avcodec_free_context(&mut self.ctx); + ffi::av_buffer_unref(&mut self.hw_device); + } + // `ring` drops after the codec: no decode can be in flight past avcodec_free_context, + // and the slots' CloseHandle only closes OUR handle — a presenter-side import that is + // still parked keeps its own reference to the payload. + } +} + +/// One-time dump of the first decoded surface's layout — the forensics for a new GPU/driver. +fn log_layout_once(width: u32, height: u32, index: u32, pq: bool) { + use std::sync::atomic::{AtomicBool, Ordering}; + static ONCE: AtomicBool = AtomicBool::new(true); + if ONCE.swap(false, Ordering::Relaxed) { + tracing::info!(width, height, slice = index, pq, "D3D11VA first frame"); + } +} diff --git a/crates/pf-presenter/src/d3d11.rs b/crates/pf-presenter/src/d3d11.rs new file mode 100644 index 00000000..5dbb37c8 --- /dev/null +++ b/crates/pf-presenter/src/d3d11.rs @@ -0,0 +1,182 @@ +//! D3D11 shared-texture → Vulkan import (Windows): the presenter half of the D3D11VA +//! decode path (`pf_client_core::video_d3d11`). Each decoded frame arrives as the NT +//! handle of a shareable **BGRA8** texture (the decoder's VideoProcessor already did +//! YUV→RGB); we import it as a single-plane VkImage (`VK_KHR_external_memory_win32`, +//! dedicated allocation) and the presenter blits it straight into its video image — no +//! CSC pass. Single-plane RGBA is deliberate: importing the earlier multiplanar NV12 +//! hand-off device-lost on NVIDIA however it was consumed (sampling or DMA copy, both +//! validation-clean — bisected 2026-07-09), while RGBA D3D11↔Vulkan interop is the path +//! Chromium/ANGLE exercise on every Windows driver. +//! +//! Synchronization is the texture's DXGI **keyed mutex** (`VK_KHR_win32_keyed_mutex`), +//! key 0 on both sides: the submit chains an acquire(0)/release(0) pair, so the GPU +//! waits for the decoder's conversion to complete before reading and the decoder's next +//! `AcquireSync(0)` on that ring slot blocks until our reads are done. Import is +//! per-frame (same discipline as the dmabuf path — parked in `Retired` until the fence +//! proves the GPU past it); NT-handle ownership stays with the decoder ring, the import +//! only references the payload. + +use anyhow::{bail, Context as _, Result}; +use ash::vk; +use pf_client_core::video::{ColorDesc, D3d11Frame}; + +/// The two device extensions this path needs; queried at device creation. Broadly present +/// on every Windows driver (NVIDIA/AMD/Intel) — a device without them just reports +/// `supports_d3d11() == false` and the decoder chain skips D3D11VA. +pub const DEVICE_EXTENSIONS: [&std::ffi::CStr; 2] = [ + ash::khr::external_memory_win32::NAME, + ash::khr::win32_keyed_mutex::NAME, +]; + +/// Can this device import a D3D11 BGRA8 texture as a blit source? The spec-required +/// capability probe for the exact image the import path creates — creating an external +/// image the driver doesn't support is undefined behavior (observed as +/// `VK_ERROR_DEVICE_LOST` at the first submits with the old NV12 hand-off). +pub fn import_supported(instance: &ash::Instance, pdev: vk::PhysicalDevice) -> bool { + let mut ext_info = vk::PhysicalDeviceExternalImageFormatInfo::default() + .handle_type(vk::ExternalMemoryHandleTypeFlags::D3D11_TEXTURE); + let fmt_info = vk::PhysicalDeviceImageFormatInfo2::default() + .format(vk::Format::B8G8R8A8_UNORM) + .ty(vk::ImageType::TYPE_2D) + .tiling(vk::ImageTiling::OPTIMAL) + .usage(vk::ImageUsageFlags::TRANSFER_SRC) + .push_next(&mut ext_info); + let mut ext_props = vk::ExternalImageFormatProperties::default(); + let mut props = vk::ImageFormatProperties2::default().push_next(&mut ext_props); + let ok = unsafe { + instance.get_physical_device_image_format_properties2(pdev, &fmt_info, &mut props) + } + .is_ok() + && ext_props + .external_memory_properties + .external_memory_features + .contains(vk::ExternalMemoryFeatureFlags::IMPORTABLE); + tracing::info!(bgra8 = ok, "D3D11 texture → Vulkan import support"); + ok +} + +/// One imported frame: the BGRA8 image over the shared texture and its imported +/// (dedicated) memory — a blit source, nothing more. Parked until the in-flight fence +/// proves the GPU past the blit, then [`HwFrame::destroy`]ed — the memory is what the +/// keyed-mutex info on the submit references. +pub struct HwFrame { + pub color: ColorDesc, + pub width: u32, + pub height: u32, + image: vk::Image, + memory: vk::DeviceMemory, +} + +impl HwFrame { + /// The imported image — the acquire barrier + copy source. + pub fn image(&self) -> vk::Image { + self.image + } + + /// The imported memory object — the submit's keyed-mutex acquire/release info needs it. + pub fn memory(&self) -> vk::DeviceMemory { + self.memory + } + + pub fn destroy(self, device: &ash::Device) { + unsafe { + device.destroy_image(self.image, None); + device.free_memory(self.memory, None); + } + } +} + +/// Import one hand-off frame. Fails cleanly (the caller demotes to software after a +/// streak) on anything the driver rejects: unsupported multiplanar external format, +/// import refusal, no matching memory type. +pub fn import( + device: &ash::Device, + ext_mem_win32: &ash::khr::external_memory_win32::Device, + frame: &D3d11Frame, +) -> Result { + // The demotion test hook — same contract as the dmabuf path's. + if std::env::var_os("PUNKTFUNK_HW_FAULT").is_some_and(|v| v == "import") { + bail!("injected import failure (PUNKTFUNK_HW_FAULT=import)"); + } + let mp_format = vk::Format::B8G8R8A8_UNORM; + let handle_type = vk::ExternalMemoryHandleTypeFlags::D3D11_TEXTURE; + + // One single-plane image over the whole texture, transfer-source only — the blit is + // the whole job. Kept maximally "identical" to the D3D11 resource (no view-format + // aliasing, no extra usages). + let mut external_info = vk::ExternalMemoryImageCreateInfo::default().handle_types(handle_type); + let image = unsafe { + device.create_image( + &vk::ImageCreateInfo::default() + .push_next(&mut external_info) + .image_type(vk::ImageType::TYPE_2D) + .format(mp_format) + .extent(vk::Extent3D { + width: frame.width, + height: frame.height, + depth: 1, + }) + .mip_levels(1) + .array_layers(1) + .samples(vk::SampleCountFlags::TYPE_1) + .tiling(vk::ImageTiling::OPTIMAL) + .usage(vk::ImageUsageFlags::TRANSFER_SRC) + .initial_layout(vk::ImageLayout::UNDEFINED), + None, + ) + } + .with_context(|| format!("create {}x{} {mp_format:?} external image", frame.width, frame.height))?; + + let result = (|| { + // The handle's importable memory types, intersected with the image's requirement. + let handle = frame.handle as vk::HANDLE; + let mut handle_props = vk::MemoryWin32HandlePropertiesKHR::default(); + unsafe { + ext_mem_win32.get_memory_win32_handle_properties(handle_type, handle, &mut handle_props) + } + .context("vkGetMemoryWin32HandlePropertiesKHR")?; + let reqs = unsafe { device.get_image_memory_requirements(image) }; + let bits = reqs.memory_type_bits & handle_props.memory_type_bits; + let type_index = (0..32u32) + .find(|i| bits & (1 << i) != 0) + .context("no importable memory type for the D3D11 texture")?; + + // Import does NOT take handle ownership (NT handle rule): the decoder ring keeps + // closing its own handle; this allocation references the payload independently. + let mut import_info = vk::ImportMemoryWin32HandleInfoKHR::default() + .handle_type(handle_type) + .handle(handle); + let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().image(image); + let memory = unsafe { + device.allocate_memory( + &vk::MemoryAllocateInfo::default() + .push_next(&mut import_info) + .push_next(&mut dedicated) + .allocation_size(reqs.size) + .memory_type_index(type_index), + None, + ) + } + .context("import D3D11 texture memory")?; + if let Err(e) = unsafe { device.bind_image_memory(image, memory, 0) } { + unsafe { device.free_memory(memory, None) }; + return Err(e).context("bind imported memory"); + } + Ok(memory) + })(); + let memory = match result { + Ok(m) => m, + Err(e) => { + unsafe { device.destroy_image(image, None) }; + return Err(e); + } + }; + + Ok(HwFrame { + color: frame.color, + width: frame.width, + height: frame.height, + image, + memory, + }) +} diff --git a/crates/pf-presenter/src/lib.rs b/crates/pf-presenter/src/lib.rs index b4d1536d..d84c1ad0 100644 --- a/crates/pf-presenter/src/lib.rs +++ b/crates/pf-presenter/src/lib.rs @@ -10,11 +10,14 @@ //! 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). +//! Builds on Linux AND Windows; `dmabuf` is Linux-only (DRM-PRIME does not exist on +//! Windows) and `d3d11` is its Windows counterpart (D3D11VA shared-texture import) — +//! the decode chain there is Vulkan → D3D11VA → software. #[cfg(any(target_os = "linux", windows))] pub mod csc; +#[cfg(windows)] +pub mod d3d11; #[cfg(target_os = "linux")] pub mod dmabuf; #[cfg(any(target_os = "linux", windows))] diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index a27c6fe8..fc1de433 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -817,6 +817,47 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result } false } + // D3D11VA: shared-texture import, same gate + failure-streak + // demotion contract as the dmabuf path. + #[cfg(windows)] + DecodedImage::D3d11(d) if presenter.supports_d3d11() && !st.dmabuf_demoted => { + st.hdr = d.color.is_pq(); + match presenter.present( + &window, + FrameInput::D3d11(d), + overlay_frame.as_ref(), + ) { + Ok(p) => { + st.hw_fails = 0; + p + } + Err(e) => { + st.hw_fails += 1; + tracing::warn!(error = %format!("{e:#}"), fails = st.hw_fails, + "hardware present failed"); + if st.hw_fails >= 3 && !st.dmabuf_demoted { + st.dmabuf_demoted = true; + tracing::warn!("demoting the decoder to software"); + st.force_software.store(true, Ordering::Relaxed); + } + false + } + } + } + #[cfg(windows)] + DecodedImage::D3d11(_) => { + // No import extensions on this device (or already demoted) — the + // pump rebuilds the decoder as software; frames flow again soon. + if !st.dmabuf_demoted { + st.dmabuf_demoted = true; + tracing::warn!( + "no win32 external-memory import on this device — demoting \ + the decoder to software" + ); + st.force_software.store(true, Ordering::Relaxed); + } + false + } // Vulkan-Video: decoded on the presenter's own device — present is // views + CSC, no import step to gate on. Same failure-streak // demotion contract as the dmabuf path. diff --git a/crates/pf-presenter/src/vk.rs b/crates/pf-presenter/src/vk.rs index 751e5489..e6dd7507 100644 --- a/crates/pf-presenter/src/vk.rs +++ b/crates/pf-presenter/src/vk.rs @@ -37,6 +37,9 @@ pub enum FrameInput<'a> { 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), } /// The dmabuf/CSC machinery, present only when the device carries the import extensions. @@ -45,12 +48,22 @@ 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], @@ -62,6 +75,8 @@ impl Retired { 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 { @@ -333,6 +348,10 @@ pub struct Presenter { /// pass itself is unconditional: Vulkan-Video frames need it everywhere). #[cfg(target_os = "linux")] hw: Option, + /// D3D11 shared-texture import — `None` when the device lacks the win32 external + /// memory / keyed-mutex extensions. + #[cfg(windows)] + hw_win: Option, csc: CscPass, /// FFmpeg Vulkan Video decode handles — `None` when the stack can't do it. video_export: Option, @@ -451,6 +470,31 @@ impl Presenter { 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 @@ -596,12 +640,22 @@ impl Presenter { } 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)?; - // The exported handle bundle for FFmpeg's Vulkan Video decoder (None = the - // decoder chain skips straight to VAAPI/software). Extension lists must mirror - // creation exactly — FFmpeg keys its code paths off the strings. - let video_export = if video_ok { + // 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. + #[cfg(windows)] + let export_worthy = video_ok || win_capable; + #[cfg(not(windows))] + let export_worthy = video_ok; + let video_export = if export_worthy { let qf_props = unsafe { instance.get_physical_device_queue_family_properties(pdev) }; let mut device_extensions: Vec = vec![CString::from(ash::khr::swapchain::NAME)]; @@ -610,6 +664,11 @@ impl Presenter { 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)); } @@ -628,9 +687,15 @@ impl Presenter { .map(|e| CString::new(e.as_str()).unwrap()) .collect(), device_extensions, - f_sampler_ycbcr: true, - f_timeline_semaphore: true, - f_synchronization2: true, + 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, + video_decode: video_ok, + #[cfg(windows)] + d3d11_import: win_capable, + #[cfg(not(windows))] + d3d11_import: false, + adapter_luid, }) } else { None @@ -685,6 +750,8 @@ impl Presenter { qfi, #[cfg(target_os = "linux")] hw, + #[cfg(windows)] + hw_win, csc, video_export, overlay_pipe, @@ -881,6 +948,13 @@ impl Presenter { 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. @@ -980,6 +1054,8 @@ impl Presenter { #[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()), }; if let Some(pq) = frame_pq { let want = pq && self.hdr10_format.is_some(); @@ -992,6 +1068,8 @@ impl Presenter { // semaphore. #[cfg(target_os = "linux")] let mut hw_frame: Option = None; + #[cfg(windows)] + let mut win_frame: Option = None; let mut vk_frame: Option<(VkVideoFrame, [vk::ImageView; 2])> = None; let cpu_frame = match input { FrameInput::Redraw => None, @@ -1005,6 +1083,15 @@ impl Presenter { 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)); @@ -1047,6 +1134,17 @@ impl Presenter { 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 @@ -1086,6 +1184,10 @@ impl Presenter { 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); } @@ -1122,6 +1224,49 @@ impl Presenter { ); } + // 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, @@ -1331,6 +1476,33 @@ impl Presenter { 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 = 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). @@ -1358,6 +1530,10 @@ impl Presenter { 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]; @@ -2002,6 +2178,40 @@ fn vkframe_acquire_barrier( } } +/// Acquire an imported D3D11 texture from the EXTERNAL queue family as a copy source. +/// The keyed mutex on the submit is the actual cross-API ordering; per the +/// external-memory rules an UNDEFINED-old-layout transition on externally-bound memory +/// preserves the contents (unlike ordinary images), so this is purely the +/// layout/ownership hop. +#[cfg(windows)] +fn external_acquire_barrier( + device: &ash::Device, + cmd: vk::CommandBuffer, + image: vk::Image, + qfi: u32, +) { + let b = vk::ImageMemoryBarrier::default() + .src_access_mask(vk::AccessFlags::empty()) + .dst_access_mask(vk::AccessFlags::TRANSFER_READ) + .old_layout(vk::ImageLayout::UNDEFINED) + .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) + .src_queue_family_index(vk::QUEUE_FAMILY_EXTERNAL) + .dst_queue_family_index(qfi) + .image(image) + .subresource_range(subresource_range()); + unsafe { + device.cmd_pipeline_barrier( + cmd, + vk::PipelineStageFlags::TOP_OF_PIPE, + vk::PipelineStageFlags::TRANSFER, + vk::DependencyFlags::empty(), + &[], + &[], + &[b], + ); + } +} + /// Acquire a dmabuf plane image from its foreign owner (the VAAPI decoder): queue-family /// transfer FOREIGN → ours, UNDEFINED → SHADER_READ_ONLY (content is preserved across /// the transfer regardless of the UNDEFINED old-layout, per the external-memory rules).