Implement OpenXR Settings Panel and Stereo Overlay

This commit is contained in:
iChris4 committed 2026-09-17 04:32:22 +02:00
1 parent f222eedf6b
commit 1346e53cd6
22 files changed
+1466 -101

No files matched your search

+8
View File
@@ -404,6 +404,14 @@ target_include_directories(mkw_vr_wii_remote_tests PRIVATE "${CMAKE_CURRENT_LIST
target_compile_features(mkw_vr_wii_remote_tests PRIVATE cxx_std_17)
add_test(NAME mkw_vr_wii_remote_tests COMMAND mkw_vr_wii_remote_tests)
# The in-headset settings panel's controller chord, release latch, selection,
# scrolling and canvas mapping, plus the thread bridge they publish through.
add_executable(mkw_vr_settings_panel_tests tests/vr_settings_panel_tests.cpp src/vr/openxr_settings_panel.cpp)
target_include_directories(mkw_vr_settings_panel_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_features(mkw_vr_settings_panel_tests PRIVATE cxx_std_17)
set_target_properties(mkw_vr_settings_panel_tests PROPERTIES UNITY_BUILD OFF)
add_test(NAME mkw_vr_settings_panel_tests COMMAND mkw_vr_settings_panel_tests)
# Resolve the real local-kart pointer walk against synthetic offline/online rosters.
add_executable(mkw_vr_player_tests "${CMAKE_CURRENT_LIST_DIR}/tests/vr_player_tests.cpp")
target_include_directories(mkw_vr_player_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
+20 -4
View File
@@ -5,6 +5,7 @@
#if defined(MKW_ENABLE_OPENXR)
#include "vr/openxr_runtime.h"
#include "vr/openxr_settings_panel.h"
#include "vr/openxr_wii_remote.h"
#include <array>
@@ -54,6 +55,11 @@ struct OpenXRPointerScreen {
// Both are bound for the Oculus Touch profile; khr/simple_controller gets
// select/menu and the poses so an unknown runtime still offers something.
//
// Clicking both thumbsticks opens the in-headset settings panel
// (openxr_settings_panel.h). While it is open, and until every button has been
// released after it closes, the game sees idle controllers: the chord, the
// pointer and the triggers belong to the panel.
//
// Lifetime: Create after the session exists (attaches the action set, which
// OpenXR permits once per session), Sync once per xrWaitFrame, Idle while the
// session is not running, Destroy before the session is destroyed. All of them
@@ -75,8 +81,10 @@ public:
// xrSyncActions + state reads, then publishes to the virtual gamepad and
// the Wii Remote bridge. predicted_display_time is the frame's XrTime;
// screen is where the pointer can land this frame.
void Sync(XrTime predicted_display_time, const OpenXRPointerScreen& screen);
// screen is where the Wii Remote pointer can land this frame, and
// settings_panel where the settings panel is (its whole rectangle).
void Sync(XrTime predicted_display_time, const OpenXRPointerScreen& screen,
const OpenXRPointerScreen& settings_panel);
// Publishes a remote with nothing held, at rest and not pointing, and stops
// the haptics, for frames without focused input.
@@ -100,8 +108,13 @@ private:
XrTime InputSampleTime(XrTime predicted_display_time) const;
bool AttachVirtualGamepad();
void DetachVirtualGamepad();
void PublishWiiRemote(XrTime predicted_display_time, const OpenXRPointerScreen& screen,
const std::array<wii_remote::HandInputs, kHands>& hands, uint32_t injected_buttons);
// `withheld` publishes a remote at rest with nothing held and no pointer,
// while still tracking motion so releasing it does not read as a jolt.
void PublishWiiRemote(XrTime input_time, const OpenXRPointerScreen& screen,
const std::array<wii_remote::HandInputs, kHands>& hands, uint32_t injected_buttons,
bool withheld);
void PublishSettingsPanel(XrTime input_time, const OpenXRPointerScreen& panel,
const settings_panel::Frame& frame);
void UpdateRumble();
void StopRumble();
bool Check(XrResult result, const char* operation);
@@ -128,6 +141,9 @@ private:
PFN_xrVoidFunction m_convert_now_to_xr_time = nullptr;
wii_remote::MotionTracker m_motion[kHands];
wii_remote::PointerFilter m_pointer;
settings_panel::Controls m_panel_controls;
XrTime m_last_input_time = 0;
bool m_panel_select_held = false;
std::array<float, 2> m_horizon{1.0f, 0.0f};
bool m_haptics_active[kHands]{};
uint32_t m_joystick_id = 0; // SDL_JoystickID; 0 when detached
+165
View File
@@ -0,0 +1,165 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include "vr/openxr_wii_remote.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
namespace mkw::vr {
// The settings panel shown inside the headset: the F10 settings, drawn by the
// settings overlay with an ImGui context of its own and laid over the eyes by
// Aurora (aurora_imgui_set_stereo_overlay). It sits on the virtual screen, the
// menu quad or the race's 2D screen, and is operated with the tracked
// controllers, which are withheld from the game while it is open.
//
// The XR pacing thread owns the controllers: it opens and closes the panel from
// the controller chord, aims the pointer and publishes it here. The game thread
// draws the panel, may close it (its Close button) or open it (the F10 bar), and
// reads the pointer once per presented frame. Nothing in this header depends on
// OpenXR, so the settings overlay compiles the same in builds without it.
// The panel's canvas in ImGui pixels, drawn at twice the desktop menu's scale.
inline constexpr float kSettingsPanelWidthPixels = 1440.0f;
inline constexpr float kSettingsPanelHeightPixels = 1080.0f;
inline constexpr float kSettingsPanelUiScale = 2.0f;
// Its width as a fraction of the virtual screen's.
inline constexpr float kSettingsPanelWidthFraction = 0.75f;
struct OpenXRSettingsPanelPointer {
bool valid = false;
float x = 0.0f; // canvas pixels, +x right
float y = 0.0f; // canvas pixels, +y down
bool select = false;
float wheel = 0.0f; // ImGui wheel steps since the last read, +up
};
// Either thread.
void OpenXRSetSettingsPanelOpen(bool open) noexcept;
bool OpenXRSettingsPanelOpen() noexcept;
// XR thread: this frame's pointer, and wheel steps to add to what is pending.
void OpenXRPublishSettingsPanelPointer(bool valid, float x, float y, bool select, float wheel) noexcept;
// Game thread: the latest pointer, taking the wheel steps accumulated since the last call.
OpenXRSettingsPanelPointer OpenXRTakeSettingsPanelPointer() noexcept;
namespace settings_panel {
using wii_remote::HandInputs;
// Thumbstick deflection below this does not scroll.
inline constexpr float kScrollDeadzone = 0.25f;
// Wheel steps per second at full deflection. ImGui scrolls about five lines a step.
inline constexpr float kScrollStepsPerSecond = 8.0f;
// The panel's half extents, in metres, on a virtual screen `screen_width` metres across.
inline std::array<float, 2> HalfExtents(float screen_width) noexcept {
const float half_width = 0.5f * screen_width * kSettingsPanelWidthFraction;
return {half_width, half_width * kSettingsPanelHeightPixels / kSettingsPanelWidthPixels};
}
// A hit on the panel (-1..1 across it, +v up) in canvas pixels.
inline std::array<float, 2> CanvasPoint(const wii_remote::ScreenHit& hit) noexcept {
return {0.5f * (hit.u + 1.0f) * kSettingsPanelWidthPixels, 0.5f * (1.0f - hit.v) * kSettingsPanelHeightPixels};
}
// What the controllers do this frame.
struct Frame {
bool open = false; // the panel is showing
bool withheld = false; // the game must not see the controllers
uint32_t pointing_hand = 1;
bool select = false;
float wheel = 0.0f;
};
// The controller side of the panel, one Update per XR frame:
//
// - Clicking both thumbsticks together opens or closes it. Nothing else opens
// it from the controllers: every other button already means something to the
// game.
// - While it is open, the left menu button also closes it, both triggers and
// the A / X buttons select, the thumbsticks scroll, and the hand whose trigger
// was pulled last is the one pointing.
// - The game gets the controllers back only once everything is released, so the
// press that closed the panel never lands in the game as well.
class Controls {
public:
// `open` is the shared flag: the chord flips it, and the game thread may
// have changed it since the last frame. `dt_seconds` is the time since the
// previous frame.
Frame Update(const std::array<HandInputs, 2>& hands, bool& open, float dt_seconds) noexcept {
const bool chord = hands[0].thumbstick_click && hands[1].thumbstick_click;
if (chord && !m_chord_held) {
open = !open;
}
m_chord_held = chord;
if (open && hands[0].menu && !m_menu_held && m_was_open) {
open = false;
}
m_menu_held = hands[0].menu;
for (uint32_t hand = 0; hand < 2; ++hand) {
const bool pulled = hands[hand].trigger > wii_remote::kPressThreshold;
if (pulled && !m_trigger_held[hand]) {
m_pointing_hand = hand;
}
m_trigger_held[hand] = pulled;
}
if (open != m_was_open) {
// Whatever is held across the change belongs to that change.
m_release_pending = true;
}
if (m_release_pending && !AnyHeld(hands)) {
m_release_pending = false;
}
m_was_open = open;
Frame frame{};
frame.open = open;
frame.withheld = open || m_release_pending;
frame.pointing_hand = m_pointing_hand;
if (!open) {
return frame;
}
// Opening held nothing that selects, but a trigger still down from the
// game must not click the first thing under the pointer.
frame.select = !m_release_pending && (m_trigger_held[0] || m_trigger_held[1] || hands[0].primary ||
hands[1].primary);
const float stick = std::fabs(hands[1].stick_y) >= std::fabs(hands[0].stick_y) ? hands[1].stick_y
: hands[0].stick_y;
if (std::fabs(stick) > kScrollDeadzone) {
const float magnitude = (std::fabs(stick) - kScrollDeadzone) / (1.0f - kScrollDeadzone);
frame.wheel = std::copysign(magnitude, stick) * kScrollStepsPerSecond * std::clamp(dt_seconds, 0.0f, 0.1f);
}
return frame;
}
void Reset() noexcept { *this = Controls{}; }
static bool AnyHeld(const std::array<HandInputs, 2>& hands) noexcept {
for (const HandInputs& hand : hands) {
if (hand.primary || hand.secondary || hand.menu || hand.thumbstick_click ||
hand.trigger > wii_remote::kPressThreshold || hand.squeeze > wii_remote::kPressThreshold) {
return true;
}
}
return false;
}
private:
bool m_chord_held = false;
bool m_menu_held = false;
std::array<bool, 2> m_trigger_held{};
bool m_was_open = false;
bool m_release_pending = false;
uint32_t m_pointing_hand = 1;
};
} // namespace settings_panel
} // namespace mkw::vr
+34 -16
View File
@@ -962,6 +962,31 @@ constexpr DWORD kCppExceptionCodeMsvc = 0xE06D7363;
constexpr DWORD kAsanFatalAppExit = 0x40000015; // STATUS_FATAL_APP_EXIT
LONG ReportFatalSehAndExit(EXCEPTION_POINTERS* info);
// Exceptions the processor itself raises for the faulting instruction.
bool IsCpuFaultException(DWORD code) noexcept {
switch (code) {
case EXCEPTION_ACCESS_VIOLATION:
case EXCEPTION_IN_PAGE_ERROR:
case EXCEPTION_ILLEGAL_INSTRUCTION:
case EXCEPTION_PRIV_INSTRUCTION:
case EXCEPTION_INT_DIVIDE_BY_ZERO:
case EXCEPTION_INT_OVERFLOW:
case EXCEPTION_ARRAY_BOUNDS_EXCEEDED:
case EXCEPTION_DATATYPE_MISALIGNMENT:
case EXCEPTION_STACK_OVERFLOW:
case EXCEPTION_FLT_DENORMAL_OPERAND:
case EXCEPTION_FLT_DIVIDE_BY_ZERO:
case EXCEPTION_FLT_INEXACT_RESULT:
case EXCEPTION_FLT_INVALID_OPERATION:
case EXCEPTION_FLT_OVERFLOW:
case EXCEPTION_FLT_STACK_CHECK:
case EXCEPTION_FLT_UNDERFLOW:
return true;
default:
return false;
}
}
void ReportStructuredException(EXCEPTION_POINTERS* info) {
if (!info || !info->ExceptionRecord) {
RT_LOG(RT_TAG_RUNTIME) << "Structured exception occurred, but no diagnostic info was captured." << std::endl;
@@ -1051,22 +1076,15 @@ LONG CALLBACK SehLogger(EXCEPTION_POINTERS* info) {
if (g_suppressSehReporting) {
return EXCEPTION_CONTINUE_SEARCH;
}
// Let C++ exceptions propagate to std::terminate so we can log their what().
if (info->ExceptionRecord->ExceptionCode == kCppExceptionCodeGcc ||
info->ExceptionRecord->ExceptionCode == kCppExceptionCodeMsvc) {
return EXCEPTION_CONTINUE_SEARCH;
}
if (info->ExceptionRecord->ExceptionCode == 0x40010006 || // DBG_PRINTEXCEPTION_C
info->ExceptionRecord->ExceptionCode == 0x4001000A || // DBG_PRINTEXCEPTION_WIDE_C (OutputDebugStringW)
info->ExceptionRecord->ExceptionCode == 0x406D1388 || // SetThreadName
info->ExceptionRecord->ExceptionCode == kAsanFatalAppExit) { // ASan reporting - let it print first
return EXCEPTION_CONTINUE_SEARCH;
}
// Software-raised exceptions (customer bit set) are used for internal control flow by
// system DLLs (e.g. msxml6 while mscms parses a display colour profile) and are caught
// by their own frame handlers. Only hardware faults are fatal at first chance; anything
// else that truly goes unhandled reaches UnhandledSehFilter.
if ((info->ExceptionRecord->ExceptionCode & 0x20000000u) != 0) {
// This handler sees every exception on every thread before any frame handler
// does, so only CPU faults are fatal here. Everything else is raised in
// software and is often caught by its own frames: C++ exceptions (left to
// reach std::terminate so their what() is logged), OutputDebugString,
// SetThreadName, ASan reports, msxml6's customer codes while mscms parses a
// display colour profile, and RPC's 0x6BA (RPC_S_SERVER_UNAVAILABLE) inside
// the shell folder picker behind F10 > Diagnostics > Export Logs. Any of
// them that truly goes unhandled still reaches UnhandledSehFilter.
if (!IsCpuFaultException(info->ExceptionRecord->ExceptionCode)) {
return EXCEPTION_CONTINUE_SEARCH;
}
return ReportFatalSehAndExit(info);
+182 -18
View File
@@ -12,9 +12,11 @@
#include "vr/mkw_vr_policy.h"
#include "vr/openxr_diagnostics.h"
#include "vr/openxr_integration.h"
#include "vr/openxr_settings_panel.h"
#include "vr/openxr_wii_remote.h"
#include "wii_remote_input.h"
#include <aurora/imgui.h>
#include <imgui.h>
#include <SDL3/SDL_dialog.h>
#include <SDL3/SDL_error.h>
@@ -28,6 +30,7 @@
#include <algorithm>
#include <atomic>
#include <cctype>
#include <cfloat>
#include <chrono>
#include <cmath>
#include <cstdint>
@@ -81,6 +84,12 @@ const char* GraphicsApiDisplayName() {
return "Unknown";
}
// Fixed widths in the menus are written for the desktop bar's 13 px font. The
// headset's settings panel draws the same menus with a larger one.
float Scaled(float pixels) {
return pixels * ImGui::GetFontSize() / 13.0f;
}
bool g_topBarVisible = false;
bool g_rumbleEnabled = RuntimeConfigFile::RumbleEnabled(true);
int g_controllerPort = 0;
@@ -364,7 +373,7 @@ void DrawWiiRemoteAccelerometer(uint32_t port) {
// and it falls back to a nominal zero point, leaving a small per-axis bias;
// measured here with the remote at rest.
if (WiiRemoteInput::IsAccelCalibrating()) {
ImGui::ProgressBar(WiiRemoteInput::AccelCalibrationProgress(), ImVec2(220.0f, 0.0f), "Hold still...");
ImGui::ProgressBar(WiiRemoteInput::AccelCalibrationProgress(), ImVec2(Scaled(220.0f), 0.0f), "Hold still...");
} else if (ImGui::Button("Calibrate (remote lying flat, buttons up)")) {
WiiRemoteInput::StartAccelCalibration(port);
}
@@ -492,7 +501,7 @@ int ExpressionResizeCallback(ImGuiInputTextCallbackData* data) {
void DrawExpressionSettings() {
ImGui::SeparatorText("Expressions (Dolphin syntax)");
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 440.0f);
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(440.0f));
ImGui::TextDisabled(
"Optional. An expression overrides nothing: its result is combined with the "
"button mapping above. Operators ! & | ^ and functions if, min, max, clamp, "
@@ -538,7 +547,7 @@ void DrawExpressionSettings() {
for (size_t i = 0; i < InputBindings::kControls.size(); ++i) {
ImGui::PushID(static_cast<int>(i) + 2000);
std::string& text = buffers[i];
ImGui::SetNextItemWidth(300.0f);
ImGui::SetNextItemWidth(Scaled(300.0f));
if (ImGui::InputText(InputBindings::kControls[i].label, text.data(), text.capacity() + 1,
ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackResize,
ExpressionResizeCallback, &text)) {
@@ -708,7 +717,7 @@ void DrawControllerSettings() {
const NativeButtonItem& current = NativeButtonForValue(mappingIt->nativeButton);
ImGui::PushID(static_cast<int>(i));
ImGui::SetNextItemWidth(190.0f);
ImGui::SetNextItemWidth(Scaled(190.0f));
if (ImGui::BeginCombo("##primary", current.label)) {
for (const auto& candidate : kNativeButtons) {
const bool selected = candidate.nativeButton == mappingIt->nativeButton;
@@ -741,7 +750,7 @@ void DrawControllerSettings() {
ImGui::TextUnformatted("or");
ImGui::SameLine();
const char* altLabel = altBound ? NativeButtonForValue(altIt->nativeButton).label : "None";
ImGui::SetNextItemWidth(190.0f);
ImGui::SetNextItemWidth(Scaled(190.0f));
if (ImGui::BeginCombo("##alt", altLabel)) {
for (const auto& candidate : kNativeButtons) {
const bool isNone = candidate.nativeButton == PAD_NATIVE_BUTTON_INVALID;
@@ -772,7 +781,7 @@ void DrawControllerSettings() {
}
void DrawAudioSettings() {
ImGui::SetNextItemWidth(220.0f);
ImGui::SetNextItemWidth(Scaled(220.0f));
if (ImGui::SliderInt("Master", &g_audioVolumePercent, 0, 100, "%d%%")) {
const float volume = static_cast<float>(g_audioVolumePercent) / 100.0f;
AudioBackend::Instance().SetMasterVolume(volume);
@@ -918,7 +927,7 @@ void DrawGraphicsSettings() {
aurora_set_display_mode(AURORA_DISPLAY_MODE_EXCLUSIVE);
}
}
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f));
ImGui::TextDisabled("Frame interpolation is experimental, you might find visual artifacts");
ImGui::PopTextWrapPos();
if (ImGui::Checkbox("Disable copy filter", &g_disableCopyFilter)) {
@@ -963,6 +972,19 @@ void DrawVrSettings() {
"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.");
}
ImGui::BeginDisabled(!mkw::vr::OpenXRIsRunning());
bool settingsPanelOpen = mkw::vr::OpenXRSettingsPanelOpen();
if (ImGui::Checkbox("Show these settings in the headset", &settingsPanelOpen)) {
mkw::vr::OpenXRSetSettingsPanelOpen(settingsPanelOpen);
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
ImGui::SetTooltip(
"Opens these settings on a panel in front of you, in menus and races alike.\n"
"In the headset, clicking both thumbsticks together opens and closes it too.\n"
"Aim at it and pull a trigger to change a setting; push a thumbstick to scroll.\n"
"While it is open the game does not see the VR controllers.");
}
if (ImGui::Combo("VR controllers", &g_vrControllerMode, kVrControllerModeLabels.data(),
static_cast<int>(kVrControllerModeLabels.size()))) {
mkw::vr::OpenXRSetControllerMode(static_cast<mkw::vr::OpenXRControllerMode>(g_vrControllerMode));
@@ -1035,7 +1057,7 @@ void DrawVrSettings() {
"Wii's reused EFB for the next frame; an eye attachment is built fresh, so "
"replaying it only erases the eye.");
}
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f));
ImGui::TextDisabled(
"Both apply on the next frame. Turning either off restores the raw replay and is "
"expected to black out the eyes.");
@@ -1139,7 +1161,7 @@ void DrawVrSettings() {
RuntimeConfigFile::SetVrFirstPersonHeadRightMeters(g_vrFirstPersonHeadRight);
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
}
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f));
ImGui::TextDisabled("Where the head sits in the kart's own frame.");
ImGui::PopTextWrapPos();
constexpr std::array<const char*, 3> kRotationLabels{"Yaw only", "Yaw + Pitch", "Full rotation"};
@@ -1310,7 +1332,7 @@ void DrawDiagnosticsSettings() {
"Turn it on to report stutter or black frames in VR, and off again afterwards.\n"
"Off by default. Applies immediately and is remembered.");
}
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f));
if (g_openxrDiagnosticsLogging && !mkw::vr::OpenXRIsRunning()) {
ImGui::TextDisabled("OpenXR is not running, so nothing is logged until a VR session starts.");
}
@@ -1337,7 +1359,7 @@ void DrawDiagnosticsSettings() {
failed = g_logExport.failed;
}
if (!message.empty()) {
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f));
if (failed) {
ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.35f, 1.0f), "%s", message.c_str());
} else {
@@ -1444,13 +1466,7 @@ void DrawStartupScreen() {
ImGui::PopStyleColor();
}
void DrawTopBar() {
if (!g_topBarVisible || !ImGui::BeginMainMenuBar()) {
return;
}
ImGui::TextUnformatted("WiiCompiled");
ImGui::Separator();
void DrawResolutionMenu() {
const auto resolutionIt = std::find_if(kResolutions.begin(), kResolutions.end(), [](const ResolutionItem& item) {
return std::fabs(item.scale - g_resolutionScale) < 0.001f;
});
@@ -1469,6 +1485,16 @@ void DrawTopBar() {
}
ImGui::EndMenu();
}
}
void DrawTopBar() {
if (!g_topBarVisible || !ImGui::BeginMainMenuBar()) {
return;
}
ImGui::TextUnformatted("WiiCompiled");
ImGui::Separator();
DrawResolutionMenu();
if (ImGui::BeginMenu("Graphics")) {
DrawGraphicsSettings();
@@ -1574,6 +1600,143 @@ void PersistDisplayModeIfChanged() {
g_displayMode = active;
RuntimeConfigFile::SetDisplayMode(std::string(kDisplayModeConfigNames[static_cast<size_t>(active)]));
}
// The headset's settings panel (vr/openxr_settings_panel.h): the same menus as
// the F10 bar, drawn with an ImGui context of its own at a size fixed in panel
// pixels, operated by the VR controllers' pointer and handed to Aurora, which
// lays it over the eyes. The desktop context is untouched, so the F10 bar and
// the panel can both be open.
struct VrSettingsPanel {
ImGuiContext* context = nullptr;
Clock::time_point lastFrame{};
bool selectHeld = false;
};
VrSettingsPanel g_vrSettingsPanel;
ImGuiContext* CreateVrSettingsPanelContext() {
ImGuiContext* const desktop = ImGui::GetCurrentContext();
ImGuiContext* const context = ImGui::CreateContext();
ImGui::SetCurrentContext(context);
ImGuiIO& io = ImGui::GetIO();
io.IniFilename = nullptr;
io.LogFilename = nullptr;
// No platform backend draws a cursor for it, and nothing else shows where
// the controller is aiming.
io.MouseDrawCursor = true;
// Aurora's WebGPU backend, which renders this context's draw data, honours it.
io.BackendFlags |= ImGuiBackendFlags_RendererHasVtxOffset;
// The default font rasterised at the panel's scale rather than magnified,
// with an atlas of its own so the desktop font texture is left alone.
ImFontConfig font;
font.SizePixels = 13.0f * mkw::vr::kSettingsPanelUiScale;
io.Fonts->AddFontDefault(&font);
unsigned char* pixels = nullptr;
int width = 0;
int height = 0;
io.Fonts->GetTexDataAsRGBA32(&pixels, &width, &height);
io.Fonts->SetTexID(aurora_imgui_add_texture(static_cast<uint32_t>(width), static_cast<uint32_t>(height), pixels));
io.Fonts->ClearTexData();
ImGui::StyleColorsDark();
ImGuiStyle& style = ImGui::GetStyle();
style.ScaleAllSizes(mkw::vr::kSettingsPanelUiScale);
// Nothing behind the panel is meant to be read through it.
style.Colors[ImGuiCol_WindowBg].w = 0.97f;
style.Colors[ImGuiCol_PopupBg].w = 0.98f;
ImGui::SetCurrentContext(desktop);
return context;
}
void DrawVrSettingsPanelWindow() {
const ImGuiViewport* viewport = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(viewport->Pos, ImGuiCond_Always);
ImGui::SetNextWindowSize(viewport->Size, ImGuiCond_Always);
constexpr ImGuiWindowFlags kFlags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBringToFrontOnFocus;
if (ImGui::Begin("WiiCompiled settings", nullptr, kFlags)) {
ImGui::TextUnformatted("WiiCompiled settings");
const ImGuiStyle& style = ImGui::GetStyle();
const float recenterWidth = ImGui::CalcTextSize("Recenter view").x + style.FramePadding.x * 2.0f;
const float closeWidth = ImGui::CalcTextSize("Close").x + style.FramePadding.x * 2.0f;
ImGui::SameLine(ImGui::GetContentRegionMax().x - recenterWidth - closeWidth - style.ItemSpacing.x);
if (ImGui::Button("Recenter view")) {
mkw::vr::OpenXRRequestRecenter();
}
ImGui::SameLine();
if (ImGui::Button("Close")) {
mkw::vr::OpenXRSetSettingsPanelOpen(false);
}
ImGui::TextDisabled("Aim and pull a trigger to change a setting, push a thumbstick to scroll.");
ImGui::TextDisabled("Click both thumbsticks or press Menu to close. The game does not see the controllers meanwhile.");
ImGui::Separator();
if (ImGui::BeginTabBar("Settings")) {
const auto tab = [](const char* label, void (*draw)()) {
if (ImGui::BeginTabItem(label)) {
// Its own scrolling region, so the header stays in view.
ImGui::BeginChild("Contents");
draw();
ImGui::EndChild();
ImGui::EndTabItem();
}
};
tab("VR", DrawVrSettings);
tab("Graphics", [] {
DrawResolutionMenu();
ImGui::Separator();
DrawGraphicsSettings();
});
tab("Controllers", DrawControllerSettings);
tab("Audio", DrawAudioSettings);
tab("Diagnostics", DrawDiagnosticsSettings);
ImGui::EndTabBar();
}
}
ImGui::End();
}
// Called once per presented frame after the desktop menus, with the frame
// worker done: the draw data handed to Aurora must stay put until the next
// frame is encoded, and only the next call here rebuilds it.
void DrawVrSettingsPanel() {
if (!mkw::vr::OpenXRIsRunning() || !mkw::vr::OpenXRSettingsPanelOpen()) {
aurora_imgui_set_stereo_overlay(nullptr, 0.0f);
return;
}
VrSettingsPanel& panel = g_vrSettingsPanel;
if (panel.context == nullptr) {
panel.context = CreateVrSettingsPanelContext();
}
const mkw::vr::OpenXRSettingsPanelPointer pointer = mkw::vr::OpenXRTakeSettingsPanelPointer();
const Clock::time_point now = Clock::now();
float deltaSeconds = 1.0f / 60.0f;
if (panel.lastFrame != Clock::time_point{}) {
deltaSeconds = std::clamp(std::chrono::duration<float>(now - panel.lastFrame).count(), 1.0e-4f, 0.25f);
}
panel.lastFrame = now;
ImGuiContext* const desktop = ImGui::GetCurrentContext();
ImGui::SetCurrentContext(panel.context);
ImGuiIO& io = ImGui::GetIO();
io.DisplaySize = ImVec2(mkw::vr::kSettingsPanelWidthPixels, mkw::vr::kSettingsPanelHeightPixels);
io.DeltaTime = deltaSeconds;
if (pointer.valid) {
io.AddMousePosEvent(pointer.x, pointer.y);
} else {
io.AddMousePosEvent(-FLT_MAX, -FLT_MAX);
}
if (pointer.select != panel.selectHeld) {
io.AddMouseButtonEvent(ImGuiMouseButton_Left, pointer.select);
panel.selectHeld = pointer.select;
}
if (pointer.wheel != 0.0f) {
io.AddMouseWheelEvent(0.0f, pointer.wheel);
}
ImGui::NewFrame();
DrawVrSettingsPanelWindow();
ImGui::Render();
ImDrawData* const drawData = ImGui::GetDrawData();
ImGui::SetCurrentContext(desktop);
aurora_imgui_set_stereo_overlay(drawData, mkw::vr::kSettingsPanelWidthFraction);
}
} // namespace
void InitializeRuntimeSettings() noexcept {
@@ -1685,6 +1848,7 @@ void Draw() noexcept {
PADBlockInput(inputBlocked);
InputBindings::SetInputBlocked(inputBlocked);
DrawStartupScreen();
DrawVrSettingsPanel();
}
bool StartupScreenVisible() noexcept {
+105 -18
View File
@@ -40,8 +40,9 @@ namespace {
// A new sequence number holds the button for kInjectHoldFrames XR frames.
// Buttons: a, b, x, y, start, up, down, left, right, and for the Wii Remote
// presentation also home, c and z (x/y/start press 1/2/+ there, and the
// directions push the Nunchuk stick). The property is unset in normal use, so
// this costs one property read every few frames.
// directions push the Nunchuk stick). `panel` clicks both thumbsticks, opening
// or closing the settings panel, where `a` then selects. The property is unset
// in normal use, so this costs one property read every few frames.
constexpr uint32_t kInjectHoldFrames = 12;
constexpr uint32_t kInjectPollFrames = 4;
@@ -468,6 +469,9 @@ void OpenXRInput::Destroy() {
}
m_pointer.Reset();
m_horizon = {1.0f, 0.0f};
m_panel_controls.Reset();
m_last_input_time = 0;
m_panel_select_held = false;
m_created = false;
m_runtime = nullptr;
}
@@ -482,6 +486,11 @@ void OpenXRInput::Idle() {
m_pointer.Reset();
m_horizon = {1.0f, 0.0f};
OpenXRPublishWiiRemote(m_joystick_id, OpenXRWiiRemoteSample{});
// The panel stays as it was; only what the controllers were holding is forgotten.
m_panel_controls.Reset();
m_last_input_time = 0;
m_panel_select_held = false;
OpenXRPublishSettingsPanelPointer(false, 0.0f, 0.0f, false, 0.0f);
StopRumble();
// Nothing stays held on the gamepad either while input is away.
if (m_joystick != nullptr) {
@@ -495,7 +504,8 @@ void OpenXRInput::Idle() {
}
}
void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen& screen) {
void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen& screen,
const OpenXRPointerScreen& settings_panel) {
if (!m_created || m_runtime == nullptr) {
return;
}
@@ -566,18 +576,48 @@ void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen&
}
PollInjection();
wii_remote::HandInputs& left = hands[0];
const wii_remote::HandInputs& right = hands[1];
if (Injected("up")) {
left.stick_y = 1.0f;
hands[0].stick_y = 1.0f;
} else if (Injected("down")) {
left.stick_y = -1.0f;
hands[0].stick_y = -1.0f;
} else if (Injected("left")) {
left.stick_x = -1.0f;
hands[0].stick_x = -1.0f;
} else if (Injected("right")) {
left.stick_x = 1.0f;
hands[0].stick_x = 1.0f;
}
const XrTime input_time = InputSampleTime(predicted_display_time);
const float dt_seconds =
m_last_input_time != 0 && input_time > m_last_input_time
? static_cast<float>(input_time - m_last_input_time) * 1.0e-9f
: 0.0f;
m_last_input_time = input_time;
std::array<wii_remote::HandInputs, kHands> panel_hands = hands;
if (Injected("panel")) {
panel_hands[0].thumbstick_click = true;
panel_hands[1].thumbstick_click = true;
}
if (Injected("a")) {
panel_hands[1].primary = true;
}
// The game thread may open or close the panel too; only a change made here
// is written back.
const bool was_open = OpenXRSettingsPanelOpen();
bool open = was_open;
const settings_panel::Frame panel = m_panel_controls.Update(panel_hands, open, dt_seconds);
// Pointer first: the game thread reads it as soon as it sees the panel open.
PublishSettingsPanel(input_time, settings_panel, panel);
if (open != was_open) {
OpenXRSetSettingsPanelOpen(open);
}
// While the panel has the controllers, the game sees them idle.
static const std::array<wii_remote::HandInputs, kHands> kIdleHands{};
const auto& game_hands = panel.withheld ? kIdleHands : hands;
const wii_remote::HandInputs& left = game_hands[0];
const wii_remote::HandInputs& right = game_hands[1];
const auto injected = [&panel](const char* button) { return !panel.withheld && Injected(button); };
if (m_joystick != nullptr) {
auto* joystick = static_cast<SDL_Joystick*>(m_joystick);
// OpenXR thumbsticks report +Y up; SDL gamepads report +Y down.
@@ -588,27 +628,65 @@ void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen&
SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_LEFT_TRIGGER, ToTrigger(left.trigger));
SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, ToTrigger(right.trigger));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_SOUTH, right.primary || Injected("a"));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_EAST, right.secondary || Injected("b"));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_WEST, left.primary || Injected("x"));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_NORTH, left.secondary || Injected("y"));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_START, left.menu || Injected("start"));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_SOUTH, right.primary || injected("a"));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_EAST, right.secondary || injected("b"));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_WEST, left.primary || injected("x"));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_NORTH, left.secondary || injected("y"));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_START, left.menu || injected("start"));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_LEFT_STICK, left.thumbstick_click);
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_RIGHT_STICK, right.thumbstick_click);
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, left.squeeze > 0.5f);
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, right.squeeze > 0.5f);
}
PublishWiiRemote(predicted_display_time, screen, hands, InjectedWiiRemoteButtons());
PublishWiiRemote(input_time, screen, game_hands, panel.withheld ? 0u : InjectedWiiRemoteButtons(),
panel.withheld);
UpdateRumble();
}
void OpenXRInput::PublishWiiRemote(XrTime predicted_display_time, const OpenXRPointerScreen& screen,
// The pointing hand's aim ray against the whole panel, in canvas pixels, with a
// short tick in that hand when a selection starts.
void OpenXRInput::PublishSettingsPanel(XrTime input_time, const OpenXRPointerScreen& panel,
const settings_panel::Frame& frame) {
if (!frame.open) {
// Nothing may still read as held when the panel next opens.
m_panel_select_held = false;
OpenXRPublishSettingsPanelPointer(false, 0.0f, 0.0f, false, 0.0f);
return;
}
constexpr XrSpaceLocationFlags kPoseValid =
XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT;
bool valid = false;
std::array<float, 2> point{};
const XrSpace aim_space = m_aim_spaces[frame.pointing_hand];
if (panel.valid && aim_space != XR_NULL_HANDLE) {
XrSpaceLocation location{XR_TYPE_SPACE_LOCATION};
if (XR_SUCCEEDED(xrLocateSpace(aim_space, m_runtime->AppSpace(), input_time, &location)) &&
(location.locationFlags & kPoseValid) == kPoseValid) {
wii_remote::Screen target{};
target.pose = ToWiiRemotePose(panel.pose);
target.half_width = panel.half_width_meters;
target.half_height = panel.half_height_meters;
const wii_remote::ScreenHit hit = wii_remote::RaycastScreen(ToWiiRemotePose(location.pose), target);
if (hit.valid) {
valid = true;
point = settings_panel::CanvasPoint(hit);
}
}
}
if (frame.select && !m_panel_select_held) {
constexpr XrDuration kTickNs = 15'000'000;
ApplyHaptic(frame.pointing_hand, 0.35f, kTickNs);
}
m_panel_select_held = frame.select;
OpenXRPublishSettingsPanelPointer(valid, point[0], point[1], frame.select, frame.wheel);
}
void OpenXRInput::PublishWiiRemote(XrTime input_time, const OpenXRPointerScreen& screen,
const std::array<wii_remote::HandInputs, kHands>& hands,
uint32_t injected_buttons) {
uint32_t injected_buttons, bool withheld) {
constexpr XrSpaceLocationFlags kPoseValid =
XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT;
const XrTime input_time = InputSampleTime(predicted_display_time);
OpenXRWiiRemoteSample sample{};
sample.hold = wii_remote::RemoteButtons(hands[0], hands[1]) | injected_buttons;
@@ -648,6 +726,15 @@ void OpenXRInput::PublishWiiRemote(XrTime predicted_display_time, const OpenXRPo
(hand == 0 ? sample.nunchuk_acc : sample.acc) = acc;
}
if (withheld) {
// Waving a controller around the panel must not trick or wheelie.
sample.acc = OpenXRWiiRemoteSample{}.acc;
sample.nunchuk_acc = OpenXRWiiRemoteSample{}.nunchuk_acc;
m_pointer.Reset();
OpenXRPublishWiiRemote(m_joystick_id, sample);
return;
}
wii_remote::Screen target{};
wii_remote::ScreenHit hit{};
if (screen.valid && aim_valid[1]) {
+81 -41
View File
@@ -741,7 +741,8 @@ private:
if (input_ != nullptr) {
// After the screen is placed, so the pointer aims at this
// frame's screen rather than the previous one's.
input_->Sync(frame.xr_frame.predicted_display_time, PointerScreen(frame, policy, immersive));
input_->Sync(frame.xr_frame.predicted_display_time, PointerScreen(frame, policy, immersive),
SettingsPanelScreen(frame, policy, immersive));
}
if (!frame.expects_gpu_submission) {
@@ -951,55 +952,19 @@ private:
if (!aurora_get_stereo_screen_aspects(&picture_aspect, &snapshot_aspect)) {
return screen;
}
const OpenXRFrame& xr_frame = frame.xr_frame;
const bool views_usable = xr_frame.views_valid &&
(xr_frame.view_state_flags & XR_VIEW_STATE_ORIENTATION_VALID_BIT) != 0;
const bool position_usable =
views_usable && (xr_frame.view_state_flags & XR_VIEW_STATE_POSITION_VALID_BIT) != 0;
if (immersive) {
const float distance = policy.config.hud_distance_meters;
const float width = policy.config.hud_width_meters;
if (!aurora_get_stereo_hud_screen_enabled() || !(distance > 0.0f) || !(width > 0.0f)) {
if (!aurora_get_stereo_hud_screen_enabled() || !(policy.config.hud_width_meters > 0.0f) ||
!RaceScreenPose(frame, policy, screen.pose)) {
return screen;
}
std::array<float, 3> base{};
if (base_position_valid_ && last_immersive_) {
base = base_position_;
} else if (position_usable) {
// BuildPublishedFrame latches exactly this for the frame.
base = CenterPosition(xr_frame);
} else {
return screen;
}
const float half_angle =
0.5f * lean_back_degrees_.load(std::memory_order_relaxed) * kDegreesToRadians;
const Quaternion lean{std::sin(half_angle), 0.0f, 0.0f, std::cos(half_angle)};
const std::array<float, 3> ahead = Rotate(lean, {0.0f, 0.0f, -distance});
screen.pose.orientation = {lean.x, lean.y, lean.z, lean.w};
screen.pose.position = {base[0] + ahead[0], base[1] + ahead[1], base[2] + ahead[2]};
screen.half_width_meters = 0.5f * width;
screen.half_width_meters = 0.5f * policy.config.hud_width_meters;
screen.half_height_meters = screen.half_width_meters / picture_aspect;
screen.valid = true;
return screen;
}
if (frame.presentation.mode != OpenXRFrameMode::VirtualScreen || frame.render_width[0] == 0 ||
frame.render_height[0] == 0) {
return screen;
}
if (frame.presentation.quad_anchored) {
screen.pose = frame.presentation.quad_pose;
} else if (position_usable) {
// Head-locked in the view space: straight ahead of the head.
const auto& head = xr_frame.views[0].pose.orientation;
const Quaternion orientation = Normalize({head.x, head.y, head.z, head.w});
const std::array<float, 3> center = CenterPosition(xr_frame);
const std::array<float, 3> ahead = Rotate(
orientation, {0.0f, 0.0f, -std::max(0.25f, frame.presentation.quad_distance_meters)});
screen.pose.orientation = {orientation.x, orientation.y, orientation.z, orientation.w};
screen.pose.position = {center[0] + ahead[0], center[1] + ahead[1], center[2] + ahead[2]};
} else {
if (frame.render_width[0] == 0 || frame.render_height[0] == 0 || !MenuScreenPose(frame, screen.pose)) {
return screen;
}
const float eye_aspect =
@@ -1012,6 +977,81 @@ private:
return screen;
}
// The settings panel's rectangle: centred on the same screen, a fixed
// fraction of its width, the way Aurora lays it over the eyes
// (aurora_imgui_set_stereo_overlay). Unlike the pointer's picture it is
// there in a race even when the 2D layer is not on the screen.
OpenXRPointerScreen SettingsPanelScreen(const OpenXRBackendFrame& frame, const MkwVRPolicySnapshot& policy,
bool immersive) const noexcept {
OpenXRPointerScreen screen{};
const float screen_width =
immersive ? policy.config.hud_width_meters : std::max(0.25f, frame.presentation.quad_width_meters);
if (!(screen_width > 0.0f) ||
!(immersive ? RaceScreenPose(frame, policy, screen.pose) : MenuScreenPose(frame, screen.pose))) {
return screen;
}
const std::array<float, 2> extents = settings_panel::HalfExtents(screen_width);
screen.half_width_meters = extents[0];
screen.half_height_meters = extents[1];
screen.valid = true;
return screen;
}
// Centre of the race's 2D screen. ViewFromBase maps a point p of the
// recorded centre-eye space (in metres) to base + lean * p in the
// application space, so the screen Aurora hangs hud_distance_meters ahead
// sits at base + lean * (0, 0, -distance), turned by the lean.
bool RaceScreenPose(const OpenXRBackendFrame& frame, const MkwVRPolicySnapshot& policy,
XrPosef& pose) const noexcept {
const OpenXRFrame& xr_frame = frame.xr_frame;
const float distance = policy.config.hud_distance_meters;
if (!(distance > 0.0f)) {
return false;
}
std::array<float, 3> base{};
if (base_position_valid_ && last_immersive_) {
base = base_position_;
} else if (xr_frame.views_valid &&
(xr_frame.view_state_flags & XR_VIEW_STATE_ORIENTATION_VALID_BIT) != 0 &&
(xr_frame.view_state_flags & XR_VIEW_STATE_POSITION_VALID_BIT) != 0) {
// BuildPublishedFrame latches exactly this for the frame.
base = CenterPosition(xr_frame);
} else {
return false;
}
const float half_angle = 0.5f * lean_back_degrees_.load(std::memory_order_relaxed) * kDegreesToRadians;
const Quaternion lean{std::sin(half_angle), 0.0f, 0.0f, std::cos(half_angle)};
const std::array<float, 3> ahead = Rotate(lean, {0.0f, 0.0f, -distance});
pose.orientation = {lean.x, lean.y, lean.z, lean.w};
pose.position = {base[0] + ahead[0], base[1] + ahead[1], base[2] + ahead[2]};
return true;
}
// Centre of the menu quad: where UpdateVirtualScreenPose anchored it, or
// its head-locked fallback straight ahead of the head.
bool MenuScreenPose(const OpenXRBackendFrame& frame, XrPosef& pose) const noexcept {
if (frame.presentation.mode != OpenXRFrameMode::VirtualScreen) {
return false;
}
if (frame.presentation.quad_anchored) {
pose = frame.presentation.quad_pose;
return true;
}
const OpenXRFrame& xr_frame = frame.xr_frame;
if (!xr_frame.views_valid || (xr_frame.view_state_flags & XR_VIEW_STATE_ORIENTATION_VALID_BIT) == 0 ||
(xr_frame.view_state_flags & XR_VIEW_STATE_POSITION_VALID_BIT) == 0) {
return false;
}
const auto& head = xr_frame.views[0].pose.orientation;
const Quaternion orientation = Normalize({head.x, head.y, head.z, head.w});
const std::array<float, 3> center = CenterPosition(xr_frame);
const std::array<float, 3> ahead =
Rotate(orientation, {0.0f, 0.0f, -std::max(0.25f, frame.presentation.quad_distance_meters)});
pose.orientation = {orientation.x, orientation.y, orientation.z, orientation.w};
pose.position = {center[0] + ahead[0], center[1] + ahead[1], center[2] + ahead[2]};
return true;
}
void ApplyPendingReferenceSpaceChange(const OpenXRFrame& frame) noexcept {
if (runtime_->ConsumeAppSpaceChangesThrough(frame.predicted_display_time)) {
ResetTrackingOrigin();
+52
View File
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "vr/openxr_settings_panel.h"
#include <atomic>
#include <mutex>
namespace mkw::vr {
// Named rather than anonymous: runtime sources are unity-built in groups.
namespace settings_panel_bridge {
std::atomic_bool g_open{false};
struct PublishedPointer {
std::mutex mutex;
OpenXRSettingsPanelPointer pointer{};
};
PublishedPointer& Published() {
static PublishedPointer published;
return published;
}
} // namespace settings_panel_bridge
void OpenXRSetSettingsPanelOpen(bool open) noexcept {
settings_panel_bridge::g_open.store(open, std::memory_order_release);
}
bool OpenXRSettingsPanelOpen() noexcept {
return settings_panel_bridge::g_open.load(std::memory_order_acquire);
}
void OpenXRPublishSettingsPanelPointer(bool valid, float x, float y, bool select, float wheel) noexcept {
auto& published = settings_panel_bridge::Published();
std::lock_guard lock(published.mutex);
published.pointer.valid = valid;
published.pointer.x = x;
published.pointer.y = y;
published.pointer.select = select;
published.pointer.wheel += wheel;
}
OpenXRSettingsPanelPointer OpenXRTakeSettingsPanelPointer() noexcept {
auto& published = settings_panel_bridge::Published();
std::lock_guard lock(published.mutex);
OpenXRSettingsPanelPointer pointer = published.pointer;
published.pointer.wheel = 0.0f;
return pointer;
}
} // namespace mkw::vr
+190
View File
@@ -0,0 +1,190 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The in-headset settings panel's controller handling, tested without a
// headset: the thumbstick chord that opens and closes it, the release latch that
// keeps a closing press out of the game, selection, scrolling, and where a hit
// lands on the panel's canvas.
#include "vr/openxr_settings_panel.h"
#include <cmath>
#include <iostream>
namespace {
using namespace mkw::vr;
using namespace mkw::vr::settings_panel;
int g_failures = 0;
void Check(bool condition, const char* what) {
if (!condition) {
++g_failures;
std::cerr << "FAILED: " << what << '\n';
}
}
void CheckNear(float actual, float expected, const char* what, float tolerance = 1.0e-3f) {
if (!(std::fabs(actual - expected) <= tolerance)) {
++g_failures;
std::cerr << "FAILED: " << what << " (expected " << expected << ", got " << actual << ")\n";
}
}
constexpr float kDt = 1.0f / 90.0f;
std::array<HandInputs, 2> Released() {
return {};
}
std::array<HandInputs, 2> Chord() {
std::array<HandInputs, 2> hands{};
hands[0].thumbstick_click = true;
hands[1].thumbstick_click = true;
return hands;
}
void ChordOpensAndClosesOnce() {
Controls controls;
bool open = false;
auto one = Released();
one[0].thumbstick_click = true;
Frame frame = controls.Update(one, open, kDt);
Check(!open && !frame.withheld, "one thumbstick click alone neither opens nor withholds");
frame = controls.Update(Chord(), open, kDt);
Check(open && frame.open && frame.withheld, "clicking both thumbsticks opens the panel");
frame = controls.Update(Chord(), open, kDt);
Check(open, "holding the chord does not toggle again");
frame = controls.Update(Released(), open, kDt);
Check(open && frame.withheld, "the panel stays open and keeps the controllers after the chord is released");
frame = controls.Update(Chord(), open, kDt);
Check(!open && !frame.open, "the chord closes the panel again");
Check(frame.withheld, "the closing chord is still withheld from the game");
frame = controls.Update(Released(), open, kDt);
Check(!frame.withheld, "the game gets the controllers back once everything is released");
}
void MenuClosesAndItsPressStaysOutOfTheGame() {
Controls controls;
bool open = false;
controls.Update(Chord(), open, kDt);
controls.Update(Released(), open, kDt);
auto menu = Released();
menu[0].menu = true;
Frame frame = controls.Update(menu, open, kDt);
Check(!open, "the left menu button closes an open panel");
frame = controls.Update(menu, open, kDt);
Check(frame.withheld, "HOME held across the close does not reach the game");
Check(!open, "a held menu button does not reopen the panel");
frame = controls.Update(Released(), open, kDt);
Check(!frame.withheld, "released, the controllers go back to the game");
frame = controls.Update(menu, open, kDt);
Check(!open && !frame.withheld, "with the panel closed the menu button is the game's");
}
void SelectWaitsForAReleaseAndFollowsTheTrigger() {
Controls controls;
bool open = false;
// Opened from the settings bar while a trigger is held for the game.
auto trigger = Released();
trigger[1].trigger = 1.0f;
controls.Update(trigger, open, kDt);
open = true;
Frame frame = controls.Update(trigger, open, kDt);
Check(frame.open && !frame.select, "a trigger held from before the panel opened does not click");
frame = controls.Update(Released(), open, kDt);
Check(!frame.select, "nothing held, nothing selected");
frame = controls.Update(trigger, open, kDt);
Check(frame.select && frame.pointing_hand == 1, "a fresh right trigger selects and points with the right hand");
auto left = Released();
left[0].trigger = 0.9f;
controls.Update(Released(), open, kDt);
frame = controls.Update(left, open, kDt);
Check(frame.select && frame.pointing_hand == 0, "pulling the left trigger hands the pointer to the left hand");
auto button = Released();
button[1].primary = true;
controls.Update(Released(), open, kDt);
frame = controls.Update(button, open, kDt);
Check(frame.select && frame.pointing_hand == 0, "A selects without moving the pointer to another hand");
}
void ThumbstickScrolls() {
Controls controls;
bool open = true;
controls.Update(Released(), open, kDt);
auto small = Released();
small[1].stick_y = 0.2f;
Check(controls.Update(small, open, kDt).wheel == 0.0f, "a resting thumbstick does not scroll");
auto up = Released();
up[1].stick_y = 1.0f;
CheckNear(controls.Update(up, open, 0.5f).wheel, kScrollStepsPerSecond * 0.1f,
"full deflection scrolls up at the full rate, with a long frame clamped");
auto down = Released();
down[0].stick_y = -1.0f;
down[1].stick_y = 0.3f;
Check(controls.Update(down, open, kDt).wheel < 0.0f, "the more deflected stick decides the direction");
bool closed = false;
Controls idle;
Check(idle.Update(up, closed, kDt).wheel == 0.0f, "a closed panel does not scroll");
}
void HitsMapOntoTheCanvas() {
wii_remote::ScreenHit hit{};
hit.valid = true;
hit.u = -1.0f;
hit.v = 1.0f;
auto point = CanvasPoint(hit);
CheckNear(point[0], 0.0f, "the panel's left edge is canvas x 0");
CheckNear(point[1], 0.0f, "the panel's top edge is canvas y 0");
hit.u = 1.0f;
hit.v = -1.0f;
point = CanvasPoint(hit);
CheckNear(point[0], kSettingsPanelWidthPixels, "the right edge is the canvas width");
CheckNear(point[1], kSettingsPanelHeightPixels, "the bottom edge is the canvas height");
const auto extents = HalfExtents(2.4f);
CheckNear(extents[0], 0.9f, "three quarters of a 2.4 m screen is 1.8 m across");
CheckNear(extents[1], 0.675f, "and keeps the canvas's 4:3");
}
void BridgeAccumulatesWheelUntilTaken() {
OpenXRSetSettingsPanelOpen(true);
Check(OpenXRSettingsPanelOpen(), "the open flag is shared");
OpenXRPublishSettingsPanelPointer(true, 10.0f, 20.0f, false, 0.5f);
OpenXRPublishSettingsPanelPointer(true, 11.0f, 21.0f, true, 0.25f);
OpenXRSettingsPanelPointer pointer = OpenXRTakeSettingsPanelPointer();
Check(pointer.valid && pointer.select, "the latest pointer state is read");
CheckNear(pointer.x, 11.0f, "the latest x is read");
CheckNear(pointer.wheel, 0.75f, "wheel steps from every XR frame are kept");
pointer = OpenXRTakeSettingsPanelPointer();
CheckNear(pointer.wheel, 0.0f, "taking the pointer consumes its wheel steps");
OpenXRSetSettingsPanelOpen(false);
Check(!OpenXRSettingsPanelOpen(), "and can be cleared again");
}
} // namespace
int main() {
ChordOpensAndClosesOnce();
MenuClosesAndItsPressStaysOutOfTheGame();
SelectWaitsForAReleaseAndFollowsTheTrigger();
ThumbstickScrolls();
HitsMapOntoTheCanvas();
BridgeAccumulatesWheelUntilTaken();
if (g_failures != 0) {
std::cerr << g_failures << " check(s) failed\n";
return 1;
}
std::cout << "vr_settings_panel_tests: all checks passed\n";
return 0;
}