From 17f824c3e9cb9149dec76c70dca9d997c857cd6d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 28 Jul 2026 14:38:08 +0200 Subject: [PATCH] feat(encode/nvenc): an HDR capture stays zero-copy on NVIDIA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/pf-encode/src/enc/linux/nvenc_cuda.rs | 118 +++++++++++++++---- crates/pf-encode/src/lib.rs | 33 ++++++ crates/pf-zerocopy/src/imp/cursor_blend.comp | 49 +++++++- crates/pf-zerocopy/src/imp/cursor_blend.spv | Bin 20528 -> 24732 bytes crates/pf-zerocopy/src/imp/vkslot.rs | 76 ++++++++---- 5 files changed, 224 insertions(+), 52 deletions(-) diff --git a/crates/pf-encode/src/enc/linux/nvenc_cuda.rs b/crates/pf-encode/src/enc/linux/nvenc_cuda.rs index 77569b44..344e8a24 100644 --- a/crates/pf-encode/src/enc/linux/nvenc_cuda.rs +++ b/crates/pf-encode/src/enc/linux/nvenc_cuda.rs @@ -558,21 +558,46 @@ fn retrieve_loop( } } -/// The NVENC input buffer format for a captured `DeviceBuffer`'s layout. NV12/YUV444 are the zero- -/// copy worker's convert outputs; packed RGB (`ABGR`) is the fallback where NVENC does the internal -/// CSC. 10-bit is never produced on Linux today (Phase 5.1), so everything is 8-bit. -fn buffer_format(buf: &cuda::DeviceBuffer) -> nv::NV_ENC_BUFFER_FORMAT { +/// The NVENC input buffer format for a captured frame. NV12/YUV444 are the zero-copy worker's +/// convert outputs and are recognised from the `DeviceBuffer`'s layout; the packed formats are 4 +/// bytes per pixel either way, so their DEPTH and channel order can only come from the capture +/// 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 { nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444 } else if buf.is_nv12() { nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 } else { - // 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 + match fmt { + // `x:R:G:B` 2:10:10:10 LE — NVENC's `ARGB10` is the same word layout (B in the low + // 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 /// use (device→device) and the registration is created once at session init, unregistered at teardown. struct RingSlot { @@ -596,6 +621,10 @@ fn slot_fmt_of(fmt: nv::NV_ENC_BUFFER_FORMAT) -> SlotFormat { match fmt { nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444 => SlotFormat::Yuv444, 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, } } @@ -796,9 +825,10 @@ unsafe impl Send for NvencCudaEncoder {} impl NvencCudaEncoder { /// 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 - /// captured `DeviceBuffer`'s layout (lazy init in `submit`), and this backend only accepts CUDA - /// frames (a CPU/dmabuf payload `bail`s). `bit_depth` is pinned to 8 on Linux (Phase 5.1 will - /// lift it once P010 capture exists). + /// captured frame (lazy init in `submit`), and this backend only accepts CUDA frames (a + /// CPU/dmabuf payload `bail`s). The effective `bit_depth`/`hdr` are derived from that same + /// 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)] pub fn open( 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 // clear reason instead of an opaque session error on the first frame. 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 { encoder: ptr::null_mut(), cu_ctx: ptr::null_mut(), @@ -835,7 +856,9 @@ impl NvencCudaEncoder { fps, bitrate_bps, 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. chroma_444: chroma.is_444() && codec == Codec::H265, yuv444_supported: false, @@ -1164,8 +1187,8 @@ impl NvencCudaEncoder { let mut cfg = preset.presetCfg; // 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 - // 8-bit today, so AV1's input-depth is 0. + // low-latency contract. On Linux the full-chroma input is a YUV444 surface; AV1's + // input-depth follows the surface format (10-bit for a packed PQ/BT.2020 HDR capture). let yuv444_input = matches!( self.buffer_fmt, nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444 @@ -1180,7 +1203,11 @@ impl NvencCudaEncoder { chroma_444: self.chroma_444, full_chroma_input: yuv444_input, 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, rfi_supported: self.rfi_supported, slices: self.slices, @@ -1676,7 +1703,7 @@ impl Encoder for NvencCudaEncoder { self.maybe_disengage_async(); // 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. - let new_fmt = buffer_format(buf); + let new_fmt = buffer_format(buf, captured.format); let size_changed = self.inited && (self.width != captured.width || self.height != captured.height); let fmt_changed = self.inited && self.buffer_fmt != new_fmt; @@ -1697,6 +1724,21 @@ impl Encoder for NvencCudaEncoder { self.width = captured.width; self.height = captured.height; 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 // can't reconstruct full chroma, so clear the flag so `caps().chroma_444` is truthful. self.chroma_444 = self.chroma_444 && buf.yuv444; @@ -2407,6 +2449,32 @@ mod tests { use pf_frame::{CapturedFrame, FramePayload, PixelFormat}; 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 { // 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). diff --git a/crates/pf-encode/src/lib.rs b/crates/pf-encode/src/lib.rs index 99fe0eae..c4432332 100644 --- a/crates/pf-encode/src/lib.rs +++ b/crates/pf-encode/src/lib.rs @@ -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 // 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. + // + // 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")] if matches!(codec, Codec::H265 | Codec::Av1) && 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`] /// ([`EncoderCaps::blends_cursor`]) — answered BEFORE capture opens, so the host plans cursor /// delivery honestly instead of discovering a cursorless stream after the fact (the diff --git a/crates/pf-zerocopy/src/imp/cursor_blend.comp b/crates/pf-zerocopy/src/imp/cursor_blend.comp index 5edb7795..70a21860 100644 --- a/crates/pf-zerocopy/src/imp/cursor_blend.comp +++ b/crates/pf-zerocopy/src/imp/cursor_blend.comp @@ -8,8 +8,17 @@ // // 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 -// pitch*surfH). BT.709 limited-range coefficients — identical to rgb2nv12_buf.comp and the -// retired .cu, so the cursor colour matches the frame regardless of backend. +// pitch*surfH), 3 = packed 10-bit x:R:G:B 2:10:10:10 LE (NVENC ARGB10), 4 = packed 10-bit +// 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 // 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 // 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 -// file; or glslc — CI diffs the disassembly against this source) +// Rebuild (vendored beside this file; CI diffs the disassembly against this source), either +// 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; @@ -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 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. void rmw_byte(uint word_idx, uint lane, uint val8, uint a) { uint w = surf[word_idx]; @@ -67,6 +94,20 @@ void rmw_byte(uint word_idx, uint lane, uint val8, uint a) { } 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) { // ARGB: one invocation per cursor pixel; each surface pixel is one exclusive word. int cx = int(gl_GlobalInvocationID.x); diff --git a/crates/pf-zerocopy/src/imp/cursor_blend.spv b/crates/pf-zerocopy/src/imp/cursor_blend.spv index f9b9604e732c277cf46322496171f6188759aa64..a74c2fd7fc24f5306cb55ec86aaad986b0c31de2 100644 GIT binary patch literal 24732 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 diff --git a/crates/pf-zerocopy/src/imp/vkslot.rs b/crates/pf-zerocopy/src/imp/vkslot.rs index 187826b6..9bf703da 100644 --- a/crates/pf-zerocopy/src/imp/vkslot.rs +++ b/crates/pf-zerocopy/src/imp/vkslot.rs @@ -44,6 +44,10 @@ use ash::vk; /// Max cursor-overlay bitmap edge (px) — matches [`cuda::CURSOR_MAX`] and the capture-side clamp. 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 /// `glslangValidator -V cursor_blend.comp -o cursor_blend.spv`; CI gates drift). const CURSOR_SPV: &[u8] = include_bytes!("cursor_blend.spv"); @@ -58,6 +62,13 @@ pub enum SlotFormat { Nv12, /// Planar YUV444: three full-res planes stacked at `pitch × height` intervals. 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 { @@ -66,19 +77,35 @@ impl SlotFormat { SlotFormat::Argb => 0, SlotFormat::Nv12 => 1, 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 { + if self.is_packed32() { + return width as u64 * 4; + } match self { - SlotFormat::Argb => width as u64 * 4, SlotFormat::Nv12 | SlotFormat::Yuv444 => width as u64, + _ => unreachable!("packed formats returned above"), } } fn rows(self, height: u32) -> u64 { + if self.is_packed32() { + return height as u64; + } match self { - SlotFormat::Argb => height as u64, SlotFormat::Nv12 => height as u64 + (height as u64 / 2).max(1), SlotFormat::Yuv444 => height as u64 * 3, + _ => unreachable!("packed formats returned above"), } } } @@ -159,7 +186,7 @@ pub struct VkSlotBlend { pipe_layout: vk::PipelineLayout, desc_pool: vk::DescriptorPool, /// 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. cur_buf: vk::Buffer, cur_mem: vk::DeviceMemory, @@ -281,7 +308,7 @@ impl VkSlotBlend { desc_layout: vk::DescriptorSetLayout::null(), pipe_layout: vk::PipelineLayout::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_mem: vk::DeviceMemory::null(), cur_map: std::ptr::null_mut(), @@ -470,7 +497,7 @@ impl VkSlotBlend { self.shader = d .create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&words), None) .context("create blend shader module")?; - for mode in 0u32..3 { + for mode in 0u32..PIPELINE_MODES { let entries = [vk::SpecializationMapEntry::default() .constant_id(0) .offset(0) @@ -768,24 +795,27 @@ impl VkSlotBlend { ox, oy, }; - let (gx, gy) = match fmt { - SlotFormat::Argb => (cw.div_ceil(8), ch.div_ceil(8)), - _ => { - let x0 = (ox >> 2) << 2; - let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32; - let rows = match fmt { - SlotFormat::Nv12 => { - // 2-row blocks anchored to the SURFACE chroma grid (cursor_blend.comp - // derives the same y0): count the blocks covering luma rows - // [oy, oy+ch) — one more than ch/2 when oy is odd. - let first = oy.div_euclid(2); - let last = (oy + ch as i32 - 1).div_euclid(2); - (last - first + 1) as u32 - } - _ => ch, - }; - (spans.div_ceil(8), rows.div_ceil(8)) - } + // `is_packed32`, not `== Argb`: the two 10-bit HDR formats are packed 32-bit words too, so + // they take the one-invocation-per-pixel arm exactly as ARGB does. Everything else is the + // word-aligned-span arm. + let (gx, gy) = if fmt.is_packed32() { + // One invocation per cursor pixel = one exclusively-owned 32-bit word. + (cw.div_ceil(8), ch.div_ceil(8)) + } else { + let x0 = (ox >> 2) << 2; + let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32; + let rows = match fmt { + SlotFormat::Nv12 => { + // 2-row blocks anchored to the SURFACE chroma grid (cursor_blend.comp + // derives the same y0): count the blocks covering luma rows + // [oy, oy+ch) — one more than ch/2 when oy is odd. + let first = oy.div_euclid(2); + let last = (oy + ch as i32 - 1).div_euclid(2); + (last - first + 1) as u32 + } + _ => ch, + }; + (spans.div_ceil(8), rows.div_ceil(8)) }; Some((push, gx, gy)) }