mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 18:02:19 +02:00
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:
14 files changed
+166
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 :
|
||||
|
||||
Reference in new issue
Block a user