diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b2915cb..56198a98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the DisplayXR Unity plugin will be documented in this fil The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed +- **The projection layer's view count now comes from the ACTIVE rendering mode at submit, not from the swapchain-create latch.** `displayxr-runtime#1486` tightened `xrEndFrame`: under `XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO` — which is what this provider begins with, and stays on, because Unity's stereo topology is fixed at 2 — a projection layer must carry exactly 2 views, or 1 **only** while the active rendering mode is itself 1-view (and the instance enabled `XR_DXR_display_info`, which the provider always does). A 1-view submission in a 2-view mode is now `XR_ERROR_VALIDATION_FAILURE` where the runtime used to silently flat-blit it. The provider derived that count from `s_ps.sc_view_count`, a shadow of the mode latched when the swapchain was created and refreshed only by `ps_reconcile_primary()` — a refresh that is skippable and can lag: it early-returns on a zero-size target (minimised window, unresolved zone rect) *before* it ever compares the view count, `dxr_prov_reconcile_size()` returns early while a frame is begun, a failed swapchain recreate leaves the stale value in place, and on macOS the mode event is polled from the main thread (`DisplayXRProviderDriver.LateUpdate`) while submit runs on the render thread. So a 2D→3D flip could leave a 1-view submission outliving its 1-view mode. `dxr_prov_submit_frame` now reads the active mode directly and clamps to the slices actually allocated (`arraySize`, always 2), so **2 — always legal — is the failure direction and a 1 is emitted only while the mode says 1-view**. Raising the count is safe content-wise: `GfxPopulateNextFrameDesc` fills both SPI slices / both MultiPass passes every frame whatever the mode, so slice 1 is never stale. A count/latch disagreement logs once (capped at 8 lines, never per frame). No behaviour change in the steady state of either mode. +- **`native~/displayxr_extensions.h`: `XR_DXR_DISPLAY_INFO_SPEC_VERSION` 12 → 19**, and the hand-written mirror now carries `XR_VIEW_CONFIGURATION_TYPE_PRIMARY_MULTIVIEW_DXR` (`1004999212`) with a note that the provider deliberately does **not** use it — Unity's topology is fixed at 2 views, so `PRIMARY_STEREO` is the right type and now reports exactly that. Documentation value only; nothing compiles against the constant today, and a future N-view/quilt path (ADR-007) is the only consumer it anticipates. The mirror is otherwise complete for everything the provider references — audited by diffing every `XR_*_DXR` identifier used in `native~/` against what the header defines. + ## [2.19.3] - 2026-09-15 ### Fixed diff --git a/Runtime/Plugins/macOS/displayxr_unity.bundle/Contents/MacOS/displayxr_unity b/Runtime/Plugins/macOS/displayxr_unity.bundle/Contents/MacOS/displayxr_unity index 6ec58a09..a352378c 100755 Binary files a/Runtime/Plugins/macOS/displayxr_unity.bundle/Contents/MacOS/displayxr_unity and b/Runtime/Plugins/macOS/displayxr_unity.bundle/Contents/MacOS/displayxr_unity differ diff --git a/native~/displayxr_extensions.h b/native~/displayxr_extensions.h index e5ebecc6..6c5a7355 100644 --- a/native~/displayxr_extensions.h +++ b/native~/displayxr_extensions.h @@ -13,8 +13,14 @@ extern "C" { #endif // --- XR_DXR_display_info --- +// This header is a HAND-WRITTEN mirror of the runtime's +// src/external/openxr_includes/openxr/XR_DXR_display_info.h — it carries only the +// constants and structs the provider actually uses, so the version below tracks the +// runtime header it was last reconciled against, not everything that header defines. +// The runtime spells the macro XR_DXR_display_info_SPEC_VERSION; the mirror's +// all-caps spelling is kept for source compatibility with the provider TUs. #define XR_DXR_DISPLAY_INFO_EXTENSION_NAME "XR_DXR_display_info" -#define XR_DXR_DISPLAY_INFO_SPEC_VERSION 12 +#define XR_DXR_DISPLAY_INFO_SPEC_VERSION 19 #define XR_TYPE_DISPLAY_INFO_DXR ((XrStructureType)1004999003) @@ -29,6 +35,26 @@ typedef struct XrDisplayInfoDXR { uint32_t displayPixelHeight; } XrDisplayInfoDXR; +// --- N-view view configuration (XR_DXR_display_info SPEC_VERSION 19) --- +// DisplayXR/displayxr-runtime#1486: PRIMARY_STEREO now reports EXACTLY 2 views and +// xrEndFrame under it accepts exactly 2 — or 1, but only while the ACTIVE rendering +// mode is itself 1-view AND the instance enabled XR_DXR_display_info. An app that +// wants the device's N-view (quad / light-field) modes enables XR_DXR_display_info, +// finds this type in xrEnumerateViewConfigurations, and begins its session with it. +// +// THIS PROVIDER DOES NOT USE IT, deliberately. Unity's stereo topology is fixed at 2 +// (DXR_PROV_MAX_VIEWS, one arraySize=2 swapchain, two render passes/params), so the +// provider begins PRIMARY_STEREO (dxr_prov_poll_events, SESSION_STATE_READY) and gets +// the 2 views it can fill. The define is mirrored for documentation value — so the +// next reader does not have to go to the runtime to learn why PRIMARY_STEREO is a +// choice here rather than the only option — and so a future N-view/quilt render path +// (ADR-007) has the constant already in hand. It is a cast #define, not an +// enumerator, so it is invisible to -Wswitch: any switch over XrViewConfigurationType +// that must handle it needs an explicit case. +// +// Runtime reference: docs/reference/view-configuration-model.md. +#define XR_VIEW_CONFIGURATION_TYPE_PRIMARY_MULTIVIEW_DXR ((XrViewConfigurationType)1004999212) + // --- Desktop position of the 3D panel (XR_DXR_display_info SPEC_VERSION 16) --- // Chained onto XrSystemProperties (alongside XrDisplayInfoDXR) so a client can find // out WHERE on the Windows virtual desktop the panel is, which XrDisplayInfoDXR does diff --git a/native~/displayxr_xrprovider/displayxr_provider_session.cpp b/native~/displayxr_xrprovider/displayxr_provider_session.cpp index 16f8355e..7b4e7ea1 100644 --- a/native~/displayxr_xrprovider/displayxr_provider_session.cpp +++ b/native~/displayxr_xrprovider/displayxr_provider_session.cpp @@ -6430,7 +6430,49 @@ int dxr_prov_submit_frame(uint32_t image_index) } s_ps.frame_begun = 0; - uint32_t submit_n = s_ps.sc_view_count >= 2 ? 2 : 1; + // How many views this frame's projection layer carries. Derived from the ACTIVE + // rendering mode HERE, at submit, NOT inherited from s_ps.sc_view_count. + // + // Why (runtime #1486): under XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO — which is + // what this provider begins with (dxr_prov_poll_events, SESSION_STATE_READY) — + // xrEndFrame now accepts exactly 2 views, or 1 ONLY while the active rendering mode + // is itself 1-view and the instance enabled XR_DXR_display_info (it always does). + // A 1-view submission in a 2-view mode is XR_ERROR_VALIDATION_FAILURE where it used + // to be silently flat-blitted. So 2 is always legal and 1 never is unless the mode + // says so — which makes the mode, not a latch, the only safe source for the count. + // + // s_ps.sc_view_count is a SHADOW of the mode latched at swapchain create + // (ps_create_swapchain) and refreshed only by ps_reconcile_primary(). That refresh + // is skippable and can lag by frames: + // - ps_reconcile_primary() early-returns on a 0-size target (minimised window, + // unresolved zone rect) BEFORE it ever compares the view count; + // - dxr_prov_reconcile_size() returns early while a frame is begun; + // - a failed ps_recreate_primary_swapchain() leaves the old value in place; + // - on macOS the mode event is polled from the MAIN thread + // (DisplayXRProviderDriver.LateUpdate) while this runs on the render thread. + // The mode read below is still the provider's cached active_mode_index, so the + // residual window is the event-delivery latency itself — but it is the freshest + // value we hold, and every path above that used to widen it is closed. + // + // Clamped to the slices we actually allocated (arraySize, always 2), so we can + // never submit more views than Unity rendered. Raising the count to 2 is always + // safe content-wise: the render topology is fixed at 2 (GfxPopulateNextFrameDesc + // always fills both SPI slices / both MultiPass passes) whatever the mode. + uint32_t submit_n = ps_active_view_count(); + if (s_ps.sc_array > 0 && submit_n > s_ps.sc_array) submit_n = s_ps.sc_array; + if (submit_n < 1) submit_n = 1; + if (submit_n != s_ps.sc_view_count) { + // Transient by construction (the next reconcile reallocs the tile to match), + // but it is exactly the window that used to produce a rejected xrEndFrame — + // so record it, hard-capped so it can never become a per-frame log. + static unsigned s_vc_skew_logged = 0; + if (s_vc_skew_logged < 8) { + s_vc_skew_logged++; + ps_log("[DisplayXR-PROV] submit views %u (active mode) != swapchain latch %u " + "— submitting the mode's count (#1486)\n", + submit_n, s_ps.sc_view_count); + } + } // D3D12: Unity rendered both eyes into the shared BRIDGE on its device (topology is // fixed at 2 views). Copy the SUBMITTED slices into the acquired runtime swapchain // image on our own device, fence-synced against Unity's render (shared fence below). @@ -6614,8 +6656,8 @@ int dxr_prov_submit_frame(uint32_t image_index) } if (d11_diag) ps_log("[DisplayXR-PROV] D3D11 submit[%u]: released, building layers\n", d11_frames); - // Build the projection layer. Submit sc_view_count views (#172 P4): 2 in stereo 3D - // (array layers 0/1), 1 in hardware 2D (layer 0 only = a single full-res tile). The + // Build the projection layer. Submit the ACTIVE mode's view count (#172 P4, #1486): + // 2 in stereo 3D (array layers 0/1), 1 in hardware 2D (layer 0 only = a single tile). The // runtime composites only the submitted views, so 2D shows one clean view (no weave // ghost) even though Unity rendered both eyes into the 2-slice bridge. uint32_t n = submit_n;