feat: add tested Quest OpenXR frontend

This commit is contained in:
XxJSONDeruloxX committed 2026-10-14 17:04:25 -04:00
1 parent f635e92498
commit 07f5e5f83d
9 files changed
+692

No files matched your search

+2
View File
@@ -33,3 +33,5 @@ __pycache__/
.signing.env
app/src/main/assets/directaudio/
/tools/vr/out/
/questxr-build.log
/questxr-validation/
+60
View File
@@ -0,0 +1,60 @@
# Quest OpenXR implementation
Branch: `feat/quest-openxr`. The goal is standalone immersive SteamVR on Quest,
using DroidDeck's Linux guest and an Android OpenXR frontend.
## Milestone 1: native headset frontend
`questxr` builds a separate `com.droiddeck.questxr` companion so the frontend can
be validated without replacing the installed DroidDeck runtime. It uses the
Khronos Android loader, GLES swapchains, predicted stereo views and head poses,
Touch grip/trigger actions, and trigger-edge haptic feedback. The test scene has
world-fixed geometry and tracked controller cubes. Controller availability is
optional for the diagnostic scene; sleeping controllers remain inactive.
Build with `:questxr:assembleDebug`. `tools/deploy_quest_xr.ps1` builds, aligns,
signs with an external key directory, verifies the expected certificate and
installs the companion. Private keys must remain outside the checkout.
Run `python tools/test_quest_xr.py --adb <adb executable>`. The runner restarts
the companion, simulates wearing the headset using Meta's documented
`com.oculus.vrpowermanager.prox_close` broadcast, checks fresh advancing frame
telemetry and valid tracked head pose, and stores local JSON/logcat results in
the ignored `questxr-validation` directory. Use `--require-controllers` when
both Touch controllers are awake. That option must pass before claiming tracked
controller validation. Trigger haptics require an actual trigger press.
## Device evidence, 2026-10-14
Quest 3, Android 14, DroidDeck 0.4.0. Local Windows build with NDK 27.2.12479018
passed. The deployed companion certificate matches `keystore/release-signer.sha256`.
The automated 15-second test passed with 792 rendered stereo frames at the last
sample, a 13,888,888 ns predicted display period (72 Hz), and head location flags
15 (position/orientation valid and tracked). A focused state was observed before
an OS panel took input focus; stereo frame submission continued in VISIBLE.
Neither controller was active. Controller motion and physical haptics have not
been validated. This establishes native OpenXR rendering, not SteamVR gameplay.
Quest reported `XR_KHR_android_surface_swapchain` support. Next, validate an
Android-produced surface swapchain as a projection source, then connect the
existing Android Wayland compositor to it. This may avoid transferring Linux
dmabufs directly into the runtime's graphics device. Surface support alone does
not establish that projection, synchronization, or SteamVR rendering works.
The first launch left a controller-required OS dialog from an earlier manifest.
After declaring optional controller/hand capability and restarting VR Shell,
the app launched. Automated tests should report launch blockers rather than
treating a successful `am start` as successful rendering.
## Remaining stages
- Android/Linux eye-image bridge and frame ownership/synchronization.
- OpenVR driver IPC for predicted 6DoF head, per-eye views and tracked hands.
- Real Touch input mapping and haptics returning through the bridge.
- Runtime-paced driver timing and render-pose association.
- Guest SteamVR dashboard and owned-game validation on Quest.
- Sustained timing, memory and thermal measurements; existing semaphore
workarounds must be assessed under the real graphics chain.
References: [Meta scriptable testing](https://developers.meta.com/vr/documentation/native/android/ts-scriptable-testing/),
[Khronos Android surface swapchain](https://registry.khronos.org/OpenXR/specs/1.1/man/html/xrCreateSwapchainAndroidSurfaceKHR.html).
+32
View File
@@ -0,0 +1,32 @@
plugins {
id 'com.android.application'
}
android {
namespace 'com.droiddeck.questxr'
compileSdk 34
ndkVersion project.findProperty('ndkVersion') ?: '27.3.13750724'
defaultConfig {
applicationId 'com.droiddeck.questxr'
minSdk 26
targetSdk 32
versionCode 1
versionName '0.1.0'
ndk { abiFilters 'arm64-v8a' }
externalNativeBuild {
cmake { arguments '-DANDROID_STL=c++_static' }
}
}
buildFeatures { prefab true }
packagingOptions { jniLibs { useLegacyPackaging = true } }
externalNativeBuild {
cmake {
path 'src/main/cpp/CMakeLists.txt'
version '3.22.1'
}
}
}
dependencies {
implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49'
}
+24
View File
@@ -0,0 +1,24 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.vr.headtracking" android:version="1" android:required="true" />
<uses-feature android:glEsVersion="0x00030000" android:required="true" />
<uses-feature android:name="oculus.software.handtracking" android:required="false" />
<uses-feature android:name="android.hardware.vr.controller" android:required="false" />
<uses-permission android:name="com.oculus.permission.HAND_TRACKING" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.INTERNET" />
<application android:label="DroidDeck XR" android:hasCode="false" android:allowBackup="false" android:extractNativeLibs="true">
<meta-data android:name="com.oculus.supportedDevices" android:value="quest2|questpro|quest3|quest3s" />
<activity android:name="android.app.NativeActivity" android:exported="true" android:launchMode="singleTask"
android:resizeableActivity="false" android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|keyboard|navigation|uiMode"
android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen">
<meta-data android:name="android.app.lib_name" android:value="droiddeckxr" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="com.oculus.intent.category.VR" />
<category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" />
</intent-filter>
</activity>
</application>
</manifest>
+11
View File
@@ -0,0 +1,11 @@
cmake_minimum_required(VERSION 3.22.1)
project(droiddeckxr C CXX)
find_package(OpenXR REQUIRED CONFIG)
add_library(native_app_glue STATIC ${ANDROID_NDK}/sources/android/native_app_glue/android_native_app_glue.c)
target_include_directories(native_app_glue PUBLIC ${ANDROID_NDK}/sources/android/native_app_glue)
add_library(droiddeckxr SHARED quest_xr.cpp)
target_compile_features(droiddeckxr PRIVATE cxx_std_17)
target_compile_definitions(droiddeckxr PRIVATE XR_USE_PLATFORM_ANDROID XR_USE_GRAPHICS_API_OPENGL_ES)
target_compile_options(droiddeckxr PRIVATE -Wall -Wextra -Wno-missing-field-initializers -Werror=return-type)
target_link_options(droiddeckxr PRIVATE -Wl,-z,max-page-size=16384 -uANativeActivity_onCreate)
target_link_libraries(droiddeckxr PRIVATE native_app_glue OpenXR::openxr_loader EGL GLESv3 android log)
+467
View File
@@ -0,0 +1,467 @@
#include <jni.h>
#include <EGL/egl.h>
#include <GLES3/gl3.h>
#include <android/log.h>
#include <android_native_app_glue.h>
#include <openxr/openxr.h>
#include <openxr/openxr_platform.h>
#include <array>
#include <vector>
#include <string>
#include <stdexcept>
#include <cstdio>
#include <cstring>
#include <cmath>
#include <chrono>
static void check(XrResult result, const char *operation) {
if (XR_FAILED(result)) throw std::runtime_error(std::string(operation) + ": " + std::to_string(result));
}
using Matrix = std::array<float, 16>;
static Matrix multiply(const Matrix &left, const Matrix &right) {
Matrix out{};
for (int column = 0; column < 4; column++)
for (int row = 0; row < 4; row++)
for (int inner = 0; inner < 4; inner++)
out[column * 4 + row] += left[inner * 4 + row] * right[column * 4 + inner];
return out;
}
static Matrix transform(const XrPosef &pose) {
const auto &rotation = pose.orientation;
Matrix matrix{
1 - 2 * (rotation.y * rotation.y + rotation.z * rotation.z),
2 * (rotation.x * rotation.y + rotation.w * rotation.z),
2 * (rotation.x * rotation.z - rotation.w * rotation.y), 0,
2 * (rotation.x * rotation.y - rotation.w * rotation.z),
1 - 2 * (rotation.x * rotation.x + rotation.z * rotation.z),
2 * (rotation.y * rotation.z + rotation.w * rotation.x), 0,
2 * (rotation.x * rotation.z + rotation.w * rotation.y),
2 * (rotation.y * rotation.z - rotation.w * rotation.x),
1 - 2 * (rotation.x * rotation.x + rotation.y * rotation.y), 0,
pose.position.x, pose.position.y, pose.position.z, 1
};
return matrix;
}
static Matrix viewProjection(const XrView &view) {
XrPosef inverse{};
inverse.orientation = {-view.pose.orientation.x, -view.pose.orientation.y,
-view.pose.orientation.z, view.pose.orientation.w};
Matrix camera = transform(inverse);
for (int row = 0; row < 3; row++)
camera[12 + row] = -(camera[row] * view.pose.position.x + camera[4 + row] * view.pose.position.y
+ camera[8 + row] * view.pose.position.z);
float left = std::tan(view.fov.angleLeft), right = std::tan(view.fov.angleRight);
float down = std::tan(view.fov.angleDown), up = std::tan(view.fov.angleUp);
const float nearPlane = 0.05f, farPlane = 50.f;
Matrix projection{};
projection[0] = 2 / (right - left);
projection[5] = 2 / (up - down);
projection[8] = (right + left) / (right - left);
projection[9] = (up + down) / (up - down);
projection[10] = -(farPlane + nearPlane) / (farPlane - nearPlane);
projection[11] = -1;
projection[14] = -2 * farPlane * nearPlane / (farPlane - nearPlane);
return multiply(projection, camera);
}
struct Eye {
XrSwapchain swapchain = XR_NULL_HANDLE;
int width = 0, height = 0;
std::vector<XrSwapchainImageOpenGLESKHR> images;
GLuint depth = 0;
};
class QuestXR {
public:
explicit QuestXR(android_app *app) : app_(app) {}
~QuestXR() { cleanup(); }
void run() {
initialize();
while (!app_->destroyRequested && !exit_) {
android_poll_source *source = nullptr;
int events = 0;
while (ALooper_pollOnce(running_ ? 0 : 100, nullptr, &events,
reinterpret_cast<void **>(&source)) >= 0) {
if (source) source->process(app_, source);
if (app_->destroyRequested) break;
}
pollEvents();
if (running_ && !app_->destroyRequested) frame();
}
}
void health(const char *error = "") {
std::string path = std::string(app_->activity->externalDataPath) + "/xr-health.json";
std::string temp = path + ".tmp";
FILE *file = std::fopen(temp.c_str(), "w");
if (!file) return;
std::fprintf(file,
"{\"frames\":%llu,\"rendered\":%llu,\"sessionState\":%d,\"headFlags\":%llu,"
"\"headPosition\":[%.6f,%.6f,%.6f],\"controllersActive\":[%s,%s],"
"\"periodNs\":%lld,\"surfaceSwapchain\":%s,\"error\":\"%s\"}\n",
(unsigned long long)frames_, (unsigned long long)rendered_, state_,
(unsigned long long)head_.locationFlags,
head_.pose.position.x, head_.pose.position.y, head_.pose.position.z,
handActive_[0] ? "true" : "false", handActive_[1] ? "true" : "false",
(long long)period_, surfaceSupported_ ? "true" : "false", error);
std::fclose(file);
std::rename(temp.c_str(), path.c_str());
}
private:
android_app *app_;
JNIEnv *env_ = nullptr;
XrInstance instance_ = XR_NULL_HANDLE;
XrSession session_ = XR_NULL_HANDLE;
XrSystemId system_ = XR_NULL_SYSTEM_ID;
XrSpace world_ = XR_NULL_HANDLE, view_ = XR_NULL_HANDLE;
XrActionSet actions_ = XR_NULL_HANDLE;
XrAction grip_ = XR_NULL_HANDLE, trigger_ = XR_NULL_HANDLE, vibration_ = XR_NULL_HANDLE;
XrPath hands_[2]{};
XrSpace handSpaces_[2]{};
XrSpaceLocation head_{XR_TYPE_SPACE_LOCATION};
XrSpaceLocation handLocations_[2]{{XR_TYPE_SPACE_LOCATION}, {XR_TYPE_SPACE_LOCATION}};
bool handActive_[2]{}, pressed_[2]{};
bool running_ = false, exit_ = false, surfaceSupported_ = false;
XrSessionState state_ = XR_SESSION_STATE_UNKNOWN;
uint64_t frames_ = 0, rendered_ = 0;
XrDuration period_ = 0;
EGLDisplay display_ = EGL_NO_DISPLAY;
EGLContext context_ = EGL_NO_CONTEXT;
EGLSurface surface_ = EGL_NO_SURFACE;
std::array<Eye, 2> eyes_;
GLuint framebuffer_ = 0, program_ = 0, buffer_ = 0, vertexArray_ = 0;
GLint matrixUniform_ = -1, colorUniform_ = -1;
XrPath path(const char *text) {
XrPath result{};
check(xrStringToPath(instance_, text, &result), "xrStringToPath");
return result;
}
void initialize() {
app_->activity->vm->AttachCurrentThread(&env_, nullptr);
PFN_xrInitializeLoaderKHR initializeLoader = nullptr;
check(xrGetInstanceProcAddr(XR_NULL_HANDLE, "xrInitializeLoaderKHR",
reinterpret_cast<PFN_xrVoidFunction *>(&initializeLoader)), "loader init entrypoint");
XrLoaderInitInfoAndroidKHR loader{XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR};
loader.applicationVM = app_->activity->vm;
loader.applicationContext = app_->activity->clazz;
check(initializeLoader(reinterpret_cast<XrLoaderInitInfoBaseHeaderKHR *>(&loader)), "xrInitializeLoaderKHR");
uint32_t count = 0;
check(xrEnumerateInstanceExtensionProperties(nullptr, 0, &count, nullptr), "extensions count");
std::vector<XrExtensionProperties> extensions(count, {XR_TYPE_EXTENSION_PROPERTIES});
check(xrEnumerateInstanceExtensionProperties(nullptr, count, &count, extensions.data()), "extensions");
for (const auto &extension : extensions) {
if (!std::strcmp(extension.extensionName, XR_KHR_ANDROID_SURFACE_SWAPCHAIN_EXTENSION_NAME))
surfaceSupported_ = true;
}
const char *enabled[] = {XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME, XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME};
XrInstanceCreateInfoAndroidKHR androidInfo{XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
androidInfo.applicationVM = app_->activity->vm;
androidInfo.applicationActivity = app_->activity->clazz;
XrInstanceCreateInfo create{XR_TYPE_INSTANCE_CREATE_INFO};
create.next = &androidInfo;
std::strcpy(create.applicationInfo.applicationName, "DroidDeck XR");
create.applicationInfo.applicationVersion = 1;
create.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0);
create.enabledExtensionCount = 2;
create.enabledExtensionNames = enabled;
check(xrCreateInstance(&create, &instance_), "xrCreateInstance");
XrSystemGetInfo systemInfo{XR_TYPE_SYSTEM_GET_INFO};
systemInfo.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY;
check(xrGetSystem(instance_, &systemInfo, &system_), "xrGetSystem");
PFN_xrGetOpenGLESGraphicsRequirementsKHR requirementsFunction = nullptr;
check(xrGetInstanceProcAddr(instance_, "xrGetOpenGLESGraphicsRequirementsKHR",
reinterpret_cast<PFN_xrVoidFunction *>(&requirementsFunction)), "GLES requirements entrypoint");
XrGraphicsRequirementsOpenGLESKHR requirements{XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR};
check(requirementsFunction(instance_, system_, &requirements), "GLES requirements");
if (requirements.minApiVersionSupported > XR_MAKE_VERSION(3, 0, 0)
|| requirements.maxApiVersionSupported < XR_MAKE_VERSION(3, 0, 0))
throw std::runtime_error("Runtime does not support GLES 3.0");
display_ = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (!eglInitialize(display_, nullptr, nullptr)) throw std::runtime_error("eglInitialize failed");
const EGLint attributes[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT, EGL_SURFACE_TYPE, EGL_PBUFFER_BIT,
EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_NONE};
EGLConfig config{};
EGLint found = 0;
if (!eglChooseConfig(display_, attributes, &config, 1, &found) || found != 1)
throw std::runtime_error("EGL config unavailable");
const EGLint contextAttributes[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
context_ = eglCreateContext(display_, config, EGL_NO_CONTEXT, contextAttributes);
const EGLint surfaceAttributes[] = {EGL_WIDTH, 16, EGL_HEIGHT, 16, EGL_NONE};
surface_ = eglCreatePbufferSurface(display_, config, surfaceAttributes);
if (!eglMakeCurrent(display_, surface_, surface_, context_)) throw std::runtime_error("eglMakeCurrent failed");
XrGraphicsBindingOpenGLESAndroidKHR binding{XR_TYPE_GRAPHICS_BINDING_OPENGL_ES_ANDROID_KHR};
binding.display = display_; binding.config = config; binding.context = context_;
XrSessionCreateInfo sessionInfo{XR_TYPE_SESSION_CREATE_INFO};
sessionInfo.next = &binding; sessionInfo.systemId = system_;
check(xrCreateSession(instance_, &sessionInfo, &session_), "xrCreateSession");
XrReferenceSpaceCreateInfo reference{XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
reference.poseInReferenceSpace.orientation.w = 1;
reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
check(xrCreateReferenceSpace(session_, &reference, &world_), "local space");
reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW;
check(xrCreateReferenceSpace(session_, &reference, &view_), "view space");
initializeActions();
initializeImages();
initializeGraphics();
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "initialized GLES; Android surface swapchain supported=%d", surfaceSupported_);
health();
}
void initializeActions() {
hands_[0] = path("/user/hand/left"); hands_[1] = path("/user/hand/right");
XrActionSetCreateInfo setInfo{XR_TYPE_ACTION_SET_CREATE_INFO};
std::strcpy(setInfo.actionSetName, "quest"); std::strcpy(setInfo.localizedActionSetName, "Quest controllers");
check(xrCreateActionSet(instance_, &setInfo, &actions_), "action set");
auto action = [&](const char *name, XrActionType type, XrAction *handle) {
XrActionCreateInfo info{XR_TYPE_ACTION_CREATE_INFO};
std::strcpy(info.actionName, name); std::strcpy(info.localizedActionName, name);
info.actionType = type; info.countSubactionPaths = 2; info.subactionPaths = hands_;
check(xrCreateAction(actions_, &info, handle), "create action");
};
action("grip_pose", XR_ACTION_TYPE_POSE_INPUT, &grip_);
action("trigger", XR_ACTION_TYPE_FLOAT_INPUT, &trigger_);
action("vibration", XR_ACTION_TYPE_VIBRATION_OUTPUT, &vibration_);
std::vector<XrActionSuggestedBinding> bindings;
for (const char *hand : {"left", "right"}) {
std::string root = std::string("/user/hand/") + hand;
bindings.push_back({grip_, path((root + "/input/grip/pose").c_str())});
bindings.push_back({trigger_, path((root + "/input/trigger/value").c_str())});
bindings.push_back({vibration_, path((root + "/output/haptic").c_str())});
}
XrInteractionProfileSuggestedBinding suggestion{XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
suggestion.interactionProfile = path("/interaction_profiles/oculus/touch_controller");
suggestion.countSuggestedBindings = bindings.size(); suggestion.suggestedBindings = bindings.data();
check(xrSuggestInteractionProfileBindings(instance_, &suggestion), "Touch bindings");
XrSessionActionSetsAttachInfo attach{XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO};
attach.countActionSets = 1; attach.actionSets = &actions_;
check(xrAttachSessionActionSets(session_, &attach), "attach actions");
for (int hand = 0; hand < 2; hand++) {
XrActionSpaceCreateInfo space{XR_TYPE_ACTION_SPACE_CREATE_INFO};
space.action = grip_; space.subactionPath = hands_[hand]; space.poseInActionSpace.orientation.w = 1;
check(xrCreateActionSpace(session_, &space, &handSpaces_[hand]), "controller space");
}
}
void initializeImages() {
uint32_t count = 0;
check(xrEnumerateViewConfigurationViews(instance_, system_, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,
0, &count, nullptr), "view count");
if (count != 2) throw std::runtime_error("Expected two stereo views");
std::vector<XrViewConfigurationView> views(count, {XR_TYPE_VIEW_CONFIGURATION_VIEW});
check(xrEnumerateViewConfigurationViews(instance_, system_, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,
count, &count, views.data()), "view configuration");
check(xrEnumerateSwapchainFormats(session_, 0, &count, nullptr), "format count");
std::vector<int64_t> formats(count);
check(xrEnumerateSwapchainFormats(session_, count, &count, formats.data()), "formats");
int64_t format = 0;
for (int64_t candidate : formats) if (candidate == GL_SRGB8_ALPHA8) format = candidate;
if (!format) throw std::runtime_error("sRGB swapchain unavailable");
for (int eye = 0; eye < 2; eye++) {
Eye &target = eyes_[eye];
target.width = views[eye].recommendedImageRectWidth;
target.height = views[eye].recommendedImageRectHeight;
XrSwapchainCreateInfo create{XR_TYPE_SWAPCHAIN_CREATE_INFO};
create.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT;
create.format = format; create.sampleCount = 1;
create.width = target.width; create.height = target.height;
create.faceCount = 1; create.arraySize = 1; create.mipCount = 1;
check(xrCreateSwapchain(session_, &create, &target.swapchain), "swapchain");
check(xrEnumerateSwapchainImages(target.swapchain, 0, &count, nullptr), "image count");
target.images.resize(count, {XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR});
check(xrEnumerateSwapchainImages(target.swapchain, count, &count,
reinterpret_cast<XrSwapchainImageBaseHeader *>(target.images.data())), "images");
glGenRenderbuffers(1, &target.depth);
glBindRenderbuffer(GL_RENDERBUFFER, target.depth);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, target.width, target.height);
}
}
GLuint shader(GLenum type, const char *text) {
GLuint handle = glCreateShader(type);
glShaderSource(handle, 1, &text, nullptr); glCompileShader(handle);
GLint success = 0; glGetShaderiv(handle, GL_COMPILE_STATUS, &success);
if (!success) { glDeleteShader(handle); throw std::runtime_error("Shader compile failed"); }
return handle;
}
void initializeGraphics() {
GLuint vertex = shader(GL_VERTEX_SHADER,
"#version 300 es\nlayout(location=0) in vec3 position; uniform mat4 matrix; void main(){gl_Position=matrix*vec4(position,1.0);}");
GLuint fragment = shader(GL_FRAGMENT_SHADER,
"#version 300 es\nprecision mediump float; uniform vec3 color; out vec4 outputColor; void main(){outputColor=vec4(color,1.0);}");
program_ = glCreateProgram(); glAttachShader(program_, vertex); glAttachShader(program_, fragment);
glLinkProgram(program_); glDeleteShader(vertex); glDeleteShader(fragment);
GLint linked = 0; glGetProgramiv(program_, GL_LINK_STATUS, &linked);
if (!linked) throw std::runtime_error("Shader link failed");
matrixUniform_ = glGetUniformLocation(program_, "matrix"); colorUniform_ = glGetUniformLocation(program_, "color");
const float vertices[] = {
-1,-1,-1, 1,-1,-1, 1,1,-1, -1,-1,-1, 1,1,-1, -1,1,-1,
-1,-1,1, 1,1,1, 1,-1,1, -1,-1,1, -1,1,1, 1,1,1,
-1,-1,-1, -1,1,-1, -1,1,1, -1,-1,-1, -1,1,1, -1,-1,1,
1,-1,-1, 1,-1,1, 1,1,1, 1,-1,-1, 1,1,1, 1,1,-1,
-1,-1,-1, -1,-1,1, 1,-1,1, -1,-1,-1, 1,-1,1, 1,-1,-1,
-1,1,-1, 1,1,-1, 1,1,1, -1,1,-1, 1,1,1, -1,1,1};
glGenVertexArrays(1, &vertexArray_); glBindVertexArray(vertexArray_);
glGenBuffers(1, &buffer_); glBindBuffer(GL_ARRAY_BUFFER, buffer_);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, nullptr); glEnableVertexAttribArray(0);
glGenFramebuffers(1, &framebuffer_);
}
void cube(const Matrix &camera, const XrPosef &pose, float size, float red, float green, float blue) {
Matrix model = transform(pose);
for (int column = 0; column < 3; column++)
for (int row = 0; row < 3; row++) model[column * 4 + row] *= size;
Matrix matrix = multiply(camera, model);
glUniformMatrix4fv(matrixUniform_, 1, GL_FALSE, matrix.data());
glUniform3f(colorUniform_, red, green, blue); glDrawArrays(GL_TRIANGLES, 0, 36);
}
void render(const XrView &view, Eye &eye, uint32_t image) {
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer_);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, eye.images[image].image, 0);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, eye.depth);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
throw std::runtime_error("Incomplete eye framebuffer");
glViewport(0, 0, eye.width, eye.height);
glEnable(GL_DEPTH_TEST); glClearColor(0.015f, 0.025f, 0.06f, 1);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glUseProgram(program_); glBindVertexArray(vertexArray_);
Matrix camera = viewProjection(view);
XrPosef pose{}; pose.orientation.w = 1;
for (int column = -4; column <= 4; column++) {
pose.position = {column * 0.6f, -1.2f, -3};
cube(camera, pose, 0.08f, 0.1f, 0.3f, 0.8f);
}
pose.position = {0, 0, -2}; cube(camera, pose, 0.2f, 0.1f, 0.9f, 0.4f);
for (int hand = 0; hand < 2; hand++)
if (handActive_[hand] && (handLocations_[hand].locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT))
cube(camera, handLocations_[hand].pose, 0.06f, hand == 0 ? 0.9f : 0.2f, 0.3f, hand == 1 ? 0.9f : 0.2f);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
if (glGetError() != GL_NO_ERROR) throw std::runtime_error("OpenGL render error");
glFlush();
}
void pollEvents() {
XrEventDataBuffer event{XR_TYPE_EVENT_DATA_BUFFER};
XrResult result;
while ((result = xrPollEvent(instance_, &event)) == XR_SUCCESS) {
if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) {
state_ = reinterpret_cast<XrEventDataSessionStateChanged *>(&event)->state;
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "session state=%d", state_);
if (state_ == XR_SESSION_STATE_READY) {
XrSessionBeginInfo begin{XR_TYPE_SESSION_BEGIN_INFO};
begin.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
check(xrBeginSession(session_, &begin), "xrBeginSession"); running_ = true;
} else if (state_ == XR_SESSION_STATE_STOPPING) {
check(xrEndSession(session_), "xrEndSession"); running_ = false;
} else if (state_ == XR_SESSION_STATE_EXITING || state_ == XR_SESSION_STATE_LOSS_PENDING) exit_ = true;
} else if (event.type == XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING) exit_ = true;
event = {XR_TYPE_EVENT_DATA_BUFFER};
}
check(result, "xrPollEvent");
}
void frame() {
XrFrameWaitInfo wait{XR_TYPE_FRAME_WAIT_INFO};
XrFrameState frameState{XR_TYPE_FRAME_STATE};
check(xrWaitFrame(session_, &wait, &frameState), "xrWaitFrame");
period_ = frameState.predictedDisplayPeriod;
XrFrameBeginInfo begin{XR_TYPE_FRAME_BEGIN_INFO}; check(xrBeginFrame(session_, &begin), "xrBeginFrame");
check(xrLocateSpace(view_, world_, frameState.predictedDisplayTime, &head_), "head pose");
if (state_ == XR_SESSION_STATE_FOCUSED) {
XrActiveActionSet active{actions_, XR_NULL_PATH};
XrActionsSyncInfo sync{XR_TYPE_ACTIONS_SYNC_INFO}; sync.countActiveActionSets = 1; sync.activeActionSets = &active;
check(xrSyncActions(session_, &sync), "xrSyncActions");
for (int hand = 0; hand < 2; hand++) {
XrActionStateGetInfo get{XR_TYPE_ACTION_STATE_GET_INFO}; get.action = grip_; get.subactionPath = hands_[hand];
XrActionStatePose pose{XR_TYPE_ACTION_STATE_POSE}; check(xrGetActionStatePose(session_, &get, &pose), "grip state");
handActive_[hand] = pose.isActive;
check(xrLocateSpace(handSpaces_[hand], world_, frameState.predictedDisplayTime, &handLocations_[hand]), "hand pose");
get.action = trigger_;
XrActionStateFloat trigger{XR_TYPE_ACTION_STATE_FLOAT}; check(xrGetActionStateFloat(session_, &get, &trigger), "trigger state");
bool pressed = trigger.isActive && trigger.currentState > 0.8f;
if (pressed && !pressed_[hand]) {
XrHapticActionInfo info{XR_TYPE_HAPTIC_ACTION_INFO}; info.action = vibration_; info.subactionPath = hands_[hand];
XrHapticVibration pulse{XR_TYPE_HAPTIC_VIBRATION}; pulse.duration = 30000000; pulse.frequency = XR_FREQUENCY_UNSPECIFIED; pulse.amplitude = 0.3f;
check(xrApplyHapticFeedback(session_, &info, reinterpret_cast<XrHapticBaseHeader *>(&pulse)), "haptic pulse");
}
pressed_[hand] = pressed;
}
} else handActive_[0] = handActive_[1] = false;
XrViewLocateInfo locate{XR_TYPE_VIEW_LOCATE_INFO}; locate.viewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
locate.displayTime = frameState.predictedDisplayTime; locate.space = world_;
XrViewState viewState{XR_TYPE_VIEW_STATE}; uint32_t count = 0;
XrView views[2]{{XR_TYPE_VIEW}, {XR_TYPE_VIEW}};
check(xrLocateViews(session_, &locate, &viewState, 2, &count, views), "xrLocateViews");
XrCompositionLayerProjectionView projection[2]{{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}, {XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}};
bool valid = frameState.shouldRender && count == 2 &&
(viewState.viewStateFlags & (XR_VIEW_STATE_ORIENTATION_VALID_BIT | XR_VIEW_STATE_POSITION_VALID_BIT))
== (XR_VIEW_STATE_ORIENTATION_VALID_BIT | XR_VIEW_STATE_POSITION_VALID_BIT);
if (valid) for (int eye = 0; eye < 2; eye++) {
uint32_t image = 0;
XrSwapchainImageAcquireInfo acquire{XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO};
check(xrAcquireSwapchainImage(eyes_[eye].swapchain, &acquire, &image), "acquire image");
XrSwapchainImageWaitInfo imageWait{XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO}; imageWait.timeout = XR_INFINITE_DURATION;
check(xrWaitSwapchainImage(eyes_[eye].swapchain, &imageWait), "wait image");
render(views[eye], eyes_[eye], image);
XrSwapchainImageReleaseInfo release{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
check(xrReleaseSwapchainImage(eyes_[eye].swapchain, &release), "release image");
projection[eye].pose = views[eye].pose; projection[eye].fov = views[eye].fov;
projection[eye].subImage.swapchain = eyes_[eye].swapchain;
projection[eye].subImage.imageRect.extent = {eyes_[eye].width, eyes_[eye].height};
}
XrCompositionLayerProjection layer{XR_TYPE_COMPOSITION_LAYER_PROJECTION};
layer.space = world_; layer.viewCount = 2; layer.views = projection;
const XrCompositionLayerBaseHeader *layers[] = {reinterpret_cast<XrCompositionLayerBaseHeader *>(&layer)};
XrFrameEndInfo end{XR_TYPE_FRAME_END_INFO}; end.displayTime = frameState.predictedDisplayTime;
end.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE; end.layerCount = valid ? 1 : 0; end.layers = layers;
check(xrEndFrame(session_, &end), "xrEndFrame");
frames_++; if (valid) rendered_++;
if (frames_ % 72 == 0) {
health();
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "frames=%llu rendered=%llu head=%llu hands=%d,%d period=%lld",
(unsigned long long)frames_, (unsigned long long)rendered_, (unsigned long long)head_.locationFlags,
handActive_[0], handActive_[1], (long long)period_);
}
}
void cleanup() {
if (display_ != EGL_NO_DISPLAY && context_ != EGL_NO_CONTEXT) {
glDeleteFramebuffers(1, &framebuffer_); glDeleteProgram(program_);
glDeleteBuffers(1, &buffer_); glDeleteVertexArrays(1, &vertexArray_);
for (auto &eye : eyes_) glDeleteRenderbuffers(1, &eye.depth);
}
for (auto &eye : eyes_) if (eye.swapchain) xrDestroySwapchain(eye.swapchain);
for (auto space : handSpaces_) if (space) xrDestroySpace(space);
if (view_) xrDestroySpace(view_); if (world_) xrDestroySpace(world_);
if (session_) xrDestroySession(session_);
if (actions_) xrDestroyActionSet(actions_);
if (instance_) xrDestroyInstance(instance_);
if (display_ != EGL_NO_DISPLAY) {
eglMakeCurrent(display_, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
if (surface_ != EGL_NO_SURFACE) eglDestroySurface(display_, surface_);
if (context_ != EGL_NO_CONTEXT) eglDestroyContext(display_, context_);
eglTerminate(display_);
}
if (env_) app_->activity->vm->DetachCurrentThread();
}
};
void android_main(android_app *app) {
QuestXR runtime(app);
try { runtime.run(); }
catch (const std::exception &error) {
__android_log_print(ANDROID_LOG_ERROR, "DroidDeckXR", "%s", error.what());
runtime.health(error.what());
ANativeActivity_finish(app->activity);
}
}
+1
View File
@@ -16,3 +16,4 @@ dependencyResolutionManagement {
rootProject.name = "DroidDeck"
include ':app'
include ':questxr'
+29
View File
@@ -0,0 +1,29 @@
param(
[Parameter(Mandatory = $true)][string]$KeyDirectory,
[Parameter(Mandatory = $true)][string]$Adb,
[string]$Sdk = "$env:USERPROFILE\android-sdk",
[string]$NdkVersion = '27.3.13750724',
[string]$BuildTools = '35.0.0'
)
$ErrorActionPreference = 'Stop'
$repo = Split-Path $PSScriptRoot -Parent
Push-Location $repo
try {
& .\gradlew.bat :questxr:assembleDebug "-PndkVersion=$NdkVersion" '-PskipRust=true' --console=plain
if ($LASTEXITCODE) { throw 'Quest XR build failed' }
$tools = Join-Path $Sdk "build-tools\$BuildTools"
$aligned = 'questxr\build\questxr-aligned.apk'
$signed = 'questxr\build\questxr-signed.apk'
& "$tools\zipalign.exe" -f -p 4 questxr\build\outputs\apk\debug\questxr-debug.apk $aligned
if ($LASTEXITCODE) { throw 'APK alignment failed' }
& "$tools\apksigner.bat" sign --key "$KeyDirectory\droiddeck-release.pk8" --cert "$KeyDirectory\droiddeck-release.x509.pem" --out $signed $aligned
if ($LASTEXITCODE) { throw 'APK signing failed' }
$certificate = & "$tools\apksigner.bat" verify --print-certs $signed
if ($LASTEXITCODE) { throw 'APK verification failed' }
$expected = (Get-Content keystore\release-signer.sha256 -Raw).Trim()
if (-not ($certificate -match "certificate SHA-256 digest: $expected")) { throw 'Unexpected signing certificate' }
& $Adb install --no-incremental -r $signed
if ($LASTEXITCODE) { throw 'Quest XR deployment failed' }
} finally {
Pop-Location
}
+66
View File
@@ -0,0 +1,66 @@
import argparse
import json
import os
import subprocess
import time
from pathlib import Path
from adb_device import resolve_serial
def main():
parser = argparse.ArgumentParser(description='Validate immersive DroidDeck XR on a connected Quest')
parser.add_argument('--adb', default=os.environ.get('ADB', 'adb'))
parser.add_argument('--serial')
parser.add_argument('--seconds', type=int, default=20)
parser.add_argument('--output', type=Path, default=Path('questxr-validation'))
parser.add_argument('--require-controllers', action='store_true')
args = parser.parse_args()
if args.seconds < 5 or args.seconds > 600:
parser.error('--seconds must be between 5 and 600')
serial = resolve_serial(args.adb, args.serial)
args.output.mkdir(parents=True, exist_ok=True)
def adb(*command):
return subprocess.run([args.adb, '-s', serial, *command], check=True,
capture_output=True, text=True).stdout
health_path = '/sdcard/Android/data/com.droiddeck.questxr/files/xr-health.json'
adb('shell', 'am', 'force-stop', 'com.droiddeck.questxr')
adb('shell', 'rm', '-f', health_path)
adb('shell', 'input', 'keyevent', 'KEYCODE_WAKEUP')
adb('shell', 'am', 'broadcast', '-a', 'com.oculus.vrpowermanager.prox_close')
adb('shell', 'am', 'start', '-n', 'com.droiddeck.questxr/android.app.NativeActivity')
samples = []
failure = None
deadline = time.monotonic() + args.seconds
while time.monotonic() < deadline:
time.sleep(1)
adb('shell', 'am', 'broadcast', '-a', 'com.oculus.vrpowermanager.prox_close')
try:
sample = json.loads(adb('shell', 'cat', health_path))
samples.append(sample)
if sample.get('error'):
failure = sample['error']
break
except (subprocess.CalledProcessError, json.JSONDecodeError):
pass
log = adb('logcat', '-d', '-s', 'DroidDeckXR:I', 'OpenXR-Loader:W', 'AndroidRuntime:E')
(args.output / 'logcat.txt').write_text(log, encoding='utf-8')
if len(samples) < 2:
failure = failure or 'No advancing telemetry; check runtime initialization and headset wake/proximity'
elif samples[-1]['rendered'] - samples[0]['rendered'] < 72:
failure = failure or 'Fewer than 72 additional stereo frames were submitted'
elif not any(sample['sessionState'] == 5 and sample['headFlags'] & 3 == 3 for sample in samples):
failure = failure or 'No focused session with valid position and orientation'
elif args.require_controllers and not any(all(sample['controllersActive']) for sample in samples):
failure = failure or 'Both controllers did not become active'
report = {'ok': failure is None, 'error': failure, 'samples': samples}
(args.output / 'report.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
print(json.dumps({'ok': report['ok'], 'error': failure, 'samples': len(samples),
'last': samples[-1] if samples else None}, indent=2))
return 0 if report['ok'] else 1
if __name__ == '__main__':
raise SystemExit(main())