Skip to content

fix(🤖): open a SurfaceView canvas's hole with its first frame - #561

Open
YevheniiKotyrlo wants to merge 1 commit into
wcandillon:mainfrom
YevheniiKotyrlo:fix/surfaceview-first-frame-hole
Open

YevheniiKotyrlo wants to merge 1 commit into
wcandillon:mainfrom
YevheniiKotyrlo:fix/surfaceview-first-frame-hole

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A SurfaceView canvas on Android sits behind its window, and the window punches a hole where the canvas sits once the SurfaceView reports its draw finished. WebGPUSurfaceView implements SurfaceHolder.Callback, not Callback2, so that report goes out as soon as surfaceChanged returns (SurfaceCallbackHelper): the hole opens on a surface with no frame, and black shows through it until the canvas presents.

The registry adds black of its own: a context configured before its surface exists creates an empty offscreen texture, and so does switchToOffscreen when a surface goes, and the next surface's attach blits that texture and presents it as a frame.

The example's new SurfaceView First Frame diagnostic mounts a SurfaceView canvas over a blue stage and draws its first frame, solid red, half a second after its device is ready, or a second and a half with its late first frame switch. With the late first frame, a remounted canvas on main is black until that frame, 1546–1565 ms on a Galaxy S23 (Android 14), 1539–1542 ms on a Mi MIX 3 (Android 10) and about 1.5 s on an Android 13 emulator; with this change the stage shows until the first frame on all three (measurement).

Solution

  • WebGPUSurfaceView implements Callback2 and holds the drawingFinished runnable of surfaceRedrawNeededAsync until a frame is presented into the surface, as GLSurfaceView holds it until its next frame (FirstFrameDrawGate).
  • attachSurface takes a callback for the surface's first present, a frame or the blit of the latest offscreen frame onto it, which SurfaceInfo runs once, on the presenting thread and outside its lock. The frame-presented callback the vote uses skips that blit, and the blit is what a new surface shows first. For a SurfaceView the callback reports back to its WebGPUView, posted to the main thread; that report can arrive after its surface was replaced, so each surface carries a token and a report for another one is ignored.
  • Before Android 14 the wait is bounded at one second: a canvas that never presents, or a present that fails, would otherwise hold the window's draw report for good. From Android 14 the platform bounds it itself (SurfaceSyncGroup's TRANSACTION_READY_TIMEOUT), so the gate sets no deadline there. A view leaving its window reports the draws it holds: Android 12L settles them itself as the view leaves (android-12.1.0_r27), but Android 13's SurfaceSyncer has no timeout and does not.
  • The hole opens only over a frame. A report that goes out without one, at that second or for a later surface of the same view, which SurfaceView counts as reported for good, would still open it on an empty surface: WebGPUSurfaceView punches the hole in dispatchDraw, and adds itself to the window's transparent region, only while FirstFrameDrawGate.hasFrame(), and redraws when that changes. Until then the window shows the views behind the canvas.
  • configure without a surface no longer creates the empty texture, and detach, now one operation for a lost surface and a destroyed view (it replaces switchToOffscreen and detachSurface), creates none either: the offscreen texture appears with the first frame rendered into it, so a surface that attaches before one is presented nothing. On iOS the same order no longer presents an empty frame to the layer.
  • canvas.mdx says when the hole opens.

This is the SurfaceView half of wcandillon/react-native-skia#4191 for this repo, with the same gate. Its other half, a hole the canvas does not bound on Android 10 and 11, does not apply here: WebGPUView clips its children.

Tests

FirstFrameDrawGateTest drives the gate with a recording host: a draw report waits for its own surface's first frame, a frame reported for a replaced surface opens nothing, the one-second bound reports a draw whose frame never comes before Android 14 and no bound applies from it, a detach reports the draws it holds, and the hole stays closed until the surface's own first frame, past the bound and on a later surface. The library had no JVM tests, so build.gradle adds JUnit and CI runs :react-native-webgpu:testDebugUnitTest after the example's Android build. The hole itself shows only in what the window composites, so the numbers above come from 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 · ./gradlew :react-native-webgpu:testDebugUnitTest (16 tests) · the example's iOS build for the simulator SDK, on 80175493, 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 iPad Pro 12.9-inch (4th generation, 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.

Known limits

Before Android 12L a SurfaceView canvas leaving the screen can still show one black frame, as on main: its SurfaceView removes its layer before the window frame that stops punching its hole.

A SurfaceView that gets a new surface, scrolled back into view by a removeClippedSubviews list or back from the background, shows the views behind the canvas until the canvas presents into it. The canvas presents on its rendering thread after the window has drawn, so a canvas that keeps drawing opens its hole a frame or two later on a Galaxy S23 (Android 14), and one that stopped drawing comes back when #544 presents its last frame.

@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Device measurement — before / after

main — with the diagnostic's first frame 1.5 s late, a remounted SurfaceView canvas is black until that frame: 1546–1565 ms on a Galaxy S23, 1539–1542 ms on a Mi MIX 3, about 1.5 s on an Android 13 emulator.

This PR — the stage shows until the first frame, and the hole opens with it.

first frame 1.5 s after the device main c080934e this PR a485b36c
Galaxy S23 (Android 14, API 34), three remounts black 1546 / 1565 / 1559 ms the stage 1614 / 1616 / 1621 ms, no black
Mi MIX 3 (Android 10, API 29), three remounts black 1540 / 1539 / 1542 ms the stage 1571 / 1529 / 1535 ms; two remounts show one 13–14 ms black frame as the old canvas leaves, the departure in Known limits
emulator, Android 13 (API 33), two remounts the stage 35 / 31 ms, then black 1547 ms / 818 ms to the recording's end the stage 1583 / 1580 ms, no black

On the phones, release builds of the example opening the diagnostic at launch, #565's JavaScript (which carries this diagnostic) over main's native code and over this PR's; on the emulator, debug builds with this PR's JavaScript. screenrecord while Remount is tapped, the colour at the canvas's centre classified in every recorded frame and timed from the recording's own frame timestamps.

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

before — main after — with this PR

Galaxy S23 (Android 14), debug build of the example app, the SurfaceView First Frame diagnostic. Each picture is the screenrecord frame just after Remount, about half a second before the canvas draws its first frame, solid red. In that recording the canvas stayed black for 563 ms on main; with this PR the stage showed for 614 ms and no frame was black. Both builds run the same JavaScript; only the native code differs.

On that head (main 8f664fca, this PR 80175493, release builds) the diagnostic's other cases held too: black 552–568 ms on the S23 and 534–539 ms on a Mi MIX 3 (Android 10) for a first frame half a second late, and black after a configure while hidden on both phones, each the stage with this PR; on an Android 12L emulator, black 1476–1865 ms against the stage.

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.

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