Why this issue
The recent sRGB/gamma fixes (#407 GL, #408 D3D11/D3D12/VK/Metal) corrected a real bug, but they also surfaced that runtime color handling has grown ad-hoc — each of the three compositor paths uses a different colorspace strategy, tuned reactively to whatever apps it happened to meet. The in-process path simply hadn't met an sRGB-swapchain app until the GL viewer, which is why the gamma bug sat undetected.
This issue proposes a common mental model and invites input before we commit to a direction. No code decision is being made here yet — looking for opinions from folks who've touched the service/workspace paths and the Leia weaver.
The one thing to track: encoding state
Every texture holds pixels in one of two states:
- Linear (scene-referred / radiance) — proportional to light. Blending, filtering, MSAA resolve, mipmap gen are only correct here.
- Encoded (display-referred, sRGB/gamma) — in the panel's transfer function; what the display physically wants.
A texture's format declares which (*_SRGB ⟹ encoded, GPU auto-decodes on sample / auto-encodes on RT write; UNORM/float ⟹ linear). The whole problem reduces to one invariant:
Colorspace conversions must come in matched pairs (decode … encode) or not at all (passthrough) — never half.
The bug we chased across five compositors was always the same half-conversion: decode-on-sample (format said sRGB) with no encode-on-write (atlas was UNORM) → ~2.2× too dark.
Two canonical spaces define the whole pipeline
app render → [swapchain] → sample → [compose / atlas] → DP handoff → panel
^^^^^^ ^^^^^^^^^^^^
COMPOSE space DP-HANDOFF space
- Compose space — linear (correct blend math) vs encoded (simpler, wrong blending).
- DP-handoff space — what the Leia weaver expects. Empirically: encoded (display-referred) bytes. (The older "DP wants linear" note described a service-path intermediate, not the handoff.)
Pin those two and every compositor's job is just: get bytes from the swapchain's declared space into compose space, then into DP-handoff space, with matched conversions.
Current state — three paths, three strategies (the ad-hoc-ness)
| Path |
Compose strategy |
| In-process (handle/hosted/texture → per-API native comp → DP) |
Passthrough (after #407/#408): no decode, no encode; atlas opaque UNORM. Now consistent across GL/D3D11/D3D12/VK/Metal. Single-layer zero-copy = passthrough by construction. |
| IPC / multi-compositor (service, WebXR bridge) |
Linearize-on-write: sRGB SRV decode into the per-client atlas. |
| Workspace / shell |
Track-and-reinterpret-on-read: raw byte copy + dual UNORM/sRGB SRVs chosen by a per-client atlas_holds_srgb_bytes flag. |
Three philosophies, each grown to fit the apps it met.
Two coherent end-states
Model A — Passthrough (what in-process now is). Treat the swapchain as already display-ready bytes; no conversions; DP gets them verbatim.
- ✅ Simple, consistent, matches how apps here actually behave (they write display-referred bytes into both sRGB and UNORM swapchains).
- ❌ Compose blending in non-linear space → alpha overlays / launcher band / compose-under-bg transparency / MSAA / mips technically wrong (usually tolerable). Can't honor a true-linear app.
Model B — Colorspace-aware (the "proper" model). Decode iff sRGB → compose in linear → encode at the DP boundary (sRGB-typed handoff). DP always gets encoded.
- ✅ Correct linear compositing (real win for the Leia transparency model). Honors both linear and encoded apps per their declared format. One rule for all three paths.
- ❌ Requires apps to declare colorspace honestly — and today they don't: the cube test apps put encoded content in UNORM swapchains, so Model B would over-brighten them. So Model B is an ecosystem change, not just a runtime change.
Proposed systematic design (Model B, if we go there)
One rule for all three paths:
- Format is source-of-truth for a swapchain's encoding state (needs an app-side contract: sRGB ⟺ encoded, UNORM/float ⟺ linear).
- Atlas/compose space is linear (UNORM-as-linear, or float16 for HDR/wide-gamut headroom later).
- DP-handoff is encoded — DP-input texture sRGB-typed (or final write encodes), so the weaver always receives display-referred bytes.
What's missing isn't code — it's a canonical ADR stating the encoding state at every hop + asserting the matched-pair invariant. That's what turns "ad-hoc per app" into "systematic per contract."
Open questions for discussion
- Do we want Model A (passthrough) or Model B (linear compose)? Model B is "more correct" and fixes blending/transparency quality, but requires an app colorspace audit. Is the blending-correctness win worth it for the Leia transparency / compose-under-bg work?
- What does the Leia weaver actually want at handoff — confirm encoded-bytes for all DP backends (D3D11/D3D12/GL/VK/Metal), and does any DP path expect linear?
- Can we get apps to declare colorspace honestly, or do we need a per-app "treat-as-encoded" override for legacy/test apps?
- HDR / wide-gamut: if that's on any roadmap, a float16 linear compose space changes the calculus toward Model B now.
- Verification gap: none of our current test apps render true linear into a UNORM swapchain, so the "linear input" path has never been exercised. Should we add a true-linear test app regardless of the model we pick?
Suggested next step (cheap, model-agnostic)
Write the contract down — an ADR "Color management & the encoding-state invariant" defining compose space, DP-handoff space, and the app format contract. Even staying on Model A, documenting "swapchain holds display-referred bytes; runtime passes through; DP presents" stops the next reactive patch.
cc @dfattal — context from the #407/#408 investigation.
🤖 drafted by Claude Code from the sRGB-fix investigation
Why this issue
The recent sRGB/gamma fixes (#407 GL, #408 D3D11/D3D12/VK/Metal) corrected a real bug, but they also surfaced that runtime color handling has grown ad-hoc — each of the three compositor paths uses a different colorspace strategy, tuned reactively to whatever apps it happened to meet. The in-process path simply hadn't met an sRGB-swapchain app until the GL viewer, which is why the gamma bug sat undetected.
This issue proposes a common mental model and invites input before we commit to a direction. No code decision is being made here yet — looking for opinions from folks who've touched the service/workspace paths and the Leia weaver.
The one thing to track: encoding state
Every texture holds pixels in one of two states:
A texture's format declares which (
*_SRGB⟹ encoded, GPU auto-decodes on sample / auto-encodes on RT write; UNORM/float ⟹ linear). The whole problem reduces to one invariant:The bug we chased across five compositors was always the same half-conversion: decode-on-sample (format said sRGB) with no encode-on-write (atlas was UNORM) → ~2.2× too dark.
Two canonical spaces define the whole pipeline
Pin those two and every compositor's job is just: get bytes from the swapchain's declared space into compose space, then into DP-handoff space, with matched conversions.
Current state — three paths, three strategies (the ad-hoc-ness)
atlas_holds_srgb_bytesflag.Three philosophies, each grown to fit the apps it met.
Two coherent end-states
Model A — Passthrough (what in-process now is). Treat the swapchain as already display-ready bytes; no conversions; DP gets them verbatim.
Model B — Colorspace-aware (the "proper" model). Decode iff sRGB → compose in linear → encode at the DP boundary (sRGB-typed handoff). DP always gets encoded.
Proposed systematic design (Model B, if we go there)
One rule for all three paths:
What's missing isn't code — it's a canonical ADR stating the encoding state at every hop + asserting the matched-pair invariant. That's what turns "ad-hoc per app" into "systematic per contract."
Open questions for discussion
Suggested next step (cheap, model-agnostic)
Write the contract down — an ADR "Color management & the encoding-state invariant" defining compose space, DP-handoff space, and the app format contract. Even staying on Model A, documenting "swapchain holds display-referred bytes; runtime passes through; DP presents" stops the next reactive patch.
cc @dfattal — context from the #407/#408 investigation.
🤖 drafted by Claude Code from the sRGB-fix investigation