Files
daniel-lynch--ovrplugin-ope…/docs/research/lever2-detect-skip-black-handoff.md
T
Daniel LynchandClaude Opus 4.8 a72a79ad29 Initial public release: OVRPlugin→OpenXR interoperability shim
An independent reimplementation of Meta's libOVRPlugin ABI on top of OpenXR, so
VrApi-era Meta Quest VR titles can run on non-Meta OpenXR runtimes (Monado,
Steam Frame) instead of being locked to Meta hardware. Original code only — no
Meta/Epic/Capcom binaries, headers, or assets. Includes a desktop harness that
drives the shim against Monado headless.

Scope/legal: interoperability; entitlement handling is out of scope. See README
for the legal/scope section and docs/ for the research trail and design notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6sFYGXZPsq3v7xtcDES6g
2026-06-29 00:48:48 -04:00

87 lines
5.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Lever 2 handoff — detect dropped/black frames and reproject instead of presenting black
Entry point for implementing the elegant fix to the in-game black. Read alongside the
auto-memory: `black-is-not-submit-timing-ue-renders-empty`, `ingame-black-render-content`,
`gameplay-eye-image-is-pure-black-confirmed`, `standing-vs-sitting-blackflash`.
## The settled root cause (do not re-litigate)
The in-game black is a **UE-internal frame-drop under GPU load**, NOT a shim bug:
- RenderDoc (`~/renderdoc-captures/RE4/work_frame.rdc`): under load UE renders only **~28
draws into its eye target vs ~198 in a normal frame** (a truncated frame), and the
**resolved eye = pure black** (MEAN [0,0,0], reliable ms=1 read).
- Ruled out conclusively: submit-timing (present-on-submit incl. full one-frame defer
still blacks — `debug.re4vr.submithook 2`/`defern 99`), image targeting (LAYER MISMATCH
count = 0, stage==acquiredIndex), empty-frame submission (SUBMIT-BLACK/COMPOSE-EMPTY = 0).
- Load-gated: bridge (sparse) never blacks; house/dense geometry blacks; "especially while
casting" (extra load). The game does NOT self-scale from our GPU-time feed.
## The idea
When UE hands us a truncated/black frame, **don't present it** — re-present the **last
good** eye image with the current frame's pose, so the OpenXR compositor **timewarps/
reprojects** the last good content to the new head pose. A reprojected (slightly stale)
frame is far better than a black flash. This is exactly the "app didn't produce a new
frame" path that compositors are built for; our black frames currently defeat it.
## Two hard parts
### A. A cheap "this frame is bad" signal (the crux)
Per-frame GPU readback to detect black is too costly + unreliable (observer effect; see
`texture-dump-is-unreliable-probe`). Candidate signals, cheapest first:
1. **Post-hitch heuristic:** the truncated frame is the RECOVERY frame after a stall
(RenderDoc: "recovery frame was 26 draws"). We already detect HITCH (dt>20ms in the
FRAME trace). Try: skip+repeat the 1–2 frames following a detected hitch. Coarse but
zero new cost; test first.
2. **Submit/command-buffer count via the vkQueueSubmit hook (already built,
`debug.re4vr.submithook 1`):** a truncated frame issues fewer submits / command buffers.
Instrument submits-per-frame (between END markers) and correlate with perceived black.
Under load we saw ~6 submits/frame normal — a dropped frame may show fewer. Needs a
black ground-truth to calibrate (hard without readback; use the post-hitch frames as a
proxy, or a one-off RenderDoc cross-check).
3. **GPU frame time over budget:** `g_gpuFrameMs` already tracked; but it's the PREVIOUS
frame's dt (lagging), so use it to predict the next frame is at-risk, not to gate the
current one.
Recommendation: start with (1) post-hitch repeat — simplest, no new signal — and measure.
If it helps but is too coarse, add (2) via the hook.
### B. Re-presenting the last good frame
OpenXR requires xrEndFrame every frame after xrBeginFrame; you can't simply skip present.
To reproject, present the LAST GOOD eye image again with the current `predictedDisplayTime`
+ located views (the runtime timewarps it). Mechanics:
- Keep a reference to the last-good eye image. Two options: (a) DON'T release frame N-1's
swapchain image and re-submit it (risky — holding across frames is what made
`pipeline=1` unstable; see `deferred-flush-unstable-abandoned`), or (b) **copy** the last
good eye image into a shim-owned VkImage (the copy-ring infra already exists:
`xrr_vk_alloc_images`, `shimImages[]`, the copy path in end_frame) and present a normal
fresh swapchain image blitted from the held copy. (b) avoids the cross-frame-hold
instability.
- On a bad frame: skip UE's (black) image, blit last-good copy → the acquired swapchain
image, submit the previous composition's layer with the CURRENT pose/displayTime.
- Gate the whole thing behind a new `debug.re4vr.skipblack` prop (default 0), like the
other levers, so the known-good path is untouched.
## Key code locations (shim/src/xr_runtime.c unless noted)
- `xrr_end_frame` sync branch (~1053): where present happens; add the skip+repeat here.
- FRAME/HITCH trace (~1123): the hitch signal (dt>20ms) for heuristic (1).
- `build_composition` (~620): the composition we'd re-submit with updated pose.
- Copy-ring infra: `xrr_vk_alloc_images` / `shimImages[]` (vk_session.c) + the copy-ring
path in end_frame (~922) — reuse for holding/blitting the last-good image.
- vkQueueSubmit hook + `xrr_on_ue_submit` (~820): submits-per-frame instrumentation for
signal (2). `debug.re4vr.submithook 1` = instrument.
- Pose update for reprojection: `xrr_eye_fov_tangents` + the located views in `g_xr.views`.
## Validation
Device: Quest 2 `<redacted-serial>` (USB). Build/deploy:
`./shim/build_android.sh && ./packaging/repack.sh && adb -s <redacted-serial> install -r packaging/out/re4vr-shim.apk`
(NOTE: repack silently bundles the LAST successful build — always confirm the build had no
errors and check `shim/build/arm64/libOVRPlugin.so` timestamp before repack.)
Test: Standing, walk off the bridge toward the house (reliable black trigger), `trace=1`.
Success = black flashes replaced by (at worst) brief reprojection judder, not black.
## Prerequisite vs Lever 1
If Lever 1 (force lower GPU load so UE completes frames: `ffr=3` + `debug.re4vr.resscale`
< 100 + `sscap=1`) sufficiently stops the drops at acceptable quality, Lever 2 may be
unnecessary or only needed for the worst spikes. Decide after the Lever 1 result.
```
debug.re4vr.resscale = percent of eye size (default 100; e.g. 75, 50). New this session.
```