From 4cf9f60ee730dfeb62d75dfa9d2a831be2280121 Mon Sep 17 00:00:00 2001 From: iChris4 Date: Wed, 9 Sep 2026 00:18:41 +0200 Subject: [PATCH] Added Desktop view selector --- OPENXR.md | 9 ++ aurora-main/include/aurora/gfx.h | 22 +++++ aurora-main/lib/aurora.cpp | 148 ++++++++++++++++++++++++++++--- aurora-main/lib/gfx/common.cpp | 22 +++++ aurora-main/lib/gfx/common.hpp | 5 ++ runtime/include/runtime_config.h | 32 +++++++ runtime/src/settings_overlay.cpp | 38 +++++++- 7 files changed, 260 insertions(+), 16 deletions(-) diff --git a/OPENXR.md b/OPENXR.md index 19bcc50..1c2cae0 100644 --- a/OPENXR.md +++ b/OPENXR.md @@ -22,6 +22,7 @@ configuration and is created with the following defaults: [vr] enabled = false required = false +mirror_view = "normal" render_scale = 1.0 world_units_per_meter = 500.0 hud_distance_meters = 2.0 @@ -47,6 +48,14 @@ required. or graphics-binding failure is logged and the game continues in ordinary desktop mode. Set it to `true` only when a failed VR startup should stop the game with an error. +`mirror_view` chooses what the desktop window shows while the headset is running: `"normal"` +keeps the ordinary desktop view, `"both"`, `"left"` and `"right"` mirror the headset's eyes, and +`"none"` blacks the window out. It is live and can be changed from the F10 settings bar, where it +sits directly under the enable switch as *Desktop view*. Menus reach the headset as a virtual +screen carrying the desktop image itself, so there is no separate eye view to mirror there and the +three eye choices show that same image; only `"none"` differs. The F10 bar is drawn over whichever +image is chosen, so the setting can always be changed back. + `render_scale` scales the per-eye size recommended by the OpenXR runtime. `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 diff --git a/aurora-main/include/aurora/gfx.h b/aurora-main/include/aurora/gfx.h index 070dd2f..ae52179 100644 --- a/aurora-main/include/aurora/gfx.h +++ b/aurora-main/include/aurora/gfx.h @@ -106,6 +106,28 @@ bool aurora_get_stereo_skip_copy_clears(); void aurora_set_stereo_hud_screen(bool enabled, float width, float distance); bool aurora_get_stereo_hud_screen_enabled(); +// What the desktop window shows while a headset is being fed. NORMAL leaves the +// ordinary mono presentation untouched, the eye views mirror what the headset is +// actually displaying, and NONE presents a black window. Live, and only +// consulted while a stereo frame provider is supplying frames. +// +// A menu frame reaches the headset as a virtual screen carrying the very mono +// image the desktop already shows, so there is no distinct eye view to mirror: +// every eye choice presents that same image there, and only NONE differs. +// +// Interpolated presentation slots are encoded before the frame's eyes are +// rendered, so under an eye choice they mirror the previous frame's eyes while +// the real slot mirrors the current one. +typedef enum { + AURORA_STEREO_MIRROR_NORMAL = 0, + AURORA_STEREO_MIRROR_BOTH_EYES = 1, + AURORA_STEREO_MIRROR_LEFT_EYE = 2, + AURORA_STEREO_MIRROR_RIGHT_EYE = 3, + AURORA_STEREO_MIRROR_NONE = 4, +} AuroraStereoMirrorView; +void aurora_set_stereo_mirror_view(AuroraStereoMirrorView view); +AuroraStereoMirrorView aurora_get_stereo_mirror_view(); + // Guest-RAM write tracking. `generation` changes whenever guest RAM covering a host range was // written (or returns AURORA_GUEST_WRITE_UNTRACKED); `notify` reports writes aurora made itself. #define AURORA_GUEST_WRITE_UNTRACKED UINT64_MAX diff --git a/aurora-main/lib/aurora.cpp b/aurora-main/lib/aurora.cpp index 0917245..d5ce72f 100644 --- a/aurora-main/lib/aurora.cpp +++ b/aurora-main/lib/aurora.cpp @@ -537,11 +537,24 @@ struct StereoEyeTarget { uint32_t samples = 0; wgpu::TextureFormat colorFormat = wgpu::TextureFormat::Undefined; wgpu::TextureFormat depthFormat = wgpu::TextureFormat::Undefined; + // Built on demand for the desktop mirror only, and dropped with the rest of + // the target when ensure_stereo_eye_target replaces the textures. + wgpu::BindGroup copyBindGroup; const webgpu::TextureWithSampler& output() const noexcept { return resolvedColor.texture ? resolvedColor : color; } }; std::array g_stereoEyeTargets; +// The eye targets outlive a frame, so the mirror samples them through a bind +// group cached beside them rather than one built per presentation slot. +wgpu::BindGroup stereo_eye_copy_bind_group(uint32_t eyeIndex) { + auto& target = g_stereoEyeTargets[eyeIndex]; + if (!target.copyBindGroup && target.output().texture) { + target.copyBindGroup = webgpu::create_copy_bind_group(target.output()); + } + return target.copyBindGroup; +} + void ensure_stereo_eye_target(uint32_t eyeIndex, uint32_t width, uint32_t height) { auto& target = g_stereoEyeTargets[eyeIndex]; const uint32_t samples = webgpu::g_graphicsConfig.msaaSamples; @@ -1405,10 +1418,68 @@ void stop_presenter() noexcept { g_presenterStarted.store(false, std::memory_order_release); } +// What a presentation slot draws under the image. Mono is the ordinary desktop +// view; the rest mirror the headset and are only ever chosen while a stereo +// provider is feeding one. ImGui is drawn over all of them alike, so the +// settings menu stays reachable even under Black. +enum class MirrorPlan { + Mono, + LeftEye, + RightEye, + BothEyes, + Black, +}; + +// A virtual-screen (menu) frame puts the desktop's own mono image on both eyes, +// so there is no separate eye view to mirror and the eye choices collapse onto +// Mono. Only Black still has something distinct to do there. +MirrorPlan resolve_mirror_plan(AuroraStereoMirrorView view, bool stereoOutput, bool immersiveReplay) noexcept { + if (!stereoOutput) { + return MirrorPlan::Mono; + } + switch (view) { + case AURORA_STEREO_MIRROR_NONE: + return MirrorPlan::Black; + case AURORA_STEREO_MIRROR_BOTH_EYES: + return immersiveReplay ? MirrorPlan::BothEyes : MirrorPlan::Mono; + case AURORA_STEREO_MIRROR_LEFT_EYE: + return immersiveReplay ? MirrorPlan::LeftEye : MirrorPlan::Mono; + case AURORA_STEREO_MIRROR_RIGHT_EYE: + return immersiveReplay ? MirrorPlan::RightEye : MirrorPlan::Mono; + case AURORA_STEREO_MIRROR_NORMAL: + break; + } + return MirrorPlan::Mono; +} + +// Places one eye inside `bounds`, keeping the eye's own aspect ratio rather +// than the game's presented one: an eye is already the shape the headset asked +// for, so letterboxing it to the game's aspect would crop the compositor's view. +void draw_mirror_eye(const wgpu::RenderPassEncoder& pass, uint32_t eyeIndex, const webgpu::Viewport& bounds) { + const auto bindGroup = stereo_eye_copy_bind_group(eyeIndex); + const auto& output = g_stereoEyeTargets[eyeIndex].output(); + if (!bindGroup || output.size.width == 0 || output.size.height == 0 || bounds.width <= 0.f || + bounds.height <= 0.f) { + return; + } + const auto fitted = webgpu::calculate_present_viewport(static_cast(bounds.width), + static_cast(bounds.height), output.size.width, + output.size.height); + // A window too small to hold a half still rounds down to nothing here. + if (fitted.width <= 0.f || fitted.height <= 0.f) { + return; + } + pass.SetBindGroup(0, bindGroup, 0, nullptr); + pass.SetViewport(bounds.left + fitted.left, bounds.top + fitted.top, fitted.width, fitted.height, fitted.znear, + fitted.zfar); + pass.Draw(3); +} + // `presentSource` is latched in the seal prologue: by the time this encodes, the producer's next // gfx::begin_frame() may already have cleared the display-copy override. void encode_presentation_snapshot(const wgpu::CommandEncoder& encoder, const webgpu::PresentSource& presentSource, - const PresentationImage& image, bool includeImGui) { + const PresentationImage& image, bool includeImGui, + MirrorPlan plan = MirrorPlan::Mono) { ZoneScoped; auto viewport = webgpu::calculate_present_viewport(image.texture.size.width, image.texture.size.height, presentSource.size.width, presentSource.size.height); @@ -1432,10 +1503,41 @@ void encode_presentation_snapshot(const wgpu::CommandEncoder& encoder, const web .colorAttachments = attachments.data(), }; const auto pass = encoder.BeginRenderPass(&renderPassDescriptor); - pass.SetPipeline(webgpu::g_CopyPipeline); - pass.SetBindGroup(0, presentBindGroup, 0, nullptr); - pass.SetViewport(viewport.left, viewport.top, viewport.width, viewport.height, viewport.znear, viewport.zfar); - pass.Draw(3); + const auto imageWidth = static_cast(image.texture.size.width); + const auto imageHeight = static_cast(image.texture.size.height); + // Black needs nothing but the clear the attachment already performed. + if (plan != MirrorPlan::Black) { + pass.SetPipeline(webgpu::g_CopyPipeline); + } + switch (plan) { + case MirrorPlan::Mono: + pass.SetBindGroup(0, presentBindGroup, 0, nullptr); + pass.SetViewport(viewport.left, viewport.top, viewport.width, viewport.height, viewport.znear, viewport.zfar); + pass.Draw(3); + break; + case MirrorPlan::LeftEye: + case MirrorPlan::RightEye: + draw_mirror_eye(pass, plan == MirrorPlan::LeftEye ? 0u : 1u, + {.left = 0.f, .top = 0.f, .width = imageWidth, .height = imageHeight, .znear = 0.f, .zfar = 1.f}); + break; + case MirrorPlan::BothEyes: { + // Side by side in the window's two halves, in the order the compositor + // receives them, so the pair reads the way the headset is wearing it. + const float halfWidth = imageWidth * 0.5f; + for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) { + draw_mirror_eye(pass, eye, + {.left = static_cast(eye) * halfWidth, + .top = 0.f, + .width = halfWidth, + .height = imageHeight, + .znear = 0.f, + .zfar = 1.f}); + } + break; + } + case MirrorPlan::Black: + break; + } pass.End(); } if (includeImGui) { @@ -1679,6 +1781,11 @@ std::vector encode_sealed_frame(gfx::SealedFrame& sealedFrame, std::vector presentationJobs; presentationJobs.reserve(presentationJobCount); std::optional pendingStereoSink; + const bool stereoOutput = ctx.stereoReplay.has_value(); + const bool immersiveReplay = stereoOutput && ctx.immersiveStereoPrepared; + // One choice for the whole group: a slot showing the mono view next to slots + // mirroring an eye would strobe between two different images. + const MirrorPlan mirrorPlan = resolve_mirror_plan(gfx::get_stereo_mirror_view(), stereoOutput, immersiveReplay); // Each slot is submitted as soon as it is encoded, so the GPU starts slot 0 while slot 1 is still // recording. Queue order preserves the ordering the single batched buffer gave. @@ -1704,7 +1811,7 @@ std::vector encode_sealed_frame(gfx::SealedFrame& sealedFrame, for (uint32_t interpolatedFrame = 0; interpolatedFrame < ctx.interpolatedFrameCount; ++interpolatedFrame) { gfx::render(sealedFrame, encoder, static_cast(interpolatedFrame), false); auto image = acquire_presentation_image(interpolatedFrame, ctx.snapshotWidth, ctx.snapshotHeight); - encode_presentation_snapshot(encoder, ctx.presentSource, *image, true); + encode_presentation_snapshot(encoder, ctx.presentSource, *image, true, mirrorPlan); presentationJobs.push_back({ .image = std::move(image), .logicalFrame = ctx.logicalFrame, @@ -1718,8 +1825,6 @@ std::vector encode_sealed_frame(gfx::SealedFrame& sealedFrame, // A demanded CPU-visible EFB readback submits a prefix of the frame, so replaying the resumed // stream would mutate an already-rendered EFB. Render once, then duplicate into the slots. - const bool stereoOutput = ctx.stereoReplay.has_value(); - const bool immersiveReplay = stereoOutput && ctx.immersiveStereoPrepared; gfx::render(sealedFrame, encoder, -1, !immersiveReplay); // The copy targets now hold this frame's resolves, so queue their readbacks on the same encoder; // completion is harvested in gfx::after_submit, never waited on here. @@ -1727,7 +1832,7 @@ std::vector encode_sealed_frame(gfx::SealedFrame& sealedFrame, if (!ctx.replayInterpolatedFrames) { for (uint32_t interpolatedFrame = 0; interpolatedFrame < ctx.interpolatedFrameCount; ++interpolatedFrame) { auto image = acquire_presentation_image(interpolatedFrame, ctx.snapshotWidth, ctx.snapshotHeight); - encode_presentation_snapshot(encoder, ctx.presentSource, *image, true); + encode_presentation_snapshot(encoder, ctx.presentSource, *image, true, mirrorPlan); presentationJobs.push_back({ .image = std::move(image), .logicalFrame = ctx.logicalFrame, @@ -1739,18 +1844,28 @@ std::vector encode_sealed_frame(gfx::SealedFrame& sealedFrame, encoder = g_device.CreateCommandEncoder(&encoderDescriptor); } } - auto finalImage = acquire_presentation_image(ctx.interpolatedFrameCount, ctx.snapshotWidth, ctx.snapshotHeight); - encode_presentation_snapshot(encoder, ctx.presentSource, *finalImage, true); - // Keep both eye replays and the sink copy in the final submission. The // duplicate-slot path above may submit and rotate the encoder several // times, so encoding stereo before it would pair the post-submit callback - // with the wrong command buffer. + // with the wrong command buffer. Within this last encoder the eyes come + // first, so a mirroring final slot samples this frame's eyes rather than the + // previous frame's; the interpolated slots above necessarily mirror the + // previous frame, having been encoded before this replay. if (immersiveReplay) { for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) { gfx::render_stereo_eye(sealedFrame, encoder, *ctx.stereoReplay, eye, eye + 1 == AURORA_STEREO_EYE_COUNT); } - } else if (stereoOutput) { + } + + auto finalImage = acquire_presentation_image(ctx.interpolatedFrameCount, ctx.snapshotWidth, ctx.snapshotHeight); + // A virtual-screen frame builds its eyes out of the completed mono snapshot, + // so that snapshot must hold the mono image whatever the desktop ends up + // showing. Black re-clears it below, once the eyes have taken their copy. + const bool virtualScreenNeedsMono = stereoOutput && !immersiveReplay; + encode_presentation_snapshot(encoder, ctx.presentSource, *finalImage, true, + virtualScreenNeedsMono ? MirrorPlan::Mono : mirrorPlan); + + if (virtualScreenNeedsMono) { // Use the completed mono snapshot so virtual-screen XR includes ImGui at // the same scale and aspect as the desktop presentation. Rendering the // same ImGui draw data directly into differently-sized eye textures would @@ -1764,6 +1879,9 @@ std::vector encode_sealed_frame(gfx::SealedFrame& sealedFrame, for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) { encode_virtual_screen_eye(encoder, completedMono, eye); } + if (mirrorPlan == MirrorPlan::Black) { + encode_presentation_snapshot(encoder, ctx.presentSource, *finalImage, true, MirrorPlan::Black); + } } if (stereoOutput) { pendingStereoSink = run_stereo_sink(encoder, ctx.stereoFrameToken, ctx.logicalFrame, ctx.stereoFrameMode); @@ -2325,6 +2443,8 @@ void aurora_set_stereo_hud_screen(bool enabled, float width, float distance) { aurora::gfx::set_stereo_hud_screen(enabled, width, distance); } bool aurora_get_stereo_hud_screen_enabled() { return aurora::gfx::get_stereo_hud_screen_enabled(); } +void aurora_set_stereo_mirror_view(AuroraStereoMirrorView view) { aurora::gfx::set_stereo_mirror_view(view); } +AuroraStereoMirrorView aurora_get_stereo_mirror_view() { return aurora::gfx::get_stereo_mirror_view(); } void aurora_set_background_input(bool value) { aurora::g_config.allowJoystickBackgroundEvents = value; aurora::window::set_background_input(value); diff --git a/aurora-main/lib/gfx/common.cpp b/aurora-main/lib/gfx/common.cpp index d951aa0..21a7e94 100644 --- a/aurora-main/lib/gfx/common.cpp +++ b/aurora-main/lib/gfx/common.cpp @@ -228,6 +228,28 @@ void set_stereo_hud_screen(bool enabled, float width, float distance) noexcept { } bool get_stereo_hud_screen_enabled() noexcept { return g_stereoHudScreenEnabled.load(std::memory_order_relaxed); } +// The desktop mirror choice. Normal is the ordinary mono presentation, so a +// build that never touches this setting presents exactly as it did before. +static std::atomic g_stereoMirrorView{AURORA_STEREO_MIRROR_NORMAL}; + +void set_stereo_mirror_view(AuroraStereoMirrorView value) noexcept { + switch (value) { + case AURORA_STEREO_MIRROR_NORMAL: + case AURORA_STEREO_MIRROR_BOTH_EYES: + case AURORA_STEREO_MIRROR_LEFT_EYE: + case AURORA_STEREO_MIRROR_RIGHT_EYE: + case AURORA_STEREO_MIRROR_NONE: + break; + default: + // An out-of-range value would otherwise black the window out with no way + // back from inside the game. + Log.warn("Ignoring unknown stereo mirror view {}", static_cast(value)); + return; + } + g_stereoMirrorView.store(value, std::memory_order_relaxed); +} +AuroraStereoMirrorView get_stereo_mirror_view() noexcept { return g_stereoMirrorView.load(std::memory_order_relaxed); } + // Recycle command storage: discarding passes used to free their command lists too, so each frame // rebuilt hundreds of KB from zero capacity. The passes themselves are cheap to recreate. using CommandListPool = std::vector; diff --git a/aurora-main/lib/gfx/common.hpp b/aurora-main/lib/gfx/common.hpp index e1958d7..924f92d 100644 --- a/aurora-main/lib/gfx/common.hpp +++ b/aurora-main/lib/gfx/common.hpp @@ -388,6 +388,11 @@ bool get_stereo_skip_copy_clears() noexcept; void set_stereo_hud_screen(bool enabled, float width, float distance) noexcept; bool get_stereo_hud_screen_enabled() noexcept; +// What the desktop window presents while a stereo provider is feeding a +// headset. Live, and read once per presentation group by the frame worker. +void set_stereo_mirror_view(AuroraStereoMirrorView value) noexcept; +AuroraStereoMirrorView get_stereo_mirror_view() noexcept; + void begin_offscreen(uint32_t width, uint32_t height); void end_offscreen(); bool is_offscreen() noexcept; diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index c180bfe..ab449da 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -56,6 +56,7 @@ struct RuntimeUserConfig { std::optional vrHudVirtualScreen; std::optional vrStopAtDisplayCopy; std::optional vrSkipCopyClears; + std::optional vrMirrorView; std::optional vrFirstPerson; std::optional vrFirstPersonUnitsPerMeter; std::optional vrFirstPersonHeadUpMeters; @@ -158,6 +159,15 @@ inline constexpr const char* kVrFirstPersonRotationDefault = "yaw"; inline bool IsSupportedVrFirstPersonRotation(std::string_view value) { return value == "yaw" || value == "yaw_pitch" || value == "full"; } +// What the desktop window shows while the headset is running: "normal" leaves +// the ordinary desktop view alone, "both", "left" and "right" mirror the +// headset's eyes, and "none" blacks the window out. Matches +// AuroraStereoMirrorView. +inline constexpr const char* kVrMirrorViewDefault = "normal"; + +inline bool IsSupportedVrMirrorView(std::string_view value) { + return value == "normal" || value == "both" || value == "left" || value == "right" || value == "none"; +} // SDL scancode name, spelled the way SDL_GetScancodeName produces it. An // empty string leaves the recenter hotkey unbound, menu button only. inline constexpr std::string_view kVrRecenterKeyDefault = "F9"; @@ -360,6 +370,11 @@ inline void EnsureConfigFile() { "# to the ordinary desktop renderer. These values are read at launch.\n" "enabled = false\n" "required = false\n" + "# What the desktop window shows while the headset is running:\n" + "# \"normal\" keeps the ordinary desktop view, \"both\", \"left\" and\n" + "# \"right\" mirror the headset's eyes, and \"none\" blacks the window\n" + "# out. Changeable live from the F10 menu.\n" + "mirror_view = \"normal\"\n" "render_scale = 1.0\n" "world_units_per_meter = 500.0\n" "hud_distance_meters = 2.0\n" @@ -616,6 +631,10 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { value && IsSupportedVrFirstPersonRotation(*value)) { config.vrFirstPersonRotation = *value; } + if (auto value = FindConfigValue(document, "vr", "mirror_view"); + value && IsSupportedVrMirrorView(*value)) { + config.vrMirrorView = *value; + } if (auto value = FindConfigInt(document, "vr", "first_person_hidden_model"); value && *value >= -1 && *value <= 31) { config.vrFirstPersonHiddenModel = static_cast(*value); @@ -909,6 +928,14 @@ inline bool SetVrFirstPersonHideDriver(bool value) { return WriteSetting("vr", "first_person_hide_driver", value ? "true" : "false"); } +inline bool SetVrMirrorView(std::string value) { + if (!IsSupportedVrMirrorView(value)) { + return false; + } + Mutable().vrMirrorView = value; + return WriteSetting("vr", "mirror_view", FormatString(value)); +} + inline bool SetVrFirstPersonRotation(std::string value) { if (!IsSupportedVrFirstPersonRotation(value)) { return false; @@ -1236,6 +1263,11 @@ inline bool VrFirstPersonHideDriver(bool fallback = kVrFirstPersonHideDriverDefa return Get().vrFirstPersonHideDriver.value_or(fallback); } +inline std::string VrMirrorView(std::string fallback = kVrMirrorViewDefault) { + const auto& value = Get().vrMirrorView; + return value && IsSupportedVrMirrorView(*value) ? *value : std::move(fallback); +} + inline std::string VrFirstPersonRotation(std::string fallback = kVrFirstPersonRotationDefault) { const auto& value = Get().vrFirstPersonRotation; return value && IsSupportedVrFirstPersonRotation(*value) ? *value : std::move(fallback); diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index efe93bc..c30548e 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -118,6 +118,25 @@ float g_vrFirstPersonHeadForward = RuntimeConfigFile::VrFirstPersonHeadForwardMe float g_vrFirstPersonHeadRight = RuntimeConfigFile::VrFirstPersonHeadRightMeters(); bool g_vrFirstPersonHideDriver = RuntimeConfigFile::VrFirstPersonHideDriver(); int g_vrFirstPersonHiddenModel = RuntimeConfigFile::VrFirstPersonHiddenModel(); +// Config spellings and menu labels for the desktop mirror, index-matched to +// AuroraStereoMirrorView so the combo selection converts to either directly. +constexpr std::array kVrMirrorViewNames{"normal", "both", "left", "right", "none"}; +constexpr std::array kVrMirrorViewLabels{"Normal", "Both eyes", "Left eye", "Right eye", "None"}; +static_assert(kVrMirrorViewNames.size() == kVrMirrorViewLabels.size()); +static_assert(static_cast(AURORA_STEREO_MIRROR_NORMAL) == 0); +static_assert(static_cast(AURORA_STEREO_MIRROR_BOTH_EYES) == 1); +static_assert(static_cast(AURORA_STEREO_MIRROR_LEFT_EYE) == 2); +static_assert(static_cast(AURORA_STEREO_MIRROR_RIGHT_EYE) == 3); +static_assert(static_cast(AURORA_STEREO_MIRROR_NONE) == 4); +int g_vrMirrorView = [] { + const std::string mode = RuntimeConfigFile::VrMirrorView(); + for (size_t i = 0; i < kVrMirrorViewNames.size(); ++i) { + if (mode == kVrMirrorViewNames[i]) { + return static_cast(i); + } + } + return 0; +}(); constexpr std::array kVrFirstPersonRotationNames{"yaw", "yaw_pitch", "full"}; int g_vrFirstPersonRotation = [] { const std::string mode = RuntimeConfigFile::VrFirstPersonRotation(); @@ -891,9 +910,23 @@ void DrawVrSettings() { RuntimeConfigFile::SetVrEnabled(g_vrEnabled); } ImGui::TextDisabled("OpenXR mode changes take effect after restarting the game."); + // Live, unlike the enable toggle above, so it is left usable either way: + // set before a restart it is simply what the next session starts on. + if (ImGui::Combo("Desktop view", &g_vrMirrorView, kVrMirrorViewLabels.data(), + static_cast(kVrMirrorViewLabels.size()))) { + aurora_set_stereo_mirror_view(static_cast(g_vrMirrorView)); + RuntimeConfigFile::SetVrMirrorView(kVrMirrorViewNames[static_cast(g_vrMirrorView)]); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip( + "What this window shows while the headset is running. Normal keeps the ordinary " + "desktop view, the eye choices mirror what you are actually seeing in the headset, " + "and None leaves the window black. Menus reach the headset as a screen showing this " + "same desktop image, so the eye choices only differ from Normal during a race."); + } - // Unlike the toggle above, these two apply to the very next frame, so they - // can be compared against each other from inside a running race. + // Like the mirror above and unlike the enable toggle, these two apply to the + // very next frame, so they can be compared from inside a running race. ImGui::Separator(); ImGui::Text("VR eye replay (EFB)"); if (ImGui::Checkbox("Stop eye at display copy", &g_vrStopAtDisplayCopy)) { @@ -1331,6 +1364,7 @@ void InitializeRuntimeSettings() noexcept { aurora_set_disable_copy_filter(g_disableCopyFilter); aurora_set_stereo_stop_at_display_copy(g_vrStopAtDisplayCopy); aurora_set_stereo_skip_copy_clears(g_vrSkipCopyClears); + aurora_set_stereo_mirror_view(static_cast(g_vrMirrorView)); ApplyVrHudVirtualScreen(); aurora_set_skip_unready_pipelines(g_skipUnreadyPipelines); mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();