From a485b36c8a9824c9e6b036a76a83c4e7931fafc1 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 18 Oct 2026 19:33:19 +0300 Subject: [PATCH 1/3] =?UTF-8?q?fix(=F0=9F=A4=96):=20open=20a=20SurfaceView?= =?UTF-8?q?=20canvas's=20hole=20with=20its=20first=20frame?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/ci.yml | 6 + apps/docs/content/api/canvas.mdx | 2 +- apps/example/src/App.tsx | 3 + .../src/Diagnostics/DiagnosticsList.tsx | 4 + .../src/Diagnostics/FirstFrameHole.tsx | 138 ++++++++++ apps/example/src/Route.ts | 1 + packages/webgpu/android/build.gradle | 1 + packages/webgpu/android/cpp/cpp-adapter.cpp | 43 +++- .../java/com/webgpu/FirstFrameDrawGate.java | 139 ++++++++++ .../src/main/java/com/webgpu/WebGPUAPI.java | 9 +- .../java/com/webgpu/WebGPUSurfaceView.java | 72 +++++- .../com/webgpu/WebGPUSurfaceViewWithSC.java | 2 +- .../java/com/webgpu/WebGPUTextureView.java | 2 +- .../src/main/java/com/webgpu/WebGPUView.java | 20 +- .../com/webgpu/FirstFrameDrawGateTest.java | 242 ++++++++++++++++++ packages/webgpu/apple/MetalView.mm | 2 +- packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h | 99 ++++--- 17 files changed, 722 insertions(+), 63 deletions(-) create mode 100644 apps/example/src/Diagnostics/FirstFrameHole.tsx create mode 100644 packages/webgpu/android/src/main/java/com/webgpu/FirstFrameDrawGate.java create mode 100644 packages/webgpu/android/src/test/java/com/webgpu/FirstFrameDrawGateTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d986f278dd..0c6a0f50c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -390,6 +390,12 @@ jobs: ORG_GRADLE_PROJECT_reactNativeArchitectures: x86_64 run: ./gradlew assembleDebug --warning-mode all --stacktrace + - name: Unit test the Android library + working-directory: apps/example/android + env: + ORG_GRADLE_PROJECT_reactNativeArchitectures: x86_64 + run: ./gradlew :react-native-webgpu:testDebugUnitTest + # Hardware acceleration for the emulator. - name: Enable KVM group perms run: | diff --git a/apps/docs/content/api/canvas.mdx b/apps/docs/content/api/canvas.mdx index dc34e4594d..d49983dfce 100644 --- a/apps/docs/content/api/canvas.mdx +++ b/apps/docs/content/api/canvas.mdx @@ -156,7 +156,7 @@ On Android, `Canvas` can be backed by one of three native views, with different **`TextureView`** is the default: a regular view, so transforms, clipping, alpha and z-order all apply, and the canvas can be composited over other React Native views. The cost is an extra copy, typically a frame of latency, and occasional stalls during some view animations. -**`SurfaceView`** is the fastest option. It is not regular view content, though: parent transforms, clipping, rounded corners and z-ordering with sibling views do not apply to it. Use it for a canvas that is a plain rectangle: a full-screen scene or a game. It is opaque unless asked otherwise: a translucent `SurfaceView` blends against the window background (usually black) instead of the React Native views beneath it, which are punched out. Pair it with `zOrderOnTop` to composite over React Native content, and note that `zOrderOnTop` draws above all React Native views in the window, including navigation screens, regardless of `zIndex`. +**`SurfaceView`** is the fastest option. It is not regular view content, though: parent transforms, clipping, rounded corners and z-ordering with sibling views do not apply to it. When the `SurfaceView` gets a surface, the window opens the hole once the canvas has presented a frame on it, and shows the views behind the canvas until then. Use it for a canvas that is a plain rectangle: a full-screen scene or a game. It is opaque unless asked otherwise: a translucent `SurfaceView` blends against the window background (usually black) instead of the React Native views beneath it, which are punched out. Pair it with `zOrderOnTop` to composite over React Native content, and note that `zOrderOnTop` draws above all React Native views in the window, including navigation screens, regardless of `zIndex`. **`HardwareBufferView`** composites like a `TextureView`, with lower latency. It is experimental: if the device cannot allocate the buffers the canvas shows nothing instead of falling back, and some devices may show a stale or torn frame. Test it on your target devices, and drop the `backingView` to go back to `TextureView`. Below Android 10 it uses a `TextureView`, with a warning in development. diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index 59a3069046..9919076e81 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -46,6 +46,7 @@ import { RenderAfterUnmount } from "./Diagnostics/RenderAfterUnmount"; import { BackgroundDetach } from "./Diagnostics/BackgroundDetach"; import { SurfaceChurn } from "./Diagnostics/SurfaceChurn"; import { TransparencyMode } from "./Diagnostics/TransparencyMode"; +import { FirstFrameHole } from "./Diagnostics/FirstFrameHole"; import { DeviceDestroyBeforeDetach } from "./Diagnostics/DeviceDestroyBeforeDetach"; import { StorageBufferVertices } from "./StorageBufferVertices"; import { SharedTextureMemory } from "./SharedTextureMemory"; @@ -107,6 +108,7 @@ const linking: LinkingOptions = { SurfaceChurn: "diagnostics/surface-churn", TransparencyMode: "diagnostics/transparency-mode", DeviceDestroyBeforeDetach: "diagnostics/device-destroy-before-detach", + FirstFrameHole: "diagnostics/first-frame-hole", StorageBufferVertices: "storage-buffer-vertices", SharedTextureMemory: "shared-texture-memory", ImportExternalTexture: "import-external-texture", @@ -198,6 +200,7 @@ function App() { + { diff --git a/apps/example/src/Diagnostics/FirstFrameHole.tsx b/apps/example/src/Diagnostics/FirstFrameHole.tsx new file mode 100644 index 0000000000..2b65c0c85b --- /dev/null +++ b/apps/example/src/Diagnostics/FirstFrameHole.tsx @@ -0,0 +1,138 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Button, ScrollView, StyleSheet, Text, View } from "react-native"; +import type { CanvasRef } from "react-native-webgpu"; +import { Canvas } from "react-native-webgpu"; + +import { + diagnosticStyles, + drawClearFrame, + initGPU, + useDiagnosticLog, +} from "./surfaceLifecycle"; + +// Long enough to see the canvas before its first frame. +const FIRST_FRAME_DELAY_MS = 500; +// Longer than the second a window waits for a first frame before Android 14. +const LATE_FIRST_FRAME_DELAY_MS = 1500; +const RED: GPUColor = [1, 0, 0, 1]; + +interface ProbeCanvasProps { + configureHidden: boolean; + lateFirstFrame: boolean; + append: (line: string) => void; +} + +// One mount: the device, then the first frame FIRST_FRAME_DELAY_MS later, or +// LATE_FIRST_FRAME_DELAY_MS with lateFirstFrame. With configureHidden the +// canvas is configured while hidden, before its surface exists, and shown +// afterwards. +const ProbeCanvas = ({ + configureHidden, + lateFirstFrame, + append, +}: ProbeCanvasProps) => { + const ref = useRef(null); + const [hidden, setHidden] = useState(configureHidden); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType | undefined; + initGPU(append) + .then(({ device, format }) => { + const context = ref.current?.getContext("webgpu"); + if (cancelled || !context) { + return; + } + context.configure({ device, format, alphaMode: "opaque" }); + setHidden(false); + timer = setTimeout( + () => { + drawClearFrame(device, context, 0, RED); + append("first frame presented"); + }, + lateFirstFrame ? LATE_FIRST_FRAME_DELAY_MS : FIRST_FRAME_DELAY_MS, + ); + }) + .catch((error: Error) => append(`init failed: ${error.message}`)); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [append, lateFirstFrame]); + + return ( + + ); +}; + +export const FirstFrameHole = () => { + const { log, append } = useDiagnosticLog(); + const [mount, setMount] = useState(0); + const [configureHidden, setConfigureHidden] = useState(false); + const [lateFirstFrame, setLateFirstFrame] = useState(false); + + return ( + + + + A SurfaceView canvas on Android, opaque, over a blue stage. Its first + frame, solid red, is drawn half a second after its device is ready, or + a second and a half with a late first frame, and the stage should show + until then: black means the window showed the surface before it had a + frame. + + +