From b2353e141065136becafa4bd17e174b8b2394e3f Mon Sep 17 00:00:00 2001 From: iChris4 Date: Wed, 23 Sep 2026 15:28:38 +0200 Subject: [PATCH] Fix for SDL stalling the pacing thread --- OPENXR.md | 11 +++ runtime/include/vr/openxr_input.h | 10 +- runtime/include/vr/openxr_integration.h | 7 ++ runtime/src/hle/input/pad.cpp | 4 + runtime/src/settings_overlay.cpp | 3 + runtime/src/vr/openxr_input.cpp | 125 +++++++++++++++++++----- runtime/src/vr/openxr_integration.cpp | 6 ++ 7 files changed, 141 insertions(+), 25 deletions(-) diff --git a/OPENXR.md b/OPENXR.md index 50666e8..9b2763e 100644 --- a/OPENXR.md +++ b/OPENXR.md @@ -167,6 +167,17 @@ thread (`runtime/src/vr/openxr_input.cpp`), which feeds a virtual SDL gamepad th to a port like any other. `controller_mode` decides what the game finds on that port, and is live from **F10 > VR > VR controllers**; the game sees a change as a controller reconnection. +The pacing thread only publishes that gamepad; the game thread writes it to SDL where it already +polls controllers (`OpenXRApplyControllerState`, called from `PAD__Read_HLE` and the overlay's +per-frame work). SDL holds its joystick lock for the length of a device enumeration, and the +Bluetooth Wii Remote rescan (**F10 > Controller settings > Keep scanning**, `wii_continuous_scan`, +off by default) makes SDL close and reopen every HID device twice per scan. Measured at 15 ms on a +plain desk and over 200 ms with a Lighthouse setup's dongles on the bus, which is why the pacing +thread must not wait on it: a frame it holds open that long costs the compositor every display slot +that passes, and `[xr-diag]` reports it as a stalled, late frame with skipped display slots. That +rescan still pauses the *game* thread for as long, so leave it off unless a real Wii Remote is in +use. + `"wii_remote"`, the default, presents them as a Wii Remote with a Nunchuk, the way DolphinXR's OpenXR Wii Remote does, with buttons adapted from its default `OpenXR Wii Remote` profile for the Touch controllers. The port is served through KPAD like a Bluetooth remote diff --git a/runtime/include/vr/openxr_input.h b/runtime/include/vr/openxr_input.h index ed72e91..28292cc 100644 --- a/runtime/include/vr/openxr_input.h +++ b/runtime/include/vr/openxr_input.h @@ -76,8 +76,10 @@ struct OpenXRPointerScreen { // 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 -// run on the XR pacing thread; SDL's virtual joystick setters and the Wii -// Remote bridge are internally locked, so the game thread may read concurrently. +// run on the XR pacing thread. The Wii Remote bridge is internally locked, so +// the game thread may read it concurrently, and the virtual gamepad is only +// published here: OpenXRApplyVirtualGamepad() performs the SDL writes on the +// game thread, keeping SDL's joystick lock off this thread entirely. class OpenXRInput final { public: explicit OpenXRInput(OpenXRLogCallback logger = {}); @@ -185,6 +187,10 @@ private: std::string m_last_error; }; +// Game thread: writes the gamepad the pacing thread last published, if any. +// Does nothing when no OpenXR controllers are attached. +void OpenXRApplyVirtualGamepad() noexcept; + } // namespace mkw::vr #endif // defined(MKW_ENABLE_OPENXR) diff --git a/runtime/include/vr/openxr_integration.h b/runtime/include/vr/openxr_integration.h index 2e9be00..8b36204 100644 --- a/runtime/include/vr/openxr_integration.h +++ b/runtime/include/vr/openxr_integration.h @@ -34,6 +34,13 @@ void OpenXRShutdownBeforeAurora() noexcept; void OpenXRServiceProducerFrameBoundary() noexcept; bool OpenXRIsRunning() noexcept; + +// Writes the controllers the pacing thread last published to the virtual +// gamepad. Call it from the thread that polls controllers, wherever the game +// is about to read them: the pacing thread deliberately leaves SDL alone, since +// SDL's joystick lock is held for the length of a device enumeration and an +// OpenXR frame may not wait that long. Cheap and safe to call when VR is off. +void OpenXRApplyControllerState() noexcept; std::string OpenXRLastError(); // Recenters on where the player is sitting now: it moves the immersive race diff --git a/runtime/src/hle/input/pad.cpp b/runtime/src/hle/input/pad.cpp index 41a2bfd..b06e6cd 100644 --- a/runtime/src/hle/input/pad.cpp +++ b/runtime/src/hle/input/pad.cpp @@ -4,6 +4,7 @@ #include "input_bindings.h" #include "physical_wheel.h" #include "vr/mkw_vr_policy.h" +#include "vr/openxr_integration.h" #include "wii_remote_input.h" #include @@ -118,6 +119,9 @@ extern "C" uint32_t PAD__Read_HLE(uint32_t statusPtr) PADStatus statuses[PAD_CHANMAX]{}; // Keep looking for a Bluetooth Wii Remote that dropped out (or was turned on late). WiiRemoteInput::Poll(); + // The VR controllers the pacing thread published reach SDL here, on the + // thread that polls controllers, rather than from the pacing thread itself. + mkw::vr::OpenXRApplyControllerState(); uint32_t rumbleMask = PADRead(statuses); // Wii Remotes reach the game through KPAD, not as GameCube pads. This also // applies while input is blocked (overlay open) so the port does not flip diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 5e32635..0a6276b 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -2407,6 +2407,9 @@ void Draw() noexcept { // its "communications interrupted" prompt without polling pads). Same guest // thread as PADRead, so no concurrent access to the scanner's state. WiiRemoteInput::Poll(); + // Likewise for the VR controllers' gamepad, so it keeps being written even + // while the game is not reading pads. + mkw::vr::OpenXRApplyControllerState(); if (g_firstPersonToggleRequested.exchange(false, std::memory_order_acq_rel)) { ToggleFirstPersonCamera(); } diff --git a/runtime/src/vr/openxr_input.cpp b/runtime/src/vr/openxr_input.cpp index d6f1b58..f1ad009 100644 --- a/runtime/src/vr/openxr_input.cpp +++ b/runtime/src/vr/openxr_input.cpp @@ -23,9 +23,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -38,6 +40,81 @@ namespace mkw::vr { namespace { +// SDL holds its joystick lock for as long as an enumeration takes, and the +// Bluetooth Wii Remote rescan (F10 > Controller settings > Keep scanning) +// closes and reopens every HID device twice per scan: 15 ms on a plain desk, +// over 200 ms on a machine carrying several HID devices, such as a Lighthouse +// setup's base-station dongles. The pacing thread must never wait on that, +// because the OpenXR frame it holds open costs the compositor every display +// slot that passes. So it leaves the gamepad here, and the game thread writes +// it to SDL where it already polls controllers. +// +// m_sdl is the lock the SDL work runs under; the pacing thread only ever takes +// m_state, and only for the copy. Both are taken in that order. +class VirtualGamepadRelay { +public: + struct Pad { + std::array axes{}; + std::array buttons{}; + }; + + void Attach(SDL_Joystick* joystick) { + std::scoped_lock lock(m_sdl, m_state); + m_joystick = joystick; + m_pending = false; + } + + void Detach() { + std::scoped_lock lock(m_sdl, m_state); + m_joystick = nullptr; + m_pending = false; + } + + // Pacing thread. + void Publish(const Pad& pad) { + std::lock_guard lock(m_state); + m_pad = pad; + m_pending = true; + } + + // Game thread. Holding m_sdl here is what keeps Detach from closing the + // joystick underneath the writes. + void Apply() { + std::lock_guard sdl(m_sdl); + Pad pad; + SDL_Joystick* joystick = nullptr; + { + std::lock_guard lock(m_state); + if (!m_pending || m_joystick == nullptr) { + return; + } + pad = m_pad; + joystick = m_joystick; + m_pending = false; + } + for (int axis = 0; axis < SDL_GAMEPAD_AXIS_COUNT; ++axis) { + SDL_SetJoystickVirtualAxis(joystick, static_cast(axis), + pad.axes[static_cast(axis)]); + } + for (int button = 0; button < SDL_GAMEPAD_BUTTON_COUNT; ++button) { + SDL_SetJoystickVirtualButton(joystick, static_cast(button), + pad.buttons[static_cast(button)]); + } + } + +private: + std::mutex m_sdl; + std::mutex m_state; + SDL_Joystick* m_joystick = nullptr; + Pad m_pad; + bool m_pending = false; +}; + +VirtualGamepadRelay& Relay() { + static VirtualGamepadRelay relay; + return relay; +} + #if defined(__ANDROID__) // Debug-only remote button presses for headset experiments driven over adb, so // a menu can be reached without someone wearing the headset: @@ -407,6 +484,10 @@ XrTime OpenXRInput::InputSampleTime(XrTime predicted_display_time) const { return now > 0 ? (std::min)(predicted_display_time, now) : predicted_display_time; } +void OpenXRApplyVirtualGamepad() noexcept { + Relay().Apply(); +} + bool OpenXRInput::AttachVirtualGamepad() { SDL_VirtualJoystickDesc desc; SDL_INIT_INTERFACE(&desc); @@ -438,10 +519,13 @@ bool OpenXRInput::AttachVirtualGamepad() { } m_joystick_id = id; m_joystick = joystick; + Relay().Attach(joystick); return true; } void OpenXRInput::DetachVirtualGamepad() { + // Before the handle goes: the game thread may be writing through it. + Relay().Detach(); if (m_joystick != nullptr) { SDL_CloseJoystick(static_cast(m_joystick)); m_joystick = nullptr; @@ -503,13 +587,7 @@ void OpenXRInput::Idle() { StopRumble(); // Nothing stays held on the gamepad either while input is away. if (m_joystick != nullptr) { - auto* joystick = static_cast(m_joystick); - for (int axis = 0; axis < SDL_GAMEPAD_AXIS_COUNT; ++axis) { - SDL_SetJoystickVirtualAxis(joystick, axis, 0); - } - for (int button = 0; button < SDL_GAMEPAD_BUTTON_COUNT; ++button) { - SDL_SetJoystickVirtualButton(joystick, button, false); - } + Relay().Publish({}); } } @@ -648,24 +726,25 @@ void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen& const auto injected = [&panel](const char* button) { return !panel.withheld && Injected(button); }; if (m_joystick != nullptr) { - auto* joystick = static_cast(m_joystick); + VirtualGamepadRelay::Pad pad; // OpenXR thumbsticks report +Y up; SDL gamepads report +Y down. - SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_LEFTX, ToAxis(left.stick_x)); - SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_LEFTY, ToAxis(-left.stick_y)); - SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_RIGHTX, ToAxis(right.stick_x)); - SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_RIGHTY, ToAxis(-right.stick_y)); - SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_LEFT_TRIGGER, ToTrigger(left.trigger)); - SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, ToTrigger(right.trigger)); + pad.axes[SDL_GAMEPAD_AXIS_LEFTX] = ToAxis(left.stick_x); + pad.axes[SDL_GAMEPAD_AXIS_LEFTY] = ToAxis(-left.stick_y); + pad.axes[SDL_GAMEPAD_AXIS_RIGHTX] = ToAxis(right.stick_x); + pad.axes[SDL_GAMEPAD_AXIS_RIGHTY] = ToAxis(-right.stick_y); + pad.axes[SDL_GAMEPAD_AXIS_LEFT_TRIGGER] = ToTrigger(left.trigger); + pad.axes[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_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); + pad.buttons[SDL_GAMEPAD_BUTTON_SOUTH] = right.primary || injected("a"); + pad.buttons[SDL_GAMEPAD_BUTTON_EAST] = right.secondary || injected("b"); + pad.buttons[SDL_GAMEPAD_BUTTON_WEST] = left.primary || injected("x"); + pad.buttons[SDL_GAMEPAD_BUTTON_NORTH] = left.secondary || injected("y"); + pad.buttons[SDL_GAMEPAD_BUTTON_START] = left.menu || injected("start"); + pad.buttons[SDL_GAMEPAD_BUTTON_LEFT_STICK] = left.thumbstick_click; + pad.buttons[SDL_GAMEPAD_BUTTON_RIGHT_STICK] = right.thumbstick_click; + pad.buttons[SDL_GAMEPAD_BUTTON_LEFT_SHOULDER] = left.squeeze > 0.5f; + pad.buttons[SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER] = right.squeeze > 0.5f; + Relay().Publish(pad); } PublishWiiRemote(input_time, screen, game_hands, panel.withheld ? 0u : InjectedWiiRemoteButtons(), diff --git a/runtime/src/vr/openxr_integration.cpp b/runtime/src/vr/openxr_integration.cpp index 8c50d3d..a9a891a 100644 --- a/runtime/src/vr/openxr_integration.cpp +++ b/runtime/src/vr/openxr_integration.cpp @@ -1768,6 +1768,12 @@ bool OpenXRIsRunning() noexcept { #endif } +void OpenXRApplyControllerState() noexcept { +#if MKW_OPENXR_GRAPHICS_BACKEND + OpenXRApplyVirtualGamepad(); +#endif +} + void OpenXRRequestRecenter() noexcept { #if MKW_OPENXR_GRAPHICS_BACKEND OpenXRIntegration::Get().RequestRecenter();