diff --git a/CONFIG.md b/CONFIG.md index b6a8105..1561ee8 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -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) diff --git a/android/app/src/main/cpp/qs_config.c b/android/app/src/main/cpp/qs_config.c index e8791f9..512306f 100644 --- a/android/app/src/main/cpp/qs_config.c +++ b/android/app/src/main/cpp/qs_config.c @@ -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"); + 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; } diff --git a/android/app/src/main/cpp/qs_config.h b/android/app/src/main/cpp/qs_config.h index 087cf53..341c212 100644 --- a/android/app/src/main/cpp/qs_config.h +++ b/android/app/src/main/cpp/qs_config.h @@ -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 diff --git a/android/app/src/main/cpp/questshock_native.c b/android/app/src/main/cpp/questshock_native.c index f1ca9b3..be9851a 100644 --- a/android/app/src/main/cpp/questshock_native.c +++ b/android/app/src/main/cpp/questshock_native.c @@ -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 diff --git a/android/app/src/main/cpp/xr_input.c b/android/app/src/main/cpp/xr_input.c index 6910527..8385528 100644 --- a/android/app/src/main/cpp/xr_input.c +++ b/android/app/src/main/cpp/xr_input.c @@ -20,6 +20,12 @@ #define LEFT 0 #define RIGHT 1 +// Same constant (deliberately not '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, ¤tX, ¤tY, &liveDistance, &halfWidth, + &halfHeight, ¤tYaw); + (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; } diff --git a/android/app/src/main/cpp/xr_input.h b/android/app/src/main/cpp/xr_input.h index 3044f35..6c56ee4 100644 --- a/android/app/src/main/cpp/xr_input.h +++ b/android/app/src/main/cpp/xr_input.h @@ -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 diff --git a/android/app/src/main/cpp/xr_overlay.c b/android/app/src/main/cpp/xr_overlay.c index a788054..6232e63 100644 --- a/android/app/src/main/cpp/xr_overlay.c +++ b/android/app/src/main/cpp/xr_overlay.c @@ -1,6 +1,7 @@ #include "xr_overlay.h" #include +#include #include #include @@ -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 '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; diff --git a/android/app/src/main/cpp/xr_overlay.h b/android/app/src/main/cpp/xr_overlay.h index 2cad415..84fc546 100644 --- a/android/app/src/main/cpp/xr_overlay.h +++ b/android/app/src/main/cpp/xr_overlay.h @@ -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); diff --git a/android/app/src/main/cpp/xr_session.c b/android/app/src/main/cpp/xr_session.c index a8d6a1c..1f37c86 100644 --- a/android/app/src/main/cpp/xr_session.c +++ b/android/app/src/main/cpp/xr_session.c @@ -1,6 +1,7 @@ #include "xr_session.h" #include +#include #include #include @@ -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); diff --git a/android/app/src/main/cpp/xr_session.h b/android/app/src/main/cpp/xr_session.h index d71fd89..7f16be5 100644 --- a/android/app/src/main/cpp/xr_session.h +++ b/android/app/src/main/cpp/xr_session.h @@ -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 diff --git a/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java b/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java index d0da2bd..e38d7af 100644 --- a/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java +++ b/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java @@ -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. diff --git a/android/app/src/main/java/de/ladkau/questshock/Strings.java b/android/app/src/main/java/de/ladkau/questshock/Strings.java index ce104d1..2531150 100644 --- a/android/app/src/main/java/de/ladkau/questshock/Strings.java +++ b/android/app/src/main/java/de/ladkau/questshock/Strings.java @@ -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");