Преглед изворни кода

test: Add testgpu_spinning_cube_xr (#14943)

Co-authored-by: Ethan Lee <flibitijibibo@gmail.com>
Aaron Benjamin пре 1 недеља
родитељ
комит
7678226f4a
4 измењених фајлова са 1428 додато и 1 уклоњено
  1. 320 0
      docs/README-xr.md
  2. 31 1
      test/CMakeLists.txt
  3. 94 0
      test/android/cmake/AndroidManifest.xr.xml.cmake
  4. 983 0
      test/testgpu_spinning_cube_xr.c

+ 320 - 0
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`

+ 31 - 1
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"

+ 94 - 0
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>

+ 983 - 0
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(xrLocateViews);
+    XR_LOAD(xrAcquireSwapchainImage);
+    XR_LOAD(xrWaitSwapchainImage);
+    XR_LOAD(xrReleaseSwapchainImage);
+
+#undef XR_LOAD
+
+    SDL_Log("Loaded all XR functions successfully");
+    return true;
+}
+
+/* ========================================================================
+ * Pipeline and Buffer Creation
+ * ======================================================================== */
+
+static bool create_pipeline(SDL_GPUTextureFormat color_format)
+{
+    SDL_GPUShader *vert_shader = load_shader(true, 0, 1);
+    SDL_GPUShader *frag_shader = load_shader(false, 0, 0);
+
+    if (!vert_shader || !frag_shader) {
+        if (vert_shader) SDL_ReleaseGPUShader(gpu_device, vert_shader);
+        if (frag_shader) SDL_ReleaseGPUShader(gpu_device, frag_shader);
+        return false;
+    }
+
+    SDL_GPUGraphicsPipelineCreateInfo pipeline_info = {
+        .vertex_shader = vert_shader,
+        .fragment_shader = frag_shader,
+        .target_info = {
+            .num_color_targets = 1,
+            .color_target_descriptions = (SDL_GPUColorTargetDescription[]){{
+                .format = color_format
+            }},
+            .has_depth_stencil_target = true,
+            .depth_stencil_format = DEPTH_FORMAT
+        },
+        .depth_stencil_state = {
+            .enable_depth_test = true,
+            .enable_depth_write = true,
+            .compare_op = SDL_GPU_COMPAREOP_LESS_OR_EQUAL
+        },
+        .rasterizer_state = {
+            .cull_mode = SDL_GPU_CULLMODE_BACK,
+            .front_face = SDL_GPU_FRONTFACE_CLOCKWISE,  /* Cube indices wind clockwise when viewed from outside */
+            .fill_mode = SDL_GPU_FILLMODE_FILL
+        },
+        .vertex_input_state = {
+            .num_vertex_buffers = 1,
+            .vertex_buffer_descriptions = (SDL_GPUVertexBufferDescription[]){{
+                .slot = 0,
+                .pitch = sizeof(PositionColorVertex),
+                .input_rate = SDL_GPU_VERTEXINPUTRATE_VERTEX
+            }},
+            .num_vertex_attributes = 2,
+            .vertex_attributes = (SDL_GPUVertexAttribute[]){{
+                .location = 0,
+                .buffer_slot = 0,
+                .format = SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3,
+                .offset = 0
+            }, {
+                .location = 1,
+                .buffer_slot = 0,
+                .format = SDL_GPU_VERTEXELEMENTFORMAT_UBYTE4_NORM,
+                .offset = sizeof(float) * 3
+            }}
+        },
+        .primitive_type = SDL_GPU_PRIMITIVETYPE_TRIANGLELIST
+    };
+
+    pipeline = SDL_CreateGPUGraphicsPipeline(gpu_device, &pipeline_info);
+
+    SDL_ReleaseGPUShader(gpu_device, vert_shader);
+    SDL_ReleaseGPUShader(gpu_device, frag_shader);
+
+    if (!pipeline) {
+        SDL_Log("Failed to create pipeline: %s", SDL_GetError());
+        return false;
+    }
+
+    SDL_Log("Created graphics pipeline for format %d", color_format);
+    return true;
+}
+
+static bool create_cube_buffers(void)
+{
+    float s = CUBE_HALF_SIZE;
+
+    PositionColorVertex vertices[24] = {
+        /* Front face (red) */
+        {-s,-s,-s, 255,0,0,255}, {s,-s,-s, 255,0,0,255}, {s,s,-s, 255,0,0,255}, {-s,s,-s, 255,0,0,255},
+        /* Back face (green) */
+        {s,-s,s, 0,255,0,255}, {-s,-s,s, 0,255,0,255}, {-s,s,s, 0,255,0,255}, {s,s,s, 0,255,0,255},
+        /* Left face (blue) */
+        {-s,-s,s, 0,0,255,255}, {-s,-s,-s, 0,0,255,255}, {-s,s,-s, 0,0,255,255}, {-s,s,s, 0,0,255,255},
+        /* Right face (yellow) */
+        {s,-s,-s, 255,255,0,255}, {s,-s,s, 255,255,0,255}, {s,s,s, 255,255,0,255}, {s,s,-s, 255,255,0,255},
+        /* Top face (magenta) */
+        {-s,s,-s, 255,0,255,255}, {s,s,-s, 255,0,255,255}, {s,s,s, 255,0,255,255}, {-s,s,s, 255,0,255,255},
+        /* Bottom face (cyan) */
+        {-s,-s,s, 0,255,255,255}, {s,-s,s, 0,255,255,255}, {s,-s,-s, 0,255,255,255}, {-s,-s,-s, 0,255,255,255}
+    };
+
+    Uint16 indices[36] = {
+        0,1,2, 0,2,3,       /* Front */
+        4,5,6, 4,6,7,       /* Back */
+        8,9,10, 8,10,11,    /* Left */
+        12,13,14, 12,14,15, /* Right */
+        16,17,18, 16,18,19, /* Top */
+        20,21,22, 20,22,23  /* Bottom */
+    };
+
+    SDL_GPUBufferCreateInfo vertex_buf_info = {
+        .usage = SDL_GPU_BUFFERUSAGE_VERTEX,
+        .size = sizeof(vertices)
+    };
+    vertex_buffer = SDL_CreateGPUBuffer(gpu_device, &vertex_buf_info);
+    CHECK_CREATE(vertex_buffer, "Vertex Buffer");
+
+    SDL_GPUBufferCreateInfo index_buf_info = {
+        .usage = SDL_GPU_BUFFERUSAGE_INDEX,
+        .size = sizeof(indices)
+    };
+    index_buffer = SDL_CreateGPUBuffer(gpu_device, &index_buf_info);
+    CHECK_CREATE(index_buffer, "Index Buffer");
+
+    /* Create transfer buffer and upload data */
+    SDL_GPUTransferBufferCreateInfo transfer_info = {
+        .usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD,
+        .size = sizeof(vertices) + sizeof(indices)
+    };
+    SDL_GPUTransferBuffer *transfer = SDL_CreateGPUTransferBuffer(gpu_device, &transfer_info);
+    CHECK_CREATE(transfer, "Transfer Buffer");
+
+    void *data = SDL_MapGPUTransferBuffer(gpu_device, transfer, false);
+    SDL_memcpy(data, vertices, sizeof(vertices));
+    SDL_memcpy((Uint8*)data + sizeof(vertices), indices, sizeof(indices));
+    SDL_UnmapGPUTransferBuffer(gpu_device, transfer);
+
+    SDL_GPUCommandBuffer *cmd = SDL_AcquireGPUCommandBuffer(gpu_device);
+    SDL_GPUCopyPass *copy_pass = SDL_BeginGPUCopyPass(cmd);
+
+    SDL_GPUTransferBufferLocation src_vertex = { .transfer_buffer = transfer, .offset = 0 };
+    SDL_GPUBufferRegion dst_vertex = { .buffer = vertex_buffer, .offset = 0, .size = sizeof(vertices) };
+    SDL_UploadToGPUBuffer(copy_pass, &src_vertex, &dst_vertex, false);
+
+    SDL_GPUTransferBufferLocation src_index = { .transfer_buffer = transfer, .offset = sizeof(vertices) };
+    SDL_GPUBufferRegion dst_index = { .buffer = index_buffer, .offset = 0, .size = sizeof(indices) };
+    SDL_UploadToGPUBuffer(copy_pass, &src_index, &dst_index, false);
+
+    SDL_EndGPUCopyPass(copy_pass);
+    SDL_SubmitGPUCommandBuffer(cmd);
+    SDL_ReleaseGPUTransferBuffer(gpu_device, transfer);
+
+    SDL_Log("Created cube vertex (%u bytes) and index (%u bytes) buffers", (unsigned int)sizeof(vertices), (unsigned int)sizeof(indices));
+    return true;
+}
+
+/* ========================================================================
+ * XR Session Initialization
+ * ======================================================================== */
+
+static bool init_xr_session(void)
+{
+    XrResult result;
+
+    /* Create session */
+    XrSessionCreateInfo session_info = { XR_TYPE_SESSION_CREATE_INFO };
+    result = SDL_CreateGPUXRSession(gpu_device, &session_info, &xr_session);
+    XR_CHECK(result, "Failed to create XR session");
+
+    /* Create reference space */
+    XrReferenceSpaceCreateInfo space_info = { XR_TYPE_REFERENCE_SPACE_CREATE_INFO };
+    space_info.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
+    space_info.poseInReferenceSpace.orientation.w = 1.0f; /* Identity quaternion */
+
+    result = pfn_xrCreateReferenceSpace(xr_session, &space_info, &xr_local_space);
+    XR_CHECK(result, "Failed to create reference space");
+
+    return true;
+}
+
+static bool create_swapchains(void)
+{
+    XrResult result;
+
+    /* Get view configuration */
+    result = pfn_xrEnumerateViewConfigurationViews(
+        xr_instance, xr_system_id,
+        XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,
+        0, &view_count, NULL);
+    XR_CHECK(result, "Failed to enumerate view config views (count)");
+
+    SDL_Log("View count: %" SDL_PRIu32, view_count);
+
+    XrViewConfigurationView *view_configs = SDL_calloc(view_count, sizeof(XrViewConfigurationView));
+    for (Uint32 i = 0; i < view_count; i++) {
+        view_configs[i].type = XR_TYPE_VIEW_CONFIGURATION_VIEW;
+    }
+
+    result = pfn_xrEnumerateViewConfigurationViews(
+        xr_instance, xr_system_id,
+        XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,
+        view_count, &view_count, view_configs);
+    if (XR_FAILED(result)) {
+        SDL_free(view_configs);
+        SDL_Log("Failed to enumerate view config views");
+        return false;
+    }
+
+    /* Allocate swapchains and views */
+    vr_swapchains = SDL_calloc(view_count, sizeof(VRSwapchain));
+    xr_views = SDL_calloc(view_count, sizeof(XrView));
+
+    /* Query available swapchain formats
+     * Per PR #14837: format arrays are terminated with SDL_GPU_TEXTUREFORMAT_INVALID */
+    int num_formats = 0;
+    SDL_GPUTextureFormat *formats = SDL_GetGPUXRSwapchainFormats(gpu_device, xr_session, &num_formats);
+    if (!formats || num_formats == 0) {
+        SDL_Log("Failed to get XR swapchain formats");
+        SDL_free(view_configs);
+        return false;
+    }
+    
+    /* Use first available format (typically sRGB)
+     * Note: Could iterate with: while (formats[i] != SDL_GPU_TEXTUREFORMAT_INVALID) */
+    SDL_GPUTextureFormat swapchain_format = formats[0];
+    SDL_Log("Using swapchain format: %d (of %d available)", swapchain_format, num_formats);
+    
+    /* Log all available formats for debugging */
+    for (int f = 0; f < num_formats && formats[f] != SDL_GPU_TEXTUREFORMAT_INVALID; f++) {
+        SDL_Log("  Available format [%d]: %d", f, formats[f]);
+    }
+    SDL_free(formats);
+
+    for (Uint32 i = 0; i < view_count; i++) {
+        xr_views[i].type = XR_TYPE_VIEW;
+        xr_views[i].pose.orientation.w = 1.0f;
+
+        SDL_Log("Eye %" SDL_PRIu32 ": recommended %ux%u", i,
+                (unsigned int)view_configs[i].recommendedImageRectWidth,
+                (unsigned int)view_configs[i].recommendedImageRectHeight);
+
+        /* Create swapchain using OpenXR's XrSwapchainCreateInfo */
+        XrSwapchainCreateInfo swapchain_info = { XR_TYPE_SWAPCHAIN_CREATE_INFO };
+        swapchain_info.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_SAMPLED_BIT;
+        swapchain_info.format = 0; /* Ignored - SDL uses the format parameter */
+        swapchain_info.sampleCount = 1;
+        swapchain_info.width = view_configs[i].recommendedImageRectWidth;
+        swapchain_info.height = view_configs[i].recommendedImageRectHeight;
+        swapchain_info.faceCount = 1;
+        swapchain_info.arraySize = 1;
+        swapchain_info.mipCount = 1;
+
+        result = SDL_CreateGPUXRSwapchain(
+            gpu_device,
+            xr_session,
+            &swapchain_info,
+            swapchain_format,
+            &vr_swapchains[i].swapchain,
+            &vr_swapchains[i].images);
+
+        vr_swapchains[i].format = swapchain_format;
+
+        if (XR_FAILED(result)) {
+            SDL_Log("Failed to create swapchain %" SDL_PRIu32, i);
+            SDL_free(view_configs);
+            return false;
+        }
+
+        /* Get image count by enumerating swapchain images */
+        result = pfn_xrEnumerateSwapchainImages(vr_swapchains[i].swapchain, 0, &vr_swapchains[i].image_count, NULL);
+        if (XR_FAILED(result)) {
+            vr_swapchains[i].image_count = 3; /* Assume 3 if we can't query */
+        }
+
+        vr_swapchains[i].size.width = (int32_t)swapchain_info.width;
+        vr_swapchains[i].size.height = (int32_t)swapchain_info.height;
+
+        /* Create local depth texture for this eye
+         * Per PR #14837: Depth buffers are "really recommended" for XR apps.
+         * Using a local depth texture (not XR-managed) is the simplest approach
+         * for proper z-ordering without requiring XR_KHR_composition_layer_depth. */
+        SDL_GPUTextureCreateInfo depth_info = {
+            .type = SDL_GPU_TEXTURETYPE_2D,
+            .format = DEPTH_FORMAT,
+            .width = swapchain_info.width,
+            .height = swapchain_info.height,
+            .layer_count_or_depth = 1,
+            .num_levels = 1,
+            .sample_count = SDL_GPU_SAMPLECOUNT_1,
+            .usage = SDL_GPU_TEXTUREUSAGE_DEPTH_STENCIL_TARGET,
+            .props = 0
+        };
+        vr_swapchains[i].depth_texture = SDL_CreateGPUTexture(gpu_device, &depth_info);
+        if (!vr_swapchains[i].depth_texture) {
+            SDL_Log("Failed to create depth texture for eye %" SDL_PRIu32 ": %s", i, SDL_GetError());
+            SDL_free(view_configs);
+            return false;
+        }
+
+        SDL_Log("Created swapchain %" SDL_PRIu32 ": %" SDL_PRIs32 "x%" SDL_PRIs32 ", %" SDL_PRIu32 " images, with depth buffer",
+                i, vr_swapchains[i].size.width, vr_swapchains[i].size.height,
+                vr_swapchains[i].image_count);
+    }
+
+    SDL_free(view_configs);
+
+    /* Create the pipeline using the swapchain format */
+    if (view_count > 0 && pipeline == NULL) {
+        if (!create_pipeline(vr_swapchains[0].format)) {
+            return false;
+        }
+        if (!create_cube_buffers()) {
+            return false;
+        }
+    }
+
+    return true;
+}
+
+/* ========================================================================
+ * XR Event Handling
+ * ======================================================================== */
+
+static void handle_xr_events(void)
+{
+    XrEventDataBuffer event_buffer = { XR_TYPE_EVENT_DATA_BUFFER };
+
+    while (pfn_xrPollEvent(xr_instance, &event_buffer) == XR_SUCCESS) {
+        switch (event_buffer.type) {
+            case XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED: {
+                XrEventDataSessionStateChanged *state_event =
+                    (XrEventDataSessionStateChanged*)&event_buffer;
+
+                SDL_Log("Session state changed: %d", state_event->state);
+
+                switch (state_event->state) {
+                    case XR_SESSION_STATE_READY: {
+                        XrSessionBeginInfo begin_info = { XR_TYPE_SESSION_BEGIN_INFO };
+                        begin_info.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
+
+                        XrResult result = pfn_xrBeginSession(xr_session, &begin_info);
+                        if (XR_SUCCEEDED(result)) {
+                            SDL_Log("XR Session begun!");
+                            xr_session_running = true;
+
+                            /* Create swapchains now that session is ready */
+                            if (!create_swapchains()) {
+                                SDL_Log("Failed to create swapchains");
+                                xr_should_quit = true;
+                            }
+                        }
+                        break;
+                    }
+                    case XR_SESSION_STATE_STOPPING:
+                        pfn_xrEndSession(xr_session);
+                        xr_session_running = false;
+                        break;
+                    case XR_SESSION_STATE_EXITING:
+                    case XR_SESSION_STATE_LOSS_PENDING:
+                        xr_should_quit = true;
+                        break;
+                    default:
+                        break;
+                }
+                break;
+            }
+            case XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING:
+                xr_should_quit = true;
+                break;
+            default:
+                break;
+        }
+
+        event_buffer.type = XR_TYPE_EVENT_DATA_BUFFER;
+    }
+}
+
+/* ========================================================================
+ * Rendering
+ * ======================================================================== */
+
+static void render_frame(void)
+{
+    if (!xr_session_running) return;
+
+    XrFrameState frame_state = { XR_TYPE_FRAME_STATE };
+    XrFrameWaitInfo wait_info = { XR_TYPE_FRAME_WAIT_INFO };
+
+    XrResult result = pfn_xrWaitFrame(xr_session, &wait_info, &frame_state);
+    if (XR_FAILED(result)) return;
+
+    XrFrameBeginInfo begin_info = { XR_TYPE_FRAME_BEGIN_INFO };
+    result = pfn_xrBeginFrame(xr_session, &begin_info);
+    if (XR_FAILED(result)) return;
+
+    XrCompositionLayerProjectionView *proj_views = NULL;
+    XrCompositionLayerProjection layer = { XR_TYPE_COMPOSITION_LAYER_PROJECTION };
+    Uint32 layer_count = 0;
+    const XrCompositionLayerBaseHeader *layers[1] = {0};
+
+    if (frame_state.shouldRender && view_count > 0 && vr_swapchains != NULL) {
+        /* Update animation time */
+        Uint64 now = SDL_GetTicks();
+        if (last_ticks == 0) last_ticks = now;
+        float delta = (float)(now - last_ticks) / 1000.0f;
+        last_ticks = now;
+        anim_time += delta;
+
+        /* Locate views */
+        XrViewState view_state = { XR_TYPE_VIEW_STATE };
+        XrViewLocateInfo locate_info = { XR_TYPE_VIEW_LOCATE_INFO };
+        locate_info.viewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
+        locate_info.displayTime = frame_state.predictedDisplayTime;
+        locate_info.space = xr_local_space;
+
+        Uint32 view_count_output;
+        result = pfn_xrLocateViews(xr_session, &locate_info, &view_state, view_count, &view_count_output, xr_views);
+        if (XR_FAILED(result)) {
+            SDL_Log("xrLocateViews failed");
+            goto endFrame;
+        }
+
+        proj_views = SDL_calloc(view_count, sizeof(XrCompositionLayerProjectionView));
+
+        SDL_GPUCommandBuffer *cmd_buf = SDL_AcquireGPUCommandBuffer(gpu_device);
+
+        /* Multi-pass stereo: render each eye separately */
+        for (Uint32 i = 0; i < view_count; i++) {
+            VRSwapchain *swapchain = &vr_swapchains[i];
+
+            /* Acquire swapchain image */
+            Uint32 image_index;
+            XrSwapchainImageAcquireInfo acquire_info = { XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO };
+            result = pfn_xrAcquireSwapchainImage(swapchain->swapchain, &acquire_info, &image_index);
+            if (XR_FAILED(result)) continue;
+
+            XrSwapchainImageWaitInfo wait_image_info = { XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO };
+            wait_image_info.timeout = XR_INFINITE_DURATION;
+            result = pfn_xrWaitSwapchainImage(swapchain->swapchain, &wait_image_info);
+            if (XR_FAILED(result)) {
+                XrSwapchainImageReleaseInfo release_info = { XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO };
+                pfn_xrReleaseSwapchainImage(swapchain->swapchain, &release_info);
+                continue;
+            }
+
+            /* Render the scene to this eye */
+            SDL_GPUTexture *target_texture = swapchain->images[image_index];
+
+            /* Build view and projection matrices from XR pose/fov */
+            Mat4 view_matrix = Mat4_FromXrPose(xr_views[i].pose);
+            Mat4 proj_matrix = Mat4_Projection(xr_views[i].fov, 0.05f, 100.0f);
+
+            SDL_GPUColorTargetInfo color_target = {0};
+            color_target.texture = target_texture;
+            color_target.load_op = SDL_GPU_LOADOP_CLEAR;
+            color_target.store_op = SDL_GPU_STOREOP_STORE;
+            /* Dark blue background */
+            color_target.clear_color.r = 0.05f;
+            color_target.clear_color.g = 0.05f;
+            color_target.clear_color.b = 0.15f;
+            color_target.clear_color.a = 1.0f;
+
+            /* Set up depth target for proper z-ordering */
+            SDL_GPUDepthStencilTargetInfo depth_target = {0};
+            depth_target.texture = swapchain->depth_texture;
+            depth_target.clear_depth = 1.0f;  /* Far plane */
+            depth_target.load_op = SDL_GPU_LOADOP_CLEAR;
+            depth_target.store_op = SDL_GPU_STOREOP_DONT_CARE;  /* We don't need to preserve depth */
+            depth_target.stencil_load_op = SDL_GPU_LOADOP_DONT_CARE;
+            depth_target.stencil_store_op = SDL_GPU_STOREOP_DONT_CARE;
+            depth_target.cycle = true;  /* Allow GPU to cycle the texture for efficiency */
+
+            SDL_GPURenderPass *render_pass = SDL_BeginGPURenderPass(cmd_buf, &color_target, 1, &depth_target);
+
+            if (pipeline && vertex_buffer && index_buffer) {
+                SDL_BindGPUGraphicsPipeline(render_pass, pipeline);
+
+                SDL_GPUViewport viewport = {0, 0, (float)swapchain->size.width, (float)swapchain->size.height, 0, 1};
+                SDL_SetGPUViewport(render_pass, &viewport);
+
+                SDL_Rect scissor = {0, 0, swapchain->size.width, swapchain->size.height};
+                SDL_SetGPUScissor(render_pass, &scissor);
+
+                SDL_GPUBufferBinding vertex_binding = {vertex_buffer, 0};
+                SDL_BindGPUVertexBuffers(render_pass, 0, &vertex_binding, 1);
+
+                SDL_GPUBufferBinding index_binding = {index_buffer, 0};
+                SDL_BindGPUIndexBuffer(render_pass, &index_binding, SDL_GPU_INDEXELEMENTSIZE_16BIT);
+
+                /* Draw each cube */
+                for (int cube_idx = 0; cube_idx < NUM_CUBES; cube_idx++) {
+                    float rot = anim_time * cube_speeds[cube_idx];
+                    Vec3 pos = cube_positions[cube_idx];
+
+                    /* Build model matrix: scale -> rotateY -> rotateX -> translate */
+                    Mat4 scale = Mat4_Scale(cube_scales[cube_idx]);
+                    Mat4 rotY = Mat4_RotationY(rot);
+                    Mat4 rotX = Mat4_RotationX(rot * 0.7f);
+                    Mat4 trans = Mat4_Translation(pos.x, pos.y, pos.z);
+
+                    Mat4 model = Mat4_Multiply(Mat4_Multiply(Mat4_Multiply(scale, rotY), rotX), trans);
+                    Mat4 mv = Mat4_Multiply(model, view_matrix);
+                    Mat4 mvp = Mat4_Multiply(mv, proj_matrix);
+
+                    SDL_PushGPUVertexUniformData(cmd_buf, 0, &mvp, sizeof(mvp));
+                    SDL_DrawGPUIndexedPrimitives(render_pass, 36, 1, 0, 0, 0);
+                }
+            }
+
+            SDL_EndGPURenderPass(render_pass);
+
+            /* Release swapchain image */
+            XrSwapchainImageReleaseInfo release_info = { XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO };
+            pfn_xrReleaseSwapchainImage(swapchain->swapchain, &release_info);
+
+            /* Set up projection view */
+            proj_views[i].type = XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW;
+            proj_views[i].pose = xr_views[i].pose;
+            proj_views[i].fov = xr_views[i].fov;
+            proj_views[i].subImage.swapchain = swapchain->swapchain;
+            proj_views[i].subImage.imageRect.offset.x = 0;
+            proj_views[i].subImage.imageRect.offset.y = 0;
+            proj_views[i].subImage.imageRect.extent = swapchain->size;
+            proj_views[i].subImage.imageArrayIndex = 0;
+        }
+
+        SDL_SubmitGPUCommandBuffer(cmd_buf);
+
+        layer.space = xr_local_space;
+        layer.viewCount = view_count;
+        layer.views = proj_views;
+        layers[0] = (XrCompositionLayerBaseHeader*)&layer;
+        layer_count = 1;
+    }
+
+endFrame:;
+    XrFrameEndInfo end_info = { XR_TYPE_FRAME_END_INFO };
+    end_info.displayTime = frame_state.predictedDisplayTime;
+    end_info.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE;
+    end_info.layerCount = layer_count;
+    end_info.layers = layers;
+
+    pfn_xrEndFrame(xr_session, &end_info);
+
+    if (proj_views) SDL_free(proj_views);
+}
+
+/* ========================================================================
+ * Main
+ * ======================================================================== */
+
+int main(int argc, char *argv[])
+{
+    (void)argc;
+    (void)argv;
+
+    SDL_Log("SDL GPU OpenXR Spinning Cubes Test starting...");
+    SDL_Log("Stereo rendering mode: Multi-pass (one render pass per eye)");
+
+    if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS)) {
+        SDL_Log("SDL_Init failed: %s", SDL_GetError());
+        return 1;
+    }
+
+    SDL_Log("SDL initialized");
+
+    /* Create GPU device with OpenXR enabled */
+    SDL_Log("Creating GPU device with OpenXR enabled...");
+
+    SDL_PropertiesID props = SDL_CreateProperties();
+    SDL_SetBooleanProperty(props, SDL_PROP_GPU_DEVICE_CREATE_SHADERS_SPIRV_BOOLEAN, true);
+    SDL_SetBooleanProperty(props, SDL_PROP_GPU_DEVICE_CREATE_SHADERS_DXIL_BOOLEAN, true);
+    SDL_SetBooleanProperty(props, SDL_PROP_GPU_DEVICE_CREATE_DEBUGMODE_BOOLEAN, true);
+    /* Enable XR - SDL will create the OpenXR instance for us */
+    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);
+    SDL_SetStringProperty(props, SDL_PROP_GPU_DEVICE_CREATE_XR_APPLICATION_NAME_STRING, "SDL XR Spinning Cubes Test");
+    SDL_SetNumberProperty(props, SDL_PROP_GPU_DEVICE_CREATE_XR_APPLICATION_VERSION_NUMBER, 1);
+
+    gpu_device = SDL_CreateGPUDeviceWithProperties(props);
+    SDL_DestroyProperties(props);
+
+    if (!gpu_device) {
+        SDL_Log("Failed to create GPU device: %s", SDL_GetError());
+        SDL_Quit();
+        return 1;
+    }
+
+    /* Load OpenXR function pointers */
+    if (!load_xr_functions()) {
+        SDL_Log("Failed to load XR functions");
+        quit(1);
+    }
+
+    /* Initialize XR session */
+    if (!init_xr_session()) {
+        SDL_Log("Failed to init XR session");
+        quit(1);
+    }
+
+    SDL_Log("Entering main loop... Put on your VR headset!");
+
+    /* Main loop */
+    while (!xr_should_quit) {
+        SDL_Event event;
+        while (SDL_PollEvent(&event)) {
+            if (event.type == SDL_EVENT_QUIT) {
+                xr_should_quit = true;
+            }
+            if (event.type == SDL_EVENT_KEY_DOWN && event.key.key == SDLK_ESCAPE) {
+                xr_should_quit = true;
+            }
+        }
+
+        handle_xr_events();
+        render_frame();
+    }
+
+    quit(0);
+    return 0;
+}