From 578c6055b4ed8586eb47b7c7fd251a6bc61ed907 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 17:30:15 +0000 Subject: [PATCH 1/2] linux: add chroma_supersample for exact 4:2:0 chroma under integer upscale AMD VCN and Intel Quick Sync have no 4:4:4 profile on any codec, so streaming at native resolution always subsamples chroma in hardware, causing visible fringing on small text. A known workaround is to stream at an integer multiple of the desktop resolution and let the client downscale: if the upscale is point (nearest-neighbour) sampling, every captured pixel becomes an identical NxN block, so the encoder's 4:2:0 chroma averaging reproduces the capture's own chroma exactly, prior to quantisation. Today the Linux GL capture path hardcodes GL_LINEAR for every texture (gl::tex_t::make()), so this property doesn't hold: the bilinear blit softens chroma before the encoder ever sees it, reducing fringing without eliminating it. Add chroma_supersample (default off). When enabled and the stream resolution is an exact, even integer multiple of the capture resolution in both axes, switch the source texture to GL_NEAREST for the capture-to-stream blit in sws_t, rather than changing tex_t::make() globally (which backs every texture in the GL path, including the cursor overlay). The factor must be even, not just integral: 4:2:0 chroma averages a 2x2 window of stream pixels, and that window only stays inside identical-valued blocks when the block size is even - measured an odd factor (3x) to be worse than the existing bilinear path, not just less exact, so odd factors fall back to bilinear with a logged warning. ConvertUV.frag's two-tap horizontal average is dropped to a single effective tap (width_i = 0) under point sampling, since both taps already resolve to the same texel once the source is piecewise-constant. With the option off, behavior is unchanged (same GL_LINEAR, same width_i). Verified in a numpy simulation of the GL sampling rules against a synthetic test pattern: pre-quantisation chroma error is exactly zero at 2x/4x, and worse than bilinear at 3x, confirming the even-factor restriction. --- docs/configuration.md | 38 +++++++++++++ src/config.cpp | 4 ++ src/config.h | 2 + src/platform/linux/graphics.cpp | 97 ++++++++++++++++++++++++++++++++- src/platform/linux/graphics.h | 31 +++++++++++ 5 files changed, 169 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index d55dc380f82..ff7b4cbeb75 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2129,6 +2129,44 @@ editing the `conf` file in a text editor. Use the examples as reference. +### chroma_supersample + + + + + + + + + + + + + + +
Description + When the stream resolution is an exact, even integer multiple of the capture resolution (2x, 4x, ...) in + both axes, use nearest-neighbour (point) sampling for the capture-to-stream upscale instead of the + default bilinear filter. + @note{This makes 4:2:0 chroma subsampling exact: every captured pixel is replicated into an identical + NxN block, so the encoder's 2x2 chroma averaging reproduces the capture's own chroma bit-for-bit, prior + to quantisation. This is a practical alternative to 4:4:4 encoding on GPUs whose hardware encoder has no + 4:4:4 profile at all (e.g. AMD VCN, Intel Quick Sync). See + [Moonlight issue #1671](https://github.com/moonlight-stream/moonlight-qt/issues/1671) for the + protocol-level version of the same idea; this option makes the existing supersampling workaround + exact without any client-side changes.} + @note{The client (Moonlight) must present the stream at exactly 1/N of the stream resolution for the + property to hold end to end — fullscreen playback is the reliable way to get this; a window with + decorations, or any non-integer client-side scaling, silently breaks it.} + @note{Only applies on Linux, where the capture-to-stream scale is a single GL blit. When the geometry + does not qualify (non-integer or odd ratio, mismatched axes), Sunshine logs a warning and falls back to + the existing bilinear scaling — the stream still works, just without the exactness property.} +
Default@code{} + disabled + @endcode
Example@code{} + chroma_supersample = enabled + @endcode
+ ### capture diff --git a/src/config.cpp b/src/config.cpp index de3979ee4a9..6f515b0d456 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -772,6 +772,8 @@ namespace config { 2, // vk.rc_mode (default: cbr) }, + false, // chroma_supersample + {}, // capture {}, // encoder {}, // adapter_name @@ -1659,6 +1661,8 @@ namespace config { int_f(vars, "vk_tune", video.vk.tune); int_f(vars, "vk_rc_mode", video.vk.rc_mode); + bool_f(vars, "chroma_supersample", video.chroma_supersample); + string_f(vars, "capture", video.capture); string_f(vars, "encoder", video.encoder); string_f(vars, "adapter_name", video.adapter_name); diff --git a/src/config.h b/src/config.h index adc4e84219a..569c45ae15b 100644 --- a/src/config.h +++ b/src/config.h @@ -118,6 +118,8 @@ namespace config { int rc_mode; // 0=driver, 1=cqp, 2=cbr, 4=vbr } vk; ///< Vulkan encoder options. + bool chroma_supersample; ///< Use point sampling when the stream resolution is an exact, even integer multiple of the capture resolution. + std::string capture; ///< Capture backend name selected by configuration. std::string encoder; ///< Encoder backend name selected by configuration. std::string adapter_name; ///< Display adapter name selected in configuration. diff --git a/src/platform/linux/graphics.cpp b/src/platform/linux/graphics.cpp index 1ef27c7166e..946cfdc4e58 100644 --- a/src/platform/linux/graphics.cpp +++ b/src/platform/linux/graphics.cpp @@ -7,6 +7,7 @@ // local includes #include "graphics.h" +#include "src/config.h" #include "src/file_handler.h" #include "src/logging.h" #include "src/video.h" @@ -994,6 +995,75 @@ namespace egl { return 0; } + int supersample_factor(int in_width, int in_height, int out_width, int out_height) { + if (in_width <= 0 || in_height <= 0) { + return 0; + } + + const int factor = out_width / in_width; + if (factor < 2) { + return 0; + } + + // Both axes must scale by the same exact integer. Anything else maps a captured pixel + // onto a fractional number of stream pixels, which point sampling would alias. + if (out_width != factor * in_width || out_height != factor * in_height) { + return 0; + } + + // The factor must also be even. 4:2:0 chroma is a box average of a 2x2 window of stream + // pixels; that window lands entirely inside one replicated source block only when the + // block size is even, so every 2x2 window is either fully inside one block or fully + // straddles two equal-valued blocks - both exact. An odd factor puts some windows half in + // one block and half in the next, blending two different source pixels' chroma; measured + // empirically to be *worse* than bilinear at 3x, not just merely inexact. + if (factor & 1) { + return 0; + } + + return factor; + } + + /** + * @brief Select point sampling for the capture-to-stream upscale when it is exactly integral. + * + * Leaves sws.supersample at 0 - and therefore the pipeline on its default bilinear filter - + * unless `chroma_supersample` is enabled and the geometry supports exact pixel replication. + * + * @param sws Software-scaling pipeline to configure. + * @param is_yuv444 Whether the destination uses three full-resolution planes. + */ + void configure_supersampling(sws_t &sws, bool is_yuv444) { + if (!config::video.chroma_supersample) { + return; + } + + const int factor = supersample_factor(sws.in_width, sws.in_height, sws.out_width, sws.out_height); + if (!factor) { + BOOST_LOG(warning) + << "chroma_supersample: stream resolution ["sv << sws.out_width << 'x' << sws.out_height + << "] is not an exact, even integer multiple (2x, 4x, ...) of the capture resolution ["sv + << sws.in_width << 'x' << sws.in_height << "]; keeping bilinear scaling"sv; + return; + } + + // The chroma planes of a 4:2:0 target render into a viewport at exactly half the luma + // offset. An odd offset truncates, shifting the chroma grid half a captured pixel away + // from the luma blocks it belongs to. + if (!is_yuv444 && ((sws.offsetX & 1) || (sws.offsetY & 1))) { + BOOST_LOG(warning) + << "chroma_supersample: letterbox offset ["sv << sws.offsetX << ',' << sws.offsetY + << "] is odd; keeping bilinear scaling"sv; + return; + } + + sws.supersample = factor; + + BOOST_LOG(info) + << "chroma_supersample: point sampling ["sv << sws.in_width << 'x' << sws.in_height + << "] -> ["sv << sws.out_width << 'x' << sws.out_height << "] ("sv << factor << "x)"sv; + } + std::optional sws_t::make_nv12(int in_width, int in_height, int out_width, int out_height, gl::tex_t &&tex) { sws_t sws; @@ -1017,7 +1087,14 @@ namespace egl { sws.offsetX = offsetX_f; sws.offsetY = offsetY_f; - auto width_i = 1.0f / sws.out_width; + configure_supersampling(sws, false); + + // ConvertUV.frag averages two horizontal taps, `width_i` apart, to place the chroma + // sample on the left-sited position H.264/HEVC assume by default. Under point sampling + // both taps already resolve to the same captured pixel - every position inside an NxN + // block holds the same value - so the offset is dropped to keep that exact instead of + // relying on the two taps rounding to the same texel. + auto width_i = sws.supersample ? 0.0f : 1.0f / sws.out_width; { constexpr std::array sources {{ @@ -1123,6 +1200,8 @@ namespace egl { sws.offsetX = offsetX_f; sws.offsetY = offsetY_f; + configure_supersampling(sws, true); + { constexpr std::array sources {{ SUNSHINE_SHADERS_DIR "/Scene.vert", @@ -1349,9 +1428,21 @@ namespace egl { return 0; } - int sws_t::convert_nv12(gl::frame_buf_t &fb) { + void sws_t::bind_source_texture() { gl::ctx.BindTexture(GL_TEXTURE_2D, loaded_texture); + if (supersample) { + // Set here rather than in gl::tex_t::make(), which backs every texture in the GL path + // including the cursor. The source may also be a texture imported from a DMA-BUF and + // owned elsewhere, so this is the only point that reliably covers both cases. + gl::ctx.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + gl::ctx.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + } + } + + int sws_t::convert_nv12(gl::frame_buf_t &fb) { + bind_source_texture(); + GLenum attachments[] { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1 @@ -1372,7 +1463,7 @@ namespace egl { } int sws_t::convert_yuv444(gl::frame_buf_t &fb) { - gl::ctx.BindTexture(GL_TEXTURE_2D, loaded_texture); + bind_source_texture(); GLenum attachments[] { GL_COLOR_ATTACHMENT0, diff --git a/src/platform/linux/graphics.h b/src/platform/linux/graphics.h index 2a2a06429c1..5ff2619afc7 100644 --- a/src/platform/linux/graphics.h +++ b/src/platform/linux/graphics.h @@ -634,6 +634,26 @@ namespace egl { std::optional pw_flags; ///< PipeWire frame flags reported with the buffer. }; + /** + * @brief Determine the exact integer capture-to-stream upscale factor. + * + * Point (nearest-neighbour) upscaling only replicates each captured pixel into an + * identical NxN block when the stream resolution is an exact integer multiple of the + * capture resolution in both axes. The factor must additionally be even: 4:2:0 chroma is + * a box average of a 2x2 window of stream pixels, and that window only ever falls fully + * inside one replicated block (or fully spans two identical ones) when the block size is + * even. An odd factor blends two different source pixels' chroma in some windows - + * measured to be worse than the existing bilinear scaling, not just merely inexact. Any + * ratio that fails either test must keep the default bilinear filter. + * + * @param in_width Capture width in pixels. + * @param in_height Capture height in pixels. + * @param out_width Aspect-corrected stream width in pixels. + * @param out_height Aspect-corrected stream height in pixels. + * @return The upscale factor when it is an even integer of at least 2 and identical in both axes, otherwise 0. + */ + int supersample_factor(int in_width, int in_height, int out_width, int out_height); + /** * @brief EGL/OpenGL scaler and colorspace conversion pipeline. */ @@ -719,6 +739,14 @@ namespace egl { */ int blank(gl::frame_buf_t &fb, int offsetX_, int offsetY_, int width, int height, bool is_yuv444); + /** + * @brief Bind the loaded source texture and select the filter used by the scaling blit. + * + * Applies GL_NEAREST when an exact integer upscale was detected, so each captured pixel is + * replicated as an identical block instead of being interpolated. + */ + void bind_source_texture(); + /** * @brief Load ram data from the backing API or store. * @@ -764,6 +792,9 @@ namespace egl { int offsetX; ///< Offset x. int offsetY; ///< Offset y. + // Non-zero when the capture->stream blit uses point sampling instead of bilinear + int supersample {0}; ///< Integer capture-to-stream upscale factor, or 0 to keep bilinear filtering. + // Pointer to the texture to be converted to nv12 int loaded_texture; ///< Loaded texture. From 9655df73f0c6e0cb0f1927c4433ddb977d2cc90d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:18:06 +0000 Subject: [PATCH 2/2] web-ui, tests: wire up chroma_supersample and cover supersample_factor Local validation pass on the previous commit surfaced two gaps: - ConfigConsistencyTest.AllConfigOptionsExistInAllFiles failed: chroma_supersample was documented in configuration.md but never added to config.html's option defaults or en.json's locale strings, so the web UI had no way to set it. Add the option (ordered to match docs/config.html consistently, verified by ConfigOptionsInSameOrderWithinSections) and wire a Checkbox into Advanced.vue, gated to platform === 'linux' to match where the underlying code lives. - supersample_factor() was exposed in graphics.h as pure, testable logic but had zero test coverage. Add tests/unit/platform/linux/test_graphics.cpp covering the even-factor requirement (2x/4x/6x/8x/10x/12x accepted, 3x/5x/7x rejected - matching the numpy-verified exactness results from development), the >=2 minimum, non-integer ratios, anisotropic ratios, and non-positive capture dimensions. Also verified separately (not part of this diff): clang-format is clean on the changed lines, and a full BUILD_DOCS=ON doxygen pass produces zero warnings on any changed file (the one pre-existing doxygen error in this checkout is an unrelated alias-argument bug in the third-party/doxyconfig submodule's own example doc, reproducible on master). --- src_assets/common/assets/web/config.html | 1 + .../assets/web/configs/tabs/Advanced.vue | 10 ++++ .../assets/web/public/assets/locale/en.json | 2 + tests/unit/platform/linux/test_graphics.cpp | 52 +++++++++++++++++++ 4 files changed, 65 insertions(+) create mode 100644 tests/unit/platform/linux/test_graphics.cpp diff --git a/src_assets/common/assets/web/config.html b/src_assets/common/assets/web/config.html index f8df1ae4d24..5b4937ea46a 100644 --- a/src_assets/common/assets/web/config.html +++ b/src_assets/common/assets/web/config.html @@ -302,6 +302,7 @@

{{ $t('config.configuration') }}

"min_threads": 2, "hevc_mode": 0, "av1_mode": 0, + "chroma_supersample": "disabled", "capture": "", "encoder": "", }, diff --git a/src_assets/common/assets/web/configs/tabs/Advanced.vue b/src_assets/common/assets/web/configs/tabs/Advanced.vue index 108d2b3362d..662a19c1fe0 100644 --- a/src_assets/common/assets/web/configs/tabs/Advanced.vue +++ b/src_assets/common/assets/web/configs/tabs/Advanced.vue @@ -1,6 +1,7 @@