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 :