Skip to content

Commit f97199c

Browse files
add android-openxr support
1 parent f5cfc47 commit f97199c

File tree

9 files changed

+173
-22
lines changed

9 files changed

+173
-22
lines changed

AndroidManifest.openxr.xml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3+
package="com.example.wgpu_android_xr">
4+
5+
<uses-feature android:glEsVersion="0x00030000" android:required="true" />
6+
<uses-feature android:name="android.hardware.vr.headtracking" android:required="true" />
7+
<uses-permission android:name="android.permission.INTERNET" />
8+
9+
<application
10+
android:allowBackup="false"
11+
android:icon="@mipmap/ic_launcher"
12+
android:label="Wgpu OpenXR Example"
13+
android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen"
14+
android:hasCode="false">
15+
16+
<meta-data android:name="com.oculus.supportedDevices" android:value="quest2|questpro|quest3|quest3s" />
17+
18+
<activity
19+
android:name="android.app.NativeActivity"
20+
android:exported="true"
21+
android:excludeFromRecents="true"
22+
android:configChanges="density|keyboard|keyboardHidden|navigation|orientation|screenLayout|screenSize|uiMode"
23+
android:launchMode="singleTask"
24+
android:screenOrientation="landscape">
25+
<meta-data
26+
android:name="android.app.lib_name"
27+
android:value="app_core" />
28+
<intent-filter>
29+
<action android:name="android.intent.action.MAIN" />
30+
<category android:name="android.intent.category.LAUNCHER" />
31+
<category android:name="com.oculus.intent.category.VR" />
32+
</intent-filter>
33+
</activity>
34+
</application>
35+
</manifest>

Cargo.lock

Lines changed: 1 addition & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ nalgebra-glm = { version = "0.20.0", features = [
1919
"convert-bytemuck",
2020
"serde-serialize",
2121
] }
22-
openxr = { version = "0.19", features = ["static", "loaded"], optional = true }
22+
openxr = { version = "0.19", features = ["loaded"], optional = true }
2323
web-time = "1.1.0"
2424
wgpu = { version = "27.0.1", default-features = false }
2525
winit = "0.30.12"
@@ -43,6 +43,7 @@ wasm-bindgen-futures = "0.4.56"
4343
android-activity = { version = "0.6", features = ["native-activity"] }
4444
android_logger = "0.15"
4545
egui-winit = { version = "0.33.3", default-features = false }
46+
ndk-context = "0.1"
4647
ndk-sys = "0.6"
4748

4849
[features]
@@ -51,3 +52,4 @@ openxr = ["dep:openxr", "dep:ash", "dep:wgpu-hal", "dep:gpu-allocator"]
5152
webgl = ["wgpu/webgl"]
5253
webgpu = ["wgpu/webgpu"]
5354
android = ["wgpu/vulkan"]
55+
android-openxr = ["android", "openxr"]

README.md

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ Other languages (experimental):
2424
| WebGL | `trunk serve --features webgl --open` |
2525
| Android | `just run-android DEVICE_ID` |
2626
| Steam Deck | `just build-steamdeck && just deploy-steamdeck` |
27-
| OpenXR VR | `just run-openxr` |
27+
| OpenXR VR (Desktop) | `just run-openxr` |
28+
| OpenXR VR (Quest) | `just build-android-openxr` |
2829

2930
## Platform Setup
3031

@@ -75,8 +76,9 @@ The build uses `--features android` which enables wgpu's Vulkan backend.
7576
<summary><strong>Additional Android Commands</strong></summary>
7677

7778
```bash
78-
just build-android # Build only
79+
just build-android # Build only (windowed app)
7980
just build-android-all # Build for arm64 and x64
81+
just build-android-openxr # Build for Meta Quest VR
8082
just install-android DEVICE_ID # Install without running
8183
just connect-android IP:PORT # Connect over wireless ADB
8284
just list-android # List connected devices
@@ -108,16 +110,50 @@ cd ~/Downloads && ./app
108110

109111
The `Cross.toml` file configures system libraries for graphics and windowing support.
110112

111-
### OpenXR VR Mode
113+
### OpenXR VR Mode (Desktop)
112114

113-
Renders the spinning triangle with an infinite grid, procedural skybox, and hand tracking in VR.
115+
Renders the spinning triangle with an infinite grid, procedural skybox, and hand tracking in VR via PCVR streaming.
114116

115117
**Setup:**
116118
1. Install [SteamVR](https://store.steampowered.com/app/250820/SteamVR/)
117119
2. Install [Virtual Desktop](https://www.vrdesktop.net/) or another OpenXR-compatible runtime
118120
3. Start Virtual Desktop and stream your desktop to your VR headset
119121
4. Run `just run-openxr` on your desktop
120122

123+
### OpenXR VR Mode (Meta Quest)
124+
125+
Native standalone VR for Meta Quest 2, Quest Pro, Quest 3, and Quest 3S.
126+
127+
**Prerequisites:**
128+
- All Android prerequisites (see above)
129+
- Meta Quest device with Developer Mode enabled
130+
131+
**Build:**
132+
```bash
133+
just build-android-openxr
134+
```
135+
136+
This produces an APK at `target/x/release/android/app.apk`.
137+
138+
**Install on Quest:**
139+
```bash
140+
adb install -r target/x/release/android/app.apk
141+
```
142+
143+
Or use [SideQuest](https://sidequestvr.com) to drag and drop the APK onto your Quest.
144+
145+
The app appears in your Quest library under "Unknown Sources".
146+
147+
<details>
148+
<summary><strong>Technical Notes</strong></summary>
149+
150+
- Uses the `android-openxr` feature which combines `android` and `openxr` features
151+
- Bundles Meta's OpenXR loader from `libs/arm64-v8a/libopenxr_loader.so`
152+
- Manifest includes `com.oculus.intent.category.VR` for proper VR app handling
153+
- Supports Quest hand tracking and controller input
154+
- Requires `manifest.yaml` with `runtime_libs` configuration for library bundling
155+
</details>
156+
121157
## Screenshots
122158

123159
<img width="1665" height="1287" alt="webgl" src="https://github.com/user-attachments/assets/d8771e73-4b0b-459a-baf2-5ce1f79f943e" />

justfile

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,22 @@ build-android-all:
107107
x build --release --platform android --arch x64 --features android
108108
Copy-Item -Force target/x/release/android/x64/cargo/x86_64-linux-android/release/libapp_core.so target/x/release/android/x64/cargo/x86_64-linux-android/release/libapp.so
109109

110+
# Build the app for Android with OpenXR (arm64) for Meta Quest
111+
[unix]
112+
build-android-openxr:
113+
mv AndroidManifest.xml AndroidManifest.xml.bak
114+
cp AndroidManifest.openxr.xml AndroidManifest.xml
115+
x build --release --platform android --arch arm64 --features android-openxr || (mv AndroidManifest.xml.bak AndroidManifest.xml && exit 1)
116+
mv AndroidManifest.xml.bak AndroidManifest.xml
117+
cp -f target/x/release/android/arm64/cargo/aarch64-linux-android/release/libapp_core.so target/x/release/android/arm64/cargo/aarch64-linux-android/release/libapp.so
118+
119+
[windows]
120+
build-android-openxr:
121+
Move-Item AndroidManifest.xml AndroidManifest.xml.bak
122+
Copy-Item AndroidManifest.openxr.xml AndroidManifest.xml
123+
x build --release --platform android --arch arm64 --features android-openxr; $r = $LASTEXITCODE; Move-Item -Force AndroidManifest.xml.bak AndroidManifest.xml; if ($r -ne 0) { exit $r }
124+
Copy-Item -Force target/x/release/android/arm64/cargo/aarch64-linux-android/release/libapp_core.so target/x/release/android/arm64/cargo/aarch64-linux-android/release/libapp.so
125+
110126
# Install the app on connected Android device
111127
install-android device:
112128
x build --release --arch arm64 --features android --device adb:{{device}}

libs/arm64-v8a/libopenxr_loader.so

7.87 MB
Binary file not shown.

manifest.yaml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
android:
2+
runtime_libs:
3+
- libs
4+
manifest:
5+
package: com.example.wgpu_android_xr
6+
uses_feature:
7+
- name: android.hardware.vulkan.level
8+
required: true
9+
version: 1
10+
application:
11+
meta_data:
12+
- name: com.oculus.supportedDevices
13+
value: quest2|questpro|quest3|quest3s
14+
activities:
15+
- name: android.app.NativeActivity
16+
exported: true
17+
orientation: landscape
18+
config_changes: "orientation|keyboardHidden|screenSize|screenLayout|uiMode|density"
19+
meta_data:
20+
- name: android.app.lib_name
21+
value: app
22+
intent_filters:
23+
- actions:
24+
- android.intent.action.MAIN
25+
categories:
26+
- android.intent.category.LAUNCHER
27+
- com.oculus.intent.category.VR

src/lib.rs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ use winit::{
2121
window::{Theme, Window},
2222
};
2323

24-
#[cfg(target_os = "android")]
24+
#[cfg(all(target_os = "android", not(feature = "openxr")))]
2525
use winit::platform::android::EventLoopBuilderExtAndroid;
2626

27-
#[cfg(target_os = "android")]
27+
#[cfg(all(target_os = "android", not(feature = "openxr")))]
2828
#[unsafe(no_mangle)]
2929
fn android_main(app: AndroidApp) {
3030
android_logger::init_once(
@@ -43,6 +43,31 @@ fn android_main(app: AndroidApp) {
4343
.expect("Failed to run app!");
4444
}
4545

46+
#[cfg(all(target_os = "android", feature = "openxr"))]
47+
#[unsafe(no_mangle)]
48+
fn android_main(app: AndroidApp) {
49+
android_logger::init_once(
50+
android_logger::Config::default().with_max_level(log::LevelFilter::Info),
51+
);
52+
53+
let handle = std::thread::spawn(|| {
54+
run_xr().expect("XR session failed");
55+
});
56+
57+
loop {
58+
app.poll_events(Some(std::time::Duration::from_millis(100)), |event| {
59+
if let android_activity::PollEvent::Main(android_activity::MainEvent::Destroy) = event {
60+
std::process::exit(0);
61+
}
62+
});
63+
if handle.is_finished() {
64+
break;
65+
}
66+
}
67+
68+
handle.join().expect("XR thread panicked");
69+
}
70+
4671
#[derive(Default)]
4772
pub struct App {
4873
window: Option<Arc<Window>>,

src/xr.rs

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,6 @@ pub struct XrContext {
4848
_views: Vec<xr::ViewConfigurationView>,
4949
action_set: xr::ActionSet,
5050
move_action: xr::Action<xr::Vector2f>,
51-
left_hand_action: xr::Action<xr::Posef>,
52-
right_hand_action: xr::Action<xr::Posef>,
5351
left_trigger_action: xr::Action<f32>,
5452
right_trigger_action: xr::Action<f32>,
5553
left_hand_space: xr::Space,
@@ -64,15 +62,24 @@ pub struct XrContext {
6462
sky_uniform_buffer: wgpu::Buffer,
6563
sky_bind_group: wgpu::BindGroup,
6664
sky_pipeline: wgpu::RenderPipeline,
65+
session_running: bool,
6766
}
6867

6968
impl XrContext {
7069
pub fn new() -> Result<(Self, wgpu::Device, wgpu::Queue), Box<dyn std::error::Error>> {
71-
let xr_entry = xr::Entry::linked();
70+
let xr_entry = unsafe { xr::Entry::load()? };
71+
72+
#[cfg(target_os = "android")]
73+
xr_entry.initialize_android_loader()?;
7274

7375
let mut required_extensions = xr::ExtensionSet::default();
7476
required_extensions.khr_vulkan_enable2 = true;
7577

78+
#[cfg(target_os = "android")]
79+
{
80+
required_extensions.khr_android_create_instance = true;
81+
}
82+
7683
let xr_instance = xr_entry.create_instance(
7784
&xr::ApplicationInfo {
7885
application_name: "wgpu-xr-example",
@@ -596,8 +603,6 @@ impl XrContext {
596603
_views: views,
597604
action_set,
598605
move_action,
599-
left_hand_action,
600-
right_hand_action,
601606
left_trigger_action,
602607
right_trigger_action,
603608
left_hand_space,
@@ -612,6 +617,7 @@ impl XrContext {
612617
sky_uniform_buffer,
613618
sky_bind_group,
614619
sky_pipeline,
620+
session_running: false,
615621
},
616622
wgpu_device,
617623
wgpu_queue,
@@ -628,13 +634,16 @@ impl XrContext {
628634
xr::SessionState::READY => {
629635
self.session
630636
.begin(xr::ViewConfigurationType::PRIMARY_STEREO)?;
637+
self.session_running = true;
631638
log::info!("XR Session started");
632639
}
633640
xr::SessionState::STOPPING => {
641+
self.session_running = false;
634642
self.session.end()?;
635643
log::info!("XR Session ended");
636644
}
637645
xr::SessionState::EXITING | xr::SessionState::LOSS_PENDING => {
646+
self.session_running = false;
638647
log::info!("XR Session exiting");
639648
return Ok(false);
640649
}
@@ -651,6 +660,10 @@ impl XrContext {
651660
Ok(true)
652661
}
653662

663+
pub fn is_session_running(&self) -> bool {
664+
self.session_running
665+
}
666+
654667
pub fn wait_frame(&mut self) -> Result<xr::FrameState, Box<dyn std::error::Error>> {
655668
Ok(self.frame_wait.wait()?)
656669
}
@@ -1200,6 +1213,7 @@ impl XrContext {
12001213
}
12011214

12021215
pub fn run_xr() -> Result<(), Box<dyn std::error::Error>> {
1216+
#[cfg(not(target_os = "android"))]
12031217
env_logger::init();
12041218
log::info!("Initializing OpenXR mode");
12051219

@@ -1215,6 +1229,11 @@ pub fn run_xr() -> Result<(), Box<dyn std::error::Error>> {
12151229
break;
12161230
}
12171231

1232+
if !xr_context.is_session_running() {
1233+
std::thread::sleep(std::time::Duration::from_millis(10));
1234+
continue;
1235+
}
1236+
12181237
let now = Instant::now();
12191238
let delta_time = (now - last_render_time).as_secs_f32();
12201239
last_render_time = now;

0 commit comments

Comments
 (0)