From 8b08e0c4f4e9657808573eea25b810abdcae3327 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 07:29:10 +0000 Subject: [PATCH] Quest: add Gradle quest flavor, OpenXR loader and build wiring Android gains phone/quest product flavors: phone is today's build, quest passes PICASIM_QUEST=ON to CMake, links the Khronos OpenXR Android loader (org.khronos.openxr:openxr_loader_for_android:1.1.49 via prefab, minSdk 29 for this flavor as the loader requires 24+) and merges a manifest overlay with the Quest metadata (vr.headtracking, com.oculus.intent.category.VR, supportedDevices, no orientation lock). CMake: new PICASIM_QUEST option; on Android it enables VR, finds the OpenXR prefab package, defines PICASIM_VR_SUPPORT/PICASIM_QUEST and links GLESv3 + openxr_loader. Non-quest Android keeps VR forced off. CI builds both flavors and uploads a separate quest APK artifact; APK paths updated for the flavored output layout. QUEST.md documents building, sideloading and input (RC transmitter as SDL gamepad, no VR controller support). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0123xeZJrveacZTdK8bCv7YX Quest: Android/OpenGL ES backend for the OpenXR runtime Adds the Android path to the OpenXR layer, all behind PICASIM_ANDROID with the Win32/desktop-GL path unchanged: - XR_USE_GRAPHICS_API_OPENGL_ES + XR_USE_PLATFORM_ANDROID, with EGL, GLES3 and JNI headers ahead of openxr_platform.h - one-time xrInitializeLoaderKHR with the JavaVM and activity obtained from SDL, before any other OpenXR call (mandatory on Android) - instance extensions XR_KHR_opengl_es_enable and XR_KHR_android_create_instance, with XrInstanceCreateInfoAndroidKHR chained into instance creation - session creation via XrGraphicsBindingOpenGLESAndroidKHR built from the current EGL display/context, with the EGLConfig recovered from the context's EGL_CONFIG_ID - swapchain images typed XrSwapchainImageOpenGLESKHR Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0123xeZJrveacZTdK8bCv7YX Quest: port the VR render path to OpenGL ES 3.0 The VR rendering code was written against desktop GL; this makes it compile and behave on an ES 3.0 context (Quest), with every change inert unless PICASIM_ANDROID/PICASIM_QUEST is defined: - Window: request an ES 3.0 context and GLES3 headers for the quest flavor (non-quest Android stays on ES 2.0) - glDepthRange -> glDepthRangef on GLES (double variant doesn't exist) - MSAA color renderbuffer uses GL_SRGB8_ALPHA8 on Android: GLES3 requires matching internal formats on multisample resolve blits and the swapchain prefers sRGB - panorama depth copy: GLES cannot glCopyTexImage2D depth, so it is replaced by a depth blit through a temporary FBO; depth texture allocated with GL_UNSIGNED_INT as GLES3 rejects DEPTH_COMPONENT24 + GL_FLOAT - desktop mirror-window rendering and per-eye mirror blits compiled out on Android (no mirror on device; swapping the invisible SDL surface could stall the XR frame loop), and the eye mirror FBOs are skipped entirely, saving GPU memory Known runtime follow-ups documented in QUEST.md territory: swapchain format fallback if the runtime lacks SRGB8_ALPHA8, and possible double sRGB encoding to tune in the swapchain choice. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0123xeZJrveacZTdK8bCv7YX Quest: fix glad include in PicaSim.cpp for Android VR builds PicaSim.cpp's VR block included glad unconditionally; it had only ever compiled on Windows. Use GLES3 headers on Android like the rest of the VR path. First compile failure caught by CI on the quest flavor. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0123xeZJrveacZTdK8bCv7YX Quest: enable VR at startup so menus render in the headset On-device testing showed the app healthy (assets extracted, GL up, OpenXR instance created on the Meta runtime) but stuck on the system loading interstitial: the menus were rendering to the invisible SDL surface with zero XR frames submitted. The menus already go through VRMenuRenderer, which is gated on mOptions.mEnableVR - default false and only togglable from the desktop settings UI. On the quest flavor, force it on at startup: the SDL window is invisible, the app only exists in VR. Also from the first device-test round: - quest_run.sh: one-step adb install + launch + PID-filtered logcat (drops the Quest system noise, keeps PicaSim/SDL/OpenXR output), saving to picasim_quest_run.log with a crash-report dump on Ctrl-C - CI names the quest APK com.rowlhouse.picasim-quest-v-b so sideloaded builds are identifiable Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0123xeZJrveacZTdK8bCv7YX Quest: skip menus at startup, stable debug signing, quest-only CI workflow [skip ci] Second device-test round: the XR session comes up and the menus render in the headset, so now the quest flavor goes straight to flying via the existing mFreeFlyOnStartup path (menu navigation needs a Bluetooth mouse for now and is a later step). Sideload fixes: - debug builds are now signed with a keystore committed to the repo (standard Android debug credentials): every CI runner used to generate its own debug key, so each new APK failed to install with INSTALL_FAILED_UPDATE_INCOMPATIBLE - CI passes -PpicasimBuildNumber=: versionCode becomes <2MMNNPP>000+build, strictly increasing across CI builds so updates install cleanly; quest_run.sh installs with -r -d (downgrade allowed for local builds) and auto-uninstalls on a signature mismatch quest-build.yml is a new workflow_dispatch-only workflow that builds just the quest flavor, to iterate on the port without burning CI minutes on the full multi-platform suite; combined with [skip ci] on work-in-progress pushes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0123xeZJrveacZTdK8bCv7YX Android CI: optional custom signing keystore via secrets [skip ci] Debug builds can now be signed with a user-provided key: the workflows (quest-build, android-build, github-release) decode ANDROID_KEYSTORE_BASE64 and export it with its credentials (ANDROID_KEYSTORE_PASSWORD/KEY_ALIAS/KEY_PASSWORD) as PICASIM_KEYSTORE_* environment variables, which build.gradle's debug signing config picks up. With any secret missing - e.g. on fork PRs, where secrets are unavailable - the build falls back to the debug keystore committed in android/, preserving the stable-signature behavior. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0123xeZJrveacZTdK8bCv7YX Quest: local Docker build environment for fast iteration [skip ci] quest_docker_build.sh builds the APK in a container instead of waiting for CI: docker/android-build.Dockerfile bakes JDK 17 + Android SDK, NDK 27.0.12077973 (the version AGP actually resolves on CI) and cmake 3.22.1, with licenses pre-accepted so AGP can fetch anything else. Gradle caches persist in the picasim-gradle named volume and native build outputs live in the repo bind-mount, so incremental builds are fast. A host android/local.properties is shadowed inside the container so it can't point gradle at the host SDK path. Runs as linux/amd64 (the NDK has no linux/arm64 host toolchain); on Apple Silicon enable Rosetta emulation in Docker Desktop. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0123xeZJrveacZTdK8bCv7YX quest_run.sh: add -u/--uninstall option for clean installs [skip ci] ./quest_run.sh -u uninstalls the old app before installing (clean install, drops app data); ./quest_run.sh -u alone just uninstalls and exits. The automatic uninstall-on-signature-mismatch fallback stays. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0123xeZJrveacZTdK8bCv7YX Quest: start with the powered trainer in the 3D field; fix emulator smoke test [skip ci] The quest flavor now always starts flying the Jackdaw (powered, with wheels) in RecreationGround3D - the same combination as the built-in powered-trainer scenario but with the full-3D environment instead of the panoramic one - until the menus are usable in the headset. The test-android job had been broken since its introduction: the android-emulator-runner action executes its script input line by line, so the multi-line polling loop died with a shell syntax error. The logic now lives in tools/android_smoke_test.sh, called as one line. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0123xeZJrveacZTdK8bCv7YX --- .github/workflows/android-build.yml | 66 +++++--- .github/workflows/github-release.yml | 29 +++- .github/workflows/quest-build.yml | 94 +++++++++++ CMakeLists.txt | 27 ++- QUEST.md | 95 +++++++++++ android/app/build.gradle | 64 +++++++- android/app/src/quest/AndroidManifest.xml | 37 +++++ android/debug.keystore | Bin 0 -> 2666 bytes docker/android-build.Dockerfile | 38 +++++ quest_docker_build.sh | 54 ++++++ quest_run.sh | 86 ++++++++++ source/Framework/RenderManager.cpp | 55 ++++++- source/PicaSim/Main.cpp | 28 ++++ source/PicaSim/PicaSim.cpp | 4 + source/Platform/OpenXRRuntime.cpp | 190 +++++++++++++++++++++- source/Platform/OpenXRRuntime.h | 12 ++ source/Platform/VRManager.cpp | 13 ++ source/Platform/VRMenuRenderer.cpp | 13 +- source/Platform/Window.cpp | 15 +- tools/android_smoke_test.sh | 23 +++ 20 files changed, 912 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/quest-build.yml create mode 100644 QUEST.md create mode 100644 android/app/src/quest/AndroidManifest.xml create mode 100644 android/debug.keystore create mode 100644 docker/android-build.Dockerfile create mode 100755 quest_docker_build.sh create mode 100755 quest_run.sh create mode 100755 tools/android_smoke_test.sh diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index ff021d08..366226ea 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -47,20 +47,60 @@ jobs: restore-keys: | gradle- + # Optional custom signing: set the ANDROID_KEYSTORE_BASE64, + # ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD + # secrets to sign with your own key. Without them (e.g. on fork PRs, + # where secrets are unavailable) the build falls back to the debug + # keystore committed in android/. + - name: Set up signing keystore (optional) + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + if [ -n "$ANDROID_KEYSTORE_BASE64" ] && [ -n "$ANDROID_KEYSTORE_PASSWORD" ] && [ -n "$ANDROID_KEY_ALIAS" ] && [ -n "$ANDROID_KEY_PASSWORD" ]; then + echo -n "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/picasim.keystore" + { + echo "PICASIM_KEYSTORE_FILE=$RUNNER_TEMP/picasim.keystore" + echo "PICASIM_KEYSTORE_PASSWORD=$ANDROID_KEYSTORE_PASSWORD" + echo "PICASIM_KEY_ALIAS=$ANDROID_KEY_ALIAS" + echo "PICASIM_KEY_PASSWORD=$ANDROID_KEY_PASSWORD" + } >> "$GITHUB_ENV" + echo "Signing with the custom keystore from secrets." + else + echo "Signing secrets not (fully) configured - using the repo debug keystore." + fi + - name: Generate asset manifest run: | python3 tools/generate_asset_manifest.py data data/assets_manifest.txt packaging_excludes.txt - - name: Build debug APK + - name: Build debug APKs (phone + quest) working-directory: android run: | - ./gradlew assembleDebug + ./gradlew assemblePhoneDebug assembleQuestDebug -PpicasimBuildNumber=${{ github.run_number }} - - name: Upload debug APK + - name: Upload phone debug APK uses: actions/upload-artifact@v4 with: name: picasim-android-debug - path: android/app/build/outputs/apk/debug/app-debug.apk + path: android/app/build/outputs/apk/phone/debug/app-phone-debug.apk + retention-days: 14 + + # Name the quest APK with the bundle id, version (VERSIONS.txt) and CI + # build number so sideloaded builds are identifiable on the headset. + - name: Rename quest APK + run: | + VERSION=$(grep -oE 'Version [0-9.]+' VERSIONS.txt | head -1 | sed 's/Version //') + cp android/app/build/outputs/apk/quest/debug/app-quest-debug.apk \ + "com.rowlhouse.picasim-quest-v${VERSION}-b${{ github.run_number }}.apk" + + - name: Upload quest debug APK + uses: actions/upload-artifact@v4 + with: + name: picasim-quest-debug + path: com.rowlhouse.picasim-quest-v*.apk retention-days: 14 # Smoke test: install the APK on an emulator and verify it doesn't crash at startup. @@ -99,21 +139,9 @@ jobs: target: default arch: x86_64 emulator-options: -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect - script: | - adb install apk/app-debug.apk - adb shell am start -n com.rowlhouse.picasim/.PicaSimActivity - # Poll for up to 60 seconds for the process to appear - for i in $(seq 1 12); do - sleep 5 - if adb shell ps -A | grep -q com.rowlhouse.picasim; then - echo "PicaSim is running OK" - exit 0 - fi - echo "Waiting for PicaSim... ($((i*5))s)" - done - echo "PicaSim did not start within 60 seconds" - adb logcat -d | grep -E "com.rowlhouse.picasim|SDL|FATAL|AndroidRuntime" | tail -100 - exit 1 + # The action runs each script line as a separate sh -c command, so + # multi-line shell constructs break - keep the logic in a script. + script: ./tools/android_smoke_test.sh - name: Collect logs if: always() diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index c2d3a2aa..173d1ef1 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -149,6 +149,31 @@ jobs: yes | "$SDKMANAGER" --licenses > /dev/null || true "$SDKMANAGER" --install "ndk;25.2.9519653" + # Optional custom signing: set the ANDROID_KEYSTORE_BASE64, + # ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD + # secrets to sign with your own key. Without them (e.g. on fork PRs, + # where secrets are unavailable) the build falls back to the debug + # keystore committed in android/. + - name: Set up signing keystore (optional) + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + if [ -n "$ANDROID_KEYSTORE_BASE64" ] && [ -n "$ANDROID_KEYSTORE_PASSWORD" ] && [ -n "$ANDROID_KEY_ALIAS" ] && [ -n "$ANDROID_KEY_PASSWORD" ]; then + echo -n "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/picasim.keystore" + { + echo "PICASIM_KEYSTORE_FILE=$RUNNER_TEMP/picasim.keystore" + echo "PICASIM_KEYSTORE_PASSWORD=$ANDROID_KEYSTORE_PASSWORD" + echo "PICASIM_KEY_ALIAS=$ANDROID_KEY_ALIAS" + echo "PICASIM_KEY_PASSWORD=$ANDROID_KEY_PASSWORD" + } >> "$GITHUB_ENV" + echo "Signing with the custom keystore from secrets." + else + echo "Signing secrets not (fully) configured - using the repo debug keystore." + fi + - name: Generate asset manifest run: | python3 tools/generate_asset_manifest.py data data/assets_manifest.txt packaging_excludes.txt @@ -156,12 +181,12 @@ jobs: - name: Build debug APK working-directory: android run: | - ./gradlew assembleDebug + ./gradlew assemblePhoneDebug - name: Rename APK run: | VERSION=$(grep -oE 'Version [0-9.]+' VERSIONS.txt | head -1 | sed 's/Version //') - cp android/app/build/outputs/apk/debug/app-debug.apk "PicaSim-${VERSION}-android-debug.apk" + cp android/app/build/outputs/apk/phone/debug/app-phone-debug.apk "PicaSim-${VERSION}-android-debug.apk" - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/quest-build.yml b/.github/workflows/quest-build.yml new file mode 100644 index 00000000..924020f5 --- /dev/null +++ b/.github/workflows/quest-build.yml @@ -0,0 +1,94 @@ +name: Quest Build + +# Manual-only build of just the Meta Quest flavor, for fast iteration on the +# Quest port without spending CI minutes on the full multi-platform suite. +# Tip: push work-in-progress commits with "[skip ci]" in the message to keep +# the PR workflows quiet, then dispatch this workflow on the branch. + +on: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build-quest: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Install NDK + run: | + SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + yes | "$SDKMANAGER" --licenses > /dev/null || true + "$SDKMANAGER" --install "ndk;25.2.9519653" + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ hashFiles('android/**/*.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: | + gradle- + + # Optional custom signing: set the ANDROID_KEYSTORE_BASE64, + # ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD + # secrets to sign with your own key. Without them (e.g. on forks) the + # build falls back to the debug keystore committed in android/. + - name: Set up signing keystore (optional) + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + if [ -n "$ANDROID_KEYSTORE_BASE64" ] && [ -n "$ANDROID_KEYSTORE_PASSWORD" ] && [ -n "$ANDROID_KEY_ALIAS" ] && [ -n "$ANDROID_KEY_PASSWORD" ]; then + echo -n "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/picasim.keystore" + { + echo "PICASIM_KEYSTORE_FILE=$RUNNER_TEMP/picasim.keystore" + echo "PICASIM_KEYSTORE_PASSWORD=$ANDROID_KEYSTORE_PASSWORD" + echo "PICASIM_KEY_ALIAS=$ANDROID_KEY_ALIAS" + echo "PICASIM_KEY_PASSWORD=$ANDROID_KEY_PASSWORD" + } >> "$GITHUB_ENV" + echo "Signing with the custom keystore from secrets." + else + echo "Signing secrets not (fully) configured - using the repo debug keystore." + fi + + - name: Generate asset manifest + run: | + python3 tools/generate_asset_manifest.py data data/assets_manifest.txt packaging_excludes.txt + + - name: Build quest debug APK + working-directory: android + run: | + ./gradlew assembleQuestDebug -PpicasimBuildNumber=${{ github.run_number }} + + - name: Rename APK + run: | + VERSION=$(grep -oE 'Version [0-9.]+' VERSIONS.txt | head -1 | sed 's/Version //') + cp android/app/build/outputs/apk/quest/debug/app-quest-debug.apk \ + "com.rowlhouse.picasim-quest-v${VERSION}-b${{ github.run_number }}.apk" + + - name: Upload quest debug APK + uses: actions/upload-artifact@v4 + with: + name: picasim-quest-debug + path: com.rowlhouse.picasim-quest-v*.apk + retention-days: 14 diff --git a/CMakeLists.txt b/CMakeLists.txt index 42a50416..557abb92 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,9 +74,23 @@ if(NOT EXISTS ${CMAKE_SOURCE_DIR}/third_party/SDL2/CMakeLists.txt) endif() # --- Platform-specific build options --- + +# Meta Quest flavor: Android build with OpenXR VR support (set by the 'quest' +# Gradle product flavor via -DPICASIM_QUEST=ON). +option(PICASIM_QUEST "Build the Meta Quest (Android + OpenXR) flavor" OFF) + if(ANDROID) - # Force VR off on Android - set(PICASIM_ENABLE_VR OFF CACHE BOOL "" FORCE) + if(PICASIM_QUEST) + # Quest: enable VR via the OpenXR loader AAR (org.khronos.openxr), + # exposed to CMake through Gradle's prefab support. + set(PICASIM_ENABLE_VR ON CACHE BOOL "" FORCE) + find_package(OpenXR CONFIG REQUIRED) + add_compile_definitions(PICASIM_VR_SUPPORT=1 PICASIM_QUEST=1) + message(STATUS "Meta Quest build: VR support ENABLED (OpenXR via prefab)") + else() + # Force VR off on non-Quest Android (phones/tablets) + set(PICASIM_ENABLE_VR OFF CACHE BOOL "" FORCE) + endif() # 16KB page size alignment required for Android 15+ (API 35) set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,-z,max-page-size=16384 -Wl,-z,common-page-size=16384") @@ -254,6 +268,9 @@ target_link_libraries(Framework PUBLIC ) if(ANDROID) target_link_libraries(Framework PUBLIC GLESv2) + if(PICASIM_QUEST) + target_link_libraries(Framework PUBLIC GLESv3) + endif() elseif(IOS) # OpenGLES framework linked via PicaSim target (iOS frameworks need to be on the app) else() @@ -307,6 +324,9 @@ target_link_libraries(Platform PUBLIC ) if(ANDROID) target_link_libraries(Platform PUBLIC GLESv2 EGL log android) + if(PICASIM_QUEST) + target_link_libraries(Platform PUBLIC GLESv3 OpenXR::openxr_loader) + endif() elseif(IOS) # OpenGLES and system frameworks linked via PicaSim target else() @@ -416,6 +436,9 @@ target_link_libraries(PicaSim PRIVATE ) if(ANDROID) target_link_libraries(PicaSim PRIVATE SDL2main GLESv2 EGL log android) + if(PICASIM_QUEST) + target_link_libraries(PicaSim PRIVATE GLESv3 OpenXR::openxr_loader) + endif() elseif(IOS) target_link_libraries(PicaSim PRIVATE SDL2main) target_link_libraries(PicaSim PRIVATE diff --git a/QUEST.md b/QUEST.md new file mode 100644 index 00000000..90d4b072 --- /dev/null +++ b/QUEST.md @@ -0,0 +1,95 @@ +# PicaSim on Meta Quest + +The `quest` Gradle product flavor builds the Android app for Meta Quest +headsets (Quest 2 / Quest Pro / Quest 3) with OpenXR VR support enabled. +The regular Android build is the `phone` flavor and is unchanged. + +## What the quest flavor does + +- Passes `-DPICASIM_QUEST=ON` to CMake, which enables `PICASIM_ENABLE_VR`, + defines `PICASIM_VR_SUPPORT=1` and `PICASIM_QUEST=1`, and links the OpenXR + loader and `GLESv3` in addition to `GLESv2`. +- Pulls the Khronos OpenXR loader from Maven Central + (`org.khronos.openxr:openxr_loader_for_android:1.1.49`), exposed to the + CMake build via Gradle's prefab support (`find_package(OpenXR CONFIG)`). +- Merges a Quest manifest overlay (`android/app/src/quest/AndroidManifest.xml`): + head-tracking feature requirement, `com.oculus.intent.category.VR` launcher + category, `com.oculus.supportedDevices` metadata, and removal of the + phone-oriented `screenOrientation` lock. +- Uses the same application id as the phone flavor + (`com.rowlhouse.picasim`), so only one of the two can be installed on a + device at a time. + +## Building + +```sh +cd android +./gradlew assembleQuestDebug # -> app/build/outputs/apk/quest/debug/app-quest-debug.apk +./gradlew assemblePhoneDebug # regular phone build +``` + +CI (`.github/workflows/android-build.yml`) builds both flavors and uploads +the quest APK as the `picasim-quest-debug` artifact. + +## Installing (sideload) + +1. Enable developer mode on the headset (requires a Meta developer account: + pair the headset with the Meta Horizon phone app, then toggle Developer + Mode) and connect via USB-C. +2. Install with adb: + + ```sh + adb install android/app/build/outputs/apk/quest/debug/app-quest-debug.apk + ``` + +3. Launch from **Library → Unknown Sources** on the headset. + +### Fast local builds with Docker + +CI round-trips are slow for iteration; `./quest_docker_build.sh` builds the +APK locally in a container (image built once from +`docker/android-build.Dockerfile`, with JDK + Android SDK/NDK). Gradle caches +persist in the `picasim-gradle` named volume and native build outputs live in +the repo bind-mount, so incremental builds are fast. Any gradle task can be +passed as argument (default `assembleQuestDebug`); `--rebuild-image` forces +an image rebuild. On Apple Silicon enable "Use Rosetta for x86_64/amd64 +emulation" in Docker Desktop (the NDK has no linux/arm64 host toolchain). +Typical loop: + +```sh +./quest_docker_build.sh && ./quest_run.sh android/app/build/outputs/apk/quest/debug/app-quest-debug.apk +``` + +### One-step install + launch + logs + +`./quest_run.sh [path/to.apk]` (repo root) installs the APK if given, +(re)launches the app via adb (USB or WiFi) and streams the app's logcat +filtered by PID — dropping all the Quest system noise while keeping PicaSim +traces, SDL and OpenXR loader/runtime output. The stream is also saved to +`picasim_quest_run.log`, and on Ctrl-C it appends any native crash report. +CI names the quest APK `com.rowlhouse.picasim-quest-v-b.apk` +so sideloaded builds are identifiable. + +### Signing + +Debug builds (local and CI) are signed with `android/debug.keystore`, +committed to the repo with the standard Android debug credentials, so every +build shares one signature and installs over the previous one. To sign CI +builds with your own key instead, set the repository secrets +`ANDROID_KEYSTORE_BASE64` (the keystore file, base64), `ANDROID_KEYSTORE_PASSWORD`, +`ANDROID_KEY_ALIAS` and `ANDROID_KEY_PASSWORD` — with any of them missing, +the workflows fall back to the committed keystore. + +## Input + +There is no VR (Touch) controller support. Fly with an RC transmitter +connected over USB-C or Bluetooth — it shows up as a standard SDL gamepad, +just like on phones. A Bluetooth mouse works for navigating the menus. + +## Status / known gaps + +- **Experimental** — the build-system support is in place, but the rendering + path is still being ported to GLES3 for the Quest's OpenXR runtime. +- Not yet tested on real hardware. +- No hand tracking, no Touch controller bindings, no Quest-specific UI + (menus render on the 2D layer). diff --git a/android/app/build.gradle b/android/app/build.gradle index b1a77f49..61fb7dfc 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -17,6 +17,13 @@ def parsedVersionCode = 2000000 + (versionParts[1] as int) * 100 + (versionParts[2] as int) +// CI passes -PpicasimBuildNumber= so every CI build gets a +// strictly increasing versionCode and installs as an update over the +// previous one. Scheme: <2MMNNPP>000 + build (build capped at 999 so a +// version bump always outranks any build number). Local builds get 0. +def picasimBuildNumber = (project.hasProperty('picasimBuildNumber') ? project.property('picasimBuildNumber') as int : 0) % 1000 +parsedVersionCode = parsedVersionCode * 1000 + picasimBuildNumber + // Load signing keystore properties (not committed to git) def keystorePropertiesFile = rootProject.file("keystore.properties") def keystoreProperties = new Properties() @@ -35,6 +42,29 @@ android { keyAlias keystoreProperties['keyAlias'] ?: '' keyPassword keystoreProperties['keyPassword'] ?: '' } + // Debug signing, in order of preference: + // 1. A custom keystore provided via environment (CI secrets, see the + // "Set up signing keystore" workflow step): PICASIM_KEYSTORE_FILE, + // PICASIM_KEYSTORE_PASSWORD, PICASIM_KEY_ALIAS, PICASIM_KEY_PASSWORD. + // 2. Fallback: a keystore committed to the repo (standard Android + // debug credentials, nothing sensitive) so that local and CI debug + // builds all share one signature and can install over each other. + // Without a stable key, every CI runner generates its own and adb + // install fails with INSTALL_FAILED_UPDATE_INCOMPATIBLE. + debug { + def ciKeystore = System.getenv("PICASIM_KEYSTORE_FILE") + if (ciKeystore) { + storeFile file(ciKeystore) + storePassword System.getenv("PICASIM_KEYSTORE_PASSWORD") + keyAlias System.getenv("PICASIM_KEY_ALIAS") + keyPassword System.getenv("PICASIM_KEY_PASSWORD") + } else { + storeFile rootProject.file("debug.keystore") + storePassword "android" + keyAlias "androiddebugkey" + keyPassword "android" + } + } } defaultConfig { @@ -47,13 +77,43 @@ android { externalNativeBuild { cmake { arguments "-DANDROID_STL=c++_shared", - "-DPICASIM_ENABLE_VR=OFF", "-DANDROID_APP_PLATFORM=android-21" abiFilters 'arm64-v8a', 'x86_64' } } } + // Prefab exposes native libraries from AARs (the Khronos OpenXR loader) + // to the CMake build via find_package(OpenXR CONFIG). + buildFeatures { + prefab true + } + + // Product flavors: 'phone' is the regular Android build (VR off, unchanged + // behavior); 'quest' targets Meta Quest headsets with OpenXR VR support. + flavorDimensions "device" + productFlavors { + phone { + dimension "device" + externalNativeBuild { + cmake { + arguments "-DPICASIM_ENABLE_VR=OFF" + } + } + } + quest { + dimension "device" + // The OpenXR loader AAR declares minSdk 24; Quest headsets all + // run API 29+ anyway. + minSdkVersion 29 + externalNativeBuild { + cmake { + arguments "-DPICASIM_QUEST=ON" + } + } + } + } + buildTypes { release { minifyEnabled false @@ -110,4 +170,6 @@ preBuild.dependsOn generateAssetManifest dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') + // Khronos OpenXR loader (AAR with prefab support) — quest flavor only + questImplementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49' } diff --git a/android/app/src/quest/AndroidManifest.xml b/android/app/src/quest/AndroidManifest.xml new file mode 100644 index 00000000..ef16898c --- /dev/null +++ b/android/app/src/quest/AndroidManifest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/debug.keystore b/android/debug.keystore new file mode 100644 index 0000000000000000000000000000000000000000..8db57073419ee560a9ffe2dd7f8882a5fd5fab9b GIT binary patch literal 2666 zcma)8X*d*$8lIh*)7YaFnX(KTS4-gQHr6Gi1aHW{@F`Gyr zA264O077W+JuD5rjio`G|4V{Ch0vgtKk2fcRRsI(Kdu7^ASjmxHTp^FV?BO3ph)cR zSe?Je16XBi}Gbt1yO`IkJLRuwdSsv?yrASXv8}`>NMxUOgPn6N#vj#4Uap`uI+l-nH%~}p#W+*cF6y%Ud#CO!l{d0goOt2 z(rqJX=4ScHDgtt5phNibSaZ(O z?HE2=y8VT$aJ$8mQyZ%4Q&LJ?8-Ms{_HdfZ{iBnTr^CPo?I~I^y!ZC` zcG@a4H{D*8lD#DwDUR!3Vqz**pF?!R%s%MOB}lwlJG&~)5*U*}N^~eZz7ropj<`a$ zM};3J^_&19>UXE^^Qm{nD}Py7dU8&F>^U^&26c-drQ=G(K71_>*V{RidKwTn?WMJI zm)JPK5pqR1+bB{|v!sbK8HnU5*g#NLCZO0OS!%(oXLmt^rqotHR-G#H*+?@7y9X#a zx8!Y+ac>pr#GkS*|M0GH&)J5TzQ~Q{-uINhrYhWPSx)R~+adG)malpn1$PASaDU|O z-(ih^91c3lz3#h9w&{}|JVs+4)6xeIT`GZU&Dlpfp{nAl+9@{z>#w7hE8t|o#6Ul@ z@zT#UptF&$qtC{u?INs`KY=26tHRCN1?6hrR=T9pT=+c2uFjbyb=y_osoZSBH%rCw zAzopBY2X_SL!?{?ow3I0Whvwn(3>K+KKf=<8(luB`~nni{%(GYFrG`4xYl;LW7)y% z{!mX2mAT3PAn?c5qWhr!GevmVX#Ladv&EHJogJYJQ_^#dG)F%Tvui6cC~@;96XR`j z;zv8)6lF?UWpe$1O? zNGh7i_M>S)5Brtx0c2hvU1tD&6;W^y5_XS6(K^Ija5weZZ_5Abw z;DtCT2CGgh@~;$9yp+-7rUzn%u2-1P?OByn(h9r;k-q~+E;WIV7z>S9enx>{-Bk@?Ow_vq@QdX66tUokL%L> zLhuF*IgFdWGhxrFa7a=3wg_T9kF;i?pAM7OzH#kj=bQd?N8OLirAj`l4(`oR2{PAn zPPj}DXM%D5KMx`mFsha;MxTBK;v$p0Ho0cskiQ;x~#dm4o+=Eoc(r1^vZrfJ1I1jtT0B(4B z-r=LG<98p~aLumeO(@1g&^~CHJ21RaW>abFv2;-tIiq!_ z*-F=*nF=rQx#nWo?0M7yxz1y)6o(CADcBtNZJxW-NR8x*CCYs+VoC6dPgr+%j>xRl znz4h6muSS|{;PV~0cQ^P$LB8I-D+XQ;mBBP5#zCfFqC2A$n-_V)Sr*jGW8IqbL4F~ znZe$Cx5uEqFXqErb|ZnWhD0y;l2ptJ1FD$bSDM^<1{z zvr0D{=fZQ#`3)W)->c$nr)}sp-PtJ<(PlOI5K_#tLQCX>#gzu*d+Za$AAHcAGN(&I zh^algg5YqI!=l~0UXcQ)#`mHx%I1dCUwj9?f+LlQArB*98c9mc5sv_0(TpSlqg zxuJi^ylZ);=_hNgB~)9uSHU}16yB!SX`f87Vx%t7mHk<(@aQO=)84KDCOy7I=@$}D zHlG@U66{}Lc1ej*vN&{cJbyT#!S;Y4XOY{YqAAwPDSlS|sPS2Qh)MvYxi=k@!wo2C z`r&5ya1FB7ZV?{lIm0S&xpSuE`}{vLA}Xd|+1n6cDglaRGQ~|V}8pvg=D;J zyzAsL=^J`QOHLR@;lJcy3rB*bBf?u*>aGb}0O;wk5S$%nu|U%ggL*40eYrEaSS zwE~xd!?k^r{`ANi2)QtPAtx{QxTpb3Yj^|r@BaiQ-StN#lLcZSLp%b_(zUd7U zs@9)RfF*FU5?vl8Z9$OXlqd5J5|+0l2beEEKI@%@_c5MlAI7%UCN3|O_{ipo<{Rhw z%VpzgV_c)%Wwg(YR`5+QvSQh^xX(n$tEPL?(U2S!C(unG{}>8V7urU1AkE zhi!@(WTA$d`xFhK4-H3TCMFqYEfz#CA3x`jlg%yDXU(xmkMaaik(jbLpYev)TK`*e z^rH4IGQH-i;t?^;Ks1P5eYhintwX_DU~yRFum1}KgaRNk(Om*hrQ#EI`|G0B%3`O! tEhlS~kKsY%^KJprXl67LB9{KmGVQ}fMgIZoR1)>m8b>dvIO|^#@lQMP$fp1R literal 0 HcmV?d00001 diff --git a/docker/android-build.Dockerfile b/docker/android-build.Dockerfile new file mode 100644 index 00000000..b28fc5c0 --- /dev/null +++ b/docker/android-build.Dockerfile @@ -0,0 +1,38 @@ +# Local Android/Quest build environment for fast iteration without CI. +# Build the image once, then run gradle builds with the repo bind-mounted and +# the gradle cache in a named volume (see quest_docker_build.sh). +# +# NOTE: the Android NDK only ships linux x86_64 host prebuilts, so on Apple +# Silicon this image must run with --platform linux/amd64 (enable +# "Use Rosetta for x86/amd64 emulation" in Docker Desktop for decent speed). +FROM eclipse-temurin:17-jdk-jammy + +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 python-is-python3 \ + wget unzip zip ca-certificates git \ + && rm -rf /var/lib/apt/lists/* + +ENV ANDROID_HOME=/opt/android-sdk +ENV ANDROID_SDK_ROOT=/opt/android-sdk +ENV PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools + +# Android command-line tools +ARG CMDLINE_TOOLS=11076708 +RUN mkdir -p $ANDROID_HOME/cmdline-tools \ + && wget -q "https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip" -O /tmp/clt.zip \ + && unzip -q /tmp/clt.zip -d /tmp/clt \ + && mv /tmp/clt/cmdline-tools $ANDROID_HOME/cmdline-tools/latest \ + && rm -rf /tmp/clt /tmp/clt.zip + +# SDK components. NDK 27.0.12077973 is what the Android Gradle Plugin actually +# resolves to on CI; cmake satisfies the "3.22.1+" requirement in build.gradle. +# Licenses are accepted so AGP can auto-install anything else it needs. +RUN yes | sdkmanager --licenses > /dev/null \ + && sdkmanager "platform-tools" \ + "platforms;android-35" \ + "build-tools;35.0.0" \ + "ndk;27.0.12077973" \ + "cmake;3.22.1" + +WORKDIR /src/android +CMD ["./gradlew", "assembleQuestDebug"] diff --git a/quest_docker_build.sh b/quest_docker_build.sh new file mode 100755 index 00000000..efc4b7e6 --- /dev/null +++ b/quest_docker_build.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# quest_docker_build.sh - build the Quest (or any Android) APK locally in +# Docker, much faster than a CI round-trip. +# +# Usage: +# ./quest_docker_build.sh # assembleQuestDebug +# ./quest_docker_build.sh assemblePhoneDebug # any gradle task(s) +# ./quest_docker_build.sh --rebuild-image # force-rebuild the image first +# +# Caching for fast incremental builds: +# - gradle caches live in the named volume 'picasim-gradle' +# - native build outputs (android/app/.cxx, app/build) live in the repo +# bind-mount, so they persist on the host between runs +# +# The image runs as linux/amd64 because the Android NDK has no linux/arm64 +# host toolchain. On Apple Silicon enable "Use Rosetta for x86_64/amd64 +# emulation" in Docker Desktop settings, or the build will be very slow. +set -e + +cd "$(dirname "$0")" + +IMAGE=picasim-android-build +GRADLE_VOLUME=picasim-gradle + +if [ "$1" = "--rebuild-image" ]; then + shift + docker build --platform linux/amd64 -t "$IMAGE" -f docker/android-build.Dockerfile docker +elif ! docker image inspect "$IMAGE" > /dev/null 2>&1; then + echo "Building the $IMAGE image (one-time)..." + docker build --platform linux/amd64 -t "$IMAGE" -f docker/android-build.Dockerfile docker +fi + +# android/local.properties on the host (if any) points to the host SDK path; +# shadow it inside the container with the container's SDK location. +LOCAL_PROPS_OVERRIDE=$(mktemp) +echo "sdk.dir=/opt/android-sdk" > "$LOCAL_PROPS_OVERRIDE" +trap 'rm -f "$LOCAL_PROPS_OVERRIDE"' EXIT + +TASKS=("$@") +[ ${#TASKS[@]} -eq 0 ] && TASKS=(assembleQuestDebug) + +docker run --rm --platform linux/amd64 \ + -v "$PWD:/src" \ + -v "$GRADLE_VOLUME:/root/.gradle" \ + -v "$LOCAL_PROPS_OVERRIDE:/src/android/local.properties" \ + -w /src/android \ + "$IMAGE" ./gradlew "${TASKS[@]}" + +APK=android/app/build/outputs/apk/quest/debug/app-quest-debug.apk +if [ -f "$APK" ]; then + echo "" + echo "APK: $APK" + echo "Install and run on the headset with: ./quest_run.sh $APK" +fi diff --git a/quest_run.sh b/quest_run.sh new file mode 100755 index 00000000..cf1f3e97 --- /dev/null +++ b/quest_run.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# quest_run.sh - install, launch and capture filtered logs of the Quest build +# over adb (USB or WiFi). +# +# Usage: +# ./quest_run.sh # (re)launch the installed app and stream logs +# ./quest_run.sh path/to/app-quest.apk # install first, then launch and stream logs +# ./quest_run.sh -u path/to/app-quest.apk # uninstall the old app first (clean install) +# ./quest_run.sh -u # just uninstall and exit +# +# Logs are filtered by the app's PID, which drops all the Quest system noise +# (VrShell, tooltips, ...) while keeping everything from the app process: +# PicaSim traces, SDL, the OpenXR loader and the in-process runtime client. +# The stream is also saved to picasim_quest_run.log. Ctrl-C to stop. +set -e + +PKG=com.rowlhouse.picasim +ACTIVITY=.PicaSimActivity +LOG_FILE=picasim_quest_run.log + +UNINSTALL=0 +APK="" +while [ $# -gt 0 ]; do + case "$1" in + -u|--uninstall) UNINSTALL=1 ;; + -*) echo "Unknown option $1"; grep '^# ' "$0"; exit 1 ;; + *) APK="$1" ;; + esac + shift +done + +if ! adb get-state > /dev/null 2>&1; then + echo "No adb device connected (for WiFi: adb connect :5555)" + exit 1 +fi + +if [ "$UNINSTALL" = 1 ]; then + echo "Uninstalling $PKG (app data is lost)..." + adb uninstall "$PKG" || echo "(was not installed)" + if [ -z "$APK" ]; then + exit 0 + fi +fi + +if [ -n "$APK" ]; then + echo "Installing $APK..." + # -r replace, -d allow downgrade (local builds have a lower versionCode + # than CI builds). If the installed APK was signed with a different debug + # key (pre stable-keystore builds), uninstall and retry. + if ! OUT=$(adb install -r -d "$APK" 2>&1); then + echo "$OUT" + if echo "$OUT" | grep -q "INSTALL_FAILED_UPDATE_INCOMPATIBLE"; then + echo "Signature mismatch - uninstalling $PKG and retrying (app data is lost)..." + adb uninstall "$PKG" || true + adb install "$APK" + else + exit 1 + fi + fi +fi + +echo "Restarting $PKG..." +adb shell am force-stop "$PKG" +adb logcat -c +adb shell am start -n "$PKG/$ACTIVITY" > /dev/null + +# Wait for the process to appear +PID="" +for _ in $(seq 1 20); do + PID=$(adb shell pidof -s "$PKG" | tr -d '\r') + [ -n "$PID" ] && break + sleep 0.5 +done + +if [ -z "$PID" ]; then + echo "App process did not start. System-side crash log:" + adb logcat -d -v time | grep -E "FATAL|AndroidRuntime|DEBUG" | tail -50 + exit 1 +fi + +echo "App running with PID $PID - streaming logs to $LOG_FILE (Ctrl-C to stop)" +# On Ctrl-C, append any crash report lines (crash_dump runs in another +# process, so the PID filter would miss a native crash backtrace). +trap 'echo; echo "--- crash check ---"; adb logcat -d -v time | grep -E "FATAL|AndroidRuntime|DEBUG " | tail -40 | tee -a "$LOG_FILE"; exit 0' INT + +adb logcat -v time --pid="$PID" | tee "$LOG_FILE" diff --git a/source/Framework/RenderManager.cpp b/source/Framework/RenderManager.cpp index 561e972c..20cc87d7 100644 --- a/source/Framework/RenderManager.cpp +++ b/source/Framework/RenderManager.cpp @@ -19,6 +19,13 @@ #include "Camera.h" // For VROrientationMode #include "Skybox.h" // For VR parallax skybox #include +#if defined(PICASIM_ANDROID) || defined(__ANDROID__) +// Quest: the VR path uses GLES3 entry points (glBlitFramebuffer, +// glRenderbufferStorageMultisample, GL_READ/DRAW_FRAMEBUFFER, sized internal +// formats). GLES3/gl3.h is a superset of the GLES2 header pulled in via +// Graphics.h. +#include +#endif #endif std::unique_ptr RenderManager::mInstance; @@ -631,7 +638,11 @@ void RenderManager::RenderUpdateVR(VRFrameInfo& frameInfo) glDepthMask(GL_TRUE); glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); glDepthFunc(GL_LESS); +#if defined(PICASIM_ANDROID) || defined(__ANDROID__) + glDepthRangef(0.0f, 1.0f); // GLES only has the float variant +#else glDepthRange(0.0, 1.0); +#endif glDisable(GL_SCISSOR_TEST); GLenum preErr = glGetError(); if (preErr != GL_NO_ERROR) @@ -683,7 +694,14 @@ void RenderManager::RenderUpdateVR(VRFrameInfo& frameInfo) // Create MSAA color renderbuffer glGenRenderbuffers(1, &msaaColorRB); glBindRenderbuffer(GL_RENDERBUFFER, msaaColorRB); +#if defined(PICASIM_ANDROID) || defined(__ANDROID__) + // GLES3 requires the multisample resolve source and destination to + // have identical internal formats. The OpenXR runtime prefers an + // SRGB8_ALPHA8 swapchain (which Quest provides), so match it here. + glRenderbufferStorageMultisample(GL_RENDERBUFFER, msaaSamples, GL_SRGB8_ALPHA8, eyeWidth, eyeHeight); +#else glRenderbufferStorageMultisample(GL_RENDERBUFFER, msaaSamples, GL_RGBA8, eyeWidth, eyeHeight); +#endif // Create MSAA depth renderbuffer glGenRenderbuffers(1, &msaaDepthRB); @@ -922,14 +940,38 @@ void RenderManager::RenderUpdateVR(VRFrameInfo& frameInfo) GLuint depthTexture; glGenTextures(1, &depthTexture); glBindTexture(GL_TEXTURE_2D, depthTexture); +#if defined(PICASIM_ANDROID) || defined(__ANDROID__) + // GLES3: GL_DEPTH_COMPONENT24 requires type GL_UNSIGNED_INT + // (GL_FLOAT is only valid for GL_DEPTH_COMPONENT32F) + glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24, eyeWidth, eyeHeight, 0, GL_DEPTH_COMPONENT, GL_UNSIGNED_INT, nullptr); +#else glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24, eyeWidth, eyeHeight, 0, GL_DEPTH_COMPONENT, GL_FLOAT, nullptr); +#endif glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); +#if defined(PICASIM_ANDROID) || defined(__ANDROID__) + // GLES3: glCopyTexImage2D cannot copy from a depth buffer. Attach + // the depth texture to a temporary FBO and blit the depth instead + // (formats match: both are GL_DEPTH_COMPONENT24, same rectangle). + { + GLuint depthCopyFBO; + glGenFramebuffers(1, &depthCopyFBO); + glBindFramebuffer(GL_READ_FRAMEBUFFER, swapchainFBO); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, depthCopyFBO); + glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthTexture, 0); + glBlitFramebuffer(0, 0, eyeWidth, eyeHeight, + 0, 0, eyeWidth, eyeHeight, + GL_DEPTH_BUFFER_BIT, GL_NEAREST); + glBindFramebuffer(GL_FRAMEBUFFER, swapchainFBO); + glDeleteFramebuffers(1, &depthCopyFBO); + } +#else // Copy depth buffer to texture (now from resolved swapchain FBO) glCopyTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24, 0, 0, eyeWidth, eyeHeight, 0); +#endif // Rebind MSAA FBO for skybox and foreground rendering (preserves anti-aliasing) if (msaaSamples > 1) @@ -1128,8 +1170,10 @@ void RenderManager::RenderUpdateVR(VRFrameInfo& frameInfo) RenderOverlaysForVREye(eyeWidth, eyeHeight, stereoPixelShift); } +#if !defined(PICASIM_ANDROID) && !defined(__ANDROID__) // Copy rendered content to VRManager's mirror texture (for desktop display) - // We do this while the swapchain texture is still bound + // We do this while the swapchain texture is still bound. + // No desktop mirror on Quest (and the mirror textures aren't created there). uint32_t mirrorTex = vrManager.GetEyeColorTexture((VREye)eye); if (mirrorTex != 0) { @@ -1151,6 +1195,7 @@ void RenderManager::RenderUpdateVR(VRFrameInfo& frameInfo) glBindFramebuffer(GL_FRAMEBUFFER, 0); glDeleteFramebuffers(1, &mirrorFBO); } +#endif // Cleanup MSAA resources if used if (msaaSamples > 1) @@ -1357,6 +1402,13 @@ void RenderManager::DrawVRCursor(float cursorX, float cursorY) //====================================================================================================================== void RenderManager::RenderMirrorWindow(VRMirrorMode mode) { +#if defined(PICASIM_ANDROID) || defined(__ANDROID__) + // No desktop mirror window on Quest - the SDL window surface isn't the VR + // display, so drawing/swapping it would be pointless (and the swap could + // stall the OpenXR frame loop). + (void)mode; + return; +#else if (!VRManager::IsAvailable() || !VRManager::GetInstance().IsVREnabled()) { return; @@ -1523,5 +1575,6 @@ void RenderManager::RenderMirrorWindow(VRMirrorMode mode) // Swap buffers for mirror window IwGLSwapBuffers(); +#endif // !Android } #endif // PICASIM_VR_SUPPORT diff --git a/source/PicaSim/Main.cpp b/source/PicaSim/Main.cpp index f7438f90..6cf18df4 100644 --- a/source/PicaSim/Main.cpp +++ b/source/PicaSim/Main.cpp @@ -379,6 +379,34 @@ int main(int argc, char* argv[]) ShaderManager::Init(initialLoadingScreen); #ifdef PICASIM_VR_SUPPORT +#ifdef PICASIM_QUEST + // On Quest the SDL window is invisible: the app only exists in VR, so + // the XR session must be up before the menus render (they submit VR + // frames via VRMenuRenderer). Without this the system never receives + // a frame and stays on the loading interstitial forever. + gameSettings.mOptions.mEnableVR = true; + // Menu interaction on the headset needs a Bluetooth mouse for now, so + // skip the menus and go straight to flying with the last-used (or + // default) aeroplane and scenery. + gameSettings.mOptions.mFreeFlyOnStartup = true; + // Until the menus are usable in the headset, always start with the + // powered trainer (wheels) in the full-3D recreation ground - the + // same combination as the built-in powered-trainer scenario, but + // with the 3D environment instead of the panoramic one. + { + bool loadResult = gameSettings.mAeroplaneSettings.LoadFromFile("SystemSettings/Aeroplane/Jackdaw.xml"); + IwAssert(ROWLHOUSE, loadResult); + loadResult = gameSettings.mControllerSettings.LoadFromFile("SystemSettings/Controller/TwoSticksWithThrottle.xml"); + IwAssert(ROWLHOUSE, loadResult); + loadResult = gameSettings.mEnvironmentSettings.LoadFromFile("SystemSettings/Environment/RecreationGround3D.xml"); + IwAssert(ROWLHOUSE, loadResult); + gameSettings.mEnvironmentSettings.mThermalDensity = 0.0f; + loadResult = gameSettings.mObjectsSettings.LoadFromFile(gameSettings.mEnvironmentSettings.mObjectsSettingsFile); + IwAssert(ROWLHOUSE, loadResult); + gameSettings.mStatistics.mLoadedAeroplane = true; + gameSettings.mStatistics.mLoadedTerrain = true; + } +#endif // Initialize VR if it was enabled in saved settings if (VRManager::IsAvailable() && gameSettings.mOptions.mEnableVR) { diff --git a/source/PicaSim/PicaSim.cpp b/source/PicaSim/PicaSim.cpp index b6647377..a6e20f4a 100644 --- a/source/PicaSim/PicaSim.cpp +++ b/source/PicaSim/PicaSim.cpp @@ -28,7 +28,11 @@ #ifdef PICASIM_VR_SUPPORT #include "../Platform/VRManager.h" #include "../Platform/VRRuntime.h" +#if defined(PICASIM_ANDROID) +#include +#else #include +#endif #include "Menus/UIHelpers.h" #include #include "imgui_impl_sdl2.h" diff --git a/source/Platform/OpenXRRuntime.cpp b/source/Platform/OpenXRRuntime.cpp index c572c80d..7234e7f4 100644 --- a/source/Platform/OpenXRRuntime.cpp +++ b/source/Platform/OpenXRRuntime.cpp @@ -5,9 +5,13 @@ #include "Window.h" #include "../Framework/Trace.h" +#ifdef PICASIM_ANDROID +#include +#else #include #include #include +#endif #include #include @@ -146,6 +150,45 @@ bool OpenXRRuntime::IsHMDConnected() const //====================================================================================================================== bool OpenXRRuntime::CreateInstance() { +#ifdef PICASIM_ANDROID + // Get the JavaVM and activity from SDL - needed by both the loader and instance creation + JNIEnv* env = (JNIEnv*)SDL_AndroidGetJNIEnv(); + if (!env) + { + TRACE_FILE_IF(ONCE_1) TRACE("OpenXRRuntime::CreateInstance - Failed to get JNI environment"); + return false; + } + + JavaVM* vm = nullptr; + env->GetJavaVM(&vm); + jobject activity = (jobject)SDL_AndroidGetActivity(); + if (!vm || !activity) + { + TRACE_FILE_IF(ONCE_1) TRACE("OpenXRRuntime::CreateInstance - Failed to get JavaVM/activity from SDL"); + return false; + } + + // The Android OpenXR loader must be initialised with the JavaVM and activity + // before any other OpenXR call (including extension enumeration) + static bool loaderInitialized = false; + if (!loaderInitialized) + { + PFN_xrInitializeLoaderKHR xrInitializeLoaderKHR = nullptr; + XR_CHECK(xrGetInstanceProcAddr(XR_NULL_HANDLE, "xrInitializeLoaderKHR", + (PFN_xrVoidFunction*)&xrInitializeLoaderKHR)); + + XrLoaderInitInfoAndroidKHR loaderInitInfo = {}; + loaderInitInfo.type = XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR; + loaderInitInfo.next = nullptr; + loaderInitInfo.applicationVM = vm; + loaderInitInfo.applicationContext = activity; + + XR_CHECK(xrInitializeLoaderKHR((const XrLoaderInitInfoBaseHeaderKHR*)&loaderInitInfo)); + loaderInitialized = true; + TRACE_FILE_IF(ONCE_2) TRACE("OpenXRRuntime::CreateInstance - Android loader initialised"); + } +#endif + // Query available extensions uint32_t extensionCount = 0; XR_CHECK(xrEnumerateInstanceExtensionProperties(nullptr, 0, &extensionCount, nullptr)); @@ -158,11 +201,16 @@ bool OpenXRRuntime::CreateInstance() } XR_CHECK(xrEnumerateInstanceExtensionProperties(nullptr, extensionCount, &extensionCount, extensions.data())); - // Check for OpenGL extension + // Check for the graphics binding extension +#ifdef PICASIM_ANDROID + const char* graphicsExtension = XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME; +#else + const char* graphicsExtension = XR_KHR_OPENGL_ENABLE_EXTENSION_NAME; +#endif bool hasOpenGL = false; for (const auto& ext : extensions) { - if (strcmp(ext.extensionName, XR_KHR_OPENGL_ENABLE_EXTENSION_NAME) == 0) + if (strcmp(ext.extensionName, graphicsExtension) == 0) { hasOpenGL = true; break; @@ -171,14 +219,21 @@ bool OpenXRRuntime::CreateInstance() if (!hasOpenGL) { - TRACE_FILE_IF(ONCE_1) TRACE("OpenXRRuntime::CreateInstance - OpenGL extension not available"); + TRACE_FILE_IF(ONCE_1) TRACE("OpenXRRuntime::CreateInstance - %s extension not available", graphicsExtension); return false; } // Required extensions +#ifdef PICASIM_ANDROID + const char* enabledExtensions[] = { + XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME, + XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME + }; +#else const char* enabledExtensions[] = { XR_KHR_OPENGL_ENABLE_EXTENSION_NAME }; +#endif // Create instance XrInstanceCreateInfo createInfo = {}; @@ -192,9 +247,19 @@ bool OpenXRRuntime::CreateInstance() createInfo.applicationInfo.apiVersion = XR_CURRENT_API_VERSION; createInfo.enabledApiLayerCount = 0; createInfo.enabledApiLayerNames = nullptr; - createInfo.enabledExtensionCount = 1; + createInfo.enabledExtensionCount = (uint32_t)(sizeof(enabledExtensions) / sizeof(enabledExtensions[0])); createInfo.enabledExtensionNames = enabledExtensions; +#ifdef PICASIM_ANDROID + // Pass the JavaVM and activity to the runtime + XrInstanceCreateInfoAndroidKHR androidCreateInfo = {}; + androidCreateInfo.type = XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR; + androidCreateInfo.next = nullptr; + androidCreateInfo.applicationVM = vm; + androidCreateInfo.applicationActivity = activity; + createInfo.next = &androidCreateInfo; +#endif + XR_CHECK(xrCreateInstance(&createInfo, &mInstance)); // Get runtime info @@ -313,6 +378,51 @@ void OpenXRRuntime::DestroySession() DestroySessionInternal(); } +#ifdef PICASIM_ANDROID +//---------------------------------------------------------------------------------------------------------------------- +// Find the EGLConfig matching the config ID of the given context. OpenXR's Android +// graphics binding needs the EGLConfig, which SDL does not expose directly. +static EGLConfig GetEGLConfigForContext(EGLDisplay display, EGLContext context) +{ + EGLint configId = 0; + if (!eglQueryContext(display, context, EGL_CONFIG_ID, &configId)) + { + TRACE_FILE_IF(ONCE_1) TRACE("GetEGLConfigForContext - eglQueryContext failed: 0x%X", eglGetError()); + return nullptr; + } + + // EGL_CONFIG_ID matching ignores all other attributes, so this selects exactly one config + const EGLint attribs[] = { EGL_CONFIG_ID, configId, EGL_NONE }; + EGLConfig config = nullptr; + EGLint numConfigs = 0; + if (eglChooseConfig(display, attribs, &config, 1, &numConfigs) && numConfigs == 1) + { + return config; + } + + // Fall back to scanning all configs for the matching ID + EGLint totalConfigs = 0; + if (!eglGetConfigs(display, nullptr, 0, &totalConfigs) || totalConfigs <= 0) + { + return nullptr; + } + std::vector configs(totalConfigs); + if (!eglGetConfigs(display, configs.data(), totalConfigs, &totalConfigs)) + { + return nullptr; + } + for (EGLint i = 0; i < totalConfigs; ++i) + { + EGLint id = 0; + if (eglGetConfigAttrib(display, configs[i], EGL_CONFIG_ID, &id) && id == configId) + { + return configs[i]; + } + } + return nullptr; +} +#endif + //---------------------------------------------------------------------------------------------------------------------- bool OpenXRRuntime::CreateSessionInternal() { @@ -374,6 +484,74 @@ bool OpenXRRuntime::CreateSessionInternal() return false; } + return true; +#elif defined(PICASIM_ANDROID) + // The EGL context created by SDL must be current on this thread + Window* window = gWindow; + if (!window || !window->GetSDLWindow()) + { + TRACE_FILE_IF(ONCE_1) TRACE("OpenXRRuntime::CreateSessionInternal - No window available"); + return false; + } + + // Get the OpenGL ES function to retrieve graphics requirements + PFN_xrGetOpenGLESGraphicsRequirementsKHR xrGetOpenGLESGraphicsRequirementsKHR = nullptr; + XR_CHECK(xrGetInstanceProcAddr(mInstance, "xrGetOpenGLESGraphicsRequirementsKHR", + (PFN_xrVoidFunction*)&xrGetOpenGLESGraphicsRequirementsKHR)); + + // Check graphics requirements + XrGraphicsRequirementsOpenGLESKHR graphicsReqs = {}; + graphicsReqs.type = XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR; + XR_CHECK(xrGetOpenGLESGraphicsRequirementsKHR(mInstance, mSystemId, &graphicsReqs)); + + TRACE_FILE_IF(ONCE_2) TRACE("OpenXRRuntime::CreateSessionInternal - OpenGL ES requirements: %d.%d to %d.%d", + XR_VERSION_MAJOR(graphicsReqs.minApiVersionSupported), + XR_VERSION_MINOR(graphicsReqs.minApiVersionSupported), + XR_VERSION_MAJOR(graphicsReqs.maxApiVersionSupported), + XR_VERSION_MINOR(graphicsReqs.maxApiVersionSupported)); + + // Get the EGL display/context that SDL made current on this thread + EGLDisplay display = eglGetCurrentDisplay(); + EGLContext context = eglGetCurrentContext(); + if (display == EGL_NO_DISPLAY || context == EGL_NO_CONTEXT) + { + TRACE_FILE_IF(ONCE_1) TRACE("OpenXRRuntime::CreateSessionInternal - No current EGL display/context"); + return false; + } + + EGLConfig config = GetEGLConfigForContext(display, context); + if (!config) + { + TRACE_FILE_IF(ONCE_1) TRACE("OpenXRRuntime::CreateSessionInternal - Failed to find EGL config"); + return false; + } + + // Create graphics binding + XrGraphicsBindingOpenGLESAndroidKHR graphicsBinding = {}; + graphicsBinding.type = XR_TYPE_GRAPHICS_BINDING_OPENGL_ES_ANDROID_KHR; + graphicsBinding.next = nullptr; + graphicsBinding.display = display; + graphicsBinding.config = config; + graphicsBinding.context = context; + + // Create session + XrSessionCreateInfo sessionInfo = {}; + sessionInfo.type = XR_TYPE_SESSION_CREATE_INFO; + sessionInfo.next = &graphicsBinding; + sessionInfo.createFlags = 0; + sessionInfo.systemId = mSystemId; + + XR_CHECK(xrCreateSession(mInstance, &sessionInfo, &mSession)); + + TRACE_FILE_IF(ONCE_1) TRACE("OpenXRRuntime::CreateSessionInternal - Session created"); + + // Create reference space + if (!CreateReferenceSpace()) + { + DestroySessionInternal(); + return false; + } + return true; #else TRACE_FILE_IF(ONCE_1) TRACE("OpenXRRuntime::CreateSessionInternal - Platform not supported yet"); @@ -894,7 +1072,11 @@ bool OpenXRRuntime::CreateSwapchainsInternal() mSwapchains[eye].images.resize(imageCount); for (auto& img : mSwapchains[eye].images) { +#ifdef PICASIM_ANDROID + img.type = XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR; +#else img.type = XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_KHR; +#endif img.next = nullptr; } diff --git a/source/Platform/OpenXRRuntime.h b/source/Platform/OpenXRRuntime.h index 03ca1251..83a1b619 100644 --- a/source/Platform/OpenXRRuntime.h +++ b/source/Platform/OpenXRRuntime.h @@ -6,11 +6,19 @@ #include "VRRuntime.h" // OpenXR headers +#ifdef PICASIM_ANDROID +#define XR_USE_GRAPHICS_API_OPENGL_ES +#define XR_USE_PLATFORM_ANDROID +#include +#include +#include +#else #define XR_USE_GRAPHICS_API_OPENGL #ifdef _WIN32 #define XR_USE_PLATFORM_WIN32 #include #endif +#endif #include #include @@ -134,7 +142,11 @@ class OpenXRRuntime : public VRRuntime XrSwapchain swapchain; int width; int height; +#ifdef PICASIM_ANDROID + std::vector images; +#else std::vector images; +#endif uint32_t currentImageIndex; bool imageAcquired; }; diff --git a/source/Platform/VRManager.cpp b/source/Platform/VRManager.cpp index 5770fbb2..e8936be1 100644 --- a/source/Platform/VRManager.cpp +++ b/source/Platform/VRManager.cpp @@ -8,7 +8,13 @@ #include "../Framework/Graphics.h" #include "../Framework/Trace.h" +#ifdef _WIN32 #include +#elif defined(PICASIM_ANDROID) || defined(__ANDROID__) +// Quest: GLES3 header - superset of the GLES2 declarations pulled in via +// Graphics.h, adds the sized internal formats used below. +#include +#endif //====================================================================================================================== // Static member initialization @@ -350,6 +356,12 @@ bool VRManager::CreateEyeFramebuffers() return false; } +#if defined(PICASIM_ANDROID) || defined(__ANDROID__) + // The eye framebuffers exist only as copy targets for the desktop mirror + // window. There is no desktop mirror on Quest, so skip the allocation - + // GetEyeColorTexture() returning 0 disables the mirror copies. + return true; +#else for (int eye = 0; eye < VR_EYE_COUNT; ++eye) { int width, height; @@ -395,6 +407,7 @@ bool VRManager::CreateEyeFramebuffers() } return true; +#endif // !Android } //====================================================================================================================== diff --git a/source/Platform/VRMenuRenderer.cpp b/source/Platform/VRMenuRenderer.cpp index bd736811..e0668ce2 100644 --- a/source/Platform/VRMenuRenderer.cpp +++ b/source/Platform/VRMenuRenderer.cpp @@ -10,7 +10,13 @@ #include "../Framework/ShaderManager.h" #include "../Framework/Shaders.h" +#ifdef _WIN32 #include +#elif defined(PICASIM_ANDROID) || defined(__ANDROID__) +// Quest: GLES3 header - superset of the GLES2 declarations pulled in via +// Graphics.h, adds glBlitFramebuffer and the sized internal formats used below. +#include +#endif #include #include "imgui.h" @@ -302,7 +308,11 @@ void VRMenuRenderer::EndMenuFrame(float uiScale, float overlayDistance) } } - // Blit menu FBO to the desktop window for mirror display +#if !defined(PICASIM_ANDROID) && !defined(__ANDROID__) + // Blit menu FBO to the desktop window for mirror display. + // No desktop mirror on Quest - the SDL window surface isn't the VR + // display, so skip the blit and the swap there (frame pacing comes from + // the OpenXR frame loop above). Window& window = Window::GetInstance(); int winWidth = window.GetWidth(); int winHeight = window.GetHeight(); @@ -315,6 +325,7 @@ void VRMenuRenderer::EndMenuFrame(float uiScale, float overlayDistance) glBindFramebuffer(GL_FRAMEBUFFER, 0); IwGxSwapBuffers(); +#endif // Reset so the OS cursor reappears on desktop after the menu loop exits. // During the menu loop, BeginMenuFrame() sets this to true each frame. diff --git a/source/Platform/Window.cpp b/source/Platform/Window.cpp index c9479b75..c1e82bb3 100644 --- a/source/Platform/Window.cpp +++ b/source/Platform/Window.cpp @@ -7,7 +7,13 @@ #ifdef _WIN32 #include #elif defined(PICASIM_ANDROID) || defined(__ANDROID__) +#if defined(PICASIM_QUEST) +// Quest: OpenGL ES 3.0 - a superset of the ES2 API used by the rest of the +// engine. The VR path needs ES3 entry points (glBlitFramebuffer etc.). +#include +#else #include +#endif #elif defined(PICASIM_IOS) #include #elif defined(PICASIM_MACOS) @@ -78,7 +84,14 @@ bool Window::Init(int width, int height, const char* title, bool fullscreen, int // Set OpenGL attributes before creating window // Request OpenGL 3.3 Core Profile for desktop, but fall back to 2.1 if needed -#if defined(PS_PLATFORM_ANDROID) || defined(PS_PLATFORM_IOS) +#if defined(PS_PLATFORM_ANDROID) && defined(PICASIM_QUEST) + // Quest: OpenGL ES 3.0 (needed for the VR render path - glBlitFramebuffer, + // glRenderbufferStorageMultisample, sized internal formats). ES3 is + // backward compatible with the ES2 usage in the rest of the engine. + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0); +#elif defined(PS_PLATFORM_ANDROID) || defined(PS_PLATFORM_IOS) // Mobile: OpenGL ES 2.0 SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES); SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 2); diff --git a/tools/android_smoke_test.sh b/tools/android_smoke_test.sh new file mode 100755 index 00000000..b377cfec --- /dev/null +++ b/tools/android_smoke_test.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Smoke test run inside the android-emulator-runner action: install the APK, +# launch the activity and verify the process is still alive shortly after. +# Kept in a script because the action executes its `script:` input line by +# line, which breaks multi-line shell constructs. +set -e + +adb install apk/app-phone-debug.apk +adb shell am start -n com.rowlhouse.picasim/.PicaSimActivity + +# Poll for up to 60 seconds for the process to appear +for i in $(seq 1 12); do + sleep 5 + if adb shell ps -A | grep -q com.rowlhouse.picasim; then + echo "PicaSim is running OK" + exit 0 + fi + echo "Waiting for PicaSim... ($((i*5))s)" +done + +echo "PicaSim did not start within 60 seconds" +adb logcat -d | grep -E "com.rowlhouse.picasim|SDL|FATAL|AndroidRuntime" | tail -100 +exit 1