Skip to content

feat(color): window-space swapchains default to _SRGB (ADR-044 §7) - #72

Merged
dfattal merged 1 commit into
mainfrom
feat/window-space-srgb-default
Oct 2, 2026
Merged

dfattal merged 1 commit into
mainfrom
feat/window-space-srgb-default

Conversation

@dfattal

@dfattal dfattal commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

What

Window-space (HUD / toast / button / Local2D) swapchains now default to the R8G8B8A8 _SRGB sibling: DXGI 29, VK 43, GL_SRGB8_ALPHA8, or Metal 71. Before this they used R8G8B8A8 UNORM. This closes the open item named in runtime ADR-044 §7:

"The displayxr-common Windows window-space HUD swapchain stays R8G8B8A8_UNORM with display-referred bytes. On any format-honest backend it is encoded twice; its move to _SRGB is pending (displayxr-common)."

The rule is INV-4.6. An _SRGB swapchain holds encoded colour. A UNORM swapchain holds linear values, and the runtime sRGB-encodes them on D3D11 ≥ v2.21.0, D3D12 ≥ v2.21.1, GL ≥ v2.21.2 and vk_native ≥ v2.21.7. The HUD bytes are display-referred: HudRenderer draws them with D2D/DirectWrite into a private UNORM texture, and HudRendererMacOS draws them into a kCGColorSpaceSRGB bitmap. So the honest declaration for them is _SRGB.

It mirrors #49, which did the same for projection swapchains: same ColorFormatChoice, same DXR_SWAPCHAIN_ENCODING semantics, so one switch now A/Bs both kinds of swapchain.

Format-selection change

dxr::ChooseWindowSpaceSwapchainFormat(formats, pref) is new in color_policy.h. It is header-inline, so displayxr::rules consumers (the Android legs) can choose through the same rule. It is used by:

  • CreateWindowSpaceSwapchain / CreateHudSwapchain(XrSessionManager&) in xr_session_common.cpp;
  • CreateHudSwapchain(XrSession, …) in xr_window_space_hud.cpp, the overload the macOS GL/Metal/VK test apps use. That covers Metal 70 → 71.
DXR_SWAPCHAIN_ENCODING Chosen
unset (default) / srgb The first R8G8B8A8 _SRGB code advertised together with its UNORM sibling: 29/28, 43/37, 0x8C43/0x8058 or 71/70. If there is none, the R8G8B8A8 UNORM code, with one WARN. If that is missing too, formats[0].
unorm The R8G8B8A8 UNORM code. This is exactly the old choice.
  • The family stays R8G8B8A8. The chooser does not follow formats[0]'s channel order, because vk_native lists BGRA first. The copy into the image is bound to the format family, and 28 → 29 stays inside the same R8G8B8A8_TYPELESS family. I confirmed that the runtime allocates TYPELESS resources for both D3D11 and D3D12: d3d_dxgi_format_to_typeless_dxgi maps R8G8B8A8_UNORM_SRGB and R8G8B8A8_UNORM to R8G8B8A8_TYPELESS, in d3d_d3d11_allocator.cpp:88 and d3d_d3d12_allocator.cpp:95. So CopyTextureRegion / CopyResource / UpdateSubresource stay legal and byte-exact.
  • Both codes of a pair must be advertised. That stops a code from another API being taken by mistake. For example, 29 is also VK_FORMAT_R8G8B8_SRGB.
  • The chooser is pure. It never calls NoteColorSwapchainFormat(), so RenderSceneLinear() is still decided by the projection swapchain alone.
  • The misleading comments are rewritten. Both "deliberately NOT migrated" comments are gone, along with the README "Not migrated" paragraph.

Consumer audit

Inside displayxr-common: every path that touches a window-space image

Path Method Verdict on _SRGB Change
HudRenderer RenderHudAndMap / RenderButtonStandalone / RenderToastStandalone (hud_renderer.cpp) ClearRenderTargetView + D2D draw into a private R8G8B8A8_UNORM texture → CopyResource → staging → Map (CPU bytes) ✅ It never touches the swapchain image. The clears are (0,0,0,0) or (0,0,0,0.5) on a UNORM target, where 0 is 0 in any space. Doc note in hud_renderer.h (raw copy only)
text_overlay.cpp RenderText / RenderButton / RenderToast / RenderFilledRect D2D CreateDxgiSurfaceRenderTarget on the texture it is given ✅ The only in-library caller passes HudRenderer's private UNORM texture. D2D cannot bind an _SRGB surface, so an app must not point it at the swapchain image. Covered by the README table and the hud_renderer.h note
HudRendererMacOS (hud_renderer_macos.mm) CG bitmap in kCGColorSpaceSRGB (CPU) ✅ CPU bytes only; the caller uploads them Doc note (raw replaceRegion: / glTexSubImage2D / VK staging copy)
dxr::CachedLayerUploader::upload (vk_overlay_kit.h) vkCmdCopyBufferToImage into the swapchain image ✅ A raw copy, so byte-exact Doc note: never swap it for vkCmdBlitImage
dxr::PipelinedReadback (vk_overlay_kit.h) vkCmdCopyImageToBuffer (read) ✅ Read-only and raw None
toast.cpp (ToastState, ComputeToastLayerRect) State and geometry only ✅ No pixels None
xr_window_space_hud.cpp SubmitWindowSpace*Frame, EndFrameWithWindowSpaceLayers Layer submit only ✅ No pixels None

No library code renders, blits or clears into a window-space swapchain image. All the writes happen in apps.

The coordinator's constraints, one by one

  1. D2D can't bind an _SRGB DXGI surface. Satisfied. HudRenderer draws D2D into a private R8G8B8A8_UNORM texture and reads it back. No D2D draw reaches the swapchain image, in the library or in any audited app. The runtime's D3D11/D3D12 swapchain textures are R8G8B8A8_TYPELESS (see above), so an app that does want to draw could still create a UNORM (28) RTV over them. The README table says so.
  2. Vulkan. Every audited Vulkan upload into a window-space image is vkCmdCopyBufferToImage or vkCmdCopyImage: CachedLayerUploader, earthview's inline copies, avatar, mediaplayer's staging→vkCmdCopyImage, and the runtime test apps. None of them uses vkCmdBlitImage.
  3. GL. GL_FRAMEBUFFER_SRGB is never enabled in any audited repo. The only mention is a TODO comment in runtime cube_handle_gl_win/gl_renderer.cpp:388. Every GL HUD write is glTexSubImage2D, an upload that the flag does not affect. The README tells drawing apps to turn the flag off for that pass and restore it afterwards.
  4. Clears. Library clears are transparent or black. The only display-referred clear of a window-space-class image is in runtime cube_zones_vk_{linux,macos} (Local2D strips). It already goes through dxr::VkDisplayReferredClearColor, and those strips use the projection format, not this chooser.
  5. Metal. Moving to RGBA8Unorm_sRGB (71) is correct but unverified. Metal is not format-honest yet and passes bytes through, so this should be a no-op there. CI only compiles the macOS leg, so a macOS atlas check is still needed before relying on the Metal row.

Downstream: what each consumer does, so the follow-up demo audit has a list

Consumer Pin (origin/main) Window-space swapchain Write into the image Effect of bumping to this tag
displayxr-demo-modelviewer desktop v2.25.0; Android displayxr_common_rules v2.15.0 Common (Windows): HUD, button bar, toast. Android hud_bar.cpp:209 is its own swapchain, uses the projection format, and is a debug path CachedLayerUploader (raw); Android vkCmdCopyBufferToImage (raw) 37 → 43; HW: window-space pixels unchanged on vk_native v2.22.2 (see below)
displayxr-demo-gaussiansplat desktop v2.25.0; Android rules v2.16.0 Common (Windows): HUD and toast CachedLayerUploader (raw) 37 → 43
displayxr-demo-earthview desktop v2.25.0; Android displayxr_common_dxr v2.0.0 Common (Windows): HUD and bar inline vkCmdCopyBufferToImage (raw) 37 → 43
displayxr-demo-avatar desktop v2.25.0; Android rules v2.15.0 Common (Windows): bubble and toast. Own bubble on Linux (linux/main.cpp:2091), macOS (macos/main.mm:2526) and Android (hud_bar.cpp:223), already _SRGB-first vkCmdCopyBufferToImage (raw) everywhere Windows 37 → 43
displayxr-demo-mediaplayer Linux v2.26.0; Windows/macOS v2.15.0 headers only; Android v2.15.0 headers only Own on every leg (src/xr/XrSession.cpp:817, Android main.cpp:1556), already 43 → 37 → formats[0] ImGui renders into a UNORM staging image, then raw vkCmdCopyImage (src/ui/ImGuiLayer.cpp:661). The CPU HUD uses vkCmdCopyBufferToImage. UNORM-fallback path: the render pass draws directly on the swapchain image (:202) None (it bypasses common)
displayxr-runtime test_apps Windows/Linux/macOS VK v2.24.0; macOS GL/Metal v2.0.0 Common HUD in the d3d11/d3d12/gl/vk Windows cubes, the macOS gl/metal/vk cubes and the probes/windowspace_handle_*. Own Local2D panels and strips (87 / 44 / 0x8058 / Metal 80 / PickStripFormat 91→87 / GL 0x8C43→0x8058) Every write is raw (UpdateSubresource, CopyTextureRegion with footprint = HUD format, glTexSubImage2D, replaceRegion, vkCmdCopyBufferToImage), except cube_zones_vk_{linux,macos} strips, which use vkCmdClearColorImage via VkDisplayReferredClearColor The common HUD moves to _SRGB once re-pinned. The app-owned UNORM Local2D panels (87 / 44 / 0x8058 / 80) are still display-referred in UNORM and need their own fix
displayxr-shell-pvt no FetchContent Own Windows chrome (29, RTV with format 29 plus a shader draw; this is a render, not a copy), overlay and cursor (29 if the runtime is ≥ 2.21.13, else 28), with a raw CopyResource / UpdateSubresource. macOS uses hard-coded VK 37 everywhere (raw vkCmdCopyBufferToImage) n/a
displayxr-cef-host v2.7.0 None (it uses xrWeaveSubmitDXR plus a DXGI HWND swapchain) n/a n/a
displayxr-browser-pvt no FetchContent (vendors one header) None (weave-submit only) n/a n/a

Hardware check (Leia panel, runtime v2.22.2, vk_native)

displayxr-demo-modelviewer at origin/main was built twice from an out-of-tree git archive, so nothing in the model-viewer repo was modified:

  • before: its own pin, v2.25.0;
  • after: FETCHCONTENT_SOURCE_DIR_DISPLAYXR_COMMON pointing at this branch.

Both builds are otherwise identical. For each one I opened the HUD with Shift+Tab, raised the "View reset" toast with Space, and took a runtime atlas capture.

Format really changed. The app log reads Selected window-space swapchain format: 37 (0x25) before (×3: HUD, button bar, toast) and honest _SRGB 43 (0x2B) (default) after (×3).

Pixels (atlas 1280×360, left view; the right view is identical). The visible window-space layer is the common button bar ("Open…", "Mode: LeiaSR"): RenderHudAndMap buttons, D2D fill black at α 0.55, white text, uploaded by CachedLayerUploader. The scene background behind it is the same (155,169,196) in both runs.

Sample before (37, UNORM) after (43, _SRGB)
button fill (389 of 504 px in the box) (70,76,88) (70,76,88)
glyph core (white text) (172,174,178) / (253,253,253) identical
glyph AA edge (95,100,109), (101,108,122) identical
scene bg next to the bar (155,169,196) (155,169,196)
max abs diff, button-bar band, both views 1 (bg LSB noise)

So the result on this box is "no change", not "lifted → fixed". The authored button fill (black at α 0.55 over (155,169,196)) composites to exactly 0.45 × bg in encoded bytes ((70,76,88)) with either format. A linear-light blend would give about (107,117,137). On runtime v2.22.2 vk_native, then, the window-space layer is not read as linear when it is UNORM and was not double-encoded before this change. That doesn't match ADR-044 §1's vk_native row (a UNORM swapchain is linear, layers blend in linear), so it is worth a runtime-side look (cts session). This PR is still the contract-correct side (INV-4.6: _SRGB for encoded bytes) and is byte-identical here. The washed-out "before" the ADR describes should show on the backends that do read UNORM window-space layers as linear. The HUD body and toast could not be raised by synthetic key input in this run (no key events reached the app log), so the button bar is the only measured window-space layer. Also measured: the D3D11/D3D12/GL paths were not exercised on hardware, and Metal was not run at all (see constraint 5).

Build / test

  • Windows MSVC (VS 2022, the CI recipe cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --config Release && ctest -C Release): all targets build and 5/5 tests pass. The new test_window_space_format_policy() passes with and without DXR_SWAPCHAIN_ENCODING=unorm in the environment.
  • A downstream consumer (modelviewer, pinned at v2.25.0) builds unchanged against this branch.
  • macOS is left to CI. The only cross-platform TU touched is xr_window_space_hud.cpp, which now includes the platform-neutral color_policy.h. No #ifdef context was changed.

CMakeLists VERSION → 2.27.0: this is a behaviour change, and the field had drifted to 2.20.0 against the v2.26.0 tag. The repo has no CHANGELOG file; by repo convention the behaviour-change note is in the README colour section. Not tagged.

Refs: runtime ADR-044 §7, INV-4.6, #49 (projection _SRGB default), #51 (TRANSFER_DST, blit pairing), #56 / runtime #1647 (clear helpers).

🤖 Generated with Claude Code

Window-space (HUD / toast / button / Local2D) layers carry DISPLAY-REFERRED
R8G8B8A8 bytes: HudRenderer rasterizes with D2D/DirectWrite into a private
R8G8B8A8_UNORM texture, HudRendererMacOS into a kCGColorSpaceSRGB bitmap,
and the app moves them into the swapchain image. CreateWindowSpaceSwapchain
and both CreateHudSwapchain overloads still preferred R8G8B8A8_UNORM, so on
a format-honest runtime (D3D11 >= v2.21.0, D3D12 >= v2.21.1, GL >= v2.21.2,
vk_native >= v2.21.7) those bytes were read as LINEAR and encoded a second
time: the translucent black backdrop lifted to grey and the text washed out.
This is the open item ADR-044 §7 names; INV-4.6 is the rule.

- color_policy.h: dxr::ChooseWindowSpaceSwapchainFormat(), header-inline so
  displayxr::rules consumers (the Android legs) can choose through the same
  rule. Default / =srgb: the first R8G8B8A8 _SRGB code advertised together
  with its UNORM sibling (DXGI 29/28, VK 43/37, GL_SRGB8_ALPHA8/GL_RGBA8,
  Metal 71/70); else the R8G8B8A8 UNORM code (fellBack, warned); else
  formats[0]. =unorm: the R8G8B8A8 UNORM code, exactly the old choice, so
  DXR_SWAPCHAIN_ENCODING (#49) now A/Bs projection and window-space together.
  The family never follows formats[0]'s channel order: the copy is
  family-bound, and 28 -> 29 is the same R8G8B8A8_TYPELESS family the runtime
  allocates, so CopyTextureRegion / CopyResource / UpdateSubresource /
  vkCmdCopyBufferToImage / glTexSubImage2D / replaceRegion: stay legal and
  byte-exact. The pair requirement keeps a colliding code from another API
  (29 is also VK_FORMAT_R8G8B8_SRGB) from being taken. Pure; never notes the
  projection format, so RenderSceneLinear() is unaffected.
- xr_session_common.cpp CreateWindowSpaceSwapchain and xr_window_space_hud.cpp
  CreateHudSwapchain use it; the misleading "deliberately NOT migrated"
  comments are rewritten.
- Consumer audit (in-library): HudRenderer clears/draws only its private
  UNORM texture (transparent clears, D2D never binds the swapchain image);
  CachedLayerUploader is a raw vkCmdCopyBufferToImage. No library code
  renders, blits or clears into a window-space image. Header notes on
  hud_renderer.h, hud_renderer_macos.h, vk_overlay_kit.h,
  xr_session_common.h and xr_window_space_hud.h state the raw-copy rule.
- README: "Window-space swapchains: _SRGB by default too" with the selection
  table and the per-method verdict table (raw copy OK; _SRGB RTV / D2D on the
  image / vkCmdBlitImage / GL draw with GL_FRAMEBUFFER_SRGB / non-zero clears
  are not, with the fix for each).
- tests/common_smoke.cpp: test_window_space_format_policy() pins the choice
  on the four lists the runtime advertises, =unorm/=srgb, the fallbacks, the
  collision guard, and that the projection note is untouched.

CMakeLists VERSION -> 2.27.0 (behaviour change; it had drifted to 2.20.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dfattal

dfattal commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Addendum to the hardware section, so the verification claims are complete:

  • Why vk_native shows no change: the runtime's vk_hud_blend.c:366 builds every window-space source view as hard-coded R8G8B8A8_UNORM and blends into the atlas format. So vk_native window-space layers are an encoded-space passthrough today, whatever format the swapchain declares (found by the cts session). Side bug there: a B8G8R8A8 window-space swapchain would come out with R and B swapped. This is being added to ADR-044 §7 next to the Local2D passthrough row (Local2D is a passthrough on all four in-process backends).
  • Where this PR is visible: D3D11 / D3D12 / GL. Evidence on D3D12 from the same runtime family: Unity HDRP sample atlas captures before and after displayxr-unity#348 (the same UNORM → _SRGB move for its HUD) differ by exactly one sRGB encode (button 69 → 142, 205 → 232; slider 133 → 191, 200 → 229). The direct test for this PR is a D3D11/D3D12 handle app with the common HUD, e.g. runtime cube_handle_d3d11_win once test_apps re-pins to the tag carrying this.
  • On vk_native this PR is byte-identical (measured) and contract-correct for when the runtime makes window-space honest.

@dfattal
dfattal merged commit df2561c into main Oct 2, 2026
6 checks passed
@dfattal
dfattal deleted the feat/window-space-srgb-default branch October 2, 2026 17:15
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