diff --git a/app/build.gradle b/app/build.gradle index 5b29d72f..99121073 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -150,6 +150,7 @@ android { } buildFeatures { + prefab true compose true buildConfig true } @@ -225,6 +226,7 @@ if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir) tasks.named('preBuild').configure { dependsOn(buildRustStores) } dependencies { + implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49' implementation 'com.github.luben:zstd-jni:1.5.2-3@aar' implementation 'org.apache.commons:commons-compress:1.27.1' implementation 'org.tukaani:xz:1.10' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 705a261f..f04f1f2f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,6 +22,9 @@ + + + + + + + + + + + 10000) return 0; + struct timespec deadline; + clock_gettime(CLOCK_REALTIME, &deadline); + deadline.tv_sec += timeout_ms / 1000; + deadline.tv_nsec += (timeout_ms % 1000) * 1000000L; + if (deadline.tv_nsec >= 1000000000L) { deadline.tv_sec++; deadline.tv_nsec -= 1000000000L; } + pthread_mutex_lock(&g_req_lock); + const uint64_t generation = g_req_generation; + int error = 0; + while (g_req_applied < generation && error == 0) + error = pthread_cond_timedwait(&g_req_condition, &g_req_lock, &deadline); + int applied = g_req_applied >= generation; + pthread_mutex_unlock(&g_req_lock); + return applied; +} + int vkp_apply_window_request(void) { ANativeWindow *w; pthread_mutex_lock(&g_req_lock); if (!g_req_pending) { pthread_mutex_unlock(&g_req_lock); return 0; } w = g_req_window; const int detached = g_req_detached; + const uint64_t generation = g_req_generation; g_req_window = NULL; g_req_pending = 0; g_req_detached = 0; pthread_mutex_unlock(&g_req_lock); if (w == g_window) { if (w) ANativeWindow_release(w); - if (!w || !detached) return 0; + if (!w || !detached) { window_request_applied(generation); return 0; } sc_layer_window_gone(); destroy_swapchain(); + window_request_applied(generation); droiddeck_log("gpu", "screen surface rebound: rebuilding the swapchain at its new size"); return 1; } @@ -228,6 +260,7 @@ int vkp_apply_window_request(void) { g_window = w; g_swap_retry_at_ns = 0; g_swap_fail_logged = 0; + window_request_applied(generation); droiddeck_log("gpu", w ? "screen surface attached" : "screen surface gone: presenting paused"); return 1; } @@ -1638,6 +1671,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i } g_base_black = (g_plain_frame && n == 0 && !hf) ? 1 : 0; + if (fr == VK_SUCCESS) __atomic_add_fetch(&g_presented_frames, 1u, __ATOMIC_RELEASE); if (drawn && !g_first_frame_done) { g_first_frame_done = 1; diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.h b/app/src/main/cpp/waylandcomp/src/vk_present.h index 5b050d47..4f334a7c 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.h +++ b/app/src/main/cpp/waylandcomp/src/vk_present.h @@ -48,6 +48,8 @@ const char *vkp_gpu_name(void); int vkp_ready(void); int vkp_has_window(void); +int vkp_wait_window_request(int timeout_ms); +uint64_t vkp_presented_frames(void); int vkp_device_lost(void); int vkp_render(int scene_w, int scene_h, const struct vkp_draw *draws, int n); diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c index b1125ce3..1a77ff42 100644 --- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c +++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c @@ -582,6 +582,17 @@ static char *dup_bytes(JNIEnv *env, jbyteArray arr, int *len) { return buf; } +JNIEXPORT jboolean JNICALL +Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeWaitSurface( + JNIEnv *env, jclass clazz, jint timeout_ms) { + return vkp_wait_window_request(timeout_ms) ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jlong JNICALL +Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativePresentedFrames(JNIEnv *env, jclass clazz) { + return (jlong)vkp_presented_frames(); +} + JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetClipboardText(JNIEnv *env, jclass clazz, jbyteArray utf8) { int len; diff --git a/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt b/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt new file mode 100644 index 00000000..6e7b4e2e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt @@ -0,0 +1,53 @@ +package com.droiddeck.launcher + +import android.app.NativeActivity +import android.os.Bundle +import android.view.Surface +import com.droiddeck.launcher.session.SessionService +import com.droiddeck.launcher.wayland.CompositorHost +import com.droiddeck.launcher.wayland.WaylandCompositor +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class QuestXrActivity : NativeActivity() { + private var presentedBaseline = 0L + override fun onCreate(savedInstanceState: Bundle?) { + if (intent.getBooleanExtra("guest_surface", false)) { + require(CompositorHost.isStarted) { "Start a DroidDeck session before attaching XR" } + intent.putExtra("surface_test", true) + } + super.onCreate(savedInstanceState) + } + + fun onXrSurface(surface: Surface?, width: Int, height: Int) { + val complete = CountDownLatch(1) + var failure: Throwable? = null + runOnUiThread { + try { + if (surface != null) { + presentedBaseline = WaylandCompositor.nativePresentedFrames() + WaylandCompositor.nativeSetCardboard(false, 1, 0.064f, 3f, 4f, 0f, 0f, 90f, 0f, 0f) + WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) + WaylandCompositor.nativeSetZeroCopy(false) + WaylandCompositor.nativeSetOutputSize(width, height) + WaylandCompositor.nativeSetFpsLimit(0) + } + CompositorHost.attachXr(surface) + SessionService.setActivityVisible(this, surface != null) + WaylandCompositor.nativeVsync(System.nanoTime()) + } catch (error: Throwable) { + failure = error + } finally { + complete.countDown() + } + } + check(complete.await(3, TimeUnit.SECONDS)) { "XR surface UI handshake timed out" } + failure?.let { throw it } + check(WaylandCompositor.nativeWaitSurface(3000)) { "XR surface GPU handshake timed out" } + } + + fun onXrFrame(frameTimeNanos: Long): Long { + WaylandCompositor.nativeVsync(frameTimeNanos) + return (WaylandCompositor.nativePresentedFrames() - presentedBaseline).coerceAtLeast(0) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt index 9df4d4df..a68f804a 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt @@ -71,6 +71,14 @@ object CompositorHost { private var attached: Surface? = null + @Synchronized + fun attachXr(surface: Surface?) { + check(started) { "DroidDeck compositor is not running" } + pauseVsync() + attached = surface + WaylandCompositor.nativeSetSurface(surface) + } + @Synchronized fun detach(surface: Surface?) { if (!started) return diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index 97cfcf3c..ba8d96d8 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -148,6 +148,8 @@ public final class WaylandCompositor { String nativeLibDir); public static native void nativeSetSurface(Surface surface); + public static native boolean nativeWaitSurface(int timeoutMs); + public static native long nativePresentedFrames(); public static native void nativeSendPointer(int action, int x, int y); diff --git a/docs/development/quest-openxr.md b/docs/development/quest-openxr.md index 78b64172..cb35da1b 100644 --- a/docs/development/quest-openxr.md +++ b/docs/development/quest-openxr.md @@ -48,6 +48,35 @@ treating a successful `am start` as successful rendering. ## Remaining stages +## Milestone 2: Android projection surface and guest integration + +The Android Surface producer test passed repeatedly, including 1,079 submitted +stereo frames in a 15-second test at 72 Hz. The new stage-space test reports +tracked floor-relative position, and the runtime supports monotonic-time +conversion. A JNI abort during the first surface test was traced to treating +the runtime's global Surface reference as a local reference and corrected. + +The same native frontend is now compiled into the main app behind the explicit +`QuestXrActivity` entrypoint. With `guest_surface=true`, it hands its Android +projection Surface to the existing compositor. Surface transitions wait for a +compositor acknowledgement after the old Vulkan swapchain's GPU work drains. +Successful guest GPU presentations are counted separately from XR submissions. +The main app's Kotlin and native targets compile locally; an on-device main-app +guest test is still required before considering this bridge functional. + +The frontend publishes a versioned 296-byte seqlock tracking record under the +session runtime directory. It contains floor-relative head/controller poses, +per-eye view geometry, predicted display time converted to CLOCK_MONOTONIC, +display period, and Touch action values. The OpenVR driver consumes fresh +records for real head/controller poses and asymmetric FOV; it marks stale +tracking invalid. The modified ARM64 Linux driver links successfully in WSL. +Driver timing, eye-to-head transforms, input completeness, render-pose/image +association and return haptics still need implementation/validation. + +The companion's release build now targets Android 14 and passes release lint. + +## 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. diff --git a/questxr/build.gradle b/questxr/build.gradle index 3ec0e0e7..735f196e 100644 --- a/questxr/build.gradle +++ b/questxr/build.gradle @@ -9,7 +9,7 @@ android { defaultConfig { applicationId 'com.droiddeck.questxr' minSdk 26 - targetSdk 32 + targetSdk 34 versionCode 1 versionName '0.1.0' ndk { abiFilters 'arm64-v8a' } diff --git a/questxr/src/main/cpp/CMakeLists.txt b/questxr/src/main/cpp/CMakeLists.txt index 1426d176..26f0685c 100644 --- a/questxr/src/main/cpp/CMakeLists.txt +++ b/questxr/src/main/cpp/CMakeLists.txt @@ -5,7 +5,7 @@ add_library(native_app_glue STATIC ${ANDROID_NDK}/sources/android/native_app_glu 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_definitions(droiddeckxr PRIVATE XR_USE_PLATFORM_ANDROID XR_USE_GRAPHICS_API_OPENGL_ES XR_USE_TIMESPEC) 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) diff --git a/questxr/src/main/cpp/quest_xr.cpp b/questxr/src/main/cpp/quest_xr.cpp index e17364a6..de233178 100644 --- a/questxr/src/main/cpp/quest_xr.cpp +++ b/questxr/src/main/cpp/quest_xr.cpp @@ -2,7 +2,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -13,6 +15,14 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include "../../../../tools/vr/src/xr_tracking.h" static void check(XrResult result, const char *operation) { if (XR_FAILED(result)) throw std::runtime_error(std::string(operation) + ": " + std::to_string(result)); @@ -103,12 +113,12 @@ public: 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", + "\"periodNs\":%lld,\"surfaceSwapchain\":%s,\"source\":\"%s\",\"guestFrames\":%lld,\"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); + (long long)period_, surfaceSupported_ ? "true" : "false", guestSurface_ ? "guest" : surfaceTest_ ? "surface" : "native", (long long)guestFrames_, error); std::fclose(file); std::rename(temp.c_str(), path.c_str()); } @@ -122,14 +132,28 @@ private: 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; + XrAction squeeze_ = XR_NULL_HANDLE, stick_ = XR_NULL_HANDLE, stickClick_ = XR_NULL_HANDLE; + XrAction primary_ = XR_NULL_HANDLE, secondary_ = XR_NULL_HANDLE, menu_ = XR_NULL_HANDLE; + DroidDeckXrHand inputs_[2]{}; + PFN_xrConvertTimeToTimespecTimeKHR convertTime_ = nullptr; + bool timespecSupported_ = false; 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; + bool surfaceTest_ = false; + bool guestSurface_ = false, guestAttached_ = false; + jmethodID surfaceCallback_ = nullptr, frameCallback_ = nullptr; + jobject androidJavaSurface_ = nullptr; + DroidDeckXrTracking *tracking_ = nullptr; + XrSwapchain androidSwapchain_ = XR_NULL_HANDLE; + ANativeWindow *androidWindow_ = nullptr; + EGLSurface androidSurface_ = EGL_NO_SURFACE; XrSessionState state_ = XR_SESSION_STATE_UNKNOWN; uint64_t frames_ = 0, rendered_ = 0; + int64_t guestFrames_ = 0; XrDuration period_ = 0; EGLDisplay display_ = EGL_NO_DISPLAY; EGLContext context_ = EGL_NO_CONTEXT; @@ -146,6 +170,24 @@ private: void initialize() { app_->activity->vm->AttachCurrentThread(&env_, nullptr); + jclass activityClass = env_->GetObjectClass(app_->activity->clazz); + jmethodID getIntent = env_->GetMethodID(activityClass, "getIntent", "()Landroid/content/Intent;"); + jobject intent = env_->CallObjectMethod(app_->activity->clazz, getIntent); + jclass intentClass = env_->GetObjectClass(intent); + jmethodID getBoolean = env_->GetMethodID(intentClass, "getBooleanExtra", "(Ljava/lang/String;Z)Z"); + jstring extra = env_->NewStringUTF("surface_test"); + surfaceTest_ = env_->CallBooleanMethod(intent, getBoolean, extra, JNI_FALSE); + env_->DeleteLocalRef(extra); + extra = env_->NewStringUTF("guest_surface"); + guestSurface_ = env_->CallBooleanMethod(intent, getBoolean, extra, JNI_FALSE); + if (guestSurface_) { + surfaceTest_ = true; + surfaceCallback_ = env_->GetMethodID(activityClass, "onXrSurface", "(Landroid/view/Surface;II)V"); + frameCallback_ = env_->GetMethodID(activityClass, "onXrFrame", "(J)J"); + if (env_->ExceptionCheck()) { env_->ExceptionClear(); throw std::runtime_error("XR activity bridge methods unavailable"); } + } + env_->DeleteLocalRef(extra); env_->DeleteLocalRef(intentClass); + env_->DeleteLocalRef(intent); env_->DeleteLocalRef(activityClass); PFN_xrInitializeLoaderKHR initializeLoader = nullptr; check(xrGetInstanceProcAddr(XR_NULL_HANDLE, "xrInitializeLoaderKHR", reinterpret_cast(&initializeLoader)), "loader init entrypoint"); @@ -160,8 +202,13 @@ private: for (const auto &extension : extensions) { if (!std::strcmp(extension.extensionName, XR_KHR_ANDROID_SURFACE_SWAPCHAIN_EXTENSION_NAME)) surfaceSupported_ = true; + if (!std::strcmp(extension.extensionName, XR_KHR_CONVERT_TIMESPEC_TIME_EXTENSION_NAME)) + timespecSupported_ = true; } - const char *enabled[] = {XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME, XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME}; + if (surfaceTest_ && !surfaceSupported_) throw std::runtime_error("Android surface swapchains unavailable"); + std::vector enabled{XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME, XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME}; + if (surfaceSupported_) enabled.push_back(XR_KHR_ANDROID_SURFACE_SWAPCHAIN_EXTENSION_NAME); + if (timespecSupported_) enabled.push_back(XR_KHR_CONVERT_TIMESPEC_TIME_EXTENSION_NAME); XrInstanceCreateInfoAndroidKHR androidInfo{XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR}; androidInfo.applicationVM = app_->activity->vm; androidInfo.applicationActivity = app_->activity->clazz; @@ -170,9 +217,11 @@ private: 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; + create.enabledExtensionCount = enabled.size(); + create.enabledExtensionNames = enabled.data(); check(xrCreateInstance(&create, &instance_), "xrCreateInstance"); + if (timespecSupported_) check(xrGetInstanceProcAddr(instance_, "xrConvertTimeToTimespecTimeKHR", + reinterpret_cast(&convertTime_)), "time conversion entrypoint"); XrSystemGetInfo systemInfo{XR_TYPE_SYSTEM_GET_INFO}; systemInfo.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY; check(xrGetSystem(instance_, &systemInfo, &system_), "xrGetSystem"); @@ -186,8 +235,8 @@ private: 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}; + const EGLint attributes[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT, EGL_SURFACE_TYPE, EGL_PBUFFER_BIT | EGL_WINDOW_BIT, + EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_DEPTH_SIZE, 24, EGL_NONE}; EGLConfig config{}; EGLint found = 0; if (!eglChooseConfig(display_, attributes, &config, 1, &found) || found != 1) @@ -204,13 +253,21 @@ private: 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_STAGE; + XrResult referenceResult = xrCreateReferenceSpace(session_, &reference, &world_); + if (referenceResult == XR_ERROR_REFERENCE_SPACE_UNSUPPORTED) { + reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL; + reference.poseInReferenceSpace.position.y = -1.6f; + check(xrCreateReferenceSpace(session_, &reference, &world_), "local standing space"); + } else check(referenceResult, "stage space"); reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW; + reference.poseInReferenceSpace.position.y = 0; check(xrCreateReferenceSpace(session_, &reference, &view_), "view space"); initializeActions(); initializeImages(); + if (surfaceTest_) initializeAndroidSurface(config); initializeGraphics(); + initializeTracking(); __android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "initialized GLES; Android surface swapchain supported=%d", surfaceSupported_); health(); } @@ -228,14 +285,26 @@ private: }; action("grip_pose", XR_ACTION_TYPE_POSE_INPUT, &grip_); action("trigger", XR_ACTION_TYPE_FLOAT_INPUT, &trigger_); + action("squeeze", XR_ACTION_TYPE_FLOAT_INPUT, &squeeze_); + action("stick", XR_ACTION_TYPE_VECTOR2F_INPUT, &stick_); + action("stick_click", XR_ACTION_TYPE_BOOLEAN_INPUT, &stickClick_); + action("primary", XR_ACTION_TYPE_BOOLEAN_INPUT, &primary_); + action("secondary", XR_ACTION_TYPE_BOOLEAN_INPUT, &secondary_); + action("menu", XR_ACTION_TYPE_BOOLEAN_INPUT, &menu_); action("vibration", XR_ACTION_TYPE_VIBRATION_OUTPUT, &vibration_); std::vector 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({squeeze_, path((root + "/input/squeeze/value").c_str())}); + bindings.push_back({stick_, path((root + "/input/thumbstick").c_str())}); + bindings.push_back({stickClick_, path((root + "/input/thumbstick/click").c_str())}); + bindings.push_back({primary_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/x/click" : "/input/a/click")).c_str())}); + bindings.push_back({secondary_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/y/click" : "/input/b/click")).c_str())}); bindings.push_back({vibration_, path((root + "/output/haptic").c_str())}); } + bindings.push_back({menu_, path("/user/hand/left/input/menu/click")}); XrInteractionProfileSuggestedBinding suggestion{XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING}; suggestion.interactionProfile = path("/interaction_profiles/oculus/touch_controller"); suggestion.countSuggestedBindings = bindings.size(); suggestion.suggestedBindings = bindings.data(); @@ -268,6 +337,7 @@ private: Eye &target = eyes_[eye]; target.width = views[eye].recommendedImageRectWidth; target.height = views[eye].recommendedImageRectHeight; + if (surfaceTest_) continue; XrSwapchainCreateInfo create{XR_TYPE_SWAPCHAIN_CREATE_INFO}; create.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT; create.format = format; create.sampleCount = 1; @@ -284,6 +354,94 @@ private: } } + void initializeAndroidSurface(EGLConfig config) { + PFN_xrCreateSwapchainAndroidSurfaceKHR createSurface = nullptr; + check(xrGetInstanceProcAddr(instance_, "xrCreateSwapchainAndroidSurfaceKHR", + reinterpret_cast(&createSurface)), "surface swapchain entrypoint"); + if (eyes_[0].width != eyes_[1].width || eyes_[0].height != eyes_[1].height) + throw std::runtime_error("Surface path requires matching eye dimensions"); + XrSwapchainCreateInfo info{XR_TYPE_SWAPCHAIN_CREATE_INFO}; + info.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT; + info.width = eyes_[0].width * 2; info.height = eyes_[0].height; + jobject surface = nullptr; + check(createSurface(session_, &info, &androidSwapchain_, &surface), "Android surface swapchain"); + androidWindow_ = ANativeWindow_fromSurface(env_, surface); + androidJavaSurface_ = env_->NewGlobalRef(surface); + if (env_->GetObjectRefType(surface) == JNILocalRefType) env_->DeleteLocalRef(surface); + if (!androidWindow_) throw std::runtime_error("Android swapchain has no native window"); + if (!guestSurface_) { + androidSurface_ = eglCreateWindowSurface(display_, config, androidWindow_, nullptr); + if (androidSurface_ == EGL_NO_SURFACE) throw std::runtime_error("Android EGL window surface failed"); + } + __android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "Android projection surface %ux%u", info.width, info.height); + } + + void initializeTracking() { + std::string directory = std::string(app_->activity->internalDataPath) + "/.wayland-rt"; + if (mkdir(directory.c_str(), 0700) != 0 && errno != EEXIST) + throw std::runtime_error("XR tracking directory unavailable"); + std::string filename = directory + "/" DROIDDECK_XR_FILE; + int descriptor = open(filename.c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0600); + if (descriptor < 0) throw std::runtime_error("XR tracking file unavailable"); + if (ftruncate(descriptor, sizeof(DroidDeckXrTracking)) != 0) { + close(descriptor); throw std::runtime_error("XR tracking file size failed"); + } + void *mapping = mmap(nullptr, sizeof(DroidDeckXrTracking), PROT_READ | PROT_WRITE, MAP_SHARED, descriptor, 0); + close(descriptor); + if (mapping == MAP_FAILED) throw std::runtime_error("XR tracking map failed"); + tracking_ = static_cast(mapping); + } + + static DroidDeckXrPose posePacket(const XrPosef &pose, XrSpaceLocationFlags flags) { + return {{pose.orientation.x, pose.orientation.y, pose.orientation.z, pose.orientation.w}, + {pose.position.x, pose.position.y, pose.position.z}, (uint32_t)flags}; + } + + void publishTracking(const XrView views[2], XrTime predicted) { + if (!tracking_) return; + timespec now{}; clock_gettime(CLOCK_MONOTONIC, &now); + DroidDeckXrTracking packet{}; + packet.magic = DROIDDECK_XR_MAGIC; packet.version = DROIDDECK_XR_VERSION; + packet.active = state_ == XR_SESSION_STATE_VISIBLE || state_ == XR_SESSION_STATE_FOCUSED; + packet.sampled_ns = (int64_t)now.tv_sec * 1000000000LL + now.tv_nsec; + if (convertTime_) { + timespec displayTime{}; + check(convertTime_(instance_, predicted, &displayTime), "display time conversion"); + packet.predicted_ns = (int64_t)displayTime.tv_sec * 1000000000LL + displayTime.tv_nsec; + } + packet.period_ns = period_; + packet.head = posePacket(head_.pose, head_.locationFlags); + for (int eye = 0; eye < 2; eye++) { + packet.eyes[eye].pose = posePacket(views[eye].pose, head_.locationFlags); + packet.eyes[eye].fov[0] = views[eye].fov.angleLeft; + packet.eyes[eye].fov[1] = views[eye].fov.angleRight; + packet.eyes[eye].fov[2] = views[eye].fov.angleUp; + packet.eyes[eye].fov[3] = views[eye].fov.angleDown; + packet.eyes[eye].width = eyes_[eye].width; packet.eyes[eye].height = eyes_[eye].height; + packet.hands[eye] = inputs_[eye]; + packet.hands[eye].pose = posePacket(handLocations_[eye].pose, handLocations_[eye].locationFlags); + packet.hands[eye].active = handActive_[eye]; + } + uint32_t sequence = __atomic_load_n(&tracking_->seq, __ATOMIC_RELAXED); + __atomic_store_n(&tracking_->seq, sequence | 1u, __ATOMIC_SEQ_CST); + __atomic_thread_fence(__ATOMIC_SEQ_CST); + std::memcpy(tracking_, &packet, offsetof(DroidDeckXrTracking, seq)); + std::memcpy(reinterpret_cast(tracking_) + offsetof(DroidDeckXrTracking, active), + reinterpret_cast(&packet) + offsetof(DroidDeckXrTracking, active), + sizeof(packet) - offsetof(DroidDeckXrTracking, active)); + __atomic_store_n(&tracking_->seq, (sequence | 1u) + 1u, __ATOMIC_RELEASE); + } + + void attachGuest(bool attach) { + if (!guestSurface_ || attach == guestAttached_) return; + env_->CallVoidMethod(app_->activity->clazz, surfaceCallback_, attach ? androidJavaSurface_ : nullptr, + eyes_[0].width * 2, eyes_[0].height); + if (env_->ExceptionCheck()) { + env_->ExceptionDescribe(); env_->ExceptionClear(); throw std::runtime_error("Guest surface handshake failed"); + } + guestAttached_ = attach; + } + GLuint shader(GLenum type, const char *text) { GLuint handle = glCreateShader(type); glShaderSource(handle, 1, &text, nullptr); glCompileShader(handle); @@ -331,23 +489,29 @@ private: 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); + scene(view, eye.width, eye.height, 0); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + if (glGetError() != GL_NO_ERROR) throw std::runtime_error("OpenGL render error"); + glFlush(); + } + + void scene(const XrView &view, int width, int height, int offset) { + glViewport(offset, 0, width, height); + glEnable(GL_SCISSOR_TEST); glScissor(offset, 0, width, 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}; + pose.position = {column * 0.6f, 0.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); + pose.position = {0, 1.4f, -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(); + glDisable(GL_SCISSOR_TEST); } void pollEvents() { @@ -357,6 +521,7 @@ private: if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) { state_ = reinterpret_cast(&event)->state; __android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "session state=%d", state_); + attachGuest(state_ == XR_SESSION_STATE_VISIBLE || state_ == XR_SESSION_STATE_FOCUSED); if (state_ == XR_SESSION_STATE_READY) { XrSessionBeginInfo begin{XR_TYPE_SESSION_BEGIN_INFO}; begin.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; @@ -388,6 +553,21 @@ private: 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"); + inputs_[hand].trigger = trigger.isActive ? trigger.currentState : 0; + get.action = squeeze_; + XrActionStateFloat squeeze{XR_TYPE_ACTION_STATE_FLOAT}; check(xrGetActionStateFloat(session_, &get, &squeeze), "squeeze state"); + inputs_[hand].squeeze = squeeze.isActive ? squeeze.currentState : 0; + get.action = stick_; + XrActionStateVector2f stick{XR_TYPE_ACTION_STATE_VECTOR2F}; check(xrGetActionStateVector2f(session_, &get, &stick), "stick state"); + inputs_[hand].stick[0] = stick.isActive ? stick.currentState.x : 0; + inputs_[hand].stick[1] = stick.isActive ? stick.currentState.y : 0; + inputs_[hand].buttons = 0; + XrAction buttons[] = {stickClick_, primary_, secondary_, menu_}; + for (int button = 0; button < 4; button++) { + get.action = buttons[button]; + XrActionStateBoolean value{XR_TYPE_ACTION_STATE_BOOLEAN}; check(xrGetActionStateBoolean(session_, &get, &value), "button state"); + if (value.isActive && value.currentState) inputs_[hand].buttons |= 1u << button; + } 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]; @@ -402,11 +582,30 @@ private: 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"); + publishTracking(views, frameState.predictedDisplayTime); + if (guestSurface_ && guestAttached_) { + timespec now{}; clock_gettime(CLOCK_MONOTONIC, &now); + guestFrames_ = env_->CallLongMethod(app_->activity->clazz, frameCallback_, (jlong)now.tv_sec * 1000000000LL + now.tv_nsec); + if (env_->ExceptionCheck()) { env_->ExceptionClear(); throw std::runtime_error("Guest frame callback failed"); } + } XrCompositionLayerProjectionView projection[2]{{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}, {XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}}; - bool valid = frameState.shouldRender && count == 2 && + bool valid = frameState.shouldRender && (!surfaceTest_ || state_ == XR_SESSION_STATE_VISIBLE || state_ == XR_SESSION_STATE_FOCUSED) && 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++) { + if (valid && surfaceTest_) { + if (!guestSurface_ && !eglMakeCurrent(display_, androidSurface_, androidSurface_, context_)) + throw std::runtime_error("Surface context binding failed"); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + for (int eye = 0; eye < 2; eye++) { + if (!guestSurface_) scene(views[eye], eyes_[eye].width, eyes_[eye].height, eye * eyes_[eye].width); + projection[eye].pose = views[eye].pose; projection[eye].fov = views[eye].fov; + projection[eye].subImage.swapchain = androidSwapchain_; + projection[eye].subImage.imageRect.offset = {eye * eyes_[eye].width, 0}; + projection[eye].subImage.imageRect.extent = {eyes_[eye].width, eyes_[eye].height}; + } + if (glGetError() != GL_NO_ERROR) throw std::runtime_error("Surface rendering error"); + if (!guestSurface_ && !eglSwapBuffers(display_, androidSurface_)) throw std::runtime_error("Surface presentation failed"); + } else 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"); @@ -435,11 +634,25 @@ private: } void cleanup() { + try { attachGuest(false); } catch (const std::exception &error) { + __android_log_print(ANDROID_LOG_ERROR, "DroidDeckXR", "cleanup: %s", error.what()); + } + if (tracking_) { + __atomic_store_n(&tracking_->active, 0u, __ATOMIC_RELEASE); + munmap(tracking_, sizeof(DroidDeckXrTracking)); + } 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); } + if (androidSurface_ != EGL_NO_SURFACE) { + eglMakeCurrent(display_, surface_, surface_, context_); + eglDestroySurface(display_, androidSurface_); + } + if (androidWindow_) ANativeWindow_release(androidWindow_); + if (androidSwapchain_) xrDestroySwapchain(androidSwapchain_); + if (androidJavaSurface_) env_->DeleteGlobalRef(androidJavaSurface_); 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_); diff --git a/tools/deploy_quest_xr.ps1 b/tools/deploy_quest_xr.ps1 index 28d8bca8..a69512c4 100644 --- a/tools/deploy_quest_xr.ps1 +++ b/tools/deploy_quest_xr.ps1 @@ -6,24 +6,24 @@ param( [string]$BuildTools = '35.0.0' ) $ErrorActionPreference = 'Stop' +function Invoke-Checked([string]$Program, [string[]]$Arguments) { + $ErrorActionPreference = 'Continue' + & $Program @Arguments + if ($LASTEXITCODE -ne 0) { throw "$Program exited with $LASTEXITCODE" } +} $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' } + Invoke-Checked .\gradlew.bat @(':questxr:assembleDebug', "-PndkVersion=$NdkVersion", '-PskipRust=true', '--console=plain') $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' } + Invoke-Checked "$tools\zipalign.exe" @('-f', '-p', '4', 'questxr\build\outputs\apk\debug\questxr-debug.apk', $aligned) + Invoke-Checked "$tools\apksigner.bat" @('sign', '--key', "$KeyDirectory\droiddeck-release.pk8", '--cert', "$KeyDirectory\droiddeck-release.x509.pem", '--out', $signed, $aligned) + $certificate = Invoke-Checked "$tools\apksigner.bat" @('verify', '--print-certs', $signed) $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' } + Invoke-Checked $Adb @('install', '--no-incremental', '-r', $signed) } finally { Pop-Location } diff --git a/tools/test_quest_xr.py b/tools/test_quest_xr.py index dbe42b10..a53afb66 100644 --- a/tools/test_quest_xr.py +++ b/tools/test_quest_xr.py @@ -15,6 +15,8 @@ def main(): 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') + parser.add_argument('--surface', action='store_true') + parser.add_argument('--guest', action='store_true') args = parser.parse_args() if args.seconds < 5 or args.seconds > 600: parser.error('--seconds must be between 5 and 600') @@ -25,12 +27,20 @@ def main(): 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') + package = 'com.droiddeck.launcher' if args.guest else 'com.droiddeck.questxr' + health_path = f'/sdcard/Android/data/{package}/files/xr-health.json' + if not args.guest: + adb('shell', 'am', 'force-stop', package) 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') + component = f'{package}/.QuestXrActivity' if args.guest else f'{package}/android.app.NativeActivity' + launch = ['shell', 'am', 'start', '-n', component] + if args.surface: + launch += ['--ez', 'surface_test', 'true'] + if args.guest: + launch += ['--ez', 'guest_surface', 'true'] + adb(*launch) samples = [] failure = None deadline = time.monotonic() + args.seconds @@ -55,6 +65,10 @@ def main(): 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' + elif args.guest and samples[-1].get('guestFrames', 0) - samples[0].get('guestFrames', 0) < 72: + failure = failure or 'No advancing guest GPU presentation; submitting an XR layer alone is insufficient' + elif samples[-1].get('source') != ('guest' if args.guest else 'surface' if args.surface else 'native'): + failure = failure or 'Wrong projection source' 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), diff --git a/tools/vr/src/driver_droiddeck.cpp b/tools/vr/src/driver_droiddeck.cpp index 1397cc1c..09a9066a 100644 --- a/tools/vr/src/driver_droiddeck.cpp +++ b/tools/vr/src/driver_droiddeck.cpp @@ -17,6 +17,7 @@ #include "../../../app/src/main/cpp/waylandcomp/src/headpose_shm.h" #include "presenter.h" +#include "xr_tracking_file.h" #define DD_EXPORT extern "C" __attribute__((visibility("default"))) @@ -372,6 +373,24 @@ public: DriverPose_t GetPose() override { return pose_; } + void UpdateXr(const DroidDeckXrHand &hand) { + if (id_ == k_unTrackedDeviceIndexInvalid) return; + DriverPose_t pose{}; + pose.qWorldFromDriverRotation.w = pose.qDriverFromHeadRotation.w = 1; + pose.qRotation = {hand.pose.orientation[3], hand.pose.orientation[0], hand.pose.orientation[1], hand.pose.orientation[2]}; + for (int axis = 0; axis < 3; axis++) pose.vecPosition[axis] = hand.pose.position[axis]; + pose.deviceIsConnected = hand.active; + pose.poseIsValid = hand.active && (hand.pose.flags & 3u) == 3u; + pose.result = pose.poseIsValid ? TrackingResult_Running_OK : TrackingResult_Running_OutOfRange; + pose_ = pose; + VRServerDriverHost()->TrackedDevicePoseUpdated(id_, pose, sizeof(pose)); + VRDriverInput()->UpdateScalarComponent(trigger_, hand.active ? hand.trigger : 0, 0); + VRDriverInput()->UpdateBooleanComponent(triggerClick_, hand.active && hand.trigger > 0.6f, 0); + VRDriverInput()->UpdateBooleanComponent(grip_, hand.active && hand.squeeze > 0.6f, 0); + VRDriverInput()->UpdateScalarComponent(padX_, hand.active ? hand.stick[0] : 0, 0); + VRDriverInput()->UpdateScalarComponent(padY_, hand.active ? hand.stick[1] : 0, 0); + } + void Update(const HmdQuaternion_t &head, const PadState *pad) { if (id_ == k_unTrackedDeviceIndexInvalid) return; DriverPose_t p = {}; @@ -426,6 +445,12 @@ public: if (height_ <= 0) height_ = 1080; float hz = VRSettings()->GetFloat(kSettingsSection, "displayFrequency"); if (hz <= 0.0f) hz = 60.0f; + DroidDeckXrTracking xr; + xrMode_ = xr_.Read(&xr) && XrTrackingFile::Fresh(xr); + if (xrMode_) { + width_ = xr.eyes[0].width * 2; height_ = xr.eyes[0].height; + hz = 1000000000.f / xr.period_ns; + } tanHalf_ = 1.0f; eyeAspect_ = (width_ * 0.5f) / height_; droiddeck_headpose hp; @@ -433,7 +458,7 @@ public: tanHalf_ = hp.tan_half_fov; if (hp.eye_aspect > 0.0f) eyeAspect_ = hp.eye_aspect; } - VRProperties()->SetStringProperty(c, Prop_ModelNumber_String, "DroidDeck Cardboard"); + VRProperties()->SetStringProperty(c, Prop_ModelNumber_String, xrMode_ ? "DroidDeck Quest OpenXR" : "DroidDeck Cardboard"); VRProperties()->SetStringProperty(c, Prop_ManufacturerName_String, "DroidDeck"); VRProperties()->SetStringProperty(c, Prop_TrackingSystemName_String, "droiddeck"); VRProperties()->SetFloatProperty(c, Prop_UserIpdMeters_Float, 0.064f); @@ -488,6 +513,18 @@ public: p.qWorldFromDriverRotation.w = 1; p.qDriverFromHeadRotation.w = 1; p.qRotation.w = 1; + DroidDeckXrTracking xr; + bool freshXr = xr_.Read(&xr) && XrTrackingFile::Fresh(xr); + if (freshXr || xrMode_) { + p.deviceIsConnected = true; + p.poseIsValid = freshXr && (xr.head.flags & 3u) == 3u; + p.result = p.poseIsValid ? TrackingResult_Running_OK : TrackingResult_Running_OutOfRange; + if (p.poseIsValid) { + p.qRotation = {xr.head.orientation[3], xr.head.orientation[0], xr.head.orientation[1], xr.head.orientation[2]}; + for (int axis = 0; axis < 3; axis++) p.vecPosition[axis] = xr.head.position[axis]; + } + return p; + } p.vecPosition[1] = 1.6; droiddeck_headpose hp; bool ok = pose_.Read(&hp); @@ -509,6 +546,7 @@ public: bool IsDisplayOnDesktop() override { return !directMode_; } bool IsDisplayRealDisplay() override { return false; } void GetRecommendedRenderTargetSize(uint32_t *w, uint32_t *h) override { + if (xrMode_) { *w = width_ / 2; *h = height_; return; } *w = (uint32_t)(width_ / 2 * 1.4f); *h = (uint32_t)(height_ * 1.4f); } @@ -516,7 +554,14 @@ public: *y = 0; *w = (uint32_t)width_ / 2; *h = (uint32_t)height_; *x = eye == Eye_Left ? 0 : (uint32_t)width_ / 2; } - void GetProjectionRaw(EVREye, float *left, float *right, float *top, float *bottom) override { + void GetProjectionRaw(EVREye eye, float *left, float *right, float *top, float *bottom) override { + DroidDeckXrTracking xr; + if (xrMode_ && xr_.Read(&xr) && XrTrackingFile::Fresh(xr)) { + const float *fov = xr.eyes[eye == Eye_Right ? 1 : 0].fov; + *left = std::tan(fov[0]); *right = std::tan(fov[1]); + *top = -std::tan(fov[2]); *bottom = -std::tan(fov[3]); + return; + } *left = -tanHalf_ * eyeAspect_; *right = tanHalf_ * eyeAspect_; *top = -tanHalf_; *bottom = tanHalf_; } @@ -536,6 +581,19 @@ private: uint32_t lastSeq = 0; auto lastSent = std::chrono::steady_clock::now(); while (running_) { + DroidDeckXrTracking xr; + if (xrMode_) { + bool fresh = xr_.Read(&xr) && XrTrackingFile::Fresh(xr); + DriverPose_t head = GetPose(); + VRServerDriverHost()->TrackedDevicePoseUpdated(id_, head, sizeof(head)); + for (int hand = 0; hand < 2; hand++) if (hands_[hand]) { + DroidDeckXrHand state = fresh ? xr.hands[hand] : DroidDeckXrHand{}; + hands_[hand]->UpdateXr(state); + } + VRDriverInput()->UpdateBooleanComponent(proximity_, fresh, 0); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + continue; + } droiddeck_headpose hp; bool fresh = pose_.Read(&hp) && hp.seq != lastSeq; auto now = std::chrono::steady_clock::now(); @@ -570,6 +628,8 @@ public: private: PoseFile pose_; + XrTrackingFile xr_; + bool xrMode_ = false; PadRing pad_; PadState lastPad_; VRInputComponentHandle_t proximity_ = 0; diff --git a/tools/vr/src/presenter.cpp b/tools/vr/src/presenter.cpp index 2b0d446e..a5537940 100644 --- a/tools/vr/src/presenter.cpp +++ b/tools/vr/src/presenter.cpp @@ -9,6 +9,7 @@ typedef unsigned long VisualID; #include "xdg-shell-client-protocol.h" #include "presenter.h" +#include "xr_tracking_file.h" #include #include @@ -268,6 +269,11 @@ private: if (pread(fd, eye, sizeof(eye), 72) == (ssize_t)sizeof(eye) && eye[0] && eye[1]) { width_ = eye[0] * 2; height_ = eye[1]; } close(fd); } + XrTrackingFile xrFile; + DroidDeckXrTracking tracking; + if (xrFile.Read(&tracking) && XrTrackingFile::Fresh(tracking)) { + width_ = tracking.eyes[0].width * 2; height_ = tracking.eyes[0].height; + } if (!width_ && cfgW_ > 0 && cfgH_ > 0) { width_ = (uint32_t)cfgW_; height_ = (uint32_t)cfgH_; } if (!width_) { width_ = 1920; height_ = 1080; } Log("presenter: %ux%u fullscreen window on Wayland display %s (configured %s)", width_, height_, name, configured_ ? "yes" : "no"); diff --git a/tools/vr/src/xr_tracking.h b/tools/vr/src/xr_tracking.h new file mode 100644 index 00000000..6258d32a --- /dev/null +++ b/tools/vr/src/xr_tracking.h @@ -0,0 +1,34 @@ +#pragma once +#include + +#define DROIDDECK_XR_MAGIC 0x52584444u +#define DROIDDECK_XR_VERSION 1u +#define DROIDDECK_XR_FILE "droiddeck-xr-tracking" + +struct DroidDeckXrPose { + float orientation[4]; + float position[3]; + uint32_t flags; +}; + +struct DroidDeckXrEye { + DroidDeckXrPose pose; + float fov[4]; + uint32_t width, height; +}; + +struct DroidDeckXrHand { + DroidDeckXrPose pose; + float trigger, squeeze, stick[2]; + uint32_t buttons, active; +}; + +struct DroidDeckXrTracking { + uint32_t magic, version, seq, active; + int64_t sampled_ns, predicted_ns, period_ns; + DroidDeckXrPose head; + DroidDeckXrEye eyes[2]; + DroidDeckXrHand hands[2]; +}; + +static_assert(sizeof(DroidDeckXrTracking) == 296, "XR tracking IPC layout"); diff --git a/tools/vr/src/xr_tracking_file.h b/tools/vr/src/xr_tracking_file.h new file mode 100644 index 00000000..5295ee20 --- /dev/null +++ b/tools/vr/src/xr_tracking_file.h @@ -0,0 +1,51 @@ +#pragma once +#include "xr_tracking.h" +#include +#include +#include +#include +#include +#include +#include + +class XrTrackingFile { +public: + ~XrTrackingFile() { if (mapping_) munmap(mapping_, sizeof(*mapping_)); } + bool Read(DroidDeckXrTracking *packet) { + std::lock_guard lock(mutex_); + if (!mapping_) { + int descriptor = open("/run/droiddeck/" DROIDDECK_XR_FILE, O_RDONLY | O_CLOEXEC); + if (descriptor < 0) return false; + struct stat status{}; + if (fstat(descriptor, &status) != 0 || status.st_size != sizeof(*mapping_)) { + close(descriptor); return false; + } + void *mapping = mmap(nullptr, sizeof(*mapping_), PROT_READ, MAP_SHARED, descriptor, 0); + close(descriptor); + if (mapping == MAP_FAILED) return false; + mapping_ = static_cast(mapping); + } + for (int attempt = 0; attempt < 8; attempt++) { + uint32_t sequence = __atomic_load_n(&mapping_->seq, __ATOMIC_ACQUIRE); + if (sequence & 1u) continue; + std::memcpy(packet, mapping_, sizeof(*packet)); + __atomic_thread_fence(__ATOMIC_ACQUIRE); + if (__atomic_load_n(&mapping_->seq, __ATOMIC_RELAXED) == sequence) + return packet->magic == DROIDDECK_XR_MAGIC && packet->version == DROIDDECK_XR_VERSION; + } + return false; + } + static int64_t Now() { + timespec now{}; clock_gettime(CLOCK_MONOTONIC, &now); + return (int64_t)now.tv_sec * 1000000000LL + now.tv_nsec; + } + static bool Fresh(const DroidDeckXrTracking &packet) { + int64_t age = Now() - packet.sampled_ns; + return packet.active && age >= 0 && age < 500000000LL && packet.period_ns >= 1000000LL + && packet.period_ns <= 50000000LL && packet.eyes[0].width > 0 && packet.eyes[0].width <= 8192 + && packet.eyes[0].height > 0 && packet.eyes[0].height <= 8192; + } +private: + DroidDeckXrTracking *mapping_ = nullptr; + std::mutex mutex_; +};