From e4d198ee138367a128c0360e5e3653167d28f8c1 Mon Sep 17 00:00:00 2001 From: iChris4 Date: Sat, 26 Sep 2026 02:25:34 +0200 Subject: [PATCH] Implement render scale adjustments for OpenXR D3D12 and Vulkan backends - Added SetRenderScale method to both D3D12 and Vulkan backends to adjust the render scale dynamically. - Updated eye size calculations to reflect the new render scale in both backends. - Implemented ResizeWritablePair method to handle resizing of swapchains based on the requested eye sizes. - Modified BeginFrame methods to ensure swapchains are resized appropriately before rendering. - Enhanced error handling to maintain current eye sizes when the requested sizes cannot be allocated. - Added tests to validate render scale functionality, ensuring correct behavior when scaling up and down, including handling of refused sizes. --- OPENXR.md | 22 ++- aurora-main/include/aurora/d3d12_interop.h | 9 + aurora-main/include/aurora/vulkan_interop.h | 9 + .../include/aurora/vulkan_win32_interop.h | 4 + aurora-main/lib/webgpu/d3d12_interop.cpp | 30 +++ aurora-main/lib/webgpu/vulkan_interop.cpp | 39 +++- .../lib/webgpu/vulkan_win32_interop.cpp | 22 +++ docs/quest-port.md | 8 +- runtime/include/runtime_config.h | 16 +- runtime/include/vr/openxr_d3d12.h | 8 + runtime/include/vr/openxr_integration.h | 19 ++ runtime/include/vr/openxr_runtime.h | 24 +++ runtime/include/vr/openxr_vulkan.h | 4 + runtime/include/vr/openxr_vulkan_win32.h | 3 + runtime/include/vr/openxr_windows.h | 3 + runtime/src/settings_overlay.cpp | 31 +++ runtime/src/vr/openxr_d3d12.cpp | 80 +++++++- runtime/src/vr/openxr_integration.cpp | 69 +++++++ runtime/src/vr/openxr_runtime.cpp | 17 +- runtime/src/vr/openxr_vulkan.cpp | 187 ++++++++++++++++-- runtime/src/vr/openxr_vulkan_win32.cpp | 77 +++++++- runtime/tests/openxr_d3d12_replay_tests.cpp | 146 +++++++++++++- 22 files changed, 779 insertions(+), 48 deletions(-) diff --git a/OPENXR.md b/OPENXR.md index 96cd193..81e8fd5 100644 --- a/OPENXR.md +++ b/OPENXR.md @@ -135,8 +135,26 @@ The old Eager Frame Heartbeat option has been removed and existing `eager_frame_ settings are ignored. Completed rendering wakes the XR thread immediately. A 50 ms keep-alive still protects pauses and window dragging without eager repeats during rendering. -`render_scale` scales the per-eye size recommended by the OpenXR runtime. It defaults to 1.0 on PC -and 0.8 on the Quest, whose mobile GPU needs the headroom. +`render_scale` scales the per-eye size recommended by the OpenXR runtime (0.25 to 2, never above the +runtime's maximum). It defaults to 1.0 on PC and 0.8 on the Quest, whose mobile GPU needs the +headroom. It is live: **F10 → VR → Render resolution** (also on the headset panel's VR tab) sets it +in percent, applies it when the slider is let go, and saves it. Below the slider, *Each eye* gives +the left eye's size now and, while they differ, the size the slider's value gives. + +A new scale never interrupts the picture. Each backend keeps two swapchain pairs, one on display and +one Aurora writes next, and rebuilds only the second, at the start of the frame that writes it; the +other follows a frame later, once it is the one written. The new +swapchains are created before the old ones go, and Aurora first forgets the old images: the D3D12 +bridge waits for any copy still writing them, the Windows Vulkan bridge drains Dawn's queue and drops +its wraps of the old `VkImage`s (the runtime may hand the same handles to the new swapchains), and +the Quest also replaces its shared eye buffers, whose Dawn imports are released +(`aurora_vulkan_forget_stereo_buffers`). Those buffers are kept as large as both pairs, growing with +the first pair rebuilt larger and shrinking once the second has followed it down, and each copy moves +only what fits the image it writes. If the runtime cannot allocate a size, the log says so, the eyes +keep the size they had (a pair already rebuilt goes back to it), and that size is not tried again +until the scale changes; the saved value is still what the next launch asks for. +`mkw_openxr_replay_tests` and `mkw_openxr_vulkan_replay_tests` cover the rebuild, the display pair +left alone, the images forgotten before their swapchains are destroyed, and both kinds of refusal. `world_units_per_meter` controls the scale of headset translation in the game world. `hud_distance_meters` and `hud_width_meters` place and size the virtual screen. They are read at launch and govern both the menu screen and the in-race 2D screen, so 2D content keeps its place diff --git a/aurora-main/include/aurora/d3d12_interop.h b/aurora-main/include/aurora/d3d12_interop.h index 455f6ae..507e7e4 100644 --- a/aurora-main/include/aurora/d3d12_interop.h +++ b/aurora-main/include/aurora/d3d12_interop.h @@ -84,6 +84,15 @@ bool aurora_d3d12_set_stereo_targets_with_panel(uint64_t frameToken, */ bool aurora_d3d12_cancel_stereo_targets(uint64_t frameToken); +/** + * The host is about to destroy these target resources (the eye swapchains, when + * the render resolution changes): returns once no copy the bridge queued can + * still write them, waiting for the queue if it has to. Call with no frame + * token pending. False when the queue could not be fenced, in which case the + * resources must be kept. + */ +bool aurora_d3d12_forget_stereo_targets(void* const* resources, uint32_t count); + /** * Removes the sink and drains bridge resources. The worker must be idle. Returns * false when queued work cannot be fenced; in that case the bridge is retained diff --git a/aurora-main/include/aurora/vulkan_interop.h b/aurora-main/include/aurora/vulkan_interop.h index f902b14..39b3dd4 100644 --- a/aurora-main/include/aurora/vulkan_interop.h +++ b/aurora-main/include/aurora/vulkan_interop.h @@ -132,6 +132,15 @@ bool aurora_vulkan_set_stereo_targets_with_panel(uint64_t frameToken, */ bool aurora_vulkan_cancel_stereo_targets(uint64_t frameToken); +/** + * Releases Dawn's imports of buffers the caller is about to free (the eyes' + * ring, when the render resolution changes), which would otherwise keep every + * old buffer alive for the bridge's lifetime. Dawn's own reference to each + * buffer lasts until its queue is done with it, so the caller need not wait for + * Dawn. Call with no frame token pending; false if one is, releasing nothing. + */ +bool aurora_vulkan_forget_stereo_buffers(struct AHardwareBuffer* const* buffers, uint32_t count); + /** * Removes the sink and releases the cached Dawn imports. The worker must be * idle. Returns false only when Dawn could not be drained, in which case the diff --git a/aurora-main/include/aurora/vulkan_win32_interop.h b/aurora-main/include/aurora/vulkan_win32_interop.h index fbecb5c..8c67551 100644 --- a/aurora-main/include/aurora/vulkan_win32_interop.h +++ b/aurora-main/include/aurora/vulkan_win32_interop.h @@ -16,6 +16,10 @@ bool aurora_vulkan_win32_set_targets(uint64_t token, const AuroraD3D12StereoTarg bool aurora_vulkan_win32_set_targets_with_panel(uint64_t token, const AuroraD3D12StereoTarget* targets, uint32_t count, const AuroraD3D12StereoTarget* panel); bool aurora_vulkan_win32_cancel(uint64_t token); +// As aurora_d3d12_forget_stereo_targets, for VkImages: waits for Dawn's queue, +// then drops the textures wrapping them, since the runtime may hand the same +// handles out again for the swapchains that replace them. +bool aurora_vulkan_win32_forget_targets(void* const* images, uint32_t count); bool aurora_vulkan_win32_disable(); void* aurora_vulkan_win32_lock_queue(); void aurora_vulkan_win32_unlock_queue(void* guard); diff --git a/aurora-main/lib/webgpu/d3d12_interop.cpp b/aurora-main/lib/webgpu/d3d12_interop.cpp index d401c7b..d947b8a 100644 --- a/aurora-main/lib/webgpu/d3d12_interop.cpp +++ b/aurora-main/lib/webgpu/d3d12_interop.cpp @@ -319,6 +319,26 @@ public: return true; } + // The command references keep a destroyed swapchain's resources alive, but + // not necessarily the runtime's memory behind them, so a copy still writing + // one is waited for. Usually the last one finished frames ago. + bool ForgetTargets(void* const* resources, uint32_t count) noexcept { + std::lock_guard lock(m_mutex); + if (m_framePending) { + return false; + } + CollectCompletedCommandsLocked(); + const auto writesOne = [&](const InFlightCommand& command) { + return std::any_of(command.destinations.begin(), command.destinations.end(), [&](const auto& destination) { + return destination && std::find(resources, resources + count, destination.Get()) != resources + count; + }); + }; + if (std::none_of(m_commands.begin(), m_commands.end(), writesOne)) { + return true; + } + return WaitForGpuLocked(); + } + private: bool EnsureIntermediate(uint32_t eye, const stereo::EyeImage& source) noexcept { auto& intermediate = m_intermediates[eye]; @@ -756,6 +776,14 @@ bool aurora_d3d12_cancel_stereo_targets(uint64_t frameToken) { return g_bridge && g_bridge->CancelBeforeEncode(frameToken); } +bool aurora_d3d12_forget_stereo_targets(void* const* resources, uint32_t count) { + using namespace aurora::d3d12_interop; + if (!g_bridge) { + return true; + } + return resources != nullptr && g_bridge->ForgetTargets(resources, count); +} + bool aurora_d3d12_disable_stereo_bridge() { using namespace aurora::d3d12_interop; if (!g_bridge) { @@ -798,6 +826,8 @@ bool aurora_d3d12_set_stereo_targets_with_panel(uint64_t, const AuroraD3D12Stere bool aurora_d3d12_cancel_stereo_targets(uint64_t) { return false; } +bool aurora_d3d12_forget_stereo_targets(void* const*, uint32_t) { return true; } + bool aurora_d3d12_disable_stereo_bridge() { return true; } #endif diff --git a/aurora-main/lib/webgpu/vulkan_interop.cpp b/aurora-main/lib/webgpu/vulkan_interop.cpp index 665aa6a..d52c3be 100644 --- a/aurora-main/lib/webgpu/vulkan_interop.cpp +++ b/aurora-main/lib/webgpu/vulkan_interop.cpp @@ -93,8 +93,9 @@ bool device_supports_bridge() noexcept { } // One AHardwareBuffer imported into Dawn. The import is created on first use -// and kept until the bridge is disabled; the ring the OpenXR side recycles is -// tiny (two buffers per eye), so this never grows beyond a handful of entries. +// and kept until the bridge is disabled or the OpenXR side replaces the buffer +// (a new render resolution); the ring it recycles is tiny (two buffers per +// eye), so this never grows beyond a handful of entries. struct Import { AHardwareBuffer* buffer = nullptr; wgpu::SharedTextureMemory memory; @@ -254,6 +255,30 @@ public: return true; } + bool ForgetBuffers(AHardwareBuffer* const* buffers, uint32_t count) noexcept { + std::lock_guard lock(m_mutex); + if (m_framePending) { + return false; + } + for (uint32_t i = 0; i < count; ++i) { + const auto found = m_imports.find(buffers[i]); + if (found == m_imports.end()) { + continue; + } + auto& import = found->second; + if (import.accessBegun) { + // Only an abandoned frame leaves one open (see PrepareForDestruction). + wgpu::SharedTextureMemoryEndAccessState end{}; + import.memory.EndAccess(import.texture, &end); + } + import.texture = nullptr; + import.memory = nullptr; + AHardwareBuffer_release(import.buffer); + m_imports.erase(found); + } + return true; + } + private: // An eye may be smaller than its buffer (the immersive window's eyes are the window only): it is // copied into the buffer's top-left corner, and the OpenXR side shows just that rectangle. @@ -629,6 +654,14 @@ bool aurora_vulkan_cancel_stereo_targets(uint64_t frameToken) { return g_bridge && g_bridge->CancelBeforeEncode(frameToken); } +bool aurora_vulkan_forget_stereo_buffers(AHardwareBuffer* const* buffers, uint32_t count) { + using namespace aurora::vulkan_interop; + if (!g_bridge) { + return true; + } + return buffers != nullptr && g_bridge->ForgetBuffers(buffers, count); +} + bool aurora_vulkan_disable_stereo_bridge() { using namespace aurora::vulkan_interop; if (!g_bridge) { @@ -668,6 +701,8 @@ bool aurora_vulkan_set_stereo_targets_with_panel(uint64_t, const AuroraVulkanSte bool aurora_vulkan_cancel_stereo_targets(uint64_t) { return false; } +bool aurora_vulkan_forget_stereo_buffers(struct AHardwareBuffer* const*, uint32_t) { return true; } + bool aurora_vulkan_disable_stereo_bridge() { return true; } #endif diff --git a/aurora-main/lib/webgpu/vulkan_win32_interop.cpp b/aurora-main/lib/webgpu/vulkan_win32_interop.cpp index b420199..45dba21 100644 --- a/aurora-main/lib/webgpu/vulkan_win32_interop.cpp +++ b/aurora-main/lib/webgpu/vulkan_win32_interop.cpp @@ -92,6 +92,22 @@ public: if (!guard.owns_lock() || encoded || !token || token != wanted) return false; token = 0; return true; } + // Before the runtime destroys these images: the drain retires every copy into + // them, and their wraps go because a new swapchain may get the same handles. + bool Forget(void* const* images, uint32_t n) { + { + std::lock_guard guard(mutex); + if (token) return false; + } + if (!api.drain(webgpu::g_device.Get())) return false; + std::lock_guard guard(mutex); + std::erase_if(imports, [&](const Import& entry) { + return std::find(images, images + n, reinterpret_cast(static_cast(entry.image))) != + images + n; + }); + active = {}; + return true; + } bool Encode(wgpu::CommandEncoder& encoder, const stereo::SinkFrame& frame) { std::lock_guard guard(mutex); if (!token || encoded || frame.frameToken != token) return false; @@ -187,6 +203,11 @@ bool aurora_vulkan_win32_cancel(uint64_t token) { using namespace aurora::vulkan_win32; return bridge && bridge->Cancel(token); } +bool aurora_vulkan_win32_forget_targets(void* const* images, uint32_t count) { + using namespace aurora::vulkan_win32; + if (!bridge) return true; + return images && bridge->Forget(images, count); +} bool aurora_vulkan_win32_disable() { using namespace aurora::vulkan_win32; if (!bridge) return true; @@ -214,6 +235,7 @@ bool aurora_vulkan_win32_set_targets(uint64_t, const AuroraD3D12StereoTarget*, u bool aurora_vulkan_win32_set_targets_with_panel(uint64_t, const AuroraD3D12StereoTarget*, uint32_t, const AuroraD3D12StereoTarget*) { return false; } bool aurora_vulkan_win32_cancel(uint64_t) { return false; } +bool aurora_vulkan_win32_forget_targets(void* const*, uint32_t) { return true; } bool aurora_vulkan_win32_disable() { return true; } void* aurora_vulkan_win32_lock_queue() { return nullptr; } void aurora_vulkan_win32_unlock_queue(void*) {} diff --git a/docs/quest-port.md b/docs/quest-port.md index 354ba60..0d8ab54 100644 --- a/docs/quest-port.md +++ b/docs/quest-port.md @@ -294,8 +294,9 @@ seat, hand steering, tracked hands, lean back, render scale, VR interpolation, virtual screen size and distance), Graphics (resolution, widescreen, bloom, shader stutter), Controls (controller mode, vibration, the Wii Remote mapping), Audio, and About (paths, OpenXR -logging). The launch-time geometry (`render_scale`, `hud_distance_meters`, -`hud_width_meters`) is only reachable here, not from the in-headset panel. +logging). The launch-time geometry (`hud_distance_meters`, `hud_width_meters`) +is only reachable here, not from the in-headset panel; `render_scale` is also the +panel's live **VR → Render resolution** slider (OPENXR.md). **Patches**, between the two, is the PC launcher's mods page (`PatchesPage`, `ModLibrary`). Import takes one or more picked files, asks for a name and makes @@ -1113,7 +1114,8 @@ or `EndAccess` errors); a black mirror too points at Aurora itself. profiled on the XR2. The first run compiles every bundled pipeline recipe (about half a minute); later runs load Dawn's pipeline cache from `Cache/` next to `DATA`. `render_scale` defaults to 0.8 here (1.0 on - PC); lower it further if the compositor reports missed frames. + PC); lower it further if the compositor reports missed frames. It can be + changed during a race from the headset panel (VR → Render resolution). Foveated rendering (above) is off by default: at `render_scale` 0.8 it saves nothing measurable, above that 8 to 22% of the eyes' GPU time. - **Lifecycle.** Backgrounding (the Quest menu, guardian) pauses the session diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index 6940099..816982a 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -195,6 +195,10 @@ inline constexpr float kVrRenderScaleDefault = 0.8f; inline constexpr float kVrRenderScaleDefault = 1.0f; #define MKW_VR_RENDER_SCALE_DEFAULT_TEXT "1.0" #endif +// The range the file accepts and the F10 bar's slider offers; the Quest +// launcher's settings page (SettingsPage.kt) repeats it. +inline constexpr float kVrRenderScaleMin = 0.25f; +inline constexpr float kVrRenderScaleMax = 2.0f; // First-person camera defaults and the range its head offsets accept, in one // place: the config getters, the on-disk template and the F10 bar's reset all @@ -773,7 +777,7 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { config.vrEnabled = FindConfigValue(document, "vr", "enabled"); config.vrRequired = FindConfigValue(document, "vr", "required"); if (auto value = FindConfigFloat(document, "vr", "render_scale"); - value && *value >= 0.25f && *value <= 2.0f) { + value && *value >= kVrRenderScaleMin && *value <= kVrRenderScaleMax) { config.vrRenderScale = *value; } if (auto value = FindConfigFloat(document, "vr", "world_units_per_meter"); @@ -1180,6 +1184,14 @@ inline bool SetVrRecenterKey(std::string value) { return WriteSetting("vr", "recenter_key", FormatString(value)); } +inline bool SetVrRenderScale(float value) { + value = std::clamp(value, kVrRenderScaleMin, kVrRenderScaleMax); + Mutable().vrRenderScale = value; + std::ostringstream formatted; + formatted << value; + return WriteSetting("vr", "render_scale", formatted.str()); +} + inline bool SetVrLeanBackDegrees(float value) { value = std::clamp(value, -kVrLeanBackDegreesLimit, kVrLeanBackDegreesLimit); Mutable().vrLeanBackDegrees = value; @@ -1578,7 +1590,7 @@ inline bool VrRequired(bool fallback = false) { } inline float VrRenderScale(float fallback = kVrRenderScaleDefault) { - return std::clamp(Get().vrRenderScale.value_or(fallback), 0.25f, 2.0f); + return std::clamp(Get().vrRenderScale.value_or(fallback), kVrRenderScaleMin, kVrRenderScaleMax); } inline float VrWorldUnitsPerMeter(float fallback = 500.0f) { diff --git a/runtime/include/vr/openxr_d3d12.h b/runtime/include/vr/openxr_d3d12.h index 57703a2..7add886 100644 --- a/runtime/include/vr/openxr_d3d12.h +++ b/runtime/include/vr/openxr_d3d12.h @@ -54,6 +54,14 @@ public: bool QueryGraphicsRequirements(OpenXRRuntime& runtime); bool BindAurora(OpenXRRuntime& runtime); + // The eyes' resolution, as a scale of the size the runtime recommends (the + // session starts at OpenXRConfig::resolution_scale). A new one applies from + // the next BeginFrame or PreparePacket: each swapchain pair is rebuilt at it + // the next time it is the pair Aurora writes, so the pair on display is + // never touched. A size the runtime cannot allocate keeps the one before, + // until the scale changes again. + void SetRenderScale(float scale); + OpenXRD3D12BeginStatus BeginFrame(const OpenXRD3D12Presentation& presentation, OpenXRD3D12Frame& frame); diff --git a/runtime/include/vr/openxr_integration.h b/runtime/include/vr/openxr_integration.h index 72007fe..64e963d 100644 --- a/runtime/include/vr/openxr_integration.h +++ b/runtime/include/vr/openxr_integration.h @@ -4,6 +4,7 @@ #include +#include #include namespace mkw::vr { @@ -74,6 +75,24 @@ void OpenXRSetPassthrough(bool enabled) noexcept; // altogether, makes it moot. void OpenXRSetImmersiveWindow(bool enabled) noexcept; +// The headset's eye resolution, as a scale of the size the OpenXR runtime +// recommends (clamped to RuntimeConfigFile::kVrRenderScaleMin..Max). Callable +// from any thread. The pacing thread rebuilds the eye swapchains at it over the +// next two frames, never touching the one on display; a size the runtime +// cannot allocate keeps the eyes as they are. +void OpenXRSetRenderScale(float scale) noexcept; + +// The left eye's image size: width x height is what the headset is shown now, +// scaled_width x scaled_height what `scale` gives on this headset. All zero +// while no OpenXR session runs. +struct OpenXREyeResolution { + uint32_t width = 0; + uint32_t height = 0; + uint32_t scaled_width = 0; + uint32_t scaled_height = 0; +}; +OpenXREyeResolution OpenXRGetEyeResolution(float scale) noexcept; + // Live scene interpolation at the headset's own display deadlines. // 0 = Off, 1 = Auto, otherwise 72/90/120 as a rendering-rate ceiling. void OpenXRSetFrameInterpolationFps(uint32_t target) noexcept; diff --git a/runtime/include/vr/openxr_runtime.h b/runtime/include/vr/openxr_runtime.h index 60e8563..aae2f75 100644 --- a/runtime/include/vr/openxr_runtime.h +++ b/runtime/include/vr/openxr_runtime.h @@ -4,7 +4,9 @@ #include "vr/openxr_config.h" +#include #include +#include #include #include #include @@ -41,6 +43,28 @@ struct OpenXRViewConfiguration { uint32_t render_height = 0; }; +struct OpenXREyeSize { + uint32_t width = 0; + uint32_t height = 0; + + bool operator==(const OpenXREyeSize& other) const noexcept { + return width == other.width && height == other.height; + } + bool operator!=(const OpenXREyeSize& other) const noexcept { return !(*this == other); } +}; + +// A view's image at `scale` of the size the runtime recommends, never below one +// pixel or above the runtime's maximum. The size the session starts at, and the +// one a backend rebuilds its eyes at when the render scale changes live. +inline OpenXREyeSize OpenXRScaledEyeSize(const XrViewConfigurationView& view, float scale) noexcept { + const auto scaled = [scale](uint32_t recommended, uint32_t maximum) { + const double value = std::round(static_cast(recommended) * static_cast(scale)); + return static_cast(std::clamp(value, 1.0, static_cast(std::max(maximum, 1u)))); + }; + return {scaled(view.recommendedImageRectWidth, view.maxImageRectWidth), + scaled(view.recommendedImageRectHeight, view.maxImageRectHeight)}; +} + struct OpenXRRuntimeInfo { std::string runtime_name; XrVersion runtime_version = 0; diff --git a/runtime/include/vr/openxr_vulkan.h b/runtime/include/vr/openxr_vulkan.h index 6fdfe8a..7b2e46e 100644 --- a/runtime/include/vr/openxr_vulkan.h +++ b/runtime/include/vr/openxr_vulkan.h @@ -51,6 +51,10 @@ public: bool QueryGraphicsRequirements(OpenXRRuntime& runtime); bool BindAurora(OpenXRRuntime& runtime); + // As OpenXRD3D12Backend::SetRenderScale. The shared eye buffers are + // reallocated along with the first pair rebuilt at the new size. + void SetRenderScale(float scale); + OpenXRBeginStatus BeginFrame(const OpenXRPresentation& presentation, OpenXRBackendFrame& frame); OpenXRSubmissionStatus WaitForSubmission(const OpenXRBackendFrame& frame, uint32_t timeout_ms = UINT32_MAX); diff --git a/runtime/include/vr/openxr_vulkan_win32.h b/runtime/include/vr/openxr_vulkan_win32.h index 95a7ed5..638290e 100644 --- a/runtime/include/vr/openxr_vulkan_win32.h +++ b/runtime/include/vr/openxr_vulkan_win32.h @@ -51,6 +51,9 @@ public: bool QueryGraphicsRequirements(OpenXRRuntime& runtime); bool BindAurora(OpenXRRuntime& runtime); + // As OpenXRD3D12Backend::SetRenderScale. + void SetRenderScale(float scale); + OpenXRWindowsVulkanBeginStatus BeginFrame(const OpenXRWindowsVulkanPresentation& presentation, OpenXRWindowsVulkanFrame& frame); diff --git a/runtime/include/vr/openxr_windows.h b/runtime/include/vr/openxr_windows.h index 475e6d3..20a8797 100644 --- a/runtime/include/vr/openxr_windows.h +++ b/runtime/include/vr/openxr_windows.h @@ -21,6 +21,9 @@ public: bool BindAurora(OpenXRRuntime& runtime) { return std::visit([&](auto& backend) -> bool { return backend->BindAurora(runtime); }, backend_); } + void SetRenderScale(float scale) { + std::visit([&](auto& backend) { backend->SetRenderScale(scale); }, backend_); + } OpenXRBeginStatus BeginFrame(const OpenXRPresentation& presentation, OpenXRBackendFrame& frame) { return std::visit([&](auto& backend) -> OpenXRBeginStatus { return backend->BeginFrame(presentation, frame); }, backend_); } diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 3e26c24..02dba35 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -238,6 +238,8 @@ SDL_Scancode g_vrRecenterScancode = [] { }(); bool g_vrRecenterRebinding = false; float g_vrLeanBackDegrees = RuntimeConfigFile::VrLeanBackDegrees(); +// [vr] render_scale as the slider shows it, in percent of the headset's recommended size. +int g_vrRenderScalePercent = static_cast(std::lround(RuntimeConfigFile::VrRenderScale() * 100.0f)); uint32_t g_disabledPostProcessingPaths = RuntimeConfigFile::DisabledPostProcessingPaths(); std::array g_configuredControllerIndices = [] { std::array indices{}; @@ -1419,6 +1421,35 @@ void DrawVrSettings() { } } + // Live, and usable without a session like the mirror above. It applies when the slider is let + // go rather than at every step of a drag, since each new size means new eye swapchains. + ImGui::SliderInt("Render resolution", &g_vrRenderScalePercent, + static_cast(RuntimeConfigFile::kVrRenderScaleMin * 100.0f), + static_cast(RuntimeConfigFile::kVrRenderScaleMax * 100.0f), "%d%%", + ImGuiSliderFlags_AlwaysClamp); + if (ImGui::IsItemDeactivatedAfterEdit()) { + const float scale = static_cast(g_vrRenderScalePercent) / 100.0f; + mkw::vr::OpenXRSetRenderScale(scale); + RuntimeConfigFile::SetVrRenderScale(scale); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip( + "How many pixels each eye is rendered with, as a share of what your headset's OpenXR " + "runtime recommends. Lower is faster, higher is sharper, and the GPU's work grows with the " + "pixel count: 150%% draws about 2.25 times as many as 100%%.\n" + "Applies when you let go of the slider, without restarting; the headset keeps showing " + "the game while the eyes change size."); + } + if (const auto eyes = mkw::vr::OpenXRGetEyeResolution(static_cast(g_vrRenderScalePercent) / 100.0f); + eyes.width != 0) { + if (eyes.width == eyes.scaled_width && eyes.height == eyes.scaled_height) { + ImGui::TextDisabled("Each eye: %u x %u", eyes.width, eyes.height); + } else { + ImGui::TextDisabled("Each eye: %u x %u now, %u x %u at %d%%", eyes.width, eyes.height, eyes.scaled_width, + eyes.scaled_height, g_vrRenderScalePercent); + } + } + if (ImGui::Combo("VR frame interpolation (experimental)", &g_vrFrameInterpolationMode, kVrInterpolationLabels.data(), static_cast(kVrInterpolationLabels.size()))) { const auto target = kVrInterpolationFps[static_cast(g_vrFrameInterpolationMode)]; diff --git a/runtime/src/vr/openxr_d3d12.cpp b/runtime/src/vr/openxr_d3d12.cpp index 06e4ff4..6cc2ed7 100644 --- a/runtime/src/vr/openxr_d3d12.cpp +++ b/runtime/src/vr/openxr_d3d12.cpp @@ -195,6 +195,11 @@ public: } owns_session_ = true; aurora_format_ = static_cast(handles.colorDxgiFormat); + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + const auto& view = runtime.ViewConfiguration()[eye]; + eye_size_[eye] = {view.render_width, view.render_height}; + } + requested_eye_size_ = eye_size_; if (!SelectSwapchainFormat() || !CreateSwapchains()) { DestroySwapchains(); @@ -226,6 +231,21 @@ public: return true; } + void SetRenderScale(float scale) { + if (runtime_ == nullptr) { + return; + } + std::array requested{}; + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + requested[eye] = OpenXRScaledEyeSize(runtime_->ViewConfiguration()[eye].properties, scale); + } + // Only a change: a refused size stays refused while it is still the one asked for. + if (requested != requested_eye_size_) { + requested_eye_size_ = requested; + eye_size_ = requested; + } + } + OpenXRD3D12BeginStatus BeginFrame(const OpenXRD3D12Presentation& presentation, OpenXRD3D12Frame& frame) { frame = {}; @@ -238,6 +258,9 @@ public: Fail("BeginFrame called while another OpenXR frame is active"); return OpenXRD3D12BeginStatus::Error; } + if (!ResizeWritablePair()) { + return OpenXRD3D12BeginStatus::Error; + } const OpenXRFrameStatus status = runtime_->WaitFrame(frame.xr_frame); if (status != OpenXRFrameStatus::Ready) { @@ -372,6 +395,7 @@ public: const auto status = KeepAliveCycle(); if (status != OpenXRBeginStatus::Ready) return status; } + if (!ResizeWritablePair()) return OpenXRBeginStatus::Error; packet.xr_frame.serial = next_packet_serial_++; packet.xr_frame.predicted_display_time = last_display_time_ + 2 * last_display_period_; packet.xr_frame.predicted_display_period = last_display_period_; @@ -814,8 +838,7 @@ private: bool CreateSwapchainPair(std::array& pair) { for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { - const auto& view = runtime_->ViewConfiguration()[eye]; - if (!CreateSwapchain(pair[eye], view.render_width, view.render_height, + if (!CreateSwapchain(pair[eye], eye_size_[eye].width, eye_size_[eye].height, eye == 0 ? "left eye" : "right eye")) { return false; } @@ -823,6 +846,53 @@ private: return true; } + // Rebuilds the pair Aurora writes next at eye_size_ when it is another size. Only that pair, + // with none of its images acquired, never the one on display. The new swapchains are made + // before the old ones go, so a size the runtime cannot allocate leaves the pair as it was. + // False only on a failure that ends the session. + bool ResizeWritablePair() { + std::array current{}; + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + if (eye_swapchains_[eye].acquired) { + return true; + } + current[eye] = {eye_swapchains_[eye].width, eye_swapchains_[eye].height}; + } + if (current == eye_size_) { + return true; + } + std::array replacement{}; + if (!CreateSwapchainPair(replacement)) { + DestroySwapchainPair(replacement); + std::ostringstream message; + message << "OpenXR D3D12 eyes stay " << current[0].width << 'x' << current[0].height + << ": the runtime could not make " << eye_size_[0].width << 'x' << eye_size_[0].height + << " swapchains (" << last_error_ << ')'; + Log(OpenXRLogLevel::Warning, message.str()); + ClearError(); + // Back to this pair's size, which also returns the other pair to it if it was rebuilt. + eye_size_ = current; + return true; + } + std::vector retired; + for (const EyeSwapchain& swapchain : eye_swapchains_) { + for (const auto& image : swapchain.images) { + retired.push_back(image.texture); + } + } + if (!aurora_d3d12_forget_stereo_targets(retired.data(), static_cast(retired.size()))) { + DestroySwapchainPair(replacement); + return Fail("Aurora could not retire its copies into the old D3D12 eye swapchains"); + } + DestroySwapchainPair(eye_swapchains_); + eye_swapchains_ = std::move(replacement); + std::ostringstream message; + message << "OpenXR D3D12 eyes resized: " << eye_size_[0].width << 'x' << eye_size_[0].height << " / " + << eye_size_[1].width << 'x' << eye_size_[1].height; + Log(OpenXRLogLevel::Info, message.str()); + return true; + } + bool CreateSwapchain(EyeSwapchain& swapchain, uint32_t width, uint32_t height, const char* what) { swapchain.width = width; swapchain.height = height; @@ -1063,6 +1133,10 @@ private: OpenXRD3D12GraphicsRequirements requirements_{}; std::array eye_swapchains_{}; std::array retained_swapchains_{}; + // The size a rebuilt eye pair gets, and the one the render scale last asked + // for, which differ while a size the runtime refused is being kept. + std::array eye_size_{}; + std::array requested_eye_size_{}; // The settings panel's layer: written like the eyes into panel_swapchain_, // shown from retained_panel_swapchain_ (see FinishFrame). EyeSwapchain panel_swapchain_{}; @@ -1119,6 +1193,8 @@ bool OpenXRD3D12Backend::BindAurora(OpenXRRuntime& runtime) { return m_impl->BindAurora(runtime); } +void OpenXRD3D12Backend::SetRenderScale(float scale) { m_impl->SetRenderScale(scale); } + OpenXRD3D12BeginStatus OpenXRD3D12Backend::BeginFrame( const OpenXRD3D12Presentation& presentation, OpenXRD3D12Frame& frame) { return m_impl->BeginFrame(presentation, frame); diff --git a/runtime/src/vr/openxr_integration.cpp b/runtime/src/vr/openxr_integration.cpp index 431f08d..b4a3514 100644 --- a/runtime/src/vr/openxr_integration.cpp +++ b/runtime/src/vr/openxr_integration.cpp @@ -474,6 +474,13 @@ public: ResetPreparedObjects(); return false; } + { + const OpenXRViewConfiguration& left = runtime_->ViewConfiguration()[0]; + std::lock_guard lock(eye_view_mutex_); + eye_view_ = left.properties; + eye_width_.store(left.render_width, std::memory_order_relaxed); + eye_height_.store(left.render_height, std::memory_order_relaxed); + } input_ = std::make_unique(logger_); if (!input_->Create(*runtime_)) { RT_LOG(RT_TAG_RUNTIME) << "OpenXR controller input unavailable: " << input_->LastError() @@ -563,6 +570,12 @@ public: rendered_fps_.store(0, std::memory_order_relaxed); interpolation_available_.store(false, std::memory_order_release); hand_tracking_available_.store(false, std::memory_order_release); + { + std::lock_guard lock(eye_view_mutex_); + eye_view_ = {XR_TYPE_VIEW_CONFIGURATION_VIEW}; + eye_width_.store(0, std::memory_order_relaxed); + eye_height_.store(0, std::memory_order_relaxed); + } ResetTrackingOrigin(); applied_session_run_serial_ = 0; session_was_active_ = false; @@ -605,6 +618,23 @@ public: std::memory_order_relaxed); } + void SetRenderScale(float scale) noexcept { + render_scale_.store(ClampRenderScale(scale), std::memory_order_relaxed); + } + + OpenXREyeResolution EyeResolution(float scale) const noexcept { + OpenXREyeResolution resolution{}; + std::lock_guard lock(eye_view_mutex_); + resolution.width = eye_width_.load(std::memory_order_relaxed); + resolution.height = eye_height_.load(std::memory_order_relaxed); + if (resolution.width != 0) { + const OpenXREyeSize scaled = OpenXRScaledEyeSize(eye_view_, ClampRenderScale(scale)); + resolution.scaled_width = scaled.width; + resolution.scaled_height = scaled.height; + } + return resolution; + } + void ServiceProducerFrameBoundary() noexcept { if (teardown_requested_.load(std::memory_order_acquire)) { Shutdown(); @@ -631,6 +661,17 @@ private: // at the headset's refresh rate. static constexpr uint32_t kMaxConsecutiveSkips = 300; + static float ClampRenderScale(float scale) noexcept { + return std::clamp(scale, RuntimeConfigFile::kVrRenderScaleMin, RuntimeConfigFile::kVrRenderScaleMax); + } + + // The size the eyes are rendered at now, for the settings: the pair being written, before the + // immersive window may aim its eyes through the window. + void NoteEyeSize(const OpenXRBackendFrame& frame) noexcept { + eye_width_.store(frame.render_width[0], std::memory_order_relaxed); + eye_height_.store(frame.render_height[0], std::memory_order_relaxed); + } + bool BackendMatchesConfiguredGraphicsApi(const AuroraConfig& aurora_config) { #if defined(_WIN32) kRequiredAuroraBackend = aurora_config.desiredBackend == BACKEND_VULKAN ? BACKEND_VULKAN : BACKEND_D3D12; @@ -927,6 +968,8 @@ private: RT_LOG(RT_TAG_RUNTIME) << "OpenXR " << kGraphicsBackendName << " pacing: " << (render_first ? "render-first" : "frame-first (VR interpolation)") << std::endl; } + // A new scale rebuilds the eyes as the backend next prepares them. + backend_->SetRenderScale(render_scale_.load(std::memory_order_relaxed)); if (render_first) { if (!RenderFirstCycle(presentation, policy, immersive, consecutive_skips, immersive_submission_logged)) { @@ -949,6 +992,7 @@ private: fatal = true; break; } + NoteEyeSize(frame); UpdateFrameTiming(frame.xr_frame); if (diagnostics::Enabled()) { @@ -1140,6 +1184,7 @@ private: SetError(backend_->LastError()); return false; } + NoteEyeSize(packet); // The head pose this packet was located with places the screens and aims the pointer. ServiceRecenterRequest(); UpdateVirtualScreenPose(packet); @@ -1917,6 +1962,13 @@ private: std::atomic_bool teardown_requested_{false}; std::atomic_bool recenter_requested_{false}; std::atomic lean_back_degrees_{RuntimeConfigFile::VrLeanBackDegrees()}; + std::atomic render_scale_{RuntimeConfigFile::VrRenderScale()}; + // The left eye for OpenXRGetEyeResolution: the runtime's description of it, set while a + // session runs, and the size it is rendered at now (0 without a session). + mutable std::mutex eye_view_mutex_; + XrViewConfigurationView eye_view_{XR_TYPE_VIEW_CONFIGURATION_VIEW}; + std::atomic_uint32_t eye_width_{0}; + std::atomic_uint32_t eye_height_{0}; std::atomic_bool passthrough_{RuntimeConfigFile::VrPassthrough()}; std::atomic_bool immersive_window_{RuntimeConfigFile::VrImmersiveWindow()}; std::atomic_uint32_t frame_interpolation_fps_{RuntimeConfigFile::VrFrameInterpolationFps()}; @@ -2049,6 +2101,23 @@ void OpenXRSetImmersiveWindow(bool enabled) noexcept { #endif } +void OpenXRSetRenderScale(float scale) noexcept { +#if MKW_OPENXR_GRAPHICS_BACKEND + OpenXRIntegration::Get().SetRenderScale(scale); +#else + (void)scale; +#endif +} + +OpenXREyeResolution OpenXRGetEyeResolution(float scale) noexcept { +#if MKW_OPENXR_GRAPHICS_BACKEND + return OpenXRIntegration::Get().EyeResolution(scale); +#else + (void)scale; + return {}; +#endif +} + void OpenXRSetFrameInterpolationFps(uint32_t target) noexcept { #if MKW_OPENXR_GRAPHICS_BACKEND OpenXRIntegration::Get().SetFrameInterpolationFps(target); diff --git a/runtime/src/vr/openxr_runtime.cpp b/runtime/src/vr/openxr_runtime.cpp index 80c5dba..384e40f 100644 --- a/runtime/src/vr/openxr_runtime.cpp +++ b/runtime/src/vr/openxr_runtime.cpp @@ -48,14 +48,6 @@ bool IsFinitePositive(float value) noexcept { (bits & 0x7f800000u) != 0x7f800000u; } -uint32_t ScaledDimension(uint32_t recommended, uint32_t maximum, float scale) { - const double scaled = std::round(static_cast(recommended) * - static_cast(scale)); - const double clamped = std::clamp( - scaled, 1.0, static_cast(std::max(maximum, 1u))); - return static_cast(clamped); -} - const char* ReferenceSpaceName(XrReferenceSpaceType type) { switch (type) { case XR_REFERENCE_SPACE_TYPE_VIEW: @@ -309,12 +301,9 @@ bool OpenXRRuntime::EnumerateViewConfiguration() { for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { OpenXRViewConfiguration& destination = m_view_configuration[eye]; destination.properties = properties[eye]; - destination.render_width = ScaledDimension( - properties[eye].recommendedImageRectWidth, - properties[eye].maxImageRectWidth, m_config.resolution_scale); - destination.render_height = ScaledDimension( - properties[eye].recommendedImageRectHeight, - properties[eye].maxImageRectHeight, m_config.resolution_scale); + const OpenXREyeSize size = OpenXRScaledEyeSize(properties[eye], m_config.resolution_scale); + destination.render_width = size.width; + destination.render_height = size.height; } return true; } diff --git a/runtime/src/vr/openxr_vulkan.cpp b/runtime/src/vr/openxr_vulkan.cpp index 6de16f4..9dbdacc 100644 --- a/runtime/src/vr/openxr_vulkan.cpp +++ b/runtime/src/vr/openxr_vulkan.cpp @@ -181,6 +181,9 @@ public: // The settings panel's layer image follows the eyes. bool panel = false; std::array swapchain_images{}; + // Each image's size. A shared buffer can be larger than the image while + // the render resolution is changing, and a copy moves only what fits. + std::array swapchain_extents{}; uint32_t Count() const noexcept { return target_count + (panel ? 1u : 0u); } }; @@ -291,6 +294,11 @@ public: return Fail("OpenXR rejected the Vulkan device binding"); } owns_session_ = true; + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + const auto& view = runtime.ViewConfiguration()[eye]; + eye_size_[eye] = {view.render_width, view.render_height}; + } + requested_eye_size_ = eye_size_; if (!SelectSwapchainFormat() || !CreateSwapchains() || !AllocateSlots()) { DestroySwapchains(); @@ -329,6 +337,21 @@ public: return true; } + void SetRenderScale(float scale) { + if (runtime_ == nullptr) { + return; + } + std::array requested{}; + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + requested[eye] = OpenXRScaledEyeSize(runtime_->ViewConfiguration()[eye].properties, scale); + } + // Only a change: a refused size stays refused while it is still the one asked for. + if (requested != requested_eye_size_) { + requested_eye_size_ = requested; + eye_size_ = requested; + } + } + OpenXRBeginStatus BeginFrame(const OpenXRPresentation& presentation, OpenXRBackendFrame& frame) { frame = {}; frame.presentation = presentation; @@ -340,6 +363,9 @@ public: Fail("BeginFrame called while another OpenXR frame is active"); return OpenXRBeginStatus::Error; } + if (!ResizeWritablePair()) { + return OpenXRBeginStatus::Error; + } const OpenXRFrameStatus status = runtime_->WaitFrame(frame.xr_frame); if (status != OpenXRFrameStatus::Ready) { @@ -423,14 +449,7 @@ public: panel_target = SlotTargetLocked(panel_slots_[slot]); } pending_copy_ = {frame.xr_frame.serial, slot, target_count, panel, {}}; - for (uint32_t eye = 0; eye < target_count; ++eye) { - pending_copy_.swapchain_images[eye] = - eye_swapchains_[eye].images[eye_swapchains_[eye].acquired_index].image; - } - if (panel) { - pending_copy_.swapchain_images[target_count] = - panel_swapchain_.images[panel_swapchain_.acquired_index].image; - } + RecordCopyImagesLocked(target_count, panel); } { std::lock_guard lock(submission_mutex_); @@ -543,6 +562,9 @@ public: return primed; } } + if (!ResizeWritablePair()) { + return OpenXRBeginStatus::Error; + } packet.xr_frame.serial = next_packet_serial_++; // The eyes are ready after at most one game frame plus the encode and show at the first // display slot after that: two periods past the last predicted display time. @@ -774,14 +796,7 @@ public: diagnostics::OnSwapchainAcquire(acquire_timer); { std::lock_guard lock(vk_mutex_); - for (uint32_t eye = 0; eye < target_count; ++eye) { - pending_copy_.swapchain_images[eye] = - eye_swapchains_[eye].images[eye_swapchains_[eye].acquired_index].image; - } - if (panel) { - pending_copy_.swapchain_images[target_count] = - panel_swapchain_.images[panel_swapchain_.acquired_index].image; - } + RecordCopyImagesLocked(target_count, panel); } return OpenXRBeginStatus::Ready; } @@ -1568,6 +1583,20 @@ private: self->submission_cv_.notify_all(); } + // The acquired compositor images the pending copy writes: the eyes', then the panel's. + void RecordCopyImagesLocked(uint32_t target_count, bool panel) noexcept { + const auto record = [this](uint32_t n, const EyeSwapchain& swapchain) { + pending_copy_.swapchain_images[n] = swapchain.images[swapchain.acquired_index].image; + pending_copy_.swapchain_extents[n] = {swapchain.width, swapchain.height}; + }; + for (uint32_t eye = 0; eye < target_count; ++eye) { + record(eye, eye_swapchains_[eye]); + } + if (panel) { + record(target_count, panel_swapchain_); + } + } + // The slot image `n` of a copy reads: an eye's, or after the eyes the panel's. EyeSlot& CopySlotLocked(const PendingCopy& copy, uint32_t n) noexcept { return n < copy.target_count ? slots_[n][copy.slot] : panel_slots_[copy.slot]; @@ -1670,7 +1699,8 @@ private: VkImageCopy region{}; region.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; - region.extent = {slot.width, slot.height, 1}; + region.extent = {std::min(slot.width, copy.swapchain_extents[n].width), + std::min(slot.height, copy.swapchain_extents[n].height), 1}; vkCmdCopyImage(cmd, slot.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, copy.swapchain_images[n], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); @@ -1804,8 +1834,7 @@ private: bool CreateSwapchainPair(std::array& pair) { for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { - const auto& view = runtime_->ViewConfiguration()[eye]; - if (!CreateSwapchain(pair[eye], view.render_width, view.render_height, + if (!CreateSwapchain(pair[eye], eye_size_[eye].width, eye_size_[eye].height, eye == 0 ? "left eye" : "right eye")) { return false; } @@ -1813,6 +1842,121 @@ private: return true; } + // As the D3D12 backend's, with the shared eye buffers, which hold whichever pair is written + // and so are kept as large as both pairs: they grow with the first pair rebuilt larger and + // shrink once the second has followed it down (each copy moves only what fits the image it + // writes). Everything new is allocated before anything old goes, so a size that cannot be had + // leaves the eyes as they were. + bool ResizeWritablePair() { + std::array current{}; + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + if (eye_swapchains_[eye].acquired) { + return true; + } + current[eye] = {eye_swapchains_[eye].width, eye_swapchains_[eye].height}; + } + if (current == eye_size_) { + return true; + } + const auto keep_current = [&](const char* what) { + std::ostringstream message; + message << "OpenXR Vulkan eyes stay " << current[0].width << 'x' << current[0].height + << ": could not make " << eye_size_[0].width << 'x' << eye_size_[0].height << ' ' << what + << " (" << last_error_ << ')'; + Log(OpenXRLogLevel::Warning, message.str()); + ClearError(); + // Back to this pair's size, which also returns the other pair to it if it was rebuilt. + eye_size_ = current; + }; + std::array replacement{}; + if (!CreateSwapchainPair(replacement)) { + DestroySwapchainPair(replacement); + keep_current("swapchains"); + return true; + } + + std::lock_guard lock(vk_mutex_); + std::array needed{}; + std::array new_slots{}; + bool must_grow = false; + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + needed[eye] = {std::max(eye_size_[eye].width, retained_swapchains_[eye].width), + std::max(eye_size_[eye].height, retained_swapchains_[eye].height)}; + const OpenXREyeSize held{slots_[eye][0].width, slots_[eye][0].height}; + new_slots[eye] = needed[eye] != held; + must_grow |= needed[eye].width > held.width || needed[eye].height > held.height; + } + decltype(slots_) slots{}; + const auto discard_new_slots = [&] { + for (auto& eye : slots) { + for (EyeSlot& slot : eye) { + DestroySlotLocked(slot); + } + } + new_slots = {}; + }; + bool allocated = true; + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + if (new_slots[eye]) { + for (EyeSlot& slot : slots[eye]) { + allocated = allocated && AllocateSlot(slot, needed[eye].width, needed[eye].height); + } + } + } + if (!allocated) { + discard_new_slots(); + if (must_grow) { + DestroySwapchainPair(replacement); + keep_current("shared buffers"); + return true; + } + // Smaller buffers would only give memory back; the larger ones still hold both pairs. + Log(OpenXRLogLevel::Warning, "OpenXR Vulkan keeps its larger shared eye buffers: " + last_error_); + ClearError(); + } + // This device's copies out of the old buffers and into the old images are done after this. + const VkResult idle = vkDeviceWaitIdle(vk_device_); + if (idle != VK_SUCCESS) { + discard_new_slots(); + DestroySwapchainPair(replacement); + return Fail(VkFailure("vkDeviceWaitIdle failed before resizing the eyes", idle)); + } + std::vector retired; + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + if (new_slots[eye]) { + for (EyeSlot& slot : slots_[eye]) { + retired.push_back(slot.buffer); + } + } + } + if (!retired.empty()) { + if (!aurora_vulkan_forget_stereo_buffers(retired.data(), static_cast(retired.size()))) { + discard_new_slots(); + DestroySwapchainPair(replacement); + return Fail("Aurora could not release the old shared eye buffers"); + } + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + if (!new_slots[eye]) { + continue; + } + for (uint32_t slot = 0; slot < kSlotCount; ++slot) { + DestroySlotLocked(slots_[eye][slot]); + slots_[eye][slot] = slots[eye][slot]; + } + } + } + DestroySwapchainPair(eye_swapchains_); + eye_swapchains_ = std::move(replacement); + std::ostringstream message; + message << "OpenXR Vulkan eyes resized: " << eye_size_[0].width << 'x' << eye_size_[0].height << " / " + << eye_size_[1].width << 'x' << eye_size_[1].height; + if (!retired.empty()) { + message << ", shared buffers " << slots_[0][0].width << 'x' << slots_[0][0].height; + } + Log(OpenXRLogLevel::Info, message.str()); + return true; + } + bool CreateSwapchain(EyeSwapchain& swapchain, uint32_t width, uint32_t height, const char* what) { swapchain.width = width; swapchain.height = height; @@ -2030,6 +2174,9 @@ private: OpenXRVulkanGraphicsRequirements requirements_{}; std::array eye_swapchains_{}; std::array retained_swapchains_{}; + // As the D3D12 backend's. + std::array eye_size_{}; + std::array requested_eye_size_{}; // The settings panel's layer: written like the eyes into panel_swapchain_, // shown from retained_panel_swapchain_ (see FinishFrame). EyeSwapchain panel_swapchain_{}; @@ -2110,6 +2257,8 @@ bool OpenXRVulkanBackend::BindAurora(OpenXRRuntime& runtime) { return m_impl->BindAurora(runtime); } +void OpenXRVulkanBackend::SetRenderScale(float scale) { m_impl->SetRenderScale(scale); } + OpenXRBeginStatus OpenXRVulkanBackend::BeginFrame(const OpenXRPresentation& presentation, OpenXRBackendFrame& frame) { return m_impl->BeginFrame(presentation, frame); diff --git a/runtime/src/vr/openxr_vulkan_win32.cpp b/runtime/src/vr/openxr_vulkan_win32.cpp index 9f50d50..f0af3ef 100644 --- a/runtime/src/vr/openxr_vulkan_win32.cpp +++ b/runtime/src/vr/openxr_vulkan_win32.cpp @@ -216,6 +216,11 @@ public: if (!runtime.CreateSession(&binding)) return Fail("OpenXR rejected Dawn's Vulkan device binding"); owns_session_ = true; aurora_format_ = static_cast(format); + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + const auto& view = runtime.ViewConfiguration()[eye]; + eye_size_[eye] = {view.render_width, view.render_height}; + } + requested_eye_size_ = eye_size_; const auto abandon_session = [&] { DestroySwapchains(); runtime.DestroySession(); @@ -248,6 +253,21 @@ public: return true; } + void SetRenderScale(float scale) { + if (runtime_ == nullptr) { + return; + } + std::array requested{}; + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + requested[eye] = OpenXRScaledEyeSize(runtime_->ViewConfiguration()[eye].properties, scale); + } + // Only a change: a refused size stays refused while it is still the one asked for. + if (requested != requested_eye_size_) { + requested_eye_size_ = requested; + eye_size_ = requested; + } + } + OpenXRWindowsVulkanBeginStatus BeginFrame(const OpenXRWindowsVulkanPresentation& presentation, OpenXRWindowsVulkanFrame& frame) { frame = {}; @@ -260,6 +280,9 @@ public: Fail("BeginFrame called while another OpenXR frame is active"); return OpenXRWindowsVulkanBeginStatus::Error; } + if (!ResizeWritablePair()) { + return OpenXRWindowsVulkanBeginStatus::Error; + } const OpenXRFrameStatus status = runtime_->WaitFrame(frame.xr_frame); if (status != OpenXRFrameStatus::Ready) { @@ -394,6 +417,7 @@ public: const auto status = KeepAliveCycle(); if (status != OpenXRBeginStatus::Ready) return status; } + if (!ResizeWritablePair()) return OpenXRBeginStatus::Error; packet.xr_frame.serial = next_packet_serial_++; packet.xr_frame.predicted_display_time = last_display_time_ + 2 * last_display_period_; packet.xr_frame.predicted_display_period = last_display_period_; @@ -841,8 +865,7 @@ private: bool CreateSwapchainPair(std::array& pair) { for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { - const auto& view = runtime_->ViewConfiguration()[eye]; - if (!CreateSwapchain(pair[eye], view.render_width, view.render_height, + if (!CreateSwapchain(pair[eye], eye_size_[eye].width, eye_size_[eye].height, eye == 0 ? "left eye" : "right eye")) { return false; } @@ -850,6 +873,51 @@ private: return true; } + // As the D3D12 backend's. Aurora's bridge wraps each swapchain VkImage for Dawn, and the + // runtime may hand the old handles out again, so the wraps go with the swapchains. + bool ResizeWritablePair() { + std::array current{}; + for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) { + if (eye_swapchains_[eye].acquired) { + return true; + } + current[eye] = {eye_swapchains_[eye].width, eye_swapchains_[eye].height}; + } + if (current == eye_size_) { + return true; + } + std::array replacement{}; + if (!CreateSwapchainPair(replacement)) { + DestroySwapchainPair(replacement); + std::ostringstream message; + message << "OpenXR Vulkan eyes stay " << current[0].width << 'x' << current[0].height + << ": the runtime could not make " << eye_size_[0].width << 'x' << eye_size_[0].height + << " swapchains (" << last_error_ << ')'; + Log(OpenXRLogLevel::Warning, message.str()); + ClearError(); + // Back to this pair's size, which also returns the other pair to it if it was rebuilt. + eye_size_ = current; + return true; + } + std::vector retired; + for (const EyeSwapchain& swapchain : eye_swapchains_) { + for (const auto& image : swapchain.images) { + retired.push_back(reinterpret_cast(image.image)); + } + } + if (!aurora_vulkan_win32_forget_targets(retired.data(), static_cast(retired.size()))) { + DestroySwapchainPair(replacement); + return Fail("Aurora could not retire its copies into the old Vulkan eye swapchains"); + } + DestroySwapchainPair(eye_swapchains_); + eye_swapchains_ = std::move(replacement); + std::ostringstream message; + message << "OpenXR Vulkan eyes resized: " << eye_size_[0].width << 'x' << eye_size_[0].height << " / " + << eye_size_[1].width << 'x' << eye_size_[1].height; + Log(OpenXRLogLevel::Info, message.str()); + return true; + } + bool CreateSwapchain(EyeSwapchain& swapchain, uint32_t width, uint32_t height, const char* what) { swapchain.width = width; swapchain.height = height; @@ -1102,6 +1170,9 @@ private: OpenXRWindowsVulkanGraphicsRequirements requirements_{}; std::array eye_swapchains_{}; std::array retained_swapchains_{}; + // As the D3D12 backend's. + std::array eye_size_{}; + std::array requested_eye_size_{}; // The settings panel's layer: written like the eyes into panel_swapchain_, // shown from retained_panel_swapchain_ (see FinishFrame). EyeSwapchain panel_swapchain_{}; @@ -1158,6 +1229,8 @@ bool OpenXRWindowsVulkanBackend::BindAurora(OpenXRRuntime& runtime) { return m_impl->BindAurora(runtime); } +void OpenXRWindowsVulkanBackend::SetRenderScale(float scale) { m_impl->SetRenderScale(scale); } + OpenXRWindowsVulkanBeginStatus OpenXRWindowsVulkanBackend::BeginFrame( const OpenXRWindowsVulkanPresentation& presentation, OpenXRWindowsVulkanFrame& frame) { return m_impl->BeginFrame(presentation, frame); diff --git a/runtime/tests/openxr_d3d12_replay_tests.cpp b/runtime/tests/openxr_d3d12_replay_tests.cpp index b912edd..f69a7a1 100644 --- a/runtime/tests/openxr_d3d12_replay_tests.cpp +++ b/runtime/tests/openxr_d3d12_replay_tests.cpp @@ -17,6 +17,7 @@ #define aurora_d3d12_set_stereo_targets aurora_vulkan_win32_set_targets #define aurora_d3d12_set_stereo_targets_with_panel aurora_vulkan_win32_set_targets_with_panel #define aurora_d3d12_cancel_stereo_targets aurora_vulkan_win32_cancel +#define aurora_d3d12_forget_stereo_targets aurora_vulkan_win32_forget_targets #define aurora_d3d12_disable_stereo_bridge aurora_vulkan_win32_disable #else #define CINTERFACE @@ -32,6 +33,7 @@ #include "vr/openxr_wii_remote.h" +#include #include #include #include @@ -51,6 +53,8 @@ void RequireAt(bool condition, int line, const char* expression) { struct Image { uint64_t content = 0; }; struct Swapchain { Image image; + uint32_t width = 0; + uint32_t height = 0; bool acquired = false; bool waited = false; bool released = false; @@ -74,6 +78,11 @@ uint64_t displayed_content = 0; uint32_t layer_count = 0; uint32_t releases = 0; uint32_t live_swapchains = 0; +uint32_t create_attempts = 0; +int creates_before_failure = -1; // xrCreateSwapchain fails once this reaches 0; -1 never +bool bridge_enabled = false; +std::vector forgotten; // images the backend had Aurora forget +int32_t shown_width = 0; // the scene layer's image width XrTime display_time = 0; XrStructureType layer_type = XR_TYPE_UNKNOWN; XrPosef quad_pose{}; @@ -160,6 +169,7 @@ bool aurora_d3d12_get_native_handles(AuroraD3D12NativeHandles* handles) { bool aurora_d3d12_enable_stereo_bridge(AuroraD3D12StereoSubmittedCallback cb, void* data) { callback = cb; callback_data = data; + bridge_enabled = true; return true; } bool aurora_d3d12_set_stereo_targets_with_panel(uint64_t token, const AuroraD3D12StereoTarget* data, @@ -180,14 +190,26 @@ bool aurora_d3d12_cancel_stereo_targets(uint64_t token) { pending_token = 0; return true; } +bool aurora_d3d12_forget_stereo_targets(void* const* resources, uint32_t count) { + Require(pending_token == 0); // Never while Aurora may be writing a target. + forgotten.insert(forgotten.end(), resources, resources + count); + return true; +} bool aurora_d3d12_disable_stereo_bridge() { pending_token = 0; encoded = false; + bridge_enabled = false; return true; // Simulate a successful queue drain. } -XrResult XRAPI_CALL xrCreateSwapchain(XrSession, const XrSwapchainCreateInfo*, XrSwapchain* out) { - *out = reinterpret_cast(new Swapchain); +XrResult XRAPI_CALL xrCreateSwapchain(XrSession, const XrSwapchainCreateInfo* info, XrSwapchain* out) { + ++create_attempts; + if (creates_before_failure == 0) return XR_ERROR_OUT_OF_MEMORY; + if (creates_before_failure > 0) --creates_before_failure; + auto* chain = new Swapchain; + chain->width = info->width; + chain->height = info->height; + *out = reinterpret_cast(chain); ++live_swapchains; return XR_SUCCESS; } @@ -236,6 +258,10 @@ XrResult XRAPI_CALL xrReleaseSwapchainImage(XrSwapchain handle, XrResult XRAPI_CALL xrDestroySwapchain(XrSwapchain handle) { auto* chain = reinterpret_cast(handle); Require(!chain->acquired); + // An image Aurora has written goes only once Aurora forgot it, or with the whole bridge. + if (bridge_enabled && chain->released) { + Require(std::find(forgotten.begin(), forgotten.end(), static_cast(&chain->image)) != forgotten.end()); + } delete chain; --live_swapchains; return XR_SUCCESS; @@ -250,6 +276,10 @@ OpenXRRuntime::OpenXRRuntime(OpenXRLogCallback) { m_swapchain_formats = {DXGI_FORMAT_R8G8B8A8_UNORM_SRGB}; #endif for (auto& view : m_view_configuration) { + view.properties.recommendedImageRectWidth = 100; + view.properties.recommendedImageRectHeight = 80; + view.properties.maxImageRectWidth = 400; + view.properties.maxImageRectHeight = 320; view.render_width = 100; view.render_height = 80; } @@ -325,11 +355,16 @@ bool OpenXRRuntime::EndFrame(const OpenXRFrame& frame, const auto check_image = [](const XrSwapchainSubImage& subimage) { const auto& chain = *reinterpret_cast(subimage.swapchain); Require(chain.released && !chain.acquired && chain.image.content != 0); + // The layer shows its own image's size, whichever size the other pair has. + Require(subimage.imageRect.offset.x == 0 && subimage.imageRect.offset.y == 0); + Require(subimage.imageRect.extent.width == static_cast(chain.width)); + Require(subimage.imageRect.extent.height == static_cast(chain.height)); return chain.image.content; }; if (layer_type == XR_TYPE_COMPOSITION_LAYER_PROJECTION) { auto& projection = *reinterpret_cast(layers[0]); Require(projection.viewCount == 2); + shown_width = projection.views[0].subImage.imageRect.extent.width; for (const auto& view : {projection.views[0], projection.views[1]}) { displayed_content = check_image(view.subImage); // Detect a new image paired with an old pose, or a repeated image @@ -341,6 +376,7 @@ bool OpenXRRuntime::EndFrame(const OpenXRFrame& frame, Require(layer_type == XR_TYPE_COMPOSITION_LAYER_QUAD); const auto& quad = *reinterpret_cast(layers[0]); displayed_content = check_image(quad.subImage); + shown_width = quad.subImage.imageRect.extent.width; quad_pose = quad.pose; } if (count == 2) { @@ -584,6 +620,111 @@ void TestPanelLayer() { display_time = 0; } +// A new render scale rebuilds each swapchain pair the next time it is the one Aurora writes: the +// pair on display is never touched, Aurora forgets the old images before they go (checked by +// xrDestroySwapchain), and a size the runtime refuses keeps the eyes as they were. +void TestRenderScale() { + display_time = 0; + forgotten.clear(); + OpenXRRuntime runtime; + OpenXRD3D12Backend backend; + Require(backend.QueryGraphicsRequirements(runtime) && backend.BindAurora(runtime)); + OpenXRPresentation presentation; + OpenXRBackendFrame frame; + const auto begin = [&](uint32_t width, uint32_t height) { + Require(backend.BeginFrame(presentation, frame) == OpenXRBeginStatus::Ready); + Require(frame.render_width[0] == width && frame.render_height[1] == height && targets.size() == 2); + for (const auto& target : targets) Require(target.width == width && target.height == height); + Require(live_swapchains == 4); + }; + const auto finish = [&] { + Complete(); + Require(backend.WaitForSubmission(frame, 0) == OpenXRSubmissionStatus::Success); + Require(backend.FinishFrame(frame, true)); + Require(layer_count == 1 && displayed_content == frame.xr_frame.serial); + }; + + backend.SetRenderScale(1.0f); // The session's own scale rebuilds nothing. + begin(100, 80); + finish(); + Require(forgotten.empty() && shown_width == 100); + + backend.SetRenderScale(1.5f); + begin(150, 120); // The pair written next, while the one on display still shows the last frame. + Require(forgotten.size() == 2); + Require(backend.RepeatFrame(frame) && layer_count == 1 && shown_width == 100); + finish(); + Require(shown_width == 150); + begin(150, 120); // Then the other pair, once it is the one written. + Require(forgotten.size() == 4); + finish(); + begin(150, 120); // Both are the new size: nothing more is rebuilt. + finish(); + Require(forgotten.size() == 4); + + backend.SetRenderScale(5.0f); // Clamped to the runtime's maximum. + begin(400, 320); + finish(); + begin(400, 320); + finish(); + backend.SetRenderScale(1.5f); + begin(150, 120); + finish(); + begin(150, 120); + finish(); + const size_t forgotten_before_refusal = forgotten.size(); + + // A size the runtime cannot make keeps the eyes, and is not retried while it is still asked for. + creates_before_failure = 1; // One eye's swapchain is made, then the other's is refused. + const uint32_t attempts = create_attempts; + backend.SetRenderScale(3.0f); + begin(150, 120); + finish(); + backend.SetRenderScale(3.0f); + begin(150, 120); + finish(); + Require(create_attempts == attempts + 2 && forgotten.size() == forgotten_before_refusal); + creates_before_failure = -1; + backend.SetRenderScale(0.5f); // A new size is tried again. + begin(50, 40); + finish(); + begin(50, 40); + finish(); + + // Refused for the second pair only: the first follows it back to the size it kept. + creates_before_failure = 2; + backend.SetRenderScale(2.0f); + begin(200, 160); + finish(); + Require(shown_width == 200); + begin(50, 40); + finish(); + Require(shown_width == 50); + creates_before_failure = -1; + begin(50, 40); + finish(); + const size_t forgotten_settled = forgotten.size(); + begin(50, 40); + finish(); + Require(forgotten.size() == forgotten_settled); + + // Render-first pacing rebuilds the pair its packet will be rendered into. + expect_render_first = true; + backend.SetRenderScale(1.0f); + OpenXRBackendFrame packet; + Require(backend.PreparePacket(presentation, packet) == OpenXRBeginStatus::Ready); + Require(packet.render_width[0] == 100 && targets.size() == 2 && targets[0].width == 100); + Complete(); + Require(backend.WaitForSubmission(packet, 0) == OpenXRSubmissionStatus::Success); + Require(backend.BeginFrameForPacket(packet, frame) == OpenXRBeginStatus::Ready); + Require(backend.CopyRenderedEyes(frame) == OpenXRSubmissionStatus::Success); + Require(backend.FinishFrame(frame, true)); + Require(layer_count == 1 && shown_width == 100); + expect_render_first = false; + Require(backend.Shutdown() && live_swapchains == 0); + display_time = 0; +} + bool SameRect(const XrRect2Di& rect, int32_t x, int32_t y, int32_t width, int32_t height) { return rect.offset.x == x && rect.offset.y == y && rect.extent.width == width && rect.extent.height == height; } @@ -610,6 +751,7 @@ void TestVirtualScreenContentRect() { int main() { TestRenderFirst(); TestPanelLayer(); + TestRenderScale(); TestVirtualScreenContentRect(); OpenXRRuntime runtime; OpenXRD3D12Backend backend;