Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
48 changes: 36 additions & 12 deletions apps/docs/content/api/canvas.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/getting-started/canvas.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| ----------- | --------------------- |
Expand Down
2 changes: 2 additions & 0 deletions apps/example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -198,6 +199,7 @@ function App() {
<Stack.Screen name="BackgroundDetach" component={BackgroundDetach} />
<Stack.Screen name="SurfaceChurn" component={SurfaceChurn} />
<Stack.Screen name="TransparencyMode" component={TransparencyMode} />
<Stack.Screen name="FirstFrameHole" component={FirstFrameHole} />
<Stack.Screen
name="DeviceDestroyBeforeDetach"
component={DeviceDestroyBeforeDetach}
Expand Down
4 changes: 4 additions & 0 deletions apps/example/src/Diagnostics/DiagnosticsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ const tests = [
screen: "DeviceDestroyBeforeDetach",
title: "⚠️ Device Destroy Before Detach",
},
{
screen: "FirstFrameHole",
title: "⚠️ Opaque Canvas Mount and Unmount",
},
] as const;

export const DiagnosticsList = () => {
Expand Down
142 changes: 142 additions & 0 deletions apps/example/src/Diagnostics/FirstFrameHole.tsx
Original file line number Diff line number Diff line change
@@ -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<CanvasRef>(null);
const [hidden, setHidden] = useState(configureHidden);

useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | 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 (
<View style={[styles.fill, hidden ? styles.hidden : null]}>
<Canvas ref={ref} style={StyleSheet.absoluteFill} />
</View>
);
};

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 (
<View style={diagnosticStyles.container}>
<View style={diagnosticStyles.controls}>
<Text style={diagnosticStyles.description}>
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.
</Text>
<View style={styles.buttons}>
<Button
testID="remount"
title="remount"
onPress={() => setMount((value) => value + 1)}
/>
<Button
testID="toggle-configure-hidden"
title={`configure while hidden: ${configureHidden}`}
onPress={() => {
setConfigureHidden((value) => !value);
setMount((value) => value + 1);
}}
/>
<Button
testID="toggle-late-first-frame"
title={`late first frame: ${lateFirstFrame}`}
onPress={() => {
setLateFirstFrame((value) => !value);
setMount((value) => value + 1);
}}
/>
<Button
testID="toggle-mounted"
title={`mounted: ${mounted}`}
onPress={() => setMounted((value) => !value)}
/>
</View>
</View>
<View style={styles.stage}>
{mounted ? (
<ProbeCanvas
key={mount}
configureHidden={configureHidden}
lateFirstFrame={lateFirstFrame}
append={append}
/>
) : null}
</View>
<ScrollView style={diagnosticStyles.log}>
{log.map((line, i) => (
<Text key={i} style={diagnosticStyles.logLine}>
{line}
</Text>
))}
</ScrollView>
</View>
);
};

const styles = StyleSheet.create({
buttons: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
stage: { flex: 1, backgroundColor: "#3498db" },
fill: { flex: 1 },
hidden: { display: "none" },
});
1 change: 1 addition & 0 deletions apps/example/src/Route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export type Routes = {
BackgroundDetach: undefined;
SurfaceChurn: undefined;
TransparencyMode: undefined;
FirstFrameHole: undefined;
DeviceDestroyBeforeDetach: undefined;
StorageBufferVertices: undefined;
SharedTextureMemory: undefined;
Expand Down
25 changes: 25 additions & 0 deletions apps/example/src/Tests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
PixelRatio,
findNodeHandle,
} from "react-native";
import type { CanvasRef } from "react-native-webgpu";
import {
Canvas,
GPUOffscreenCanvas,
// The flags as the package exports them, not the globals of the same name
// (see the `exportedFlags` spec in Constants.spec.ts).
Expand All @@ -25,6 +27,7 @@ import { redFragWGSL, triangleVertWGSL } from "./Triangle/triangle";
import type { AssetProps } from "./components/useAssets";
import { Texture } from "./components/Texture";
import { workletDeviceStress } from "./components/workletDeviceStress";
import { presentFrames } from "./components/presentFrames";
import { workletSerialization } from "./components/workletSerialization";

export const CI = process.env.CI === "true";
Expand All @@ -33,6 +36,7 @@ const { width } = Dimensions.get("window");
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
const PROBE_SIZE = 64;
const QUADRANT = PROBE_SIZE / 2;
const TEXTURE_CANVAS_SIZE = 32;

export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => {
const [texture, setTexture] = useState<GPUTexture | null>(null);
Expand All @@ -42,6 +46,10 @@ export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => {
// A fixed native view the drawElementImageToTexture spec snapshots: four
// 32x32pt solid quadrants (red, lime, blue, white), 64x64pt in total.
const probeRef = useRef<View>(null);
// A canvas backed by a TextureView on Android, for the TextureView
// present spec, and the view around it, which that spec snapshots.
const textureCanvasRef = useRef<CanvasRef>(null);
const textureCanvasViewRef = useRef<View>(null);
useEffect(() => {
let cancelled = false;
(async () => {
Expand Down Expand Up @@ -129,8 +137,14 @@ export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => {
probe: findNodeHandle(probeRef.current),
probeSize: PROBE_SIZE,
pixelRatio: PixelRatio.get(),
textureCanvas: () => textureCanvasRef.current,
textureCanvasView: findNodeHandle(
textureCanvasViewRef.current,
),
textureCanvasSize: TEXTURE_CANVAS_SIZE,
},
workletDeviceStress,
presentFrames,
workletSerialization,
exportedFlags: {
GPUBufferUsage: exportedBufferUsage,
Expand Down Expand Up @@ -229,6 +243,17 @@ export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => {
}}
/>
</View>
<View
ref={textureCanvasViewRef}
collapsable={false}
style={{ width: TEXTURE_CANVAS_SIZE, height: TEXTURE_CANVAS_SIZE }}
>
<Canvas
ref={textureCanvasRef}
style={{ flex: 1 }}
android={{ surfaceType: "TextureView" }}
/>
</View>
<Texture
texture={texture}
device={device}
Expand Down
Loading
Loading