Implement OpenXR Wii Remote support

This commit is contained in:
iChris4 committed 2026-09-16 23:58:04 +02:00
1 parent 75f0f5f26d
commit 19485b9e8b
18 files changed
+1622 -93

No files matched your search

+71 -3
View File
@@ -29,6 +29,7 @@ Standalone launches remain opt-in. `Config.toml` is created with the following d
enabled = false
required = false
mirror_view = "normal"
controller_mode = "wii_remote"
frame_interpolation_fps = 0
render_scale = 1.0
world_units_per_meter = 500.0
@@ -124,6 +125,72 @@ default on and can be changed live from the F10 settings bar for diagnostics.
`first_person` and the `first_person_*` values are the first-person camera described below. All
four are live and are also exposed in the F10 settings bar.
## Controllers
The headset's tracked controllers reach the game through an OpenXR action set synced on the pacing
thread (`runtime/src/vr/openxr_input.cpp`), which feeds a virtual SDL gamepad that Aurora assigns
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.
`"wii_remote"`, the default, presents them as a Wii Remote with a Nunchuk, the way DolphinXR's
OpenXR Wii Remote does, including its default `OpenXR Wii Remote` profile for the Touch
controllers. The port is served through KPAD like a Bluetooth remote (`wii_remote_input.cpp`), so
`WPADProbe` reports a Nunchuk and the game runs its own Wii Remote + Nunchuk control scheme:
| Controller | Wii |
| --- | --- |
| Right A | A |
| Right trigger | B |
| Right stick up / down | 1 / 2 |
| Right stick left / right | − / + |
| Left stick | Nunchuk stick |
| Left trigger | Z |
| Left grip | C |
| Left menu | HOME |
| 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, left X/Y and the stick clicks are unbound,
as in DolphinXR's profile. 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
(`XR_KHR_win32_convert_performance_counter_time`, `XR_KHR_convert_timespec_time` on Android), not
the predicted display time, whose extrapolation sprays fast wrist motion. The grip's linear
velocity, averaged with one derived from its position, is differentiated over XrTime into
acceleration; gravity is added and the result is expressed in the aim pose's frame. KPAD's
accelerometer axes are the aim pose's `(x, -y, z)` in g: a level controller reads `(0, -1, 0)`,
pointing at the floor `(0, 0, 1)`. Readings saturate at ±3.6 g like the remote's sensor, and a
controller that loses tracking repeats its last reading. The game's own motion detection (tricks,
wheelies) then works on these readings as it would on a remote's.
**Pointer.** The pointer is absolute, as in DolphinXR: the right controller's aim ray is intersected
with the screen the renderer is showing, and the point it meets is where the cursor goes, so there
is nothing to recenter. On the menu screen that is the quad layer, `hud_width_meters` across with
the eye texture's aspect, and the pointer spans the game picture inside it (Aurora letterboxes the
desktop image into the quad and the picture into the desktop image, so a 4:3 picture keeps its
pillarboxes). During a race it is the 2D layer's screen, `hud_distance_meters` ahead of the latched
race origin and turned by the lean-back angle, with the picture's aspect. With
`hud_virtual_screen = false` the race's 2D layer has no fixed place and the pointer is off. The
game's own pointer switch (`KPADEnableDpd` / `KPADDisableDpd`) is honoured as well.
The hit becomes KPAD's `pos` (−1..1 across the picture, +y down), `horizon` (the controller's roll
on the screen) and `dist` (perpendicular distance in metres, so rotating the controller does not
change it). Like a real remote's camera, the pointer keeps tracking up to 1.9 half-widths and 1.5
half-heights past the picture's centre; a lost hit or an excursion beyond that holds or pins the
cursor for 100 ms before it disappears, so tracking spikes during fast motion do not drop it.
Raw IR camera dots in `KPADGetUnifiedWpadStatus` stay invalid; the game reads the pointer from
`KPADStatus`.
`"gamepad"` keeps the controllers one ordinary gamepad read through PAD as a GameCube controller:
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.
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
raycast and debounce, the picture placement and the button profile without a headset.
## The first-person camera
By default the headset sits where Mario Kart's own chase camera sits, and `world_units_per_meter`
@@ -309,9 +376,10 @@ ends, including mid-frame flushes, so live setting changes cannot invalidate pen
## Current limitations
- Only the project's supported PAL `RMCP01` translation has race instrumentation addresses.
- Wii Remote pointer/motion emulation from tracked controllers is not implemented. OpenXR action
bindings exist only on the Android build, where they present the Touch controllers as one
ordinary gamepad; on Windows use the existing game-controller input path.
- The tracked controllers are always Player 1's Wii Remote; there is no left-handed swap, and only
the Touch and simple controller profiles have suggested bindings. The Wii Remote presentation
still needs headset validation: cursor direction and roll, trick/wheelie motion, rumble strength
and the HOME Menu.
- The Quest build (`android/`, `docs/quest-port.md`) runs on a Quest 3 through menus and races.
Lifecycle events and performance (about 43 game FPS) are still open. Apple visionOS packaging
is not implemented.
+9
View File
@@ -111,6 +111,15 @@ 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();
// Aspect ratios behind the most recent headset frame's 2D content, for mapping a
// point on a virtual screen back onto the game picture (the VR Wii Remote
// pointer). `pictureAspect` is the game picture's width over height, which the
// immersive HUD screen's height follows; `snapshotAspect` is the desktop
// presentation image's, which the virtual-screen eye texture letterboxes and
// the picture is letterboxed inside. False until a headset frame was encoded.
// Safe to call from any thread.
bool aurora_get_stereo_screen_aspects(float* pictureAspect, float* snapshotAspect);
// 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
+37
View File
@@ -1495,6 +1495,30 @@ void draw_mirror_eye(const wgpu::RenderPassEncoder& pass, uint32_t eyeIndex, con
pass.Draw(3);
}
// Read by aurora_get_stereo_screen_aspects from the XR input thread.
std::atomic<float> g_stereoPictureAspect{0.f};
std::atomic<float> g_stereoSnapshotAspect{0.f};
// Records the geometry a headset frame's 2D content was laid out with: the picture aspect exactly as
// encode_presentation_snapshot fits it into the snapshot (and, for immersive replay, as
// stereo_hud_screen sizes the HUD screen), and the snapshot's own aspect.
void publish_stereo_screen_aspects(const webgpu::PresentSource& presentSource, const wgpu::Extent3D& snapshotSize,
bool immersiveReplay) noexcept {
float picture = 0.f;
if (!window::get_present_aspect_ratio(picture) || !(picture > 0.f)) {
if (immersiveReplay) {
picture = 16.f / 9.f;
} else if (presentSource.size.width != 0 && presentSource.size.height != 0) {
picture = static_cast<float>(presentSource.size.width) / static_cast<float>(presentSource.size.height);
}
}
const float snapshot = snapshotSize.width != 0 && snapshotSize.height != 0
? static_cast<float>(snapshotSize.width) / static_cast<float>(snapshotSize.height)
: 0.f;
g_stereoPictureAspect.store(picture, std::memory_order_relaxed);
g_stereoSnapshotAspect.store(snapshot, std::memory_order_relaxed);
}
// `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,
@@ -1962,6 +1986,9 @@ std::vector<PresentationJob> encode_sealed_frame(gfx::SealedFrame& sealedFrame,
const bool virtualScreenNeedsMono = stereoOutput && !immersiveReplay;
encode_presentation_snapshot(encoder, ctx.presentSource, *finalImage, true,
virtualScreenNeedsMono ? MirrorPlan::Mono : mirrorPlan);
if (stereoOutput) {
publish_stereo_screen_aspects(ctx.presentSource, finalImage->texture.size, immersiveReplay);
}
if (virtualScreenNeedsMono) {
// Use the completed mono snapshot so virtual-screen XR includes ImGui at
@@ -2595,6 +2622,16 @@ 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(); }
bool aurora_get_stereo_screen_aspects(float* pictureAspect, float* snapshotAspect) {
const float picture = aurora::g_stereoPictureAspect.load(std::memory_order_relaxed);
const float snapshot = aurora::g_stereoSnapshotAspect.load(std::memory_order_relaxed);
if (pictureAspect == nullptr || snapshotAspect == nullptr || !(picture > 0.f) || !(snapshot > 0.f)) {
return false;
}
*pictureAspect = picture;
*snapshotAspect = snapshot;
return true;
}
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) {
+10 -7
View File
@@ -89,12 +89,15 @@ the bridge needs.
Quest Touch controllers are not HID gamepads, so `openxr_input.cpp` syncs an
OpenXR action set on the pacing thread and feeds a virtual SDL joystick
(`SDL_AttachVirtualJoystick`, type gamepad). Aurora opens it like any pad and
assigns it to player 1; every existing binding, dead zone and overlay setting
applies. Mapping: A/B → South/East, X/Y → West/North, index triggers → trigger
axes, grips → shoulders, thumbsticks → sticks (clicks → stick buttons), left
menu → Start. Bindings are suggested for `oculus/touch_controller` and
`khr/simple_controller`.
(`SDL_AttachVirtualJoystick`, type gamepad) that Aurora assigns to player 1.
By default (`[vr] controller_mode = "wii_remote"`) that port is then served
through KPAD as a Wii Remote with a Nunchuk, with motion and an IR pointer
aimed at the virtual screen; see "Controllers" in `OPENXR.md` for the mapping
and the geometry. With `controller_mode = "gamepad"` it stays an ordinary pad:
A/B → South/East, X/Y → West/North, index triggers → trigger axes, grips →
shoulders, thumbsticks → sticks (clicks → stick buttons), left menu → Start,
and every existing binding, dead zone and overlay setting applies. Bindings are
suggested for `oculus/touch_controller` and `khr/simple_controller`.
### Android platform glue
@@ -253,7 +256,7 @@ the app:
| --- | --- |
| `debug.wiicompiled.vtxpad 0` | Turns the stride padding off, to re-check a driver update |
| `debug.wiicompiled.validation 1` | Keeps WebGPU validation and robustness on in release builds |
| `debug.wiicompiled.inject <n>:<button>` | Presses `a`, `b`, `x`, `y`, `start`, `up`, `down`, `left` or `right` for 12 XR frames each time `<n>` changes |
| `debug.wiicompiled.inject <n>:<button>` | Presses `a`, `b`, `x`, `y`, `start`, `up`, `down`, `left` or `right` for 12 XR frames each time `<n>` changes. As a Wii Remote, `x`/`y`/`start` are 1/2/+, the directions push the Nunchuk stick, and `home`, `c` and `z` also exist |
| `debug.wiicompiled.fpslog 1` | Logs the game's rendered frame rate every 5 s. The compositor's `VrApi` log line gives headset FPS, `GPU%`, `CPU%` and app GPU time (`App=`) |
The injector makes headset tests possible with nobody wearing the headset.
+7
View File
@@ -397,6 +397,13 @@ target_include_directories(mkw_vr_first_person_tests PRIVATE "${CMAKE_CURRENT_LI
target_compile_features(mkw_vr_first_person_tests PRIVATE cxx_std_17)
add_test(NAME mkw_vr_first_person_tests COMMAND mkw_vr_first_person_tests)
# The VR controllers' Wii Remote presentation (accelerometer frame, pointer
# raycast, debounce, button profile) is header-only for the same reason.
add_executable(mkw_vr_wii_remote_tests "${CMAKE_CURRENT_LIST_DIR}/tests/vr_wii_remote_tests.cpp")
target_include_directories(mkw_vr_wii_remote_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
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)
# 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")
+32
View File
@@ -58,6 +58,7 @@ struct RuntimeUserConfig {
std::optional<bool> vrStopAtDisplayCopy;
std::optional<bool> vrSkipCopyClears;
std::optional<std::string> vrMirrorView;
std::optional<std::string> vrControllerMode;
std::optional<uint32_t> vrFrameInterpolationFps;
std::optional<bool> vrFirstPerson;
std::optional<float> vrFirstPersonUnitsPerMeter;
@@ -183,6 +184,14 @@ inline constexpr const char* kVrMirrorViewDefault = "normal";
inline bool IsSupportedVrMirrorView(std::string_view value) {
return value == "normal" || value == "both" || value == "left" || value == "right" || value == "none";
}
// What the tracked VR controllers are to the game: "wii_remote" is a Wii
// Remote with a Nunchuk (motion and pointer included), "gamepad" one ordinary
// controller read as a GameCube pad. Matches mkw::vr::OpenXRControllerMode.
inline constexpr const char* kVrControllerModeDefault = "wii_remote";
inline bool IsSupportedVrControllerMode(std::string_view value) {
return value == "wii_remote" || value == "gamepad";
}
// 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";
@@ -393,6 +402,12 @@ inline void EnsureConfigFile() {
"# \"right\" mirror the headset's eyes, and \"none\" blacks the window\n"
"# out. Changeable live from the F10 menu.\n"
"mirror_view = \"normal\"\n"
"# What the headset's controllers are to the game: \"wii_remote\"\n"
"# is a Wii Remote (right hand, with motion and a pointer aimed at\n"
"# the virtual screen) plus a Nunchuk (left hand); \"gamepad\" is one\n"
"# ordinary controller read as a GameCube pad. Changeable live from\n"
"# the F10 menu.\n"
"controller_mode = \"wii_remote\"\n"
"# VR interpolation: 0 = Off, 1 = Auto, or 72/90/120 FPS. Live.\n"
"frame_interpolation_fps = 0\n"
"render_scale = 1.0\n"
@@ -655,6 +670,10 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
value && IsSupportedVrMirrorView(*value)) {
config.vrMirrorView = *value;
}
if (auto value = FindConfigValue<std::string>(document, "vr", "controller_mode");
value && IsSupportedVrControllerMode(*value)) {
config.vrControllerMode = *value;
}
config.vrFrameInterpolationFps = FindConfigValue<uint32_t>(document, "vr", "frame_interpolation_fps");
if (!config.vrFrameInterpolationFps) {
// Migrate the initial experimental checkbox to Auto.
@@ -962,6 +981,14 @@ inline bool SetVrMirrorView(std::string value) {
return WriteSetting("vr", "mirror_view", FormatString(value));
}
inline bool SetVrControllerMode(std::string value) {
if (!IsSupportedVrControllerMode(value)) {
return false;
}
Mutable().vrControllerMode = value;
return WriteSetting("vr", "controller_mode", FormatString(value));
}
inline bool SetVrFrameInterpolationFps(uint32_t value) {
value = mkw::vr::NormalizeFrameInterpolationFps(value);
Mutable().vrFrameInterpolationFps = value;
@@ -1302,6 +1329,11 @@ inline std::string VrMirrorView(std::string fallback = kVrMirrorViewDefault) {
return value && IsSupportedVrMirrorView(*value) ? *value : std::move(fallback);
}
inline std::string VrControllerMode(std::string fallback = kVrControllerModeDefault) {
const auto& value = Get().vrControllerMode;
return value && IsSupportedVrControllerMode(*value) ? *value : std::move(fallback);
}
inline uint32_t VrFrameInterpolationFps() {
return mkw::vr::NormalizeFrameInterpolationFps(Get().vrFrameInterpolationFps.value_or(0));
}
+68 -19
View File
@@ -5,25 +5,45 @@
#if defined(MKW_ENABLE_OPENXR)
#include "vr/openxr_runtime.h"
#include "vr/openxr_wii_remote.h"
#include <array>
#include <cstdint>
#include <string>
namespace mkw::vr {
// OpenXR action-based controller input, surfaced to the rest of the runtime as
// one ordinary SDL gamepad.
// The rectangle the game's picture occupies on whichever virtual screen is
// showing it, in the application reference space: the target the Wii Remote
// pointer is aimed at. The pose faces +Z with +X right and +Y up across the
// picture. Invalid when no screen can be pointed at (for instance a race with
// its 2D layer left stretched across the eyes).
struct OpenXRPointerScreen {
bool valid = false;
XrPosef pose{{0.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f}};
float half_width_meters = 0.0f;
float half_height_meters = 0.0f;
};
// OpenXR action-based controller input.
//
// Quest Touch controllers are not visible to SDL's joystick layer (the OS does
// not expose them as HID gamepads), so a standalone headset build would have no
// input at all. Rather than adding a second input path through the PAD/WPAD
// HLE, this module syncs an OpenXR action set on the pacing thread and feeds a
// virtual SDL joystick (SDL_AttachVirtualJoystick) that Aurora's existing
// controller code opens, maps and assigns to player 1 exactly like a physical
// pad. Every binding the settings overlay already offers keeps working.
// input at all. This module syncs an OpenXR action set on the pacing thread and
// feeds a virtual SDL joystick (SDL_AttachVirtualJoystick) that Aurora's
// existing controller code opens and assigns to a port exactly like a physical
// pad. What the game then sees on that port depends on OpenXRControllerMode:
//
// Mapping (Oculus Touch profile; the same actions are also bound for
// khr/simple_controller so an unknown runtime still gets A/menu):
// Wii Remote (default): the port is served through KPAD as a Wii Remote with a
// Nunchuk, like DolphinXR's OpenXR Wii Remote. Every XR frame the aim and grip
// poses are located at the measured current time, turned into both
// accelerometers and the IR pointer (the right aim ray against the virtual
// screen the renderer is showing), and published through openxr_wii_remote.h.
// Buttons follow DolphinXR's "OpenXR Wii Remote" profile (see RemoteButtons).
// The game's rumble drives both controllers' haptics.
//
// Gamepad: the virtual joystick is read through PAD as a GameCube controller,
// and every binding the settings overlay offers applies:
// right A / B -> gamepad South / East (GameCube A / B)
// left X / Y -> gamepad West / North (GameCube X / Y)
// index triggers -> left / right trigger axes
@@ -31,12 +51,14 @@ namespace mkw::vr {
// left / right thumbstick-> left / right stick axes, clicks -> stick buttons
// left menu -> Start
//
// Lifetime: OpenXRInputCreate after the session exists (attaches the action
// set, which OpenXR permits once per session), OpenXRInputSync once per
// xrWaitFrame when the session is focused, OpenXRInputDestroy before the
// session is destroyed. All three run on the XR pacing thread; SDL's virtual
// joystick setters are internally locked, so the game thread may read the pad
// concurrently.
// Both are bound for the Oculus Touch profile; khr/simple_controller gets
// select/menu and the poses so an unknown runtime still offers something.
//
// 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.
class OpenXRInput final {
public:
explicit OpenXRInput(OpenXRLogCallback logger = {});
@@ -51,9 +73,14 @@ public:
bool Create(OpenXRRuntime& runtime);
void Destroy();
// xrSyncActions + state reads, then publishes to the virtual gamepad.
// predicted_display_time is the frame's XrTime for pose-based lookups.
void Sync(XrTime predicted_display_time);
// 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);
// Publishes a remote with nothing held, at rest and not pointing, and stops
// the haptics, for frames without focused input.
void Idle();
// Rumble for the given hand (0 = left, 1 = right); amplitude 0..1.
void ApplyHaptic(uint32_t hand, float amplitude, XrDuration duration);
@@ -63,10 +90,20 @@ public:
const std::string& LastError() const noexcept { return m_last_error; }
private:
static constexpr uint32_t kHands = 2;
bool CreateActions();
bool SuggestBindings();
void CreatePoseSpaces();
void DestroyPoseSpaces();
void LoadInputClock();
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);
void UpdateRumble();
void StopRumble();
bool Check(XrResult result, const char* operation);
void Log(OpenXRLogLevel level, const std::string& message) const noexcept;
@@ -80,12 +117,24 @@ private:
XrAction m_button_primary = XR_NULL_HANDLE; // A / X
XrAction m_button_secondary = XR_NULL_HANDLE; // B / Y
XrAction m_menu = XR_NULL_HANDLE;
XrAction m_aim_pose = XR_NULL_HANDLE;
XrAction m_grip_pose = XR_NULL_HANDLE;
XrAction m_haptic = XR_NULL_HANDLE;
XrPath m_hand_paths[2]{};
XrPath m_hand_paths[kHands]{};
XrSpace m_aim_spaces[kHands]{};
XrSpace m_grip_spaces[kHands]{};
// xrConvertWin32PerformanceCounterToTimeKHR / xrConvertTimespecTimeToTimeKHR,
// when the runtime offers them; the input time falls back to display time.
PFN_xrVoidFunction m_convert_now_to_xr_time = nullptr;
wii_remote::MotionTracker m_motion[kHands];
wii_remote::PointerFilter m_pointer;
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
void* m_joystick = nullptr; // SDL_Joystick*
bool m_created = false;
bool m_logged_sync_failure = false;
bool m_logged_pointer = false;
std::string m_last_error;
};
+377
View File
@@ -0,0 +1,377 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
namespace mkw::vr {
// The tracked VR controllers presented to the game as a Wii Remote with a
// Nunchuk, the way DolphinXR's "OpenXR Wii Remote" source does it: the right
// controller is the remote (buttons, accelerometer and IR pointer), the left
// one is the Nunchuk (stick, C/Z and its own accelerometer).
//
// The OpenXR pacing thread builds one OpenXRWiiRemoteSample per XR frame and
// publishes it here; the KPAD/WPAD HLE on the guest thread reads the latest one
// whenever the game polls. Nothing in this header depends on OpenXR, so the
// guest side compiles (and simply never sees a remote) in builds without it.
enum class OpenXRControllerMode : uint8_t {
// Wii Remote + Nunchuk through KPAD, with motion and pointing.
WiiRemote,
// One ordinary gamepad, read through PAD as a GameCube controller.
Gamepad,
};
struct OpenXRWiiRemoteSample {
uint32_t hold = 0; // WPAD_BUTTON_* bits, Nunchuk C/Z included
std::array<float, 3> acc{0.0f, -1.0f, 0.0f}; // remote accelerometer in g, KPAD frame
std::array<float, 2> stick{}; // Nunchuk stick, -1..1, +y up
std::array<float, 3> nunchuk_acc{0.0f, -1.0f, 0.0f};
// IR pointer in KPADStatus terms: pos is -1..1 across the game picture with
// +y down, horizon is the remote's x axis on the screen ((1, 0) held level,
// (0, 1) rolled a quarter turn clockwise), distance in metres.
bool pointer_valid = false;
std::array<float, 2> pointer{};
std::array<float, 2> horizon{1.0f, 0.0f};
float distance_meters = 0.0f;
};
// Live switch between the two presentations; the settings bar and the launch
// configuration both go through it.
void OpenXRSetControllerMode(OpenXRControllerMode mode) noexcept;
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;
// Latest published sample; false before the first one or after withdrawal.
bool OpenXRReadWiiRemote(OpenXRWiiRemoteSample& sample) noexcept;
// WPADControlMotor for the emulated remote.
void OpenXRSetWiiRemoteRumble(bool active) noexcept;
// XR side.
void OpenXRPublishWiiRemote(uint32_t sdl_joystick_id, const OpenXRWiiRemoteSample& sample) noexcept;
void OpenXRWithdrawWiiRemote() noexcept;
bool OpenXRWiiRemoteRumbleRequested() noexcept;
// The geometry and signal conditioning behind a sample, kept free of OpenXR
// types so it can be checked headlessly (tests/vr_wii_remote_tests.cpp).
//
// Conventions are OpenXR's: right-handed, +Y up, metres. A controller's aim
// pose points down its -Z axis with +X to the right and +Y up; a screen faces
// its +Z axis with +X to the right and +Y up across the picture.
namespace wii_remote {
// WPAD_BUTTON_* bits as KPADStatus.hold carries them.
inline constexpr uint32_t kButtonLeft = 0x0001, kButtonRight = 0x0002, kButtonDown = 0x0004,
kButtonUp = 0x0008, kButtonPlus = 0x0010, kButtonTwo = 0x0100,
kButtonOne = 0x0200, kButtonB = 0x0400, kButtonA = 0x0800,
kButtonMinus = 0x1000, kButtonZ = 0x2000, kButtonC = 0x4000,
kButtonHome = 0x8000;
inline constexpr float kStandardGravity = 9.80665f;
// The remote's ADXL330 saturates a little past +-3 g.
inline constexpr float kAccelRangeG = 3.6f;
// Analog inputs count as a press past this, like Dolphin's button threshold.
inline constexpr float kPressThreshold = 0.5f;
// How far past the picture's edge (in half extents) the pointer is still
// reported. A real remote's camera (42 x 31.5 degrees) keeps seeing the sensor
// bar well beyond the screen, so it does not drop the cursor at the border.
inline constexpr float kPointerMarginU = 1.9f;
inline constexpr float kPointerMarginV = 1.5f;
// An excursion past those margins, or a lost hit, must last this long before
// the pointer is hidden: pose spikes during fast wrist motion otherwise drop it.
inline constexpr int64_t kPointerHideDelayNs = 100'000'000;
using Vec3 = std::array<float, 3>;
using Quat = std::array<float, 4>; // x, y, z, w
struct Pose {
Vec3 position{};
Quat orientation{0.0f, 0.0f, 0.0f, 1.0f};
};
inline float Dot(const Vec3& a, const Vec3& b) noexcept {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
// q * v * conjugate(q) for a unit quaternion.
inline Vec3 Rotate(const Quat& q, const Vec3& v) noexcept {
const Vec3 t{2.0f * (q[1] * v[2] - q[2] * v[1]), 2.0f * (q[2] * v[0] - q[0] * v[2]),
2.0f * (q[0] * v[1] - q[1] * v[0])};
return {v[0] + q[3] * t[0] + (q[1] * t[2] - q[2] * t[1]),
v[1] + q[3] * t[1] + (q[2] * t[0] - q[0] * t[2]),
v[2] + q[3] * t[2] + (q[0] * t[1] - q[1] * t[0])};
}
inline Quat Conjugate(const Quat& q) noexcept {
return {-q[0], -q[1], -q[2], q[3]};
}
// A flat rectangle: the part of a virtual screen the game's picture covers.
struct Screen {
Pose pose;
float half_width = 0.0f;
float half_height = 0.0f;
};
struct ScreenHit {
bool valid = false;
float u = 0.0f; // -1..1 across the picture, +right; beyond +-1 off the edge
float v = 0.0f; // -1..1, +up
float distance_meters = 0.0f;
};
// Where the aim ray meets the screen's plane, the same absolute mapping as
// DolphinXR's ComputeVirtualScreenHit: aiming at a point puts the pointer
// there, with nothing to recenter.
inline ScreenHit RaycastScreen(const Pose& aim, const Screen& screen) noexcept {
ScreenHit hit{};
if (!(screen.half_width > 0.0f) || !(screen.half_height > 0.0f)) {
return hit;
}
const Quat inverse = Conjugate(screen.pose.orientation);
const Vec3 offset{aim.position[0] - screen.pose.position[0], aim.position[1] - screen.pose.position[1],
aim.position[2] - screen.pose.position[2]};
const Vec3 origin = Rotate(inverse, offset);
const Vec3 direction = Rotate(inverse, Rotate(aim.orientation, {0.0f, 0.0f, -1.0f}));
// Only from in front of the picture, and only towards it.
if (!(origin[2] > 0.0f) || !(direction[2] < -1.0e-6f)) {
return hit;
}
const float t = -origin[2] / direction[2];
hit.valid = true;
hit.u = (origin[0] + t * direction[0]) / screen.half_width;
hit.v = (origin[1] + t * direction[1]) / screen.half_height;
// Perpendicular distance: rotating the controller must not move it.
hit.distance_meters = origin[2];
return hit;
}
// KPADStatus.pos for a hit: the SDK's pointer runs from (-1, -1) at the
// picture's top left to (1, 1) at its bottom right.
inline std::array<float, 2> KpadPosition(const ScreenHit& hit) noexcept {
return {hit.u, -hit.v};
}
// KPADStatus.horizon: the remote's right axis as it lies on the screen, in the
// pointer's +y-down frame.
inline std::array<float, 2> Horizon(const Pose& aim, const Screen& screen) noexcept {
const Vec3 right = Rotate(Conjugate(screen.pose.orientation), Rotate(aim.orientation, {1.0f, 0.0f, 0.0f}));
const float length = std::sqrt(right[0] * right[0] + right[1] * right[1]);
if (!(length > 1.0e-3f)) {
return {1.0f, 0.0f};
}
return {right[0] / length, -right[1] / length};
}
// KPAD accelerometer reading for a controller whose aim orientation is
// `orientation` while it accelerates at `world_acceleration` (m/s^2).
//
// An accelerometer measures specific force, acceleration minus gravity, so a
// remote at rest reads 1 g upwards. KPAD's frame is x right across the face, y
// through the back of the remote and z towards the player (Wii axes
// (-x, -z, y)), which on an aim pose is (x, -y, z): at rest, level, that is
// (0, -1, 0), and DolphinXR's (-x, z, y) Wii-frame mapping lands on the same.
inline Vec3 KpadAcceleration(const Quat& orientation, const Vec3& world_acceleration) noexcept {
const Vec3 specific_force{world_acceleration[0], world_acceleration[1] + kStandardGravity,
world_acceleration[2]};
const Vec3 local = Rotate(Conjugate(orientation), specific_force);
const auto axis = [](float value) {
return std::clamp(value / kStandardGravity, -kAccelRangeG, kAccelRangeG);
};
return {axis(local[0]), axis(-local[1]), axis(local[2])};
}
// Differentiates a controller's linear velocity into the acceleration its
// accelerometer would add to gravity, mirroring DolphinXR's
// OpenXRVelocityHistory: the runtime's velocity is averaged with one derived
// from the pose, because some runtimes smooth theirs heavily and a flick loses
// its peak. Time is XrTime nanoseconds, so wall-clock jitter never enters dt.
class MotionTracker {
public:
// `orientation` is the aim pose, `position`/`velocity` the grip's. Returns
// the KPAD reading; with no orientation it repeats the last one.
Vec3 Update(const Quat* orientation, const Vec3* position, const Vec3* velocity, int64_t time_ns) noexcept {
if (orientation == nullptr) {
Reset();
return m_last;
}
const float dt = m_has_position ? static_cast<float>(time_ns - m_time_ns) * 1.0e-9f : 0.0f;
const bool dt_usable = dt > 0.001f;
bool have_velocity = velocity != nullptr;
Vec3 current = have_velocity ? *velocity : Vec3{};
if (position != nullptr && m_has_position && dt_usable) {
const Vec3 from_pose{((*position)[0] - m_position[0]) / dt, ((*position)[1] - m_position[1]) / dt,
((*position)[2] - m_position[2]) / dt};
for (size_t i = 0; i < 3; ++i) {
current[i] = have_velocity ? 0.5f * (current[i] + from_pose[i]) : from_pose[i];
}
have_velocity = true;
}
Vec3 acceleration{};
if (have_velocity && m_has_velocity && dt_usable) {
for (size_t i = 0; i < 3; ++i) {
acceleration[i] = (current[i] - m_velocity[i]) / dt;
}
}
if (position != nullptr) {
m_position = *position;
m_time_ns = time_ns;
m_has_position = true;
} else {
m_has_position = false;
m_has_velocity = false;
}
if (have_velocity) {
m_velocity = current;
m_has_velocity = true;
} else if (!m_has_position) {
m_has_velocity = false;
}
m_last = KpadAcceleration(*orientation, acceleration);
return m_last;
}
void Reset() noexcept {
m_has_position = false;
m_has_velocity = false;
}
// Back to a remote lying still, for when the controllers go idle.
void Rest() noexcept {
Reset();
m_last = {0.0f, -1.0f, 0.0f};
}
private:
bool m_has_position = false;
bool m_has_velocity = false;
Vec3 m_position{};
Vec3 m_velocity{};
int64_t m_time_ns = 0;
Vec3 m_last{0.0f, -1.0f, 0.0f};
};
// Hides the pointer the way a real remote loses the sensor bar, without
// dropping it on every tracking hiccup: brief excursions and lost hits hold or
// pin the last position, and only a sustained one hides it.
class PointerFilter {
public:
ScreenHit Update(const ScreenHit& hit, int64_t time_ns) noexcept {
const bool on_screen = hit.valid && std::fabs(hit.u) <= kPointerMarginU &&
std::fabs(hit.v) <= kPointerMarginV;
if (on_screen) {
m_off_screen = false;
m_held = hit;
return hit;
}
if (!m_off_screen) {
m_off_screen = true;
m_off_since_ns = time_ns;
}
if (!m_held.valid || time_ns - m_off_since_ns >= kPointerHideDelayNs) {
m_held.valid = false;
return {};
}
if (!hit.valid) {
return m_held;
}
ScreenHit pinned = hit;
pinned.u = std::clamp(hit.u, -kPointerMarginU, kPointerMarginU);
pinned.v = std::clamp(hit.v, -kPointerMarginV, kPointerMarginV);
return pinned;
}
void Reset() noexcept {
m_held = {};
m_off_screen = false;
}
private:
ScreenHit m_held{};
bool m_off_screen = false;
int64_t m_off_since_ns = 0;
};
// The part of an aspect-ratio-preserving fit a `content` aspect takes inside a
// `container` aspect, as fractions of the container's width and height.
inline std::array<float, 2> FitFraction(float content_aspect, float container_aspect) noexcept {
if (!(content_aspect > 0.0f) || !(container_aspect > 0.0f)) {
return {1.0f, 1.0f};
}
return content_aspect >= container_aspect ? std::array<float, 2>{1.0f, container_aspect / content_aspect}
: std::array<float, 2>{content_aspect / container_aspect, 1.0f};
}
// Half extents, in metres, of the game picture on the menu quad. The quad is
// `quad_width` across with the eye texture's aspect; Aurora fits the desktop
// snapshot into that texture and the game picture into the snapshot, both
// letterboxed, so a 4:3 picture in a 16:9 window keeps its pillarboxes.
inline std::array<float, 2> MenuPictureHalfExtents(float quad_width, float eye_aspect, float snapshot_aspect,
float picture_aspect) noexcept {
const float quad_half_width = 0.5f * quad_width;
const float quad_half_height = eye_aspect > 0.0f ? quad_half_width / eye_aspect : quad_half_width;
const std::array<float, 2> snapshot = FitFraction(snapshot_aspect, eye_aspect);
const std::array<float, 2> picture = FitFraction(picture_aspect, snapshot_aspect);
return {quad_half_width * snapshot[0] * picture[0], quad_half_height * snapshot[1] * picture[1]};
}
// One controller's digital and analog inputs.
struct HandInputs {
bool primary = false; // A / X
bool secondary = false; // B / Y
bool menu = false;
bool thumbstick_click = false;
float trigger = 0.0f;
float squeeze = 0.0f;
float stick_x = 0.0f;
float stick_y = 0.0f; // +up
};
// DolphinXR's default "OpenXR Wii Remote" profile (Data/Sys/Profiles/Wiimote):
// right A -> A, right trigger -> B, right stick up/down -> 1/2,
// right stick left/right -> -/+, left menu -> HOME,
// left grip -> C, left trigger -> Z, left stick -> Nunchuk stick.
inline uint32_t RemoteButtons(const HandInputs& left, const HandInputs& right) noexcept {
uint32_t hold = 0;
const auto press = [&hold](bool held, uint32_t bit) {
if (held) {
hold |= bit;
}
};
press(right.primary, kButtonA);
press(right.trigger > kPressThreshold, kButtonB);
press(right.stick_y > kPressThreshold, kButtonOne);
press(right.stick_y < -kPressThreshold, kButtonTwo);
press(right.stick_x < -kPressThreshold, kButtonMinus);
press(right.stick_x > kPressThreshold, kButtonPlus);
press(left.menu, kButtonHome);
press(left.squeeze > kPressThreshold, kButtonC);
press(left.trigger > kPressThreshold, kButtonZ);
return hold;
}
// The left thumbstick as the Nunchuk's, kept inside its circular gate.
inline std::array<float, 2> NunchukStick(const HandInputs& left) noexcept {
float x = left.stick_x;
float y = left.stick_y;
const float length = std::sqrt(x * x + y * y);
if (length > 1.0f) {
x /= length;
y /= length;
}
return {x, y};
}
} // namespace wii_remote
} // namespace mkw::vr
+14
View File
@@ -17,6 +17,11 @@ struct PADStatus;
// extension in or out mid-game switches control scheme like on the console. Only
// the Wii U Pro Controller, which has no Wii-era equivalent, goes through
// aurora's PAD layer as a GameCube pad.
//
// The OpenXR build's tracked controllers join the same path: while they are
// presented as a Wii Remote (vr/openxr_wii_remote.h), the virtual gamepad they
// feed is a Wii Remote with a Nunchuk here, and its samples, IR pointer
// included, come from the headset instead of SDL.
namespace WiiRemoteInput {
enum class Kind : uint8_t {
@@ -52,6 +57,13 @@ struct KpadSample {
int16_t clRStickRaw[2] = {};
uint8_t clTriggerL = 0; // 0..255; SDL only exposes the digital click
uint8_t clTriggerR = 0;
// IR pointer, only from the VR controllers (a Bluetooth remote's camera
// data does not reach SDL). pos is -1..1 across the game picture with +y
// down, horizon the remote's x axis on screen, dist in metres.
bool dpdValid = false;
float pos[2] = {};
float horizon[2] = {1.0f, 0.0f};
float dist = 0.0f;
};
// What the game should see on `chan`: the controller SDL has there right now,
@@ -63,6 +75,8 @@ Kind EffectiveKind(uint32_t chan);
// True when the game reads `chan` through KPAD: a Wii Remote alone, with a
// Nunchuk or with a Classic Controller (live or within the swap grace period).
bool IsRemoteChannel(uint32_t chan);
// True when the remote on `chan` is the VR controllers (see above).
bool IsVrControllerChannel(uint32_t chan);
// Reads the current state of the remote on `chan`; false when IsRemoteChannel
// is false. During the swap grace period the sample is neutral.
bool ReadKpadSample(uint32_t chan, KpadSample& sample);
+74 -12
View File
@@ -7,19 +7,30 @@
#include <cmath>
#include <cstdint>
// KPAD HLE fed by a real Bluetooth Wii Remote. The game calls KPADRead once per
// frame with room for 16 KPADStatus entries and only looks at entry 0; with a
// Classic Controller it also calls KPADGetUnifiedWpadStatus for the raw
// WPADCLStatus (buttons, sticks and triggers of the extension).
// KPAD HLE fed by a real Bluetooth Wii Remote, or by VR controllers standing in
// for one. The game calls KPADRead once per frame with room for 16 KPADStatus
// entries and only looks at entry 0; with a Classic Controller it also calls
// KPADGetUnifiedWpadStatus for the raw WPADCLStatus (buttons, sticks and
// triggers of the extension). Of the pointer, Input::WiiController::UpdateImpl
// reads pos, horizon and dist, and only while dpd_valid_fg is positive.
namespace {
constexpr uint32_t kKpadStatusSize = 0x84;
// KPADStatus field offsets (RVL SDK).
constexpr uint32_t kHold = 0x00, kTrig = 0x04, kRelease = 0x08, kAcc = 0x0C, kAccValue = 0x18,
kAccSpeed = 0x1C, kPos = 0x20, kAccVertical = 0x54, kDevType = 0x5C, kWpadErr = 0x5D,
kDpdValidFg = 0x5E, kDataFormat = 0x5F, kFsStick = 0x60, kFsAcc = 0x68, kFsAccValue = 0x74,
kFsAccSpeed = 0x78;
kAccSpeed = 0x1C, kPos = 0x20, kVec = 0x28, kSpeed = 0x30, kHorizon = 0x34, kHoriVec = 0x3C,
kHoriSpeed = 0x44, kDist = 0x48, kDistVec = 0x4C, kDistSpeed = 0x50, kAccVertical = 0x54,
kDevType = 0x5C, kWpadErr = 0x5D, kDpdValidFg = 0x5E, kDataFormat = 0x5F, kFsStick = 0x60,
kFsAcc = 0x68, kFsAccValue = 0x74, kFsAccSpeed = 0x78;
// KPAD's per-channel work area (PAL RMCP01): KPADInitEx sets the pointer
// switch to 1 for every channel, and KPADEnableDpd / KPADDisableDpd, which the
// game inlines into Input::WiiController::TogglePointer, flip it. The SDK
// reports no pointer while it is off.
constexpr uint32_t kKpadWorkBase = 0x803457E0, kKpadWorkSize = 0x538, kKpadDpdEnabled = 0x520;
// dpd_valid_fg for a pointer computed from both sensor-bar dots.
constexpr uint8_t kDpdValidTwoDots = 2;
// KPADStatus.ex_status.cl (KPADEXStatus, Classic Controller view).
constexpr uint32_t kClHold = 0x60, kClTrig = 0x64, kClRelease = 0x68, kClLStick = 0x6C, kClRStick = 0x74,
kClLTrigger = 0x7C, kClRTrigger = 0x80;
@@ -52,6 +63,10 @@ struct ChannelState {
uint32_t prevClHold = 0;
float prevAcc[3] = {0.0f, -1.0f, 0.0f};
float prevFsAcc[3] = {0.0f, -1.0f, 0.0f};
bool prevDpdValid = false;
float prevPos[2] = {};
float prevHorizon[2] = {1.0f, 0.0f};
float prevDist = 0.0f;
};
std::array<ChannelState, 4> g_channels{};
@@ -74,6 +89,17 @@ void WriteVec3(uint32_t addr, const float* v) {
Memory::WriteFloat32(addr + 8, v[2]);
}
// Writes two big-endian floats to guest memory.
void WriteVec2(uint32_t addr, const float* v) {
Memory::WriteFloat32(addr, v[0]);
Memory::WriteFloat32(addr + 4, v[1]);
}
// Whether the game has the pointer switched on for `chan` (see kKpadDpdEnabled).
bool DpdEnabled(uint32_t chan) {
return Memory::Read8(kKpadWorkBase + chan * kKpadWorkSize + kKpadDpdEnabled) != 0;
}
// Zeroes `count` consecutive floats in guest memory.
void WriteZeroFloats(uint32_t addr, uint32_t count) {
for (uint32_t i = 0; i < count; ++i) {
@@ -107,10 +133,44 @@ int32_t WriteStatus(uint32_t chan, uint32_t addr, const WiiRemoteInput::KpadSamp
Memory::WriteFloat32(addr + kAccSpeed, Distance(sample->acc, state.prevAcc));
for (int i = 0; i < 3; ++i) state.prevAcc[i] = sample->acc[i];
// No IR pointer: pos .. acc_vertical zeroed and dpd_valid_fg clear, which
// the game treats as "pointing away from the screen".
WriteZeroFloats(addr + kPos, (kAccVertical + 8 - kPos) / 4);
Memory::Write8(addr + kDpdValidFg, 0);
// IR pointer. Without one, pos .. acc_vertical are zeroed and dpd_valid_fg
// is clear, which the game treats as "pointing away from the screen".
// vec, hori_vec and dist_vec are the frame-to-frame changes, the speeds
// their lengths, as the SDK derives them. acc_vertical stays zero.
if (sample->dpdValid && DpdEnabled(chan)) {
if (!state.prevDpdValid) {
state.prevPos[0] = sample->pos[0];
state.prevPos[1] = sample->pos[1];
state.prevHorizon[0] = sample->horizon[0];
state.prevHorizon[1] = sample->horizon[1];
state.prevDist = sample->dist;
}
const float vec[2] = {sample->pos[0] - state.prevPos[0], sample->pos[1] - state.prevPos[1]};
const float horiVec[2] = {sample->horizon[0] - state.prevHorizon[0],
sample->horizon[1] - state.prevHorizon[1]};
const float distVec = sample->dist - state.prevDist;
WriteVec2(addr + kPos, sample->pos);
WriteVec2(addr + kVec, vec);
Memory::WriteFloat32(addr + kSpeed, std::hypot(vec[0], vec[1]));
WriteVec2(addr + kHorizon, sample->horizon);
WriteVec2(addr + kHoriVec, horiVec);
Memory::WriteFloat32(addr + kHoriSpeed, std::hypot(horiVec[0], horiVec[1]));
Memory::WriteFloat32(addr + kDist, sample->dist);
Memory::WriteFloat32(addr + kDistVec, distVec);
Memory::WriteFloat32(addr + kDistSpeed, std::fabs(distVec));
WriteZeroFloats(addr + kAccVertical, 2);
Memory::Write8(addr + kDpdValidFg, kDpdValidTwoDots);
state.prevDpdValid = true;
state.prevPos[0] = sample->pos[0];
state.prevPos[1] = sample->pos[1];
state.prevHorizon[0] = sample->horizon[0];
state.prevHorizon[1] = sample->horizon[1];
state.prevDist = sample->dist;
} else {
WriteZeroFloats(addr + kPos, (kAccVertical + 8 - kPos) / 4);
Memory::Write8(addr + kDpdValidFg, 0);
state.prevDpdValid = false;
}
const uint8_t devType = sample->hasClassic ? kDevClassic : sample->hasNunchuk ? kDevFreestyle : kDevCore;
const uint8_t dataFormat =
@@ -170,7 +230,9 @@ void WriteUnifiedStatus(uint32_t addr, const WiiRemoteInput::KpadSample* sample)
Memory::Write16(addr + kUAccX, RawAcc(-sample->acc[0]));
Memory::Write16(addr + kUAccY, RawAcc(sample->acc[2]));
Memory::Write16(addr + kUAccZ, RawAcc(-sample->acc[1]));
// No IR: every DPDObject invalid (x/y at the sensor's out-of-range value).
// No raw IR: every DPDObject invalid (x/y at the sensor's out-of-range
// value). The VR pointer is only synthesised at the KPADStatus level, which
// is where the game reads it; no camera dots are invented for it here.
for (uint32_t i = 0; i < 4; ++i) {
Memory::Write16(addr + kUObj + i * 8, 0x3FF);
Memory::Write16(addr + kUObj + i * 8 + 2, 0x3FF);
+6
View File
@@ -94,6 +94,12 @@ extern "C" void PAD_HLE_SetRumbleEnabled(bool enabled)
g_rumbleEnabled.store(enabled, std::memory_order_relaxed);
}
// The same switch, for the Wii Remote motor the VR controllers stand in for.
extern "C" bool PAD_HLE_RumbleEnabled()
{
return g_rumbleEnabled.load(std::memory_order_relaxed);
}
extern "C" uint32_t PAD__Init_HLE()
{
return PADInit() ? 1u : 0u;
+8 -2
View File
@@ -1,15 +1,18 @@
#include "hle_stubs.h"
#include "memory.h"
#include "hle/controller_status_contract.h"
#include "vr/openxr_wii_remote.h"
#include "wii_remote_input.h"
#include <cstdint>
void NandQueueIosCallback(uint32_t callbackPtr, int32_t result, uint32_t callbackArg);
extern "C" bool PAD_HLE_RumbleEnabled();
namespace {
constexpr uint32_t kDefaultWorkMemSize = 0x20000;
constexpr uint32_t kWpadMotorRumble = 1; // WPAD_MOTOR_RUMBLE; WPAD_MOTOR_STOP is 0
constexpr uint8_t kDefaultDpdSensitivity = 3;
constexpr int32_t kStatusOk = 0;
@@ -141,10 +144,13 @@ extern "C" int32_t WPADProbe_HLE(uint32_t chan, uint32_t typePtr)
}
PPC_NATIVE_OVERRIDE(801C0990, WPADProbe_HLE, int32_t, (uint32_t chan, uint32_t typePtr), (chan, typePtr));
// WPADControlMotor: a Bluetooth remote's motor is not driven, but VR controllers
// standing in for a remote rumble both hands while the game holds it on.
extern "C" void WPADControlMotor_HLE(uint32_t chan, uint32_t command)
{
(void)chan;
(void)command;
if (chan < WpadContract::kChannelCount && WiiRemoteInput::IsVrControllerChannel(chan)) {
mkw::vr::OpenXRSetWiiRemoteRumble(command == kWpadMotorRumble && PAD_HLE_RumbleEnabled());
}
}
PPC_NATIVE_OVERRIDE(801C0EC4, WPADControlMotor_HLE, void, (uint32_t chan, uint32_t command), (chan, command));
+45
View File
@@ -10,6 +10,7 @@
#include "vr/mkw_vr_first_person.h"
#include "vr/mkw_vr_policy.h"
#include "vr/openxr_integration.h"
#include "vr/openxr_wii_remote.h"
#include "wii_remote_input.h"
#include <imgui.h>
@@ -144,6 +145,21 @@ int g_vrMirrorView = [] {
}
return 0;
}();
// Config spellings and menu labels for the VR controllers, index-matched to
// mkw::vr::OpenXRControllerMode.
constexpr std::array<const char*, 2> kVrControllerModeNames{"wii_remote", "gamepad"};
constexpr std::array<const char*, 2> kVrControllerModeLabels{"Wii Remote + Nunchuk", "Gamepad"};
static_assert(static_cast<int>(mkw::vr::OpenXRControllerMode::WiiRemote) == 0);
static_assert(static_cast<int>(mkw::vr::OpenXRControllerMode::Gamepad) == 1);
int g_vrControllerMode = [] {
const std::string mode = RuntimeConfigFile::VrControllerMode();
for (size_t i = 0; i < kVrControllerModeNames.size(); ++i) {
if (mode == kVrControllerModeNames[i]) {
return static_cast<int>(i);
}
}
return 0;
}();
constexpr std::array<const char*, 3> kVrFirstPersonRotationNames{"yaw", "yaw_pitch", "full"};
int g_vrFirstPersonRotation = [] {
const std::string mode = RuntimeConfigFile::VrFirstPersonRotation();
@@ -542,6 +558,7 @@ void DrawRumbleSettings() {
PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD,
};
PADControlAllMotors(stopAll.data());
mkw::vr::OpenXRSetWiiRemoteRumble(false);
}
}
if (ImGui::IsItemHovered()) {
@@ -936,6 +953,33 @@ 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.");
}
if (ImGui::Combo("VR controllers", &g_vrControllerMode, kVrControllerModeLabels.data(),
static_cast<int>(kVrControllerModeLabels.size()))) {
mkw::vr::OpenXRSetControllerMode(static_cast<mkw::vr::OpenXRControllerMode>(g_vrControllerMode));
RuntimeConfigFile::SetVrControllerMode(kVrControllerModeNames[static_cast<size_t>(g_vrControllerMode)]);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Wii Remote + Nunchuk: the right controller is a Wii Remote, with motion and a pointer "
"that lands where you aim on the virtual screen; the left one is the Nunchuk.\n"
" Right: A = A, trigger = B, stick up/down = 1/2, stick left/right = -/+\n"
" Left: stick = Nunchuk stick, trigger = Z, grip = C, menu = HOME\n"
"Gamepad: both controllers are one ordinary controller, read as a GameCube pad.\n"
"Applies immediately; the game sees the controller change as a reconnection.");
}
if (mkw::vr::OpenXRIsRunning() &&
mkw::vr::OpenXRGetControllerMode() == mkw::vr::OpenXRControllerMode::WiiRemote) {
mkw::vr::OpenXRWiiRemoteSample remote;
if (mkw::vr::OpenXRReadWiiRemote(remote)) {
if (remote.pointer_valid) {
ImGui::TextDisabled("Pointer %+.2f %+.2f | Remote %+.2f %+.2f %+.2f g", remote.pointer[0],
remote.pointer[1], remote.acc[0], remote.acc[1], remote.acc[2]);
} else {
ImGui::TextDisabled("Pointer off screen | Remote %+.2f %+.2f %+.2f g", remote.acc[0],
remote.acc[1], remote.acc[2]);
}
}
}
if (ImGui::Combo("VR frame interpolation (experimental)", &g_vrFrameInterpolationMode,
kVrInterpolationLabels.data(), static_cast<int>(kVrInterpolationLabels.size()))) {
@@ -1398,6 +1442,7 @@ void InitializeRuntimeSettings() noexcept {
aurora_set_stereo_stop_at_display_copy(g_vrStopAtDisplayCopy);
aurora_set_stereo_skip_copy_clears(g_vrSkipCopyClears);
aurora_set_stereo_mirror_view(static_cast<AuroraStereoMirrorView>(g_vrMirrorView));
mkw::vr::OpenXRSetControllerMode(static_cast<mkw::vr::OpenXRControllerMode>(g_vrControllerMode));
ApplyVrHudVirtualScreen();
aurora_set_skip_unready_pipelines(g_skipUnreadyPipelines);
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
+334 -42
View File
@@ -2,6 +2,13 @@
#if defined(MKW_ENABLE_OPENXR)
#if defined(_WIN32)
#if !defined(NOMINMAX)
#define NOMINMAX
#endif
#include <windows.h>
#endif
#include "vr/openxr_input.h"
#include <SDL3/SDL_gamepad.h>
@@ -13,11 +20,14 @@
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <initializer_list>
#include <sstream>
#include <utility>
#include <vector>
#if defined(__ANDROID__)
#include <sys/system_properties.h>
#include <time.h>
#endif
namespace mkw::vr {
@@ -28,8 +38,10 @@ namespace {
// a menu can be reached without someone wearing the headset:
// adb shell setprop debug.wiicompiled.inject <sequence>:<button>
// A new sequence number holds the button for kInjectHoldFrames XR frames.
// Buttons: a, b, x, y, start, up, down, left, right. The property is unset in
// normal use, so this costs one property read every few 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.
constexpr uint32_t kInjectHoldFrames = 12;
constexpr uint32_t kInjectPollFrames = 4;
@@ -77,13 +89,23 @@ bool Injected(const char* button) {
const InjectedPress& press = Injection();
return press.frames_left > 0 && press.button == button;
}
using ConvertNowToXrTime = XrResult(XRAPI_PTR*)(XrInstance, const struct timespec*, XrTime*);
#else
void PollInjection() {}
bool Injected(const char*) { return false; }
#if defined(_WIN32)
using ConvertNowToXrTime = XrResult(XRAPI_PTR*)(XrInstance, const LARGE_INTEGER*, XrTime*);
#endif
#endif
constexpr uint32_t kHandCount = 2;
// Re-sent every frame while the game holds the motor on, so a rumble whose stop
// never arrives (or a stalled pacing thread) dies out on its own.
constexpr XrDuration kRumblePulseNs = 50'000'000;
struct Binding {
XrAction* action;
const char* path;
@@ -100,6 +122,30 @@ Sint16 ToTrigger(float value) noexcept {
return static_cast<Sint16>(std::lround(clamped * 32767.0f));
}
wii_remote::Pose ToWiiRemotePose(const XrPosef& pose) noexcept {
return {{pose.position.x, pose.position.y, pose.position.z},
{pose.orientation.x, pose.orientation.y, pose.orientation.z, pose.orientation.w}};
}
// The adb injection's buttons as the Wii Remote presentation's WPAD bits.
uint32_t InjectedWiiRemoteButtons() {
uint32_t hold = 0;
const auto press = [&hold](const char* name, uint32_t bit) {
if (Injected(name)) {
hold |= bit;
}
};
press("a", wii_remote::kButtonA);
press("b", wii_remote::kButtonB);
press("x", wii_remote::kButtonOne);
press("y", wii_remote::kButtonTwo);
press("start", wii_remote::kButtonPlus);
press("home", wii_remote::kButtonHome);
press("c", wii_remote::kButtonC);
press("z", wii_remote::kButtonZ);
return hold;
}
} // namespace
OpenXRInput::OpenXRInput(OpenXRLogCallback logger) : m_logger(std::move(logger)) {}
@@ -139,11 +185,15 @@ bool OpenXRInput::Create(OpenXRRuntime& runtime) {
return false;
}
m_created = true;
CreatePoseSpaces();
LoadInputClock();
if (!AttachVirtualGamepad()) {
Log(OpenXRLogLevel::Warning,
"SDL refused the virtual gamepad; OpenXR controllers will not reach the game");
}
Log(OpenXRLogLevel::Info, "OpenXR controller actions attached");
Log(OpenXRLogLevel::Info, OpenXRGetControllerMode() == OpenXRControllerMode::WiiRemote
? "OpenXR controller actions attached (Wii Remote + Nunchuk)"
: "OpenXR controller actions attached (gamepad)");
return true;
}
@@ -162,7 +212,7 @@ bool OpenXRInput::CreateActions() {
const char* localized;
XrActionType type;
};
const std::array<Spec, 8> specs{{
const std::array<Spec, 10> specs{{
{&m_thumbstick, "thumbstick", "Thumbstick", XR_ACTION_TYPE_VECTOR2F_INPUT},
{&m_thumbstick_click, "thumbstick_click", "Thumbstick Click", XR_ACTION_TYPE_BOOLEAN_INPUT},
{&m_trigger, "trigger", "Trigger", XR_ACTION_TYPE_FLOAT_INPUT},
@@ -170,6 +220,8 @@ bool OpenXRInput::CreateActions() {
{&m_button_primary, "button_primary", "A / X", XR_ACTION_TYPE_BOOLEAN_INPUT},
{&m_button_secondary, "button_secondary", "B / Y", XR_ACTION_TYPE_BOOLEAN_INPUT},
{&m_menu, "menu", "Menu", XR_ACTION_TYPE_BOOLEAN_INPUT},
{&m_aim_pose, "aim_pose", "Pointer", XR_ACTION_TYPE_POSE_INPUT},
{&m_grip_pose, "grip_pose", "Motion", XR_ACTION_TYPE_POSE_INPUT},
{&m_haptic, "haptic", "Haptic", XR_ACTION_TYPE_VIBRATION_OUTPUT},
}};
for (const Spec& spec : specs) {
@@ -235,17 +287,26 @@ bool OpenXRInput::SuggestBindings() {
{&m_button_secondary, "/user/hand/left/input/y/click"},
{&m_button_secondary, "/user/hand/right/input/b/click"},
{&m_menu, "/user/hand/left/input/menu/click"},
{&m_aim_pose, "/user/hand/left/input/aim/pose"},
{&m_aim_pose, "/user/hand/right/input/aim/pose"},
{&m_grip_pose, "/user/hand/left/input/grip/pose"},
{&m_grip_pose, "/user/hand/right/input/grip/pose"},
{&m_haptic, "/user/hand/left/output/haptic"},
{&m_haptic, "/user/hand/right/output/haptic"},
};
if (!suggest("/interaction_profiles/oculus/touch_controller", touch, true)) {
return false;
}
// Minimal fallback so an unfamiliar runtime still offers a select and a menu.
// Minimal fallback so an unfamiliar runtime still offers a select, a menu
// and something to point with.
const std::vector<Binding> simple{
{&m_button_primary, "/user/hand/right/input/select/click"},
{&m_button_secondary, "/user/hand/left/input/select/click"},
{&m_menu, "/user/hand/left/input/menu/click"},
{&m_aim_pose, "/user/hand/left/input/aim/pose"},
{&m_aim_pose, "/user/hand/right/input/aim/pose"},
{&m_grip_pose, "/user/hand/left/input/grip/pose"},
{&m_grip_pose, "/user/hand/right/input/grip/pose"},
{&m_haptic, "/user/hand/left/output/haptic"},
{&m_haptic, "/user/hand/right/output/haptic"},
};
@@ -253,6 +314,92 @@ bool OpenXRInput::SuggestBindings() {
return true;
}
void OpenXRInput::CreatePoseSpaces() {
bool logged = false;
for (uint32_t hand = 0; hand < kHandCount; ++hand) {
for (auto [action, spaces] : {std::pair{m_aim_pose, m_aim_spaces}, std::pair{m_grip_pose, m_grip_spaces}}) {
XrActionSpaceCreateInfo info{XR_TYPE_ACTION_SPACE_CREATE_INFO};
info.action = action;
info.subactionPath = m_hand_paths[hand];
info.poseInActionSpace.orientation.w = 1.0f;
const XrResult result = xrCreateActionSpace(m_runtime->Session(), &info, &spaces[hand]);
m_runtime->ObserveResult(result);
if (XR_FAILED(result)) {
spaces[hand] = XR_NULL_HANDLE;
if (!logged) {
logged = true;
std::ostringstream message;
message << "xrCreateActionSpace failed (" << result
<< "); the Wii Remote will have no motion or pointer";
Log(OpenXRLogLevel::Warning, message.str());
}
}
}
}
}
void OpenXRInput::DestroyPoseSpaces() {
for (uint32_t hand = 0; hand < kHandCount; ++hand) {
for (XrSpace* space : {&m_aim_spaces[hand], &m_grip_spaces[hand]}) {
if (*space != XR_NULL_HANDLE) {
xrDestroySpace(*space);
*space = XR_NULL_HANDLE;
}
}
}
}
// Poses for input are located at the measured current time, not the frame's
// predicted display time: that lies tens of milliseconds ahead, and the runtime
// extrapolates a fast wrist turn that far past where the hand really is, which
// sprays the pointer and invents acceleration (DolphinXR's fast-motion fix).
void OpenXRInput::LoadInputClock() {
const auto& extensions = m_runtime->EnabledExtensions();
const auto enabled = [&](const char* name) {
return std::find(extensions.begin(), extensions.end(), name) != extensions.end();
};
PFN_xrVoidFunction function = nullptr;
#if defined(_WIN32)
if (enabled("XR_KHR_win32_convert_performance_counter_time")) {
m_runtime->GetInstanceProcAddress("xrConvertWin32PerformanceCounterToTimeKHR", &function);
}
#elif defined(__ANDROID__)
if (enabled("XR_KHR_convert_timespec_time")) {
m_runtime->GetInstanceProcAddress("xrConvertTimespecTimeToTimeKHR", &function);
}
#else
(void)enabled;
#endif
m_convert_now_to_xr_time = function;
if (m_convert_now_to_xr_time == nullptr) {
Log(OpenXRLogLevel::Info,
"OpenXR offers no clock conversion; controller motion is sampled at display time");
}
}
XrTime OpenXRInput::InputSampleTime(XrTime predicted_display_time) const {
if (m_convert_now_to_xr_time == nullptr) {
return predicted_display_time;
}
XrTime now = 0;
#if defined(_WIN32)
LARGE_INTEGER counter{};
if (QueryPerformanceCounter(&counter) == 0 ||
XR_FAILED(reinterpret_cast<ConvertNowToXrTime>(m_convert_now_to_xr_time)(m_runtime->Instance(),
&counter, &now))) {
return predicted_display_time;
}
#elif defined(__ANDROID__)
timespec spec{};
if (clock_gettime(CLOCK_MONOTONIC, &spec) != 0 ||
XR_FAILED(reinterpret_cast<ConvertNowToXrTime>(m_convert_now_to_xr_time)(m_runtime->Instance(),
&spec, &now))) {
return predicted_display_time;
}
#endif
return now > 0 ? (std::min)(predicted_display_time, now) : predicted_display_time;
}
bool OpenXRInput::AttachVirtualGamepad() {
SDL_VirtualJoystickDesc desc;
SDL_INIT_INTERFACE(&desc);
@@ -299,7 +446,13 @@ void OpenXRInput::DetachVirtualGamepad() {
}
void OpenXRInput::Destroy() {
// The game must stop reading a remote whose controllers are going away.
OpenXRWithdrawWiiRemote();
if (m_created) {
StopRumble();
}
DetachVirtualGamepad();
DestroyPoseSpaces();
if (m_action_set != XR_NULL_HANDLE) {
// Destroying the set destroys every action created from it.
xrDestroyActionSet(m_action_set);
@@ -307,13 +460,47 @@ void OpenXRInput::Destroy() {
}
m_thumbstick = m_thumbstick_click = m_trigger = m_squeeze = XR_NULL_HANDLE;
m_button_primary = m_button_secondary = m_menu = m_haptic = XR_NULL_HANDLE;
m_aim_pose = m_grip_pose = XR_NULL_HANDLE;
m_hand_paths[0] = m_hand_paths[1] = XR_NULL_PATH;
m_convert_now_to_xr_time = nullptr;
for (auto& motion : m_motion) {
motion.Rest();
}
m_pointer.Reset();
m_horizon = {1.0f, 0.0f};
m_created = false;
m_runtime = nullptr;
}
void OpenXRInput::Sync(XrTime) {
if (!m_created || m_runtime == nullptr || !m_runtime->IsSessionFocused()) {
void OpenXRInput::Idle() {
if (!m_created) {
return;
}
for (auto& motion : m_motion) {
motion.Rest();
}
m_pointer.Reset();
m_horizon = {1.0f, 0.0f};
OpenXRPublishWiiRemote(m_joystick_id, OpenXRWiiRemoteSample{});
StopRumble();
// Nothing stays held on the gamepad either while input is away.
if (m_joystick != nullptr) {
auto* joystick = static_cast<SDL_Joystick*>(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);
}
}
}
void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen& screen) {
if (!m_created || m_runtime == nullptr) {
return;
}
if (!m_runtime->IsSessionFocused()) {
Idle();
return;
}
XrActiveActionSet active{m_action_set, XR_NULL_PATH};
@@ -329,12 +516,9 @@ void OpenXRInput::Sync(XrTime) {
message << "xrSyncActions failed (" << result << ')';
Log(OpenXRLogLevel::Warning, message.str());
}
Idle();
return;
}
if (m_joystick == nullptr) {
return;
}
auto* joystick = static_cast<SDL_Joystick*>(m_joystick);
const auto boolean = [&](XrAction action, uint32_t hand) {
XrActionStateGetInfo info{XR_TYPE_ACTION_STATE_GET_INFO};
@@ -367,39 +551,147 @@ void OpenXRInput::Sync(XrTime) {
return state.currentState;
};
PollInjection();
XrVector2f left = vector(m_thumbstick, 0);
const XrVector2f right = vector(m_thumbstick, 1);
if (Injected("up")) {
left.y = 1.0f;
} else if (Injected("down")) {
left.y = -1.0f;
} else if (Injected("left")) {
left.x = -1.0f;
} else if (Injected("right")) {
left.x = 1.0f;
std::array<wii_remote::HandInputs, kHands> hands{};
for (uint32_t hand = 0; hand < kHands; ++hand) {
wii_remote::HandInputs& inputs = hands[hand];
inputs.primary = boolean(m_button_primary, hand);
inputs.secondary = boolean(m_button_secondary, hand);
inputs.menu = boolean(m_menu, hand);
inputs.thumbstick_click = boolean(m_thumbstick_click, hand);
inputs.trigger = scalar(m_trigger, hand);
inputs.squeeze = scalar(m_squeeze, hand);
const XrVector2f stick = vector(m_thumbstick, hand);
inputs.stick_x = stick.x;
inputs.stick_y = stick.y;
}
// OpenXR thumbsticks report +Y up; SDL gamepads report +Y down.
SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_LEFTX, ToAxis(left.x));
SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_LEFTY, ToAxis(-left.y));
SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_RIGHTX, ToAxis(right.x));
SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_RIGHTY, ToAxis(-right.y));
SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_LEFT_TRIGGER, ToTrigger(scalar(m_trigger, 0)));
SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, ToTrigger(scalar(m_trigger, 1)));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_SOUTH,
boolean(m_button_primary, 1) || Injected("a"));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_EAST,
boolean(m_button_secondary, 1) || Injected("b"));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_WEST,
boolean(m_button_primary, 0) || Injected("x"));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_NORTH,
boolean(m_button_secondary, 0) || Injected("y"));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_START, boolean(m_menu, 0) || Injected("start"));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_LEFT_STICK, boolean(m_thumbstick_click, 0));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_RIGHT_STICK, boolean(m_thumbstick_click, 1));
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, scalar(m_squeeze, 0) > 0.5f);
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, scalar(m_squeeze, 1) > 0.5f);
PollInjection();
wii_remote::HandInputs& left = hands[0];
const wii_remote::HandInputs& right = hands[1];
if (Injected("up")) {
left.stick_y = 1.0f;
} else if (Injected("down")) {
left.stick_y = -1.0f;
} else if (Injected("left")) {
left.stick_x = -1.0f;
} else if (Injected("right")) {
left.stick_x = 1.0f;
}
if (m_joystick != nullptr) {
auto* joystick = static_cast<SDL_Joystick*>(m_joystick);
// 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));
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());
UpdateRumble();
}
void OpenXRInput::PublishWiiRemote(XrTime predicted_display_time, const OpenXRPointerScreen& screen,
const std::array<wii_remote::HandInputs, kHands>& hands,
uint32_t injected_buttons) {
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;
sample.stick = wii_remote::NunchukStick(hands[0]);
// Left is the Nunchuk, right is the remote.
std::array<wii_remote::Pose, kHands> aims{};
std::array<bool, kHands> aim_valid{};
for (uint32_t hand = 0; hand < kHands; ++hand) {
if (m_aim_spaces[hand] != XR_NULL_HANDLE) {
XrSpaceLocation location{XR_TYPE_SPACE_LOCATION};
if (XR_SUCCEEDED(xrLocateSpace(m_aim_spaces[hand], m_runtime->AppSpace(), input_time, &location)) &&
(location.locationFlags & kPoseValid) == kPoseValid) {
aims[hand] = ToWiiRemotePose(location.pose);
aim_valid[hand] = true;
}
}
wii_remote::Vec3 grip_position{};
wii_remote::Vec3 grip_velocity{};
bool position_valid = false;
bool velocity_valid = false;
if (m_grip_spaces[hand] != XR_NULL_HANDLE) {
XrSpaceVelocity velocity{XR_TYPE_SPACE_VELOCITY};
XrSpaceLocation location{XR_TYPE_SPACE_LOCATION};
location.next = &velocity;
if (XR_SUCCEEDED(xrLocateSpace(m_grip_spaces[hand], m_runtime->AppSpace(), input_time, &location))) {
position_valid = (location.locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT) != 0;
velocity_valid = (velocity.velocityFlags & XR_SPACE_VELOCITY_LINEAR_VALID_BIT) != 0;
grip_position = {location.pose.position.x, location.pose.position.y, location.pose.position.z};
grip_velocity = {velocity.linearVelocity.x, velocity.linearVelocity.y, velocity.linearVelocity.z};
}
}
const wii_remote::Vec3 acc =
m_motion[hand].Update(aim_valid[hand] ? &aims[hand].orientation : nullptr,
position_valid ? &grip_position : nullptr,
velocity_valid ? &grip_velocity : nullptr, input_time);
(hand == 0 ? sample.nunchuk_acc : sample.acc) = acc;
}
wii_remote::Screen target{};
wii_remote::ScreenHit hit{};
if (screen.valid && aim_valid[1]) {
target.pose = ToWiiRemotePose(screen.pose);
target.half_width = screen.half_width_meters;
target.half_height = screen.half_height_meters;
hit = wii_remote::RaycastScreen(aims[1], target);
}
if (screen.valid && aim_valid[1]) {
m_horizon = wii_remote::Horizon(aims[1], target);
}
const wii_remote::ScreenHit pointer = m_pointer.Update(hit, input_time);
if (pointer.valid) {
sample.pointer_valid = true;
sample.pointer = wii_remote::KpadPosition(pointer);
// Held with the position through a tracking blip.
sample.horizon = m_horizon;
sample.distance_meters = pointer.distance_meters;
if (!m_logged_pointer) {
m_logged_pointer = true;
Log(OpenXRLogLevel::Info, "OpenXR Wii Remote pointer reached the virtual screen");
}
}
OpenXRPublishWiiRemote(m_joystick_id, sample);
}
void OpenXRInput::UpdateRumble() {
if (!OpenXRWiiRemoteRumbleRequested() || !OpenXRWiiRemoteOwnsGamepad(m_joystick_id)) {
StopRumble();
return;
}
for (uint32_t hand = 0; hand < kHandCount; ++hand) {
ApplyHaptic(hand, 1.0f, kRumblePulseNs);
m_haptics_active[hand] = true;
}
}
void OpenXRInput::StopRumble() {
for (uint32_t hand = 0; hand < kHandCount; ++hand) {
if (m_haptics_active[hand]) {
ApplyHaptic(hand, 0.0f, 0);
m_haptics_active[hand] = false;
}
}
}
void OpenXRInput::ApplyHaptic(uint32_t hand, float amplitude, XrDuration duration) {
+91 -3
View File
@@ -595,6 +595,9 @@ private:
break;
}
if (!session_active) {
if (input_ != nullptr) {
input_->Idle();
}
SetInterpolationActive(false);
interpolation_pacing_.Reset();
rendered_fps_.store(0, std::memory_order_relaxed);
@@ -652,13 +655,15 @@ private:
}
UpdateFrameTiming(frame.xr_frame);
if (input_ != nullptr) {
input_->Sync(frame.xr_frame.predicted_display_time);
}
// Both of these read this frame's located head pose and must run
// before FinishFrame submits a layer built from it.
ServiceRecenterRequest();
UpdateVirtualScreenPose(frame);
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));
}
if (!frame.expects_gpu_submission) {
if (!backend_->FinishFrame(frame, false)) {
@@ -840,6 +845,89 @@ private:
frame.presentation.quad_pose = virtual_screen_pose_;
}
// The rectangle the game picture covers on the screen this frame shows, in
// the application space, for the Wii Remote pointer to aim at.
//
// Menus: the quad layer UpdateVirtualScreenPose placed (or its head-locked
// fallback), sized like the backends size it: hud_width_meters across with
// the eye texture's aspect, the desktop snapshot letterboxed into it and the
// picture into the snapshot.
//
// Races: the 2D layer's screen, which Aurora hangs hud_distance_meters
// ahead in the recorded centre-eye space. ViewFromBase maps a point p of
// that space (in metres) to base + lean * p in the application space, so the
// screen sits at base + lean * (0, 0, -distance), turned by the lean, its
// height following the picture aspect as stereo_hud_screen's does. With the
// 2D layer stretched across the eyes there is no screen to point at.
OpenXRPointerScreen PointerScreen(const OpenXRBackendFrame& frame, const MkwVRPolicySnapshot& policy,
bool immersive) const noexcept {
OpenXRPointerScreen screen{};
float picture_aspect = 0.0f;
float snapshot_aspect = 0.0f;
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)) {
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_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 {
return screen;
}
const float eye_aspect =
static_cast<float>(frame.render_width[0]) / static_cast<float>(frame.render_height[0]);
const std::array<float, 2> extents = wii_remote::MenuPictureHalfExtents(
std::max(0.25f, frame.presentation.quad_width_meters), eye_aspect, snapshot_aspect, picture_aspect);
screen.half_width_meters = extents[0];
screen.half_height_meters = extents[1];
screen.valid = true;
return screen;
}
void ApplyPendingReferenceSpaceChange(const OpenXRFrame& frame) noexcept {
if (runtime_->ConsumeAppSpaceChangesThrough(frame.predicted_display_time)) {
ResetTrackingOrigin();
+85
View File
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "vr/openxr_wii_remote.h"
#include <atomic>
#include <mutex>
namespace mkw::vr {
// Named rather than anonymous: runtime sources are unity-built in groups, and
// anonymous namespaces from other VR files would share this one's scope.
namespace wii_remote_bridge {
struct PublishedSample {
std::mutex mutex;
OpenXRWiiRemoteSample sample{};
bool available = false;
};
PublishedSample& Published() {
static PublishedSample published;
return published;
}
std::atomic<OpenXRControllerMode> g_mode{OpenXRControllerMode::WiiRemote};
// SDL_JoystickID of the virtual gamepad the samples belong to; 0 when none.
std::atomic<uint32_t> g_joystick_id{0};
std::atomic<bool> g_rumble{false};
} // namespace wii_remote_bridge
void OpenXRSetControllerMode(OpenXRControllerMode mode) noexcept {
wii_remote_bridge::g_mode.store(mode, std::memory_order_relaxed);
if (mode != OpenXRControllerMode::WiiRemote) {
// The game stops addressing the remote's motor once it is gone.
wii_remote_bridge::g_rumble.store(false, std::memory_order_relaxed);
}
}
OpenXRControllerMode OpenXRGetControllerMode() noexcept {
return wii_remote_bridge::g_mode.load(std::memory_order_relaxed);
}
bool OpenXRWiiRemoteOwnsGamepad(uint32_t sdl_joystick_id) noexcept {
return sdl_joystick_id != 0 && OpenXRGetControllerMode() == OpenXRControllerMode::WiiRemote &&
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);
if (!published.available) {
return false;
}
sample = published.sample;
return true;
}
void OpenXRSetWiiRemoteRumble(bool active) noexcept {
wii_remote_bridge::g_rumble.store(active, std::memory_order_relaxed);
}
void OpenXRPublishWiiRemote(uint32_t sdl_joystick_id, const OpenXRWiiRemoteSample& sample) noexcept {
auto& published = wii_remote_bridge::Published();
{
std::lock_guard lock(published.mutex);
published.sample = sample;
published.available = true;
}
wii_remote_bridge::g_joystick_id.store(sdl_joystick_id, std::memory_order_relaxed);
}
void OpenXRWithdrawWiiRemote() noexcept {
wii_remote_bridge::g_joystick_id.store(0, std::memory_order_relaxed);
wii_remote_bridge::g_rumble.store(false, std::memory_order_relaxed);
auto& published = wii_remote_bridge::Published();
std::lock_guard lock(published.mutex);
published.available = false;
}
bool OpenXRWiiRemoteRumbleRequested() noexcept {
return OpenXRGetControllerMode() == OpenXRControllerMode::WiiRemote &&
wii_remote_bridge::g_rumble.load(std::memory_order_relaxed);
}
} // namespace mkw::vr
+61 -5
View File
@@ -2,6 +2,7 @@
#include "runtime_config.h"
#include "runtime_log.h"
#include "vr/openxr_wii_remote.h"
#include <dolphin/pad.h>
#include <SDL3/SDL_gamepad.h>
@@ -530,12 +531,28 @@ Kind KindForName(const char* name) {
return Kind::Remote;
}
// True for the OpenXR virtual gamepad while it stands in for a Wii Remote.
static bool IsVrControllerGamepad(SDL_Gamepad* gamepad) {
return gamepad != nullptr && mkw::vr::OpenXRWiiRemoteOwnsGamepad(SDL_GetGamepadID(gamepad));
}
// Kind of an SDL gamepad: the VR controllers are a remote with a Nunchuk, every
// other device is classified by the name SDL gives it.
static Kind KindForGamepad(SDL_Gamepad* gamepad) {
if (gamepad == nullptr) return Kind::NotWii;
if (IsVrControllerGamepad(gamepad)) return Kind::RemoteWithNunchuk;
return KindForName(SDL_GetGamepadName(gamepad));
}
// Kind of the SDL gamepad assigned to a game port, NotWii when empty.
Kind KindForPort(uint32_t port) {
if (port >= PAD_MAX_CONTROLLERS) return Kind::NotWii;
SDL_Gamepad* gamepad = SDL_GetGamepadFromPlayerIndex(static_cast<int>(port));
if (gamepad == nullptr) return Kind::NotWii;
return KindForName(SDL_GetGamepadName(gamepad));
return KindForGamepad(SDL_GetGamepadFromPlayerIndex(static_cast<int>(port)));
}
// True when the port's remote is the VR controllers.
bool IsVrControllerChannel(uint32_t chan) {
return chan < PAD_MAX_CONTROLLERS && IsVrControllerGamepad(SDL_GetGamepadFromPlayerIndex(static_cast<int>(chan)));
}
// Human-readable name of a Kind for the settings overlay.
@@ -561,7 +578,7 @@ Kind EffectiveKind(uint32_t chan) {
if (chan >= PAD_MAX_CONTROLLERS) return Kind::NotWii;
PortMemory& memory = g_ports[chan];
SDL_Gamepad* gamepad = SDL_GetGamepadFromPlayerIndex(static_cast<int>(chan));
const Kind live = gamepad != nullptr ? KindForName(SDL_GetGamepadName(gamepad)) : Kind::NotWii;
const Kind live = KindForGamepad(gamepad);
const uint64_t now = SDL_GetTicks();
if (live != Kind::NotWii) {
memory.lastKind = live;
@@ -613,13 +630,47 @@ int16_t ClassicStickRaw(Sint16 axis, bool invert) {
return static_cast<int16_t>(std::clamp(std::lround(value * 512.0f), -512L, 511L));
}
// The VR controllers' latest sample as a remote with a Nunchuk. Before the XR
// thread has published one the remote is simply at rest.
static void ReadVrControllerSample(uint32_t chan, KpadSample& sample) {
mkw::vr::OpenXRWiiRemoteSample vr;
if (!mkw::vr::OpenXRReadWiiRemote(vr)) {
FillGraceSample(chan, Kind::RemoteWithNunchuk, sample);
return;
}
sample = {};
sample.hold = vr.hold;
sample.hasNunchuk = true;
for (int i = 0; i < 3; ++i) {
sample.acc[i] = vr.acc[i];
sample.nunchukAcc[i] = vr.nunchuk_acc[i];
}
sample.stick[0] = vr.stick[0];
sample.stick[1] = vr.stick[1];
sample.dpdValid = vr.pointer_valid;
if (vr.pointer_valid) {
sample.pos[0] = vr.pointer[0];
sample.pos[1] = vr.pointer[1];
sample.horizon[0] = vr.horizon[0];
sample.horizon[1] = vr.horizon[1];
sample.dist = vr.distance_meters;
}
// Carried over should the port briefly lose its gamepad.
g_lastAcc[chan].valid = true;
for (int i = 0; i < 3; ++i) g_lastAcc[chan].acc[i] = sample.acc[i];
}
// Samples buttons, accelerometers and the extension of the remote on a port.
bool ReadKpadSample(uint32_t chan, KpadSample& sample) {
if (chan >= PAD_MAX_CONTROLLERS) {
return false;
}
SDL_Gamepad* gamepad = SDL_GetGamepadFromPlayerIndex(static_cast<int>(chan));
const Kind kind = gamepad != nullptr ? KindForName(SDL_GetGamepadName(gamepad)) : Kind::NotWii;
if (IsVrControllerGamepad(gamepad)) {
ReadVrControllerSample(chan, sample);
return true;
}
const Kind kind = KindForGamepad(gamepad);
if (!IsKpadKind(kind)) {
const Kind remembered = EffectiveKind(chan);
if (!IsKpadKind(remembered)) {
@@ -751,6 +802,11 @@ void StartAccelCalibration(uint32_t chan) {
FinishAccelCalibration("No Wii Remote on this port.");
return;
}
if (IsVrControllerChannel(chan)) {
// Their motion comes from headset tracking, which has no zero-point bias.
FinishAccelCalibration("The VR controllers need no calibration.");
return;
}
g_calibration = {};
g_calibration.active = true;
g_calibration.chan = chan;
+293
View File
@@ -0,0 +1,293 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The VR controllers' Wii Remote presentation, tested without a headset: the
// KPAD accelerometer frame, the absolute pointer against a virtual screen, the
// picture's place on the menu quad, the off-screen debounce and the button
// profile. Every expectation is stated in the Wii's own terms, so a sign error
// in the geometry shows up as the wrong button face, tilt or cursor edge.
#include "vr/openxr_wii_remote.h"
#include <cmath>
#include <iostream>
namespace {
using namespace mkw::vr::wii_remote;
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 kHalfTurn = 3.14159265f;
constexpr float kQuarterTurn = 0.5f * kHalfTurn;
Quat AxisAngle(float x, float y, float z, float radians) {
const float s = std::sin(0.5f * radians);
return {x * s, y * s, z * s, std::cos(0.5f * radians)};
}
// Aim poses as a player holds the controller. Forward is -Z, up +Y, right +X.
constexpr Quat kLevel{0.0f, 0.0f, 0.0f, 1.0f};
Quat PitchedUp(float radians) { return AxisAngle(1.0f, 0.0f, 0.0f, radians); }
Quat YawedLeft(float radians) { return AxisAngle(0.0f, 1.0f, 0.0f, radians); }
// Seen from behind the controller, looking where it points.
Quat RolledClockwise(float radians) { return AxisAngle(0.0f, 0.0f, 1.0f, -radians); }
void TestRestingAccelerometer() {
const Vec3 zero{};
// KPAD's rest reading with the buttons up.
Vec3 acc = KpadAcceleration(kLevel, zero);
CheckNear(acc[0], 0.0f, "level remote: x");
CheckNear(acc[1], -1.0f, "level remote: gravity through the back of the remote");
CheckNear(acc[2], 0.0f, "level remote: z");
// Pointing at the floor, the remote's back end (KPAD +z, towards the
// player) is the side facing up.
acc = KpadAcceleration(PitchedUp(-kQuarterTurn), zero);
CheckNear(acc[1], 0.0f, "pointing down: y");
CheckNear(acc[2], 1.0f, "pointing down: gravity along z");
// Pointing at the ceiling it is the tip that faces up.
acc = KpadAcceleration(PitchedUp(kQuarterTurn), zero);
CheckNear(acc[2], -1.0f, "pointing up: gravity along -z");
// Rolled a quarter turn clockwise the remote's left edge (KPAD -x) faces up.
acc = KpadAcceleration(RolledClockwise(kQuarterTurn), zero);
CheckNear(acc[0], -1.0f, "rolled clockwise: gravity along -x");
CheckNear(acc[1], 0.0f, "rolled clockwise: y");
// Buttons facing the floor.
acc = KpadAcceleration(RolledClockwise(kHalfTurn), zero);
CheckNear(acc[1], 1.0f, "upside down: gravity along +y");
}
void TestAccelerometerRange() {
// A 10 g upward jolt saturates like the ADXL330 instead of reporting 11 g.
const Vec3 acc = KpadAcceleration(kLevel, {0.0f, 10.0f * kStandardGravity, 0.0f});
CheckNear(acc[1], -kAccelRangeG, "saturates at the sensor's range");
}
void TestMotionTracker() {
MotionTracker tracker;
const Quat orientation = kLevel;
// Rising at 1 g: p = a t^2 / 2, v = a t, sampled at 90 Hz.
const float a = kStandardGravity;
const int64_t step_ns = 11'111'111;
Vec3 acc{};
for (int i = 0; i < 6; ++i) {
const float t = static_cast<float>(i) * static_cast<float>(step_ns) * 1.0e-9f;
const Vec3 position{0.0f, 0.5f * a * t * t, 0.0f};
const Vec3 velocity{0.0f, a * t, 0.0f};
acc = tracker.Update(&orientation, &position, &velocity, i * step_ns);
if (i == 0) {
CheckNear(acc[1], -1.0f, "first sample has no history: gravity only");
}
}
// Gravity plus the climb: 2 g through the back of the remote.
CheckNear(acc[1], -2.0f, "steady 1 g climb reads 2 g", 1.0e-2f);
// Holding still again settles back to rest.
const Vec3 still_position{0.0f, 1.0f, 0.0f};
const Vec3 still_velocity{};
tracker.Reset();
for (int i = 0; i < 4; ++i) {
acc = tracker.Update(&orientation, &still_position, &still_velocity, (10 + i) * step_ns);
}
CheckNear(acc[1], -1.0f, "at rest after a reset", 1.0e-3f);
// Losing tracking repeats the last reading instead of inventing one.
const Vec3 held = tracker.Update(nullptr, nullptr, nullptr, 20 * step_ns);
CheckNear(held[1], acc[1], "untracked controller holds its last reading");
}
Screen ScreenAhead(float distance, float half_width, float half_height) {
Screen screen;
screen.pose.position = {0.0f, 0.0f, -distance};
screen.half_width = half_width;
screen.half_height = half_height;
return screen;
}
void TestRaycast() {
const Screen screen = ScreenAhead(2.0f, 1.2f, 0.675f);
Pose aim;
ScreenHit hit = RaycastScreen(aim, screen);
Check(hit.valid, "aiming at the screen's centre hits");
CheckNear(hit.u, 0.0f, "centre: u");
CheckNear(hit.v, 0.0f, "centre: v");
CheckNear(hit.distance_meters, 2.0f, "centre: distance");
// Absolute: sliding the hand to the right edge puts the pointer there.
aim.position = {1.2f, 0.0f, 0.0f};
hit = RaycastScreen(aim, screen);
CheckNear(hit.u, 1.0f, "hand at the right edge: u");
// Tilting up to the top edge: KPAD's y is -1 at the top.
aim.position = {};
aim.orientation = PitchedUp(std::atan(0.675f / 2.0f));
hit = RaycastScreen(aim, screen);
CheckNear(hit.v, 1.0f, "tilted to the top edge: v");
CheckNear(KpadPosition(hit)[1], -1.0f, "top edge is KPAD y -1");
// Turning left moves the pointer left.
aim.orientation = YawedLeft(std::atan(0.6f / 2.0f));
hit = RaycastScreen(aim, screen);
CheckNear(hit.u, -0.5f, "turned left: u");
CheckNear(KpadPosition(hit)[0], -0.5f, "turned left: KPAD x");
// Rotating the controller does not change the distance.
CheckNear(hit.distance_meters, 2.0f, "distance is perpendicular");
// Pointing away, or standing behind the screen, is no hit at all.
aim.orientation = YawedLeft(kHalfTurn);
Check(!RaycastScreen(aim, screen).valid, "pointing away misses");
aim.orientation = kLevel;
aim.position = {0.0f, 0.0f, -3.0f};
Check(!RaycastScreen(aim, screen).valid, "behind the screen misses");
// A screen off to the side, facing the player: only the pose matters.
Screen side;
side.pose.position = {-2.0f, 0.0f, 0.0f};
side.pose.orientation = YawedLeft(kQuarterTurn); // its +Z faces +X, back at the player
side.half_width = 1.0f;
side.half_height = 1.0f;
Pose towards_side;
towards_side.orientation = YawedLeft(kQuarterTurn); // aim -Z becomes -X
hit = RaycastScreen(towards_side, side);
Check(hit.valid, "turned towards a side screen hits");
CheckNear(hit.u, 0.0f, "side screen: u");
towards_side.position = {0.0f, 0.5f, 0.0f};
hit = RaycastScreen(towards_side, side);
CheckNear(hit.v, 0.5f, "side screen: raised hand raises the pointer");
}
void TestHorizon() {
const Screen screen = ScreenAhead(2.0f, 1.0f, 1.0f);
Pose aim;
std::array<float, 2> horizon = Horizon(aim, screen);
CheckNear(horizon[0], 1.0f, "level remote: horizon x");
CheckNear(horizon[1], 0.0f, "level remote: horizon y");
// The SDK's (0, 1) for a quarter turn clockwise.
aim.orientation = RolledClockwise(kQuarterTurn);
horizon = Horizon(aim, screen);
CheckNear(horizon[0], 0.0f, "rolled clockwise: horizon x");
CheckNear(horizon[1], 1.0f, "rolled clockwise: horizon y");
}
void TestPointerFilter() {
PointerFilter filter;
const int64_t ms = 1'000'000;
ScreenHit on{true, 0.25f, -0.5f, 1.5f};
ScreenHit result = filter.Update(on, 0);
Check(result.valid && result.u == 0.25f, "on screen passes through");
// A tracking blip holds the last position.
result = filter.Update(ScreenHit{}, 10 * ms);
Check(result.valid && result.u == 0.25f, "lost hit holds the pointer");
// An excursion past the margin pins at the margin.
result = filter.Update(ScreenHit{true, 3.0f, 0.0f, 1.5f}, 50 * ms);
Check(result.valid, "short excursion stays visible");
CheckNear(result.u, kPointerMarginU, "short excursion pins at the margin");
// Sustained, it is hidden like a remote that lost the sensor bar.
result = filter.Update(ScreenHit{true, 3.0f, 0.0f, 1.5f}, 110 * ms);
Check(!result.valid, "sustained excursion hides the pointer");
result = filter.Update(ScreenHit{}, 120 * ms);
Check(!result.valid, "stays hidden");
// Coming back shows it straight away.
result = filter.Update(on, 130 * ms);
Check(result.valid, "returning to the screen shows it again");
// Never having been on screen, a lost hit is simply no pointer.
PointerFilter fresh;
Check(!fresh.Update(ScreenHit{}, 0).valid, "no pointer before the first hit");
// Just inside the margins still counts as on screen.
Check(fresh.Update(ScreenHit{true, 1.8f, -1.4f, 1.0f}, ms).valid, "inside the margins is tracked");
}
void TestMenuPicture() {
// 16:9 game in a 16:9 window on a square eye texture: full width.
std::array<float, 2> extents = MenuPictureHalfExtents(2.4f, 1.0f, 16.0f / 9.0f, 16.0f / 9.0f);
CheckNear(extents[0], 1.2f, "16:9 picture: half width");
CheckNear(extents[1], 0.675f, "16:9 picture: half height");
// 4:3 game pillarboxed inside that window.
extents = MenuPictureHalfExtents(2.4f, 1.0f, 16.0f / 9.0f, 4.0f / 3.0f);
CheckNear(extents[0], 0.9f, "4:3 picture in 16:9 window: half width");
CheckNear(extents[1], 0.675f, "4:3 picture in 16:9 window: half height");
// A wide eye texture pillarboxes a square snapshot by width instead.
extents = MenuPictureHalfExtents(2.0f, 2.0f, 1.0f, 1.0f);
CheckNear(extents[0], 0.5f, "square picture on a wide quad: half width");
CheckNear(extents[1], 0.5f, "square picture on a wide quad: half height");
}
void TestButtons() {
HandInputs left;
HandInputs right;
Check(RemoteButtons(left, right) == 0, "nothing held");
right.primary = true;
right.trigger = 0.6f;
Check(RemoteButtons(left, right) == (kButtonA | kButtonB), "right A and trigger are A and B");
right = {};
right.stick_y = 0.9f;
Check(RemoteButtons(left, right) == kButtonOne, "right stick up is 1");
right.stick_y = -0.9f;
Check(RemoteButtons(left, right) == kButtonTwo, "right stick down is 2");
right.stick_y = 0.0f;
right.stick_x = -0.9f;
Check(RemoteButtons(left, right) == kButtonMinus, "right stick left is -");
right.stick_x = 0.9f;
Check(RemoteButtons(left, right) == kButtonPlus, "right stick right is +");
right.stick_x = 0.3f;
Check(RemoteButtons(left, right) == 0, "a light push is no press");
right = {};
left.menu = true;
left.squeeze = 0.8f;
left.trigger = 0.7f;
Check(RemoteButtons(left, right) == (kButtonHome | kButtonC | kButtonZ), "left menu, grip, trigger are HOME, C, Z");
// Left X and Y have no Wii button in the profile.
left = {};
left.primary = true;
left.secondary = true;
Check(RemoteButtons(left, right) == 0, "left X/Y are unbound");
left.stick_x = 1.0f;
left.stick_y = 1.0f;
const std::array<float, 2> stick = NunchukStick(left);
CheckNear(std::hypot(stick[0], stick[1]), 1.0f, "diagonal stays inside the gate");
CheckNear(stick[0], stick[1], "diagonal keeps its direction");
}
} // namespace
int main() {
TestRestingAccelerometer();
TestAccelerometerRange();
TestMotionTracker();
TestRaycast();
TestHorizon();
TestPointerFilter();
TestMenuPicture();
TestButtons();
if (g_failures != 0) {
std::cerr << g_failures << " check(s) failed\n";
return 1;
}
std::cout << "vr_wii_remote_tests: all checks passed\n";
return 0;
}