diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d986f278d..0c6a0f50c 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 dc34e4594..57dc53250 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. @@ -167,6 +167,8 @@ The backing view is created when the canvas mounts and kept while it is mounted. ``` +A canvas that stops drawing keeps showing its last frame, as it does on iOS and the web: when the app comes back from the background, and when its view comes back to the window (a native-stack screen covered and revealed again, a list with `removeClippedSubviews` scrolling it out and back). Android destroys a `SurfaceView`'s surface, with its buffers, in each case, so the canvas reads that frame back once as the surface goes and shows it on the next one. A `TextureView` keeps its `SurfaceTexture`, and a `HardwareBufferView` its last buffer, while the view is out of the window. + Some combinations for other trade-offs: ```tsx diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index 59a306904..9919076e8 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 000000000..7e64101bc --- /dev/null +++ b/apps/example/src/Diagnostics/FirstFrameHole.tsx @@ -0,0 +1,279 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Button, ScrollView, StyleSheet, Text, View } from "react-native"; +import type { + AndroidBackingView, + CanvasRef, + RNCanvasContext, +} from "react-native-webgpu"; +import { Canvas } from "react-native-webgpu"; + +import type { DiagnosticGPU } from "./surfaceLifecycle"; +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 REATTACH_STEP_MS = 1500; +const RED: GPUColor = [1, 0, 0, 1]; +const MAGENTA: GPUColor = [1, 0, 1, 1]; +const STAGE_BLUE = "#3498db"; +const BACKING_VIEWS: readonly AndroidBackingView[] = [ + "SurfaceView", + "TextureView", + "HardwareBufferView", +]; + +interface ReattachStep { + away: boolean; + redraw?: GPUColor; +} + +// The reattach cycle's steps, one every REATTACH_STEP_MS, in turn: the canvas +// scrolls away or back, and halfway through a step with a redraw draws a +// frame in its colour, magenta while it is away and red while it shows. +const REATTACH_STEPS: readonly ReattachStep[] = [ + { away: true, redraw: MAGENTA }, + { away: false }, + { away: true }, + { away: false, redraw: RED }, + { away: true }, + { away: false }, +]; + +interface Redraw { + count: number; + colour: GPUColor; +} + +const NO_REDRAW: Redraw = { count: 0, colour: RED }; + +interface ProbeCanvasProps { + configureHidden: boolean; + lateFirstFrame: boolean; + backingView: AndroidBackingView; + redraw: Redraw; + 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. Every new redraw draws one more frame, in its colour. +const ProbeCanvas = ({ + configureHidden, + lateFirstFrame, + backingView, + redraw, + append, +}: ProbeCanvasProps) => { + const ref = useRef(null); + const target = useRef<{ + gpu: DiagnosticGPU; + context: RNCanvasContext; + } | null>(null); + const [hidden, setHidden] = useState(configureHidden); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType | undefined; + initGPU(append) + .then((gpu) => { + const context = ref.current?.getContext("webgpu"); + if (cancelled || !context) { + return; + } + context.configure({ + device: gpu.device, + format: gpu.format, + alphaMode: "opaque", + }); + target.current = { gpu, context }; + setHidden(false); + timer = setTimeout( + () => { + drawClearFrame(gpu.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]); + + useEffect(() => { + const { current } = target; + if (redraw.count === 0 || current === null) { + return; + } + drawClearFrame(current.gpu.device, current.context, 0, redraw.colour); + }, [redraw]); + + return ( + + ); +}; + +// "reattach" scrolls the canvas out of a row that removes its clipped views +// and back (REATTACH_STEPS), so the same canvas leaves its window and comes +// back, mostly without drawing again: its last frame, red or magenta, should +// show again at once. +export const FirstFrameHole = () => { + const { log, append } = useDiagnosticLog(); + const [mount, setMount] = useState(0); + const [configureHidden, setConfigureHidden] = useState(false); + const [lateFirstFrame, setLateFirstFrame] = useState(false); + const [backingViewIndex, setBackingViewIndex] = useState(0); + const [reattach, setReattach] = useState(false); + const [rowWidth, setRowWidth] = useState(0); + const [redraw, setRedraw] = useState(NO_REDRAW); + const row = useRef(null); + const backingView = BACKING_VIEWS[backingViewIndex]; + + useEffect(() => { + if (!reattach || rowWidth === 0) { + return; + } + let step = 0; + let redrawTimer: ReturnType | undefined; + const timer = setInterval(() => { + const { away, redraw: colour } = + REATTACH_STEPS[step % REATTACH_STEPS.length]; + step += 1; + row.current?.scrollTo({ x: away ? rowWidth : 0, animated: false }); + if (colour !== undefined) { + redrawTimer = setTimeout( + () => setRedraw((value) => ({ count: value.count + 1, colour })), + REATTACH_STEP_MS / 2, + ); + } + }, REATTACH_STEP_MS); + return () => { + clearInterval(timer); + clearTimeout(redrawTimer); + }; + }, [reattach, rowWidth]); + + const canvas = ( + + ); + + return ( + + + + A canvas over a blue stage, backed on Android by the view the backing + view button names, a SurfaceView at first. 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. When + it scrolls away and back, its last frame, red or magenta, should show + again at once. + + +