Repository navigation
Conversation
Contributor
Author
|
Attaching the profiles I promised, so both defects can be reproduced without building one:
Two videos, both The Outer Worlds 2 on plain master (4ee5c6b), no patch,
animation.gif.mp4
53ce1ba80495015969b8e9be892ad363.2.mp4 |
UEVR keys its pose pipeline on the frame number from the game's FSceneViewFamily: poses live in pipeline_states[frame_count % QUEUE_SIZE], the game and render threads are matched by it, and it reaches enqueue_render_poses. OpenXR::on_pre_render_game_thread refreshes a slot only when it is empty or when the key jumped forward past it. Any other revisit keeps the predicted display time already in the slot, so xrLocateViews predicts for a moment that has passed and the image comes loose from the head and swims against it. The game's number revisits slots out of order in at least two measured ways: it freezes while a fullscreen menu is up though the game keeps rendering, held for 159 view families in The Outer Worlds 2, and it restarts from scratch on a level load, in Gylt and Silent Hill 2. Carry the number onto a key that only moves forward: count on while it is frozen, and step one past the highest reached when it restarts. An offset rather than a counter of our own, so a game that does neither keeps its own numbering with the offset at zero. Both publishers of a frame number add it; applying it in one leaves the two disagreeing by the width of a stall, and a pose stored under one key is looked up under another. Progress is "exceeded the highest number seen", not "differs from the previous one": a game can push several view families per frame, so the previous number repeats or alternates between calls. Equality against it either fires throughout ordinary play, shifting the key a frame every frame, or almost never fires and hides a real stall. Gylt and Silent Hill 2 repeat a number within a frame, The Outer Worlds 2 sends one per call, so no single title predicts which. A restart is told from extra view families by size: orders of magnitude against a few. The stall is decided where the number is read, not precomputed where it is published, so a game that stops running that path cannot leave a flag stuck at its last value. The threshold is 250ms of time, because "produced no frame" and "produces frames more slowly than we present" differ only in duration. The same stall breaks the native stereo fix, which takes the left eye from the game's backbuffer and the right from a scene capture UEVR drives. Behind a fullscreen menu the game stops drawing the world into its backbuffer while the capture keeps rendering one, so the right eye holds a frozen world while the left correctly holds the menu on black. Take both eyes from the backbuffer while stalled, as the null scene capture branch already does. Log both stall edges with the key, and log the pattern the game submits, once each. The game carries on rendering, so nothing else in the log changes when its number freezes.
Remleo
force-pushed
the
paused-frame-counter
branch
from
September 4, 2026 19:36
ab70f67 to
b6b5746
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
UEVR uses the frame number it reads from the game as the key into its pose pipeline, and that number is
not always monotonic: it freezes while some games sit in a fullscreen menu, and it restarts from 1
on a level load. Either way a pose gets read from a slot belonging to another frame, and the view comes
loose from your head and swims against it, faster the longer it lasts. That is the main problem here and
most of what follows is about it -- Problem 1, in
FFakeStereoRenderingHook.cpp.On top of it, a smaller one. On the
VR_NativeStereoFixpath UEVR keeps rendering the world into theright eye after the game has stopped drawing it, while the left eye shows the black backdrop the game
switched to. The left eye is the correct picture of the two -- the right eye is the one that should have
stopped as well. Problem 2, in
D3D12Component.cpp.Apologies for the length. The code is small -- most of what follows is why it is shaped this way,
because the obvious version ("compare the number with the previous one") is the one that breaks, and
only measurements show it. If you would rather see the defects first, there are two videos in the first
comment.
Problem 1: poses read from the wrong slot
What goes wrong
The game's frame number is used directly as the pipeline key:
OpenXR::on_pre_render_game_threadrefreshes a slot in only two cases:prev_frame_count + 1 < frame_countAny other revisit keeps the predicted display time that is already in the slot.
xrLocateViewsthenpredicts for a moment that has already passed, and the further behind it is, the faster the image
swims.
Both misbehaviours get there their own way, and there may well be more of them:
Same visible result, same cause: a stale predicted display time reaching
xrLocateViews.The change
Why it is shaped this way:
the offset stays zero.
another backwards jump -- and stalls here reached 159 frames.
extra families differ by a few.
begin_render_viewfamilyand the render thread path bothname a frame to the runtime. Apply it in one and the two disagree by the width of a stall, so a
pose stored under one key is looked up under another. The render thread path is only spared this by
returning early when
VR_NativeStereoFixis on -- which is not a guarantee.Why not
number == previous number?Because a game can push several view families per frame, and then that test breaks in both
directions:
==testMeasured, not reasoned:
equality test in place
Which pattern a game shows is up to the game, so a single title proves nothing either way.
Exceeding the highest number seen is immune to both.
Deciding that the game has stalled
Both fixes rest on this one question and pass nothing else between them:
is_game_frame_stalled()in
VR.hpp, answered from the timestamp of the last real advance of the number.running that path cannot leave a stored flag stuck at its last value.
differ only in duration. A count of presents would depend on the game's frame rate against the
headset's; a count of calls, on how many view families the game pushes.
Problem 2: the right eye renders a world the game has stopped drawing
On the
VR_NativeStereoFixpath only, and only while the number is frozen. It calls the detector aboveand nothing else.
What goes wrong
VR_NativeStereoFixbuilds the stereo pair from two different sources:While a fullscreen menu is up, the game stops drawing the world into its backbuffer. That is deliberate
-- the menu is meant to sit on a black backdrop. UEVR's scene capture does not know that and keeps
rendering the world.
Nothing is wrong with the left eye: it is showing what the game presents. The fault is on the right --
it holds a still picture of a world the game has stopped drawing, and with one eye holding a scene and
the other holding none of it, turning your head reads as the image rotating the wrong way.
The change
While the game is stalled, take the right eye from the backbuffer as well:
Both eyes then show what the game is actually presenting -- the menu, on the backdrop the game intended
-- and the capture comes back on the frame the game resumes. The backbuffer is already what the existing
m_scene_capture_tex == nullptrbranch feeds the right eye, so this routes into behaviour that is therealready.
Why one PR and not two
Two defects, two places, but they hang off one shared piece:
way round.
sequential ones, with the detector travelling in whichever lands first.
timer.
The detector is also the part worth arguing about -- progress by the highest number rather than the
previous one, decided at the point of use, on a time threshold. Together it gets reviewed once.
Happy to split it either way if you would rather -- say which and I will.
Logging
Neither the stall nor the pattern is visible any other way: the game keeps rendering, so nothing else
in the log changes when its number freezes.
an unbounded test would report every stall as the very pattern it has to be told apart from:
several view families make a run of several, a stall makes a run of hundreds.
Reproducing it
Both profiles I used are attached in the first comment.
The freeze shows both problems at once. The Outer Worlds 2, with its profile -- it is what makes
the menu usable in VR:
The restart shows Problem 1. Gylt, with its profile. Inject while the game sits in its
main menu, not after loading a save: UEVR has to be hooked before the level load, otherwise it never
sees the number restart and nothing goes wrong.
The Outer Worlds 2 profile only sets mod values that are already in master --
VR_AimMethod,VR_RoomscaleMovement,VR_CameraForwardOffset/UpOffset,UI_Distance/UI_Size-- and callsrecenter_view(). It hasUObjectHook_EnabledAtStartup=falseand its scripts never touchUObjectHook, so it does not depend on anything unmerged.
Rolling your own profile instead? Then turn
VR_NativeStereoFixon, or there is nothing to see:with it off neither game shows the defect at all. Both of my profiles also have
VR_NativeStereoFixSamePasson, and that pair is what I measured. I did not tryVR_NativeStereoFixonwith
VR_NativeStereoFixSamePassoff, so I cannot say whether that half matters here. The renderingmethod was
VR_RenderingMethod=0in both.Does this depend on
VR_NativeStereoFix?Yes. Measured both ways on plain master, with
VR_NativeStereoFixthe only thing changed:VR_NativeStereoFixonFor Problem 2 that follows from the code: that copy only exists on the
VR_NativeStereoFixpath.For Problem 1 it does not, so why
VR_NativeStereoFixdecides whether the swim shows up is worth a note.From reading the code, the render thread publisher bails out on a repeated number only when it is on:
That part is a reading, not a measurement -- I did not instrument it.
What it means for risk: with
VR_NativeStereoFixoff, this change is inert in every game I couldtest. The offset is still applied by both publishers, so the two cannot name a frame differently.
That is not a niche configuration, though. Without
VR_NativeStereoFixplusVR_NativeStereoFixSamePass, The Outer Worlds 2 renders shadows in one eye only: the game ships noInstanced Stereo permutations, so the second eye arrives as
eSSP_SECONDARYand parts of the rendererskip it on
IStereoRendering::IsASecondaryView().VR_NativeStereoFixSamePassis what flips thatenum back. All three games I tested have both on, and this one needs them. I would expect the same of
most modern UE titles -- that part is an expectation, not something I measured.
Scope
Problem 2's fix touches the D3D12 + OpenXR path only. The same scene capture feeds the right
eye in three more places:
Each sits next to a branch that already copies from the backbuffer, so the change would be the same
shape in all four. I can only test one: I have no D3D11 game that stalls, and The Outer Worlds 2 is
not playable on OpenVR at all for unrelated reasons. Happy to extend it if you would rather have all
four in one go, but three of them would be untested.
Tested
Release build, Quest 3 over Virtual Desktop, OpenXR, D3D12.
Every transition was checked against the log. The key lands exactly one above the highest value
it had used -- no gap, no overlap:
VR_NativeStereoFixoffThe Gylt row injected in the main menu is the case that used to break: it swam on master, and still
swam with only the freeze handled.
The
VR_NativeStereoFixoff row is a consistency check, not a regression -- nothing visibly broke therebefore or after. It is in the table because that is the configuration where the two publishers would
name a frame differently if the offset were applied in only one of them.
Sample lines:
Ordinary gameplay is untouched. With a game that neither freezes nor restarts, the offset stays
zero and the key is the game's own number.