diff --git a/README.md b/README.md index 2b1a1db..02e61f0 100644 --- a/README.md +++ b/README.md @@ -184,10 +184,15 @@ set only on the keyboard's layer - the menu launcher stays fully opaque) so whatever's behind it - the game quad, mid-play - stays visible while it's up. -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 +While the menu launcher or keyboard is open, each hand also gets a thin, +colored laser-beam quad (cyan left, amber right) from the controller to +wherever its ray currently crosses that panel's plane - a second, +billboarded `XrCompositionLayerQuad` per hand (`xr_input_build_beam()` in +`xr_input.c`), oriented so it reads as a line from the viewer's eye +regardless of angle, since the flat cross-shaped reticle used while +aiming at the game quad is drawn directly into the game quad's own +texture and can't represent a ray traversing real 3D space. Aiming at the +game quad itself still uses that flat reticle, unchanged. (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 diff --git a/android/app/src/main/cpp/xr_input.c b/android/app/src/main/cpp/xr_input.c index 12e555a..f0e1af7 100644 --- a/android/app/src/main/cpp/xr_input.c +++ b/android/app/src/main/cpp/xr_input.c @@ -9,6 +9,7 @@ #include "xr_overlay.h" #include "xr_session.h" +#include "xr_swapchain.h" #define TAG "QuestShock" #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) @@ -72,6 +73,29 @@ static float g_keyboard_drag_offset_y = 0.0f; static GLuint g_reticle_program = 0; static GLint g_reticle_color_loc = -1; +// A "view" reference space, located once per frame (not per hand) to get +// an approximate head position for the laser-beam billboard math below - +// the only consumer of a head pose in this file. Both LOCAL (g_local_space, +// owned by xr_session.c) and VIEW reference spaces are mandated by core +// OpenXR, so no capability check is needed to create this. +static XrSpace g_view_space = XR_NULL_HANDLE; + +// Per-hand laser-beam state: a thin, billboarded quad spanning from the +// controller to wherever that hand's ray currently crosses the plane of +// whichever overlay (keyboard/menu) claimed it this frame - see +// xr_input_build_beam()/xr_input_get_beam_layer(). Each hand gets its own +// tiny solid-color swapchain (no shared tint on XrCompositionLayerQuad, +// so two separately-colored textures is simplest). g_beam_quad_valid is +// reset to false at the top of every xr_input_sync_and_draw() call and +// only set back to true if that hand actually claims a beam this frame. +#define BEAM_TEX_SIZE 2 +#define BEAM_THICKNESS_METERS 0.004f +#define MAX_BEAM_LENGTH_METERS 2.0f + +static XrSwapchainState g_beam_swapchain[2]; +static XrCompositionLayerQuad g_beam_quad[2]; +static bool g_beam_quad_valid[2] = {false, false}; + static bool xr_check(XrResult result, const char *what) { if (XR_SUCCEEDED(result)) return true; @@ -95,6 +119,70 @@ static void quat_rotate_vec(const XrQuaternionf *q, float vx, float vy, float vz *outz = vz + 2.0f * (q->x * cy - q->y * cx); } +static void vec3_sub(const float a[3], const float b[3], float out[3]) { + out[0] = a[0] - b[0]; + out[1] = a[1] - b[1]; + out[2] = a[2] - b[2]; +} + +static float vec3_dot(const float a[3], const float b[3]) { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +static void vec3_cross(const float a[3], const float b[3], float out[3]) { + out[0] = a[1] * b[2] - a[2] * b[1]; + out[1] = a[2] * b[0] - a[0] * b[2]; + out[2] = a[0] * b[1] - a[1] * b[0]; +} + +// Returns false (out left untouched) if v is too close to zero-length to +// normalize safely - callers use this to detect a degenerate billboard +// axis and fall back to another reference vector. +static bool vec3_normalize(const float v[3], float out[3]) { + float len = sqrtf(vec3_dot(v, v)); + if (len < 1e-6f) + return false; + out[0] = v[0] / len; + out[1] = v[1] / len; + out[2] = v[2] / len; + return true; +} + +// Converts an orthonormal local->world rotation matrix (given as its +// columns - x/y/z, each the world-space direction of that local axis) to +// an XrQuaternionf, via the standard trace-based (Shepperd) method. +static void mat3_to_quat(const float x[3], const float y[3], const float z[3], XrQuaternionf *q) { + float m00 = x[0], m10 = x[1], m20 = x[2]; + float m01 = y[0], m11 = y[1], m21 = y[2]; + float m02 = z[0], m12 = z[1], m22 = z[2]; + float trace = m00 + m11 + m22; + if (trace > 0.0f) { + float s = sqrtf(trace + 1.0f) * 2.0f; + q->w = 0.25f * s; + q->x = (m21 - m12) / s; + q->y = (m02 - m20) / s; + q->z = (m10 - m01) / s; + } else if (m00 > m11 && m00 > m22) { + float s = sqrtf(1.0f + m00 - m11 - m22) * 2.0f; + q->w = (m21 - m12) / s; + q->x = 0.25f * s; + q->y = (m01 + m10) / s; + q->z = (m02 + m20) / s; + } else if (m11 > m22) { + float s = sqrtf(1.0f + m11 - m00 - m22) * 2.0f; + q->w = (m02 - m20) / s; + q->x = (m01 + m10) / s; + q->y = 0.25f * s; + q->z = (m12 + m21) / s; + } else { + float s = sqrtf(1.0f + m22 - m00 - m11) * 2.0f; + q->w = (m10 - m01) / s; + q->x = (m02 + m20) / s; + q->y = (m12 + m21) / s; + q->z = 0.25f * s; + } +} + static GLuint compile_shader(GLenum type, const char *src) { GLuint shader = glCreateShader(type); glShaderSource(shader, 1, &src, NULL); @@ -152,7 +240,7 @@ static bool xr_input_init_reticle_program(void) { return true; } -bool xr_input_init(XrInstance instance, XrSession session) { +bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_format) { g_instance = instance; g_session = session; @@ -246,6 +334,21 @@ bool xr_input_init(XrInstance instance, XrSession session) { if (!xr_check(xrAttachSessionActionSets(session, &attachInfo), "xrAttachSessionActionSets")) return false; + XrReferenceSpaceCreateInfo viewSpaceInfo = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO}; + viewSpaceInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW; + viewSpaceInfo.poseInReferenceSpace.orientation.w = 1.0f; + if (!xr_check(xrCreateReferenceSpace(session, &viewSpaceInfo, &g_view_space), + "xrCreateReferenceSpace(view)")) + return false; + + for (int hand = 0; hand < 2; hand++) { + if (!xr_swapchain_create(instance, session, swapchain_format, BEAM_TEX_SIZE, BEAM_TEX_SIZE, + &g_beam_swapchain[hand])) { + LOGE("XR: beam swapchain setup failed for hand %d", hand); + return false; + } + } + if (!xr_input_init_reticle_program()) return false; @@ -266,7 +369,9 @@ static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand, const XrSpaceLocation *location, float fx, float fy, float fz, bool selectDownEdge, bool selectUpEdge, bool *touchActive, bool *prevHit, const char *quadName, float *outCursorU, - float *outCursorV, bool *outCursorHit) { + float *outCursorV, bool *outCursorHit, bool *outPlaneHit, + float *outPlaneDistance) { + *outPlaneHit = false; if (!xr_overlay_is_visible(overlay)) return false; @@ -283,9 +388,9 @@ static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand, // view's pixel grid and the on-quad hit logs use) are only meaningful // within the quad's current bounds, unlike planeHit. bool planeHit = false, hit = false; - float u = 0.0f, v = 0.0f, worldX = 0.0f, worldY = 0.0f; + float u = 0.0f, v = 0.0f, worldX = 0.0f, worldY = 0.0f, t = 0.0f; if (fabsf(fz) > 1e-5f) { - float t = (-distance - location->pose.position.z) / fz; + t = (-distance - location->pose.position.z) / fz; if (t > 0.0f) { planeHit = true; worldX = location->pose.position.x + t * fx; @@ -297,6 +402,8 @@ static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand, v = (1.0f - cy) * 0.5f; } } + *outPlaneHit = planeHit; + *outPlaneDistance = t; if (hit != *prevHit) { LOGI("XR: %s aim ray %s %s quad (u=%.2f v=%.2f)", kHandName[hand], @@ -353,6 +460,114 @@ static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand, return true; } +// Builds this hand's laser-beam quad - a thin, billboarded ribbon from the +// controller (location->pose.position) along the aim ray (fx,fy,fz, +// already unit length) to length_m meters out, oriented so it reads as a +// line from the viewer's eye (headLoc->pose.position) regardless of angle: +// one in-plane axis follows the ray direction, the other is the thin +// "width", and the quad's normal is billboarded toward the eye +// (orthogonalized against the ray axis, so the ribbon only rotates around +// its own long axis as the hand moves, never twists). Acquires/clears/ +// releases this hand's tiny beam swapchain and leaves g_beam_quad[hand] +// ready for xr_input_get_beam_layer() - doesn't set space/eyeVisibility/ +// layerFlags (see that function's doc comment in xr_input.h). +static void xr_input_build_beam(int hand, const XrSpaceLocation *location, float fx, float fy, + float fz, float length_m, const XrSpaceLocation *headLoc) { + float origin[3] = {location->pose.position.x, location->pose.position.y, + location->pose.position.z}; + float dirY[3] = {fx, fy, fz}; + float endpoint[3] = {origin[0] + length_m * fx, origin[1] + length_m * fy, + origin[2] + length_m * fz}; + float center[3] = {(origin[0] + endpoint[0]) * 0.5f, (origin[1] + endpoint[1]) * 0.5f, + (origin[2] + endpoint[2]) * 0.5f}; + float head[3] = {headLoc->pose.position.x, headLoc->pose.position.y, + headLoc->pose.position.z}; + + float toEyeRaw[3], toEye[3]; + vec3_sub(head, center, toEyeRaw); + if (!vec3_normalize(toEyeRaw, toEye)) { + // Head is (almost) exactly at the beam's midpoint - astronomically + // unlikely, but fall back to a fixed direction rather than divide + // by ~0. + toEye[0] = 0.0f; + toEye[1] = 0.0f; + toEye[2] = 1.0f; + } + + // Orthogonalize toEye against the ray axis to get the billboard + // normal - if the ray points almost straight at/away from the eye, + // that leaves ~nothing to normalize, so fall back to world-up then + // world-Z, each reprojected the same way. + float normalZ[3]; + float d = vec3_dot(toEye, dirY); + float proj[3] = {toEye[0] - dirY[0] * d, toEye[1] - dirY[1] * d, toEye[2] - dirY[2] * d}; + if (!vec3_normalize(proj, normalZ)) { + static const float kWorldUp[3] = {0.0f, 1.0f, 0.0f}; + d = vec3_dot(kWorldUp, dirY); + proj[0] = kWorldUp[0] - dirY[0] * d; + proj[1] = kWorldUp[1] - dirY[1] * d; + proj[2] = kWorldUp[2] - dirY[2] * d; + if (!vec3_normalize(proj, normalZ)) { + static const float kWorldZ[3] = {0.0f, 0.0f, 1.0f}; + d = vec3_dot(kWorldZ, dirY); + proj[0] = kWorldZ[0] - dirY[0] * d; + proj[1] = kWorldZ[1] - dirY[1] * d; + proj[2] = kWorldZ[2] - dirY[2] * d; + if (!vec3_normalize(proj, normalZ)) { + // dirY parallel to both world-up and world-Z is impossible + // for two non-parallel vectors - unreachable in practice, + // but keep the beam well-defined regardless. + normalZ[0] = 1.0f; + normalZ[1] = 0.0f; + normalZ[2] = 0.0f; + } + } + } + + float axisX[3]; + vec3_cross(dirY, normalZ, axisX); + if (!vec3_normalize(axisX, axisX)) { + axisX[0] = 1.0f; + axisX[1] = 0.0f; + axisX[2] = 0.0f; + } + float axisZ[3]; + vec3_cross(axisX, dirY, axisZ); // already unit length - axisX/dirY are orthonormal + + XrQuaternionf orientation; + mat3_to_quat(axisX, dirY, axisZ, &orientation); + + if (xr_swapchain_acquire(g_instance, &g_beam_swapchain[hand])) { + // Premultiplied alpha (matches OverlayPanel.compositeAndPublish()'s + // convention, and XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT's + // assumption - see xr_session.c) - kHandColor's RGB scaled by this + // beam's own alpha, not the opaque RGB itself. + const float alpha = 0.55f; + const float *c = kHandColor[hand]; + glClearColor(c[0] * alpha, c[1] * alpha, c[2] * alpha, alpha); + glClear(GL_COLOR_BUFFER_BIT); + xr_swapchain_release(g_instance, &g_beam_swapchain[hand]); + } + + XrCompositionLayerQuad *quad = &g_beam_quad[hand]; + quad->type = XR_TYPE_COMPOSITION_LAYER_QUAD; + quad->next = NULL; + quad->subImage.swapchain = g_beam_swapchain[hand].swapchain; + quad->subImage.imageRect.offset.x = 0; + quad->subImage.imageRect.offset.y = 0; + quad->subImage.imageRect.extent.width = BEAM_TEX_SIZE; + quad->subImage.imageRect.extent.height = BEAM_TEX_SIZE; + quad->subImage.imageArrayIndex = 0; + quad->pose.position.x = center[0]; + quad->pose.position.y = center[1]; + quad->pose.position.z = center[2]; + quad->pose.orientation = orientation; + quad->size.width = BEAM_THICKNESS_METERS; + quad->size.height = length_m; + + g_beam_quad_valid[hand] = true; +} + void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) { if (g_action_set == XR_NULL_HANDLE) return; @@ -375,6 +590,25 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) { g_keyboard_drag_hand = -1; } + // A hand only ever gets a laser-beam quad this frame if it actually + // claims the keyboard or menu overlay's ray below (see the + // xr_input_build_beam() calls) - reset here so a hand that doesn't + // claim one this frame doesn't keep showing last frame's beam. + g_beam_quad_valid[LEFT] = g_beam_quad_valid[RIGHT] = false; + + // The billboard math needs an approximate head position - only bother + // locating it on frames where a beam could possibly be drawn at all. + bool needBeams = draw && (menuVisible || keyboardVisible); + XrSpaceLocation headLoc = {XR_TYPE_SPACE_LOCATION}; + bool haveHead = false; + if (needBeams) { + const XrSpaceLocationFlags neededHead = + XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT; + haveHead = xr_check(xrLocateSpace(g_view_space, baseSpace, time, &headLoc), + "xrLocateSpace(view)") && + (headLoc.locationFlags & neededHead) == neededHead; + } + // 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 @@ -454,16 +688,33 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) { // Keyboard first (it's the more likely target while it's up), then // the menu launcher, then - only if neither is visible - the game // quad's own reticle below. A single ray only ever interacts with - // one target per hand per frame. + // one target per hand per frame. Whichever overlay claims the ray + // also gets a laser-beam quad built for it (if a head pose is + // available and the ray actually crosses that overlay's plane) - + // see xr_input_build_beam(). + bool keyboardPlaneHit = false; + float keyboardPlaneDistance = 0.0f; if (xr_input_try_overlay(keyboardOverlay, true, hand, &location, fx, fy, fz, selectDownEdge, selectUpEdge, &g_keyboard_touch_active[hand], &g_keyboard_prev_hit[hand], "keyboard", &keyboardCursorU, - &keyboardCursorV, &keyboardCursorHit)) + &keyboardCursorV, &keyboardCursorHit, &keyboardPlaneHit, + &keyboardPlaneDistance)) { + if (haveHead && keyboardPlaneHit) + xr_input_build_beam(hand, &location, fx, fy, fz, + fminf(keyboardPlaneDistance, MAX_BEAM_LENGTH_METERS), &headLoc); continue; + } + bool menuPlaneHit = false; + float menuPlaneDistance = 0.0f; if (xr_input_try_overlay(menuOverlay, false, hand, &location, fx, fy, fz, selectDownEdge, selectUpEdge, &g_menu_touch_active[hand], &g_menu_prev_hit[hand], - "menu", &menuCursorU, &menuCursorV, &menuCursorHit)) + "menu", &menuCursorU, &menuCursorV, &menuCursorHit, &menuPlaneHit, + &menuPlaneDistance)) { + if (haveHead && menuPlaneHit) + xr_input_build_beam(hand, &location, fx, fy, fz, + fminf(menuPlaneDistance, MAX_BEAM_LENGTH_METERS), &headLoc); continue; + } float distance, halfWidth, halfHeight; xr_get_game_quad_extent(&distance, &halfWidth, &halfHeight); @@ -508,12 +759,23 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) { keyboardCursorHit); } +bool xr_input_get_beam_layer(int hand, XrCompositionLayerQuad *out_quad) { + if (hand < 0 || hand > 1 || !g_beam_quad_valid[hand]) + return false; + *out_quad = g_beam_quad[hand]; + return true; +} + void xr_input_shutdown(void) { for (int hand = 0; hand < 2; hand++) { if (g_aim_space[hand] != XR_NULL_HANDLE) xrDestroySpace(g_aim_space[hand]); g_aim_space[hand] = XR_NULL_HANDLE; + xr_swapchain_destroy(&g_beam_swapchain[hand]); } + if (g_view_space != XR_NULL_HANDLE) + xrDestroySpace(g_view_space); + g_view_space = XR_NULL_HANDLE; if (g_action_set != XR_NULL_HANDLE) xrDestroyActionSet(g_action_set); g_action_set = XR_NULL_HANDLE; @@ -533,6 +795,7 @@ void xr_input_shutdown(void) { memset(g_menu_touch_active, 0, sizeof(g_menu_touch_active)); memset(g_keyboard_prev_hit, 0, sizeof(g_keyboard_prev_hit)); memset(g_keyboard_touch_active, 0, sizeof(g_keyboard_touch_active)); + g_beam_quad_valid[LEFT] = g_beam_quad_valid[RIGHT] = false; g_prev_menu = false; g_keyboard_dragging = false; g_keyboard_drag_hand = -1; diff --git a/android/app/src/main/cpp/xr_input.h b/android/app/src/main/cpp/xr_input.h index 24c57e3..9b30c7a 100644 --- a/android/app/src/main/cpp/xr_input.h +++ b/android/app/src/main/cpp/xr_input.h @@ -5,13 +5,17 @@ // visible, this tests against the game quad xr_session.c submits and draws // a small reticle where each hand's aim ray crosses it; while either // overlay is visible, it tests against that overlay's quad instead (the -// keyboard is tried first) and forwards trigger edges as synthetic touches -// (no reticle - each overlay's own content comes from its Java view's -// rendered Bitmap). +// keyboard is tried first), forwards trigger edges as synthetic touches, +// and - since neither overlay's own on-quad cursor gives any feedback +// while the ray is short of actually landing on it - builds a thin, +// billboarded laser-beam quad per hand from the controller to wherever +// the ray currently crosses that overlay's plane (see +// xr_input_get_beam_layer()). #ifndef QUESTSHOCK_XR_INPUT_H #define QUESTSHOCK_XR_INPUT_H #include +#include #define XR_USE_PLATFORM_ANDROID 1 #define XR_USE_GRAPHICS_API_OPENGL_ES 1 @@ -24,10 +28,12 @@ extern "C" { // Call once, right after the session is created (see xr_session.c's // xr_create_instance_and_session()) - creates the action set/actions, // suggests Touch controller bindings, creates the per-hand aim action -// spaces, and attaches the set to the session. Returns false (logged, -// non-fatal - the caller keeps rendering without input) if any of that -// fails. -bool xr_input_init(XrInstance instance, XrSession session); +// spaces and a view-space reference (for the laser-beam billboard math), +// creates the two per-hand beam swapchains (swapchain_format - the same +// format shared by the game swapchain and both overlays), and attaches +// the action set to the session. Returns false (logged, non-fatal - the +// caller keeps rendering without input) if any of that fails. +bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_format); // Call once per frame from xr_frame_end(), before releasing the acquired // swapchain image - syncs this frame's action states (always, so edge @@ -40,6 +46,16 @@ bool xr_input_init(XrInstance instance, XrSession session); // xr_frame_begin() used to predict this frame. void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw); +// Call once per hand (hand: 0=left, 1=right) after xr_input_sync_and_draw() +// in the same frame - if that hand's ray claimed the keyboard or menu +// overlay this frame (see the file comment above), fills *out_quad's +// subImage/pose/size for its laser-beam ribbon and returns true. +// space/eyeVisibility/layerFlags are left for the caller to set, same +// convention as xr_overlay_render_and_build_layer(). Returns false +// (out_quad untouched) if no beam should be shown for that hand this +// frame. +bool xr_input_get_beam_layer(int hand, XrCompositionLayerQuad *out_quad); + void xr_input_shutdown(void); #ifdef __cplusplus diff --git a/android/app/src/main/cpp/xr_session.c b/android/app/src/main/cpp/xr_session.c index 10a1dc6..2de8aa4 100644 --- a/android/app/src/main/cpp/xr_session.c +++ b/android/app/src/main/cpp/xr_session.c @@ -213,19 +213,16 @@ static bool xr_create_instance_and_session(int game_width, int game_height) { "xrCreateReferenceSpace")) return false; - // Non-fatal if it fails (e.g. no controllers bound yet) - rendering - // keeps working either way, just without the laser pointer. - xr_input_init(g_instance, g_session); - // Prefer a plain linear 8-bit format over GL_SRGB8_ALPHA8: the source // SDL surface pixels are already sRGB-encoded (as ordinary 8-bit image // data conventionally is) and get uploaded/sampled as plain linear // 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. Every swapchain below (game quad, menu/keyboard overlays) - // shares this one choice - the compositor's supported format set - // doesn't depend on swapchain size. + // data. Every swapchain below (game quad, menu/keyboard overlays, and + // - via xr_input_init() - the per-hand laser-beam 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); @@ -240,6 +237,10 @@ static bool xr_create_instance_and_session(int game_width, int game_height) { } free(formats); + // Non-fatal if it fails (e.g. no controllers bound yet) - rendering + // keeps working either way, just without the laser pointer/beams. + xr_input_init(g_instance, g_session, chosenFormat); + if (!xr_swapchain_create(g_instance, g_session, chosenFormat, game_width, game_height, &g_game_swapchain)) { LOGE("XR: game swapchain setup failed"); @@ -367,12 +368,14 @@ void xr_frame_end(void) { 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, + // Up to 5 layers: the game quad (if a frame was actually rendered), + // the menu launcher and keyboard overlays while visible - 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. + // around here, just each overlay's own blit - and, per hand, a + // laser-beam quad while that hand's ray is aimed at one of those two + // overlays (see xr_input_get_beam_layer()). XrCompositionLayerQuad gameQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD}; gameQuad.space = g_local_space; gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; @@ -385,7 +388,7 @@ void xr_frame_end(void) { gameQuad.size.height = QUAD_WIDTH_METERS * (float)g_game_swapchain.height / (float)g_game_swapchain.width; - const XrCompositionLayerBaseHeader *layers[3]; + const XrCompositionLayerBaseHeader *layers[5]; uint32_t layerCount = 0; if (g_have_acquired_game_image) layers[layerCount++] = (XrCompositionLayerBaseHeader *)&gameQuad; @@ -413,6 +416,20 @@ void xr_frame_end(void) { layers[layerCount++] = (XrCompositionLayerBaseHeader *)&keyboardQuad; } + // Submitted last (frontmost) so a beam doesn't z-fight against the + // panel surface it's aimed at/terminates on - see + // xr_input_get_beam_layer(). Translucent for the same reason the + // keyboard is (see comment above). + XrCompositionLayerQuad beamQuad[2]; + for (int hand = 0; hand < 2; hand++) { + if (xr_input_get_beam_layer(hand, &beamQuad[hand])) { + beamQuad[hand].space = g_local_space; + beamQuad[hand].eyeVisibility = XR_EYE_VISIBILITY_BOTH; + beamQuad[hand].layerFlags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT; + layers[layerCount++] = (XrCompositionLayerBaseHeader *)&beamQuad[hand]; + } + } + XrFrameEndInfo endInfo = {XR_TYPE_FRAME_END_INFO}; endInfo.displayTime = g_predicted_display_time; endInfo.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE;