Files
questshock/android/app/src/main/cpp/xr_session.c
T
ml 09280dd410
build / build (push) Successful in 2m25s
Overhaul the VR menu UI: header, tabs, live video options, hover/click feedback, i18n, and dragging
- Header with title, git-tag-derived version, and a live FPS/frame-time/
  CPU/GPU-utilization/free-memory stats ribbon below the tab bar (FPS via
  a new per-frame tracker in xr_session.c; CPU/GPU via the OpenXR
  XR_META_performance_metrics extension, gracefully unavailable on
  runtimes that don't support it)
- Bottom tab bar with a live-editable Video Options tab (renderer/
  resolution), applying changes immediately via qs_config.c
- Hover and press feedback on every menu/keyboard button via a
  StateListDrawable (Android's default ripple never animates on this
  permanently unattached view tree), with a lighter default button color
  for visibility
- i18n for all menu/keyboard chrome text, following the game's own
  language setting (Strings.java)
- The main menu is now draggable via its header, matching the keyboard;
  xr_input.c's drag handling is generalized to per-overlay state instead
  of being keyboard-only
- The keyboard's title bar now matches the menu header's style
2026-09-05 07:02:30 +02:00

697 lines
31 KiB
C

#include "xr_session.h"
#include <ctype.h>
#include <stdlib.h>
#include <string.h>
#include <android/log.h>
#include <jni.h>
#include <EGL/egl.h>
#include <GLES3/gl3.h>
#define XR_USE_PLATFORM_ANDROID 1
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
#include <openxr/openxr.h>
#include <openxr/openxr_platform.h>
#include <SDL.h>
#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__)
// Fixed pose/size for the single game quad - 2m in front of the local space
// origin, sized to preserve the game's own aspect ratio at a comfortable
// visual angle. No per-eye view/projection work is needed for a quad 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
#define QUAD_WIDTH_METERS 1.6f
// 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"
// 768x552 (was 512x256, then 768x512) - 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; 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()).
#define MENU_WIDTH 768
#define MENU_HEIGHT 552
#define MENU_DISTANCE_METERS 1.3f
#define MENU_WIDTH_METERS 0.35f
#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;
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;
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;
}
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);
const char *extensions[3] = {
XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME,
XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME,
XR_META_PERFORMANCE_METRICS_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 = g_performance_metrics_supported ? 3 : 2;
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;
// 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;
// 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_local_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 quad (if a frame was actually rendered),
// 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 quad 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 quad itself (see xr_input_get_beam_layer()).
XrCompositionLayerQuad gameQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD};
gameQuad.space = g_local_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 = -QUAD_DISTANCE_METERS;
gameQuad.size.width = QUAD_WIDTH_METERS;
gameQuad.size.height =
QUAD_WIDTH_METERS * (float)g_game_swapchain.height / (float)g_game_swapchain.width;
const XrCompositionLayerBaseHeader *layers[5];
uint32_t layerCount = 0;
if (g_have_acquired_game_image)
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&gameQuad;
XrCompositionLayerQuad menuQuad;
if (g_frame_should_render && xr_overlay_render_and_build_layer(g_menu_overlay, &menuQuad)) {
menuQuad.space = g_local_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_local_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_local_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;
// 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;
}
void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m) {
*distance_m = QUAD_DISTANCE_METERS;
*half_width_m = QUAD_WIDTH_METERS * 0.5f;
*half_height_m =
QUAD_WIDTH_METERS * 0.5f * (float)g_game_swapchain.height / (float)g_game_swapchain.width;
}
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;
}