diff --git a/crates/pf-client-core/src/video.rs b/crates/pf-client-core/src/video.rs index 617e2a93..d808462b 100644 --- a/crates/pf-client-core/src/video.rs +++ b/crates/pf-client-core/src/video.rs @@ -273,6 +273,9 @@ pub struct Decoder { /// rebuild lands on the SAME GPU. #[cfg(windows)] adapter_luid: Option<[u8; 8]>, + /// [`VulkanDecodeDevice::d3d11_hdr10`], for the same demotion rebuild. + #[cfg(windows)] + d3d11_hdr10: bool, } /// Demote a hardware backend (Vulkan→VAAPI/D3D11VA, VAAPI/D3D11VA→software) only after @@ -373,9 +376,10 @@ impl Decoder { .filter(|v| !v.is_empty()) .unwrap_or_else(|| pref.to_string()); #[cfg(windows)] - let (d3d11_import, adapter_luid) = ( + let (d3d11_import, adapter_luid, d3d11_hdr10) = ( vk.is_some_and(|v| v.d3d11_import), vk.and_then(|v| v.adapter_luid), + vk.is_some_and(|v| v.d3d11_hdr10), ); let done = |backend| { Ok(Decoder { @@ -388,6 +392,8 @@ impl Decoder { d3d11_import, #[cfg(windows)] adapter_luid, + #[cfg(windows)] + d3d11_hdr10, }) }; // Linux `auto`: try VAAPI FIRST unless this device is one where Vulkan Video is @@ -434,7 +440,11 @@ impl Decoder { { if let Some(v) = vk.filter(|v| v.d3d11_import) { d3d11_tried = true; - match crate::video_d3d11::D3d11vaDecoder::new(codec_id, v.adapter_luid) { + match crate::video_d3d11::D3d11vaDecoder::new( + codec_id, + v.adapter_luid, + v.d3d11_hdr10, + ) { Ok(d) => { tracing::info!( ?codec_id, @@ -508,7 +518,11 @@ impl Decoder { if choice != "software" && choice != "vulkan" && !d3d11_tried { match vk.filter(|v| v.d3d11_import) { Some(v) => { - match crate::video_d3d11::D3d11vaDecoder::new(codec_id, v.adapter_luid) { + match crate::video_d3d11::D3d11vaDecoder::new( + codec_id, + v.adapter_luid, + v.d3d11_hdr10, + ) { Ok(d) => { tracing::info!( ?codec_id, @@ -696,6 +710,7 @@ impl Decoder { match crate::video_d3d11::D3d11vaDecoder::new( self.codec_id, self.adapter_luid, + self.d3d11_hdr10, ) { Ok(d) => { tracing::warn!(error = %e, fails = self.vaapi_fails, @@ -866,6 +881,10 @@ pub struct VulkanDecodeDevice { /// 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, + /// The presenter can also import the RGB10A2 hand-off texture AND offers an HDR10 + /// swapchain — the D3D11VA backend emits its HDR (RGB10 PQ pass-through) ring flavor + /// for PQ streams instead of tone-mapping to sRGB. Always `false` off Windows. + pub d3d11_hdr10: 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). @@ -950,6 +969,7 @@ mod tests { pyrowave_decode: false, video_decode: true, d3d11_import: false, + d3d11_hdr10: false, adapter_luid: None, queue_lock: std::sync::Arc::new(QueueLock::new()), } diff --git a/crates/pf-client-core/src/video_d3d11.rs b/crates/pf-client-core/src/video_d3d11.rs index c1394f2a..1496795b 100644 --- a/crates/pf-client-core/src/video_d3d11.rs +++ b/crates/pf-client-core/src/video_d3d11.rs @@ -27,9 +27,11 @@ //! (`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. +//! * An HDR (PQ/BT.2020) stream passes through when the presenter can take it (RGB10A2 +//! import + an HDR10 swapchain — [`crate::video::VulkanDecodeDevice::d3d11_hdr10`]): the +//! video processor converts YCbCr G2084 → RGB G2084 into an RGB10A2 ring, colorspace +//! only, no tone mapping. On an SDR-only path it tone-maps to sRGB instead (input +//! `G2084_P2020`, output sRGB) — correct picture, no HDR presentation. //! //! 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. @@ -55,11 +57,12 @@ use windows::Win32::Graphics::Direct3D11::{ 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_P2020, - DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P601, 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_P601, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, - DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_RATIONAL, + DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020, DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, + DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P601, + 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_P601, + DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, + DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_RATIONAL, DXGI_SAMPLE_DESC, }; use windows::Win32::Graphics::Dxgi::{ @@ -98,10 +101,14 @@ const PROFILE_AV1_VLD_PROFILE0: GUID = GUID::from_u128(0xb8be4ccb_cf53_46ba_8d59 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. + /// What the ring slot actually CONTAINS after the video processor's conversion: + /// sRGB BT.709 full-range RGB normally (a PQ stream was tone-mapped), or PQ BT.2020 + /// full-range RGB when the HDR pass-through ring is active (`rgb10`) — the presenter + /// keys its SDR/HDR handling off this. pub color: ColorDesc, + /// The ring slot's texture format: `false` = BGRA8, `true` = RGB10A2 (the HDR PQ + /// pass-through flavor) — the presenter's Vulkan import must match it exactly. + pub rgb10: bool, /// Intra keyframe (IDR/I) — the pump's post-loss re-anchor signal. See /// `crate::video::VkVideoFrame`. pub keyframe: bool, @@ -334,6 +341,9 @@ struct SharedRing { height: u32, next: usize, generation: u32, + /// HDR flavor: RGB10A2 slots the processor fills with PQ BT.2020 RGB (colorspace + /// conversion only — both sides G2084, no tone mapping). `false` = BGRA8 sRGB. + pq_out: bool, } impl SharedRing { @@ -343,6 +353,7 @@ impl SharedRing { width: u32, height: u32, generation: u32, + pq_out: bool, ) -> 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. @@ -372,10 +383,15 @@ impl SharedRing { Height: height, MipLevels: 1, ArraySize: 1, - // Single-plane BGRA8: the ONLY hand-off format whose Vulkan import is a + // Single-plane RGB: the ONLY hand-off family 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, + // on NVIDIA despite being advertised). RGB10A2 for the HDR pass-through + // flavor (gated on the presenter's probe), BGRA8 otherwise. + Format: if pq_out { + DXGI_FORMAT_R10G10B10A2_UNORM + } else { + DXGI_FORMAT_B8G8R8A8_UNORM + }, SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0, @@ -433,7 +449,8 @@ impl SharedRing { height, slots = RING_SLOTS, generation, - "D3D11 shared hand-off ring built (VideoProcessor → BGRA8)" + hdr = pq_out, + "D3D11 shared hand-off ring built (VideoProcessor → RGB)" ); Ok(SharedRing { slots, @@ -443,6 +460,7 @@ impl SharedRing { height, next: 0, generation, + pq_out, }) } } @@ -460,6 +478,10 @@ pub(crate) struct D3d11vaDecoder { /// setters (Win10 1703+, universally present — init fails to software without it). video_context1: ID3D11VideoContext1, ring: Option, + /// The presenter can import RGB10A2 AND offers an HDR10 swapchain + /// ([`crate::video::VulkanDecodeDevice::d3d11_hdr10`]) — PQ streams get the HDR + /// pass-through ring; without it they keep the tonemap-to-sRGB ring. + hdr10_out: bool, } // Single-owner pointers + COM interfaces, only touched from the session pump thread (the @@ -470,6 +492,7 @@ impl D3d11vaDecoder { pub(crate) fn new( codec_id: ffmpeg::codec::Id, luid: Option<[u8; 8]>, + hdr10_out: bool, ) -> Result { use ffmpeg::ffi; let (device, context) = create_device(luid)?; @@ -540,6 +563,7 @@ impl D3d11vaDecoder { video_device, video_context1, ring: None, + hdr10_out, }) } } @@ -594,12 +618,15 @@ impl D3d11vaDecoder { 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). + // (Re)build the ring + video processor on first use, a stream size change, or a + // flavor change (the host flips PQ in-band; SDR↔HDR swaps the slot format, so + // it rebuilds like a resize — bit DEPTH alone still never rebuilds: an SDR + // 10-bit stream and an 8-bit one share the same output flavor). + let pq_out = self.hdr10_out && color.is_pq(); let rebuild = self .ring .as_ref() - .is_none_or(|r| r.width != width || r.height != height); + .is_none_or(|r| r.width != width || r.height != height || r.pq_out != pq_out); if rebuild { let generation = self.ring.as_ref().map_or(0, |r| r.generation + 1); self.ring = Some(SharedRing::build( @@ -608,6 +635,7 @@ impl D3d11vaDecoder { width, height, generation, + pq_out, )?); } let ring = self.ring.as_mut().expect("ring built above"); @@ -653,7 +681,14 @@ impl D3d11vaDecoder { video_context1.VideoProcessorSetStreamColorSpace1(&ring.vp, 0, in_cs); video_context1.VideoProcessorSetOutputColorSpace1( &ring.vp, - DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, + // HDR ring: PQ in, PQ out — a pure colorspace conversion (YCbCr→RGB), + // no tone mapping; the presenter passes the values through to its HDR10 + // swapchain. SDR ring: sRGB out (a PQ stream is tone-mapped here). + if ring.pq_out { + DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 + } else { + DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709 + }, ); let stream = D3D11_VIDEO_PROCESSOR_STREAM { @@ -691,13 +726,25 @@ impl D3d11vaDecoder { 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, + // What the slot now CONTAINS. HDR ring: PQ BT.2020 full-range RGB (the + // presenter reads is_pq() and flips its HDR10 swapchain). SDR ring: sRGB + // BT.709 full-range RGB (PQ was tone-mapped above). + color: if ring.pq_out { + ColorDesc { + primaries: 9, + transfer: 16, // PQ / SMPTE ST.2084 + matrix: 0, // identity — RGB + full_range: true, + } + } else { + ColorDesc { + primaries: 1, + transfer: 13, // sRGB (H.273) + matrix: 0, // identity — RGB + full_range: true, + } }, + rgb10: ring.pq_out, // SAFETY: `self.frame` is the live decoded AVFrame for this call. keyframe: crate::video::frame_is_keyframe(self.frame), handle, diff --git a/crates/pf-presenter/src/d3d11.rs b/crates/pf-presenter/src/d3d11.rs index 3e824ec7..a61d12f0 100644 --- a/crates/pf-presenter/src/d3d11.rs +++ b/crates/pf-presenter/src/d3d11.rs @@ -1,6 +1,7 @@ //! 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 +//! handle of a shareable single-plane RGB texture — **BGRA8** sRGB normally, **RGB10A2** +//! PQ for the HDR pass-through flavor (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 @@ -28,31 +29,41 @@ pub const DEVICE_EXTENSIONS: [&std::ffi::CStr; 2] = [ ash::khr::win32_keyed_mutex::NAME, ]; -/// Can this device import a D3D11 BGRA8 texture as a blit source? The spec-required +/// Can this device import a D3D11 texture of `format` 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 { +fn format_importable( + instance: &ash::Instance, + pdev: vk::PhysicalDevice, + format: vk::Format, +) -> 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) + .format(format) .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() + 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 + .contains(vk::ExternalMemoryFeatureFlags::IMPORTABLE) +} + +/// The two hand-off flavors' import support: `.0` = BGRA8 (the SDR ring — gates the whole +/// D3D11VA path), `.1` = RGB10A2 (the HDR PQ ring — gates only the pass-through flavor; +/// without it a PQ stream keeps the decoder-side tonemap to BGRA8). +pub fn import_supported(instance: &ash::Instance, pdev: vk::PhysicalDevice) -> (bool, bool) { + let bgra8 = format_importable(instance, pdev, vk::Format::B8G8R8A8_UNORM); + let rgb10 = format_importable(instance, pdev, vk::Format::A2B10G10R10_UNORM_PACK32); + tracing::info!(bgra8, rgb10, "D3D11 texture → Vulkan import support"); + (bgra8, rgb10) } /// One imported frame: the BGRA8 image over the shared texture and its imported @@ -98,7 +109,13 @@ pub fn import( 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; + // DXGI R10G10B10A2 and Vulkan A2B10G10R10_PACK32 are the same bit layout (R in the + // low bits) — the standard interop pairing, same as BGRA8 ↔ B8G8R8A8. + let mp_format = if frame.rgb10 { + vk::Format::A2B10G10R10_UNORM_PACK32 + } else { + 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 diff --git a/crates/pf-presenter/src/vk/present.rs b/crates/pf-presenter/src/vk/present.rs index af49c44e..0a1f26fd 100644 --- a/crates/pf-presenter/src/vk/present.rs +++ b/crates/pf-presenter/src/vk/present.rs @@ -239,11 +239,12 @@ impl Presenter { ); } - // D3D11 frame: acquire the imported BGRA texture from the external "queue + // D3D11 frame: acquire the imported RGB 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. + // blit it into the video image — the frame arrives as ready RGB from the + // decoder's VideoProcessor (sRGB BGRA8, or PQ RGB10A2 on the HDR ring — + // matching the HDR-mode video image), so there is no CSC pass; the blit + // converts 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); diff --git a/crates/pf-presenter/src/vk/setup.rs b/crates/pf-presenter/src/vk/setup.rs index 642b1126..57983ebb 100644 --- a/crates/pf-presenter/src/vk/setup.rs +++ b/crates/pf-presenter/src/vk/setup.rs @@ -94,8 +94,9 @@ impl Presenter { // (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); + let (import_bgra8, import_rgb10) = crate::d3d11::import_supported(&instance, pdev); + #[cfg(windows)] + let win_capable = crate::d3d11::DEVICE_EXTENSIONS.iter().all(|n| has(n)) && import_bgra8; #[cfg(windows)] if win_capable { dev_exts.extend(crate::d3d11::DEVICE_EXTENSIONS.iter().map(|n| n.as_ptr())); @@ -392,14 +393,25 @@ impl Presenter { d3d11_import: win_capable, #[cfg(not(windows))] d3d11_import: false, + // Filled in below — the HDR10 surface facts arrive with pick_formats. + d3d11_hdr10: false, adapter_luid, queue_lock: queue_lock.clone(), }) } else { None }; + #[cfg(windows)] + let mut video_export = video_export; let (format, hdr10_format) = pick_formats(&surface_i, pdev, surface, has_colorspace_ext)?; + // The D3D11VA backend may emit its HDR (RGB10 PQ) ring only when this device can + // import the 10-bit texture AND the surface offers an HDR10 swapchain to pass it + // through to; otherwise a PQ stream keeps the decoder-side tonemap to sRGB. + #[cfg(windows)] + if let Some(v) = video_export.as_mut() { + v.d3d11_hdr10 = win_capable && import_rgb10 && hdr10_format.is_some(); + } let present_mode = pick_present_mode(&surface_i, pdev, surface)?; tracing::info!( ?format,