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 654ee76..7ac62a7 100644 --- a/openxr-api-layer/utils/overlay_renderer.cpp +++ b/openxr-api-layer/utils/overlay_renderer.cpp @@ -373,15 +373,61 @@ 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. - 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. + // 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); + } + + // 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. int64_t pickSwapchainFormat(OpenXrApi* api, XrSession session) { uint32_t count = 0; if (XR_FAILED(api->xrEnumerateSwapchainFormats(session, 0, &count, nullptr)) || @@ -393,12 +439,62 @@ namespace openxr_api_layer::detail { session, count, &count, formats.data()))) { return 0; } + // 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.swapchain) return want.swapchain; + } + } + // 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", + formats.size(), advertised)); return 0; } + // 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 ---------------------------------------- // // One GpuTextFormat per IDWriteTextFormat the D2D path uses @@ -2781,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; @@ -2793,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; @@ -3006,8 +3106,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; } @@ -3044,10 +3144,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; @@ -3137,17 +3239,18 @@ 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/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; 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 = DXGI_FORMAT_B8G8R8A8_UNORM; - 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[{}]) " @@ -3161,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 @@ -3173,9 +3277,12 @@ namespace openxr_api_layer::detail { Log(fmt::format( "xr_telemetry: overlay D3D11 renderer ready ({} swapchain " - "images, direct-to-swapchain BGRA8 RT, " + "images, swapchain format={}{}, RT format={}, " "feature_level={:#x})\n", - imgCount, static_cast(appLevel))); + imgCount, format, + isSrgbSwapchainFormat(format) ? " (sRGB)" : "", + static_cast(rtvFormat), + static_cast(appLevel))); return true; } @@ -3221,8 +3328,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. @@ -3486,8 +3594,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 +3676,12 @@ 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; rtvFormatForSwapchain maps an + // sRGB swapchain to its UNORM sibling so a typeless wrapped + // 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; @@ -3580,13 +3691,9 @@ namespace openxr_api_layer::detail { "image " + std::to_string(i) + "\n"); return false; } - D3D11_RENDER_TARGET_VIEW_DESC rtvDesc{}; - rtvDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; - 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"); @@ -3600,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. @@ -3611,8 +3719,11 @@ 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, swapchain format=" + + std::to_string(format) + + (isSrgbSwapchainFormat(format) ? " (sRGB)" : "") + + ", direct-to-image RT format=" + + std::to_string(static_cast(rtvFormat)) + ")\n"); return true; }