fix(compositor-gl): sRGB passthrough for in-process GL swapchains#407
Conversation
In-process OpenGL apps rendered ~2.2x too dark versus the IPC/service path. The native GL compositor creates app swapchains as GL_SRGB8_ALPHA8 and samples them during compose; the GPU auto-decodes sRGB->linear on the sample, but compose writes to a non-sRGB atlas with no matching re-encode, so the Leia DP (which expects sRGB-encoded bytes) received linearized, too-dark content. Set GL_TEXTURE_SRGB_DECODE_EXT=GL_SKIP_DECODE_EXT on the swapchain texture (guarded by a runtime GL_EXT_texture_sRGB_decode check) so the compositor's sample passes the stored bytes through unchanged, matching the desktop, other OpenXR runtimes, and the service path. Correct whether or not the app enables GL_FRAMEBUFFER_SRGB, and affects only runtime-side sampling, never the app's own rendering. Verified on-display with a GL Gaussian-splat player (cube/me_zoom sequence): midtones/shadows now match the desktop build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What this change means for appsSymptomIn-process OpenGL apps (the Why it was brokenFor in-process GL apps DisplayXR uses the native OpenGL compositor ( The compositor then samples that swapchain to build the multi-view atlas it hands to the Leia display processor. Because the swapchain texture is sRGB-typed, the GPU auto-applied an sRGB→linear decode on every sample — but the compositor wrote the result into a plain (non-sRGB) In one line: an unmatched sRGB decode — a The fixWhen the app's swapchain is sRGB, set "Can apps now submit either linear or sRGB and the runtime gets it right?"Partly — here's the exact contract after this change. The GL compositor is now a faithful byte passthrough to the (sRGB) panel:
Guidance for app authors: request an sRGB swapchain ( Scope / follow-upThis fixes the OpenGL in-process native compositor only. The D3D11 in-process native compositor has the same structural shape (it samples the client's sRGB swapchain through an sRGB-typed SRV into a UNORM atlas with no re-encode), and D3D12/Vulkan are worth checking too — it would surface for any in-process app that picks an sRGB swapchain. Tracking an audit separately. |
Follow-up audit: the other in-process native compositorsPer the "Scope / follow-up" note, I audited the other in-process native compositors for the same unmatched-sRGB-decode shape this PR fixed in GL (sample the app's sRGB swapchain → GPU auto-decodes sRGB→linear → write into a linear/UNORM atlas with no re-encode → DP gets ~2.2× too-dark content). Read-only audit; no code changed. All four are AFFECTED on their composited path.
Two things that matter for the fixes1. The single-layer zero-copy path is already exempt — D3D11 ( 2. Vulkan is the odd one out. D3D11 / D3D12 / Metal are the same easy shape as GL — sample the app's sRGB swapchain through a linear view of the same bytes:
No existing sRGB handling or TODO in any of the four compose paths. 🤖 audit by Claude Code (read-only) |
Two more agent-tripping topics for the app-authoring guide: - INV-4.6 color space (per PR #407): an in-process native compositor is a byte passthrough to the sRGB panel and does NOT color-manage a linear-format swapchain. Rule: request an sRGB swapchain (_UNORM_SRGB / GL_SRGB8_ALPHA8 / _SRGB / MTLPixelFormat*sRGB) and store a correctly-encoded image (render linear with GPU sRGB-write, or write display-referred bytes — not both). A linear/UNORM swapchain is passthrough only (genuinely-linear values come out too bright). Data textures stay linear. Notes the in-process vs D3D11-service difference and the cross-API rollout (GL done in #407; D3D11/D3D12/VK/Metal in-process being aligned). compositor-pipeline.md: the "DP expects linear" bullet is rescoped to the service path (in-process DP wants display-referred bytes). Checklist updated. - Multi-compositor: the current service/multi-app compositor is the native D3D11 service multi-compositor (compositor/d3d11_service/), Metal on macOS. Removed the stale references that told agents the service/multi-app path uses a Vulkan multi-compositor (deleted outright rather than corrected, so there's nothing left to trip on): * ADR-004 -> Superseded; dropped the Vulkan-multi-comp path description. * ADR-002 -> dropped the Monado VK chain from Context and compositor/multi/ from the preserved list; states multi-app compositing is d3d11_service. * 3d-capture.md frontmatter code-paths multi/ -> d3d11_service/. INV-10.3 states the current model positively (no cross-API intermediary). Authoritative ref: docs/architecture/multi-compositor.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#409 forward-ref - Rollout note: as of #408 all five in-process native compositors (GL #407, D3D11/D3D12/VK/Metal #408) pass sRGB swapchains through correctly — drop the "being aligned / currently too dark" caveat. - Linear/UNORM-swapchain bullet: add a "subject to #409" forward-reference noting this describes current Model-A behavior and would flip if the color model lands on linear-compose (Model B). The request-an-sRGB-swapchain recommendation is correct either way. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Problem
In-process OpenGL apps rendered ~2.2× too dark versus the IPC/service path and other OpenXR runtimes. The Leia DP received linearized (too-dark) content.
Root cause
In-process GL apps run on the native
comp_gl_compositor.cpp(bypassing Vulkan). It creates the app swapchain asGL_SRGB8_ALPHA8and samples it during compose. The GPU auto-decodes sRGB→linear on the sample, but compose writes to a non-sRGB atlas with no matching re-encode, so the DP — which expects sRGB-encoded bytes on this path — got ~2.2× too-dark content. The IPC/service path was unaffected because it passes the app's sRGB bytes straight through.Fix
Set
GL_TEXTURE_SRGB_DECODE_EXT = GL_SKIP_DECODE_EXTon the swapchain texture (guarded by a runtimeGL_EXT_texture_sRGB_decodecheck) so the compositor's sample passes the stored bytes through unchanged — matching the desktop build, other runtimes, and the service path. Correct whether or not the app enablesGL_FRAMEBUFFER_SRGB; affects only runtime-side sampling, never the app's own rendering.Verification
Verified on-display with a GL Gaussian-splat player (me_zoom sequence): midtones/shadows now match the desktop build. Side-by-side before/after confirmed by eye on the Leia panel.
Scope / follow-up
Covers the GL native compositor only. The same sample-time-decode shape could affect other in-process native compositors (D3D11/D3D12/Metal/VK) if an app picks an sRGB swapchain — worth a follow-up audit.
🤖 Generated with Claude Code