diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt
index f218f9b..3789507 100644
--- a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt
+++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt
@@ -243,6 +243,13 @@ class SettingsPage(
read = { stringIndex(it, "vr", "performance_level", PERFORMANCE_LEVELS) },
write = { c, index -> c.setString("vr", "performance_level", PERFORMANCE_LEVELS[index]) },
)
+ choice(
+ R.string.vr_refresh_rate, R.string.vr_refresh_rate_helper,
+ listOf(activity.getString(R.string.vr_refresh_rate_headset), "72 Hz", "90 Hz", "120 Hz", "144 Hz"),
+ read = { REFRESH_RATES.indexOf(vrRefreshRate(it)) },
+ write = { c, index -> c.setInteger("vr", "refresh_rate", REFRESH_RATES[index]) },
+ custom = { "${vrRefreshRate(it)} Hz" },
+ )
choice(
R.string.vr_foveation, R.string.vr_foveation_helper,
listOf(
@@ -784,6 +791,10 @@ class SettingsPage(
val FOVEATION_DEFAULT = FOVEATION_LEVELS.indexOf("medium")
val CONTROLLER_MODES = listOf("wii_remote", "gamepad", "none")
val INTERPOLATION_FPS = listOf(0L, 1L, 72L, 90L, 120L)
+ // [vr] refresh_rate choices, 0 being the headset's own rate.
+ val REFRESH_RATES = listOf(0L, 72L, 90L, 120L, 144L)
+ /** runtime_config.h's kVrRefreshRateDefault: 120 on the Steam Frame, the headset's own elsewhere. */
+ val REFRESH_RATE_DEFAULT = if (BuildConfig.STEAM_FRAME) 120L else 0L
val RESOLUTIONS = listOf(1.0, 1.5, 2.0, 3.0, 4.0)
val SUPPORTED_RESOLUTIONS = listOf(0.0, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0)
const val BLOOM_PATH = 0x10L
@@ -813,6 +824,10 @@ class SettingsPage(
config.integer("video", "disabled_post_processing_paths")
?.takeIf { it in 0L..UINT32_MAX && (it and BLOOM_PATH.inv()) == 0L } ?: BLOOM_PATH
+ /** vr.refresh_rate where IsSupportedVrRefreshRate accepts it, else the runtime's default. */
+ fun vrRefreshRate(config: TomlConfig): Long =
+ config.integer("vr", "refresh_rate")?.takeIf { it == 0L || it in 60L..240L } ?: REFRESH_RATE_DEFAULT
+
/** vr.frame_interpolation_fps, with the legacy frame_interpolation switch and NormalizeFrameInterpolationFps. */
fun vrInterpolationFps(config: TomlConfig): Long {
val value = config.integer("vr", "frame_interpolation_fps")?.takeIf { it in 0L..UINT32_MAX }
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 87e2e12..7a9d218 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -559,6 +559,9 @@
Sustained low
Power savings
Headset default
+ Headset refresh rate
+ The display rate asked of the headset. The game runs at 60 FPS, so 120 Hz shows every frame for exactly two refreshes, where 72 and 90 Hz hold some frames longer than others. A rate the headset does not offer leaves its own.
+ Headset\'s own
Foveated rendering
Shades the edges of the race view in coarser blocks, where the lenses blur the picture anyway, to free GPU time for a higher render scale or a steadier frame rate. Higher levels start closer to the centre; High also coarsens the corners of the HUD. Menus are never foveated.
Off
diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h
index 1193c64..f2e45b4 100644
--- a/runtime/include/runtime_config.h
+++ b/runtime/include/runtime_config.h
@@ -63,6 +63,7 @@ struct RuntimeUserConfig {
std::optional vrMirrorView;
std::optional vrControllerMode;
std::optional vrFrameInterpolationFps;
+ std::optional vrRefreshRate;
std::optional vrFirstPerson;
std::optional vrFirstPersonToggleClick;
std::optional vrFirstPersonUnitsPerMeter;
@@ -281,6 +282,25 @@ inline bool IsSupportedVrPerformanceLevel(std::string_view value) {
return value == "default" || value == "power_savings" || value == "sustained_low" ||
value == "sustained_high" || value == "boost";
}
+// The display refresh rate asked of the OpenXR runtime (XR_FB_display_refresh_rate), in Hz, each
+// time the session starts and whenever the setting changes; 0 leaves the headset's own rate. The
+// game renders 60 frames a second, so at 120 Hz every frame shows for exactly two refreshes, where
+// 72 or 90 Hz hold some frames longer than others. The Steam Frame starts at 120; elsewhere the
+// headset's own setting stays in charge. A runtime without the extension, or one that declines the
+// rate, keeps its own.
+#if defined(MKW_HEADSET_STEAM_FRAME)
+inline constexpr uint32_t kVrRefreshRateDefault = 120;
+#define MKW_VR_REFRESH_RATE_DEFAULT_TEXT "120"
+#else
+inline constexpr uint32_t kVrRefreshRateDefault = 0;
+#define MKW_VR_REFRESH_RATE_DEFAULT_TEXT "0"
+#endif
+inline constexpr uint32_t kVrRefreshRateMin = 60;
+inline constexpr uint32_t kVrRefreshRateMax = 240;
+
+inline bool IsSupportedVrRefreshRate(uint32_t value) {
+ return value == 0 || (value >= kVrRefreshRateMin && value <= kVrRefreshRateMax);
+}
// Fixed foveated rendering of the immersive eyes on the Quest, in the order of
// aurora_set_stereo_foveation's levels: the periphery is shaded in 2x2, then
// 4x4 pixel blocks, the higher the level the closer to the centre. Whether the
@@ -545,6 +565,11 @@ inline void EnsureConfigFile() {
"controller_mode = \"wii_remote\"\n"
"# VR interpolation: 0 = Off, 1 = Auto, or 72/90/120 FPS. Live.\n"
"frame_interpolation_fps = 0\n"
+ "# Display refresh rate asked of the headset, in Hz (72, 90, 120,\n"
+ "# 144, ...), or 0 to leave the headset's own setting. The game runs\n"
+ "# at 60, so 120 shows every frame twice. Only runtimes that let apps\n"
+ "# choose (XR_FB_display_refresh_rate) take it. Live.\n"
+ "refresh_rate = " MKW_VR_REFRESH_RATE_DEFAULT_TEXT "\n"
"render_scale = " MKW_VR_RENDER_SCALE_DEFAULT_TEXT "\n"
"world_units_per_meter = 500.0\n"
"hud_distance_meters = 2.0\n"
@@ -872,6 +897,9 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
value && IsSupportedVrControllerMode(*value)) {
config.vrControllerMode = *value;
}
+ if (auto value = FindConfigUint(document, "vr", "refresh_rate"); value && IsSupportedVrRefreshRate(*value)) {
+ config.vrRefreshRate = *value;
+ }
config.vrFrameInterpolationFps = FindConfigValue(document, "vr", "frame_interpolation_fps");
if (!config.vrFrameInterpolationFps) {
// Migrate the initial experimental checkbox to Auto.
@@ -1241,6 +1269,14 @@ inline bool SetVrFrameInterpolationFps(uint32_t value) {
return WriteSetting("vr", "frame_interpolation_fps", std::to_string(value));
}
+inline bool SetVrRefreshRate(uint32_t value) {
+ if (!IsSupportedVrRefreshRate(value)) {
+ return false;
+ }
+ Mutable().vrRefreshRate = value;
+ return WriteSetting("vr", "refresh_rate", std::to_string(value));
+}
+
inline bool SetVrFirstPersonRotation(std::string value) {
if (!IsSupportedVrFirstPersonRotation(value)) {
return false;
@@ -1743,6 +1779,11 @@ inline uint32_t VrFrameInterpolationFps() {
return mkw::vr::NormalizeFrameInterpolationFps(Get().vrFrameInterpolationFps.value_or(0));
}
+inline uint32_t VrRefreshRate(uint32_t fallback = kVrRefreshRateDefault) {
+ const uint32_t value = Get().vrRefreshRate.value_or(fallback);
+ return IsSupportedVrRefreshRate(value) ? value : 0;
+}
+
inline bool DiagnosticsOpenXRLogging(bool fallback = false) {
return Get().diagnosticsOpenXRLogging.value_or(fallback);
}
diff --git a/runtime/include/vr/frame_interpolation_pacing.h b/runtime/include/vr/frame_interpolation_pacing.h
index e4efaab..52232fb 100644
--- a/runtime/include/vr/frame_interpolation_pacing.h
+++ b/runtime/include/vr/frame_interpolation_pacing.h
@@ -9,6 +9,21 @@ inline uint32_t NormalizeFrameInterpolationFps(uint32_t value) noexcept {
return value == 1 || value == 72 || value == 90 || value == 120 ? value : 0;
}
+// The display refresh rate to ask the runtime for ([vr] refresh_rate): the one it lists within half
+// a hertz of `requested` (runtimes report 119.9 or 120.00001 for 120), or 0 when none is close.
+inline float MatchDisplayRefreshRate(const float* rates, uint32_t count, float requested) noexcept {
+ float best = 0.0f;
+ float best_distance = 0.5f;
+ for (uint32_t i = 0; i < count; ++i) {
+ const float distance = rates[i] > requested ? rates[i] - requested : requested - rates[i];
+ if (rates[i] > 0.0f && distance <= best_distance) {
+ best = rates[i];
+ best_distance = distance;
+ }
+ }
+ return best;
+}
+
// A render-rate ceiling on the compositor's own display-time grid. Auto (1)
// renders every tick. Fixed targets cannot increase the physical refresh rate.
class FrameInterpolationPacing {
diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp
index 2e568f6..4492c01 100644
--- a/runtime/src/settings_overlay.cpp
+++ b/runtime/src/settings_overlay.cpp
@@ -187,6 +187,9 @@ int g_vrFrameInterpolationMode = [] {
return static_cast(std::find(kVrInterpolationFps.begin(), kVrInterpolationFps.end(), value) -
kVrInterpolationFps.begin());
}();
+// [vr] refresh_rate choices; a value set in Config.toml outside them is shown as it is.
+constexpr std::array kVrRefreshRates{0, 72, 90, 120, 144};
+uint32_t g_vrRefreshRate = RuntimeConfigFile::VrRefreshRate();
int g_vrFirstPersonHiddenModel = RuntimeConfigFile::VrFirstPersonHiddenModel();
bool g_openxrDiagnosticsLogging = RuntimeConfigFile::DiagnosticsOpenXRLogging(false);
bool g_firstPersonDiagnosticsLogging = RuntimeConfigFile::DiagnosticsFirstPersonLogging(false);
@@ -1517,6 +1520,29 @@ void DrawVrSettings() {
"off, or without a GPU that supports it: a new level applies after a restart.");
}
#endif
+ {
+ const auto rateLabel = [](uint32_t hz) {
+ return hz == 0 ? std::string("Headset's own") : std::to_string(hz) + " Hz";
+ };
+ if (ImGui::BeginCombo("Headset refresh rate", rateLabel(g_vrRefreshRate).c_str())) {
+ for (const uint32_t hz : kVrRefreshRates) {
+ if (ImGui::Selectable(rateLabel(hz).c_str(), hz == g_vrRefreshRate) && hz != g_vrRefreshRate) {
+ g_vrRefreshRate = hz;
+ // The XR thread follows the setting and asks the runtime for it.
+ RuntimeConfigFile::SetVrRefreshRate(hz);
+ }
+ }
+ ImGui::EndCombo();
+ }
+ if (ImGui::IsItemHovered()) {
+ ImGui::SetTooltip(
+ "Asks the headset for this display rate. The game runs at 60 FPS, so 120 Hz shows every "
+ "frame for exactly two refreshes, where 72 and 90 Hz hold some frames longer than others. "
+ "Only runtimes that let apps choose (XR_FB_display_refresh_rate: the Quest, the Steam "
+ "Frame, Virtual Desktop) take it; the Headset line below shows the rate in use. Applies "
+ "immediately.");
+ }
+ }
if (ImGui::Combo("VR frame interpolation (experimental)", &g_vrFrameInterpolationMode,
kVrInterpolationLabels.data(), static_cast(kVrInterpolationLabels.size()))) {
const auto target = kVrInterpolationFps[static_cast(g_vrFrameInterpolationMode)];
@@ -1526,7 +1552,7 @@ void DrawVrSettings() {
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Auto matches the headset refresh rate. 72, 90 and 120 cap the scene rendering rate; "
- "set the headset's refresh rate in Virtual Desktop or your VR runtime. "
+ "set the headset's refresh rate above, or in Virtual Desktop or your VR runtime. "
"The game stays at 60 Hz. Adds one game frame of scene latency; head tracking stays current. "
"Needs GPU headroom and may show interpolation artifacts. Applies immediately.");
}
diff --git a/runtime/src/vr/openxr_integration.cpp b/runtime/src/vr/openxr_integration.cpp
index e91a65e..81e5651 100644
--- a/runtime/src/vr/openxr_integration.cpp
+++ b/runtime/src/vr/openxr_integration.cpp
@@ -29,6 +29,7 @@
#include
#include
#include
+#include
#if defined(MKW_ENABLE_OPENXR)
#include "vr/openxr_backend.h"
@@ -446,6 +447,8 @@ public:
#endif
if (has_extension("XR_FB_display_refresh_rate")) {
runtime_->LoadFunction("xrGetDisplayRefreshRateFB", &get_display_refresh_rate_);
+ runtime_->LoadFunction("xrEnumerateDisplayRefreshRatesFB", &enumerate_refresh_rates_);
+ runtime_->LoadFunction("xrRequestDisplayRefreshRateFB", &request_refresh_rate_);
}
if (has_extension("XR_EXT_performance_settings")) {
runtime_->LoadFunction("xrPerfSettingsSetPerformanceLevelEXT", &set_performance_level_);
@@ -576,6 +579,8 @@ public:
prepared_ = false;
convert_display_time_ = nullptr;
get_display_refresh_rate_ = nullptr;
+ enumerate_refresh_rates_ = nullptr;
+ request_refresh_rate_ = nullptr;
set_performance_level_ = nullptr;
headset_hz_.store(0, std::memory_order_relaxed);
rendered_fps_.store(0, std::memory_order_relaxed);
@@ -590,6 +595,10 @@ public:
ResetTrackingOrigin();
applied_session_run_serial_ = 0;
session_was_active_ = false;
+ refresh_rate_session_serial_ = 0;
+ requested_refresh_rate_ = 0;
+ session_start_refresh_rate_ = 0.0f;
+ refresh_rate_changed_ = false;
}
bool IsRunning() const noexcept { return running_.load(std::memory_order_acquire); }
@@ -797,6 +806,70 @@ private:
<< (XR_SUCCEEDED(gpu) ? "set" : "refused") << " (" << gpu << ")" << std::endl;
}
+ // [vr] refresh_rate (XR_FB_display_refresh_rate), asked of the runtime each time the session
+ // starts running and whenever the setting changes. The game renders 60 frames a second, so a
+ // display at 120 Hz shows each one for exactly two refreshes. 0 gives back the rate the session
+ // started at, if this changed it. A rate the runtime does not list, or declines (SteamVR may),
+ // is logged and the runtime keeps its own.
+ void ApplyRefreshRate(uint32_t requested, bool session_started) {
+ if (runtime_ == nullptr || !runtime_->HasSession()) {
+ return;
+ }
+ if (session_started) {
+ session_start_refresh_rate_ = 0.0f;
+ refresh_rate_changed_ = false;
+ if (get_display_refresh_rate_ != nullptr &&
+ XR_FAILED(get_display_refresh_rate_(runtime_->Session(), &session_start_refresh_rate_))) {
+ session_start_refresh_rate_ = 0.0f;
+ }
+ }
+ float target = static_cast(requested);
+ if (requested == 0) {
+ if (!refresh_rate_changed_ || !(session_start_refresh_rate_ > 0.0f)) {
+ return;
+ }
+ target = session_start_refresh_rate_;
+ }
+ if (enumerate_refresh_rates_ == nullptr || request_refresh_rate_ == nullptr) {
+ RT_LOG(RT_TAG_RUNTIME) << "OpenXR: display refresh rate " << target
+ << " Hz not requested: the runtime does not offer XR_FB_display_refresh_rate"
+ << std::endl;
+ return;
+ }
+ uint32_t count = 0;
+ XrResult result = enumerate_refresh_rates_(runtime_->Session(), 0, &count, nullptr);
+ std::vector rates;
+ if (XR_SUCCEEDED(result) && count > 0) {
+ rates.resize(count);
+ result = enumerate_refresh_rates_(runtime_->Session(), count, &count, rates.data());
+ rates.resize(XR_SUCCEEDED(result) ? std::min(count, rates.size()) : 0);
+ }
+ if (XR_FAILED(result) || rates.empty()) {
+ RT_LOG(RT_TAG_RUNTIME) << "OpenXR: display refresh rate " << target
+ << " Hz not requested: the runtime lists no rates (" << result << ")" << std::endl;
+ return;
+ }
+ std::ostringstream available;
+ for (size_t i = 0; i < rates.size(); ++i) {
+ available << (i == 0 ? "" : "/") << rates[i];
+ }
+ const float rate = MatchDisplayRefreshRate(rates.data(), static_cast(rates.size()), target);
+ if (rate == 0.0f) {
+ RT_LOG(RT_TAG_RUNTIME) << "OpenXR: display refresh rate " << target
+ << " Hz is not offered (available " << available.str()
+ << " Hz); keeping the runtime's" << std::endl;
+ return;
+ }
+ const XrResult set = request_refresh_rate_(runtime_->Session(), rate);
+ if (XR_SUCCEEDED(set)) {
+ refresh_rate_changed_ = requested != 0;
+ }
+ RT_LOG(RT_TAG_RUNTIME) << "OpenXR: display refresh rate " << rate << " Hz "
+ << (XR_SUCCEEDED(set) ? "requested" : "refused") << " (" << set << "; available "
+ << available.str() << " Hz, session started at " << session_start_refresh_rate_
+ << " Hz)" << std::endl;
+ }
+
static bool ProvideStereoFrame(uint32_t, AuroraStereoFrame* output, void* userdata) {
auto* self = static_cast(userdata);
if (self == nullptr || output == nullptr) {
@@ -894,6 +967,14 @@ private:
WaitForStopOrDelay(std::chrono::milliseconds(5));
continue;
}
+ // The configured refresh rate, at each session start and whenever it changes.
+ if (const uint32_t refresh_rate = RuntimeConfigFile::VrRefreshRate();
+ session_run_serial != refresh_rate_session_serial_ || refresh_rate != requested_refresh_rate_) {
+ const bool session_started = session_run_serial != refresh_rate_session_serial_;
+ refresh_rate_session_serial_ = session_run_serial;
+ requested_refresh_rate_ = refresh_rate;
+ ApplyRefreshRate(refresh_rate, session_started);
+ }
const MkwVRPolicySnapshot policy = MkwVRPolicyGetSnapshot();
aurora_set_stereo_motion_logging(diagnostics::Enabled());
@@ -1874,7 +1955,15 @@ private:
std::chrono::steady_clock::time_point timing_start_ = std::chrono::steady_clock::now();
uint32_t timing_submissions_ = 0;
PFN_xrGetDisplayRefreshRateFB get_display_refresh_rate_ = nullptr;
+ PFN_xrEnumerateDisplayRefreshRatesFB enumerate_refresh_rates_ = nullptr;
+ PFN_xrRequestDisplayRefreshRateFB request_refresh_rate_ = nullptr;
PFN_xrPerfSettingsSetPerformanceLevelEXT set_performance_level_ = nullptr;
+ // [vr] refresh_rate as last applied, the session run it was applied in, and the rate that run
+ // started at (pacing thread).
+ uint64_t refresh_rate_session_serial_ = 0;
+ uint32_t requested_refresh_rate_ = 0;
+ float session_start_refresh_rate_ = 0.0f;
+ bool refresh_rate_changed_ = false;
#if defined(_WIN32)
using ConvertDisplayTime = XrResult (XRAPI_PTR*)(XrInstance, XrTime, LARGE_INTEGER*);
#else
diff --git a/runtime/tests/frame_interpolation_pacing_tests.cpp b/runtime/tests/frame_interpolation_pacing_tests.cpp
index af99498..1f58283 100644
--- a/runtime/tests/frame_interpolation_pacing_tests.cpp
+++ b/runtime/tests/frame_interpolation_pacing_tests.cpp
@@ -12,6 +12,13 @@ static void Require(bool condition) {
}
int main() {
+ // [vr] refresh_rate picks the runtime's own value for the rate asked.
+ const float frame_rates[] = {72.0f, 90.0f, 119.98f, 144.0f};
+ Require(mkw::vr::MatchDisplayRefreshRate(frame_rates, 4, 120.0f) == 119.98f);
+ Require(mkw::vr::MatchDisplayRefreshRate(frame_rates, 4, 90.0f) == 90.0f);
+ Require(mkw::vr::MatchDisplayRefreshRate(frame_rates, 4, 60.0f) == 0.0f);
+ Require(mkw::vr::MatchDisplayRefreshRate(frame_rates, 4, 100.0f) == 0.0f);
+ Require(mkw::vr::MatchDisplayRefreshRate(frame_rates, 0, 120.0f) == 0.0f);
for (uint32_t target : {0u, 1u, 72u, 90u, 120u}) {
std::istringstream input("[vr]\nframe_interpolation_fps = " + std::to_string(target) + "\n");
const auto config = RuntimeConfigFile::ParseConfig(input);
diff --git a/runtime/tests/vr_config_tests.cpp b/runtime/tests/vr_config_tests.cpp
index 2dc3fa6..4cb5e6f 100644
--- a/runtime/tests/vr_config_tests.cpp
+++ b/runtime/tests/vr_config_tests.cpp
@@ -50,6 +50,24 @@ int main() {
Require(RuntimeConfigFile::VrFoveationLevelIndex("high") == 3);
Require(RuntimeConfigFile::VrFoveationLevelIndex("ultra") == 0);
+ // [vr] refresh_rate: Hz asked of the runtime, 0 leaving its own. The Steam Frame
+ // starts at 120, twice the game's 60.
+ Require(Parse("[vr]\nrefresh_rate = 120\n").vrRefreshRate == 120u);
+ Require(Parse("[vr]\nrefresh_rate = 0\n").vrRefreshRate == 0u);
+ Require(Parse("[vr]\nrefresh_rate = 144\n").vrRefreshRate == 144u);
+ Require(!Parse("[vr]\nrefresh_rate = 30\n").vrRefreshRate.has_value());
+ Require(!Parse("[vr]\nrefresh_rate = 500\n").vrRefreshRate.has_value());
+ Require(!Parse("[vr]\nrefresh_rate = -1\n").vrRefreshRate.has_value());
+ Require(!Parse("[vr]\nrefresh_rate = \"120\"\n").vrRefreshRate.has_value());
+ Require(!Parse("[vr]\n").vrRefreshRate.has_value());
+#if defined(MKW_HEADSET_STEAM_FRAME)
+ Require(RuntimeConfigFile::kVrRefreshRateDefault == 120u);
+ Require(std::string_view(MKW_VR_REFRESH_RATE_DEFAULT_TEXT) == "120");
+#else
+ Require(RuntimeConfigFile::kVrRefreshRateDefault == 0u);
+ Require(std::string_view(MKW_VR_REFRESH_RATE_DEFAULT_TEXT) == "0");
+#endif
+
// [vr] hand_tracking: the cockpit hands follow the headset's hand tracking.
Require(Parse("[vr]\nhand_tracking = true\n").vrHandTracking == true);
Require(Parse("[vr]\nhand_tracking = false\n").vrHandTracking == false);