From 7678226f4ae6247fbfec1971e6ccb270dd187ed4 Mon Sep 17 00:00:00 2001
From: Aaron Benjamin <[EMAIL REDACTED]>
Date: Mon, 23 Feb 2026 11:14:19 -0500
Subject: [PATCH] test: Add testgpu_spinning_cube_xr (#14943)
Co-authored-by: Ethan Lee <flibitijibibo@gmail.com>
---
docs/README-xr.md | 320 ++++++
test/CMakeLists.txt | 32 +-
.../cmake/AndroidManifest.xr.xml.cmake | 94 ++
test/testgpu_spinning_cube_xr.c | 983 ++++++++++++++++++
4 files changed, 1428 insertions(+), 1 deletion(-)
create mode 100644 docs/README-xr.md
create mode 100644 test/android/cmake/AndroidManifest.xr.xml.cmake
create mode 100644 test/testgpu_spinning_cube_xr.c
diff --git a/docs/README-xr.md b/docs/README-xr.md
new file mode 100644
index 0000000000000..9147df26e98ab
--- /dev/null
+++ b/docs/README-xr.md
@@ -0,0 +1,320 @@
+# OpenXR / VR Development with SDL
+
+This document covers how to build OpenXR (VR/AR) applications using SDL's GPU API with OpenXR integration.
+
+## Overview
+
+SDL3 provides OpenXR integration through the GPU API, allowing you to render to VR/AR headsets using a unified interface across multiple graphics backends (Vulkan, D3D12, Metal).
+
+**Key features:**
+- Automatic OpenXR instance and session management
+- Swapchain creation and image acquisition
+- Support for multi-pass stereo rendering
+- Works with desktop VR runtimes (SteamVR, Oculus, Windows Mixed Reality) and standalone headsets (Meta Quest, Pico)
+
+## Desktop Development
+
+### Requirements
+
+1. **OpenXR Loader** (`openxr_loader.dll` / `libopenxr_loader.so`)
+ - On Windows: Usually installed with VR runtime software (Oculus, SteamVR)
+ - On Linux: Install via package manager (e.g., `libopenxr-loader1` on Ubuntu)
+ - Can also use `SDL_HINT_OPENXR_LIBRARY` to specify a custom loader path
+
+2. **OpenXR Runtime**
+ - At least one OpenXR runtime must be installed and active
+ - Examples: SteamVR, Oculus Desktop, Monado (Linux)
+
+3. **VR Headset**
+ - Connected and recognized by the runtime
+
+### Basic Usage
+
+```c
+#include <openxr/openxr.h>
+#include <SDL3/SDL.h>
+#include <SDL3/SDL_openxr.h>
+
+// These will be populated by SDL
+XrInstance xr_instance = XR_NULL_HANDLE;
+XrSystemId xr_system_id = 0;
+
+// Create GPU device with XR enabled
+SDL_PropertiesID props = SDL_CreateProperties();
+SDL_SetBooleanProperty(props, SDL_PROP_GPU_DEVICE_CREATE_XR_ENABLE_BOOLEAN, true);
+SDL_SetPointerProperty(props, SDL_PROP_GPU_DEVICE_CREATE_XR_INSTANCE_POINTER, &xr_instance);
+SDL_SetPointerProperty(props, SDL_PROP_GPU_DEVICE_CREATE_XR_SYSTEM_ID_POINTER, &xr_system_id);
+
+// Optional: Override app name/version (defaults to SDL_SetAppMetadata values if not set)
+SDL_SetStringProperty(props, SDL_PROP_GPU_DEVICE_CREATE_XR_APPLICATION_NAME_STRING, "My VR App");
+SDL_SetNumberProperty(props, SDL_PROP_GPU_DEVICE_CREATE_XR_APPLICATION_VERSION_NUMBER, 1);
+
+SDL_GPUDevice *device = SDL_CreateGPUDeviceWithProperties(props);
+SDL_DestroyProperties(props);
+
+// xr_instance and xr_system_id are now populated by SDL
+```
+
+See `test/testgpu_spinning_cube_xr.c` for a complete example.
+
+---
+
+## Android Development
+
+Building OpenXR applications for Android standalone headsets (Meta Quest, Pico, etc.) requires additional manifest configuration beyond standard Android apps.
+
+### Android Manifest Requirements
+
+The manifest requirements fall into three categories:
+
+1. **OpenXR Standard (Khronos)** - Required for all OpenXR apps
+2. **Platform-Specific** - Required for specific headset platforms
+3. **Optional Features** - Enable additional capabilities
+
+---
+
+### OpenXR Standard Requirements (All Platforms)
+
+These are required by the Khronos OpenXR specification for Android:
+
+#### Permissions
+
+```xml
+<!-- OpenXR runtime broker communication -->
+<uses-permission android:name="org.khronos.openxr.permission.OPENXR" />
+<uses-permission android:name="org.khronos.openxr.permission.OPENXR_SYSTEM" />
+```
+
+#### Queries (Android 11+)
+
+Required for the app to discover OpenXR runtimes:
+
+```xml
+<queries>
+ <provider android:authorities="org.khronos.openxr.runtime_broker;org.khronos.openxr.system_runtime_broker" />
+ <intent>
+ <action android:name="org.khronos.openxr.OpenXRRuntimeService" />
+ </intent>
+ <intent>
+ <action android:name="org.khronos.openxr.OpenXRApiLayerService" />
+ </intent>
+</queries>
+```
+
+#### Hardware Features
+
+```xml
+<!-- VR head tracking (standard OpenXR requirement) -->
+<uses-feature android:name="android.hardware.vr.headtracking"
+ android:required="true"
+ android:version="1" />
+
+<!-- Touchscreen not required for VR -->
+<uses-feature android:name="android.hardware.touchscreen"
+ android:required="false" />
+
+<!-- Graphics requirements -->
+<uses-feature android:glEsVersion="0x00030002" android:required="true" />
+<uses-feature android:name="android.hardware.vulkan.level"
+ android:required="true"
+ android:version="1" />
+<uses-feature android:name="android.hardware.vulkan.version"
+ android:required="true"
+ android:version="0x00401000" />
+```
+
+#### Intent Category
+
+```xml
+<activity ...>
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ <!-- Khronos OpenXR immersive app category -->
+ <category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" />
+ </intent-filter>
+</activity>
+```
+
+---
+
+### Meta Quest Requirements
+
+These are **required** for apps to run properly on Meta Quest devices. Without these, your app may launch in "pancake" 2D mode instead of VR.
+
+#### VR Intent Category (Critical!)
+
+```xml
+<activity ...>
+ <intent-filter>
+ ...
+ <!-- CRITICAL: Without this, app launches in 2D mode on Quest! -->
+ <category android:name="com.oculus.intent.category.VR" />
+ </intent-filter>
+</activity>
+```
+
+#### Supported Devices
+
+```xml
+<application ...>
+ <!-- Required: Specifies which Quest devices are supported -->
+ <meta-data android:name="com.oculus.supportedDevices"
+ android:value="quest|quest2|questpro|quest3|quest3s" />
+</application>
+```
+
+#### Focus Handling (Recommended)
+
+```xml
+<application ...>
+ <!-- Properly handles when user opens the Quest system menu -->
+ <meta-data android:name="com.oculus.vr.focusaware"
+ android:value="true" />
+</application>
+```
+
+#### Hand Tracking (Optional)
+
+```xml
+<!-- Feature declaration -->
+<uses-feature android:name="oculus.software.handtracking"
+ android:required="false" />
+
+<application ...>
+ <!-- V2.0 allows app to launch without controllers -->
+ <meta-data android:name="com.oculus.handtracking.version"
+ android:value="V2.0" />
+ <meta-data android:name="com.oculus.handtracking.frequency"
+ android:value="HIGH" />
+</application>
+```
+
+#### VR Splash Screen (Optional)
+
+```xml
+<application ...>
+ <meta-data android:name="com.oculus.ossplash"
+ android:value="true" />
+ <meta-data android:name="com.oculus.ossplash.colorspace"
+ android:value="QUEST_SRGB_NONGAMMA" />
+ <meta-data android:name="com.oculus.ossplash.background"
+ android:resource="@drawable/vr_splash" />
+</application>
+```
+
+---
+
+### Pico Requirements
+
+For Pico Neo, Pico 4, and other Pico headsets:
+
+#### VR Intent Category
+
+```xml
+<activity ...>
+ <intent-filter>
+ ...
+ <!-- Pico VR category -->
+ <category android:name="com.picovr.intent.category.VR" />
+ </intent-filter>
+</activity>
+```
+
+#### Supported Devices (Optional)
+
+```xml
+<application ...>
+ <!-- Pico device support -->
+ <meta-data android:name="pvr.app.type"
+ android:value="vr" />
+</application>
+```
+
+---
+
+### HTC Vive Focus / VIVE XR Elite
+
+```xml
+<activity ...>
+ <intent-filter>
+ ...
+ <!-- HTC Vive category -->
+ <category android:name="com.htc.intent.category.VRAPP" />
+ </intent-filter>
+</activity>
+```
+
+---
+
+## Quick Reference Table
+
+| Declaration | Purpose | Scope |
+|-------------|---------|-------|
+| `org.khronos.openxr.permission.OPENXR` | Runtime communication | All OpenXR |
+| `android.hardware.vr.headtracking` | Marks app as VR | All OpenXR |
+| `org.khronos.openxr.intent.category.IMMERSIVE_HMD` | Khronos standard VR category | All OpenXR |
+| `com.oculus.intent.category.VR` | Launch in VR mode | Meta Quest |
+| `com.oculus.supportedDevices` | Device compatibility | Meta Quest |
+| `com.oculus.vr.focusaware` | System menu handling | Meta Quest |
+| `com.picovr.intent.category.VR` | Launch in VR mode | Pico |
+| `com.htc.intent.category.VRAPP` | Launch in VR mode | HTC Vive |
+
+---
+
+## Example Manifest
+
+SDL provides an example XR manifest template at:
+`test/android/cmake/AndroidManifest.xr.xml.cmake`
+
+This template includes:
+- All Khronos OpenXR requirements
+- Meta Quest support (configurable via `SDL_ANDROID_XR_META_SUPPORT` CMake option)
+- Proper intent filters for VR launching
+
+---
+
+## Common Issues
+
+### App launches in 2D "pancake" mode
+
+**Cause:** Missing platform-specific VR intent category.
+
+**Solution:** Add the appropriate category for your target platform:
+- Meta Quest: `com.oculus.intent.category.VR`
+- Pico: `com.picovr.intent.category.VR`
+- HTC: `com.htc.intent.category.VRAPP`
+
+### "No OpenXR runtime found" error
+
+**Cause:** The OpenXR loader can't find a runtime.
+
+**Solutions:**
+- **Desktop:** Ensure VR software (SteamVR, Oculus) is installed and running
+- **Android:** Ensure your manifest has the correct `<queries>` block for runtime discovery
+- **Linux:** Install `libopenxr-loader1` and configure the active runtime
+
+### OpenXR loader not found
+
+**Cause:** `openxr_loader.dll` / `libopenxr_loader.so` is not in the library path.
+
+**Solutions:**
+- Install the Khronos OpenXR SDK
+- On Windows, VR runtimes typically install this, but may not add it to PATH
+- Use `SDL_HINT_OPENXR_LIBRARY` to specify the loader path explicitly
+
+### Vulkan validation errors on shutdown
+
+**Cause:** GPU resources destroyed while still in use.
+
+**Solution:** Call `SDL_WaitForGPUIdle(device)` before releasing any GPU resources or destroying the device.
+
+---
+
+## Additional Resources
+
+- [Khronos OpenXR Specification](https://www.khronos.org/openxr/)
+- [Meta Quest Developer Documentation](https://developer.oculus.com/documentation/)
+- [Pico Developer Documentation](https://developer.pico-interactive.com/)
+- [SDL GPU API Documentation](https://wiki.libsdl.org/)
+- Example code: `test/testgpu_spinning_cube_xr.c`
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 2b58fa4ec04f3..a77c5aa685c00 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -382,6 +382,8 @@ add_sdl_test_executable(testgpu_simple_clear SOURCES testgpu_simple_clear.c)
add_sdl_test_executable(testgpu_spinning_cube SOURCES testgpu_spinning_cube.c ${icon_png_header} DEPENDS generate-icon_png_header)
add_sdl_test_executable(testgpurender_effects MAIN_CALLBACKS NEEDS_RESOURCES TESTUTILS SOURCES testgpurender_effects.c)
add_sdl_test_executable(testgpurender_msdf MAIN_CALLBACKS NEEDS_RESOURCES TESTUTILS SOURCES testgpurender_msdf.c)
+add_sdl_test_executable(testgpu_spinning_cube_xr SOURCES testgpu_spinning_cube_xr.c)
+
if(ANDROID)
target_link_libraries(testgles PRIVATE GLESv1_CM)
elseif(IOS OR TVOS)
@@ -750,7 +752,35 @@ if(ANDROID AND TARGET SDL3::Jar)
configure_file(android/cmake/SDLTestActivity.java.cmake "${JAVA_PACKAGE_DIR}/SDLTestActivity.java" @ONLY)
configure_file(android/cmake/res/values/strings.xml.cmake android/res/values/strings-${TEST}.xml @ONLY)
configure_file(android/cmake/res/xml/shortcuts.xml.cmake "${GENERATED_RES_FOLDER}/xml/shortcuts.xml" @ONLY)
- configure_file(android/cmake/AndroidManifest.xml.cmake "${generated_manifest_path}" @ONLY)
+ # Use XR-specific manifest for XR tests, standard manifest for others
+ if("${TEST}" MATCHES "_xr$")
+ # Meta Quest-specific manifest sections (enabled by default, set to empty to disable)
+ # These are ignored by non-Meta runtimes but required for proper Quest integration
+ if(NOT DEFINED SDL_ANDROID_XR_META_SUPPORT OR SDL_ANDROID_XR_META_SUPPORT)
+ set(ANDROID_XR_META_FEATURES
+" <!-- Meta Quest hand tracking support -->
+ <uses-feature android:name=\"oculus.software.handtracking\" android:required=\"false\" />
+")
+ set(ANDROID_XR_META_METADATA
+" <!-- Meta Quest supported devices -->
+ <meta-data android:name=\"com.oculus.supportedDevices\" android:value=\"quest|quest2|questpro|quest3|quest3s\" />
+ <meta-data android:name=\"com.oculus.vr.focusaware\" android:value=\"true\" />
+ <!-- Hand tracking support level (V2 allows launching without controllers) -->
+ <meta-data android:name=\"com.oculus.handtracking.version\" android:value=\"V2.0\" />
+ <meta-data android:name=\"com.oculus.handtracking.frequency\" android:value=\"HIGH\" />
+")
+ set(ANDROID_XR_META_INTENT_CATEGORY
+" <!-- VR intent category for Meta Quest -->
+ <category android:name=\"com.oculus.intent.category.VR\" />")
+ else()
+ set(ANDROID_XR_META_FEATURES "")
+ set(ANDROID_XR_META_METADATA "")
+ set(ANDROID_XR_META_INTENT_CATEGORY "")
+ endif()
+ configure_file(android/cmake/AndroidManifest.xr.xml.cmake "${generated_manifest_path}" @ONLY)
+ else()
+ configure_file(android/cmake/AndroidManifest.xml.cmake "${generated_manifest_path}" @ONLY)
+ endif()
file(GENERATE
OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/android/${TEST}-$<CONFIG>/res/values/strings.xml"
INPUT "${CMAKE_CURRENT_BINARY_DIR}/android/res/values/strings-${TEST}.xml"
diff --git a/test/android/cmake/AndroidManifest.xr.xml.cmake b/test/android/cmake/AndroidManifest.xr.xml.cmake
new file mode 100644
index 0000000000000..8f9beffdcb5a4
--- /dev/null
+++ b/test/android/cmake/AndroidManifest.xr.xml.cmake
@@ -0,0 +1,94 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ package="@ANDROID_MANIFEST_PACKAGE@"
+ android:versionCode="1"
+ android:versionName="1.0"
+ android:installLocation="auto">
+
+ <!-- OpenGL ES 3.2 for Vulkan fallback on XR devices -->
+ <uses-feature android:glEsVersion="0x00030002" android:required="true" />
+
+ <!-- Vulkan requirements -->
+ <uses-feature android:name="android.hardware.vulkan.level" android:required="true" android:version="1" />
+ <uses-feature android:name="android.hardware.vulkan.version" android:required="true" android:version="0x00401000" />
+
+ <!-- VR Head Tracking (standard OpenXR requirement) -->
+ <uses-feature android:name="android.hardware.vr.headtracking" android:required="true" android:version="1" />
+
+ <!-- Touchscreen not required for VR -->
+ <uses-feature
+ android:name="android.hardware.touchscreen"
+ android:required="false" />
+
+@ANDROID_XR_META_FEATURES@
+ <!-- Game controller support -->
+ <uses-feature
+ android:name="android.hardware.bluetooth"
+ android:required="false" />
+ <uses-feature
+ android:name="android.hardware.gamepad"
+ android:required="false" />
+ <uses-feature
+ android:name="android.hardware.usb.host"
+ android:required="false" />
+
+ <!-- Allow access to the vibrator (for controller haptics) -->
+ <uses-permission android:name="android.permission.VIBRATE" />
+
+ <!-- OpenXR permissions (for runtime broker communication) -->
+ <uses-permission android:name="org.khronos.openxr.permission.OPENXR" />
+ <uses-permission android:name="org.khronos.openxr.permission.OPENXR_SYSTEM" />
+
+ <!-- OpenXR runtime/layer queries -->
+ <queries>
+ <provider android:authorities="org.khronos.openxr.runtime_broker;org.khronos.openxr.system_runtime_broker" />
+ <intent>
+ <action android:name="org.khronos.openxr.OpenXRRuntimeService" />
+ </intent>
+ <intent>
+ <action android:name="org.khronos.openxr.OpenXRApiLayerService" />
+ </intent>
+ </queries>
+
+ <application
+ android:allowBackup="true"
+ android:icon="@mipmap/sdl-test"
+ android:roundIcon="@mipmap/sdl-test_round"
+ android:label="@string/label"
+ android:supportsRtl="true"
+ android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen"
+ android:enableOnBackInvokedCallback="false"
+ android:hardwareAccelerated="true">
+
+@ANDROID_XR_META_METADATA@
+ <activity
+ android:name="@ANDROID_MANIFEST_PACKAGE@.SDLTestActivity"
+ android:exported="true"
+ android:label="@string/label"
+ android:alwaysRetainTaskState="true"
+ android:launchMode="singleTask"
+ android:configChanges="density|keyboard|keyboardHidden|navigation|orientation|screenLayout|screenSize|uiMode"
+ android:screenOrientation="landscape"
+ android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen"
+ android:excludeFromRecents="false"
+ android:resizeableActivity="false"
+ tools:ignore="NonResizeableActivity">
+
+ <!-- Standard launcher intent -->
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+@ANDROID_XR_META_INTENT_CATEGORY@
+ <!-- Khronos OpenXR category (for broader compatibility) -->
+ <category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" />
+ </intent-filter>
+ </activity>
+
+ <activity
+ android:name="@ANDROID_MANIFEST_PACKAGE@.SDLEntryTestActivity"
+ android:exported="false"
+ android:label="@string/label">
+ </activity>
+ </application>
+</manifest>
diff --git a/test/testgpu_spinning_cube_xr.c b/test/testgpu_spinning_cube_xr.c
new file mode 100644
index 0000000000000..01995c34af1a1
--- /dev/null
+++ b/test/testgpu_spinning_cube_xr.c
@@ -0,0 +1,983 @@
+/*
+ Copyright (C) 1997-2026 Sam Lantinga <slouken@libsdl.org>
+
+ This software is provided 'as-is', without any express or implied
+ warranty. In no event will the authors be held liable for any damages
+ arising from the use of this software.
+
+ Permission is granted to anyone to use this software for any purpose,
+ including commercial applications, and to alter it and redistribute it
+ freely.
+*/
+
+/*
+ * testgpu_spinning_cube_xr.c - SDL3 GPU API OpenXR Spinning Cubes Test
+ *
+ * This is an XR-enabled version of testgpu_spinning_cube that renders
+ * spinning colored cubes in VR using OpenXR and SDL's GPU API.
+ *
+ * Rendering approach: Multi-pass stereo (one render pass per eye)
+ * This is the simplest and most compatible approach, working on all
+ * OpenXR-capable platforms (Desktop VR runtimes, Quest, etc.)
+ *
+ * For more information on stereo rendering techniques, see:
+ * - Multi-pass: Traditional, 2 render passes (used here)
+ * - Multiview (GL_OVR_multiview): Single pass with texture arrays
+ * - Single-pass instanced: GPU instancing to select eye
+ */
+
+#include <SDL3/SDL.h>
+#include <SDL3/SDL_main.h>
+
+/* Include OpenXR headers BEFORE SDL_openxr.h to get full type definitions */
+#ifdef HAVE_OPENXR_H
+#include <openxr/openxr.h>
+#else
+/* SDL includes a copy for building on systems without the OpenXR SDK */
+#include "../src/video/khronos/openxr/openxr.h"
+#endif
+
+#include <SDL3/SDL_openxr.h>
+
+/* Standard library for exit() */
+#include <stdlib.h>
+
+/* Include compiled shader bytecode for all backends */
+#include "testgpu/cube.frag.dxil.h"
+#include "testgpu/cube.frag.spv.h"
+#include "testgpu/cube.vert.dxil.h"
+#include "testgpu/cube.vert.spv.h"
+
+#define CHECK_CREATE(var, thing) { if (!(var)) { SDL_Log("Failed to create %s: %s", thing, SDL_GetError()); return false; } }
+#define XR_CHECK(result, msg) do { if (XR_FAILED(result)) { SDL_Log("OpenXR Error: %s (result=%d)", msg, (int)(result)); return false; } } while(0)
+#define XR_CHECK_QUIT(result, msg) do { if (XR_FAILED(result)) { SDL_Log("OpenXR Error: %s (result=%d)", msg, (int)(result)); quit(2); return; } } while(0)
+
+/* ========================================================================
+ * Math Types and Functions
+ * ======================================================================== */
+
+typedef struct { float x, y, z; } Vec3;
+typedef struct { float m[16]; } Mat4;
+
+static Mat4 Mat4_Multiply(Mat4 a, Mat4 b)
+{
+ Mat4 result = {{0}};
+ for (int i = 0; i < 4; i++) {
+ for (int j = 0; j < 4; j++) {
+ for (int k = 0; k < 4; k++) {
+ result.m[i * 4 + j] += a.m[i * 4 + k] * b.m[k * 4 + j];
+ }
+ }
+ }
+ return result;
+}
+
+static Mat4 Mat4_Translation(float x, float y, float z)
+{
+ return (Mat4){{ 1,0,0,0, 0,1,0,0, 0,0,1,0, x,y,z,1 }};
+}
+
+static Mat4 Mat4_Scale(float s)
+{
+ return (Mat4){{ s,0,0,0, 0,s,0,0, 0,0,s,0, 0,0,0,1 }};
+}
+
+static Mat4 Mat4_RotationY(float rad)
+{
+ float c = SDL_cosf(rad), s = SDL_sinf(rad);
+ return (Mat4){{ c,0,-s,0, 0,1,0,0, s,0,c,0, 0,0,0,1 }};
+}
+
+static Mat4 Mat4_RotationX(float rad)
+{
+ float c = SDL_cosf(rad), s = SDL_sinf(rad);
+ return (Mat4){{ 1,0,0,0, 0,c,s,0, 0,-s,c,0, 0,0,0,1 }};
+}
+
+/* Convert XrPosef to view matrix (inverted transform) */
+static Mat4 Mat4_FromXrPose(XrPosef pose)
+{
+ float x = pose.orientation.x, y = pose.orientation.y;
+ float z = pose.orientation.z, w = pose.orientation.w;
+
+ /* Quaternion to rotation matrix columns */
+ Vec3 right = { 1-2*(y*y+z*z), 2*(x*y+w*z), 2*(x*z-w*y) };
+ Vec3 up = { 2*(x*y-w*z), 1-2*(x*x+z*z), 2*(y*z+w*x) };
+ Vec3 fwd = { 2*(x*z+w*y), 2*(y*z-w*x), 1-2*(x*x+y*y) };
+ Vec3 pos = { pose.position.x, pose.position.y, pose.position.z };
+
+ /* Inverted transform for view matrix */
+ float dr = -(right.x*pos.x + right.y*pos.y + right.z*pos.z);
+ float du = -(up.x*pos.x + up.y*pos.y + up.z*pos.z);
+ float df = -(fwd.x*pos.x + fwd.y*pos.y + fwd.z*pos.z);
+
+ return (Mat4){{ right.x,up.x,fwd.x,0, right.y,up.y,fwd.y,0, right.z,up.z,fwd.z,0, dr,du,df,1 }};
+}
+
+/* Create asymmetric projection matrix from XR FOV */
+static Mat4 Mat4_Projection(XrFovf fov, float nearZ, float farZ)
+{
+ float tL = SDL_tanf(fov.angleLeft), tR = SDL_tanf(fov.angleRight);
+ float tU = SDL_tanf(fov.angleUp), tD = SDL_tanf(fov.angleDown);
+ float w = tR - tL, h = tU - tD;
+
+ return (Mat4){{
+ 2/w, 0, 0, 0,
+ 0, 2/h, 0, 0,
+ (tR+tL)/w, (tU+tD)/h, -farZ/(farZ-nearZ), -1,
+ 0, 0, -(farZ*nearZ)/(farZ-nearZ), 0
+ }};
+}
+
+/* ========================================================================
+ * Vertex Data
+ * ======================================================================== */
+
+typedef struct {
+ float x, y, z;
+ Uint8 r, g, b, a;
+} PositionColorVertex;
+
+/* Cube vertices - 0.25m half-size, each face a different color */
+static const float CUBE_HALF_SIZE = 0.25f;
+
+/* ========================================================================
+ * OpenXR Function Pointers (loaded dynamically)
+ * ======================================================================== */
+
+static PFN_xrGetInstanceProcAddr pfn_xrGetInstanceProcAddr = NULL;
+static PFN_xrEnumerateViewConfigurationViews pfn_xrEnumerateViewConfigurationViews = NULL;
+static PFN_xrEnumerateSwapchainImages pfn_xrEnumerateSwapchainImages = NULL;
+static PFN_xrCreateReferenceSpace pfn_xrCreateReferenceSpace = NULL;
+static PFN_xrDestroySpace pfn_xrDestroySpace = NULL;
+static PFN_xrDestroySession pfn_xrDestroySession = NULL;
+static PFN_xrDestroyInstance pfn_xrDestroyInstance = NULL;
+static PFN_xrPollEvent pfn_xrPollEvent = NULL;
+static PFN_xrBeginSession pfn_xrBeginSession = NULL;
+static PFN_xrEndSession pfn_xrEndSession = NULL;
+static PFN_xrWaitFrame pfn_xrWaitFrame = NULL;
+static PFN_xrBeginFrame pfn_xrBeginFrame = NULL;
+static PFN_xrEndFrame pfn_xrEndFrame = NULL;
+static PFN_xrLocateViews pfn_xrLocateViews = NULL;
+static PFN_xrAcquireSwapchainImage pfn_xrAcquireSwapchainImage = NULL;
+static PFN_xrWaitSwapchainImage pfn_xrWaitSwapchainImage = NULL;
+static PFN_xrReleaseSwapchainImage pfn_xrReleaseSwapchainImage = NULL;
+
+/* ========================================================================
+ * Global State
+ * ======================================================================== */
+
+/* OpenXR state */
+static XrInstance xr_instance = XR_NULL_HANDLE;
+static XrSystemId xr_system_id = XR_NULL_SYSTEM_ID;
+static XrSession xr_session = XR_NULL_HANDLE;
+static XrSpace xr_local_space = XR_NULL_HANDLE;
+static bool xr_session_running = false;
+static bool xr_should_quit = false;
+
+/* Swapchain state */
+typedef struct {
+ XrSwapchain swapchain;
+ SDL_GPUTexture **images;
+ SDL_GPUTexture *depth_texture; /* Local depth buffer for z-ordering */
+ XrExtent2Di size;
+ SDL_GPUTextureFormat format;
+ Uint32 image_count;
+} VRSwapchain;
+
+/* Depth buffer format - use D24 for wide compatibility */
+static const SDL_GPUTextureFormat DEPTH_FORMAT = SDL_GPU_TEXTUREFORMAT_D24_UNORM;
+
+static VRSwapchain *vr_swapchains = NULL;
+static XrView *xr_views = NULL;
+static Uint32 view_count = 0;
+
+/* SDL GPU state */
+static SDL_GPUDevice *gpu_device = NULL;
+static SDL_GPUGraphicsPipeline *pipeline = NULL;
+static SDL_GPUBuffer *vertex_buffer = NULL;
+static SDL_GPUBuffer *index_buffer = NULL;
+
+/* Animation time */
+static float anim_time = 0.0f;
+static Uint64 last_ticks = 0;
+
+/* Cube scene configuration */
+#define NUM_CUBES 5
+static Vec3 cube_positions[NUM_CUBES] = {
+ { 0.0f, 0.0f, -2.0f }, /* Center, in front */
+ { -1.2f, 0.4f, -2.5f }, /* Upper left */
+ { 1.2f, 0.3f, -2.5f }, /* Upper right */
+ { -0.6f, -0.4f, -1.8f }, /* Lower left close */
+ { 0.6f, -0.3f, -1.8f }, /* Lower right close */
+};
+static float cube_scales[NUM_CUBES] = { 1.0f, 0.6f, 0.6f, 0.5f, 0.5f };
+static float cube_speeds[NUM_CUBES] = { 1.0f, 1.5f, -1.2f, 2.0f, -0.8f };
+
+/* ========================================================================
+ * Cleanup and Quit
+ * ======================================================================== */
+
+static void quit(int rc)
+{
+ SDL_Log("Cleaning up...");
+
+ /* CRITICAL: Wait for GPU to finish before destroying resources
+ * Per PR #14837 discussion - prevents Vulkan validation errors */
+ if (gpu_device) {
+ SDL_WaitForGPUIdle(gpu_device);
+ }
+
+ /* Release GPU resources first */
+ if (pipeline) {
+ SDL_ReleaseGPUGraphicsPipeline(gpu_device, pipeline);
+ pipeline = NULL;
+ }
+ if (vertex_buffer) {
+ SDL_ReleaseGPUBuffer(gpu_device, vertex_buffer);
+ vertex_buffer = NULL;
+ }
+ if (index_buffer) {
+ SDL_ReleaseGPUBuffer(gpu_device, index_buffer);
+ index_buffer = NULL;
+ }
+
+ /* Release swapchains and depth textures */
+ if (vr_swapchains) {
+ for (Uint32 i = 0; i < view_count; i++) {
+ if (vr_swapchains[i].depth_texture) {
+ SDL_ReleaseGPUTexture(gpu_device, vr_swapchains[i].depth_texture);
+ }
+ if (vr_swapchains[i].swapchain) {
+ SDL_DestroyGPUXRSwapchain(gpu_device, vr_swapchains[i].swapchain, vr_swapchains[i].images);
+ }
+ }
+ SDL_free(vr_swapchains);
+ vr_swapchains = NULL;
+ }
+
+ if (xr_views) {
+ SDL_free(xr_views);
+ xr_views = NULL;
+ }
+
+ /* Destroy OpenXR resources */
+ if (xr_local_space && pfn_xrDestroySpace) {
+ pfn_xrDestroySpace(xr_local_space);
+ xr_local_space = XR_NULL_HANDLE;
+ }
+ if (xr_session && pfn_xrDestroySession) {
+ pfn_xrDestroySession(xr_session);
+ xr_session = XR_NULL_HANDLE;
+ }
+
+ /* Destroy GPU device (this also handles XR instance cleanup) */
+ if (gpu_device) {
+ SDL_DestroyGPUDevice(gpu_device);
+ gpu_device = NULL;
+ }
+
+ SDL_Quit();
+ exit(rc);
+}
+
+/* ========================================================================
+ * Shader Loading
+ * ======================================================================== */
+
+static SDL_GPUShader *load_shader(bool is_vertex, Uint32 sampler_count, Uint32 uniform_buffer_count)
+{
+ SDL_GPUShaderCreateInfo createinfo;
+ createinfo.num_samplers = sampler_count;
+ createinfo.num_storage_buffers = 0;
+ createinfo.num_storage_textures = 0;
+ createinfo.num_uniform_buffers = uniform_buffer_count;
+
+ SDL_GPUShaderFormat format = SDL_GetGPUShaderFormats(gpu_device);
+ if (format & SDL_GPU_SHADERFORMAT_DXIL) {
+ createinfo.format = SDL_GPU_SHADERFORMAT_DXIL;
+ if (is_vertex) {
+ createinfo.code = cube_vert_dxil;
+ createinfo.code_size = cube_vert_dxil_len;
+ createinfo.entrypoint = "main";
+ } else {
+ createinfo.code = cube_frag_dxil;
+ createinfo.code_size = cube_frag_dxil_len;
+ createinfo.entrypoint = "main";
+ }
+ } else if (format & SDL_GPU_SHADERFORMAT_SPIRV) {
+ createinfo.format = SDL_GPU_SHADERFORMAT_SPIRV;
+ if (is_vertex) {
+ createinfo.code = cube_vert_spv;
+ createinfo.code_size = cube_vert_spv_len;
+ createinfo.entrypoint = "main";
+ } else {
+ createinfo.code = cube_frag_spv;
+ createinfo.code_size = cube_frag_spv_len;
+ createinfo.entrypoint = "main";
+ }
+ } else {
+ SDL_Log("No supported shader format found!");
+ return NULL;
+ }
+
+ createinfo.stage = is_vertex ? SDL_GPU_SHADERSTAGE_VERTEX : SDL_GPU_SHADERSTAGE_FRAGMENT;
+ createinfo.props = 0;
+
+ return SDL_CreateGPUShader(gpu_device, &createinfo);
+}
+
+/* ========================================================================
+ * OpenXR Function Loading
+ * ======================================================================== */
+
+static bool load_xr_functions(void)
+{
+ pfn_xrGetInstanceProcAddr = (PFN_xrGetInstanceProcAddr)SDL_OpenXR_GetXrGetInstanceProcAddr();
+ if (!pfn_xrGetInstanceProcAddr) {
+ SDL_Log("Failed to get xrGetInstanceProcAddr");
+ return false;
+ }
+
+#define XR_LOAD(fn) \
+ if (XR_FAILED(pfn_xrGetInstanceProcAddr(xr_instance, #fn, (PFN_xrVoidFunction*)&pfn_##fn))) { \
+ SDL_Log("Failed to load " #fn); \
+ return false; \
+ }
+
+ XR_LOAD(xrEnumerateViewConfigurationViews);
+ XR_LOAD(xrEnumerateSwapchainImages);
+ XR_LOAD(xrCreateReferenceSpace);
+ XR_LOAD(xrDestroySpace);
+ XR_LOAD(xrDestroySession);
+ XR_LOAD(xrDestroyInstance);
+ XR_LOAD(xrPollEvent);
+ XR_LOAD(xrBeginSession);
+ XR_LOAD(xrEndSession);
+ XR_LOAD(xrWaitFrame);
+ XR_LOAD(xrBeginFrame);
+ XR_LOAD(xrEndFrame);
+ XR_LOAD(
(Patch may be truncated, please check the link at the top of this post.)