Skip to content

fix(🤖): show every frame a worklet presents on a TextureView canvas - #542

Open
YevheniiKotyrlo wants to merge 1 commit into
wcandillon:mainfrom
YevheniiKotyrlo:fix/textureview-present-pacing
Open

YevheniiKotyrlo wants to merge 1 commit into
wcandillon:mainfrom
YevheniiKotyrlo:fix/textureview-present-pacing

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A canvas backed by a TextureView (the default backing view) and drawn from a worklet on Reanimated's UI thread shows every other frame. The worklet runs in the main thread's Choreographer frame, so each frame is presented before the window draws. The TextureView takes a new buffer only once its frame-available listener has run (TextureView.applyUpdate()), which is after that draw, and the next frame, presented ahead of the next draw, overwrites the buffer it has not taken (BufferQueueProducer::queueBuffer). A frame presented from the JS thread lands between the main thread's frames and is not affected.

On a Galaxy S23 (Android 14, 120 Hz), a full-screen TextureView canvas redrawn on the UI thread at 120 fps composites 601–603 frames per window of about 10 s on main and 1202–1213 with this PR; on a Mi MIX 3 (Android 10, 60 Hz), 299 and 575–586 (measurement and recordings).

This is wcandillon/react-native-skia#4129's defect for this repo.

Solution

onSurfaceCreate gives a TextureView's surface a present poster. When SurfaceInfo::presentFrame() runs on the main thread for such a surface, it keeps the frame and has WebGPUView post presentPostedFrame() to the main looper: a plain message, which the window's traversal barrier holds until the window has drawn, as Skia's FrameScheduler presents a TextureView frame. One post is outstanding at a time. The next getCurrentTexture(), configure(), reconfigure() or unconfigure() presents a frame still waiting first, so no texture is acquired over one not yet presented, and a detach or a new surface drops it with its surface. Frames presented on another thread, or to a SurfaceView or HardwareBufferView, are presented as before.

Android 11 keeps presenting at once: its Vulkan loader gives a TextureView's buffer queue a 4.3 s dequeue timeout, and a present held behind the draw stretched those waits into 14 s freezes and an ANR on the Android 11 emulator. #543, stacked on this, holds the present there too, behind its acquire gate.

WeakViewCall now answers whether the view took the call: the posted present goes through it beside the frame-ready wake-up and the animation report, and a frame whose view is gone is presented on the spot. canvas.mdx says when a frame presented on the UI thread reaches a TextureView.

Tests

TextureViewPresent.spec.ts presents five colors on a TextureView canvas of the tests screen from a worklet on the UI thread, the first two in the same frame, and checks with drawElementImageToTexture that the canvas shows the last one two frames later. With the posted present removed it shows the fourth. The half rate itself shows only in what the window composites, so the numbers above come from dumpsys and screenrecord.

Verification

yarn lint · yarn tsc · yarn turbo run build --filter='./packages/*' · the Expo template's web build · yarn test:plugin · yarn check-dawn · yarn build:docs · cpplint with the package's flags · clang-format on the changed lines · the example's iOS build for the simulator SDK, on d60069d4, whose iOS-compiled files this head keeps. CI's Android e2e step (debug build, Metro with CI=true) on an Android 15 emulator: 221 of 223, main's 220 and the new spec; the two failures, the YUV cases of ExternalTextureColorSpace and NativeBufferWrapper, fail on main there too.

On a Galaxy S23 (Android 14) the whole suite passes, 223 of 223; main c080934e passes 222 of 222. On an iPhone 12 Pro Max (iOS 27.0.1), built and run as CI's iOS job does on a simulator (Debug build, Metro with CI=true), it passed 222 of 222 before the rebase onto #574 (main 8f664fca: 221 of 221). Not run here: the SwiftPM example's build and the semantic-release dry run, which needs the repository's token. Recordings in this comment.

@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Device measurement — before / after

main — a full-screen TextureView canvas that a worklet on Reanimated's UI thread redraws every frame composites every other frame: 601–603 frames per window of about 10 s on a Galaxy S23, 299 on a Mi MIX 3.

This PR — every frame, 1202–1213 and 575–586; a SurfaceView canvas composites as on main.

frames composited per window main c080934e this PR 0f8007bd
Galaxy S23, TextureView, drawn on the UI thread 601 / 602 / 603 1213 / 1202 / 1206
Galaxy S23, SurfaceView, drawn on the UI thread 1206 1205
Mi MIX 3, TextureView, drawn on the UI thread 299 / 299 / 299 575 / 586 / 582
Mi MIX 3, SurfaceView, drawn on the UI thread 581 574

Galaxy S23 (Android 14, API 34, 120 Hz) and Mi MIX 3 (Android 10, API 29, 60 Hz), release builds of the example opening a frame-rate probe screen at launch, the same JavaScript over main's native code and over this PR's: a full-screen canvas cleared every frame from a worklet on the UI thread, frames read from dumpsys SurfaceFlinger --timestats (the window's layer for a TextureView, the SurfaceView's own layer) over windows of about 10 s, from enabling the stats to dumping them.

Before the rebase onto #574 (main 46f42a6b, this PR 4844eea6, debug builds) the same lane read 601–603 against 1203–1237 on the S23, 296–298 against 570–618 on the Mi MIX 3, and a canvas drawn on the JS thread composited at the full rate on both builds (1190–1192 and 1196–1211 on the S23).

Device evidence — before / after

UNFIXED — on main, the window takes every other frame: each frame overwrites the previous one before the window draws it.

FIXED — with this PR, the window shows every frame.

before — main

after — with this PR

Galaxy S23, the UI-thread TextureView canvas clearing to the next of eight colors 120 times a second, recorded before the rebase onto #574 on the same present path this head keeps. Each strip is 200 ms of a screenrecord of the screen, one block per frame the window composited, as wide as it stayed on screen and in the color read from that frame: on main every other color, with this PR every color in turn. Over the whole 3 s recording, 182 frames on main and 358 with this PR.

YevheniiKotyrlo added a commit to YevheniiKotyrlo/react-native-webgpu-evidence that referenced this pull request Oct 16, 2026
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/textureview-present-pacing branch from bd7e065 to e57cc26 Compare October 16, 2026 15:27
YevheniiKotyrlo added a commit to YevheniiKotyrlo/react-native-webgpu-evidence that referenced this pull request Oct 16, 2026
YevheniiKotyrlo added a commit to YevheniiKotyrlo/react-native-webgpu-evidence that referenced this pull request Oct 16, 2026
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/textureview-present-pacing branch from e57cc26 to 4844eea Compare October 16, 2026 16:05
YevheniiKotyrlo added a commit to YevheniiKotyrlo/react-native-webgpu-evidence that referenced this pull request Oct 16, 2026
YevheniiKotyrlo added a commit to YevheniiKotyrlo/react-native-webgpu-evidence that referenced this pull request Oct 16, 2026
A canvas drawn from Reanimated's UI thread presents inside the main
thread's Choreographer frame, ahead of the window's draw. A TextureView
takes its next buffer only when the window draws, and its buffer queue
replaces a buffer it has not taken yet, so the next present replaced
every other frame and the window showed half of them.

A frame presented on the main thread to a TextureView surface now waits
for a present the view posts to the main looper, behind the draw the
window has pending, as react-native-skia's FrameScheduler does. The next
frame boundary presents a frame still waiting first, a detach drops it
with its surface, and frames presented on other threads, or to a
SurfaceView or HardwareBufferView, are presented as before.

Android 11 keeps presenting at once: its Vulkan loader gives a
TextureView's buffer queue a 4.3 s dequeue timeout, and a present held
behind the draw stretched those waits into 14 s freezes and an ANR on
the Android 11 emulator.

The weak handle the hardware buffer view's waker kept on its view is
shared by both calls from native, the canvas docs say when a UI-thread
frame reaches a TextureView, and the e2e suite presents frames from a
worklet on a TextureView canvas on the tests screen.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/textureview-present-pacing branch from d60069d to 0f8007b Compare October 18, 2026 20:13

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