gamescope's built-in PipeWire node has always been SDR-only: it offers BGRx and NV12, and `paint_pipewire()` hardcodes a Gamma-2.2 composite with the SDR screenshot LUT set. So an HDR game reaches punktfunk already tone-mapped down, and the whole gamescope backend streams 8-bit no matter what the client can decode. Games CAN render HDR on a headless gamescope today — only the capture half is missing. Carry the two patches that close it (packaging/gamescope/patches): the node additionally offers `xRGB_210LE`/`xBGR_210LE` with MANDATORY SMPTE ST.2084 + BT.2020 props, mapped to the same 10-bit capture texture the HDR AVIF screenshot path already allocates, and `paint_pipewire()` composites into them with the HDR screenshot LUTs + `EOTF_PQ` — which is exactly the in-tree `bHDRScreenshot` branch. The new formats are listed LAST, so every existing consumer keeps negotiating the 8-bit stream bit-for-bit. Offered upstream against ValveSoftware/gamescope#2126. Ship it as `punktfunk-gamescope`, beside the distro's own binary rather than replacing it: a Bazzite sysext payload (`build-sysext.sh --gamescope`, which refuses a binary without the marker), an Arch package, a nix derivation + `services.punktfunk.host.gamescopeHdr`, and one distro-agnostic build script the rest call. The second patch stamps `+pfhdr1` into the `--version` banner. That is not cosmetic: punktfunk fixes a session's bit depth in the Welcome, before the display exists and with no way to take it back, so its capability answer has to be a static property of the binary it will spawn.
245 lines
12 KiB
Diff
245 lines
12 KiB
Diff
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
|
From: =?UTF-8?q?Enrico=20B=C3=BChler?= <enrico.buehler@unom.io>
|
|
Date: Tue, 28 Jul 2026 13:53:41 +0200
|
|
Subject: [PATCH] pipewire: offer 10-bit BT.2020 PQ capture formats (HDR
|
|
streams)
|
|
MIME-Version: 1.0
|
|
Content-Type: text/plain; charset=UTF-8
|
|
Content-Transfer-Encoding: 8bit
|
|
|
|
gamescope's built-in PipeWire node has always been SDR: build_format_params()
|
|
offers BGRx and NV12 only, and paint_pipewire() hardcodes a Gamma-2.2 composite
|
|
with the SDR screenshot LUT set, so an HDR game reaches every consumer already
|
|
tone-mapped down. Screen-capture consumers that CAN carry HDR — Steam Remote
|
|
Play (#2126), other remote-play hosts — have no way to ask for it.
|
|
|
|
Offer SPA xRGB_210LE / xBGR_210LE alongside the existing formats, carrying
|
|
MANDATORY SMPTE ST.2084 (PQ) + BT.2020 colorimetry properties, and map them to
|
|
DRM_FORMAT_XRGB2101010 / XBGR2101010 — the same 10-bit capture texture the HDR
|
|
AVIF screenshot path already allocates. When such a format is negotiated,
|
|
paint_pipewire() switches to g_ScreenshotColorMgmtLutsHDR and EOTF_PQ: exactly
|
|
the bHDRScreenshot branch, which is a fixed BT.2020+PQ target whose LUT set is
|
|
built unconditionally for every input EOTF, so SDR content lands in the PQ
|
|
container at --hdr-sdr-content-nits and HDR content passes through.
|
|
|
|
The new formats are listed last, so every consumer that negotiates the 8-bit
|
|
stream today keeps negotiating it bit-for-bit; the MANDATORY colorimetry props
|
|
mean a consumer cannot land on 10-bit without agreeing to PQ/BT.2020. Unlike
|
|
bHDRScreenshot the switch does not depend on the focused app being HDR — a
|
|
stream's colorimetry is fixed for the life of the negotiation and must not
|
|
follow what the app happens to render.
|
|
|
|
Works on the headless backend as well as a real connector: no HDR display is
|
|
involved anywhere in the LUT set.
|
|
---
|
|
src/pipewire.cpp | 99 ++++++++++++++++++++++++++++++++------------
|
|
src/steamcompmgr.cpp | 21 ++++++++--
|
|
2 files changed, 91 insertions(+), 29 deletions(-)
|
|
|
|
diff --git a/src/pipewire.cpp b/src/pipewire.cpp
|
|
index 76b3ea8..0cf71d2 100644
|
|
--- a/src/pipewire.cpp
|
|
+++ b/src/pipewire.cpp
|
|
@@ -18,6 +18,25 @@
|
|
|
|
static LogScope pwr_log("pipewire");
|
|
|
|
+// 10-bit HDR capture.
|
|
+//
|
|
+// The packed 10-bit RGB SPA formats (xRGB_210LE / xBGR_210LE) and the SMPTE ST.2084 (PQ)
|
|
+// transfer function all predate PipeWire 1.0 by a wide margin; the guard only exists so the
|
|
+// file still compiles against an ancient libspa, in which case the stream offers exactly the
|
|
+// 8-bit formats it always did.
|
|
+#if PW_CHECK_VERSION(1, 0, 0)
|
|
+#define GAMESCOPE_PIPEWIRE_HDR 1
|
|
+#else
|
|
+#define GAMESCOPE_PIPEWIRE_HDR 0
|
|
+#endif
|
|
+
|
|
+#if GAMESCOPE_PIPEWIRE_HDR
|
|
+static bool is_hdr_video_format(uint32_t format)
|
|
+{
|
|
+ return format == SPA_VIDEO_FORMAT_xRGB_210LE || format == SPA_VIDEO_FORMAT_xBGR_210LE;
|
|
+}
|
|
+#endif
|
|
+
|
|
static struct pipewire_state pipewire_state = { .stream_node_id = SPA_ID_INVALID };
|
|
static int nudgePipe[2] = { -1, -1 };
|
|
|
|
@@ -94,6 +113,37 @@ static void calculate_capture_size()
|
|
s_nCaptureHeight = SPA_ROUND_UP_N(s_nCaptureHeight, kCaptureAlign);
|
|
}
|
|
|
|
+// The colorimetry properties that go with `format`, appended into an already-open EnumFormat
|
|
+// object. NV12 keeps offering the BT.601/BT.709 matrix+range choice it always did; the 10-bit
|
|
+// packed-RGB formats carry a FIXED, MANDATORY BT.2020 + SMPTE ST.2084 (PQ) description, because
|
|
+// that is exactly what paint_pipewire() encodes into them (the HDR screenshot LUT set — a fixed
|
|
+// PQ container, independent of what the focused app renders). MANDATORY means a consumer that
|
|
+// does not speak PQ/BT.2020 cannot land on these formats by accident: it fails to intersect this
|
|
+// pod and falls back to the 8-bit ones listed before it.
|
|
+static void add_format_color_params(struct spa_pod_builder *builder, spa_video_format format)
|
|
+{
|
|
+ if (format == SPA_VIDEO_FORMAT_NV12) {
|
|
+ spa_pod_builder_add(builder,
|
|
+ SPA_FORMAT_VIDEO_colorMatrix, SPA_POD_CHOICE_ENUM_Id(3,
|
|
+ SPA_VIDEO_COLOR_MATRIX_BT601,
|
|
+ SPA_VIDEO_COLOR_MATRIX_BT601,
|
|
+ SPA_VIDEO_COLOR_MATRIX_BT709),
|
|
+ SPA_FORMAT_VIDEO_colorRange, SPA_POD_CHOICE_ENUM_Id(3,
|
|
+ SPA_VIDEO_COLOR_RANGE_16_235,
|
|
+ SPA_VIDEO_COLOR_RANGE_16_235,
|
|
+ SPA_VIDEO_COLOR_RANGE_0_255),
|
|
+ 0);
|
|
+ }
|
|
+#if GAMESCOPE_PIPEWIRE_HDR
|
|
+ else if (is_hdr_video_format(format)) {
|
|
+ spa_pod_builder_prop(builder, SPA_FORMAT_VIDEO_transferFunction, SPA_POD_PROP_FLAG_MANDATORY);
|
|
+ spa_pod_builder_id(builder, SPA_VIDEO_TRANSFER_SMPTE2084);
|
|
+ spa_pod_builder_prop(builder, SPA_FORMAT_VIDEO_colorPrimaries, SPA_POD_PROP_FLAG_MANDATORY);
|
|
+ spa_pod_builder_id(builder, SPA_VIDEO_COLOR_PRIMARIES_BT2020);
|
|
+ }
|
|
+#endif
|
|
+}
|
|
+
|
|
static void build_format_params(struct spa_pod_builder *builder, spa_video_format format, std::vector<const struct spa_pod *> ¶ms) {
|
|
struct spa_rectangle size = SPA_RECTANGLE(s_nCaptureWidth, s_nCaptureHeight);
|
|
struct spa_rectangle min_requested_size = { 0, 0 };
|
|
@@ -112,18 +162,7 @@ static void build_format_params(struct spa_pod_builder *builder, spa_video_forma
|
|
SPA_FORMAT_VIDEO_requested_size, SPA_POD_CHOICE_RANGE_Rectangle( &min_requested_size, &min_requested_size, &max_requested_size ),
|
|
SPA_FORMAT_VIDEO_gamescope_focus_appid, SPA_POD_CHOICE_RANGE_Long( 0ll, INT64_MIN, INT64_MAX ),
|
|
0);
|
|
- if (format == SPA_VIDEO_FORMAT_NV12) {
|
|
- spa_pod_builder_add(builder,
|
|
- SPA_FORMAT_VIDEO_colorMatrix, SPA_POD_CHOICE_ENUM_Id(3,
|
|
- SPA_VIDEO_COLOR_MATRIX_BT601,
|
|
- SPA_VIDEO_COLOR_MATRIX_BT601,
|
|
- SPA_VIDEO_COLOR_MATRIX_BT709),
|
|
- SPA_FORMAT_VIDEO_colorRange, SPA_POD_CHOICE_ENUM_Id(3,
|
|
- SPA_VIDEO_COLOR_RANGE_16_235,
|
|
- SPA_VIDEO_COLOR_RANGE_16_235,
|
|
- SPA_VIDEO_COLOR_RANGE_0_255),
|
|
- 0);
|
|
- }
|
|
+ add_format_color_params(builder, format);
|
|
spa_pod_builder_prop(builder, SPA_FORMAT_VIDEO_modifier, SPA_POD_PROP_FLAG_MANDATORY);
|
|
spa_pod_builder_push_choice(builder, &choice_frame, SPA_CHOICE_Enum, 0);
|
|
spa_pod_builder_long(builder, modifier); // default
|
|
@@ -141,18 +180,7 @@ static void build_format_params(struct spa_pod_builder *builder, spa_video_forma
|
|
SPA_FORMAT_VIDEO_requested_size, SPA_POD_CHOICE_RANGE_Rectangle( &min_requested_size, &min_requested_size, &max_requested_size ),
|
|
SPA_FORMAT_VIDEO_gamescope_focus_appid, SPA_POD_CHOICE_RANGE_Long( 0ll, INT64_MIN, INT64_MAX ),
|
|
0);
|
|
- if (format == SPA_VIDEO_FORMAT_NV12) {
|
|
- spa_pod_builder_add(builder,
|
|
- SPA_FORMAT_VIDEO_colorMatrix, SPA_POD_CHOICE_ENUM_Id(3,
|
|
- SPA_VIDEO_COLOR_MATRIX_BT601,
|
|
- SPA_VIDEO_COLOR_MATRIX_BT601,
|
|
- SPA_VIDEO_COLOR_MATRIX_BT709),
|
|
- SPA_FORMAT_VIDEO_colorRange, SPA_POD_CHOICE_ENUM_Id(3,
|
|
- SPA_VIDEO_COLOR_RANGE_16_235,
|
|
- SPA_VIDEO_COLOR_RANGE_16_235,
|
|
- SPA_VIDEO_COLOR_RANGE_0_255),
|
|
- 0);
|
|
- }
|
|
+ add_format_color_params(builder, format);
|
|
params.push_back((const struct spa_pod *) spa_pod_builder_pop(builder, &obj_frame));
|
|
|
|
// for (auto& param : params)
|
|
@@ -166,6 +194,14 @@ static std::vector<const struct spa_pod *> build_format_params(struct spa_pod_bu
|
|
|
|
build_format_params(builder, SPA_VIDEO_FORMAT_BGRx, params);
|
|
build_format_params(builder, SPA_VIDEO_FORMAT_NV12, params);
|
|
+#if GAMESCOPE_PIPEWIRE_HDR
|
|
+ // Listed LAST, on purpose: PipeWire intersects in offer order, so every consumer that
|
|
+ // negotiates today's 8-bit stream keeps negotiating it bit-for-bit. Only a consumer that
|
|
+ // asks for a 10-bit format by name — and accepts the MANDATORY BT.2020 + PQ colorimetry
|
|
+ // above — ever reaches these.
|
|
+ build_format_params(builder, SPA_VIDEO_FORMAT_xRGB_210LE, params);
|
|
+ build_format_params(builder, SPA_VIDEO_FORMAT_xBGR_210LE, params);
|
|
+#endif
|
|
|
|
return params;
|
|
}
|
|
@@ -288,7 +324,7 @@ static void dispatch_nudge(struct pipewire_state *state, int fd)
|
|
if (s_nCaptureWidth != state->video_info.size.width || s_nCaptureHeight != state->video_info.size.height) {
|
|
pwr_log.debugf("renegotiating stream params (size: %dx%d)", s_nCaptureWidth, s_nCaptureHeight);
|
|
|
|
- uint8_t buf[4096];
|
|
+ uint8_t buf[8192];
|
|
struct spa_pod_builder builder = SPA_POD_BUILDER_INIT(buf, sizeof(buf));
|
|
std::vector<const struct spa_pod *> format_params = build_format_params(&builder);
|
|
int ret = pw_stream_update_params(state->stream, format_params.data(), format_params.size());
|
|
@@ -412,6 +448,12 @@ static void stream_handle_param_changed(void *data, uint32_t id, const struct sp
|
|
state->video_info.size.width, state->video_info.size.height,
|
|
s_nRequestedWidth, s_nRequestedHeight,
|
|
state->video_info.format, state->shm_stride, shm_size, state->dmabuf);
|
|
+
|
|
+#if GAMESCOPE_PIPEWIRE_HDR
|
|
+ if (is_hdr_video_format(state->video_info.format)) {
|
|
+ pwr_log.infof("negotiated a 10-bit stream — the composite is encoded as BT.2020 + PQ (HDR10)");
|
|
+ }
|
|
+#endif
|
|
}
|
|
|
|
static void randname(char *buf)
|
|
@@ -450,6 +492,11 @@ uint32_t spa_format_to_drm(uint32_t spa_format)
|
|
switch (spa_format)
|
|
{
|
|
case SPA_VIDEO_FORMAT_NV12: return DRM_FORMAT_NV12;
|
|
+#if GAMESCOPE_PIPEWIRE_HDR
|
|
+ // Same texture machinery the HDR AVIF screenshot path already uses.
|
|
+ case SPA_VIDEO_FORMAT_xRGB_210LE: return DRM_FORMAT_XRGB2101010;
|
|
+ case SPA_VIDEO_FORMAT_xBGR_210LE: return DRM_FORMAT_XBGR2101010;
|
|
+#endif
|
|
default:
|
|
case SPA_VIDEO_FORMAT_BGR: return DRM_FORMAT_XRGB8888;
|
|
}
|
|
@@ -715,7 +762,7 @@ bool init_pipewire(void)
|
|
s_nOutputHeight = g_nOutputHeight;
|
|
calculate_capture_size();
|
|
|
|
- uint8_t buf[4096];
|
|
+ uint8_t buf[8192];
|
|
struct spa_pod_builder builder = SPA_POD_BUILDER_INIT(buf, sizeof(buf));
|
|
std::vector<const struct spa_pod *> format_params = build_format_params(&builder);
|
|
|
|
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
|
|
index ff9ae1f..01b2abf 100644
|
|
--- a/src/steamcompmgr.cpp
|
|
+++ b/src/steamcompmgr.cpp
|
|
@@ -2335,17 +2335,32 @@ static void paint_pipewire()
|
|
if ( !s_pPipewireBuffer || !s_pPipewireBuffer->texture )
|
|
return;
|
|
|
|
+ // The negotiated stream format decides the composite's colorimetry. A 10-bit target (the
|
|
+ // xRGB_210LE / xBGR_210LE offer, whose SPA props say BT.2020 + PQ) gets the same fixed
|
|
+ // BT.2020/PQ container the HDR AVIF screenshot path uses; everything else keeps the SDR
|
|
+ // Gamma-2.2 composite it always had.
|
|
+ //
|
|
+ // Deliberately NOT conditional on the focused app being HDR, unlike bHDRScreenshot: the
|
|
+ // stream's colorimetry is fixed for the lifetime of the negotiation, so it must not follow
|
|
+ // what the app happens to render. g_ScreenshotColorMgmtLutsHDR is built for EVERY input EOTF
|
|
+ // (see update_screenshot_color_mgmt), so SDR content lands in the PQ container at
|
|
+ // --hdr-sdr-content-nits, HDR content passes through, and mixed scenes composite correctly.
|
|
+ const uint32_t uStreamDrmFormat = s_pPipewireBuffer->texture->drmFormat();
|
|
+ const bool bHDRStream = uStreamDrmFormat == DRM_FORMAT_XRGB2101010 ||
|
|
+ uStreamDrmFormat == DRM_FORMAT_XBGR2101010;
|
|
+
|
|
struct FrameInfo_t frameInfo = {};
|
|
frameInfo.applyOutputColorMgmt = true;
|
|
- frameInfo.outputEncodingEOTF = EOTF_Gamma22;
|
|
+ frameInfo.outputEncodingEOTF = bHDRStream ? EOTF_PQ : EOTF_Gamma22;
|
|
frameInfo.allowVRR = false;
|
|
frameInfo.bFadingOut = false;
|
|
|
|
// Apply screenshot-style color management.
|
|
for ( uint32_t nInputEOTF = 0; nInputEOTF < EOTF_Count; nInputEOTF++ )
|
|
{
|
|
- frameInfo.lut3D[nInputEOTF] = g_ScreenshotColorMgmtLuts[nInputEOTF].vk_lut3d;
|
|
- frameInfo.shaperLut[nInputEOTF] = g_ScreenshotColorMgmtLuts[nInputEOTF].vk_lut1d;
|
|
+ auto& luts = bHDRStream ? g_ScreenshotColorMgmtLutsHDR : g_ScreenshotColorMgmtLuts;
|
|
+ frameInfo.lut3D[nInputEOTF] = luts[nInputEOTF].vk_lut3d;
|
|
+ frameInfo.shaperLut[nInputEOTF] = luts[nInputEOTF].vk_lut1d;
|
|
}
|
|
|
|
const uint64_t ulFocusAppId = s_pPipewireBuffer->gamescope_info.focus_appid;
|