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