feat(windows): pf-vdisplay IDD-push — HDR + pipelined zero-copy capture
HDR (display-driven, matching the WGC path): - CTA-861.3 HDR EDID (BT.2020 primaries + HDR Static Metadata block) so Windows offers "Use HDR" on the virtual display. The host FOLLOWS the display's live advanced-color state, recreating the shared ring at the matching format (FP16 in HDR / BGRA in SDR) on a toggle — no freeze. - Always emit Main10/BT.2020-PQ Rgb10a2 while the display is HDR; the client auto-detects PQ from the HEVC VUI (clients under-report VIDEO_CAP_10BIT). Generic HDR10 mastering SEI on every IDR. - Generation-tagged `latest` (gen<<40|seq<<8|slot) + driver `is_stale` re-attach kill the toggle-time garbage frame and any stale-ring read. Perf: - Pipeline the encode loop (Capturer::pipeline_depth; IDD-push = 2): submit N+1 before polling N so the convert/copy on the 3D engine overlaps the NVENC encode of N on the ASIC. PUNKTFUNK_IDD_DEPTH overrides (1 = synchronous). - Rotating host output ring (OUT_RING) so the in-flight encode and the next convert never touch the same texture. - HDR converts directly from the keyed-mutex slot's SRV into the output ring (drops the redundant slot->fp16 scratch copy); SDR copies the BGRA slot in. The slot mutex is held only across the convert/copy, not the encode. RING_LEN 3->6 for publish headroom. - Capture-health diagnostic: new_fps vs repeat_fps under PUNKTFUNK_PERF (a low new_fps at a high send rate means the source isn't compositing, not an encode stall). Validated live on the RTX box: 5120x1440@240 HDR streams; driver composes ~180 new fps, encode 240 fps @ ~4.3 ms p50. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,29 +4,39 @@ use std::{
|
||||
Arc,
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use log::{debug, error};
|
||||
use wdf_umdf::{
|
||||
IddCxSwapChainFinishedProcessingFrame, IddCxSwapChainReleaseAndAcquireBuffer,
|
||||
IddCxSwapChainFinishedProcessingFrame, IddCxSwapChainReleaseAndAcquireBuffer2,
|
||||
IddCxSwapChainSetDevice, WdfObjectDelete,
|
||||
};
|
||||
use wdf_umdf_sys::{
|
||||
HANDLE, IDARG_IN_SWAPCHAINSETDEVICE, IDARG_OUT_RELEASEANDACQUIREBUFFER, IDDCX_SWAPCHAIN,
|
||||
NTSTATUS, WAIT_TIMEOUT, WDFOBJECT,
|
||||
HANDLE, IDARG_IN_RELEASEANDACQUIREBUFFER2, IDARG_IN_SWAPCHAINSETDEVICE,
|
||||
IDARG_OUT_RELEASEANDACQUIREBUFFER2, IDDCX_SWAPCHAIN, NTSTATUS, WAIT_TIMEOUT, WDFOBJECT,
|
||||
};
|
||||
use windows::{
|
||||
core::{w, Interface},
|
||||
Win32::{
|
||||
Foundation::HANDLE as WHANDLE,
|
||||
Graphics::Dxgi::IDXGIDevice,
|
||||
Graphics::{
|
||||
Direct3D11::ID3D11Texture2D,
|
||||
Dxgi::{IDXGIDevice, IDXGIResource},
|
||||
},
|
||||
System::Threading::{
|
||||
AvRevertMmThreadCharacteristics, AvSetMmThreadCharacteristicsW, WaitForSingleObject,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{direct_3d_device::Direct3DDevice, helpers::Sendable};
|
||||
use crate::{
|
||||
direct_3d_device::Direct3DDevice,
|
||||
frame_transport::{
|
||||
dbg_frame, dbg_header_attempt, dbg_run_core_entry, dbg_set_target, FramePublisher,
|
||||
},
|
||||
helpers::Sendable,
|
||||
};
|
||||
|
||||
pub struct SwapChainProcessor {
|
||||
terminate: Arc<AtomicBool>,
|
||||
@@ -47,8 +57,11 @@ impl SwapChainProcessor {
|
||||
pub fn run(
|
||||
&mut self,
|
||||
swap_chain: IDDCX_SWAPCHAIN,
|
||||
device: Direct3DDevice,
|
||||
device: Arc<Direct3DDevice>,
|
||||
available_buffer_event: HANDLE,
|
||||
target_id: u32,
|
||||
render_luid_low: u32,
|
||||
render_luid_high: i32,
|
||||
) {
|
||||
let available_buffer_event = unsafe { Sendable::new(available_buffer_event) };
|
||||
let swap_chain = unsafe { Sendable::new(swap_chain) };
|
||||
@@ -64,7 +77,17 @@ impl SwapChainProcessor {
|
||||
return;
|
||||
};
|
||||
|
||||
Self::run_core(*swap_chain, &device, *available_buffer_event, &terminate);
|
||||
Self::run_core(
|
||||
*swap_chain,
|
||||
&device,
|
||||
*available_buffer_event,
|
||||
&terminate,
|
||||
target_id,
|
||||
render_luid_low,
|
||||
render_luid_high,
|
||||
);
|
||||
|
||||
error!("run_core RETURNED (target={target_id}) — deleting swap-chain, device drops next");
|
||||
|
||||
let res = unsafe { WdfObjectDelete(*swap_chain as WDFOBJECT) };
|
||||
if let Err(e) = res {
|
||||
@@ -87,31 +110,140 @@ impl SwapChainProcessor {
|
||||
device: &Direct3DDevice,
|
||||
available_buffer_event: HANDLE,
|
||||
terminate: &AtomicBool,
|
||||
target_id: u32,
|
||||
render_luid_low: u32,
|
||||
render_luid_high: i32,
|
||||
) {
|
||||
let dxgi_device = device.device.cast::<IDXGIDevice>();
|
||||
let Ok(dxgi_device) = dxgi_device else {
|
||||
error!("Failed to cast ID3D11Device to IDXGIDevice: {dxgi_device:?}");
|
||||
return;
|
||||
};
|
||||
// P2 direct frame push: lazily ATTACH to the HOST-created shared ring. The restricted UMDF
|
||||
// token can't create named objects, so the host creates the header + event + textures and we
|
||||
// only OPEN them once they appear (`try_open`). Until then we just drain — exactly the P1
|
||||
// behaviour — so a non-IDD-push session never stalls. Retried every ~30 frames.
|
||||
let mut publisher: Option<FramePublisher> = None;
|
||||
let mut frames_since_try: u32 = u32::MAX; // attach attempt on the first acquired frame
|
||||
|
||||
// Bring-up debug: prove run_core ran + record the target/render LUID we'll name objects with.
|
||||
dbg_run_core_entry();
|
||||
dbg_set_target(target_id, render_luid_low, render_luid_high);
|
||||
|
||||
// SetDevice fails (0x887A0026, FACILITY_DXGI) when the monitor briefly flaps INACTIVE during
|
||||
// topology activation — the OS unassigns + re-assigns the swap-chain, and a fresh run_core thread
|
||||
// can lose the race to the unassign. Retry briefly so a stable re-assign binds the device instead
|
||||
// of giving up on the first transient failure. `terminate` (set when the OS unassigns + drops the
|
||||
// processor) breaks us out promptly.
|
||||
// Cast to IDXGIDevice ONCE and BORROW it to the swap-chain across all retries. The previous
|
||||
// code re-cast + `into_raw()`'d on EVERY attempt — and a flapping monitor fails several
|
||||
// attempts per session — so each failure orphaned one IDXGIDevice reference, pinning the D3D
|
||||
// device so it (and its ~dozen D3D worker threads + tens of MB of VRAM) was NEVER freed when
|
||||
// the processor dropped. That leaked ~71 threads / ~57 MB VRAM per reconnect until the driver
|
||||
// choked and sessions fell to 0 bytes. `as_raw()` keeps our single reference (released right
|
||||
// after the loop); IddCx AddRefs its own on success, and `device` keeps the object alive for
|
||||
// the drain loop regardless.
|
||||
let dxgi_device = match device.device.cast::<IDXGIDevice>() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
error!("Failed to cast ID3D11Device to IDXGIDevice: {e:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let set_device = IDARG_IN_SWAPCHAINSETDEVICE {
|
||||
pDevice: dxgi_device.into_raw().cast(),
|
||||
pDevice: dxgi_device.as_raw().cast(),
|
||||
};
|
||||
|
||||
let res = unsafe { IddCxSwapChainSetDevice(swap_chain, &set_device) };
|
||||
if res.is_err() {
|
||||
debug!("Failed to set swapchain device: {res:?}");
|
||||
let mut set_ok = false;
|
||||
let mut terminated = false;
|
||||
for attempt in 0..60u32 {
|
||||
if terminate.load(Ordering::Relaxed) {
|
||||
error!("run_core: terminated during SetDevice (attempt {attempt}, target={target_id})");
|
||||
terminated = true;
|
||||
break;
|
||||
}
|
||||
let res = unsafe { IddCxSwapChainSetDevice(swap_chain, &set_device) };
|
||||
if res.is_ok() {
|
||||
set_ok = true;
|
||||
error!("run_core: SetDevice OK (target={target_id}, attempt={attempt}) — entering drain loop");
|
||||
break;
|
||||
}
|
||||
if attempt == 0 {
|
||||
debug!("run_core: SetDevice attempt 0 failed ({res:?}) — retrying up to 60x@50ms (monitor may be flapping)");
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
// Release our borrowed device reference — IddCx holds its own now, or we gave up. (Explicit
|
||||
// drop so NLL can't release it mid-loop while the swap-chain still references the raw ptr.)
|
||||
drop(dxgi_device);
|
||||
if !set_ok {
|
||||
if !terminated {
|
||||
error!("run_core: SetDevice never succeeded after retries (target={target_id}) — giving up");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut logged_pending = false;
|
||||
let mut logged_frame = false;
|
||||
loop {
|
||||
let mut buffer = IDARG_OUT_RELEASEANDACQUIREBUFFER::default();
|
||||
let hr: NTSTATUS =
|
||||
unsafe { IddCxSwapChainReleaseAndAcquireBuffer(swap_chain, &mut buffer).into() };
|
||||
// Check terminate at the TOP, every iteration. The success branch below does NOT re-check
|
||||
// it, so during a CONTINUOUS frame burst (DWM rendering the freshly-activated desktop) a
|
||||
// thread that the OS unassigns — or that `free_swap_chain_processor` is dropping — never
|
||||
// sees the flag and loops on, pinning its D3D device (and ~36 NVIDIA worker threads). That
|
||||
// is THE reconnect leak: it only reproduced at full speed, because cdb's pacing forced
|
||||
// E_PENDING gaps (which DO check terminate) and masked it. Without this, `SwapChainProcessor::drop`'s
|
||||
// join can also block until the burst ends.
|
||||
if terminate.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
// The host recreates the shared ring (new format) mid-session when the display's HDR mode
|
||||
// flips — it bumps the header generation. Detect that and drop the publisher so we re-attach
|
||||
// to the new-format textures below; otherwise we'd keep CopyResource'ing into the stale ring,
|
||||
// whose format now mismatches the surface → the publish() format-guard drops every frame and
|
||||
// the stream freezes until the next swap-chain recreate.
|
||||
if publisher.as_ref().is_some_and(FramePublisher::is_stale) {
|
||||
publisher = None;
|
||||
frames_since_try = u32::MAX; // re-attach immediately
|
||||
}
|
||||
// Lazy-attach (rate-limited) at the loop TOP so we keep trying even while the display is
|
||||
// idle (E_PENDING / no frames presented yet), not only when a frame is acquired. `try_open`
|
||||
// is a cheap OpenFileMapping that fails fast until the host has created the ring.
|
||||
if publisher.is_none() {
|
||||
if frames_since_try >= 30 {
|
||||
frames_since_try = 0;
|
||||
match FramePublisher::try_open(
|
||||
target_id,
|
||||
render_luid_low,
|
||||
render_luid_high,
|
||||
&device.device,
|
||||
&device.device_context,
|
||||
) {
|
||||
Ok(p) => {
|
||||
dbg_header_attempt(0, true);
|
||||
publisher = Some(p);
|
||||
}
|
||||
Err(e) => dbg_header_attempt(e.code().0 as u32, false),
|
||||
}
|
||||
} else {
|
||||
frames_since_try += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// B2: ...Buffer2 is required once CAN_PROCESS_FP16 is set. AcquireSystemMemoryBuffer=FALSE
|
||||
// keeps the GPU surface (out.MetaData.pSurface). The surface format varies per-frame —
|
||||
// FP16 (R16G16B16A16_FLOAT) in HDR, BGRA in SDR — and the publisher's format guard handles
|
||||
// a frame that doesn't match the ring until B3 makes the ring FP16.
|
||||
let mut in_args = IDARG_IN_RELEASEANDACQUIREBUFFER2 {
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
Size: std::mem::size_of::<IDARG_IN_RELEASEANDACQUIREBUFFER2>() as u32,
|
||||
AcquireSystemMemoryBuffer: 0,
|
||||
};
|
||||
let mut buffer = IDARG_OUT_RELEASEANDACQUIREBUFFER2::default();
|
||||
let hr: NTSTATUS = unsafe {
|
||||
IddCxSwapChainReleaseAndAcquireBuffer2(swap_chain, &mut in_args, &mut buffer).into()
|
||||
};
|
||||
|
||||
#[allow(clippy::items_after_statements)]
|
||||
const E_PENDING: u32 = 0x8000_000A;
|
||||
if u32::from(hr) == E_PENDING {
|
||||
if !logged_pending {
|
||||
error!("run_core: E_PENDING (target={target_id}) — swap-chain valid but DWM has composed NO frame yet");
|
||||
logged_pending = true;
|
||||
}
|
||||
let wait_result =
|
||||
unsafe { WaitForSingleObject(WHANDLE(available_buffer_event.cast()), 16).0 };
|
||||
|
||||
@@ -130,8 +262,29 @@ impl SwapChainProcessor {
|
||||
// The wait was cancelled or something unexpected happened
|
||||
break;
|
||||
} else if hr.is_success() {
|
||||
if !logged_frame {
|
||||
error!("run_core: FIRST FRAME acquired (target={target_id}) — DWM IS compositing the virtual display!");
|
||||
logged_frame = true;
|
||||
}
|
||||
dbg_frame(); // bring-up: prove frames actually flow (vs an idle display)
|
||||
// This is the most performance-critical section of code in an IddCx driver. It's important that whatever
|
||||
// is done with the acquired surface be finished as quickly as possible.
|
||||
//
|
||||
// P2: copy the acquired surface into the shared ring BEFORE FinishedProcessingFrame
|
||||
// (the surface is valid until the next ReleaseAndAcquire). The pointer is BORROWED —
|
||||
// `from_raw_borrowed` does not take IddCx's refcount — and the GPU-side copy is ordered
|
||||
// before the consumer via the slot keyed mutex. (Attach happens at the loop top.)
|
||||
if let Some(pub_) = publisher.as_mut() {
|
||||
let raw = buffer.MetaData.pSurface as *mut core::ffi::c_void;
|
||||
if !raw.is_null() {
|
||||
if let Some(res) = unsafe { IDXGIResource::from_raw_borrowed(&raw) } {
|
||||
if let Ok(tex) = res.cast::<ID3D11Texture2D>() {
|
||||
pub_.publish(&tex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let hr = unsafe { IddCxSwapChainFinishedProcessingFrame(swap_chain) };
|
||||
|
||||
if hr.is_err() {
|
||||
|
||||
Reference in New Issue
Block a user