Repeat a frame only when the new eyes miss the runtime's next wake

The first repeat_frames waited a millisecond for the eyes before spending a
refresh on the retained layer, so nearly every packet also cost a needless
repeat, the next packet missed the game's next frame, and the Steam Frame
showed about 35 new frames a second. The pacing thread now waits until
1.5 ms before the next wake, a period after the last xrWaitFrame returned,
which OpenXRRuntime records.

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 17:22:03 +00:00
1 parent 8718d1ddd2
commit 5849356025
4 files changed
+33 -9

No files matched your search

+19 -6
View File
@@ -710,8 +710,10 @@ private:
// Skipped eye copies tolerated back to back before the session is given up: a few seconds
// at the headset's refresh rate.
static constexpr uint32_t kMaxConsecutiveSkips = 300;
// With [vr] repeat_frames, how long render-first pacing waits for the eyes before it spends the
// next display refresh on the retained layer. The repeat's xrWaitFrame does the actual pacing.
// With [vr] repeat_frames, render-first pacing waits for the eyes until this long before the
// runtime's next wake, then spends that refresh on the retained layer; before the first wake,
// it waits this many milliseconds. The repeat's xrWaitFrame does the actual pacing.
static constexpr std::chrono::microseconds kRepeatFrameMargin{1500};
static constexpr uint32_t kRepeatFramePollMs = 1;
static float ClampRenderScale(float scale) noexcept {
@@ -1340,15 +1342,26 @@ private:
// Aurora renders the eyes at its next seal. Meanwhile the compositor keeps showing the
// retained layer; a 50 ms stall repeats it explicitly and withdraws the packet. With
// [vr] repeat_frames the retained layer is also submitted for every display refresh the
// eyes are not ready for, each cycle paced by xrWaitFrame, so the runtime sees the app at
// the display's rate rather than the game's and never fills refreshes in itself.
const uint32_t wait_ms = RuntimeConfigFile::VrRepeatFrames() ? kRepeatFramePollMs : 50;
// eyes are not ready for, so the runtime sees the app at the display's rate rather than the
// game's and never fills refreshes in itself. The eyes are waited for until shortly before
// the runtime's next wake: a repeat begun earlier would spend a refresh the new eyes could
// still have made, and hold back the next packet past the game's next frame.
const bool repeat_frames = RuntimeConfigFile::VrRepeatFrames();
const auto wait_ms = [&]() -> uint32_t {
if (!repeat_frames) return 50;
const XrDuration period = runtime_->LastWaitFramePeriod();
if (period <= 0) return kRepeatFramePollMs;
const auto wake = runtime_->LastWaitFrameReturn() + std::chrono::nanoseconds(period);
const auto left = std::chrono::duration_cast<std::chrono::milliseconds>(
wake - kRepeatFrameMargin - std::chrono::steady_clock::now());
return static_cast<uint32_t>(std::clamp<int64_t>(left.count(), 0, 50));
};
OpenXRSubmissionStatus submission = OpenXRSubmissionStatus::Timeout;
bool canceled_before_encode = false;
const auto cancel_after = std::chrono::steady_clock::now() + std::chrono::milliseconds(50);
while (!stop_.load(std::memory_order_acquire) && submission == OpenXRSubmissionStatus::Timeout) {
submission = diagnostics::Measure(diagnostics::Stage::SubmissionWait, [&] {
return backend_->WaitForSubmission(packet, wait_ms);
return backend_->WaitForSubmission(packet, wait_ms());
});
if (submission == OpenXRSubmissionStatus::Timeout) {
if (std::chrono::steady_clock::now() >= cancel_after) {
+2
View File
@@ -710,6 +710,8 @@ OpenXRFrameStatus OpenXRRuntime::WaitFrame(OpenXRFrame& frame) {
return OpenXRFrameStatus::Error;
}
diagnostics::OnWaitFrame(wait_timer, state.predictedDisplayTime, state.predictedDisplayPeriod);
m_last_wait_return = std::chrono::steady_clock::now();
m_last_wait_period = state.predictedDisplayPeriod;
frame = {};
frame.serial = m_next_frame_serial++;