Split the menu quad back onto its own independent OpenXR swapchain
build / build (push) Successful in 1m43s

Replaces the single shared, side-by-side swapchain (game + menu content
packed into one image, needing viewport/scissor juggling to keep the
menu's own glClear from bleeding into the game's region) with two fully
independent XrSwapchainState instances, each sized to exactly its own
content and acquired/released on its own within the same
xrBeginFrame/xrEndFrame pair - an ordinary multi-layer OpenXR setup.

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

Also adds a visible cursor to the menu quad itself: xr_input.c now
tracks whichever hand's aim ray hits the menu each frame and forwards it
to a new MenuOverlay.nativeUpdateCursor(), which draws a small dot at
that position (composited through the same Bitmap the menu's own
content already goes through) - previously there was no visual feedback
at all showing where you were pointing before pulling the trigger.
This commit is contained in:
ml
2026-08-02 08:01:14 +02:00
parent 2b52b818e8
commit 1be0bebda2
7 changed files with 336 additions and 227 deletions
+11 -6
View File
@@ -140,12 +140,17 @@ can be sideloaded onto any Android-based VR headset with OpenXR support
vendor's store. The game's own rendering is unchanged (still a flat, 2D vendor's store. The game's own rendering is unchanged (still a flat, 2D
render, no stereo 3D scene), but instead of running as a Home-hosted 2D render, no stereo 3D scene), but instead of running as a Home-hosted 2D
panel it's now shown as a single head-tracked quad floating in front of panel it's now shown as a single head-tracked quad floating in front of
the viewer in an otherwise empty space. There's no controller-driven the viewer, with a separate menu quad (currently just one "Keyboard"
interaction yet (that's ongoing work - a laser-pointer-driven menu and button - the actual on-screen keyboard behind it is still ongoing work)
on-screen keyboard); for now, play with a Bluetooth mouse/keyboard toggled by a controller button and driven by a laser-pointer-style aim
connected to the headset same as before. (Only tested on Meta Quest so ray from each hand (`android/app/src/main/cpp/xr_input.c`) - point and
far - the steps below use Quest-specific tool names where relevant, but pull the trigger to interact with the menu, same as the game's own
the same `adb install` flow applies to any Android headset with USB Bluetooth mouse/keyboard input otherwise works unchanged. The laser
pointer/cursor is currently only visible while actually aiming at the
game or menu quad respectively - there's no visual feedback yet while
aiming at empty space between them. (Only tested on Meta Quest so far -
the steps below use Quest-specific tool names where relevant, but the
same `adb install` flow applies to any Android headset with USB
debugging enabled.) debugging enabled.)
### 5.1. Installing and playing ### 5.1. Installing and playing
+21 -6
View File
@@ -244,16 +244,23 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
{1.0f, 0.85f, 0.1f, 1.0f}, // right: amber {1.0f, 0.85f, 0.1f, 1.0f}, // right: amber
}; };
// The reticle is drawn directly into whatever framebuffer is currently // The GL reticle is drawn directly into whatever framebuffer is
// bound - the game quad's swapchain image (see xr_frame_end(), which // currently bound - the game quad's swapchain image (see xr_frame_end(),
// calls this while that image is still bound). That only makes sense // which calls this while that image is still bound). That only makes
// while aiming at the game quad; the menu quad's content comes from // sense while aiming at the game quad; the menu quad's own cursor is
// MenuOverlay's rendered Bitmap instead (xr_menu.c), uploaded on a // drawn by MenuOverlay itself instead (see xr_menu_update_cursor()
// separate swapchain, so no reticle is drawn for it here. // below), composited into its Bitmap the same way its other content is.
bool drawReticle = draw && !menuVisible; bool drawReticle = draw && !menuVisible;
if (drawReticle) if (drawReticle)
glUseProgram(g_reticle_program); glUseProgram(g_reticle_program);
// Fed to xr_menu_update_cursor() after the loop below - whichever hand's
// ray hits the menu quad last wins if both do, good enough for a single
// on-quad cursor (only ever updated when hit is true, so a later hand
// that misses doesn't hide an earlier hand's hit).
bool menuCursorHit = false;
float menuCursorU = 0.0f, menuCursorV = 0.0f;
for (int hand = 0; hand < 2; hand++) { for (int hand = 0; hand < 2; hand++) {
XrActionStateGetInfo selectInfo = {XR_TYPE_ACTION_STATE_GET_INFO}; XrActionStateGetInfo selectInfo = {XR_TYPE_ACTION_STATE_GET_INFO};
selectInfo.action = g_select_click_action; selectInfo.action = g_select_click_action;
@@ -335,6 +342,11 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
hit ? "entered" : "left", u, v); hit ? "entered" : "left", u, v);
g_prev_hit[hand] = hit; g_prev_hit[hand] = hit;
} }
if (hit) {
menuCursorHit = true;
menuCursorU = u;
menuCursorV = v;
}
if (selectDownEdge && hit) { if (selectDownEdge && hit) {
xr_menu_touch(u, v, true); xr_menu_touch(u, v, true);
g_menu_touch_active[hand] = true; g_menu_touch_active[hand] = true;
@@ -364,6 +376,9 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
glDrawArrays(GL_LINES, 0, 4); glDrawArrays(GL_LINES, 0, 4);
glDisableVertexAttribArray(RETICLE_POSITION_LOC); glDisableVertexAttribArray(RETICLE_POSITION_LOC);
} }
if (menuVisible)
xr_menu_update_cursor(menuCursorU, menuCursorV, menuCursorHit);
} }
void xr_input_shutdown(void) { void xr_input_shutdown(void) {
+36 -18
View File
@@ -31,6 +31,7 @@ static jclass g_menu_overlay_class = NULL;
static jmethodID g_native_init_method = NULL; static jmethodID g_native_init_method = NULL;
static jmethodID g_take_pixels_method = NULL; static jmethodID g_take_pixels_method = NULL;
static jmethodID g_dispatch_touch_method = NULL; static jmethodID g_dispatch_touch_method = NULL;
static jmethodID g_update_cursor_method = NULL;
// The menu overlay's content lives in this texture, uploaded by // The menu overlay's content lives in this texture, uploaded by
// xr_menu_upload_source_texture_if_dirty() below. It's created through // xr_menu_upload_source_texture_if_dirty() below. It's created through
@@ -138,7 +139,10 @@ static bool xr_menu_init_jni(void) {
(*env)->GetStaticMethodID(env, g_menu_overlay_class, "nativeTakePixelsIfDirty", "()[B"); (*env)->GetStaticMethodID(env, g_menu_overlay_class, "nativeTakePixelsIfDirty", "()[B");
g_dispatch_touch_method = g_dispatch_touch_method =
(*env)->GetStaticMethodID(env, g_menu_overlay_class, "nativeDispatchTouch", "(FFZ)V"); (*env)->GetStaticMethodID(env, g_menu_overlay_class, "nativeDispatchTouch", "(FFZ)V");
if (!g_native_init_method || !g_take_pixels_method || !g_dispatch_touch_method) { g_update_cursor_method =
(*env)->GetStaticMethodID(env, g_menu_overlay_class, "nativeUpdateCursor", "(FFZ)V");
if (!g_native_init_method || !g_take_pixels_method || !g_dispatch_touch_method ||
!g_update_cursor_method) {
LOGE("XR: menu - could not resolve MenuOverlay JNI methods"); LOGE("XR: menu - could not resolve MenuOverlay JNI methods");
return false; return false;
} }
@@ -235,6 +239,20 @@ void xr_menu_touch(float u, float v, bool down) {
(*env)->CallStaticVoidMethodA(env, g_menu_overlay_class, g_dispatch_touch_method, args); (*env)->CallStaticVoidMethodA(env, g_menu_overlay_class, g_dispatch_touch_method, args);
} }
void xr_menu_update_cursor(float u, float v, bool visible) {
if (g_menu_overlay_class == NULL)
return;
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
if (env == NULL)
return;
// Same jvalue-form rationale as xr_menu_touch() above.
jvalue args[3];
args[0].f = u;
args[1].f = v;
args[2].z = (jboolean)visible;
(*env)->CallStaticVoidMethodA(env, g_menu_overlay_class, g_update_cursor_method, args);
}
// Pulls MenuOverlay's latest pixels (if it redrew since the last check) // 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 // into g_pixel_cache and bumps g_pixel_generation - called once per visible
// frame, before deciding whether g_source_texture needs a fresh upload. // frame, before deciding whether g_source_texture needs a fresh upload.
@@ -293,18 +311,20 @@ void xr_menu_render_if_visible(void) {
xr_menu_refresh_pixel_cache(); xr_menu_refresh_pixel_cache();
xr_menu_upload_source_texture_if_dirty(); xr_menu_upload_source_texture_if_dirty();
// Copies g_source_texture (via g_source_fbo) directly into the menu's // Copies g_source_texture (via g_source_fbo) directly into the
// sub-rectangle of the currently-bound shared swapchain framebuffer, // currently-bound framebuffer - the menu's own dedicated swapchain
// using the real GLES3 hardware blit (glBlitFramebuffer) rather than a // image, sized to exactly MENU_WIDTH x MENU_HEIGHT (see xr_session.c),
// shader-based full-screen-quad draw. A blit has no vertex/fragment // so the destination is always the whole image - using the real GLES3
// shading stage, no shader program, and no texture units involved at // hardware blit (glBlitFramebuffer) rather than a shader-based
// all, so gl4es's fixed-pipeline-emulation layer - which unconditionally // full-screen-quad draw. A blit has no vertex/fragment shading stage,
// substitutes a customized shader (reproducing the game's own // no shader program, and no texture units involved at all, so gl4es's
// last-bound texture/fixed-function state) onto any gl4es-routed draw // fixed-pipeline-emulation layer - which unconditionally substitutes a
// call - has nothing to intercept here. It also never touches gl4es's // customized shader (reproducing the game's own last-bound
// own tracked program/vertex-array/texture-binding shadow state, so the // texture/fixed-function state) onto any gl4es-routed draw call - has
// only piece of state that needs save/restore is the READ framebuffer // nothing to intercept here. It also never touches gl4es's own tracked
// binding; binding only GL_READ_FRAMEBUFFER, rather than the combined // 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 // GL_FRAMEBUFFER target, leaves the DRAW side (the swapchain image
// itself) untouched throughout. // itself) untouched throughout.
// //
@@ -318,13 +338,10 @@ void xr_menu_render_if_visible(void) {
// inverted src/dst rects for exactly this). // inverted src/dst rects for exactly this).
GLint prevReadFbo = 0; GLint prevReadFbo = 0;
real_glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &prevReadFbo); 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_glBindFramebuffer(GL_READ_FRAMEBUFFER, g_source_fbo);
real_glBlitFramebuffer(0, 0, MENU_WIDTH, MENU_HEIGHT, viewport[0], viewport[1] + viewport[3], real_glBlitFramebuffer(0, 0, MENU_WIDTH, MENU_HEIGHT, 0, MENU_HEIGHT, MENU_WIDTH, 0,
viewport[0] + viewport[2], viewport[1], GL_COLOR_BUFFER_BIT, GL_COLOR_BUFFER_BIT, GL_LINEAR);
GL_LINEAR);
real_glBindFramebuffer(GL_READ_FRAMEBUFFER, (GLuint)prevReadFbo); real_glBindFramebuffer(GL_READ_FRAMEBUFFER, (GLuint)prevReadFbo);
} }
@@ -347,6 +364,7 @@ void xr_menu_shutdown(void) {
g_native_init_method = NULL; g_native_init_method = NULL;
g_take_pixels_method = NULL; g_take_pixels_method = NULL;
g_dispatch_touch_method = NULL; g_dispatch_touch_method = NULL;
g_update_cursor_method = NULL;
free(g_pixel_cache); free(g_pixel_cache);
g_pixel_cache = NULL; g_pixel_cache = NULL;
+17 -16
View File
@@ -4,14 +4,9 @@
// toggled by xr_input.c's menu_toggle action and hit-tested by the same ray // toggled by xr_input.c's menu_toggle action and hit-tested by the same ray
// xr_input.c already computes per hand. // xr_input.c already computes per hand.
// //
// The menu's content shares xr_session.c's single OpenXR swapchain (see // The menu has its own independent OpenXR swapchain, sized to exactly
// that file for why: two independently-created swapchains submitted as // xr_menu_get_content_size()'s dimensions (see xr_session.c, which creates
// separate composition layers make the Horizon OS compositor show the // it and calls into this file to render its content each visible frame).
// 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 #ifndef QUESTSHOCK_XR_MENU_H
#define QUESTSHOCK_XR_MENU_H #define QUESTSHOCK_XR_MENU_H
@@ -26,13 +21,12 @@ extern "C" {
#endif #endif
// Pixel size of the menu's own content region - xr_session.c needs this // 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 // before it creates the menu's own swapchain, to size it correctly. Has no
// game's and the menu's content. Has no OpenXR/GL dependency, safe to call // OpenXR/GL dependency, safe to call before xr_menu_init().
// before xr_menu_init().
void xr_menu_get_content_size(int *width, int *height); void xr_menu_get_content_size(int *width, int *height);
// Call once, after xr_session.c has created its shared swapchain - sets up // Call once, after xr_session.c has created the menu's own swapchain - sets
// the MenuOverlay Java-side singleton and the gl4es-owned texture its // up the MenuOverlay Java-side singleton and the gl4es-owned texture its
// pixels get uploaded into. Returns false (logged, non-fatal - the caller // pixels get uploaded into. Returns false (logged, non-fatal - the caller
// just has no menu) on failure. // just has no menu) on failure.
bool xr_menu_init(XrInstance instance, XrSession session); bool xr_menu_init(XrInstance instance, XrSession session);
@@ -54,11 +48,18 @@ void xr_menu_get_quad_extent(float *center_x_m, float *center_y_m, float *distan
// hand's aim ray currently hits the menu quad. // hand's aim ray currently hits the menu quad.
void xr_menu_touch(float u, float v, bool down); void xr_menu_touch(float u, float v, bool down);
// Updates the on-quad cursor MenuOverlay draws at the current aim hit point
// (in the quad's own 0..1 u/v, top-left origin) - called once per frame
// while xr_menu_is_visible(), regardless of click state, so the cursor
// tracks the ray continuously rather than only jumping on click edges like
// xr_menu_touch() above. visible=false hides it (no hand's ray currently
// hits the quad).
void xr_menu_update_cursor(float u, float v, bool visible);
// Refreshes MenuOverlay's pixels from Java if they changed since the last // Refreshes MenuOverlay's pixels from Java if they changed since the last
// call, and blits them into whichever framebuffer is currently bound - // call, and blits them into whichever framebuffer is currently bound -
// xr_session.c binds its shared swapchain image and sets the // xr_session.c binds the menu's own swapchain image before calling this.
// viewport/scissor to the menu's own sub-rectangle within it before calling // Only called while xr_menu_is_visible().
// this. Only called while xr_menu_is_visible().
void xr_menu_render_if_visible(void); void xr_menu_render_if_visible(void);
void xr_menu_shutdown(void); void xr_menu_shutdown(void);
+186 -162
View File
@@ -54,26 +54,28 @@ static XrSessionState g_session_state = XR_SESSION_STATE_UNKNOWN;
// later state. // later state.
static bool g_session_running = false; static bool g_session_running = false;
// A single shared swapchain, wide enough to hold the game's own content // One independent swapchain per quad (game, menu), each sized to exactly
// (left) and the menu's content (right) side by side - see xr_frame_end() // its own content and acquired/released on its own within the same
// for how each gets its own imageRect/viewport/scissor sub-rectangle within // xrBeginFrame/xrEndFrame pair - a completely ordinary multi-layer OpenXR
// it. Deliberately not two independent swapchains: submitting two // setup. A GL framebuffer wrapping each swapchain image is created once, up
// separately-created swapchains as two XrCompositionLayerQuads makes the // front, and reused every frame, since a swapchain's images are a small,
// Horizon OS compositor show the game's own content on the menu quad // fixed, runtime-owned pool (MAX_SWAPCHAIN_IMAGES above), not something
// instead of the menu's - a compositor-level limitation with that // recreated per frame.
// configuration, not something fixable from app-side GL/OpenXR code. typedef struct {
// Sharing one swapchain avoids it entirely. XrSwapchain swapchain;
static XrSwapchain g_swapchain = XR_NULL_HANDLE; GLuint fbos[MAX_SWAPCHAIN_IMAGES];
static int g_game_width = 0; uint32_t image_count;
static int g_game_height = 0; int width;
static int g_menu_width = 0; int height;
static int g_menu_height = 0; } XrSwapchainState;
static GLuint g_swapchain_fbos[MAX_SWAPCHAIN_IMAGES];
static uint32_t g_swapchain_image_count = 0; static XrSwapchainState g_game_swapchain;
static XrSwapchainState g_menu_swapchain;
static XrTime g_predicted_display_time = 0; static XrTime g_predicted_display_time = 0;
static bool g_frame_should_render = false; static bool g_frame_should_render = false;
static bool g_have_acquired_image = false; static bool g_have_acquired_game_image = false;
static bool g_have_acquired_menu_image = false;
// This file's GL calls otherwise resolve to gl4es (the only GL symbol // This file's GL calls otherwise resolve to gl4es (the only GL symbol
// provider linked into this binary - see android/engine-patches/ // provider linked into this binary - see android/engine-patches/
@@ -158,6 +160,111 @@ static EGLConfig xr_get_current_egl_config(EGLDisplay display, EGLContext contex
return config; return config;
} }
// Creates a swapchain at the given size/format and wraps each of its images
// in its own GL framebuffer, ready to bind and render into directly -
// shared by the game and menu swapchains below, since both need exactly the
// same setup, just at a different size.
static bool xr_create_swapchain_state(int64_t format, int width, int height,
XrSwapchainState *out) {
out->width = width;
out->height = height;
XrSwapchainCreateInfo swapchainInfo = {XR_TYPE_SWAPCHAIN_CREATE_INFO};
swapchainInfo.usageFlags =
XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_SAMPLED_BIT;
swapchainInfo.format = format;
swapchainInfo.sampleCount = 1;
swapchainInfo.width = (uint32_t)width;
swapchainInfo.height = (uint32_t)height;
swapchainInfo.faceCount = 1;
swapchainInfo.arraySize = 1;
swapchainInfo.mipCount = 1;
if (!xr_check(xrCreateSwapchain(g_session, &swapchainInfo, &out->swapchain),
"xrCreateSwapchain"))
return false;
uint32_t imageCount = 0;
xrEnumerateSwapchainImages(out->swapchain, 0, &imageCount, NULL);
if (imageCount > MAX_SWAPCHAIN_IMAGES) {
LOGE("XR: swapchain reports %u images, only room for %d", imageCount,
MAX_SWAPCHAIN_IMAGES);
return false;
}
// Zero-initialized, not just `.type` set per element - these structs
// also carry a `next` field the runtime may read, and an uninitialized
// stack array would leave it as garbage.
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(out->swapchain, imageCount, &imageCount,
(XrSwapchainImageBaseHeader *)images),
"xrEnumerateSwapchainImages"))
return false;
out->image_count = imageCount;
// Wraps each swapchain-provided texture in its own framebuffer, matching
// how OpenGL.cc's own CreateFrameBuffer() wraps backupBuffer - just
// without a depth/stencil attachment, since the final composite draw
// (see opengl_swap_and_restore) never needs one. The Gen/Bind calls are
// gl4es's own (linked, not dlsym'd) - see the comment above
// real_glFramebufferTexture2D for why.
bool all_complete = true;
for (uint32_t i = 0; i < imageCount; i++) {
glGenFramebuffers(1, &out->fbos[i]);
glBindFramebuffer(GL_FRAMEBUFFER, out->fbos[i]);
real_glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
images[i].image, 0);
GLenum status = real_glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (status != GL_FRAMEBUFFER_COMPLETE) {
LOGE("XR: swapchain FBO %u incomplete: 0x%x", i, status);
all_complete = false;
}
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
return all_complete;
}
static void xr_destroy_swapchain_state(XrSwapchainState *state) {
for (uint32_t i = 0; i < state->image_count; i++) {
if (state->fbos[i] != 0)
glDeleteFramebuffers(1, &state->fbos[i]);
}
state->image_count = 0;
if (state->swapchain != XR_NULL_HANDLE)
xrDestroySwapchain(state->swapchain);
state->swapchain = XR_NULL_HANDLE;
}
// Acquires the next image of the given swapchain, waits for the runtime to
// finish with it, and binds its framebuffer (sized to exactly fill it) as
// the current render target.
static bool xr_acquire_swapchain_image(XrSwapchainState *state) {
uint32_t imageIndex = 0;
XrSwapchainImageAcquireInfo acquireInfo = {XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO};
if (!xr_check(xrAcquireSwapchainImage(state->swapchain, &acquireInfo, &imageIndex),
"xrAcquireSwapchainImage"))
return false;
XrSwapchainImageWaitInfo waitImageInfo = {XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO};
waitImageInfo.timeout = XR_INFINITE_DURATION;
if (!xr_check(xrWaitSwapchainImage(state->swapchain, &waitImageInfo), "xrWaitSwapchainImage"))
return false;
// gl4es's own (linked, not dlsym'd) bind - this targets an FBO id gl4es
// itself created (see xr_create_swapchain_state()), so its own "current
// FBO" bookkeeping updates correctly and its immediate-mode draw calls
// (android_draw_surface_as_quad(), the laser reticle) land in the right
// place.
glBindFramebuffer(GL_FRAMEBUFFER, state->fbos[imageIndex]);
glViewport(0, 0, state->width, state->height);
return true;
}
static void xr_release_swapchain_image(XrSwapchainState *state) {
XrSwapchainImageReleaseInfo releaseInfo = {XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
xr_check(xrReleaseSwapchainImage(state->swapchain, &releaseInfo), "xrReleaseSwapchainImage");
}
static bool xr_create_instance_and_session(int game_width, int game_height) { static bool xr_create_instance_and_session(int game_width, int game_height) {
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv(); JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
jobject activity = (jobject)SDL_AndroidGetActivity(); jobject activity = (jobject)SDL_AndroidGetActivity();
@@ -202,10 +309,12 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
// Required by spec before creating an OpenGL ES-backed session, even // Required by spec before creating an OpenGL ES-backed session, even
// though we don't gate on the returned min/max API version here. // though we don't gate on the returned min/max API version here.
PFN_xrGetOpenGLESGraphicsRequirementsKHR getGLESRequirements = PFN_xrGetOpenGLESGraphicsRequirementsKHR getGLESRequirements =
(PFN_xrGetOpenGLESGraphicsRequirementsKHR)xr_get_proc("xrGetOpenGLESGraphicsRequirementsKHR"); (PFN_xrGetOpenGLESGraphicsRequirementsKHR)xr_get_proc(
"xrGetOpenGLESGraphicsRequirementsKHR");
if (getGLESRequirements == NULL) if (getGLESRequirements == NULL)
return false; return false;
XrGraphicsRequirementsOpenGLESKHR glesRequirements = {XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR}; XrGraphicsRequirementsOpenGLESKHR glesRequirements = {
XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR};
if (!xr_check(getGLESRequirements(g_instance, g_system_id, &glesRequirements), if (!xr_check(getGLESRequirements(g_instance, g_system_id, &glesRequirements),
"xrGetOpenGLESGraphicsRequirementsKHR")) "xrGetOpenGLESGraphicsRequirementsKHR"))
return false; return false;
@@ -246,7 +355,8 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
// GL_RGBA with no decode step anywhere in this path, matching the // GL_RGBA with no decode step anywhere in this path, matching the
// desktop SDL_RenderCopy path this replaces - an sRGB swapchain format // desktop SDL_RenderCopy path this replaces - an sRGB swapchain format
// would auto-gamma-encode on write and double-encode already-encoded // would auto-gamma-encode on write and double-encode already-encoded
// data. // data. Both swapchains below share this one choice - the compositor's
// supported format set doesn't depend on swapchain size.
uint32_t formatCount = 0; uint32_t formatCount = 0;
xrEnumerateSwapchainFormats(g_session, 0, &formatCount, NULL); xrEnumerateSwapchainFormats(g_session, 0, &formatCount, NULL);
int64_t *formats = (int64_t *)malloc(sizeof(int64_t) * formatCount); int64_t *formats = (int64_t *)malloc(sizeof(int64_t) * formatCount);
@@ -261,77 +371,25 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
} }
free(formats); 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 = sharedWidth;
swapchainInfo.height = sharedHeight;
swapchainInfo.faceCount = 1;
swapchainInfo.arraySize = 1;
swapchainInfo.mipCount = 1;
if (!xr_check(xrCreateSwapchain(g_session, &swapchainInfo, &g_swapchain),
"xrCreateSwapchain"))
return false;
uint32_t imageCount = 0;
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);
return false;
}
// Zero-initialized, not just `.type` set per element - these structs
// also carry a `next` field the runtime may read, and an
// uninitialized stack array would leave it as garbage.
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_swapchain, imageCount, &imageCount,
(XrSwapchainImageBaseHeader *)images),
"xrEnumerateSwapchainImages"))
return false;
g_swapchain_image_count = imageCount;
if (!xr_load_real_gles()) { if (!xr_load_real_gles()) {
LOGE("XR: couldn't resolve real GLES FBO functions via dlsym"); LOGE("XR: couldn't resolve real GLES FBO functions via dlsym");
return false; return false;
} }
// Wraps each swapchain-provided texture in its own framebuffer, matching if (!xr_create_swapchain_state(chosenFormat, game_width, game_height, &g_game_swapchain)) {
// how OpenGL.cc's own CreateFrameBuffer() wraps backupBuffer - just LOGE("XR: game swapchain setup failed");
// without a depth/stencil attachment, since the final composite draw
// (see opengl_swap_and_restore) never needs one. The Gen/Bind calls are
// gl4es's own (linked, not dlsym'd) - see the comment above
// real_glFramebufferTexture2D for why.
bool all_complete = true;
for (uint32_t i = 0; i < imageCount; i++) {
glGenFramebuffers(1, &g_swapchain_fbos[i]);
glBindFramebuffer(GL_FRAMEBUFFER, g_swapchain_fbos[i]);
real_glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
images[i].image, 0);
GLenum status = real_glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (status != GL_FRAMEBUFFER_COMPLETE) {
LOGE("XR: swapchain FBO %u incomplete: 0x%x", i, status);
all_complete = false;
}
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
if (!all_complete)
return false; return false;
}
LOGI("XR: instance/session/swapchain ready (%ux%u shared, %dx%d game + %dx%d menu, %u images)", int menu_width, menu_height;
sharedWidth, sharedHeight, g_game_width, g_game_height, g_menu_width, g_menu_height, xr_menu_get_content_size(&menu_width, &menu_height);
imageCount); if (!xr_create_swapchain_state(chosenFormat, menu_width, menu_height, &g_menu_swapchain)) {
LOGE("XR: menu swapchain setup failed");
return false;
}
LOGI("XR: instance/session ready (game %dx%d, menu %dx%d)", game_width, game_height,
menu_width, menu_height);
// Also non-fatal - the game keeps rendering with no menu quad if this // Also non-fatal - the game keeps rendering with no menu quad if this
// fails (e.g. MenuOverlay couldn't be resolved via JNI). // fails (e.g. MenuOverlay couldn't be resolved via JNI).
@@ -369,7 +427,8 @@ void xr_poll_events(void) {
if (g_session_state == XR_SESSION_STATE_READY) { if (g_session_state == XR_SESSION_STATE_READY) {
XrSessionBeginInfo beginInfo = {XR_TYPE_SESSION_BEGIN_INFO}; XrSessionBeginInfo beginInfo = {XR_TYPE_SESSION_BEGIN_INFO};
beginInfo.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; beginInfo.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
g_session_running = xr_check(xrBeginSession(g_session, &beginInfo), "xrBeginSession"); g_session_running =
xr_check(xrBeginSession(g_session, &beginInfo), "xrBeginSession");
} else if (g_session_state == XR_SESSION_STATE_STOPPING) { } else if (g_session_state == XR_SESSION_STATE_STOPPING) {
xr_check(xrEndSession(g_session), "xrEndSession"); xr_check(xrEndSession(g_session), "xrEndSession");
g_session_running = false; g_session_running = false;
@@ -384,7 +443,7 @@ void xr_poll_events(void) {
bool xr_is_session_running(void) { return g_session_running; } bool xr_is_session_running(void) { return g_session_running; }
bool xr_frame_begin(void) { bool xr_frame_begin(void) {
g_have_acquired_image = false; g_have_acquired_game_image = false;
if (!xr_is_session_running()) if (!xr_is_session_running())
return false; return false;
@@ -402,66 +461,40 @@ bool xr_frame_begin(void) {
if (!g_frame_should_render) if (!g_frame_should_render)
return false; return false;
uint32_t imageIndex = 0; g_have_acquired_game_image = xr_acquire_swapchain_image(&g_game_swapchain);
XrSwapchainImageAcquireInfo acquireInfo = {XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO}; return g_have_acquired_game_image;
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_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. 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;
} }
void xr_frame_end(void) { void xr_frame_end(void) {
if (g_session == XR_NULL_HANDLE) if (g_session == XR_NULL_HANDLE)
return; return;
// Draw the laser-pointer reticle into the still-bound swapchain // Draw the laser-pointer reticle into the still-bound game swapchain
// framebuffer before releasing it, so it composites on top of // framebuffer before releasing it, so it composites on top of whatever
// whatever this frame's game content already drew there. Syncing // this frame's game content already drew there. Syncing actions
// actions happens even when there's nothing to draw (no acquired // happens even when there's nothing to draw (no acquired image this
// image this frame), so edge detection (trigger/menu-button clicks) // frame), so edge detection (trigger/menu-button clicks) doesn't miss a
// doesn't miss a frame. Only ever draws while the menu isn't visible // frame. Only ever draws while the menu isn't visible (see xr_input.c) -
// (see xr_input.c), so it never needs to know about the menu's own // the menu renders into its own independent swapchain image below.
// sub-rectangle below.
if (xr_is_session_running()) if (xr_is_session_running())
xr_input_sync_and_draw(g_local_space, g_predicted_display_time, g_have_acquired_image); xr_input_sync_and_draw(g_local_space, g_predicted_display_time,
g_have_acquired_game_image);
bool menuVisible = g_have_acquired_image && xr_menu_is_visible(); if (g_have_acquired_game_image)
if (menuVisible) { xr_release_swapchain_image(&g_game_swapchain);
// 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 // The menu gets its own acquire/render/release cycle against its own
// g_swapchain's comment) and let xr_menu.c draw its content there - // swapchain - unlike the game quad, there's no per-frame engine
// still the same framebuffer bound in xr_frame_begin(), just a // rendering to wrap around here, just xr_menu.c's own blit, so this
// different sub-rectangle of it. // can happen any time before xrEndFrame rather than needing to bracket
glViewport(g_game_width, 0, g_menu_width, g_menu_height); // anything.
glScissor(g_game_width, 0, g_menu_width, g_menu_height); g_have_acquired_menu_image = false;
if (g_frame_should_render && xr_menu_is_visible()) {
g_have_acquired_menu_image = xr_acquire_swapchain_image(&g_menu_swapchain);
if (g_have_acquired_menu_image) {
xr_menu_render_if_visible(); xr_menu_render_if_visible();
xr_release_swapchain_image(&g_menu_swapchain);
} }
if (g_have_acquired_image) {
glDisable(GL_SCISSOR_TEST);
XrSwapchainImageReleaseInfo releaseInfo = {XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
xr_check(xrReleaseSwapchainImage(g_swapchain, &releaseInfo), "xrReleaseSwapchainImage");
} }
if (!xr_is_session_running()) if (!xr_is_session_running())
@@ -470,36 +503,33 @@ void xr_frame_end(void) {
XrCompositionLayerQuad gameQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD}; XrCompositionLayerQuad gameQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD};
gameQuad.space = g_local_space; gameQuad.space = g_local_space;
gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
gameQuad.subImage.swapchain = g_swapchain; gameQuad.subImage.swapchain = g_game_swapchain.swapchain;
gameQuad.subImage.imageRect.offset.x = 0; gameQuad.subImage.imageRect.extent.width = g_game_swapchain.width;
gameQuad.subImage.imageRect.offset.y = 0; gameQuad.subImage.imageRect.extent.height = g_game_swapchain.height;
gameQuad.subImage.imageRect.extent.width = g_game_width;
gameQuad.subImage.imageRect.extent.height = g_game_height;
gameQuad.pose.orientation.w = 1.0f; gameQuad.pose.orientation.w = 1.0f;
gameQuad.pose.position.z = -QUAD_DISTANCE_METERS; gameQuad.pose.position.z = -QUAD_DISTANCE_METERS;
gameQuad.size.width = QUAD_WIDTH_METERS; gameQuad.size.width = QUAD_WIDTH_METERS;
gameQuad.size.height = QUAD_WIDTH_METERS * (float)g_game_height / (float)g_game_width; gameQuad.size.height =
QUAD_WIDTH_METERS * (float)g_game_swapchain.height / (float)g_game_swapchain.width;
// Up to 2 layers: the game quad (if a frame was actually rendered) and, // 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 // while toggled on and rendered this frame, the menu quad in front of
// menu_toggle handling. Both reference the SAME g_swapchain, just // it - see xr_input.c's menu_toggle handling. Each references its own
// different imageRect sub-rectangles within it. // independent swapchain (see XrSwapchainState above).
const XrCompositionLayerBaseHeader *layers[2]; const XrCompositionLayerBaseHeader *layers[2];
uint32_t layerCount = 0; uint32_t layerCount = 0;
if (g_have_acquired_image) if (g_have_acquired_game_image)
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&gameQuad; layers[layerCount++] = (XrCompositionLayerBaseHeader *)&gameQuad;
XrCompositionLayerQuad menuQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD}; XrCompositionLayerQuad menuQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD};
if (menuVisible) { if (g_have_acquired_menu_image) {
float centerX, centerY, distance, halfWidth, halfHeight; float centerX, centerY, distance, halfWidth, halfHeight;
xr_menu_get_quad_extent(&centerX, &centerY, &distance, &halfWidth, &halfHeight); xr_menu_get_quad_extent(&centerX, &centerY, &distance, &halfWidth, &halfHeight);
menuQuad.space = g_local_space; menuQuad.space = g_local_space;
menuQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; menuQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
menuQuad.subImage.swapchain = g_swapchain; menuQuad.subImage.swapchain = g_menu_swapchain.swapchain;
menuQuad.subImage.imageRect.offset.x = g_game_width; menuQuad.subImage.imageRect.extent.width = g_menu_swapchain.width;
menuQuad.subImage.imageRect.offset.y = 0; menuQuad.subImage.imageRect.extent.height = g_menu_swapchain.height;
menuQuad.subImage.imageRect.extent.width = g_menu_width;
menuQuad.subImage.imageRect.extent.height = g_menu_height;
menuQuad.pose.orientation.w = 1.0f; menuQuad.pose.orientation.w = 1.0f;
menuQuad.pose.position.x = centerX; menuQuad.pose.position.x = centerX;
menuQuad.pose.position.y = centerY; menuQuad.pose.position.y = centerY;
@@ -518,15 +548,8 @@ void xr_frame_end(void) {
} }
void xr_shutdown(void) { void xr_shutdown(void) {
for (uint32_t i = 0; i < g_swapchain_image_count; i++) { xr_destroy_swapchain_state(&g_game_swapchain);
if (g_swapchain_fbos[i] != 0) xr_destroy_swapchain_state(&g_menu_swapchain);
glDeleteFramebuffers(1, &g_swapchain_fbos[i]);
}
g_swapchain_image_count = 0;
if (g_swapchain != XR_NULL_HANDLE)
xrDestroySwapchain(g_swapchain);
g_swapchain = XR_NULL_HANDLE;
if (g_local_space != XR_NULL_HANDLE) if (g_local_space != XR_NULL_HANDLE)
xrDestroySpace(g_local_space); xrDestroySpace(g_local_space);
@@ -551,5 +574,6 @@ void xr_shutdown(void) {
void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m) { void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m) {
*distance_m = QUAD_DISTANCE_METERS; *distance_m = QUAD_DISTANCE_METERS;
*half_width_m = QUAD_WIDTH_METERS * 0.5f; *half_width_m = QUAD_WIDTH_METERS * 0.5f;
*half_height_m = QUAD_WIDTH_METERS * 0.5f * (float)g_game_height / (float)g_game_width; *half_height_m =
QUAD_WIDTH_METERS * 0.5f * (float)g_game_swapchain.height / (float)g_game_swapchain.width;
} }
+21 -18
View File
@@ -1,10 +1,9 @@
// Minimal OpenXR bring-up for questshock's immersive Quest build. Keeps the // Minimal OpenXR bring-up for questshock's immersive Quest build. Keeps the
// game's own rendering untouched (see android/engine-patches/ // game's own rendering untouched (see android/engine-patches/
// 11-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 // instance/session/swapchains and the final "submit quad layers instead of
// presenting to a window" step. No stereo rendering, no controller input // presenting to a window" step. No stereo rendering - the game composite is
// yet (see the plan's steps B/C/D for those) - the game composite is shown // shown as a single flat quad floating in front of the viewer.
// as a single flat quad floating in front of the viewer.
#ifndef QUESTSHOCK_XR_SESSION_H #ifndef QUESTSHOCK_XR_SESSION_H
#define QUESTSHOCK_XR_SESSION_H #define QUESTSHOCK_XR_SESSION_H
@@ -16,11 +15,13 @@ extern "C" {
// Call once, right after init_opengl() (OpenGL.cc) has created and made // Call once, right after init_opengl() (OpenGL.cc) has created and made
// current the GL context SDL/gl4es already use - creates the OpenXR // current the GL context SDL/gl4es already use - creates the OpenXR
// instance/session sharing that same EGL display/context, plus one // instance/session sharing that same EGL display/context, plus the game
// swapchain sized to the game's logical resolution (game_width/height, i.e. // quad's own swapchain, sized to the game's logical resolution
// grd_cap->w/h - see Shock.c's InitSDL()). Returns false if OpenXR bring-up // (game_width/height, i.e. grd_cap->w/h - see Shock.c's InitSDL()). The
// failed (e.g. no runtime installed) - callers should fall back to the // menu quad's own, independently-sized swapchain (see xr_menu.h) is created
// existing window-present path in that case. // alongside it. Returns false if OpenXR bring-up failed (e.g. no runtime
// installed) - callers should fall back to the existing window-present path
// in that case.
bool xr_init(int game_width, int game_height); bool xr_init(int game_width, int game_height);
// Pumps XR session-state events. Call once per frame, before // Pumps XR session-state events. Call once per frame, before
@@ -34,17 +35,19 @@ void xr_poll_events(void);
bool xr_is_session_running(void); bool xr_is_session_running(void);
// Begins the XR frame (xrWaitFrame/xrBeginFrame) and, if the runtime wants // Begins the XR frame (xrWaitFrame/xrBeginFrame) and, if the runtime wants
// this frame rendered, acquires the next swapchain image and binds its // this frame rendered, acquires the game quad's next swapchain image and
// framebuffer as the current render target - ready for the caller to draw // binds its framebuffer as the current render target - ready for the
// into exactly as it would have drawn to the default framebuffer. Returns // caller to draw into exactly as it would have drawn to the default
// true if the caller should draw this frame; xr_frame_end() must be called // framebuffer. Returns true if the caller should draw this frame;
// unconditionally afterward either way (a begun XR frame must always be // xr_frame_end() must be called unconditionally afterward either way (a
// ended, rendered or not). // begun XR frame must always be ended, rendered or not).
bool xr_frame_begin(void); bool xr_frame_begin(void);
// Releases the swapchain image (if one was acquired this frame) and // Releases the game quad's swapchain image (if one was acquired this
// submits it as a single XrCompositionLayerQuad positioned in front of the // frame), acquires/renders/releases the menu quad's own swapchain image if
// local reference space's origin, then ends the XR frame. // it's currently visible (see xr_menu.h), submits whichever of the two
// quads actually rendered this frame as composition layers positioned in
// front of the local reference space's origin, then ends the XR frame.
void xr_frame_end(void); void xr_frame_end(void);
void xr_shutdown(void); void xr_shutdown(void);
@@ -3,6 +3,7 @@ package de.ladkau.questshock;
import android.app.Activity; import android.app.Activity;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.Canvas; import android.graphics.Canvas;
import android.graphics.Paint;
import android.os.SystemClock; import android.os.SystemClock;
import android.util.Log; import android.util.Log;
import android.view.Gravity; import android.view.Gravity;
@@ -30,6 +31,8 @@ public class MenuOverlay {
static final int WIDTH = 1024; static final int WIDTH = 1024;
static final int HEIGHT = 768; static final int HEIGHT = 768;
private static final float CURSOR_RADIUS = 10f;
private static MenuOverlay instance; private static MenuOverlay instance;
private final Activity activity; private final Activity activity;
@@ -37,10 +40,18 @@ public class MenuOverlay {
private final Button keyboardButton; private final Button keyboardButton;
private final Bitmap bitmap; private final Bitmap bitmap;
private final Canvas canvas; private final Canvas canvas;
private final Paint cursorPaint;
private final Object pixelLock = new Object(); private final Object pixelLock = new Object();
private byte[] pendingPixels; private byte[] pendingPixels;
// Only ever touched on the UI thread (both nativeUpdateCursor() and
// forceRedraw() run/are posted there) - no lock needed, unlike
// pendingPixels above.
private boolean cursorVisible = false;
private float cursorX = 0f;
private float cursorY = 0f;
private MenuOverlay(Activity activity) { private MenuOverlay(Activity activity) {
this.activity = activity; this.activity = activity;
@@ -57,6 +68,10 @@ public class MenuOverlay {
bitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888); bitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888);
canvas = new Canvas(bitmap); canvas = new Canvas(bitmap);
cursorPaint = new Paint();
cursorPaint.setColor(0xFFFFFFFF);
cursorPaint.setAntiAlias(true);
int widthSpec = View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY); int widthSpec = View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY);
int heightSpec = View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY); int heightSpec = View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY);
root.measure(widthSpec, heightSpec); root.measure(widthSpec, heightSpec);
@@ -103,6 +118,31 @@ public class MenuOverlay {
instance.activity.runOnUiThread(() -> instance.handleTouch(u, v, down)); instance.activity.runOnUiThread(() -> instance.handleTouch(u, v, down));
} }
// u/v are the same menu-quad hit-test coordinates nativeDispatchTouch()
// uses, but polled once per frame regardless of click state (see
// xr_menu_update_cursor()) rather than only on click edges - lets the
// cursor track the aim ray continuously instead of only jumping when a
// trigger is pressed. visible=false (no hand's ray currently on the
// quad) hides it.
public static void nativeUpdateCursor(final float u, final float v, final boolean visible) {
if (instance == null) {
return;
}
instance.activity.runOnUiThread(() -> instance.updateCursor(u, v, visible));
}
private void updateCursor(float u, float v, boolean visible) {
float x = u * WIDTH;
float y = v * HEIGHT;
if (visible == cursorVisible && x == cursorX && y == cursorY) {
return;
}
cursorVisible = visible;
cursorX = x;
cursorY = y;
forceRedraw();
}
private void handleTouch(float u, float v, boolean down) { private void handleTouch(float u, float v, boolean down) {
float x = u * WIDTH; float x = u * WIDTH;
float y = v * HEIGHT; float y = v * HEIGHT;
@@ -120,6 +160,9 @@ public class MenuOverlay {
private void forceRedraw() { private void forceRedraw() {
canvas.drawColor(0xFF202020); canvas.drawColor(0xFF202020);
root.draw(canvas); root.draw(canvas);
if (cursorVisible) {
canvas.drawCircle(cursorX, cursorY, CURSOR_RADIUS, cursorPaint);
}
byte[] pixels = new byte[WIDTH * HEIGHT * 4]; byte[] pixels = new byte[WIDTH * HEIGHT * 4];
// ARGB_8888's actual in-memory byte order is R,G,B,A - matches // ARGB_8888's actual in-memory byte order is R,G,B,A - matches