SDL: test: Add testgpu_spinning_cube_xr (#14943)

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.)