From ddc985218b7b0cbbb1be2b9dbc76d5fb497276cd Mon Sep 17 00:00:00 2001 From: dim-ghub <59436063+dim-ghub@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:18:32 -0500 Subject: [PATCH] vr: add separate VR frame-rate limit, render scale and unpaced benchmark mode vr.frame_rate_limit / MOCKTAIL_VR_FRAME_RATE_LIMIT replaces graphics.frame_rate_limit while VR is active and takes the same values. It defaults to unlimited so xrWaitFrame paces Roblox at the headset refresh rate instead of Roblox's own 60 FPS cap. MOCKTAIL_VR_RENDER_SCALE scales the runtime's recommended eye extent. SteamVR's recommendation for a Quest 2 at 90 Hz (2244x2352 per eye) missed the 11.1 ms budget and halved the rate to 45 FPS. MOCKTAIL_VR_UNPACED=1 stops guest frames from blocking in xrWaitFrame: the open XR frame spans several guest frames and is submitted once per display period, for measuring maximum render rate. --- README.md | 2 ++ config/mocktail.example.yaml | 6 ++++ docs/PERFORMANCE.md | 25 ++++++++++++++ include/mocktail/vr/openxr_backend.h | 3 ++ include/runtime/runtime_config.h | 7 ++++ src/graphics/present_mode_policy.cc | 7 +++- src/legacy/legacy_runtime.cc | 5 +++ src/main.cc | 11 ++++-- src/runtime/graphics_launch_policy.cc | 2 +- src/runtime/runtime_config.cc | 2 ++ src/runtime/runtime_config_bootstrap.cc | 6 ++++ src/runtime/runtime_config_file.cc | 13 +++++++ src/vr/openxr_backend.cc | 45 +++++++++++++++++++++++++ tests/runtime_config_file_test.cc | 37 ++++++++++++++++++++ 14 files changed, 166 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c4fcbe6..924533f 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,8 @@ Edit `~/.config/mocktail/config.yaml`. Useful options: - `graphics.frame_rate_limit: display` to unlock FPS to your monitor's rate. - `graphics.vsync: off` for the lowest lag, at the cost of tearing. +- `vr.frame_rate_limit` sets the cap in VR. It defaults to `unlimited`, so the + headset's refresh rate paces frames; use `-1` for Roblox's 60 FPS cap. - `graphics.backend: opengl` if Vulkan does not work on your GPU. ## Building diff --git a/config/mocktail.example.yaml b/config/mocktail.example.yaml index e2c8121..332eb1d 100644 --- a/config/mocktail.example.yaml +++ b/config/mocktail.example.yaml @@ -62,6 +62,12 @@ graphics: # desktop testing). Controllers use the native VR bridge. Standalone OpenXR # test scene: mocktail-vr-probe --scene. Use --no-vr to launch normally. # enabled: false + # Optional string or integer: frame-rate limit used instead of + # graphics.frame_rate_limit while VR is active. Same values: -1 (Roblox's + # own cap, usually 60), display, unlimited, or any positive integer. The + # default, unlimited, lets the OpenXR runtime pace frames at the headset's + # refresh rate. + # frame_rate_limit: unlimited performance: # Boolean (default: false): size Roblox scheduler/render queues from every diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index fb7656c..b74385d 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -157,6 +157,31 @@ MOCKTAIL_SMALL_TEXTURE_UPSCALE=1 mocktail framerate cap in charge; Nightcap sets no `DFIntTaskSchedulerTargetFps` override. A fixed value forwards that number to the scheduler. +In VR, `vr.frame_rate_limit` (`MOCKTAIL_VR_FRAME_RATE_LIMIT`) replaces +`graphics.frame_rate_limit` and takes the same values. Its default is +`unlimited`: the scheduler target goes to 240 and `xrWaitFrame` holds each +frame to the headset's refresh rate. Set `-1` to restore Roblox's own cap, or a +number such as `90` to pin it. + +`MOCKTAIL_VR_RENDER_SCALE` (default `1.0`, range `0.25`–`2.0`) scales each +axis of the OpenXR runtime's recommended eye size. SteamVR's recommendation +already includes its own supersampling, so on a Quest 2 at 90 Hz it is about +2244x2352 per eye. If a frame misses the 11.1 ms budget, the runtime halves +the rate to 45 FPS; `0.75` renders about 56% of the pixels. + +`MOCKTAIL_VR_UNPACED=1` is a benchmark mode. Roblox no longer waits for the +headset between frames; each XR frame stays open across several Roblox frames +and is submitted with the newest eyes once per display period. Pair it with a +fixed scheduler target above 240 and `MOCKTAIL_VSYNC=off` to find the +maximum rate: + +```bash +MOCKTAIL_VR_UNPACED=1 MOCKTAIL_VR_FRAME_RATE_LIMIT=9999 MOCKTAIL_VSYNC=off mocktail --vr +``` + +Frames beyond the headset refresh are never displayed, and they all reuse the +open frame's head pose, so expect judder. Do not play in this mode. + `graphics.vsync` (default `auto`) selects the presentation mode. `auto` and `on` both pick the lowest-latency synchronized mode the driver offers, in order: `FIFO_LATEST_READY`, `MAILBOX`, `FIFO_RELAXED`, `FIFO`. The first two do diff --git a/include/mocktail/vr/openxr_backend.h b/include/mocktail/vr/openxr_backend.h index d5edbd2..3a5f294 100644 --- a/include/mocktail/vr/openxr_backend.h +++ b/include/mocktail/vr/openxr_backend.h @@ -399,6 +399,9 @@ class OpenXrBackend final { bool test_session_loss_injected_ = false; std::uint64_t next_session_retry_ns_ = 0; std::uint64_t frame_display_time_ = 0; + // MOCKTAIL_VR_UNPACED benchmark mode: guest presents before this monotonic + // time leave the open XR frame alone instead of blocking in xrWaitFrame. + std::uint64_t frame_due_ns_ = 0; struct ViewState { float position[3]; float orientation[4]; diff --git a/include/runtime/runtime_config.h b/include/runtime/runtime_config.h index bdfb815..610c933 100644 --- a/include/runtime/runtime_config.h +++ b/include/runtime/runtime_config.h @@ -128,6 +128,12 @@ class RuntimeConfig { return roblox_http_user_agent_; } const FrameRatePolicy& frame_rate() const { return frame_rate_; } + // Replaces frame_rate() while VR is enabled. Defaults to unlimited so the + // OpenXR runtime's xrWaitFrame paces Roblox at the headset refresh rate. + const FrameRatePolicy& vr_frame_rate() const { return vr_frame_rate_; } + const FrameRatePolicy& active_frame_rate() const { + return vr_enabled_ ? vr_frame_rate_ : frame_rate_; + } const std::string& vsync_mode() const { return vsync_mode_; } const PerformancePolicy& performance() const { return performance_; } const std::string& audio_output_device() const { @@ -180,6 +186,7 @@ class RuntimeConfig { bool device_profile_valid_ = true; std::optional roblox_http_user_agent_; FrameRatePolicy frame_rate_; + FrameRatePolicy vr_frame_rate_{FrameRateLimitMode::kUnlimited, 0}; std::string vsync_mode_ = "auto"; PerformancePolicy performance_; std::string audio_output_device_ = "default"; diff --git a/src/graphics/present_mode_policy.cc b/src/graphics/present_mode_policy.cc index d9c5f80..03522cd 100644 --- a/src/graphics/present_mode_policy.cc +++ b/src/graphics/present_mode_policy.cc @@ -37,9 +37,14 @@ PresentModePolicy ResolvePresentModePolicy(std::string_view vsync_value, PresentModePolicy CachedPresentModePolicy() { static const PresentModePolicy policy = [] { + // main() clears MOCKTAIL_VR_ENABLED when VR cannot start for this build, + // so the VR limit only applies while VR is actually active. + const std::string_view vr = EnvironmentOr("MOCKTAIL_VR_ENABLED", "0"); + const bool vr_enabled = vr == "1" || vr == "true" || vr == "on"; return ResolvePresentModePolicy( EnvironmentOr("MOCKTAIL_VSYNC", "auto"), - EnvironmentOr("MOCKTAIL_FRAME_RATE_LIMIT", "-1")); + vr_enabled ? EnvironmentOr("MOCKTAIL_VR_FRAME_RATE_LIMIT", "unlimited") + : EnvironmentOr("MOCKTAIL_FRAME_RATE_LIMIT", "-1")); }(); return policy; } diff --git a/src/legacy/legacy_runtime.cc b/src/legacy/legacy_runtime.cc index 02fee3f..b70cdb0 100644 --- a/src/legacy/legacy_runtime.cc +++ b/src/legacy/legacy_runtime.cc @@ -4669,6 +4669,11 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options, "-1, display, unlimited, or a positive integer\n"; return EXIT_FAILURE; } + if (!runtime_config.vr_frame_rate().valid()) { + std::cerr << "[FATAL] Invalid MOCKTAIL_VR_FRAME_RATE_LIMIT; expected " + "-1, display, unlimited, or a positive integer\n"; + return EXIT_FAILURE; + } if (!runtime_config.theme_mode_valid()) { std::cerr << "[FATAL] Invalid MOCKTAIL_THEME; expected roblox, system, light, " diff --git a/src/main.cc b/src/main.cc index 8aeb294..5f02d77 100644 --- a/src/main.cc +++ b/src/main.cc @@ -859,7 +859,8 @@ int main(int argc, char* argv[]) { std::string client_settings_overrides; std::vector kept_fflag_overrides; if (!mocktail::runtime::MergeRuntimeClientSettingsOverrides( - runtime_config.config.frame_rate(), + vr_active ? runtime_config.config.vr_frame_rate() + : runtime_config.config.frame_rate(), runtime_config.config.performance(), fflags.json, &client_settings_overrides, &command_line_error, &kept_fflag_overrides)) { @@ -947,10 +948,14 @@ int main(int argc, char* argv[]) { "counts under Roblox client-settings control\n"; } } + const mocktail::runtime::FrameRatePolicy& active_frame_rate = + vr_active ? runtime_config.config.vr_frame_rate() + : runtime_config.config.frame_rate(); if (command_line.options.mode == mocktail::runtime::CommandMode::kRun && - runtime_config.config.frame_rate().mode == + active_frame_rate.mode == mocktail::runtime::FrameRateLimitMode::kUnlimited) { - std::cout << " [runtime] frame-rate mode=unlimited scheduler_target=" + std::cout << " [runtime] " << (vr_active ? "vr " : "") + << "frame-rate mode=unlimited scheduler_target=" << mocktail::runtime::kMaximumSupportedRobloxSchedulerFps << " vsync=" << runtime_config.config.vsync_mode() << '\n'; } diff --git a/src/runtime/graphics_launch_policy.cc b/src/runtime/graphics_launch_policy.cc index 8474ea4..b5aee3b 100644 --- a/src/runtime/graphics_launch_policy.cc +++ b/src/runtime/graphics_launch_policy.cc @@ -93,7 +93,7 @@ bool UnthrottledPresentation(const RuntimeConfig& config) { if (config.vsync_mode() == "off" || config.vsync_mode() == "0") { return true; } - return config.frame_rate().mode == FrameRateLimitMode::kUnlimited; + return config.active_frame_rate().mode == FrameRateLimitMode::kUnlimited; } bool ParsePciVendor(const std::string& raw, unsigned int* vendor) { diff --git a/src/runtime/runtime_config.cc b/src/runtime/runtime_config.cc index db731b1..a9c4c63 100644 --- a/src/runtime/runtime_config.cc +++ b/src/runtime/runtime_config.cc @@ -283,6 +283,8 @@ RuntimeConfig RuntimeConfig::FromEnvironment(const Environment& environment) { } config.frame_rate_ = ParseFrameRatePolicy( environment.GetOr("MOCKTAIL_FRAME_RATE_LIMIT", "-1")); + config.vr_frame_rate_ = ParseFrameRatePolicy( + environment.GetOr("MOCKTAIL_VR_FRAME_RATE_LIMIT", "unlimited")); config.vsync_mode_ = environment.GetOr("MOCKTAIL_VSYNC", "auto"); config.performance_ = ParsePerformancePolicy( environment.GetOr("MOCKTAIL_MULTITHREADED_RENDERING", "0"), diff --git a/src/runtime/runtime_config_bootstrap.cc b/src/runtime/runtime_config_bootstrap.cc index 38c7453..eef6a65 100644 --- a/src/runtime/runtime_config_bootstrap.cc +++ b/src/runtime/runtime_config_bootstrap.cc @@ -81,6 +81,12 @@ graphics: # desktop testing). Controllers use the native VR bridge. Standalone OpenXR # test scene: mocktail-vr-probe --scene. Use --no-vr to launch normally. # enabled: false + # Optional string or integer: frame-rate limit used instead of + # graphics.frame_rate_limit while VR is active. Same values: -1 (Roblox's + # own cap, usually 60), display, unlimited, or any positive integer. The + # default, unlimited, lets the OpenXR runtime pace frames at the headset's + # refresh rate. + # frame_rate_limit: unlimited performance: # Boolean (default: false): size Roblox scheduler/render queues from every diff --git a/src/runtime/runtime_config_file.cc b/src/runtime/runtime_config_file.cc index dcf5363..f863823 100644 --- a/src/runtime/runtime_config_file.cc +++ b/src/runtime/runtime_config_file.cc @@ -169,6 +169,7 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment, "runtime.headless", "runtime.roblox_library", "vr.enabled", + "vr.frame_rate_limit", "appearance.theme", "graphics.backend", "graphics.frame_rate_limit", @@ -327,6 +328,14 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment, } (*environment)["MOCKTAIL_VR_ENABLED"] = parsed ? "1" : "0"; } + if (const auto frame_rate = value("vr.frame_rate_limit"); + frame_rate.has_value()) { + if (!ParseFrameRatePolicy(*frame_rate).valid()) { + *error = "vr.frame_rate_limit is not supported: " + *frame_rate; + return false; + } + (*environment)["MOCKTAIL_VR_FRAME_RATE_LIMIT"] = *frame_rate; + } if (const auto backend = value("graphics.backend"); backend.has_value()) { if (RuntimeConfig::ParseGraphicsBackend(*backend) == GraphicsBackend::kUnknown) { @@ -790,6 +799,8 @@ RuntimeConfigLoadResult LoadRuntimeConfig( result.config = RuntimeConfig::FromEnvironment(layered); if (!result.config.frame_rate().valid()) { result.error = "frame-rate policy is invalid"; + } else if (!result.config.vr_frame_rate().valid()) { + result.error = "VR frame-rate policy is invalid"; } else if (!result.config.vr_valid()) { result.error = "VR configuration is invalid: MOCKTAIL_VR_ENABLED must be " "1/0, true/false, or on/off"; @@ -944,6 +955,8 @@ bool ExportRuntimeConfigEnvironment(const RuntimeConfig& config, config.desktop_playability() ? "1" : "0", error) && SetEnvironmentValue("MOCKTAIL_FRAME_RATE_LIMIT", FrameRateValue(config.frame_rate()), error) && + SetEnvironmentValue("MOCKTAIL_VR_FRAME_RATE_LIMIT", + FrameRateValue(config.vr_frame_rate()), error) && SetEnvironmentValue("MOCKTAIL_VSYNC", config.vsync_mode(), error) && SetEnvironmentValue( "MOCKTAIL_MULTITHREADED_RENDERING", diff --git a/src/vr/openxr_backend.cc b/src/vr/openxr_backend.cc index 58c830b..e48d771 100644 --- a/src/vr/openxr_backend.cc +++ b/src/vr/openxr_backend.cc @@ -1041,6 +1041,40 @@ bool OpenXrBackend::InitializeSessionResourcesLocked(std::string *error) { xr_eye_width_[0], xr_eye_height_[0], xr_eye_width_[1], xr_eye_height_[1]); + // Per-axis scale on the runtime's recommendation. The swapchains, the + // guest eye framebuffers and the submitted image rect all derive from + // these extents, so Roblox renders at the scaled size end to end. + double render_scale = EnvDouble("MOCKTAIL_VR_RENDER_SCALE", 1.0); + if (!std::isfinite(render_scale) || render_scale <= 0.0) { + render_scale = 1.0; + } + render_scale = std::clamp(render_scale, 0.25, 2.0); + if (render_scale != 1.0) { + const std::uint32_t max_width = + config_views[0].maxImageRectWidth != 0 + ? std::min(config_views[0].maxImageRectWidth, + config_views[1].maxImageRectWidth) + : UINT32_MAX; + const std::uint32_t max_height = + config_views[0].maxImageRectHeight != 0 + ? std::min(config_views[0].maxImageRectHeight, + config_views[1].maxImageRectHeight) + : UINT32_MAX; + for (int eye = 0; eye < 2; ++eye) { + xr_eye_width_[eye] = std::clamp( + static_cast( + std::lround(xr_eye_width_[eye] * render_scale)), + 1, max_width); + xr_eye_height_[eye] = std::clamp( + static_cast( + std::lround(xr_eye_height_[eye] * render_scale)), + 1, max_height); + } + Log(" [vr-backend] render scale %.2f: eye extents L=%ux%u R=%ux%u\n", + render_scale, xr_eye_width_[0], xr_eye_height_[0], + xr_eye_width_[1], xr_eye_height_[1]); + } + if (!CreateSwapchains(error)) { TeardownSessionLocked("swapchain failure", session_recovery_pending_); return false; @@ -2058,6 +2092,14 @@ void OpenXrBackend::NoteHostPresent(VkQueue queue, VkDevice device) { } void OpenXrBackend::RunFrameCycle(VkQueue queue, VkDevice device) { + // Benchmark mode: let Roblox render as fast as it can. The open XR frame + // spans several guest frames (all rendered with its pose) and is submitted + // with the newest eyes once per display period. Frames beyond the headset + // refresh are never shown, so this is for measuring, not for playing. + static const bool unpaced = EnvString("MOCKTAIL_VR_UNPACED", "0") == "1"; + if (unpaced && frame_open_ && perf::NowNs() < frame_due_ns_) { + return; + } const ScopedStageTimer cycle_timer(perf::Stage::kFrameCycle); const auto session = static_cast(xr_session_); const auto instance = static_cast(xr_instance_); @@ -2236,6 +2278,9 @@ void OpenXrBackend::RunFrameCycle(VkQueue queue, VkDevice device) { } predicted_display_time = state.predictedDisplayTime; should_render = state.shouldRender; + frame_due_ns_ = + perf::NowNs() + static_cast( + std::max(0, state.predictedDisplayPeriod)); } frame_open_ = true; frame_display_time_ = predicted_display_time; diff --git a/tests/runtime_config_file_test.cc b/tests/runtime_config_file_test.cc index 1acdf3c..26e576f 100644 --- a/tests/runtime_config_file_test.cc +++ b/tests/runtime_config_file_test.cc @@ -134,6 +134,43 @@ TEST(RuntimeConfigFileTest, EXPECT_NE(invalid.error.find("VR configuration"), std::string::npos); } +TEST(RuntimeConfigFileTest, VrFrameRateLimitIsSeparateFromDesktopLimit) { + TemporaryDirectory temporary; + const auto defaults = LoadRuntimeConfig( + MapEnvironment({{"MOCKTAIL_VR_ENABLED", "1"}}), + temporary.Write("version: 1\n")); + ASSERT_TRUE(defaults) << defaults.error; + EXPECT_EQ(defaults.config.frame_rate().mode, FrameRateLimitMode::kUnmanaged); + EXPECT_EQ(defaults.config.vr_frame_rate().mode, + FrameRateLimitMode::kUnlimited); + EXPECT_EQ(defaults.config.active_frame_rate().mode, + FrameRateLimitMode::kUnlimited); + + const auto file = temporary.Write( + "version: 1\ngraphics:\n frame_rate_limit: 60\n" + "vr:\n enabled: true\n frame_rate_limit: 90\n"); + const auto configured = LoadRuntimeConfig(MapEnvironment(), file); + ASSERT_TRUE(configured) << configured.error; + EXPECT_EQ(configured.config.frame_rate().fixed_fps, 60); + EXPECT_EQ(configured.config.vr_frame_rate().fixed_fps, 90); + EXPECT_EQ(configured.config.active_frame_rate().fixed_fps, 90); + + const auto desktop = + LoadRuntimeConfig(MapEnvironment({{"MOCKTAIL_VR_ENABLED", "0"}, + {"MOCKTAIL_VR_FRAME_RATE_LIMIT", "-1"}}), + file); + ASSERT_TRUE(desktop) << desktop.error; + EXPECT_EQ(desktop.config.vr_frame_rate().mode, + FrameRateLimitMode::kUnmanaged); + EXPECT_EQ(desktop.config.active_frame_rate().fixed_fps, 60); + + const auto invalid = LoadRuntimeConfig( + MapEnvironment(), + temporary.Write("version: 1\nvr:\n frame_rate_limit: fastest\n")); + EXPECT_FALSE(invalid); + EXPECT_NE(invalid.error.find("vr.frame_rate_limit"), std::string::npos); +} + TEST(RuntimeConfigFileTest, RejectsMalformedVrSectionAndUnsupportedKeys) { TemporaryDirectory temporary; for (const auto* yaml :