Skip to content

fix(app-rules): INV-4.6 — UNORM swapchains are linear since #1589; lint the _SRGB choice per leg - #1760

Merged
dfattal merged 2 commits into
mainfrom
fix/inv46-unorm-is-linear
Sep 29, 2026
Merged

dfattal merged 2 commits into
mainfrom
fix/inv46-unorm-is-linear

Conversation

@dfattal

@dfattal dfattal commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

Why

The Gaussian-splat demo looks washed out on the Leia Android tablet running runtime v2.21.11 but looks right on macOS.

The runtime is right. The app, and this repo's app guide, were wrong.

The app fix is in the demo repo: DisplayXR/displayxr-demo-gaussiansplat (linked below).

What this changes

  • Guide (INV-4.6):

    • States the current model: _SRGB holds encoded colour; UNORM is linear and gets encoded, so display-referred bytes in UNORM wash out.
    • Lists which release made each backend format-honest: D3D11 v2.21.0, D3D12 v2.21.1, GL v2.21.2, vk_native v2.21.7. Metal still passes UNORM through.
    • Says every leg of a multi-platform app must make the same _SRGB-first choice.
    • Documents the on-device A/B: adb shell setprop debug.xrt.DXR_COLOR_LEGACY_UNORM_ENCODED 1.
    • Updates the checklist line to match.
  • Linter, INV-4.6 is now checked per leg. Shared (non-platform) code counts toward every leg. In any file that calls xrEnumerateSwapchainFormats it WARNs on:

    • a format preference list naming only UNORM formats;
    • a scan loop that breaks on the first UNORM.

    The "no sRGB format anywhere" warning is now issued per leg. .claude/ (agent worktrees) is no longer scanned.

  • Tests: scripts/tests/test_check_displayxr_app_color.py has 8 hermetic cases and is wired into lint.yml. 3 of them fail against the previous linter (mutation check).

Evidence

Setup: macOS M1 Pro, MoltenVK, vk_native, sim_display. Gauss demo using GsAdrenoRenderer, the same renderer the Android leg uses, with the Android leg's UNORM choice forced. Values are atlas-capture pixels:

runtime swapchain pixel A pixel B
v2.21.11 _SRGB (229,182,127) (225,123,128)
v2.21.11 UNORM (243,220,187) (241,185,188)
v2.20.1 UNORM (229,182,127) (225,123,128)
v2.21.11 UNORM + DXR_COLOR_LEGACY_UNORM_ENCODED=1 (229,182,127) (225,123,128)
  • The UNORM row is exactly the sRGB encode of the authored value.
  • The other three atlases are byte-identical over the whole 1280×1440 frame.

Linter results

  • In-tree test_apps: INV-4.6 findings are unchanged, old vs new, on every app.
  • Demo repos: it flags the Android leg of all five demos (gauss, modelviewer, mediaplayer, earthview, avatar). For avatar it also flags the macOS and Linux legs, which break on UNORM first.

Not in this PR: the runtime itself. It stays spec-correct, and CTS depends on it.

🤖 Generated with Claude Code

… and the linter checks it per leg

The INV-4.6 guide text still described the pre-#1589 model: "a linear /
UNORM swapchain is NOT color-managed ... the compositor passes the bytes
straight through". Since the format-honest colour model shipped (D3D11
v2.21.0, D3D12 v2.21.1, GL v2.21.2, vk_native v2.21.7, 748b540) an UNORM
swapchain is read as LINEAR, as OpenXR specifies, and sRGB-encoded on the way
to the panel. An app that stores display-referred bytes in UNORM is encoded
twice and looks washed out. The runtime is right; the guide told app authors
the opposite.

That is what reached the Leia tablet: the Gaussian-splat demo's Android leg
prefers {R8G8B8A8_UNORM, B8G8R8A8_UNORM} (unchanged since the leg was
written) while its macOS/Linux legs had moved to _SRGB, so the demo was
correct on macOS and washed out on Android once the tablet took runtime
v2.21.11. Reproduced on macOS vk_native with the Android leg's choice forced:
authored (229,182,127) reaches the atlas as (243,220,187) — the exact sRGB
encode of the authored value — and v2.20.1 with the same UNORM choice is
byte-identical to v2.21.11 with _SRGB.

The linter did not catch it because INV-4.6 was app-wide: "an sRGB token
appears anywhere" was satisfied by the desktop legs. It is now per leg
(shared code counts toward every leg), and in any file that enumerates
swapchain formats it flags the two UNORM-first shapes seen in the demos: a
preference list naming only UNORM formats, and a scan loop that breaks on the
first UNORM. Across the in-tree test_apps the INV-4.6 findings are unchanged;
across the five demo repos it flags every Android leg plus the avatar desktop
legs. .claude/ (agent worktrees) is now excluded from the scan.

scripts/tests/test_check_displayxr_app_color.py pins it (8 hermetic cases,
wired into lint.yml); 3 of them fail against the previous linter.

The guide also records the on-device A/B for this class:
`adb shell setprop debug.xrt.DXR_COLOR_LEGACY_UNORM_ENCODED 1`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…chooses the format

A leg that creates swapchains in a format negotiated elsewhere (displayxr-
common's session code; avatar's windows zone swapchain reuses the main
format) was newly warned. It falls back to the app-wide test the check
always had; a leg that enumerates formats is still judged on its own.
Linted all five demos: every current main is flagged on the UNORM-first legs,
and all five fix branches (gauss #138, avatar #113, earthview #76,
modelviewer #154, mediaplayer #89) are INV-4.6-clean. test_apps unchanged.

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

dfattal commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator Author

Every demo had the same app-side bug, and each now has its own fix PR:

The linter from this PR flags every current main of those repos on the legs that pick UNORM first, and passes all five fix branches clean on INV-4.6.

The second commit narrows the per-leg "no sRGB" warning to legs that enumerate formats themselves. It was firing on avatar's Windows zone swapchain, which reuses the format displayxr-common already negotiated.

🤖 Generated with Claude Code

@dfattal
dfattal merged commit 0b56e21 into main Sep 29, 2026
34 checks passed
@dfattal
dfattal deleted the fix/inv46-unorm-is-linear branch September 29, 2026 00:00
dfattal added a commit that referenced this pull request Sep 29, 2026
…ormat (#1761)

One table for what #1589/#1610 shipped across D3D11, the D3D11 service,
D3D12, GL, vk_native (Windows / Linux / macOS-MoltenVK / Android) and Metal:

- what each backend enumerates, in order;
- what it does with an _SRGB vs a UNORM/float swapchain;
- where layers blend;
- what the DP receives;
- which CTS test pins each row.

It also records the rows that are NOT CTS-covered (vk_native on macOS and
Android, Metal) and what guards them instead. The app rule fits in one line:
write encoded colour into _SRGB, or linear into UNORM; prefer _SRGB, the same
way on every leg.

Amends ADR-021 (principles unchanged). Its "Model A" now reads as "the atlas
is encoded", not "UNORM passes through". Pointers added from ADR-021, INV-4.6
and compositor-pipeline.md. Open rows are recorded, not fixed:

- Metal is still pre-#1589 passthrough;
- the IPC comp_multi paths are unaudited;
- the displayxr-common Windows HUD swapchain is UNORM with display-referred
  bytes.

Motivated by the 2026-09-28 washed-out demos: every demo's Android leg, plus
avatar's macOS/Linux legs, picked UNORM and wrote display-referred bytes.
Fixed app-side in gauss#138, avatar#113, earthview#76, modelviewer#154 and
mediaplayer#89. The per-leg lint is in #1760.

Co-authored-by: Claude Opus 5.5 <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