From 7678226f4ae6247fbfec1971e6ccb270dd187ed4 Mon Sep 17 00:00:00 2001 From: Aaron Benjamin Date: Mon, 23 Feb 2026 11:14:19 -0500 Subject: [PATCH] test: Add testgpu_spinning_cube_xr (#14943) Co-authored-by: Ethan Lee --- docs/README-xr.md | 320 ++++++ test/CMakeLists.txt | 32 +- .../cmake/AndroidManifest.xr.xml.cmake | 94 ++ test/testgpu_spinning_cube_xr.c | 983 ++++++++++++++++++ 4 files changed, 1428 insertions(+), 1 deletion(-) create mode 100644 docs/README-xr.md create mode 100644 test/android/cmake/AndroidManifest.xr.xml.cmake create mode 100644 test/testgpu_spinning_cube_xr.c diff --git a/docs/README-xr.md b/docs/README-xr.md new file mode 100644 index 0000000000..9147df26e9 --- /dev/null +++ b/docs/README-xr.md @@ -0,0 +1,320 @@ +# OpenXR / VR Development with SDL + +This document covers how to build OpenXR (VR/AR) applications using SDL's GPU API with OpenXR integration. + +## Overview + +SDL3 provides OpenXR integration through the GPU API, allowing you to render to VR/AR headsets using a unified interface across multiple graphics backends (Vulkan, D3D12, Metal). + +**Key features:** +- Automatic OpenXR instance and session management +- Swapchain creation and image acquisition +- Support for multi-pass stereo rendering +- Works with desktop VR runtimes (SteamVR, Oculus, Windows Mixed Reality) and standalone headsets (Meta Quest, Pico) + +## Desktop Development + +### Requirements + +1. **OpenXR Loader** (`openxr_loader.dll` / `libopenxr_loader.so`) + - On Windows: Usually installed with VR runtime software (Oculus, SteamVR) + - On Linux: Install via package manager (e.g., `libopenxr-loader1` on Ubuntu) + - Can also use `SDL_HINT_OPENXR_LIBRARY` to specify a custom loader path + +2. **OpenXR Runtime** + - At least one OpenXR runtime must be installed and active + - Examples: SteamVR, Oculus Desktop, Monado (Linux) + +3. **VR Headset** + - Connected and recognized by the runtime + +### Basic Usage + +```c +#include +#include +#include + +// 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 + + + +``` + +#### Queries (Android 11+) + +Required for the app to discover OpenXR runtimes: + +```xml + + + + + + + + + +``` + +#### Hardware Features + +```xml + + + + + + + + + + +``` + +#### Intent Category + +```xml + + + + + + + + +``` + +--- + +### 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 + + + ... + + + + +``` + +#### Supported Devices + +```xml + + + + +``` + +#### Focus Handling (Recommended) + +```xml + + + + +``` + +#### Hand Tracking (Optional) + +```xml + + + + + + + + +``` + +#### VR Splash Screen (Optional) + +```xml + + + + + +``` + +--- + +### Pico Requirements + +For Pico Neo, Pico 4, and other Pico headsets: + +#### VR Intent Category + +```xml + + + ... + + + + +``` + +#### Supported Devices (Optional) + +```xml + + + + +``` + +--- + +### HTC Vive Focus / VIVE XR Elite + +```xml + + + ... + + + + +``` + +--- + +## 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 `` block for runtime discovery +- **Linux:** Install `libopenxr-loader1` and configure the active runtime + +### OpenXR loader not found + +**Cause:** `openxr_loader.dll` / `libopenxr_loader.so` is not in the library path. + +**Solutions:** +- Install the Khronos OpenXR SDK +- On Windows, VR runtimes typically install this, but may not add it to PATH +- Use `SDL_HINT_OPENXR_LIBRARY` to specify the loader path explicitly + +### Vulkan validation errors on shutdown + +**Cause:** GPU resources destroyed while still in use. + +**Solution:** Call `SDL_WaitForGPUIdle(device)` before releasing any GPU resources or destroying the device. + +--- + +## Additional Resources + +- [Khronos OpenXR Specification](https://www.khronos.org/openxr/) +- [Meta Quest Developer Documentation](https://developer.oculus.com/documentation/) +- [Pico Developer Documentation](https://developer.pico-interactive.com/) +- [SDL GPU API Documentation](https://wiki.libsdl.org/) +- Example code: `test/testgpu_spinning_cube_xr.c` diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 2b58fa4ec0..a77c5aa685 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -382,6 +382,8 @@ add_sdl_test_executable(testgpu_simple_clear SOURCES testgpu_simple_clear.c) add_sdl_test_executable(testgpu_spinning_cube SOURCES testgpu_spinning_cube.c ${icon_png_header} DEPENDS generate-icon_png_header) add_sdl_test_executable(testgpurender_effects MAIN_CALLBACKS NEEDS_RESOURCES TESTUTILS SOURCES testgpurender_effects.c) add_sdl_test_executable(testgpurender_msdf MAIN_CALLBACKS NEEDS_RESOURCES TESTUTILS SOURCES testgpurender_msdf.c) +add_sdl_test_executable(testgpu_spinning_cube_xr SOURCES testgpu_spinning_cube_xr.c) + if(ANDROID) target_link_libraries(testgles PRIVATE GLESv1_CM) elseif(IOS OR TVOS) @@ -750,7 +752,35 @@ if(ANDROID AND TARGET SDL3::Jar) configure_file(android/cmake/SDLTestActivity.java.cmake "${JAVA_PACKAGE_DIR}/SDLTestActivity.java" @ONLY) configure_file(android/cmake/res/values/strings.xml.cmake android/res/values/strings-${TEST}.xml @ONLY) configure_file(android/cmake/res/xml/shortcuts.xml.cmake "${GENERATED_RES_FOLDER}/xml/shortcuts.xml" @ONLY) - configure_file(android/cmake/AndroidManifest.xml.cmake "${generated_manifest_path}" @ONLY) + # Use XR-specific manifest for XR tests, standard manifest for others + if("${TEST}" MATCHES "_xr$") + # Meta Quest-specific manifest sections (enabled by default, set to empty to disable) + # These are ignored by non-Meta runtimes but required for proper Quest integration + if(NOT DEFINED SDL_ANDROID_XR_META_SUPPORT OR SDL_ANDROID_XR_META_SUPPORT) + set(ANDROID_XR_META_FEATURES +" + +") + set(ANDROID_XR_META_METADATA +" + + + + + +") + set(ANDROID_XR_META_INTENT_CATEGORY +" + ") + 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}-$/res/values/strings.xml" INPUT "${CMAKE_CURRENT_BINARY_DIR}/android/res/values/strings-${TEST}.xml" diff --git a/test/android/cmake/AndroidManifest.xr.xml.cmake b/test/android/cmake/AndroidManifest.xr.xml.cmake new file mode 100644 index 0000000000..8f9beffdcb --- /dev/null +++ b/test/android/cmake/AndroidManifest.xr.xml.cmake @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + +@ANDROID_XR_META_FEATURES@ + + + + + + + + + + + + + + + + + + + + + + + + + +@ANDROID_XR_META_METADATA@ + + + + + + +@ANDROID_XR_META_INTENT_CATEGORY@ + + + + + + + + + diff --git a/test/testgpu_spinning_cube_xr.c b/test/testgpu_spinning_cube_xr.c new file mode 100644 index 0000000000..01995c34af --- /dev/null +++ b/test/testgpu_spinning_cube_xr.c @@ -0,0 +1,983 @@ +/* + Copyright (C) 1997-2026 Sam Lantinga + + 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 +#include + +/* Include OpenXR headers BEFORE SDL_openxr.h to get full type definitions */ +#ifdef HAVE_OPENXR_H +#include +#else +/* SDL includes a copy for building on systems without the OpenXR SDK */ +#include "../src/video/khronos/openxr/openxr.h" +#endif + +#include + +/* Standard library for exit() */ +#include + +/* 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; +}