Files
questshock/android/app/src/main/cpp/xr_menu.c
T
ml 1be0bebda2
build / build (push) Successful in 1m43s
Split the menu quad back onto its own independent OpenXR swapchain
Replaces the single shared, side-by-side swapchain (game + menu content
packed into one image, needing viewport/scissor juggling to keep the
menu's own glClear from bleeding into the game's region) with two fully
independent XrSwapchainState instances, each sized to exactly its own
content and acquired/released on its own within the same
xrBeginFrame/xrEndFrame pair - an ordinary multi-layer OpenXR setup.

The shared-swapchain design was originally adopted to work around what
looked like a Horizon OS compositor limitation with independent
swapchains, but that was diagnosed before the real root cause of the
"menu shows the game's content" bug was found (gl4es's fpe.c
unconditionally substituting its own shader onto the menu's draw call -
see README's Debugging notes). Since the actual fix routes the menu's
blit through a real, non-gl4es glBlitFramebuffer() call - orthogonal to
how many swapchains exist - splitting back onto two swapchains works
fine and removes the GL_SCISSOR_TEST workaround and sub-rectangle
offset math entirely. Confirmed on-device: both quads render correctly,
hit-testing and 90 FPS unaffected.

Also adds a visible cursor to the menu quad itself: xr_input.c now
tracks whichever hand's aim ray hits the menu each frame and forwards it
to a new MenuOverlay.nativeUpdateCursor(), which draws a small dot at
that position (composited through the same Bitmap the menu's own
content already goes through) - previously there was no visual feedback
at all showing where you were pointing before pulling the trigger.
2026-08-02 08:01:14 +02:00

375 lines
16 KiB
C

#include "xr_menu.h"
#include <dlfcn.h>
#include <stdlib.h>
#include <android/log.h>
#include <jni.h>
#include <EGL/egl.h>
#include <GLES3/gl3.h>
#include <SDL.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__)
#define MENU_WIDTH 1024
#define MENU_HEIGHT 768
// Below the game quad (QUAD_DISTANCE_METERS/QUAD_WIDTH_METERS in
// xr_session.c, 2m out) so both are visible together without overlapping.
#define MENU_DISTANCE_METERS 1.5f
#define MENU_WIDTH_METERS 0.8f
#define MENU_CENTER_X_METERS 0.0f
#define MENU_CENTER_Y_METERS -0.45f
static bool g_visible = false;
static jclass g_menu_overlay_class = NULL;
static jmethodID g_native_init_method = NULL;
static jmethodID g_take_pixels_method = NULL;
static jmethodID g_dispatch_touch_method = NULL;
static jmethodID g_update_cursor_method = NULL;
// The menu overlay's content lives in this texture, uploaded by
// xr_menu_upload_source_texture_if_dirty() below. It's created through
// gl4es (glGenTextures/glBindTexture/glTexImage2D), like any other texture
// the engine itself creates, so ordinary glTexSubImage2D uploads against it
// work the normal way.
static GLuint g_source_texture = 0;
// A framebuffer with g_source_texture as its only color attachment, used as
// the read source for xr_menu_render_if_visible()'s glBlitFramebuffer()
// call. Created via the real (non-gl4es) GLES entry points below: that blit
// bypasses gl4es entirely (see xr_menu_render_if_visible() for why), so its
// source framebuffer has to be a real GL object rather than one gl4es
// tracks.
static GLuint g_source_fbo = 0;
typedef void (*PFNQSGETINTEGERV)(GLenum, GLint *);
typedef void (*PFNQSGENFRAMEBUFFERS)(GLsizei, GLuint *);
typedef void (*PFNQSDELETEFRAMEBUFFERS)(GLsizei, const GLuint *);
typedef void (*PFNQSBINDFRAMEBUFFER)(GLenum, GLuint);
typedef void (*PFNQSFRAMEBUFFERTEXTURE2D)(GLenum, GLenum, GLenum, GLuint, GLint);
typedef void (*PFNQSBLITFRAMEBUFFER)(GLint, GLint, GLint, GLint, GLint, GLint, GLint, GLint,
GLbitfield, GLenum);
// The menu's source framebuffer and its glBlitFramebuffer() copy into the
// swapchain image go through the real GLES driver rather than gl4es: gl4es's
// fixed-pipeline emulation unconditionally substitutes its own shader onto
// any gl4es-routed draw call, which would silently replace the menu's
// content with whatever the game itself last rendered (see
// xr_menu_render_if_visible()). A framebuffer blit has no shader stage at
// all, so going through the real driver for it sidesteps the problem
// entirely.
static PFNQSGETINTEGERV real_glGetIntegerv;
static PFNQSGENFRAMEBUFFERS real_glGenFramebuffers;
static PFNQSDELETEFRAMEBUFFERS real_glDeleteFramebuffers;
static PFNQSBINDFRAMEBUFFER real_glBindFramebuffer;
static PFNQSFRAMEBUFFERTEXTURE2D real_glFramebufferTexture2D;
// glBlitFramebuffer is a GLES 3.0 addition; resolved via eglGetProcAddress
// rather than dlsym, since Android's driver dispatch doesn't guarantee ES3+
// symbols are dlsym-able by name from libGLESv2.so, unlike the GLES2-core
// functions above.
static PFNQSBLITFRAMEBUFFER real_glBlitFramebuffer;
static bool xr_menu_load_real_gles(void) {
void *lib = dlopen("libGLESv2.so", RTLD_NOW | RTLD_LOCAL);
if (lib == NULL) {
LOGE("XR: menu dlopen(libGLESv2.so) failed: %s", dlerror());
return false;
}
real_glGetIntegerv = (PFNQSGETINTEGERV)dlsym(lib, "glGetIntegerv");
real_glGenFramebuffers = (PFNQSGENFRAMEBUFFERS)dlsym(lib, "glGenFramebuffers");
real_glDeleteFramebuffers = (PFNQSDELETEFRAMEBUFFERS)dlsym(lib, "glDeleteFramebuffers");
real_glBindFramebuffer = (PFNQSBINDFRAMEBUFFER)dlsym(lib, "glBindFramebuffer");
real_glFramebufferTexture2D = (PFNQSFRAMEBUFFERTEXTURE2D)dlsym(lib, "glFramebufferTexture2D");
real_glBlitFramebuffer = (PFNQSBLITFRAMEBUFFER)eglGetProcAddress("glBlitFramebuffer");
return real_glGetIntegerv && real_glGenFramebuffers && real_glDeleteFramebuffers &&
real_glBindFramebuffer && real_glFramebufferTexture2D && real_glBlitFramebuffer;
}
static uint32_t g_pixel_generation = 0;
static uint32_t g_source_uploaded_generation = (uint32_t)-1;
static uint8_t *g_pixel_cache = NULL; // MENU_WIDTH*MENU_HEIGHT*4 bytes
void xr_menu_get_content_size(int *width, int *height) {
*width = MENU_WIDTH;
*height = MENU_HEIGHT;
}
// FindClass() from this thread (SDL's native thread, attached to the JVM
// via AttachCurrentThread rather than spawned from Java) resolves against
// the bootstrap classloader, which only knows framework classes - it can't
// see de.ladkau.questshock.MenuOverlay at all. Routing through the
// activity's own classloader is the standard, documented workaround.
static jclass xr_menu_find_class(JNIEnv *env, jobject activity, const char *name) {
jclass activityClass = (*env)->GetObjectClass(env, activity);
jmethodID getClassLoader =
(*env)->GetMethodID(env, activityClass, "getClassLoader", "()Ljava/lang/ClassLoader;");
jobject classLoader = (*env)->CallObjectMethod(env, activity, getClassLoader);
jclass classLoaderClass = (*env)->FindClass(env, "java/lang/ClassLoader");
jmethodID loadClass = (*env)->GetMethodID(env, classLoaderClass, "loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;");
jstring className = (*env)->NewStringUTF(env, name);
jclass result = (jclass)(*env)->CallObjectMethod(env, classLoader, loadClass, className);
(*env)->DeleteLocalRef(env, className);
return result;
}
static bool xr_menu_init_jni(void) {
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
jobject activity = (jobject)SDL_AndroidGetActivity();
if (env == NULL || activity == NULL) {
LOGE("XR: menu - no JNIEnv/Activity from SDL");
return false;
}
jclass localClass = xr_menu_find_class(env, activity, "de.ladkau.questshock.MenuOverlay");
if (localClass == NULL) {
LOGE("XR: menu - could not find MenuOverlay class");
return false;
}
g_menu_overlay_class = (jclass)(*env)->NewGlobalRef(env, localClass);
g_native_init_method =
(*env)->GetStaticMethodID(env, g_menu_overlay_class, "nativeInit", "(Landroid/app/Activity;)V");
g_take_pixels_method =
(*env)->GetStaticMethodID(env, g_menu_overlay_class, "nativeTakePixelsIfDirty", "()[B");
g_dispatch_touch_method =
(*env)->GetStaticMethodID(env, g_menu_overlay_class, "nativeDispatchTouch", "(FFZ)V");
g_update_cursor_method =
(*env)->GetStaticMethodID(env, g_menu_overlay_class, "nativeUpdateCursor", "(FFZ)V");
if (!g_native_init_method || !g_take_pixels_method || !g_dispatch_touch_method ||
!g_update_cursor_method) {
LOGE("XR: menu - could not resolve MenuOverlay JNI methods");
return false;
}
(*env)->CallStaticVoidMethod(env, g_menu_overlay_class, g_native_init_method, activity);
// CallStaticVoidMethod doesn't surface Java exceptions on its own - if
// MenuOverlay's constructor throws (it runs its View measure/layout/
// draw calls on this native render thread rather than the Android UI
// thread, a plausible crash vector), the exception would otherwise be
// left silently pending and corrupt whatever JNI call runs next.
if ((*env)->ExceptionCheck(env)) {
LOGE("XR: menu MenuOverlay.nativeInit() threw a pending Java exception:");
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
return false;
}
return true;
}
static bool xr_menu_create_source_texture(void) {
glGenTextures(1, &g_source_texture);
glBindTexture(GL_TEXTURE_2D, g_source_texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, MENU_WIDTH, MENU_HEIGHT, 0, GL_RGBA, GL_UNSIGNED_BYTE,
NULL);
GLint prevFbo = 0;
real_glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFbo);
real_glGenFramebuffers(1, &g_source_fbo);
real_glBindFramebuffer(GL_FRAMEBUFFER, g_source_fbo);
real_glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
g_source_texture, 0);
real_glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prevFbo);
return g_source_texture != 0;
}
bool xr_menu_init(XrInstance instance, XrSession session) {
(void)instance;
(void)session;
if (!xr_menu_load_real_gles()) {
LOGE("XR: menu couldn't resolve real GLES functions via dlsym/eglGetProcAddress");
return false;
}
if (!xr_menu_create_source_texture())
return false;
if (!xr_menu_init_jni())
return false;
g_pixel_cache = (uint8_t *)malloc((size_t)MENU_WIDTH * MENU_HEIGHT * 4);
if (g_pixel_cache == NULL)
return false;
LOGI("XR: menu overlay ready (%dx%d)", MENU_WIDTH, MENU_HEIGHT);
return true;
}
void xr_menu_toggle_visible(void) {
g_visible = !g_visible;
LOGI("XR: menu quad now %s", g_visible ? "visible" : "hidden");
}
bool xr_menu_is_visible(void) { return g_visible; }
void xr_menu_get_quad_extent(float *center_x_m, float *center_y_m, float *distance_m,
float *half_width_m, float *half_height_m) {
*center_x_m = MENU_CENTER_X_METERS;
*center_y_m = MENU_CENTER_Y_METERS;
*distance_m = MENU_DISTANCE_METERS;
*half_width_m = MENU_WIDTH_METERS * 0.5f;
*half_height_m = MENU_WIDTH_METERS * 0.5f * (float)MENU_HEIGHT / (float)MENU_WIDTH;
}
void xr_menu_touch(float u, float v, bool down) {
if (g_menu_overlay_class == NULL)
return;
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
if (env == NULL)
return;
// Passed via CallStaticVoidMethodA/jvalue rather than the variadic
// Call*Method form - deliberately sidesteps relying on JNI
// implementations correctly un-doing C's float-to-double default
// argument promotion for varargs float parameters (a well-known JNI
// footgun; Android's ART handles it correctly, but there's no reason
// to depend on that when the jvalue form is unambiguous either way).
jvalue args[3];
args[0].f = u;
args[1].f = v;
args[2].z = (jboolean)down;
(*env)->CallStaticVoidMethodA(env, g_menu_overlay_class, g_dispatch_touch_method, args);
}
void xr_menu_update_cursor(float u, float v, bool visible) {
if (g_menu_overlay_class == NULL)
return;
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
if (env == NULL)
return;
// Same jvalue-form rationale as xr_menu_touch() above.
jvalue args[3];
args[0].f = u;
args[1].f = v;
args[2].z = (jboolean)visible;
(*env)->CallStaticVoidMethodA(env, g_menu_overlay_class, g_update_cursor_method, args);
}
// Pulls MenuOverlay's latest pixels (if it redrew since the last check)
// into g_pixel_cache and bumps g_pixel_generation - called once per visible
// frame, before deciding whether g_source_texture needs a fresh upload.
static void xr_menu_refresh_pixel_cache(void) {
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
if (env == NULL)
return;
jbyteArray pixels =
(jbyteArray)(*env)->CallStaticObjectMethod(env, g_menu_overlay_class, g_take_pixels_method);
// CallStaticObjectMethod also doesn't surface Java exceptions on its
// own - if nativeTakePixelsIfDirty() itself throws, it would otherwise
// silently look identical to "nothing changed yet" (a null return).
if ((*env)->ExceptionCheck(env)) {
LOGE("XR: menu nativeTakePixelsIfDirty() threw a pending Java exception:");
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
return;
}
if (pixels == NULL)
return;
(*env)->GetByteArrayRegion(env, pixels, 0, MENU_WIDTH * MENU_HEIGHT * 4, (jbyte *)g_pixel_cache);
(*env)->DeleteLocalRef(env, pixels);
g_pixel_generation++;
}
// Uploads g_pixel_cache into g_source_texture via plain (gl4es-routed)
// glBindTexture/glTexSubImage2D, matching how the texture was created.
// Skipped when nothing changed since the last upload. Texture-unit-0 state
// is saved and restored around the upload, since this call is gl4es-routed
// and the engine's own next-frame rendering assumes nothing touched its
// texture bindings since it last drew.
static void xr_menu_upload_source_texture_if_dirty(void) {
if (g_source_uploaded_generation == g_pixel_generation)
return;
GLint prevActiveTexture = GL_TEXTURE0;
glGetIntegerv(GL_ACTIVE_TEXTURE, &prevActiveTexture);
glActiveTexture(GL_TEXTURE0);
GLint prevTexture2D = 0;
glGetIntegerv(GL_TEXTURE_BINDING_2D, &prevTexture2D);
glBindTexture(GL_TEXTURE_2D, g_source_texture);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, MENU_WIDTH, MENU_HEIGHT, GL_RGBA, GL_UNSIGNED_BYTE,
g_pixel_cache);
g_source_uploaded_generation = g_pixel_generation;
glBindTexture(GL_TEXTURE_2D, (GLuint)prevTexture2D);
if (prevActiveTexture != GL_TEXTURE0)
glActiveTexture((GLenum)prevActiveTexture);
}
void xr_menu_render_if_visible(void) {
if (g_source_fbo == 0)
return;
xr_menu_refresh_pixel_cache();
xr_menu_upload_source_texture_if_dirty();
// Copies g_source_texture (via g_source_fbo) directly into the
// currently-bound framebuffer - the menu's own dedicated swapchain
// image, sized to exactly MENU_WIDTH x MENU_HEIGHT (see xr_session.c),
// so the destination is always the whole image - using the real GLES3
// hardware blit (glBlitFramebuffer) rather than a shader-based
// full-screen-quad draw. A blit has no vertex/fragment shading stage,
// no shader program, and no texture units involved at all, so gl4es's
// fixed-pipeline-emulation layer - which unconditionally substitutes a
// customized shader (reproducing the game's own last-bound
// texture/fixed-function state) onto any gl4es-routed draw call - has
// nothing to intercept here. It also never touches gl4es's own tracked
// program/vertex-array/texture-binding shadow state, so the only piece
// of state that needs save/restore is the READ framebuffer binding;
// binding only GL_READ_FRAMEBUFFER, rather than the combined
// GL_FRAMEBUFFER target, leaves the DRAW side (the swapchain image
// itself) untouched throughout.
//
// Y is flipped between source and destination: g_source_texture's texel
// row v=0 holds MenuOverlay's Bitmap row 0 (the top of the rendered
// content, standard top-row-first raster order), and reading that same
// texture via an attached FBO, framebuffer y=0 accesses that identical
// row. The destination swapchain framebuffer follows the opposite
// convention - its own y=0 is its bottom edge - so swapping dstY0/dstY1
// keeps the menu content right-side up (glBlitFramebuffer supports
// inverted src/dst rects for exactly this).
GLint prevReadFbo = 0;
real_glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &prevReadFbo);
real_glBindFramebuffer(GL_READ_FRAMEBUFFER, g_source_fbo);
real_glBlitFramebuffer(0, 0, MENU_WIDTH, MENU_HEIGHT, 0, MENU_HEIGHT, MENU_WIDTH, 0,
GL_COLOR_BUFFER_BIT, GL_LINEAR);
real_glBindFramebuffer(GL_READ_FRAMEBUFFER, (GLuint)prevReadFbo);
}
void xr_menu_shutdown(void) {
if (g_source_texture != 0)
glDeleteTextures(1, &g_source_texture);
g_source_texture = 0;
g_source_uploaded_generation = (uint32_t)-1;
if (g_source_fbo != 0 && real_glDeleteFramebuffers != NULL)
real_glDeleteFramebuffers(1, &g_source_fbo);
g_source_fbo = 0;
if (g_menu_overlay_class != NULL) {
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
if (env != NULL)
(*env)->DeleteGlobalRef(env, g_menu_overlay_class);
}
g_menu_overlay_class = NULL;
g_native_init_method = NULL;
g_take_pixels_method = NULL;
g_dispatch_touch_method = NULL;
g_update_cursor_method = NULL;
free(g_pixel_cache);
g_pixel_cache = NULL;
g_pixel_generation = 0;
g_visible = false;
}