Split the keyboard onto its own translucent OpenXR quad
build / build (push) Successful in 1m52s

Expand the on-screen keyboard to a full US layout (letters, digits,
punctuation, Shift layer, Tab, arrows, F1-F12, one-shot Ctrl/Alt
modifiers), make it a persistent, movable, closeable panel via a
title-bar drag handle, and lower its opacity.

Previously the keyboard was just a second panel swapped into the same
quad as the menu launcher, which made the launcher translucent too and
made the keyboard and menu mutually exclusive. Extract the shared
swapchain/JNI/touch-dispatch plumbing into two generic, reusable
modules - xr_swapchain.c (swapchain + per-image FBO setup) and
xr_overlay.c (an off-screen Android View rendered into its own OpenXR
quad, hit-tested via a laser pointer) - and make the menu launcher and
keyboard two independent XrOverlay instances instead of one. The
controller's menu button now only opens/closes the launcher; the
launcher's "Keyboard" button only opens the keyboard - so the keyboard
can stay up while picking something from the menu, and only the
keyboard's own Close button hides it.

xr_menu.c/.h are gone, fully absorbed into xr_overlay.c. On the Java
side, OverlayPanel.java carries the shared off-screen-render/touch/
cursor plumbing that MenuOverlay and the new KeyboardOverlay both
subclass.
This commit is contained in:
ml
2026-08-14 06:43:44 +02:00
parent 2d8fb49bf5
commit 3be310ade6
16 changed files with 1897 additions and 1102 deletions
+91 -215
View File
@@ -1,6 +1,5 @@
#include "xr_session.h"
#include <dlfcn.h>
#include <stdlib.h>
#include <string.h>
@@ -18,7 +17,8 @@
#include <SDL.h>
#include "xr_input.h"
#include "xr_menu.h"
#include "xr_overlay.h"
#include "xr_swapchain.h"
#define TAG "QuestShock"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
@@ -33,9 +33,26 @@
#define QUAD_DISTANCE_METERS 2.0f
#define QUAD_WIDTH_METERS 1.6f
// Up to this many swapchain images/FBOs - real runtimes report small counts
// (2-4); this is just a fixed upper bound for the cache arrays below.
#define MAX_SWAPCHAIN_IMAGES 8
// The menu launcher (just a "Keyboard" button - see MenuOverlay.java) is
// small and sits above where the keyboard defaults to; the keyboard keeps
// its previous size/position. They're independent XrOverlay instances (see
// xr_overlay.h) so both can be shown/hidden/dragged separately - deliberately
// positioned apart by default so they don't start out overlapping.
#define MENU_JAVA_CLASS "de.ladkau.questshock.MenuOverlay"
#define MENU_WIDTH 512
#define MENU_HEIGHT 256
#define MENU_DISTANCE_METERS 1.3f
#define MENU_WIDTH_METERS 0.35f
#define MENU_DEFAULT_CENTER_X_METERS 0.0f
#define MENU_DEFAULT_CENTER_Y_METERS 0.15f
#define KEYBOARD_JAVA_CLASS "de.ladkau.questshock.KeyboardOverlay"
#define KEYBOARD_WIDTH 1024
#define KEYBOARD_HEIGHT 768
#define KEYBOARD_DISTANCE_METERS 1.5f
#define KEYBOARD_WIDTH_METERS 0.8f
#define KEYBOARD_DEFAULT_CENTER_X_METERS 0.0f
#define KEYBOARD_DEFAULT_CENTER_Y_METERS -0.45f
static XrInstance g_instance = XR_NULL_HANDLE;
static XrSystemId g_system_id = XR_NULL_SYSTEM_ID;
@@ -54,57 +71,13 @@ static XrSessionState g_session_state = XR_SESSION_STATE_UNKNOWN;
// later state.
static bool g_session_running = false;
// 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 XrOverlay *g_menu_overlay = NULL;
static XrOverlay *g_keyboard_overlay = NULL;
static XrTime g_predicted_display_time = 0;
static bool g_frame_should_render = 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/
// 02-android-opengl-es.patch), which is fine for anything shared with the
// engine's own gl4es-routed rendering. But the swapchain images OpenXR
// hands us are real driver texture objects gl4es never created itself,
// and gl4es's own glFramebufferTexture2D can't attach a texture it has no
// tracked metadata for. So the FBO *container* is created via gl4es's own
// glGenFramebuffers/glBindFramebuffer (so gl4es recognizes the id as its
// own and its own per-frame glBindFramebuffer succeeds), while the
// texture-attach step - the specifically foreign part - goes through the
// real driver directly, via dlsym against libGLESv2.so.
typedef void (*PFNQSFRAMEBUFFERTEXTURE2D)(GLenum, GLenum, GLenum, GLuint, GLint);
typedef GLenum (*PFNQSCHECKFRAMEBUFFERSTATUS)(GLenum);
static PFNQSFRAMEBUFFERTEXTURE2D real_glFramebufferTexture2D;
static PFNQSCHECKFRAMEBUFFERSTATUS real_glCheckFramebufferStatus;
static bool xr_load_real_gles(void) {
void *lib = dlopen("libGLESv2.so", RTLD_NOW | RTLD_LOCAL);
if (lib == NULL) {
LOGE("XR: dlopen(libGLESv2.so) failed: %s", dlerror());
return false;
}
real_glFramebufferTexture2D = (PFNQSFRAMEBUFFERTEXTURE2D)dlsym(lib, "glFramebufferTexture2D");
real_glCheckFramebufferStatus =
(PFNQSCHECKFRAMEBUFFERSTATUS)dlsym(lib, "glCheckFramebufferStatus");
return real_glFramebufferTexture2D && real_glCheckFramebufferStatus;
}
static bool xr_check(XrResult result, const char *what) {
if (XR_SUCCEEDED(result))
@@ -160,111 +133,6 @@ 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();
@@ -355,8 +223,9 @@ 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. Both swapchains below share this one choice - the compositor's
// supported format set doesn't depend on swapchain size.
// data. Every swapchain below (game quad, menu/keyboard overlays)
// shares 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);
@@ -371,30 +240,38 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
}
free(formats);
if (!xr_load_real_gles()) {
LOGE("XR: couldn't resolve real GLES FBO functions via dlsym");
return false;
}
if (!xr_create_swapchain_state(chosenFormat, game_width, game_height, &g_game_swapchain)) {
if (!xr_swapchain_create(g_instance, g_session, chosenFormat, game_width, game_height,
&g_game_swapchain)) {
LOGE("XR: game swapchain setup failed");
return false;
}
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;
}
// Both non-fatal - the game keeps rendering with no such panel if
// either fails (e.g. the Java class couldn't be resolved via JNI).
XrOverlayConfig menuConfig = {
.java_class_name = MENU_JAVA_CLASS,
.width = MENU_WIDTH,
.height = MENU_HEIGHT,
.distance_m = MENU_DISTANCE_METERS,
.width_m = MENU_WIDTH_METERS,
.default_center_x_m = MENU_DEFAULT_CENTER_X_METERS,
.default_center_y_m = MENU_DEFAULT_CENTER_Y_METERS,
};
g_menu_overlay = xr_overlay_create(g_instance, g_session, chosenFormat, &menuConfig);
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).
xr_menu_init(g_instance, g_session);
XrOverlayConfig keyboardConfig = {
.java_class_name = KEYBOARD_JAVA_CLASS,
.width = KEYBOARD_WIDTH,
.height = KEYBOARD_HEIGHT,
.distance_m = KEYBOARD_DISTANCE_METERS,
.width_m = KEYBOARD_WIDTH_METERS,
.default_center_x_m = KEYBOARD_DEFAULT_CENTER_X_METERS,
.default_center_y_m = KEYBOARD_DEFAULT_CENTER_Y_METERS,
};
g_keyboard_overlay = xr_overlay_create(g_instance, g_session, chosenFormat, &keyboardConfig);
LOGI("XR: instance/session ready (game %dx%d, menu %dx%d, keyboard %dx%d)", game_width,
game_height, MENU_WIDTH, MENU_HEIGHT, KEYBOARD_WIDTH, KEYBOARD_HEIGHT);
return true;
}
@@ -407,6 +284,9 @@ bool xr_init(int game_width, int game_height) {
return true;
}
XrOverlay *xr_session_get_menu_overlay(void) { return g_menu_overlay; }
XrOverlay *xr_session_get_keyboard_overlay(void) { return g_keyboard_overlay; }
void xr_poll_events(void) {
if (g_instance == XR_NULL_HANDLE)
return;
@@ -461,7 +341,7 @@ bool xr_frame_begin(void) {
if (!g_frame_should_render)
return false;
g_have_acquired_game_image = xr_acquire_swapchain_image(&g_game_swapchain);
g_have_acquired_game_image = xr_swapchain_acquire(g_instance, &g_game_swapchain);
return g_have_acquired_game_image;
}
@@ -474,32 +354,25 @@ void xr_frame_end(void) {
// 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.
// frame. Only ever draws while neither overlay is visible (see
// xr_input.c) - the menu/keyboard render into their own independent
// swapchain images below.
if (xr_is_session_running())
xr_input_sync_and_draw(g_local_space, g_predicted_display_time,
g_have_acquired_game_image);
if (g_have_acquired_game_image)
xr_release_swapchain_image(&g_game_swapchain);
// 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);
}
}
xr_swapchain_release(g_instance, &g_game_swapchain);
if (!xr_is_session_running())
return;
// Up to 3 layers: the game quad (if a frame was actually rendered),
// and, while visible, the menu launcher and keyboard overlays - each
// gets its own acquire/render/release cycle against its own swapchain
// (see xr_overlay_render_and_build_layer()) any time before xrEndFrame,
// unlike the game quad there's no per-frame engine rendering to wrap
// around here, just each overlay's own blit.
XrCompositionLayerQuad gameQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD};
gameQuad.space = g_local_space;
gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
@@ -512,33 +385,34 @@ void xr_frame_end(void) {
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 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];
const XrCompositionLayerBaseHeader *layers[3];
uint32_t layerCount = 0;
if (g_have_acquired_game_image)
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&gameQuad;
XrCompositionLayerQuad menuQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD};
if (g_have_acquired_menu_image) {
float centerX, centerY, distance, halfWidth, halfHeight;
xr_menu_get_quad_extent(&centerX, &centerY, &distance, &halfWidth, &halfHeight);
XrCompositionLayerQuad menuQuad;
if (g_frame_should_render && xr_overlay_render_and_build_layer(g_menu_overlay, &menuQuad)) {
menuQuad.space = g_local_space;
menuQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
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;
menuQuad.pose.position.z = -distance;
menuQuad.size.width = halfWidth * 2.0f;
menuQuad.size.height = halfHeight * 2.0f;
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&menuQuad;
}
XrCompositionLayerQuad keyboardQuad;
if (g_frame_should_render &&
xr_overlay_render_and_build_layer(g_keyboard_overlay, &keyboardQuad)) {
keyboardQuad.space = g_local_space;
keyboardQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
// Lets the keyboard sit on screen translucently while playing
// rather than fully hiding whatever's behind it - KeyboardOverlay
// writes real sub-255 alpha into the swapchain texture (see
// OverlayPanel.compositeAndPublish()), already in Android's
// default premultiplied format, which is what this flag's absence
// of UNPREMULTIPLIED_ALPHA_BIT assumes. The menu launcher above
// deliberately doesn't get this flag - it stays fully opaque.
keyboardQuad.layerFlags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT;
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&keyboardQuad;
}
XrFrameEndInfo endInfo = {XR_TYPE_FRAME_END_INFO};
endInfo.displayTime = g_predicted_display_time;
endInfo.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE;
@@ -548,8 +422,7 @@ void xr_frame_end(void) {
}
void xr_shutdown(void) {
xr_destroy_swapchain_state(&g_game_swapchain);
xr_destroy_swapchain_state(&g_menu_swapchain);
xr_swapchain_destroy(&g_game_swapchain);
if (g_local_space != XR_NULL_HANDLE)
xrDestroySpace(g_local_space);
@@ -557,7 +430,10 @@ void xr_shutdown(void) {
// Before the session/instance they were created from.
xr_input_shutdown();
xr_menu_shutdown();
xr_overlay_destroy(g_menu_overlay);
g_menu_overlay = NULL;
xr_overlay_destroy(g_keyboard_overlay);
g_keyboard_overlay = NULL;
if (g_session != XR_NULL_HANDLE)
xrDestroySession(g_session);