Add controller input and a laser-pointer-driven menu quad
build / build (push) Successful in 2m26s

- 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:
ml
2026-08-02 06:27:49 +02:00
parent 2300d081c3
commit d2dc58391b
310 changed files with 141458 additions and 226 deletions
+392
View File
@@ -0,0 +1,392 @@
#include "xr_input.h"
#include <math.h>
#include <string.h>
#include <android/log.h>
#include <GLES3/gl3.h>
#include "xr_menu.h"
#include "xr_session.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 LEFT 0
#define RIGHT 1
static XrInstance g_instance = XR_NULL_HANDLE;
static XrSession g_session = XR_NULL_HANDLE;
static XrActionSet g_action_set = XR_NULL_HANDLE;
static XrAction g_aim_pose_action = XR_NULL_HANDLE;
static XrAction g_select_click_action = XR_NULL_HANDLE;
static XrAction g_menu_toggle_action = XR_NULL_HANDLE;
static XrPath g_hand_path[2];
static XrSpace g_aim_space[2] = {XR_NULL_HANDLE, XR_NULL_HANDLE};
// Edge-detection state, so touch dispatch and logging only fire on actual
// state changes, not every frame.
static bool g_prev_select[2] = {false, false};
static bool g_prev_menu = false;
static bool g_prev_hit[2] = {false, false};
// Tracks which hand currently has an in-flight synthetic touch down on the
// menu (so a later trigger-up edge only dispatches a matching touch-up if
// a touch-down was actually sent for that hand).
static bool g_menu_touch_active[2] = {false, false};
static GLuint g_reticle_program = 0;
static GLint g_reticle_color_loc = -1;
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;
}
// Standard quaternion-vector rotation (v + 2*cross(q.xyz, cross(q.xyz, v) +
// q.w*v)) - used to turn the aim pose's orientation into its local -Z
// ("forward") axis, the ray direction per the OpenXR aim-pose convention.
static void quat_rotate_vec(const XrQuaternionf *q, float vx, float vy, float vz, float *outx,
float *outy, float *outz) {
float cx = q->y * vz - q->z * vy + q->w * vx;
float cy = q->z * vx - q->x * vz + q->w * vy;
float cz = q->x * vy - q->y * vx + q->w * vz;
*outx = vx + 2.0f * (q->y * cz - q->z * cy);
*outy = vy + 2.0f * (q->z * cx - q->x * cz);
*outz = vz + 2.0f * (q->x * cy - q->y * cx);
}
static GLuint compile_shader(GLenum type, const char *src) {
GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &src, NULL);
glCompileShader(shader);
GLint compiled = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (!compiled) {
char log[512];
glGetShaderInfoLog(shader, sizeof(log), NULL, log);
LOGE("XR: reticle shader compile failed: %s", log);
}
return shader;
}
// A standalone flat-color shader, independent of the engine's own
// textureShaderProgram (OpenGL.cc is a separate, C++-only translation
// unit, and shader state isn't shared across programs anyway) - position
// is emitted directly in clip space, matching the same [-1,1] local quad
// coordinates android_draw_surface_as_quad() (see android/engine-patches/
// 11-android-openxr-present.patch) already uses for its own vertex
// positions, so no view/projection matrix is needed here either.
static const char *kVertexSrc = "attribute vec3 position;\n"
"void main() { gl_Position = vec4(position, 1.0); }\n";
static const char *kFragmentSrc = "precision mediump float;\n"
"uniform vec4 color;\n"
"void main() { gl_FragColor = color; }\n";
// Attribute 0 to match kVertexSrc's single "position" attribute - fine to
// reuse the same numeric index the engine's own immediate-mode drawing
// treats specially, since that's a per-program binding and this program is
// never current at the same time as gl4es's immediate-mode emulation runs;
// xr_input_sync_and_draw() disables the array again right after drawing,
// the same discipline android_draw_surface_as_quad() already established.
#define RETICLE_POSITION_LOC 0
static bool xr_input_init_reticle_program(void) {
GLuint vs = compile_shader(GL_VERTEX_SHADER, kVertexSrc);
GLuint fs = compile_shader(GL_FRAGMENT_SHADER, kFragmentSrc);
g_reticle_program = glCreateProgram();
glAttachShader(g_reticle_program, vs);
glAttachShader(g_reticle_program, fs);
glBindAttribLocation(g_reticle_program, RETICLE_POSITION_LOC, "position");
glLinkProgram(g_reticle_program);
GLint linked = GL_FALSE;
glGetProgramiv(g_reticle_program, GL_LINK_STATUS, &linked);
glDeleteShader(vs);
glDeleteShader(fs);
if (!linked) {
char log[512];
glGetProgramInfoLog(g_reticle_program, sizeof(log), NULL, log);
LOGE("XR: reticle program link failed: %s", log);
return false;
}
g_reticle_color_loc = glGetUniformLocation(g_reticle_program, "color");
return true;
}
bool xr_input_init(XrInstance instance, XrSession session) {
g_instance = instance;
g_session = session;
xrStringToPath(instance, "/user/hand/left", &g_hand_path[LEFT]);
xrStringToPath(instance, "/user/hand/right", &g_hand_path[RIGHT]);
XrActionSetCreateInfo setInfo = {XR_TYPE_ACTION_SET_CREATE_INFO};
strncpy(setInfo.actionSetName, "gameplay", XR_MAX_ACTION_SET_NAME_SIZE - 1);
strncpy(setInfo.localizedActionSetName, "Gameplay", XR_MAX_LOCALIZED_ACTION_SET_NAME_SIZE - 1);
setInfo.priority = 0;
if (!xr_check(xrCreateActionSet(instance, &setInfo, &g_action_set), "xrCreateActionSet"))
return false;
XrActionCreateInfo aimInfo = {XR_TYPE_ACTION_CREATE_INFO};
strncpy(aimInfo.actionName, "aim_pose", XR_MAX_ACTION_NAME_SIZE - 1);
strncpy(aimInfo.localizedActionName, "Aim Pose", XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1);
aimInfo.actionType = XR_ACTION_TYPE_POSE_INPUT;
aimInfo.countSubactionPaths = 2;
aimInfo.subactionPaths = g_hand_path;
if (!xr_check(xrCreateAction(g_action_set, &aimInfo, &g_aim_pose_action),
"xrCreateAction(aim_pose)"))
return false;
XrActionCreateInfo selectInfo = {XR_TYPE_ACTION_CREATE_INFO};
strncpy(selectInfo.actionName, "select_click", XR_MAX_ACTION_NAME_SIZE - 1);
strncpy(selectInfo.localizedActionName, "Select", XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1);
selectInfo.actionType = XR_ACTION_TYPE_BOOLEAN_INPUT;
selectInfo.countSubactionPaths = 2;
selectInfo.subactionPaths = g_hand_path;
if (!xr_check(xrCreateAction(g_action_set, &selectInfo, &g_select_click_action),
"xrCreateAction(select_click)"))
return false;
// The touch_controller profile's "menu" component (the three-line
// hamburger icon) only exists on the left controller - there's no
// right-hand equivalent.
XrActionCreateInfo menuInfo = {XR_TYPE_ACTION_CREATE_INFO};
strncpy(menuInfo.actionName, "menu_toggle", XR_MAX_ACTION_NAME_SIZE - 1);
strncpy(menuInfo.localizedActionName, "Menu Toggle", XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1);
menuInfo.actionType = XR_ACTION_TYPE_BOOLEAN_INPUT;
menuInfo.countSubactionPaths = 1;
menuInfo.subactionPaths = &g_hand_path[LEFT];
if (!xr_check(xrCreateAction(g_action_set, &menuInfo, &g_menu_toggle_action),
"xrCreateAction(menu_toggle)"))
return false;
XrPath profilePath;
xrStringToPath(instance, "/interaction_profiles/oculus/touch_controller", &profilePath);
// touch_controller has no .../trigger/click component, only the float
// .../trigger/value - binding this boolean action straight to it
// relies on OpenXR's standard action-type equivalence (runtimes
// auto-convert float inputs to boolean via an internal threshold), the
// same pattern Khronos' own hello_xr sample uses for its "select"
// action. Suggesting a binding to a component the profile doesn't
// have at all (like a nonexistent .../trigger/click) fails the entire
// xrSuggestInteractionProfileBindings call, not just that one entry.
XrPath aimPoseLeftPath, aimPoseRightPath, triggerLeftPath, triggerRightPath, menuClickPath;
xrStringToPath(instance, "/user/hand/left/input/aim/pose", &aimPoseLeftPath);
xrStringToPath(instance, "/user/hand/right/input/aim/pose", &aimPoseRightPath);
xrStringToPath(instance, "/user/hand/left/input/trigger/value", &triggerLeftPath);
xrStringToPath(instance, "/user/hand/right/input/trigger/value", &triggerRightPath);
xrStringToPath(instance, "/user/hand/left/input/menu/click", &menuClickPath);
XrActionSuggestedBinding bindings[] = {
{g_aim_pose_action, aimPoseLeftPath}, {g_aim_pose_action, aimPoseRightPath},
{g_select_click_action, triggerLeftPath}, {g_select_click_action, triggerRightPath},
{g_menu_toggle_action, menuClickPath},
};
XrInteractionProfileSuggestedBinding suggested = {XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
suggested.interactionProfile = profilePath;
suggested.countSuggestedBindings = sizeof(bindings) / sizeof(bindings[0]);
suggested.suggestedBindings = bindings;
if (!xr_check(xrSuggestInteractionProfileBindings(instance, &suggested),
"xrSuggestInteractionProfileBindings"))
return false;
for (int hand = 0; hand < 2; hand++) {
XrActionSpaceCreateInfo spaceInfo = {XR_TYPE_ACTION_SPACE_CREATE_INFO};
spaceInfo.action = g_aim_pose_action;
spaceInfo.subactionPath = g_hand_path[hand];
spaceInfo.poseInActionSpace.orientation.w = 1.0f;
if (!xr_check(xrCreateActionSpace(session, &spaceInfo, &g_aim_space[hand]),
"xrCreateActionSpace(aim)"))
return false;
}
XrSessionActionSetsAttachInfo attachInfo = {XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO};
attachInfo.countActionSets = 1;
attachInfo.actionSets = &g_action_set;
if (!xr_check(xrAttachSessionActionSets(session, &attachInfo), "xrAttachSessionActionSets"))
return false;
if (!xr_input_init_reticle_program())
return false;
LOGI("XR: input action set ready (aim pose + trigger + menu-toggle)");
return true;
}
void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
if (g_action_set == XR_NULL_HANDLE)
return;
XrActiveActionSet activeSet = {g_action_set, XR_NULL_PATH};
XrActionsSyncInfo syncInfo = {XR_TYPE_ACTIONS_SYNC_INFO};
syncInfo.countActiveActionSets = 1;
syncInfo.activeActionSets = &activeSet;
xr_check(xrSyncActions(g_session, &syncInfo), "xrSyncActions");
bool menuVisible = xr_menu_is_visible();
float quadCenterX = 0.0f, quadCenterY = 0.0f, distance, halfWidth, halfHeight;
if (menuVisible)
xr_menu_get_quad_extent(&quadCenterX, &quadCenterY, &distance, &halfWidth, &halfHeight);
else
xr_get_game_quad_extent(&distance, &halfWidth, &halfHeight);
static const char *kHandName[2] = {"left", "right"};
static const float kHandColor[2][4] = {
{0.2f, 1.0f, 1.0f, 1.0f}, // left: cyan
{1.0f, 0.85f, 0.1f, 1.0f}, // right: amber
};
// The reticle is drawn directly into whatever framebuffer is currently
// bound - the game quad's swapchain image (see xr_frame_end(), which
// calls this while that image is still bound). That only makes sense
// while aiming at the game quad; the menu quad's content comes from
// MenuOverlay's rendered Bitmap instead (xr_menu.c), uploaded on a
// separate swapchain, so no reticle is drawn for it here.
bool drawReticle = draw && !menuVisible;
if (drawReticle)
glUseProgram(g_reticle_program);
for (int hand = 0; hand < 2; hand++) {
XrActionStateGetInfo selectInfo = {XR_TYPE_ACTION_STATE_GET_INFO};
selectInfo.action = g_select_click_action;
selectInfo.subactionPath = g_hand_path[hand];
XrActionStateBoolean selectState = {XR_TYPE_ACTION_STATE_BOOLEAN};
xrGetActionStateBoolean(g_session, &selectInfo, &selectState);
bool selectDown = selectState.isActive && selectState.currentState;
bool selectDownEdge = selectDown && !g_prev_select[hand];
bool selectUpEdge = !selectDown && g_prev_select[hand];
if (selectDown != g_prev_select[hand]) {
LOGI("XR: %s trigger %s", kHandName[hand], selectDown ? "DOWN" : "UP");
g_prev_select[hand] = selectDown;
}
if (hand == LEFT) {
XrActionStateGetInfo menuInfo = {XR_TYPE_ACTION_STATE_GET_INFO};
menuInfo.action = g_menu_toggle_action;
menuInfo.subactionPath = g_hand_path[LEFT];
XrActionStateBoolean menuState = {XR_TYPE_ACTION_STATE_BOOLEAN};
xrGetActionStateBoolean(g_session, &menuInfo, &menuState);
bool menuDown = menuState.isActive && menuState.currentState;
if (menuDown && !g_prev_menu) {
LOGI("XR: menu-toggle button DOWN");
xr_menu_toggle_visible();
// Avoid a stale hit/touch-active carrying over from
// whichever quad was being tested against before the
// toggle - the next frame re-evaluates against the new
// target from a clean state.
g_prev_hit[LEFT] = g_prev_hit[RIGHT] = false;
g_menu_touch_active[LEFT] = g_menu_touch_active[RIGHT] = false;
} else if (!menuDown && g_prev_menu) {
LOGI("XR: menu-toggle button UP");
}
g_prev_menu = menuDown;
}
XrActionStateGetInfo poseInfo = {XR_TYPE_ACTION_STATE_GET_INFO};
poseInfo.action = g_aim_pose_action;
poseInfo.subactionPath = g_hand_path[hand];
XrActionStatePose poseState = {XR_TYPE_ACTION_STATE_POSE};
xrGetActionStatePose(g_session, &poseInfo, &poseState);
if (!poseState.isActive)
continue;
XrSpaceLocation location = {XR_TYPE_SPACE_LOCATION};
if (!xr_check(xrLocateSpace(g_aim_space[hand], baseSpace, time, &location), "xrLocateSpace"))
continue;
const XrSpaceLocationFlags needed =
XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT;
if ((location.locationFlags & needed) != needed)
continue;
float fx, fy, fz;
quat_rotate_vec(&location.pose.orientation, 0.0f, 0.0f, -1.0f, &fx, &fy, &fz);
// The quad's plane is z = -distance (facing the viewer along -Z,
// see QUAD_DISTANCE_METERS/xr_menu.c's MENU_DISTANCE_METERS) - a
// ray parallel to it (fz ~ 0) never crosses. cx/cy are the hit
// point in the quad's own [-1,1] local space (the same space
// android_draw_surface_as_quad() draws its own vertex positions
// in); u/v are the equivalent 0..1, top-left-origin coordinates
// MenuOverlay's pixel grid and the on-quad hit logs both use.
bool hit = false;
float u = 0.0f, v = 0.0f, cx = 0.0f, cy = 0.0f;
if (fabsf(fz) > 1e-5f) {
float t = (-distance - location.pose.position.z) / fz;
if (t > 0.0f) {
cx = (location.pose.position.x + t * fx - quadCenterX) / halfWidth;
cy = (location.pose.position.y + t * fy - quadCenterY) / halfHeight;
hit = fabsf(cx) <= 1.0f && fabsf(cy) <= 1.0f;
u = (cx + 1.0f) * 0.5f;
v = (1.0f - cy) * 0.5f;
}
}
if (menuVisible) {
if (hit != g_prev_hit[hand]) {
LOGI("XR: %s aim ray %s menu quad (u=%.2f v=%.2f)", kHandName[hand],
hit ? "entered" : "left", u, v);
g_prev_hit[hand] = hit;
}
if (selectDownEdge && hit) {
xr_menu_touch(u, v, true);
g_menu_touch_active[hand] = true;
} else if (selectUpEdge && g_menu_touch_active[hand]) {
xr_menu_touch(u, v, false);
g_menu_touch_active[hand] = false;
}
continue;
}
if (hit != g_prev_hit[hand]) {
LOGI("XR: %s aim ray %s game quad (u=%.2f v=%.2f)", kHandName[hand],
hit ? "entered" : "left", u, v);
g_prev_hit[hand] = hit;
}
if (!hit || !drawReticle)
continue;
const float kSize = 0.03f;
const float verts[] = {
cx - kSize, cy, 0.0f, cx + kSize, cy, 0.0f, cx, cy - kSize, 0.0f, cx, cy + kSize, 0.0f,
};
glUniform4fv(g_reticle_color_loc, 1, kHandColor[hand]);
glEnableVertexAttribArray(RETICLE_POSITION_LOC);
glVertexAttribPointer(RETICLE_POSITION_LOC, 3, GL_FLOAT, GL_FALSE, 0, verts);
glDrawArrays(GL_LINES, 0, 4);
glDisableVertexAttribArray(RETICLE_POSITION_LOC);
}
}
void xr_input_shutdown(void) {
for (int hand = 0; hand < 2; hand++) {
if (g_aim_space[hand] != XR_NULL_HANDLE)
xrDestroySpace(g_aim_space[hand]);
g_aim_space[hand] = XR_NULL_HANDLE;
}
if (g_action_set != XR_NULL_HANDLE)
xrDestroyActionSet(g_action_set);
g_action_set = XR_NULL_HANDLE;
g_aim_pose_action = XR_NULL_HANDLE;
g_select_click_action = XR_NULL_HANDLE;
g_menu_toggle_action = XR_NULL_HANDLE;
if (g_reticle_program != 0)
glDeleteProgram(g_reticle_program);
g_reticle_program = 0;
g_instance = XR_NULL_HANDLE;
g_session = XR_NULL_HANDLE;
memset(g_prev_select, 0, sizeof(g_prev_select));
memset(g_prev_hit, 0, sizeof(g_prev_hit));
memset(g_menu_touch_active, 0, sizeof(g_menu_touch_active));
g_prev_menu = false;
}
+47
View File
@@ -0,0 +1,47 @@
// Controller input for questshock's immersive Quest build: one OpenXR
// action set (aim pose + trigger click per hand, a menu-toggle button on
// the left controller) plus ray/quad hit-testing. While the menu quad
// (xr_menu.c) is hidden, this tests against the game quad xr_session.c
// submits and draws a small reticle where each hand's aim ray crosses it;
// while the menu is visible, it tests against the menu quad instead and
// forwards trigger edges as synthetic touches (no reticle - the menu's
// own content comes from MenuOverlay's rendered Bitmap).
#ifndef QUESTSHOCK_XR_INPUT_H
#define QUESTSHOCK_XR_INPUT_H
#include <stdbool.h>
#define XR_USE_PLATFORM_ANDROID 1
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
#include <openxr/openxr.h>
#ifdef __cplusplus
extern "C" {
#endif
// Call once, right after the session is created (see xr_session.c's
// xr_create_instance_and_session()) - creates the action set/actions,
// suggests Touch controller bindings, creates the per-hand aim action
// spaces, and attaches the set to the session. Returns false (logged,
// non-fatal - the caller keeps rendering without input) if any of that
// fails.
bool xr_input_init(XrInstance instance, XrSession session);
// Call once per frame from xr_frame_end(), before releasing the acquired
// swapchain image - syncs this frame's action states (always, so edge
// detection stays correct even on frames with nothing to draw), handles
// the menu_toggle button's edge, and either draws a game-quad reticle or
// forwards menu-quad touches, per the menu's current visibility (see the
// file comment above). draw gates only the reticle - if false (nothing to
// draw into this frame, e.g. no swapchain image was acquired), hit-testing
// and touch-forwarding still run. baseSpace/time must match whatever
// xr_frame_begin() used to predict this frame.
void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw);
void xr_input_shutdown(void);
#ifdef __cplusplus
}
#endif
#endif
+356
View File
@@ -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;
}
+70
View File
@@ -0,0 +1,70 @@
// Menu quad for questshock's immersive Quest build: MenuOverlay.java's
// off-screen-rendered View tree (currently just a "Keyboard" button - see
// the plan's step D for the on-screen keyboard that goes behind it),
// toggled by xr_input.c's menu_toggle action and hit-tested by the same ray
// xr_input.c already computes per hand.
//
// The menu's content shares xr_session.c's single OpenXR swapchain (see
// that file for why: two independently-created swapchains submitted as
// separate composition layers make the Horizon OS compositor show the
// game's own content on the menu quad instead of the menu's - a
// compositor-level limitation, not anything under this app's control).
// xr_session.c sizes its shared swapchain to fit both the game's and the
// menu's content side by side, and calls into this file to render the
// menu's own sub-rectangle each visible frame.
#ifndef QUESTSHOCK_XR_MENU_H
#define QUESTSHOCK_XR_MENU_H
#include <stdbool.h>
#define XR_USE_PLATFORM_ANDROID 1
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
#include <openxr/openxr.h>
#ifdef __cplusplus
extern "C" {
#endif
// Pixel size of the menu's own content region - xr_session.c needs this
// before it creates its shared swapchain, to size it to fit both the
// game's and the menu's content. Has no OpenXR/GL dependency, safe to call
// before xr_menu_init().
void xr_menu_get_content_size(int *width, int *height);
// Call once, after xr_session.c has created its shared swapchain - sets up
// the MenuOverlay Java-side singleton and the gl4es-owned texture its
// pixels get uploaded into. Returns false (logged, non-fatal - the caller
// just has no menu) on failure.
bool xr_menu_init(XrInstance instance, XrSession session);
// Flips menu-quad visibility - called by xr_input.c on the menu_toggle
// action's rising edge.
void xr_menu_toggle_visible(void);
bool xr_menu_is_visible(void);
// Local-space pose/size of the menu quad, valid regardless of visibility -
// shared with xr_input.c's ray/quad hit-testing, the same way
// xr_get_game_quad_extent() (xr_session.h) is for the game quad.
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);
// Forwards a hit on the menu quad (in the quad's own 0..1 u/v, top-left
// origin) to the MenuOverlay Java view as a synthetic touch down/up -
// called by xr_input.c on the select_click action's edges, only while a
// hand's aim ray currently hits the menu quad.
void xr_menu_touch(float u, float v, bool down);
// Refreshes MenuOverlay's pixels from Java if they changed since the last
// call, and blits them into whichever framebuffer is currently bound -
// xr_session.c binds its shared swapchain image and sets the
// viewport/scissor to the menu's own sub-rectangle within it before calling
// this. Only called while xr_menu_is_visible().
void xr_menu_render_if_visible(void);
void xr_menu_shutdown(void);
#ifdef __cplusplus
}
#endif
#endif
+129 -30
View File
@@ -17,6 +17,9 @@
#include <SDL.h>
#include "xr_input.h"
#include "xr_menu.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__)
@@ -51,9 +54,20 @@ static XrSessionState g_session_state = XR_SESSION_STATE_UNKNOWN;
// later state.
static bool g_session_running = false;
static XrSwapchain g_game_swapchain = XR_NULL_HANDLE;
// A single shared swapchain, wide enough to hold the game's own content
// (left) and the menu's content (right) side by side - see xr_frame_end()
// for how each gets its own imageRect/viewport/scissor sub-rectangle within
// it. Deliberately not two independent swapchains: submitting two
// separately-created swapchains as two XrCompositionLayerQuads makes the
// Horizon OS compositor show the game's own content on the menu quad
// instead of the menu's - a compositor-level limitation with that
// configuration, not something fixable from app-side GL/OpenXR code.
// Sharing one swapchain avoids it entirely.
static XrSwapchain g_swapchain = XR_NULL_HANDLE;
static int g_game_width = 0;
static int g_game_height = 0;
static int g_menu_width = 0;
static int g_menu_height = 0;
static GLuint g_swapchain_fbos[MAX_SWAPCHAIN_IMAGES];
static uint32_t g_swapchain_image_count = 0;
@@ -222,6 +236,10 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
"xrCreateReferenceSpace"))
return false;
// Non-fatal if it fails (e.g. no controllers bound yet) - rendering
// keeps working either way, just without the laser pointer.
xr_input_init(g_instance, g_session);
// 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
@@ -243,23 +261,30 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
}
free(formats);
g_game_width = game_width;
g_game_height = game_height;
xr_menu_get_content_size(&g_menu_width, &g_menu_height);
// The game's own content occupies (0,0)-(game_width,game_height); the
// menu's occupies (game_width,0)-(game_width+menu_width,menu_height) -
// side by side in one image, wide enough and tall enough for both.
uint32_t sharedWidth = (uint32_t)(g_game_width + g_menu_width);
uint32_t sharedHeight = (uint32_t)(g_game_height > g_menu_height ? g_game_height : g_menu_height);
XrSwapchainCreateInfo swapchainInfo = {XR_TYPE_SWAPCHAIN_CREATE_INFO};
swapchainInfo.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_SAMPLED_BIT;
swapchainInfo.format = chosenFormat;
swapchainInfo.sampleCount = 1;
swapchainInfo.width = (uint32_t)game_width;
swapchainInfo.height = (uint32_t)game_height;
swapchainInfo.width = sharedWidth;
swapchainInfo.height = sharedHeight;
swapchainInfo.faceCount = 1;
swapchainInfo.arraySize = 1;
swapchainInfo.mipCount = 1;
if (!xr_check(xrCreateSwapchain(g_session, &swapchainInfo, &g_game_swapchain),
if (!xr_check(xrCreateSwapchain(g_session, &swapchainInfo, &g_swapchain),
"xrCreateSwapchain"))
return false;
g_game_width = game_width;
g_game_height = game_height;
uint32_t imageCount = 0;
xrEnumerateSwapchainImages(g_game_swapchain, 0, &imageCount, NULL);
xrEnumerateSwapchainImages(g_swapchain, 0, &imageCount, NULL);
if (imageCount > MAX_SWAPCHAIN_IMAGES) {
LOGE("XR: swapchain reports %u images, only room for %d", imageCount,
MAX_SWAPCHAIN_IMAGES);
@@ -271,7 +296,7 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
XrSwapchainImageOpenGLESKHR images[MAX_SWAPCHAIN_IMAGES] = {0};
for (uint32_t i = 0; i < imageCount; i++)
images[i].type = XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR;
if (!xr_check(xrEnumerateSwapchainImages(g_game_swapchain, imageCount, &imageCount,
if (!xr_check(xrEnumerateSwapchainImages(g_swapchain, imageCount, &imageCount,
(XrSwapchainImageBaseHeader *)images),
"xrEnumerateSwapchainImages"))
return false;
@@ -304,8 +329,14 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
if (!all_complete)
return false;
LOGI("XR: instance/session/swapchain ready (%dx%d, %u images)", game_width, game_height,
LOGI("XR: instance/session/swapchain ready (%ux%u shared, %dx%d game + %dx%d menu, %u images)",
sharedWidth, sharedHeight, g_game_width, g_game_height, g_menu_width, g_menu_height,
imageCount);
// Also non-fatal - the game keeps rendering with no menu quad if this
// fails (e.g. MenuOverlay couldn't be resolved via JNI).
xr_menu_init(g_instance, g_session);
return true;
}
@@ -373,22 +404,28 @@ bool xr_frame_begin(void) {
uint32_t imageIndex = 0;
XrSwapchainImageAcquireInfo acquireInfo = {XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO};
if (!xr_check(xrAcquireSwapchainImage(g_game_swapchain, &acquireInfo, &imageIndex),
if (!xr_check(xrAcquireSwapchainImage(g_swapchain, &acquireInfo, &imageIndex),
"xrAcquireSwapchainImage"))
return false;
XrSwapchainImageWaitInfo waitImageInfo = {XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO};
waitImageInfo.timeout = XR_INFINITE_DURATION;
if (!xr_check(xrWaitSwapchainImage(g_game_swapchain, &waitImageInfo), "xrWaitSwapchainImage"))
if (!xr_check(xrWaitSwapchainImage(g_swapchain, &waitImageInfo), "xrWaitSwapchainImage"))
return false;
// gl4es's own (linked, not dlsym'd) bind - this targets an FBO id
// gl4es itself created (see xr_create_instance_and_session()), so its
// own "current FBO" bookkeeping updates correctly and its immediate-
// mode draw calls in android_draw_surface_as_quad() land in the right
// place.
// place. Scissor-limited to the game's own sub-rectangle of the shared
// image (see g_swapchain's comment) - glViewport alone would already
// keep the engine's draw calls within that rectangle, but glClear
// ignores viewport and would otherwise wipe the menu's own region of
// the same image whenever the engine clears before drawing.
glBindFramebuffer(GL_FRAMEBUFFER, g_swapchain_fbos[imageIndex]);
glViewport(0, 0, g_game_width, g_game_height);
glEnable(GL_SCISSOR_TEST);
glScissor(0, 0, g_game_width, g_game_height);
g_have_acquired_image = true;
return true;
@@ -398,33 +435,85 @@ void xr_frame_end(void) {
if (g_session == XR_NULL_HANDLE)
return;
// Draw the laser-pointer reticle into the still-bound swapchain
// framebuffer before releasing it, so it composites on top of
// whatever this frame's game content already drew there. Syncing
// actions happens even when there's nothing to draw (no acquired
// image this frame), so edge detection (trigger/menu-button clicks)
// doesn't miss a frame. Only ever draws while the menu isn't visible
// (see xr_input.c), so it never needs to know about the menu's own
// sub-rectangle below.
if (xr_is_session_running())
xr_input_sync_and_draw(g_local_space, g_predicted_display_time, g_have_acquired_image);
bool menuVisible = g_have_acquired_image && xr_menu_is_visible();
if (menuVisible) {
// Switch viewport/scissor to the menu's own sub-rectangle of the
// same shared image (to the right of the game's own region, see
// g_swapchain's comment) and let xr_menu.c draw its content there -
// still the same framebuffer bound in xr_frame_begin(), just a
// different sub-rectangle of it.
glViewport(g_game_width, 0, g_menu_width, g_menu_height);
glScissor(g_game_width, 0, g_menu_width, g_menu_height);
xr_menu_render_if_visible();
}
if (g_have_acquired_image) {
glDisable(GL_SCISSOR_TEST);
XrSwapchainImageReleaseInfo releaseInfo = {XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
xr_check(xrReleaseSwapchainImage(g_game_swapchain, &releaseInfo),
"xrReleaseSwapchainImage");
xr_check(xrReleaseSwapchainImage(g_swapchain, &releaseInfo), "xrReleaseSwapchainImage");
}
if (!xr_is_session_running())
return;
XrCompositionLayerQuad quad = {XR_TYPE_COMPOSITION_LAYER_QUAD};
quad.space = g_local_space;
quad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
quad.subImage.swapchain = g_game_swapchain;
quad.subImage.imageRect.extent.width = g_game_width;
quad.subImage.imageRect.extent.height = g_game_height;
quad.pose.orientation.w = 1.0f;
quad.pose.position.z = -QUAD_DISTANCE_METERS;
quad.size.width = QUAD_WIDTH_METERS;
quad.size.height = QUAD_WIDTH_METERS * (float)g_game_height / (float)g_game_width;
XrCompositionLayerQuad gameQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD};
gameQuad.space = g_local_space;
gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
gameQuad.subImage.swapchain = g_swapchain;
gameQuad.subImage.imageRect.offset.x = 0;
gameQuad.subImage.imageRect.offset.y = 0;
gameQuad.subImage.imageRect.extent.width = g_game_width;
gameQuad.subImage.imageRect.extent.height = g_game_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_height / (float)g_game_width;
const XrCompositionLayerBaseHeader *layers[1] = {(XrCompositionLayerBaseHeader *)&quad};
// Up to 2 layers: the game quad (if a frame was actually rendered) and,
// while toggled on, the menu quad in front of it - see xr_input.c's
// menu_toggle handling. Both reference the SAME g_swapchain, just
// different imageRect sub-rectangles within it.
const XrCompositionLayerBaseHeader *layers[2];
uint32_t layerCount = 0;
if (g_have_acquired_image)
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&gameQuad;
XrCompositionLayerQuad menuQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD};
if (menuVisible) {
float centerX, centerY, distance, halfWidth, halfHeight;
xr_menu_get_quad_extent(&centerX, &centerY, &distance, &halfWidth, &halfHeight);
menuQuad.space = g_local_space;
menuQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
menuQuad.subImage.swapchain = g_swapchain;
menuQuad.subImage.imageRect.offset.x = g_game_width;
menuQuad.subImage.imageRect.offset.y = 0;
menuQuad.subImage.imageRect.extent.width = g_menu_width;
menuQuad.subImage.imageRect.extent.height = g_menu_height;
menuQuad.pose.orientation.w = 1.0f;
menuQuad.pose.position.x = centerX;
menuQuad.pose.position.y = centerY;
menuQuad.pose.position.z = -distance;
menuQuad.size.width = halfWidth * 2.0f;
menuQuad.size.height = halfHeight * 2.0f;
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&menuQuad;
}
XrFrameEndInfo endInfo = {XR_TYPE_FRAME_END_INFO};
endInfo.displayTime = g_predicted_display_time;
endInfo.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE;
endInfo.layerCount = g_have_acquired_image ? 1 : 0;
endInfo.layers = g_have_acquired_image ? layers : NULL;
endInfo.layerCount = layerCount;
endInfo.layers = layerCount > 0 ? layers : NULL;
xr_check(xrEndFrame(g_session, &endInfo), "xrEndFrame");
}
@@ -435,14 +524,18 @@ void xr_shutdown(void) {
}
g_swapchain_image_count = 0;
if (g_game_swapchain != XR_NULL_HANDLE)
xrDestroySwapchain(g_game_swapchain);
g_game_swapchain = XR_NULL_HANDLE;
if (g_swapchain != XR_NULL_HANDLE)
xrDestroySwapchain(g_swapchain);
g_swapchain = XR_NULL_HANDLE;
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_menu_shutdown();
if (g_session != XR_NULL_HANDLE)
xrDestroySession(g_session);
g_session = XR_NULL_HANDLE;
@@ -454,3 +547,9 @@ void xr_shutdown(void) {
g_session_state = XR_SESSION_STATE_UNKNOWN;
g_session_running = false;
}
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_height / (float)g_game_width;
}
+7 -1
View File
@@ -1,6 +1,6 @@
// Minimal OpenXR bring-up for questshock's immersive Quest build. Keeps the
// game's own rendering untouched (see android/engine-patches/
// 12-android-openxr-present.patch) - this module only owns the OpenXR
// 11-android-openxr-present.patch) - this module only owns the OpenXR
// instance/session/swapchain and the final "submit a quad layer instead of
// presenting to a window" step. No stereo rendering, no controller input
// yet (see the plan's steps B/C/D for those) - the game composite is shown
@@ -49,6 +49,12 @@ void xr_frame_end(void);
void xr_shutdown(void);
// Local-space geometry of the single game quad xr_frame_end() submits
// (distance in front of the local space origin, half-width/half-height,
// all in meters) - shared with xr_input.c's ray/quad hit-testing so the
// laser pointer always matches whatever's actually visible.
void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,132 @@
package de.ladkau.questshock;
import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.os.SystemClock;
import android.util.Log;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.widget.Button;
import android.widget.FrameLayout;
import java.nio.ByteBuffer;
/**
* An off-screen, never-attached-to-a-window View tree for questshock's
* OpenXR menu quad (see android/app/src/main/cpp/xr_menu.c, which owns the
* quad's swapchain and drives this class entirely via JNI - construction,
* touch input, and pixel readback). Rendering to a Bitmap and dispatching
* synthetic MotionEvents into an unattached hierarchy both work the same
* way they would for an attached View - draw(Canvas)/dispatchTouchEvent()
* don't require a ViewRootImpl/window, just a measured+laid-out tree.
*/
public class MenuOverlay {
private static final String TAG = "QuestShock";
// Matches xr_menu.c's MENU_WIDTH/MENU_HEIGHT swapchain size - fixed,
// not tied to any real display metric, since this tree is never shown
// in a real window.
static final int WIDTH = 1024;
static final int HEIGHT = 768;
private static MenuOverlay instance;
private final Activity activity;
private final FrameLayout root;
private final Button keyboardButton;
private final Bitmap bitmap;
private final Canvas canvas;
private final Object pixelLock = new Object();
private byte[] pendingPixels;
private MenuOverlay(Activity activity) {
this.activity = activity;
root = new FrameLayout(activity);
root.setBackgroundColor(0xFF202020);
keyboardButton = new Button(activity);
keyboardButton.setText("Keyboard");
keyboardButton.setOnClickListener(v -> Log.i(TAG, "MenuOverlay: Keyboard button clicked"));
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(420, 140);
lp.gravity = Gravity.CENTER;
root.addView(keyboardButton, lp);
bitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888);
canvas = new Canvas(bitmap);
int widthSpec = View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY);
int heightSpec = View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY);
root.measure(widthSpec, heightSpec);
root.layout(0, 0, WIDTH, HEIGHT);
forceRedraw();
}
// Called from xr_menu.c's xr_menu_init(), on the render thread, right
// after it resolves this class via the activity's own ClassLoader
// (plain FindClass() can't see app classes from a thread that was
// attached to the JVM rather than spawned from Java - see that file's
// xr_menu_find_class()).
public static void nativeInit(Activity activity) {
if (instance == null) {
instance = new MenuOverlay(activity);
}
}
// Polled once per frame while the menu quad is visible (see
// xr_menu_render_if_visible()) - returns the current pixels only once
// per redraw (null otherwise), so the native side knows when it can
// skip re-uploading a texture that hasn't actually changed.
public static byte[] nativeTakePixelsIfDirty() {
if (instance == null) {
return null;
}
synchronized (instance.pixelLock) {
byte[] pixels = instance.pendingPixels;
instance.pendingPixels = null;
return pixels;
}
}
// u/v are the menu quad's own hit-test coordinates (0..1, top-left
// origin) - computed by xr_input.c's ray/quad intersection against
// xr_menu.c's reported quad geometry, forwarded here as a synthetic
// tap. Runs on the UI thread since the View/Bitmap/Canvas objects here
// are otherwise only ever touched from there.
public static void nativeDispatchTouch(final float u, final float v, final boolean down) {
if (instance == null) {
return;
}
instance.activity.runOnUiThread(() -> instance.handleTouch(u, v, down));
}
private void handleTouch(float u, float v, boolean down) {
float x = u * WIDTH;
float y = v * HEIGHT;
long time = SystemClock.uptimeMillis();
MotionEvent event = MotionEvent.obtain(
time, time, down ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP, x, y, 0);
try {
root.dispatchTouchEvent(event);
} finally {
event.recycle();
}
forceRedraw();
}
private void forceRedraw() {
canvas.drawColor(0xFF202020);
root.draw(canvas);
byte[] pixels = new byte[WIDTH * HEIGHT * 4];
// ARGB_8888's actual in-memory byte order is R,G,B,A - matches
// GL_RGBA/GL_UNSIGNED_BYTE on the native side with no swizzling.
bitmap.copyPixelsToBuffer(ByteBuffer.wrap(pixels));
synchronized (pixelLock) {
pendingPixels = pixels;
}
}
}
@@ -47,7 +47,7 @@ public class QuestShockActivity extends SDLActivity {
// getLibraries()/loadLibraries(), which only runs from super.onCreate())
// purely to get nativeChdir() below - Android's dynamic linker resolves
// "main"'s own dependencies (SDL2, SDL2_mixer, fluidsynth) from the
// APK's native library directory regardless of Java-side load order, so
// APK's native library directory regardless of Java-side load order, sox
// loading it early here is safe. SDLActivity loading "main" again later
// is a harmless no-op (System.loadLibrary is idempotent per
// ClassLoader).
@@ -74,11 +74,9 @@ public class QuestShockActivity extends SDLActivity {
// onWindowFocusChanged(), it never checks SDLActivity.mBrokenLibraries
// first. Since surfaceChanged() fires on essentially every launch
// regardless of that flag, setting mBrokenLibraries alone (see onCreate()/
// setUpGameDirAndContinue()) does NOT actually stop the engine from
// starting - confirmed on-device: the missing-assets crash still happened
// with mBrokenLibraries set, from exactly this path. Route through a
// subclass that adds the missing check instead of patching the vendored
// file directly.
// setUpGameDirAndContinue()) does not actually stop the engine from
// starting. Route through a subclass that adds the missing check instead
// of patching the vendored file directly.
private static class GameSurface extends SDLSurface {
GameSurface(Context context) {
super(context);
@@ -91,20 +89,6 @@ public class QuestShockActivity extends SDLActivity {
}
super.surfaceChanged(holder, format, width, height);
}
// Diagnostic only (see OpenGL.cc's matching log) - Android's real,
// legitimate signal for "this View's laid-out size actually
// changed" is onSizeChanged(), fired by the framework itself, not
// something we have to poll or guess about. If Quest's Home shell
// settles a freshly-launched panel into its final <layout> size via
// a genuine later layout pass, this is where that would show up -
// confirming whether a real event exists to hook, before writing
// any fix around it.
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
Log.i(TAG, "GameSurface.onSizeChanged() " + oldw + "x" + oldh + " -> " + w + "x" + h);
}
}
@Override