feat(encode/vulkan): Vulkan Video encodes 10-bit, so AMD/Intel HDR keeps the good path

The Vulkan Video backend was 8-bit for no structural reason — the API has
`VK_VIDEO_COMPONENT_BIT_DEPTH_10_BIT` and `PROFILE_IDC_MAIN_10` in the very
fields this pinned to 8 and MAIN, and AMD VCN and Intel both encode Main10.
It was six hardcoded sites, and the cost of leaving them was paid twice over:
an HDR session had to take libav VAAPI, losing real RFI loss recovery AND the
compute CSC's cursor blend — which on gamescope is the only way the pointer
reaches the stream at all, since gamescope has no embedded-cursor mode.

An HDR session now opens a Main10 profile with 10-bit component depths, a
`G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16` picture + DPB, and an SPS carrying
`bit_depth_*_minus8 = 2` with the BT.2020/PQ CICP triplet instead of BT.709.

`rgb2yuv10.comp` is the CSC's twin, and the two interesting parts of it are:

* it is a PURE 3x3 matrix. The samples arrive already PQ-encoded (gamescope
  composites into the PQ container), so BT.2020 NCL applies to the code values
  as they are — there is no transfer function to apply here and applying one
  would be wrong;
* the scratch planes are `R16`/`RG16`, not the picture's plane formats. The
  10-bit ycbcr plane formats are not storage-image formats, so the shader
  writes the value into the HIGH bits by hand (`code10 << 6`, hence the
  `64/65535` factor and not `1/1023`) into planes that are merely
  SIZE-compatible with the picture's — which is all `vkCmdCopyImage` requires.

Scope and safety:

* HEVC only. AV1 10-bit encode has far thinner driver coverage, and a session
  open is not the place to gamble on it — those stay on VAAPI, as does a device
  that fails the Main10 profile query inside the open (the pre-existing "failed
  Vulkan open falls back to VAAPI" net, no new probe needed).
* HDR pins the compute-CSC arm over the EFC RGB-direct one, which the EFC could
  not serve anyway: its fixed-function conversion is 8-bit BT.709 narrow with
  no knob for BT.2020.
* `open_inner` binds `hdr` to the parameter-set HEADER bytes, so the depth flag
  is `ten_bit` there — the one name collision this change had to route around.
This commit is contained in:
2026-07-28 18:03:30 +02:00
parent 86f4f950ba
commit 479f0965ee
8 changed files with 278 additions and 105 deletions
@@ -0,0 +1,70 @@
#version 450
// The 10-bit HDR twin of `rgb2yuv.comp`: packed 2:10:10:10 RGB -> 10-bit 4:2:0 (BT.2020 NCL,
// limited range), for an HEVC Main10 session off a gamescope HDR capture.
//
// The source samples are ALREADY PQ-encoded BT.2020 R'G'B' (gamescope composites into the PQ
// container; see packaging/gamescope), so this is a pure 3x3 matrix on the code values — there is
// no transfer function to apply here, and applying one would be wrong. That is the whole
// difference from the SDR shader besides the coefficients and the store width.
//
// STORE LAYOUT. The scratch planes are `r16`/`rg16` and get `vkCmdCopyImage`d into the
// `G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16` picture, whose plane texels put the 10-bit value in
// the HIGH bits of a 16-bit word (`X6` = 6 unused low bits). So a written normalized value `f`
// lands as `round(f * 65535)` and must equal `code10 << 6` — hence the `CODE10` factor below,
// NOT `1/1023`. Getting this wrong is a ~1.6% luminance error: invisible on a test pattern,
// visible as crushed blacks on real content.
//
// Rebuild: glslangValidator -V rgb2yuv10.comp -o rgb2yuv10.spv (vendored beside this file)
layout(local_size_x = 8, local_size_y = 8) in;
layout(binding = 0) uniform sampler2D rgb; // packed 2:10:10:10 input (sampled)
layout(binding = 1, r16) uniform writeonly image2D yImg; // full-res Y (10-bit in the high bits)
layout(binding = 2, rg16) uniform writeonly image2D uvImg; // half-res UV (interleaved)
layout(binding = 3) uniform sampler2D cursorTex; // straight-alpha RGBA cursor (top-left)
layout(push_constant) uniform Push {
ivec2 curOrigin; // top-left of the cursor in frame pixels (position - hotspot)
ivec2 curSize; // cursor w,h in pixels; x <= 0 => disabled
} pc;
// 10-bit code value -> the normalized float that stores it in the high bits of a 16-bit texel.
const float CODE10 = 64.0 / 65535.0;
// BT.2020 non-constant-luminance, limited range, expressed directly in 10-bit code values:
// Y' = 64 + 876*(0.2627R + 0.6780G + 0.0593B)
// Cb = 512 + 896*(B' - Y')/1.8814
// Cr = 512 + 896*(R' - Y')/1.4746
float lumaY(vec3 c) { return 64.0 + 230.1252*c.r + 593.9280*c.g + 51.9468*c.b; }
// Blend the cursor over `col` at frame pixel `p`, when `p` falls inside the cursor rectangle.
// The cursor bitmap is 8-bit sRGB and the frame is PQ, so this is a display-referred
// approximation — the same one the CPU path (`pw_cursor.rs::composite_cursor_rgb10`) and the
// CUDA path (`cursor_blend.comp` MODE 3/4) already make. Fine for a pointer.
vec3 withCursor(ivec2 p, vec3 col) {
if (pc.curSize.x <= 0) return col;
ivec2 cp = p - pc.curOrigin;
if (cp.x < 0 || cp.y < 0 || cp.x >= pc.curSize.x || cp.y >= pc.curSize.y) return col;
vec4 c = texelFetch(cursorTex, cp, 0);
return mix(col, c.rgb, c.a);
}
// Source may be SMALLER than the coded (16-aligned) Y plane — clamp every fetch to the source
// edge so the alignment-padding rows duplicate the last real row (see the SDR shader).
void main() {
ivec2 sz = imageSize(yImg);
ivec2 rmax = textureSize(rgb, 0) - 1;
ivec2 uvc = ivec2(gl_GlobalInvocationID.xy);
ivec2 p = uvc * 2;
if (p.x >= sz.x || p.y >= sz.y) return;
vec3 c00 = withCursor(p, texelFetch(rgb, min(p, rmax), 0).rgb);
vec3 c10 = withCursor(p + ivec2(1, 0), texelFetch(rgb, min(p + ivec2(1, 0), rmax), 0).rgb);
vec3 c01 = withCursor(p + ivec2(0, 1), texelFetch(rgb, min(p + ivec2(0, 1), rmax), 0).rgb);
vec3 c11 = withCursor(p + ivec2(1, 1), texelFetch(rgb, min(p + ivec2(1, 1), rmax), 0).rgb);
imageStore(yImg, p, vec4(lumaY(c00) * CODE10, 0, 0, 1));
imageStore(yImg, p + ivec2(1, 0), vec4(lumaY(c10) * CODE10, 0, 0, 1));
imageStore(yImg, p + ivec2(0, 1), vec4(lumaY(c01) * CODE10, 0, 0, 1));
imageStore(yImg, p + ivec2(1, 1), vec4(lumaY(c11) * CODE10, 0, 0, 1));
vec3 a = (c00 + c10 + c01 + c11) * 0.25;
float U = 512.0 - 125.1085*a.r - 322.8915*a.g + 448.0000*a.b;
float V = 512.0 + 448.0000*a.r - 411.9680*a.g - 36.0320*a.b;
imageStore(uvImg, uvc, vec4(U * CODE10, V * CODE10, 0, 1));
}
Binary file not shown.
+47 -19
View File
@@ -225,6 +225,7 @@ pub(super) unsafe fn make_frame(
with_ts: bool, with_ts: bool,
csc: bool, csc: bool,
pad_fmt: Option<vk::Format>, pad_fmt: Option<vk::Format>,
hdr: bool,
f: &mut Frame, f: &mut Frame,
) -> Result<()> { ) -> Result<()> {
// "no cursor uploaded yet" sentinel — a real serial may be 0 (see `prep_cursor`). // "no cursor uploaded yet" sentinel — a real serial may be 0 (see `prep_cursor`).
@@ -263,6 +264,7 @@ pub(super) unsafe fn make_frame(
csc_dsl, csc_dsl,
csc_pool, csc_pool,
sampler, sampler,
hdr,
f, f,
)?; )?;
} }
@@ -291,13 +293,15 @@ unsafe fn make_frame_csc(
csc_dsl: vk::DescriptorSetLayout, csc_dsl: vk::DescriptorSetLayout,
csc_pool: vk::DescriptorPool, csc_pool: vk::DescriptorPool,
sampler: vk::Sampler, sampler: vk::Sampler,
hdr: bool,
f: &mut Frame, f: &mut Frame,
) -> Result<()> { ) -> Result<()> {
// NV12 encode-src (filled by the CSC copy) — concurrent compute+encode. // 4:2:0 encode-src (filled by the CSC copy) — concurrent compute+encode.
let pic = yuv_format(hdr);
(f.nv12_src, f.nv12_mem) = make_video_image( (f.nv12_src, f.nv12_mem) = make_video_image(
device, device,
mem_props, mem_props,
NV12, pic,
w, w,
h, h,
1, 1,
@@ -305,12 +309,22 @@ unsafe fn make_frame_csc(
profile_list, profile_list,
fams, fams,
)?; )?;
f.nv12_view = make_view(device, f.nv12_src, NV12, 0)?; f.nv12_view = make_view(device, f.nv12_src, pic, 0)?;
// CSC scratch (Y R8 full-res, UV RG8 half-res). // CSC scratch: Y full-res + UV half-res, in a single-plane format the shader can declare as a
// storage image AND that is SIZE-COMPATIBLE with the picture's planes (`vkCmdCopyImage`
// between differing formats requires equal texel-block size). 8-bit: R8/RG8 vs the NV12
// planes' 1/2 bytes. 10-bit: R16/RG16 vs the 3PACK16 planes' 2/4 bytes — the 10-bit ycbcr
// plane formats themselves are not storage-image formats, which is why the scratch is 16-bit
// and `rgb2yuv10.comp` writes the value into the high bits by hand.
let (y_fmt, uv_fmt) = if hdr {
(vk::Format::R16_UNORM, vk::Format::R16G16_UNORM)
} else {
(vk::Format::R8_UNORM, vk::Format::R8G8_UNORM)
};
(f.y_img, f.y_mem, f.y_view) = make_plain_image( (f.y_img, f.y_mem, f.y_view) = make_plain_image(
device, device,
mem_props, mem_props,
vk::Format::R8_UNORM, y_fmt,
w, w,
h, h,
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC, vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC,
@@ -318,7 +332,7 @@ unsafe fn make_frame_csc(
(f.uv_img, f.uv_mem, f.uv_view) = make_plain_image( (f.uv_img, f.uv_mem, f.uv_view) = make_plain_image(
device, device,
mem_props, mem_props,
vk::Format::R8G8_UNORM, uv_fmt,
w / 2, w / 2,
h / 2, h / 2,
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC, vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC,
@@ -479,12 +493,20 @@ pub(super) unsafe fn build_parameters_h265(
rw: u32, rw: u32,
rh: u32, rh: u32,
quality_level: u32, quality_level: u32,
// 10-bit HDR session: Main10 + BT.2020/PQ colour signalling. Must agree with the video
// profile the session was CREATED with (`open_inner`'s `ten_bit`) — a Main SPS on a Main10
// session is a bitstream that says one thing and carries another.
ten_bit: bool,
) -> Result<(vk::VideoSessionParametersKHR, Vec<u8>)> { ) -> Result<(vk::VideoSessionParametersKHR, Vec<u8>)> {
use ash::vk::native as hh; use ash::vk::native as hh;
let mut ptl: hh::StdVideoH265ProfileTierLevel = std::mem::zeroed(); let mut ptl: hh::StdVideoH265ProfileTierLevel = std::mem::zeroed();
ptl.flags.set_general_progressive_source_flag(1); ptl.flags.set_general_progressive_source_flag(1);
ptl.flags.set_general_frame_only_constraint_flag(1); ptl.flags.set_general_frame_only_constraint_flag(1);
ptl.general_profile_idc = hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN; ptl.general_profile_idc = if ten_bit {
hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN_10
} else {
hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN
};
ptl.general_level_idc = hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_0; ptl.general_level_idc = hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_0;
let mut dpbm: hh::StdVideoH265DecPicBufMgr = std::mem::zeroed(); let mut dpbm: hh::StdVideoH265DecPicBufMgr = std::mem::zeroed();
@@ -505,6 +527,11 @@ pub(super) unsafe fn build_parameters_h265(
sps.pic_width_in_luma_samples = w; sps.pic_width_in_luma_samples = w;
sps.pic_height_in_luma_samples = h; sps.pic_height_in_luma_samples = h;
sps.log2_max_pic_order_cnt_lsb_minus4 = 4; sps.log2_max_pic_order_cnt_lsb_minus4 = 4;
// Main10's `bit_depth_*_minus8 = 2`. Zeroed (= 8-bit) for Main, as before.
if ten_bit {
sps.bit_depth_luma_minus8 = 2;
sps.bit_depth_chroma_minus8 = 2;
}
sps.log2_diff_max_min_luma_coding_block_size = 3; sps.log2_diff_max_min_luma_coding_block_size = 3;
sps.log2_diff_max_min_luma_transform_block_size = 3; sps.log2_diff_max_min_luma_transform_block_size = 3;
sps.max_transform_hierarchy_depth_inter = 4; sps.max_transform_hierarchy_depth_inter = 4;
@@ -517,25 +544,26 @@ pub(super) unsafe fn build_parameters_h265(
sps.conf_win_bottom_offset = (h - rh) / 2; // 4:2:0 SubHeightC = 2 sps.conf_win_bottom_offset = (h - rh) / 2; // 4:2:0 SubHeightC = 2
} }
// Colour signalling. This backend's CSC (`rgb2yuv.comp`) is BT.709 LIMITED 8-bit and nothing // Colour signalling, exactly what this session's CSC produced: `rgb2yuv.comp` is BT.709
// else — `open_amd_intel` routes every HDR session to VAAPI precisely because this path // limited 8-bit, `rgb2yuv10.comp` is BT.2020 NCL limited 10-bit with a PQ transfer (the
// hardcodes it — so the SPS can state it as a constant. Without the VUI the stream is // samples arrive PQ-encoded from the compositor and the matrix does not touch the transfer).
// "unspecified" and each decoder applies its own default: the punktfunk clients fall back to // Without the VUI the stream is "unspecified" and each decoder applies its own default: the
// BT.709 (`pf_client_core::video_color::csc_rows`), but vendor TV decoders guess from // punktfunk clients fall back to BT.709 (`pf_client_core::video_color::csc_rows`), but vendor
// RESOLUTION — an LG webOS panel reads a 4K SDR stream as BT.2020 and renders it visibly // TV decoders guess from RESOLUTION — an LG webOS panel reads a 4K SDR stream as BT.2020 and
// washed out. Every sibling backend (NVENC `nvenc_core.rs`, VAAPI, QSV, the Windows libav // renders it visibly washed out.
// path) already signals this triplet; this one was the hole.
// //
// `vui` must outlive `create_video_session_parameters_khr` below — it does, `sps_arr` only // `vui` must outlive `create_video_session_parameters_khr` below — it does, `sps_arr` only
// copies the pointer and both live to the end of this function. // copies the pointer and both live to the end of this function.
let mut vui: hh::StdVideoH265SequenceParameterSetVui = std::mem::zeroed(); let mut vui: hh::StdVideoH265SequenceParameterSetVui = std::mem::zeroed();
vui.flags.set_video_signal_type_present_flag(1); vui.flags.set_video_signal_type_present_flag(1);
vui.flags.set_video_full_range_flag(0); // limited/studio swing (16-235 luma) vui.flags.set_video_full_range_flag(0); // limited/studio swing
vui.flags.set_colour_description_present_flag(1); vui.flags.set_colour_description_present_flag(1);
vui.video_format = 5; // unspecified — the CICP triplet below is what matters vui.video_format = 5; // unspecified — the CICP triplet below is what matters
vui.colour_primaries = 1; // BT.709 // CICP code points: 1 = BT.709, 9 = BT.2020 primaries / BT.2020 NCL matrix, 16 = SMPTE 2084.
vui.transfer_characteristics = 1; // BT.709 let (prim, trc, mat) = if ten_bit { (9, 16, 9) } else { (1, 1, 1) };
vui.matrix_coeffs = 1; // BT.709 vui.colour_primaries = prim;
vui.transfer_characteristics = trc;
vui.matrix_coeffs = mat;
sps.flags.set_vui_parameters_present_flag(1); sps.flags.set_vui_parameters_present_flag(1);
sps.pSequenceParameterSetVui = &vui; sps.pSequenceParameterSetVui = &vui;
+18
View File
@@ -50,9 +50,23 @@ pub(crate) fn fourcc_to_vk(fourcc: u32) -> Option<vk::Format> {
const XB24: u32 = 0x3432_4258; // XBGR8888 const XB24: u32 = 0x3432_4258; // XBGR8888
const AB24: u32 = 0x3432_4241; // ABGR8888 const AB24: u32 = 0x3432_4241; // ABGR8888
const NV12: u32 = 0x3231_564e; // DRM_FORMAT_NV12 const NV12: u32 = 0x3231_564e; // DRM_FORMAT_NV12
// The 10-bit HDR capture formats. DRM packs these as a little-endian 32-bit word — XR30 is
// x:R:G:B with B in bits 0-9 — which is exactly Vulkan's `A2R10G10B10_UNORM_PACK32` bit
// layout, so the mapping is an identity on the word and not a byte swizzle like the 8-bit
// pair above.
//
// ⚠ Only `A2B10G10R10_UNORM_PACK32` is a Vulkan-mandatory SAMPLED format; `A2R10G10B10` is
// optional (widely supported on RADV/ANV, and we only ever `texelFetch` it — no filtering).
// Both are offered to the producer, so which one a session lands on is the producer's pick;
// if a device ever rejects the XR30 import, the fix is to drop that format from the capture
// offer rather than to convert here.
const XR30: u32 = 0x3033_5258; // DRM_FORMAT_XRGB2101010
const XB30: u32 = 0x3033_4258; // DRM_FORMAT_XBGR2101010
match fourcc { match fourcc {
XR24 | AR24 => Some(vk::Format::B8G8R8A8_UNORM), XR24 | AR24 => Some(vk::Format::B8G8R8A8_UNORM),
XB24 | AB24 => Some(vk::Format::R8G8B8A8_UNORM), XB24 | AB24 => Some(vk::Format::R8G8B8A8_UNORM),
XR30 => Some(vk::Format::A2R10G10B10_UNORM_PACK32),
XB30 => Some(vk::Format::A2B10G10R10_UNORM_PACK32),
NV12 => Some(vk::Format::G8_B8R8_2PLANE_420_UNORM), NV12 => Some(vk::Format::G8_B8R8_2PLANE_420_UNORM),
_ => None, _ => None,
} }
@@ -62,6 +76,10 @@ pub(crate) fn pixel_to_vk(fmt: PixelFormat) -> Option<vk::Format> {
match fmt { match fmt {
PixelFormat::Bgrx | PixelFormat::Bgra => Some(vk::Format::B8G8R8A8_UNORM), PixelFormat::Bgrx | PixelFormat::Bgra => Some(vk::Format::B8G8R8A8_UNORM),
PixelFormat::Rgbx | PixelFormat::Rgba => Some(vk::Format::R8G8B8A8_UNORM), PixelFormat::Rgbx | PixelFormat::Rgba => Some(vk::Format::R8G8B8A8_UNORM),
// The packed 10-bit PQ/BT.2020 capture formats (an HDR gamescope output). Sampling one
// yields the PQ code values normalized to [0,1] — which is what `rgb2yuv10.comp` wants.
PixelFormat::X2Rgb10 => Some(vk::Format::A2R10G10B10_UNORM_PACK32),
PixelFormat::X2Bgr10 => Some(vk::Format::A2B10G10R10_UNORM_PACK32),
_ => None, _ => None,
} }
} }
+92 -20
View File
@@ -4,8 +4,10 @@
//! slot (no IDR): HEVC via an explicit short-term RPS, AV1 via `ref_frame_idx` + a //! slot (no IDR): HEVC via an explicit short-term RPS, AV1 via `ref_frame_idx` + a
//! `primary_ref_frame = NONE` recovery anchor that also breaks the CDF chain. //! `primary_ref_frame = NONE` recovery anchor that also breaks the CDF chain.
//! //!
//! Capture delivers packed RGB (dmabuf/CPU); this backend imports it, runs an on-GPU RGB→NV12 //! Capture delivers packed RGB (dmabuf/CPU); this backend imports it, runs an on-GPU RGB→4:2:0
//! BT.709 compute CSC, then encodes. Proven end-to-end in `punktfunk-planning/design/vkenc-probe-harness`. //! compute CSC, then encodes — 8-bit BT.709 for an SDR session (`rgb2yuv.comp`), 10-bit BT.2020
//! for an HDR one (`rgb2yuv10.comp` + an HEVC Main10 session; a 10-bit AV1 session is routed to
//! VAAPI instead). Proven end-to-end in `punktfunk-planning/design/vkenc-probe-harness`.
//! Opt-in via `PUNKTFUNK_VULKAN_ENCODE`; gated to HEVC/AV1 + a device that advertises the encode op. //! Opt-in via `PUNKTFUNK_VULKAN_ENCODE`; gated to HEVC/AV1 + a device that advertises the encode op.
//! The AV1 encode structs our pinned `ash 0.38` predates are vendored in `vk_av1_encode.rs`. //! The AV1 encode structs our pinned `ash 0.38` predates are vendored in `vk_av1_encode.rs`.
#![allow(clippy::too_many_arguments)] #![allow(clippy::too_many_arguments)]
@@ -23,12 +25,31 @@ use std::ffi::c_void;
use std::os::fd::AsRawFd; use std::os::fd::AsRawFd;
const NV12: vk::Format = vk::Format::G8_B8R8_2PLANE_420_UNORM; const NV12: vk::Format = vk::Format::G8_B8R8_2PLANE_420_UNORM;
/// The 10-bit 4:2:0 picture/DPB format an HDR (HEVC Main10) session encodes from. `3PACK16`
/// stores each 10-bit sample in the HIGH bits of a 16-bit word — see `rgb2yuv10.comp`, whose
/// scratch planes are the size-compatible `R16`/`RG16` this gets `vkCmdCopyImage`d from.
const P010: vk::Format = vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16;
/// The session's 4:2:0 picture + DPB format for its bit depth. One function so the session
/// create-info, the DPB image, its views and the per-frame encode source cannot drift apart —
/// they must all name the SAME format or session creation fails.
const fn yuv_format(hdr: bool) -> vk::Format {
if hdr {
P010
} else {
NV12
}
}
/// Max resident dmabuf imports (comfortably above any PipeWire pool depth; imports alias existing /// Max resident dmabuf imports (comfortably above any PipeWire pool depth; imports alias existing
/// buffers so this holds handles, not new allocations). /// buffers so this holds handles, not new allocations).
const IMPORT_CACHE_CAP: usize = 16; const IMPORT_CACHE_CAP: usize = 16;
// Prebuilt SPIR-V for the RGB→NV12 BT.709 compute CSC. Source is `rgb2yuv.comp` beside this file; // Prebuilt SPIR-V for the RGB→NV12 BT.709 compute CSC. Source is `rgb2yuv.comp` beside this file;
// regenerate with `glslangValidator -V rgb2yuv.comp -o rgb2yuv.spv` after editing the shader. // regenerate with `glslangValidator -V rgb2yuv.comp -o rgb2yuv.spv` after editing the shader.
const CSC_SPV: &[u8] = include_bytes!("rgb2yuv.spv"); const CSC_SPV: &[u8] = include_bytes!("rgb2yuv.spv");
/// The 10-bit HDR twin (`rgb2yuv10.comp`): packed 2:10:10:10 PQ/BT.2020 RGB → 10-bit 4:2:0,
/// BT.2020 NCL limited. Separate module rather than a spec constant because a shader's storage
/// image FORMAT (`r8`/`rg8` vs `r16`/`rg16`) is part of its layout, not specializable.
const CSC10_SPV: &[u8] = include_bytes!("rgb2yuv10.spv");
/// Fixed cursor-overlay texture size (px). Larger than any real pointer; the actual `w×h` uploads /// Fixed cursor-overlay texture size (px). Larger than any real pointer; the actual `w×h` uploads
/// into the top-left and the shader's push constant bounds sampling, so one allocation fits every /// into the top-left and the shader's push constant bounds sampling, so one allocation fits every
/// cursor and no per-size recreation is needed. See the CSC shader's `cursorTex`/push constant. /// cursor and no per-size recreation is needed. See the CSC shader's `cursorTex`/push constant.
@@ -463,6 +484,9 @@ struct Frame {
uv_img: vk::Image, uv_img: vk::Image,
uv_mem: vk::DeviceMemory, uv_mem: vk::DeviceMemory,
uv_view: vk::ImageView, uv_view: vk::ImageView,
/// The CSC's output picture and this frame's encode source: NV12, or the 10-bit
/// `P010`/3PACK16 twin on an HDR session ([`yuv_format`]). The name predates the second
/// depth; every use goes through the format the session was created with.
nv12_src: vk::Image, nv12_src: vk::Image,
nv12_mem: vk::DeviceMemory, nv12_mem: vk::DeviceMemory,
nv12_view: vk::ImageView, nv12_view: vk::ImageView,
@@ -669,16 +693,30 @@ impl VulkanVideoEncoder {
cursor_blend: bool, cursor_blend: bool,
) -> Result<Self> { ) -> Result<Self> {
let native_nv12 = format == PixelFormat::Nv12; let native_nv12 = format == PixelFormat::Nv12;
// A cursor-blend session must keep the compute-CSC path — the only arm with the cursor // HDR: a packed 10-bit PQ/BT.2020 capture (a gamescope output off our `pipewire-hdr`
// blend — so it outranks an explicit RGB-direct pin (EFC cannot composite; the // build) encodes HEVC Main10 through the 10-bit compute CSC. HEVC only — AV1 10-bit
// negotiation promised the client a composited pointer). // encode has far thinner driver coverage, and `open_amd_intel` keeps those sessions on
if cursor_blend && rgb_request() == Some(true) { // libav VAAPI rather than gambling a session open on it.
tracing::info!( let hdr = format.is_hdr_rgb10();
"PUNKTFUNK_VULKAN_RGB_DIRECT=1 ignored for this session — it composites the \ if hdr && codec != Codec::H265 {
pointer, which the EFC front-end cannot; using the compute-CSC path" bail!(
"vulkan-encode: 10-bit HDR is HEVC Main10 only here (got {codec:?}) — the \
dispatcher should have routed this session to VAAPI"
); );
} }
let want_rgb = !native_nv12 && !cursor_blend && rgb_request().unwrap_or(true); // A cursor-blend session must keep the compute-CSC path — the only arm with the cursor
// blend — so it outranks an explicit RGB-direct pin (EFC cannot composite; the
// negotiation promised the client a composited pointer). HDR pins the same arm for a
// different reason: the EFC's fixed-function conversion is 8-bit BT.709 narrow with no
// knob for BT.2020, so it cannot express this session's colourimetry at all.
if (cursor_blend || hdr) && rgb_request() == Some(true) {
tracing::info!(
hdr,
"PUNKTFUNK_VULKAN_RGB_DIRECT=1 ignored for this session — the EFC front-end can \
neither composite a pointer nor convert BT.2020 10-bit; using the compute-CSC path"
);
}
let want_rgb = !native_nv12 && !cursor_blend && !hdr && rgb_request().unwrap_or(true);
Self::open_opts_inner( Self::open_opts_inner(
codec, codec,
width, width,
@@ -687,6 +725,7 @@ impl VulkanVideoEncoder {
bitrate_bps, bitrate_bps,
want_rgb, want_rgb,
native_nv12, native_nv12,
hdr,
) )
} }
@@ -704,9 +743,19 @@ impl VulkanVideoEncoder {
bitrate_bps: u64, bitrate_bps: u64,
want_rgb: bool, want_rgb: bool,
) -> Result<Self> { ) -> Result<Self> {
Self::open_opts_inner(codec, width, height, fps, bitrate_bps, want_rgb, false) Self::open_opts_inner(
codec,
width,
height,
fps,
bitrate_bps,
want_rgb,
false,
false,
)
} }
#[allow(clippy::too_many_arguments)]
fn open_opts_inner( fn open_opts_inner(
codec: Codec, codec: Codec,
width: u32, width: u32,
@@ -715,6 +764,7 @@ impl VulkanVideoEncoder {
bitrate_bps: u64, bitrate_bps: u64,
want_rgb: bool, want_rgb: bool,
native_nv12: bool, native_nv12: bool,
hdr: bool,
) -> Result<Self> { ) -> Result<Self> {
if !matches!(codec, Codec::H265 | Codec::Av1) { if !matches!(codec, Codec::H265 | Codec::Av1) {
bail!("vulkan-encode backend supports HEVC + AV1 only (got {codec:?})"); bail!("vulkan-encode backend supports HEVC + AV1 only (got {codec:?})");
@@ -737,6 +787,7 @@ impl VulkanVideoEncoder {
bitrate_bps.max(1_000_000), bitrate_bps.max(1_000_000),
want_rgb, want_rgb,
native_nv12, native_nv12,
hdr,
) )
} }
} }
@@ -752,6 +803,8 @@ impl VulkanVideoEncoder {
bitrate: u64, bitrate: u64,
want_rgb: bool, want_rgb: bool,
native_nv12: bool, native_nv12: bool,
// NOT `hdr`: this fn already binds that name to the parameter-set HEADER bytes below.
ten_bit: bool,
) -> Result<Self> { ) -> Result<Self> {
use super::vk_av1_encode as av1b; use super::vk_av1_encode as av1b;
use super::vk_valve_rgb as vrgb; use super::vk_valve_rgb as vrgb;
@@ -871,8 +924,12 @@ impl VulkanVideoEncoder {
p_next: std::ptr::null(), p_next: std::ptr::null(),
perform_encode_rgb_conversion: vk::TRUE, perform_encode_rgb_conversion: vk::TRUE,
}; };
let mut h265_profile = vk::VideoEncodeH265ProfileInfoKHR::default() let mut h265_profile =
.std_profile_idc(vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN); vk::VideoEncodeH265ProfileInfoKHR::default().std_profile_idc(if ten_bit {
vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN_10
} else {
vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN
});
let mut av1_profile = av1b::VideoEncodeAV1ProfileInfoKHR { let mut av1_profile = av1b::VideoEncodeAV1ProfileInfoKHR {
s_type: av1b::stype(av1b::ST_PROFILE_INFO), s_type: av1b::stype(av1b::ST_PROFILE_INFO),
p_next: std::ptr::null(), p_next: std::ptr::null(),
@@ -885,11 +942,20 @@ impl VulkanVideoEncoder {
if rgb_cfg.is_some() { if rgb_cfg.is_some() {
usage.p_next = &rgb_info as *const _ as *const c_void; usage.p_next = &rgb_info as *const _ as *const c_void;
} }
// A device that cannot encode 10-bit fails `get_physical_device_video_capabilities`
// below with VIDEO_PROFILE_FORMAT_NOT_SUPPORTED, which fails the open — and a failed
// Vulkan open falls back to libav VAAPI in `open_amd_intel`. That is the whole 10-bit
// capability gate: no separate probe, and no way to reach a half-configured session.
let depth = if ten_bit {
vk::VideoComponentBitDepthFlagsKHR::TYPE_10
} else {
vk::VideoComponentBitDepthFlagsKHR::TYPE_8
};
let mut profile = vk::VideoProfileInfoKHR::default() let mut profile = vk::VideoProfileInfoKHR::default()
.video_codec_operation(codec_op) .video_codec_operation(codec_op)
.chroma_subsampling(vk::VideoChromaSubsamplingFlagsKHR::TYPE_420) .chroma_subsampling(vk::VideoChromaSubsamplingFlagsKHR::TYPE_420)
.luma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8) .luma_bit_depth(depth)
.chroma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8); .chroma_bit_depth(depth);
if av1 { if av1 {
av1_profile.p_next = &usage as *const _ as *const c_void; av1_profile.p_next = &usage as *const _ as *const c_void;
profile.p_next = &av1_profile as *const _ as *const c_void; profile.p_next = &av1_profile as *const _ as *const c_void;
@@ -1120,13 +1186,13 @@ impl VulkanVideoEncoder {
.picture_format(if rgb_cfg.is_some() { .picture_format(if rgb_cfg.is_some() {
vk::Format::B8G8R8A8_UNORM vk::Format::B8G8R8A8_UNORM
} else { } else {
NV12 yuv_format(ten_bit)
}) })
.max_coded_extent(vk::Extent2D { .max_coded_extent(vk::Extent2D {
width: w, width: w,
height: h, height: h,
}) })
.reference_picture_format(NV12) .reference_picture_format(yuv_format(ten_bit))
.max_dpb_slots(DPB_SLOTS + 1) .max_dpb_slots(DPB_SLOTS + 1)
.max_active_reference_pictures(1) .max_active_reference_pictures(1)
.std_header_version(&std_hdr); .std_header_version(&std_hdr);
@@ -1236,6 +1302,7 @@ impl VulkanVideoEncoder {
rw, rw,
rh, rh,
quality_level, quality_level,
ten_bit,
)?; )?;
(p, hdr, Vec::new()) (p, hdr, Vec::new())
}; };
@@ -1247,7 +1314,7 @@ impl VulkanVideoEncoder {
let (dpb_image, dpb_mem) = make_video_image( let (dpb_image, dpb_mem) = make_video_image(
&device, &device,
&mem_props, &mem_props,
NV12, yuv_format(ten_bit),
w, w,
h, h,
DPB_SLOTS, DPB_SLOTS,
@@ -1260,7 +1327,7 @@ impl VulkanVideoEncoder {
for slot in 0..DPB_SLOTS { for slot in 0..DPB_SLOTS {
guard guard
.dpb_views .dpb_views
.push(make_view(&device, dpb_image, NV12, slot)?); .push(make_view(&device, dpb_image, yuv_format(ten_bit), slot)?);
} }
// NV12 encode-src, CSC scratch (Y/UV), bitstream, query and command buffers are all per // NV12 encode-src, CSC scratch (Y/UV), bitstream, query and command buffers are all per
@@ -1281,7 +1348,11 @@ impl VulkanVideoEncoder {
None, None,
)?; )?;
guard.sampler = sampler; guard.sampler = sampler;
let spv = ash::util::read_spv(&mut std::io::Cursor::new(CSC_SPV))?; let spv = ash::util::read_spv(&mut std::io::Cursor::new(if ten_bit {
CSC10_SPV
} else {
CSC_SPV
}))?;
let shader = let shader =
device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spv), None)?; device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spv), None)?;
guard.shader = shader; guard.shader = shader;
@@ -1396,6 +1467,7 @@ impl VulkanVideoEncoder {
.as_ref() .as_ref()
.is_some_and(|c| c.padded) .is_some_and(|c| c.padded)
.then_some(vk::Format::B8G8R8A8_UNORM), .then_some(vk::Format::B8G8R8A8_UNORM),
ten_bit,
guard.frames.last_mut().expect("frame just pushed"), guard.frames.last_mut().expect("frame just pushed"),
)?; )?;
} }
+42 -60
View File
@@ -357,24 +357,25 @@ fn open_video_backend_linux(
// Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces // Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces
// its errors crisply instead of silently trying the other). // its errors crisply instead of silently trying the other).
// AMD/Intel opener. Default = libav VAAPI. With `--features vulkan-encode` + // AMD/Intel opener. Default = libav VAAPI. With `--features vulkan-encode` +
// PUNKTFUNK_VULKAN_ENCODE, an HEVC session instead opens the raw Vulkan Video backend (real // PUNKTFUNK_VULKAN_ENCODE, an HEVC/AV1 session instead opens the raw Vulkan Video backend
// RFI loss recovery the VAAPI path can't express); a failed open falls back to VAAPI so the // (real RFI loss recovery the VAAPI path can't express); a failed open falls back to VAAPI so
// stream never dies over the new path. `format`/`bit_depth`/`chroma` only matter to VAAPI // the stream never dies over the new path. `format`/`bit_depth`/`chroma` only matter to VAAPI
// the Vulkan backend imports the dmabuf and does its own 8-bit 4:2:0 CSC. // the Vulkan backend imports the dmabuf and does its own CSC.
let open_amd_intel = || -> Result<(Box<dyn Encoder>, &'static str)> { let open_amd_intel = || -> Result<(Box<dyn Encoder>, &'static str)> {
// An HDR session (10-bit + a PQ/BT.2020 capture format) must skip the Vulkan Video // HDR (10-bit + a PQ/BT.2020 capture format) takes the Vulkan backend for **HEVC**: its
// backend — it hardcodes an 8-bit 4:2:0 BT.709 CSC — and take the libav VAAPI path, // compute CSC has a 10-bit BT.2020 variant and the session opens Main10. That keeps the
// which has the P010/Main10/PQ wiring. SDR sessions keep the Vulkan default. // two things an HDR session would otherwise lose here — real RFI recovery and the
// compute-CSC cursor blend, which is the only way a gamescope pointer reaches the stream
// (gamescope has no embedded-cursor mode to fall back to).
// //
// Two things ride this switch, and both are the accepted cost of AMD/Intel HDR until // AV1 10-bit stays on libav VAAPI: encode-side driver coverage for it is far thinner than
// Vulkan Video learns 10-bit: the Vulkan backend's real RFI loss recovery, and its // HEVC Main10, and this is not the place to gamble a session open. A device that cannot
// compute-CSC **cursor blend**. A gamescope HDR session therefore streams without the // do Main10 either fails `get_physical_device_video_capabilities` inside the open and
// host-composited XFixes pointer (gamescope has no embedded-cursor mode to fall back // lands on the VAAPI fallback below — the same net as any other unsupported config.
// 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()
&& !(bit_depth == 10 && format.is_hdr_rgb10()) && !(bit_depth == 10 && format.is_hdr_rgb10() && codec != Codec::H265)
{ {
match vulkan_video::VulkanVideoEncoder::open( match vulkan_video::VulkanVideoEncoder::open(
codec, codec,
@@ -1045,8 +1046,14 @@ pub fn linux_hdr_cuda_ok() -> bool {
/// ///
/// `cuda_planned` is the caller's prediction of a CUDA capture payload (NVIDIA + zero-copy — the /// `cuda_planned` is the caller's prediction of a CUDA capture payload (NVIDIA + zero-copy — the
/// prediction `SessionPlan` already makes); `ten_bit` the negotiated depth. Both shift the /// prediction `SessionPlan` already makes); `ten_bit` the negotiated depth. Both shift the
/// dispatch: a CPU payload keeps NVIDIA on libav NVENC (no blend), and a 10-bit HDR session /// dispatch: a CPU payload keeps NVIDIA on libav NVENC (no blend), and a 10-bit **AV1** session
/// skips Vulkan Video for libav VAAPI's P010/Main10 wiring (no blend). /// skips Vulkan Video for libav VAAPI (no blend) — 10-bit HEVC does not, its compute CSC has a
/// BT.2020 Main10 variant that blends like the 8-bit one.
///
/// Whether the DEVICE can encode Main10 is only settled when the session opens (the profile query
/// is what answers it); a device that cannot falls back to VAAPI there, and `open_video`'s
/// `blends_cursor` backstop logs the divergence. Same shape as every other open-time fallback
/// this prediction cannot see.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) -> bool { pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) -> bool {
// A negotiated PyroWave session routes to that backend before the pref is consulted // A negotiated PyroWave session routes to that backend before the pref is consulted
@@ -1071,6 +1078,8 @@ pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) ->
{ {
matches!(codec, Codec::H265 | Codec::Av1) matches!(codec, Codec::H265 | Codec::Av1)
&& vulkan_encode_enabled() && vulkan_encode_enabled()
// 10-bit is HEVC Main10 only on this backend — mirrors `open_amd_intel`.
&& !(ten_bit && codec != Codec::H265)
&& vulkan_encode_available(codec) && vulkan_encode_available(codec)
} }
#[cfg(not(feature = "vulkan-encode"))] #[cfg(not(feature = "vulkan-encode"))]
@@ -1083,7 +1092,7 @@ pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) ->
linux_auto_is_vaapi, linux_auto_is_vaapi,
cuda_planned, cuda_planned,
); );
cursor_blend_capable_for(backend, cuda_planned, ten_bit, direct_nvenc, vulkan_csc) cursor_blend_capable_for(backend, cuda_planned, direct_nvenc, vulkan_csc)
} }
/// The dispatch-mirroring core of [`cursor_blend_capable`], device-free for the unit tests. /// The dispatch-mirroring core of [`cursor_blend_capable`], device-free for the unit tests.
@@ -1094,7 +1103,6 @@ pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) ->
fn cursor_blend_capable_for( fn cursor_blend_capable_for(
backend: Option<LinuxBackend>, backend: Option<LinuxBackend>,
cuda_planned: bool, cuda_planned: bool,
ten_bit: bool,
direct_nvenc: bool, direct_nvenc: bool,
vulkan_csc: bool, vulkan_csc: bool,
) -> bool { ) -> bool {
@@ -1104,11 +1112,11 @@ fn cursor_blend_capable_for(
// Only the direct-SDK arm blends (VkSlotBlend), and it only takes CUDA payloads — // Only the direct-SDK arm blends (VkSlotBlend), and it only takes CUDA payloads —
// a CPU-payload session stays on libav NVENC, which cannot blend. // a CPU-payload session stays on libav NVENC, which cannot blend.
Some(LinuxBackend::Nvenc) => cuda_planned && direct_nvenc, Some(LinuxBackend::Nvenc) => cuda_planned && direct_nvenc,
// The Vulkan Video compute-CSC path blends; a 10-bit HDR session skips it for libav // The Vulkan Video compute-CSC path blends, at either depth. The session plan keeps a
// VAAPI (no blend). The session plan keeps a cursor-blend session off the native-NV12 // cursor-blend session off the native-NV12 and RGB-direct shapes
// and RGB-direct shapes (`SessionPlan::output_format` / `VulkanVideoEncoder::open`), // (`SessionPlan::output_format` / `VulkanVideoEncoder::open`), so CSC eligibility IS the
// so CSC eligibility IS the answer. // answer — including the depth term, which `vulkan_csc` already carries.
Some(LinuxBackend::AmdIntel) | Some(LinuxBackend::Vulkan) => !ten_bit && vulkan_csc, Some(LinuxBackend::AmdIntel) | Some(LinuxBackend::Vulkan) => vulkan_csc,
// CPU frames: the capturer composites the metadata cursor inline before the encoder // CPU frames: the capturer composites the metadata cursor inline before the encoder
// runs, but the ENCODER blends nothing — the cursor channel's on-demand composite // runs, but the ENCODER blends nothing — the cursor channel's on-demand composite
// contract can't be honored. Report the encoder's truth. // contract can't be honored. Report the encoder's truth.
@@ -2081,58 +2089,32 @@ mod tests {
Some(Pyrowave), Some(Pyrowave),
false, false,
false, false,
false,
false false
)); ));
// NVIDIA: only the direct-SDK arm blends (VkSlotBlend), and only for CUDA payloads. // NVIDIA: only the direct-SDK arm blends (VkSlotBlend), and only for CUDA payloads.
assert!(cursor_blend_capable_for( assert!(cursor_blend_capable_for(Some(Nvenc), true, true, false));
Some(Nvenc),
true,
false,
true,
false
));
assert!( assert!(
!cursor_blend_capable_for(Some(Nvenc), false, false, true, false), !cursor_blend_capable_for(Some(Nvenc), false, true, false),
"a CPU payload stays on libav NVENC, which cannot blend" "a CPU payload stays on libav NVENC, which cannot blend"
); );
assert!( assert!(
!cursor_blend_capable_for(Some(Nvenc), true, false, false, false), !cursor_blend_capable_for(Some(Nvenc), true, false, false),
"PUNKTFUNK_NVENC_DIRECT=0 (or a build without the feature) is the libav path" "PUNKTFUNK_NVENC_DIRECT=0 (or a build without the feature) is the libav path"
); );
// AMD/Intel: the Vulkan Video compute-CSC arm blends; VAAPI never does. // AMD/Intel: the Vulkan Video compute-CSC arm blends — at 8 AND 10 bits, since its CSC
assert!(cursor_blend_capable_for( // has a BT.2020 Main10 variant. VAAPI never does. The depth term lives in `vulkan_csc`
Some(AmdIntel), // (10-bit AV1 is the one combination that still falls through to VAAPI), so this arm is
false, // now exactly "did an eligible CSC arm resolve".
false, assert!(cursor_blend_capable_for(Some(AmdIntel), false, false, true));
false,
true
));
assert!( assert!(
!cursor_blend_capable_for(Some(AmdIntel), false, false, false, false), !cursor_blend_capable_for(Some(AmdIntel), false, false, false),
"no eligible Vulkan CSC arm (H.264, PUNKTFUNK_VULKAN_ENCODE=0, unsupported \ "no eligible Vulkan CSC arm (H.264, PUNKTFUNK_VULKAN_ENCODE=0, unsupported \
device) resolves to libav VAAPI, which cannot blend" device) resolves to libav VAAPI, which cannot blend"
); );
assert!( assert!(cursor_blend_capable_for(Some(Vulkan), false, false, true));
!cursor_blend_capable_for(Some(AmdIntel), false, true, false, true),
"a 10-bit HDR session skips Vulkan Video for VAAPI's P010 wiring — no blend"
);
assert!(cursor_blend_capable_for(
Some(Vulkan),
false,
false,
false,
true
));
// Software / unknown pref: CPU frames; the encoder blends nothing. // Software / unknown pref: CPU frames; the encoder blends nothing.
assert!(!cursor_blend_capable_for( assert!(!cursor_blend_capable_for(Some(Software), false, true, true));
Some(Software), assert!(!cursor_blend_capable_for(None, false, true, true));
false,
false,
true,
true
));
assert!(!cursor_blend_capable_for(None, false, false, true, true));
} }
/// WP7.7 guard (the cheap half): every `Encoder` trait method must be explicitly forwarded by /// WP7.7 guard (the cheap half): every `Encoder` trait method must be explicitly forwarded by
+4 -3
View File
@@ -158,9 +158,10 @@ Two things to know:
- **SDR content rides the same PQ stream.** The desktop, the Steam overlay and SDR games are mapped - **SDR content rides the same PQ stream.** The desktop, the Steam overlay and SDR games are mapped
into the HDR container at `PUNKTFUNK_GAMESCOPE_SDR_NITS` (400 by default). If white looks too into the HDR container at `PUNKTFUNK_GAMESCOPE_SDR_NITS` (400 by default). If white looks too
bright or too dim on your TV, that is the knob. bright or too dim on your TV, that is the knob.
- **On AMD and Intel the composited mouse pointer is currently missing from HDR sessions.** HDR - **HDR picks HEVC on AMD and Intel.** A 10-bit AV1 session falls back to a slower encode path
routes the encode through a path that cannot blend the cursor gamescope leaves out of its that also can't draw the mouse pointer gamescope leaves out of its capture — so if you stream
capture. SDR sessions are unaffected, as are NVIDIA hosts. Gaming Mode in HDR from an AMD or Intel box, leave the codec on HEVC. NVIDIA is unaffected
either way.
## Known limits ## Known limits
+5 -3
View File
@@ -27,8 +27,9 @@ the Punktfunk sysext; there's an Arch package, a NixOS option, and a build scrip
else. `punktfunk-host hdr-probe` tells you exactly which pieces are in place. else. `punktfunk-host hdr-probe` tells you exactly which pieces are in place.
Opt-in for this release while it soaks: without the knob, or without the extra package, the Opt-in for this release while it soaks: without the knob, or without the extra package, the
gamescope path streams SDR exactly as before. One known gap on AMD and Intel: the on-screen mouse gamescope path streams SDR exactly as before. One thing to know on AMD and Intel: keep the codec on
pointer is missing from HDR sessions (NVIDIA is unaffected) — SDR sessions still have it. HEVC — a 10-bit AV1 session falls back to a slower encode path that also loses the on-screen mouse
pointer. NVIDIA is unaffected either way.
## Fixed: newer KDE Plasma silently fell back to a slower, less reliable way to arrange displays ## Fixed: newer KDE Plasma silently fell back to a slower, less reliable way to arrange displays
@@ -45,7 +46,8 @@ On current KDE Plasma (6.7 and newer), Punktfunk's direct way of talking to the
- **KWin's in-process output-management path now also binds `kde_output_device_registry_v2`**, not just the per-output `kde_output_device_v2` globals it originally shipped with — KWin 6.7 stopped advertising the latter, so the module written specifically to avoid shelling out to `kscreen-doctor` (0.19.x) saw zero devices and silently degraded to it on every session. Both models are supported now; registry-sourced devices arrive one Wayland round-trip later, so the handshake gains one conditional extra barrier. - **KWin's in-process output-management path now also binds `kde_output_device_registry_v2`**, not just the per-output `kde_output_device_v2` globals it originally shipped with — KWin 6.7 stopped advertising the latter, so the module written specifically to avoid shelling out to `kscreen-doctor` (0.19.x) saw zero devices and silently degraded to it on every session. Both models are supported now; registry-sourced devices arrive one Wayland round-trip later, so the handshake gains one conditional extra barrier.
- **gamescope HDR is decided statically, before the display exists.** The punktfunk/1 Welcome fixes a session's bit depth up front and cannot take it back (PQ frames on an 8-bit encoder are a deliberate hard error), so the capability answer is the identity of the gamescope binary the host will spawn — a `+pfhdr` marker in its `--version` banner, probed once per boot — never an optimistic negotiation. The capture-side gate became source-aware (`capturer_supports_hdr_for(compositor)`), the HDR negotiation-failure latch became per-source (a wedged monitor mirror no longer disables a gamescope session's HDR, or vice versa), and the keep-alive reuse key gained `hdr` so a display brought up SDR can never be handed to an HDR session. The GameStream plane's live BT.2100 monitor probe is now scoped to the portal source — a headless gamescope box has no monitor to be in HDR mode. - **gamescope HDR is decided statically, before the display exists.** The punktfunk/1 Welcome fixes a session's bit depth up front and cannot take it back (PQ frames on an 8-bit encoder are a deliberate hard error), so the capability answer is the identity of the gamescope binary the host will spawn — a `+pfhdr` marker in its `--version` banner, probed once per boot — never an optimistic negotiation. The capture-side gate became source-aware (`capturer_supports_hdr_for(compositor)`), the HDR negotiation-failure latch became per-source (a wedged monitor mirror no longer disables a gamescope session's HDR, or vice versa), and the keep-alive reuse key gained `hdr` so a display brought up SDR can never be handed to an HDR session. The GameStream plane's live BT.2100 monitor probe is now scoped to the portal source — a headless gamescope box has no monitor to be in HDR mode.
- **The gamescope patch mirrors code already in gamescope's tree.** Its PipeWire node additionally offers `xRGB_210LE`/`xBGR_210LE` with MANDATORY SMPTE ST.2084 + BT.2020 properties, mapped to `DRM_FORMAT_XRGB2101010`/`XBGR2101010` — the same 10-bit capture texture the HDR AVIF screenshot path allocates — and `paint_pipewire()` composites into them with the HDR screenshot LUT set and `EOTF_PQ`, which is exactly the `bHDRScreenshot` branch. The new formats are listed last, so every existing consumer keeps negotiating the 8-bit stream bit-for-bit, and the fixed PQ container is deliberately *not* conditional on the focused app being HDR (a stream's colourimetry must not follow what the game happens to render). - **The gamescope patch mirrors code already in gamescope's tree.** Its PipeWire node additionally offers `xRGB_210LE`/`xBGR_210LE` with MANDATORY SMPTE ST.2084 + BT.2020 properties, mapped to `DRM_FORMAT_XRGB2101010`/`XBGR2101010` — the same 10-bit capture texture the HDR AVIF screenshot path allocates — and `paint_pipewire()` composites into them with the HDR screenshot LUT set and `EOTF_PQ`, which is exactly the `bHDRScreenshot` branch. The new formats are listed last, so every existing consumer keeps negotiating the 8-bit stream bit-for-bit, and the fixed PQ container is deliberately *not* conditional on the focused app being HDR (a stream's colourimetry must not follow what the game happens to render).
- **AMD/Intel HDR needs no new encoder code**; NVIDIA gained a zero-copy leg. The VAAPI path already ingested XR30 dmabufs into `format=p010:out_color_matrix=bt2020`. On NVIDIA the packed 10-bit frame now travels LINEAR dmabuf → Vulkan bridge → CUDA → NVENC `ARGB10`/`ABGR10`, letting NVENC do the BT.2020 CSC itself: no host CSC pass, no depth loss, and the cursor-blend compute shader gained two 10-bit modes so the pointer survives. The tiled EGL de-tile blit is still 8-bit and HDR never routes through it. A host without the direct-SDK NVENC backend keeps HDR on the CPU path, since libav's HDR route swscales into a P010 hardware frame that a packed-10-bit CUDA buffer cannot fill. - **Vulkan Video learned 10-bit** (HEVC Main10), which is what keeps AMD/Intel HDR on the good path: an HDR session opens a Main10 video profile with 10-bit component depths, a `G10X6…3PACK16` picture and DPB, an SPS carrying `bit_depth_*_minus8 = 2` and the BT.2020/PQ CICP triplet, and a new `rgb2yuv10.comp` — the 8-bit BT.709 shader's twin, doing a pure BT.2020 NCL matrix on the already-PQ-encoded samples (there is no transfer function to apply, and applying one would be wrong) and writing 10-bit values into the high bits of `R16`/`RG16` scratch planes that are size-compatible with the picture's. That keeps the two things HDR would otherwise cost on this vendor: real RFI loss recovery, and the compute CSC's cursor blend — the only way a gamescope pointer reaches the stream at all. 10-bit **AV1** still routes to libav VAAPI: encode-side driver coverage for it is far thinner than Main10. A device that can't do Main10 fails the profile query inside the open and lands on the same VAAPI fallback as any other unsupported config.
- **AMD/Intel HDR needs no new encoder code on the VAAPI fallback**; NVIDIA gained a zero-copy leg. The VAAPI path already ingested XR30 dmabufs into `format=p010:out_color_matrix=bt2020`. On NVIDIA the packed 10-bit frame now travels LINEAR dmabuf → Vulkan bridge → CUDA → NVENC `ARGB10`/`ABGR10`, letting NVENC do the BT.2020 CSC itself: no host CSC pass, no depth loss, and the cursor-blend compute shader gained two 10-bit modes so the pointer survives. The tiled EGL de-tile blit is still 8-bit and HDR never routes through it. A host without the direct-SDK NVENC backend keeps HDR on the CPU path, since libav's HDR route swscales into a P010 hardware frame that a packed-10-bit CUDA buffer cannot fill.
- **CI-only fix:** the winget release-verification step's `envs:` allow-list was a step-level sibling of `with:`/`env:` instead of nested inside `with:`, so `appleboy/ssh-action` never actually received it as an input and the step kept failing on every tag. Moved to match how `REGISTRY_TOKEN` is already forwarded elsewhere. - **CI-only fix:** the winget release-verification step's `envs:` allow-list was a step-level sibling of `with:`/`env:` instead of nested inside `with:`, so `appleboy/ssh-action` never actually received it as an input and the step kept failing on every tag. Moved to match how `REGISTRY_TOKEN` is already forwarded elsewhere.
See [v0.20.1](v0.20.1.md) for the fixes carried by that release (Windows install/winget, GameStream opt-in default, the gamescope Game Mode takeover hardening, a laptop-panel stall fix, and the PyroWave latency-creep bundle) — all included here too, since this release supersedes it. See [v0.20.1](v0.20.1.md) for the fixes carried by that release (Windows install/winget, GameStream opt-in default, the gamescope Game Mode takeover hardening, a laptop-panel stall fix, and the PyroWave latency-creep bundle) — all included here too, since this release supersedes it.