Skip to content

fix(🤖): back an opaque canvas with a TextureView before Android 12L - #562

Closed
YevheniiKotyrlo wants to merge 7 commits into
wcandillon:mainfrom
YevheniiKotyrlo:fix/opaque-texture-view-before-12l
Closed

YevheniiKotyrlo wants to merge 7 commits into
wcandillon:mainfrom
YevheniiKotyrlo:fix/opaque-texture-view-before-12l

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Problem

An opaque canvas with no android.surfaceType is a SurfaceView behind the window. Before Android 12L a SurfaceView removes its layer as it leaves its window, ahead of the window frame that stops punching its hole, so an opaque canvas leaving the screen — a conditional render, a navigation — can show a black frame where it was. Android 8 to 10 remove the layer in SurfaceView.onDetachedFromWindow (android-10.0.0_r47); Android 11 and 12 hand the removal to the render thread's positionLost, which runs after the detach has cleared the view's root and so applies it at once too; Android 12L applies it with the window's next draw (android-12.1.0_r27).

The example's opaque canvas diagnostic draws a red canvas over a blue stage. On a Mi MIX 3 (Android 10), 8 of 28 unmounts leave a black frame on main and none of 27 with this change; on an Android 11 emulator, 29 of 43 remounts do with #561 alone and none of 43 with this change (measurement).

Solution

  • BackingViewKind decides the backing view, as in react-native-skia: fromSurfaceType for a named surfaceType (HardwareBufferView still needs Android 10) and forAutoSurfaceType for "auto". Before Android 12L an opaque canvas gets a TextureView, unless it sets zOrderOnTop, which only a SurfaceView offers; from 12L it keeps the SurfaceView. surfaceType: "SurfaceView" still selects one on every release. WebGPUView keeps the kind as the enum rather than three integers, and the two decisions fix(🤖): show every frame a worklet presents on a TextureView canvas #542 and fix(🤖): stop a TextureView canvas from freezing the UI thread on Android 11 #543 make for a TextureView canvas, presenting behind the window's pending draw and, on Android 11, acquiring a frame only after the window draws, are BackingViewKind's too.
  • The canvas pages and the surfaceType TSDoc state the default, and the example's diagnostic gains a mounted switch.

This is wcandillon/react-native-skia#4194 for this repo.

A TextureView composites through the window with one more copy. On the Mi MIX 3 (Android 10) it is still the faster path under load: with 50,000 particles on screen it showed 1179–1191 frames in each of four 20 s windows, where a SurfaceView on the same native code showed 777–815, and the window's render thread spent 19.2–20.6% of a core compositing it. On a Galaxy S23 (Android 14) both show 120 frames a second and the TextureView costs the render thread 22.8–23.6% of a core, which is why 12L and later keep the SurfaceView (measurement).

Tests

BackingViewKindTest covers the decision across Android 8 to 14: a translucent canvas is a TextureView, an opaque one a TextureView before 12L and a SurfaceView from it, zOrderOnTop keeps the SurfaceView, a named surfaceType is honoured on every release, HardwareBufferView falls back below Android 10, and only a TextureView presents behind the window's pending draw, and on Android 11 alone acquires after the window draws.

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 · ./gradlew :react-native-webgpu:testDebugUnitTest (25 tests) · the example's iOS build for the simulator SDK. CI's Android e2e step (debug build, Metro with CI=true) on an Android 15 emulator: 221 of 223, main 8f664fca's 219 and the two cases #542 and #543 add to TextureViewPresent; the two failures, the YUV cases of ExternalTextureColorSpace and NativeBufferWrapper, fail on main there too. On devices the whole suite passes, 223 of 223: on a Galaxy S23 (Android 14), and 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). main passes 221 of 221 on both. Not run here: the SwiftPM example's build and the semantic-release dry run, which needs the repository's token.

Known limits

A canvas that names a SurfaceView still leaves black on Android 8 to 12; one that sets zOrderOnTop is above the window and punches no hole to leave. On Android 11 and 12 an opaque canvas no longer casts the frame-rate vote, which only a SurfaceView casts; surfaceType: "SurfaceView" keeps it.

This builds on #561, whose JVM test tier and diagnostic it extends, and on #543, which includes #542: before 12L an opaque canvas becomes a TextureView, which on main shows every other frame a worklet presents (#542) and on Android 11 freezes the UI thread for 4.3 s at a time (#543). It shows their commits until they merge.

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.
…oid 11

Android 11's Vulkan loader gives a TextureView's queue a 4.29 s dequeue
timeout where it means none, and a canvas presenting on the UI thread
then waits there for a buffer only that thread's next draw gives back.
On Android 11 a TextureView canvas acquires only once its window has
drawn the frame presented last, rendering into the offscreen texture
meanwhile, so a frame presented on the UI thread waits behind the
window's pending draw there too. The TextureViewPresent spec runs on
the UI thread as well.
@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — on main, an opaque canvas unmounted on Android 10 can leave one black frame: its SurfaceView has removed its layer while the window still shows its previous frame, the one with the hole and the old "mounted: true" button.

FIXED — with this PR the canvas is a TextureView there, and the stage follows the canvas's last frame directly.

before — main
after — with this PR

Mi MIX 3 (Android 10), debug build of the example app, the opaque canvas diagnostic: three consecutive screenrecord frames across one unmount, cropped below the status bar. Both builds run the same JavaScript; only the native code differs.

Device measurement — before / after

Before — on the Mi MIX 3 (Android 10), 8 of 28 unmounts leave a black frame where the canvas was on main; on an Android 11 emulator, 29 of 43 remounts do with #561 alone.

This PR — no departure leaves a black frame on either.

departures showing a black frame before this PR ca5beaed
Mi MIX 3 (Android 10, API 29), unmounted 8 of 28 (main) 0 of 27
emulator, Android 11 (API 30), remounted 29 of 43 (#561 80175493) 0 of 43

Release builds of the example opening the diagnostic, this PR's JavaScript over the native code each row names and over this PR's; two 58 s screenrecord recordings per build per row, every frame classified over the canvas area. The remount row compares against #561 because on main the arriving canvas's own black hole covers the departure.

Android 12 and 12L come from a build of the same change on an earlier main (08686f97): remounted on Android 12 (API 31), 14 of 58 departures black with #561's change alone and none of 58 with this PR; on Android 12L (API 32), none of 58 with either, the canvas still a SurfaceView.

Cost under load

Particles, 50,000 particles, four 20 s windows each a SurfaceView a TextureView
Mi MIX 3 (Android 10), frames shown 777–815 1179–1191
the same, JS thread 24.1–25.8% of a core 26.6–28.5%
the same, the window's render thread none 19.2–20.6%
Galaxy S23 (Android 14), frames shown 2397–2398 2397–2400
the same, JS thread 25.1–25.7% 21.9–22.2%
the same, the window's render thread none 22.8–23.6%

Release builds of the example opening Particles, on react-native-webgpu-dawn 154.3.0, with #561 and #560's asset loader so a release build loads the scene's texture, the backing named with surfaceType so both builds run the same native code, the builds alternating. The frames are those of the layer that shows the canvas, from SurfaceFlinger's timestats: the SurfaceView's own layer, or the window's, which composites the TextureView. On the Mi MIX 3 the JS thread renders the extra frames; on the S23 both reach the panel's 120 Hz, and the render thread's share is what compositing the TextureView costs. Particles presents from the JS thread, so the TextureView paths of #542 and #543 do not run here.

An opaque canvas is a SurfaceView behind its window, and the window opens
its hole once the SurfaceView reports its draw finished. WebGPUSurfaceView
reported it as soon as surfaceChanged returned, so the hole opened on a
surface with no frame and showed black until the first one landed.

The view now holds the report until a frame is presented into the surface,
as GLSurfaceView holds it until its next frame. attachSurface takes a
callback for the surface's first present, and each surface carries a token,
so a report for a replaced surface is ignored. Before Android 14 the wait is
bounded at a second; from Android 14 the platform bounds it. A report that
goes out without a frame, at that second or for a later surface of the view,
which SurfaceView counts as reported already, no longer opens the hole: the
view punches it, and adds it to the window's transparent region, only while
its surface has a frame. A context configured before its surface exists, or
left by a lost one, no longer presents an empty frame when the next surface
attaches, and detach is one operation for a lost surface and a destroyed
view.

The canvas docs say when the hole opens, the example gains a SurfaceView
First Frame diagnostic that can also draw its first frame late,
FirstFrameDrawGate is unit tested, and CI runs the Android library's unit
tests.
Before Android 12L a SurfaceView removes its layer as it leaves its window,
ahead of the window frame that stops punching its hole, so an opaque canvas
leaving the screen can show a black frame where it was. An opaque canvas
with no surfaceType now gets a TextureView on those releases, unless it sets
zOrderOnTop, which only a SurfaceView offers; surfaceType: "SurfaceView"
still selects one.

BackingViewKind decides the backing view, as in react-native-skia, and is
unit tested. The canvas docs and the surfaceType TSDoc state the default,
and the example's opaque canvas diagnostic gains a mounted switch.
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Closing: #574 makes the default a TextureView and backs a canvas with a SurfaceView only when it names one, so there is no automatic choice left to change before 12L. The departure this PR avoided still happens on an explicitly named SurfaceView up to Android 12; #578 adds that to the docs. On a Mi MIX 3 (Android 10), a release build on main's natives left no black frame in 12 unmounts of a canvas that names no backing view, and one in 5 of 12 unmounts of a named SurfaceView.

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