#include "xr_session.h" #include #include #include #include #include #include #include #include #define XR_USE_PLATFORM_ANDROID 1 #define XR_USE_GRAPHICS_API_OPENGL_ES 1 #include #include #include #include "xr_input.h" #include "xr_overlay.h" #include "xr_swapchain.h" #define TAG "QuestShock" #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) // Original, pre-screen-profile-feature default distance for the game // screen - 2m in front of the local space origin - now just the initial // value of g_screen_distance_m (below) rather than a fixed constant. // Width/curve/distance are all configurable (see g_screen_width_m/ // g_screen_curve_deg/g_screen_distance_m above and CONFIG.md's Video // Options "Initial Screen"/"Game Screen" settings), sized to preserve the // game's own pixel aspect ratio at whatever physical width is currently // configured. No per-eye view/projection work is needed for a quad/ // cylinder layer at all - the compositor handles reprojecting it per eye // itself, which is exactly why this milestone doesn't need xrLocateViews // or any stereo rendering (see the plan's step A notes). #define QUAD_DISTANCE_METERS 2.0f // The menu launcher (just a "Keyboard" button - see MenuOverlay.java) is // small and sits above where the keyboard defaults to; the keyboard keeps // its previous size/position. They're independent XrOverlay instances (see // xr_overlay.h) so both can be shown/hidden/dragged separately - deliberately // positioned apart by default so they don't start out overlapping. #define MENU_JAVA_CLASS "de.ladkau.questshock.MenuOverlay" // 768x800 (was 512x256, then 768x512, then 768x552) - grown to fit the // header/tabs/Video Options content added on top of the original single // "Keyboard" button, then again to fit the stats ribbon below the tab // bar, then again to fit the Video Options tab's screen-size grid; taller // in world space too since MENU_WIDTH_METERS (the physical size) stays // fixed and height_m is derived from it by aspect ratio (see // xr_overlay.c's xr_overlay_init()). Must match MenuOverlay.java's own // WIDTH/HEIGHT, and xr_input.c's MENU_TITLE_BAR_V_FRACTION denominator. #define MENU_WIDTH 768 #define MENU_HEIGHT 800 #define MENU_DISTANCE_METERS 1.3f // Physical size - was 0.35m, too small to read comfortably at // MENU_DISTANCE_METERS given how much text the header/tabs/stats ribbon // pack in; same pixel resolution (MENU_WIDTH/HEIGHT above) just displayed // bigger, not a layout change. #define MENU_WIDTH_METERS 0.5f #define MENU_DEFAULT_CENTER_X_METERS 0.0f #define MENU_DEFAULT_CENTER_Y_METERS 0.15f #define KEYBOARD_JAVA_CLASS "de.ladkau.questshock.KeyboardOverlay" #define KEYBOARD_WIDTH 1024 #define KEYBOARD_HEIGHT 768 #define KEYBOARD_DISTANCE_METERS 1.5f #define KEYBOARD_WIDTH_METERS 0.8f #define KEYBOARD_DEFAULT_CENTER_X_METERS 0.0f #define KEYBOARD_DEFAULT_CENTER_Y_METERS -0.45f 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* // (e.g. READY -> SYNCHRONIZED -> VISIBLE -> FOCUSED) in response to the // app actually calling xrWaitFrame/xrBeginFrame/xrEndFrame continuously // after xrBeginSession - gating frame submission on already having // reached SYNCHRONIZED+ (as this used to) is a chicken-and-egg deadlock, // since the runtime never progresses past READY without the frame loop // running in the first place. Frame calls just need the session to have // begun, per the standard OpenXR sample pattern - not any particular // later state. static bool g_session_running = false; static XrSwapchainState g_game_swapchain; static XrOverlay *g_menu_overlay = NULL; static XrOverlay *g_keyboard_overlay = NULL; static XrTime g_predicted_display_time = 0; static bool g_frame_should_render = false; static bool g_have_acquired_game_image = false; // XR_META_performance_metrics support (see xr_session_init_performance_metrics() // below) - only present if the runtime advertised it at xrCreateInstance // time (checked via xr_instance_extension_supported()), since unlike the // two always-required KHR extensions above, this one is a device-capability // extra that a build shouldn't hard-depend on. static bool g_performance_metrics_supported = false; static PFN_xrSetPerformanceMetricsStateMETA g_xrSetPerformanceMetricsStateMETA = NULL; static PFN_xrQueryPerformanceMetricsCounterMETA g_xrQueryPerformanceMetricsCounterMETA = NULL; // Resolved once, at startup, by string-matching the runtime's own // human-readable counter path names (see xr_session_init_performance_metrics()) // rather than hardcoding the exact path strings the spec defines - a // substring match is robust to spec/runtime wording we can't verify without // on-device testing. XR_NULL_PATH (0) if no match was found. static XrPath g_cpu_utilization_path = XR_NULL_PATH; static XrPath g_gpu_utilization_path = XR_NULL_PATH; // XR_KHR_composition_layer_cylinder support - same "only if the runtime // advertised it" reasoning as g_performance_metrics_supported above. If // unsupported, the curve is forced to 0 (flat) everywhere regardless of // configuration (see xr_get_game_quad_extent()) so the game screen just // silently stays flat instead of failing to submit a layer at all. static bool g_cylinder_layer_supported = false; // The game screen's currently-configured shape (see qs_config.c's // qs_screen_profile_notify_loop_mode(), which is what actually calls // xr_session_set_game_screen_shape() below - this file has no notion of // "which profile" is active, only "what shape is current"). Defaults // match the game quad's own original, pre-this-feature hardcoded width // (1.6m) and lack of curve, so a build that never touches the new config // options renders identically to before. static float g_screen_width_m = 1.6f; static float g_screen_curve_deg = 0.0f; static float g_screen_distance_m = QUAD_DISTANCE_METERS; static bool xr_check(XrResult result, const char *what) { if (XR_SUCCEEDED(result)) return true; char resultString[XR_MAX_RESULT_STRING_SIZE] = {0}; if (g_instance != XR_NULL_HANDLE) xrResultToString(g_instance, result, resultString); LOGE("XR: %s failed: %s (%d)", what, resultString[0] ? resultString : "?", (int)result); return false; } static PFN_xrVoidFunction xr_get_proc(const char *name) { PFN_xrVoidFunction fn = NULL; // xrGetInstanceProcAddr itself works with a NULL instance for the // handful of functions (like xrInitializeLoaderKHR) that must be called // before an instance exists. if (!xr_check(xrGetInstanceProcAddr(g_instance, name, &fn), name)) return NULL; return fn; } // Queried before xrCreateInstance (which is why this takes no instance // handle - xrEnumerateInstanceExtensionProperties is one of the few // functions that works without one) to decide whether it's safe to add // `name` to enabledExtensionNames - passing an unsupported extension there // would fail instance creation outright, unlike a missing optional feature // discovered afterward. static bool xr_instance_extension_supported(const char *name) { uint32_t count = 0; if (!xr_check(xrEnumerateInstanceExtensionProperties(NULL, 0, &count, NULL), "xrEnumerateInstanceExtensionProperties")) return false; XrExtensionProperties *props = (XrExtensionProperties *)malloc(sizeof(XrExtensionProperties) * count); for (uint32_t i = 0; i < count; i++) props[i].type = XR_TYPE_EXTENSION_PROPERTIES; bool supported = false; if (xr_check(xrEnumerateInstanceExtensionProperties(NULL, count, &count, props), "xrEnumerateInstanceExtensionProperties")) { for (uint32_t i = 0; i < count; i++) { if (strcmp(props[i].extensionName, name) == 0) { supported = true; break; } } } free(props); return supported; } // Case-insensitively checks whether `needle` occurs anywhere in `haystack` - // used to identify a counter path (e.g. ".../device/cpu_utilization_average") // by a short, stable substring instead of matching its exact full spelling. static bool xr_path_name_contains(const char *haystack, const char *needle) { size_t hlen = strlen(haystack); char lower[256]; if (hlen >= sizeof(lower)) hlen = sizeof(lower) - 1; for (size_t i = 0; i < hlen; i++) lower[i] = (char)tolower((unsigned char)haystack[i]); lower[hlen] = '\0'; return strstr(lower, needle) != NULL; } // Enables XR_META_performance_metrics (if the runtime advertised it - see // xr_instance_extension_supported() above) and resolves the CPU/GPU // device-utilization counter paths xr_session_update_frame_stats() polls // every frame. Called once, right after xrCreateSession succeeds. Leaves // g_cpu_utilization_path/g_gpu_utilization_path at XR_NULL_PATH (their // getters then report -1.0f) if anything here doesn't pan out - this is a // nice-to-have stats display, not something worth failing session bring-up // over. static void xr_session_init_performance_metrics(void) { if (!g_performance_metrics_supported) return; PFN_xrEnumeratePerformanceMetricsCounterPathsMETA enumeratePaths = (PFN_xrEnumeratePerformanceMetricsCounterPathsMETA)xr_get_proc( "xrEnumeratePerformanceMetricsCounterPathsMETA"); g_xrSetPerformanceMetricsStateMETA = (PFN_xrSetPerformanceMetricsStateMETA)xr_get_proc( "xrSetPerformanceMetricsStateMETA"); g_xrQueryPerformanceMetricsCounterMETA = (PFN_xrQueryPerformanceMetricsCounterMETA)xr_get_proc( "xrQueryPerformanceMetricsCounterMETA"); if (enumeratePaths == NULL || g_xrSetPerformanceMetricsStateMETA == NULL || g_xrQueryPerformanceMetricsCounterMETA == NULL) return; XrPerformanceMetricsStateMETA state = {XR_TYPE_PERFORMANCE_METRICS_STATE_META}; state.enabled = XR_TRUE; if (!xr_check(g_xrSetPerformanceMetricsStateMETA(g_session, &state), "xrSetPerformanceMetricsStateMETA")) return; uint32_t pathCount = 0; if (!xr_check(enumeratePaths(g_instance, 0, &pathCount, NULL), "xrEnumeratePerformanceMetricsCounterPathsMETA")) return; XrPath *paths = (XrPath *)malloc(sizeof(XrPath) * pathCount); if (!xr_check(enumeratePaths(g_instance, pathCount, &pathCount, paths), "xrEnumeratePerformanceMetricsCounterPathsMETA")) { free(paths); return; } for (uint32_t i = 0; i < pathCount; i++) { char pathName[256]; uint32_t pathNameLen = 0; if (!xr_check(xrPathToString(g_instance, paths[i], sizeof(pathName), &pathNameLen, pathName), "xrPathToString")) continue; if (g_cpu_utilization_path == XR_NULL_PATH && xr_path_name_contains(pathName, "cpu_utilization")) g_cpu_utilization_path = paths[i]; if (g_gpu_utilization_path == XR_NULL_PATH && xr_path_name_contains(pathName, "gpu_utilization")) g_gpu_utilization_path = paths[i]; } free(paths); LOGI("XR: performance metrics cpu_utilization=%s gpu_utilization=%s", g_cpu_utilization_path != XR_NULL_PATH ? "found" : "not found", g_gpu_utilization_path != XR_NULL_PATH ? "found" : "not found"); } // The Android OpenXR loader needs the JavaVM/context before anything else - // separate from XR_KHR_android_create_instance below, which only affects // xrCreateInstance itself. Skipping this is a common cause of // xrCreateInstance silently failing to find a runtime on Android. static bool xr_initialize_android_loader(JavaVM *vm, jobject activity) { PFN_xrInitializeLoaderKHR initializeLoader = (PFN_xrInitializeLoaderKHR)xr_get_proc("xrInitializeLoaderKHR"); if (initializeLoader == NULL) { LOGE("XR: xrInitializeLoaderKHR not available - no Android OpenXR loader present?"); return false; } XrLoaderInitInfoAndroidKHR loaderInitInfo = {XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR}; loaderInitInfo.applicationVM = vm; loaderInitInfo.applicationContext = activity; return xr_check(initializeLoader((const XrLoaderInitInfoBaseHeaderKHR *)&loaderInitInfo), "xrInitializeLoaderKHR"); } // eglGetCurrentDisplay()/eglGetCurrentContext() hand us the display/context, // but not the EGLConfig they were created with - eglQueryContext's // EGL_CONFIG_ID plus eglChooseConfig is the standard way to recover it (the // same trick Khronos' own hello_xr sample uses). static EGLConfig xr_get_current_egl_config(EGLDisplay display, EGLContext context) { EGLint configId = 0; eglQueryContext(display, context, EGL_CONFIG_ID, &configId); EGLint attribs[] = {EGL_CONFIG_ID, configId, EGL_NONE}; EGLConfig config = NULL; EGLint numConfigs = 0; eglChooseConfig(display, attribs, &config, 1, &numConfigs); 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(); if (env == NULL || activity == NULL) { LOGE("XR: no JNIEnv/Activity from SDL - can't init OpenXR"); return false; } JavaVM *vm = NULL; (*env)->GetJavaVM(env, &vm); if (!xr_initialize_android_loader(vm, activity)) return false; g_performance_metrics_supported = xr_instance_extension_supported(XR_META_PERFORMANCE_METRICS_EXTENSION_NAME); g_cylinder_layer_supported = xr_instance_extension_supported(XR_KHR_COMPOSITION_LAYER_CYLINDER_EXTENSION_NAME); // The two KHR extensions here are always required; the other two are // each independently optional device capabilities (see // g_performance_metrics_supported/g_cylinder_layer_supported's own // comments) - appended only if actually advertised, since listing an // unsupported extension fails xrCreateInstance entirely, not just that // one feature. const char *extensions[4] = { XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME, XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME, }; uint32_t extensionCount = 2; if (g_performance_metrics_supported) extensions[extensionCount++] = XR_META_PERFORMANCE_METRICS_EXTENSION_NAME; if (g_cylinder_layer_supported) extensions[extensionCount++] = XR_KHR_COMPOSITION_LAYER_CYLINDER_EXTENSION_NAME; XrInstanceCreateInfoAndroidKHR androidInfo = {XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR}; androidInfo.applicationVM = vm; androidInfo.applicationActivity = activity; XrInstanceCreateInfo createInfo = {XR_TYPE_INSTANCE_CREATE_INFO}; createInfo.next = &androidInfo; createInfo.enabledExtensionCount = extensionCount; createInfo.enabledExtensionNames = extensions; strncpy(createInfo.applicationInfo.applicationName, "QuestShock", XR_MAX_APPLICATION_NAME_SIZE - 1); strncpy(createInfo.applicationInfo.engineName, "Shockolate", XR_MAX_ENGINE_NAME_SIZE - 1); createInfo.applicationInfo.applicationVersion = 1; createInfo.applicationInfo.engineVersion = 1; createInfo.applicationInfo.apiVersion = XR_CURRENT_API_VERSION; if (!xr_check(xrCreateInstance(&createInfo, &g_instance), "xrCreateInstance")) return false; XrSystemGetInfo systemInfo = {XR_TYPE_SYSTEM_GET_INFO}; systemInfo.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY; if (!xr_check(xrGetSystem(g_instance, &systemInfo, &g_system_id), "xrGetSystem")) return false; // Required by spec before creating an OpenGL ES-backed session, even // though we don't gate on the returned min/max API version here. PFN_xrGetOpenGLESGraphicsRequirementsKHR getGLESRequirements = (PFN_xrGetOpenGLESGraphicsRequirementsKHR)xr_get_proc( "xrGetOpenGLESGraphicsRequirementsKHR"); if (getGLESRequirements == NULL) return false; XrGraphicsRequirementsOpenGLESKHR glesRequirements = { XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR}; if (!xr_check(getGLESRequirements(g_instance, g_system_id, &glesRequirements), "xrGetOpenGLESGraphicsRequirementsKHR")) return false; EGLDisplay display = eglGetCurrentDisplay(); EGLContext context = eglGetCurrentContext(); if (display == EGL_NO_DISPLAY || context == EGL_NO_CONTEXT) { LOGE("XR: no current EGL display/context - call xr_init() after init_opengl()"); return false; } XrGraphicsBindingOpenGLESAndroidKHR graphicsBinding = { XR_TYPE_GRAPHICS_BINDING_OPENGL_ES_ANDROID_KHR}; graphicsBinding.display = display; graphicsBinding.config = xr_get_current_egl_config(display, context); graphicsBinding.context = context; XrSessionCreateInfo sessionInfo = {XR_TYPE_SESSION_CREATE_INFO}; sessionInfo.next = &graphicsBinding; sessionInfo.systemId = g_system_id; if (!xr_check(xrCreateSession(g_instance, &sessionInfo, &g_session), "xrCreateSession")) return false; xr_session_init_performance_metrics(); XrReferenceSpaceCreateInfo spaceInfo = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO}; spaceInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL; spaceInfo.poseInReferenceSpace.orientation.w = 1.0f; if (!xr_check(xrCreateReferenceSpace(g_session, &spaceInfo, &g_local_space), "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 // GL_RGBA with no decode step anywhere in this path, matching the // desktop SDL_RenderCopy path this replaces - an sRGB swapchain format // would auto-gamma-encode on write and double-encode already-encoded // data. Every swapchain below (game quad, menu/keyboard overlays, and // - via xr_input_init() - the per-hand laser-beam overlays) shares // this one choice - the compositor's supported format set doesn't // depend on swapchain size. uint32_t formatCount = 0; xrEnumerateSwapchainFormats(g_session, 0, &formatCount, NULL); int64_t *formats = (int64_t *)malloc(sizeof(int64_t) * formatCount); xrEnumerateSwapchainFormats(g_session, formatCount, &formatCount, formats); int64_t chosenFormat = formats[0]; for (uint32_t i = 0; i < formatCount; i++) { LOGI("XR: swapchain format[%u] = 0x%llx", i, (unsigned long long)formats[i]); if (formats[i] == GL_RGBA8) { chosenFormat = GL_RGBA8; break; } } free(formats); // Non-fatal if it fails (e.g. no controllers bound yet) - rendering // keeps working either way, just without the laser pointer/beams. xr_input_init(g_instance, g_session, chosenFormat); if (!xr_swapchain_create(g_instance, g_session, chosenFormat, game_width, game_height, &g_game_swapchain)) { LOGE("XR: game swapchain setup failed"); return false; } // Both non-fatal - the game keeps rendering with no such panel if // either fails (e.g. the Java class couldn't be resolved via JNI). XrOverlayConfig menuConfig = { .java_class_name = MENU_JAVA_CLASS, .width = MENU_WIDTH, .height = MENU_HEIGHT, .distance_m = MENU_DISTANCE_METERS, .width_m = MENU_WIDTH_METERS, .default_center_x_m = MENU_DEFAULT_CENTER_X_METERS, .default_center_y_m = MENU_DEFAULT_CENTER_Y_METERS, }; g_menu_overlay = xr_overlay_create(g_instance, g_session, chosenFormat, &menuConfig); XrOverlayConfig keyboardConfig = { .java_class_name = KEYBOARD_JAVA_CLASS, .width = KEYBOARD_WIDTH, .height = KEYBOARD_HEIGHT, .distance_m = KEYBOARD_DISTANCE_METERS, .width_m = KEYBOARD_WIDTH_METERS, .default_center_x_m = KEYBOARD_DEFAULT_CENTER_X_METERS, .default_center_y_m = KEYBOARD_DEFAULT_CENTER_Y_METERS, }; g_keyboard_overlay = xr_overlay_create(g_instance, g_session, chosenFormat, &keyboardConfig); LOGI("XR: instance/session ready (game %dx%d, menu %dx%d, keyboard %dx%d)", game_width, game_height, MENU_WIDTH, MENU_HEIGHT, KEYBOARD_WIDTH, KEYBOARD_HEIGHT); return true; } bool xr_init(int game_width, int game_height) { if (!xr_create_instance_and_session(game_width, game_height)) { LOGE("XR: bring-up failed - falling back to the normal window present path"); xr_shutdown(); return false; } return true; } XrOverlay *xr_session_get_menu_overlay(void) { return g_menu_overlay; } XrOverlay *xr_session_get_keyboard_overlay(void) { return g_keyboard_overlay; } void xr_poll_events(void) { if (g_instance == XR_NULL_HANDLE) return; while (true) { XrEventDataBuffer event = {XR_TYPE_EVENT_DATA_BUFFER}; XrResult result = xrPollEvent(g_instance, &event); if (result == XR_EVENT_UNAVAILABLE) break; if (!xr_check(result, "xrPollEvent")) break; if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) { XrEventDataSessionStateChanged *stateEvent = (XrEventDataSessionStateChanged *)&event; g_session_state = stateEvent->state; LOGI("XR: session state -> %d", (int)g_session_state); if (g_session_state == XR_SESSION_STATE_READY) { XrSessionBeginInfo beginInfo = {XR_TYPE_SESSION_BEGIN_INFO}; beginInfo.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; g_session_running = xr_check(xrBeginSession(g_session, &beginInfo), "xrBeginSession"); } else if (g_session_state == XR_SESSION_STATE_STOPPING) { xr_check(xrEndSession(g_session), "xrEndSession"); g_session_running = false; } else if (g_session_state == XR_SESSION_STATE_EXITING || g_session_state == XR_SESSION_STATE_LOSS_PENDING) { g_session_running = false; } } } // xrPollEvent() leaves gl4es reporting a stray GL_INVALID_OPERATION on // every call while the session is running - drain it (looped, since // more than one can accumulate across xrPollEvent()'s possibly-multiple // iterations above) so it doesn't surface as a false positive at the // next unrelated glGetError() check in this app's own rendering code. while (glGetError() != GL_NO_ERROR) { } } bool xr_is_session_running(void) { return g_session_running; } bool xr_frame_begin(void) { g_have_acquired_game_image = false; if (!xr_is_session_running()) return false; XrFrameWaitInfo waitInfo = {XR_TYPE_FRAME_WAIT_INFO}; XrFrameState frameState = {XR_TYPE_FRAME_STATE}; if (!xr_check(xrWaitFrame(g_session, &waitInfo, &frameState), "xrWaitFrame")) return false; g_predicted_display_time = frameState.predictedDisplayTime; g_frame_should_render = frameState.shouldRender; XrFrameBeginInfo beginInfo = {XR_TYPE_FRAME_BEGIN_INFO}; if (!xr_check(xrBeginFrame(g_session, &beginInfo), "xrBeginFrame")) return false; if (!g_frame_should_render) return false; g_have_acquired_game_image = xr_swapchain_acquire(g_instance, &g_game_swapchain); return g_have_acquired_game_image; } // Backs xr_session_get_fps()/get_frame_ms() (see xr_session.h) - exponential // smoothing (rather than a plain per-frame instantaneous value) so // MenuOverlay's header display doesn't visibly jitter every single frame. // Called once per xr_frame_end() that reaches its own end (i.e. once per // real XR frame, whether or not the game itself actually rendered anything // new into it this frame - matches the OpenXR compositor's own frame // cadence, which is what "FPS" means to someone looking at this display). static float s_fps = 0.0f; static float s_frame_ms = 0.0f; static float s_cpu_utilization = -1.0f; static float s_gpu_utilization = -1.0f; // Reads one XR_META_performance_metrics counter (if it was found at // startup - see xr_session_init_performance_metrics()) and folds it into // *smoothed with the same exponential smoothing as FPS/frame time above. // Leaves *smoothed at -1.0f (its "unsupported" sentinel) untouched if the // path wasn't found, the query fails, or the runtime didn't mark its // floatValue valid this call. static void xr_session_update_utilization(XrPath path, float *smoothed) { if (path == XR_NULL_PATH || g_xrQueryPerformanceMetricsCounterMETA == NULL) return; XrPerformanceMetricsCounterMETA counter = {XR_TYPE_PERFORMANCE_METRICS_COUNTER_META}; if (!xr_check(g_xrQueryPerformanceMetricsCounterMETA(g_session, path, &counter), "xrQueryPerformanceMetricsCounterMETA")) return; if (!(counter.counterFlags & XR_PERFORMANCE_METRICS_COUNTER_FLOAT_VALUE_VALID_BIT_META)) return; const float kSmoothing = 0.1f; *smoothed = (*smoothed < 0.0f) ? counter.floatValue : *smoothed * (1.0f - kSmoothing) + counter.floatValue * kSmoothing; } static void xr_session_update_frame_stats(void) { static Uint32 s_last_ticks = 0; Uint32 now = SDL_GetTicks(); if (s_last_ticks != 0) { Uint32 delta_ms = now - s_last_ticks; if (delta_ms > 0) { float instant_fps = 1000.0f / (float)delta_ms; const float kSmoothing = 0.1f; s_fps = (s_fps == 0.0f) ? instant_fps : s_fps * (1.0f - kSmoothing) + instant_fps * kSmoothing; s_frame_ms = (s_frame_ms == 0.0f) ? (float)delta_ms : s_frame_ms * (1.0f - kSmoothing) + (float)delta_ms * kSmoothing; } } s_last_ticks = now; xr_session_update_utilization(g_cpu_utilization_path, &s_cpu_utilization); xr_session_update_utilization(g_gpu_utilization_path, &s_gpu_utilization); } float xr_session_get_fps(void) { return s_fps; } float xr_session_get_frame_ms(void) { return s_frame_ms; } float xr_session_get_cpu_utilization(void) { return s_cpu_utilization; } float xr_session_get_gpu_utilization(void) { return s_gpu_utilization; } 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 // nothing to draw (no acquired image this frame), so edge detection // (trigger/menu-button clicks) doesn't miss a frame. Any resulting // 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_play_space, g_predicted_display_time, g_have_acquired_game_image); if (g_have_acquired_game_image) xr_swapchain_release(g_instance, &g_game_swapchain); if (!xr_is_session_running()) return; // Up to 5 layers: the game screen (if a frame was actually rendered) - // a flat quad, or a curved cylinder if the currently-active screen // profile has a nonzero curve (see xr_get_game_quad_extent()'s own // comment; xr_input.c's xr_input_try_game_quad() hit-tests whichever // shape is actually submitted here, always agreeing since both read // the same getter) - the menu launcher and keyboard overlays while // visible - each gets its own acquire/render/release cycle against // its own swapchain (see xr_overlay_render_and_build_layer()) any // time before xrEndFrame, unlike the game screen there's no per-frame // engine rendering to wrap around here, just each overlay's own blit - // and, per hand, a laser-beam quad while that hand's ray is aimed at // the keyboard, menu, or the game screen itself (see // xr_input_get_beam_layer()). float gameDistance, gameHalfWidth, gameHalfHeight, gameRadius, gameCentralAngle; xr_get_game_quad_extent(&gameDistance, &gameHalfWidth, &gameHalfHeight, &gameRadius, &gameCentralAngle); bool gameScreenCurved = gameCentralAngle > 0.0f; XrCompositionLayerQuad gameQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD}; XrCompositionLayerCylinderKHR gameCylinder = {XR_TYPE_COMPOSITION_LAYER_CYLINDER_KHR}; if (gameScreenCurved) { gameCylinder.space = g_play_space; gameCylinder.eyeVisibility = XR_EYE_VISIBILITY_BOTH; gameCylinder.subImage.swapchain = g_game_swapchain.swapchain; gameCylinder.subImage.imageRect.extent.width = g_game_swapchain.width; gameCylinder.subImage.imageRect.extent.height = g_game_swapchain.height; gameCylinder.pose.orientation.w = 1.0f; gameCylinder.pose.position.z = -gameDistance; gameCylinder.radius = gameRadius; gameCylinder.centralAngle = gameCentralAngle; gameCylinder.aspectRatio = (float)g_game_swapchain.width / (float)g_game_swapchain.height; } else { 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; gameQuad.subImage.imageRect.extent.height = g_game_swapchain.height; gameQuad.pose.orientation.w = 1.0f; gameQuad.pose.position.z = -gameDistance; gameQuad.size.width = gameHalfWidth * 2.0f; gameQuad.size.height = gameHalfHeight * 2.0f; } const XrCompositionLayerBaseHeader *layers[5]; uint32_t layerCount = 0; if (g_have_acquired_game_image) layers[layerCount++] = gameScreenCurved ? (XrCompositionLayerBaseHeader *)&gameCylinder : (XrCompositionLayerBaseHeader *)&gameQuad; XrCompositionLayerQuad menuQuad; if (g_frame_should_render && xr_overlay_render_and_build_layer(g_menu_overlay, &menuQuad)) { menuQuad.space = g_play_space; menuQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; layers[layerCount++] = (XrCompositionLayerBaseHeader *)&menuQuad; } XrCompositionLayerQuad keyboardQuad; if (g_frame_should_render && xr_overlay_render_and_build_layer(g_keyboard_overlay, &keyboardQuad)) { 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 // writes real sub-255 alpha into the swapchain texture (see // OverlayPanel.compositeAndPublish()), already in Android's // default premultiplied format, which is what this flag's absence // of UNPREMULTIPLIED_ALPHA_BIT assumes. The menu launcher above // deliberately doesn't get this flag - it stays fully opaque. keyboardQuad.layerFlags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT; layers[layerCount++] = (XrCompositionLayerBaseHeader *)&keyboardQuad; } // Submitted last (frontmost) so a beam doesn't z-fight against the // panel surface it's aimed at/terminates on - see // xr_input_get_beam_layer(). Translucent for the same reason the // keyboard is (see comment above). XrCompositionLayerQuad beamQuad[2]; for (int hand = 0; hand < 2; hand++) { if (xr_input_get_beam_layer(hand, &beamQuad[hand])) { 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]; } } XrFrameEndInfo endInfo = {XR_TYPE_FRAME_END_INFO}; endInfo.displayTime = g_predicted_display_time; endInfo.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE; endInfo.layerCount = layerCount; endInfo.layers = layerCount > 0 ? layers : NULL; xr_check(xrEndFrame(g_session, &endInfo), "xrEndFrame"); xr_session_update_frame_stats(); } void xr_shutdown(void) { xr_swapchain_destroy(&g_game_swapchain); if (g_local_space != XR_NULL_HANDLE) 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); g_menu_overlay = NULL; xr_overlay_destroy(g_keyboard_overlay); g_keyboard_overlay = NULL; if (g_session != XR_NULL_HANDLE) xrDestroySession(g_session); g_session = XR_NULL_HANDLE; if (g_instance != XR_NULL_HANDLE) xrDestroyInstance(g_instance); g_instance = XR_NULL_HANDLE; g_session_state = XR_SESSION_STATE_UNKNOWN; g_session_running = false; g_performance_metrics_supported = false; g_xrSetPerformanceMetricsStateMETA = NULL; g_xrQueryPerformanceMetricsCounterMETA = NULL; g_cpu_utilization_path = XR_NULL_PATH; g_gpu_utilization_path = XR_NULL_PATH; g_cylinder_layer_supported = false; // g_screen_width_m/g_screen_curve_deg/g_screen_distance_m deliberately // NOT reset here - same reasoning as xr_session_set_player_transform()'s // own state: it's meant to persist across a shutdown/reinit within the // same process (e.g. an Android lifecycle pause/resume), not just // within one XR session. } void xr_session_set_game_screen_shape(float width_m, float curve_deg, float distance_m) { g_screen_width_m = width_m; g_screen_curve_deg = curve_deg; g_screen_distance_m = distance_m; } // Single source of truth for the game screen's current shape, read by // both xr_frame_end() (to build/submit the actual layer) and // xr_input_try_game_quad() (xr_input.c, to hit-test the laser pointer // against it). half_width_m is meaningful only when *central_angle_rad is // 0 (flat); a curved screen's horizontal extent is bounded by // *central_angle_rad instead, with *half_height_m always the vertical // bound either way. void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m, float *radius_m, float *central_angle_rad) { float curveDeg = g_cylinder_layer_supported ? g_screen_curve_deg : 0.0f; float aspect = (float)g_game_swapchain.height / (float)g_game_swapchain.width; *distance_m = g_screen_distance_m; *half_width_m = g_screen_width_m * 0.5f; *half_height_m = g_screen_width_m * 0.5f * aspect; if (curveDeg > 0.0f) { float theta = curveDeg * QS_DEG_TO_RAD; *central_angle_rad = theta; *radius_m = g_screen_width_m / theta; } else { *central_angle_rad = 0.0f; *radius_m = 0.0f; } } void xr_get_game_resolution(int *width, int *height) { // Not g_game_swapchain.width/height: that's the fixed physical size of // the swapchain image the game is composited into (see xr_init()) - // android_draw_surface_as_quad() (OpenGL.cc, engine-patches/11) always // stretch-blits drawSurface to fill that image regardless of // drawSurface's own pixel size, so the swapchain's size has no relation // to the coordinate space MouseX/MouseY actually live in. gScreenWide/ // gScreenHigh (InitMac.h) is drawSurface's current size - the // resolution the in-game options menu can change at runtime // (ShockBitmap.c's ChangeScreenSize()) independently of the swapchain, // which is created once at launch and never resized. Using the fixed // swapchain size here after such a change mapped the laser pointer's // quad-space hit into the wrong coordinate space entirely, moving // MouseX/MouseY out of the engine's actual screen bounds - which is // what made the cursor disappear and become unclickable. extern int32_t gScreenWide, gScreenHigh; *width = gScreenWide; *height = gScreenHigh; }