Skip to content

fix(🤖): keep the last frame of a canvas whose Android surface is recreated - #544

Draft
YevheniiKotyrlo wants to merge 2 commits into
wcandillon:mainfrom
YevheniiKotyrlo:fix/retain-presented-frame
Draft

YevheniiKotyrlo wants to merge 2 commits into
wcandillon:mainfrom
YevheniiKotyrlo:fix/retain-presented-frame

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A canvas that draws once and stops, like the example's ImageBlur or any scene rendered on demand, comes back black once Android destroys its SurfaceView's surface, which takes the frame's buffers with it:

  • the app returns from the background: SurfaceView releases its surface whenever its window's own surface is gone, before it reads its surface lifecycle, so SURFACE_LIFECYCLE_FOLLOWS_ATTACHMENT keeps nothing (SurfaceView.updateSurface, measured on Android 14);
  • the view leaves its window and comes back: a screen covered by a native stack, a list item clipped by removeClippedSubviews (the default for FlatList on Android).

A TextureView keeps its frame in the background.

The example's SurfaceView First Frame diagnostic draws one frame on a SurfaceView canvas and stops. After the app goes home and comes back through its recents card, the canvas shows the stage behind it on #561: at 3 of 3 returns on a Galaxy S23 (Android 14) and on a Mi MIX 3 (Android 10), and 2 of 2 on an Android 13 emulator. With this change it shows its frame at every return on all three (measurement).

Solution

  • WebGPUSurfaceView.surfaceDestroyed reads the surface's frame back with PixelCopy (SurfaceFrameCapture) before the canvas lets the surface go: Android keeps the surface valid until the callback returns, and it is the last moment the frame exists. The canvas keeps the copy as its offscreen frame (SurfaceInfo::keepFrame), which the next attached surface presents first, unless the canvas draws a newer frame before.
  • Nothing runs per frame: the read happens once, on the main thread, when the surface goes. On a 1080-pixel-wide canvas it took 22 ms on the S23 and 58 ms on a Mi MIX 3 (Android 10), while the app left the screen.
  • keepFrame writes RGBA8 and BGRA8 drawing buffers; a canvas configured with another format comes back with its next frame, as before. The offscreen texture gains CopyDst for it.
  • drawElementImageToTexture's bitmap reader and the kept frame share one locked-bitmap class in cpp-adapter.cpp.
  • canvas.mdx says which canvases keep their frame.

Tests

No automated test: the e2e harness evaluates JavaScript and reads textures, and the defect is in what a recreated native surface shows. The SurfaceView First Frame diagnostic reproduces it, and so does TransparencyMode's loop-off state (#545): stop the canvas drawing, then send the app to the background.

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 (16 tests) · the example's iOS build for the simulator SDK, on 9dd79b8d, whose iOS-compiled files this head keeps. CI's Android e2e step (debug build, Metro with CI=true) on an Android 15 emulator: 220 of 222, as on main c080934e; 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, 222 of 222; 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 221 of 221 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.

Base

Stacked on #561, whose single detach no longer presents an empty texture on the next surface; this shows its commit until that merges.

@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Device measurement — before / after

This PR's base, #561 — a SurfaceView canvas that drew once and stopped comes back from the background showing the stage behind it.

This PR — it comes back showing its frame.

the SurfaceView First Frame diagnostic, Home and back through the recents card base #561 a485b36c this PR 1876c015
Galaxy S23 (Android 14, API 34), three returns the stage, 3 of 3 the frame, 3 of 3
Mi MIX 3 (Android 10, API 29), three returns the stage, 3 of 3 the frame, 3 of 3
emulator, Android 13 (API 33), two returns the stage, 2 of 2 (the first after 3.2 s of the frame) the frame at once, 2 of 2

The colour at the canvas's centre, read in a screenrecord of each return and in a screenshot 2 s after it. On the phones, release builds of the example opening the diagnostic at launch, #565's JavaScript over each tree's native code; on the emulator, debug builds with this PR's JavaScript.

The read-back runs once, as the surface goes, and nothing runs per frame: PixelCopy inside surfaceDestroyed took 22 ms on the S23 and 58 ms on a Mi MIX 3 for a 1080-pixel-wide canvas, measured on the previous head, whose read-back code this head keeps.

Device evidence — before / after, before the rebase onto #574

before — main after — with this PR

Galaxy S23 (Android 14), ImageBlur opened and left to draw, then Home, then back to the app; each screenshot is 4 s after the return. Both builds run the same JavaScript; only the native code differs. On that head (main 8f664fca, this PR 9dd79b8d), TransparencyMode with its loop off showed the same on the S23 and on a Mi MIX 3 (Android 10): a SurfaceView canvas black after the return on main, the last frame 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/retain-presented-frame branch from b9735a8 to 587c530 Compare October 16, 2026 15:26
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 marked this pull request as draft October 16, 2026 17:05
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/retain-presented-frame branch from 587c530 to c346cfa Compare October 16, 2026 19:08
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/retain-presented-frame branch from c346cfa to 9dd79b8 Compare October 17, 2026 00:04
A SurfaceView canvas sits 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.
…eated

A SurfaceView's surface goes with its buffers when the app goes to the
background or the view leaves its window, and the canvas presented nothing
into the next one, so a SurfaceView canvas that draws once came back black.

surfaceDestroyed is the last moment that frame can be read: the view reads
it back there with PixelCopy and writes it into the canvas's offscreen
texture, which the next surface's attach presents. Nothing is copied while
the canvas draws.

The canvas docs say a canvas that stops drawing keeps its last frame.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/retain-presented-frame branch from 9dd79b8 to 1876c01 Compare October 18, 2026 19:52

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