Render only the window in the Quest's immersive window

- The pacing thread aims each immersive window eye through the window itself (AimEyesThroughWindow):
  it keeps its position but looks square-on at the window's plane through an off-axis frustum just
  around it, so the eye image is the window, at the display's pixel density (about 680x380 per eye
  at render_scale 0.8 instead of 1344x1408), with a two-pixel border the mask leaves transparent.
  The frame's views carry that pose and field of view to the projection layer.
- vulkan_interop.cpp copies an eye smaller than its AHardwareBuffer into the buffer's corner, and
  the Quest layer's imageRect is the rendered part of the swapchain image.
- These eyes are not foveated: their field of view follows the head, which would rebuild the
  density map every frame.
- Quest only (kWindowShapedEyesSupported); the PC backends copy whole eyes and keep masking them.
  debug.wiicompiled.window_eyes 0 renders them whole and masked again for A/B timing.
- Quest 3, paused Retro Rewind race, render_scale 1.0: Immersive window runs the GPU at level 1
  (456 MHz, app GPU 11.0 ms, eyes 8.4 ms) where Immersive needs level 3 (599-640 MHz, 13.4 ms,
  10.3 ms), about 42% fewer GPU cycles. No edge artifacts, image as sharp. Docs: OPENXR.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
iChris4andClaude Opus 5.5 committed 2026-09-24 22:29:28 +02:00
1 parent 1cf9389d69
commit c990c595f0
6 files changed
+188 -23

No files matched your search

+43 -10
View File
@@ -651,13 +651,47 @@ that keeps the colour inside the window with alpha 1 and leaves transparent blac
one-pixel ramp at the edge. The triangle carries, at each corner, where that pixel's ray meets the
window's plane in homogeneous window coordinates (`stereo_replay::window_mask`), which interpolate
exactly across the image. With `single_pass_eyes` it is drawn in the eye's own last render pass, so it
adds no pass and no tile load; otherwise it takes a pass of its own, as the cockpit overlay does. The
game still renders the whole eye: only its alpha changes. On the Quest the backend submits the
passthrough layer, then the projection layer with `XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT`
(premultiplied alpha), then the settings panel. The flag travels with the packet, so the eyes Aurora
masked and the layer that blends them always belong to the same frame, and switching the race view
mid-race needs no safety generation: presentation stays `ImmersiveRace`. The PC backends keep their
projection layer opaque, so the window is surrounded by black there.
adds no pass and no tile load; otherwise it takes a pass of its own, as the cockpit overlay does. On
the Quest the backend submits the passthrough layer, then the projection layer with
`XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT` (premultiplied alpha), then the settings panel.
The flag travels with the packet, so the eyes Aurora masked and the layer that blends them always
belong to the same frame, and switching the race view mid-race needs no safety generation:
presentation stays `ImmersiveRace`. The PC backends keep their projection layer opaque, so the window
is surrounded by black there.
**Only the window is rendered on the Quest.** Rather than render the whole eye and mask most of it,
the pacing thread aims each eye through the window itself (`AimEyesThroughWindow` in
`openxr_integration.cpp`): the eye keeps its position but looks square-on at the window's plane, through
an off-axis frustum just around the window, so the image is the window. It keeps the display's pixel
density (the swapchain's pixels per unit of tangent as the eye is located) at the window's size seen
from the race origin, which is fixed while the window's geometry is: about 680 x 380 per eye with the
default window at `render_scale` 0.8, against 1344 x 1408 for a whole eye. A two-pixel border around
the window is left transparent by the mask. The frame's views carry that pose and field of view to the
projection layer, whose `imageRect` is the rendered part of the swapchain image, and the compositor
reprojects it like any other. Aurora copies the smaller eye into the corner of the shared buffer
(`vulkan_interop.cpp`), and does not foveate these eyes: their field of view follows the head, which
would rebuild the density map every frame, and they are small already. The PC backends copy whole eyes
into the swapchain, so there the window's eyes stay full size and masked.
`adb shell setprop debug.wiicompiled.window_eyes 0` renders them whole and masked on the Quest too, to
compare the two within one session.
Measured on a Quest 3 with a Retro Rewind race paused (the same 439 draw calls every frame,
`render_scale` 1.0, 60 FPS throughout), switching the race view from the headset panel:
| Race view | GPU level and clock | App GPU per frame | Both eyes | GPU load | Compositor |
| --- | --- | --- | --- | --- | --- |
| Immersive window | 1, 456 MHz | 11.0 ms | 8.4 ms | 82% | 1.6 ms |
| Immersive | 3, 599 to 640 MHz | 13.4 ms | 10.3 ms | 88% | 0.7 ms |
The headset raised the GPU's level for the fully immersive race and it still took longer: in clock
cycles the window's frame is about 42% cheaper (5.0 against 8.6 million), which lets the Quest keep the
GPU at its lowest level. The compositor's extra time is the passthrough. During a race at `render_scale`
0.8, switching `debug.wiicompiled.window_eyes`, both eyes took 6.3 to 7.5 ms through the window against
9.1 to 9.5 ms whole and masked at similar draw counts (the compositor's `SF` field read 0.31 against
0.80), with the clock wandering between 350 and 600 MHz. The saving is smaller than the eye's pixels
(about 13% of a whole eye's) would suggest because much of an eye's cost is the geometry of every draw,
which each eye still processes; it grows with `render_scale`. Read the VrApi line's
`CPU4/GPU=<levels>,<clocks>MHz` before comparing two timings.
`gx_fifo_tests` covers the window's geometry (its corners through an asymmetric eye frustum, its
agreement with the HUD's placement, an eye turned away or beyond the window, a sideways step), and
@@ -936,9 +970,8 @@ ends, including mid-frame flushes, so live setting changes cannot invalidate pen
analog grips (Touch); the simple controller profile cannot grab.
- The headset settings panel has no laser beam, only the cursor on the panel itself, and text fields
cannot be typed into without a keyboard.
- The immersive window renders the whole eye and only masks it, so it costs what a fully immersive
race costs, plus the passthrough's compositing. Hands and a separate VR wheel are masked with the
rest of the eye, so outside the window they are not seen.
- On the PC the immersive window renders the whole eye and only masks it, so it costs what a fully
immersive race costs. Hands and a separate VR wheel are seen only through the window.
- The desktop window remains available as a mirror/fallback.
OpenXR diagnostics are written to the normal run log under
+3 -1
View File
@@ -841,7 +841,9 @@ gfx::StereoReplayFrame make_stereo_replay_frame(const AuroraStereoFrame& input,
.msaaSamples = webgpu::g_graphicsConfig.msaaSamples,
.depthFormat = owned.depth.format,
};
if (input.mode == AURORA_STEREO_FRAME_IMMERSIVE_REPLAY) {
// Not the immersive window's eyes: the host may aim them through the window, whose field of
// view then changes with every head movement and would rebuild the density map each frame.
if (input.mode == AURORA_STEREO_FRAME_IMMERSIVE_REPLAY && !input.window) {
view.target.foveatedColorView = foveated_eye_view(eye, input.eyes[eye]);
}
std::memcpy(&view.projection, input.eyes[eye].projection, sizeof(view.projection));
+6 -4
View File
@@ -255,11 +255,13 @@ public:
}
private:
// An eye may be smaller than its buffer (the immersive window's eyes are the window only): it is
// copied into the buffer's top-left corner, and the OpenXR side shows just that rectangle.
Import* EnsureImport(uint32_t eye, const stereo::EyeImage& source) noexcept {
const auto& target = m_targets[eye];
if (source.texture == nullptr || source.format != m_auroraFormat ||
source.size.width != target.width || source.size.height != target.height) {
Log.error("Stereo image {} does not match its OpenXR Vulkan target ({}x{} vs {}x{})", eye,
if (source.texture == nullptr || source.format != m_auroraFormat || source.size.width == 0 ||
source.size.height == 0 || source.size.width > target.width || source.size.height > target.height) {
Log.error("Stereo image {} does not fit its OpenXR Vulkan target ({}x{} in {}x{})", eye,
source.size.width, source.size.height, target.width, target.height);
return nullptr;
}
@@ -421,7 +423,7 @@ private:
.origin = {},
.aspect = wgpu::TextureAspect::All,
};
const wgpu::Extent3D extent{import.width, import.height, 1};
const wgpu::Extent3D extent{sources[eye].size.width, sources[eye].size.height, 1};
encoder.CopyTextureToTexture(&source, &destination, &extent);
m_encodedImports[eye] = imports[eye];
}
+4
View File
@@ -136,6 +136,10 @@ struct OpenXRPresentation {
// the race's 2D-layer screen (AuroraStereoFrame::window), so the projection
// layer is blended by its alpha over whatever is under it.
bool immersive_window = false;
// The immersive window's eyes were aimed through the window itself, so each
// is only the window: its image is render_width x render_height, the
// top-left part of the eye's swapchain image the layer shows.
bool window_eyes = false;
// Show the room through the headset's cameras around the virtual screen or
// the immersive window (OpenXRPassthrough). Taken when the presentation is
+128 -6
View File
@@ -87,6 +87,16 @@ using GraphicsBackend = OpenXRVulkanBackend;
inline constexpr const char* kGraphicsBackendName = "Vulkan";
#endif
// Whether the immersive window's eyes can be aimed through the window, so that only the window is
// rendered: the backend has to show just the part of each eye image they fill. The Quest's shared
// buffers and projection layer do; the PC backends copy whole eyes, so there the window's eyes stay
// full size and are only masked.
#if defined(__ANDROID__)
inline constexpr bool kWindowShapedEyesSupported = true;
#else
inline constexpr bool kWindowShapedEyesSupported = false;
#endif
struct Quaternion {
float x = 0.0f;
float y = 0.0f;
@@ -866,6 +876,7 @@ private:
aurora_set_stereo_panel_layer(panel_layer);
PollEyePassesOverride();
PollFoveationOverride();
PollWindowEyesOverride();
presentation.panel.requested = panel_layer && OpenXRSettingsPanelOpen();
// Pipeline caches are stored where their stall is least visible: once when a race
@@ -968,8 +979,7 @@ private:
// configured diorama scale. Head translation and IPD are the
// only things this multiplies, so a one-frame disagreement with
// the camera's own switch is not observable.
BuildPublishedFrame(frame, immersive, policy.EffectiveUnitsPerMeter(),
policy.content_tag);
BuildPublishedFrame(frame, immersive, policy);
diagnostics::OnPacketPublished();
published_.store(&published_frame_, std::memory_order_release);
}
@@ -1131,7 +1141,7 @@ private:
{
const diagnostics::ScopedStage publish_timer(diagnostics::Stage::Publish);
std::lock_guard lock(published_mutex_);
BuildPublishedFrame(packet, immersive, policy.EffectiveUnitsPerMeter(), policy.content_tag);
BuildPublishedFrame(packet, immersive, policy);
diagnostics::OnPacketPublished();
published_.store(&published_frame_, std::memory_order_release);
}
@@ -1246,8 +1256,11 @@ private:
return true;
}
void BuildPublishedFrame(const OpenXRBackendFrame& source, bool immersive,
float units_per_meter, uint64_t content_tag) noexcept {
// Also aims the immersive window's eyes through the window, in `source` itself, so that the layer
// later built from it shows the eyes as they were rendered.
void BuildPublishedFrame(OpenXRBackendFrame& source, bool immersive, const MkwVRPolicySnapshot& policy) noexcept {
const float units_per_meter = policy.EffectiveUnitsPerMeter();
const uint64_t content_tag = policy.content_tag;
ApplyPendingReferenceSpaceChange(source.xr_frame);
auto& destination = published_frame_.frame;
destination = {};
@@ -1279,6 +1292,13 @@ private:
base_position_valid_ = true;
}
last_immersive_ = true;
if (source.presentation.immersive_window && WindowShapedEyes()) {
source.presentation.window_eyes = AimEyesThroughWindow(source, policy);
for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) {
destination.eyes[eye].width = source.render_width[eye];
destination.eyes[eye].height = source.render_height[eye];
}
}
// Read once so both eyes are built from the same angle even if the
// settings slider moves between them.
const float lean_back_radians =
@@ -1336,6 +1356,83 @@ private:
}
}
// The immersive window's eyes, aimed through the window itself: each keeps its position but looks
// square-on at the window's plane, through an off-axis frustum just around the window, so its
// image is the window and nothing outside it is rendered. The image keeps the display's pixel
// density at the window's size seen from the race origin (fixed while the window's geometry is),
// plus a two-pixel border that Aurora's mask leaves transparent, so the compositor finds nothing
// at the image's edge. The frame's views carry this pose and field of view to the projection
// layer, which the compositor reprojects like any other. False, with nothing changed, when the
// window cannot be placed or an eye is not in front of it.
bool AimEyesThroughWindow(OpenXRBackendFrame& frame, const MkwVRPolicySnapshot& policy) const noexcept {
const float distance = policy.config.hud_distance_meters;
const float half_width = 0.5f * policy.config.hud_width_meters;
XrPosef window{};
if (!(half_width > 0.0f) || !(distance > 0.0f) || !RaceScreenPose(frame, policy, window)) {
return false;
}
float picture_aspect = 0.0f;
float snapshot_aspect = 0.0f;
if (!aurora_get_stereo_screen_aspects(&picture_aspect, &snapshot_aspect) || !(picture_aspect > 0.0f)) {
picture_aspect = 16.0f / 9.0f; // stereo_hud_screen's own fallback
}
const float half_height = half_width / picture_aspect;
const Quaternion to_window =
Conjugate(Normalize({window.orientation.x, window.orientation.y, window.orientation.z, window.orientation.w}));
constexpr uint32_t kBorder = 2;
std::array<XrFovf, kOpenXREyeCount> fov{};
std::array<uint32_t, kOpenXREyeCount> width{};
std::array<uint32_t, kOpenXREyeCount> height{};
for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) {
const XrView& view = frame.xr_frame.views[eye];
// The eye in the window's frame, whose +Z faces the viewer.
const std::array<float, 3> at = Rotate(
to_window, {view.pose.position.x - window.position.x, view.pose.position.y - window.position.y,
view.pose.position.z - window.position.z});
const float located_x = std::tan(view.fov.angleRight) - std::tan(view.fov.angleLeft);
const float located_y = std::tan(view.fov.angleUp) - std::tan(view.fov.angleDown);
if (!(at[2] > 0.05f) || !(located_x > 0.0f) || !(located_y > 0.0f) || frame.render_width[eye] <= 2 * kBorder ||
frame.render_height[eye] <= 2 * kBorder) {
return false;
}
// The display's pixels per unit of tangent, as the eye was located, across the window's
// tangent extent seen straight on from the race origin.
const auto pixels = [&](uint32_t full, float located, float half_extent) {
const float content = std::floor(static_cast<float>(full) / located * (2.0f * half_extent / distance));
return std::clamp<uint32_t>(static_cast<uint32_t>(std::max(content, 1.0f)) + 2 * kBorder, 2 * kBorder + 1,
full);
};
width[eye] = pixels(frame.render_width[eye], located_x, half_width);
height[eye] = pixels(frame.render_height[eye], located_y, half_height);
// This frame's frustum: the window's edges seen from where the eye is, widened by the border.
const float left = (-half_width - at[0]) / at[2];
const float right = (half_width - at[0]) / at[2];
const float down = (-half_height - at[1]) / at[2];
const float up = (half_height - at[1]) / at[2];
const float border_x = (right - left) * kBorder / static_cast<float>(width[eye] - 2 * kBorder);
const float border_y = (up - down) * kBorder / static_cast<float>(height[eye] - 2 * kBorder);
fov[eye].angleLeft = std::atan(left - border_x);
fov[eye].angleRight = std::atan(right + border_x);
fov[eye].angleUp = std::atan(up + border_y);
fov[eye].angleDown = std::atan(down - border_y);
}
for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) {
frame.xr_frame.views[eye].pose.orientation = window.orientation;
frame.xr_frame.views[eye].fov = fov[eye];
frame.render_width[eye] = width[eye];
frame.render_height[eye] = height[eye];
}
return true;
}
bool WindowShapedEyes() const noexcept {
#if defined(__ANDROID__)
return kWindowShapedEyesSupported && !window_eyes_forced_off_;
#else
return kWindowShapedEyesSupported;
#endif
}
// The seated frame the controllers are located in for hand steering: the
// immersive base the eye transforms use, from the previous frame (this
// frame's is latched after input).
@@ -1532,6 +1629,27 @@ private:
#endif
}
// Android: `adb shell setprop debug.wiicompiled.window_eyes 0` renders the immersive window's eyes
// whole and only masks them, as the PC does, to compare the cost within one session; an empty
// value aims them through the window again. Read about once a second.
void PollWindowEyesOverride() noexcept {
#if defined(__ANDROID__)
if (window_eyes_poll_ != 0) {
--window_eyes_poll_;
return;
}
window_eyes_poll_ = 72;
char value[PROP_VALUE_MAX] = {};
const bool off = __system_property_get("debug.wiicompiled.window_eyes", value) > 0 && value[0] == '0';
if (off != window_eyes_forced_off_) {
window_eyes_forced_off_ = off;
RT_LOG(RT_TAG_RUNTIME) << "OpenXR: immersive window eyes "
<< (off ? "rendered whole and masked" : "aimed through the window")
<< " (debug.wiicompiled.window_eyes)" << std::endl;
}
#endif
}
// The settings panel's layer hangs exactly where its pointer hits are
// tested, the rectangle it used to cover in the eyes.
static void PlacePanelLayer(OpenXRBackendFrame& frame, const OpenXRPointerScreen& screen) noexcept {
@@ -1645,7 +1763,9 @@ private:
if (diagnostics::ConsumeSessionInfoRequest()) {
LogDiagnosticSession(frame);
}
if (frame.xr_frame.should_render && frame.xr_frame.views_valid) {
// The immersive window's eyes are aimed through the window, so their fields of view follow
// the head and are no longer the headset's.
if (frame.xr_frame.should_render && frame.xr_frame.views_valid && !frame.presentation.window_eyes) {
diagnostics::OnViewGeometry(DiagnosticViewGeometry(frame));
}
}
@@ -1759,6 +1879,8 @@ private:
int eye_passes_override_ = -1;
uint32_t foveation_poll_ = 0;
int foveation_override_ = -1;
uint32_t window_eyes_poll_ = 0;
bool window_eyes_forced_off_ = false;
#endif
std::unique_ptr<OpenXRInput> input_;
std::thread pacing_thread_;
+4 -2
View File
@@ -935,10 +935,12 @@ public:
views[eye].pose.position = frame.xr_frame.views[eye].pose.position;
views[eye].fov = frame.xr_frame.views[eye].fov;
views[eye].subImage.swapchain = retained_swapchains_[eye].handle;
// The part of the image the eye was rendered into: all of it, except for the immersive
// window's eyes, which are only the window (OpenXRPresentation::window_eyes).
views[eye].subImage.imageRect = {
{0, 0},
{static_cast<int32_t>(retained_swapchains_[eye].width),
static_cast<int32_t>(retained_swapchains_[eye].height)}};
{static_cast<int32_t>(std::min(frame.render_width[eye], retained_swapchains_[eye].width)),
static_cast<int32_t>(std::min(frame.render_height[eye], retained_swapchains_[eye].height))}};
views[eye].subImage.imageArrayIndex = 0;
}
XrCompositionLayerProjection projection{XR_TYPE_COMPOSITION_LAYER_PROJECTION};