Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ cmake_minimum_required(VERSION 3.21)
# VERSION tracks the NEXT git tag. The tags are the version of record (consumers
# pin GIT_TAG, nothing reads this field) — it had drifted to 0.4.0 against v2.13.0.
project(displayxr_common
VERSION 2.20.0
VERSION 2.27.0
DESCRIPTION "Shared off-axis Kooima projection math + C++ app scaffolding for DisplayXR 3D displays"
LANGUAGES C CXX)

Expand Down
59 changes: 53 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,12 +341,59 @@ siblings, then make the storage view in the UNORM sibling and the sampled / atta
the `_SRGB` one — the same recipe the runtime's Vulkan compositor uses for its own swapchain
images. The bytes are unchanged; only the view decides whether a read decodes.

**Not migrated (deliberate):** the window-space HUD swapchain
(`CreateWindowSpaceSwapchain`, `CreateHudSwapchain`) stays `R8G8B8A8_UNORM`. It is a CPU-upload
path — the HUD is rasterized on the CPU into display-referred RGBA8 and copied in, so nothing
in it can encode, and the format also pins the copy family on four graphics APIs
(`CopyTextureRegion` / `vkCmdCopy*`). Declaring those bytes `_SRGB` is the correct end state and
costs no quality, but it needs its own verified change.
#### Window-space swapchains: `_SRGB` by default too (`v2.27.0`, ADR-044 §7)

> **Behaviour change in `v2.27.0`.** `CreateWindowSpaceSwapchain` / `CreateHudSwapchain` (both
> overloads) now ask for the **R8G8B8A8 `_SRGB` sibling** — DXGI `29`, VK `43`,
> `GL_SRGB8_ALPHA8`, Metal `71` — instead of R8G8B8A8 UNORM. **An app that moves the HUD bytes in
> with a raw copy needs no change**, and its HUD/toast stops washing out on a format-honest
> runtime. `DXR_SWAPCHAIN_ENCODING=unorm` restores the old format (same switch as above, so one
> variable A/Bs projection and window-space together).

Window-space (HUD, toast, button, Local2D) layers carry **display-referred** R8G8B8A8 bytes: the
CPU rasterizers (`HudRenderer` = D2D/DirectWrite into a private `R8G8B8A8_UNORM` texture,
`HudRendererMacOS` = a `kCGColorSpaceSRGB` bitmap) write the authored sRGB values verbatim. The
runtime is format-honest on D3D11 ≥ v2.21.0, D3D12 ≥ v2.21.1, GL ≥ v2.21.2 and vk_native ≥
v2.21.7 ([ADR-044](https://github.com/DisplayXR/displayxr-runtime/blob/main/docs/adr/ADR-044-colour-contract-per-backend.md),
[INV-4.6](https://github.com/DisplayXR/displayxr-runtime/blob/main/docs/guides/displayxr-app-rules.md)):
a UNORM swapchain holds **linear** values and is sRGB-encoded on the way to the panel, so those
bytes in the old UNORM swapchain were **encoded twice** — the translucent black backdrop lifted to
grey, the text washed out. Declaring them `_SRGB` is correct on every backend, Metal included
(which is not yet format-honest and passes bytes through either way).

`dxr::ChooseWindowSpaceSwapchainFormat()` (`color_policy.h`, header-inline, so `displayxr::rules`
— i.e. the Android legs — can call it too) is the rule:

| `DXR_SWAPCHAIN_ENCODING` | Chosen format |
|---|---|
| *unset* (**default**) / `srgb` | the first R8G8B8A8 `_SRGB` code advertised **together with its UNORM sibling** (`29`/`28`, `43`/`37`, `0x8C43`/`0x8058`, `71`/`70`); else the R8G8B8A8 UNORM code with one `WARN`; else `formats[0]` |
| `unorm` | the R8G8B8A8 UNORM code — exactly the pre-`v2.27.0` choice |

Unlike the projection rule it **never follows `formats[0]`'s channel order** (a Vulkan runtime
lists BGRA first): the copy is family-bound. `28 → 29` is the same `R8G8B8A8_TYPELESS` family the
runtime allocates, so the copy stays legal and byte-exact. The pair requirement keeps a colliding
code from another API (`29` is also `VK_FORMAT_R8G8B8_SRGB`) from being taken.

**What the change means for the code that fills the image** — the bytes are already encoded, so
they must land **without conversion**:

| Method into the window-space image | On `_SRGB` | |
|---|---|---|
| D3D11 `UpdateSubresource` / `CopyResource` / `CopySubresourceRegion`; D3D12 `CopyTextureRegion` (upload buffer → image, footprint format = the swapchain format or its UNORM sibling) | raw copy | ✅ byte-exact |
| Vulkan `vkCmdCopyBufferToImage` (`dxr::CachedLayerUploader`) / `vkCmdCopyImage` | raw copy | ✅ byte-exact |
| GL `glTexSubImage2D` | raw upload (`GL_FRAMEBUFFER_SRGB` does not apply to uploads) | ✅ byte-exact |
| Metal `replaceRegion:` / blit-encoder `copyFromTexture:` | raw copy | ✅ byte-exact |
| D3D draw through an RTV created with the swapchain format (`29`), or D2D `CreateDxgiSurfaceRenderTarget` on the swapchain texture | encodes on write; **D2D cannot bind an `_SRGB` surface at all** | ❌ — draw into a private UNORM texture and copy, or create the RTV with the **UNORM** sibling (`28`) over the runtime's `R8G8B8A8_TYPELESS` texture |
| `vkCmdBlitImage` from a UNORM source | encodes on write | ❌ — blit into a UNORM-sibling scratch, then `vkCmdCopyImage` |
| GL draw / `glBlitFramebuffer` into the image with `GL_FRAMEBUFFER_SRGB` enabled | encodes on write | ❌ — disable `GL_FRAMEBUFFER_SRGB` for that pass and restore the app's state |
| Clear to transparent `(0,0,0,0)` | 0 encodes to 0 | ✅ |
| Clear to an authored, non-zero colour | the value is linear on an `_SRGB` target | ❌ — `ClearRenderTargetViewDisplayReferred()` / `dxr::VkDisplayReferredClearColor()` / `d3d12_clear.h` / `gl_clear.h` |

Inside this library every write into a window-space image is in the first rows: `HudRenderer`
clears and draws only its **private** UNORM texture (whose bytes are then read back), and the only
library code that touches a swapchain image is `CachedLayerUploader`'s `vkCmdCopyBufferToImage`.
The writes into the swapchain image live in the apps — audit them with the table above. **Metal**
is compile-checked by CI only; a macOS atlas check is owed before relying on the Metal row.

### The undock launch contract (`launch_args.h`, `url_fetch.h`, `view_protocol.h`)

Expand Down
99 changes: 98 additions & 1 deletion common/color_policy.h
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@

namespace dxr {

//! What `DXR_SWAPCHAIN_ENCODING` asked for.
//! What `DXR_SWAPCHAIN_ENCODING` asked for. Governs BOTH the projection/quad
//! swapchain (ChooseColorSwapchainFormat) and the window-space ones
//! (ChooseWindowSpaceSwapchainFormat), so one switch A/Bs the whole app.
enum class ColorEncodingPreference {
HonestSrgb, //!< default (env unset or unrecognized) — prefer an `_SRGB` format
ForceSrgb, //!< `DXR_SWAPCHAIN_ENCODING=srgb`
Expand Down Expand Up @@ -184,4 +186,99 @@ SceneLinearToDisplayReferred(float c)
return 1.055f * std::pow(c, 1.0f / 2.4f) - 0.055f;
}

/*!
* THE selection rule for a **window-space / HUD / toast / Local2D** swapchain —
* the layers whose pixels are rasterized on the CPU (D2D/DirectWrite,
* Core Graphics) or by a private device into **display-referred R8G8B8A8**
* bytes and then *moved* into the swapchain image, never rendered into it.
*
* ADR-044 §7 / INV-4.6. On a format-honest runtime (D3D11 ≥ v2.21.0, D3D12 ≥
* v2.21.1, GL ≥ v2.21.2, vk_native ≥ v2.21.7) a UNORM swapchain holds LINEAR
* values and the runtime sRGB-encodes them, so display-referred bytes in one
* are encoded twice (washed out: lifted backdrop, grey text). The bytes ARE
* encoded, so the honest declaration is the `_SRGB` format — which costs no
* quality and needs no conversion anywhere, provided the bytes land by a RAW
* copy (see README § *Window-space swapchains*).
*
* Unlike ChooseColorSwapchainFormat() this deliberately does NOT follow the
* channel order of `formats[0]`: the CPU rasterizers always emit R8G8B8A8, and
* the copy into the image must stay inside that one family (D3D12
* `CopyTextureRegion` requires it; a buffer→image copy reinterprets the bytes
* in the image's order). R8G8B8A8_UNORM → its `_SRGB` sibling is the same
* typeless family (DXGI 28/29 are both R8G8B8A8_TYPELESS, which is what the
* runtime allocates), so a raw copy stays legal and byte-exact.
*
* - HonestSrgb (default) / ForceSrgb → the first R8G8B8A8 `_SRGB` code
* advertised **together with its UNORM sibling** — DXGI 29, VK 43,
* GL_SRGB8_ALPHA8 0x8C43, Metal 71 (the pair requirement is what keeps a
* colliding code from another API, e.g. VK_FORMAT_R8G8B8_SRGB = 29, from
* being taken). Else the R8G8B8A8 UNORM code (fellBack: the runtime offers
* no sibling, so the bytes stay as before and the caller should say so
* once). Else `formats[0]` (fellBack).
* - ForceUnorm (`DXR_SWAPCHAIN_ENCODING=unorm`) → the R8G8B8A8 UNORM code —
* exactly the pre-v2.27.0 behaviour, so ONE switch A/Bs the projection and
* the window-space swapchains together. Else `formats[0]` (fellBack).
*
* Header-inline so `displayxr::rules` consumers (the Android legs, which cannot
* link `displayxr::common`) choose through the same rule. Pure: reads no
* environment, touches no global state, and never calls
* NoteColorSwapchainFormat() — the projection swapchain alone decides
* RenderSceneLinear().
*/
inline ColorFormatChoice
ChooseWindowSpaceSwapchainFormat(const std::vector<int64_t> &formats, ColorEncodingPreference pref)
{
struct Rgba8Pair {
int64_t unorm;
int64_t srgb;
};
// Same order the pre-v2.27.0 UNORM list was scanned in.
static const Rgba8Pair kRgba8[] = {
{28, 29}, // DXGI R8G8B8A8_UNORM / R8G8B8A8_UNORM_SRGB
{37, 43}, // VK R8G8B8A8_UNORM / R8G8B8A8_SRGB
{0x8058, 0x8C43}, // GL RGBA8 / SRGB8_ALPHA8
{70, 71}, // Metal RGBA8Unorm / RGBA8Unorm_sRGB
};
auto advertised = [&formats](int64_t f) {
for (int64_t x : formats) {
if (x == f) {
return true;
}
}
return false;
};

ColorFormatChoice out;
out.preference = pref;
if (formats.empty()) {
return out;
}

if (pref != ColorEncodingPreference::ForceUnorm) {
for (const Rgba8Pair &p : kRgba8) {
if (advertised(p.srgb) && advertised(p.unorm)) {
out.format = p.srgb;
out.isSrgb = true;
return out;
}
}
}
for (const Rgba8Pair &p : kRgba8) {
if (advertised(p.unorm)) {
out.format = p.unorm;
out.fellBack = (pref != ColorEncodingPreference::ForceUnorm);
return out;
}
}
out.format = formats[0];
out.isSrgb = false;
for (const Rgba8Pair &p : kRgba8) {
if (p.srgb == formats[0]) {
out.isSrgb = true;
}
}
out.fellBack = true;
return out;
}

} // namespace dxr
8 changes: 8 additions & 0 deletions common/hud_renderer.h
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ bool InitializeHudRenderer(HudRenderer& hud, uint32_t w, uint32_t h, uint32_t fo
// Returns pixel pointer (R8G8B8A8_UNORM) and row pitch in bytes.
// Caller must call UnmapHud() after consuming the pixels.
//
// The bytes are DISPLAY-REFERRED (D2D writes the authored sRGB values verbatim
// into a private UNORM texture). Move them into the window-space swapchain
// image — `_SRGB` by default since v2.27.0 (ADR-044 §7) — with a RAW copy:
// UpdateSubresource / CopyResource / CopyTextureRegion / a staging buffer +
// vkCmdCopyBufferToImage / glTexSubImage2D. Never draw them through an `_SRGB`
// RTV, vkCmdBlitImage them, or point D2D at the swapchain image itself (D2D
// cannot bind an `_SRGB` surface): each of those encodes a second time.
//
// `drawBody`: when false, skip the text sections and emit only the buttons.
// Lets a HUD-toggle key hide the info panel without losing always-visible
// chrome buttons. When buttons are present, body text is laid out below the
Expand Down
7 changes: 7 additions & 0 deletions common/hud_renderer_macos.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
* version): produces an R8G8B8A8 CPU buffer that the caller uploads into its
* graphics-API-specific HUD swapchain image (Metal `replaceRegion:`,
* GL `glTexSubImage2D`, Vulkan staging-buffer copy).
*
* The bitmap is a kCGColorSpaceSRGB context, i.e. DISPLAY-REFERRED bytes. All
* three uploads above are RAW copies, so they stay byte-exact into the `_SRGB`
* HUD swapchain CreateHudSwapchain() picks by default since v2.27.0 (ADR-044
* §7: MTLPixelFormatRGBA8Unorm_sRGB / GL_SRGB8_ALPHA8 / VK_FORMAT_R8G8B8A8_SRGB).
* GL: keep GL_FRAMEBUFFER_SRGB irrelevant by uploading with glTexSubImage2D,
* never by drawing or glBlitFramebuffer-ing into the image.
*/

#pragma once
Expand Down
6 changes: 6 additions & 0 deletions common/vk_overlay_kit.h
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,12 @@ struct FencedStage {
* submitted layer. Changed hash → one staging copy submitted with a fence and
* NO wait. Barriers match the demos' hand-rolled pattern (UNDEFINED →
* TRANSFER_DST → COLOR_ATTACHMENT_OPTIMAL).
*
* Colour: vkCmdCopyBufferToImage is a RAW copy — it never converts — so
* display-referred RGBA8 bytes land byte-exact in the `_SRGB` window-space
* swapchain (VK_FORMAT_R8G8B8A8_SRGB) that CreateHudSwapchain /
* CreateWindowSpaceSwapchain choose by default since v2.27.0 (ADR-044 §7).
* Do not replace it with vkCmdBlitImage: a blit into an `_SRGB` image encodes.
*/
class CachedLayerUploader {
public:
Expand Down
55 changes: 30 additions & 25 deletions common/xr_session_common.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -854,33 +854,38 @@ bool CreateWindowSpaceSwapchain(XrSessionManager& xr, SwapchainInfo& out,
std::vector<int64_t> formats(formatCount);
XR_CHECK(xrEnumerateSwapchainFormats(xr.session, formatCount, &formatCount, formats.data()));

// Prefer R8G8B8A8_UNORM for window-space swapchains because HudRenderer
// (standalone D3D11 device) always outputs R8G8B8A8_UNORM pixels. Using the
// runtime's default format (often B8G8R8A8 on Vulkan-backed compositors)
// would cause a format family mismatch in D3D12 CopyTextureRegion, silently
// failing the copy.
// Well-known R8G8B8A8_UNORM codes: DXGI=28, VK=37, GL_RGBA8=0x8058.
// ADR-044 §7 / INV-4.6: the R8G8B8A8 `_SRGB` sibling by default.
// Window-space layers carry DISPLAY-REFERRED (sRGB-encoded) bytes —
// HudRenderer rasterizes them with D2D/DirectWrite into a private
// R8G8B8A8_UNORM texture and the app moves them into this image. On a
// format-honest runtime a UNORM swapchain is read as LINEAR and encoded
// again (washed out), so the honest declaration is `_SRGB`.
//
// #1589: this one deliberately does NOT follow the honest-sRGB default. It
// is a CPU-upload path — the HUD is rasterized on the CPU into
// display-referred RGBA8 and copied in, so nothing here can encode. Making
// it `_SRGB` is the right end state (the bytes ARE encoded, so an `_SRGB`
// texture would simply be declaring them honestly, at no quality cost), but
// it changes the copy-format family on four graphics APIs and so needs its
// own verified change; it is NOT part of the chooser migration. Until then
// the HUD is a known display-referred-into-UNORM source.
int64_t selectedFormat = formats[0];
const int64_t preferredFormats[] = { 28, 37, 0x8058 };
for (int64_t pref : preferredFormats) {
for (uint32_t i = 0; i < formatCount; i++) {
if (formats[i] == pref) {
selectedFormat = pref;
goto found;
}
}
// The family stays R8G8B8A8 (never formats[0]'s channel order): DXGI
// 28 -> 29 is the same R8G8B8A8_TYPELESS family the runtime allocates, so
// D3D12 CopyTextureRegion / D3D11 CopyResource / UpdateSubresource,
// vkCmdCopyBufferToImage and glTexSubImage2D all stay legal and BYTE-EXACT.
// What changes is anything that RENDERS, BLITS or CLEARS into the image —
// that now encodes on write. Move the bytes with a raw copy only; see
// README "Window-space swapchains". DXR_SWAPCHAIN_ENCODING=unorm restores
// the R8G8B8A8_UNORM choice (same switch as the projection swapchain).
const dxr::ColorEncodingPreference pref = dxr::ColorEncodingPreferenceFromEnvironment();
const dxr::ColorFormatChoice choice = dxr::ChooseWindowSpaceSwapchainFormat(formats, pref);
if (choice.format == 0) {
LOG_ERROR("Window-space swapchain: the runtime advertised no swapchain formats");
return false;
}
const int64_t selectedFormat = choice.format;
if (choice.fellBack) {
LOG_WARN("Window-space swapchain: no R8G8B8A8 %s pair advertised — using %lld (0x%llX); "
"display-referred bytes in a non-sRGB swapchain wash out on a format-honest runtime",
pref == dxr::ColorEncodingPreference::ForceUnorm ? "UNORM" : "_SRGB/UNORM",
selectedFormat, selectedFormat);
} else {
LOG_INFO("Selected window-space swapchain format: %s %lld (0x%llX)%s",
choice.isSrgb ? "honest _SRGB" : "UNORM", selectedFormat, selectedFormat,
pref == dxr::ColorEncodingPreference::HonestSrgb ? " (default)" : " (DXR_SWAPCHAIN_ENCODING)");
}
found:
LOG_INFO("Selected window-space swapchain format: %lld (0x%llX)", selectedFormat, selectedFormat);

XrSwapchainCreateInfo swapchainInfo = {XR_TYPE_SWAPCHAIN_CREATE_INFO};
swapchainInfo.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_SAMPLED_BIT |
Expand Down
9 changes: 9 additions & 0 deletions common/xr_session_common.h
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,15 @@ bool ReleaseHudSwapchainImage(XrSessionManager& xr);
// Generic window-space swapchain helpers (the Hud* ones above are thin wrappers
// over these). Used for additional independent UI layers (e.g. a standalone
// button bar). Acquire/Release operate on the passed SwapchainInfo directly.
//
// Format (ADR-044 §7, v2.27.0+): the R8G8B8A8 `_SRGB` sibling by default —
// DXGI 29 / VK 43 / GL_SRGB8_ALPHA8 — falling back to R8G8B8A8_UNORM only when
// the sibling is not advertised; DXR_SWAPCHAIN_ENCODING=unorm forces UNORM.
// `out.format` says which. The image holds DISPLAY-REFERRED bytes, so fill it
// with a RAW copy (CopyResource / CopyTextureRegion / UpdateSubresource /
// vkCmdCopyBufferToImage / vkCmdCopyImage / glTexSubImage2D). A draw through an
// `_SRGB` RTV, a vkCmdBlitImage, or a clear to a non-transparent colour now
// encodes on write — see README "Window-space swapchains".
bool CreateWindowSpaceSwapchain(XrSessionManager& xr, SwapchainInfo& out,
uint32_t width, uint32_t height);
bool AcquireWindowSpaceImage(SwapchainInfo& sc, uint32_t& imageIndex);
Expand Down
Loading
Loading