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;
}