From 050e8d4e14c2d252eba62f376fdb698676de5941 Mon Sep 17 00:00:00 2001 From: Emanuele Disco Date: Mon, 27 Jul 2026 11:06:51 +0900 Subject: [PATCH] fix: use per-eye FOV instead of averaged symmetric FOV for OpenXR compositor/projection (Quest 3 black bars) VR_GetVRProjection() and TBXR_submitFrame() averaged both eyes' angleLeft/angleRight into one shared symmetric FOV and applied it to both eyes, mixing each eye's outward (temporal) angle with the other eye's inward (nasal) angle. This was a no-op on headsets with near-symmetric per-eye FOV (Quest 2), but on Quest 3's more asymmetric pancake-lens FOV it shrank the usable FOV on both outer edges, causing visible black bars (#20). Fixed by using each eye's own actual FOV (gAppState.Projections[eye].fov) for both the render projection matrix and the FOV declared to the compositor, matching the existing pico code path. --- Projects/Android/jni/QzDoom/QzDoom_OpenXR.cpp | 19 ++++++++++--------- Projects/Android/jni/QzDoom/TBXR_Common.cpp | 15 +++++++-------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Projects/Android/jni/QzDoom/QzDoom_OpenXR.cpp b/Projects/Android/jni/QzDoom/QzDoom_OpenXR.cpp index e79f7747..4112cb33 100644 --- a/Projects/Android/jni/QzDoom/QzDoom_OpenXR.cpp +++ b/Projects/Android/jni/QzDoom/QzDoom_OpenXR.cpp @@ -323,17 +323,18 @@ bool VR_GetVRProjection(int eye, float zNear, float zFar, float* projection) if (strstr(gAppState.OpenXRHMD, "meta") != NULL) { - XrFovf fov = {}; - for (int eye = 0; eye < ovrMaxNumEyes; eye++) - { - fov.angleLeft += gAppState.Projections[eye].fov.angleLeft / 2.0f; - fov.angleRight += gAppState.Projections[eye].fov.angleRight / 2.0f; - fov.angleUp += gAppState.Projections[eye].fov.angleUp / 2.0f; - fov.angleDown += gAppState.Projections[eye].fov.angleDown / 2.0f; - } + // Use this eye's own runtime-reported FOV directly (same as the pico + // branch above) instead of averaging both eyes' angleLeft/angleRight + // into one shared symmetric FOV. That average mixed each eye's + // temporal (outward) angle with the other eye's nasal (inward) angle, + // which only happens to cancel out on headsets whose per-eye FOV is + // already close to left/right-symmetric (e.g. Quest 2). On headsets + // with more pronounced per-eye horizontal asymmetry (e.g. Quest 3's + // pancake lenses), it silently shrank the usable FOV on both outer + // edges, producing visible black bars there. XrMatrix4x4f_CreateProjectionFov( &(gAppState.ProjectionMatrices[eye]), GRAPHICS_OPENGL_ES, - fov, zNear, zFar); + gAppState.Projections[eye].fov, zNear, zFar); } memcpy(projection, gAppState.ProjectionMatrices[eye].m, 16 * sizeof(float)); diff --git a/Projects/Android/jni/QzDoom/TBXR_Common.cpp b/Projects/Android/jni/QzDoom/TBXR_Common.cpp index ba22bfeb..0d04a896 100644 --- a/Projects/Android/jni/QzDoom/TBXR_Common.cpp +++ b/Projects/Android/jni/QzDoom/TBXR_Common.cpp @@ -2099,20 +2099,19 @@ void TBXR_submitFrame() TBXR_updateProjections(); - XrFovf fov = {}; XrPosef viewTransform[2]; for (int eye = 0; eye < ovrMaxNumEyes; eye++) { XrPosef xfHeadFromEye = gAppState.Projections[eye].pose; XrPosef xfStageFromEye = XrPosef_Multiply(gAppState.xfStageFromHead, xfHeadFromEye); viewTransform[eye] = XrPosef_Inverse(xfStageFromEye); - fov.angleLeft += gAppState.Projections[eye].fov.angleLeft / 2.0f; - fov.angleRight += gAppState.Projections[eye].fov.angleRight / 2.0f; - fov.angleUp += gAppState.Projections[eye].fov.angleUp / 2.0f; - fov.angleDown += gAppState.Projections[eye].fov.angleDown / 2.0f; } - fov_y = (fabs(fov.angleUp) + fabs(fov.angleDown)) * 180.0f / M_PI; + // fov_y (QzDoom_GetFOV(), HUD/menu FOV readback only) doesn't need to be + // eye-specific; eye 0's actual vertical extent is a fine approximation. + // It is no longer used to build the per-eye compositor FOV below. + const XrFovf &fov0 = gAppState.Projections[0].fov; + fov_y = (fabs(fov0.angleUp) + fabs(fov0.angleDown)) * 180.0f / M_PI; gAppState.LayerCount = 0; @@ -2134,7 +2133,7 @@ void TBXR_submitFrame() memset(&projection_layer_elements[eye], 0, sizeof(XrCompositionLayerProjectionView)); projection_layer_elements[eye].type = XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW; projection_layer_elements[eye].pose = gAppState.xfStageFromHead; - projection_layer_elements[eye].fov = fov; + projection_layer_elements[eye].fov = gAppState.Projections[eye].fov; memset(&projection_layer_elements[eye].subImage, 0, sizeof(XrSwapchainSubImage)); projection_layer_elements[eye].subImage.swapchain = frameBuffer->ColorSwapChain.Handle; @@ -2196,4 +2195,4 @@ void TBXR_submitFrame() OXR(xrEndFrame(gAppState.Session, &endFrameInfo)); gAppState.FrameSetupRefCount--; -} +} \ No newline at end of file