diff --git a/android/Build-Quest.ps1 b/android/Build-Quest.ps1 index 77d3849..457c6e4 100644 --- a/android/Build-Quest.ps1 +++ b/android/Build-Quest.ps1 @@ -57,9 +57,11 @@ if (-not $CMakeDir -or -not (Test-Path (Join-Path $CMakeDir 'bin\cmake.exe'))) { } $sdkCMake = Get-ChildItem -Path (Join-Path $sdkRoot 'cmake') -Directory | Sort-Object Name -Descending | Select-Object -First 1 if ($sdkCMake) { $env:PATH = (Join-Path $sdkCMake.FullName 'bin') + ';' + $env:PATH } +# Parenthesised: PowerShell's comma binds tighter than +, which would join both +# properties into a single line. $localProperties = @( - 'sdk.dir=' + $sdkRoot.Replace('\', '\\'), - 'cmake.dir=' + $CMakeDir.Replace('\', '\\') + ('sdk.dir=' + $sdkRoot.Replace('\', '\\')), + ('cmake.dir=' + $CMakeDir.Replace('\', '\\')) ) Set-Content -Path (Join-Path $root 'local.properties') -Value $localProperties -Encoding ascii diff --git a/android/app/src/main/java/org/wiicompiled/quest/QuestSurface.kt b/android/app/src/main/java/org/wiicompiled/quest/QuestSurface.kt index 67fc565..8df166a 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/QuestSurface.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/QuestSurface.kt @@ -12,9 +12,21 @@ import org.libsdl.app.SDLSurface * activity mutex internal to libSDL3.so, so this subclass brackets SDL's * handling instead: begin takes Aurora's surface lock and pauses presentation, * end releases it and reports whether SDL left the surface ready. + * + * The surface buffer is also pinned to [BUFFER_WIDTH] x [BUFFER_HEIGHT]. An + * immersive app's Android surface is never shown in the headset, yet SDL sizes + * it to the whole display (4128x2208 on a Quest 3). Aurora sizes its + * presentation snapshot from it, and in menus that snapshot is the image the + * virtual screen shows in each eye. The screen spans about 900 eye pixels at + * the default HUD size, so 1280x720 keeps menus sharp at a small fraction of + * the cost. */ class QuestSurface(context: Context) : SDLSurface(context) { + init { + holder.setFixedSize(BUFFER_WIDTH, BUFFER_HEIGHT) + } + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { nativeBeginSurfaceMutation() try { @@ -35,4 +47,9 @@ class QuestSurface(context: Context) : SDLSurface(context) { private external fun nativeBeginSurfaceMutation() private external fun nativeEndSurfaceMutation(ready: Boolean) + + private companion object { + const val BUFFER_WIDTH = 1280 + const val BUFFER_HEIGHT = 720 + } } diff --git a/aurora-main/lib/aurora.cpp b/aurora-main/lib/aurora.cpp index 9f64af7..78af7ae 100644 --- a/aurora-main/lib/aurora.cpp +++ b/aurora-main/lib/aurora.cpp @@ -22,6 +22,7 @@ #include #include +#include "android_debug.hpp" #include "system_info.hpp" #include "tracy/Tracy.hpp" @@ -1165,8 +1166,22 @@ std::shared_ptr acquire_presentation_image(size_t slot, uint3 return image; } +// A standalone headset never shows the app's Android surface while OpenXR drives the display, so presenting to it +// (and copying the mirror image the desktop would show) is pure GPU cost there. Presentation snapshots are still +// encoded: in menus the virtual-screen eyes are built from them. Desktop keeps its window mirror. +bool headset_owns_display() noexcept { +#if defined(__ANDROID__) + return stereo_frame_provider_active(); +#else + return false; +#endif +} + bool present_presentation_job(const PresentationJob& job) { ZoneScoped; + if (headset_owns_display()) { + return false; + } const auto submissionStarted = PresentClock::now(); // Keep the threshold far above compositor and scheduling jitter. The timings below separate a // real surface stall from a bad deadline, and only the former needs a rebuild. @@ -1857,8 +1872,12 @@ std::vector encode_sealed_frame(gfx::SealedFrame& sealedFrame, const bool immersiveReplay = stereoOutput && ctx.immersiveStereoPrepared; // One choice for the whole group: a slot showing the mono view next to slots // mirroring an eye would strobe between two different images. - const MirrorPlan mirrorPlan = g_stereoMirrorState.Resolve( - gfx::get_stereo_mirror_view(), stereo_frame_provider_active(), stereoOutput, immersiveReplay); + // Nothing presents the snapshot on a headset, so it only needs the clear (see headset_owns_display). + const bool headsetOnly = headset_owns_display(); + const MirrorPlan mirrorPlan = + headsetOnly ? MirrorPlan::Black + : g_stereoMirrorState.Resolve(gfx::get_stereo_mirror_view(), stereo_frame_provider_active(), + stereoOutput, immersiveReplay); // Each slot is submitted as soon as it is encoded, so the GPU starts slot 0 while slot 1 is still // recording. Queue order preserves the ordering the single batched buffer gave. @@ -1958,7 +1977,7 @@ std::vector encode_sealed_frame(gfx::SealedFrame& sealedFrame, for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) { encode_virtual_screen_eye(encoder, completedMono, eye); } - if (mirrorPlan == MirrorPlan::Black) { + if (mirrorPlan == MirrorPlan::Black && !headsetOnly) { encode_presentation_snapshot(encoder, ctx.presentSource, *finalImage, true, MirrorPlan::Black); } } @@ -2077,6 +2096,26 @@ void record_frame_telemetry() { TracyPlot("aurora: mainThreadCpuUsPerFrame", static_cast((threadCpu100ns - previousThreadCpu100ns) / 10)); previousProcessCpu100ns = processCpu100ns; previousThreadCpu100ns = threadCpu100ns; +#endif +#if defined(__ANDROID__) + { + // `adb shell setprop debug.wiicompiled.fpslog 1` before launch logs the game's rendered frame rate every five + // seconds. The headset compositor's own log (logcat tag VrApi) repeats frames, so it cannot show this. + static const bool fpsLog = android_debug::property_int("debug.wiicompiled.fpslog", 0) == 1; + if (fpsLog) { + static auto windowStart = std::chrono::steady_clock::now(); + static uint32_t windowFrames = 0; + ++windowFrames; + const auto now = std::chrono::steady_clock::now(); + const std::chrono::duration elapsed = now - windowStart; + if (elapsed.count() >= 5.0) { + Log.info("Game frame rate {:.1f} FPS ({} frames in {:.2f} s)", windowFrames / elapsed.count(), windowFrames, + elapsed.count()); + windowStart = now; + windowFrames = 0; + } + } + } #endif FrameMarkNamed("Aurora frame"); } diff --git a/docs/quest-port.md b/docs/quest-port.md index 16f34f9..d5b33e5 100644 --- a/docs/quest-port.md +++ b/docs/quest-port.md @@ -116,6 +116,14 @@ menu → Start. Bindings are suggested for `oculus/touch_controller` and `QuestSurface` subclass brackets SDL's `surfaceChanged`/`surfaceDestroyed` with `aurora_android_begin/end_surface_mutation` (`aurora/android.h`), and Aurora's `SurfaceLock` owns its own recursive mutex. This is KartPad's design. +- **No full-display mirror.** SDL sizes the app's Android surface to the whole + display (4128x2208 on a Quest 3), which nobody sees while OpenXR drives the + headset. `QuestSurface` pins the surface buffer to 1280x720. That size also + sets Aurora's presentation snapshot, which is the image the menu virtual + screen shows in each eye, where it spans about 900 pixels. While a stereo + provider is registered on Android, Aurora skips the surface present and the + desktop mirror copy (`headset_owns_display` in `lib/aurora.cpp`). The game's + own render size is unaffected: at `resolution_multiplier = 1` it is 640x528. - **JNI only on the real thread stack.** Guest threads run on libco stacks inside the SDL thread, and SDL's Android event pump can reach Java (joystick polling, HIDAPI). ART binds JNI transitions to the thread's real stack, so @@ -246,6 +254,7 @@ the app: | `debug.wiicompiled.vtxpad 0` | Turns the stride padding off, to re-check a driver update | | `debug.wiicompiled.validation 1` | Keeps WebGPU validation and robustness on in release builds | | `debug.wiicompiled.inject :