Skip to content

fix(server_openvr): Linux direct mode frame sync (right-eye stutter, layers, texture bounds) - #3425

Open
fbinerd wants to merge 14 commits into
alvr-org:masterfrom
fbinerd:fix-linux-direct-mode-frame-sync
Open

fbinerd wants to merge 14 commits into
alvr-org:masterfrom
fbinerd:fix-linux-direct-mode-frame-sync

Conversation

@fbinerd

@fbinerd fbinerd commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Summary

I use ALVR on Linux with an NVIDIA card and a Quest 2. Since the switch to direct mode I kept running into the same problems: the right eye shook whenever I moved my head (in SteamVR Home, in the empty SteamVR scene and in games like VRChat) while the left eye was fine, the SteamVR menu flickered or disappeared over games, SteamVR Home looked broken, and in VRChat the image doubled when strafing because the frame rate dropped to around 58 fps.

The root cause is that ALVR read SteamVR's images at the wrong moment, only read the first layer, and held SteamVR back for almost a whole frame. This PR makes ALVR wait for the exact moment each image is ready (the same signal Steam Link uses), draws every layer, and only holds SteamVR when it really needs to. On my machine all of the problems above are gone and VRChat runs at 72 fps.

Issues this may fix:

Testing request: I could only test on NVIDIA. I would really appreciate tests from the community on AMD and Intel GPUs, with WayVR or other overlays, and with heavy games where SteamVR throttles the frame rate. If something looks wrong, ALVR's log will say if a texture had no SteamVR semaphore, which helps to tell where the problem is.

  • This PR respects our AI policy: I completely understand and "own" the coding decisions made, and can discuss them without relying on AI copy-pasted responses.
  • This PR's description contains a human-written summary of the content of the code.
  • Testing: see below.

Details

Six commits, each building on its own:

  1. Read SteamVR's textures by handle. Every submitted texture is imported the first time its handle is seen (same import as before) and drawn into the working image by a small compute shader, layer.comp. A change of layer 0's texture set (SteamVR compositor <-> SteamVR Home or a game) no longer rebuilds the renderer and encoder. Imports are released when SteamVR destroys a set.
  2. Draw only the submitted texture bounds. SteamVR Home renders into a sub-rectangle of a larger texture; the whole texture was streamed.
  3. Composite SteamVR's extra layers (the dashboard and other overlays over a game), which were ignored (TODO: Merge layers).
  4. Wait for SteamVR's per-texture timeline semaphores. NVIDIA's dma-bufs carry no implicit fences, so the fixed 4 ms sleep was the only guard, and no fixed value fits every load. Steam Link's driver waits on a timeline semaphore SteamVR keeps per shared texture; SteamVrSync.h finds the same one (through SteamVR's resource state in shared memory, which is undocumented: the header lists every assumption, how it was found, and that any failure falls back to the previous behaviour) and gets its fd through the public IVRIPCResourceManagerClient. Textures without a semaphore keep the implicit-fence wait and the 4 ms guard.
  5. Hold PostPresent only until compositor frames are read. Holding SteamVR's submit thread until the next vsync tick made vrcompositor's WaitForAcquire time out and render into the texture being read (SteamVR Home shook). Now only frames that read vrcompositor's own textures are held, until read (~2 ms); upstream pacing is kept while nothing is streamed and while SteamVR throttles an application.
  6. Skip quietly the last frame of an exited application (the recurring "Textures not found in handle map" when Home or a game exits).

How the right-eye problem was measured: the encoded stream was recorded and, for each eye half, the frame-to-frame image motion was correlated with the pose SteamVR submitted for that frame. Before: left eye matched its own frame (0.97), right eye the next frame (0.996). After: both eyes on their own frame (0.997 / 0.998).

Testing

  • Linux only; nothing that Windows builds or runs changes (graphics.rs, bindings.h and alvr_server.cpp only gain the shader bytes and two symbols, as for the other Linux shaders).
  • PC: Intel Core i5-13400, NVIDIA GeForce RTX 3060 (driver 615.71.09, NVENC), 32 GB RAM, Zorin OS 18.1 (kernel 7.0, X11), SteamVR 2.17.10. Headset: Quest 2, ALVR client, HEVC 72 Hz.
  • SteamVR Home with the dashboard opened and closed, the empty SteamVR scene, and VRChat (head turns, strafing, VRChat menu dragged, SteamVR menu): no right-eye stutter, 72 fps.
  • Transitions: Home <-> game, game exit, client reconnect, encoder rebuild.
  • Not tested, help welcome: AMD and Intel GPUs (the semaphore is expected to be present there too; if not, the previous fence path is used), and an application that SteamVR throttles.

Related: #3412 (right-eye stutter and Home on AMD), #3378 (item 3; item 4, the WayVR overlay behind the game, may be fixed by commit 3 but was not tested with WayVR). The colour fix is separate: #3423.

fbinerd and others added 6 commits October 16, 2026 19:48
The renderer imported the six textures of the first layer 0 swap set when
it was built and copied two of them into the working image each frame. A
frame from any other set (SteamVR switches between the compositor's set and
a scene application's, e.g. SteamVR Home) tore down and rebuilt the
renderer and the encoder, a stall each time.

Now each texture is imported the first time its handle is submitted, the
same way as before (OPAQUE_FD memory, SteamVR's format with MUTABLE_FORMAT,
a UNORM view of an sRGB format, so no new requirement on any vendor), and
drawn into the working image by a small compute shader, layer.comp. With
the same size and the whole texture it samples each texel centre, so the
result equals the old copy; a set of another size is scaled into the
working image, which keeps the size of the first set. The encoder is built
once per connect or rebuild. The renderer takes a list of layers; this
commit passes layer 0 only.

Imports of a set SteamVR destroys are released (DestroySwapTextureSet,
DestroyAllSwapTextureSets) after dropping any pending job, under the
encoder lock, with the generation moved so a job already taken is dropped
rather than importing an fd about to be closed.

The writers' implicit fences are now exported per texture and polled on
the CPU (same cap and disarm as before) instead of also being imported as
GPU semaphores: once the poll has seen them signalled the GPU wait adds
nothing. The 4 ms guard sleep is unchanged.

layer.comp is compiled to layer.comp.spv and embedded like the other Linux
shaders (graphics.rs, bindings.h, alvr_server.cpp); those shared files only
gain the byte array and its two symbols, nothing Windows runs changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SteamVR submits, per eye, the valid sub-rectangle of the texture
(VRTextureBounds_t). SteamVR Home renders into part of a larger texture
(bounds around 0.4-0.96, changing with load) and the whole texture was
streamed, so the headset showed the image split, partly frozen or
misplaced.

layer.comp now stretches each eye's submitted rectangle over the eye
(reversed endpoints keep a flip). Bounds that are not finite, out of
[0, 1] or empty make the layer unusable; for layer 0 the frame is dropped
with a warning.

The fix first lived in fbinerd/alvr 037b7f2f, which cropped with
vkCmdBlitImage and therefore needed a graphics-capable queue; drawing with
layer.comp keeps upstream's queue choice.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SteamVR submits its dashboard and other overlays as layers after layer 0
while a scene application runs, but only layer 0 was streamed (TODO:
Merge layers): the SteamVR menu flickered or was missing over a game.

Layers 1..n are now drawn over layer 0 in submission order by layer.comp
in blend mode (straight alpha, on the sRGB-encoded values the stream
already carries). A layer whose textures are not one of ours, or whose
bounds are invalid, is skipped instead of dropping the frame.

MAX_LAYERS becomes static constexpr: std::min(m_submitLayer, MAX_LAYERS)
odr-uses it, and as a static const int without a definition it only linked
in release builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s on Linux

NVIDIA's dma-bufs carry no implicit fences, so the worker cannot see when
a texture is finished and relied on the fixed 4 ms sleep. No fixed delay
fits every load: per-eye analysis of the encoded stream (motion of each
eye half against the pose SteamVR submitted) showed the right eye, which
the compositor draws last, on the previous frame when read too early and
on the next one when SteamVR's next write overlapped the read; in VRChat
3-6 ms were still wrong and 8 ms cost 14 fps.

Steam Link's own driver waits instead on a timeline semaphore SteamVR
keeps per shared texture. The driver can reach the same one:
SteamVrSync.h finds it through the texture's resource state in SteamVR's
shared memory (undocumented; the header lists each assumption, how it was
found and what happens if it changes) and gets its fd through the public
IVRIPCResourceManagerClient. Present reads the value of the write just
submitted for every texture of the frame; the worker imports the semaphore
once and waits for that value on the CPU, capped at two frames, then reads.
A texture without a semaphore keeps the implicit-fence wait and the 4 ms
guard (on Mesa those fences do carry the writer's work). Semaphores and
mapped states are released with their texture set.

Measured on NVIDIA: the semaphore was present for every texture; most are
already finished when the worker gets to them, waits reach ~4 ms in
SteamVR Home and ~6 ms when a heavy application's frame ends late.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… read

PostPresent held SteamVR's submit thread until the next vsync tick
(PaceAfterPresent), almost a whole frame. vrcompositor's WaitForAcquire
then times out and it renders into the texture still being read; in
SteamVR Home, with the menu, the image shook.

While frames are streamed there is no such hold any more, except for
frames that read a texture of vrcompositor's own sets (the set's process
is vrcompositor): for those the value read at Present often does not yet
cover the right eye's write, and without a hold that write lands during
the read (the right eye showed the next frame). Those frames are held
until the worker has read them, at most one frame interval (about 2 ms
measured). Application textures are not held: holding them passes the
application's late GPU frames on to the compositor (VRChat fell to
60 fps).

Upstream's pacing stays while nothing is streamed (before a client
connects, while the encoder is set up) and while SteamVR throttles an
application, which is what honours the throttle hint. Every test ran that
way; one run without any pacing from SteamVR's start hit an NVIDIA Xid 32
in vrcompositor before the client connected.

Measured on NVIDIA with a Quest 2: SteamVR Home with the menu and VRChat
(head turns, strafing, menus) without the right-eye shake, both eyes on
their frame, 72 fps. An application SteamVR throttles is untested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When an application exits (SteamVR Home when a game starts, or the game
itself), SteamVR destroys its texture sets and the compositor still
presents one last frame naming them. That frame was logged as an error,
"Textures not found in handle map", on every such transition.

The driver now remembers the textures of the last few sets it was told to
destroy; a frame naming one of them is skipped with a debug line. A texture
that was never one of ours is still an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fbinerd
fbinerd force-pushed the fix-linux-direct-mode-frame-sync branch from 6149e92 to 332afeb Compare October 16, 2026 22:55
@fbinerd fbinerd changed the title fix(server_openvr): Linux direct mode right-eye stutter, SteamVR menu and Home fix(server_openvr): Linux direct mode frame sync (right-eye stutter, layers, texture bounds) Oct 16, 2026

@Vixea Vixea left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious if you see the occasional glitched frame with this on NV still or if this might be an amd issue only, other than that no comment from this quick overview review

@fbinerd

fbinerd commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

I haven't noticed glitched frames in my setup, but they may still be there. I'd need to analyse it calmly and over more time to be sure. There are things that likely need work for AMD, I don't have AMD hardware, so it's hard for me to work on that directly. What I could do is work on Intel, which shares the same Mesa/VAAPI path. That would fix the vendor-neutral parts (like the ownership transfer) and make it easier to finish the AMD side later with someone who has the hardware.

@Vixea

Vixea commented Oct 19, 2026

Copy link
Copy Markdown
Member

Sounds good, it'll probably need a little deep dive have you checked vulkan validation with these patches?

@csabia

csabia commented Oct 19, 2026 •

Copy link
Copy Markdown

Tested on AMD — works great.

Setup: RX 9070 XT, Mesa 26.2.4 (radeonsi, VAAPI encoder, AV1 10-bit, Constant 150 Mbps), Ryzen 7 9700X, Bazzite (Fedora 44, kernel 7.2.9, KDE Wayland), SteamVR beta, Quest 3 over Wi-Fi 6 (wired PC, −25 dBm / 2401 Mbps link on the headset). Max buffering frames: 1.2. Before this PR I had raised it to 2.0 to mask the stutter; it's no longer needed. Built ebec107 + this PR. I also kept the body of ~EncodePipelineVAAPI commented out, because the flush (avcodec_send_frame(encoder_ctx, nullptr)) segfaults vrserver on encoder rebuild on my system (see #3412).

Before (ebec107 / nightly 2026.10.12): stutter in the right eye only (left eye smooth), in Titanfall 2 (OpenXR mod under Proton), Moss II and No Man's Sky. Server FPS oscillated between roughly 70 and 110, and the game's frame interval alternated 10/12 ms.

After the PR, in a 46-minute Titanfall 2 session at 3288×3440 per eye (SteamVR supersampling 250%), ~156 Mbps:

  • no right-eye stutter; both eyes smooth and in sync;
  • server FPS flat at 90 in the ALVR graph;
  • game frame interval steady at 11.11 ± 0.07 ms;
  • vrcompositor summary at exit: 249,301 presents, 0 dropped, 0 reprojected;
  • only a handful of "WaitForAcquire timed out" events over the whole session;
  • SteamVR dashboard over the game: works (it's composited correctly now).

Glitched frames (re: @Vixea's question): yes, on AMD I still see an occasional flash/glitched frame: a few per session, rare and short. Once there were a few seconds of flicker plus stutter that then went away by itself. This is with "image corruption fix" enabled. Some of the hitches may line up with ~1 s stalls of the game itself (periodic, likely autosave/level streaming), but I can't tell yet whether the flashes do too. I can test with "image corruption fix" off, with HEVC instead of AV1 if useful.

Not tested: SteamVR Home (it got stuck on the loading screen and I disabled it). Before starting the game, vrcompositor also crashed twice at startup while Home was enabled, jumping to an invalid address right from main. I can't say whether that's related to this PR or to SteamVR itself; happy to collect more data if useful.

Thanks a lot for this!

@Vixea Vixea left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, after checking if overlay is pre-multiplied

pre-commit-ci-lite Bot and others added 7 commits October 24, 2026 00:47
SteamVR submits the layers above the scene (the dashboard, other
overlays) with premultiplied alpha, so blending them with straight alpha
applied the alpha twice and darkened their translucent parts (the laser,
the icon bar, antialiased edges, fades).

Checked by dumping the raw dashboard layer SteamVR gives the driver (one
composited R8G8B8A8_SRGB texture): across five frames with the menu open
no partly transparent pixel has a colour above its alpha, including the
edges of white text, which straight alpha would leave near full white.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion and cleanup - fixes many bugs from the original "working code"
…ue family

SteamVR's textures are created by another process. The Vulkan spec requires
an image backed by external memory to be acquired from
VK_QUEUE_FAMILY_EXTERNAL before use and released back afterwards. Every
frame, the renderer now acquires each SteamVR texture before reading it and
releases it after the reads, keeping its layout and content.

NVIDIA's driver and Intel ANV produced correct pictures without the
transfer (tested with and without it), but drivers that keep images
compressed, AMD's DCC in particular, may need it to resolve the compression
at the handover. It is not a proven fix for a visible artefact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Ency0810

Ency0810 commented Oct 26, 2026 •

Copy link
Copy Markdown

Tested on NVIDIA — works for me.

Setup: RTX 4060 Laptop, driver 615.78.08, CachyOS, SteamVR, Quest 3 over
the USB link, H.264 @ 600 Mbps.

I built the streamer from source following the wired-Quest guide
(https://blog.pax.moe/wired-quest-2-vr-on-linux), with this PR applied on
top of current master. Apart from this PR, my build differs from master only
in the nv-codec-headers version (13.0.19.x instead of 12.1.14.0, needed
because the 12.1 headers don't work on driver 580+). That is encoder-side
only and shouldn't affect direct mode, but I'm mentioning it for
reproducibility. I opened #3434 for that header bump separately — it is
unrelated to this PR and doesn't need to be resolved here.

The official build was not usable for me. With this build, gameplay is fine
— I don't see the stuttering or the glitched frames described in the thread.
AMD untested.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants