- xr_input.c/h: an OpenXR action set (aim pose, trigger/select click, a menu-toggle button) plus ray/quad hit-testing, and a visible laser-pointer line drawn from the controller toward the hit point. - xr_menu.c/h + MenuOverlay.java: a second composition-layer quad, toggled by a controller button, showing an off-screen, never-attached Android View tree (one "Keyboard" button so far) driven by synthetic touch events computed from the laser ray's hit UV. - Migrate gl4es from a prebuilt binary baked into the Docker build-image to a vendored source snapshot (android/gl4es-src/) compiled from source at APK build time via CMake add_subdirectory(), patched through a new android/gl4es-patches/ (mirrors the existing engine-patches/ pattern). This was needed to track down and fix a bug hit while building the menu: gl4es's fixed-pipeline emulation (fpe.c) was unconditionally substituting its own shader onto the menu's draw call (fpe_ReleventState() always sets alphafunc to a nonzero sentinel, so its fpe_IsEmpty() check could never see the state as empty), making the menu quad show the game's own rendering instead of its own content. Fixed by routing the menu's blit through a real glBlitFramebuffer() call instead of a shader-based draw, which gl4es's fpe.c has nothing to intercept - documented in README.md's new "Debugging notes" section. - Renumber android/engine-patches/ to close the gap left by removing an unrelated diagnostic-only patch, and strip investigation-journal comments and dead diagnostic code (temporary tracing, env-var probes) left over from finding the bug above. - Restructure README.md into numbered sections, document every engine/gl4es patch, add Android Studio dev/testing-cycle instructions, and generalize Quest-specific wording to any OpenXR headset. Update NOTICE.txt to match (gl4es is now vendored/patched source, not a prebuilt binary; add the OpenXR-SDK loader).
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
#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;
|
||||
|
||||
// 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");
|
||||
if (!g_native_init_method || !g_take_pixels_method || !g_dispatch_touch_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);
|
||||
}
|
||||
|
||||
// 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 menu's
|
||||
// sub-rectangle of the currently-bound shared swapchain framebuffer,
|
||||
// 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);
|
||||
GLint viewport[4] = {0, 0, 0, 0};
|
||||
real_glGetIntegerv(GL_VIEWPORT, viewport);
|
||||
|
||||
real_glBindFramebuffer(GL_READ_FRAMEBUFFER, g_source_fbo);
|
||||
real_glBlitFramebuffer(0, 0, MENU_WIDTH, MENU_HEIGHT, viewport[0], viewport[1] + viewport[3],
|
||||
viewport[0] + viewport[2], viewport[1], 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;
|
||||
|
||||
free(g_pixel_cache);
|
||||
g_pixel_cache = NULL;
|
||||
g_pixel_generation = 0;
|
||||
|
||||
g_visible = false;
|
||||
}
|
||||
Reference in New Issue
Block a user