Skip to content

fix(compositor-gl): sRGB passthrough for in-process GL swapchains#407

Merged
dfattal merged 1 commit into
mainfrom
fix/inprocess-gl-srgb-gamma
Jun 4, 2026
Merged

fix(compositor-gl): sRGB passthrough for in-process GL swapchains#407
dfattal merged 1 commit into
mainfrom
fix/inprocess-gl-srgb-gamma

Conversation

@dfattal
Copy link
Copy Markdown
Collaborator

@dfattal dfattal commented Jun 4, 2026

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 as GL_SRGB8_ALPHA8 and 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_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 build, other runtimes, and the service path. Correct whether or not the app enables GL_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

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>
@dfattal dfattal merged commit 58d3f7c into main Jun 4, 2026
22 checks passed
@dfattal dfattal deleted the fix/inprocess-gl-srgb-gamma branch June 4, 2026 03:01
@dfattal
Copy link
Copy Markdown
Collaborator Author

dfattal commented Jun 4, 2026

What this change means for apps

Symptom

In-process OpenGL apps (the handle / hosted / texture classes that render directly to the runtime — no IPC/service) came out ~2.2× too dark on the display: crushed shadows and midtones. The same app looked correct on other OpenXR runtimes, on our desktop (non-XR) build, and through our IPC/service path. A GL Gaussian-splat XR viewer was the repro.

Why it was broken

For in-process GL apps DisplayXR uses the native OpenGL compositor (comp_gl_compositor.cpp, no Vulkan/D3D interop). The app requests a GL_SRGB8_ALPHA8 swapchain and writes its final, display-referred pixels into it — typically with GL_FRAMEBUFFER_SRGB off, like most apps and our own desktop build.

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) GL_RGBA8 atlas and never re-encoded. The display processor expects display-referred (sRGB-encoded) bytes, so it got linearized values and presented them ~2.2× too dark.

In one line: an unmatched sRGB decode — a linear = decode(srgb) on sample with no matching srgb = encode(linear) on output.

The fix

When the app's swapchain is sRGB, set GL_TEXTURE_SRGB_DECODE_EXT = GL_SKIP_DECODE_EXT on the swapchain texture (guarded by a runtime GL_EXT_texture_sRGB_decode check). That disables the sample-time decode, so the app's stored bytes pass through the compositor unchanged to the display processor — byte-for-byte identical to our desktop build, the IPC/service path, and other runtimes. It only affects the runtime's sampling; it never touches the app's own rendering.

"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:

  • sRGB swapchain (GL_SRGB8_ALPHA8) — the recommended choice — is correct regardless of how the app renders into it:
    • render linear + GL_FRAMEBUFFER_SRGB on → GPU encodes to sRGB bytes → passthrough → correct.
    • write display-referred bytes directly (GL_FRAMEBUFFER_SRGB off) → already sRGB bytes → passthrough → correct.
  • ⚠️ Linear / UNORM swapchain (GL_RGBA8)not color-managed. The runtime passes the bytes straight through with no linear→sRGB encode:
    • write display-referred bytes → fine.
    • write genuinely-linear values expecting the runtime to encode → too bright.

Guidance for app authors: request an sRGB swapchain (GL_SRGB8_ALPHA8) and you're correct whether you render linear-with-FRAMEBUFFER_SRGB or write final sRGB bytes. True "submit a linear-format swapchain and the runtime color-manages it" would need a follow-up (the compositor would encode linear→sRGB for non-sRGB swapchains based on the swapchain's declared colorspace) — not in this PR.

Scope / follow-up

This 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.

@dfattal
Copy link
Copy Markdown
Collaborator Author

dfattal commented Jun 4, 2026

Follow-up audit: the other in-process native compositors

Per 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.

Compositor Offers sRGB swapchain Auto-decodes on sample Re-encodes before DP Verdict
D3D11 yes (comp_d3d11_compositor.cpp:1933,1935) yes — sRGB-typed SRV (comp_d3d11_swapchain.cpp:411,440), sampled comp_d3d11_renderer.cpp:557,627 no — UNORM atlas (comp_d3d11_renderer.cpp:378) AFFECTED
D3D12 yes (comp_d3d12_compositor.cpp:2232,2234) yes — concrete sRGB resource, no TYPELESS promo (comp_d3d12_swapchain.cpp:249,278), SRV comp_d3d12_renderer.cpp:1019 no — UNORM atlas (comp_d3d12_renderer.cpp:287) AFFECTED
Vulkan yes (comp_vk_native_compositor.c:3026,3028) yes — vkCmdBlitImage reads sRGB src → UNORM atlas (comp_vk_native_renderer.c:435; img fmt comp_vk_native_swapchain.c:264) no — UNORM atlas (comp_vk_native_renderer.c:213) AFFECTED
Metal yes (comp_metal_compositor.m:2196,2461) yes — *_sRGB texture sampled (comp_metal_compositor.m:890,1693; shader :316) no — BGRA8Unorm atlas (comp_metal_compositor.m:545) AFFECTED

Two things that matter for the fixes

1. The single-layer zero-copy path is already exempt — D3D11 (comp_d3d11_compositor.cpp:1190), Metal (comp_metal_compositor.m:1576), and D3D12 hand the app texture straight to the DP with no sampling, so no decode happens. The bug only bites the composited route (multi-view-into-one-atlas, sub-rects, >1 layer). Consistent with the "passthrough is correct" contract.

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:

  • D3D11: build the layer SRV with the UNORM sibling format instead of the concrete sRGB format (comp_d3d11_swapchain.cpp:440).

  • D3D12: same, at comp_d3d12_renderer.cpp:1019 (note D3D12 does not TYPELESS-promote color resources, so the resource itself is concrete sRGB — comp_d3d12_swapchain.cpp:278).

  • Metal: a *_Unorm MTLTextureView over the sRGB swapchain texture for the compose sample.

    But Vulkan composes via vkCmdBlitImage, which reads the source in its own sRGB format — there's no SRV/sampler to retag. You'd need a UNORM image/view alias of the sRGB source (the swapchain images aren't created VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT today), or a matching-sRGB atlas — a slightly larger change than the other three.

No existing sRGB handling or TODO in any of the four compose paths.

🤖 audit by Claude Code (read-only)

dfattal added a commit that referenced this pull request Jun 4, 2026
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>
dfattal added a commit that referenced this pull request Jun 4, 2026
…#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>
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.

1 participant