diff --git a/CMakeLists.txt b/CMakeLists.txt index b79986b..de17242 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -261,8 +261,7 @@ set(DISPLAYXR_COMMON_SOURCES common/rig_mode.h common/rig_transition.cpp # platform-neutral convert-then-lerp rig transition common/rig_transition.h - common/mode_switch.cpp # platform-neutral smooth 2D<->3D mode-switch sequencer - common/mode_switch.h + common/mode_switch.h # header-only smooth 2D<->3D mode-switch sequencer (every platform; also via displayxr::rules) common/toast.cpp # platform-neutral transient confirmation-message state common/toast.h common/recenter_control.h # header-only per-axis dynamic-recenter pin state @@ -332,6 +331,12 @@ endif() # Keep it header-only: anything added here must compile on Windows, macOS, # Linux AND Android with no platform sources and no external dependencies. # +# common/mode_switch.h (dxr::ModeSwitch) is the same story for 2D<->3D +# transitions: every leg must ease a rendering-mode switch the same way, and +# before it went header-only the macOS / Linux legs hand-copied the per-frame +# glue that only Windows' XrSessionUpdateModeSwitch had. It needs nothing at all +# (no OpenXR, no platform headers). +# # common/dxr_view_config.h is the same story for the view CONFIGURATION: the # PRIMARY_MULTIVIEW_DXR opt-in (runtime #1486) has to be applied by every leg, # and the non-Windows legs cannot reach XrSessionManager. It needs OpenXR on diff --git a/README.md b/README.md index 8d1361f..3cd157b 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ The `common/` directory is the lib's second target (epic #396 W4, re-scoped [#39 | Rear-depth-budget clip policy: near/far/clipFar from `XrRearDepthBudgetDXR` (+ pre-extension fallback) | `clip_policy.h` | both | | Content-bounds ROI for the rear-depth budget: project a world-space AABB to a canvas-normalised rect, rebase a zoned app's rect into window space, chain it as `XrContentBoundsDXR` | `content_bounds.h` | both | | Content-MASK ROI for the rear-depth budget: any-coverage downsample of the app's own silhouette/alpha coverage onto the extension's cell grid (whole-window or zone-placed), union, cell count, chain it as `XrContentMaskDXR` | `content_mask.h` | both | +| **Smooth 2D↔3D transitions**: `dxr::ModeSwitch` — eases the stereo disparity around every rendering-mode switch and sequences the request (see [below](#smooth-2d3d-transitions-mode_switchh--every-app-every-platform)); `XrSessionUpdateModeSwitch()` is the Windows wrapper | `mode_switch.h` (header-only), `xr_session_common.{h,cpp}` | both (also `displayxr::rules`, so Linux/Android legs and engine ports get it) | | View-configuration opt-in: `DxrSelectViewConfigType()` — begin the session with `PRIMARY_MULTIVIEW_DXR` when the runtime advertises it; `DxrAliasInactiveViews()` — submit every located view, aliasing the ones the active mode does not use (ADR-041) | `dxr_view_config.h` | both (also `displayxr::rules`, so Linux/Android legs get it) | | Vulkan **frame ring**: N frames in flight (default 3), ONE `vkQueueSubmit` per frame, per-slot command buffer + fence + dynamic-offset UBO slice allocated once, tile barriers, deferred deletion, one named blocking escape hatch (`submitImmediate`) — see *Vulkan frame discipline* below | `vk_frame_ring.h` | all (also `displayxr::rules`, so Linux/Android legs get it; needs ``) | | Vulkan overlay kit: content-hash-gated layer uploads (`CachedLayerUploader`) + fence-pipelined readbacks (`PipelinedReadback`), each with ONE reused command buffer ([runtime#837](https://github.com/DisplayXR/displayxr-runtime/issues/837)) | `vk_overlay_kit.h` | all (needs ``) | @@ -246,6 +247,67 @@ resize/teardown; `ring.submitImmediate(record)` is the one sanctioned blocking s Android has no usable environment, so pass `Config::framesInFlight` there. `tests/vk_frame_ring_test.cpp` pins the ring arithmetic, env clamp, UBO slice alignment and deferred-deletion order GPU-free. +### Smooth 2D↔3D transitions (`mode_switch.h`) — every app, every platform + +**What the runtime does, and what it does not.** `xrRequestDisplayRenderingModeDXR(session, mode)` switches +the display in **one frame**: from the frame the new mode lands, the atlas recipe (view count, tile layout), +the display processor's weave-vs-flat choice and the switchable lens / backlight all follow that mode +(runtime ADR-028). The runtime does **not** ease the change, on any platform: the stereo disparity you see +is the *app's* content — the separation of the views it renders — and the app owns it. The runtime's only +transition aid is a one-frame guard (runtime #615) that keeps the first frame of a 2D→3D switch flat while +the display processor's eye set catches up. So an app that calls `xrRequestDisplayRenderingModeDXR` straight +from a key press **snaps** between full parallax and flat, on Windows, macOS, Linux and Android alike. + +**What every app must do: route its mode requests through `dxr::ModeSwitch`.** It ramps the view rig's +`ipdFactor` (0 = both eyes at the centre = flat, `steady` = the user's full 3D) over 0.18 s (SmoothStep, +wall-clock driven) and — the part hand-rolled versions get wrong — owns the **order** of ramp and request: + +| Switch | Sequence | Why | +|---|---|---| +| 3D → 2D | ramp disparity to 0 **first**, request the 2D mode on the frame it lands | 2D engages on content that is already flat: no jump at the cut | +| 2D → 3D | request the 3D mode **first** (its first frame is flat), then ramp disparity up to steady | the lens switches on flat content, depth then grows in | +| 3D → 3D, 2D → 2D | request now, keep the disparity | nothing to flatten | +| toggle mashed mid-ramp | retarget from the disparity on screen; an un-fired 3D → 2D that is reversed ramps back up and **never** switches | no half-switched state | + +It restores the app's **tuned** `ipdFactor`, not 1.0, and it is header-only and dependency-free (no OpenXR, +no platform headers), so the same file serves the native apps, the demos, the Android legs (through +`displayxr::rules`) and the engine ports. + +**Wiring — one call per frame, before `xrLocateViews`:** + +- **Windows** (`XrSessionManager` apps): `XrSessionUpdateModeSwitch(xr, inputState, dt)`. It consumes the + V / 0-8 request flags from `input_handler`, writes `inputState.viewParams.ipdFactor`, and issues the request. +- **Every other leg** (macOS, Linux, Android, engines — they carry their own session code): + +```cpp +#include "mode_switch.h" +static dxr::ModeSwitch g_modeSwitch; // defaults: 0.18 s SmoothStep, no configure() needed + +int32_t want = -1; // -1 = no request this frame +if (vPressed) want = dxr::ModeSwitch::cycleTarget(currentMode, modeCount); +if (digitPressed) want = digit; // absolute 0-8; out-of-range is ignored +if (!canRequest) want = -1; // no session / no PFN yet: the ramp still advances +dxr::ModeSwitchFrame f = g_modeSwitch.step(dt, want, modeCount, modeViewCounts, currentMode, steadyIpd); +rig.ipdFactor = f.ipd; // XrDisplayRigDXR / XrCameraRigDXR, THIS frame +if (f.fire) + xrRequestDisplayRenderingModeDXR(session, f.mode); +``` + +The inputs: `modeViewCounts[i]` is `XrDisplayRenderingModeInfoDXR::viewCount` from +`xrEnumerateDisplayRenderingModesDXR` (1 = a 2D mode); `currentMode` is the mode the **runtime** reports — the +enumerate's `isActive`, then every `XrEventDataRenderingModeChangedDXR` — never `f.mode` (a denied or reverted +request must not desynchronise the app); `steadyIpd` is the user-tuned full-3D value (`ViewParams::steadyIpdFactor` +— edit *that* from the +/- keys, not `ipdFactor`, which is the ramp's output). An app that computes its own +Kooima instead of declaring an `XR_DXR_view_rig` scales its eye separation by `f.ipd` before the projection. + +**Mode changes the app did not ask for are not eased.** A runtime-owned V press in a `_hosted` / legacy app, +the workspace shell's Ctrl+V, or an agent's mode request reach the app only as `XrEventDataRenderingModeChangedDXR` +after the fact; the app updates `currentMode` and renders the new mode at steady disparity. + +**Reference wiring:** the runtime's `cube_handle_*` test apps (Windows: `XrSessionUpdateModeSwitch`; macOS: +`step()`) and `displayxr-demo-modelviewer`'s Windows / macOS / Linux legs. `tests/common_smoke.cpp` pins the sequencing, the +interruption, the first-press case and `step()`'s request routing. + ### Color: honest sRGB by default (`color_policy.h`, ADR-021 / [#1589](https://github.com/DisplayXR/displayxr-runtime/issues/1589)) > **Behaviour change in `v2.15.0`.** Apps built against this lib now ask for an **`_SRGB`** diff --git a/common/mode_switch.cpp b/common/mode_switch.cpp deleted file mode 100644 index e97ac2e..0000000 --- a/common/mode_switch.cpp +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright 2026, DisplayXR -// SPDX-License-Identifier: BSL-1.0 -/*! - * @file - * @brief Implementation of the 2D<->3D mode-switch sequencer — see - * mode_switch.h. - * - * The whole helper is the dimensionality-transition decision in request() plus - * a scalar disparity ramp in update(). Calling request() mid-flight fully - * resets the phase/ramp from the caller-supplied current disparity, so it - * doubles as the retarget: a pending (un-fired) ->2D that the user reverses is - * dropped automatically when the new request overwrites the phase. - */ -#include "mode_switch.h" - -namespace dxr { - -void -ModeSwitch::configure(float duration_s, ModeSwitchEasing easing) -{ - dur_ = duration_s > 0.0f ? duration_s : 0.0f; - easing_ = easing; -} - -float -ModeSwitch::ease(float t) const -{ - if (t <= 0.0f) - return 0.0f; - if (t >= 1.0f) - return 1.0f; - switch (easing_) { - case ModeSwitchEasing::Linear: return t; - case ModeSwitchEasing::EaseOutCubic: { - float u = 1.0f - t; - return 1.0f - u * u * u; - } - case ModeSwitchEasing::SmoothStep: - default: return t * t * (3.0f - 2.0f * t); - } -} - -void -ModeSwitch::request(uint32_t targetMode, - uint32_t targetVC, - uint32_t currentMode, - uint32_t currentVC, - float currentIpd, - float steadyIpd) -{ - const bool toMono = targetVC <= 1; - const bool fromMono = currentVC <= 1; - - targetMode_ = targetMode; - from_ = currentIpd; - - if (toMono && !fromMono) { - // 3D -> 2D: flatten the disparity first, then switch on landing so 2D - // engages on already-mono content. The mode request is held until the - // ramp completes (fireAtEnd_). - phase_ = Phase::RampDownThenFire; - to_ = 0.0f; - fireAtEnd_ = true; - firePending_ = false; - } else if (!toMono && fromMono) { - // 2D -> 3D: switch now so the first 3D frame is flat (from_ = 0 forces - // it regardless of any stale ipd), then ease the disparity up to steady. - // The runtime's #615 coherence guard absorbs the one-frame DP eye-set - // lag on that first frame. - phase_ = Phase::FireThenRampUp; - from_ = 0.0f; - to_ = steadyIpd; - firePending_ = true; - fireAtEnd_ = false; - } else { - // Same dimensionality: 2D->2D, 3D->3D, or a reversal of a not-yet-fired - // ->2D (the runtime is still in 3D, so currentVC is still >1). No - // flatten needed — switch now (skip the fire when it's a no-op back to - // the current mode) and restore steady disparity for 3D modes; for 2D - // the disparity is irrelevant (mono), so leave it where it is. - phase_ = Phase::FireThenRampUp; - to_ = toMono ? currentIpd : steadyIpd; - firePending_ = (targetMode != currentMode); - fireAtEnd_ = false; - } - - t_ = dur_ > 0.0f ? 0.0f : 1.0f; - cur_ = from_; -} - -void -ModeSwitch::update(float dt_s, float *outIpd, bool *outFire, uint32_t *outMode) -{ - bool fire = false; - - if (phase_ != Phase::Idle) { - // Advance the normalized ramp. - if (t_ < 1.0f && dur_ > 0.0f) { - t_ += dt_s / dur_; - if (t_ > 1.0f) - t_ = 1.0f; - } else { - t_ = 1.0f; - } - cur_ = from_ + (to_ - from_) * ease(t_); - - if (phase_ == Phase::FireThenRampUp) { - if (firePending_) { - firePending_ = false; - fire = true; - } - if (t_ >= 1.0f) - phase_ = Phase::Idle; - } else { // RampDownThenFire - if (t_ >= 1.0f) { - if (fireAtEnd_) { - fireAtEnd_ = false; - fire = true; - } - phase_ = Phase::Idle; - } - } - } - - if (outIpd != nullptr) - *outIpd = cur_; - if (outFire != nullptr) - *outFire = fire; - if (outMode != nullptr) - *outMode = targetMode_; -} - -} // namespace dxr diff --git a/common/mode_switch.h b/common/mode_switch.h index b38d839..4dbb70a 100644 --- a/common/mode_switch.h +++ b/common/mode_switch.h @@ -2,7 +2,16 @@ // SPDX-License-Identifier: BSL-1.0 /*! * @file - * @brief Smooth 2D<->3D rendering-mode switch sequencer. + * @brief Smooth 2D<->3D rendering-mode switch sequencer (header-only). + * + * THE 2D<->3D TRANSITION CONTRACT (see README "Smooth 2D<->3D transitions"): + * the runtime switches a rendering mode in ONE frame — the atlas recipe, the + * display processor's weave-vs-flat choice and the switchable lens all follow + * the mode the moment it lands (ADR-028). The runtime does NOT ease the + * stereo disparity: that is app content, and the app owns it. So an app that + * calls xrRequestDisplayRenderingModeDXR directly on a key press snaps between + * full parallax and flat. Every app — every platform, every graphics API — + * should route its mode requests through this sequencer instead. * * A platform-neutral state machine that eases the stereo disparity around a * rendering-mode switch by ramping the view-rig `ipdFactor`, and — crucially — @@ -18,25 +27,29 @@ * top. Driven by wall-clock dt (frame-rate independent), interruptible (mashing * the toggle mid-ramp reverses cleanly — a not-yet-fired ->2D simply ramps back * up without ever switching), and dependency-free (own easing, no dxr_view_math - * / OpenXR / windows.h) so the native apps, the demos, and the engine ports can - * all share one implementation. + * / OpenXR / windows.h / AppKit) and HEADER-ONLY, so the native apps on every + * platform, the demos, the Android legs (via displayxr::rules) and the engine + * ports can all share one implementation. * - * Usage (per frame, in the app's main loop): + * Usage — the one-call form every app's main loop should use (any platform): * @code - * // On the toggle key, compute the target mode and hand it to the sequencer. - * if (cycleRequested) { - * uint32_t next = (xr.currentModeIndex + 1) % xr.renderingModeCount; - * mode_switch.request(next, viewCountOf(next), - * xr.currentModeIndex, viewCountOf(xr.currentModeIndex), - * viewParams.ipdFactor, app.steadyIpd); - * } - * // Every frame: advance the ramp and act on its outputs. - * float ipd; bool fire; uint32_t mode; - * mode_switch.update(dt_seconds, &ipd, &fire, &mode); - * viewParams.ipdFactor = ipd; // submit on the rig this frame - * if (fire && mode != xr.currentModeIndex) // issue the runtime switch - * pfnRequestDisplayRenderingModeEXT(session, mode); + * static dxr::ModeSwitch g_modeSwitch; // defaults: 0.18 s, SmoothStep + * + * // Each frame, BEFORE xrLocateViews: + * int32_t want = -1; // no request + * if (vPressed) want = dxr::ModeSwitch::cycleTarget(currentMode, modeCount); + * if (digitPressed) want = digit; // absolute jump + * dxr::ModeSwitchFrame f = g_modeSwitch.step(dt_seconds, want, modeCount, + * modeViewCounts, currentMode, + * steadyIpd); + * rig.ipdFactor = f.ipd; // submit THIS value this frame + * if (f.fire) + * xrRequestDisplayRenderingModeDXR(session, f.mode); + * // currentMode follows XrEventDataRenderingModeChangedDXR, never f.mode. * @endcode + * + * The lower-level request()/update() pair is what step() is built from; use it + * directly only when the app needs a different request policy. */ #pragma once @@ -53,13 +66,95 @@ enum class ModeSwitchEasing { EaseOutCubic //!< 1-(1-t)^3 }; +//! What one ModeSwitch::step() call asks the app to do this frame. +struct ModeSwitchFrame +{ + float ipd = 1.0f; //!< ipdFactor to submit on the view rig THIS frame + bool fire = false; //!< true → call xrRequestDisplayRenderingModeDXR(session, mode) NOW + uint32_t mode = 0; //!< the mode index to request when fire is true +}; + +//! Default ramp duration (seconds). Long enough to read as a transition, short +//! enough that a V press still feels immediate. Shared by every DisplayXR app. +constexpr float kModeSwitchDefaultDurationS = 0.18f; + //! Smooth 2D<->3D mode-switch sequencer. One instance per session. class ModeSwitch { public: //! Ramp duration (seconds) and easing curve. duration_s <= 0 → instant - //! (fire immediately, no ramp). Defaults: 0.18 s, SmoothStep. - void configure(float duration_s, ModeSwitchEasing easing = ModeSwitchEasing::SmoothStep); + //! (fire immediately, no ramp). Defaults (no configure() call needed): + //! kModeSwitchDefaultDurationS, SmoothStep. + void + configure(float duration_s, ModeSwitchEasing easing = ModeSwitchEasing::SmoothStep) + { + dur_ = duration_s > 0.0f ? duration_s : 0.0f; + easing_ = easing; + } + + //! The mode a V-style "cycle" key should target: currentMode + 1, wrapping. + //! Returns -1 when there are no modes. + static int32_t + cycleTarget(uint32_t currentMode, uint32_t modeCount) + { + return modeCount > 0 ? (int32_t)((currentMode + 1) % modeCount) : -1; + } + + //! Per-frame glue — THE call every app makes, once per frame, on every + //! platform. Funnels this frame's user request (if any) into the sequencer, + //! advances the ramp, and reports what to submit / request. + //! + //! dt_s : wall-clock seconds since the previous call + //! requestedMode : -1 = no request this frame; else the mode index the user + //! asked for (cycleTarget() for V, the digit for 0-8). + //! Out-of-range indices are ignored. Pass -1 while the app + //! cannot request (no session / no PFN) — the ramp still + //! advances. + //! modeCount : number of rendering modes (xrEnumerateDisplayRenderingModesDXR) + //! modeViewCounts : per-mode viewCount from the same enumeration (may be + //! null → every mode treated as 2D-dimensioned = 1 view) + //! currentMode : the runtime's CURRENT mode (the enumerate's isActive, + //! then XrEventDataRenderingModeChangedDXR) + //! steadyIpd : the app's full-3D ipdFactor (the user-tuned value, NOT + //! assumed 1.0); returned verbatim while idle + //! + //! fire is already suppressed when the target equals currentMode (a no-op + //! reversal), so the caller requests unconditionally when fire is true. + ModeSwitchFrame + step(float dt_s, + int32_t requestedMode, + uint32_t modeCount, + const uint32_t *modeViewCounts, + uint32_t currentMode, + float steadyIpd) + { + auto vcOf = [&](uint32_t m) -> uint32_t { + return (modeViewCounts != nullptr && m < modeCount && modeViewCounts[m] > 0) ? modeViewCounts[m] + : 1u; + }; + + if (requestedMode >= 0 && (uint32_t)requestedMode < modeCount) { + // currentIpd = the disparity actually on screen right now: the last + // ramp output while a ramp is in flight, else the steady value (what + // the idle branch renders). NOT the bare ipd() — it is 0 until the + // first ramp ever runs, so the FIRST 3D->2D would ramp 0->0 and fire + // immediately = a one-time snap from full disparity. + const float currentIpd = active() ? cur_ : steadyIpd; + request((uint32_t)requestedMode, vcOf((uint32_t)requestedMode), currentMode, vcOf(currentMode), + currentIpd, steadyIpd); + } + + ModeSwitchFrame f; + f.ipd = steadyIpd; // idle: render the tuned steady value (tracks +/- live) + f.mode = currentMode; + if (active()) { + update(dt_s, &f.ipd, &f.fire, &f.mode); + if (f.fire && f.mode == currentMode) { + f.fire = false; // reversal back to where the runtime already is + } + } + return f; + } //! Begin — or, if called mid-flight, seamlessly redirect — a switch to //! `targetMode`. Safe to call every time the user toggles; a mid-ramp call @@ -75,37 +170,145 @@ class ModeSwitch //! steadyIpd : the app's full-3D ipdFactor to restore to (e.g. the //! configured viewParams.ipdFactor; NOT assumed to be 1.0 — //! the user may have tuned it, or a camera rig caps it) - void request(uint32_t targetMode, - uint32_t targetVC, - uint32_t currentMode, - uint32_t currentVC, - float currentIpd, - float steadyIpd); + void + request(uint32_t targetMode, + uint32_t targetVC, + uint32_t currentMode, + uint32_t currentVC, + float currentIpd, + float steadyIpd) + { + const bool toMono = targetVC <= 1; + const bool fromMono = currentVC <= 1; + + targetMode_ = targetMode; + from_ = currentIpd; + + if (toMono && !fromMono) { + // 3D -> 2D: flatten the disparity first, then switch on landing so + // 2D engages on already-mono content. The mode request is held + // until the ramp completes (fireAtEnd_). + phase_ = Phase::RampDownThenFire; + to_ = 0.0f; + fireAtEnd_ = true; + firePending_ = false; + } else if (!toMono && fromMono) { + // 2D -> 3D: switch now so the first 3D frame is flat (from_ = 0 + // forces it regardless of any stale ipd), then ease the disparity + // up to steady. The runtime's #615 coherence guard absorbs the + // one-frame DP eye-set lag on that first frame. + phase_ = Phase::FireThenRampUp; + from_ = 0.0f; + to_ = steadyIpd; + firePending_ = true; + fireAtEnd_ = false; + } else { + // Same dimensionality: 2D->2D, 3D->3D, or a reversal of a + // not-yet-fired ->2D (the runtime is still in 3D, so currentVC is + // still >1). No flatten needed — switch now (skip the fire when + // it's a no-op back to the current mode) and restore steady + // disparity for 3D modes; for 2D the disparity is irrelevant + // (mono), so leave it where it is. + phase_ = Phase::FireThenRampUp; + to_ = toMono ? currentIpd : steadyIpd; + firePending_ = (targetMode != currentMode); + fireAtEnd_ = false; + } + + t_ = dur_ > 0.0f ? 0.0f : 1.0f; + cur_ = from_; + } //! Advance the ramp by dt_s and report this frame's outputs. //! *outIpd : the ipdFactor to submit on the rig this frame //! *outFire : true on exactly ONE frame — when the caller should issue - //! pfnRequestDisplayRenderingModeEXT(*outMode). The caller + //! xrRequestDisplayRenderingModeDXR(*outMode). The caller //! should still skip the call when *outMode == currentMode //! (a no-op reversal), though the runtime tolerates it. + //! (step() does that skip for you.) //! *outMode : the mode index to request when *outFire is true //! Any of the out-pointers may be null. - void update(float dt_s, float *outIpd, bool *outFire, uint32_t *outMode); + void + update(float dt_s, float *outIpd, bool *outFire, uint32_t *outMode) + { + bool fire = false; + + if (phase_ != Phase::Idle) { + // Advance the normalized ramp. + if (t_ < 1.0f && dur_ > 0.0f) { + t_ += dt_s / dur_; + if (t_ > 1.0f) + t_ = 1.0f; + } else { + t_ = 1.0f; + } + cur_ = from_ + (to_ - from_) * ease(t_); + + if (phase_ == Phase::FireThenRampUp) { + if (firePending_) { + firePending_ = false; + fire = true; + } + if (t_ >= 1.0f) + phase_ = Phase::Idle; + } else { // RampDownThenFire + if (t_ >= 1.0f) { + if (fireAtEnd_) { + fireAtEnd_ = false; + fire = true; + } + phase_ = Phase::Idle; + } + } + } + + if (outIpd != nullptr) + *outIpd = cur_; + if (outFire != nullptr) + *outFire = fire; + if (outMode != nullptr) + *outMode = targetMode_; + } //! True while a ramp is in flight or a mode request is still pending. - bool active() const { return phase_ != Phase::Idle; } + bool + active() const + { + return phase_ != Phase::Idle; + } //! Current ipdFactor without advancing the clock. - float ipd() const { return cur_; } + float + ipd() const + { + return cur_; + } private: - enum class Phase { + enum class Phase + { Idle, RampDownThenFire, //!< 3D->2D: ramp disparity to 0, THEN fire the switch FireThenRampUp //!< ->3D or same-dim: fire now, THEN ease disparity }; - float ease(float t) const; + float + ease(float t) const + { + if (t <= 0.0f) + return 0.0f; + if (t >= 1.0f) + return 1.0f; + switch (easing_) { + case ModeSwitchEasing::Linear: return t; + case ModeSwitchEasing::EaseOutCubic: { + float u = 1.0f - t; + return 1.0f - u * u * u; + } + case ModeSwitchEasing::SmoothStep: + default: return t * t * (3.0f - 2.0f * t); + } + } Phase phase_ = Phase::Idle; uint32_t targetMode_ = 0; @@ -116,7 +319,7 @@ class ModeSwitch float to_ = 0.0f; float cur_ = 0.0f; //!< last evaluated ipd float t_ = 1.0f; //!< normalized progress; 1 = landed/idle - float dur_ = 0.18f; + float dur_ = kModeSwitchDefaultDurationS; ModeSwitchEasing easing_ = ModeSwitchEasing::SmoothStep; }; diff --git a/common/xr_session_common.cpp b/common/xr_session_common.cpp index 640978f..b086222 100644 --- a/common/xr_session_common.cpp +++ b/common/xr_session_common.cpp @@ -428,61 +428,34 @@ bool ReleaseQuadSwapchainImage(XrSessionManager& xr) { } void XrSessionUpdateModeSwitch(XrSessionManager& xr, InputState& state, float dt) { - // One-time configure of the sequencer (0.18 s SmoothStep ease). + // Windows convenience wrapper over the platform-neutral dxr::ModeSwitch::step() + // (mode_switch.h) — the macOS / Linux / Android legs call step() directly with + // the same arguments, so every platform eases a switch identically. if (!xr.modeSwitchConfigured) { - xr.modeSwitch.configure(0.18f, dxr::ModeSwitchEasing::SmoothStep); + xr.modeSwitch.configure(dxr::kModeSwitchDefaultDurationS, dxr::ModeSwitchEasing::SmoothStep); xr.modeSwitchConfigured = true; } - const float steady = state.viewParams.steadyIpdFactor; - - // Active view count of a mode (1 = 2D/mono, >1 = 3D); clamp empty/OOB to 1. - auto vcOf = [&](uint32_t m) -> uint32_t { - return (m < xr.renderingModeCount && xr.renderingModeViewCounts[m] > 0) - ? xr.renderingModeViewCounts[m] : 1; - }; - // Funnel the V-cycle and the 0-8 absolute requests into one target. Absolute // wins if both fired the same frame. Clear the flags as we consume them. int32_t target = -1; if (state.cycleRenderingModeRequested) { state.cycleRenderingModeRequested = false; - if (xr.renderingModeCount > 0) - target = (int32_t)((xr.currentModeIndex + 1) % xr.renderingModeCount); + target = dxr::ModeSwitch::cycleTarget(xr.currentModeIndex, xr.renderingModeCount); } if (state.absoluteRenderingModeRequested >= 0) { int32_t a = state.absoluteRenderingModeRequested; state.absoluteRenderingModeRequested = -1; if ((uint32_t)a < xr.renderingModeCount) target = a; } - - if (target >= 0 && xr.session != XR_NULL_HANDLE && - xr.pfnRequestDisplayRenderingModeEXT != nullptr) { - // Hand the switch to the sequencer. currentIpd = the disparity actually on - // screen right now: the last ramp output while a ramp is in flight, else - // the steady value (what the idle branch below renders). Do NOT use the - // bare modeSwitch.ipd() — it is 0 until the first ramp ever runs, so the - // FIRST 3D->2D switch would ramp 0->0 and fire immediately = a one-time - // snap from full disparity. steadyIpd = the tuned target to restore to. - const float currentIpd = xr.modeSwitch.active() ? xr.modeSwitch.ipd() : steady; - xr.modeSwitch.request((uint32_t)target, vcOf((uint32_t)target), - xr.currentModeIndex, vcOf(xr.currentModeIndex), - currentIpd, steady); - } - - if (xr.modeSwitch.active()) { - float ipd = steady; - bool fire = false; - uint32_t mode = xr.currentModeIndex; - xr.modeSwitch.update(dt, &ipd, &fire, &mode); - state.viewParams.ipdFactor = ipd; // the rig render value this frame - if (fire && mode != xr.currentModeIndex && xr.session != XR_NULL_HANDLE && - xr.pfnRequestDisplayRenderingModeEXT != nullptr) { - xr.pfnRequestDisplayRenderingModeEXT(xr.session, mode); - } - } else { - // Idle: render the tuned steady value (tracks +/- adjustments immediately). - state.viewParams.ipdFactor = steady; + const bool canRequest = xr.session != XR_NULL_HANDLE && xr.pfnRequestDisplayRenderingModeEXT != nullptr; + + const dxr::ModeSwitchFrame f = xr.modeSwitch.step( + dt, canRequest ? target : -1, xr.renderingModeCount, xr.renderingModeViewCounts, + xr.currentModeIndex, state.viewParams.steadyIpdFactor); + state.viewParams.ipdFactor = f.ipd; // the rig render value this frame + if (f.fire && canRequest) { + xr.pfnRequestDisplayRenderingModeEXT(xr.session, f.mode); } } diff --git a/tests/common_smoke.cpp b/tests/common_smoke.cpp index 3ffed17..f6834bd 100644 --- a/tests/common_smoke.cpp +++ b/tests/common_smoke.cpp @@ -567,6 +567,65 @@ static void test_mode_switch() } CHECK(fireCount == 1 && firedMode == 2, "3D->3D must fire the new mode once"); } + + // --- step(): the one-call per-frame glue every platform uses. --- + { + CHECK(dxr::ModeSwitch::cycleTarget(1, 3) == 2 && dxr::ModeSwitch::cycleTarget(2, 3) == 0 && + dxr::ModeSwitch::cycleTarget(0, 0) == -1, + "cycleTarget must wrap and reject an empty mode list"); + + // Modes: 0 = 2D (1 view), 1 = stereo (2 views), 2 = quad (4 views). + const uint32_t vcs[3] = {1, 2, 4}; + const float steady = 0.8f; // user-tuned, NOT 1.0 + dxr::ModeSwitch ms; // no configure(): defaults must be the shared ramp + uint32_t current = 1; + + // The FIRST request of a fresh sequencer being 3D->2D must ramp from the + // on-screen steady disparity (the 4307382 first-press snap regression). + { + dxr::ModeSwitch fresh; + dxr::ModeSwitchFrame g = fresh.step(DT, 0, 3, vcs, 1, steady); + CHECK(!g.fire && g.ipd > 0.1f, "fresh 3D->2D must not snap flat on the first frame"); + } + + // Idle: steady verbatim, never fires. + dxr::ModeSwitchFrame f = ms.step(DT, -1, 3, vcs, current, steady); + CHECK(!f.fire && f.ipd == steady && !ms.active(), "idle step: steady ipd, no fire"); + + // V from stereo -> mode 2 (3D->3D): fires at once, keeps steady disparity. + f = ms.step(DT, dxr::ModeSwitch::cycleTarget(current, 3), 3, vcs, current, steady); + CHECK(f.fire && f.mode == 2 && f.ipd > steady - 1e-4f, "3D->3D step fires immediately"); + current = 2; // the runtime's RenderingModeChanged event + while (ms.active()) ms.step(DT, -1, 3, vcs, current, steady); + + // 0 (absolute) from quad -> 2D: the FIRST frame after the request must + // still carry nearly full disparity (a ramp, not a snap), and the fire + // comes only on landing at zero. + f = ms.step(DT, 0, 3, vcs, current, steady); + CHECK(!f.fire && f.ipd > 0.0f && f.ipd < steady, "3D->2D step: first frame is mid-ramp, not fired"); + int frames = 1; + uint32_t fires = 0; + while (ms.active() && frames < 20) { + f = ms.step(DT, -1, 3, vcs, current, steady); + frames++; + if (f.fire) { fires++; CHECK(f.mode == 0 && f.ipd < 1e-3f, "3D->2D fires mode 0 at zero disparity"); } + } + CHECK(fires == 1 && frames >= 3 && frames <= 4, "default ramp = 0.18 s ~ 3 frames at DT, one fire"); + current = 0; + + // Out-of-range request is ignored. + f = ms.step(DT, 7, 3, vcs, current, steady); + CHECK(!f.fire && !ms.active(), "out-of-range request must be ignored"); + + // A request for the mode the runtime is already in never fires. + f = ms.step(DT, 0, 3, vcs, current, steady); + CHECK(!f.fire, "request for the current mode must not fire"); + while (ms.active()) ms.step(DT, -1, 3, vcs, current, steady); + + // Null view counts: every mode reads as 1 view -> same-dim, fires now. + f = ms.step(DT, 1, 3, nullptr, current, steady); + CHECK(f.fire && f.mode == 1, "null view-count table must still route the request"); + } } // Off-axis projection self-test: display-rig math, plus display<->camera rig