feat(encode/nvenc): an HDR capture stays zero-copy on NVIDIA
AMD/Intel needed no new encoder code for HDR — the VAAPI path already ingests an XR30 dmabuf into `format=p010:out_color_matrix=bt2020`. NVIDIA did: the 10-bit formats were excluded from the GPU import outright, so an HDR session fell back to a CPU readback plus swscale, which is the one thing the capture path is not allowed to ship. It turns out no CSC kernel is needed. NVENC ingests packed 10-bit RGB natively as `ARGB10`/`ABGR10` and does the conversion itself following the configured VUI matrix — which `apply_low_latency_config` already sets to BT.2020 NCL for an HDR session. So the frame travels LINEAR dmabuf → Vulkan bridge → CUDA → NVENC unconverted: no host CSC pass, no depth loss, no extra work on a contended SM. * invariant 1 is restated rather than dropped: HDR must never take the TILED EGL de-tile blit (it renders into an 8-bit `GL_RGBA8` texture). The HDR pods are LINEAR-only by construction, so the plan may build the importer; the per-frame gate — which sees the negotiated modifier the plan cannot — is what enforces the tiled half, and falls back to the CPU path if a producer ever ignores our offer. * …but only where the encoder can actually take the payload (`linux_hdr_cuda_ok`). libav's HDR route builds a P010 hardware frames context and swscales into it, so on a host without the direct-SDK backend a packed-2:10:10:10 CUDA buffer would land in a P010 surface as garbage. Those keep the CPU path. * `nvenc_cuda` stops pinning 8-bit/SDR. Depth and HDR now follow the INPUT format, like the Windows backend: a 10-bit session whose capture came back 8-bit encodes AND labels 8-bit rather than mislabelling. * the cursor-blend compute shader gains two 10-bit modes, so the pointer gamescope leaves out of its node survives the HDR path. Same display-referred blend the CPU path's `composite_cursor_rgb10` already does — the samples are PQ, and a real sRGB→PQ cursor LUT is polish, not correctness for a pointer.
This commit is contained in:
@@ -558,21 +558,46 @@ fn retrieve_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The NVENC input buffer format for a captured `DeviceBuffer`'s layout. NV12/YUV444 are the zero-
|
/// The NVENC input buffer format for a captured frame. NV12/YUV444 are the zero-copy worker's
|
||||||
/// copy worker's convert outputs; packed RGB (`ABGR`) is the fallback where NVENC does the internal
|
/// convert outputs and are recognised from the `DeviceBuffer`'s layout; the packed formats are 4
|
||||||
/// CSC. 10-bit is never produced on Linux today (Phase 5.1), so everything is 8-bit.
|
/// bytes per pixel either way, so their DEPTH and channel order can only come from the capture
|
||||||
fn buffer_format(buf: &cuda::DeviceBuffer) -> nv::NV_ENC_BUFFER_FORMAT {
|
/// format — which is why `fmt` is a parameter and not something derived from `buf`.
|
||||||
|
///
|
||||||
|
/// Packed RGB lets NVENC do the CSC internally, which is exactly what an HDR gamescope session
|
||||||
|
/// wants: the frame is already PQ-encoded BT.2020 RGB, and NVENC's internal conversion follows the
|
||||||
|
/// configured VUI matrix (BT.2020 NCL for HDR — see `apply_low_latency_config`), so there is no
|
||||||
|
/// host-side CSC pass and no depth loss anywhere on the path.
|
||||||
|
fn buffer_format(buf: &cuda::DeviceBuffer, fmt: pf_frame::PixelFormat) -> nv::NV_ENC_BUFFER_FORMAT {
|
||||||
if buf.yuv444 {
|
if buf.yuv444 {
|
||||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444
|
||||||
} else if buf.is_nv12() {
|
} else if buf.is_nv12() {
|
||||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12
|
||||||
} else {
|
} else {
|
||||||
// Packed 4-byte BGRA-order (the `copy_device_to_device` fallback path); NVENC's `ARGB`
|
match fmt {
|
||||||
// ingests this layout + does the internal CSC, matching the proven Windows RGB-input path.
|
// `x:R:G:B` 2:10:10:10 LE — NVENC's `ARGB10` is the same word layout (B in the low
|
||||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB
|
// 10 bits, R in bits 20-29).
|
||||||
|
pf_frame::PixelFormat::X2Rgb10 => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB10,
|
||||||
|
// `x:B:G:R` 2:10:10:10 LE — NVENC's `ABGR10` (R in the low 10 bits).
|
||||||
|
pf_frame::PixelFormat::X2Bgr10 => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10,
|
||||||
|
// Packed 4-byte BGRA-order (the `copy_device_to_device` fallback path); NVENC's `ARGB`
|
||||||
|
// ingests this layout + does the internal CSC, matching the proven Windows RGB-input
|
||||||
|
// path.
|
||||||
|
_ => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Is `fmt` one of NVENC's packed 10-bit RGB inputs? Decides the session's effective bit depth and
|
||||||
|
/// HDR flag — the input format is the only honest source for both (a 10-bit-negotiated session
|
||||||
|
/// whose capture came back 8-bit must encode, and label, 8-bit).
|
||||||
|
fn is_ten_bit_input(fmt: nv::NV_ENC_BUFFER_FORMAT) -> bool {
|
||||||
|
matches!(
|
||||||
|
fmt,
|
||||||
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB10
|
||||||
|
| nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// One encoder-owned input surface + its NVENC registration. The surface is copied into each
|
/// One encoder-owned input surface + its NVENC registration. The surface is copied into each
|
||||||
/// use (device→device) and the registration is created once at session init, unregistered at teardown.
|
/// use (device→device) and the registration is created once at session init, unregistered at teardown.
|
||||||
struct RingSlot {
|
struct RingSlot {
|
||||||
@@ -596,6 +621,10 @@ fn slot_fmt_of(fmt: nv::NV_ENC_BUFFER_FORMAT) -> SlotFormat {
|
|||||||
match fmt {
|
match fmt {
|
||||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444 => SlotFormat::Yuv444,
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444 => SlotFormat::Yuv444,
|
||||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => SlotFormat::Nv12,
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => SlotFormat::Nv12,
|
||||||
|
// Still 4 bytes per pixel, so the slot GEOMETRY matches `Argb` — but the cursor blend
|
||||||
|
// must unpack 10-bit channels instead of bytes, hence a separate mode per channel order.
|
||||||
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB10 => SlotFormat::X2Rgb10,
|
||||||
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10 => SlotFormat::X2Bgr10,
|
||||||
_ => SlotFormat::Argb,
|
_ => SlotFormat::Argb,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -796,9 +825,10 @@ unsafe impl Send for NvencCudaEncoder {}
|
|||||||
impl NvencCudaEncoder {
|
impl NvencCudaEncoder {
|
||||||
/// Signature mirrors `super::NvencEncoder::open` so the Linux dispatcher fork is a one-line swap.
|
/// Signature mirrors `super::NvencEncoder::open` so the Linux dispatcher fork is a one-line swap.
|
||||||
/// `format`/`cuda` are advisory: the session's real input format is derived from the first
|
/// `format`/`cuda` are advisory: the session's real input format is derived from the first
|
||||||
/// captured `DeviceBuffer`'s layout (lazy init in `submit`), and this backend only accepts CUDA
|
/// captured frame (lazy init in `submit`), and this backend only accepts CUDA frames (a
|
||||||
/// frames (a CPU/dmabuf payload `bail`s). `bit_depth` is pinned to 8 on Linux (Phase 5.1 will
|
/// CPU/dmabuf payload `bail`s). The effective `bit_depth`/`hdr` are derived from that same
|
||||||
/// lift it once P010 capture exists).
|
/// input format rather than trusted from the negotiation — a 10-bit session whose capture came
|
||||||
|
/// back 8-bit must encode 8-bit AND say so, never mislabel.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn open(
|
pub fn open(
|
||||||
codec: Codec,
|
codec: Codec,
|
||||||
@@ -815,16 +845,7 @@ impl NvencCudaEncoder {
|
|||||||
// The runtime `.so` load is the real "is NVENC possible here" gate: fail the open with a
|
// The runtime `.so` load is the real "is NVENC possible here" gate: fail the open with a
|
||||||
// clear reason instead of an opaque session error on the first frame.
|
// clear reason instead of an opaque session error on the first frame.
|
||||||
try_api().map_err(|e| anyhow!("NVENC (Linux direct) unavailable: {e}"))?;
|
try_api().map_err(|e| anyhow!("NVENC (Linux direct) unavailable: {e}"))?;
|
||||||
if bit_depth >= 10 {
|
|
||||||
// An HDR (GNOME 50 portal) session never reaches this backend: its X2RGB10 frames ride
|
|
||||||
// the CPU/dmabuf paths (no CUDA import for the 10-bit formats yet), so the dispatcher
|
|
||||||
// opens the libav P010 path instead. Reaching here 10-bit means a CUDA capture payload
|
|
||||||
// on a 10-bit session — not wired; encode 8-bit rather than mislabel.
|
|
||||||
tracing::warn!(
|
|
||||||
"Linux direct-NVENC: 10-bit requested but the CUDA capture path has no 10-bit \
|
|
||||||
import yet (HDR rides the libav P010 path) — encoding 8-bit SDR"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
encoder: ptr::null_mut(),
|
encoder: ptr::null_mut(),
|
||||||
cu_ctx: ptr::null_mut(),
|
cu_ctx: ptr::null_mut(),
|
||||||
@@ -835,7 +856,9 @@ impl NvencCudaEncoder {
|
|||||||
fps,
|
fps,
|
||||||
bitrate_bps,
|
bitrate_bps,
|
||||||
buffer_fmt: nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12,
|
buffer_fmt: nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12,
|
||||||
bit_depth: 8,
|
// Provisional until the first frame names the real input format (see `submit`'s init
|
||||||
|
// block, which sets both from `buffer_fmt`).
|
||||||
|
bit_depth,
|
||||||
// 4:4:4 is HEVC-only; confirmed against the frame layout + GPU support at init.
|
// 4:4:4 is HEVC-only; confirmed against the frame layout + GPU support at init.
|
||||||
chroma_444: chroma.is_444() && codec == Codec::H265,
|
chroma_444: chroma.is_444() && codec == Codec::H265,
|
||||||
yuv444_supported: false,
|
yuv444_supported: false,
|
||||||
@@ -1164,8 +1187,8 @@ impl NvencCudaEncoder {
|
|||||||
let mut cfg = preset.presetCfg;
|
let mut cfg = preset.presetCfg;
|
||||||
|
|
||||||
// Steps 3-7 (RC/VBV, tier+level, chroma+bit-depth, colour VUI, RFI DPB) are the shared
|
// Steps 3-7 (RC/VBV, tier+level, chroma+bit-depth, colour VUI, RFI DPB) are the shared
|
||||||
// low-latency contract. On Linux the full-chroma input is a YUV444 surface and the input is
|
// low-latency contract. On Linux the full-chroma input is a YUV444 surface; AV1's
|
||||||
// 8-bit today, so AV1's input-depth is 0.
|
// input-depth follows the surface format (10-bit for a packed PQ/BT.2020 HDR capture).
|
||||||
let yuv444_input = matches!(
|
let yuv444_input = matches!(
|
||||||
self.buffer_fmt,
|
self.buffer_fmt,
|
||||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444
|
||||||
@@ -1180,7 +1203,11 @@ impl NvencCudaEncoder {
|
|||||||
chroma_444: self.chroma_444,
|
chroma_444: self.chroma_444,
|
||||||
full_chroma_input: yuv444_input,
|
full_chroma_input: yuv444_input,
|
||||||
bit_depth: self.bit_depth,
|
bit_depth: self.bit_depth,
|
||||||
av1_input_depth_minus8: 0,
|
av1_input_depth_minus8: if is_ten_bit_input(self.buffer_fmt) {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
hdr: self.hdr,
|
hdr: self.hdr,
|
||||||
rfi_supported: self.rfi_supported,
|
rfi_supported: self.rfi_supported,
|
||||||
slices: self.slices,
|
slices: self.slices,
|
||||||
@@ -1676,7 +1703,7 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
self.maybe_disengage_async();
|
self.maybe_disengage_async();
|
||||||
// Re-init on a size change (the capturer can return at a different resolution after a mode
|
// Re-init on a size change (the capturer can return at a different resolution after a mode
|
||||||
// switch). Format changes (NV12↔YUV444) likewise re-init.
|
// switch). Format changes (NV12↔YUV444) likewise re-init.
|
||||||
let new_fmt = buffer_format(buf);
|
let new_fmt = buffer_format(buf, captured.format);
|
||||||
let size_changed =
|
let size_changed =
|
||||||
self.inited && (self.width != captured.width || self.height != captured.height);
|
self.inited && (self.width != captured.width || self.height != captured.height);
|
||||||
let fmt_changed = self.inited && self.buffer_fmt != new_fmt;
|
let fmt_changed = self.inited && self.buffer_fmt != new_fmt;
|
||||||
@@ -1697,6 +1724,21 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
self.width = captured.width;
|
self.width = captured.width;
|
||||||
self.height = captured.height;
|
self.height = captured.height;
|
||||||
self.buffer_fmt = new_fmt;
|
self.buffer_fmt = new_fmt;
|
||||||
|
// Depth + HDR follow the INPUT, like the Windows backend: a packed 10-bit PQ/BT.2020
|
||||||
|
// capture (an HDR gamescope output) selects Main10 / AV1 10-bit and the BT.2020 PQ
|
||||||
|
// colour signalling; anything else is 8-bit SDR. Deriving it here rather than
|
||||||
|
// trusting the negotiated depth is what keeps the label and the bitstream in step
|
||||||
|
// when capture and negotiation disagree.
|
||||||
|
let ten_bit_in = is_ten_bit_input(new_fmt);
|
||||||
|
if self.bit_depth >= 10 && !ten_bit_in {
|
||||||
|
tracing::warn!(
|
||||||
|
format = ?captured.format,
|
||||||
|
"Linux direct-NVENC: 10-bit negotiated but the capture delivered an 8-bit \
|
||||||
|
format — encoding 8-bit SDR (the stream is labelled to match)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.bit_depth = if ten_bit_in { 10 } else { 8 };
|
||||||
|
self.hdr = ten_bit_in;
|
||||||
// 4:4:4 honesty: engage FREXT only on a genuine YUV444 input; a subsampled NV12/RGB input
|
// 4:4:4 honesty: engage FREXT only on a genuine YUV444 input; a subsampled NV12/RGB input
|
||||||
// can't reconstruct full chroma, so clear the flag so `caps().chroma_444` is truthful.
|
// can't reconstruct full chroma, so clear the flag so `caps().chroma_444` is truthful.
|
||||||
self.chroma_444 = self.chroma_444 && buf.yuv444;
|
self.chroma_444 = self.chroma_444 && buf.yuv444;
|
||||||
@@ -2407,6 +2449,32 @@ mod tests {
|
|||||||
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||||
use pf_zerocopy::cuda::DeviceBuffer;
|
use pf_zerocopy::cuda::DeviceBuffer;
|
||||||
|
|
||||||
|
/// The 10-bit input mapping is load-bearing in a way a smoke test can't reach: pick the wrong
|
||||||
|
/// NVENC format for a packed 2:10:10:10 capture and the encoder reads the words as 8-bit
|
||||||
|
/// `ARGB` — a picture that decodes, looks *almost* right, and is silently 8-bit with the
|
||||||
|
/// channels shifted. These are the two tables that decide it.
|
||||||
|
#[test]
|
||||||
|
fn ten_bit_rgb_maps_to_the_matching_nvenc_format_and_blend_mode() {
|
||||||
|
use nv::NV_ENC_BUFFER_FORMAT as F;
|
||||||
|
// `x:R:G:B` (B in the low bits) is NVENC's ARGB10; `x:B:G:R` is ABGR10.
|
||||||
|
assert!(is_ten_bit_input(F::NV_ENC_BUFFER_FORMAT_ARGB10));
|
||||||
|
assert!(is_ten_bit_input(F::NV_ENC_BUFFER_FORMAT_ABGR10));
|
||||||
|
assert!(!is_ten_bit_input(F::NV_ENC_BUFFER_FORMAT_ARGB));
|
||||||
|
assert!(!is_ten_bit_input(F::NV_ENC_BUFFER_FORMAT_NV12));
|
||||||
|
assert!(!is_ten_bit_input(F::NV_ENC_BUFFER_FORMAT_YUV444));
|
||||||
|
// …and each gets the cursor-blend mode that unpacks ITS channel order. Swapping these
|
||||||
|
// would tint the pointer (R and B exchanged) with nothing else out of place.
|
||||||
|
assert_eq!(
|
||||||
|
slot_fmt_of(F::NV_ENC_BUFFER_FORMAT_ARGB10),
|
||||||
|
SlotFormat::X2Rgb10
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
slot_fmt_of(F::NV_ENC_BUFFER_FORMAT_ABGR10),
|
||||||
|
SlotFormat::X2Bgr10
|
||||||
|
);
|
||||||
|
assert_eq!(slot_fmt_of(F::NV_ENC_BUFFER_FORMAT_ARGB), SlotFormat::Argb);
|
||||||
|
}
|
||||||
|
|
||||||
fn nv12_frame(w: u32, h: u32, i: u32) -> CapturedFrame {
|
fn nv12_frame(w: u32, h: u32, i: u32) -> CapturedFrame {
|
||||||
// Content is uninitialized device memory — NVENC encodes it fine; this smoke test asserts the
|
// Content is uninitialized device memory — NVENC encodes it fine; this smoke test asserts the
|
||||||
// session/registration/encode/RFI machinery, not picture fidelity (that's the on-glass A/B).
|
// session/registration/encode/RFI machinery, not picture fidelity (that's the on-glass A/B).
|
||||||
|
|||||||
@@ -365,6 +365,12 @@ fn open_video_backend_linux(
|
|||||||
// An HDR session (10-bit + a PQ/BT.2020 capture format) must skip the Vulkan Video
|
// An HDR session (10-bit + a PQ/BT.2020 capture format) must skip the Vulkan Video
|
||||||
// backend — it hardcodes an 8-bit 4:2:0 BT.709 CSC — and take the libav VAAPI path,
|
// backend — it hardcodes an 8-bit 4:2:0 BT.709 CSC — and take the libav VAAPI path,
|
||||||
// which has the P010/Main10/PQ wiring. SDR sessions keep the Vulkan default.
|
// which has the P010/Main10/PQ wiring. SDR sessions keep the Vulkan default.
|
||||||
|
//
|
||||||
|
// Two things ride this switch, and both are the accepted cost of AMD/Intel HDR until
|
||||||
|
// Vulkan Video learns 10-bit: the Vulkan backend's real RFI loss recovery, and its
|
||||||
|
// compute-CSC **cursor blend**. A gamescope HDR session therefore streams without the
|
||||||
|
// host-composited XFixes pointer (gamescope has no embedded-cursor mode to fall back
|
||||||
|
// to) — `open_video`'s `blends_cursor` backstop logs it per session.
|
||||||
#[cfg(feature = "vulkan-encode")]
|
#[cfg(feature = "vulkan-encode")]
|
||||||
if matches!(codec, Codec::H265 | Codec::Av1)
|
if matches!(codec, Codec::H265 | Codec::Av1)
|
||||||
&& vulkan_encode_enabled()
|
&& vulkan_encode_enabled()
|
||||||
@@ -1002,6 +1008,33 @@ pub fn linux_native_nv12_ok(codec: Codec) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Can this host's encode path ingest a **packed 10-bit PQ/BT.2020 CUDA payload** — i.e. may an
|
||||||
|
/// HDR capture stay zero-copy on NVIDIA?
|
||||||
|
///
|
||||||
|
/// Only the direct-SDK NVENC backend can: it registers the buffer as an `ARGB10`/`ABGR10` input
|
||||||
|
/// surface and does the BT.2020 CSC in the encoder itself. The libav fallback cannot — its HDR
|
||||||
|
/// route builds a **P010** hardware frames context and swscales the RGB into it, so handing it a
|
||||||
|
/// packed-10-bit CUDA buffer would copy 2:10:10:10 words into a P010 surface and stream garbage.
|
||||||
|
/// So when the direct path is compiled out or vetoed (`PUNKTFUNK_NVENC_DIRECT=0`), the capturer
|
||||||
|
/// must NOT build the importer for an HDR session and the frames take the CPU path instead — the
|
||||||
|
/// same route HDR took before the direct path learned 10-bit.
|
||||||
|
///
|
||||||
|
/// Resolved by the host facade into [`pf_capture::ZeroCopyPolicy`], like every other
|
||||||
|
/// encode-backend fact capture is allowed to know (the one-way capture→encode edge).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn linux_hdr_cuda_ok() -> bool {
|
||||||
|
#[cfg(feature = "nvenc")]
|
||||||
|
{
|
||||||
|
// Same two terms `open_nvenc_probed` uses to take the direct arm — minus `cuda`, which is
|
||||||
|
// the very thing the caller is deciding.
|
||||||
|
nvenc_direct_enabled() && !linux_zero_copy_is_vaapi()
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "nvenc"))]
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the encode backend this session will resolve to composites [`CapturedFrame::cursor`]
|
/// Whether the encode backend this session will resolve to composites [`CapturedFrame::cursor`]
|
||||||
/// ([`EncoderCaps::blends_cursor`]) — answered BEFORE capture opens, so the host plans cursor
|
/// ([`EncoderCaps::blends_cursor`]) — answered BEFORE capture opens, so the host plans cursor
|
||||||
/// delivery honestly instead of discovering a cursorless stream after the fact (the
|
/// delivery honestly instead of discovering a cursorless stream after the fact (the
|
||||||
|
|||||||
@@ -8,8 +8,17 @@
|
|||||||
//
|
//
|
||||||
// MODE (spec constant): 0 = packed 4-byte ARGB (NVENC byte order B,G,R,A), 1 = NV12 (Y plane +
|
// MODE (spec constant): 0 = packed 4-byte ARGB (NVENC byte order B,G,R,A), 1 = NV12 (Y plane +
|
||||||
// interleaved half-res UV at row surfH), 2 = planar YUV444 (3 full-res planes stacked at
|
// interleaved half-res UV at row surfH), 2 = planar YUV444 (3 full-res planes stacked at
|
||||||
// pitch*surfH). BT.709 limited-range coefficients — identical to rgb2nv12_buf.comp and the
|
// pitch*surfH), 3 = packed 10-bit x:R:G:B 2:10:10:10 LE (NVENC ARGB10), 4 = packed 10-bit
|
||||||
// retired .cu, so the cursor colour matches the frame regardless of backend.
|
// x:B:G:R (NVENC ABGR10). BT.709 limited-range coefficients for the YUV modes — identical to
|
||||||
|
// rgb2nv12_buf.comp and the retired .cu, so the cursor colour matches the frame regardless of
|
||||||
|
// backend.
|
||||||
|
//
|
||||||
|
// The 10-bit modes are the HDR path: the surface holds PQ-encoded BT.2020 samples and NVENC does
|
||||||
|
// the CSC itself, so the blend stays in RGB — it scales the 8-bit cursor to 10 bits and blends in
|
||||||
|
// the destination's own (PQ) encoding. That is display-referred, i.e. an approximation, exactly
|
||||||
|
// like the 8-bit gamma-space blend above and byte-for-byte what the CPU path's
|
||||||
|
// `composite_cursor_rgb10` does. A real sRGB→PQ cursor LUT is polish, not correctness for a
|
||||||
|
// pointer.
|
||||||
//
|
//
|
||||||
// The surface SSBO is uint[] (no 8-bit storage dependency — maximum driver reach): every
|
// The surface SSBO is uint[] (no 8-bit storage dependency — maximum driver reach): every
|
||||||
// invocation exclusively owns the 32-bit words it read-modify-writes. ARGB: one invocation per
|
// invocation exclusively owns the 32-bit words it read-modify-writes. ARGB: one invocation per
|
||||||
@@ -19,8 +28,10 @@
|
|||||||
// block rows are likewise anchored to the surface chroma grid (even rows), so each UV sample's
|
// block rows are likewise anchored to the surface chroma grid (even rows), so each UV sample's
|
||||||
// 2x2 footprint is exactly the luma rows it averages, at any `oy`.
|
// 2x2 footprint is exactly the luma rows it averages, at any `oy`.
|
||||||
//
|
//
|
||||||
// Rebuild: glslangValidator -V cursor_blend.comp -o cursor_blend.spv (vendored beside this
|
// Rebuild (vendored beside this file; CI diffs the disassembly against this source), either
|
||||||
// file; or glslc — CI diffs the disassembly against this source)
|
// compiler — target SPIR-V 1.0 to keep the driver reach the module was chosen for:
|
||||||
|
// glslc --target-env=vulkan1.0 cursor_blend.comp -o cursor_blend.spv
|
||||||
|
// glslangValidator -V --target-env vulkan1.0 cursor_blend.comp -o cursor_blend.spv
|
||||||
|
|
||||||
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
|
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
|
||||||
|
|
||||||
@@ -57,6 +68,22 @@ uint y_of(uvec4 s) {
|
|||||||
float u_of(uvec4 s) { return 128.0 - 0.1006 * float(s.r) - 0.3386 * float(s.g) + 0.4392 * float(s.b); }
|
float u_of(uvec4 s) { return 128.0 - 0.1006 * float(s.r) - 0.3386 * float(s.g) + 0.4392 * float(s.b); }
|
||||||
float v_of(uvec4 s) { return 128.0 + 0.4392 * float(s.r) - 0.3989 * float(s.g) - 0.0403 * float(s.b); }
|
float v_of(uvec4 s) { return 128.0 + 0.4392 * float(s.r) - 0.3989 * float(s.g) - 0.0403 * float(s.b); }
|
||||||
|
|
||||||
|
// Read-modify-write one packed-2:10:10:10 pixel. `r_shift` is R's bit offset (20 for x:R:G:B,
|
||||||
|
// 0 for x:B:G:R); G is always at 10 and B sits at the opposite end from R. Matches
|
||||||
|
// pw_cursor.rs::composite_cursor_rgb10 exactly, including the 8→10-bit expansion (replicate the
|
||||||
|
// top 2 bits into the bottom) and the preservation of the top 2 (alpha/x) bits.
|
||||||
|
void rmw_rgb10(uint idx, uvec4 s, uint r_shift) {
|
||||||
|
uint b_shift = 20u - r_shift;
|
||||||
|
uint w = surf[idx];
|
||||||
|
uint sr = (s.r << 2) | (s.r >> 6);
|
||||||
|
uint sg = (s.g << 2) | (s.g >> 6);
|
||||||
|
uint sb = (s.b << 2) | (s.b >> 6);
|
||||||
|
uint dr = (((w >> r_shift) & 0x3FFu) * (255u - s.a) + sr * s.a) / 255u;
|
||||||
|
uint dg = (((w >> 10u) & 0x3FFu) * (255u - s.a) + sg * s.a) / 255u;
|
||||||
|
uint db = (((w >> b_shift) & 0x3FFu) * (255u - s.a) + sb * s.a) / 255u;
|
||||||
|
surf[idx] = (w & 0xC0000000u) | (dr << r_shift) | (dg << 10u) | (db << b_shift);
|
||||||
|
}
|
||||||
|
|
||||||
// Read-modify-write one byte lane of a word index.
|
// Read-modify-write one byte lane of a word index.
|
||||||
void rmw_byte(uint word_idx, uint lane, uint val8, uint a) {
|
void rmw_byte(uint word_idx, uint lane, uint val8, uint a) {
|
||||||
uint w = surf[word_idx];
|
uint w = surf[word_idx];
|
||||||
@@ -67,6 +94,20 @@ void rmw_byte(uint word_idx, uint lane, uint val8, uint a) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
if (MODE == 3u || MODE == 4u) {
|
||||||
|
// Packed 10-bit: same one-invocation-per-pixel/word geometry as MODE 0.
|
||||||
|
int cx = int(gl_GlobalInvocationID.x);
|
||||||
|
int cy = int(gl_GlobalInvocationID.y);
|
||||||
|
if (cx >= int(pc.curW) || cy >= int(pc.curH)) return;
|
||||||
|
int px = pc.ox + cx, py = pc.oy + cy;
|
||||||
|
if (px < 0 || py < 0 || px >= int(pc.surfW) || py >= int(pc.surfH)) return;
|
||||||
|
uvec4 s = cursor_px(cx, cy);
|
||||||
|
if (s.a == 0u) return;
|
||||||
|
uint idx = (uint(py) * pc.pitch + uint(px) * 4u) / 4u;
|
||||||
|
rmw_rgb10(idx, s, MODE == 3u ? 20u : 0u);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (MODE == 0u) {
|
if (MODE == 0u) {
|
||||||
// ARGB: one invocation per cursor pixel; each surface pixel is one exclusive word.
|
// ARGB: one invocation per cursor pixel; each surface pixel is one exclusive word.
|
||||||
int cx = int(gl_GlobalInvocationID.x);
|
int cx = int(gl_GlobalInvocationID.x);
|
||||||
|
|||||||
Binary file not shown.
@@ -44,6 +44,10 @@ use ash::vk;
|
|||||||
/// Max cursor-overlay bitmap edge (px) — matches [`cuda::CURSOR_MAX`] and the capture-side clamp.
|
/// Max cursor-overlay bitmap edge (px) — matches [`cuda::CURSOR_MAX`] and the capture-side clamp.
|
||||||
pub const CURSOR_MAX: u32 = cuda::CURSOR_MAX;
|
pub const CURSOR_MAX: u32 = cuda::CURSOR_MAX;
|
||||||
|
|
||||||
|
/// Number of `cursor_blend.comp` MODE variants — one specialized pipeline each, indexed by
|
||||||
|
/// [`SlotFormat::mode`]. Bump together with the shader's MODE list.
|
||||||
|
const PIPELINE_MODES: u32 = 5;
|
||||||
|
|
||||||
/// The vendored SPIR-V for `cursor_blend.comp` (beside this file; rebuild with
|
/// The vendored SPIR-V for `cursor_blend.comp` (beside this file; rebuild with
|
||||||
/// `glslangValidator -V cursor_blend.comp -o cursor_blend.spv`; CI gates drift).
|
/// `glslangValidator -V cursor_blend.comp -o cursor_blend.spv`; CI gates drift).
|
||||||
const CURSOR_SPV: &[u8] = include_bytes!("cursor_blend.spv");
|
const CURSOR_SPV: &[u8] = include_bytes!("cursor_blend.spv");
|
||||||
@@ -58,6 +62,13 @@ pub enum SlotFormat {
|
|||||||
Nv12,
|
Nv12,
|
||||||
/// Planar YUV444: three full-res planes stacked at `pitch × height` intervals.
|
/// Planar YUV444: three full-res planes stacked at `pitch × height` intervals.
|
||||||
Yuv444,
|
Yuv444,
|
||||||
|
/// Packed 10-bit `x:R:G:B` 2:10:10:10 LE (NVENC `ARGB10`) — the HDR capture format, handed to
|
||||||
|
/// NVENC unconverted. Same 4-bytes-per-pixel geometry as [`Argb`](Self::Argb); it needs its
|
||||||
|
/// own mode only because the blend must unpack 10-bit channels instead of bytes.
|
||||||
|
X2Rgb10,
|
||||||
|
/// Packed 10-bit `x:B:G:R` 2:10:10:10 LE (NVENC `ABGR10`) — [`X2Rgb10`](Self::X2Rgb10) with
|
||||||
|
/// R and B swapped.
|
||||||
|
X2Bgr10,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SlotFormat {
|
impl SlotFormat {
|
||||||
@@ -66,19 +77,35 @@ impl SlotFormat {
|
|||||||
SlotFormat::Argb => 0,
|
SlotFormat::Argb => 0,
|
||||||
SlotFormat::Nv12 => 1,
|
SlotFormat::Nv12 => 1,
|
||||||
SlotFormat::Yuv444 => 2,
|
SlotFormat::Yuv444 => 2,
|
||||||
|
SlotFormat::X2Rgb10 => 3,
|
||||||
|
SlotFormat::X2Bgr10 => 4,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// True for the layouts that are one 32-bit word per pixel — the same slot geometry AND the
|
||||||
|
/// same one-invocation-per-pixel dispatch, whatever the per-channel packing inside the word.
|
||||||
|
fn is_packed32(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
SlotFormat::Argb | SlotFormat::X2Rgb10 | SlotFormat::X2Bgr10
|
||||||
|
)
|
||||||
|
}
|
||||||
fn row_bytes(self, width: u32) -> u64 {
|
fn row_bytes(self, width: u32) -> u64 {
|
||||||
|
if self.is_packed32() {
|
||||||
|
return width as u64 * 4;
|
||||||
|
}
|
||||||
match self {
|
match self {
|
||||||
SlotFormat::Argb => width as u64 * 4,
|
|
||||||
SlotFormat::Nv12 | SlotFormat::Yuv444 => width as u64,
|
SlotFormat::Nv12 | SlotFormat::Yuv444 => width as u64,
|
||||||
|
_ => unreachable!("packed formats returned above"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn rows(self, height: u32) -> u64 {
|
fn rows(self, height: u32) -> u64 {
|
||||||
|
if self.is_packed32() {
|
||||||
|
return height as u64;
|
||||||
|
}
|
||||||
match self {
|
match self {
|
||||||
SlotFormat::Argb => height as u64,
|
|
||||||
SlotFormat::Nv12 => height as u64 + (height as u64 / 2).max(1),
|
SlotFormat::Nv12 => height as u64 + (height as u64 / 2).max(1),
|
||||||
SlotFormat::Yuv444 => height as u64 * 3,
|
SlotFormat::Yuv444 => height as u64 * 3,
|
||||||
|
_ => unreachable!("packed formats returned above"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +186,7 @@ pub struct VkSlotBlend {
|
|||||||
pipe_layout: vk::PipelineLayout,
|
pipe_layout: vk::PipelineLayout,
|
||||||
desc_pool: vk::DescriptorPool,
|
desc_pool: vk::DescriptorPool,
|
||||||
/// One pipeline per [`SlotFormat`], indexed by `mode()` (spec constant).
|
/// One pipeline per [`SlotFormat`], indexed by `mode()` (spec constant).
|
||||||
pipelines: [vk::Pipeline; 3],
|
pipelines: [vk::Pipeline; PIPELINE_MODES as usize],
|
||||||
/// Host-visible cursor bitmap staging (CURSOR_MAX²·4, tight rows), persistently mapped.
|
/// Host-visible cursor bitmap staging (CURSOR_MAX²·4, tight rows), persistently mapped.
|
||||||
cur_buf: vk::Buffer,
|
cur_buf: vk::Buffer,
|
||||||
cur_mem: vk::DeviceMemory,
|
cur_mem: vk::DeviceMemory,
|
||||||
@@ -281,7 +308,7 @@ impl VkSlotBlend {
|
|||||||
desc_layout: vk::DescriptorSetLayout::null(),
|
desc_layout: vk::DescriptorSetLayout::null(),
|
||||||
pipe_layout: vk::PipelineLayout::null(),
|
pipe_layout: vk::PipelineLayout::null(),
|
||||||
desc_pool: vk::DescriptorPool::null(),
|
desc_pool: vk::DescriptorPool::null(),
|
||||||
pipelines: [vk::Pipeline::null(); 3],
|
pipelines: [vk::Pipeline::null(); PIPELINE_MODES as usize],
|
||||||
cur_buf: vk::Buffer::null(),
|
cur_buf: vk::Buffer::null(),
|
||||||
cur_mem: vk::DeviceMemory::null(),
|
cur_mem: vk::DeviceMemory::null(),
|
||||||
cur_map: std::ptr::null_mut(),
|
cur_map: std::ptr::null_mut(),
|
||||||
@@ -470,7 +497,7 @@ impl VkSlotBlend {
|
|||||||
self.shader = d
|
self.shader = d
|
||||||
.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&words), None)
|
.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&words), None)
|
||||||
.context("create blend shader module")?;
|
.context("create blend shader module")?;
|
||||||
for mode in 0u32..3 {
|
for mode in 0u32..PIPELINE_MODES {
|
||||||
let entries = [vk::SpecializationMapEntry::default()
|
let entries = [vk::SpecializationMapEntry::default()
|
||||||
.constant_id(0)
|
.constant_id(0)
|
||||||
.offset(0)
|
.offset(0)
|
||||||
@@ -768,24 +795,27 @@ impl VkSlotBlend {
|
|||||||
ox,
|
ox,
|
||||||
oy,
|
oy,
|
||||||
};
|
};
|
||||||
let (gx, gy) = match fmt {
|
// `is_packed32`, not `== Argb`: the two 10-bit HDR formats are packed 32-bit words too, so
|
||||||
SlotFormat::Argb => (cw.div_ceil(8), ch.div_ceil(8)),
|
// they take the one-invocation-per-pixel arm exactly as ARGB does. Everything else is the
|
||||||
_ => {
|
// word-aligned-span arm.
|
||||||
let x0 = (ox >> 2) << 2;
|
let (gx, gy) = if fmt.is_packed32() {
|
||||||
let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32;
|
// One invocation per cursor pixel = one exclusively-owned 32-bit word.
|
||||||
let rows = match fmt {
|
(cw.div_ceil(8), ch.div_ceil(8))
|
||||||
SlotFormat::Nv12 => {
|
} else {
|
||||||
// 2-row blocks anchored to the SURFACE chroma grid (cursor_blend.comp
|
let x0 = (ox >> 2) << 2;
|
||||||
// derives the same y0): count the blocks covering luma rows
|
let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32;
|
||||||
// [oy, oy+ch) — one more than ch/2 when oy is odd.
|
let rows = match fmt {
|
||||||
let first = oy.div_euclid(2);
|
SlotFormat::Nv12 => {
|
||||||
let last = (oy + ch as i32 - 1).div_euclid(2);
|
// 2-row blocks anchored to the SURFACE chroma grid (cursor_blend.comp
|
||||||
(last - first + 1) as u32
|
// derives the same y0): count the blocks covering luma rows
|
||||||
}
|
// [oy, oy+ch) — one more than ch/2 when oy is odd.
|
||||||
_ => ch,
|
let first = oy.div_euclid(2);
|
||||||
};
|
let last = (oy + ch as i32 - 1).div_euclid(2);
|
||||||
(spans.div_ceil(8), rows.div_ceil(8))
|
(last - first + 1) as u32
|
||||||
}
|
}
|
||||||
|
_ => ch,
|
||||||
|
};
|
||||||
|
(spans.div_ceil(8), rows.div_ceil(8))
|
||||||
};
|
};
|
||||||
Some((push, gx, gy))
|
Some((push, gx, gy))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user