From 49a0f59c07998fcecefa687744ecf8b8f0796cae Mon Sep 17 00:00:00 2001 From: mledour Date: Tue, 30 Jun 2026 15:47:44 +0200 Subject: [PATCH 1/4] fix(overlay): accept RGBA8/sRGB swapchain formats, not just BGRA8 The overlay refused to initialise on SteamVR/OpenXR (seen via OpenComposite) because pickSwapchainFormat() required DXGI_FORMAT_B8G8R8A8_UNORM exactly and bailed when the runtime didn't advertise it. SteamVR advertises RGBA8/sRGB variants instead. Accept a prioritised list (BGRA8_UNORM -> RGBA8_UNORM -> their sRGB siblings) and create the per-image RTV in the chosen format on both the D3D11 and D3D11On12 paths. The GPU shader path writes a logical float4(r,g,b,a) and the output-merger swizzles to the RTV layout, so an RGBA8 target renders identical colours. On failure we now log the full list of formats the runtime advertised instead of guessing. Co-Authored-By: Claude Opus 4.8 --- openxr-api-layer/utils/overlay_renderer.cpp | 80 +++++++++++++++------ 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/openxr-api-layer/utils/overlay_renderer.cpp b/openxr-api-layer/utils/overlay_renderer.cpp index 654ee76..0c920e1 100644 --- a/openxr-api-layer/utils/overlay_renderer.cpp +++ b/openxr-api-layer/utils/overlay_renderer.cpp @@ -373,15 +373,31 @@ namespace openxr_api_layer::detail { // remains. constexpr float kHeaderSepInsetY = 8.0f; - // Target DXGI format for the swapchain image — also the format - // the D2D RenderTarget paints into. + // Preferred swapchain format for the overlay image. BGRA8_UNORM is + // the historical first choice (the original D2D path painted BGRA), + // but the GPU shader path writes a logical float4(r,g,b,a) and lets + // the output-merger swizzle to the RTV's memory layout, so an RGBA8 + // target renders identical colours. We therefore accept a small + // prioritised list and create the RTV in the chosen format. constexpr int64_t kFormatBGRA = static_cast(DXGI_FORMAT_B8G8R8A8_UNORM); - // Pick a swapchain format the runtime advertises that we can paint - // into. Returns kFormatBGRA on success, 0 on failure (caller logs - // and degrades). We don't try to fall back to RGBA8 — D2D's BGRA - // pipeline is the simple path, and modern runtimes (Pimax, SteamVR, - // WMR, Oculus, Varjo) all advertise BGRA8. + // Acceptable swapchain formats, most-preferred first. UNORM variants + // come before their sRGB siblings: an sRGB RTV makes the GPU re-encode + // our already-sRGB UI colours on write (slightly washed out), so we + // only take sRGB when the runtime advertises nothing linear. SteamVR + // (seen via OpenComposite) advertises RGBA/sRGB but not BGRA8_UNORM, + // which is exactly the case the BGRA-only gate used to reject. + constexpr int64_t kAcceptedFormats[] = { + kFormatBGRA, + static_cast(DXGI_FORMAT_R8G8B8A8_UNORM), + static_cast(DXGI_FORMAT_B8G8R8A8_UNORM_SRGB), + static_cast(DXGI_FORMAT_R8G8B8A8_UNORM_SRGB), + }; + + // Pick the highest-priority format from kAcceptedFormats that the + // runtime advertises. Returns that format on success, 0 on failure + // (caller logs and degrades). On failure we dump the advertised list + // so a future unsupported-runtime report is one log line, not a guess. int64_t pickSwapchainFormat(OpenXrApi* api, XrSession session) { uint32_t count = 0; if (XR_FAILED(api->xrEnumerateSwapchainFormats(session, 0, &count, nullptr)) || @@ -393,9 +409,23 @@ namespace openxr_api_layer::detail { session, count, &count, formats.data()))) { return 0; } + for (const int64_t want : kAcceptedFormats) { + for (const int64_t f : formats) { + if (f == want) return want; + } + } + // Nothing usable — log what the runtime actually offered so the + // gate can be widened from a real list rather than a guess. + std::string advertised; for (const int64_t f : formats) { - if (f == kFormatBGRA) return kFormatBGRA; + if (!advertised.empty()) advertised += ", "; + advertised += std::to_string(f); } + Log(fmt::format( + "xr_telemetry: overlay — runtime advertised {} swapchain " + "format(s): [{}]; none are an accepted BGRA8/RGBA8 (UNORM or " + "sRGB) target\n", + count, advertised)); return 0; } @@ -3006,8 +3036,8 @@ namespace openxr_api_layer::detail { } const int64_t format = pickSwapchainFormat(m_api, m_session); if (format == 0) { - Log("xr_telemetry: overlay disabled — runtime doesn't advertise " - "DXGI_FORMAT_B8G8R8A8_UNORM among supported swapchain formats\n"); + Log("xr_telemetry: overlay disabled — runtime advertises no " + "accepted BGRA8/RGBA8 swapchain format (see list above)\n"); return false; } @@ -3137,12 +3167,14 @@ namespace openxr_api_layer::detail { // One RTV per swapchain image — we paint chrome + text + // bars DIRECTLY into the acquired image each frame (no // intermediate texture, no CopyResource). The runtime's - // images are BGRA8 (typeless on Pimax), so we give the - // RTV an explicit BGRA8_UNORM desc to get a typed view. + // images can come back typeless (e.g. Pimax), so we give the + // RTV an explicit typed desc in the format we chose above — + // BGRA8 or, when that's all the runtime offers, RGBA8/sRGB. + const DXGI_FORMAT rtvFormat = static_cast(format); m_imageRtvs.resize(m_images.size()); for (size_t i = 0; i < m_images.size(); ++i) { D3D11_RENDER_TARGET_VIEW_DESC rtvDesc{}; - rtvDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + rtvDesc.Format = rtvFormat; rtvDesc.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2D; rtvDesc.Texture2D.MipSlice = 0; if (FAILED(m_device->CreateRenderTargetView( @@ -3173,9 +3205,10 @@ namespace openxr_api_layer::detail { Log(fmt::format( "xr_telemetry: overlay D3D11 renderer ready ({} swapchain " - "images, direct-to-swapchain BGRA8 RT, " + "images, direct-to-swapchain RT format={}, " "feature_level={:#x})\n", - imgCount, static_cast(appLevel))); + imgCount, static_cast(rtvFormat), + static_cast(appLevel))); return true; } @@ -3486,8 +3519,8 @@ namespace openxr_api_layer::detail { } const int64_t format = pickSwapchainFormat(m_api, m_session); if (format == 0) { - Log("xr_telemetry: overlay disabled — runtime doesn't advertise " - "BGRA8 for the D3D12 swapchain\n"); + Log("xr_telemetry: overlay disabled — runtime advertises no " + "accepted BGRA8/RGBA8 swapchain format for the D3D12 path\n"); return false; } @@ -3568,9 +3601,10 @@ namespace openxr_api_layer::detail { // into the acquired image each frame (no shim, no per-frame // CopyResource). The wrapped resources were created with // BIND_RENDER_TARGET above, so they're valid RTV targets. - // Use an EXPLICIT BGRA8_UNORM view so a typeless wrapped - // resource (some runtimes) still resolves to the format the - // shaders write — mirrors the D3D11 path's explicit desc. + // Use an EXPLICIT typed view in the format we chose above so a + // typeless wrapped resource (some runtimes) still resolves to + // the format the shaders write — mirrors the D3D11 path. + const DXGI_FORMAT rtvFormat = static_cast(format); m_imageRtvs.resize(imgCount); for (uint32_t i = 0; i < imgCount; ++i) { ComPtr tex2d; @@ -3581,7 +3615,7 @@ namespace openxr_api_layer::detail { return false; } D3D11_RENDER_TARGET_VIEW_DESC rtvDesc{}; - rtvDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + rtvDesc.Format = rtvFormat; rtvDesc.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2D; rtvDesc.Texture2D.MipSlice = 0; if (FAILED(m_d3d11Device->CreateRenderTargetView( @@ -3611,8 +3645,8 @@ namespace openxr_api_layer::detail { Log("xr_telemetry: overlay D3D12 renderer ready (" + std::to_string(imgCount) + - " swapchain images, D3D11On12 bridge, direct-to-image " - "BGRA8 RT)\n"); + " swapchain images, D3D11On12 bridge, direct-to-image RT " + "format=" + std::to_string(static_cast(rtvFormat)) + ")\n"); return true; } From c95bb977190d5cf54413d3a51ba622a7d53e480c Mon Sep 17 00:00:00 2001 From: mledour Date: Tue, 30 Jun 2026 16:21:57 +0200 Subject: [PATCH 2/4] fix(overlay): paint sRGB swapchains through a UNORM RTV (SteamVR washout) SteamVR/OpenXR advertises only the _SRGB swapchain variants (no linear UNORM) and returns a TYPELESS resource. The previous change picked B8G8R8A8_UNORM_SRGB and created the RTV in that same sRGB format, so the GPU applied linear->sRGB encoding when writing our already-sRGB UI colours; SteamVR then decoded again, leaving the overlay washed out / light grey. Pimax/WMR/Oculus were unaffected because they advertise a UNORM format. Add rtvFormatForSwapchain(): map an sRGB swapchain pick to its UNORM sibling (91->87, 29->28) for the render-target view, so the shader output is stored verbatim and the runtime does the correct sRGB decode on composite. A UNORM view over the TYPELESS resource is valid. UNORM picks pass through unchanged, so the working runtimes keep their exact path. Applied to both the D3D11 and D3D11On12 backends. Co-Authored-By: Claude Opus 4.8 --- openxr-api-layer/utils/overlay_renderer.cpp | 37 ++++++++++++++++----- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/openxr-api-layer/utils/overlay_renderer.cpp b/openxr-api-layer/utils/overlay_renderer.cpp index 0c920e1..605434b 100644 --- a/openxr-api-layer/utils/overlay_renderer.cpp +++ b/openxr-api-layer/utils/overlay_renderer.cpp @@ -429,6 +429,26 @@ namespace openxr_api_layer::detail { return 0; } + // Format for the RTV we paint through. It must NOT re-encode our + // already-sRGB UI colours: when the runtime only offers an sRGB + // swapchain (SteamVR advertises the _SRGB variants and returns a + // TYPELESS resource), an sRGB RTV would apply linear->sRGB on write, + // leaving the composited overlay washed out / light grey. Map an sRGB + // pick to its UNORM sibling so the shader output is stored verbatim + // and the runtime does the correct sRGB decode when compositing. A + // UNORM view over the TYPELESS resource is valid. UNORM picks (Pimax, + // WMR, Oculus, Varjo) pass straight through unchanged. + DXGI_FORMAT rtvFormatForSwapchain(int64_t swapchainFormat) { + switch (swapchainFormat) { + case DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: + return DXGI_FORMAT_B8G8R8A8_UNORM; + case DXGI_FORMAT_R8G8B8A8_UNORM_SRGB: + return DXGI_FORMAT_R8G8B8A8_UNORM; + default: + return static_cast(swapchainFormat); + } + } + // -------- GPU text formats ---------------------------------------- // // One GpuTextFormat per IDWriteTextFormat the D2D path uses @@ -3167,10 +3187,11 @@ namespace openxr_api_layer::detail { // One RTV per swapchain image — we paint chrome + text + // bars DIRECTLY into the acquired image each frame (no // intermediate texture, no CopyResource). The runtime's - // images can come back typeless (e.g. Pimax), so we give the - // RTV an explicit typed desc in the format we chose above — - // BGRA8 or, when that's all the runtime offers, RGBA8/sRGB. - const DXGI_FORMAT rtvFormat = static_cast(format); + // images can come back typeless (e.g. Pimax/SteamVR), so we + // give the RTV an explicit typed desc. rtvFormatForSwapchain + // maps an sRGB swapchain to its UNORM sibling so we don't + // double-encode the colours (see helper for the why). + const DXGI_FORMAT rtvFormat = rtvFormatForSwapchain(format); m_imageRtvs.resize(m_images.size()); for (size_t i = 0; i < m_images.size(); ++i) { D3D11_RENDER_TARGET_VIEW_DESC rtvDesc{}; @@ -3601,10 +3622,10 @@ namespace openxr_api_layer::detail { // into the acquired image each frame (no shim, no per-frame // CopyResource). The wrapped resources were created with // BIND_RENDER_TARGET above, so they're valid RTV targets. - // Use an EXPLICIT typed view in the format we chose above so a - // typeless wrapped resource (some runtimes) still resolves to - // the format the shaders write — mirrors the D3D11 path. - const DXGI_FORMAT rtvFormat = static_cast(format); + // Use an EXPLICIT typed view; rtvFormatForSwapchain maps an + // sRGB swapchain to its UNORM sibling so a typeless wrapped + // resource resolves to a non-re-encoding view — mirrors D3D11. + const DXGI_FORMAT rtvFormat = rtvFormatForSwapchain(format); m_imageRtvs.resize(imgCount); for (uint32_t i = 0; i < imgCount; ++i) { ComPtr tex2d; From ad54de48558273b71b8699961725c26a9ce0712a Mon Sep 17 00:00:00 2001 From: mledour Date: Tue, 30 Jun 2026 18:06:15 +0200 Subject: [PATCH 3/4] refactor(overlay): unify swapchain/RTV format table, harden sRGB RTV path Addresses code-review findings on the swapchain-format fallback: - Single source of truth: fold the accept list and the sRGB->UNORM RTV map into one kOverlayFormats table, so adding a format is one row and the two can't drift out of sync (a missing map entry previously fell through to an sRGB RTV, silently reintroducing the washout). - RTV fallback: createOverlayImageRtv retries with the sRGB view if the UNORM view is rejected, for runtimes that hand back a fully-typed (non-typeless) sRGB resource where CreateRenderTargetView(UNORM) would fail with E_INVALIDARG. Shared by the D3D11 and D3D11On12 paths. - Diagnostics: the 'renderer ready' logs now record the picked swapchain format (and tag it (sRGB)), not just the de-sRGB'd RTV format, so a color/text report shows whether the sRGB composite path engaged. - Fix the failure-path format list: trust the count from the second xrEnumerateSwapchainFormats (resize) so the diagnostic never lists stale trailing zeros or a count that disagrees with the list. - Refresh stale comments that still claimed the RTV is always BGRA8_UNORM. No behavioural change on the working runtimes (UNORM picks map to themselves); SteamVR's sRGB pick keeps the UNORM RTV from c95bb97. Co-Authored-By: Claude Opus 4.8 --- openxr-api-layer/utils/overlay_renderer.cpp | 182 +++++++++++++------- 1 file changed, 116 insertions(+), 66 deletions(-) diff --git a/openxr-api-layer/utils/overlay_renderer.cpp b/openxr-api-layer/utils/overlay_renderer.cpp index 605434b..602eff2 100644 --- a/openxr-api-layer/utils/overlay_renderer.cpp +++ b/openxr-api-layer/utils/overlay_renderer.cpp @@ -373,28 +373,58 @@ namespace openxr_api_layer::detail { // remains. constexpr float kHeaderSepInsetY = 8.0f; - // Preferred swapchain format for the overlay image. BGRA8_UNORM is - // the historical first choice (the original D2D path painted BGRA), - // but the GPU shader path writes a logical float4(r,g,b,a) and lets - // the output-merger swizzle to the RTV's memory layout, so an RGBA8 - // target renders identical colours. We therefore accept a small - // prioritised list and create the RTV in the chosen format. - constexpr int64_t kFormatBGRA = static_cast(DXGI_FORMAT_B8G8R8A8_UNORM); - - // Acceptable swapchain formats, most-preferred first. UNORM variants - // come before their sRGB siblings: an sRGB RTV makes the GPU re-encode - // our already-sRGB UI colours on write (slightly washed out), so we - // only take sRGB when the runtime advertises nothing linear. SteamVR - // (seen via OpenComposite) advertises RGBA/sRGB but not BGRA8_UNORM, - // which is exactly the case the BGRA-only gate used to reject. - constexpr int64_t kAcceptedFormats[] = { - kFormatBGRA, - static_cast(DXGI_FORMAT_R8G8B8A8_UNORM), - static_cast(DXGI_FORMAT_B8G8R8A8_UNORM_SRGB), - static_cast(DXGI_FORMAT_R8G8B8A8_UNORM_SRGB), + // Single source of truth for the overlay's swapchain/RTV formats. + // Each row pairs an accepted swapchain format with the RTV format we + // paint through. The GPU shader path writes a logical float4(r,g,b,a) + // and the output-merger swizzles to the RTV's memory layout, so BGRA8 + // and RGBA8 render identical colours. + // + // The RTV must NOT re-encode our already-sRGB UI colours: when the + // runtime only offers an sRGB swapchain (SteamVR advertises the _SRGB + // variants and returns a TYPELESS resource), an sRGB RTV would apply + // linear->sRGB on write, leaving the overlay washed out / light grey. + // So each sRGB row maps to its UNORM sibling for the RTV — the shader + // output is stored verbatim and the runtime does the correct sRGB + // decode when compositing. A UNORM view over the TYPELESS resource is + // valid; UNORM picks (Pimax, WMR, Oculus, Varjo) map to themselves and + // keep their exact original path. + // + // Rows are in priority order (most-preferred first): linear UNORM + // ahead of sRGB so we only take an sRGB swapchain when the runtime + // advertises nothing linear. Keeping the accept list and the RTV map + // in ONE table means adding a format is one row — they cannot drift. + struct OverlayFormat { + int64_t swapchain; // an xrEnumerateSwapchainFormats value + DXGI_FORMAT rtv; // RTV format we paint through (never sRGB) }; + constexpr OverlayFormat kOverlayFormats[] = { + { static_cast(DXGI_FORMAT_B8G8R8A8_UNORM), DXGI_FORMAT_B8G8R8A8_UNORM }, + { static_cast(DXGI_FORMAT_R8G8B8A8_UNORM), DXGI_FORMAT_R8G8B8A8_UNORM }, + { static_cast(DXGI_FORMAT_B8G8R8A8_UNORM_SRGB), DXGI_FORMAT_B8G8R8A8_UNORM }, + { static_cast(DXGI_FORMAT_R8G8B8A8_UNORM_SRGB), DXGI_FORMAT_R8G8B8A8_UNORM }, + }; + + // True when the picked swapchain is an sRGB format — i.e. the runtime + // will sRGB-decode the quad at composite. Drives the RTV-fallback and + // the diagnostic logs; the text shader's gamma direction also depends + // on it (see overlay_text_ps.hlsl's DIRECTION CAVEAT). + bool isSrgbSwapchainFormat(int64_t f) { + return f == static_cast(DXGI_FORMAT_B8G8R8A8_UNORM_SRGB) || + f == static_cast(DXGI_FORMAT_R8G8B8A8_UNORM_SRGB); + } - // Pick the highest-priority format from kAcceptedFormats that the + // RTV format for a picked swapchain format. Looks the pick up in + // kOverlayFormats so the sRGB->UNORM mapping has one definition; an + // unlisted format (shouldn't happen — pick only returns listed ones) + // falls back to the format itself. + DXGI_FORMAT rtvFormatForSwapchain(int64_t swapchainFormat) { + for (const OverlayFormat& e : kOverlayFormats) { + if (e.swapchain == swapchainFormat) return e.rtv; + } + return static_cast(swapchainFormat); + } + + // Pick the highest-priority format from kOverlayFormats that the // runtime advertises. Returns that format on success, 0 on failure // (caller logs and degrades). On failure we dump the advertised list // so a future unsupported-runtime report is one log line, not a guess. @@ -409,9 +439,12 @@ namespace openxr_api_layer::detail { session, count, &count, formats.data()))) { return 0; } - for (const int64_t want : kAcceptedFormats) { + // The second call writes `count` entries; trust it over the vector's + // initial size so the diagnostic below never lists stale trailing 0s. + formats.resize(count); + for (const OverlayFormat& want : kOverlayFormats) { for (const int64_t f : formats) { - if (f == want) return want; + if (f == want.swapchain) return want.swapchain; } } // Nothing usable — log what the runtime actually offered so the @@ -425,28 +458,41 @@ namespace openxr_api_layer::detail { "xr_telemetry: overlay — runtime advertised {} swapchain " "format(s): [{}]; none are an accepted BGRA8/RGBA8 (UNORM or " "sRGB) target\n", - count, advertised)); + formats.size(), advertised)); return 0; } - // Format for the RTV we paint through. It must NOT re-encode our - // already-sRGB UI colours: when the runtime only offers an sRGB - // swapchain (SteamVR advertises the _SRGB variants and returns a - // TYPELESS resource), an sRGB RTV would apply linear->sRGB on write, - // leaving the composited overlay washed out / light grey. Map an sRGB - // pick to its UNORM sibling so the shader output is stored verbatim - // and the runtime does the correct sRGB decode when compositing. A - // UNORM view over the TYPELESS resource is valid. UNORM picks (Pimax, - // WMR, Oculus, Varjo) pass straight through unchanged. - DXGI_FORMAT rtvFormatForSwapchain(int64_t swapchainFormat) { - switch (swapchainFormat) { - case DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: - return DXGI_FORMAT_B8G8R8A8_UNORM; - case DXGI_FORMAT_R8G8B8A8_UNORM_SRGB: - return DXGI_FORMAT_R8G8B8A8_UNORM; - default: - return static_cast(swapchainFormat); + // Create the per-image RTV, with a fallback for runtimes that hand + // back a fully-TYPED sRGB resource instead of TYPELESS. Normally the + // UNORM `primary` view is what we want (no colour re-encode). But a + // UNORM view is only legal over a TYPELESS or UNORM resource; a runtime + // that allocates a typed _SRGB backing would reject it with + // E_INVALIDARG. In that case retry with the sRGB `fallback` so the + // overlay still shows (colours may look washed out — logged once) and + // degrade only if even that fails. Shared by the D3D11 and D3D11On12 + // paths so the two can't drift. + bool createOverlayImageRtv(ID3D11Device* device, ID3D11Texture2D* tex, + DXGI_FORMAT primary, DXGI_FORMAT fallback, + ID3D11RenderTargetView** out) { + D3D11_RENDER_TARGET_VIEW_DESC rtvDesc{}; + rtvDesc.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2D; + rtvDesc.Texture2D.MipSlice = 0; + rtvDesc.Format = primary; + if (SUCCEEDED(device->CreateRenderTargetView(tex, &rtvDesc, out))) + return true; + if (fallback != primary) { + rtvDesc.Format = fallback; + if (SUCCEEDED(device->CreateRenderTargetView(tex, &rtvDesc, out))) { + Log(fmt::format( + "xr_telemetry: overlay — UNORM RTV (format={}) rejected; " + "fell back to sRGB RTV (format={}). Resource is typed " + "sRGB, not typeless — overlay colours may look washed " + "out on this runtime\n", + static_cast(primary), static_cast(fallback))); + return true; + } } + return false; } // -------- GPU text formats ---------------------------------------- @@ -3094,10 +3140,12 @@ namespace openxr_api_layer::detail { } // Stash the OpenXR swapchain images; the per-image RTVs - // are created below (Pimax hands back BGRA8_TYPELESS, so - // each RTV needs an explicit BGRA8_UNORM view desc). The - // diagnostic log on image 0 stays around for future - // format / bindFlags regressions. + // are created below with an explicit typed view desc (the + // resource comes back typeless — BGRA8_TYPELESS on Pimax, + // sRGB-typeless on SteamVR — so the view format is chosen by + // rtvFormatForSwapchain, not the resource). The diagnostic log + // on image 0 stays around for future format / bindFlags + // regressions. m_images.resize(imgCount); for (uint32_t i = 0; i < imgCount; ++i) { m_images[i] = raw[i].texture; @@ -3190,17 +3238,15 @@ namespace openxr_api_layer::detail { // images can come back typeless (e.g. Pimax/SteamVR), so we // give the RTV an explicit typed desc. rtvFormatForSwapchain // maps an sRGB swapchain to its UNORM sibling so we don't - // double-encode the colours (see helper for the why). + // double-encode the colours; createOverlayImageRtv retries + // with the sRGB view if the resource is typed-sRGB not typeless. const DXGI_FORMAT rtvFormat = rtvFormatForSwapchain(format); + const DXGI_FORMAT rtvFallback = static_cast(format); m_imageRtvs.resize(m_images.size()); for (size_t i = 0; i < m_images.size(); ++i) { - D3D11_RENDER_TARGET_VIEW_DESC rtvDesc{}; - rtvDesc.Format = rtvFormat; - rtvDesc.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2D; - rtvDesc.Texture2D.MipSlice = 0; - if (FAILED(m_device->CreateRenderTargetView( - m_images[i].Get(), &rtvDesc, - m_imageRtvs[i].GetAddressOf()))) { + if (!createOverlayImageRtv(m_device.Get(), m_images[i].Get(), + rtvFormat, rtvFallback, + m_imageRtvs[i].GetAddressOf())) { Log(fmt::format( "xr_telemetry: overlay disabled — " "CreateRenderTargetView (swapchain image[{}]) " @@ -3226,9 +3272,11 @@ namespace openxr_api_layer::detail { Log(fmt::format( "xr_telemetry: overlay D3D11 renderer ready ({} swapchain " - "images, direct-to-swapchain RT format={}, " + "images, swapchain format={}{}, RT format={}, " "feature_level={:#x})\n", - imgCount, static_cast(rtvFormat), + imgCount, format, + isSrgbSwapchainFormat(format) ? " (sRGB)" : "", + static_cast(rtvFormat), static_cast(appLevel))); return true; } @@ -3275,8 +3323,9 @@ namespace openxr_api_layer::detail { ComPtr m_overlayState; ComPtr m_savedState; XrSwapchain m_swapchain = XR_NULL_HANDLE; - // OpenXR swapchain images (BGRA8_TYPELESS on Pimax) + one - // typed BGRA8_UNORM RTV each. We paint directly into the + // OpenXR swapchain images (typeless on the tested runtimes) + + // one typed RTV each, in the UNORM format rtvFormatForSwapchain + // picks (BGRA8 or RGBA8; never sRGB). We paint directly into the // acquired image's RTV every frame — no intermediate, no // CopyResource. State isolation (SwapDeviceContextState) // keeps our pipeline changes from leaking into the app. @@ -3624,8 +3673,10 @@ namespace openxr_api_layer::detail { // BIND_RENDER_TARGET above, so they're valid RTV targets. // Use an EXPLICIT typed view; rtvFormatForSwapchain maps an // sRGB swapchain to its UNORM sibling so a typeless wrapped - // resource resolves to a non-re-encoding view — mirrors D3D11. + // resource resolves to a non-re-encoding view — mirrors D3D11, + // including the typed-sRGB fallback in createOverlayImageRtv. const DXGI_FORMAT rtvFormat = rtvFormatForSwapchain(format); + const DXGI_FORMAT rtvFallback = static_cast(format); m_imageRtvs.resize(imgCount); for (uint32_t i = 0; i < imgCount; ++i) { ComPtr tex2d; @@ -3635,13 +3686,9 @@ namespace openxr_api_layer::detail { "image " + std::to_string(i) + "\n"); return false; } - D3D11_RENDER_TARGET_VIEW_DESC rtvDesc{}; - rtvDesc.Format = rtvFormat; - rtvDesc.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2D; - rtvDesc.Texture2D.MipSlice = 0; - if (FAILED(m_d3d11Device->CreateRenderTargetView( - tex2d.Get(), &rtvDesc, - m_imageRtvs[i].GetAddressOf()))) { + if (!createOverlayImageRtv(m_d3d11Device.Get(), tex2d.Get(), + rtvFormat, rtvFallback, + m_imageRtvs[i].GetAddressOf())) { Log("xr_telemetry: overlay disabled — D3D12 path " "CreateRenderTargetView (wrapped image " + std::to_string(i) + ") failed\n"); @@ -3666,8 +3713,11 @@ namespace openxr_api_layer::detail { Log("xr_telemetry: overlay D3D12 renderer ready (" + std::to_string(imgCount) + - " swapchain images, D3D11On12 bridge, direct-to-image RT " - "format=" + std::to_string(static_cast(rtvFormat)) + ")\n"); + " swapchain images, D3D11On12 bridge, swapchain format=" + + std::to_string(format) + + (isSrgbSwapchainFormat(format) ? " (sRGB)" : "") + + ", direct-to-image RT format=" + + std::to_string(static_cast(rtvFormat)) + ")\n"); return true; } From a4d2aea464269f978bfb6c11286ea1fa939092d6 Mon Sep 17 00:00:00 2001 From: mledour Date: Tue, 30 Jun 2026 18:12:20 +0200 Subject: [PATCH 4/4] fix(overlay): make text coverage-gamma direction follow the composite space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The glyph PS pre-corrects DirectWrite coverage with pow(c, 1/TEXT_GAMMA), tuned for runtimes that composite the quad in linear space (UNORM swapchain). Now that the overlay also runs on sRGB-decoding runtimes (SteamVR), that direction is wrong there — the shader's own DIRECTION CAVEAT says to invert it — so HUD text renders at the wrong stem weight. Thread an `srgbComposite` flag from the picked swapchain format through initOverlayRenderers -> glyph Renderer::init -> the TextConstants cbuffer (reusing a pad slot, layout unchanged, static_assert intact). The PS picks pow(c, 1/TEXT_GAMMA) for linear composites and pow(c, TEXT_GAMMA) for sRGB. Gated on supersample > 1, so the snapshot/golden (1x) path is byte-identical; the flag defaults to false so every other init() caller keeps the linear direction. Direction + weight are still worth an on-headset A/B (SteamVR vs Pimax); tunable via TEXT_GAMMA. Co-Authored-By: Claude Opus 4.8 --- openxr-api-layer/shaders/overlay_text.hlsli | 9 ++++++-- openxr-api-layer/shaders/overlay_text_ps.hlsl | 23 ++++++++++++------- .../utils/glyph_atlas_renderer.cpp | 7 ++++-- openxr-api-layer/utils/glyph_atlas_renderer.h | 20 +++++++++++++--- openxr-api-layer/utils/overlay_renderer.cpp | 16 +++++++++---- 5 files changed, 55 insertions(+), 20 deletions(-) diff --git a/openxr-api-layer/shaders/overlay_text.hlsli b/openxr-api-layer/shaders/overlay_text.hlsli index 79f266a..bfd2cfa 100644 --- a/openxr-api-layer/shaders/overlay_text.hlsli +++ b/openxr-api-layer/shaders/overlay_text.hlsli @@ -49,8 +49,13 @@ cbuffer TextConstants : register(b0) // Overlay supersample factor (= renderer m_ss). The VS ignores it; the PS // gates its edge-contrast + gamma corrections on supersample > 1 so the // 1x (snapshot/golden) path stays byte-identical to the legacy shader. - float supersample; // reg1.x - float3 _pad; // reg1.yzw + float supersample; // reg1.x + // 1.0 when the runtime sRGB-decodes the overlay quad at composite (sRGB + // swapchain, e.g. SteamVR); 0.0 when it composites linearly (UNORM + // swapchain). Flips the PS coverage-gamma direction — see the DIRECTION + // CAVEAT in overlay_text_ps.hlsl. + float srgbComposite; // reg1.y + float2 _pad; // reg1.zw }; Texture2D atlasTexture : register(t0); // R8_UNORM glyph atlas diff --git a/openxr-api-layer/shaders/overlay_text_ps.hlsl b/openxr-api-layer/shaders/overlay_text_ps.hlsl index 45be8bf..83cc53d 100644 --- a/openxr-api-layer/shaders/overlay_text_ps.hlsl +++ b/openxr-api-layer/shaders/overlay_text_ps.hlsl @@ -49,11 +49,13 @@ // was tuned for; it is the single tuning knob: // * lower toward 1.0 → lighter stems (1.0 == the old no-correction path) // * raise → heavier stems -// DIRECTION CAVEAT: this assumes the runtime composites the quad in linear -// space (true for the _UNORM swapchain on the tested runtimes). If a runtime -// instead treats it as sRGB, the correction inverts — use pow(c, TEXT_GAMMA). -// Worth one on-headset A/B pass to confirm weight + direction; it is purely -// cosmetic and trivially reverted via this constant. +// DIRECTION CAVEAT: the correction direction depends on whether the runtime +// composites the quad in linear space (UNORM swapchain — Pimax/WMR/Oculus) or +// sRGB-decodes it (sRGB swapchain — SteamVR). The renderer passes which case +// applies via the `srgbComposite` cbuffer flag: linear → pow(c, 1/TEXT_GAMMA) +// (lifts stems); sRGB → pow(c, TEXT_GAMMA) (the inverse). Both are still worth +// one on-headset A/B pass to confirm weight + direction; purely cosmetic and +// trivially tuned via TEXT_GAMMA (or by forcing the branch). // ============================================================================= #include "overlay_text.hlsli" @@ -98,11 +100,16 @@ float4 PSMain(TextVSOutput i) : SV_TARGET // Edge contrast first: steepen the coverage ramp about 0.5 so glyph // edges survive the compositor's bilinear resample crisper. coverage = saturate((coverage - 0.5f) * EDGE_SHARPEN + 0.5f); - // Then gamma-correct the coverage for the linear-space alpha-over - // blend. Guard pow(0): pow(0, x) is spec-undefined in SM4.0 (a driver + // Then gamma-correct the coverage. The exponent direction depends on + // how the runtime composites the quad (see DIRECTION CAVEAT): linear + // (UNORM swapchain) lifts with 1/TEXT_GAMMA; sRGB (sRGB swapchain, the + // runtime sRGB-decodes the quad) needs the inverse, TEXT_GAMMA. + // Guard pow(0): pow(0, x) is spec-undefined in SM4.0 (a driver // returning NaN would poison the blend) and 0 coverage must stay fully // transparent, so branch it rather than lifting it with an epsilon. - coverage = (coverage > 0.0f) ? pow(coverage, 1.0f / TEXT_GAMMA) + float gammaExp = (srgbComposite > 0.5f) ? TEXT_GAMMA + : (1.0f / TEXT_GAMMA); + coverage = (coverage > 0.0f) ? pow(coverage, gammaExp) : 0.0f; } diff --git a/openxr-api-layer/utils/glyph_atlas_renderer.cpp b/openxr-api-layer/utils/glyph_atlas_renderer.cpp index 21d0de6..388fdae 100644 --- a/openxr-api-layer/utils/glyph_atlas_renderer.cpp +++ b/openxr-api-layer/utils/glyph_atlas_renderer.cpp @@ -52,7 +52,8 @@ namespace openxr_api_layer::utils::glyph_atlas { UINT dstHeight, const BuildResult& atlas, UINT renderWidth, - UINT renderHeight) { + UINT renderHeight, + bool srgbComposite) { if (!device || !ctx || dstWidth == 0 || dstHeight == 0) return false; if (atlas.atlasWidth == 0 || atlas.atlasHeight == 0) return false; if (atlas.bitmap.empty()) return false; @@ -67,6 +68,7 @@ namespace openxr_api_layer::utils::glyph_atlas { m_renderW = renderWidth ? renderWidth : dstWidth; m_renderH = renderHeight ? renderHeight : dstHeight; m_ss = static_cast(m_renderW) / static_cast(m_dstW); + m_srgbComposite = srgbComposite; // Snapshot the atlas dimensions before createBuffers — it bakes // them (with texSize) into an IMMUTABLE constant buffer, so they // must be set first. @@ -234,7 +236,8 @@ namespace openxr_api_layer::utils::glyph_atlas { { static_cast(m_dstW), static_cast(m_dstH) }, { static_cast(m_atlasW), static_cast(m_atlasH) }, m_ss, - { 0.0f, 0.0f, 0.0f } + m_srgbComposite ? 1.0f : 0.0f, + { 0.0f, 0.0f } }; D3D11_BUFFER_DESC bd{}; bd.ByteWidth = sizeof(TextConstants); diff --git a/openxr-api-layer/utils/glyph_atlas_renderer.h b/openxr-api-layer/utils/glyph_atlas_renderer.h index 41f3a22..c8b771b 100644 --- a/openxr-api-layer/utils/glyph_atlas_renderer.h +++ b/openxr-api-layer/utils/glyph_atlas_renderer.h @@ -113,13 +113,20 @@ namespace openxr_api_layer::utils::glyph_atlas { // // Returns false on any pipeline-creation failure. Caller logs + // degrades to bypass — never crashes the host. + // `srgbComposite` tells the PS which way to apply its coverage gamma + // correction: false (default) = the runtime composites the quad in + // linear space (UNORM swapchain — Pimax/WMR/Oculus); true = the runtime + // sRGB-decodes the quad (sRGB swapchain — SteamVR), which inverts the + // correction. See overlay_text_ps.hlsl's DIRECTION CAVEAT. Defaulting + // to false keeps the snapshot/golden (supersample==1) path byte-stable. bool init(Microsoft::WRL::ComPtr device, Microsoft::WRL::ComPtr ctx, UINT dstWidth, UINT dstHeight, const BuildResult& atlas, UINT renderWidth = 0, - UINT renderHeight = 0); + UINT renderHeight = 0, + bool srgbComposite = false); bool isReady() const noexcept { return m_ready; } @@ -197,12 +204,15 @@ namespace openxr_api_layer::utils::glyph_atlas { }; // Cbuffer mirror — two 16-byte registers. reg0 = texSize + atlasSize - // (2× float2); reg1 = supersample + pad. Must match overlay_text.hlsli. + // (2× float2); reg1 = supersample + srgbComposite + pad. Must match + // overlay_text.hlsli. struct TextConstants { float texSize[2]; // dest tex (kTexW, kTexH) float atlasSize[2]; // atlas (atlasWidth, atlasHeight) float supersample; // = m_ss; PS gates its corrections on > 1 - float pad[3]; + float srgbComposite; // 1.0 = runtime sRGB-decodes the quad; flips + // the PS coverage-gamma direction + float pad[2]; }; static_assert(sizeof(TextConstants) == 32, "TextConstants must mirror overlay_text.hlsli's cbuffer " @@ -240,6 +250,10 @@ namespace openxr_api_layer::utils::glyph_atlas { UINT m_renderW = 0; UINT m_renderH = 0; float m_ss = 1.0f; + // True when the runtime sRGB-decodes the overlay quad at composite + // (sRGB swapchain); baked into the cbuffer to flip the PS coverage + // gamma. Default false = linear composite (the golden path). + bool m_srgbComposite = false; Microsoft::WRL::ComPtr m_vs; Microsoft::WRL::ComPtr m_ps; diff --git a/openxr-api-layer/utils/overlay_renderer.cpp b/openxr-api-layer/utils/overlay_renderer.cpp index 602eff2..7ac62a7 100644 --- a/openxr-api-layer/utils/overlay_renderer.cpp +++ b/openxr-api-layer/utils/overlay_renderer.cpp @@ -2877,7 +2877,8 @@ namespace openxr_api_layer::detail { CoreRenderer& core, HistogramBarRenderer& bars, glyph_atlas::Renderer& gpuText, - chrome_shapes::Renderer& gpuShapes) { + chrome_shapes::Renderer& gpuShapes, + bool srgbComposite) { if (!bars.init(dev, ctx, kEffectiveSupersample)) { Log("xr_telemetry: overlay disabled — bars init failed\n"); return false; @@ -2889,11 +2890,14 @@ namespace openxr_api_layer::detail { } // dst = LOGICAL design space (cbuffer texSize); render = PHYSICAL // swapchain-image extent (viewport). They differ only when - // supersampled — the renderer maps between them. + // supersampled — the renderer maps between them. srgbComposite + // flips the text coverage-gamma direction for sRGB-decoding + // runtimes (SteamVR); only the glyph PS consumes it. if (!gpuText.init(dev, ctx, static_cast(kTexW), static_cast(kTexH), core.atlas(), static_cast(kTexWPhys), - static_cast(kTexHPhys))) { + static_cast(kTexHPhys), + srgbComposite)) { Log("xr_telemetry: overlay disabled — glyph renderer init " "failed\n"); return false; @@ -3260,7 +3264,8 @@ namespace openxr_api_layer::detail { // with the D3D12 backend so the two bring-ups can't drift. if (!initOverlayRenderers(m_device.Get(), m_context.Get(), m_core, m_bars, m_glyphRenderer, - m_chromeShapeRenderer)) + m_chromeShapeRenderer, + isSrgbSwapchainFormat(format))) return false; // Fill the immutable XrCompositionLayerQuad fields @@ -3702,7 +3707,8 @@ namespace openxr_api_layer::detail { if (!initOverlayRenderers(m_d3d11Device.Get(), m_d3d11Context.Get(), m_core, m_bars, m_glyphRenderer, - m_chromeShapeRenderer)) + m_chromeShapeRenderer, + isSrgbSwapchainFormat(format))) return false; // One-time fill of the immutable quad-layer fields.