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.
This commit is contained in:
dim-ghub committed 2026-10-12 22:44:46 -05:00
1 parent 33dd7769f0
commit ddc985218b
14 files changed
+166 -5

No files matched your search

+2
View File
@@ -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
+6
View File
@@ -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
+25
View File
@@ -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
+3
View File
@@ -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];
+7
View File
@@ -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<std::string> 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";
+6 -1
View File
@@ -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;
}
+5
View File
@@ -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, "
+8 -3
View File
@@ -859,7 +859,8 @@ int main(int argc, char* argv[]) {
std::string client_settings_overrides;
std::vector<std::string> 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';
}
+1 -1
View File
@@ -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) {
+2
View File
@@ -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"),
+6
View File
@@ -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
+13
View File
@@ -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",
+45
View File
@@ -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<std::uint32_t>(
static_cast<std::uint32_t>(
std::lround(xr_eye_width_[eye] * render_scale)),
1, max_width);
xr_eye_height_[eye] = std::clamp<std::uint32_t>(
static_cast<std::uint32_t>(
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<XrSession>(xr_session_);
const auto instance = static_cast<XrInstance>(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::uint64_t>(
std::max<XrDuration>(0, state.predictedDisplayPeriod));
}
frame_open_ = true;
frame_display_time_ = predicted_display_time;
+37
View File
@@ -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 :