feat(linux/vulkan-encode): default-on native NV12 capture + RGB true-extent
ci / web (push) Successful in 48s
ci / docs-site (push) Successful in 1m5s
apple / swift (push) Successful in 1m20s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 25s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / bench (push) Successful in 5m59s
apple / screenshots (push) Successful in 6m17s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6m29s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m16s
deb / build-publish (push) Successful in 12m58s
deb / build-publish-host (push) Successful in 13m40s
windows-host / package (push) Successful in 10m46s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m52s
android / android (push) Successful in 16m18s
arch / build-publish (push) Successful in 16m20s
docker / deploy-docs (push) Successful in 30s
ci / rust (push) Successful in 19m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m19s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m19s
ci / web (push) Successful in 48s
ci / docs-site (push) Successful in 1m5s
apple / swift (push) Successful in 1m20s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 25s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / bench (push) Successful in 5m59s
apple / screenshots (push) Successful in 6m17s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6m29s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m16s
deb / build-publish (push) Successful in 12m58s
deb / build-publish-host (push) Successful in 13m40s
windows-host / package (push) Successful in 10m46s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m52s
android / android (push) Successful in 16m18s
arch / build-publish (push) Successful in 16m20s
docker / deploy-docs (push) Successful in 30s
ci / rust (push) Successful in 19m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m19s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m19s
Flip both zero-copy levers from opt-in to default: - The capture negotiation now PREFERS gamescope's producer-side NV12 pod by default (PUNKTFUNK_PIPEWIRE_NV12=0 restores the packed-RGB negotiation). Codec-aware gating rides a new OutputFormat::nv12_native -> ZeroCopyPolicy::native_nv12_session edge resolved by the host facade from the session plan (pf_encode::linux_native_nv12_ok): only H265/AV1 sessions whose backend can open the raw Vulkan Video encoder ever see the NV12 pod -- an H264/Moonlight session (libav VAAPI, which would misread the two-plane buffer) keeps today's BGRx negotiation, as do the GameStream-resolve and portal-mirror paths, PyroWave, and NVENC prefs. - RGB-direct's unaligned modes default to the true-extent direct import (PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT=0 restores the padded-copy staging). Guarded-tested on Van Gogh with the kernel journal watched: clean, and at 5.38 ms p50 the fastest 1080p encode path measured on that hardware. The EFC only exists on Mesa >= 26, where the codedExtent-driven session_init padding is guaranteed (verified back to Mesa 24.2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -259,6 +259,12 @@ pub struct ZeroCopyPolicy {
|
||||
/// passthrough (like the VAAPI backend) instead of the EGL→CUDA import whose payloads only
|
||||
/// NVENC can consume. Per-session (the codec is negotiated), unlike `backend_is_vaapi`.
|
||||
pub pyrowave_session: bool,
|
||||
/// THIS session's encoder can ingest a producer-native NV12 capture (the Linux raw Vulkan
|
||||
/// Video backend on an H265/AV1 session — resolved by the host facade via
|
||||
/// `pf_encode::linux_native_nv12_ok`). Gates whether the negotiation PREFERS gamescope's
|
||||
/// producer-side NV12 pod: libav VAAPI (H264's backend) would misread the two-plane buffer,
|
||||
/// so H264/GameStream/PyroWave sessions must never see NV12 frames.
|
||||
pub native_nv12_session: bool,
|
||||
/// The PyroWave encoder's Vulkan-importable dmabuf modifiers for the capture's packed-RGB fourcc,
|
||||
/// resolved when the session encodes PyroWave (the passthrough advertises them so Mutter+NVIDIA,
|
||||
/// which allocates tiled-only, still negotiates zero-copy). Empty otherwise.
|
||||
|
||||
@@ -2086,22 +2086,26 @@ mod pipewire {
|
||||
// PyroWave session (the wavelet encoder's own Vulkan device, any vendor) → hand the raw
|
||||
// dmabuf straight to the encoder.
|
||||
let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && raw_passthrough;
|
||||
// Bring-up gate for producer-side NV12. Gamescope offers a one-fd LINEAR NV12 image when
|
||||
// the consumer asks for it; keep this opt-in until the live Vulkan import path is proven.
|
||||
// Raw passthrough is required because CUDA/PyroWave expect packed RGB, and 4:4:4/HDR must
|
||||
// not be silently subsampled/downconverted.
|
||||
let prefer_native_nv12 = std::env::var("PUNKTFUNK_PIPEWIRE_NV12").as_deref() == Ok("1")
|
||||
// Producer-side NV12 (default-on; PUNKTFUNK_PIPEWIRE_NV12=0 escapes): gamescope offers a
|
||||
// one-fd LINEAR NV12 image when the consumer asks — its compositor pass does the RGB→YUV,
|
||||
// and the Vulkan Video encoder imports the buffer as its encode source directly (no host
|
||||
// CSC at all). `native_nv12_session` restricts this to sessions whose encoder can ingest
|
||||
// it (Linux vulkan-encode H265/AV1 — never H264/libav-VAAPI, GameStream-resolve, or
|
||||
// PyroWave, whose Vulkan compute CSC ingests packed RGB only). Raw passthrough is
|
||||
// required because the CUDA importer expects packed RGB, and 4:4:4/HDR must not be
|
||||
// silently subsampled/downconverted. Non-NV12 compositors (KWin/GNOME) simply match the
|
||||
// packed-RGB fallback pod.
|
||||
let prefer_native_nv12 = std::env::var("PUNKTFUNK_PIPEWIRE_NV12").as_deref() != Ok("0")
|
||||
&& policy.native_nv12_session
|
||||
&& backend_is_vaapi
|
||||
&& vaapi_passthrough
|
||||
// PyroWave rides the same raw passthrough but its Vulkan compute CSC ingests packed
|
||||
// RGB only — native NV12 would feed it an unreadable two-plane image.
|
||||
&& !policy.pyrowave_session
|
||||
&& !want_444
|
||||
&& !want_hdr;
|
||||
if prefer_native_nv12 {
|
||||
tracing::info!(
|
||||
"PUNKTFUNK_PIPEWIRE_NV12=1: preferring gamescope producer-side NV12 LINEAR \
|
||||
DMA-BUF (no host RGB CSC)"
|
||||
"zero-copy: preferring gamescope producer-side NV12 LINEAR DMA-BUF (no host \
|
||||
RGB CSC; PUNKTFUNK_PIPEWIRE_NV12=0 restores the packed-RGB negotiation)"
|
||||
);
|
||||
}
|
||||
// Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR
|
||||
|
||||
@@ -84,13 +84,14 @@ fn rgb_request() -> Option<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT=1` (bring-up): at unaligned modes, replace RGB-direct's
|
||||
/// padded-copy staging with a direct import carrying the TRUE-SIZE source `codedExtent` — RADV
|
||||
/// derives nonzero VCN firmware padding from it, so the EFC is told the source lacks the
|
||||
/// alignment rows (see [`RgbDirect::true_extent`]). Off by default until the EFC front-end is
|
||||
/// proven to honor the padding like the YUV fetch path does.
|
||||
/// True-extent RGB-direct at unaligned modes (default ON; `PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT=0`
|
||||
/// restores the padded-copy staging): direct-import the visible-size capture with the TRUE-SIZE
|
||||
/// source `codedExtent` — RADV derives nonzero VCN firmware padding from it, so the EFC is told
|
||||
/// the source lacks the alignment rows (see [`RgbDirect::true_extent`]). Guarded-tested on Van
|
||||
/// Gogh 2026-07-21 (kernel clean, and the fastest 1080p encode path measured); the EFC only
|
||||
/// exists on Mesa ≥ 26, where the `codedExtent`-driven `session_init` is guaranteed.
|
||||
fn rgb_true_extent_request() -> bool {
|
||||
std::env::var("PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT").as_deref() == Ok("1")
|
||||
std::env::var("PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT").as_deref() != Ok("0")
|
||||
}
|
||||
|
||||
/// Live RGB-direct session config: the chroma-siting bits the session was created with
|
||||
@@ -105,13 +106,13 @@ struct RgbDirect {
|
||||
/// with the edge rows/columns duplicated into the padding (transfer-only, no shader) and
|
||||
/// encoded from there. Aligned modes keep the true zero-copy import.
|
||||
padded: bool,
|
||||
/// `PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT=1` bring-up alternative to `padded` at unaligned
|
||||
/// modes: direct-import the visible-size buffer and pass the TRUE-SIZE source
|
||||
/// The default unaligned-mode source strategy (`PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT=0` falls
|
||||
/// back to `padded`): direct-import the visible-size buffer and pass the TRUE-SIZE source
|
||||
/// `codedExtent` — RADV then programs nonzero firmware padding from it (Mesa ≥ 24.2
|
||||
/// derives `session_init` padding from `srcPictureResource.codedExtent`; see
|
||||
/// [`VulkanVideoEncoder::native_nv12`]), telling the VCN the source lacks the alignment
|
||||
/// rows. The session/SPS/DPB stay app-aligned. Field-proven for YUV fetches; the EFC RGB
|
||||
/// front-end honoring the padding is what this gate exists to prove.
|
||||
/// rows, which the hardware edge-extends internally. The session/SPS/DPB stay app-aligned.
|
||||
/// Guarded-tested on Van Gogh (kernel clean; fastest 1080p path measured).
|
||||
true_extent: bool,
|
||||
}
|
||||
|
||||
@@ -696,7 +697,7 @@ impl VulkanVideoEncoder {
|
||||
) =>
|
||||
"active(true-extent: unaligned mode, direct import with the true-size \
|
||||
source codedExtent — RADV firmware padding covers the alignment rows; \
|
||||
PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT bring-up)",
|
||||
PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT=0 restores the padded copy)",
|
||||
(_, _, Some(RgbDirect { padded: false, .. })) => "active",
|
||||
(_, _, Some(RgbDirect { padded: true, .. })) =>
|
||||
"active(padded-copy: mode is not 64x16-aligned — staging blit + edge \
|
||||
|
||||
@@ -351,8 +351,8 @@ fn open_video_backend(
|
||||
Err(e) if format == PixelFormat::Nv12 => {
|
||||
return Err(e.context(
|
||||
"Vulkan Video open failed on a native-NV12 capture \
|
||||
(PUNKTFUNK_PIPEWIRE_NV12=1) — no VAAPI fallback exists; unset the \
|
||||
flag to restore the packed-RGB pipeline",
|
||||
— no VAAPI fallback exists; set PUNKTFUNK_PIPEWIRE_NV12=0 to \
|
||||
restore the packed-RGB negotiation",
|
||||
));
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
@@ -365,10 +365,10 @@ fn open_video_backend(
|
||||
// PUNKTFUNK_VULKAN_ENCODE=0, or a build without the feature).
|
||||
if format == PixelFormat::Nv12 {
|
||||
anyhow::bail!(
|
||||
"native NV12 capture (PUNKTFUNK_PIPEWIRE_NV12=1) requires the Vulkan Video \
|
||||
encoder (HEVC/AV1 session, --features vulkan-encode, \
|
||||
PUNKTFUNK_VULKAN_ENCODE not 0) — this session resolved to libav VAAPI; \
|
||||
unset the flag"
|
||||
"native NV12 capture requires the Vulkan Video encoder (HEVC/AV1 \
|
||||
session, --features vulkan-encode, PUNKTFUNK_VULKAN_ENCODE not 0) — this \
|
||||
session resolved to libav VAAPI; set PUNKTFUNK_PIPEWIRE_NV12=0 to restore \
|
||||
the packed-RGB negotiation"
|
||||
);
|
||||
}
|
||||
vaapi::VaapiEncoder::open(
|
||||
@@ -841,6 +841,33 @@ fn vulkan_encode_enabled() -> bool {
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Whether THIS session's encoder can ingest a producer-native NV12 capture: only the raw
|
||||
/// Vulkan Video backend does (libav VAAPI would misread the two-plane buffer as packed RGB —
|
||||
/// [`open_video`] refuses the combination), so the session's codec must be one it encodes and
|
||||
/// the backend must be eligible to open. The host facade threads the verdict into the capture
|
||||
/// negotiation (`OutputFormat::nv12_native` → `ZeroCopyPolicy::native_nv12_session`), which
|
||||
/// then PREFERS gamescope's producer-side NV12 pod (default-on; `PUNKTFUNK_PIPEWIRE_NV12=0`
|
||||
/// escapes at the capture gate).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn linux_native_nv12_ok(codec: Codec) -> bool {
|
||||
#[cfg(feature = "vulkan-encode")]
|
||||
{
|
||||
matches!(codec, Codec::H265 | Codec::Av1)
|
||||
&& vulkan_encode_enabled()
|
||||
// NVENC/PyroWave prefs never open the Vulkan Video backend; every other pref
|
||||
// (auto/vaapi/amd/intel/vulkan) tries it first on AMD/Intel — see [`open_video`].
|
||||
&& !matches!(
|
||||
pf_host_config::config().encoder_pref.as_str(),
|
||||
"nvenc" | "nvidia" | "cuda" | "pyrowave"
|
||||
)
|
||||
}
|
||||
#[cfg(not(feature = "vulkan-encode"))]
|
||||
{
|
||||
let _ = codec;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA
|
||||
/// kernel driver exposes these device nodes, AMD/Intel boxes have neither. Deliberately does NOT
|
||||
/// create a CUDA context (that would allocate GPU state on every host that merely *might* be
|
||||
|
||||
@@ -146,6 +146,12 @@ pub struct OutputFormat {
|
||||
/// (never BGRA-passthrough / P010). `false` on every non-PyroWave session and on Linux (the
|
||||
/// wavelet encoder ingests dmabufs / CPU RGB there, not a D3D11 texture).
|
||||
pub pyrowave: bool,
|
||||
/// THIS session's encoder can ingest a producer-native NV12 capture (Linux raw Vulkan Video
|
||||
/// backend on an H265/AV1 session — see `pf_encode::linux_native_nv12_ok`). The Linux capture
|
||||
/// negotiation only offers gamescope the NV12 pod when this is set: libav VAAPI (the H264
|
||||
/// codec's backend, and the fallback family) would misread the two-plane buffer as packed
|
||||
/// RGB. Always `false` on Windows (the IDD-push capturer owns its own formats).
|
||||
pub nv12_native: bool,
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
@@ -163,6 +169,11 @@ impl OutputFormat {
|
||||
chroma_444: false,
|
||||
// GameStream never negotiates PyroWave (native punktfunk/1 only).
|
||||
pyrowave: false,
|
||||
// Conservative: the GameStream + spike paths don't resolve the codec here, and a
|
||||
// Moonlight client may negotiate H264 (whose VAAPI backend can't ingest NV12) — so
|
||||
// they never prefer the producer-native NV12 pod. The punktfunk/1 plane opts in via
|
||||
// `SessionPlan::output_format()`, which knows the codec.
|
||||
nv12_native: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ pub use pf_capture::{dxgi, synthetic_nv12};
|
||||
/// capture→encode cycle). Resolved here (the host facade) and threaded in, so the edge stays one-way
|
||||
/// (plan §2.4 / §W6).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn zero_copy_policy(pyrowave_session: bool) -> pf_capture::ZeroCopyPolicy {
|
||||
fn zero_copy_policy(
|
||||
pyrowave_session: bool,
|
||||
native_nv12_session: bool,
|
||||
) -> pf_capture::ZeroCopyPolicy {
|
||||
let backend_is_vaapi = crate::encode::linux_zero_copy_is_vaapi();
|
||||
// The raw-dmabuf passthrough serves a PyroWave session on ANY vendor (the wavelet encoder's
|
||||
// own Vulkan device imports the dmabuf) — per-session from the negotiated codec, plus the
|
||||
@@ -56,6 +59,7 @@ fn zero_copy_policy(pyrowave_session: bool) -> pf_capture::ZeroCopyPolicy {
|
||||
backend_is_gpu: crate::encode::resolved_backend_is_gpu(),
|
||||
pyrowave_session,
|
||||
pyrowave_modifiers,
|
||||
native_nv12_session,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +74,9 @@ pub fn open_portal_monitor(want_hdr: bool) -> Result<Box<dyn Capturer>> {
|
||||
let anchored = crate::inject::default_backend() == crate::inject::Backend::Libei;
|
||||
// Monitor mirrors never carry the native PyroWave plane (GameStream protocol) — per-session
|
||||
// passthrough is virtual-output-only; the global encoder-pref lever still applies inside.
|
||||
pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy(false))
|
||||
// Native NV12 stays off too: the mirror path doesn't resolve the codec here, and the desktop
|
||||
// compositors it mirrors (GNOME/KWin) don't produce NV12 anyway.
|
||||
pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy(false, false))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
@@ -101,7 +107,7 @@ pub fn capture_virtual_output(
|
||||
vout.keepalive,
|
||||
want.gpu,
|
||||
want.chroma_444,
|
||||
zero_copy_policy(want.pyrowave),
|
||||
zero_copy_policy(want.pyrowave, want.nv12_native),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +179,13 @@ impl SessionPlan {
|
||||
// Vulkan device; on Linux the capture facade flips the zero-copy policy to the
|
||||
// raw-dmabuf passthrough (see above).
|
||||
pyrowave: self.codec == crate::encode::Codec::PyroWave,
|
||||
// Producer-native NV12 (gamescope) is consumable only by the Linux Vulkan Video
|
||||
// backend — resolved HERE from the plan's codec so the capturer never reaches back
|
||||
// into encode (the same one-way edge as `gpu` above).
|
||||
#[cfg(target_os = "linux")]
|
||||
nv12_native: crate::encode::linux_native_nv12_ok(self.codec),
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
nv12_native: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user