Add VR locomotion: move/turn with thumbsticks while the menu is open
build / build (push) Successful in 2m25s

- Left thumbstick snap-turns, right thumbstick smooth-moves, letting the
  player line themselves up with the fixed game quad ("virtual screen")
  without needing physical room-scale space to do it in. Implemented via
  a second OpenXR reference space (a "play space") derived from the real
  LOCAL space by the inverse of the player's transform, so every existing
  quad/hit-testing/rendering call site just needed g_local_space swapped
  for it - no manual coordinate-transform math required elsewhere
- Generalized the ray/quad hit-testing in xr_input.c to a proper
  oriented-plane intersection (needed once quads can rotate); verified it
  degenerates to the old axis-aligned formula when unrotated, so the
  keyboard and non-recentered menu are unaffected
- The menu launcher now re-centers in front of the player, correctly
  oriented to face them, each time it's freshly reopened
- "Reset Position" (Main Menu tab) recenters using the player's current
  real head pose - not a plain reset-to-origin, which only undid
  thumbstick movement and ignored physical drift since the session
  started. The same recenter runs once automatically at startup too, as a
  fallback for when there's no complete saved position to use instead
- Position/orientation persists in questshock.config.json's new
  player_position group (independent of savegames), including new
  numeric-value support in the config file's hand-rolled JSON parser
  (previously string-enums only)
This commit is contained in:
ml
2026-09-05 15:47:54 +02:00
parent c0619a55ab
commit 360a1f7e3d
12 changed files with 812 additions and 46 deletions
+303 -15
View File
@@ -20,6 +20,12 @@
#define LEFT 0
#define RIGHT 1
// Same constant (deliberately not <math.h>'s M_PI - see xr_overlay.c's own
// copy of this comment) as xr_session.c's play-space rebuild and
// xr_overlay.c's quad-rotation math - this file's ray/oriented-quad
// intersection and thumbstick-turn math need the same conversion.
#define QS_DEG_TO_RAD (3.14159265358979323846f / 180.0f)
static const char *kHandName[2] = {"left", "right"};
static const float kHandColor[2][4] = {
{0.2f, 1.0f, 1.0f, 1.0f}, // left: cyan
@@ -63,6 +69,10 @@ static XrAction g_space_key_action = XR_NULL_HANDLE;
// SDL_TEXTINPUT needed (see the space_key injection below for why that
// matters).
static XrAction g_forward_key_action = XR_NULL_HANDLE;
// Player locomotion while the menu is open (see xr_session.h's
// xr_session_set_player_transform()) - left stick snap-turns, right stick
// smooth-moves, both read from the same Vector2f action via subactionPath.
static XrAction g_thumbstick_action = XR_NULL_HANDLE;
static XrPath g_hand_path[2];
static XrSpace g_aim_space[2] = {XR_NULL_HANDLE, XR_NULL_HANDLE};
@@ -75,6 +85,25 @@ static bool g_prev_select[2] = {false, false};
static bool g_prev_menu = false;
static bool g_prev_space_key = false;
static bool g_prev_forward_key = false;
// Snap-turn hysteresis - true when the left stick has returned near center
// since the last turn it triggered, i.e. it's "armed" to trigger another
// one; false from the moment a turn triggers until the stick re-centers,
// so holding the stick deflected turns exactly once, not once per frame.
static bool g_turn_ready = true;
// Self-contained delta-time tracker for smooth movement - deliberately not
// shared with xr_session.c's own SDL_GetTicks()-based FPS smoothing, which
// is a private implementation detail of that file's stats display.
static Uint32 g_last_move_ticks = 0;
// True until either xr_input_recenter_player_to_screen() has succeeded
// once this session, or xr_input_skip_startup_recenter() has been called -
// checked once per frame in xr_input_sync_and_draw() (see its own comment
// there) as a *fallback* default player position/orientation for when
// there's no complete saved one to fall back on instead (see
// qs_config_load_startup()'s own comment on when it calls
// xr_input_skip_startup_recenter()). While still pending, retries every
// frame until a valid head pose is actually available - typically the
// very first frame, but tracking isn't guaranteed to be valid that early.
static bool g_pending_startup_recenter = true;
static bool g_game_prev_hit[2] = {false, false};
static bool g_game_touch_active[2] = {false, false};
static bool g_menu_prev_hit[2] = {false, false};
@@ -282,6 +311,17 @@ bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_for
"xrCreateAction(forward_key)"))
return false;
// Player locomotion, see g_thumbstick_action's own declaration comment.
XrActionCreateInfo thumbstickInfo = {XR_TYPE_ACTION_CREATE_INFO};
strncpy(thumbstickInfo.actionName, "thumbstick", XR_MAX_ACTION_NAME_SIZE - 1);
strncpy(thumbstickInfo.localizedActionName, "Thumbstick", XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1);
thumbstickInfo.actionType = XR_ACTION_TYPE_VECTOR2F_INPUT;
thumbstickInfo.countSubactionPaths = 2;
thumbstickInfo.subactionPaths = g_hand_path;
if (!xr_check(xrCreateAction(g_action_set, &thumbstickInfo, &g_thumbstick_action),
"xrCreateAction(thumbstick)"))
return false;
XrPath profilePath;
xrStringToPath(instance, "/interaction_profiles/oculus/touch_controller", &profilePath);
@@ -294,7 +334,7 @@ bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_for
// have at all (like a nonexistent .../trigger/click) fails the entire
// xrSuggestInteractionProfileBindings call, not just that one entry.
XrPath aimPoseLeftPath, aimPoseRightPath, triggerLeftPath, triggerRightPath, menuClickPath,
aClickPath, bClickPath;
aClickPath, bClickPath, thumbstickLeftPath, thumbstickRightPath;
xrStringToPath(instance, "/user/hand/left/input/aim/pose", &aimPoseLeftPath);
xrStringToPath(instance, "/user/hand/right/input/aim/pose", &aimPoseRightPath);
xrStringToPath(instance, "/user/hand/left/input/trigger/value", &triggerLeftPath);
@@ -302,6 +342,8 @@ bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_for
xrStringToPath(instance, "/user/hand/left/input/menu/click", &menuClickPath);
xrStringToPath(instance, "/user/hand/right/input/a/click", &aClickPath);
xrStringToPath(instance, "/user/hand/right/input/b/click", &bClickPath);
xrStringToPath(instance, "/user/hand/left/input/thumbstick", &thumbstickLeftPath);
xrStringToPath(instance, "/user/hand/right/input/thumbstick", &thumbstickRightPath);
XrActionSuggestedBinding bindings[] = {
{g_aim_pose_action, aimPoseLeftPath}, {g_aim_pose_action, aimPoseRightPath},
@@ -309,6 +351,7 @@ bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_for
{g_menu_toggle_action, menuClickPath},
{g_space_key_action, aClickPath},
{g_forward_key_action, bClickPath},
{g_thumbstick_action, thumbstickLeftPath}, {g_thumbstick_action, thumbstickRightPath},
};
XrInteractionProfileSuggestedBinding suggested = {XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
suggested.interactionProfile = profilePath;
@@ -387,28 +430,51 @@ static bool xr_input_try_overlay(XrOverlay *overlay, float titleBarVFraction,
bool hadTouch = *touchActive;
bool hadDrag = dragState->dragging && dragState->hand == hand;
float quadCenterX, quadCenterY, distance, halfWidth, halfHeight;
float quadCenterX, quadCenterY, distance, halfWidth, halfHeight, yawDeg;
xr_overlay_get_quad_extent(overlay, &quadCenterX, &quadCenterY, &distance, &halfWidth,
&halfHeight);
&halfHeight, &yawDeg);
// The quad's plane is z = -distance (facing the viewer along -Z) - a
// ray parallel to it (fz ~ 0) never crosses. worldX/Y is the raw
// world-space intersection point, needed as-is for dragging (which can
// legitimately move the quad to where the ray isn't currently hitting
// it); cx/cy are that same point in the quad's own [-1,1] local space;
// hit/u/v (the equivalent 0..1, top-left-origin coordinates the Java
// view's pixel grid and the on-quad hit logs use) are only meaningful
// within the quad's current bounds, unlike planeHit.
// General ray/oriented-quad intersection - the quad's plane passes
// through (quadCenterX, quadCenterY, -distance) with normal
// R_y(-yawDeg)*(0,0,1) (see xr_overlay.c's own pose-building comment on
// the same rotation). Solved by rotating the ray's origin (relative to
// the quad's center) and direction into the quad's own local frame via
// R_y(+yawDeg) - the inverse of the render-time rotation - and
// intersecting against the local z=0 plane there; A/B below are that
// rotated frame's z-components of the (center-relative) origin and the
// direction, so `t = -A/B` solves local_z(t) = A + t*B = 0. Degenerates
// to exactly the old plain "z = -distance, no rotation" formula when
// yawDeg is 0 (cosPhi=1, sinPhi=0) - true for every overlay except the
// menu launcher right after xr_input_sync_and_draw() has re-centered it
// in front of a turned player (see its own comment there).
float phi = yawDeg * QS_DEG_TO_RAD;
float cosPhi = cosf(phi), sinPhi = sinf(phi);
float dx0 = location->pose.position.x - quadCenterX;
float dz0 = location->pose.position.z + distance;
float a = -dx0 * sinPhi + dz0 * cosPhi;
float b = -fx * sinPhi + fz * cosPhi;
// worldX/Y is the raw base-space intersection point, needed as-is for
// dragging (which can legitimately move the quad to where the ray
// isn't currently hitting it, and always operates in base-space X/Y via
// xr_overlay_set_position() regardless of any rotation); cx/cy are that
// same point in the quad's own [-1,1] local space; hit/u/v (the
// equivalent 0..1, top-left-origin coordinates the Java view's pixel
// grid and the on-quad hit logs use) are only meaningful within the
// quad's current bounds, unlike planeHit.
bool planeHit = false, hit = false;
float u = 0.0f, v = 0.0f, worldX = 0.0f, worldY = 0.0f, t = 0.0f;
if (fabsf(fz) > 1e-5f) {
t = (-distance - location->pose.position.z) / fz;
if (fabsf(b) > 1e-5f) {
t = -a / b;
if (t > 0.0f) {
planeHit = true;
worldX = location->pose.position.x + t * fx;
worldY = location->pose.position.y + t * fy;
float cx = (worldX - quadCenterX) / halfWidth;
float cy = (worldY - quadCenterY) / halfHeight;
float worldZ = location->pose.position.z + t * fz;
float localX = (worldX - quadCenterX) * cosPhi + (worldZ + distance) * sinPhi;
float localY = worldY - quadCenterY;
float cx = localX / halfWidth;
float cy = localY / halfHeight;
hit = fabsf(cx) <= 1.0f && fabsf(cy) <= 1.0f;
u = (cx + 1.0f) * 0.5f;
v = (1.0f - cy) * 0.5f;
@@ -619,6 +685,126 @@ static bool xr_input_try_game_quad(int hand, const XrSpaceLocation *location, fl
return hit;
}
// Re-centers the menu launcher in front of wherever the player is
// currently looking, each time it's freshly opened (see the menu-toggle
// handling below) - otherwise a player who's walked/turned away from the
// menu's last position (via the thumbstick locomotion further down, or a
// manual drag) would find it stuck somewhere behind/beside them. Uses
// xr_overlay_get_default_distance_m() (the overlay's original, never-
// mutated distance_m) rather than duplicating MENU_DISTANCE_METERS here -
// deliberately NOT the live distance_m from xr_overlay_get_quad_extent(),
// which xr_overlay_set_pose() repurposes as a raw base-space Z coordinate
// once rotation is involved (see that function's own comment) - reusing
// that as a "how far in front" magnitude would drift the menu further
// away with every reopen instead of holding a stable distance. Height
// (center_y_m) doesn't have this problem (it's never touched by any
// rotation math), so that one *is* read live, preserving a manual drag.
// Independent of needBeams/draw below (a menu toggle can happen on a frame
// nothing renders), so this does its own dedicated head locate rather than
// reusing headLoc.
static void xr_input_recenter_menu(XrOverlay *menuOverlay, XrSpace baseSpace, XrTime time) {
XrSpaceLocation headLoc = {XR_TYPE_SPACE_LOCATION};
const XrSpaceLocationFlags needed =
XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT;
if (!xr_check(xrLocateSpace(g_view_space, baseSpace, time, &headLoc),
"xrLocateSpace(view, menu recenter)") ||
(headLoc.locationFlags & needed) != needed)
return;
float fx, fy, fz;
quat_rotate_vec(&headLoc.pose.orientation, 0.0f, 0.0f, -1.0f, &fx, &fy, &fz);
// Horizontal-only, matching the play space's own yaw-only rotation -
// looking sharply up/down shouldn't tip the menu with you.
float len = sqrtf(fx * fx + fz * fz);
if (len < 1e-4f)
return; // looking almost straight up/down - leave the existing pose alone
fx /= len;
fz /= len;
float currentX, currentY, liveDistance, halfWidth, halfHeight, currentYaw;
xr_overlay_get_quad_extent(menuOverlay, &currentX, &currentY, &liveDistance, &halfWidth,
&halfHeight, &currentYaw);
(void)currentX;
(void)liveDistance;
(void)halfWidth;
(void)halfHeight;
(void)currentYaw;
float distance = xr_overlay_get_default_distance_m(menuOverlay);
float newX = headLoc.pose.position.x + fx * distance;
float newZ = headLoc.pose.position.z + fz * distance;
// xr_overlay_set_pose()'s distance_m is simply the quad's base-space Z
// position negated (see xr_overlay_render_and_build_layer()) - not
// necessarily positive once the quad can end up anywhere the player
// has walked to, unlike every other overlay's fixed, always-in-front-
// of-the-origin spawn pose.
float newDistance = -newZ;
// See xr_overlay_render_and_build_layer()'s own rotation comment -
// solved so the quad's local front normal (0,0,1) ends up pointing
// along -forward, i.e. back at the player looking along +forward at it.
float newYawDeg = -atan2f(-fx, -fz) / QS_DEG_TO_RAD;
xr_overlay_set_pose(menuOverlay, newX, currentY, newDistance, newYawDeg);
}
// See xr_input.h's own comment. Chooses a new player transform such that
// the player's CURRENT real head pose maps to play-space position (0,0)
// facing (0,0,-1) - i.e. exactly reproducing this app's own startup
// framing (the fixed game quad dead ahead at its authored distance), just
// re-anchored to wherever the player is standing/facing right now instead
// of wherever their headset happened to be when the session first began.
// Deliberately locates the head against the REAL local space (via
// xr_session_get_local_space()), not the play space every other consumer
// in this file uses - computing a brand new transform from the player's
// real pose would be circular if measured through the very transform
// being replaced.
//
// Math: with V(p) = R_y(yaw_deg) * p + (x, z) defined as "real-space pose p
// maps to this play-space pose" (see xr_session_rebuild_play_space()'s own
// derivation, which builds the inverse of this same V), solving
// V(headPos) = (0,0) and V(headForward) = (0,0,-1) for (x, z, yaw_deg)
// gives the formulas below - verified by substitution that a
// player standing at the real origin facing the real default -Z (i.e. one
// who has never moved) yields (0, 0, 0), the same as this feature's
// original naive reset-to-identity behavior.
bool xr_input_recenter_player_to_screen(void) {
XrSpace localSpace = xr_session_get_local_space();
if (localSpace == XR_NULL_HANDLE || g_view_space == XR_NULL_HANDLE)
return false;
XrTime time = xr_session_get_predicted_display_time();
XrSpaceLocation headLoc = {XR_TYPE_SPACE_LOCATION};
const XrSpaceLocationFlags needed =
XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT;
if (!xr_check(xrLocateSpace(g_view_space, localSpace, time, &headLoc),
"xrLocateSpace(view, recenter to screen)") ||
(headLoc.locationFlags & needed) != needed)
return false;
float fx, fy, fz;
quat_rotate_vec(&headLoc.pose.orientation, 0.0f, 0.0f, -1.0f, &fx, &fy, &fz);
float len = sqrtf(fx * fx + fz * fz);
if (len < 1e-4f)
return false; // looking almost straight up/down - nothing sane to recenter onto
fx /= len;
fz /= len;
// angle(0,-1) - angle(fx,fz), using the same atan2f(x, z) "angle"
// convention as xr_overlay_render_and_build_layer()'s own rotation
// comment (not the more usual atan2f(y, x)).
float theta = atan2f(0.0f, -1.0f) - atan2f(fx, fz);
float cosTheta = cosf(theta), sinTheta = sinf(theta);
float hx = headLoc.pose.position.x, hz = headLoc.pose.position.z;
float newX = -(hx * cosTheta + hz * sinTheta);
float newZ = hx * sinTheta - hz * cosTheta;
float newYawDeg = theta / QS_DEG_TO_RAD;
xr_session_set_player_transform(newX, newZ, newYawDeg);
return true;
}
void xr_input_skip_startup_recenter(void) { g_pending_startup_recenter = false; }
void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
if (g_action_set == XR_NULL_HANDLE)
return;
@@ -629,6 +815,12 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
syncInfo.activeActionSets = &activeSet;
xr_check(xrSyncActions(g_session, &syncInfo), "xrSyncActions");
// See g_pending_startup_recenter's own comment - retried every frame
// (cheap: one xrLocateSpace call) until head tracking actually has a
// valid pose to recenter onto, typically the very first frame.
if (g_pending_startup_recenter && xr_input_recenter_player_to_screen())
g_pending_startup_recenter = false;
XrOverlay *menuOverlay = xr_session_get_menu_overlay();
XrOverlay *keyboardOverlay = xr_session_get_keyboard_overlay();
bool menuVisible = xr_overlay_is_visible(menuOverlay);
@@ -702,14 +894,49 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
// fully independent (its own Close button is the only way
// to hide it, see KeyboardOverlay.java).
if (menuOverlay != NULL) {
bool wasHidden = !menuVisible;
xr_overlay_toggle_visible(menuOverlay);
g_menu_prev_hit[LEFT] = g_menu_prev_hit[RIGHT] = false;
g_menu_touch_active[LEFT] = g_menu_touch_active[RIGHT] = false;
if (wasHidden && xr_overlay_is_visible(menuOverlay))
xr_input_recenter_menu(menuOverlay, baseSpace, time);
}
} else if (!menuDown && g_prev_menu) {
LOGI("XR: menu-toggle button UP");
}
g_prev_menu = menuDown;
// Snap turn - only while the menu is open (per the feature's
// own design: repositioning is for lining yourself up with the
// game quad from the menu, not a general gameplay locomotion
// scheme). g_turn_ready is a hysteresis latch (see its own
// declaration comment) so holding the stick past the threshold
// turns exactly once, not once per frame - it only re-arms once
// the stick has come back near center.
if (menuVisible) {
XrActionStateGetInfo turnInfo = {XR_TYPE_ACTION_STATE_GET_INFO};
turnInfo.action = g_thumbstick_action;
turnInfo.subactionPath = g_hand_path[LEFT];
XrActionStateVector2f turnState = {XR_TYPE_ACTION_STATE_VECTOR2F};
xrGetActionStateVector2f(g_session, &turnInfo, &turnState);
if (turnState.isActive) {
const float kSnapThreshold = 0.7f;
const float kSnapRearm = 0.3f;
const float kSnapDegrees = 30.0f;
float x = turnState.currentState.x;
if (g_turn_ready && fabsf(x) > kSnapThreshold) {
float px, pz, pyaw;
xr_session_get_player_transform(&px, &pz, &pyaw);
// Confirmed backwards on-device from the initial
// (unverifiable-without-a-headset) guess - flipped.
pyaw += (x > 0.0f) ? -kSnapDegrees : kSnapDegrees;
xr_session_set_player_transform(px, pz, pyaw);
g_turn_ready = false;
} else if (fabsf(x) < kSnapRearm) {
g_turn_ready = true;
}
}
}
}
if (hand == RIGHT) {
@@ -786,6 +1013,63 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
SDL_PushEvent(&event);
g_prev_forward_key = forwardKeyDown;
}
// Smooth move - only while the menu is open, same reasoning as
// the left stick's snap turn above. g_last_move_ticks is
// refreshed every frame regardless of menuVisible (not just
// while actively moving) so dt is always "time since the
// previous frame" and never goes stale/huge across a stretch
// with the menu closed.
Uint32 nowTicks = SDL_GetTicks();
Uint32 lastMoveTicks = g_last_move_ticks;
g_last_move_ticks = nowTicks;
if (menuVisible && lastMoveTicks != 0) {
XrActionStateGetInfo moveInfo = {XR_TYPE_ACTION_STATE_GET_INFO};
moveInfo.action = g_thumbstick_action;
moveInfo.subactionPath = g_hand_path[RIGHT];
XrActionStateVector2f moveState = {XR_TYPE_ACTION_STATE_VECTOR2F};
xrGetActionStateVector2f(g_session, &moveInfo, &moveState);
const float kMoveDeadzone = 0.15f;
float mx = moveState.currentState.x, my = moveState.currentState.y;
float mag = sqrtf(mx * mx + my * my);
if (moveState.isActive && mag > kMoveDeadzone) {
XrSpaceLocation headLoc = {XR_TYPE_SPACE_LOCATION};
const XrSpaceLocationFlags neededHead2 =
XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT;
if (xr_check(xrLocateSpace(g_view_space, baseSpace, time, &headLoc),
"xrLocateSpace(view, move)") &&
(headLoc.locationFlags & neededHead2) == neededHead2) {
float hfx, hfy, hfz;
quat_rotate_vec(&headLoc.pose.orientation, 0.0f, 0.0f, -1.0f, &hfx, &hfy,
&hfz);
float horizLen = sqrtf(hfx * hfx + hfz * hfz);
if (horizLen > 1e-4f) {
hfx /= horizLen;
hfz /= horizLen;
// "Right" relative to forward, in the horizontal
// plane - R_y(-90deg) applied to (hfx, hfz), see
// xr_overlay.c's own rotation-matrix comment for
// the same R_y() convention.
float rfx = -hfz, rfz = hfx;
const float kMoveSpeedMetersPerSec = 1.2f;
float dt = (float)(nowTicks - lastMoveTicks) / 1000.0f;
float moveX = (hfx * my + rfx * mx) * kMoveSpeedMetersPerSec * dt;
float moveZ = (hfz * my + rfz * mx) * kMoveSpeedMetersPerSec * dt;
float px, pz, pyaw;
xr_session_get_player_transform(&px, &pz, &pyaw);
// Moves the play space opposite the player's
// intended travel, so quads (fixed in play
// space) get closer/farther as the player
// virtually walks toward/away from them - see
// xr_session_set_player_transform()'s own
// comment. Confirmed backwards on-device from
// the initial (unverifiable-without-a-headset)
// guess - flipped.
xr_session_set_player_transform(px + moveX, pz + moveZ, pyaw);
}
}
}
}
}
XrActionStateGetInfo poseInfo = {XR_TYPE_ACTION_STATE_GET_INFO};
@@ -919,6 +1203,7 @@ void xr_input_shutdown(void) {
g_menu_toggle_action = XR_NULL_HANDLE;
g_space_key_action = XR_NULL_HANDLE;
g_forward_key_action = XR_NULL_HANDLE;
g_thumbstick_action = XR_NULL_HANDLE;
g_instance = XR_NULL_HANDLE;
g_session = XR_NULL_HANDLE;
@@ -937,4 +1222,7 @@ void xr_input_shutdown(void) {
g_keyboard_drag.hand = -1;
g_menu_drag.dragging = false;
g_menu_drag.hand = -1;
g_turn_ready = true;
g_last_move_ticks = 0;
g_pending_startup_recenter = true;
}