diff --git a/README.md b/README.md index 6552a76..323d2a7 100644 --- a/README.md +++ b/README.md @@ -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 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 -the viewer in an otherwise empty space. There's no controller-driven -interaction yet (that's ongoing work - a laser-pointer-driven menu and -on-screen keyboard); for now, play with a Bluetooth mouse/keyboard -connected to the headset same as before. (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 +the viewer, with a separate menu quad (currently just one "Keyboard" +button - the actual on-screen keyboard behind it is still ongoing work) +toggled by a controller button and driven by a laser-pointer-style aim +ray from each hand (`android/app/src/main/cpp/xr_input.c`) - point and +pull the trigger to interact with the menu, same as the game's own +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.) ### 5.1. Installing and playing diff --git a/android/app/src/main/cpp/xr_input.c b/android/app/src/main/cpp/xr_input.c index 3ebe0c2..1a9a9e6 100644 --- a/android/app/src/main/cpp/xr_input.c +++ b/android/app/src/main/cpp/xr_input.c @@ -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 }; - // 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. + // The GL 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 own cursor is + // drawn by MenuOverlay itself instead (see xr_menu_update_cursor() + // below), composited into its Bitmap the same way its other content is. bool drawReticle = draw && !menuVisible; if (drawReticle) 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++) { XrActionStateGetInfo selectInfo = {XR_TYPE_ACTION_STATE_GET_INFO}; 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); g_prev_hit[hand] = hit; } + if (hit) { + menuCursorHit = true; + menuCursorU = u; + menuCursorV = v; + } if (selectDownEdge && hit) { xr_menu_touch(u, v, 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); glDisableVertexAttribArray(RETICLE_POSITION_LOC); } + + if (menuVisible) + xr_menu_update_cursor(menuCursorU, menuCursorV, menuCursorHit); } void xr_input_shutdown(void) { diff --git a/android/app/src/main/cpp/xr_menu.c b/android/app/src/main/cpp/xr_menu.c index 986d924..fbc2528 100644 --- a/android/app/src/main/cpp/xr_menu.c +++ b/android/app/src/main/cpp/xr_menu.c @@ -31,6 +31,7 @@ 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; +static jmethodID g_update_cursor_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 @@ -138,7 +139,10 @@ static bool xr_menu_init_jni(void) { (*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) { + 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"); 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); } +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) // into g_pixel_cache and bumps g_pixel_generation - called once per visible // 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_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 + // Copies g_source_texture (via g_source_fbo) directly into the + // currently-bound framebuffer - the menu's own dedicated swapchain + // image, sized to exactly MENU_WIDTH x MENU_HEIGHT (see xr_session.c), + // so the destination is always the whole image - 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. // @@ -318,13 +338,10 @@ void xr_menu_render_if_visible(void) { // 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_glBlitFramebuffer(0, 0, MENU_WIDTH, MENU_HEIGHT, 0, MENU_HEIGHT, MENU_WIDTH, 0, + GL_COLOR_BUFFER_BIT, GL_LINEAR); real_glBindFramebuffer(GL_READ_FRAMEBUFFER, (GLuint)prevReadFbo); } @@ -347,6 +364,7 @@ void xr_menu_shutdown(void) { g_native_init_method = NULL; g_take_pixels_method = NULL; g_dispatch_touch_method = NULL; + g_update_cursor_method = NULL; free(g_pixel_cache); g_pixel_cache = NULL; diff --git a/android/app/src/main/cpp/xr_menu.h b/android/app/src/main/cpp/xr_menu.h index 1618812..7e49068 100644 --- a/android/app/src/main/cpp/xr_menu.h +++ b/android/app/src/main/cpp/xr_menu.h @@ -4,14 +4,9 @@ // 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. +// The menu has its own independent OpenXR swapchain, sized to exactly +// xr_menu_get_content_size()'s dimensions (see xr_session.c, which creates +// it and calls into this file to render its content each visible frame). #ifndef QUESTSHOCK_XR_MENU_H #define QUESTSHOCK_XR_MENU_H @@ -26,13 +21,12 @@ 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(). +// before it creates the menu's own swapchain, to size it correctly. 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 +// Call once, after xr_session.c has created the menu's own 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); @@ -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. 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 // 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(). +// xr_session.c binds the menu's own swapchain image before calling this. +// Only called while xr_menu_is_visible(). void xr_menu_render_if_visible(void); void xr_menu_shutdown(void); diff --git a/android/app/src/main/cpp/xr_session.c b/android/app/src/main/cpp/xr_session.c index 5bb1c4a..3acc2c6 100644 --- a/android/app/src/main/cpp/xr_session.c +++ b/android/app/src/main/cpp/xr_session.c @@ -54,26 +54,28 @@ static XrSessionState g_session_state = XR_SESSION_STATE_UNKNOWN; // later state. static bool g_session_running = false; -// 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; +// One independent swapchain per quad (game, menu), each sized to exactly +// its own content and acquired/released on its own within the same +// xrBeginFrame/xrEndFrame pair - a completely ordinary multi-layer OpenXR +// setup. A GL framebuffer wrapping each swapchain image is created once, up +// front, and reused every frame, since a swapchain's images are a small, +// fixed, runtime-owned pool (MAX_SWAPCHAIN_IMAGES above), not something +// recreated per frame. +typedef struct { + XrSwapchain swapchain; + GLuint fbos[MAX_SWAPCHAIN_IMAGES]; + uint32_t image_count; + int width; + int height; +} XrSwapchainState; + +static XrSwapchainState g_game_swapchain; +static XrSwapchainState g_menu_swapchain; static XrTime g_predicted_display_time = 0; 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 // 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; } +// 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) { JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv(); 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 // though we don't gate on the returned min/max API version here. PFN_xrGetOpenGLESGraphicsRequirementsKHR getGLESRequirements = - (PFN_xrGetOpenGLESGraphicsRequirementsKHR)xr_get_proc("xrGetOpenGLESGraphicsRequirementsKHR"); + (PFN_xrGetOpenGLESGraphicsRequirementsKHR)xr_get_proc( + "xrGetOpenGLESGraphicsRequirementsKHR"); if (getGLESRequirements == NULL) 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), "xrGetOpenGLESGraphicsRequirementsKHR")) 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 // desktop SDL_RenderCopy path this replaces - an sRGB swapchain format // 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; xrEnumerateSwapchainFormats(g_session, 0, &formatCount, NULL); 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); - 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()) { LOGE("XR: couldn't resolve real GLES FBO functions via dlsym"); return false; } - // 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, &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) + if (!xr_create_swapchain_state(chosenFormat, game_width, game_height, &g_game_swapchain)) { + LOGE("XR: game swapchain setup failed"); return false; + } - 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); + int menu_width, menu_height; + xr_menu_get_content_size(&menu_width, &menu_height); + 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 // 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) { XrSessionBeginInfo beginInfo = {XR_TYPE_SESSION_BEGIN_INFO}; 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) { xr_check(xrEndSession(g_session), "xrEndSession"); 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_frame_begin(void) { - g_have_acquired_image = false; + g_have_acquired_game_image = false; if (!xr_is_session_running()) return false; @@ -402,66 +461,40 @@ bool xr_frame_begin(void) { if (!g_frame_should_render) return false; - uint32_t imageIndex = 0; - XrSwapchainImageAcquireInfo acquireInfo = {XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO}; - 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; + g_have_acquired_game_image = xr_acquire_swapchain_image(&g_game_swapchain); + return g_have_acquired_game_image; } 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. + // Draw the laser-pointer reticle into the still-bound game 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) - + // the menu renders into its own independent swapchain image below. 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 (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_game_image) + xr_release_swapchain_image(&g_game_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"); + // The menu gets its own acquire/render/release cycle against its own + // swapchain - unlike the game quad, there's no per-frame engine + // rendering to wrap around here, just xr_menu.c's own blit, so this + // can happen any time before xrEndFrame rather than needing to bracket + // anything. + 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_release_swapchain_image(&g_menu_swapchain); + } } if (!xr_is_session_running()) @@ -470,36 +503,33 @@ void xr_frame_end(void) { 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.subImage.swapchain = g_game_swapchain.swapchain; + gameQuad.subImage.imageRect.extent.width = g_game_swapchain.width; + gameQuad.subImage.imageRect.extent.height = g_game_swapchain.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; + 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, - // 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. + // while toggled on and rendered this frame, the menu quad in front of + // it - see xr_input.c's menu_toggle handling. Each references its own + // independent swapchain (see XrSwapchainState above). const XrCompositionLayerBaseHeader *layers[2]; uint32_t layerCount = 0; - if (g_have_acquired_image) + if (g_have_acquired_game_image) layers[layerCount++] = (XrCompositionLayerBaseHeader *)&gameQuad; XrCompositionLayerQuad menuQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD}; - if (menuVisible) { + if (g_have_acquired_menu_image) { float centerX, centerY, distance, halfWidth, halfHeight; xr_menu_get_quad_extent(¢erX, ¢erY, &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.subImage.swapchain = g_menu_swapchain.swapchain; + menuQuad.subImage.imageRect.extent.width = g_menu_swapchain.width; + menuQuad.subImage.imageRect.extent.height = g_menu_swapchain.height; menuQuad.pose.orientation.w = 1.0f; menuQuad.pose.position.x = centerX; menuQuad.pose.position.y = centerY; @@ -518,15 +548,8 @@ void xr_frame_end(void) { } void xr_shutdown(void) { - for (uint32_t i = 0; i < g_swapchain_image_count; i++) { - if (g_swapchain_fbos[i] != 0) - 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; + xr_destroy_swapchain_state(&g_game_swapchain); + xr_destroy_swapchain_state(&g_menu_swapchain); if (g_local_space != XR_NULL_HANDLE) 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) { *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; + *half_height_m = + QUAD_WIDTH_METERS * 0.5f * (float)g_game_swapchain.height / (float)g_game_swapchain.width; } diff --git a/android/app/src/main/cpp/xr_session.h b/android/app/src/main/cpp/xr_session.h index bcfa052..3036d86 100644 --- a/android/app/src/main/cpp/xr_session.h +++ b/android/app/src/main/cpp/xr_session.h @@ -1,10 +1,9 @@ // Minimal OpenXR bring-up for questshock's immersive Quest build. Keeps the // game's own rendering untouched (see android/engine-patches/ // 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 -// as a single flat quad floating in front of the viewer. +// instance/session/swapchains and the final "submit quad layers instead of +// presenting to a window" step. No stereo rendering - the game composite is +// shown as a single flat quad floating in front of the viewer. #ifndef 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 // current the GL context SDL/gl4es already use - creates the OpenXR -// instance/session sharing that same EGL display/context, plus one -// swapchain sized to the game's logical resolution (game_width/height, i.e. -// grd_cap->w/h - see Shock.c's InitSDL()). 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. +// instance/session sharing that same EGL display/context, plus the game +// quad's own swapchain, sized to the game's logical resolution +// (game_width/height, i.e. grd_cap->w/h - see Shock.c's InitSDL()). The +// menu quad's own, independently-sized swapchain (see xr_menu.h) is created +// 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); // 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); // Begins the XR frame (xrWaitFrame/xrBeginFrame) and, if the runtime wants -// this frame rendered, acquires the next swapchain image and binds its -// framebuffer as the current render target - ready for the caller to draw -// into exactly as it would have drawn to the default framebuffer. Returns -// true if the caller should draw this frame; xr_frame_end() must be called -// unconditionally afterward either way (a begun XR frame must always be -// ended, rendered or not). +// this frame rendered, acquires the game quad's next swapchain image and +// binds its framebuffer as the current render target - ready for the +// caller to draw into exactly as it would have drawn to the default +// framebuffer. Returns true if the caller should draw this frame; +// xr_frame_end() must be called unconditionally afterward either way (a +// begun XR frame must always be ended, rendered or not). bool xr_frame_begin(void); -// Releases the swapchain image (if one was acquired this frame) and -// submits it as a single XrCompositionLayerQuad positioned in front of the -// local reference space's origin, then ends the XR frame. +// Releases the game quad's swapchain image (if one was acquired this +// frame), acquires/renders/releases the menu quad's own swapchain image if +// 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_shutdown(void); diff --git a/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java b/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java index 6a6636a..b76dbeb 100644 --- a/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java +++ b/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java @@ -3,6 +3,7 @@ package de.ladkau.questshock; import android.app.Activity; import android.graphics.Bitmap; import android.graphics.Canvas; +import android.graphics.Paint; import android.os.SystemClock; import android.util.Log; import android.view.Gravity; @@ -30,6 +31,8 @@ public class MenuOverlay { static final int WIDTH = 1024; static final int HEIGHT = 768; + private static final float CURSOR_RADIUS = 10f; + private static MenuOverlay instance; private final Activity activity; @@ -37,10 +40,18 @@ public class MenuOverlay { private final Button keyboardButton; private final Bitmap bitmap; private final Canvas canvas; + private final Paint cursorPaint; private final Object pixelLock = new Object(); 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) { this.activity = activity; @@ -57,6 +68,10 @@ public class MenuOverlay { bitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888); canvas = new Canvas(bitmap); + cursorPaint = new Paint(); + cursorPaint.setColor(0xFFFFFFFF); + cursorPaint.setAntiAlias(true); + int widthSpec = View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY); int heightSpec = View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY); root.measure(widthSpec, heightSpec); @@ -103,6 +118,31 @@ public class MenuOverlay { 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) { float x = u * WIDTH; float y = v * HEIGHT; @@ -120,6 +160,9 @@ public class MenuOverlay { private void forceRedraw() { canvas.drawColor(0xFF202020); root.draw(canvas); + if (cursorVisible) { + canvas.drawCircle(cursorX, cursorY, CURSOR_RADIUS, cursorPaint); + } byte[] pixels = new byte[WIDTH * HEIGHT * 4]; // ARGB_8888's actual in-memory byte order is R,G,B,A - matches