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
+44
View File
@@ -51,6 +51,11 @@ startup, in addition to (and independent of) the engine's own `prefs.txt`.
},
"general": {
"language": "english"
},
"player_position": {
"x": 0.0,
"z": 0.0,
"yaw_degrees": 0.0
}
}
```
@@ -94,3 +99,42 @@ project's own menu/keyboard chrome text (title bar, buttons, etc.).
open. See README.md section 3 ("Game assets") for where `mfdfrn.res`/
`mfdger.res` come from - the GOG Enhanced Edition installer alone doesn't
include them.
## Player Position (`player_position`)
Where the player is virtually standing/facing, independent of - and not
reset by - starting a new game or loading a save (same independence from
`prefs.txt`/savegames as every other group here). Set by moving around
in-headset (menu open, left thumbstick snap-turns, right thumbstick smooth-
moves) to line yourself up with the game quad ("virtual screen"). Height is
never included here - it always comes from your real tracked headset
height, not this file.
**Startup fallback**: only applied at startup if all three fields (`x`,
`z`, `yaw_degrees`) are present - a config file with none of them (or none
at all), or a partial/hand-edited one missing any of the three, falls back
to an automatic recenter instead (see **Reset Position** below), using
your real head pose at that moment. A complete saved position is applied
as-is, with no such recenter.
**Reset Position** (Main Menu tab) is a separate, live-only action, not a
config value: it recenters using your *current* real head pose, so the
game quad ends up directly ahead of wherever you're actually standing/
facing right now - not a plain "go back to `(0, 0, 0)`" (which would only
undo thumbstick movement, not any physical drift/rotation since the
session started). It's unrelated to `questshock.config.json` - it never
reads or writes this file.
### `x`, `z`
Horizontal position, in meters, on the play space's floor plane.
- **Type**: number
- **Restart required?**: No (live-appliable)
### `yaw_degrees`
Which way the player is virtually facing, in degrees.
- **Type**: number
- **Restart required?**: No (live-appliable)
+167 -6
View File
@@ -19,6 +19,9 @@
#include "qs_config.h"
#include "xr_input.h"
#include "xr_session.h"
#include "Prefs.h"
#include "fullscrn.h"
#include "lg.h"
@@ -143,6 +146,38 @@ static bool qs_json_skip_number(QsJsonParser *p) {
return true;
}
// Parses a JSON number at the cursor into *out - unlike qs_json_skip_number()
// above (which every other option so far has only ever needed, since every
// existing option is a string enum), this one actually extracts the value.
// Same scan-then-copy-then-parse approach as qs_json_parse_string(), needed
// because the source buffer isn't NUL-terminated at an arbitrary offset, so
// strtof() can't just be pointed at the cursor directly.
static bool qs_json_parse_number(QsJsonParser *p, float *out) {
if (p->error)
return false;
const char *start = p->p;
if (p->p < p->end && *p->p == '-')
p->p++;
while (p->p < p->end && (isdigit((unsigned char)*p->p) || *p->p == '.' || *p->p == 'e' ||
*p->p == 'E' || *p->p == '+' || *p->p == '-'))
p->p++;
size_t len = (size_t)(p->p - start);
if (len == 0 || len >= 64) {
qs_json_fail(p);
return false;
}
char buf[64];
memcpy(buf, start, len);
buf[len] = '\0';
char *endptr = NULL;
*out = strtof(buf, &endptr);
if (endptr == buf) {
qs_json_fail(p);
return false;
}
return true;
}
static bool qs_json_skip_literal(QsJsonParser *p, const char *literal) {
size_t len = strlen(literal);
if ((size_t)(p->end - p->p) < len || strncmp(p->p, literal, len) != 0) {
@@ -275,6 +310,19 @@ static void qs_json_take_string_field(QsJsonParser *p, char *out, size_t out_siz
qs_json_skip_value(p);
}
// Same "skip if wrong type" behavior as qs_json_take_string_field() above,
// for a numeric field - sets *has to true only on success, since (unlike
// the string groups, where an empty string means "not present") 0.0f is a
// perfectly valid value and can't double as an absent-sentinel.
static void qs_json_take_number_field(QsJsonParser *p, float *out, bool *has) {
if (!p->error && p->p < p->end && (isdigit((unsigned char)*p->p) || *p->p == '-')) {
if (qs_json_parse_number(p, out))
*has = true;
} else {
qs_json_skip_value(p);
}
}
// ===========================================================================
// Schema: "video_options" group
// ===========================================================================
@@ -288,11 +336,21 @@ typedef struct {
char language[16]; // "" = not present in the file
} QsParsedGeneral;
typedef struct {
float x, z, yaw_degrees;
// Unlike every string-valued option above, 0.0f is a perfectly valid
// value here and can't double as "not present in the file" - see
// qs_json_take_number_field()'s own comment.
bool has_x, has_z, has_yaw_degrees;
} QsParsedPlayerPosition;
typedef struct {
QsParsedVideoOptions video_options;
bool has_video_options;
QsParsedGeneral general;
bool has_general;
QsParsedPlayerPosition player_position;
bool has_player_position;
} QsParsedConfig;
static void qs_parse_video_options_field(const char *key, QsJsonParser *p, void *ctx) {
@@ -313,12 +371,27 @@ static void qs_parse_general_field(const char *key, QsJsonParser *p, void *ctx)
qs_json_skip_value(p);
}
static void qs_parse_player_position_field(const char *key, QsJsonParser *p, void *ctx) {
QsParsedPlayerPosition *out = (QsParsedPlayerPosition *)ctx;
if (strcmp(key, "x") == 0)
qs_json_take_number_field(p, &out->x, &out->has_x);
else if (strcmp(key, "z") == 0)
qs_json_take_number_field(p, &out->z, &out->has_z);
else if (strcmp(key, "yaw_degrees") == 0)
qs_json_take_number_field(p, &out->yaw_degrees, &out->has_yaw_degrees);
else
qs_json_skip_value(p);
}
static void qs_parse_top_level_field(const char *key, QsJsonParser *p, void *ctx) {
QsParsedConfig *out = (QsParsedConfig *)ctx;
if (strcmp(key, "video_options") == 0 && !p->error && p->p < p->end && *p->p == '{')
out->has_video_options = qs_json_parse_object(p, qs_parse_video_options_field, &out->video_options);
else if (strcmp(key, "general") == 0 && !p->error && p->p < p->end && *p->p == '{')
out->has_general = qs_json_parse_object(p, qs_parse_general_field, &out->general);
else if (strcmp(key, "player_position") == 0 && !p->error && p->p < p->end && *p->p == '{')
out->has_player_position =
qs_json_parse_object(p, qs_parse_player_position_field, &out->player_position);
else
qs_json_skip_value(p);
}
@@ -569,6 +642,46 @@ static const char *qs_current_language_name(void) {
return "english"; // shouldn't happen - safe fallback for Save
}
// ===========================================================================
// General: player position
// ===========================================================================
// Live-appliable: xr_session_set_player_transform() (xr_session.c) is a
// plain state setter with no XR API calls of its own - safe to call before
// xr_init() has even run (which is how startup seeding works here, unlike
// every other option: nothing in xr_session.c exists yet to "apply" this
// to at startup, so the value is simply stored and picked up once
// xr_init() creates its own play space - see that function's own comment)
// as well as live, mid-session.
#define QS_PLAYER_POSITION_REQUIRES_RESTART false
// Applies only the fields actually present in *parsed, leaving the other
// axis/yaw untouched - e.g. a hand-edited file with just `"yaw_degrees"`
// shouldn't reset x/z to 0. Always live-appliable (see above) - the `live`
// parameter argument that qs_apply_renderer()/qs_apply_resolution()/
// qs_apply_language() take doesn't apply here, so unlike those this takes
// no such argument.
static bool qs_apply_player_position(const QsParsedPlayerPosition *parsed) {
float x, z, yaw_degrees;
xr_session_get_player_transform(&x, &z, &yaw_degrees);
bool changed = false;
if (parsed->has_x && parsed->x != x) {
x = parsed->x;
changed = true;
}
if (parsed->has_z && parsed->z != z) {
z = parsed->z;
changed = true;
}
if (parsed->has_yaw_degrees && parsed->yaw_degrees != yaw_degrees) {
yaw_degrees = parsed->yaw_degrees;
changed = true;
}
if (changed)
xr_session_set_player_transform(x, z, yaw_degrees);
return changed;
}
// ===========================================================================
// Read-only queries for the Video Options tab (see qs_config.h)
// ===========================================================================
@@ -629,6 +742,11 @@ static void qs_config_apply_parsed(const QsParsedConfig *parsed, bool live, char
qs_append_restart_reason(restart_needed, restart_needed_size, "language");
}
}
if (parsed->has_player_position) {
bool changed = qs_apply_player_position(&parsed->player_position);
if (changed && QS_PLAYER_POSITION_REQUIRES_RESTART)
qs_append_restart_reason(restart_needed, restart_needed_size, "player_position");
}
}
static bool qs_config_write_file(void) {
@@ -644,7 +762,16 @@ static bool qs_config_write_file(void) {
fprintf(f, " },\n");
fprintf(f, " \"general\": {\n");
fprintf(f, " \"language\": \"%s\"\n", qs_current_language_name());
fprintf(f, " },\n");
{
float x, z, yaw_degrees;
xr_session_get_player_transform(&x, &z, &yaw_degrees);
fprintf(f, " \"player_position\": {\n");
fprintf(f, " \"x\": %f,\n", x);
fprintf(f, " \"z\": %f,\n", z);
fprintf(f, " \"yaw_degrees\": %f\n", yaw_degrees);
fprintf(f, " }\n");
}
fprintf(f, "}\n");
fclose(f);
return true;
@@ -725,24 +852,38 @@ static Uint32 s_load_event_type;
static Uint32 s_set_renderer_event_type;
static Uint32 s_set_resolution_event_type;
static Uint32 s_set_language_event_type;
static Uint32 s_reset_position_event_type;
void qs_config_load_startup(void) {
// Registers the custom SDL event types qs_config_request_save()/_load()/
// _set_renderer()/_set_resolution()/_set_language() enqueue - done here,
// once, on the engine's own thread and before the menu/its buttons could
// possibly exist yet, rather than lazily from those functions themselves
// (which run on Android's UI thread and could otherwise race each other
// doing this lazily).
Uint32 base = SDL_RegisterEvents(5);
// _set_renderer()/_set_resolution()/_set_language()/_reset_position()
// enqueue - done here, once, on the engine's own thread and before the
// menu/its buttons could possibly exist yet, rather than lazily from
// those functions themselves (which run on Android's UI thread and
// could otherwise race each other doing this lazily).
Uint32 base = SDL_RegisterEvents(6);
s_save_event_type = base;
s_load_event_type = base + 1;
s_set_renderer_event_type = base + 2;
s_set_resolution_event_type = base + 3;
s_set_language_event_type = base + 4;
s_reset_position_event_type = base + 5;
QsParsedConfig parsed;
if (qs_config_read_file(&parsed))
qs_config_apply_parsed(&parsed, /*live=*/false, NULL, 0);
// A saved player_position only means anything if all three fields are
// present (a partial one - e.g. just "yaw_degrees" from a hand-edited
// file - isn't a complete, sane transform) - xr_input.c's own
// automatic startup recenter (xr_input_recenter_player_to_screen(),
// via g_pending_startup_recenter) is the fallback for a missing file,
// a missing player_position group, or an incomplete one; skip it here
// once a complete saved position was actually loaded, so it doesn't
// immediately override what was just applied above.
if (parsed.has_player_position && parsed.player_position.has_x &&
parsed.player_position.has_z && parsed.player_position.has_yaw_degrees)
xr_input_skip_startup_recenter();
}
void qs_config_request_save(void) {
@@ -783,6 +924,13 @@ void qs_config_request_set_language(int language_index) {
SDL_PushEvent(&event);
}
void qs_config_request_reset_position(void) {
SDL_Event event;
SDL_zero(event);
event.type = s_reset_position_event_type;
SDL_PushEvent(&event);
}
bool qs_config_handle_event(const SDL_Event *event) {
if (event->type == s_save_event_type) {
if (qs_config_write_file())
@@ -821,5 +969,18 @@ bool qs_config_handle_event(const SDL_Event *event) {
qs_apply_language(kLanguages[index].name, /*live=*/true);
return true;
}
if (event->type == s_reset_position_event_type) {
// Deliberately not xr_session_set_player_transform(0,0,0) - see
// xr_input_recenter_player_to_screen()'s own comment: a "return to
// (0,0,0)" reset only undoes thumbstick locomotion, not any real,
// physical drift/rotation since the session started, which is what
// actually made this feel "slightly off" - recentering onto the
// player's current real head pose fixes both at once, and is
// unrelated to questshock.config.json (this event doesn't read or
// write the config file at all, just like this one never has).
xr_input_recenter_player_to_screen();
message_info("Recentered");
return true;
}
return false;
}
+16 -6
View File
@@ -56,13 +56,23 @@ void qs_config_request_set_resolution(int resolution_index);
// menu applies a language change (see qs_config.c's qs_apply_language()).
void qs_config_request_set_language(int language_index);
// Returns true and handles the event if it's one of the five custom events
// Same cross-thread-dispatch reasoning as the three above - called from the
// Main Menu tab's "Reset Position" button. A pure, live "return to a sane
// view of the game quad" action (see xr_input.h's own
// xr_input_recenter_player_to_screen(), which this dispatches to) -
// unrelated to questshock.config.json despite living in this file for the
// cross-thread dispatch machinery: it never reads or writes the config
// file, and isn't a "reset to (0,0,0)" - it recenters based on the
// player's current real head pose instead.
void qs_config_request_reset_position(void);
// Returns true and handles the event if it's one of the six custom events
// qs_config_request_save()/_load()/_set_renderer()/_set_resolution()/
// _set_language() enqueue - called from pump_events() (sdl_events.c, see
// android/engine-patches/21-android-config.patch) on the engine's own
// thread, once per polled event, before its normal switch on well-known
// SDL_* event types. Returns false (no-op) for any other event type,
// including before the five custom types have even been registered.
// _set_language()/_reset_position() enqueue - called from pump_events()
// (sdl_events.c, see android/engine-patches/21-android-config.patch) on the
// engine's own thread, once per polled event, before its normal switch on
// well-known SDL_* event types. Returns false (no-op) for any other event
// type, including before the six custom types have even been registered.
bool qs_config_handle_event(const SDL_Event *event);
// Read-only queries for the Video Options tab's own selection highlight
@@ -125,6 +125,13 @@ Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetLanguage(JNIEnv *env, jcla
qs_config_request_set_language(languageIndex);
}
// MenuOverlay's Main Menu tab's "Reset Position" button - same enqueue-only
// reasoning as above.
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestResetPosition(JNIEnv *env, jclass clazz) {
qs_config_request_reset_position();
}
// Read-only queries - safe to call directly (no enqueue needed, see
// qs_config.h's own comment on each of these).
JNIEXPORT jboolean JNICALL
+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;
}
+30
View File
@@ -66,6 +66,36 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw);
// frame.
bool xr_input_get_beam_layer(int hand, XrCompositionLayerQuad *out_quad);
// Recenters the player so the game quad ("virtual screen") appears directly
// ahead of wherever they're currently really standing/facing - a pure,
// live "return to a sane view of the screen" action, unrelated to any
// stored/config value (unlike xr_session_set_player_transform()'s other
// callers, this never reads or writes questshock.config.json - it's driven
// entirely by the player's current real head pose, the same way
// xr_input_sync_and_draw()'s own menu re-center is). Called from the Main
// Menu tab's "Reset Position" button (via qs_config.c's cross-thread
// dispatch - see qs_config_request_reset_position()), but doesn't touch
// anything else qs_config.c owns; also called once automatically, from
// inside xr_input_sync_and_draw() itself, on whichever frame first has a
// valid head pose after startup (see g_pending_startup_recenter's own
// comment) - but only as a *fallback* default for when there's no
// complete saved player_position to fall back on instead (see
// xr_input_skip_startup_recenter() below), since a config-loaded value
// describes a transform relative to a *previous* session's real-space
// origin, which is only meaningful if that previous position was actually
// captured (LOCAL space's own origin isn't a stable point across
// launches). Returns false (no-op otherwise) if no valid head pose was
// available to recenter onto yet.
bool xr_input_recenter_player_to_screen(void);
// Called once, from qs_config_load_startup() (qs_config.c), when a
// complete saved player_position was actually loaded from
// questshock.config.json - cancels the pending automatic startup recenter
// above so it doesn't immediately override the value that was just
// applied. Safe to call regardless of whether xr_input_init() has even run
// yet (this only touches the plain file-local flag, no XR calls).
void xr_input_skip_startup_recenter(void);
void xr_input_shutdown(void);
#ifdef __cplusplus
+53 -3
View File
@@ -1,6 +1,7 @@
#include "xr_overlay.h"
#include <dlfcn.h>
#include <math.h>
#include <stdlib.h>
#include <string.h>
@@ -18,11 +19,33 @@
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
// Same constant (deliberately not <math.h>'s M_PI, which isn't used
// anywhere else in this codebase and isn't guaranteed by the C standard -
// only POSIX) as xr_session.c's own play-space rebuild math.
#define QS_DEG_TO_RAD (3.14159265358979323846f / 180.0f)
struct XrOverlay {
XrInstance instance; // for error logging only
int width, height;
float distance_m, half_width_m, half_height_m;
float center_x_m, center_y_m;
// The *authored* distance_m (config->distance_m at creation time),
// never touched again afterward by anything, including
// xr_overlay_set_pose() - kept separate from the live, mutable
// distance_m above because that one stops meaning "how far in front of
// the origin" once a rotated set_pose() call (the menu recenter)
// repurposes it as a raw base-space Z coordinate; reusing *that* as a
// "how far in front of me" magnitude on a later recenter would drift
// further with every reopen instead of staying at a stable distance -
// see xr_overlay_get_default_distance_m().
float default_distance_m;
// Rotation about the vertical axis, applied on top of center_x_m/
// center_y_m/distance_m (see xr_overlay_set_pose()) - 0 for every
// overlay except the menu launcher immediately after it's freshly
// reopened (xr_input.c re-centers it in front of wherever the player
// is currently facing, which needs this to keep the quad actually
// facing them instead of whatever direction it started out at).
float yaw_deg;
bool visible;
XrSwapchainState swapchain;
@@ -205,6 +228,7 @@ XrOverlay *xr_overlay_create(XrInstance instance, XrSession session, int64_t swa
overlay->width = config->width;
overlay->height = config->height;
overlay->distance_m = config->distance_m;
overlay->default_distance_m = config->distance_m;
overlay->half_width_m = config->width_m * 0.5f;
overlay->half_height_m = config->width_m * 0.5f * (float)config->height / (float)config->width;
overlay->center_x_m = config->default_center_x_m;
@@ -281,9 +305,10 @@ void xr_overlay_set_visible(XrOverlay *overlay, bool visible) {
bool xr_overlay_is_visible(const XrOverlay *overlay) { return overlay != NULL && overlay->visible; }
void xr_overlay_get_quad_extent(const XrOverlay *overlay, float *center_x_m, float *center_y_m,
float *distance_m, float *half_width_m, float *half_height_m) {
float *distance_m, float *half_width_m, float *half_height_m,
float *yaw_deg) {
if (overlay == NULL) {
*center_x_m = *center_y_m = *distance_m = *half_width_m = *half_height_m = 0.0f;
*center_x_m = *center_y_m = *distance_m = *half_width_m = *half_height_m = *yaw_deg = 0.0f;
return;
}
*center_x_m = overlay->center_x_m;
@@ -291,6 +316,7 @@ void xr_overlay_get_quad_extent(const XrOverlay *overlay, float *center_x_m, flo
*distance_m = overlay->distance_m;
*half_width_m = overlay->half_width_m;
*half_height_m = overlay->half_height_m;
*yaw_deg = overlay->yaw_deg;
}
void xr_overlay_set_position(XrOverlay *overlay, float center_x_m, float center_y_m) {
@@ -300,6 +326,20 @@ void xr_overlay_set_position(XrOverlay *overlay, float center_x_m, float center_
overlay->center_y_m = center_y_m;
}
void xr_overlay_set_pose(XrOverlay *overlay, float center_x_m, float center_y_m, float distance_m,
float yaw_deg) {
if (overlay == NULL)
return;
overlay->center_x_m = center_x_m;
overlay->center_y_m = center_y_m;
overlay->distance_m = distance_m;
overlay->yaw_deg = yaw_deg;
}
float xr_overlay_get_default_distance_m(const XrOverlay *overlay) {
return overlay == NULL ? 0.0f : overlay->default_distance_m;
}
void xr_overlay_touch(XrOverlay *overlay, float u, float v, bool down) {
if (overlay == NULL)
return;
@@ -443,7 +483,17 @@ bool xr_overlay_render_and_build_layer(XrOverlay *overlay, XrCompositionLayerQua
out_quad->subImage.swapchain = overlay->swapchain.swapchain;
out_quad->subImage.imageRect.extent.width = overlay->swapchain.width;
out_quad->subImage.imageRect.extent.height = overlay->swapchain.height;
out_quad->pose.orientation.w = 1.0f;
// Rotation about the vertical axis only (see the struct field's own
// comment) - same R_y(-yaw_deg) quaternion construction as
// xr_session.c's own play-space rebuild, for the same reason: this
// quad's pose is expressed as "yaw_deg turned from its own local -Z",
// so a point/ray in that rotated frame maps back into the (unrotated)
// base space by rotating by -yaw_deg.
float half = -(overlay->yaw_deg * QS_DEG_TO_RAD) * 0.5f;
out_quad->pose.orientation.x = 0.0f;
out_quad->pose.orientation.y = sinf(half);
out_quad->pose.orientation.z = 0.0f;
out_quad->pose.orientation.w = cosf(half);
out_quad->pose.position.x = overlay->center_x_m;
out_quad->pose.position.y = overlay->center_y_m;
out_quad->pose.position.z = -overlay->distance_m;
+27 -6
View File
@@ -58,18 +58,39 @@ void xr_overlay_toggle_visible(XrOverlay *overlay);
void xr_overlay_set_visible(XrOverlay *overlay, bool visible);
bool xr_overlay_is_visible(const XrOverlay *overlay);
// Local-space pose/size of the quad, valid regardless of visibility -
// shared with xr_input.c's ray/quad hit-testing.
// Base-space pose/size of the quad, valid regardless of visibility - shared
// with xr_input.c's ray/quad hit-testing. yaw_deg is rotation about the
// vertical axis (see xr_overlay_set_pose()) - 0 unless something has
// called that.
void xr_overlay_get_quad_extent(const XrOverlay *overlay, float *center_x_m, float *center_y_m,
float *distance_m, float *half_width_m, float *half_height_m);
float *distance_m, float *half_width_m, float *half_height_m,
float *yaw_deg);
// Repositions the quad (world-space X/Y offset, same units/space as
// Repositions the quad (base-space X/Y offset, same units/space as
// xr_overlay_get_quad_extent()'s center_x_m/center_y_m) - e.g. driven by
// xr_input.c while the user drags a panel's title bar. Distance from the
// viewer isn't adjustable this way - only left/right/up/down
// repositioning, not push/pull.
// viewer and rotation aren't adjustable this way - only left/right/up/down
// repositioning, not push/pull or turning - see xr_overlay_set_pose() for
// that.
void xr_overlay_set_position(XrOverlay *overlay, float center_x_m, float center_y_m);
// Full reposition: X/Y offset, distance ("push/pull"), and rotation about
// the vertical axis (degrees, same sign convention as
// xr_session_set_player_transform()'s yaw_deg) - used by xr_input.c to
// re-center the menu launcher in front of wherever the player currently
// is/facing each time it's freshly reopened, unlike
// xr_overlay_set_position()'s drag-only X/Y-at-fixed-distance-and-facing.
void xr_overlay_set_pose(XrOverlay *overlay, float center_x_m, float center_y_m, float distance_m,
float yaw_deg);
// The overlay's original, never-mutated distance_m (from XrOverlayConfig at
// creation time) - unlike xr_overlay_get_quad_extent()'s live distance_m,
// which xr_overlay_set_pose() repurposes as a raw base-space Z coordinate
// once any rotation is involved (see xr_overlay_set_pose()'s own comment),
// this stays a stable "how far in front" magnitude across repeated
// recenters. 0 for a NULL overlay.
float xr_overlay_get_default_distance_m(const XrOverlay *overlay);
// Forwards a hit on the quad (in the quad's own 0..1 u/v, top-left origin)
// to the backing Java view as a synthetic touch down/up.
void xr_overlay_touch(XrOverlay *overlay, float u, float v, bool down);
+105 -5
View File
@@ -1,6 +1,7 @@
#include "xr_session.h"
#include <ctype.h>
#include <math.h>
#include <stdlib.h>
#include <string.h>
@@ -65,6 +66,26 @@ static XrInstance g_instance = XR_NULL_HANDLE;
static XrSystemId g_system_id = XR_NULL_SYSTEM_ID;
static XrSession g_session = XR_NULL_HANDLE;
static XrSpace g_local_space = XR_NULL_HANDLE;
// Everything this app submits/locates (game/menu/keyboard/beam quads, hand
// and head poses) is expressed relative to g_play_space, not g_local_space
// directly - it's g_local_space translated/rotated by the inverse of the
// player's own virtual transform (see xr_session_set_player_transform()'s
// own comment in xr_session.h and xr_session_rebuild_play_space() below),
// so moving/turning the player moves/turns everything else relative to
// them without touching any actual pose-building or hit-testing math
// elsewhere in the app - it's all still "relative to the base space",
// which just happens to no longer be g_local_space itself.
static XrSpace g_play_space = XR_NULL_HANDLE;
static float g_player_x = 0.0f;
static float g_player_z = 0.0f;
static float g_player_yaw_deg = 0.0f;
// Set by xr_session_set_player_transform() (including before xr_init() has
// even run - see its own comment), cleared once xr_session_rebuild_play_space()
// actually rebuilds g_play_space from the current x/z/yaw_deg - checked
// once per frame in xr_frame_end() so a stick held at rest costs nothing.
static bool g_player_transform_dirty = false;
static XrSessionState g_session_state = XR_SESSION_STATE_UNKNOWN;
// Distinct from g_session_state - true from a successful xrBeginSession
// until xrEndSession/loss/exit. Many runtimes only advance the *state*
@@ -256,6 +277,65 @@ static EGLConfig xr_get_current_egl_config(EGLDisplay display, EGLContext contex
return config;
}
#define QS_DEG_TO_RAD (3.14159265358979323846f / 180.0f)
// Destroys/recreates g_play_space from g_local_space, offset by the
// inverse of the player's current virtual transform (g_player_x/z/yaw_deg) -
// see xr_session_set_player_transform()'s comment in xr_session.h for why
// this is the inverse rather than the transform itself. Given
// V = Translate(x, 0, z) * Rotate_Y(yaw_deg) (the player's own transform,
// "apparent pose in play space = V composed with real pose in local
// space"), the new space's poseInReferenceSpace must be V's inverse:
// orientation = Rotate_Y(-yaw_deg), position = Rotate_Y(-yaw_deg) * (-x, 0, -z).
// Called once, right after g_local_space itself is created (picking up
// whatever qs_config_load_startup() already seeded before xr_init() ever
// ran), and once per frame from xr_frame_end() whenever
// g_player_transform_dirty says the transform actually changed since the
// last rebuild - a plain stick-at-rest frame costs nothing beyond that one
// bool check. g_session must already exist.
static void xr_session_rebuild_play_space(void) {
if (g_play_space != XR_NULL_HANDLE) {
xrDestroySpace(g_play_space);
g_play_space = XR_NULL_HANDLE;
}
float theta = g_player_yaw_deg * QS_DEG_TO_RAD;
float half = -theta * 0.5f;
float c = cosf(-theta), s = sinf(-theta);
float vx = -g_player_x, vz = -g_player_z;
XrReferenceSpaceCreateInfo spaceInfo = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
spaceInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
spaceInfo.poseInReferenceSpace.orientation.x = 0.0f;
spaceInfo.poseInReferenceSpace.orientation.y = sinf(half);
spaceInfo.poseInReferenceSpace.orientation.z = 0.0f;
spaceInfo.poseInReferenceSpace.orientation.w = cosf(half);
spaceInfo.poseInReferenceSpace.position.x = vx * c + vz * s;
spaceInfo.poseInReferenceSpace.position.y = 0.0f;
spaceInfo.poseInReferenceSpace.position.z = -vx * s + vz * c;
xr_check(xrCreateReferenceSpace(g_session, &spaceInfo, &g_play_space),
"xrCreateReferenceSpace(play)");
g_player_transform_dirty = false;
}
void xr_session_set_player_transform(float x, float z, float yaw_deg) {
g_player_x = x;
g_player_z = z;
g_player_yaw_deg = yaw_deg;
g_player_transform_dirty = true;
}
void xr_session_get_player_transform(float *x, float *z, float *yaw_deg) {
*x = g_player_x;
*z = g_player_z;
*yaw_deg = g_player_yaw_deg;
}
XrSpace xr_session_get_local_space(void) { return g_local_space; }
XrTime xr_session_get_predicted_display_time(void) { return g_predicted_display_time; }
static bool xr_create_instance_and_session(int game_width, int game_height) {
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
jobject activity = (jobject)SDL_AndroidGetActivity();
@@ -342,6 +422,14 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
"xrCreateReferenceSpace"))
return false;
// Picks up whatever qs_config_load_startup() already stored into
// g_player_x/z/yaw_deg via xr_session_set_player_transform() before
// this function ever ran (that call is pure state, no XR API calls, so
// it works fine even this early) - so a saved position is already in
// effect from the very first frame, not just after the player first
// moves a thumbstick.
xr_session_rebuild_play_space();
// Prefer a plain linear 8-bit format over GL_SRGB8_ALPHA8: the source
// SDL surface pixels are already sRGB-encoded (as ordinary 8-bit image
// data conventionally is) and get uploaded/sampled as plain linear
@@ -546,6 +634,14 @@ void xr_frame_end(void) {
if (g_session == XR_NULL_HANDLE)
return;
// Rebuilt before xr_input_sync_and_draw() below so this same frame's
// hit-testing and layer submission both see whatever the player's
// thumbstick input (or a config-driven "Reset Position"/Load
// Configuration) changed last frame - a no-op check on every frame
// nothing moved.
if (g_player_transform_dirty)
xr_session_rebuild_play_space();
// Syncs actions and hit-tests/dispatches each hand's ray against
// keyboard/menu/game quad (see xr_input.c) before the game swapchain
// image is released below - this always runs, even when there's
@@ -554,7 +650,7 @@ void xr_frame_end(void) {
// laser-beam quads are submitted as their own composition layers
// further down, not drawn into the game swapchain itself.
if (xr_is_session_running())
xr_input_sync_and_draw(g_local_space, g_predicted_display_time,
xr_input_sync_and_draw(g_play_space, g_predicted_display_time,
g_have_acquired_game_image);
if (g_have_acquired_game_image)
@@ -572,7 +668,7 @@ void xr_frame_end(void) {
// laser-beam quad while that hand's ray is aimed at the keyboard,
// menu, or the game quad itself (see xr_input_get_beam_layer()).
XrCompositionLayerQuad gameQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD};
gameQuad.space = g_local_space;
gameQuad.space = g_play_space;
gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
gameQuad.subImage.swapchain = g_game_swapchain.swapchain;
gameQuad.subImage.imageRect.extent.width = g_game_swapchain.width;
@@ -590,7 +686,7 @@ void xr_frame_end(void) {
XrCompositionLayerQuad menuQuad;
if (g_frame_should_render && xr_overlay_render_and_build_layer(g_menu_overlay, &menuQuad)) {
menuQuad.space = g_local_space;
menuQuad.space = g_play_space;
menuQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&menuQuad;
}
@@ -598,7 +694,7 @@ void xr_frame_end(void) {
XrCompositionLayerQuad keyboardQuad;
if (g_frame_should_render &&
xr_overlay_render_and_build_layer(g_keyboard_overlay, &keyboardQuad)) {
keyboardQuad.space = g_local_space;
keyboardQuad.space = g_play_space;
keyboardQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
// Lets the keyboard sit on screen translucently while playing
// rather than fully hiding whatever's behind it - KeyboardOverlay
@@ -618,7 +714,7 @@ void xr_frame_end(void) {
XrCompositionLayerQuad beamQuad[2];
for (int hand = 0; hand < 2; hand++) {
if (xr_input_get_beam_layer(hand, &beamQuad[hand])) {
beamQuad[hand].space = g_local_space;
beamQuad[hand].space = g_play_space;
beamQuad[hand].eyeVisibility = XR_EYE_VISIBILITY_BOTH;
beamQuad[hand].layerFlags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT;
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&beamQuad[hand];
@@ -643,6 +739,10 @@ void xr_shutdown(void) {
xrDestroySpace(g_local_space);
g_local_space = XR_NULL_HANDLE;
if (g_play_space != XR_NULL_HANDLE)
xrDestroySpace(g_play_space);
g_play_space = XR_NULL_HANDLE;
// Before the session/instance they were created from.
xr_input_shutdown();
xr_overlay_destroy(g_menu_overlay);
+47 -4
View File
@@ -62,14 +62,14 @@ bool xr_frame_begin(void);
// launcher and keyboard overlay quads via xr_overlay_render_and_build_layer()
// for whichever of them is currently visible, and submits whichever of the
// three quads actually rendered this frame as composition layers
// positioned in front of the local reference space's origin, then ends the
// XR frame.
// positioned in front of the play space's origin (see
// xr_session_set_player_transform() above), then ends the XR frame.
void xr_frame_end(void);
void xr_shutdown(void);
// Local-space geometry of the single game quad xr_frame_end() submits
// (distance in front of the local space origin, half-width/half-height,
// Play-space geometry of the single game quad xr_frame_end() submits
// (distance in front of the play space origin, half-width/half-height,
// all in meters) - shared with xr_input.c's ray/quad hit-testing so the
// laser pointer always matches whatever's actually visible.
void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m);
@@ -98,6 +98,49 @@ float xr_session_get_frame_ms(void);
float xr_session_get_cpu_utilization(void);
float xr_session_get_gpu_utilization(void);
// The player's virtual position/orientation - horizontal-plane only (x/z
// plus a yaw rotation about the vertical axis); height always comes from
// the real tracked HMD pose, never from this. Everything the app submits
// (game/menu/keyboard/beam quads) and every hand/head pose it locates is
// expressed relative to a second reference space, derived from the real
// LOCAL space by this transform's inverse (see xr_session.c's own
// xr_session_rebuild_play_space()) - so changing it moves/turns the player
// relative to those otherwise-fixed quads, letting them walk up to and
// square up with the game quad ("virtual screen") without needing any
// physical room-scale space to do it in.
//
// xr_session_set_player_transform() only stores the three floats (no XR
// calls) - safe to call before xr_init() has even run, which is how
// qs_config_load_startup() seeds the saved position before the OpenXR
// session/spaces exist yet (xr_init() picks it up when it creates its own
// spaces); also used live by xr_input.c's thumbstick locomotion and
// "Reset Position" recenter (xr_input_recenter_player_to_screen()) and
// qs_config.c's "Load Configuration", and read back by "Save Configuration"
// via the getter.
void xr_session_set_player_transform(float x, float z, float yaw_deg);
void xr_session_get_player_transform(float *x, float *z, float *yaw_deg);
// The real, physical tracking-origin reference space - used only by
// xr_input_recenter_player_to_screen() (xr_input.c), which needs the
// player's CURRENT real head pose (not one already bent through the
// existing play-space transform - see xr_session_set_player_transform()'s
// own comment above) to compute a brand new one. Every other consumer in
// this app (rendering, hit-testing) uses the play space instead - see
// xr_session_rebuild_play_space()'s own comment for why this one stays a
// separate, never-swapped-out space.
XrSpace xr_session_get_local_space(void);
// The most recent xrWaitFrame-predicted display time (xr_frame_begin()'s
// own g_predicted_display_time) - for the same reason as
// xr_session_get_local_space() above, xr_input_recenter_player_to_screen()
// needs a valid XrTime to locate a space with but isn't itself called from
// the normal per-frame xr_frame_end()/xr_input_sync_and_draw() call chain
// that already threads one through (it's triggered by a button press,
// dispatched via qs_config.c's cross-thread event queue instead). A frame
// or so of staleness doesn't matter for a one-shot recenter. 0 before the
// first frame has ever begun.
XrTime xr_session_get_predicted_display_time(void);
#ifdef __cplusplus
}
#endif
@@ -95,6 +95,7 @@ public class MenuOverlay extends OverlayPanel {
private Button keyboardButton;
private Button saveButton;
private Button loadButton;
private Button resetPositionButton;
private Button dismissButton;
private Button restartNowButton;
@@ -213,6 +214,8 @@ public class MenuOverlay extends OverlayPanel {
v -> nativeRequestSaveConfig());
loadButton = addRowButton(buttonRow, Strings.get("load_configuration", currentLanguage),
v -> nativeRequestLoadConfig());
resetPositionButton = addRowButton(buttonRow, Strings.get("reset_position", currentLanguage),
v -> nativeRequestResetPosition());
mainMenuContent.addView(buttonRow, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT));
@@ -514,6 +517,7 @@ public class MenuOverlay extends OverlayPanel {
keyboardButton.setText(Strings.get("keyboard", currentLanguage));
saveButton.setText(Strings.get("save_configuration", currentLanguage));
loadButton.setText(Strings.get("load_configuration", currentLanguage));
resetPositionButton.setText(Strings.get("reset_position", currentLanguage));
dismissButton.setText(Strings.get("dismiss", currentLanguage));
restartNowButton.setText(Strings.get("restart_now", currentLanguage));
mainMenuTabButton.setText(Strings.get("main_menu", currentLanguage));
@@ -574,6 +578,12 @@ public class MenuOverlay extends OverlayPanel {
private static native void nativeRequestSetResolution(int resolutionIndex);
// Main Menu tab's Language row - same enqueue-only reasoning as above.
private static native void nativeRequestSetLanguage(int languageIndex);
// Main Menu tab's "Reset Position" button - same enqueue-only reasoning
// as above; recenters the player so the game quad appears directly
// ahead of wherever they're currently standing/facing (xr_input.c's
// xr_input_recenter_player_to_screen()) - a live-only action, unrelated
// to questshock.config.json.
private static native void nativeRequestResetPosition();
// Read-only queries - safe to call directly, see qs_config.h's own
// comment on each of these.
@@ -37,6 +37,8 @@ final class Strings {
"Konfiguration speichern");
put("load_configuration", "Load Configuration", "Charger la configuration",
"Konfiguration laden");
put("reset_position", "Reset Position", "Réinitialiser la position",
"Position zurücksetzen");
put("main_menu", "Main Menu", "Menu principal", "Hauptmenü");
put("video_options", "Video Options", "Options vidéo", "Videooptionen");
put("dismiss", "Dismiss", "Fermer", "Schließen");