test: Add testgpu_spinning_cube_xr (#14943)

Co-authored-by: Ethan Lee <flibitijibibo@gmail.com>
This commit is contained in:
Aaron Benjamin
2026-02-23 11:14:19 -05:00
committed by GitHub
parent bbcc205de9
commit 7678226f4a
4 changed files with 1428 additions and 1 deletions

320
docs/README-xr.md Normal file
View File

@@ -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`

View File

@@ -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"

View File

@@ -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>

View File

@@ -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;
}