diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aab42b91a..0e24e078b 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 b126ba84d..2b86eb268 100644 --- a/apps/docs/content/api/canvas.mdx +++ b/apps/docs/content/api/canvas.mdx @@ -186,26 +186,33 @@ regular view, so `Canvas` can be backed by one of three native views. The | Option | Type | Effect | | --- | --- | --- | -| `surfaceType` | `"SurfaceView" \| "TextureView" \| "HardwareBufferView"` | Backing view. Defaults to `SurfaceView` when `opaque` and to `TextureView` otherwise. `HardwareBufferView` is opt-in and needs Android 10 (API 29) | +| `surfaceType` | `"SurfaceView" \| "TextureView" \| "HardwareBufferView"` | Backing view. Defaults to `SurfaceView` when `opaque` (from Android 12L, see below) and to `TextureView` otherwise. `HardwareBufferView` is opt-in and needs Android 10 (API 29) | | `zOrderOnTop` | `boolean` | `SurfaceView` only: `setZOrderOnTop`, composites above every React Native view in the window. Defaults to `false` | -**`SurfaceView`** is the default for an opaque canvas and the fastest path. +**`SurfaceView`** is the default for an opaque canvas from Android 12L and the +fastest path. The frame goes straight to the system compositor as its own layer, with no extra copy and no involvement of the React Native view hierarchy. The price is that it is not really view content: it punches a hole through the window, so parent transforms, clipping, rounded corners, and z-ordering with sibling views -do not apply to it. Use it whenever the canvas is a plain opaque rectangle. -On an adaptive display (Android 11 and later) the canvas asks for the -display's highest refresh rate while it animates, as the window does for -regular views, and lets the rate drop again once the animation stops. +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 whenever the canvas is a plain opaque rectangle. +On an adaptive display (Android 11 and later) the canvas asks for the display's +highest refresh rate while it animates, as the window does for regular views, +and lets the rate drop again once the animation stops. **`TextureView`** is the default for a non-opaque canvas. It is a regular Android view: frames go through a `SurfaceTexture` that the UI toolkit samples as an external texture when it draws the view, so parent transforms, clipping, alpha, and z-order all apply and the canvas can be composited over other React Native views. That routing costs an extra texture copy and typically a frame of -latency, and it can stall during some view animations. It works on every -supported Android version. +latency, and it can stall during some view animations. A frame presented on +the UI thread, from a worklet, reaches the `TextureView` behind the draw its +window has pending, so that the window draws every frame. On Android 11 a +`TextureView` takes no new frame before its window has drawn the last one, so +a frame that comes sooner renders offscreen and is copied in once the window +has drawn. It works on every supported Android version. **`HardwareBufferView`** is an opt-in, experimental alternative to `TextureView` for the non-opaque case, available on Android 10 (API 29) and @@ -220,15 +227,32 @@ Requesting it on a device below API 29 silently uses `TextureView`. | --- | --- | --- | --- | | Minimum Android | 8 (API 26) | 8 (API 26) | 10 (API 29) | | Composited like a regular view | no | yes | yes | -| Extra copy | none | one | none | +| Extra copy | none | one (two on Android 11, see above) | none | | Expected latency | lowest | highest | between the two (unmeasured) | -| Selected when | `opaque` (default) | `opaque={false}` (default) | `surfaceType: "HardwareBufferView"` | +| Selected when | `opaque`, from Android 12L (default) | `opaque={false}`, or `opaque` before Android 12L (default) | `surfaceType: "HardwareBufferView"` | + +Before Android 12L, a `SurfaceView` loses its layer as soon as it leaves the +window, a frame before the window stops showing the hole it punched, so an +opaque canvas leaving the screen flashes black. On those releases an opaque +canvas is therefore backed by a `TextureView`, unless it sets `zOrderOnTop`, +which only a `SurfaceView` provides. `surfaceType: "SurfaceView"` still +selects a `SurfaceView` there. `opaque` applies to whichever view is selected: on a `SurfaceView` it picks `PixelFormat.OPAQUE` or `PixelFormat.TRANSLUCENT`, on a `TextureView` it calls `setOpaque`, and a `HardwareBufferView` always composites with alpha. Changing -`opaque` at runtime updates the view in place; changing `surfaceType` or -`zOrderOnTop` replaces the backing view. +`opaque` at runtime updates the view in place, unless `surfaceType` is unset +and the change moves the canvas between `SurfaceView` and `TextureView`; that, +like changing `surfaceType` or `zOrderOnTop`, replaces the backing view. The +canvas stays on screen through a replacement: a new `TextureView` shows the +latest frame from its first draw, and a new `SurfaceView` stays under the +outgoing `TextureView` until its own first frame is on screen. + +A canvas that stops drawing keeps showing its last frame. Android destroys a +`SurfaceView`'s surface, with its buffers, when the app goes to the background +or the view leaves its window, so the canvas reads that frame back once as the +surface goes and shows it on the next one; a `TextureView` keeps its frame in +the background, and a replaced `TextureView` hands its frame to the new view. The defaults composite correctly in React Native stacking order without further flags. Set `surfaceType` when you need a different trade-off: diff --git a/apps/docs/content/docs/getting-started/canvas.mdx b/apps/docs/content/docs/getting-started/canvas.mdx index aa6bc2087..e9af568d0 100644 --- a/apps/docs/content/docs/getting-started/canvas.mdx +++ b/apps/docs/content/docs/getting-started/canvas.mdx @@ -7,7 +7,7 @@ The [`Canvas`](/api/canvas) component is where you can access a WebGPU context a The API is symmetric with its Web counterpart, with a few differences. For instance on React Native you must call `context.present()` after every `device.queue.submit()` to present the frame to the screen. -On iOS, Canvas uses a `CAMetalLayer`. On Android it uses a `SurfaceView` for an opaque canvas and a `TextureView` for a transparent one, with an opt-in `HardwareBufferView` backend on Android 10 (API 29) and above. The [Canvas API page](/api/canvas#android-rendering-options) compares the three and explains how to pick one explicitly. +On iOS, Canvas uses a `CAMetalLayer`. On Android it uses a `SurfaceView` for an opaque canvas from Android 12L (API 32) and a `TextureView` otherwise, with an opt-in `HardwareBufferView` backend on Android 10 (API 29) and above. The [Canvas API page](/api/canvas#android-rendering-options) compares the three and explains how to pick one explicitly. | Web concept | React Native WebGPU | | ----------- | --------------------- | diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index 59a306904..e8e1488eb 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"; @@ -198,6 +199,7 @@ function App() { + { diff --git a/apps/example/src/Diagnostics/FirstFrameHole.tsx b/apps/example/src/Diagnostics/FirstFrameHole.tsx new file mode 100644 index 000000000..dcf57959c --- /dev/null +++ b/apps/example/src/Diagnostics/FirstFrameHole.tsx @@ -0,0 +1,142 @@ +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); + const [mounted, setMounted] = useState(true); + + return ( + + + + An opaque canvas 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. The stage should show until that frame, and + again as soon as the canvas is unmounted: black means the window + opened a hole with no frame behind it. + + +