Add adaptive resolution for the immersive eyes

[vr] adaptive_resolution (off by default, in the VR tab) lowers the
race's eye resolution by 10% steps, down to 70%, while new eye frames
fall below 85% of the game's 60 FPS, and raises it again after three
seconds on time. Adapted from heurazy/Wiicompiled_VR-PLUS.

The eyes render into the top-left corner of the full swapchain image
and the projection layer shows only that corner, so the compositor
upscales them and no swapchain is recreated, unlike render_scale. The
Linux same-device bridge now copies an eye smaller than its target;
the Quest's bridge and layer already did. The PC's D3D12 path copies
whole eyes, so the setting is not offered on Windows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sabhzu6VgoWcHtpSk4C9KZ
This commit is contained in:
Claude committed 2026-10-05 12:35:26 +00:00
1 parent 00034b17fc
commit 94bfc555c8
10 files changed
+207 -6

No files matched your search

+13
View File
@@ -159,6 +159,7 @@ int g_vrFoveation = static_cast<int>(RuntimeConfigFile::VrFoveationLevelIndex(Ru
bool g_vrEyeTrackedFoveation = RuntimeConfigFile::VrEyeTrackedFoveation();
#endif
bool g_vrRepeatFrames = RuntimeConfigFile::VrRepeatFrames();
bool g_vrAdaptiveResolution = RuntimeConfigFile::VrAdaptiveResolution();
bool g_vrFirstPerson = RuntimeConfigFile::VrFirstPerson(false);
bool g_vrFirstPersonToggleClick = RuntimeConfigFile::VrFirstPersonToggleClick();
// Set from any thread by the right-thumbstick click, applied on the game thread.
@@ -1568,6 +1569,18 @@ void DrawVrSettings() {
"fill the gaps itself, which doubles the HUD and the menu screen as you turn your head "
"(SteamVR on the Steam Frame). Applies immediately.");
}
#if !defined(_WIN32)
// Only the Vulkan backends show part of an eye image; the PC's copy whole eyes.
if (ImGui::Checkbox("Adaptive resolution (experimental)", &g_vrAdaptiveResolution)) {
RuntimeConfigFile::SetVrAdaptiveResolution(g_vrAdaptiveResolution);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"During races, lowers the eyes' resolution a step at a time, down to 70%%, while new "
"frames fall behind the game's 60 FPS, and raises it again once they keep up. Each change "
"rebuilds the foveation maps. Applies immediately.");
}
#endif
if (ImGui::Combo("VR frame interpolation (experimental)", &g_vrFrameInterpolationMode,
kVrInterpolationLabels.data(), static_cast<int>(kVrInterpolationLabels.size()))) {
const auto target = kVrInterpolationFps[static_cast<size_t>(g_vrFrameInterpolationMode)];
+37 -1
View File
@@ -9,6 +9,7 @@
#include "runtime_config.h"
#include "gx_thread.h"
#include "runtime_log.h"
#include "vr/adaptive_resolution.h"
#include "vr/eye_gaze.h"
#include "vr/mkw_vr_culling.h"
#include "vr/mkw_vr_first_person.h"
@@ -111,6 +112,15 @@ inline constexpr bool kWindowShapedEyesSupported = true;
inline constexpr bool kWindowShapedEyesSupported = false;
#endif
// Whether the immersive eyes can be rendered smaller than their swapchain images for
// [vr] adaptive_resolution: the backend copies them into a corner of the image and its projection
// layer shows that corner. The Vulkan backends do; the PC's D3D12 one copies whole eyes.
#if defined(_WIN32)
inline constexpr bool kScaledEyesSupported = false;
#else
inline constexpr bool kScaledEyesSupported = true;
#endif
struct Quaternion {
float x = 0.0f;
float y = 0.0f;
@@ -1506,6 +1516,15 @@ private:
destination.eyes[eye].height = source.render_height[eye];
}
}
// The backend shows the part of each image this size fills, so the field of view is kept.
if (const float scale = adaptive_scale_.load(std::memory_order_relaxed); kScaledEyesSupported && scale < 1.0f) {
for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) {
source.render_width[eye] = AdaptiveResolution::Scaled(source.render_width[eye], scale);
source.render_height[eye] = AdaptiveResolution::Scaled(source.render_height[eye], scale);
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 =
@@ -1879,7 +1898,21 @@ private:
const auto now = std::chrono::steady_clock::now();
const float elapsed = std::chrono::duration<float>(now - timing_start_).count();
if (elapsed >= 1.0f) {
rendered_fps_.store(static_cast<float>(timing_submissions_) / elapsed, std::memory_order_relaxed);
const float fps = static_cast<float>(timing_submissions_) / elapsed;
rendered_fps_.store(fps, std::memory_order_relaxed);
// Measured during races only: menus and loading screens run below 60 on their own.
// The target is the game's 60 FPS, or the headset's rate when frames are interpolated.
const bool adaptive = kScaledEyesSupported && RuntimeConfigFile::VrAdaptiveResolution();
if (!adaptive || last_immersive_) {
const float target = aurora_get_stereo_frame_interpolation() ? hz : std::min(hz, 60.0f);
const float previous = adaptive_resolution_.Scale();
const float scale = adaptive_resolution_.Observe(fps, target, adaptive);
adaptive_scale_.store(scale, std::memory_order_relaxed);
if (scale != previous) {
RT_LOG(RT_TAG_RUNTIME) << "OpenXR: adaptive resolution " << static_cast<int>(scale * 100.0f + 0.5f)
<< "% (" << fps << " of " << target << " FPS)" << std::endl;
}
}
timing_start_ = now;
timing_submissions_ = 0;
}
@@ -2031,6 +2064,9 @@ private:
std::atomic<float> rendered_fps_{0};
std::chrono::steady_clock::time_point timing_start_ = std::chrono::steady_clock::now();
uint32_t timing_submissions_ = 0;
// [vr] adaptive_resolution: measured on the pacing thread, read where eyes are published.
AdaptiveResolution adaptive_resolution_;
std::atomic<float> adaptive_scale_{1.0f};
PFN_xrGetDisplayRefreshRateFB get_display_refresh_rate_ = nullptr;
PFN_xrEnumerateDisplayRefreshRatesFB enumerate_refresh_rates_ = nullptr;
PFN_xrRequestDisplayRefreshRateFB request_refresh_rate_ = nullptr;
+3 -2
View File
@@ -744,10 +744,11 @@ 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 eyes may fill only a corner of the image ([vr] adaptive_resolution).
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};