diff --git a/OPENXR.md b/OPENXR.md index e903e6d..c5ccf87 100644 --- a/OPENXR.md +++ b/OPENXR.md @@ -48,6 +48,7 @@ hud_virtual_screen = true stop_at_display_copy = true skip_copy_clears = true first_person = false +first_person_toggle_click = true first_person_seat = "cockpit" cockpit_units_per_meter = 100.0 first_person_units_per_meter = 50.0 @@ -173,11 +174,12 @@ Touch controllers. The port is served through KPAD like a Bluetooth remote | Left trigger | Z | | Left grip | C | | Left Y | Settings panel (not a Wii button) | +| Right stick click | First-person camera on / off (not a Wii button) | | Right controller motion and aim | Wii Remote accelerometer and pointer | | Left controller motion | Nunchuk accelerometer | -Analog inputs count as pressed past half travel. Right B, right stick left / right and the stick -clicks are unbound, and no controller button presses HOME. The game's Wii Remote rumble vibrates +Analog inputs count as pressed past half travel. Right B, right stick left / right and the left +stick click are unbound, and no controller button presses HOME. The game's Wii Remote rumble vibrates both controllers, subject to the ordinary controller-vibration switch. **Motion.** Each XR frame the aim and grip poses are located at the measured current time @@ -221,7 +223,7 @@ own motion detection still reads them, so a sharp enough turn can read as a shak A/B → South/East, X/Y → West/North, index triggers → trigger axes, grips → shoulders, thumbsticks → sticks (clicks → stick buttons), left menu → Start. Every binding in the F10 controller menu applies. Left Y is GameCube Y here, so clicking both thumbsticks together opens the settings panel -instead. +instead. The right thumbstick click on its own still toggles the first-person camera. Bindings are suggested for `oculus/touch_controller` (Quest 2, 3 and Pro) and `khr/simple_controller`. `mkw_vr_wii_remote_tests` checks the accelerometer frame, the pointer @@ -289,6 +291,17 @@ to the local driver's head instead, at one of two seats: Both are a matter of taste rather than properties of the game, so the F10 bar exposes them. +**Toggling it from a controller.** Clicking the right thumbstick turns first person on or off +exactly as the F10 checkbox does, and the choice is saved the same way. It works on the VR +controllers in either presentation (the right controller gives a short tick), and on any other +gamepad while VR is running. A click counts on release, and only if the left thumbstick stayed up +and the settings panel stayed closed throughout, so clicking both thumbsticks to open the panel in +gamepad mode never toggles the camera. A gamepad whose right thumbstick click is bound to a +GameCube control on its port, as a button or in an input expression, keeps it for the game instead. +Toggled in a menu, the change applies from the next race. `first_person_toggle_click = false`, or +the F10 checkbox under the camera toggle, turns the click off. `mkw_vr_camera_toggle_tests` covers +the click rule. + The kart is selected through the game's local-screen-to-racer mapping, including online races where your racer is not slot zero. First person requires a locally controlled racer; spectating another racer keeps the game's own camera. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index b40e302..b2e1636 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -410,7 +410,7 @@ add_test(NAME mkw_vr_first_person_tests COMMAND mkw_vr_first_person_tests) # The first-person cockpit (seated eye, wheel and handlebar geometry, level seat, # native wheel vertices) and hand steering (grab, turn, hand-off to the game), # ported from heurazy's mario-kart-wii-VR-port. All header-only. -foreach(test_name mkw_steering_wheel_tests mkw_vr_cockpit_tests mkw_vr_hand_steering_tests) +foreach(test_name mkw_steering_wheel_tests mkw_vr_cockpit_tests mkw_vr_hand_steering_tests mkw_vr_camera_toggle_tests) string(REGEX REPLACE "^mkw_" "" test_source "${test_name}") add_executable(${test_name} "${CMAKE_CURRENT_LIST_DIR}/tests/${test_source}.cpp") target_include_directories(${test_name} PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index fa68da7..724f260 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -63,6 +63,7 @@ struct RuntimeUserConfig { std::optional vrControllerMode; std::optional vrFrameInterpolationFps; std::optional vrFirstPerson; + std::optional vrFirstPersonToggleClick; std::optional vrFirstPersonUnitsPerMeter; std::optional vrFirstPersonHeadUpMeters; std::optional vrFirstPersonHeadForwardMeters; @@ -502,6 +503,9 @@ inline void EnsureConfigFile() { "# the kart, with the horizon kept level. Changeable live from the\n" "# F10 menu, and only during a single-screen race.\n" "first_person = false\n" + "# Clicking the right thumbstick, on the VR controllers or on any\n" + "# gamepad while VR runs, toggles first_person as the F10 checkbox does.\n" + "first_person_toggle_click = true\n" "# Where the head sits: \"cockpit\" puts it at the driver's own eyes,\n" "# behind the steering wheel, at a life-size scale that allows for\n" "# the character's height, so the wheel is within reach.\n" @@ -742,6 +746,7 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { config.vrStopAtDisplayCopy = FindConfigValue(document, "vr", "stop_at_display_copy"); config.vrSkipCopyClears = FindConfigValue(document, "vr", "skip_copy_clears"); config.vrFirstPerson = FindConfigValue(document, "vr", "first_person"); + config.vrFirstPersonToggleClick = FindConfigValue(document, "vr", "first_person_toggle_click"); if (auto value = FindConfigFloat(document, "vr", "first_person_units_per_meter"); value && *value >= 1.0f && *value <= 10000.0f) { config.vrFirstPersonUnitsPerMeter = *value; @@ -1066,6 +1071,11 @@ inline bool SetVrFirstPerson(bool value) { return WriteSetting("vr", "first_person", value ? "true" : "false"); } +inline bool SetVrFirstPersonToggleClick(bool value) { + Mutable().vrFirstPersonToggleClick = value; + return WriteSetting("vr", "first_person_toggle_click", value ? "true" : "false"); +} + inline bool SetVrFirstPersonUnitsPerMeter(float value) { value = std::clamp(value, 1.0f, 10000.0f); Mutable().vrFirstPersonUnitsPerMeter = value; @@ -1510,6 +1520,10 @@ inline bool VrFirstPerson(bool fallback = false) { return Get().vrFirstPerson.value_or(fallback); } +inline bool VrFirstPersonToggleClick(bool fallback = true) { + return Get().vrFirstPersonToggleClick.value_or(fallback); +} + inline float VrFirstPersonUnitsPerMeter(float fallback = kVrFirstPersonUnitsPerMeterDefault) { return std::clamp(Get().vrFirstPersonUnitsPerMeter.value_or(fallback), 1.0f, 10000.0f); } diff --git a/runtime/include/settings_overlay.h b/runtime/include/settings_overlay.h index d34e2e3..1d702c0 100644 --- a/runtime/include/settings_overlay.h +++ b/runtime/include/settings_overlay.h @@ -16,6 +16,10 @@ void AdvancePresentedFrame() noexcept; // converted with the world scale currently in effect, so switching the // first-person camera on or off has to repeat it. void RefreshVrHudVirtualScreen() noexcept; +// Flips the first-person camera exactly as its F10 checkbox does, on the game +// thread at the next frame. Callable from any thread (the VR controllers' +// right-thumbstick click). +void RequestFirstPersonToggle() noexcept; // Put host controllers back to a neutral state before the process ends. void ReleaseControllers() noexcept; } // namespace settings_overlay diff --git a/runtime/include/vr/camera_toggle.h b/runtime/include/vr/camera_toggle.h new file mode 100644 index 0000000..d286f3c --- /dev/null +++ b/runtime/include/vr/camera_toggle.h @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +// The right thumbstick click that toggles the first-person camera, on the VR +// controllers and on any gamepad while VR runs (F10 > VR > +// first_person_toggle_click). Kept free of OpenXR and SDL so it is tested +// headlessly (tests/vr_camera_toggle_tests.cpp). + +namespace mkw::vr { + +// One clean click of a button: it fires on release, and only if its partner +// (the other thumbstick, which with it opens the headset settings panel on a +// gamepad) stayed up and nothing owned the controllers at any point during the +// press. Firing on release is what lets a two-stick chord pass untouched. +class ClickToggle { +public: + bool Update(bool held, bool partner_held, bool blocked) noexcept { + if (held) { + if (!held_) { + held_ = true; + spoiled_ = partner_held || blocked; + } else if (partner_held || blocked) { + spoiled_ = true; + } + return false; + } + const bool fire = held_ && !spoiled_ && !blocked; + held_ = false; + spoiled_ = false; + return fire; + } + void Reset() noexcept { + held_ = false; + spoiled_ = false; + } + bool Held() const noexcept { return held_; } + +private: + bool held_ = false; + bool spoiled_ = false; +}; + +} // namespace mkw::vr diff --git a/runtime/include/vr/openxr_input.h b/runtime/include/vr/openxr_input.h index 97b70a3..ed72e91 100644 --- a/runtime/include/vr/openxr_input.h +++ b/runtime/include/vr/openxr_input.h @@ -4,6 +4,7 @@ #if defined(MKW_ENABLE_OPENXR) +#include "vr/camera_toggle.h" #include "vr/openxr_driving.h" #include "vr/openxr_runtime.h" #include "vr/openxr_settings_panel.h" @@ -64,6 +65,9 @@ struct OpenXRPointerScreen { // the left stick's X axis in both presentations and that grip no longer reaches // the game (C on the Nunchuk, a shoulder on the gamepad). // +// A right-thumbstick click on its own toggles the first-person camera, as its +// F10 checkbox does (first_person_toggle_click). +// // Left Y (both thumbsticks clicked together as a gamepad) 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 @@ -166,6 +170,7 @@ private: bool m_haptics_active[kHands]{}; uint32_t m_joystick_id = 0; // SDL_JoystickID; 0 when detached void* m_joystick = nullptr; // SDL_Joystick* + ClickToggle m_first_person_click; SteeringWheel m_wheel; WheelReferenceLatch m_wheel_reference; driving::WheelVisual m_wheel_visual; diff --git a/runtime/include/vr/openxr_wii_remote.h b/runtime/include/vr/openxr_wii_remote.h index ddf4c7f..0bf5f11 100644 --- a/runtime/include/vr/openxr_wii_remote.h +++ b/runtime/include/vr/openxr_wii_remote.h @@ -48,6 +48,9 @@ OpenXRControllerMode OpenXRGetControllerMode() noexcept; // Guest side. True when `sdl_joystick_id` is the OpenXR virtual gamepad and the // controllers are currently presented as a Wii Remote. bool OpenXRWiiRemoteOwnsGamepad(uint32_t sdl_joystick_id) noexcept; +// True when `sdl_joystick_id` is the VR controllers' virtual gamepad, in either +// presentation. +bool OpenXRIsControllerGamepad(uint32_t sdl_joystick_id) noexcept; // Latest published sample; false before the first one or after withdrawal. bool OpenXRReadWiiRemote(OpenXRWiiRemoteSample& sample) noexcept; // WPADControlMotor for the emulated remote. diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index d8f67b0..dbd932a 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -11,6 +11,7 @@ #include "music_attenuation.h" #include "runtime_config.h" #include "runtime_log.h" +#include "vr/camera_toggle.h" #include "vr/mkw_vr_first_person.h" #include "vr/mkw_vr_policy.h" #include "vr/openxr_diagnostics.h" @@ -33,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -140,6 +142,11 @@ bool g_vrStopAtDisplayCopy = RuntimeConfigFile::VrStopAtDisplayCopy(true); bool g_vrSkipCopyClears = RuntimeConfigFile::VrSkipCopyClears(true); bool g_vrHudVirtualScreen = RuntimeConfigFile::VrHudVirtualScreen(true); bool g_vrFirstPerson = RuntimeConfigFile::VrFirstPerson(false); +bool g_vrFirstPersonToggleClick = RuntimeConfigFile::VrFirstPersonToggleClick(); +// Set from any thread by the right-thumbstick click, applied on the game thread. +std::atomic g_firstPersonToggleRequested{false}; +// Per physical gamepad; the VR controllers keep theirs on the XR side. +std::unordered_map g_gamepadFirstPersonClicks; float g_vrFirstPersonUnitsPerMeter = RuntimeConfigFile::VrFirstPersonUnitsPerMeter(); // 0 = cockpit, 1 = custom, matching kVrFirstPersonSeatNames. constexpr std::array kVrFirstPersonSeatNames{"cockpit", "custom"}; @@ -1477,6 +1484,12 @@ void DrawVrSettings() { RuntimeConfigFile::SetVrFirstPerson(g_vrFirstPerson); mkw::vr::MkwVRFirstPersonApplyConfiguredSettings(); } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Also toggled by clicking the right thumbstick, on the VR controllers or on a gamepad."); + } + if (ImGui::Checkbox("Right thumbstick click toggles it", &g_vrFirstPersonToggleClick)) { + RuntimeConfigFile::SetVrFirstPersonToggleClick(g_vrFirstPersonToggleClick); + } if (ImGui::IsItemHovered()) { ImGui::SetTooltip( "Moves the camera to the Player 1 driver's head and keeps the horizon level, " @@ -1614,6 +1627,65 @@ void DrawVrSettings() { } } +// The right-thumbstick click: flips the first-person camera exactly as its +// checkbox does. Game thread. +void ToggleFirstPersonCamera() { + g_vrFirstPerson = !g_vrFirstPerson; + RuntimeConfigFile::SetVrFirstPerson(g_vrFirstPerson); + mkw::vr::MkwVRFirstPersonApplyConfiguredSettings(); + RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] first-person camera " << (g_vrFirstPerson ? "on" : "off") + << " (right thumbstick click)" << std::endl; +} + +// A gamepad whose right thumbstick click reaches the game (bound to a +// GameCube control on its port) keeps it; toggling the camera as well would +// fire both. +bool RightStickDrivesGame(SDL_Gamepad* gamepad) { + if (gamepad == nullptr) { + return false; + } + for (uint32_t port = 0; port < PAD_MAX_CONTROLLERS; ++port) { + const s32 index = PADGetIndexForPort(port); + if (index < 0 || PADGetSDLGamepadForIndex(static_cast(index)) != gamepad) { + continue; + } + for (auto* mappings : {&PADGetButtonMappings, &PADGetAltButtonMappings}) { + u32 count = 0; + const PADButtonMapping* list = (*mappings)(port, &count); + for (u32 i = 0; list != nullptr && i < count; ++i) { + if (list[i].nativeButton == SDL_GAMEPAD_BUTTON_RIGHT_STICK) { + return true; + } + } + } + for (size_t control = 0; control < InputBindings::kControls.size(); ++control) { + const std::string expression = InputBindings::GetExpression(port, control); + if (expression.find("Thumb R") != std::string::npos || expression.find("Button 11") != std::string::npos) { + return true; + } + } + } + return false; +} + +// A physical gamepad's right-thumbstick click, while VR runs. +void HandleGamepadFirstPersonClick(const SDL_GamepadButtonEvent& event) { + const bool right = event.button == SDL_GAMEPAD_BUTTON_RIGHT_STICK; + const bool left = event.button == SDL_GAMEPAD_BUTTON_LEFT_STICK; + if ((!right && !left) || mkw::vr::OpenXRIsControllerGamepad(event.which)) { + return; + } + SDL_Gamepad* gamepad = SDL_GetGamepadFromID(event.which); + auto& click = g_gamepadFirstPersonClicks[event.which]; + const bool held = right ? event.down : click.Held(); + const bool partner = left ? event.down : gamepad != nullptr && SDL_GetGamepadButton(gamepad, SDL_GAMEPAD_BUTTON_LEFT_STICK); + const bool blocked = g_rebind.active || InputBindings::InputBlocked(); + if (click.Update(held, partner, blocked) && g_vrFirstPersonToggleClick && mkw::vr::OpenXRIsRunning() && + !RightStickDrivesGame(gamepad)) { + ToggleFirstPersonCamera(); + } +} + // Export Logs. SDL shows the folder picker without blocking the game and calls // back on a thread of its choosing (its own dialog thread on Windows), where the // copy then runs; the menu only reads the outcome through this state. @@ -2217,6 +2289,8 @@ void InitializeRuntimeSettings() noexcept { void RefreshVrHudVirtualScreen() noexcept { ApplyVrHudVirtualScreen(); } +void RequestFirstPersonToggle() noexcept { g_firstPersonToggleRequested.store(true, std::memory_order_release); } + void HandleEvents(const AuroraEvent* events) noexcept { if (!events) { return; @@ -2237,6 +2311,9 @@ void HandleEvents(const AuroraEvent* events) noexcept { CompleteRebind(g_rebind.kind == RebindKind::Controller ? PAD_NATIVE_BUTTON_DISABLED : static_cast(PAD_KEY_INVALID)); } + if (ev->sdl.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN || ev->sdl.type == SDL_EVENT_GAMEPAD_BUTTON_UP) { + HandleGamepadFirstPersonClick(ev->sdl.gbutton); + } if (!g_rebind.active && IsToggleKey(ev->sdl, SDL_SCANCODE_F10)) { SetTopBarVisible(!g_topBarVisible); } @@ -2312,6 +2389,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(); + if (g_firstPersonToggleRequested.exchange(false, std::memory_order_acq_rel)) { + ToggleFirstPersonCamera(); + } ApplyConfiguredMappings(); PersistDisplayModeIfChanged(); UpdateCursorAutoHide(); diff --git a/runtime/src/vr/openxr_input.cpp b/runtime/src/vr/openxr_input.cpp index 7bec497..d6f1b58 100644 --- a/runtime/src/vr/openxr_input.cpp +++ b/runtime/src/vr/openxr_input.cpp @@ -12,6 +12,7 @@ #include "vr/openxr_input.h" #include "physical_wheel.h" #include "runtime_config.h" +#include "settings_overlay.h" #include "vr/mkw_vr_first_person.h" #include "vr/openxr_diagnostics.h" @@ -497,6 +498,7 @@ void OpenXRInput::Idle() { m_last_input_time = 0; m_panel_select_held = false; OpenXRPublishSettingsPanelPointer(false, 0.0f, 0.0f, false, 0.0f); + m_first_person_click.Reset(); ResetDriving(); StopRumble(); // Nothing stays held on the gamepad either while input is away. @@ -623,6 +625,17 @@ void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen& OpenXRSetSettingsPanelOpen(open); } + // A clean right-thumbstick click toggles the first-person camera. It fires + // on release, so the two-thumbstick panel chord never toggles it, and + // never while the panel has the controllers. + if (m_first_person_click.Update(hands[1].thumbstick_click, hands[0].thumbstick_click, + panel.open || panel.withheld) && + RuntimeConfigFile::VrFirstPersonToggleClick()) { + settings_overlay::RequestFirstPersonToggle(); + constexpr XrDuration kToggleTickNs = 20'000'000; + ApplyHaptic(1, 0.35f, kToggleTickNs); + } + // The cockpit's wheel before the game reads the controllers: a held wheel // steers through the left stick and keeps its grips from the game. UpdateDriving(predicted_display_time, seat, hands, panel.withheld); diff --git a/runtime/src/vr/openxr_wii_remote.cpp b/runtime/src/vr/openxr_wii_remote.cpp index d2ce24f..ebe14de 100644 --- a/runtime/src/vr/openxr_wii_remote.cpp +++ b/runtime/src/vr/openxr_wii_remote.cpp @@ -45,6 +45,10 @@ bool OpenXRWiiRemoteOwnsGamepad(uint32_t sdl_joystick_id) noexcept { wii_remote_bridge::g_joystick_id.load(std::memory_order_relaxed) == sdl_joystick_id; } +bool OpenXRIsControllerGamepad(uint32_t sdl_joystick_id) noexcept { + return sdl_joystick_id != 0 && wii_remote_bridge::g_joystick_id.load(std::memory_order_relaxed) == sdl_joystick_id; +} + bool OpenXRReadWiiRemote(OpenXRWiiRemoteSample& sample) noexcept { auto& published = wii_remote_bridge::Published(); std::lock_guard lock(published.mutex); diff --git a/runtime/tests/vr_camera_toggle_tests.cpp b/runtime/tests/vr_camera_toggle_tests.cpp new file mode 100644 index 0000000..3f3084a --- /dev/null +++ b/runtime/tests/vr_camera_toggle_tests.cpp @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// The right-thumbstick click that toggles the first-person camera +// (vr/camera_toggle.h). + +#include "vr/camera_toggle.h" + +#include + +namespace { + +using mkw::vr::ClickToggle; + +int g_failures = 0; + +void Check(bool condition, const char* what) { + if (!condition) { + ++g_failures; + std::cerr << "FAILED: " << what << '\n'; + } +} + +} // namespace + +int main() { + ClickToggle click; + Check(!click.Update(true, false, false), "nothing on press"); + Check(!click.Update(true, false, false), "nothing while held"); + Check(click.Update(false, false, false), "a clean click fires on release"); + Check(!click.Update(false, false, false), "fires once"); + + // Both thumbsticks (the gamepad settings-panel chord), in either order. + click.Update(true, false, false); + click.Update(true, true, false); + Check(!click.Update(false, true, false), "right then left: the chord, no toggle"); + click.Update(false, true, false); + click.Update(true, true, false); + Check(!click.Update(false, false, false), "left then right: the chord, no toggle"); + + // The panel owned the controllers during the press. + click.Update(true, false, false); + click.Update(true, false, true); + Check(!click.Update(false, false, false), "blocked midway: no toggle"); + click.Update(true, false, false); + Check(!click.Update(false, false, true), "blocked at release: no toggle"); + + // Recovers for the next clean click. + click.Update(true, false, false); + Check(click.Update(false, false, false), "a later clean click fires again"); + + click.Update(true, false, false); + click.Reset(); + Check(!click.Update(false, false, false), "reset forgets a held press"); + + if (g_failures != 0) { + std::cerr << g_failures << " check(s) failed\n"; + return 1; + } + std::cout << "vr camera toggle tests passed\n"; + return 0; +}