Skip to content

Fix HDR mode for virtual display - #281

Open
ArturKorop wants to merge 1 commit into
TheElixZammuto:masterfrom
ArturKorop:akorop/fix-hdr
Open

Fix HDR mode for virtual display#281
ArturKorop wants to merge 1 commit into
TheElixZammuto:masterfrom
ArturKorop:akorop/fix-hdr

Conversation

@ArturKorop

@ArturKorop ArturKorop commented Jul 7, 2026

Copy link
Copy Markdown

It is a possible fix for the HDR issue reported here: #234

As I've never done any C++ programming (but have worked with a bunch of other languages over the last 15 years), I rely on Cursor for the proposed changes, with the following explanations of possible HDR issues.

I've published the project and tested via Dev Mode on my Xbox; HDR works fine.

Both reasons and fixes seem reasonable. I am not an expert, and if the changes don't make sense, so be it. I hope these proposed changes will help to fix HDR issues properly.

Reason 1

Swap chain color space never set to PQ (highest probability)
The swap chain defaults to sRGB/gamma 2.2 for R10G10B10A2. HDR10 requires explicitly setting DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020.
Problems:

  • CheckColorSpaceSupport can fail silently — if it fails, nothing is logged and m_LastColorTrc is still updated, so it never retries.
  • Only triggers when color_trc == AVCOL_TRC_SMPTE2084 — if FFmpeg leaves color_trc as UNSPECIFIED or BT709 on HDR frames, the swap chain stays in SDR mode while PQ content is drawn.
  • moonlight-qt does not use CheckColorSpaceSupport — it calls SetColorSpace1 directly and logs HRESULT on failure.
  • Symptom: TV in HDR mode, PQ pixel data presented as sRGB → classic washed-out, overly bright HDR look.

Fix:

  • Call SetColorSpace1 directly (like moonlight-qt) and log failures.
  • When HDR is active (configuration->enableHDR or client->IsHDR()), treat UNSPECIFIED/BT709 as PQ for swap chain purposes.
  • Reset m_LastColorTrc when enabling HDR so the next frame re-applies color space.

Reason 2

HDMI HDR enabled, but rendering path still SDR (timing / coordination)
SetDisplayHDR (HDMI) and SetColorSpace1 (swap chain) are triggered separately:

  • HDMI: SetHDR callback → VideoRenderer::SetHDR → SetDisplayHDR
  • Swap chain: per-frame in Render() based on frame->color_trc

SetHDR waits for loading to finish; frames can render before HDMI switches or before swap chain color space is set.

There is also a known issue in the project TODO:

"When changing the Windows HDR calibration profile on the host, it causes the stream to reset and often switch to the wrong colorspace."

Fix:

  • Set swap chain color space inside SetHDR(true), not only from frame metadata.
  • Reset m_LastColorTrc when toggling HDR so color space is re-applied on the next frame.
  • After ResizeBuffers / device lost, color space resets to default — force re-apply (reset m_LastColorTrc on swap chain recreate).

Summary by CodeRabbit

  • Bug Fixes
    • Improved video color handling so the display updates more reliably when switching between HDR and SDR content.
    • Kept HDR settings and the video output in sync, reducing cases where colors could appear incorrect after a mode change.
    • Added more robust logging around color-space updates to help detect and recover from display issues.

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR refactors HDR colorspace handling in VideoRenderer by adding two private helper methods (frameUsesHdrColorSpace, applySwapChainColorSpace), a cached state member (m_SwapChainHdrColorSpace), updating Render to apply swap-chain colorspace via these helpers, and modifying SetHDR to reset caches and directly synchronize display and swap-chain colorspaces.

Changes

HDR Swap-Chain Colorspace Handling

Layer / File(s) Summary
Helper declarations and state
Streaming/VideoRenderer.h
Declares frameUsesHdrColorSpace and applySwapChainColorSpace private methods and adds m_SwapChainHdrColorSpace boolean member defaulting to false.
Render path colorspace application
Streaming/VideoRenderer.cpp
Render now calls applySwapChainColorSpace(frame) after drawing; new helpers determine HDR status from frame color_trc, config, and client settings, then apply via SetColorSpace1 with caching, replacing prior CheckColorSpaceSupport/ThrowIfFailed logic.
SetHDR synchronization
Streaming/VideoRenderer.cpp
SetHDR resets colorspace caches and directly calls SetColorSpace1 to set HDR10 (P2020/PQ) or SDR (P709) colorspace, with logging of success/failure.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • TheElixZammuto/moonlight-xbox#164: Both PRs modify VideoRenderer::SetHDR(bool) in Streaming/VideoRenderer.cpp, with this PR syncing swap-chain colorspace and the related PR changing HDR display-mode selection criteria.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is clear and directly reflects the main change: fixing HDR behavior for the virtual display.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Streaming/VideoRenderer.cpp`:
- Around line 707-730: VideoRenderer::SetHDR is mutating shared color-space
state and calling swap-chain APIs from the connection callback path, which can
race with Render() and applySwapChainColorSpace(). Update SetHDR so the actual
m_LastColorTrc/m_SwapChainHdrColorSpace changes and swap-chain SetColorSpace1
call are marshaled onto the render thread, or protected with the same
synchronization used by the render loop. Keep the logic anchored around
VideoRenderer::SetHDR, Render(), and applySwapChainColorSpace() so all DXGI
color-space updates happen on one thread.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6c5187ff-a805-4222-9b2c-29bcbbe5534f

📥 Commits

Reviewing files that changed from the base of the PR and between 261303b and 9704eb0.

📒 Files selected for processing (2)
  • Streaming/VideoRenderer.cpp
  • Streaming/VideoRenderer.h

Comment on lines +707 to +730

// Keep HDMI and swap chain color spaces in sync; force re-apply on next frame if this fails
m_LastColorTrc = AVCOL_TRC_UNSPECIFIED;
m_SwapChainHdrColorSpace = !enabled;

auto* swapChain = m_deviceResources->GetSwapChain();
if (!swapChain) {
return;
}

DXGI_COLOR_SPACE_TYPE colorspace = enabled
? DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020
: DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709;

HRESULT hr = swapChain->SetColorSpace1(colorspace);
if (SUCCEEDED(hr)) {
m_SwapChainHdrColorSpace = enabled;
Utils::Logf("SetHDR(%s): swap chain colorspace set to %s\n",
enabled ? "true" : "false",
enabled ? "DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020"
: "DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709");
} else {
Utils::Logf("SetHDR(%s): SetColorSpace1 failed: 0x%08X\n", enabled ? "true" : "false", hr);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Trace which thread invokes SetHDR to confirm cross-thread swap-chain access.
rg -nP -C4 '\bSetHDR\s*\(' --type=cpp

Repository: TheElixZammuto/moonlight-xbox

Length of output: 167


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- SetHDR / applySwapChainColorSpace symbols ---'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/build/**' --glob '!**/dist/**' \
  '\b(SetHDR|applySwapChainColorSpace|m_SwapChainHdrColorSpace|m_LastColorTrc)\b' .

printf '\n%s\n' '--- likely thread/callback dispatch references ---'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/build/**' --glob '!**/dist/**' \
  '\b(callback|thread|render thread|dispatch|invoke|post|queue|task|async)\b' State Streaming | head -n 300

Repository: TheElixZammuto/moonlight-xbox

Length of output: 7322


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- file list ---'
git ls-files | sed -n '1,200p'

Repository: TheElixZammuto/moonlight-xbox

Length of output: 5492


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Streaming/moonlight_xbox_dxMain.cpp (around SetHDR assignment) ---'
sed -n '120,150p' Streaming/moonlight_xbox_dxMain.cpp

printf '\n%s\n' '--- Streaming/VideoRenderer.h ---'
sed -n '1,130p' Streaming/VideoRenderer.h

printf '\n%s\n' '--- Streaming/VideoRenderer.cpp (around SetHDR and applySwapChainColorSpace) ---'
sed -n '150,240p' Streaming/VideoRenderer.cpp
printf '\n%s\n' '--- Streaming/VideoRenderer.cpp (around SetHDR) ---'
sed -n '680,740p' Streaming/VideoRenderer.cpp

Repository: TheElixZammuto/moonlight-xbox

Length of output: 10272


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Streaming/moonlight_xbox_dxMain.cpp (render task / thread comments) ---'
sed -n '200,260p' Streaming/moonlight_xbox_dxMain.cpp
printf '\n%s\n' '--- Streaming/moonlight_xbox_dxMain.cpp (stream start / task creation) ---'
sed -n '340,390p' Streaming/moonlight_xbox_dxMain.cpp

Repository: TheElixZammuto/moonlight-xbox

Length of output: 4116


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Streaming/moonlight_xbox_dxMain.cpp (120-150) ---'
sed -n '120,150p' Streaming/moonlight_xbox_dxMain.cpp

printf '\n%s\n' '--- Streaming/moonlight_xbox_dxMain.cpp (200-260) ---'
sed -n '200,260p' Streaming/moonlight_xbox_dxMain.cpp

printf '\n%s\n' '--- Streaming/moonlight_xbox_dxMain.cpp (340-390) ---'
sed -n '340,390p' Streaming/moonlight_xbox_dxMain.cpp

printf '\n%s\n' '--- State/MoonlightClient.cpp (372-386) ---'
sed -n '372,386p' State/MoonlightClient.cpp

printf '\n%s\n' '--- Streaming/VideoRenderer.cpp (170-210) ---'
sed -n '170,210p' Streaming/VideoRenderer.cpp

printf '\n%s\n' '--- Streaming/VideoRenderer.cpp (690-730) ---'
sed -n '690,730p' Streaming/VideoRenderer.cpp

Repository: TheElixZammuto/moonlight-xbox

Length of output: 8626


Marshal SetHDR onto the render thread

client->SetHDR is invoked from the connection callback path, but VideoRenderer::SetHDR now mutates m_LastColorTrc/m_SwapChainHdrColorSpace and calls SetColorSpace1 directly. That bypasses the render-loop synchronization and can race with Render()/applySwapChainColorSpace(). Move the swap-chain update onto the render thread or guard the shared state and DXGI access.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Streaming/VideoRenderer.cpp` around lines 707 - 730, VideoRenderer::SetHDR is
mutating shared color-space state and calling swap-chain APIs from the
connection callback path, which can race with Render() and
applySwapChainColorSpace(). Update SetHDR so the actual
m_LastColorTrc/m_SwapChainHdrColorSpace changes and swap-chain SetColorSpace1
call are marshaled onto the render thread, or protected with the same
synchronization used by the render loop. Keep the logic anchored around
VideoRenderer::SetHDR, Render(), and applySwapChainColorSpace() so all DXGI
color-space updates happen on one thread.

@andygrundman

Copy link
Copy Markdown
Collaborator

If I'm following this correctly, you are saying that ffmpeg's frame->color_trc contains wrong data? I have never seen this, this field always matches the host's colorspace. An PQ-encoded HDR frame will always have color_trc set to AVCOL_TRC_SMPTE2084.

Also, I don't think there is anything wrong with checking CheckColorSpaceSupport() before calling SetColorSpace1(). It has always worked for me.

Maybe you can get a log showing these things failing?

@ArturKorop

Copy link
Copy Markdown
Author

If I'm following this correctly, you are saying that ffmpeg's frame->color_trc contains wrong data? I have never seen this, this field always matches the host's colorspace. An PQ-encoded HDR frame will always have color_trc set to AVCOL_TRC_SMPTE2084.

Also, I don't think there is anything wrong with checking CheckColorSpaceSupport() before calling SetColorSpace1(). It has always worked for me.

Maybe you can get a log showing these things failing?

I just have HDR never properly worked on my setup for Apollo (with virtual display) + XBOX Series X. It works as expected on Google Streamer.
I've already reported the issue here: #234

@Wyldra

Wyldra commented Jul 18, 2026

Copy link
Copy Markdown

I'm getting this exact issue using Xbox Series X. Windows HDR calibration, both white screen tests are completely white regardless of moving the sliders in any direction, also having the same with when pressing the menu shortcut buttons on the controller to enter mouse mode ect... while that overlay is on the HDR looks completely fine? then once I come out of the overlay, HDR goes back to blooming whites, the Sun/Fire - other bright light sources might as well be white light blobs, barely see any details. My PC is also connected to a 3440x1440p monitor? not sure if it makes any different. Tried for hours with the calibration tools, both on Xbox and Windows, also messing around with my TV's settings, switch HGIG on ect, something is completely overriding any attempt to calibrate HDR it seems. I have now used both Apollo and Vibeshine, no difference. I will try it on my Nvidea shield to see if the client device makes any difference.

Interesting how it's almost the exact same issues here.

@andygrundman

andygrundman commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

If you're using Apollo and have a non-virtual HDR display you can hook up temporarily, it would be great if you could test with a recent build of official Sunshine streaming from a real HDR display in HDR mode. It doesn't need to be good or calibrated, just enough to let you enable HDR on it.

The fact that the tone-mapping changes when you bring up the quick menu makes me wonder if the problem could be something with the shader applied by Apollo to outgoing frames. This was significantly reworked in Sunshine within the last year. For reference, when I use the quick menu, the rest of the stream gets a very slightly dimmed effect.

Here is a shot of the effect while playing an HDR video (ignore the fact that it's been converted to SDR). It's very subtle, the light gray box around the Youtube description goes from RGB #303030 to #272727.

Xbox-HDR-quick-menu-tone-mapping

@Wyldra

Wyldra commented Jul 18, 2026

Copy link
Copy Markdown

Using official latest Sunshine (no Apollo, no virtual display) — I'm experiencing the exact same issue as described.

Setup below.
GPU: RTX 5080
Monitor: 3440x1440 (physical display)
Client: Xbox Series X + Moonlight
TV: Sony Bravia 8

Symptoms:

HDR is extremely bright/blown out in games and in Windows HDR calibration tool (both white boxes are pure white, sliders do nothing).
SDR streams perfectly with no issues.
Bringing up the Moonlight quick menu (Guide + Menu buttons) immediately improves HDR — brightness normalizes and details return in bright areas.
Changing tone mapping or HDR settings while streaming has no visible effect, but the same settings work fine on the Xbox dashboard.

I’ve tried full HDR calibration on both Windows and Xbox, different TV HDR modes (including HGIG), and disabled all NVIDIA overlays/auto-optimization. Nothing seems to affect the blown-out HDR during streaming.
This is the only report I’ve found that matches my issue so closely (especially the calibration tool and quick menu behavior). If OP or anyone else sees this, it would be helpful to know what GPU/TV combo you’re using so we can find common factors.
Still planning to test on NVIDIA Shield, but I’d really miss 4K120 on the Xbox.

Still find it very odd that the quick menu feels like it actually fixes the issue lol

@Wyldra

Wyldra commented Jul 18, 2026

Copy link
Copy Markdown

Update.

After changing a few more settings on Ghost of tsushima, it doesn't look too bad anymore. It's a little bright but nothing crazy, not sure if Sunshine fixed it or at least helped, potentially. It does look better now.

Thanks!

@ArturKorop

ArturKorop commented Jul 18, 2026

Copy link
Copy Markdown
Author

My setup is:
GPU: RTX 5080
Monitor: 5120x2160 (physical display) - but I am using a virtual display only
Server: Vibepollo/Vibeshine/Apollo - I've tried all 3 of them because of virtual monitor support, using Vibepollo right now.
Client: Xbox Series X + Moonlight
TV: LG G5 - using in 4K resolution, thus, need a virtual display

Important part, I guess: I have a separate setup on Google Streamer 4K with Android Artemis and Moonlight clients, and HDR works perfectly.

@andygrundman

Copy link
Copy Markdown
Collaborator

Please include your server logs, there might be clues in the desktop/capture format and colorspace values. Does anyone have this problem who is not using a virtual display? Is anyone using the new experimental virtual display that is part of Vibeshine? (I am using it successfully , but in a manual way with Sunshine.)

@Wyldra

Wyldra commented Jul 20, 2026

Copy link
Copy Markdown

Latest Sunshine build connected to my real 3440x1440 monitor. (Not using a virtual display here)
Vibeshine (virtual display) also tested.
All HDR settings enabled on Windows, Xbox, and TV.
Full HDR calibration attempted on both Windows and Xbox.

As per previous comments, all the same results regardless of virtual display or not.

On Xbox Series X + Moonlight: HDR is blown out. Both Windows HDR calibration screens (Maximum Luminance and Max Full Frame Luminance) are completely white — sliders do nothing. Bright areas (sky, sun, lights) turn into white blobs with no detail. SDR streams perfectly with no issues. Changing tone mapping or HDR settings while streaming has no visible effect.

On NVIDIA Shield + Artemis: HDR works correctly. Calibration tests are visible, and I can properly adjust the sliders.

Attached the logs.
20260720_101750
20260720_084751
20260720_083713

If there's anything else I can do, please let me know.

Thanks.

@andygrundman

Copy link
Copy Markdown
Collaborator

I can see that the HDR profile you're using in Windows is set to 460 (the line "Sending HDR10 metadata"), but this wouldn't break the HDR Calibration app. You should be seeing the gray cross in the white box up until around 800. Windows turns off all other adjustments when running that app so the only thing that would mess with it is your TV's tone mapping setting. It should look similar to the Xbox checkerboard pattern... are you able to use the Xbox one?

The only other thing I can think of that can create a blown-out look in Windows is the SDR Content Brightness slider, which is inside the HDR settings page. If it's set too high it will make the SDR content in Windows too bright (the right value is usually 15). But that's not what you're seeing anyway.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants