Skip to content

Color management: define one systematic encoding-state model across in-process / IPC / workspace compositors #409

@dfattal

Description

@dfattal

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

  1. Format is source-of-truth for a swapchain's encoding state (needs an app-side contract: sRGB ⟺ encoded, UNORM/float ⟺ linear).
  2. Atlas/compose space is linear (UNORM-as-linear, or float16 for HDR/wide-gamut headroom later).
  3. 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

  1. 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?
  2. 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?
  3. Can we get apps to declare colorspace honestly, or do we need a per-app "treat-as-encoded" override for legacy/test apps?
  4. HDR / wide-gamut: if that's on any roadmap, a float16 linear compose space changes the calculus toward Model B now.
  5. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions