feat(host): default the Windows AMF encode input to zero-copy D3D11

On-glass A/B on the Ryzen 7000 iGPU (1080p120 HDR P010, hevc_amf,
PUNKTFUNK_PERF stage split): the system-memory readback costs the encode
thread 2.7-2.9 ms p50 (6.6 ms p99) per frame in submit; the zero-copy D3D11
pool path does the same work in 0.26 ms p50 (0.5 ms p99) — and on an iGPU the
readback also burns the shared memory bandwidth the game needs. The docs-site
already promised "on by default ... D3D11 on Windows" since the Linux flip
(2f9e61d was Linux-only); the Windows code now delivers it.

PUNKTFUNK_ZEROCOPY becomes a tri-state override: unset defers to a per-vendor
default in zerocopy_enabled(vendor) — ON for AMF (validated above; open
failures still fall back to system-memory readback), OFF for QSV until it is
validated on Intel glass (the fallback only catches *setup* errors; a QSV
derive that opens but maps wrong would corrupt silently, so probe-never-assume
applies). Explicit values force either way: 0|false|off|no = readback,
anything else = zero-copy, so the old presence-style =1 keeps working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 14:36:43 +02:00
parent 06d594bc2a
commit 8fb126482e
3 changed files with 41 additions and 27 deletions
+12 -6
View File
@@ -25,9 +25,12 @@
//! - **Path / genuinely-dynamic reads**: the config-dir resolution, `PATH` executable search, the //! - **Path / genuinely-dynamic reads**: the config-dir resolution, `PATH` executable search, the
//! env-forward-to-child loop, `PUNKTFUNK_MGMT_TOKEN`, `PUNKTFUNK_HOST_CMD`, `PUNKTFUNK_RENDER_NODE`. //! env-forward-to-child loop, `PUNKTFUNK_MGMT_TOKEN`, `PUNKTFUNK_HOST_CMD`, `PUNKTFUNK_RENDER_NODE`.
//! //!
//! `PUNKTFUNK_ZEROCOPY` note: this field uses **presence** semantics (`var_os(..).is_some()`) to match the //! `PUNKTFUNK_ZEROCOPY` note: this field is a **tri-state override** (`None` = unset). Unset defers to
//! Windows `encode/ffmpeg_win.rs` reader. The Linux `zerocopy` module keeps its own *truthy* parser //! the per-vendor default in `encode/ffmpeg_win.rs::zerocopy_enabled` (AMF on — on-glass validated
//! (`1|true|yes|on`) — the two are independent features that share a name; do NOT conflate them. //! 2026-07-06; QSV off until validated on Intel glass); an explicit value forces it (`0|false|off|no`
//! = off, anything else = on, so the old presence-style `=1` keeps working). The Linux `zerocopy`
//! module keeps its own *truthy* parser (`1|true|yes|on`) — the two are independent features that
//! share a name; do NOT conflate them.
use std::sync::OnceLock; use std::sync::OnceLock;
@@ -43,8 +46,9 @@ pub struct HostConfig {
pub render_adapter: Option<String>, pub render_adapter: Option<String>,
/// `PUNKTFUNK_IDD_DEPTH` — IDD-push pipeline depth override (default 2; the call site clamps to its `OUT_RING`). /// `PUNKTFUNK_IDD_DEPTH` — IDD-push pipeline depth override (default 2; the call site clamps to its `OUT_RING`).
pub idd_depth: usize, pub idd_depth: usize,
/// `PUNKTFUNK_ZEROCOPY` — opt into the Windows D3D11 zero-copy encode path (presence semantics; see module docs). /// `PUNKTFUNK_ZEROCOPY` — Windows D3D11 zero-copy encode input override. `None` (unset) defers to
pub zerocopy: bool, /// the per-vendor default (AMF on, QSV off — see module docs and `encode/ffmpeg_win.rs`).
pub zerocopy: Option<bool>,
/// `PUNKTFUNK_10BIT` — host policy gate for HEVC Main10 (only honored when the client also advertised 10-bit). /// `PUNKTFUNK_10BIT` — host policy gate for HEVC Main10 (only honored when the client also advertised 10-bit).
pub ten_bit: bool, pub ten_bit: bool,
/// `PUNKTFUNK_444` — host policy gate for full-chroma HEVC 4:4:4 (Range Extensions). Honored only /// `PUNKTFUNK_444` — host policy gate for full-chroma HEVC 4:4:4 (Range Extensions). Honored only
@@ -84,7 +88,9 @@ impl HostConfig {
idd_depth: val("PUNKTFUNK_IDD_DEPTH") idd_depth: val("PUNKTFUNK_IDD_DEPTH")
.and_then(|s| s.parse::<usize>().ok()) .and_then(|s| s.parse::<usize>().ok())
.unwrap_or(2), .unwrap_or(2),
zerocopy: flag("PUNKTFUNK_ZEROCOPY"), zerocopy: val("PUNKTFUNK_ZEROCOPY").map(|s| {
!matches!(s.trim().to_ascii_lowercase().as_str(), "0" | "false" | "off" | "no")
}),
ten_bit: flag("PUNKTFUNK_10BIT"), ten_bit: flag("PUNKTFUNK_10BIT"),
four_four_four: flag("PUNKTFUNK_444"), four_four_four: flag("PUNKTFUNK_444"),
perf: flag("PUNKTFUNK_PERF"), perf: flag("PUNKTFUNK_PERF"),
@@ -8,21 +8,23 @@
//! BGRA/Rgb10a2 as a fallback) on the capturer's own `ID3D11Device`. Two input paths, chosen lazily //! BGRA/Rgb10a2 as a fallback) on the capturer's own `ID3D11Device`. Two input paths, chosen lazily
//! from the first frame and the `PUNKTFUNK_ZEROCOPY` knob: //! from the first frame and the `PUNKTFUNK_ZEROCOPY` knob:
//! //!
//! * **System-memory** ([`SystemInner`], the default): read the captured D3D11 surface back to a CPU //! * **System-memory** ([`SystemInner`]): read the captured D3D11 surface back to a CPU
//! NV12/P010 [`AVFrame`] (a same-format `CopyResource` → staging → `Map`, plus a `swscale` step for //! NV12/P010 [`AVFrame`] (a same-format `CopyResource` → staging → `Map`, plus a `swscale` step for
//! the BGRA fallback) and `avcodec_send_frame` it. AMF/QSV upload it internally. One //! the BGRA fallback) and `avcodec_send_frame` it. AMF/QSV upload it internally. One
//! GPU→CPU→GPU round-trip per frame — the robust path, and the only one that can be brought up //! GPU→CPU→GPU round-trip per frame — the robust path, the QSV default, and the automatic
//! without on-glass validation (it is the analogue of the VAAPI "CPU input" fallback). //! fallback when the zero-copy setup fails (it is the analogue of the VAAPI "CPU input" fallback).
//! * **Zero-copy D3D11** ([`ZeroCopyInner`], `PUNKTFUNK_ZEROCOPY=1`): wrap the capturer's //! * **Zero-copy D3D11** ([`ZeroCopyInner`], the AMF default; see [`zerocopy_enabled`]): wrap the
//! `ID3D11Device` as an `AV_HWDEVICE_TYPE_D3D11VA` hwdevice (shared, *not* a second device — the //! capturer's `ID3D11Device` as an `AV_HWDEVICE_TYPE_D3D11VA` hwdevice (shared, *not* a second
//! capture textures are not shared-handle, so a different device couldn't read them), keep an //! device — the capture textures are not shared-handle, so a different device couldn't read them),
//! FFmpeg D3D11 frames pool, `CopySubresourceRegion` the captured texture into a pooled array //! keep an FFmpeg D3D11 frames pool, `CopySubresourceRegion` the captured texture into a pooled
//! slice (a GPU-local copy, like NVENC's CUDA path), then feed AMF `AV_PIX_FMT_D3D11` directly, //! array slice (a GPU-local copy, like NVENC's CUDA path), then feed AMF `AV_PIX_FMT_D3D11`
//! or map the D3D11 frame to a derived QSV surface for QSV. If the hw setup fails to open, this //! directly, or map the D3D11 frame to a derived QSV surface for QSV. If the hw setup fails to
//! falls back to the system-memory path for the session. //! open, this falls back to the system-memory path for the session.
//! //!
//! **Status: compiles in CI; not yet on-glass validated** (no AMD/Intel Windows box in the lab as of //! **Status:** AMF on-glass validated 2026-07-06 (Ryzen 7000 iGPU, 1080p120 HDR P010, both input
//! 2026-06-22). The system path is the conservative default; zero-copy is opt-in until validated. //! paths; zero-copy cut `submit_us` p50 2.8 ms → 0.26 ms) — zero-copy is the AMF default. QSV is
//! still not on-glass validated (no Intel Windows box in the lab), so its zero-copy path stays
//! opt-in via `PUNKTFUNK_ZEROCOPY=1`.
//! //!
//! Raw FFI: `ffmpeg-next` has no hwcontext wrappers for D3D11VA, so the hwdevice/hwframes calls go //! Raw FFI: `ffmpeg-next` has no hwcontext wrappers for D3D11VA, so the hwdevice/hwframes calls go
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The //! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
@@ -108,10 +110,16 @@ impl WinVendor {
} }
} }
/// Is the zero-copy D3D11 path enabled? Opt-in (`PUNKTFUNK_ZEROCOPY=1`) until on-glass validated; /// Is the zero-copy D3D11 path enabled for this vendor? An explicit `PUNKTFUNK_ZEROCOPY`
/// the default is the robust system-memory readback path. /// (`0|false|off|no` = off, anything else = on) overrides; unset defers to the per-vendor default:
fn zerocopy_enabled() -> bool { /// **on for AMF** — on-glass validated 2026-07-06 (Ryzen iGPU, 1080p120 HDR P010: `submit_us` p50
crate::config::config().zerocopy /// 2.8 ms → 0.26 ms vs readback) — and **off for QSV** until validated on Intel glass (the
/// open-failure fallback only catches *setup* errors; a derive that opens but maps wrong would
/// corrupt silently, so it stays opt-in per the probe-never-assume rule).
fn zerocopy_enabled(vendor: WinVendor) -> bool {
crate::config::config()
.zerocopy
.unwrap_or(matches!(vendor, WinVendor::Amf))
} }
/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only). /// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
@@ -771,9 +779,9 @@ impl Drop for SystemInner {
} }
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
// Zero-copy D3D11 path (PUNKTFUNK_ZEROCOPY=1): share the capture device, pool D3D11 frames, copy // Zero-copy D3D11 path (the AMF default; QSV opt-in — see `zerocopy_enabled`): share the capture
// the captured texture into a pooled slice, feed AMF directly / map to QSV. Falls back to the // device, pool D3D11 frames, copy the captured texture into a pooled slice, feed AMF directly /
// system path if the hw setup fails to open. Untested on glass — opt-in only for now. // map to QSV. Falls back to the system path if the hw setup fails to open.
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
struct D3d11Hw { struct D3d11Hw {
@@ -1199,7 +1207,7 @@ impl FfmpegWinEncoder {
} }
self.inner = None; self.inner = None;
self.bound_device = dev_raw; self.bound_device = dev_raw;
let inner = if zerocopy_enabled() { let inner = if zerocopy_enabled(self.vendor) {
match ZeroCopyInner::open( match ZeroCopyInner::open(
self.vendor, self.vendor,
self.codec, self.codec,
+1 -1
View File
@@ -36,7 +36,7 @@ On Linux the host **rewrites `WAYLAND_DISPLAY` / `XDG_CURRENT_DESKTOP` / `XDG_RU
|---|---|---| |---|---|---|
| `PUNKTFUNK_COMPOSITOR` | `kwin` · `mutter` · `gamescope` · `wlroots` (aliases: `kde`/`plasma`, `gnome`, `sway`/`hyprland`) | Which backend creates the virtual display. **Leave unset to auto-detect;** set only to force one. | | `PUNKTFUNK_COMPOSITOR` | `kwin` · `mutter` · `gamescope` · `wlroots` (aliases: `kde`/`plasma`, `gnome`, `sway`/`hyprland`) | Which backend creates the virtual display. **Leave unset to auto-detect;** set only to force one. |
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. | | `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. |
| `PUNKTFUNK_ZEROCOPY` | `1` · `0` *(default on)* | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). **On by default** — no need to set it; it falls back to a CPU path automatically. Set `0` to force the CPU path. | | `PUNKTFUNK_ZEROCOPY` | `1` · `0` *(default on)* | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). **On by default** — no need to set it; it falls back to a CPU path automatically. Set `0` to force the CPU path. One exception: Windows **Intel/QSV** keeps the CPU path by default until zero-copy is validated on Intel hardware — set `1` to try it there. |
| `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots. Auto-detected with the compositor. | | `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots. Auto-detected with the compositor. |
| `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` (Windows) · `software` | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV. `software` (aliases `sw`/`openh264`) is the GPU-less H.264 path on both platforms — on Windows `auto` falls back to it when no GPU is found; on Linux it is **explicit-only** (`auto` never picks it). | | `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` (Windows) · `software` | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV. `software` (aliases `sw`/`openh264`) is the GPU-less H.264 path on both platforms — on Windows `auto` falls back to it when no GPU is found; on Linux it is **explicit-only** (`auto` never picks it). |
| `PUNKTFUNK_RENDER_NODE` | path | Linux DRM render node for zero-copy (default `/dev/dri/renderD128`). Set on multi-GPU boxes to pick the right GPU. | | `PUNKTFUNK_RENDER_NODE` | path | Linux DRM render node for zero-copy (default `/dev/dri/renderD128`). Set on multi-GPU boxes to pick the right GPU. |