Skip to content

fix(🤖): present TextureView frames rendered on the main thread after the Choreographer frame - #613

Open
camchis wants to merge 1 commit into
wcandillon:mainfrom
camchis:fix/android-textureview-defer-present
Open

camchis wants to merge 1 commit into
wcandillon:mainfrom
camchis:fix/android-textureview-defer-present

Conversation

@camchis

@camchis camchis commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

Description

On Android with the TextureView backing, a canvas rendered from a main-thread loop (Reanimated useFrameCallback / requestAnimationFrame on the UI runtime) presents during the Choreographer frame's animation phase, before that frame's traversal. If a traversal is already scheduled, HWUI latches the new buffer in that same frame, and the SurfaceTexture frame-available callback (which sets TextureView's mUpdateLayer and invalidates) does not fire for it. So the next vsync has no traversal, and that frame's buffer is replaced by the following one before it is drawn. A loop presenting every vsync is shown at every other vsync: 60 fps on a 120 Hz screen. This PR presents those frames right after the Choreographer frame instead.

The rest was written with a coding agent.

Evidence

Perfetto trace on a Solana Seeker (Android 16, Mali, 120 Hz, Release build of our app): the app's QueuePresentKHR ran 120 times a second, but RenderThread Drawing ran 60 times a second. SurfaceTexture$1 (frame available) appeared only after presents that were not consumed in the same frame. react-native-skia's TextureView canvases, which present from a separate PresentThread (usually after the traversal), did not show the halving.

Fix

  • SurfaceRegistry.h: MainLooperPresents registers an eventfd on the main ALooper (ALooper_addFd), so deferred presents run right after the current Choreographer frame. For a surface that defers, SurfaceInfo::presentFrame() on the main thread marks _presentPending instead of calling _surface.Present() and wakes the looper.
  • presentDeferredLocked() flushes the pending present before anything else touches the surface: the next getCurrentTexture(), configureSurfaceLocked(), unconfigure(), detach(), and the surface swap in applyPendingAttach(). The applyPendingAttach() flush sits after its early return, because presentFrame() itself calls applyPendingAttach() and a flush at its top would undo the deferral.
  • recreateSurfaceLocked() (from releaseSurfaceForDevice(), the DAWN_WORKAROUND_DEVICE_DESTROY_BEFORE_SURFACE_RELEASE path) drops a pending present, like it drops _acquiredFromSurface: the replacement surface is unconfigured.
  • ~SurfaceInfo cancels its pending entry. The cancel takes the same lock the looper callback holds while it presents, so a surface can't be freed in the middle of its deferred present.
  • WebGPUView.surfaceCreated passes deferPresent = mKind == BackingViewKind.TEXTURE_VIEW through onSurfaceCreate, and cpp-adapter.cpp calls info->setDeferPresent(deferPresent).
  • Unchanged: SurfaceViews, the HardwareBuffer view, MakeWebGPUCanvasContextFromNativeWindow surfaces, iOS, and presents from any thread other than the main one (the JS thread, dedicated worklet runtimes).

Trade-off

A deferred frame is shown one vsync later. That is the latency any TextureView producer that presents after the traversal already pays, Skia's PresentThread included. A SurfaceView, which composites outside the view hierarchy, keeps presenting immediately.

Measurements

These numbers come from our app (fiddl) on react-native-webgpu 0.21.0 with the same change applied as a patch, not from this branch. Seeker, Release build, TextureView, the same flick script before and after, from dumpsys SurfaceFlinger --latency:

Canvas Before After
Repel (full-screen field) 66.8 fps (median 16.6 ms, p90 16.7 ms) 119.4 fps (8.3 / 8.3 ms)
Fireworks (GPU particles) 65.9 fps 113.9 fps (8.3 / 8.4 ms)
Vanishing ink 67.2 fps 114.8 fps
Poolside (water ripples) 66.3 fps 115.7 fps

A canvas paced to 60 fps on the 120 Hz screen went from a p90 of 24.9 ms to 16.7 ms.

What I ran on this branch

  • yarn build:android (example app, every ABI) and an arm64 assembleRelease. Both pass, with no warnings from the changed files.
  • yarn cpplint flags (as in CI) and clang-format on the changed C++ files are clean.
  • Not yet run on a device from this branch: the measurements above are from the 0.21.0 patch.

Reproducing

The example's Reanimated → UI Thread screen is a reproducer: it renders with requestAnimationFrame on the UI runtime into a default (TextureView) Canvas.

  1. On a 120 Hz Android device, open that screen. A Release build is the cleanest. The example's app.json lists only dist/res for Android, so bundle to dist/main.android.jsbundle and add it to resources.android. The demo also console.logs every frame.
  2. Find the window's layer with adb shell dumpsys SurfaceFlinger --list | grep com.reactnativewebgpu.example, then run adb shell dumpsys SurfaceFlinger --latency '<full layer name>' (quote the full name, hash prefix included).
  3. On main, consecutive present timestamps sit about 16.7 ms apart. With this branch they sit about 8.3 ms apart. SurfaceFlinger keeps only the last ~127 frames, so sample while the demo is running.

…the Choreographer frame

A render loop driven from the main thread (Reanimated frame callbacks)
presents in the Choreographer frame's animation phase, before its
traversal. When a traversal is already scheduled, HWUI latches the new
buffer in that frame and the SurfaceTexture frame-available callback does
not invalidate the TextureView, so the next vsync has no traversal and its
buffer is replaced before it is drawn: a loop presenting every vsync was
shown at every other one (60 fps on a 120 Hz screen).

For a TextureView's surface, presentFrame() on the main thread now marks
the present pending and an eventfd on the main ALooper runs it right
after the current frame. Anything that touches the surface first
(getCurrentTexture, configure, unconfigure, attach, detach) flushes it,
and recreating the surface for a destroyed device drops it. WebGPUView
passes the backing kind explicitly; SurfaceViews and presents from other
threads are unchanged.

This branch has not been deployed

No deployments
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.

1 participant