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
+187 -163
View File
@@ -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(&centerX, &centerY, &distance, &halfWidth, &halfHeight);
menuQuad.space = g_local_space;
menuQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
menuQuad.subImage.swapchain = g_swapchain;
menuQuad.subImage.imageRect.offset.x = g_game_width;
menuQuad.subImage.imageRect.offset.y = 0;
menuQuad.subImage.imageRect.extent.width = g_menu_width;
menuQuad.subImage.imageRect.extent.height = g_menu_height;
menuQuad.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;
}