Ask the headset for a configured refresh rate, 120 Hz on the Steam Frame

[vr] refresh_rate (Hz, 0 = the headset's own) is requested through
XR_FB_display_refresh_rate each time the session starts running and
whenever the setting changes, matched against the runtime's own list
within half a hertz. Setting it back to 0 restores the rate the session
started at. A rate that is not offered, or one the runtime declines, is
logged and changes nothing.

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 build defaults to 120; every other build keeps
the headset's own rate unless the player picks one.

The F10/headset panel and the Android launcher's Headset section offer
the choice; the interpolation tooltip points at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HBRGKTE1GnN2ah8gcZKr3
This commit is contained in:
Claude committed 2026-10-04 08:32:33 +00:00
1 parent 150062280d
commit 8b98330485
8 files changed
+215 -1

No files matched your search

+41
View File
@@ -63,6 +63,7 @@ struct RuntimeUserConfig {
std::optional<std::string> vrMirrorView;
std::optional<std::string> vrControllerMode;
std::optional<uint32_t> vrFrameInterpolationFps;
std::optional<uint32_t> vrRefreshRate;
std::optional<bool> vrFirstPerson;
std::optional<bool> vrFirstPersonToggleClick;
std::optional<float> 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<uint32_t>(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);
}
@@ -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 {
+27 -1
View File
@@ -187,6 +187,9 @@ int g_vrFrameInterpolationMode = [] {
return static_cast<int>(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<uint32_t, 5> 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<int>(kVrInterpolationLabels.size()))) {
const auto target = kVrInterpolationFps[static_cast<size_t>(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.");
}
+89
View File
@@ -29,6 +29,7 @@
#include <sstream>
#include <string>
#include <thread>
#include <vector>
#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<float>(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<float> 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<size_t>(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<uint32_t>(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<OpenXRIntegration*>(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
@@ -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);
+18
View File
@@ -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);