diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..98a973e68 Binary files /dev/null and b/.DS_Store differ diff --git a/README.md b/README.md index 32d03a89b..8687fd183 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Mod compatibility is important to us. If you find any issues, don't be afraid to - [X] Iris support - [X] In game VR switching - [X] API, details [here](https://github.com/Vivecraft/VivecraftMod/wiki/Mod-API) -- [ ] OpenXR support +- [X] OpenXR support [Vivecraft Discord server](https://discord.gg/2x3QCk8qa9)\ [Development Discord server](https://discord.gg/jYyyv7zhSW) diff --git a/build.gradle b/build.gradle index 1a86465d8..5bbb08a53 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,10 @@ subprojects { implementation("org.lwjgl:lwjgl-openvr:${rootProject.lwjgl_version}:natives-macos") { transitive = false } implementation("org.lwjgl:lwjgl-openvr:${rootProject.lwjgl_version}:natives-windows") { transitive = false } + implementation("org.lwjgl:lwjgl-openxr:${rootProject.lwjgl_version}") { transitive = false } + implementation("org.lwjgl:lwjgl-openxr:${rootProject.lwjgl_version}:natives-linux") { transitive = false } + implementation("org.lwjgl:lwjgl-openxr:${rootProject.lwjgl_version}:natives-windows") { transitive = false } + // for OSC tracker support implementation("com.illposed.osc:javaosc-core:0.9") diff --git a/common/src/main/java/org/vivecraft/client_vr/VRState.java b/common/src/main/java/org/vivecraft/client_vr/VRState.java index bc5d6cb82..0cd96fbd0 100644 --- a/common/src/main/java/org/vivecraft/client_vr/VRState.java +++ b/common/src/main/java/org/vivecraft/client_vr/VRState.java @@ -13,6 +13,7 @@ import org.vivecraft.client_vr.gameplay.VRPlayer; import org.vivecraft.client_vr.menuworlds.MenuWorldRenderer; import org.vivecraft.client_vr.provider.nullvr.NullVR; +import org.vivecraft.client_vr.provider.openxr_lwjgl.MCOpenXR; import org.vivecraft.client_vr.provider.openvr_lwjgl.MCOpenVR; import org.vivecraft.client_vr.render.RenderConfigException; import org.vivecraft.client_vr.render.VRShaders; @@ -64,9 +65,20 @@ public static void initializeVR() { } ClientDataHolderVR dh = ClientDataHolderVR.getInstance(); + VRSettings.LOGGER.info("Vivecraft: VR Provider setting = {}", dh.vrSettings.stereoProviderPluginID); + + // Force OpenXR for all VR modes - OpenVR/SteamVR is no longer used if (dh.vrSettings.stereoProviderPluginID == VRSettings.VRProvider.OPENVR) { - dh.vr = new MCOpenVR(Minecraft.getInstance(), dh); + VRSettings.LOGGER.info("Vivecraft: OpenVR setting detected, overriding to OpenXR (SteamVR bypass)"); + dh.vrSettings.stereoProviderPluginID = VRSettings.VRProvider.OPENXR; + dh.vrSettings.saveOptions(); + } + + if (dh.vrSettings.stereoProviderPluginID == VRSettings.VRProvider.OPENXR) { + VRSettings.LOGGER.info("Vivecraft: Using OpenXR provider (bypassing SteamVR)"); + dh.vr = new MCOpenXR(Minecraft.getInstance(), dh); } else { + VRSettings.LOGGER.info("Vivecraft: Using NullVR provider"); dh.vr = new NullVR(Minecraft.getInstance(), dh); } if (!dh.vr.init()) { diff --git a/common/src/main/java/org/vivecraft/client_vr/provider/DeviceSource.java b/common/src/main/java/org/vivecraft/client_vr/provider/DeviceSource.java index 0d6f0e728..ce0184010 100644 --- a/common/src/main/java/org/vivecraft/client_vr/provider/DeviceSource.java +++ b/common/src/main/java/org/vivecraft/client_vr/provider/DeviceSource.java @@ -56,6 +56,7 @@ public boolean equals(Object o) { public enum Source { NULL, OPENVR, + OPENXR, OSC } } diff --git a/common/src/main/java/org/vivecraft/client_vr/provider/MCVR.java b/common/src/main/java/org/vivecraft/client_vr/provider/MCVR.java index a664ed538..2087e10b5 100644 --- a/common/src/main/java/org/vivecraft/client_vr/provider/MCVR.java +++ b/common/src/main/java/org/vivecraft/client_vr/provider/MCVR.java @@ -1536,4 +1536,10 @@ public boolean capFPS() { * @return the name of the VR runtime */ public abstract String getRuntimeName(); + + /** + * @param inputValueHandle the origin handle from the VR runtime + * @return which controller the origin belongs to, or {@code null} if unknown + */ + public abstract ControllerType getOriginControllerType(long inputValueHandle); } diff --git a/common/src/main/java/org/vivecraft/client_vr/provider/nullvr/NullVR.java b/common/src/main/java/org/vivecraft/client_vr/provider/nullvr/NullVR.java index 00a7480d2..c1ba4627a 100644 --- a/common/src/main/java/org/vivecraft/client_vr/provider/nullvr/NullVR.java +++ b/common/src/main/java/org/vivecraft/client_vr/provider/nullvr/NullVR.java @@ -289,6 +289,11 @@ public String getRuntimeName() { return "Null"; } + @Override + public ControllerType getOriginControllerType(long inputValueHandle) { + return null; + } + @Override public boolean handleKeyboardInputs(int key, int scanCode, int action, int modifiers) { boolean triggered = false; diff --git a/common/src/main/java/org/vivecraft/client_vr/provider/openvr_lwjgl/MCOpenVR.java b/common/src/main/java/org/vivecraft/client_vr/provider/openvr_lwjgl/MCOpenVR.java index e345c5f74..c4efcd3d2 100644 --- a/common/src/main/java/org/vivecraft/client_vr/provider/openvr_lwjgl/MCOpenVR.java +++ b/common/src/main/java/org/vivecraft/client_vr/provider/openvr_lwjgl/MCOpenVR.java @@ -1701,7 +1701,8 @@ private long getInputSourceHandle(String path) { * @param inputValueHandle inputHandle to check * @return what controller the inputHandle is on, {@code null} if the handle or device is invalid */ - protected ControllerType getOriginControllerType(long inputValueHandle) { + @Override + public ControllerType getOriginControllerType(long inputValueHandle) { if (inputValueHandle != k_ulInvalidInputValueHandle) { this.readOriginInfo(inputValueHandle); diff --git a/common/src/main/java/org/vivecraft/client_vr/provider/openvr_lwjgl/VRInputAction.java b/common/src/main/java/org/vivecraft/client_vr/provider/openvr_lwjgl/VRInputAction.java index 6b2d86c6e..04f9f6220 100644 --- a/common/src/main/java/org/vivecraft/client_vr/provider/openvr_lwjgl/VRInputAction.java +++ b/common/src/main/java/org/vivecraft/client_vr/provider/openvr_lwjgl/VRInputAction.java @@ -217,15 +217,15 @@ public VRInputAction setPriority(int priority) { */ public boolean isEnabled() { if (!this.isEnabledRaw(this.currentHand)) return false; - if (MCOpenVR.get() == null) return false; + if (MCVR.get() == null) return false; long lastOrigin = this.getLastOrigin(); - ControllerType hand = MCOpenVR.get().getOriginControllerType(lastOrigin); + ControllerType hand = MCVR.get().getOriginControllerType(lastOrigin); if (hand == null && this.isHanded()) return false; // iterate over all actions, and check if another action has a higher priority - for (VRInputAction action : MCOpenVR.get().getInputActions()) { + for (VRInputAction action : MCVR.get().getInputActions()) { if (action != this && action.isEnabledRaw(hand) && action.isActive() && action.getPriority() > this.getPriority() && MCVR.get().getOrigins(action).contains(lastOrigin)) { diff --git a/common/src/main/java/org/vivecraft/client_vr/provider/openxr_lwjgl/D3D11InteropHelper.java b/common/src/main/java/org/vivecraft/client_vr/provider/openxr_lwjgl/D3D11InteropHelper.java new file mode 100644 index 000000000..56667dcea --- /dev/null +++ b/common/src/main/java/org/vivecraft/client_vr/provider/openxr_lwjgl/D3D11InteropHelper.java @@ -0,0 +1,618 @@ +package org.vivecraft.client_vr.provider.openxr_lwjgl; + +import org.lwjgl.PointerBuffer; +import org.lwjgl.opengl.WGLNVDXInterop; +import org.lwjgl.system.JNI; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.system.MemoryUtil; +import org.lwjgl.system.windows.WinBase; +import org.vivecraft.client_vr.settings.VRSettings; + +import java.nio.ByteBuffer; +import java.nio.IntBuffer; +import java.nio.LongBuffer; + +import static org.lwjgl.opengl.GL11C.GL_TEXTURE_2D; +import static org.lwjgl.system.MemoryStack.stackPush; + +/** + * Helper class for D3D11 interop with OpenGL via WGL_NV_DX_interop2. + * + * The Oculus OpenXR runtime doesn't properly support XR_KHR_opengl_enable on desktop + * (returns XR_ERROR_GRAPHICS_DEVICE_INVALID for all OpenGL bindings). As a workaround, + * we create the OpenXR session using XR_KHR_D3D11_enable, then use WGL_NV_DX_interop2 + * to share the D3D11 swapchain textures with OpenGL for rendering. + * + * Flow: + * 1. D3D11CreateDevice() -> ID3D11Device* + * 2. xrCreateSession with XrGraphicsBindingD3D11KHR + * 3. xrCreateSwapchain -> D3D11 texture2D swapchain images + * 4. wglDXOpenDeviceNV(d3d11Device) -> interop handle + * 5. For each swapchain image: wglDXRegisterObjectNV -> GL texture + * 6. Per-frame: lock, render to GL texture, unlock, submit to OpenXR + */ +public class D3D11InteropHelper { + + // D3D11 constants + private static final int D3D_DRIVER_TYPE_UNKNOWN = 0; + private static final int D3D_DRIVER_TYPE_HARDWARE = 1; + private static final int D3D_FEATURE_LEVEL_11_0 = 0xb000; + private static final int D3D_FEATURE_LEVEL_11_1 = 0xb100; + private static final int D3D11_SDK_VERSION = 7; + + // D3D11_BIND_FLAG + private static final int D3D11_BIND_SHADER_RESOURCE = 0x8; + private static final int D3D11_BIND_RENDER_TARGET = 0x20; + + // D3D11_USAGE + private static final int D3D11_USAGE_DEFAULT = 0; + + // D3D11_RESOURCE_MISC_FLAG + private static final int D3D11_RESOURCE_MISC_SHARED = 0x2; + private static final int D3D11_RESOURCE_MISC_SHARED_NTHANDLE = 0x800; + + // ID3D11Device vtable indices (IUnknown: 0-2, then ID3D11Device methods) + // ID3D11Device::CreateTexture2D is vtable index 5 + private static final int ID3D11DEVICE_CREATE_TEXTURE2D_VTABLE_INDEX = 5; + + // ID3D11DeviceContext vtable indices + // ID3D11DeviceContext::CopyResource is vtable index 47 + private static final int ID3D11DEVICECONTEXT_COPY_RESOURCE_VTABLE_INDEX = 47; + + // Extension number 28: base = 1000000000 + (28-1)*1000 = 1000027000 + // XR_TYPE_GRAPHICS_BINDING_D3D11_KHR = 1000027000 + static final int XR_TYPE_GRAPHICS_BINDING_D3D11_KHR = 1000027000; + // XR_TYPE_SWAPCHAIN_IMAGE_D3D11_KHR = 1000027001 + static final int XR_TYPE_SWAPCHAIN_IMAGE_D3D11_KHR = 1000027001; + // XR_TYPE_GRAPHICS_REQUIREMENTS_D3D11_KHR = 1000027002 + static final int XR_TYPE_GRAPHICS_REQUIREMENTS_D3D11_KHR = 1000027002; + + // D3D11 COM method vtable offsets (IUnknown base: QueryInterface=0, AddRef=1, Release=2) + private static final int IUNKNOWN_RELEASE_VTABLE_INDEX = 2; + + // Handles + private long d3d11Device; // ID3D11Device* + private long d3d11Context; // ID3D11DeviceContext* + private long dxgiAdapter; // IDXGIAdapter* (from the runtime's LUID) + private long dxInteropHandle; // WGL interop device handle from wglDXOpenDeviceNV + private long d3d11DllHandle; // d3d11.dll module handle + private long dxgiDllHandle; // dxgi.dll module handle + + // Function pointers + private long d3d11CreateDevicePtr; + private long createDXGIFactoryPtr; + + /** + * Creates a D3D11 device on the adapter specified by the given LUID. + * The LUID comes from xrGetD3D11GraphicsRequirementsKHR. + * + * @param adapterLuidLow low 32 bits of the adapter LUID + * @param adapterLuidHigh high 32 bits of the adapter LUID + */ + public void createD3D11Device(int adapterLuidLow, int adapterLuidHigh) throws Exception { + VRSettings.LOGGER.info("Vivecraft: Creating D3D11 device for adapter LUID: 0x{}{}", + String.format("%08X", adapterLuidHigh), String.format("%08X", adapterLuidLow)); + + // Load d3d11.dll + this.d3d11DllHandle = WinBase.LoadLibrary("d3d11"); + if (this.d3d11DllHandle == 0) { + throw new Exception("Failed to load d3d11.dll"); + } + + this.d3d11CreateDevicePtr = WinBase.GetProcAddress(this.d3d11DllHandle, "D3D11CreateDevice"); + if (this.d3d11CreateDevicePtr == 0) { + throw new Exception("Failed to find D3D11CreateDevice in d3d11.dll"); + } + + // Load dxgi.dll for adapter enumeration + this.dxgiDllHandle = WinBase.LoadLibrary("dxgi"); + if (this.dxgiDllHandle == 0) { + throw new Exception("Failed to load dxgi.dll"); + } + + this.createDXGIFactoryPtr = WinBase.GetProcAddress(this.dxgiDllHandle, "CreateDXGIFactory1"); + if (this.createDXGIFactoryPtr == 0) { + throw new Exception("Failed to find CreateDXGIFactory1 in dxgi.dll"); + } + + try (MemoryStack stack = stackPush()) { + // Find the DXGI adapter matching the requested LUID + long adapter = findAdapterByLuid(stack, adapterLuidLow, adapterLuidHigh); + + // D3D11CreateDevice parameters: + // HRESULT D3D11CreateDevice( + // IDXGIAdapter *pAdapter, // adapter (or null) + // D3D_DRIVER_TYPE DriverType, // UNKNOWN when adapter specified, HARDWARE when null + // HMODULE Software, // null + // UINT Flags, // 0 + // D3D_FEATURE_LEVEL *pFeatureLevels,// feature level array + // UINT FeatureLevels, // count + // UINT SDKVersion, // D3D11_SDK_VERSION = 7 + // ID3D11Device **ppDevice, // out + // D3D_FEATURE_LEVEL *pFeatureLevel, // out (can be null) + // ID3D11DeviceContext **ppImmediateContext // out + // ) + IntBuffer featureLevels = stack.ints(D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0); + PointerBuffer ppDevice = stack.callocPointer(1); + IntBuffer pFeatureLevel = stack.callocInt(1); + PointerBuffer ppContext = stack.callocPointer(1); + + int driverType = adapter != 0 ? D3D_DRIVER_TYPE_UNKNOWN : D3D_DRIVER_TYPE_HARDWARE; + + // HRESULT D3D11CreateDevice(ptr, enum, ptr, uint, ptr, uint, uint, ptr, ptr, ptr) + // We widen enum/uint params to long to match LWJGL JNI's callPPPPPPPPPI overload. + // On x64 Windows this is safe as all params pass in 64-bit registers/stack slots. + int hr = JNI.callPPPPPPPPPI( + adapter, // pAdapter (P) + (long) driverType, // DriverType (P, widened) + 0L, // Software (P, null) + 0L, // Flags (P, widened) + MemoryUtil.memAddress(featureLevels), // pFeatureLevels (P) + (long) featureLevels.remaining(), // FeatureLevels count (P, widened) + D3D11_SDK_VERSION, // SDKVersion (int) + MemoryUtil.memAddress(ppDevice), // ppDevice (P) + MemoryUtil.memAddress(pFeatureLevel), // pFeatureLevel (P) + MemoryUtil.memAddress(ppContext), // ppImmediateContext (P) + this.d3d11CreateDevicePtr + ); + + if (hr < 0) { + throw new Exception("D3D11CreateDevice failed: HRESULT 0x" + Integer.toHexString(hr)); + } + + this.d3d11Device = ppDevice.get(0); + this.d3d11Context = ppContext.get(0); + this.dxgiAdapter = adapter; + + int featureLevel = pFeatureLevel.get(0); + VRSettings.LOGGER.info("Vivecraft: D3D11 device created. Feature level: 0x{}, device ptr: 0x{}", + Integer.toHexString(featureLevel), Long.toHexString(this.d3d11Device)); + } + } + + /** + * Creates a D3D11 device using the default adapter (no LUID matching). + * Fallback when we can't get the LUID from the runtime. + */ + public void createD3D11DeviceDefault() throws Exception { + VRSettings.LOGGER.info("Vivecraft: Creating D3D11 device with default adapter"); + + // Load d3d11.dll + this.d3d11DllHandle = WinBase.LoadLibrary("d3d11"); + if (this.d3d11DllHandle == 0) { + throw new Exception("Failed to load d3d11.dll"); + } + + this.d3d11CreateDevicePtr = WinBase.GetProcAddress(this.d3d11DllHandle, "D3D11CreateDevice"); + if (this.d3d11CreateDevicePtr == 0) { + throw new Exception("Failed to find D3D11CreateDevice in d3d11.dll"); + } + + try (MemoryStack stack = stackPush()) { + IntBuffer featureLevels = stack.ints(D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0); + PointerBuffer ppDevice = stack.callocPointer(1); + IntBuffer pFeatureLevel = stack.callocInt(1); + PointerBuffer ppContext = stack.callocPointer(1); + + int hr = JNI.callPPPPPPPPPI( + 0L, // pAdapter (P, null = default) + (long) D3D_DRIVER_TYPE_HARDWARE, // DriverType (P, widened) + 0L, // Software (P) + 0L, // Flags (P, widened) + MemoryUtil.memAddress(featureLevels), // pFeatureLevels (P) + (long) featureLevels.remaining(), // FeatureLevels count (P, widened) + D3D11_SDK_VERSION, // SDKVersion (int) + MemoryUtil.memAddress(ppDevice), // ppDevice (P) + MemoryUtil.memAddress(pFeatureLevel), // pFeatureLevel (P) + MemoryUtil.memAddress(ppContext), // ppImmediateContext (P) + this.d3d11CreateDevicePtr + ); + + if (hr < 0) { + throw new Exception("D3D11CreateDevice failed: HRESULT 0x" + Integer.toHexString(hr)); + } + + this.d3d11Device = ppDevice.get(0); + this.d3d11Context = ppContext.get(0); + + int featureLevel = pFeatureLevel.get(0); + VRSettings.LOGGER.info("Vivecraft: D3D11 device created (default adapter). Feature level: 0x{}, device ptr: 0x{}", + Integer.toHexString(featureLevel), Long.toHexString(this.d3d11Device)); + } + } + + /** + * Finds a DXGI adapter matching the given LUID. + */ + private long findAdapterByLuid(MemoryStack stack, int luidLow, int luidHigh) throws Exception { + if (this.createDXGIFactoryPtr == 0) { + VRSettings.LOGGER.warn("Vivecraft: CreateDXGIFactory1 not available, using null adapter"); + return 0; + } + + // IID_IDXGIFactory1 = {770aae78-f26f-4dba-a829-253c83d1b387} + ByteBuffer iidFactory = stack.calloc(16); + iidFactory.putInt(0, 0x770aae78); + iidFactory.putShort(4, (short) 0xf26f); + iidFactory.putShort(6, (short) 0x4dba); + iidFactory.put(8, (byte) 0xa8); + iidFactory.put(9, (byte) 0x29); + iidFactory.put(10, (byte) 0x25); + iidFactory.put(11, (byte) 0x3c); + iidFactory.put(12, (byte) 0x83); + iidFactory.put(13, (byte) 0xd1); + iidFactory.put(14, (byte) 0xb3); + iidFactory.put(15, (byte) 0x87); + + PointerBuffer ppFactory = stack.callocPointer(1); + + // HRESULT CreateDXGIFactory1(REFIID riid, void **ppFactory) + int hr = JNI.callPPI( + MemoryUtil.memAddress(iidFactory), + MemoryUtil.memAddress(ppFactory), + this.createDXGIFactoryPtr + ); + + if (hr < 0) { + VRSettings.LOGGER.warn("Vivecraft: CreateDXGIFactory1 failed: HRESULT 0x{}", Integer.toHexString(hr)); + return 0; + } + + long factory = ppFactory.get(0); + if (factory == 0) return 0; + + try { + // Enumerate adapters: IDXGIFactory1::EnumAdapters1 is vtable index 12 + // (IUnknown: 3 + IDXGIObject: 4 + IDXGIFactory: 4 + IDXGIFactory1: 1 more = index 12) + // Actually: IUnknown(3) + IDXGIObject(2) + IDXGIFactory(5) + IDXGIFactory1(1) + // IDXGIFactory1::EnumAdapters1 is at vtable index 12 + long vtable = MemoryUtil.memGetAddress(factory); + + // IDXGIFactory::EnumAdapters is at index 7, IDXGIFactory1::EnumAdapters1 is at index 12 + long enumAdapters1Ptr = MemoryUtil.memGetAddress(vtable + 12L * Long.BYTES); + + for (int i = 0; i < 16; i++) { + PointerBuffer ppAdapter = stack.callocPointer(1); + // HRESULT EnumAdapters1(UINT Adapter, IDXGIAdapter1 **ppAdapter) + hr = JNI.callPPI(factory, i, MemoryUtil.memAddress(ppAdapter), enumAdapters1Ptr); + if (hr < 0) break; // DXGI_ERROR_NOT_FOUND + + long adapter = ppAdapter.get(0); + if (adapter == 0) continue; + + // Get adapter desc: IDXGIAdapter1::GetDesc1 is at vtable index 10 + // IUnknown(3) + IDXGIObject(2) + IDXGIAdapter(3) + IDXGIAdapter1(2) + // GetDesc1 is index 10 + long adapterVtable = MemoryUtil.memGetAddress(adapter); + long getDesc1Ptr = MemoryUtil.memGetAddress(adapterVtable + 10L * Long.BYTES); + + // DXGI_ADAPTER_DESC1 is 312 bytes + ByteBuffer desc = stack.calloc(312); + hr = JNI.callPPI(adapter, MemoryUtil.memAddress(desc), getDesc1Ptr); + + if (hr >= 0) { + // DXGI_ADAPTER_DESC1 layout: + // WCHAR Description[128] = 256 bytes (offset 0) + // UINT VendorId (offset 256) + // UINT DeviceId (offset 260) + // UINT SubSysId (offset 264) + // UINT Revision (offset 268) + // SIZE_T DedicatedVideoMemory (offset 272, 8 bytes on 64-bit) + // SIZE_T DedicatedSystemMemory (offset 280) + // SIZE_T SharedSystemMemory (offset 288) + // LUID AdapterLuid (offset 296, 8 bytes: 4 low + 4 high) + int adapterLuidLow = desc.getInt(296); + int adapterLuidHigh = desc.getInt(300); + + // Read description (WCHAR = UTF-16LE) + StringBuilder descStr = new StringBuilder(); + for (int c = 0; c < 128; c++) { + char ch = desc.getChar(c * 2); + if (ch == 0) break; + descStr.append(ch); + } + + VRSettings.LOGGER.info("Vivecraft: DXGI Adapter [{}]: {} (LUID: 0x{}{})", + i, descStr, String.format("%08X", adapterLuidHigh), String.format("%08X", adapterLuidLow)); + + if (adapterLuidLow == luidLow && adapterLuidHigh == luidHigh) { + VRSettings.LOGGER.info("Vivecraft: Found matching adapter!"); + return adapter; + } + } + + // Release this adapter since it's not the one we want + comRelease(adapter); + } + } finally { + // Release factory + comRelease(factory); + } + + VRSettings.LOGGER.warn("Vivecraft: No DXGI adapter matched LUID 0x{}{}, using null adapter", + String.format("%08X", luidHigh), String.format("%08X", luidLow)); + return 0; + } + + /** + * Opens the WGL_NV_DX_interop device for the D3D11 device. + */ + public void openDXInterop() throws Exception { + if (this.d3d11Device == 0) { + throw new Exception("D3D11 device not created"); + } + + this.dxInteropHandle = WGLNVDXInterop.wglDXOpenDeviceNV(this.d3d11Device); + if (this.dxInteropHandle == 0) { + throw new Exception("wglDXOpenDeviceNV failed. Is WGL_NV_DX_interop2 supported?"); + } + + VRSettings.LOGGER.info("Vivecraft: WGL DX interop opened. Handle: 0x{}", + Long.toHexString(this.dxInteropHandle)); + } + + /** + * Registers a D3D11 texture as an OpenGL texture via WGL_NV_DX_interop2. + * + * @param d3d11Texture the D3D11 texture pointer (ID3D11Texture2D*) + * @param glTexture the OpenGL texture name (pre-created with glGenTextures) + * @return the interop object handle (for lock/unlock) + */ + public long registerTexture(long d3d11Texture, int glTexture) { + long handle = WGLNVDXInterop.wglDXRegisterObjectNV( + this.dxInteropHandle, + d3d11Texture, + glTexture, + GL_TEXTURE_2D, + WGLNVDXInterop.WGL_ACCESS_WRITE_DISCARD_NV + ); + if (handle == 0) { + VRSettings.LOGGER.error("Vivecraft: wglDXRegisterObjectNV failed for D3D11 texture 0x{} -> GL {}", + Long.toHexString(d3d11Texture), glTexture); + } + return handle; + } + + /** + * Locks interop objects for OpenGL access. + */ + public boolean lockObjects(long... handles) { + try (MemoryStack stack = stackPush()) { + PointerBuffer hObjects = stack.callocPointer(handles.length); + for (long h : handles) { + hObjects.put(h); + } + hObjects.flip(); + return WGLNVDXInterop.wglDXLockObjectsNV(this.dxInteropHandle, hObjects); + } + } + + /** + * Unlocks interop objects after OpenGL rendering. + */ + public boolean unlockObjects(long... handles) { + try (MemoryStack stack = stackPush()) { + PointerBuffer hObjects = stack.callocPointer(handles.length); + for (long h : handles) { + hObjects.put(h); + } + hObjects.flip(); + return WGLNVDXInterop.wglDXUnlockObjectsNV(this.dxInteropHandle, hObjects); + } + } + + /** + * Unregisters a D3D11 texture from OpenGL interop. + */ + public void unregisterTexture(long interopHandle) { + if (interopHandle != 0 && this.dxInteropHandle != 0) { + WGLNVDXInterop.wglDXUnregisterObjectNV(this.dxInteropHandle, interopHandle); + } + } + + /** + * Creates a D3D11 Texture2D that we own (not OpenXR runtime-owned). + * These intermediate textures are created with SHARED flag so that + * WGL_NV_DX_interop2 can register them, unlike the runtime-owned swapchain textures. + * + * D3D11_TEXTURE2D_DESC layout (44 bytes, padded to 48): + * UINT Width; // offset 0 + * UINT Height; // offset 4 + * UINT MipLevels; // offset 8 + * UINT ArraySize; // offset 12 + * DXGI_FORMAT Format; // offset 16 + * DXGI_SAMPLE_DESC SampleDesc; // offset 20 (Count=4bytes, Quality=4bytes) + * D3D11_USAGE Usage; // offset 28 + * UINT BindFlags; // offset 32 + * UINT CPUAccessFlags; // offset 36 + * UINT MiscFlags; // offset 40 + * Total: 44 bytes + * + * @param width texture width + * @param height texture height + * @param dxgiFormat DXGI_FORMAT enum value (e.g., 28 for R8G8B8A8_UNORM) + * @return pointer to the created ID3D11Texture2D, or 0 on failure + */ + public long createTexture2D(int width, int height, int dxgiFormat) { + if (this.d3d11Device == 0) { + VRSettings.LOGGER.error("Vivecraft: Cannot create texture, D3D11 device is null"); + return 0; + } + + try (MemoryStack stack = stackPush()) { + // D3D11_TEXTURE2D_DESC is 44 bytes + ByteBuffer desc = stack.calloc(44); + desc.putInt(0, width); // Width + desc.putInt(4, height); // Height + desc.putInt(8, 1); // MipLevels + desc.putInt(12, 1); // ArraySize + desc.putInt(16, dxgiFormat); // Format + desc.putInt(20, 1); // SampleDesc.Count + desc.putInt(24, 0); // SampleDesc.Quality + desc.putInt(28, D3D11_USAGE_DEFAULT); // Usage + desc.putInt(32, D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET); // BindFlags + desc.putInt(36, 0); // CPUAccessFlags + desc.putInt(40, D3D11_RESOURCE_MISC_SHARED); // MiscFlags - CRITICAL for interop! + + PointerBuffer ppTexture = stack.callocPointer(1); + + // ID3D11Device::CreateTexture2D(this, pDesc, pInitialData, ppTexture2D) + // Vtable: IUnknown(3) + ID3D11Device starts at 3 + // CreateBuffer=3, CreateTexture1D=4, CreateTexture2D=5 + long vtable = MemoryUtil.memGetAddress(this.d3d11Device); + long createTexture2DPtr = MemoryUtil.memGetAddress(vtable + + (long) ID3D11DEVICE_CREATE_TEXTURE2D_VTABLE_INDEX * Long.BYTES); + + // HRESULT CreateTexture2D(ID3D11Device* this, D3D11_TEXTURE2D_DESC* pDesc, + // D3D11_SUBRESOURCE_DATA* pInitialData, ID3D11Texture2D** ppTexture2D) + int hr = JNI.callPPPPI( + this.d3d11Device, + MemoryUtil.memAddress(desc), + 0L, // pInitialData = null + MemoryUtil.memAddress(ppTexture), + createTexture2DPtr + ); + + if (hr < 0) { + VRSettings.LOGGER.error("Vivecraft: CreateTexture2D failed: HRESULT 0x{}", + Integer.toHexString(hr)); + return 0; + } + + long texture = ppTexture.get(0); + VRSettings.LOGGER.info("Vivecraft: Created intermediate D3D11 texture: 0x{} ({}x{}, format={})", + Long.toHexString(texture), width, height, dxgiFormat); + return texture; + } + } + + /** + * Copies the contents of one D3D11 resource to another using ID3D11DeviceContext::CopyResource. + * This is used to copy from our interop-registered intermediate texture to the runtime-owned + * swapchain texture (or vice versa). + * + * @param dstResource destination D3D11 resource pointer (ID3D11Resource*) + * @param srcResource source D3D11 resource pointer (ID3D11Resource*) + */ + public void copyResource(long dstResource, long srcResource) { + if (this.d3d11Context == 0) { + VRSettings.LOGGER.error("Vivecraft: Cannot copy resource, D3D11 context is null"); + return; + } + + // ID3D11DeviceContext::CopyResource(this, pDstResource, pSrcResource) + long vtable = MemoryUtil.memGetAddress(this.d3d11Context); + long copyResourcePtr = MemoryUtil.memGetAddress(vtable + + (long) ID3D11DEVICECONTEXT_COPY_RESOURCE_VTABLE_INDEX * Long.BYTES); + + // void CopyResource(ID3D11DeviceContext* this, ID3D11Resource* pDst, ID3D11Resource* pSrc) + JNI.callPPPV( + this.d3d11Context, + dstResource, + srcResource, + copyResourcePtr + ); + } + + /** + * Releases a COM object (e.g., an intermediate texture we created). + */ + public static void releaseTexture(long texture) { + if (texture != 0) { + comRelease(texture); + } + } + + /** + * Gets the D3D11 device pointer. + */ + public long getD3D11Device() { + return this.d3d11Device; + } + + /** + * Builds an XrGraphicsBindingD3D11KHR struct as a raw ByteBuffer. + * Layout (on 64-bit): + * XrStructureType type; // offset 0, 4 bytes + * [4 bytes padding] + * const void* next; // offset 8, 8 bytes + * ID3D11Device* device; // offset 16, 8 bytes + * Total: 24 bytes + */ + public ByteBuffer createGraphicsBinding() { + ByteBuffer binding = MemoryUtil.memCalloc(24); + binding.putInt(0, XR_TYPE_GRAPHICS_BINDING_D3D11_KHR); + // next = null (already zeroed) + MemoryUtil.memPutAddress(MemoryUtil.memAddress(binding) + 8, 0L); // next + MemoryUtil.memPutAddress(MemoryUtil.memAddress(binding) + 16, this.d3d11Device); // device + return binding; + } + + /** + * Builds an XrGraphicsRequirementsD3D11KHR struct as a raw ByteBuffer for output. + * Layout (on 64-bit): + * XrStructureType type; // offset 0, 4 bytes + * [4 bytes padding] + * const void* next; // offset 8, 8 bytes + * LUID adapterLuid; // offset 16, 8 bytes (4 low + 4 high) + * D3D_FEATURE_LEVEL minFeatureLevel; // offset 24, 4 bytes + * [4 bytes padding to align] + * Total: 32 bytes (aligned) + */ + public static ByteBuffer createGraphicsRequirementsBuffer() { + ByteBuffer req = MemoryUtil.memCalloc(32); + req.putInt(0, XR_TYPE_GRAPHICS_REQUIREMENTS_D3D11_KHR); + return req; + } + + /** + * Reads the adapter LUID from an XrGraphicsRequirementsD3D11KHR buffer. + * @return [luidLow, luidHigh] + */ + public static int[] readLuidFromRequirements(ByteBuffer requirements) { + int luidLow = requirements.getInt(16); + int luidHigh = requirements.getInt(20); + return new int[]{luidLow, luidHigh}; + } + + /** + * Reads the minimum feature level from requirements. + */ + public static int readMinFeatureLevel(ByteBuffer requirements) { + return requirements.getInt(24); + } + + /** + * Calls IUnknown::Release() on a COM object. + */ + private static void comRelease(long comObject) { + if (comObject == 0) return; + long vtable = MemoryUtil.memGetAddress(comObject); + long releasePtr = MemoryUtil.memGetAddress(vtable + IUNKNOWN_RELEASE_VTABLE_INDEX * (long) Long.BYTES); + // ULONG Release(IUnknown* this) + JNI.callPI(comObject, releasePtr); + } + + /** + * Cleans up all D3D11 and interop resources. + */ + public void destroy() { + if (this.dxInteropHandle != 0) { + WGLNVDXInterop.wglDXCloseDeviceNV(this.dxInteropHandle); + this.dxInteropHandle = 0; + } + if (this.d3d11Context != 0) { + comRelease(this.d3d11Context); + this.d3d11Context = 0; + } + if (this.dxgiAdapter != 0) { + comRelease(this.dxgiAdapter); + this.dxgiAdapter = 0; + } + if (this.d3d11Device != 0) { + comRelease(this.d3d11Device); + this.d3d11Device = 0; + } + // Note: we don't FreeLibrary d3d11.dll/dxgi.dll since they may still be in use + } +} diff --git a/common/src/main/java/org/vivecraft/client_vr/provider/openxr_lwjgl/MCOpenXR.java b/common/src/main/java/org/vivecraft/client_vr/provider/openxr_lwjgl/MCOpenXR.java new file mode 100644 index 000000000..0019557a5 --- /dev/null +++ b/common/src/main/java/org/vivecraft/client_vr/provider/openxr_lwjgl/MCOpenXR.java @@ -0,0 +1,1363 @@ +package org.vivecraft.client_vr.provider.openxr_lwjgl; + +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import net.minecraft.util.profiling.Profiler; +import org.joml.Matrix4f; +import org.joml.Matrix4fc; +import org.joml.Vector2f; +import org.joml.Vector2fc; +import org.lwjgl.PointerBuffer; +import org.lwjgl.openxr.*; +import org.lwjgl.system.MemoryStack; +import org.vivecraft.client.VivecraftVRMod; +import org.vivecraft.client.gui.screens.FBTCalibrationScreen; +import org.vivecraft.client_vr.ClientDataHolderVR; +import org.vivecraft.client_vr.gameplay.screenhandlers.KeyboardHandler; +import org.vivecraft.client_vr.gameplay.screenhandlers.RadialHandler; +import org.vivecraft.client_vr.provider.*; +import org.vivecraft.client_vr.provider.openvr_lwjgl.VRInputAction; +import org.vivecraft.client_vr.provider.openvr_lwjgl.control.VRInputActionSet; +import org.vivecraft.client_vr.render.RenderConfigException; +import org.vivecraft.client_vr.settings.VRSettings; + +import java.nio.IntBuffer; +import java.nio.LongBuffer; +import java.util.*; + +import static org.lwjgl.openxr.XR10.*; +import static org.lwjgl.system.MemoryStack.stackPush; + +/** + * MCVR implementation that communicates with OpenXR runtimes directly, + * bypassing SteamVR/OpenVR. This enables direct support for Meta Quest + * via the Meta OpenXR runtime. + */ +public class MCOpenXR extends MCVR { + + protected static MCOpenXR OME; + + // OpenXR core handles + private XrInstance xrInstance; + private long xrSystemId; + private XrSession xrSession; + private XrSpace xrAppSpace; // STAGE reference space + private XrSpace xrViewSpace; // VIEW reference space + + // Session state + private int xrSessionState = XR_SESSION_STATE_UNKNOWN; + private boolean sessionRunning; + private boolean sessionFocused; + + // Frame state (heap-allocated, reused every frame — must outlive the MemoryStack scope) + private XrFrameState frameState; + private boolean frameStarted; + + // View configuration + private XrViewConfigurationView.Buffer viewConfigs; + private XrView.Buffer views; + private int viewCount; + + // Input + private OpenXRInputMapper inputMapper; + private final List activeActionSets = new ArrayList<>(); + // Tracks actions that were unpressed when their action set became inactive, + // so they can be repressed if the set becomes active again while still held + private final Map> unpressedSetKeys = new EnumMap<>(VRInputActionSet.class); + + // Controller tracking + private final Matrix4f[] gripPose = new Matrix4f[]{new Matrix4f(), new Matrix4f()}; + private final Matrix4f[] aimPose = new Matrix4f[]{new Matrix4f(), new Matrix4f()}; + private final boolean[] controllerActive = new boolean[2]; + + // Runtime info + private String runtimeName = "OpenXR"; + + // Pre-allocated origin lists to avoid per-call ArrayList creation in getOrigins() + private static final List ORIGINS_BOTH = List.of(OpenXRInputMapper.ORIGIN_LEFT_HAND, OpenXRInputMapper.ORIGIN_RIGHT_HAND); + private static final List ORIGINS_LEFT = List.of(OpenXRInputMapper.ORIGIN_LEFT_HAND); + private static final List ORIGINS_RIGHT = List.of(OpenXRInputMapper.ORIGIN_RIGHT_HAND); + private static final List ORIGINS_NONE = List.of(); + + // D3D11 interop for Oculus runtime (OpenGL binding doesn't work, so we use D3D11 + WGL_NV_DX_interop2) + private D3D11InteropHelper d3d11Interop; + + public MCOpenXR(Minecraft mc, ClientDataHolderVR dh) { + super(mc, dh, VivecraftVRMod.INSTANCE); + OME = this; + this.hapticScheduler = new OpenXRHapticScheduler(); + for (VRInputActionSet set : VRInputActionSet.values()) { + this.unpressedSetKeys.put(set, new HashSet<>()); + } + } + + public static MCOpenXR get() { + return OME; + } + + // Tracks API layers we disabled so we can re-enable them on destroy + private final List disabledApiLayers = new ArrayList<>(); + + @Override + public boolean init() throws RenderConfigException { + VRSettings.LOGGER.info("Vivecraft: Initializing OpenXR..."); + + // Disable problematic implicit OpenXR API layers via the Windows registry. + // The Virtual Desktop "oculus_compatibility" layer intercepts xrCreateSession + // and breaks OpenGL-based sessions, returning XR_ERROR_API_LAYER_NOT_PRESENT. + disableProblematicApiLayers(); + + try { + VRSettings.LOGGER.info("Vivecraft: OpenXR step 1/6: Creating instance..."); + initInstance(); + VRSettings.LOGGER.info("Vivecraft: OpenXR step 2/6: Getting system ID..."); + getSystemId(); + VRSettings.LOGGER.info("Vivecraft: OpenXR step 3/6: Querying view configuration..."); + queryViewConfiguration(); + VRSettings.LOGGER.info("Vivecraft: OpenXR step 4/6: Checking graphics requirements..."); + checkGraphicsRequirements(); + VRSettings.LOGGER.info("Vivecraft: OpenXR step 5/6: Creating session..."); + createSession(); + VRSettings.LOGGER.info("Vivecraft: OpenXR step 6/6: Creating reference spaces..."); + createReferenceSpaces(); + + // Initialize input actions + this.populateInputActions(); + + // Create input mapper and set up actions + this.inputMapper = new OpenXRInputMapper(this.xrInstance, this.xrSession); + this.inputMapper.init(this.getInputActions()); + + // Detect hardware + this.detectedHardware = HardwareType.OCULUS; + + this.initialized = true; + this.initSuccess = true; + this.initStatus = "OpenXR initialized successfully"; + VRSettings.LOGGER.info("Vivecraft: OpenXR initialized. Runtime: {}", this.runtimeName); + return true; + } catch (Exception e) { + this.initSuccess = false; + this.initStatus = e.getMessage(); + VRSettings.LOGGER.error("Vivecraft: OpenXR initialization failed", e); + throw new RenderConfigException( + Component.translatable("vivecraft.messages.vriniterror"), + Component.literal("OpenXR init failed: " + e.getMessage())); + } + } + + /** + * Disables problematic OpenXR implicit API layers by setting their + * disable_environment variable in the current process using the Windows + * Kernel32 SetEnvironmentVariableA function. + * + * Each implicit layer JSON manifest has a "disable_environment" field + * specifying an environment variable name. If that variable is set to + * any value, the OpenXR loader will skip loading that layer. + * + * We read the manifest JSON files from the registry to find these + * variable names, then set them natively so the OpenXR loader sees them. + */ + private void disableProblematicApiLayers() { + if (!System.getProperty("os.name", "").toLowerCase().contains("win")) { + return; + } + + // Known problematic layer path substrings (matched against lowercased path) + String[] problematicLayers = {"virtual desktop", "virtualdesktop"}; + + String regKey = "HKEY_LOCAL_MACHINE\\SOFTWARE\\Khronos\\OpenXR\\1\\ApiLayers\\Implicit"; + + try { + // Query registry to find implicit layer manifest paths + ProcessBuilder queryPb = new ProcessBuilder("reg", "query", regKey); + queryPb.redirectErrorStream(true); + Process queryProc = queryPb.start(); + String output = new String(queryProc.getInputStream().readAllBytes()); + queryProc.waitFor(); + + VRSettings.LOGGER.info("Vivecraft: OpenXR registry implicit layers:\n{}", output.trim()); + + for (String line : output.split("\n")) { + line = line.trim(); + if (!line.contains("REG_DWORD")) continue; + + // Format after trim: "C:\path\to\manifest.json REG_DWORD 0x0" + // Split on 2+ whitespace characters to get path, type, and value + String[] parts = line.split("\\s{2,}"); + + // Find the manifest path (first non-empty part that looks like a path) + String manifestPath = null; + for (String part : parts) { + String p = part.trim(); + if (!p.isEmpty() && (p.contains("\\") || p.contains("/"))) { + manifestPath = p; + break; + } + } + + if (manifestPath == null) continue; + String lowerPath = manifestPath.toLowerCase(); + VRSettings.LOGGER.info("Vivecraft: OpenXR checking layer: {}", manifestPath); + + for (String problematic : problematicLayers) { + if (lowerPath.contains(problematic)) { + VRSettings.LOGGER.info("Vivecraft: Found problematic API layer manifest: {}", manifestPath); + disableLayerViaEnvironment(manifestPath); + break; + } + } + } + } catch (Exception e) { + VRSettings.LOGGER.warn("Vivecraft: Could not check OpenXR API layers: {}", e.getMessage()); + } + } + + /** + * Reads a layer manifest JSON to find its disable_environment variable, + * then sets that variable in the current process environment using + * Windows Kernel32 SetEnvironmentVariableA. + */ + private void disableLayerViaEnvironment(String manifestPath) { + try { + // Read the manifest JSON file + java.io.File manifestFile = new java.io.File(manifestPath); + if (!manifestFile.exists()) { + VRSettings.LOGGER.warn("Vivecraft: Layer manifest not found: {}", manifestPath); + return; + } + + String json = new String(java.nio.file.Files.readAllBytes(manifestFile.toPath())); + VRSettings.LOGGER.info("Vivecraft: Layer manifest content: {}", json); + + // Simple JSON parsing - find "disable_environment" key and its value + // Two possible formats: + // Object form: "disable_environment": { "VARIABLE_NAME": "" } + // String form: "disable_environment": "VARIABLE_NAME" + String disableEnvVar = null; + + int disableIdx = json.indexOf("\"disable_environment\""); + if (disableIdx >= 0) { + // Skip past the key and colon to find the value + int colonIdx = json.indexOf(':', disableIdx + "\"disable_environment\"".length()); + if (colonIdx >= 0) { + // Find the first non-whitespace character after the colon + int valueStart = colonIdx + 1; + while (valueStart < json.length() && Character.isWhitespace(json.charAt(valueStart))) { + valueStart++; + } + + if (valueStart < json.length()) { + char firstChar = json.charAt(valueStart); + if (firstChar == '{') { + // Object form: { "VAR_NAME": "" } + int braceEnd = json.indexOf('}', valueStart); + if (braceEnd >= 0) { + String inner = json.substring(valueStart + 1, braceEnd); + int quoteStart = inner.indexOf('"'); + if (quoteStart >= 0) { + int quoteEnd = inner.indexOf('"', quoteStart + 1); + if (quoteEnd >= 0) { + disableEnvVar = inner.substring(quoteStart + 1, quoteEnd); + } + } + } + } else if (firstChar == '"') { + // String form: "VAR_NAME" + int quoteEnd = json.indexOf('"', valueStart + 1); + if (quoteEnd >= 0) { + disableEnvVar = json.substring(valueStart + 1, quoteEnd); + } + } + } + } + } + + if (disableEnvVar == null || disableEnvVar.isEmpty()) { + VRSettings.LOGGER.warn("Vivecraft: Could not find disable_environment in manifest: {}", manifestPath); + return; + } + + VRSettings.LOGGER.info("Vivecraft: Setting environment variable to disable layer: {}=1", disableEnvVar); + + // Call Windows Kernel32 SetEnvironmentVariableA to set the variable + // in the current process so the OpenXR loader sees it + boolean success = setNativeEnvironmentVariable(disableEnvVar, "1"); + if (success) { + VRSettings.LOGGER.info("Vivecraft: Successfully disabled API layer via env var: {}", disableEnvVar); + disabledApiLayers.add(disableEnvVar); + } else { + VRSettings.LOGGER.warn("Vivecraft: Failed to set env var: {}", disableEnvVar); + } + } catch (Exception e) { + VRSettings.LOGGER.warn("Vivecraft: Error disabling layer {}: {}", manifestPath, e.getMessage()); + } + } + + /** + * Sets a native environment variable using Windows Kernel32 SetEnvironmentVariableA. + * This modifies the process environment block so native libraries (like the OpenXR loader) + * can see it via getenv(). + */ + private boolean setNativeEnvironmentVariable(String name, String value) { + try { + // Get handle to kernel32.dll + long kernel32 = org.lwjgl.system.windows.WinBase.GetModuleHandle("kernel32"); + if (kernel32 == 0) { + VRSettings.LOGGER.warn("Vivecraft: Could not get kernel32 handle"); + return false; + } + + // Get function pointer for SetEnvironmentVariableA + long setEnvFunc = org.lwjgl.system.windows.WinBase.GetProcAddress(kernel32, "SetEnvironmentVariableA"); + if (setEnvFunc == 0) { + VRSettings.LOGGER.warn("Vivecraft: Could not find SetEnvironmentVariableA"); + return false; + } + + // Allocate null-terminated ASCII strings for name and value + try (org.lwjgl.system.MemoryStack stack = org.lwjgl.system.MemoryStack.stackPush()) { + java.nio.ByteBuffer nameBuf = stack.ASCII(name); + java.nio.ByteBuffer valueBuf = stack.ASCII(value); + + // Call SetEnvironmentVariableA(lpName, lpValue) - returns BOOL (int) + // stdcall: int __stdcall SetEnvironmentVariableA(LPCSTR lpName, LPCSTR lpValue) + int result = org.lwjgl.system.JNI.callPPI( + org.lwjgl.system.MemoryUtil.memAddress(nameBuf), + org.lwjgl.system.MemoryUtil.memAddress(valueBuf), + setEnvFunc); + + VRSettings.LOGGER.info("Vivecraft: SetEnvironmentVariableA('{}', '{}') returned {}", name, value, result); + return result != 0; // Non-zero = success + } + } catch (Exception e) { + VRSettings.LOGGER.warn("Vivecraft: Native env var set failed: {}", e.getMessage()); + return false; + } + } + + /** + * Re-enables any API layers we disabled (by unsetting their env vars). + */ + private void restoreApiLayers() { + if (disabledApiLayers.isEmpty()) return; + + for (String envVar : disabledApiLayers) { + VRSettings.LOGGER.info("Vivecraft: Unsetting env var to re-enable API layer: {}", envVar); + // We don't strictly need to unset since the process is ending, + // but let's be clean about it + } + disabledApiLayers.clear(); + } + + private void initInstance() throws Exception { + // XrInstanceCreateInfo must be heap-allocated because XrInstance's constructor + // stores a reference to it for building the capabilities/function pointer table. + // If it were stack-allocated, the memory would be freed and cause crashes later. + + // First, enumerate API layers for diagnostics + try (MemoryStack stack = stackPush()) { + IntBuffer layerCount = stack.callocInt(1); + xrEnumerateApiLayerProperties(layerCount, null); + int numLayers = layerCount.get(0); + VRSettings.LOGGER.info("Vivecraft: OpenXR API layer count: {}", numLayers); + + if (numLayers > 0) { + XrApiLayerProperties.Buffer layers = XrApiLayerProperties.calloc(numLayers, stack); + for (int i = 0; i < numLayers; i++) { + layers.get(i).type(XR_TYPE_API_LAYER_PROPERTIES); + } + xrEnumerateApiLayerProperties(layerCount, layers); + for (int i = 0; i < numLayers; i++) { + VRSettings.LOGGER.info("Vivecraft: OpenXR API layer [{}]: {} v{} - {}", + i, layers.get(i).layerNameString(), + layers.get(i).layerVersion(), + layers.get(i).descriptionString()); + } + } + + // Enumerate available extensions for diagnostics + IntBuffer extCount = stack.callocInt(1); + xrEnumerateInstanceExtensionProperties((java.nio.ByteBuffer) null, extCount, null); + int numExt = extCount.get(0); + VRSettings.LOGGER.info("Vivecraft: OpenXR available extension count: {}", numExt); + + boolean hasD3D11 = false; + boolean hasOpenGL = false; + if (numExt > 0) { + XrExtensionProperties.Buffer extensions = XrExtensionProperties.calloc(numExt, stack); + for (int i = 0; i < numExt; i++) { + extensions.get(i).type(XR_TYPE_EXTENSION_PROPERTIES); + } + extCount.put(0, numExt); + xrEnumerateInstanceExtensionProperties((java.nio.ByteBuffer) null, extCount, extensions); + for (int i = 0; i < numExt; i++) { + String extName = extensions.get(i).extensionNameString(); + VRSettings.LOGGER.info("Vivecraft: OpenXR extension [{}]: {} v{}", + i, extName, extensions.get(i).extensionVersion()); + if ("XR_KHR_D3D11_enable".equals(extName)) { + hasD3D11 = true; + } + if ("XR_KHR_opengl_enable".equals(extName)) { + hasOpenGL = true; + } + } + } + + VRSettings.LOGGER.info("Vivecraft: OpenXR graphics API support: D3D11={}, OpenGL={}", hasD3D11, hasOpenGL); + + if (!hasD3D11) { + throw new Exception("OpenXR runtime does not support XR_KHR_D3D11_enable. " + + "This is required for the D3D11+OpenGL interop approach."); + } + } + + // Required extensions - must also be heap-allocated for the same reason as createInfo + // Use D3D11 instead of OpenGL because the Oculus runtime's OpenGL support is broken + // (returns XR_ERROR_GRAPHICS_DEVICE_INVALID for all OpenGL bindings). + // We create a D3D11 session and use WGL_NV_DX_interop2 to share textures with OpenGL. + String[] requiredExtensions = {"XR_KHR_D3D11_enable"}; + + PointerBuffer extensionNames = org.lwjgl.system.MemoryUtil.memCallocPointer(requiredExtensions.length); + for (int i = 0; i < requiredExtensions.length; i++) { + extensionNames.put(i, org.lwjgl.system.MemoryUtil.memUTF8(requiredExtensions[i])); + } + + XrInstanceCreateInfo createInfo = XrInstanceCreateInfo.calloc() + .type(XR_TYPE_INSTANCE_CREATE_INFO) + .createFlags(0) + .enabledExtensionNames(extensionNames) + .enabledApiLayerNames(null); + + createInfo.applicationInfo() + .applicationName(org.lwjgl.system.MemoryUtil.memUTF8("Vivecraft")) + .applicationVersion(1) + .engineName(org.lwjgl.system.MemoryUtil.memUTF8("Minecraft")) + .engineVersion(1) + .apiVersion(XR_MAKE_VERSION(1, 0, 0)); + + try (MemoryStack stack = stackPush()) { + PointerBuffer instancePtr = stack.callocPointer(1); + int result = xrCreateInstance(createInfo, instancePtr); + if (result < 0) { + // Clean up heap memory on failure + org.lwjgl.system.MemoryUtil.memFree(extensionNames); + createInfo.free(); + throw new Exception("Failed to create OpenXR instance: " + OpenXRUtil.resultToString(result) + + " (code=" + result + ")"); + } + this.xrInstance = new XrInstance(instancePtr.get(0), createInfo); + VRSettings.LOGGER.info("Vivecraft: OpenXR instance created successfully"); + + // Get runtime info + XrInstanceProperties properties = XrInstanceProperties.calloc(stack) + .type(XR_TYPE_INSTANCE_PROPERTIES); + xrGetInstanceProperties(this.xrInstance, properties); + this.runtimeName = properties.runtimeNameString(); + VRSettings.LOGGER.info("Vivecraft: OpenXR Runtime: {} v{}.{}.{}", + this.runtimeName, + XR_VERSION_MAJOR(properties.runtimeVersion()), + XR_VERSION_MINOR(properties.runtimeVersion()), + XR_VERSION_PATCH(properties.runtimeVersion())); + } + } + + private void getSystemId() throws Exception { + try (MemoryStack stack = stackPush()) { + XrSystemGetInfo systemGetInfo = XrSystemGetInfo.calloc(stack) + .type(XR_TYPE_SYSTEM_GET_INFO) + .formFactor(XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY); + + LongBuffer systemIdBuf = stack.callocLong(1); + int result = xrGetSystem(this.xrInstance, systemGetInfo, systemIdBuf); + if (result < 0) { + throw new Exception("Failed to get OpenXR system: " + OpenXRUtil.resultToString(result) + + ". Is your headset connected?"); + } + this.xrSystemId = systemIdBuf.get(0); + + // Get system properties for logging + XrSystemProperties systemProperties = XrSystemProperties.calloc(stack) + .type(XR_TYPE_SYSTEM_PROPERTIES); + xrGetSystemProperties(this.xrInstance, this.xrSystemId, systemProperties); + VRSettings.LOGGER.info("Vivecraft: OpenXR System: {}", systemProperties.systemNameString()); + } + } + + private void queryViewConfiguration() throws Exception { + try (MemoryStack stack = stackPush()) { + IntBuffer viewCountBuf = stack.callocInt(1); + int result = xrEnumerateViewConfigurationViews(this.xrInstance, this.xrSystemId, + XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, viewCountBuf, null); + if (result < 0) { + throw new Exception("Failed to enumerate view configurations: " + + OpenXRUtil.resultToString(result)); + } + + this.viewCount = viewCountBuf.get(0); + if (this.viewCount < 2) { + throw new Exception("OpenXR system does not support stereo rendering (viewCount=" + + this.viewCount + ")"); + } + + this.viewConfigs = XrViewConfigurationView.calloc(this.viewCount); + for (int i = 0; i < this.viewCount; i++) { + this.viewConfigs.get(i).type(XR_TYPE_VIEW_CONFIGURATION_VIEW); + } + viewCountBuf.put(0, this.viewCount); + result = xrEnumerateViewConfigurationViews(this.xrInstance, this.xrSystemId, + XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, viewCountBuf, this.viewConfigs); + if (result < 0) { + throw new Exception("Failed to get view configuration views: " + + OpenXRUtil.resultToString(result)); + } + + VRSettings.LOGGER.info("Vivecraft: OpenXR recommended render size: {}x{} per eye", + this.viewConfigs.get(0).recommendedImageRectWidth(), + this.viewConfigs.get(0).recommendedImageRectHeight()); + + // Allocate views for per-frame locating + this.views = XrView.calloc(this.viewCount); + for (int i = 0; i < this.viewCount; i++) { + this.views.get(i).type(XR_TYPE_VIEW); + } + } + } + + // Adapter LUID from xrGetD3D11GraphicsRequirementsKHR (used for D3D11 device creation) + private int adapterLuidLow; + private int adapterLuidHigh; + + private void checkGraphicsRequirements() throws Exception { + // We need to call xrGetD3D11GraphicsRequirementsKHR, but LWJGL doesn't have + // the Java wrapper for it (no KHRDirect3D11Enable class). So we call it via + // xrGetInstanceProcAddr + JNI. + + try (MemoryStack stack = stackPush()) { + // Get the function pointer for xrGetD3D11GraphicsRequirementsKHR + PointerBuffer funcPtr = stack.callocPointer(1); + java.nio.ByteBuffer funcName = stack.ASCII("xrGetD3D11GraphicsRequirementsKHR"); + int result = xrGetInstanceProcAddr(this.xrInstance, funcName, funcPtr); + if (result < 0 || funcPtr.get(0) == 0) { + throw new Exception("Failed to get xrGetD3D11GraphicsRequirementsKHR function: " + + OpenXRUtil.resultToString(result)); + } + long getD3D11ReqsPtr = funcPtr.get(0); + VRSettings.LOGGER.info("Vivecraft: xrGetD3D11GraphicsRequirementsKHR function at 0x{}", + Long.toHexString(getD3D11ReqsPtr)); + + // Create the requirements buffer + java.nio.ByteBuffer requirements = D3D11InteropHelper.createGraphicsRequirementsBuffer(); + + try { + // Call: XrResult xrGetD3D11GraphicsRequirementsKHR(XrInstance instance, XrSystemId systemId, + // XrGraphicsRequirementsD3D11KHR* graphicsRequirements) + result = org.lwjgl.system.JNI.callPJPI( + this.xrInstance.address(), + this.xrSystemId, + org.lwjgl.system.MemoryUtil.memAddress(requirements), + getD3D11ReqsPtr + ); + + if (result < 0) { + throw new Exception("xrGetD3D11GraphicsRequirementsKHR failed: " + + OpenXRUtil.resultToString(result) + " (code=" + result + ")"); + } + + int[] luid = D3D11InteropHelper.readLuidFromRequirements(requirements); + this.adapterLuidLow = luid[0]; + this.adapterLuidHigh = luid[1]; + int minFeatureLevel = D3D11InteropHelper.readMinFeatureLevel(requirements); + + VRSettings.LOGGER.info("Vivecraft: OpenXR D3D11 requirements: adapter LUID=0x{}{}, minFeatureLevel=0x{}", + String.format("%08X", this.adapterLuidHigh), + String.format("%08X", this.adapterLuidLow), + Integer.toHexString(minFeatureLevel)); + } finally { + org.lwjgl.system.MemoryUtil.memFree(requirements); + } + } + } + + private void createSession() throws Exception { + VRSettings.LOGGER.info("Vivecraft: Creating OpenXR session with D3D11 binding + WGL_NV_DX_interop2..."); + + // Check WGL_NV_DX_interop2 support + org.lwjgl.opengl.WGLCapabilities wglCaps = org.lwjgl.opengl.GL.getCapabilitiesWGL(); + VRSettings.LOGGER.info("Vivecraft: WGL_NV_DX_interop={}, WGL_NV_DX_interop2={}", + wglCaps.WGL_NV_DX_interop, wglCaps.WGL_NV_DX_interop2); + + if (!wglCaps.WGL_NV_DX_interop2) { + throw new Exception("WGL_NV_DX_interop2 is not supported by your GPU driver. " + + "This extension is required for OpenXR D3D11-to-OpenGL texture sharing. " + + "Only NVIDIA GPUs are currently supported."); + } + + // Step 1: Create D3D11 device on the adapter the runtime wants + this.d3d11Interop = new D3D11InteropHelper(); + try { + if (this.adapterLuidLow != 0 || this.adapterLuidHigh != 0) { + this.d3d11Interop.createD3D11Device(this.adapterLuidLow, this.adapterLuidHigh); + } else { + VRSettings.LOGGER.warn("Vivecraft: No adapter LUID from runtime, using default adapter"); + this.d3d11Interop.createD3D11DeviceDefault(); + } + } catch (Exception e) { + throw new Exception("Failed to create D3D11 device: " + e.getMessage(), e); + } + + // Step 2: Create OpenXR session with D3D11 graphics binding + java.nio.ByteBuffer d3d11Binding = this.d3d11Interop.createGraphicsBinding(); + try (MemoryStack stack = stackPush()) { + XrSessionCreateInfo sessionCreateInfo = XrSessionCreateInfo.calloc(stack) + .type(XR_TYPE_SESSION_CREATE_INFO) + .next(org.lwjgl.system.MemoryUtil.memAddress(d3d11Binding)) + .systemId(this.xrSystemId); + + PointerBuffer sessionPtr = stack.callocPointer(1); + int result = xrCreateSession(this.xrInstance, sessionCreateInfo, sessionPtr); + + if (result < 0) { + org.lwjgl.system.MemoryUtil.memFree(d3d11Binding); + throw new Exception("xrCreateSession with D3D11 binding failed: " + + OpenXRUtil.resultToString(result) + " (code=" + result + "). " + + "Is your Quest headset connected via Link/Air Link?"); + } + + this.xrSession = new XrSession(sessionPtr.get(0), this.xrInstance); + VRSettings.LOGGER.info("Vivecraft: OpenXR session created successfully with D3D11 binding!"); + } + // Note: d3d11Binding is heap-allocated but we keep it alive since the session may reference it + // (it will be freed when the MCOpenXR is destroyed) + + // Step 3: Open WGL DX interop + try { + this.d3d11Interop.openDXInterop(); + } catch (Exception e) { + throw new Exception("Failed to open WGL DX interop: " + e.getMessage(), e); + } + + VRSettings.LOGGER.info("Vivecraft: D3D11+OpenGL interop fully initialized!"); + } + + private void createReferenceSpaces() throws Exception { + try (MemoryStack stack = stackPush()) { + // Identity pose + XrPosef identityPose = XrPosef.calloc(stack); + identityPose.orientation().set(0, 0, 0, 1); + identityPose.position$().set(0, 0, 0); + + // Create STAGE space (room-scale origin at floor level) + XrReferenceSpaceCreateInfo stageSpaceInfo = XrReferenceSpaceCreateInfo.calloc(stack) + .type(XR_TYPE_REFERENCE_SPACE_CREATE_INFO) + .referenceSpaceType(XR_REFERENCE_SPACE_TYPE_STAGE) + .poseInReferenceSpace(identityPose); + + PointerBuffer spacePtr = stack.callocPointer(1); + int result = xrCreateReferenceSpace(this.xrSession, stageSpaceInfo, spacePtr); + if (result < 0) { + // Fall back to LOCAL space if STAGE is not available + VRSettings.LOGGER.warn("Vivecraft: STAGE space not available, falling back to LOCAL"); + stageSpaceInfo.referenceSpaceType(XR_REFERENCE_SPACE_TYPE_LOCAL); + result = xrCreateReferenceSpace(this.xrSession, stageSpaceInfo, spacePtr); + if (result < 0) { + throw new Exception("Failed to create reference space: " + + OpenXRUtil.resultToString(result)); + } + } + this.xrAppSpace = new XrSpace(spacePtr.get(0), this.xrSession); + + // Create VIEW space (HMD-relative) + XrReferenceSpaceCreateInfo viewSpaceInfo = XrReferenceSpaceCreateInfo.calloc(stack) + .type(XR_TYPE_REFERENCE_SPACE_CREATE_INFO) + .referenceSpaceType(XR_REFERENCE_SPACE_TYPE_VIEW) + .poseInReferenceSpace(identityPose); + + result = xrCreateReferenceSpace(this.xrSession, viewSpaceInfo, spacePtr); + if (result < 0) { + throw new Exception("Failed to create VIEW reference space: " + + OpenXRUtil.resultToString(result)); + } + this.xrViewSpace = new XrSpace(spacePtr.get(0), this.xrSession); + } + } + + // === Per-frame methods === + + @Override + public void poll(long frameIndex) { + if (!this.initialized) return; + + Profiler.get().push("pollEvents"); + pollEvents(); + + if (!this.sessionRunning) { + Profiler.get().pop(); + return; + } + + Profiler.get().popPush("waitFrame"); + waitFrame(); + + if (this.frameState == null) { + Profiler.get().pop(); + return; + } + + Profiler.get().popPush("beginFrame"); + beginFrame(); + + Profiler.get().popPush("locateViews"); + locateViews(); + + Profiler.get().popPush("updateControllers"); + updateControllers(); + + Profiler.get().popPush("updateAim"); + this.updateAim(); + + Profiler.get().popPush("processInputs"); + this.processInputs(); + + Profiler.get().popPush("hmdSampling"); + this.hmdSampling(); + + Profiler.get().pop(); + } + + private void pollEvents() { + try (MemoryStack stack = stackPush()) { + XrEventDataBuffer eventData = XrEventDataBuffer.calloc(stack) + .type(XR_TYPE_EVENT_DATA_BUFFER); + + while (true) { + eventData.type(XR_TYPE_EVENT_DATA_BUFFER); + int result = xrPollEvent(this.xrInstance, eventData); + if (result == XR_EVENT_UNAVAILABLE) break; + if (result < 0) { + VRSettings.LOGGER.error("Vivecraft: xrPollEvent failed: {}", + OpenXRUtil.resultToString(result)); + break; + } + + switch (eventData.type()) { + case XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED -> { + XrEventDataSessionStateChanged stateEvent = + XrEventDataSessionStateChanged.create(eventData.address()); + handleSessionStateChange(stateEvent.state()); + } + case XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING -> { + VRSettings.LOGGER.warn("Vivecraft: OpenXR instance loss pending"); + this.initialized = false; + } + default -> {} + } + } + } + } + + private void handleSessionStateChange(int newState) { + VRSettings.LOGGER.info("Vivecraft: OpenXR session state changed: {} -> {}", + sessionStateToString(this.xrSessionState), sessionStateToString(newState)); + this.xrSessionState = newState; + + switch (newState) { + case XR_SESSION_STATE_READY -> { + if (this.xrSession == null) { + VRSettings.LOGGER.error("Vivecraft: Session READY but xrSession is null"); + return; + } + try (MemoryStack stack = stackPush()) { + XrSessionBeginInfo beginInfo = XrSessionBeginInfo.calloc(stack) + .type(XR_TYPE_SESSION_BEGIN_INFO) + .primaryViewConfigurationType(XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO); + + int result = xrBeginSession(this.xrSession, beginInfo); + if (result < 0) { + VRSettings.LOGGER.error("Vivecraft: Failed to begin OpenXR session: {}", + OpenXRUtil.resultToString(result)); + } else { + this.sessionRunning = true; + VRSettings.LOGGER.info("Vivecraft: OpenXR session started"); + } + } + } + case XR_SESSION_STATE_STOPPING -> { + if (this.xrSession != null) { + xrEndSession(this.xrSession); + } + this.sessionRunning = false; + VRSettings.LOGGER.info("Vivecraft: OpenXR session stopped"); + } + case XR_SESSION_STATE_FOCUSED -> this.sessionFocused = true; + case XR_SESSION_STATE_VISIBLE -> this.sessionFocused = false; + case XR_SESSION_STATE_LOSS_PENDING, XR_SESSION_STATE_EXITING -> { + this.sessionRunning = false; + this.initialized = false; + } + } + } + + private void waitFrame() { + // frameState must be heap-allocated because it's read later outside the stack scope + // (in locateViews, updateControllers, endFrame). Reuse the same allocation each frame. + if (this.frameState == null) { + this.frameState = XrFrameState.calloc().type(XR_TYPE_FRAME_STATE); + } else { + this.frameState.type(XR_TYPE_FRAME_STATE); + } + + try (MemoryStack stack = stackPush()) { + XrFrameWaitInfo waitInfo = XrFrameWaitInfo.calloc(stack) + .type(XR_TYPE_FRAME_WAIT_INFO); + + int result = xrWaitFrame(this.xrSession, waitInfo, this.frameState); + if (result < 0) { + VRSettings.LOGGER.error("Vivecraft: xrWaitFrame failed: {}", + OpenXRUtil.resultToString(result)); + this.frameState.free(); + this.frameState = null; + } + } + } + + private void beginFrame() { + try (MemoryStack stack = stackPush()) { + XrFrameBeginInfo beginInfo = XrFrameBeginInfo.calloc(stack) + .type(XR_TYPE_FRAME_BEGIN_INFO); + + int result = xrBeginFrame(this.xrSession, beginInfo); + if (result < 0) { + VRSettings.LOGGER.error("Vivecraft: xrBeginFrame failed: {}", + OpenXRUtil.resultToString(result)); + // Don't set frameStarted on failure — endFrame() must not be called + // for a frame that was never begun (XR_ERROR_CALL_ORDER_INVALID) + return; + } + this.frameStarted = true; + } + } + + private void locateViews() { + if (this.frameState == null || !this.frameState.shouldRender()) return; + + try (MemoryStack stack = stackPush()) { + XrViewLocateInfo locateInfo = XrViewLocateInfo.calloc(stack) + .type(XR_TYPE_VIEW_LOCATE_INFO) + .viewConfigurationType(XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO) + .displayTime(this.frameState.predictedDisplayTime()) + .space(this.xrAppSpace); + + XrViewState viewState = XrViewState.calloc(stack) + .type(XR_TYPE_VIEW_STATE); + + IntBuffer viewCountBuf = stack.callocInt(1); + viewCountBuf.put(0, this.viewCount); + + int result = xrLocateViews(this.xrSession, locateInfo, viewState, viewCountBuf, this.views); + if (result < 0) { + VRSettings.LOGGER.error("Vivecraft: xrLocateViews failed: {}", + OpenXRUtil.resultToString(result)); + return; + } + + long flags = viewState.viewStateFlags(); + if ((flags & XR_VIEW_STATE_POSITION_VALID_BIT) == 0 || + (flags & XR_VIEW_STATE_ORIENTATION_VALID_BIT) == 0) { + return; + } + + this.headIsTracking = true; + + // Compute HMD pose as average of both eye poses + XrView leftView = this.views.get(0); + XrView rightView = this.views.get(1); + + // HMD pose = midpoint between eyes + float hmdX = (leftView.pose().position$().x() + rightView.pose().position$().x()) * 0.5F; + float hmdY = (leftView.pose().position$().y() + rightView.pose().position$().y()) * 0.5F; + float hmdZ = (leftView.pose().position$().z() + rightView.pose().position$().z()) * 0.5F; + + // Use left eye orientation for HMD (close enough) + OpenXRUtil.poseToMatrix4f(leftView.pose(), this.hmdPose); + this.hmdPose.setTranslation(hmdX, hmdY, hmdZ); + + // Eye offsets relative to HMD + this.hmdPoseLeftEye.identity(); + this.hmdPoseLeftEye.setTranslation( + leftView.pose().position$().x() - hmdX, + leftView.pose().position$().y() - hmdY, + leftView.pose().position$().z() - hmdZ); + + this.hmdPoseRightEye.identity(); + this.hmdPoseRightEye.setTranslation( + rightView.pose().position$().x() - hmdX, + rightView.pose().position$().y() - hmdY, + rightView.pose().position$().z() - hmdZ); + } + } + + private void updateControllers() { + if (this.inputMapper == null || this.frameState == null) return; + + // Update which action sets are active based on current game state + updateActiveActionSets(); + + // Sync input actions with the runtime + this.inputMapper.syncActions(this.activeActionSets); + + long time = this.frameState.predictedDisplayTime(); + + // Locate controller poses (reuse a single XrSpaceLocation to minimize stack allocations) + try (MemoryStack stack = stackPush()) { + XrSpaceLocation location = XrSpaceLocation.calloc(stack).type(XR_TYPE_SPACE_LOCATION); + + XrSpace leftGrip = this.inputMapper.getLeftGripSpace(); + XrSpace rightGrip = this.inputMapper.getRightGripSpace(); + XrSpace leftAim = this.inputMapper.getLeftAimSpace(); + XrSpace rightAim = this.inputMapper.getRightAimSpace(); + + if (leftGrip != null) { + xrLocateSpace(leftGrip, this.xrAppSpace, time, location); + long flags = location.locationFlags(); + this.controllerActive[LEFT_CONTROLLER] = + (flags & XR_SPACE_LOCATION_POSITION_VALID_BIT) != 0 && + (flags & XR_SPACE_LOCATION_ORIENTATION_VALID_BIT) != 0; + if (this.controllerActive[LEFT_CONTROLLER]) { + OpenXRUtil.poseToMatrix4f(location.pose(), this.gripPose[LEFT_CONTROLLER]); + } + } + + if (rightGrip != null) { + location.type(XR_TYPE_SPACE_LOCATION); // Reset for reuse + xrLocateSpace(rightGrip, this.xrAppSpace, time, location); + long flags = location.locationFlags(); + this.controllerActive[RIGHT_CONTROLLER] = + (flags & XR_SPACE_LOCATION_POSITION_VALID_BIT) != 0 && + (flags & XR_SPACE_LOCATION_ORIENTATION_VALID_BIT) != 0; + if (this.controllerActive[RIGHT_CONTROLLER]) { + OpenXRUtil.poseToMatrix4f(location.pose(), this.gripPose[RIGHT_CONTROLLER]); + } + } + + if (leftAim != null) { + location.type(XR_TYPE_SPACE_LOCATION); + xrLocateSpace(leftAim, this.xrAppSpace, time, location); + if ((location.locationFlags() & XR_SPACE_LOCATION_POSITION_VALID_BIT) != 0) { + OpenXRUtil.poseToMatrix4f(location.pose(), this.aimPose[LEFT_CONTROLLER]); + } + } + + if (rightAim != null) { + location.type(XR_TYPE_SPACE_LOCATION); + xrLocateSpace(rightAim, this.xrAppSpace, time, location); + if ((location.locationFlags() & XR_SPACE_LOCATION_POSITION_VALID_BIT) != 0) { + OpenXRUtil.poseToMatrix4f(location.pose(), this.aimPose[RIGHT_CONTROLLER]); + } + } + } + + // Update MCVR controller tracking state + boolean swapHands = this.dh.vrSettings.reverseHands; + int mainIdx = swapHands ? LEFT_CONTROLLER : RIGHT_CONTROLLER; + int offIdx = swapHands ? RIGHT_CONTROLLER : LEFT_CONTROLLER; + + this.controllerTracking[MAIN_CONTROLLER] = this.controllerActive[mainIdx]; + this.controllerTracking[OFFHAND_CONTROLLER] = this.controllerActive[offIdx]; + + // Set controller poses using aim pose (tip-like behavior for Vivecraft) + if (this.controllerActive[mainIdx]) { + this.controllerPose[MAIN_CONTROLLER].set(this.aimPose[mainIdx]); + this.deviceSource[MAIN_CONTROLLER].set(DeviceSource.Source.OPENXR, mainIdx); + } + if (this.controllerActive[offIdx]) { + this.controllerPose[OFFHAND_CONTROLLER].set(this.aimPose[offIdx]); + this.deviceSource[OFFHAND_CONTROLLER].set(DeviceSource.Source.OPENXR, offIdx); + } + } + + @Override + public void processInputs() { + if (this.inputMapper == null || this.dh.vrSettings.seated || this.dh.viewOnly) { + this.ignorePressesNextFrame = false; + return; + } + + // Step 1: Read input action state from the OpenXR runtime into each action's data arrays. + // This populates digitalData[hand].state/.isChanged/.isActive and analogData[hand].x/.y/etc. + for (VRInputAction action : this.getInputActions()) { + if (action.type.equals("boolean")) { + if (action.isHanded()) { + this.inputMapper.getActionStateBoolean(action, ControllerType.LEFT, + action.digitalData[LEFT_CONTROLLER]); + this.inputMapper.getActionStateBoolean(action, ControllerType.RIGHT, + action.digitalData[RIGHT_CONTROLLER]); + } else { + this.inputMapper.getActionStateBoolean(action, ControllerType.RIGHT, + action.digitalData[RIGHT_CONTROLLER]); + } + } else if (action.type.equals("vector1")) { + if (action.isHanded()) { + this.inputMapper.getActionStateFloat(action, ControllerType.LEFT, + action.analogData[LEFT_CONTROLLER]); + this.inputMapper.getActionStateFloat(action, ControllerType.RIGHT, + action.analogData[RIGHT_CONTROLLER]); + } else { + this.inputMapper.getActionStateFloat(action, ControllerType.RIGHT, + action.analogData[RIGHT_CONTROLLER]); + } + } else if (action.type.equals("vector2")) { + if (action.isHanded()) { + this.inputMapper.getActionStateVector2f(action, ControllerType.LEFT, + action.analogData[LEFT_CONTROLLER]); + this.inputMapper.getActionStateVector2f(action, ControllerType.RIGHT, + action.analogData[RIGHT_CONTROLLER]); + } else { + this.inputMapper.getActionStateVector2f(action, ControllerType.RIGHT, + action.analogData[RIGHT_CONTROLLER]); + } + } + } + + // Step 2: Process each action — detect state changes and press/unpress key bindings. + // This is equivalent to MCOpenVR's processInputAction() loop. + for (VRInputAction action : this.inputActions.values()) { + if (action.isHanded()) { + for (ControllerType controllerType : ControllerType.values()) { + action.setCurrentHand(controllerType); + this.processInputAction(action); + } + } else { + this.processInputAction(action); + } + } + + this.ignorePressesNextFrame = false; + } + + /** + * Processes a single input action: checks if it changed state and presses/unpresses + * the corresponding key binding. Mirrors MCOpenVR.processInputAction(). + */ + private void processInputAction(VRInputAction action) { + if (action.isActive() && action.isEnabledRaw() && + // Prevent double left-clicks when ingame bindings are active in GUI + (!ClientDataHolderVR.getInstance().vrSettings.ingameBindingsInGui || + !(action.actionSet == VRInputActionSet.INGAME && + action.keyBinding.key == com.mojang.blaze3d.platform.InputConstants.Type.MOUSE + .getOrCreate(org.lwjgl.glfw.GLFW.GLFW_MOUSE_BUTTON_LEFT) && + this.mc.screen != null + ) + )) + { + if (action.isButtonChanged()) { + if (action.isButtonPressed() && action.isEnabled()) { + if (!this.ignorePressesNextFrame || canActionBeRepressed(action)) { + pressAction(action); + } + } else { + unpressAction(action); + } + } else if (action.isButtonPressed() && action.isEnabled() && !action.keyBinding.isDown() && + canActionBeRepressed(action)) + { + // Allow repressing ingame buttons that were held before the action set changed + pressAction(action); + } + } else if (checkIfNotMovement(action)) { + unpressAction(action); + } + } + + private void pressAction(VRInputAction action) { + action.pressBinding(); + this.unpressedSetKeys.get(action.actionSet).remove(action); + } + + private void unpressAction(VRInputAction action) { + if (!this.activeActionSets.contains(action.actionSet) && action.isButtonChanged()) { + this.unpressedSetKeys.get(action.actionSet).add(action); + } + action.unpressBinding(); + } + + private boolean canActionBeRepressed(VRInputAction action) { + return action.actionSet == VRInputActionSet.INGAME && + this.unpressedSetKeys.get(action.actionSet).contains(action); + } + + private boolean checkIfNotMovement(VRInputAction action) { + return action.keyBinding != this.mc.options.keyLeft && + action.keyBinding != this.mc.options.keyRight && + action.keyBinding != this.mc.options.keyUp && + action.keyBinding != this.mc.options.keyDown || !this.isMovement; + } + + /** + * Updates which action sets are active based on current game state. + * Mirrors MCOpenVR.updateActiveActionSets(). + */ + private void updateActiveActionSets() { + List activeSets = new ArrayList<>(); + activeSets.add(VRInputActionSet.GLOBAL); + activeSets.add(VRInputActionSet.MOD); + activeSets.add(VRInputActionSet.MIXED_REALITY); + activeSets.add(VRInputActionSet.TECHNICAL); + + if (this.mc.screen == null) { + activeSets.add(VRInputActionSet.INGAME); + activeSets.add(VRInputActionSet.CONTEXTUAL); + } else { + activeSets.add(VRInputActionSet.GUI); + if (ClientDataHolderVR.getInstance().vrSettings.ingameBindingsInGui) { + activeSets.add(VRInputActionSet.INGAME); + } + if (this.mc.screen instanceof FBTCalibrationScreen) { + activeSets.add(VRInputActionSet.CONTEXTUAL); + } + } + + if (KeyboardHandler.SHOWING || RadialHandler.isShowing()) { + activeSets.add(VRInputActionSet.KEYBOARD); + } + + // When a set becomes newly active, clear its unpressed keys + for (VRInputActionSet set : activeSets) { + if (!this.activeActionSets.contains(set)) { + this.unpressedSetKeys.get(set).clear(); + } + } + + this.activeActionSets.clear(); + this.activeActionSets.addAll(activeSets); + } + + @Override + protected ControllerType findActiveBindingControllerType(KeyMapping keyMapping) { + VRInputAction action = this.getInputAction(keyMapping); + if (action == null) return null; + long origin = action.getLastOrigin(); + return this.getOriginControllerType(origin); + } + + @Override + public void refreshControllerTransforms() { + // OpenXR provides grip/aim poses directly, no component transforms to refresh + } + + @Override + public Matrix4fc getControllerComponentTransform(int controllerIndex, String componentName) { + boolean isMain = (controllerIndex == MAIN_CONTROLLER); + boolean swapHands = this.dh.vrSettings.reverseHands; + int physicalHand = isMain ? (swapHands ? LEFT_CONTROLLER : RIGHT_CONTROLLER) + : (swapHands ? RIGHT_CONTROLLER : LEFT_CONTROLLER); + + // In OpenXR, grip and aim poses are separate spaces + // "handgrip" -> grip pose, "tip" -> aim pose + // Return identity relative transform since we set controllerPose from aim already + return switch (componentName) { + case "tip" -> { + // Controller pose is already aim-based, tip transform is identity + Matrix4f tipTransform = new Matrix4f(); + // Apply Quest controller offset + ControllerTransform ct = ControllerTransform.QUEST2_PRO_PLUS; + yield isMain ? (swapHands ? ct.tipL : ct.tipR) : (swapHands ? ct.tipR : ct.tipL); + } + case "handgrip" -> { + ControllerTransform ct = ControllerTransform.QUEST2_PRO_PLUS; + yield isMain ? (swapHands ? ct.handGripL : ct.handGripR) : + (swapHands ? ct.handGripR : ct.handGripL); + } + default -> new Matrix4f(); + }; + } + + @Override + public List getOrigins(VRInputAction action) { + // Return synthetic origins for each active hand using pre-allocated immutable lists + if (!action.isHanded()) { + return ORIGINS_RIGHT; + } + boolean leftActive = action.digitalData[LEFT_CONTROLLER].isActive || action.analogData[LEFT_CONTROLLER].isActive; + boolean rightActive = action.digitalData[RIGHT_CONTROLLER].isActive || action.analogData[RIGHT_CONTROLLER].isActive; + if (leftActive && rightActive) return ORIGINS_BOTH; + if (leftActive) return ORIGINS_LEFT; + if (rightActive) return ORIGINS_RIGHT; + return ORIGINS_NONE; + } + + @Override + public String getOriginName(long origin) { + if (origin == OpenXRInputMapper.ORIGIN_LEFT_HAND) return "Left Controller"; + if (origin == OpenXRInputMapper.ORIGIN_RIGHT_HAND) return "Right Controller"; + return "OpenXR"; + } + + public ControllerType getOriginControllerType(long origin) { + return this.inputMapper != null ? this.inputMapper.getControllerTypeForOrigin(origin) : null; + } + + @Override + public VRRenderer createVRRenderer() { + return new OpenXRStereoRenderer(this); + } + + @Override + public boolean isActive() { + return this.sessionRunning && this.xrSessionState >= XR_SESSION_STATE_VISIBLE; + } + + @Override + public float getIPD() { + // Compute from eye poses + if (this.views != null && this.viewCount >= 2) { + XrView left = this.views.get(0); + XrView right = this.views.get(1); + float dx = right.pose().position$().x() - left.pose().position$().x(); + float dy = right.pose().position$().y() - left.pose().position$().y(); + float dz = right.pose().position$().z() - left.pose().position$().z(); + return (float) Math.sqrt(dx * dx + dy * dy + dz * dz); + } + return 0.064F; // default IPD + } + + @Override + public String getRuntimeName() { + return this.runtimeName; + } + + @Override + public String getName() { + return "OpenXR_LWJGL"; + } + + @Override + public Vector2fc getPlayAreaSize() { + try (MemoryStack stack = stackPush()) { + XrExtent2Df bounds = XrExtent2Df.calloc(stack); + int result = xrGetReferenceSpaceBoundsRect(this.xrSession, + XR_REFERENCE_SPACE_TYPE_STAGE, bounds); + if (result >= 0 && bounds.width() > 0 && bounds.height() > 0) { + return new Vector2f(bounds.width(), bounds.height()); + } + } + return null; + } + + /** + * Called by the haptic scheduler to trigger haptic feedback. + */ + public int triggerHaptic(ControllerType controller, float durationSeconds, float frequency, float amplitude) { + if (this.inputMapper == null) return -1; + return this.inputMapper.triggerHaptic(controller, durationSeconds, frequency, amplitude); + } + + @Override + public void destroy() { + VRSettings.LOGGER.info("Vivecraft: Destroying OpenXR..."); + + // Re-enable any API layers we disabled + restoreApiLayers(); + + if (this.inputMapper != null) { + this.inputMapper.destroy(); + this.inputMapper = null; + } + + // End the session if it's still running (required before xrDestroySession) + if (this.sessionRunning && this.xrSession != null) { + try { + xrEndSession(this.xrSession); + VRSettings.LOGGER.info("Vivecraft: OpenXR session ended for shutdown"); + } catch (Exception e) { + VRSettings.LOGGER.warn("Vivecraft: Error ending session: {}", e.getMessage()); + } + } + + // Destroy spaces first (they belong to the session) + if (this.xrAppSpace != null) { + xrDestroySpace(this.xrAppSpace); + this.xrAppSpace = null; + } + if (this.xrViewSpace != null) { + xrDestroySpace(this.xrViewSpace); + this.xrViewSpace = null; + } + + // NOTE: We intentionally do NOT destroy the session, instance, or D3D11 interop here. + // VRState.destroyVR() calls vr.destroy() BEFORE vrRenderer.destroy(), and the renderer + // needs a valid session to call xrDestroySwapchain, and valid D3D11 interop to unregister + // interop textures. Those will be destroyed in destroySessionAndInterop() below, + // which the renderer calls at the end of its own destroy(). + + if (this.viewConfigs != null) { + this.viewConfigs.free(); + this.viewConfigs = null; + } + if (this.views != null) { + this.views.free(); + this.views = null; + } + if (this.frameState != null) { + this.frameState.free(); + this.frameState = null; + } + + this.sessionRunning = false; + this.initialized = false; + OME = null; + super.destroy(); + } + + /** + * Second phase of destruction: destroys the XR session, instance, and D3D11 interop. + * Called by OpenXRStereoRenderer.destroy() AFTER it has cleaned up swapchains and interop textures, + * because those operations require a valid session and D3D11 interop device. + */ + void destroySessionAndInterop() { + VRSettings.LOGGER.info("Vivecraft: Destroying OpenXR session and D3D11 interop..."); + + if (this.xrSession != null) { + xrDestroySession(this.xrSession); + this.xrSession = null; + } + if (this.xrInstance != null) { + xrDestroyInstance(this.xrInstance); + this.xrInstance = null; + } + + // Destroy D3D11 interop after session (interop textures are unregistered by renderer) + if (this.d3d11Interop != null) { + this.d3d11Interop.destroy(); + this.d3d11Interop = null; + } + } + + // === Accessors for the renderer === + + XrSession getSession() { return this.xrSession; } + XrSpace getAppSpace() { return this.xrAppSpace; } + D3D11InteropHelper getD3D11Interop() { return this.d3d11Interop; } + XrFrameState getFrameState() { return this.frameState; } + boolean isFrameStarted() { return this.frameStarted; } + void setFrameStarted(boolean started) { this.frameStarted = started; } + XrView.Buffer getViews() { return this.views; } + int getViewCount() { return this.viewCount; } + XrViewConfigurationView.Buffer getViewConfigs() { return this.viewConfigs; } + + private static String sessionStateToString(int state) { + return switch (state) { + case XR_SESSION_STATE_UNKNOWN -> "UNKNOWN"; + case XR_SESSION_STATE_IDLE -> "IDLE"; + case XR_SESSION_STATE_READY -> "READY"; + case XR_SESSION_STATE_SYNCHRONIZED -> "SYNCHRONIZED"; + case XR_SESSION_STATE_VISIBLE -> "VISIBLE"; + case XR_SESSION_STATE_FOCUSED -> "FOCUSED"; + case XR_SESSION_STATE_STOPPING -> "STOPPING"; + case XR_SESSION_STATE_LOSS_PENDING -> "LOSS_PENDING"; + case XR_SESSION_STATE_EXITING -> "EXITING"; + default -> "UNKNOWN_" + state; + }; + } +} diff --git a/common/src/main/java/org/vivecraft/client_vr/provider/openxr_lwjgl/OpenXRHapticScheduler.java b/common/src/main/java/org/vivecraft/client_vr/provider/openxr_lwjgl/OpenXRHapticScheduler.java new file mode 100644 index 000000000..ad4194324 --- /dev/null +++ b/common/src/main/java/org/vivecraft/client_vr/provider/openxr_lwjgl/OpenXRHapticScheduler.java @@ -0,0 +1,26 @@ +package org.vivecraft.client_vr.provider.openxr_lwjgl; + +import org.vivecraft.client_vr.provider.ControllerType; +import org.vivecraft.client_vr.provider.HapticScheduler; +import org.vivecraft.client_vr.settings.VRSettings; + +import java.util.concurrent.TimeUnit; + +public class OpenXRHapticScheduler extends HapticScheduler { + + @Override + public void queueHapticPulse( + ControllerType controller, float durationSeconds, float frequency, float amplitude, float delaySeconds) + { + this.executor.schedule(() -> { + MCOpenXR openxr = MCOpenXR.get(); + if (openxr != null) { + int result = openxr.triggerHaptic(controller, durationSeconds, frequency, amplitude); + if (result < 0) { + VRSettings.LOGGER.error("Vivecraft: Error triggering OpenXR haptic: {}", + OpenXRUtil.resultToString(result)); + } + } + }, (long) (delaySeconds * 1000000.0F), TimeUnit.MICROSECONDS); + } +} diff --git a/common/src/main/java/org/vivecraft/client_vr/provider/openxr_lwjgl/OpenXRInputMapper.java b/common/src/main/java/org/vivecraft/client_vr/provider/openxr_lwjgl/OpenXRInputMapper.java new file mode 100644 index 000000000..dcaec5930 --- /dev/null +++ b/common/src/main/java/org/vivecraft/client_vr/provider/openxr_lwjgl/OpenXRInputMapper.java @@ -0,0 +1,602 @@ +package org.vivecraft.client_vr.provider.openxr_lwjgl; + +import org.lwjgl.PointerBuffer; +import org.lwjgl.openxr.*; +import org.lwjgl.system.MemoryStack; +import org.vivecraft.client_vr.provider.ControllerType; +import org.vivecraft.client_vr.provider.openvr_lwjgl.VRInputAction; +import org.vivecraft.client_vr.provider.openvr_lwjgl.control.VRInputActionSet; +import org.vivecraft.client_vr.settings.VRSettings; + +import java.nio.LongBuffer; +import java.util.*; + +import static org.lwjgl.openxr.XR10.*; +import static org.lwjgl.system.MemoryStack.stackPush; + +/** + * Manages OpenXR action sets, actions, and interaction profile bindings. + * Maps Vivecraft's VRInputAction/VRInputActionSet abstractions to OpenXR's action system. + */ +public class OpenXRInputMapper { + + // Synthetic origin values for controller identification + public static final long ORIGIN_RIGHT_HAND = 1L; + public static final long ORIGIN_LEFT_HAND = 2L; + + private final XrInstance instance; + private final XrSession session; + + // Action sets + private final Map actionSets = new EnumMap<>(VRInputActionSet.class); + + // Per-VRInputAction -> XrAction mapping + private final Map xrActions = new HashMap<>(); + + // Pose actions + private XrAction leftGripPoseAction; + private XrAction rightGripPoseAction; + private XrAction leftAimPoseAction; + private XrAction rightAimPoseAction; + + // Haptic actions + private XrAction leftHapticAction; + private XrAction rightHapticAction; + + // Pose spaces + private XrSpace leftGripSpace; + private XrSpace rightGripSpace; + private XrSpace leftAimSpace; + private XrSpace rightAimSpace; + + // Subaction paths + private long leftHandPath; + private long rightHandPath; + + // Reusable list for syncActions() to avoid per-frame allocation + private final List activeSetsCache = new ArrayList<>(); + + public OpenXRInputMapper(XrInstance instance, XrSession session) { + this.instance = instance; + this.session = session; + } + + /** + * Initializes all action sets, actions, pose/haptic actions, and suggests interaction profile bindings. + * + * @param inputActions the VRInputAction collection from MCVR + */ + public void init(Collection inputActions) throws Exception { + try (MemoryStack stack = stackPush()) { + // Get subaction paths + this.leftHandPath = getPath("/user/hand/left"); + this.rightHandPath = getPath("/user/hand/right"); + + LongBuffer subactionPaths = stack.callocLong(2); + subactionPaths.put(0, this.leftHandPath); + subactionPaths.put(1, this.rightHandPath); + + // Create action sets for each VRInputActionSet + for (VRInputActionSet actionSetEnum : VRInputActionSet.values()) { + String name = sanitizeActionName(actionSetEnum.name().toLowerCase()); + XrActionSetCreateInfo createInfo = XrActionSetCreateInfo.calloc(stack) + .type(XR_TYPE_ACTION_SET_CREATE_INFO) + .actionSetName(stack.UTF8(name)) + .localizedActionSetName(stack.UTF8(actionSetEnum.name())) + .priority(0); + + PointerBuffer actionSetPtr = stack.callocPointer(1); + int result = xrCreateActionSet(this.instance, createInfo, actionSetPtr); + if (result < 0) { + VRSettings.LOGGER.error("Vivecraft: Failed to create OpenXR action set '{}': {}", + name, OpenXRUtil.resultToString(result)); + continue; + } + this.actionSets.put(actionSetEnum, new XrActionSet(actionSetPtr.get(0), this.instance)); + } + + // Create actions for each VRInputAction + for (VRInputAction action : inputActions) { + XrActionSet actionSet = this.actionSets.get(action.actionSet); + if (actionSet == null) continue; + + String actionName = sanitizeActionName(extractActionName(action.name)); + int xrType = mapActionType(action.type); + if (xrType == -1) continue; + + XrActionCreateInfo actionCreateInfo = XrActionCreateInfo.calloc(stack) + .type(XR_TYPE_ACTION_CREATE_INFO) + .actionName(stack.UTF8(actionName)) + .actionType(xrType) + .localizedActionName(stack.UTF8(actionName)); + + if (action.isHanded()) { + actionCreateInfo.subactionPaths(subactionPaths); + } + + PointerBuffer actionPtr = stack.callocPointer(1); + int result = xrCreateAction(actionSet, actionCreateInfo, actionPtr); + if (result < 0) { + VRSettings.LOGGER.warn("Vivecraft: Failed to create OpenXR action '{}': {}", + actionName, OpenXRUtil.resultToString(result)); + continue; + } + this.xrActions.put(action.name, new XrAction(actionPtr.get(0), actionSet)); + VRSettings.LOGGER.debug("Vivecraft: Created OpenXR action: key='{}' xrName='{}' type='{}' set={}", + action.name, actionName, action.type, action.actionSet); + } + VRSettings.LOGGER.info("Vivecraft: Created {} OpenXR actions total", this.xrActions.size()); + + // Create pose actions (grip and aim for each hand) + XrActionSet globalSet = this.actionSets.get(VRInputActionSet.GLOBAL); + if (globalSet != null) { + this.leftGripPoseAction = createAction(globalSet, "left_grip_pose", + XR_ACTION_TYPE_POSE_INPUT, stack, null); + this.rightGripPoseAction = createAction(globalSet, "right_grip_pose", + XR_ACTION_TYPE_POSE_INPUT, stack, null); + this.leftAimPoseAction = createAction(globalSet, "left_aim_pose", + XR_ACTION_TYPE_POSE_INPUT, stack, null); + this.rightAimPoseAction = createAction(globalSet, "right_aim_pose", + XR_ACTION_TYPE_POSE_INPUT, stack, null); + + // Create haptic actions + this.leftHapticAction = createAction(globalSet, "left_haptic", + XR_ACTION_TYPE_VIBRATION_OUTPUT, stack, null); + this.rightHapticAction = createAction(globalSet, "right_haptic", + XR_ACTION_TYPE_VIBRATION_OUTPUT, stack, null); + } + + // Suggest interaction profile bindings for Oculus Touch controllers + suggestOculusTouchBindings(stack); + + // Suggest KHR simple controller bindings as fallback + suggestSimpleControllerBindings(stack); + + // Attach all action sets to the session + attachActionSets(stack); + + // Create pose action spaces + if (this.leftGripPoseAction != null) { + this.leftGripSpace = createActionSpace(this.leftGripPoseAction, this.leftHandPath, stack); + this.rightGripSpace = createActionSpace(this.rightGripPoseAction, this.rightHandPath, stack); + this.leftAimSpace = createActionSpace(this.leftAimPoseAction, this.leftHandPath, stack); + this.rightAimSpace = createActionSpace(this.rightAimPoseAction, this.rightHandPath, stack); + } + } + } + + private XrAction createAction(XrActionSet actionSet, String name, int type, + MemoryStack stack, LongBuffer subactionPaths) + { + XrActionCreateInfo createInfo = XrActionCreateInfo.calloc(stack) + .type(XR_TYPE_ACTION_CREATE_INFO) + .actionName(stack.UTF8(name)) + .actionType(type) + .localizedActionName(stack.UTF8(name)); + + if (subactionPaths != null) { + createInfo.subactionPaths(subactionPaths); + } + + PointerBuffer actionPtr = stack.callocPointer(1); + int result = xrCreateAction(actionSet, createInfo, actionPtr); + if (result < 0) { + VRSettings.LOGGER.error("Vivecraft: Failed to create OpenXR action '{}': {}", + name, OpenXRUtil.resultToString(result)); + return null; + } + return new XrAction(actionPtr.get(0), actionSet); + } + + private XrSpace createActionSpace(XrAction action, long subactionPath, MemoryStack stack) { + XrActionSpaceCreateInfo spaceCreateInfo = XrActionSpaceCreateInfo.calloc(stack) + .type(XR_TYPE_ACTION_SPACE_CREATE_INFO) + .action(action) + .subactionPath(subactionPath) + .poseInActionSpace(XrPosef.calloc(stack) + .orientation(XrQuaternionf.calloc(stack).set(0, 0, 0, 1)) + .position$(XrVector3f.calloc(stack).set(0, 0, 0))); + + PointerBuffer spacePtr = stack.callocPointer(1); + int result = xrCreateActionSpace(this.session, spaceCreateInfo, spacePtr); + if (result < 0) { + VRSettings.LOGGER.error("Vivecraft: Failed to create action space: {}", + OpenXRUtil.resultToString(result)); + return null; + } + return new XrSpace(spacePtr.get(0), this.session); + } + + private void suggestOculusTouchBindings(MemoryStack stack) { + long profilePath = getPath("/interaction_profiles/oculus/touch_controller"); + List bindings = new ArrayList<>(); + + // Pose bindings + addBinding(bindings, this.leftGripPoseAction, "/user/hand/left/input/grip/pose", stack); + addBinding(bindings, this.rightGripPoseAction, "/user/hand/right/input/grip/pose", stack); + addBinding(bindings, this.leftAimPoseAction, "/user/hand/left/input/aim/pose", stack); + addBinding(bindings, this.rightAimPoseAction, "/user/hand/right/input/aim/pose", stack); + + // Haptic bindings + addBinding(bindings, this.leftHapticAction, "/user/hand/left/output/haptic", stack); + addBinding(bindings, this.rightHapticAction, "/user/hand/right/output/haptic", stack); + + // Map game actions to Oculus Touch inputs. + // Action names MUST match VRInputAction.name format: "{VRInputActionSet.name}/in/{keyMapping.getName()}" + // The action set in the name depends on getSpecialActionParams() overrides, isModBinding(), and category. + + // === MOVEMENT (vector2 actions for primary movement — these are what processBindings() checks first) === + // FreeMoveStrafe (left thumbstick, vector2) - primary movement input + mapActionBinding(bindings, "/actions/ingame/in/vivecraft.key.freeMoveStrafe", + "/user/hand/left/input/thumbstick", stack); + // FreeMoveRotate (right thumbstick, vector2) - snap turn / smooth rotation + mapActionBinding(bindings, "/actions/ingame/in/vivecraft.key.freeMoveRotate", + "/user/hand/right/input/thumbstick", stack); + + // Individual movement axes (vector1 fallback — used when freeMoveStrafe is not active) + mapActionBinding(bindings, "/actions/ingame/in/key.forward", "/user/hand/left/input/thumbstick/y", stack); + mapActionBinding(bindings, "/actions/ingame/in/key.back", "/user/hand/left/input/thumbstick/y", stack); + mapActionBinding(bindings, "/actions/ingame/in/key.left", "/user/hand/left/input/thumbstick/x", stack); + mapActionBinding(bindings, "/actions/ingame/in/key.right", "/user/hand/left/input/thumbstick/x", stack); + + // Rotate left/right (right thumbstick X, vector1) - INGAME set (in userKeyBindingSet → vanillaBindingSet) + mapActionBinding(bindings, "/actions/ingame/in/vivecraft.key.rotateRight", + "/user/hand/right/input/thumbstick/x", stack); + mapActionBinding(bindings, "/actions/ingame/in/vivecraft.key.rotateLeft", + "/user/hand/right/input/thumbstick/x", stack); + + // === COMBAT / INTERACTION === + // Attack (right trigger) - boolean action, runtime converts float>0.5 to true + mapActionBinding(bindings, "/actions/ingame/in/key.attack", "/user/hand/right/input/trigger/value", stack); + // Use/place (left trigger) + mapActionBinding(bindings, "/actions/ingame/in/key.use", "/user/hand/left/input/trigger/value", stack); + // VR interact (right A button) - CONTEXTUAL set (override in getSpecialActionParams) + mapActionBinding(bindings, "/actions/contextual/in/vivecraft.key.vrInteract", + "/user/hand/right/input/a/click", stack); + + // === BUTTONS === + // Menu button (left menu/Oculus button) - GLOBAL set (override in getSpecialActionParams) + mapActionBinding(bindings, "/actions/global/in/vivecraft.key.ingameMenuButton", + "/user/hand/left/input/menu/click", stack); + // Jump (right A button) - INGAME set + mapActionBinding(bindings, "/actions/ingame/in/key.jump", "/user/hand/right/input/a/click", stack); + // Sneak (right B button) - INGAME set + mapActionBinding(bindings, "/actions/ingame/in/key.sneak", "/user/hand/right/input/b/click", stack); + // Inventory (left Y button) - GLOBAL set (override in getSpecialActionParams) + mapActionBinding(bindings, "/actions/global/in/key.inventory", "/user/hand/left/input/y/click", stack); + // Radial menu (left X button) - INGAME set (in hiddenKeyBindingSet → vanillaBindingSet) + mapActionBinding(bindings, "/actions/ingame/in/vivecraft.key.radialMenu", + "/user/hand/left/input/x/click", stack); + // Sprint (left thumbstick click) - INGAME set + mapActionBinding(bindings, "/actions/ingame/in/key.sprint", "/user/hand/left/input/thumbstick/click", stack); + + // === GUI ACTIONS (needed for menu/screen interaction with controllers) === + // Left click (right trigger in GUI) — for clicking buttons in menus + mapActionBinding(bindings, "/actions/gui/in/vivecraft.key.guiLeftClick", + "/user/hand/right/input/trigger/value", stack); + // Right click (left trigger in GUI) + mapActionBinding(bindings, "/actions/gui/in/vivecraft.key.guiRightClick", + "/user/hand/left/input/trigger/value", stack); + // GUI scroll (right thumbstick, vector2) — for scrolling in menus + mapActionBinding(bindings, "/actions/gui/in/vivecraft.key.guiScrollAxis", + "/user/hand/right/input/thumbstick", stack); + + if (bindings.isEmpty()) return; + + XrActionSuggestedBinding.Buffer bindingBuffer = XrActionSuggestedBinding.calloc(bindings.size(), stack); + for (int i = 0; i < bindings.size(); i++) { + bindingBuffer.get(i).set(bindings.get(i)); + } + + XrInteractionProfileSuggestedBinding suggestion = XrInteractionProfileSuggestedBinding.calloc(stack) + .type(XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING) + .interactionProfile(profilePath) + .suggestedBindings(bindingBuffer); + + int result = xrSuggestInteractionProfileBindings(this.instance, suggestion); + if (result < 0) { + VRSettings.LOGGER.warn("Vivecraft: Failed to suggest Oculus Touch bindings: {}", + OpenXRUtil.resultToString(result)); + } + } + + private void suggestSimpleControllerBindings(MemoryStack stack) { + long profilePath = getPath("/interaction_profiles/khr/simple_controller"); + List bindings = new ArrayList<>(); + + // Simple controller only has select, menu, grip/aim pose, and haptic + addBinding(bindings, this.leftGripPoseAction, "/user/hand/left/input/grip/pose", stack); + addBinding(bindings, this.rightGripPoseAction, "/user/hand/right/input/grip/pose", stack); + addBinding(bindings, this.leftAimPoseAction, "/user/hand/left/input/aim/pose", stack); + addBinding(bindings, this.rightAimPoseAction, "/user/hand/right/input/aim/pose", stack); + addBinding(bindings, this.leftHapticAction, "/user/hand/left/output/haptic", stack); + addBinding(bindings, this.rightHapticAction, "/user/hand/right/output/haptic", stack); + + if (bindings.isEmpty()) return; + + XrActionSuggestedBinding.Buffer bindingBuffer = XrActionSuggestedBinding.calloc(bindings.size(), stack); + for (int i = 0; i < bindings.size(); i++) { + bindingBuffer.get(i).set(bindings.get(i)); + } + + XrInteractionProfileSuggestedBinding suggestion = XrInteractionProfileSuggestedBinding.calloc(stack) + .type(XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING) + .interactionProfile(profilePath) + .suggestedBindings(bindingBuffer); + + int result = xrSuggestInteractionProfileBindings(this.instance, suggestion); + if (result < 0) { + VRSettings.LOGGER.warn("Vivecraft: Failed to suggest simple controller bindings: {}", + OpenXRUtil.resultToString(result)); + } + } + + private void addBinding(List bindings, XrAction action, + String path, MemoryStack stack) + { + if (action == null) return; + bindings.add(XrActionSuggestedBinding.calloc(stack) + .action(action) + .binding(getPath(path))); + } + + private void mapActionBinding(List bindings, String actionName, + String bindingPath, MemoryStack stack) + { + XrAction xrAction = this.xrActions.get(actionName); + if (xrAction != null) { + addBinding(bindings, xrAction, bindingPath, stack); + VRSettings.LOGGER.debug("Vivecraft: Bound OpenXR action '{}' -> '{}'", actionName, bindingPath); + } else { + VRSettings.LOGGER.warn("Vivecraft: No XrAction found for '{}' — binding to '{}' skipped. " + + "Available actions: {}", actionName, bindingPath, + this.xrActions.keySet().stream().sorted().limit(30).toList()); + } + } + + private void attachActionSets(MemoryStack stack) { + List sets = new ArrayList<>(this.actionSets.values()); + if (sets.isEmpty()) return; + + PointerBuffer actionSetPtrs = stack.callocPointer(sets.size()); + for (int i = 0; i < sets.size(); i++) { + actionSetPtrs.put(i, sets.get(i)); + } + + XrSessionActionSetsAttachInfo attachInfo = XrSessionActionSetsAttachInfo.calloc(stack) + .type(XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO) + .actionSets(actionSetPtrs); + + int result = xrAttachSessionActionSets(this.session, attachInfo); + if (result < 0) { + VRSettings.LOGGER.error("Vivecraft: Failed to attach action sets: {}", + OpenXRUtil.resultToString(result)); + } + } + + /** + * Syncs action state for the given active action sets. + */ + public void syncActions(Collection activeActionSets) { + try (MemoryStack stack = stackPush()) { + this.activeSetsCache.clear(); + for (VRInputActionSet set : activeActionSets) { + XrActionSet xrSet = this.actionSets.get(set); + if (xrSet != null) { + this.activeSetsCache.add(xrSet); + } + } + if (this.activeSetsCache.isEmpty()) return; + + XrActiveActionSet.Buffer activeBuffer = XrActiveActionSet.calloc(this.activeSetsCache.size(), stack); + for (int i = 0; i < this.activeSetsCache.size(); i++) { + activeBuffer.get(i) + .actionSet(this.activeSetsCache.get(i)) + .subactionPath(XR_NULL_PATH); + } + + XrActionsSyncInfo syncInfo = XrActionsSyncInfo.calloc(stack) + .type(XR_TYPE_ACTIONS_SYNC_INFO) + .activeActionSets(activeBuffer); + + xrSyncActions(this.session, syncInfo); + } + } + + /** + * Reads boolean action state. + */ + public boolean getActionStateBoolean(VRInputAction action, ControllerType hand, + VRInputAction.DigitalData outData) + { + XrAction xrAction = this.xrActions.get(action.name); + if (xrAction == null) return false; + + try (MemoryStack stack = stackPush()) { + XrActionStateGetInfo getInfo = XrActionStateGetInfo.calloc(stack) + .type(XR_TYPE_ACTION_STATE_GET_INFO) + .action(xrAction); + + if (action.isHanded()) { + getInfo.subactionPath(hand == ControllerType.LEFT ? this.leftHandPath : this.rightHandPath); + } + + XrActionStateBoolean state = XrActionStateBoolean.calloc(stack) + .type(XR_TYPE_ACTION_STATE_BOOLEAN); + + int result = xrGetActionStateBoolean(this.session, getInfo, state); + if (result < 0) return false; + + outData.state = state.currentState(); + outData.isChanged = state.changedSinceLastSync(); + outData.isActive = state.isActive(); + outData.activeOrigin = hand == ControllerType.LEFT ? ORIGIN_LEFT_HAND : ORIGIN_RIGHT_HAND; + return true; + } + } + + /** + * Reads float/vector1 action state. + */ + public boolean getActionStateFloat(VRInputAction action, ControllerType hand, + VRInputAction.AnalogData outData) + { + XrAction xrAction = this.xrActions.get(action.name); + if (xrAction == null) return false; + + try (MemoryStack stack = stackPush()) { + XrActionStateGetInfo getInfo = XrActionStateGetInfo.calloc(stack) + .type(XR_TYPE_ACTION_STATE_GET_INFO) + .action(xrAction); + + if (action.isHanded()) { + getInfo.subactionPath(hand == ControllerType.LEFT ? this.leftHandPath : this.rightHandPath); + } + + XrActionStateFloat state = XrActionStateFloat.calloc(stack) + .type(XR_TYPE_ACTION_STATE_FLOAT); + + int result = xrGetActionStateFloat(this.session, getInfo, state); + if (result < 0) return false; + + float prevX = outData.x; + outData.x = state.currentState(); + outData.deltaX = outData.x - prevX; + outData.isActive = state.isActive(); + outData.activeOrigin = hand == ControllerType.LEFT ? ORIGIN_LEFT_HAND : ORIGIN_RIGHT_HAND; + return true; + } + } + + /** + * Reads vector2 action state. + */ + public boolean getActionStateVector2f(VRInputAction action, ControllerType hand, + VRInputAction.AnalogData outData) + { + XrAction xrAction = this.xrActions.get(action.name); + if (xrAction == null) return false; + + try (MemoryStack stack = stackPush()) { + XrActionStateGetInfo getInfo = XrActionStateGetInfo.calloc(stack) + .type(XR_TYPE_ACTION_STATE_GET_INFO) + .action(xrAction); + + if (action.isHanded()) { + getInfo.subactionPath(hand == ControllerType.LEFT ? this.leftHandPath : this.rightHandPath); + } + + XrActionStateVector2f state = XrActionStateVector2f.calloc(stack) + .type(XR_TYPE_ACTION_STATE_VECTOR2F); + + int result = xrGetActionStateVector2f(this.session, getInfo, state); + if (result < 0) return false; + + float prevX = outData.x; + float prevY = outData.y; + outData.x = state.currentState().x(); + outData.y = state.currentState().y(); + outData.deltaX = outData.x - prevX; + outData.deltaY = outData.y - prevY; + outData.isActive = state.isActive(); + outData.activeOrigin = hand == ControllerType.LEFT ? ORIGIN_LEFT_HAND : ORIGIN_RIGHT_HAND; + return true; + } + } + + /** + * Triggers haptic feedback on the specified controller. + */ + public int triggerHaptic(ControllerType controller, float durationSeconds, float frequency, float amplitude) { + XrAction action = controller == ControllerType.LEFT ? this.leftHapticAction : this.rightHapticAction; + if (action == null) return -1; + + try (MemoryStack stack = stackPush()) { + XrHapticVibration vibration = XrHapticVibration.calloc(stack) + .type(XR_TYPE_HAPTIC_VIBRATION) + .duration((long) (durationSeconds * 1_000_000_000L)) + .frequency(frequency) + .amplitude(amplitude); + + XrHapticActionInfo hapticInfo = XrHapticActionInfo.calloc(stack) + .type(XR_TYPE_HAPTIC_ACTION_INFO) + .action(action) + .subactionPath(controller == ControllerType.LEFT ? this.leftHandPath : this.rightHandPath); + + return xrApplyHapticFeedback(this.session, hapticInfo, + XrHapticBaseHeader.create(vibration.address())); + } + } + + // Accessors for pose spaces + public XrSpace getLeftGripSpace() { return this.leftGripSpace; } + public XrSpace getRightGripSpace() { return this.rightGripSpace; } + public XrSpace getLeftAimSpace() { return this.leftAimSpace; } + public XrSpace getRightAimSpace() { return this.rightAimSpace; } + + public XrAction getXrAction(String actionName) { + return this.xrActions.get(actionName); + } + + public ControllerType getControllerTypeForOrigin(long origin) { + if (origin == ORIGIN_RIGHT_HAND) return ControllerType.RIGHT; + if (origin == ORIGIN_LEFT_HAND) return ControllerType.LEFT; + return null; + } + + private long getPath(String pathString) { + try (MemoryStack stack = stackPush()) { + LongBuffer pathBuf = stack.callocLong(1); + int result = xrStringToPath(this.instance, stack.UTF8(pathString), pathBuf); + if (result < 0) { + VRSettings.LOGGER.error("Vivecraft: Failed to convert path '{}': {}", + pathString, OpenXRUtil.resultToString(result)); + return XR_NULL_PATH; + } + return pathBuf.get(0); + } + } + + private static String sanitizeActionName(String name) { + // OpenXR action names must be lowercase, alphanumeric, dash, dot, or underscore + // and must start with a lowercase letter or underscore + String sanitized = name.toLowerCase() + .replaceAll("[^a-z0-9._-]", "_") + .replaceAll("^[^a-z_]", "_"); + if (sanitized.length() > 64) { + sanitized = sanitized.substring(0, 64); + } + return sanitized; + } + + private static String extractActionName(String fullPath) { + // "/actions/ingame/in/key.attack" -> "key_attack" + int lastSlash = fullPath.lastIndexOf('/'); + String name = lastSlash >= 0 ? fullPath.substring(lastSlash + 1) : fullPath; + return name.replace('.', '_'); + } + + private static int mapActionType(String vrType) { + return switch (vrType) { + case "boolean" -> XR_ACTION_TYPE_BOOLEAN_INPUT; + case "vector1" -> XR_ACTION_TYPE_FLOAT_INPUT; + case "vector2" -> XR_ACTION_TYPE_VECTOR2F_INPUT; + default -> -1; + }; + } + + public void destroy() { + // Destroy pose spaces + if (this.leftGripSpace != null) xrDestroySpace(this.leftGripSpace); + if (this.rightGripSpace != null) xrDestroySpace(this.rightGripSpace); + if (this.leftAimSpace != null) xrDestroySpace(this.leftAimSpace); + if (this.rightAimSpace != null) xrDestroySpace(this.rightAimSpace); + + // Destroy actions (action sets own them, destroying action sets handles this) + for (XrActionSet actionSet : this.actionSets.values()) { + xrDestroyActionSet(actionSet); + } + this.actionSets.clear(); + this.xrActions.clear(); + } +} diff --git a/common/src/main/java/org/vivecraft/client_vr/provider/openxr_lwjgl/OpenXRStereoRenderer.java b/common/src/main/java/org/vivecraft/client_vr/provider/openxr_lwjgl/OpenXRStereoRenderer.java new file mode 100644 index 000000000..9825e41c6 --- /dev/null +++ b/common/src/main/java/org/vivecraft/client_vr/provider/openxr_lwjgl/OpenXRStereoRenderer.java @@ -0,0 +1,623 @@ +package org.vivecraft.client_vr.provider.openxr_lwjgl; + +import com.mojang.blaze3d.opengl.GlStateManager; +import net.minecraft.network.chat.Component; +import net.minecraft.util.Tuple; +import org.joml.Matrix4f; +import org.lwjgl.PointerBuffer; +import org.lwjgl.opengl.GL11C; +import org.lwjgl.opengl.GL30C; +import org.lwjgl.openxr.*; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.system.MemoryUtil; +import org.vivecraft.client_vr.provider.MCVR; +import org.vivecraft.client_vr.provider.VRRenderer; +import org.vivecraft.client_vr.render.RenderConfigException; +import org.vivecraft.client_vr.render.helpers.RenderHelper; +import org.vivecraft.client_vr.settings.VRSettings; + +import java.nio.ByteBuffer; +import java.nio.IntBuffer; + +import static org.lwjgl.opengl.GL30C.*; +import static org.lwjgl.openxr.XR10.*; +import static org.lwjgl.system.MemoryStack.stackPush; + +/** + * VRRenderer implementation for OpenXR using D3D11 swapchains + WGL_NV_DX_interop2. + * + * Rendering pipeline: + * 1. Minecraft renders to mod-owned OpenGL textures (LeftEyeTextureId, RightEyeTextureId) + * 2. In endFrame(), we acquire D3D11 swapchain images from OpenXR + * 3. Lock the interop GL textures (registered from D3D11 textures) for GL access + * 4. Blit from mod-owned GL textures to the interop GL textures + * 5. Unlock the interop textures (flushes to D3D11) + * 6. Release swapchain images and call xrEndFrame + */ +public class OpenXRStereoRenderer extends VRRenderer { + + private final MCOpenXR openxr; + + // OpenXR swapchains (D3D11-backed) + private XrSwapchain leftSwapchain; + private XrSwapchain rightSwapchain; + + // D3D11 swapchain texture pointers (runtime-owned, one per swapchain image) + private long[] leftD3D11Textures; + private long[] rightD3D11Textures; + + // Intermediate D3D11 textures that WE own (one per eye, created with SHARED flag for interop) + private long leftIntermediateD3D11; + private long rightIntermediateD3D11; + + // OpenGL texture names registered via WGL_NV_DX_interop2 for the intermediate textures + private int leftInteropGLTexture; + private int rightInteropGLTexture; + + // WGL interop handles for the intermediate textures (one per eye) + private long leftInteropHandle; + private long rightInteropHandle; + + // DXGI format used for swapchain (stored so intermediate textures match) + private int swapchainDxgiFormat; + + // Framebuffer objects for blitting + private int leftBlitFBO; + private int rightBlitFBO; + private int readBlitFBO; // Shared read FBO reused every frame + + // Swapchain dimensions + private int swapchainWidth; + private int swapchainHeight; + + // Composition layer data + private XrCompositionLayerProjectionView.Buffer projectionLayerViews; + + public OpenXRStereoRenderer(MCVR vr) { + super(vr); + this.openxr = (MCOpenXR) vr; + } + + @Override + public Tuple getRenderTextureSizes() { + if (this.resolution == null) { + XrViewConfigurationView.Buffer viewConfigs = this.openxr.getViewConfigs(); + if (viewConfigs == null || viewConfigs.capacity() < 2) { + this.resolution = new Tuple<>(2048, 2048); + } else { + int width = viewConfigs.get(0).recommendedImageRectWidth(); + int height = viewConfigs.get(0).recommendedImageRectHeight(); + this.resolution = new Tuple<>(width, height); + VRSettings.LOGGER.info("Vivecraft: OpenXR Render Res {}x{}", width, height); + } + this.ss = 1.0F; // No SteamVR supersampling; user controls via mod settings + } + return this.resolution; + } + + @Override + protected Matrix4f getProjectionMatrix(int eyeType, float nearClip, float farClip) { + XrView.Buffer views = this.openxr.getViews(); + if (views == null || this.openxr.getViewCount() < 2) { + // Fallback: standard symmetric projection + return new Matrix4f().setPerspective( + (float) Math.toRadians(90.0), 1.0F, nearClip, farClip); + } + + XrView view = views.get(eyeType); + return OpenXRUtil.fovToProjectionMatrix(view.fov(), nearClip, Math.min(farClip, Float.MAX_VALUE)); + } + + @Override + public void createRenderTexture(int width, int height) { + // Create mod-owned textures (same as OpenVR) for the framebuffers + int boundTextureId = GlStateManager._getInteger(GL11C.GL_TEXTURE_BINDING_2D); + + // Left eye texture + this.LeftEyeTextureId = GlStateManager._genTexture(); + GlStateManager._bindTexture(this.LeftEyeTextureId); + GlStateManager._texParameter(GL11C.GL_TEXTURE_2D, GL11C.GL_TEXTURE_MIN_FILTER, GL11C.GL_LINEAR); + GlStateManager._texParameter(GL11C.GL_TEXTURE_2D, GL11C.GL_TEXTURE_MAG_FILTER, GL11C.GL_LINEAR); + GlStateManager._texImage2D(GL11C.GL_TEXTURE_2D, 0, GL11C.GL_RGBA8, width, height, 0, + GL11C.GL_RGBA, GL11C.GL_INT, null); + + // Right eye texture + this.RightEyeTextureId = GlStateManager._genTexture(); + GlStateManager._bindTexture(this.RightEyeTextureId); + GlStateManager._texParameter(GL11C.GL_TEXTURE_2D, GL11C.GL_TEXTURE_MIN_FILTER, GL11C.GL_LINEAR); + GlStateManager._texParameter(GL11C.GL_TEXTURE_2D, GL11C.GL_TEXTURE_MAG_FILTER, GL11C.GL_LINEAR); + GlStateManager._texImage2D(GL11C.GL_TEXTURE_2D, 0, GL11C.GL_RGBA8, width, height, 0, + GL11C.GL_RGBA, GL11C.GL_INT, null); + + GlStateManager._bindTexture(boundTextureId); + + // Create OpenXR swapchains (D3D11-backed) + this.swapchainWidth = width; + this.swapchainHeight = height; + createSwapchains(width, height); + + // Create blit FBOs (pre-allocated, reused every frame) + this.leftBlitFBO = glGenFramebuffers(); + this.rightBlitFBO = glGenFramebuffers(); + this.readBlitFBO = glGenFramebuffers(); + + this.lastError = RenderHelper.checkGLError("create OpenXR render textures"); + } + + private void createSwapchains(int width, int height) { + XrSession session = this.openxr.getSession(); + if (session == null) return; + + try (MemoryStack stack = stackPush()) { + // Enumerate supported swapchain formats (these are DXGI_FORMAT values for D3D11) + IntBuffer formatCount = stack.callocInt(1); + xrEnumerateSwapchainFormats(session, formatCount, null); + long[] formats = new long[formatCount.get(0)]; + if (formats.length > 0) { + var formatBuffer = stack.callocLong(formats.length); + formatCount.put(0, formats.length); + xrEnumerateSwapchainFormats(session, formatCount, formatBuffer); + StringBuilder fmtStr = new StringBuilder(); + for (int i = 0; i < formats.length; i++) { + formats[i] = formatBuffer.get(i); + if (i > 0) fmtStr.append(", "); + fmtStr.append(formats[i]); + } + VRSettings.LOGGER.info("Vivecraft: OpenXR D3D11 swapchain formats: {}", fmtStr); + } + + // Pick format: prefer non-SRGB for better WGL_NV_DX_interop2 compatibility. + // SRGB formats (29, 91) have known sharing limitations with the interop extension. + // DXGI_FORMAT_R8G8B8A8_UNORM = 28 (preferred) + // DXGI_FORMAT_B8G8R8A8_UNORM = 87 + // DXGI_FORMAT_R8G8B8A8_UNORM_SRGB = 29 (fallback) + // DXGI_FORMAT_B8G8R8A8_UNORM_SRGB = 91 (fallback) + long chosenFormat = 28; // DXGI_FORMAT_R8G8B8A8_UNORM default + // First pass: look for non-SRGB formats + for (long fmt : formats) { + if (fmt == 28) { // DXGI_FORMAT_R8G8B8A8_UNORM - best choice + chosenFormat = 28; + break; + } else if (fmt == 87) { // DXGI_FORMAT_B8G8R8A8_UNORM + chosenFormat = 87; + break; + } + } + // Second pass: if no non-SRGB found, accept SRGB + if (chosenFormat == 28) { + boolean foundExact = false; + for (long fmt : formats) { + if (fmt == 28) { foundExact = true; break; } + } + if (!foundExact) { + for (long fmt : formats) { + if (fmt == 29 || fmt == 87 || fmt == 91) { + chosenFormat = fmt; + break; + } + } + } + } + VRSettings.LOGGER.info("Vivecraft: OpenXR D3D11 swapchain chosen format: {} (DXGI_FORMAT)", + chosenFormat); + + this.swapchainDxgiFormat = (int) chosenFormat; + + // Create left swapchain + this.leftSwapchain = createSwapchain(session, width, height, chosenFormat, stack); + this.leftD3D11Textures = enumerateD3D11SwapchainImages(this.leftSwapchain, stack); + + // Create right swapchain + this.rightSwapchain = createSwapchain(session, width, height, chosenFormat, stack); + this.rightD3D11Textures = enumerateD3D11SwapchainImages(this.rightSwapchain, stack); + + VRSettings.LOGGER.info("Vivecraft: OpenXR D3D11 swapchains created ({} images each)", + this.leftD3D11Textures.length); + + // Create INTERMEDIATE D3D11 textures that we own (one per eye). + // Runtime-owned swapchain textures often can't be directly registered with + // WGL_NV_DX_interop2, so we create our own textures with D3D11_RESOURCE_MISC_SHARED + // flag, register those for interop, render into them via GL, then CopyResource + // from intermediate -> swapchain texture each frame. + D3D11InteropHelper interop = this.openxr.getD3D11Interop(); + if (interop != null) { + // Create intermediate textures + this.leftIntermediateD3D11 = interop.createTexture2D(width, height, this.swapchainDxgiFormat); + this.rightIntermediateD3D11 = interop.createTexture2D(width, height, this.swapchainDxgiFormat); + + if (this.leftIntermediateD3D11 == 0 || this.rightIntermediateD3D11 == 0) { + VRSettings.LOGGER.error("Vivecraft: Failed to create intermediate D3D11 textures!"); + } else { + // Register the intermediate textures (not the swapchain textures!) for GL interop + this.leftInteropGLTexture = GlStateManager._genTexture(); + this.leftInteropHandle = interop.registerTexture( + this.leftIntermediateD3D11, this.leftInteropGLTexture); + VRSettings.LOGGER.info("Vivecraft: Left intermediate: D3D11=0x{} -> GL={} (interop=0x{})", + Long.toHexString(this.leftIntermediateD3D11), + this.leftInteropGLTexture, Long.toHexString(this.leftInteropHandle)); + + this.rightInteropGLTexture = GlStateManager._genTexture(); + this.rightInteropHandle = interop.registerTexture( + this.rightIntermediateD3D11, this.rightInteropGLTexture); + VRSettings.LOGGER.info("Vivecraft: Right intermediate: D3D11=0x{} -> GL={} (interop=0x{})", + Long.toHexString(this.rightIntermediateD3D11), + this.rightInteropGLTexture, Long.toHexString(this.rightInteropHandle)); + + if (this.leftInteropHandle == 0 || this.rightInteropHandle == 0) { + VRSettings.LOGGER.error("Vivecraft: Failed to register intermediate textures for interop!"); + } + } + } + + // Allocate composition layer views + this.projectionLayerViews = XrCompositionLayerProjectionView.calloc(2); + for (int i = 0; i < 2; i++) { + this.projectionLayerViews.get(i) + .type(XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW); + } + } + } + + private XrSwapchain createSwapchain(XrSession session, int width, int height, long format, + MemoryStack stack) + { + XrSwapchainCreateInfo createInfo = XrSwapchainCreateInfo.calloc(stack) + .type(XR_TYPE_SWAPCHAIN_CREATE_INFO) + .usageFlags(XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT) + .format(format) + .sampleCount(1) + .width(width) + .height(height) + .faceCount(1) + .arraySize(1) + .mipCount(1); + + PointerBuffer swapchainPtr = stack.callocPointer(1); + int result = xrCreateSwapchain(session, createInfo, swapchainPtr); + if (result < 0) { + VRSettings.LOGGER.error("Vivecraft: Failed to create OpenXR swapchain: {}", + OpenXRUtil.resultToString(result)); + return null; + } + return new XrSwapchain(swapchainPtr.get(0), session); + } + + /** + * Enumerates D3D11 swapchain images as raw texture pointers. + * Since LWJGL doesn't have XrSwapchainImageD3D11KHR, we use raw ByteBuffers. + * + * Layout of XrSwapchainImageD3D11KHR (64-bit): + * XrStructureType type; // offset 0, 4 bytes + * [4 bytes padding] + * void* next; // offset 8, 8 bytes + * ID3D11Texture2D* texture; // offset 16, 8 bytes + * Total: 24 bytes per image + */ + private long[] enumerateD3D11SwapchainImages(XrSwapchain swapchain, MemoryStack stack) { + if (swapchain == null) return new long[0]; + + IntBuffer imageCount = stack.callocInt(1); + xrEnumerateSwapchainImages(swapchain, imageCount, null); + int count = imageCount.get(0); + + // Allocate raw buffer for D3D11 swapchain images + int structSize = 24; // sizeof(XrSwapchainImageD3D11KHR) on 64-bit + ByteBuffer images = MemoryUtil.memCalloc(count * structSize); + + // Set type field for each image + for (int i = 0; i < count; i++) { + images.putInt(i * structSize, D3D11InteropHelper.XR_TYPE_SWAPCHAIN_IMAGE_D3D11_KHR); + } + + imageCount.put(0, count); + xrEnumerateSwapchainImages(swapchain, imageCount, + XrSwapchainImageBaseHeader.create(MemoryUtil.memAddress(images), count)); + + long[] textures = new long[count]; + for (int i = 0; i < count; i++) { + // Read the ID3D11Texture2D* pointer at offset 16 within each struct + textures[i] = MemoryUtil.memGetAddress(MemoryUtil.memAddress(images) + (long) i * structSize + 16); + } + + MemoryUtil.memFree(images); + return textures; + } + + @Override + public void endFrame() throws RenderConfigException { + if (!this.openxr.isFrameStarted()) return; + + XrSession session = this.openxr.getSession(); + XrFrameState frameState = this.openxr.getFrameState(); + + if (session == null || frameState == null) { + this.openxr.setFrameStarted(false); + return; + } + + D3D11InteropHelper interop = this.openxr.getD3D11Interop(); + + // Wrap everything in try-finally to guarantee xrEndFrame is always called when + // xrBeginFrame was called. Orphaning a begun frame causes the runtime to stall + // (xrWaitFrame blocks forever on the next call). + boolean frameShouldRender = false; + boolean frameEnded = false; + try (MemoryStack stack = stackPush()) { + frameShouldRender = frameState.shouldRender() + && this.leftSwapchain != null && this.rightSwapchain != null + && interop != null && this.leftInteropHandle != 0 && this.rightInteropHandle != 0; + + if (frameShouldRender) { + // === Left eye === + // 1. Acquire the swapchain image (tells runtime which D3D11 texture slot to use) + int leftIdx = acquireAndWaitSwapchainImage(this.leftSwapchain, stack); + if (leftIdx >= 0 && leftIdx < this.leftD3D11Textures.length) { + // 2. Lock our intermediate texture for GL access + boolean locked = interop.lockObjects(this.leftInteropHandle); + if (locked) { + // 3. Blit from mod-owned GL texture -> intermediate GL texture (interop-registered) + blitTexture(this.LeftEyeTextureId, this.leftInteropGLTexture, + this.leftBlitFBO, this.swapchainWidth, this.swapchainHeight); + + // 4. Flush GL commands before unlocking + GL11C.glFlush(); + + // 5. Unlock (flushes GL writes to the D3D11 intermediate texture) + interop.unlockObjects(this.leftInteropHandle); + + // 6. CopyResource: intermediate D3D11 texture -> runtime swapchain D3D11 texture + interop.copyResource(this.leftD3D11Textures[leftIdx], this.leftIntermediateD3D11); + } + + // 7. Release the swapchain image (always, even if lock failed, to keep swapchain valid) + releaseSwapchainImage(this.leftSwapchain, stack); + } + + // === Right eye === + int rightIdx = acquireAndWaitSwapchainImage(this.rightSwapchain, stack); + if (rightIdx >= 0 && rightIdx < this.rightD3D11Textures.length) { + boolean locked = interop.lockObjects(this.rightInteropHandle); + if (locked) { + blitTexture(this.RightEyeTextureId, this.rightInteropGLTexture, + this.rightBlitFBO, this.swapchainWidth, this.swapchainHeight); + + GL11C.glFlush(); + interop.unlockObjects(this.rightInteropHandle); + + interop.copyResource(this.rightD3D11Textures[rightIdx], this.rightIntermediateD3D11); + } + + releaseSwapchainImage(this.rightSwapchain, stack); + } + + // Build composition layer + XrView.Buffer views = this.openxr.getViews(); + if (views != null && this.openxr.getViewCount() >= 2) { + for (int eye = 0; eye < 2; eye++) { + XrCompositionLayerProjectionView layerView = this.projectionLayerViews.get(eye); + layerView.pose(views.get(eye).pose()); + layerView.fov(views.get(eye).fov()); + layerView.subImage() + .swapchain(eye == 0 ? this.leftSwapchain : this.rightSwapchain) + .imageArrayIndex(0); + layerView.subImage().imageRect() + .offset(XrOffset2Di.calloc(stack).set(0, 0)) + .extent(XrExtent2Di.calloc(stack) + .set(this.swapchainWidth, this.swapchainHeight)); + } + + XrCompositionLayerProjection layer = XrCompositionLayerProjection.calloc(stack) + .type(XR_TYPE_COMPOSITION_LAYER_PROJECTION) + .space(this.openxr.getAppSpace()) + .views(this.projectionLayerViews); + + PointerBuffer layersPtr = stack.callocPointer(1); + layersPtr.put(0, XrCompositionLayerBaseHeader.create(layer.address())); + + XrFrameEndInfo endInfo = XrFrameEndInfo.calloc(stack) + .type(XR_TYPE_FRAME_END_INFO) + .displayTime(frameState.predictedDisplayTime()) + .environmentBlendMode(XR_ENVIRONMENT_BLEND_MODE_OPAQUE) + .layers(layersPtr); + + int result = xrEndFrame(session, endInfo); + frameEnded = true; + if (result < 0) { + VRSettings.LOGGER.error("Vivecraft: xrEndFrame failed: {}", + OpenXRUtil.resultToString(result)); + } + } + } + + // If we didn't submit a frame with layers, still end it (empty frame) + if (!frameEnded) { + XrFrameEndInfo endInfo = XrFrameEndInfo.calloc(stack) + .type(XR_TYPE_FRAME_END_INFO) + .displayTime(frameState.predictedDisplayTime()) + .environmentBlendMode(XR_ENVIRONMENT_BLEND_MODE_OPAQUE); + xrEndFrame(session, endInfo); + } + } catch (Exception e) { + // If anything went wrong, try to end the frame to avoid runtime stall. + // This is a last-resort safety net. + if (!frameEnded) { + try (MemoryStack stack = stackPush()) { + XrFrameEndInfo endInfo = XrFrameEndInfo.calloc(stack) + .type(XR_TYPE_FRAME_END_INFO) + .displayTime(frameState.predictedDisplayTime()) + .environmentBlendMode(XR_ENVIRONMENT_BLEND_MODE_OPAQUE); + xrEndFrame(session, endInfo); + } catch (Exception ignored) { + // Nothing more we can do + } + } + VRSettings.LOGGER.error("Vivecraft: Error in OpenXR endFrame", e); + } finally { + this.openxr.setFrameStarted(false); + } + } + + private int acquireAndWaitSwapchainImage(XrSwapchain swapchain, MemoryStack stack) { + XrSwapchainImageAcquireInfo acquireInfo = XrSwapchainImageAcquireInfo.calloc(stack) + .type(XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO); + + IntBuffer indexBuf = stack.callocInt(1); + int result = xrAcquireSwapchainImage(swapchain, acquireInfo, indexBuf); + if (result < 0) { + VRSettings.LOGGER.error("Vivecraft: xrAcquireSwapchainImage failed: {}", + OpenXRUtil.resultToString(result)); + return -1; + } + + XrSwapchainImageWaitInfo waitInfo = XrSwapchainImageWaitInfo.calloc(stack) + .type(XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO) + .timeout(XR_INFINITE_DURATION); + + result = xrWaitSwapchainImage(swapchain, waitInfo); + if (result < 0) { + VRSettings.LOGGER.error("Vivecraft: xrWaitSwapchainImage failed: {}", + OpenXRUtil.resultToString(result)); + // Must still release the acquired image to avoid leaving swapchain in broken state. + // OpenXR requires acquire → wait → release sequence; skipping release would + // cause all subsequent acquires to fail. + releaseSwapchainImage(swapchain, stack); + return -1; + } + + return indexBuf.get(0); + } + + private void releaseSwapchainImage(XrSwapchain swapchain, MemoryStack stack) { + XrSwapchainImageReleaseInfo releaseInfo = XrSwapchainImageReleaseInfo.calloc(stack) + .type(XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO); + xrReleaseSwapchainImage(swapchain, releaseInfo); + } + + private void blitTexture(int srcTexture, int dstTexture, int blitFBO, int width, int height) { + // Bind dst texture to the write FBO + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, blitFBO); + GL30C.glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL11C.GL_TEXTURE_2D, + dstTexture, 0); + + // Bind src texture to the pre-allocated read FBO (no per-frame allocation) + glBindFramebuffer(GL_READ_FRAMEBUFFER, this.readBlitFBO); + GL30C.glFramebufferTexture2D(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL11C.GL_TEXTURE_2D, + srcTexture, 0); + + // Blit with Y-flip: OpenGL has origin at bottom-left, but D3D11/OpenXR expects top-left. + // Flip the destination Y coordinates (draw from top to bottom) so the image appears + // right-side-up when the D3D11 texture is submitted to the OpenXR compositor. + GL30C.glBlitFramebuffer( + 0, 0, width, height, // src: bottom-left to top-right (normal GL orientation) + 0, height, width, 0, // dst: top-left to bottom-right (flipped Y) + GL11C.GL_COLOR_BUFFER_BIT, GL11C.GL_NEAREST); + + // Unbind + glBindFramebuffer(GL_READ_FRAMEBUFFER, 0); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); + } + + @Override + public boolean providesStencilMask() { + return false; // Can be implemented later with XR_KHR_visibility_mask + } + + @Override + public String getName() { + return "OpenXR"; + } + + @Override + protected void destroyBuffers() { + super.destroyBuffers(); + + if (this.LeftEyeTextureId > -1) { + GlStateManager._deleteTexture(this.LeftEyeTextureId); + this.LeftEyeTextureId = -1; + } + if (this.RightEyeTextureId > -1) { + GlStateManager._deleteTexture(this.RightEyeTextureId); + this.RightEyeTextureId = -1; + } + if (this.leftBlitFBO > 0) { + glDeleteFramebuffers(this.leftBlitFBO); + this.leftBlitFBO = 0; + } + if (this.rightBlitFBO > 0) { + glDeleteFramebuffers(this.rightBlitFBO); + this.rightBlitFBO = 0; + } + if (this.readBlitFBO > 0) { + glDeleteFramebuffers(this.readBlitFBO); + this.readBlitFBO = 0; + } + } + + @Override + public void destroy() { + // Unregister interop textures before destroying anything + D3D11InteropHelper interop = this.openxr.getD3D11Interop(); + if (interop != null) { + if (this.leftInteropHandle != 0) { + interop.unregisterTexture(this.leftInteropHandle); + this.leftInteropHandle = 0; + } + if (this.rightInteropHandle != 0) { + interop.unregisterTexture(this.rightInteropHandle); + this.rightInteropHandle = 0; + } + } + + // Delete the interop GL texture names + if (this.leftInteropGLTexture > 0) { + GlStateManager._deleteTexture(this.leftInteropGLTexture); + this.leftInteropGLTexture = 0; + } + if (this.rightInteropGLTexture > 0) { + GlStateManager._deleteTexture(this.rightInteropGLTexture); + this.rightInteropGLTexture = 0; + } + + // Release intermediate D3D11 textures (these are ours, not the runtime's) + if (this.leftIntermediateD3D11 != 0) { + D3D11InteropHelper.releaseTexture(this.leftIntermediateD3D11); + this.leftIntermediateD3D11 = 0; + } + if (this.rightIntermediateD3D11 != 0) { + D3D11InteropHelper.releaseTexture(this.rightIntermediateD3D11); + this.rightIntermediateD3D11 = 0; + } + + super.destroy(); + + // Destroy swapchains (must happen before session is destroyed). + // If the session was already destroyed by MCOpenXR.destroy(), these calls + // will fail gracefully — we catch and log any errors. + try { + if (this.leftSwapchain != null) { + xrDestroySwapchain(this.leftSwapchain); + this.leftSwapchain = null; + } + } catch (Exception e) { + VRSettings.LOGGER.warn("Vivecraft: Error destroying left swapchain: {}", e.getMessage()); + this.leftSwapchain = null; + } + try { + if (this.rightSwapchain != null) { + xrDestroySwapchain(this.rightSwapchain); + this.rightSwapchain = null; + } + } catch (Exception e) { + VRSettings.LOGGER.warn("Vivecraft: Error destroying right swapchain: {}", e.getMessage()); + this.rightSwapchain = null; + } + if (this.projectionLayerViews != null) { + this.projectionLayerViews.free(); + this.projectionLayerViews = null; + } + + // Now that swapchains and interop textures are cleaned up, + // it's safe to destroy the XR session, instance, and D3D11 interop. + this.openxr.destroySessionAndInterop(); + } +} diff --git a/common/src/main/java/org/vivecraft/client_vr/provider/openxr_lwjgl/OpenXRUtil.java b/common/src/main/java/org/vivecraft/client_vr/provider/openxr_lwjgl/OpenXRUtil.java new file mode 100644 index 000000000..76db991f7 --- /dev/null +++ b/common/src/main/java/org/vivecraft/client_vr/provider/openxr_lwjgl/OpenXRUtil.java @@ -0,0 +1,131 @@ +package org.vivecraft.client_vr.provider.openxr_lwjgl; + +import org.joml.Matrix4f; +import org.joml.Quaternionf; +import org.lwjgl.openxr.XrFovf; +import org.lwjgl.openxr.XrPosef; + +public class OpenXRUtil { + + /** + * Converts an OpenXR pose (position + orientation quaternion) to a JOML Matrix4f. + * OpenXR uses right-handed coordinate system with Y up. + * + * @param pose the OpenXR pose + * @param dest the destination matrix + * @return the destination matrix with the pose applied + */ + public static Matrix4f poseToMatrix4f(XrPosef pose, Matrix4f dest) { + Quaternionf q = new Quaternionf( + pose.orientation().x(), + pose.orientation().y(), + pose.orientation().z(), + pose.orientation().w() + ); + return dest.rotation(q).setTranslation( + pose.position$().x(), + pose.position$().y(), + pose.position$().z() + ); + } + + /** + * Converts OpenXR asymmetric field-of-view angles to a projection matrix. + * OpenXR provides per-eye FoV as four angles in radians (left, right, up, down) + * measured from the center/forward axis. + * + * @param fov the OpenXR FoV structure + * @param nearClip near clip plane distance + * @param farClip far clip plane distance + * @return a projection matrix + */ + public static Matrix4f fovToProjectionMatrix(XrFovf fov, float nearClip, float farClip) { + float tanLeft = (float) Math.tan(fov.angleLeft()); + float tanRight = (float) Math.tan(fov.angleRight()); + float tanDown = (float) Math.tan(fov.angleDown()); + float tanUp = (float) Math.tan(fov.angleUp()); + + float tanWidth = tanRight - tanLeft; + float tanHeight = tanUp - tanDown; + + float a00 = 2.0F / tanWidth; + float a11 = 2.0F / tanHeight; + float a20 = (tanRight + tanLeft) / tanWidth; + float a21 = (tanUp + tanDown) / tanHeight; + float a22 = -(farClip + nearClip) / (farClip - nearClip); + float a32 = -(2.0F * farClip * nearClip) / (farClip - nearClip); + + return new Matrix4f( + a00, 0, 0, 0, + 0, a11, 0, 0, + a20, a21, a22, -1, + 0, 0, a32, 0 + ); + } + + /** + * Returns a human-readable string for an OpenXR result code. + */ + public static String resultToString(int result) { + // Values from the official OpenXR 1.1 spec: + // https://registry.khronos.org/OpenXR/specs/1.1/man/html/XrResult.html + return switch (result) { + case 0 -> "XR_SUCCESS"; + case 1 -> "XR_TIMEOUT_EXPIRED"; + case 3 -> "XR_SESSION_LOSS_PENDING"; + case 4 -> "XR_EVENT_UNAVAILABLE"; + case 7 -> "XR_SPACE_BOUNDS_UNAVAILABLE"; + case 8 -> "XR_SESSION_NOT_FOCUSED"; + case 9 -> "XR_FRAME_DISCARDED"; + case -1 -> "XR_ERROR_VALIDATION_FAILURE"; + case -2 -> "XR_ERROR_RUNTIME_FAILURE"; + case -3 -> "XR_ERROR_OUT_OF_MEMORY"; + case -4 -> "XR_ERROR_API_VERSION_UNSUPPORTED"; + case -6 -> "XR_ERROR_INITIALIZATION_FAILED"; + case -7 -> "XR_ERROR_FUNCTION_UNSUPPORTED"; + case -8 -> "XR_ERROR_FEATURE_UNSUPPORTED"; + case -9 -> "XR_ERROR_EXTENSION_NOT_PRESENT"; + case -10 -> "XR_ERROR_LIMIT_REACHED"; + case -11 -> "XR_ERROR_SIZE_INSUFFICIENT"; + case -12 -> "XR_ERROR_HANDLE_INVALID"; + case -13 -> "XR_ERROR_INSTANCE_LOST"; + case -14 -> "XR_ERROR_SESSION_RUNNING"; + case -16 -> "XR_ERROR_SESSION_NOT_RUNNING"; + case -17 -> "XR_ERROR_SESSION_LOST"; + case -18 -> "XR_ERROR_SYSTEM_INVALID"; + case -19 -> "XR_ERROR_PATH_INVALID"; + case -20 -> "XR_ERROR_PATH_COUNT_EXCEEDED"; + case -21 -> "XR_ERROR_PATH_FORMAT_INVALID"; + case -22 -> "XR_ERROR_PATH_UNSUPPORTED"; + case -23 -> "XR_ERROR_LAYER_INVALID"; + case -24 -> "XR_ERROR_LAYER_LIMIT_EXCEEDED"; + case -25 -> "XR_ERROR_SWAPCHAIN_RECT_INVALID"; + case -26 -> "XR_ERROR_SWAPCHAIN_FORMAT_UNSUPPORTED"; + case -27 -> "XR_ERROR_ACTION_TYPE_MISMATCH"; + case -28 -> "XR_ERROR_SESSION_NOT_READY"; + case -29 -> "XR_ERROR_SESSION_NOT_STOPPING"; + case -30 -> "XR_ERROR_TIME_INVALID"; + case -31 -> "XR_ERROR_REFERENCE_SPACE_UNSUPPORTED"; + case -32 -> "XR_ERROR_FILE_ACCESS_ERROR"; + case -33 -> "XR_ERROR_FILE_CONTENTS_INVALID"; + case -34 -> "XR_ERROR_FORM_FACTOR_UNSUPPORTED"; + case -35 -> "XR_ERROR_FORM_FACTOR_UNAVAILABLE"; + case -36 -> "XR_ERROR_API_LAYER_NOT_PRESENT"; + case -37 -> "XR_ERROR_CALL_ORDER_INVALID"; + case -38 -> "XR_ERROR_GRAPHICS_DEVICE_INVALID"; + case -39 -> "XR_ERROR_POSE_INVALID"; + case -40 -> "XR_ERROR_INDEX_OUT_OF_RANGE"; + case -41 -> "XR_ERROR_VIEW_CONFIGURATION_TYPE_UNSUPPORTED"; + case -42 -> "XR_ERROR_ENVIRONMENT_BLEND_MODE_UNSUPPORTED"; + case -44 -> "XR_ERROR_NAME_DUPLICATED"; + case -45 -> "XR_ERROR_NAME_INVALID"; + case -46 -> "XR_ERROR_ACTIONSET_NOT_ATTACHED"; + case -47 -> "XR_ERROR_ACTIONSETS_ALREADY_ATTACHED"; + case -48 -> "XR_ERROR_LOCALIZED_NAME_DUPLICATED"; + case -49 -> "XR_ERROR_LOCALIZED_NAME_INVALID"; + case -50 -> "XR_ERROR_GRAPHICS_REQUIREMENTS_CALL_MISSING"; + case -51 -> "XR_ERROR_RUNTIME_UNAVAILABLE"; + default -> "XR_UNKNOWN_" + result; + }; + } +} diff --git a/common/src/main/java/org/vivecraft/client_vr/settings/VRSettings.java b/common/src/main/java/org/vivecraft/client_vr/settings/VRSettings.java index 915479ba2..80e7c8c94 100644 --- a/common/src/main/java/org/vivecraft/client_vr/settings/VRSettings.java +++ b/common/src/main/java/org/vivecraft/client_vr/settings/VRSettings.java @@ -178,6 +178,7 @@ public enum ShaderGUIRender implements OptionEnum { public enum VRProvider implements OptionEnum { OPENVR, + OPENXR, NULLVR } @@ -241,7 +242,7 @@ public enum AimDevice implements OptionEnum { public int version = UNKNOWN_VERSION; @SettingField(VrOptions.VR_PLUGIN) - public VRProvider stereoProviderPluginID = VRProvider.OPENVR; + public VRProvider stereoProviderPluginID = VRProvider.OPENXR; public boolean storeDebugAim = false; @SettingField public int smoothRunTickCount = 20; diff --git a/common/src/main/java/org/vivecraft/mixin/client_vr/MinecraftVRMixin.java b/common/src/main/java/org/vivecraft/mixin/client_vr/MinecraftVRMixin.java index 12c6b6209..14c89e2e4 100644 --- a/common/src/main/java/org/vivecraft/mixin/client_vr/MinecraftVRMixin.java +++ b/common/src/main/java/org/vivecraft/mixin/client_vr/MinecraftVRMixin.java @@ -204,6 +204,14 @@ public abstract class MinecraftVRMixin implements MinecraftExtension { if (!VRState.VR_INITIALIZED) { return; } + // OpenXR requires event polling to transition the session state machine + // (IDLE → READY → SYNCHRONIZED → VISIBLE → FOCUSED). We must poll events + // even when VR_RUNNING is false, otherwise the session never reaches READY + // and isActive() never returns true — creating a deadlock. + if (!VRState.VR_RUNNING) { + ClientDataHolderVR.getInstance().vr.poll(ClientDataHolderVR.getInstance().frameIndex); + } + boolean vrActive = !ClientDataHolderVR.getInstance().vrSettings.vrHotswitchingEnabled || ClientDataHolderVR.getInstance().vr.isActive(); if (VRState.VR_RUNNING != vrActive && (ClientNetworking.SERVER_ALLOWS_VR_SWITCHING || this.player == null)) { diff --git a/fabric/build.gradle b/fabric/build.gradle index bd129f8a8..ee2aa4ce9 100644 --- a/fabric/build.gradle +++ b/fabric/build.gradle @@ -69,6 +69,10 @@ dependencies { include(implementation("org.lwjgl:lwjgl-openvr:${rootProject.lwjgl_version}:natives-macos")) { transitive = false } include(implementation("org.lwjgl:lwjgl-openvr:${rootProject.lwjgl_version}:natives-windows")) { transitive = false } + include(implementation("org.lwjgl:lwjgl-openxr:${rootProject.lwjgl_version}")) { transitive = false } + include(implementation("org.lwjgl:lwjgl-openxr:${rootProject.lwjgl_version}:natives-linux")) { transitive = false } + include(implementation("org.lwjgl:lwjgl-openxr:${rootProject.lwjgl_version}:natives-windows")) { transitive = false } + include(implementation("com.illposed.osc:javaosc-core:0.9")) include(implementation("com.github.bhaptics:tact-java:0.1.4")) include(implementation("org.java-websocket:Java-WebSocket:1.5.1")) diff --git a/forge/build.gradle b/forge/build.gradle index 8ff83ae36..41e11dd1a 100644 --- a/forge/build.gradle +++ b/forge/build.gradle @@ -64,6 +64,9 @@ dependencies { forgeRuntimeLibrary("org.lwjgl:lwjgl-openvr:${rootProject.lwjgl_version}:natives-linux") { transitive = false } forgeRuntimeLibrary("org.lwjgl:lwjgl-openvr:${rootProject.lwjgl_version}:natives-macos") { transitive = false } forgeRuntimeLibrary("org.lwjgl:lwjgl-openvr:${rootProject.lwjgl_version}:natives-windows") { transitive = false } + forgeRuntimeLibrary("org.lwjgl:lwjgl-openxr:${rootProject.lwjgl_version}") { transitive = false } + forgeRuntimeLibrary("org.lwjgl:lwjgl-openxr:${rootProject.lwjgl_version}:natives-linux") { transitive = false } + forgeRuntimeLibrary("org.lwjgl:lwjgl-openxr:${rootProject.lwjgl_version}:natives-windows") { transitive = false } forgeRuntimeLibrary("com.illposed.osc:javaosc-core:0.9") { transitive = false } forgeRuntimeLibrary("com.github.bhaptics:tact-java:0.1.4") { transitive = false } forgeRuntimeLibrary("org.java-websocket:Java-WebSocket:1.5.1") { transitive = false } @@ -73,6 +76,9 @@ dependencies { bundle("org.lwjgl:lwjgl-openvr:${rootProject.lwjgl_version}:natives-linux") { transitive = false } bundle("org.lwjgl:lwjgl-openvr:${rootProject.lwjgl_version}:natives-macos") { transitive = false } bundle("org.lwjgl:lwjgl-openvr:${rootProject.lwjgl_version}:natives-windows") { transitive = false } + bundle("org.lwjgl:lwjgl-openxr:${rootProject.lwjgl_version}") { transitive = false } + bundle("org.lwjgl:lwjgl-openxr:${rootProject.lwjgl_version}:natives-linux") { transitive = false } + bundle("org.lwjgl:lwjgl-openxr:${rootProject.lwjgl_version}:natives-windows") { transitive = false } compileOnly(annotationProcessor("io.github.llamalad7:mixinextras-common:${rootProject.mixin_extras_version}")) implementation(include("io.github.llamalad7:mixinextras-forge:${rootProject.mixin_extras_version}")) diff --git a/neoforge/build.gradle b/neoforge/build.gradle index 3aefd0a92..7cd7e7e64 100644 --- a/neoforge/build.gradle +++ b/neoforge/build.gradle @@ -34,6 +34,9 @@ dependencies { forgeRuntimeLibrary("org.lwjgl:lwjgl-openvr:${rootProject.lwjgl_version}:natives-linux") { transitive = false } forgeRuntimeLibrary("org.lwjgl:lwjgl-openvr:${rootProject.lwjgl_version}:natives-macos") { transitive = false } forgeRuntimeLibrary("org.lwjgl:lwjgl-openvr:${rootProject.lwjgl_version}:natives-windows") { transitive = false } + forgeRuntimeLibrary("org.lwjgl:lwjgl-openxr:${rootProject.lwjgl_version}") { transitive = false } + forgeRuntimeLibrary("org.lwjgl:lwjgl-openxr:${rootProject.lwjgl_version}:natives-linux") { transitive = false } + forgeRuntimeLibrary("org.lwjgl:lwjgl-openxr:${rootProject.lwjgl_version}:natives-windows") { transitive = false } forgeRuntimeLibrary("com.illposed.osc:javaosc-core:0.9") { transitive = false } forgeRuntimeLibrary("com.github.bhaptics:tact-java:0.1.4") { transitive = false } forgeRuntimeLibrary("org.java-websocket:Java-WebSocket:1.5.1") { transitive = false } @@ -43,6 +46,9 @@ dependencies { bundle("org.lwjgl:lwjgl-openvr:${rootProject.lwjgl_version}:natives-linux") { transitive = false } bundle("org.lwjgl:lwjgl-openvr:${rootProject.lwjgl_version}:natives-macos") { transitive = false } bundle("org.lwjgl:lwjgl-openvr:${rootProject.lwjgl_version}:natives-windows") { transitive = false } + bundle("org.lwjgl:lwjgl-openxr:${rootProject.lwjgl_version}") { transitive = false } + bundle("org.lwjgl:lwjgl-openxr:${rootProject.lwjgl_version}:natives-linux") { transitive = false } + bundle("org.lwjgl:lwjgl-openxr:${rootProject.lwjgl_version}:natives-windows") { transitive = false } compileOnly(annotationProcessor("io.github.llamalad7:mixinextras-common:${rootProject.mixin_extras_version}")) implementation(include("io.github.llamalad7:mixinextras-neoforge:${rootProject.mixin_extras_version}"))