Refactor QuestSurface and Build-Quest scripts for improved surface handling and performance logging

This commit is contained in:
iChris4 committed 2026-09-16 22:30:05 +02:00
1 parent e52227baf5
commit 75f0f5f26d
4 files changed
+87 -9

No files matched your search

+4 -2
View File
@@ -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
@@ -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
}
}
+42 -3
View File
@@ -22,6 +22,7 @@
#include <SDL3/SDL_thread.h>
#include <magic_enum.hpp>
#include "android_debug.hpp"
#include "system_info.hpp"
#include "tracy/Tracy.hpp"
@@ -1165,8 +1166,22 @@ std::shared_ptr<PresentationImage> 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<PresentationJob> 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<PresentationJob> 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<int64_t>((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<double> 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");
}
+24 -4
View File
@@ -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 <n>:<button>` | Presses `a`, `b`, `x`, `y`, `start`, `up`, `down`, `left` or `right` for 12 XR frames each time `<n>` changes |
| `debug.wiicompiled.fpslog 1` | Logs the game's rendered frame rate every 5 s. The compositor's `VrApi` log line gives headset FPS, `GPU%`, `CPU%` and app GPU time (`App=`) |
The injector makes headset tests possible with nobody wearing the headset.
Keep the display awake, drive the menus, then take a compositor screenshot:
@@ -262,10 +271,21 @@ screen is up, reach Grand Prix character select. A value left over from an
earlier run is ignored on the first read. Presses only land while the XR
session is `FOCUSED`.
Open measurement: the attract race advanced 603 game frames in about 14 s,
roughly 43 FPS against the game's 60, with an optimized build (`-O3`,
translated code `-O2`, `-mcpu=cortex-a77`). Profiling on the XR2 Gen 2 is the
first performance task.
Performance, measured 2026-09-16 on a 50cc Luigi Circuit start with the player
idle, over 40 s, with an optimized build (`-O3`, translated code `-O2`,
`-mcpu=cortex-a77`):
| Build | Game FPS | Headset FPS | App GPU time | GPU% |
| --- | --- | --- | --- | --- |
| Full-display mirror (4128x2208) | about 48.5 | 48.7 | 15.6 ms | 83 |
| 1280x720 surface, no present | about 48.6 | 49.3 | 14.9 ms | 81 |
Removing the mirror saved about 0.7 ms of GPU time per frame but did not raise
the game rate. Two leads remain. The game runs below 60 FPS with the GPU at
about 80%, and CPU and GPU clock levels sit at 4/3. The headset FPS also
follows the game rate instead of holding 72 Hz, so the pacing thread is not
repeating the last layer as it does on desktop. Both need profiling on the
XR2 Gen 2.
Verified on device since: the menus on the virtual screen, controller input
(the user has driven races), and an immersive Grand Prix start with all 12