Skip to content
Draft
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
4 changes: 3 additions & 1 deletion apps/docs/content/api/canvas.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -167,6 +167,8 @@ The backing view is created when the canvas mounts and kept while it is mounted.
<Canvas key={backingView} android={{ backingView }} />
```

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
Expand Down
3 changes: 3 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 @@ -107,6 +108,7 @@ const linking: LinkingOptions<Routes> = {
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",
Expand Down Expand Up @@ -198,6 +200,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: "⚠️ SurfaceView First Frame",
},
] as const;

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

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

// "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<ScrollView>(null);
const backingView = BACKING_VIEWS[backingViewIndex];

useEffect(() => {
if (!reattach || rowWidth === 0) {
return;
}
let step = 0;
let redrawTimer: ReturnType<typeof setTimeout> | 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 = (
<ProbeCanvas
key={mount}
configureHidden={configureHidden}
lateFirstFrame={lateFirstFrame}
backingView={backingView}
redraw={reattach ? redraw : NO_REDRAW}
append={append}
/>
);

return (
<View style={diagnosticStyles.container}>
<View style={diagnosticStyles.controls}>
<Text style={diagnosticStyles.description}>
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.
</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="cycle-backing-view"
title={`backing view: ${backingView}`}
onPress={() => {
setBackingViewIndex(
(value) => (value + 1) % BACKING_VIEWS.length,
);
setMount((value) => value + 1);
}}
/>
<Button
testID="toggle-reattach"
title={`reattach: ${reattach}`}
onPress={() => {
setReattach((value) => !value);
setRedraw(NO_REDRAW);
setMount((value) => value + 1);
}}
/>
</View>
</View>
<View style={styles.stage}>
{reattach ? (
<ScrollView
ref={row}
horizontal
removeClippedSubviews
scrollEnabled={false}
showsHorizontalScrollIndicator={false}
style={styles.row}
onLayout={(event) => setRowWidth(event.nativeEvent.layout.width)}
>
<View style={[styles.home, { width: rowWidth }]}>
{rowWidth > 0 ? canvas : null}
</View>
<View style={[styles.away, { width: rowWidth }]} />
</ScrollView>
) : (
canvas
)}
</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: STAGE_BLUE },
// The row shows its own colour only in the frame between a scroll and the
// clipping that follows it, when neither cell is attached.
row: { backgroundColor: "#7f8c8d" },
home: { backgroundColor: STAGE_BLUE },
away: { backgroundColor: "#27ae60" },
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
4 changes: 2 additions & 2 deletions packages/webgpu/android/WebGPUHardwareBufferView.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ On resize the native pool reallocates to the new canvas size (a new generation),

## Lifecycle

- **Attach / size change.** `onSizeChanged` and `onAttachedToWindow` call `nEnablePool` (first time, which also registers the frame-ready callback) or `nSetClientSize` (subsequently) with the dp size. Attach also posts one `consume()` to pick up a frame that became ready while detached.
- **Detach.** The frame-ready callback is unregistered, `nSwitchToOffscreen` flips the context to an offscreen texture so JS keeps rendering safely (it does not block in `getCurrentTexture`), any pending `consume()` is cancelled, and all cached `Bitmap`s are recycled. A frame caught between `getCurrentTexture` and `present` gets its `EndAccess` and is handed to the waiter, so its fences are drained before the retired generation is freed; the waiter discards frames that finish after teardown instead of publishing them. The `SurfaceInfo` is intentionally not removed from the registry, so a temporary detach/re-attach (for example scrolling off screen) keeps working on the same context.
- **Attach / size change.** `onSizeChanged` and `onAttachedToWindow` call `nEnablePool` (first time, which also registers the frame-ready callback) or `nSetClientSize` (subsequently) with the dp size. Attach also posts one `consume()` to pick up a frame that became ready while detached, and `nEnablePool` schedules a flush that draws the latest frame the canvas rendered offscreen while the view was away into a slot and presents it (`SurfaceInfo::applyPendingPoolBlit`), unless a frame starts first.
- **Detach.** The frame-ready callback is unregistered, `nSwitchToOffscreen` flips the context to an offscreen texture so JS keeps rendering safely (it does not block in `getCurrentTexture`), any pending `consume()` is cancelled, and every cached `Bitmap` but the displayed one is recycled. The view keeps that one, which holds its buffer, and draws it again when it is back in a window until the next frame replaces it; it is recycled once `WebGPUView` drops the view (`FrameKeeper.retire`). A frame caught between `getCurrentTexture` and `present` gets its `EndAccess` and is handed to the waiter, so its fences are drained before the retired generation is freed; the waiter discards frames that finish after teardown instead of publishing them. The `SurfaceInfo` is intentionally not removed from the registry, so a temporary detach/re-attach (for example scrolling off screen) keeps working on the same context.
- **Drop.** When RN drops the view for good, `WebGPUViewManager.onDropViewInstance` removes the `SurfaceInfo` from the registry. The JS context keeps its own reference; once JS releases it, the destructor stops the fence waiter thread and frees the remaining GPU resources.

## Files
Expand Down
1 change: 1 addition & 0 deletions packages/webgpu/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,5 @@ dependencies {
// For > 0.71, this will be replaced by `com.facebook.react:react-android:$version` by react gradle plugin
//noinspection GradleDynamicVersion
implementation "com.facebook.react:react-native:+"
testImplementation "junit:junit:4.13.2"
}
Loading