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.
+
+
+
+
+
+ {mounted ? (
+
+ ) : null}
+
+
+ {log.map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ buttons: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
+ stage: { flex: 1, backgroundColor: "#3498db" },
+ fill: { flex: 1 },
+ hidden: { display: "none" },
+});
diff --git a/apps/example/src/Route.ts b/apps/example/src/Route.ts
index 2ca03afd6..f46bf59b0 100644
--- a/apps/example/src/Route.ts
+++ b/apps/example/src/Route.ts
@@ -38,6 +38,7 @@ export type Routes = {
BackgroundDetach: undefined;
SurfaceChurn: undefined;
TransparencyMode: undefined;
+ FirstFrameHole: undefined;
DeviceDestroyBeforeDetach: undefined;
StorageBufferVertices: undefined;
SharedTextureMemory: undefined;
diff --git a/apps/example/src/Tests.tsx b/apps/example/src/Tests.tsx
index e62dfd551..7c77c1dc4 100644
--- a/apps/example/src/Tests.tsx
+++ b/apps/example/src/Tests.tsx
@@ -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).
@@ -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";
@@ -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(null);
@@ -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(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(null);
+ const textureCanvasViewRef = useRef(null);
useEffect(() => {
let cancelled = false;
(async () => {
@@ -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,
@@ -229,6 +243,17 @@ export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => {
}}
/>
+
+
+ void,
+) => {
+ "worklet";
+ const present = (color: GPUColor) => {
+ const encoder = device.createCommandEncoder();
+ encoder
+ .beginRenderPass({
+ colorAttachments: [
+ {
+ view: context.getCurrentTexture().createView(),
+ clearValue: color,
+ loadOp: "clear",
+ storeOp: "store",
+ },
+ ],
+ })
+ .end();
+ device.queue.submit([encoder.finish()]);
+ context.present();
+ };
+ let next = 0;
+ let previousFrameMs = -1;
+ let longestFrameGapMs = 0;
+ const onFrame = (callback: () => void) => () => {
+ const nowMs = performance.now();
+ if (previousFrameMs >= 0) {
+ longestFrameGapMs = Math.max(longestFrameGapMs, nowMs - previousFrameMs);
+ }
+ previousFrameMs = nowMs;
+ callback();
+ };
+ const step = () => {
+ if (next === 0) {
+ present(colors[next++]);
+ }
+ if (next < colors.length) {
+ present(colors[next++]);
+ requestAnimationFrame(onFrame(step));
+ return;
+ }
+ requestAnimationFrame(
+ onFrame(() =>
+ requestAnimationFrame(onFrame(() => done(longestFrameGapMs))),
+ ),
+ );
+ };
+ requestAnimationFrame(onFrame(step));
+};
+
+// Presents those frames from Reanimated's UI thread or from the JS thread.
+export const presentFrames = (
+ device: GPUDevice,
+ context: RNCanvasContext,
+ colors: GPUColor[],
+ thread: "UI" | "JS",
+): Promise =>
+ new Promise((resolve) => {
+ if (thread === "JS") {
+ presentColors(device, context, colors, resolve);
+ return;
+ }
+ scheduleOnUI(() => {
+ "worklet";
+ presentColors(device, context, colors, (longestFrameGapMs) =>
+ scheduleOnRN(resolve, longestFrameGapMs),
+ );
+ });
+ });
diff --git a/packages/webgpu/android/build.gradle b/packages/webgpu/android/build.gradle
index d893d997c..e443bcebb 100644
--- a/packages/webgpu/android/build.gradle
+++ b/packages/webgpu/android/build.gradle
@@ -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"
}
diff --git a/packages/webgpu/android/cpp/cpp-adapter.cpp b/packages/webgpu/android/cpp/cpp-adapter.cpp
index b3768488c..c2ea1ca11 100644
--- a/packages/webgpu/android/cpp/cpp-adapter.cpp
+++ b/packages/webgpu/android/cpp/cpp-adapter.cpp
@@ -29,6 +29,56 @@
std::shared_ptr manager;
+namespace {
+
+// An ARGB_8888 bitmap's pixels (premultiplied RGBA bytes), locked for the
+// object's lifetime.
+class LockedRgbaBitmap {
+public:
+ enum class Failure { None, Format, Lock };
+
+ LockedRgbaBitmap(JNIEnv *env, jobject bitmap) : _env(env), _bitmap(bitmap) {
+ if (AndroidBitmap_getInfo(env, bitmap, &_info) !=
+ ANDROID_BITMAP_RESULT_SUCCESS ||
+ _info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) {
+ _failure = Failure::Format;
+ return;
+ }
+ void *pixels = nullptr;
+ if (AndroidBitmap_lockPixels(env, bitmap, &pixels) !=
+ ANDROID_BITMAP_RESULT_SUCCESS ||
+ pixels == nullptr) {
+ _failure = Failure::Lock;
+ return;
+ }
+ _pixels = static_cast(pixels);
+ }
+
+ ~LockedRgbaBitmap() {
+ if (_pixels != nullptr) {
+ AndroidBitmap_unlockPixels(_env, _bitmap);
+ }
+ }
+
+ LockedRgbaBitmap(const LockedRgbaBitmap &) = delete;
+ LockedRgbaBitmap &operator=(const LockedRgbaBitmap &) = delete;
+
+ Failure failure() const { return _failure; }
+ const uint8_t *pixels() const { return _pixels; }
+ uint32_t width() const { return _info.width; }
+ uint32_t height() const { return _info.height; }
+ uint32_t stride() const { return _info.stride; }
+
+private:
+ JNIEnv *_env;
+ jobject _bitmap;
+ AndroidBitmapInfo _info{};
+ const uint8_t *_pixels = nullptr;
+ Failure _failure = Failure::None;
+};
+
+} // namespace
+
extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUModule_initializeNative(
JNIEnv *env, jobject thiz, jlong jsRuntime, jobject jsCallInvokerHolder,
jobject blobModule) {
@@ -68,36 +118,34 @@ extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUModule_onViewSnapshot(
callbacks->onError("drawElementImageToTexture: no bitmap was produced");
return;
}
- AndroidBitmapInfo info;
- if (AndroidBitmap_getInfo(env, bitmap, &info) !=
- ANDROID_BITMAP_RESULT_SUCCESS ||
- info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) {
- callbacks->onError(
- "drawElementImageToTexture: unexpected snapshot bitmap format");
- return;
- }
- void *pixels = nullptr;
- if (AndroidBitmap_lockPixels(env, bitmap, &pixels) !=
- ANDROID_BITMAP_RESULT_SUCCESS ||
- pixels == nullptr) {
- callbacks->onError(
- "drawElementImageToTexture: couldn't lock the snapshot bitmap");
- return;
- }
rnwgpu::ImageData image;
- image.width = info.width;
- image.height = info.height;
- image.format = wgpu::TextureFormat::RGBA8Unorm;
- // Bitmap.Config.ARGB_8888 stores premultiplied RGBA bytes.
- image.premultiplied = true;
- const size_t rowBytes = static_cast(info.width) * 4;
- image.data.resize(rowBytes * info.height);
- const auto *src = static_cast(pixels);
- for (uint32_t row = 0; row < info.height; ++row) {
- std::memcpy(image.data.data() + row * rowBytes, src + row * info.stride,
- rowBytes);
- }
- AndroidBitmap_unlockPixels(env, bitmap);
+ {
+ LockedRgbaBitmap locked(env, bitmap);
+ switch (locked.failure()) {
+ case LockedRgbaBitmap::Failure::None:
+ break;
+ case LockedRgbaBitmap::Failure::Format:
+ callbacks->onError(
+ "drawElementImageToTexture: unexpected snapshot bitmap format");
+ return;
+ case LockedRgbaBitmap::Failure::Lock:
+ callbacks->onError(
+ "drawElementImageToTexture: couldn't lock the snapshot bitmap");
+ return;
+ }
+ image.width = locked.width();
+ image.height = locked.height();
+ image.format = wgpu::TextureFormat::RGBA8Unorm;
+ // Bitmap.Config.ARGB_8888 stores premultiplied RGBA bytes.
+ image.premultiplied = true;
+ const size_t rowBytes = static_cast(locked.width()) * 4;
+ image.data.resize(rowBytes * locked.height());
+ for (uint32_t row = 0; row < locked.height(); ++row) {
+ std::memcpy(image.data.data() + row * rowBytes,
+ locked.pixels() + static_cast(row) * locked.stride(),
+ rowBytes);
+ }
+ }
callbacks->onPixels(std::move(image));
}
@@ -162,20 +210,24 @@ extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onSurfaceChanged(
namespace {
-// Weak handle on a Java view and one of its no-argument methods, for native
-// threads to call into it: the fence waiter wakes a WebGPUHardwareBufferView
-// when a frame is ready, the presenting thread tells a SurfaceView it is
-// animating (AnimationReporter). Holding the view weakly means a view that RN
-// has dropped is not kept alive by a SurfaceInfo that outlives it (the
-// registry keeps the SurfaceInfo until onDropViewInstance).
+// Weak handle on a Java view and one of its void methods, for native threads
+// to call into it: the fence waiter wakes a WebGPUHardwareBufferView when a
+// frame is ready, the presenting thread tells a SurfaceView it is animating
+// (AnimationReporter) and that its first frame is presented
+// (FirstFrameDrawGate), and a TextureView canvas presenting on the main
+// thread posts its present behind the window's pending draw. Holding the view
+// weakly means a view that RN has dropped is not kept alive by a SurfaceInfo
+// that outlives it (the registry keeps the SurfaceInfo until
+// onDropViewInstance).
struct WeakViewCall {
jweak view = nullptr;
jmethodID method = nullptr;
- WeakViewCall(JNIEnv *env, jobject thiz, const char *methodName) {
+ WeakViewCall(JNIEnv *env, jobject thiz, const char *methodName,
+ const char *signature = "()V") {
view = env->NewWeakGlobalRef(thiz);
jclass cls = env->GetObjectClass(thiz);
- method = env->GetMethodID(cls, methodName, "()V");
+ method = env->GetMethodID(cls, methodName, signature);
env->DeleteLocalRef(cls);
}
@@ -188,22 +240,25 @@ struct WeakViewCall {
}
}
- // Called on a native thread, outside the SurfaceInfo locks.
- void operator()() const {
+ // Returns whether the view was there to take the call. Called on a native
+ // thread, outside the SurfaceInfo locks.
+ template bool operator()(Args... args) const {
facebook::jni::ThreadScope scope;
JNIEnv *env = facebook::jni::Environment::current();
if (env == nullptr || method == nullptr) {
- return;
+ return false;
}
jobject local = env->NewLocalRef(view);
if (local == nullptr) {
- return; // the view was garbage collected
+ return false; // the view was garbage collected
}
- env->CallVoidMethod(local, method);
- if (env->ExceptionCheck()) {
+ env->CallVoidMethod(local, method, args...);
+ const bool called = !env->ExceptionCheck();
+ if (!called) {
env->ExceptionClear();
}
env->DeleteLocalRef(local);
+ return called;
}
};
@@ -284,7 +339,9 @@ void setAnimationReporter(int contextId,
extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onSurfaceCreate(
JNIEnv *env, jobject thiz, jobject jSurface, jint contextId, jfloat width,
- jfloat height, jboolean reportAnimation) {
+ jfloat height, jboolean presentsBehindPendingDraw,
+ jboolean acquiresAfterDraw, jboolean reportAnimation, jlong firstFrameToken,
+ jboolean adoptsBeforeFirstDraw) {
if (manager == nullptr) {
return;
}
@@ -298,13 +355,32 @@ extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onSurfaceCreate(
auto &runtime = rnwgpu::WebGPURuntime::shared();
auto gpu = runtime.instance();
auto surface = runtime.makeSurface(window);
+ // A SurfaceView behind its window holds its draw report until its first
+ // frame is presented (FirstFrameDrawGate): a frame, or the blit of the
+ // latest offscreen frame.
+ rnwgpu::NativeSurfaceFirstPresent firstPresent;
+ if (firstFrameToken != 0) {
+ auto onFirstFramePresented = std::make_shared(
+ env, thiz, "onFirstFramePresented", "(J)V");
+ firstPresent = [onFirstFramePresented, firstFrameToken]() {
+ (*onFirstFramePresented)(firstFrameToken);
+ };
+ }
+ rnwgpu::NativeSurfacePresentPoster presentPoster;
+ if (presentsBehindPendingDraw) {
+ auto postPresent = std::make_shared(
+ env, thiz, "postPresentBehindPendingDraw");
+ presentPoster = [postPresent]() { return (*postPresent)(); };
+ }
// Find-or-create + attach runs atomically under the registry lock so a
// concurrent destroyContext cannot orphan this surface.
auto info = registry.attachSurface(
contextId, gpu, static_cast(width), static_cast(height), window,
- surface, [](void *nativeSurface) {
+ surface,
+ [](void *nativeSurface) {
ANativeWindow_release(static_cast(nativeSurface));
- });
+ },
+ std::move(firstPresent), std::move(presentPoster), acquiresAfterDraw);
// A SurfaceView asks to hear when it animates, for its refresh rate vote
// (WebGPUView.onAnimating).
if (reportAnimation) {
@@ -316,12 +392,43 @@ extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onSurfaceCreate(
setAnimationReporter(contextId, nullptr);
info->setFramePresentedCallback(nullptr);
}
+ // A TextureView given its surface before its first draw shows the latest
+ // frame in that draw only if it is presented into the surface first.
+ if (adoptsBeforeFirstDraw) {
+ info->applyPendingAttach();
+ }
// The attach is adopted at the next frame boundary by the rendering thread;
// schedule a flush so contexts that are not currently rendering still pick
// it up (and present their last offscreen frame).
manager->flushPendingSurfaceTransition(info);
}
+// The present WebGPUView.postPresentBehindPendingDraw posted (UI thread).
+extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_presentPostedFrame(
+ JNIEnv *env, jobject thiz, jint contextId) {
+ auto ®istry = rnwgpu::SurfaceRegistry::getInstance();
+ if (auto info = registry.getSurfaceInfo(contextId)) {
+ info->presentPostedFrame();
+ }
+}
+
+// WebGPUView.surfaceUpdated, while the window draws (UI thread).
+extern "C" JNIEXPORT jlong JNICALL Java_com_webgpu_WebGPUView_onSurfaceDrawing(
+ JNIEnv *env, jobject thiz, jint contextId) {
+ auto ®istry = rnwgpu::SurfaceRegistry::getInstance();
+ auto info = registry.getSurfaceInfo(contextId);
+ return info ? static_cast(info->presentCount()) : 0;
+}
+
+// Posted by WebGPUView.surfaceUpdated behind the window's draw (UI thread).
+extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onSurfaceDrawn(
+ JNIEnv *env, jobject thiz, jint contextId, jlong presentCount) {
+ auto ®istry = rnwgpu::SurfaceRegistry::getInstance();
+ if (auto info = registry.getSurfaceInfo(contextId)) {
+ info->surfaceDrawn(static_cast(presentCount));
+ }
+}
+
extern "C" JNIEXPORT void JNICALL
Java_com_webgpu_WebGPUView_switchToOffscreenSurface(JNIEnv *env, jobject thiz,
jint contextId) {
@@ -329,8 +436,41 @@ Java_com_webgpu_WebGPUView_switchToOffscreenSurface(JNIEnv *env, jobject thiz,
auto ®istry = rnwgpu::SurfaceRegistry::getInstance();
if (auto info = registry.getSurfaceInfo(contextId)) {
info->setFramePresentedCallback(nullptr);
- info->switchToOffscreen();
+ info->detach();
+ }
+}
+
+// See SurfaceInfo::keepableFrameSize; width << 32 | height, 0 when there is no
+// frame to keep.
+extern "C" JNIEXPORT jlong JNICALL Java_com_webgpu_WebGPUView_keepableFrameSize(
+ JNIEnv *env, jobject thiz, jint contextId) {
+ auto ®istry = rnwgpu::SurfaceRegistry::getInstance();
+ auto info = registry.getSurfaceInfo(contextId);
+ if (!info) {
+ return 0;
+ }
+ const rnwgpu::Size size = info->keepableFrameSize();
+ return (static_cast(size.width) << 32) |
+ static_cast(static_cast(size.height));
+}
+
+// See SurfaceInfo::keepFrame.
+extern "C" JNIEXPORT jboolean JNICALL
+Java_com_webgpu_WebGPUView_keepBitmapFrame(JNIEnv *env, jobject thiz,
+ jint contextId, jobject bitmap) {
+ auto ®istry = rnwgpu::SurfaceRegistry::getInstance();
+ auto info = registry.getSurfaceInfo(contextId);
+ if (!info) {
+ return JNI_FALSE;
+ }
+ LockedRgbaBitmap locked(env, bitmap);
+ if (locked.failure() != LockedRgbaBitmap::Failure::None) {
+ return JNI_FALSE;
}
+ return info->keepFrame(locked.pixels(), locked.width(), locked.height(),
+ locked.stride())
+ ? JNI_TRUE
+ : JNI_FALSE;
}
// See AnimationReporter::releaseIfIdle; 0 when the surface reports nothing.
@@ -360,7 +500,7 @@ extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onViewDestroyed(
auto ®istry = rnwgpu::SurfaceRegistry::getInstance();
if (auto info = registry.getSurfaceInfo(contextId)) {
info->setFramePresentedCallback(nullptr);
- info->detachSurface();
+ info->detach();
}
registry.removeSurfaceInfo(contextId);
}
@@ -454,6 +594,6 @@ Java_com_webgpu_WebGPUHardwareBufferView_nSwitchToOffscreen(JNIEnv *env,
auto info = registry.getSurfaceInfo(contextId);
if (info != nullptr) {
info->setPoolFrameReadyCallback(nullptr);
- info->switchToOffscreen();
+ info->detach();
}
}
diff --git a/packages/webgpu/android/src/main/java/com/webgpu/BackingViewKind.java b/packages/webgpu/android/src/main/java/com/webgpu/BackingViewKind.java
new file mode 100644
index 000000000..2b1c0c634
--- /dev/null
+++ b/packages/webgpu/android/src/main/java/com/webgpu/BackingViewKind.java
@@ -0,0 +1,121 @@
+package com.webgpu;
+
+import android.os.Build;
+
+import androidx.annotation.Nullable;
+
+/** The Android view a WebGPUView draws into, see WebGPUView.updateView(). */
+enum BackingViewKind {
+ SURFACE_VIEW,
+ TEXTURE_VIEW,
+ HARDWARE_BUFFER_VIEW;
+
+ /**
+ * The kind the androidSurfaceType prop asks for, or null for "auto", which
+ * leaves the choice to forAutoSurfaceType. WebGPUHardwareBufferView (a plain
+ * View drawing AHardwareBuffers inline) needs Android 10, so below it the
+ * request gets a TextureView. It stays opt-in until it falls back to
+ * TextureView at runtime when a device cannot import the AHardwareBuffer.
+ */
+ @Nullable
+ static BackingViewKind fromSurfaceType(@Nullable String surfaceType, int sdkInt) {
+ if ("SurfaceView".equals(surfaceType)) {
+ return SURFACE_VIEW;
+ }
+ if ("TextureView".equals(surfaceType)) {
+ return TEXTURE_VIEW;
+ }
+ if ("HardwareBufferView".equals(surfaceType)) {
+ return sdkInt >= Build.VERSION_CODES.Q ? HARDWARE_BUFFER_VIEW : TEXTURE_VIEW;
+ }
+ return null;
+ }
+
+ /**
+ * The kind "auto" picks: a SurfaceView for an opaque canvas and a
+ * TextureView otherwise. Before Android 12L an opaque canvas gets a
+ * TextureView too, unless it asks for zOrderOnTop, which only a SurfaceView
+ * offers: those releases remove a SurfaceView's layer as the view leaves its
+ * window, ahead of the window frame that stops showing it, so an opaque
+ * canvas leaving the screen leaves a black hole for a frame. Android 8 to 10
+ * remove it in onDetachedFromWindow; Android 11 and 12 hand it to the render
+ * thread's positionLost, which runs after the detach has cleared the view's
+ * root and so applies it at once too. Android 12L applies the removal with
+ * the window's next draw.
+ */
+ static BackingViewKind forAutoSurfaceType(boolean opaque, boolean zOrderOnTop, int sdkInt) {
+ if (!opaque) {
+ return TEXTURE_VIEW;
+ }
+ if (sdkInt < Build.VERSION_CODES.S_V2 && !zOrderOnTop) {
+ return TEXTURE_VIEW;
+ }
+ return SURFACE_VIEW;
+ }
+
+ /**
+ * Whether a frame presented on the main thread waits for the draw its window
+ * has pending: a TextureView shows a new frame only when its window draws,
+ * and a frame presented inside a Choreographer frame, ahead of that draw,
+ * would replace the one the window has yet to take.
+ */
+ boolean presentsBehindPendingDraw() {
+ return this == TEXTURE_VIEW;
+ }
+
+ /**
+ * Whether a frame is acquired only once the window has drawn the one
+ * presented last. Android 11's Vulkan loader hands a TextureView's buffer
+ * queue its dequeue timeout as an int where the window reads an int64_t
+ * (frameworks/native android-11.0.0_r48, vulkan/libvulkan/swapchain.cpp), so
+ * the queue waits up to 4.3 s for a free buffer.
+ */
+ boolean acquiresAfterDraw(int sdkInt) {
+ return this == TEXTURE_VIEW && sdkInt == Build.VERSION_CODES.R;
+ }
+
+ /**
+ * Whether a new view of this kind gets its surface, with the latest frame
+ * presented into it, before its first draw. A TextureView makes its
+ * SurfaceTexture in its first draw and shows a frame presented then only in
+ * the next one, so it would draw empty once. One that is not shown or not
+ * laid out keeps the lazy path: a texture handed to it would hold its
+ * buffers with no draw to show them.
+ */
+ boolean receivesSurfaceBeforeFirstDraw(boolean shown, int width, int height) {
+ return this == TEXTURE_VIEW && shown && width > 0 && height > 0;
+ }
+
+ /**
+ * Whether a new view of this kind, replacing an outgoing one on screen,
+ * keeps that view until the new one shows the canvas (OutgoingViewHold). A
+ * SurfaceView shows nothing before its first frame, while an outgoing
+ * TextureView leaves with the window frame that removes it, so the swap
+ * would show what lies under the canvas in between. An outgoing SurfaceView
+ * is not kept: a TextureView draws inside the window, so it reaches the
+ * screen with the window frame the SurfaceView leaves with.
+ */
+ boolean keepsOutgoingOnScreen(BackingViewKind outgoing, boolean shown, int width, int height) {
+ return this == SURFACE_VIEW && outgoing == TEXTURE_VIEW && shown && width > 0 && height > 0;
+ }
+
+ /**
+ * Whether a view of this kind, replaced on screen, stays attached and
+ * undrawn rather than being removed, so that it leaves the screen with the
+ * window frame that covers it. Android 10 to 12 remove a detached
+ * SurfaceView's layer ahead of that frame (10 in onDetachedFromWindow, 11
+ * and 12 in a positionLost that no longer has the view's root to defer to)
+ * and hide the layer of an attached one the window stops drawing in step
+ * with the frame. Android 12L removes a detached one with the window's next
+ * draw, and Android 9 and earlier hide nothing in positionLost. A view that
+ * is not shown or not laid out has nothing on screen to keep.
+ */
+ boolean retiresWhenReplaced(int sdkInt, boolean shown, int width, int height) {
+ return this == SURFACE_VIEW
+ && sdkInt >= Build.VERSION_CODES.Q
+ && sdkInt <= Build.VERSION_CODES.S
+ && shown
+ && width > 0
+ && height > 0;
+ }
+}
diff --git a/packages/webgpu/android/src/main/java/com/webgpu/FirstFrameDrawGate.java b/packages/webgpu/android/src/main/java/com/webgpu/FirstFrameDrawGate.java
new file mode 100644
index 000000000..d531159a4
--- /dev/null
+++ b/packages/webgpu/android/src/main/java/com/webgpu/FirstFrameDrawGate.java
@@ -0,0 +1,144 @@
+package com.webgpu;
+
+import android.os.Build;
+
+import java.util.ArrayList;
+
+/**
+ * Holds a SurfaceView's draw reports, and its hole, until a frame is
+ * presented into its surface. The window punches the hole a surface behind it
+ * shows through once the draw is reported finished, so a report sent before
+ * the first frame would open the hole on an empty surface, which shows black.
+ * From Android 14 the platform bounds the window's wait for that report
+ * itself, so the report waits for the frame however long it takes. Before it,
+ * a first frame that does not come within FIRST_FRAME_WAIT_MILLIS is not
+ * waited for: the reports go out, and the view keeps the hole closed until
+ * hasFrame(). A SurfaceView counts its draw as reported once, for its later
+ * surfaces too, so the view keeps their holes closed the same way. Leaving
+ * the window reports what is held, because Android 13 settles a SurfaceView's
+ * pending draw only through that report. Main thread.
+ *
+ * Frames are presented on the JS thread, the UI thread or a worklet runtime,
+ * so the report of a first frame reaches the main thread posted, possibly
+ * after its surface was replaced: each surface carries a token, and a frame
+ * reported for another token is not this surface's.
+ */
+final class FirstFrameDrawGate {
+ /**
+ * How long a window waits for the first frame before Android 14, the bound
+ * Android 14 puts on the SurfaceSyncGroup a window waits for
+ * (TRANSACTION_READY_TIMEOUT). Android 12L reports the draw through
+ * ViewRootImpl.drawPending and Android 13 through a SurfaceSyncer, neither
+ * of them bounded, so there a present that fails, or a canvas that never
+ * presents, would otherwise hold the window's draw report for good.
+ */
+ static final long FIRST_FRAME_WAIT_MILLIS = 1000;
+
+ /** What the gate asks of the view that owns it. */
+ interface Host {
+ /** Runs action on the main thread after delayMillis, unless removed. */
+ void postDelayed(Runnable action, long delayMillis);
+
+ /** Removes an action posted with postDelayed. */
+ void removeCallbacks(Runnable action);
+
+ /** hasFrame() changed: the view redraws, opening or closing its hole. */
+ void invalidate();
+ }
+
+ private final Host mHost;
+ private final boolean mWindowWaitBoundedByPlatform;
+ private final Runnable mFirstFrameDeadline = this::onFirstFrameDeadline;
+ private long mSurfaceToken = 0;
+ private boolean mFramePresented = false;
+ private boolean mDeadlinePosted = false;
+ private final ArrayList mPendingDrawsFinished = new ArrayList<>();
+
+ FirstFrameDrawGate(Host host, int sdkInt) {
+ mHost = host;
+ mWindowWaitBoundedByPlatform = sdkInt >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE;
+ }
+
+ /** Whether a frame has been presented into the current surface: the view opens its hole only then. */
+ boolean hasFrame() {
+ return mFramePresented;
+ }
+
+ /** A new surface, named by surfaceToken: nothing has been presented into it yet. */
+ void onSurfaceCreated(long surfaceToken) {
+ mSurfaceToken = surfaceToken;
+ setFramePresented(false);
+ }
+
+ /** The window asks to be told once the surface has drawn. */
+ void onRedrawNeeded(Runnable drawingFinished) {
+ if (mFramePresented) {
+ drawingFinished.run();
+ return;
+ }
+ mPendingDrawsFinished.add(drawingFinished);
+ if (!mWindowWaitBoundedByPlatform && !mDeadlinePosted) {
+ mDeadlinePosted = true;
+ mHost.postDelayed(mFirstFrameDeadline, FIRST_FRAME_WAIT_MILLIS);
+ }
+ }
+
+ /** Whether surfaceToken names the surface the view has now. */
+ boolean hasSurface(long surfaceToken) {
+ return surfaceToken != 0 && surfaceToken == mSurfaceToken;
+ }
+
+ /** A frame was presented into the surface surfaceToken names. */
+ void onFirstFramePresented(long surfaceToken) {
+ if (surfaceToken != mSurfaceToken) {
+ return;
+ }
+ setFramePresented(true);
+ cancelDeadline();
+ reportPendingDraws();
+ }
+
+ /** The surface is gone, so it has nothing left to draw. */
+ void onSurfaceReleased() {
+ mSurfaceToken = 0;
+ setFramePresented(false);
+ cancelDeadline();
+ reportPendingDraws();
+ }
+
+ /** The view is leaving its window, which must hear about every draw it waits for. */
+ void onDetachedFromWindow() {
+ cancelDeadline();
+ reportPendingDraws();
+ }
+
+ private void onFirstFrameDeadline() {
+ mDeadlinePosted = false;
+ reportPendingDraws();
+ }
+
+ private void setFramePresented(boolean framePresented) {
+ if (mFramePresented != framePresented) {
+ mFramePresented = framePresented;
+ mHost.invalidate();
+ }
+ }
+
+ private void cancelDeadline() {
+ if (mDeadlinePosted) {
+ mDeadlinePosted = false;
+ mHost.removeCallbacks(mFirstFrameDeadline);
+ }
+ }
+
+ private void reportPendingDraws() {
+ if (mPendingDrawsFinished.isEmpty()) {
+ return;
+ }
+ ArrayList pending = new ArrayList<>(mPendingDrawsFinished);
+ mPendingDrawsFinished.clear();
+ for (Runnable drawingFinished : pending) {
+ drawingFinished.run();
+ }
+ }
+}
diff --git a/packages/webgpu/android/src/main/java/com/webgpu/OutgoingViewHold.java b/packages/webgpu/android/src/main/java/com/webgpu/OutgoingViewHold.java
new file mode 100644
index 000000000..6f08bed2c
--- /dev/null
+++ b/packages/webgpu/android/src/main/java/com/webgpu/OutgoingViewHold.java
@@ -0,0 +1,127 @@
+package com.webgpu;
+
+import android.os.Build;
+
+import androidx.annotation.Nullable;
+
+/**
+ * Keeps the view a WebGPUView replaced on screen until the new one shows the
+ * canvas: the outgoing view stays above the new one until the new view is in
+ * the window (see Arrival) and a window frame drawn after that has committed,
+ * so the new view is on screen beneath it before it leaves. A new view that is
+ * not in the window within FirstFrameDrawGate's wait is not waited for, as the
+ * window does not wait for its first frame either. Another swap, or the
+ * WebGPUView leaving its window, removes the view at once. Main thread.
+ */
+final class OutgoingViewHold {
+ /** What puts a new SurfaceView in the window. */
+ enum Arrival {
+ /** Its first frame, which the window shows once the frame is reported drawn. */
+ FIRST_FRAME,
+ /**
+ * Its first frame and then its hole, which the window opens in a message
+ * posted after the frame is reported drawn: Android 13's SurfaceView
+ * completes the report through SurfaceSyncer.setupSync(Runnable), which
+ * posts it, so the window frames drawn in between have no hole.
+ */
+ HOLE_AFTER_FIRST_FRAME;
+
+ /** How a SurfaceView above or behind the window reaches it on sdkInt. */
+ static Arrival ofSurfaceView(int sdkInt, boolean zOrderOnTop) {
+ return sdkInt == Build.VERSION_CODES.TIRAMISU && !zOrderOnTop ? HOLE_AFTER_FIRST_FRAME : FIRST_FRAME;
+ }
+ }
+
+ /** What the hold asks of the view that owns it. */
+ interface Host {
+ /** Removes a held view from the hierarchy. */
+ void removeView(V view);
+
+ /** Runs action once a window frame drawn after this call has committed. */
+ void runAfterWindowFrameCommits(Runnable action);
+
+ /** Runs action on the main thread after delayMillis, unless removed. */
+ void postDelayed(Runnable action, long delayMillis);
+
+ /** Removes an action posted with postDelayed. */
+ void removeCallbacks(Runnable action);
+ }
+
+ /** What the held view waits for next. */
+ private enum Wait {
+ FIRST_FRAME,
+ HOLE,
+ WINDOW_FRAME,
+ }
+
+ private final Host mHost;
+ private final Runnable mArrivalDeadline = this::release;
+ @Nullable
+ private V mHeld;
+ private Arrival mArrival = Arrival.FIRST_FRAME;
+ private Wait mWait = Wait.FIRST_FRAME;
+
+ OutgoingViewHold(Host host) {
+ mHost = host;
+ }
+
+ /** The held view, laid out with the canvas while it stays; null when none is held. */
+ @Nullable
+ V held() {
+ return mHeld;
+ }
+
+ /** Holds view until a new view arriving as arrival is in the window, removing the one held before it. */
+ void hold(V view, Arrival arrival) {
+ release();
+ mHeld = view;
+ mArrival = arrival;
+ mWait = Wait.FIRST_FRAME;
+ mHost.postDelayed(mArrivalDeadline, FirstFrameDrawGate.FIRST_FRAME_WAIT_MILLIS);
+ }
+
+ /** Removes the held view now. */
+ void release() {
+ if (mHeld == null) {
+ return;
+ }
+ V view = mHeld;
+ mHeld = null;
+ mHost.removeCallbacks(mArrivalDeadline);
+ mHost.removeView(view);
+ }
+
+ /** The new view presented its first frame. */
+ void onFirstFramePresented() {
+ if (mHeld == null || mWait != Wait.FIRST_FRAME) {
+ return;
+ }
+ if (mArrival == Arrival.HOLE_AFTER_FIRST_FRAME) {
+ mWait = Wait.HOLE;
+ } else {
+ leaveAfterWindowFrame();
+ }
+ }
+
+ /**
+ * The new view asked for the window's transparent region, which a
+ * SurfaceView does as it attaches and, from Android 11, as it opens its hole.
+ */
+ void onTransparentRegionRequested() {
+ if (mHeld == null || mWait != Wait.HOLE) {
+ return;
+ }
+ leaveAfterWindowFrame();
+ }
+
+ private void leaveAfterWindowFrame() {
+ mWait = Wait.WINDOW_FRAME;
+ mHost.removeCallbacks(mArrivalDeadline);
+ V view = mHeld;
+ mHost.runAfterWindowFrameCommits(() -> {
+ if (mHeld == view) {
+ release();
+ }
+ });
+ }
+}
diff --git a/packages/webgpu/android/src/main/java/com/webgpu/SurfaceFrameCapture.java b/packages/webgpu/android/src/main/java/com/webgpu/SurfaceFrameCapture.java
new file mode 100644
index 000000000..9ab991680
--- /dev/null
+++ b/packages/webgpu/android/src/main/java/com/webgpu/SurfaceFrameCapture.java
@@ -0,0 +1,69 @@
+package com.webgpu;
+
+import android.graphics.Bitmap;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.view.PixelCopy;
+import android.view.Surface;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Reads the frame a surface shows back into a bitmap. A SurfaceView's surface
+ * goes with its buffers when the app goes to the background or the view leaves
+ * its window, and SurfaceHolder.Callback.surfaceDestroyed is the last moment
+ * that frame can be read; the canvas keeps the copy for its next surface.
+ */
+final class SurfaceFrameCapture {
+
+ // The UI thread waits for the read; one that has not completed by then is
+ // given up, and the canvas comes back with the next frame it draws.
+ private static final long MAX_WAIT_MILLIS = 1000;
+
+ private static HandlerThread sListenerThread;
+
+ private SurfaceFrameCapture() {}
+
+ /** The surface's frame at width x height, or null if it could not be read. Main thread. */
+ @Nullable
+ static Bitmap capture(@NonNull Surface surface, int width, int height) {
+ if (width <= 0 || height <= 0 || !surface.isValid()) {
+ return null;
+ }
+ Bitmap frame = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ CountDownLatch done = new CountDownLatch(1);
+ int[] result = {PixelCopy.ERROR_UNKNOWN};
+ PixelCopy.request(surface, frame, copyResult -> {
+ result[0] = copyResult;
+ done.countDown();
+ }, listenerHandler());
+ try {
+ if (!done.await(MAX_WAIT_MILLIS, TimeUnit.MILLISECONDS)) {
+ frame.recycle();
+ return null;
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ frame.recycle();
+ return null;
+ }
+ if (result[0] != PixelCopy.SUCCESS) {
+ frame.recycle();
+ return null;
+ }
+ return frame;
+ }
+
+ // PixelCopy reports on a looper; the main thread is the one waiting here.
+ private static synchronized Handler listenerHandler() {
+ if (sListenerThread == null) {
+ sListenerThread = new HandlerThread("WebGPUFrameCapture");
+ sListenerThread.start();
+ }
+ return new Handler(sListenerThread.getLooper());
+ }
+}
diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAPI.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAPI.java
index 487128285..9c6a45915 100644
--- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAPI.java
+++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAPI.java
@@ -12,16 +12,58 @@
* is blitted onto the next attached surface.
*/
public interface WebGPUAPI {
+ /** What a backing view the canvas no longer draws into reports to: nothing. */
+ WebGPUAPI DETACHED = new WebGPUAPI() {
+ @Override
+ public void surfaceCreated(Surface surface, long firstFrameToken, boolean beforeFirstDraw) {}
+ @Override
+ public void surfaceChanged(Surface surface) {}
+
+ @Override
+ public void keepFrame(Surface surface) {}
+
+ @Override
+ public void surfaceOffscreen() {}
+
+ @Override
+ public void surfaceUpdated() {}
+
+ @Override
+ public int getContextId() {
+ return 0;
+ }
+ };
+
+ /**
+ * firstFrameToken is nonzero for a surface whose view waits for its first
+ * frame (a SurfaceView behind its window, see FirstFrameDrawGate): the
+ * native side reports that frame back with it, from the thread that
+ * presented it, through WebGPUView.onFirstFramePresented. beforeFirstDraw
+ * is set for a surface the view was given before its first draw, which
+ * shows the latest frame only if it is presented into it right away.
+ */
void surfaceCreated(
- Surface surface
+ Surface surface,
+ long firstFrameToken,
+ boolean beforeFirstDraw
);
void surfaceChanged(
Surface surface
);
+ /**
+ * The surface is about to be destroyed together with the frame it shows: the
+ * canvas reads that frame back and shows it on its next surface. Main
+ * thread, before surfaceOffscreen, while the surface is still valid.
+ */
+ void keepFrame(Surface surface);
+
void surfaceOffscreen();
+ /** A TextureView's window is drawing the newest frame of its surface. */
+ void surfaceUpdated();
+
int getContextId();
}
diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceView.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceView.java
index 1da8bd519..efe2a449c 100644
--- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceView.java
+++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceView.java
@@ -2,16 +2,42 @@
import android.annotation.SuppressLint;
import android.content.Context;
+import android.graphics.Canvas;
import android.graphics.PixelFormat;
+import android.graphics.Region;
+import android.os.Build;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.concurrent.atomic.AtomicLong;
@SuppressLint("ViewConstructor")
-public class WebGPUSurfaceView extends SurfaceView implements SurfaceHolder.Callback {
+public class WebGPUSurfaceView extends SurfaceView implements SurfaceHolder.Callback2 {
+
+ // Unique across views: a first frame reported for a surface the canvas has
+ // since replaced, in this view or the one before it, matches no live token.
+ private static final AtomicLong sNextSurfaceToken = new AtomicLong();
WebGPUAPI mApi;
+ private final FirstFrameDrawGate mDrawGate = new FirstFrameDrawGate(new FirstFrameDrawGate.Host() {
+ @Override
+ public void postDelayed(Runnable action, long delayMillis) {
+ WebGPUSurfaceView.this.postDelayed(action, delayMillis);
+ }
+
+ @Override
+ public void removeCallbacks(Runnable action) {
+ WebGPUSurfaceView.this.removeCallbacks(action);
+ }
+
+ @Override
+ public void invalidate() {
+ WebGPUSurfaceView.this.invalidate();
+ }
+ }, Build.VERSION.SDK_INT);
public WebGPUSurfaceView(Context context, WebGPUAPI api, boolean zOrderOnTop, boolean opaque) {
super(context);
@@ -27,17 +53,45 @@ public void setOpaque(boolean opaque) {
getHolder().setFormat(opaque ? PixelFormat.OPAQUE : PixelFormat.TRANSLUCENT);
}
+ // SurfaceView skips draw() and punches its hole in dispatchDraw.
+ @Override
+ protected void dispatchDraw(@NonNull Canvas canvas) {
+ if (mDrawGate.hasFrame()) {
+ super.dispatchDraw(canvas);
+ }
+ }
+
+ // SurfaceView adds its bounds to the window's transparent region once its
+ // draw is reported, and the compositor skips the window there.
+ @Override
+ public boolean gatherTransparentRegion(@Nullable Region region) {
+ return !mDrawGate.hasFrame() || super.gatherTransparentRegion(region);
+ }
+
+ // The canvas moved to another view while this one stays attached and
+ // undrawn (BackingViewKind.retiresWhenReplaced): its frame goes with the
+ // canvas, its surface leaves the renderer now, its draw reports are settled,
+ // and the view reports nothing more and, with no frame, punches no hole.
+ void retire() {
+ mApi.keepFrame(getHolder().getSurface());
+ releaseSurface();
+ mApi = WebGPUAPI.DETACHED;
+ }
+
@Override
protected void onDetachedFromWindow() {
+ mDrawGate.onDetachedFromWindow();
super.onDetachedFromWindow();
// surfaceDestroyed() normally fires during detach as well; going offscreen
// is idempotent, so this is just a safety net for paths where it does not.
- mApi.surfaceOffscreen();
+ releaseSurface();
}
@Override
public void surfaceCreated(@NonNull SurfaceHolder holder) {
- mApi.surfaceCreated(holder.getSurface());
+ long surfaceToken = sNextSurfaceToken.incrementAndGet();
+ mDrawGate.onSurfaceCreated(surfaceToken);
+ mApi.surfaceCreated(holder.getSurface(), surfaceToken, false);
}
@Override
@@ -45,8 +99,39 @@ public void surfaceChanged(@NonNull SurfaceHolder holder, int format, int width,
mApi.surfaceChanged(holder.getSurface());
}
+ @Override
+ public void surfaceRedrawNeeded(@NonNull SurfaceHolder holder) {
+ // Answered in surfaceRedrawNeededAsync, which SurfaceView calls instead.
+ }
+
+ // The draw is reported finished once a frame is in the surface: see
+ // FirstFrameDrawGate. GLSurfaceView holds this runnable the same way.
+ @Override
+ public void surfaceRedrawNeededAsync(@NonNull SurfaceHolder holder, @NonNull Runnable drawingFinished) {
+ mDrawGate.onRedrawNeeded(drawingFinished);
+ }
+
+ // The surface goes with its buffers (the app going to the background, the
+ // view leaving its window or being replaced): the canvas keeps its frame for
+ // the next surface.
@Override
public void surfaceDestroyed(@NonNull SurfaceHolder holder) {
+ mApi.keepFrame(holder.getSurface());
+ releaseSurface();
+ }
+
+ /** Whether surfaceToken names the surface this view has now. Main thread. */
+ boolean hasSurface(long surfaceToken) {
+ return mDrawGate.hasSurface(surfaceToken);
+ }
+
+ /** A frame was presented into the surface surfaceToken names. Main thread. */
+ void onFirstFramePresented(long surfaceToken) {
+ mDrawGate.onFirstFramePresented(surfaceToken);
+ }
+
+ private void releaseSurface() {
+ mDrawGate.onSurfaceReleased();
mApi.surfaceOffscreen();
}
}
diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceViewWithSC.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceViewWithSC.java
index e999e098d..f99c389fe 100644
--- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceViewWithSC.java
+++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceViewWithSC.java
@@ -54,7 +54,7 @@ public void surfaceCreated(@NonNull SurfaceHolder holder) {
scb.setFormat(PixelFormat.RGBA_8888);
mSurfaceControl = scb.build();
mSurface = new Surface(mSurfaceControl);
- mApi.surfaceCreated(mSurface);
+ mApi.surfaceCreated(mSurface, 0, false);
}
SurfaceControl.Transaction tr = new SurfaceControl.Transaction();
tr.setVisibility(mSurfaceControl, true);
diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUTextureView.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUTextureView.java
index e84f73806..86f879846 100644
--- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUTextureView.java
+++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUTextureView.java
@@ -20,10 +20,27 @@ public WebGPUTextureView(Context context, WebGPUAPI api, boolean opaque) {
setSurfaceTextureListener(this);
}
+ // Hands the view its texture before its first draw, which then shows the
+ // frame presented into it (BackingViewKind.receivesSurfaceBeforeFirstDraw).
+ // TextureView reports no onSurfaceTextureAvailable for a texture it was given.
+ void supplySurfaceTexture(int width, int height) {
+ SurfaceTexture surfaceTexture = new SurfaceTexture(false);
+ surfaceTexture.setDefaultBufferSize(width, height);
+ setSurfaceTexture(surfaceTexture);
+ mSurface = new Surface(surfaceTexture);
+ mApi.surfaceCreated(mSurface, 0, true);
+ }
+
+ // The canvas moved to another view: this one keeps its last frame on screen
+ // until it is removed, and reports nothing.
+ void detachFromRenderer() {
+ mApi = WebGPUAPI.DETACHED;
+ }
+
@Override
public void onSurfaceTextureAvailable(@NonNull SurfaceTexture surfaceTexture, int width, int height) {
mSurface = new Surface(surfaceTexture);
- mApi.surfaceCreated(mSurface);
+ mApi.surfaceCreated(mSurface, 0, false);
}
@Override
@@ -45,6 +62,6 @@ public boolean onSurfaceTextureDestroyed(@NonNull SurfaceTexture surfaceTexture)
@Override
public void onSurfaceTextureUpdated(@NonNull SurfaceTexture surfaceTexture) {
- // No implementation needed
+ mApi.surfaceUpdated();
}
}
diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java
index 2f2c5a46a..ffbec33e1 100644
--- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java
+++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java
@@ -1,33 +1,69 @@
package com.webgpu;
import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
import android.view.Surface;
import android.view.View;
+import androidx.annotation.Nullable;
+
import com.facebook.proguard.annotations.DoNotStrip;
import com.facebook.react.uimanager.ThemedReactContext;
import com.facebook.react.views.view.ReactViewGroup;
public class WebGPUView extends ReactViewGroup implements WebGPUAPI {
- // Backing view kinds, see updateView().
- private static final int KIND_SURFACE_VIEW = 0;
- private static final int KIND_TEXTURE_VIEW = 1;
- private static final int KIND_HARDWARE_BUFFER_VIEW = 2;
-
private int mContextId;
private boolean mOpaque = true;
private String mSurfaceType = "auto";
private boolean mZOrderOnTop = false;
- private int mAppliedKind = -1;
+ private BackingViewKind mAppliedKind = null;
private boolean mAppliedZOrderOnTop;
private WebGPUModule mModule;
private View mView = null;
+ // Native posts a frame presented on the main thread here, to present it
+ // behind the draw the window has pending (see SurfaceRegistry.h).
+ private final Handler mMainHandler = new Handler(Looper.getMainLooper());
+ private final Runnable mPostedPresent = () -> presentPostedFrame(mContextId);
// Only a SurfaceView votes: a TextureView or HardwareBufferView is
// composited into the window, which votes for it.
private final SurfaceFrameRateVote mFrameRateVote = new SurfaceFrameRateVote(
this, idleAfterNanos -> releaseFrameRateVoteIfIdle(mContextId, idleAfterNanos));
+ // Attached and undrawn until the next swap, until the canvas leaves its
+ // window or the window hides: see BackingViewKind.retiresWhenReplaced.
+ private WebGPUSurfaceView mRetiredSurfaceView = null;
+ // Above mView until mView shows the canvas, see updateView().
+ private final OutgoingViewHold mOutgoingView =
+ new OutgoingViewHold<>(new OutgoingViewHold.Host() {
+ @Override
+ public void removeView(WebGPUTextureView view) {
+ WebGPUView.this.removeView(view);
+ }
+
+ @Override
+ public void runAfterWindowFrameCommits(Runnable action) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ action.run();
+ return;
+ }
+ getViewTreeObserver().registerFrameCommitCallback(action);
+ invalidate();
+ }
+
+ @Override
+ public void postDelayed(Runnable action, long delayMillis) {
+ WebGPUView.this.postDelayed(action, delayMillis);
+ }
+
+ @Override
+ public void removeCallbacks(Runnable action) {
+ WebGPUView.this.removeCallbacks(action);
+ }
+ });
WebGPUView(Context context) {
super(context);
@@ -63,24 +99,14 @@ public void setZOrderOnTop(boolean value) {
mZOrderOnTop = value;
}
- // Resolve the backing view from the props. "auto" picks SurfaceView for an
- // opaque canvas and TextureView for a non-opaque one. WebGPUHardwareBufferView
- // (a plain View drawing AHardwareBuffers inline, API 29+) is opt-in via
- // surfaceType until it gets a runtime fallback to TextureView when the
- // AHardwareBuffer import fails on a device; below API 29 the request itself
- // falls back to TextureView.
- private int resolveKind() {
- if ("SurfaceView".equals(mSurfaceType)) {
- return KIND_SURFACE_VIEW;
+ // Resolve the backing view from the props: see BackingViewKind for what each
+ // surfaceType, and "auto", picks.
+ private BackingViewKind resolveKind() {
+ BackingViewKind requested = BackingViewKind.fromSurfaceType(mSurfaceType, Build.VERSION.SDK_INT);
+ if (requested != null) {
+ return requested;
}
- if ("TextureView".equals(mSurfaceType)) {
- return KIND_TEXTURE_VIEW;
- }
- if ("HardwareBufferView".equals(mSurfaceType)) {
- boolean hardwareBufferSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
- return hardwareBufferSupported ? KIND_HARDWARE_BUFFER_VIEW : KIND_TEXTURE_VIEW;
- }
- return mOpaque ? KIND_SURFACE_VIEW : KIND_TEXTURE_VIEW;
+ return BackingViewKind.forAutoSurfaceType(mOpaque, mZOrderOnTop, Build.VERSION.SDK_INT);
}
// Apply the complete prop transaction once, after contextId and all rendering
@@ -88,39 +114,90 @@ private int resolveKind() {
// which a SurfaceView must know before it attaches) replaces the child;
// opacity is applied in place.
public void updateView() {
- int kind = resolveKind();
- boolean zOrderOnTop = kind == KIND_SURFACE_VIEW && mZOrderOnTop;
+ BackingViewKind kind = resolveKind();
+ boolean zOrderOnTop = kind == BackingViewKind.SURFACE_VIEW && mZOrderOnTop;
if (mView == null || kind != mAppliedKind || zOrderOnTop != mAppliedZOrderOnTop) {
+ mOutgoingView.release();
+ removeRetiredSurfaceView();
if (mView != null) {
- removeView(mView);
+ if (mAppliedKind.retiresWhenReplaced(Build.VERSION.SDK_INT, isShown(), getWidth(), getHeight())) {
+ retireSurfaceView((WebGPUSurfaceView) mView);
+ } else if (kind.keepsOutgoingOnScreen(mAppliedKind, isShown(), getWidth(), getHeight())) {
+ WebGPUTextureView outgoing = (WebGPUTextureView) mView;
+ outgoing.detachFromRenderer();
+ mOutgoingView.hold(outgoing, OutgoingViewHold.Arrival.ofSurfaceView(Build.VERSION.SDK_INT, zOrderOnTop));
+ } else {
+ if (mView instanceof WebGPUTextureView) {
+ keepTextureViewFrame((WebGPUTextureView) mView);
+ }
+ removeView(mView);
+ }
}
mAppliedKind = kind;
mAppliedZOrderOnTop = zOrderOnTop;
Context ctx = getContext();
switch (kind) {
- case KIND_HARDWARE_BUFFER_VIEW:
+ case HARDWARE_BUFFER_VIEW:
mView = new WebGPUHardwareBufferView(ctx, this);
break;
- case KIND_TEXTURE_VIEW:
+ case TEXTURE_VIEW:
mView = new WebGPUTextureView(ctx, this, mOpaque);
break;
+ case SURFACE_VIEW:
default:
mView = new WebGPUSurfaceView(ctx, this, zOrderOnTop, mOpaque);
break;
}
- addView(mView);
+ // Beneath an outgoing view, which covers it until it shows the canvas.
+ addView(mView, 0);
// ReactViewGroup.requestLayout() is a deliberate no-op, so addView outside
// the layout pass never lays the child out; do it by hand or the new view
// stays 0x0 and never gets a surface.
layoutChild();
- } else if (kind == KIND_TEXTURE_VIEW) {
+ // Swapping a canvas on screen to a TextureView draws no empty frame: the
+ // new view gets its texture, and the latest frame, before its first draw
+ // rather than during it.
+ if (kind.receivesSurfaceBeforeFirstDraw(isShown(), getWidth(), getHeight())) {
+ ((WebGPUTextureView) mView).supplySurfaceTexture(getWidth(), getHeight());
+ }
+ } else if (kind == BackingViewKind.TEXTURE_VIEW) {
((WebGPUTextureView) mView).setOpaque(mOpaque);
- } else if (kind == KIND_SURFACE_VIEW) {
+ } else if (kind == BackingViewKind.SURFACE_VIEW) {
((WebGPUSurfaceView) mView).setOpaque(mOpaque);
}
// WebGPUHardwareBufferView always composites with alpha; opacity is a no-op.
}
+ private void retireSurfaceView(WebGPUSurfaceView view) {
+ view.retire();
+ mRetiredSurfaceView = view;
+ // The window recomputes its transparent region only on a layout pass,
+ // which React Native's requestLayout never reaches.
+ requestTransparentRegion(view);
+ }
+
+ private void removeRetiredSurfaceView() {
+ WebGPUSurfaceView retired = mRetiredSurfaceView;
+ if (retired == null) {
+ return;
+ }
+ mRetiredSurfaceView = null;
+ removeView(retired);
+ }
+
+ @Override
+ protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
+ return child != mRetiredSurfaceView && super.drawChild(canvas, child, drawingTime);
+ }
+
+ @Override
+ protected void onWindowVisibilityChanged(int visibility) {
+ super.onWindowVisibilityChanged(visibility);
+ if (visibility != VISIBLE) {
+ removeRetiredSurfaceView();
+ }
+ }
+
private void layoutChild() {
mView.layout(0, 0, getMeasuredWidth(), getMeasuredHeight());
}
@@ -131,18 +208,74 @@ protected void onLayout(boolean changed, int left, int top, int right, int botto
if (mView != null) {
layoutChild();
}
+ WebGPUTextureView outgoing = mOutgoingView.held();
+ if (outgoing != null) {
+ outgoing.layout(0, 0, getMeasuredWidth(), getMeasuredHeight());
+ }
}
+ // A SurfaceView asks its parent for the window's transparent region as it
+ // attaches and, from Android 11, as it opens its hole
+ // (SurfaceView.performDrawFinished).
@Override
- public void surfaceCreated(Surface surface) {
+ public void requestTransparentRegion(View child) {
+ super.requestTransparentRegion(child);
+ if (child == mView) {
+ mOutgoingView.onTransparentRegionRequested();
+ }
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ mOutgoingView.release();
+ removeRetiredSurfaceView();
+ super.onDetachedFromWindow();
+ }
+
+ @Override
+ public void surfaceCreated(Surface surface, long firstFrameToken, boolean beforeFirstDraw) {
float density = getResources().getDisplayMetrics().density;
float width = getWidth() / density;
float height = getHeight() / density;
- boolean votesFrameRate = mAppliedKind == KIND_SURFACE_VIEW;
+ // The outgoing view still shows the canvas while it is held: the new
+ // surface starts from the frame on screen now.
+ WebGPUTextureView outgoing = mOutgoingView.held();
+ if (outgoing != null) {
+ keepTextureViewFrame(outgoing);
+ }
+ boolean votesFrameRate = mAppliedKind == BackingViewKind.SURFACE_VIEW;
if (votesFrameRate) {
mFrameRateVote.attach(surface);
}
- onSurfaceCreate(surface, mContextId, width, height, votesFrameRate);
+ onSurfaceCreate(
+ surface,
+ mContextId,
+ width,
+ height,
+ mAppliedKind.presentsBehindPendingDraw(),
+ mAppliedKind.acquiresAfterDraw(Build.VERSION.SDK_INT),
+ votesFrameRate,
+ firstFrameToken,
+ beforeFirstDraw);
+ }
+
+ /**
+ * The first frame was presented into the surface firstFrameToken names.
+ * Called from native on the thread that presented it.
+ */
+ @DoNotStrip
+ public void onFirstFramePresented(long firstFrameToken) {
+ post(() -> {
+ if (mView instanceof WebGPUSurfaceView) {
+ WebGPUSurfaceView surfaceView = (WebGPUSurfaceView) mView;
+ // Before the SurfaceView reports its draw: a report made during a
+ // window relayout opens the hole at once, even on Android 13.
+ if (surfaceView.hasSurface(firstFrameToken)) {
+ mOutgoingView.onFirstFramePresented();
+ }
+ surfaceView.onFirstFramePresented(firstFrameToken);
+ }
+ });
}
@Override
@@ -153,6 +286,31 @@ public void surfaceChanged(Surface surface) {
onSurfaceChanged(surface, mContextId, width, height);
}
+ @Override
+ public void keepFrame(Surface surface) {
+ long size = keepableFrameSize(mContextId);
+ Bitmap frame = SurfaceFrameCapture.capture(surface, (int) (size >>> 32), (int) size);
+ keepCapturedFrame(frame);
+ }
+
+ // A TextureView replaced by another backing view takes its frame with it;
+ // its layer, which getBitmap reads, is gone once it leaves the window.
+ private void keepTextureViewFrame(WebGPUTextureView view) {
+ long size = keepableFrameSize(mContextId);
+ int width = (int) (size >>> 32);
+ int height = (int) size;
+ if (width > 0 && height > 0 && view.isAvailable()) {
+ keepCapturedFrame(view.getBitmap(width, height));
+ }
+ }
+
+ private void keepCapturedFrame(@Nullable Bitmap frame) {
+ if (frame != null) {
+ keepBitmapFrame(mContextId, frame);
+ frame.recycle();
+ }
+ }
+
@Override
public void surfaceOffscreen() {
// The surface is still valid here, so the vote can be taken back.
@@ -169,20 +327,42 @@ void onAnimating() {
mFrameRateVote.onAnimating();
}
+ @Override
+ public void surfaceUpdated() {
+ if (!mAppliedKind.acquiresAfterDraw(Build.VERSION.SDK_INT)) {
+ return;
+ }
+ long presentCount = onSurfaceDrawing(mContextId);
+ // The window takes the frame when this draw syncs with its render thread,
+ // and a message posted now runs after that sync.
+ mMainHandler.post(() -> onSurfaceDrawn(mContextId, presentCount));
+ }
+
@Override
public int getContextId() {
return mContextId;
}
+ /** Called by native on the main thread, see mPostedPresent. */
+ @DoNotStrip
+ private void postPresentBehindPendingDraw() {
+ mMainHandler.post(mPostedPresent);
+ }
+
/**
* Called from WebGPUViewManager.onDropViewInstance when React removes this
* view: the view dies with its Canvas, so it retires the registry entry.
*/
public void destroy() {
mFrameRateVote.detach();
+ mOutgoingView.release();
+ removeRetiredSurfaceView();
onViewDestroyed(mContextId);
}
+ // presentsBehindPendingDraw has native post a present made on the main
+ // thread through postPresentBehindPendingDraw(); acquiresAfterDraw has it
+ // acquire a frame only once the window drew the last one (surfaceUpdated);
// reportAnimation asks native to call onAnimating() when frames start being
// presented to this surface back to back.
@DoNotStrip
@@ -191,7 +371,11 @@ private native void onSurfaceCreate(
int contextId,
float width,
float height,
- boolean reportAnimation
+ boolean presentsBehindPendingDraw,
+ boolean acquiresAfterDraw,
+ boolean reportAnimation,
+ long firstFrameToken,
+ boolean adoptsBeforeFirstDraw
);
@DoNotStrip
@@ -202,9 +386,26 @@ private native void onSurfaceChanged(
float height
);
+ @DoNotStrip
+ private native void presentPostedFrame(int contextId);
+
+ @DoNotStrip
+ private native long onSurfaceDrawing(int contextId);
+
+ @DoNotStrip
+ private native void onSurfaceDrawn(int contextId, long presentCount);
+
@DoNotStrip
private native void switchToOffscreenSurface(int contextId);
+ // The drawing buffer's size a kept frame is read at, width << 32 | height;
+ // 0 when there is nothing to keep.
+ @DoNotStrip
+ private native long keepableFrameSize(int contextId);
+
+ @DoNotStrip
+ private native boolean keepBitmapFrame(int contextId, Bitmap frame);
+
@DoNotStrip
private native void onViewDestroyed(int contextId);
diff --git a/packages/webgpu/android/src/test/java/com/webgpu/BackingViewKindTest.java b/packages/webgpu/android/src/test/java/com/webgpu/BackingViewKindTest.java
new file mode 100644
index 000000000..e25297a6a
--- /dev/null
+++ b/packages/webgpu/android/src/test/java/com/webgpu/BackingViewKindTest.java
@@ -0,0 +1,141 @@
+package com.webgpu;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import android.os.Build;
+
+import org.junit.Test;
+
+public class BackingViewKindTest {
+ private static final int[] BEFORE_ANDROID_10 = {
+ Build.VERSION_CODES.O,
+ Build.VERSION_CODES.O_MR1,
+ Build.VERSION_CODES.P,
+ };
+
+ private static final int[] BEFORE_ANDROID_12L = {
+ Build.VERSION_CODES.O,
+ Build.VERSION_CODES.O_MR1,
+ Build.VERSION_CODES.P,
+ Build.VERSION_CODES.Q,
+ Build.VERSION_CODES.R,
+ Build.VERSION_CODES.S,
+ };
+
+ private static final int[] FROM_ANDROID_12L = {
+ Build.VERSION_CODES.S_V2,
+ Build.VERSION_CODES.TIRAMISU,
+ Build.VERSION_CODES.UPSIDE_DOWN_CAKE,
+ };
+
+ @Test
+ public void onlyAShownLaidOutSurfaceViewRetiresOnAndroid10To12() {
+ for (int sdkInt = Build.VERSION_CODES.N; sdkInt <= Build.VERSION_CODES.UPSIDE_DOWN_CAKE; sdkInt++) {
+ boolean retires = sdkInt >= Build.VERSION_CODES.Q && sdkInt <= Build.VERSION_CODES.S;
+ assertEquals("API " + sdkInt, retires, BackingViewKind.SURFACE_VIEW.retiresWhenReplaced(sdkInt, true, 100, 50));
+ assertEquals(false, BackingViewKind.SURFACE_VIEW.retiresWhenReplaced(sdkInt, false, 100, 50));
+ assertEquals(false, BackingViewKind.SURFACE_VIEW.retiresWhenReplaced(sdkInt, true, 0, 50));
+ assertEquals(false, BackingViewKind.TEXTURE_VIEW.retiresWhenReplaced(sdkInt, true, 100, 50));
+ assertEquals(false, BackingViewKind.HARDWARE_BUFFER_VIEW.retiresWhenReplaced(sdkInt, true, 100, 50));
+ }
+ }
+
+ @Test
+ public void onlyAShownLaidOutTextureViewGetsItsSurfaceBeforeItsFirstDraw() {
+ assertEquals(true, BackingViewKind.TEXTURE_VIEW.receivesSurfaceBeforeFirstDraw(true, 100, 50));
+ assertEquals(false, BackingViewKind.TEXTURE_VIEW.receivesSurfaceBeforeFirstDraw(false, 100, 50));
+ assertEquals(false, BackingViewKind.TEXTURE_VIEW.receivesSurfaceBeforeFirstDraw(true, 0, 50));
+ assertEquals(false, BackingViewKind.TEXTURE_VIEW.receivesSurfaceBeforeFirstDraw(true, 100, 0));
+ assertEquals(false, BackingViewKind.SURFACE_VIEW.receivesSurfaceBeforeFirstDraw(true, 100, 50));
+ assertEquals(false, BackingViewKind.HARDWARE_BUFFER_VIEW.receivesSurfaceBeforeFirstDraw(true, 100, 50));
+ }
+
+ @Test
+ public void onlyASurfaceViewReplacingAShownTextureViewKeepsItOnScreen() {
+ for (BackingViewKind incoming : BackingViewKind.values()) {
+ for (BackingViewKind outgoing : BackingViewKind.values()) {
+ boolean keeps = incoming == BackingViewKind.SURFACE_VIEW && outgoing == BackingViewKind.TEXTURE_VIEW;
+ assertEquals(incoming + " over " + outgoing, keeps, incoming.keepsOutgoingOnScreen(outgoing, true, 100, 50));
+ assertEquals(false, incoming.keepsOutgoingOnScreen(outgoing, false, 100, 50));
+ assertEquals(false, incoming.keepsOutgoingOnScreen(outgoing, true, 0, 0));
+ }
+ }
+ }
+
+ @Test
+ public void translucentCanvasIsATextureView() {
+ for (int sdkInt : BEFORE_ANDROID_12L) {
+ assertEquals(BackingViewKind.TEXTURE_VIEW, BackingViewKind.forAutoSurfaceType(false, false, sdkInt));
+ }
+ for (int sdkInt : FROM_ANDROID_12L) {
+ assertEquals(BackingViewKind.TEXTURE_VIEW, BackingViewKind.forAutoSurfaceType(false, true, sdkInt));
+ }
+ }
+
+ @Test
+ public void opaqueCanvasIsATextureViewBeforeAndroid12L() {
+ for (int sdkInt : BEFORE_ANDROID_12L) {
+ assertEquals(BackingViewKind.TEXTURE_VIEW, BackingViewKind.forAutoSurfaceType(true, false, sdkInt));
+ }
+ }
+
+ @Test
+ public void opaqueCanvasIsASurfaceViewFromAndroid12L() {
+ for (int sdkInt : FROM_ANDROID_12L) {
+ assertEquals(BackingViewKind.SURFACE_VIEW, BackingViewKind.forAutoSurfaceType(true, false, sdkInt));
+ }
+ }
+
+ @Test
+ public void opaqueCanvasAskingForZOrderOnTopIsASurfaceViewBeforeAndroid12L() {
+ for (int sdkInt : BEFORE_ANDROID_12L) {
+ assertEquals(BackingViewKind.SURFACE_VIEW, BackingViewKind.forAutoSurfaceType(true, true, sdkInt));
+ }
+ }
+
+ @Test
+ public void namedSurfaceTypeIsHonouredOnEveryRelease() {
+ for (int[] releases : new int[][] {BEFORE_ANDROID_12L, FROM_ANDROID_12L}) {
+ for (int sdkInt : releases) {
+ assertEquals(BackingViewKind.SURFACE_VIEW, BackingViewKind.fromSurfaceType("SurfaceView", sdkInt));
+ assertEquals(BackingViewKind.TEXTURE_VIEW, BackingViewKind.fromSurfaceType("TextureView", sdkInt));
+ }
+ }
+ }
+
+ @Test
+ public void hardwareBufferViewNeedsAndroid10() {
+ for (int sdkInt : BEFORE_ANDROID_10) {
+ assertEquals(BackingViewKind.TEXTURE_VIEW, BackingViewKind.fromSurfaceType("HardwareBufferView", sdkInt));
+ }
+ for (int sdkInt : new int[] {Build.VERSION_CODES.Q, Build.VERSION_CODES.S_V2, Build.VERSION_CODES.UPSIDE_DOWN_CAKE}) {
+ assertEquals(BackingViewKind.HARDWARE_BUFFER_VIEW, BackingViewKind.fromSurfaceType("HardwareBufferView", sdkInt));
+ }
+ }
+
+ @Test
+ public void autoLeavesTheChoiceToTheCanvas() {
+ assertNull(BackingViewKind.fromSurfaceType("auto", Build.VERSION_CODES.Q));
+ assertNull(BackingViewKind.fromSurfaceType(null, Build.VERSION_CODES.S_V2));
+ }
+
+ @Test
+ public void onlyATextureViewPresentsBehindThePendingDraw() {
+ for (BackingViewKind kind : BackingViewKind.values()) {
+ assertEquals(kind == BackingViewKind.TEXTURE_VIEW, kind.presentsBehindPendingDraw());
+ }
+ }
+
+ @Test
+ public void onlyATextureViewOnAndroid11AcquiresAfterTheWindowDraws() {
+ for (int[] releases : new int[][] {BEFORE_ANDROID_12L, FROM_ANDROID_12L}) {
+ for (int sdkInt : releases) {
+ for (BackingViewKind kind : BackingViewKind.values()) {
+ boolean acquiresAfterDraw = kind == BackingViewKind.TEXTURE_VIEW && sdkInt == Build.VERSION_CODES.R;
+ assertEquals(kind + " on API " + sdkInt, acquiresAfterDraw, kind.acquiresAfterDraw(sdkInt));
+ }
+ }
+ }
+ }
+}
diff --git a/packages/webgpu/android/src/test/java/com/webgpu/FirstFrameDrawGateTest.java b/packages/webgpu/android/src/test/java/com/webgpu/FirstFrameDrawGateTest.java
new file mode 100644
index 000000000..332613315
--- /dev/null
+++ b/packages/webgpu/android/src/test/java/com/webgpu/FirstFrameDrawGateTest.java
@@ -0,0 +1,256 @@
+package com.webgpu;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import android.os.Build;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.Test;
+
+public class FirstFrameDrawGateTest {
+ private static final long FIRST_SURFACE = 1;
+ private static final long SECOND_SURFACE = 2;
+
+ private static final class RecordingHost implements FirstFrameDrawGate.Host {
+ final List posted = new ArrayList<>();
+ final List delays = new ArrayList<>();
+ int invalidations = 0;
+
+ @Override
+ public void postDelayed(Runnable action, long delayMillis) {
+ posted.add(action);
+ delays.add(delayMillis);
+ }
+
+ @Override
+ public void removeCallbacks(Runnable action) {
+ posted.remove(action);
+ }
+
+ @Override
+ public void invalidate() {
+ invalidations++;
+ }
+
+ void elapse() {
+ List due = new ArrayList<>(posted);
+ posted.clear();
+ for (Runnable action : due) {
+ action.run();
+ }
+ }
+ }
+
+ private final List mReported = new ArrayList<>();
+ private final RecordingHost mHost = new RecordingHost();
+ private final FirstFrameDrawGate mGate = new FirstFrameDrawGate(mHost, Build.VERSION_CODES.TIRAMISU);
+
+ private Runnable draw(String name) {
+ return () -> mReported.add(name);
+ }
+
+ @Test
+ public void hasSurfaceNamesOnlyTheSurfaceTheViewHasNow() {
+ assertEquals(false, mGate.hasSurface(FIRST_SURFACE));
+ mGate.onSurfaceCreated(FIRST_SURFACE);
+ assertEquals(true, mGate.hasSurface(FIRST_SURFACE));
+ mGate.onSurfaceReleased();
+ mGate.onSurfaceCreated(SECOND_SURFACE);
+ assertEquals(false, mGate.hasSurface(FIRST_SURFACE));
+ assertEquals(true, mGate.hasSurface(SECOND_SURFACE));
+ mGate.onSurfaceReleased();
+ assertEquals(false, mGate.hasSurface(SECOND_SURFACE));
+ assertEquals(false, mGate.hasSurface(0));
+ }
+
+ @Test
+ public void drawRequestedBeforeTheFirstFrameIsReportedWithIt() {
+ mGate.onSurfaceCreated(FIRST_SURFACE);
+ mGate.onRedrawNeeded(draw("first"));
+ assertEquals(Arrays.asList(), mReported);
+
+ mGate.onFirstFramePresented(FIRST_SURFACE);
+ assertEquals(Arrays.asList("first"), mReported);
+ }
+
+ @Test
+ public void drawRequestedAfterTheFirstFrameIsReportedAtOnce() {
+ mGate.onSurfaceCreated(FIRST_SURFACE);
+ mGate.onFirstFramePresented(FIRST_SURFACE);
+ mGate.onRedrawNeeded(draw("resize"));
+ assertEquals(Arrays.asList("resize"), mReported);
+ assertEquals(0, mHost.posted.size());
+ }
+
+ @Test
+ public void newSurfaceWaitsForItsOwnFirstFrame() {
+ mGate.onSurfaceCreated(FIRST_SURFACE);
+ mGate.onFirstFramePresented(FIRST_SURFACE);
+ mGate.onSurfaceReleased();
+ mGate.onSurfaceCreated(SECOND_SURFACE);
+ mGate.onRedrawNeeded(draw("second surface"));
+ assertEquals(Arrays.asList(), mReported);
+
+ mGate.onFirstFramePresented(SECOND_SURFACE);
+ assertEquals(Arrays.asList("second surface"), mReported);
+ }
+
+ @Test
+ public void aFrameReportedForAnEarlierSurfaceDoesNotOpenTheNewOne() {
+ mGate.onSurfaceCreated(FIRST_SURFACE);
+ mGate.onSurfaceReleased();
+ mGate.onSurfaceCreated(SECOND_SURFACE);
+ mGate.onRedrawNeeded(draw("second surface"));
+
+ mGate.onFirstFramePresented(FIRST_SURFACE);
+ assertEquals(Arrays.asList(), mReported);
+
+ mGate.onFirstFramePresented(SECOND_SURFACE);
+ assertEquals(Arrays.asList("second surface"), mReported);
+ }
+
+ @Test
+ public void surfaceReleasedBeforeItsFirstFrameReportsItsPendingDraws() {
+ mGate.onSurfaceCreated(FIRST_SURFACE);
+ mGate.onRedrawNeeded(draw("a"));
+ mGate.onRedrawNeeded(draw("b"));
+ mGate.onSurfaceReleased();
+ assertEquals(Arrays.asList("a", "b"), mReported);
+ assertEquals(0, mHost.posted.size());
+ }
+
+ @Test
+ public void detachingReportsPendingDrawsOnce() {
+ mGate.onSurfaceCreated(FIRST_SURFACE);
+ mGate.onRedrawNeeded(draw("held"));
+ mGate.onDetachedFromWindow();
+ assertEquals(Arrays.asList("held"), mReported);
+
+ mGate.onSurfaceReleased();
+ mGate.onFirstFramePresented(FIRST_SURFACE);
+ mHost.elapse();
+ assertEquals(Arrays.asList("held"), mReported);
+ }
+
+ @Test
+ public void eachDrawIsReportedOnce() {
+ mGate.onSurfaceCreated(FIRST_SURFACE);
+ mGate.onRedrawNeeded(draw("once"));
+ mGate.onFirstFramePresented(FIRST_SURFACE);
+ mGate.onFirstFramePresented(FIRST_SURFACE);
+ mGate.onSurfaceReleased();
+ mHost.elapse();
+ assertEquals(Arrays.asList("once"), mReported);
+ }
+
+ @Test
+ public void aFirstFrameThatNeverComesIsWaitedForOnlyUntilTheDeadline() {
+ mGate.onSurfaceCreated(FIRST_SURFACE);
+ mGate.onRedrawNeeded(draw("held"));
+ assertEquals(Arrays.asList(Long.valueOf(FirstFrameDrawGate.FIRST_FRAME_WAIT_MILLIS)), mHost.delays);
+
+ mHost.elapse();
+ assertEquals(Arrays.asList("held"), mReported);
+ }
+
+ @Test
+ public void oneDeadlineCoversEveryDrawWaitingForTheSameFrame() {
+ mGate.onSurfaceCreated(FIRST_SURFACE);
+ mGate.onRedrawNeeded(draw("a"));
+ mGate.onRedrawNeeded(draw("b"));
+ assertEquals(1, mHost.posted.size());
+
+ mHost.elapse();
+ assertEquals(Arrays.asList("a", "b"), mReported);
+ }
+
+ @Test
+ public void theFirstFrameCancelsTheDeadline() {
+ mGate.onSurfaceCreated(FIRST_SURFACE);
+ mGate.onRedrawNeeded(draw("framed"));
+ mGate.onFirstFramePresented(FIRST_SURFACE);
+ assertEquals(0, mHost.posted.size());
+ assertEquals(Arrays.asList("framed"), mReported);
+ }
+
+ @Test
+ public void detachingCancelsTheDeadline() {
+ mGate.onSurfaceCreated(FIRST_SURFACE);
+ mGate.onRedrawNeeded(draw("held"));
+ mGate.onDetachedFromWindow();
+ assertEquals(0, mHost.posted.size());
+ }
+
+ @Test
+ public void fromAndroid14ADrawWaitsForTheFirstFrameWithNoDeadline() {
+ FirstFrameDrawGate gate = new FirstFrameDrawGate(mHost, Build.VERSION_CODES.UPSIDE_DOWN_CAKE);
+ gate.onSurfaceCreated(FIRST_SURFACE);
+ gate.onRedrawNeeded(draw("held"));
+ assertEquals(0, mHost.posted.size());
+
+ mHost.elapse();
+ assertEquals(Arrays.asList(), mReported);
+
+ gate.onFirstFramePresented(FIRST_SURFACE);
+ assertEquals(Arrays.asList("held"), mReported);
+ }
+
+ @Test
+ public void aDrawAfterTheDeadlineWaitsForTheFirstFrameAgain() {
+ mGate.onSurfaceCreated(FIRST_SURFACE);
+ mGate.onRedrawNeeded(draw("first"));
+ mHost.elapse();
+ mGate.onRedrawNeeded(draw("resize"));
+ assertEquals(Arrays.asList("first"), mReported);
+ assertEquals(1, mHost.posted.size());
+
+ mGate.onFirstFramePresented(FIRST_SURFACE);
+ assertEquals(Arrays.asList("first", "resize"), mReported);
+ }
+
+ @Test
+ public void theHoleOpensWithTheFirstFrame() {
+ mGate.onSurfaceCreated(FIRST_SURFACE);
+ assertFalse(mGate.hasFrame());
+
+ mGate.onFirstFramePresented(FIRST_SURFACE);
+ mGate.onFirstFramePresented(FIRST_SURFACE);
+ assertTrue(mGate.hasFrame());
+ assertEquals(1, mHost.invalidations);
+ }
+
+ @Test
+ public void aFirstFrameLaterThanTheDeadlineKeepsTheHoleClosedUntilItLands() {
+ mGate.onSurfaceCreated(FIRST_SURFACE);
+ mGate.onRedrawNeeded(draw("held"));
+ mHost.elapse();
+ assertEquals(Arrays.asList("held"), mReported);
+ assertFalse(mGate.hasFrame());
+ assertEquals(0, mHost.invalidations);
+
+ mGate.onFirstFramePresented(FIRST_SURFACE);
+ assertTrue(mGate.hasFrame());
+ assertEquals(1, mHost.invalidations);
+ }
+
+ @Test
+ public void aNewSurfaceKeepsTheHoleClosedUntilItsOwnFirstFrame() {
+ mGate.onSurfaceCreated(FIRST_SURFACE);
+ mGate.onFirstFramePresented(FIRST_SURFACE);
+ mGate.onSurfaceReleased();
+ assertFalse(mGate.hasFrame());
+
+ mGate.onSurfaceCreated(SECOND_SURFACE);
+ mGate.onFirstFramePresented(FIRST_SURFACE);
+ assertFalse(mGate.hasFrame());
+
+ mGate.onFirstFramePresented(SECOND_SURFACE);
+ assertTrue(mGate.hasFrame());
+ assertEquals(3, mHost.invalidations);
+ }
+}
diff --git a/packages/webgpu/android/src/test/java/com/webgpu/OutgoingViewHoldTest.java b/packages/webgpu/android/src/test/java/com/webgpu/OutgoingViewHoldTest.java
new file mode 100644
index 000000000..1e14dd7b3
--- /dev/null
+++ b/packages/webgpu/android/src/test/java/com/webgpu/OutgoingViewHoldTest.java
@@ -0,0 +1,243 @@
+package com.webgpu;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.Test;
+
+public class OutgoingViewHoldTest {
+ private static final class RecordingHost implements OutgoingViewHold.Host {
+ final List removed = new ArrayList<>();
+ final List afterFrameCommit = new ArrayList<>();
+ final List delayed = new ArrayList<>();
+ final List delays = new ArrayList<>();
+
+ @Override
+ public void removeView(String view) {
+ removed.add(view);
+ }
+
+ @Override
+ public void runAfterWindowFrameCommits(Runnable action) {
+ afterFrameCommit.add(action);
+ }
+
+ @Override
+ public void postDelayed(Runnable action, long delayMillis) {
+ delayed.add(action);
+ delays.add(delayMillis);
+ }
+
+ @Override
+ public void removeCallbacks(Runnable action) {
+ delayed.remove(action);
+ }
+
+ void commitWindowFrame() {
+ List pending = new ArrayList<>(afterFrameCommit);
+ afterFrameCommit.clear();
+ for (Runnable action : pending) {
+ action.run();
+ }
+ }
+
+ void elapse() {
+ List due = new ArrayList<>(delayed);
+ delayed.clear();
+ for (Runnable action : due) {
+ action.run();
+ }
+ }
+ }
+
+ private static final OutgoingViewHold.Arrival FIRST_FRAME = OutgoingViewHold.Arrival.FIRST_FRAME;
+ private static final OutgoingViewHold.Arrival HOLE_AFTER_FIRST_FRAME = OutgoingViewHold.Arrival.HOLE_AFTER_FIRST_FRAME;
+
+ private final RecordingHost mHost = new RecordingHost();
+ private final OutgoingViewHold mHold = new OutgoingViewHold<>(mHost);
+
+ @Test
+ public void heldViewLeavesOnceAWindowFrameAfterTheFirstFrameHasCommitted() {
+ mHold.hold("texture", FIRST_FRAME);
+ mHold.onFirstFramePresented();
+ assertEquals(Arrays.asList(), mHost.removed);
+ assertSame("texture", mHold.held());
+
+ mHost.commitWindowFrame();
+ assertEquals(Arrays.asList("texture"), mHost.removed);
+ assertNull(mHold.held());
+ }
+
+ @Test
+ public void heldViewStaysWhileNoFirstFrameHasBeenPresented() {
+ mHold.hold("texture", FIRST_FRAME);
+ mHost.commitWindowFrame();
+ assertEquals(Arrays.asList(), mHost.removed);
+ assertSame("texture", mHold.held());
+ }
+
+ @Test
+ public void releaseRemovesTheHeldViewAtOnce() {
+ mHold.hold("texture", FIRST_FRAME);
+ mHold.release();
+ assertEquals(Arrays.asList("texture"), mHost.removed);
+ assertNull(mHold.held());
+ }
+
+ @Test
+ public void holdingAnotherViewRemovesTheFirstAtOnce() {
+ mHold.hold("first", FIRST_FRAME);
+ mHold.hold("second", FIRST_FRAME);
+ assertEquals(Arrays.asList("first"), mHost.removed);
+ assertSame("second", mHold.held());
+ }
+
+ @Test
+ public void aCommitScheduledForAReplacedViewLeavesTheNewOneHeld() {
+ mHold.hold("first", FIRST_FRAME);
+ mHold.onFirstFramePresented();
+ mHold.hold("second", FIRST_FRAME);
+ mHost.commitWindowFrame();
+ assertEquals(Arrays.asList("first"), mHost.removed);
+ assertSame("second", mHold.held());
+ }
+
+ @Test
+ public void eachViewIsRemovedOnce() {
+ mHold.hold("texture", FIRST_FRAME);
+ mHold.onFirstFramePresented();
+ mHold.onFirstFramePresented();
+ mHost.commitWindowFrame();
+ mHold.release();
+ mHost.elapse();
+ assertEquals(Arrays.asList("texture"), mHost.removed);
+ }
+
+ @Test
+ public void nothingHeldMeansNothingScheduledOrRemoved() {
+ mHold.onFirstFramePresented();
+ mHold.onTransparentRegionRequested();
+ mHold.release();
+ assertEquals(0, mHost.afterFrameCommit.size());
+ assertEquals(0, mHost.delayed.size());
+ assertEquals(Arrays.asList(), mHost.removed);
+ assertNull(mHold.held());
+ }
+
+ @Test
+ public void aFirstFrameThatNeverComesIsWaitedForOnlyUntilTheDeadline() {
+ mHold.hold("texture", FIRST_FRAME);
+ assertEquals(Arrays.asList(Long.valueOf(FirstFrameDrawGate.FIRST_FRAME_WAIT_MILLIS)), mHost.delays);
+
+ mHost.elapse();
+ assertEquals(Arrays.asList("texture"), mHost.removed);
+ assertNull(mHold.held());
+ }
+
+ @Test
+ public void theFirstFrameCancelsTheDeadline() {
+ mHold.hold("texture", FIRST_FRAME);
+ mHold.onFirstFramePresented();
+ assertEquals(0, mHost.delayed.size());
+ }
+
+ @Test
+ public void releasingCancelsTheDeadline() {
+ mHold.hold("texture", FIRST_FRAME);
+ mHold.release();
+ assertEquals(0, mHost.delayed.size());
+ }
+
+ @Test
+ public void holdingAnotherViewRestartsTheDeadline() {
+ mHold.hold("first", FIRST_FRAME);
+ mHold.hold("second", FIRST_FRAME);
+ assertEquals(1, mHost.delayed.size());
+ assertEquals(Arrays.asList("first"), mHost.removed);
+
+ mHost.elapse();
+ assertEquals(Arrays.asList("first", "second"), mHost.removed);
+ }
+
+ @Test
+ public void aTransparentRegionRequestDoesNotMoveAViewArrivingWithItsFirstFrame() {
+ mHold.hold("texture", FIRST_FRAME);
+ mHold.onTransparentRegionRequested();
+ assertEquals(0, mHost.afterFrameCommit.size());
+
+ mHold.onFirstFramePresented();
+ mHold.onTransparentRegionRequested();
+ assertEquals(1, mHost.afterFrameCommit.size());
+ }
+
+ @Test
+ public void heldViewWaitingForTheHoleStaysThroughWindowFramesAfterTheFirstFrame() {
+ mHold.hold("texture", HOLE_AFTER_FIRST_FRAME);
+ mHold.onFirstFramePresented();
+ assertEquals(0, mHost.afterFrameCommit.size());
+
+ mHost.commitWindowFrame();
+ assertEquals(Arrays.asList(), mHost.removed);
+ assertSame("texture", mHold.held());
+ }
+
+ @Test
+ public void heldViewLeavesOnceAWindowFrameAfterTheHoleHasCommitted() {
+ mHold.hold("texture", HOLE_AFTER_FIRST_FRAME);
+ mHold.onFirstFramePresented();
+ mHold.onTransparentRegionRequested();
+ assertEquals(Arrays.asList(), mHost.removed);
+ assertEquals(0, mHost.delayed.size());
+
+ mHost.commitWindowFrame();
+ assertEquals(Arrays.asList("texture"), mHost.removed);
+ assertNull(mHold.held());
+ }
+
+ @Test
+ public void theRequestAViewMakesAsItAttachesIsNotItsHole() {
+ mHold.hold("texture", HOLE_AFTER_FIRST_FRAME);
+ mHold.onTransparentRegionRequested();
+ mHold.onFirstFramePresented();
+ mHost.commitWindowFrame();
+ assertEquals(Arrays.asList(), mHost.removed);
+
+ mHold.onTransparentRegionRequested();
+ mHost.commitWindowFrame();
+ assertEquals(Arrays.asList("texture"), mHost.removed);
+ }
+
+ @Test
+ public void aHoleThatNeverOpensIsWaitedForOnlyUntilTheDeadline() {
+ mHold.hold("texture", HOLE_AFTER_FIRST_FRAME);
+ mHold.onFirstFramePresented();
+ assertEquals(1, mHost.delayed.size());
+
+ mHost.elapse();
+ assertEquals(Arrays.asList("texture"), mHost.removed);
+ assertNull(mHold.held());
+ }
+
+ @Test
+ public void eachHoleIsWaitedOnOnce() {
+ mHold.hold("texture", HOLE_AFTER_FIRST_FRAME);
+ mHold.onFirstFramePresented();
+ mHold.onTransparentRegionRequested();
+ mHold.onTransparentRegionRequested();
+ assertEquals(1, mHost.afterFrameCommit.size());
+ }
+
+ @Test
+ public void onlyASurfaceViewBehindTheWindowOnAndroid13WaitsForItsHole() {
+ for (int sdkInt = 26; sdkInt <= 37; sdkInt++) {
+ OutgoingViewHold.Arrival behindTheWindow = sdkInt == 33 ? HOLE_AFTER_FIRST_FRAME : FIRST_FRAME;
+ assertEquals("API " + sdkInt, behindTheWindow, OutgoingViewHold.Arrival.ofSurfaceView(sdkInt, false));
+ assertEquals("API " + sdkInt, FIRST_FRAME, OutgoingViewHold.Arrival.ofSurfaceView(sdkInt, true));
+ }
+ }
+}
diff --git a/packages/webgpu/apple/MetalView.mm b/packages/webgpu/apple/MetalView.mm
index a231b1538..630100a9b 100644
--- a/packages/webgpu/apple/MetalView.mm
+++ b/packages/webgpu/apple/MetalView.mm
@@ -68,7 +68,7 @@ - (void)dealloc {
// surface; see RNWebGPU::destroyContext for the ownership split.
auto ®istry = rnwgpu::SurfaceRegistry::getInstance();
if (auto info = registry.getSurfaceInfo([_contextId intValue])) {
- info->detachSurface();
+ info->detach();
}
registry.removeSurfaceInfo([_contextId intValue]);
}
diff --git a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h
index e734e65f6..6038d2028 100644
--- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h
+++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h
@@ -6,6 +6,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -58,6 +59,19 @@ struct Size {
// CAMetalLayer on Apple platforms). May run on any thread.
using NativeSurfaceReleaser = std::function;
+// Invoked once, after the first present onto the native surface it was
+// attached with (a frame, or the blit of the latest offscreen frame), on the
+// thread that presented and outside SurfaceInfo's lock (Android's SurfaceView
+// first-frame report). SurfaceInfo drops it together with that surface.
+using NativeSurfaceFirstPresent = std::function;
+
+// Installed with a surface whose window shows a new frame only when the
+// window draws (Android's TextureView): has the main looper call
+// SurfaceInfo::presentPostedFrame() behind the draw the window has pending.
+// Returns false when it could not post. Called on the main thread, outside
+// SurfaceInfo's lock.
+using NativeSurfacePresentPoster = std::function;
+
#if defined(__ANDROID__)
// --- AHB-pool presentation mode (WebGPUNativeBufferView)
// -----------------------------
@@ -137,14 +151,21 @@ struct AHBPool {
// middle of a frame. For contexts that are not actively rendering,
// RNWebGPUManager::flushPendingSurfaceTransition applies the attach from the
// JS thread instead.
-// - Detaching (switchToOffscreen) is IMMEDIATE, because the platform destroys
-// the surface as soon as its callback returns. A configured context falls
-// back to rendering into an offscreen texture, so a running render loop
-// keeps working; the in-flight frame, if any, is dropped at present(). When
-// a new surface attaches, the latest offscreen frame is blitted onto it so
-// content appears without waiting for the next render — the same mechanism
-// that gives a fast time-to-first-frame when rendering starts before the
-// native surface exists.
+// - Detaching (detach) is IMMEDIATE, because the platform destroys the
+// surface as soon as its callback returns. A configured context renders the
+// frames that follow into an offscreen texture, created with the first of
+// them, so a running render loop keeps working; the in-flight frame, if
+// any, is dropped at present(). When a new surface attaches, the latest
+// offscreen frame is blitted onto it so content appears without waiting for
+// the next render — the same mechanism that gives a fast time-to-first-frame
+// when rendering starts before the native surface exists. A surface that
+// attaches before any frame was rendered offscreen is presented nothing.
+// - A surface whose buffer queue makes an acquire wait until its window has
+// drawn the frame presented last (an Android 11 TextureView, see
+// BackingViewKind.acquiresAfterDraw) defers a frame that comes sooner: it
+// renders offscreen and is copied onto the surface once the window has
+// drawn, by the frame boundary or the posted surfaceDrawn that sees it
+// first.
class SurfaceInfo {
public:
SurfaceInfo(wgpu::Instance gpu, int width, int height)
@@ -184,35 +205,109 @@ class SurfaceInfo {
// RNWebGPUManager::flushPendingSurfaceTransition so contexts that are not
// currently rendering also pick it up.
void attachSurface(void *nativeSurface, wgpu::Surface surface,
- NativeSurfaceReleaser releaser) {
+ NativeSurfaceReleaser releaser,
+ NativeSurfaceFirstPresent firstPresent = nullptr,
+ NativeSurfacePresentPoster presentPoster = nullptr,
+ bool acquiresAfterDraw = false) {
void *replacedSurface = nullptr;
NativeSurfaceReleaser replacedReleaser;
+ NativeSurfaceFirstPresent replacedFirstPresent;
+ NativeSurfacePresentPoster replacedPresentPoster;
{
std::unique_lock lock(_mutex);
if (_hasPendingAttach) {
// Replaced before it was ever adopted.
replacedSurface = _pendingNativeSurface;
replacedReleaser = std::move(_pendingReleaser);
+ replacedFirstPresent = std::move(_pendingFirstPresent);
+ replacedPresentPoster = std::move(_pendingPresentPoster);
}
_hasPendingAttach = true;
_pendingNativeSurface = nativeSurface;
_pendingSurface = std::move(surface);
_pendingReleaser = std::move(releaser);
+ _pendingFirstPresent = std::move(firstPresent);
+ _pendingPresentPoster = std::move(presentPoster);
+ _pendingAcquiresAfterDraw = acquiresAfterDraw;
}
if (replacedReleaser && replacedSurface) {
replacedReleaser(replacedSurface);
}
}
- // The platform surface is being destroyed: detach immediately. If the
- // context is configured, rendering continues into an offscreen texture whose
- // content is blitted to the next attached surface; present() no-ops until
- // then. Safe to call when already offscreen.
- void switchToOffscreen() { detach(/* createFallbackTexture = */ true); }
+ // The frames presented so far, read while the window draws: that draw
+ // syncs with every one of them queued.
+ uint64_t presentCount() {
+ std::shared_lock lock(_mutex);
+ return _presentCount;
+ }
- // Detach without creating the offscreen fallback: used when the context is
- // being destroyed and nothing will consume further frames.
- void detachSurface() { detach(/* createFallbackTexture = */ false); }
+ // The window's draw that read presentCount() has synced, taking the
+ // frames presented by then: a deferred frame waiting for it is copied
+ // onto the surface.
+ void surfaceDrawn(uint64_t presentCount) {
+ std::unique_lock lock(_mutex);
+ _drawnPresentCount = std::max(_drawnPresentCount, presentCount);
+ deliverDeferredFrameLocked();
+ }
+
+ // The size keepFrame takes a frame at: the drawing buffer's, while a surface
+ // the context is configured for is attached, its format is one keepFrame
+ // writes, and no deferred frame waits in the offscreen texture; {0, 0}
+ // otherwise.
+ Size keepableFrameSize() {
+ std::shared_lock lock(_mutex);
+ if (!_surface || _config.device == nullptr ||
+ !keepsFramesOfFormat(_config.format) ||
+ _deferredFrame == DeferredFrame::Waiting) {
+ return {0, 0};
+ }
+ return {static_cast(_config.width), static_cast(_config.height)};
+ }
+
+ // Keep a frame read back from the attached surface as it is destroyed (on
+ // Android a SurfaceView's surface goes with its buffers when the app goes to
+ // the background or the view leaves its window): it becomes the offscreen
+ // frame, which the next attached surface shows (applyPendingAttach) unless a
+ // newer frame is rendered first. The pixels are premultiplied RGBA8, as an
+ // Android ARGB_8888 bitmap stores them.
+ bool keepFrame(const uint8_t *rgbaPixels, uint32_t width, uint32_t height,
+ size_t bytesPerRow) {
+ std::unique_lock lock(_mutex);
+ if (_config.device == nullptr || !keepsFramesOfFormat(_config.format)) {
+ return false;
+ }
+ // A deferred frame waiting there is newer than the surface's.
+ if (_deferredFrame == DeferredFrame::Waiting) {
+ return true;
+ }
+ if (!_texture) {
+ _texture = createOffscreenTextureLocked();
+ }
+ const uint32_t copyWidth = std::min(width, _texture.GetWidth());
+ const uint32_t copyHeight = std::min(height, _texture.GetHeight());
+ const size_t rowSize = static_cast(copyWidth) * 4;
+ std::vector staged(rowSize * copyHeight);
+ for (uint32_t row = 0; row < copyHeight; ++row) {
+ std::memcpy(staged.data() + row * rowSize,
+ rgbaPixels + static_cast(row) * bytesPerRow, rowSize);
+ }
+ if (_config.format == wgpu::TextureFormat::BGRA8Unorm ||
+ _config.format == wgpu::TextureFormat::BGRA8UnormSrgb) {
+ for (size_t i = 0; i + 3 < staged.size(); i += 4) {
+ std::swap(staged[i], staged[i + 2]);
+ }
+ }
+ wgpu::TexelCopyTextureInfo destination{};
+ destination.texture = _texture;
+ wgpu::TexelCopyBufferLayout layout{};
+ layout.bytesPerRow = static_cast(rowSize);
+ layout.rowsPerImage = copyHeight;
+ wgpu::Extent3D extent{copyWidth, copyHeight, 1};
+ _config.device.GetQueue().WriteTexture(&destination, staged.data(),
+ staged.size(), &layout, &extent);
+ return true;
+ }
// Reflects native view layout changes. Does not resize the drawing buffer:
// that tracks canvas.width/height (like on the web), see
@@ -241,6 +336,9 @@ class SurfaceInfo {
wgpu::Device device = nullptr;
void *replacedSurface = nullptr;
NativeSurfaceReleaser replacedReleaser;
+ NativeSurfaceFirstPresent replacedFirstPresent;
+ NativeSurfaceFirstPresent firstPresent;
+ NativeSurfacePresentPoster replacedPresentPoster;
{
std::unique_lock lock(_mutex);
if (supersedeInFlightFrame) {
@@ -255,6 +353,12 @@ class SurfaceInfo {
// can be null if surface creation failed).
replacedSurface = _nativeSurface;
replacedReleaser = std::move(_releaser);
+ replacedFirstPresent = std::move(_firstPresent);
+ replacedPresentPoster = std::move(_presentPoster);
+ // A frame waiting for its posted present, or for the window's draw,
+ // belongs to the replaced surface; the blit below shows the latter.
+ _presentPending = false;
+ _deferredFrame = DeferredFrame::None;
_surface = std::move(_pendingSurface);
#if defined(__ANDROID__)
// DAWN_WORKAROUND_DEVICE_DESTROY_BEFORE_SURFACE_RELEASE
@@ -262,6 +366,9 @@ class SurfaceInfo {
#endif
_nativeSurface = _pendingNativeSurface;
_releaser = std::move(_pendingReleaser);
+ _firstPresent = std::move(_pendingFirstPresent);
+ _presentPoster = std::move(_pendingPresentPoster);
+ _acquiresAfterDraw = _pendingAcquiresAfterDraw;
_hasPendingAttach = false;
_pendingNativeSurface = nullptr;
_frameEpoch++;
@@ -307,9 +414,13 @@ class SurfaceInfo {
// (their newer content stands; presenting here would be a Dawn
// present-without-acquire error).
if (_surface && !_frameInFlight && _frameEpoch == blitEpoch) {
- _surface.Present();
+ presentSurfaceLocked();
+ firstPresent = std::move(_firstPresent);
}
}
+ if (firstPresent) {
+ firstPresent();
+ }
}
// --- Rendering thread
@@ -319,6 +430,7 @@ class SurfaceInfo {
std::vector viewFormats) {
applyPendingAttach(/* supersedeInFlightFrame = */ true);
std::unique_lock lock(_mutex);
+ presentPendingFrameLocked();
_viewFormats = std::move(viewFormats);
_config = newConfig;
// The caller's viewFormats storage dies with the call; point the stored
@@ -330,7 +442,7 @@ class SurfaceInfo {
_config.width = std::max(1, _width);
_config.height = std::max(1, _height);
_config.presentMode = wgpu::PresentMode::Fifo;
- _texture = nullptr;
+ dropOffscreenTextureLocked();
_frameEpoch++;
#if defined(__ANDROID__)
{
@@ -355,19 +467,21 @@ class SurfaceInfo {
if (_config.device == nullptr) {
return;
}
+ presentPendingFrameLocked();
_config.width = std::max(1, newWidth);
_config.height = std::max(1, newHeight);
- _texture = nullptr;
+ dropOffscreenTextureLocked();
_frameEpoch++;
_configureLocked();
}
void unconfigure() {
std::unique_lock lock(_mutex);
+ presentPendingFrameLocked();
if (_surface) {
_surface.Unconfigure();
}
- _texture = nullptr;
+ dropOffscreenTextureLocked();
_config = {};
_viewFormats.clear();
_acquiredFromSurface = false;
@@ -490,13 +604,20 @@ class SurfaceInfo {
"[WebGPU] getCurrentTexture() called on a canvas context that is "
"not configured; call context.configure() first");
}
+ presentPendingFrameLocked();
_frameInFlight = true;
_acquiredFromSurface = false;
+ // This frame supersedes one waiting for the window's draw.
+ _deferredFrame = DeferredFrame::None;
_frameEpoch++;
if (frameEpoch) {
*frameEpoch = _frameEpoch;
}
- if (_surface) {
+ if (_surface && awaitsWindowDrawLocked()) {
+ // The acquire would wait for the window's draw, which runs on the main
+ // thread, maybe this one.
+ _deferredFrame = DeferredFrame::Rendering;
+ } else if (_surface) {
auto texture = acquireSurfaceTextureLocked();
if (texture) {
_acquiredFromSurface = true;
@@ -525,10 +646,13 @@ class SurfaceInfo {
// Present the current frame. Runs synchronously on the thread that did
// getCurrentTexture/submit (main JS, Reanimated UI, or a worklet runtime),
- // preserving Dawn surface thread-affinity. Frames whose texture was not
- // acquired from the attached surface (offscreen, detached mid-frame, or
- // acquire failure) are dropped. This is also the end-of-frame boundary: it
- // adopts a surface that attached while the frame was in flight.
+ // preserving Dawn surface thread-affinity, except on a surface with a
+ // present poster (see presentsBehindPendingDrawLocked). Frames whose
+ // texture was not acquired from the attached surface (offscreen, detached
+ // mid-frame, or acquire failure) are dropped, except a deferred frame,
+ // which reaches the surface once the window has drawn. This is also the
+ // end-of-frame boundary: it adopts a surface that attached while the frame
+ // was in flight.
void presentFrame() {
#if defined(__ANDROID__)
if (_poolMode.load()) {
@@ -549,29 +673,58 @@ class SurfaceInfo {
dawn::native::metal::WaitForCommandsToBeScheduled(device.Get());
}
#endif
+ NativeSurfacePresentPoster presentPoster;
#if defined(__ANDROID__)
std::function framePresented;
#endif
+ NativeSurfaceFirstPresent firstPresent;
{
std::unique_lock lock(_mutex);
if (_surface && _acquiredFromSurface) {
- _surface.Present();
+ if (presentsBehindPendingDrawLocked()) {
+ _presentPending = true;
+ if (!_presentPosted) {
+ _presentPosted = true;
+ presentPoster = _presentPoster;
+ }
+ } else {
+ presentSurfaceLocked();
#if defined(__ANDROID__)
- framePresented = _framePresented;
+ framePresented = _framePresented;
#endif
+ firstPresent = std::move(_firstPresent);
+ }
+ } else if (_deferredFrame == DeferredFrame::Rendering) {
+ _deferredFrame = DeferredFrame::Waiting;
+ deliverDeferredFrameLocked();
}
_acquiredFromSurface = false;
_frameInFlight = false;
_frameEpoch++;
}
+ if (presentPoster && !presentPoster()) {
+ presentPostedFrame();
+ }
#if defined(__ANDROID__)
if (framePresented) {
framePresented();
}
#endif
+ if (firstPresent) {
+ firstPresent();
+ }
applyPendingAttach();
}
+ // Main thread, from the call a present poster posted: presents the frame
+ // presentFrame() left waiting, unless a frame boundary since has presented
+ // or dropped it.
+ void presentPostedFrame() {
+ std::unique_lock lock(_mutex);
+ _presentPosted = false;
+ presentPendingFrameLocked();
+ }
+
NativeInfo getNativeInfo() {
std::shared_lock lock(_mutex);
// A surface that is still pending adoption is the one callers should see.
@@ -714,10 +867,17 @@ class SurfaceInfo {
}
#endif
-private:
- void detach(bool createFallbackTexture) {
+ // The platform surface is being destroyed, or the view with it: detach
+ // immediately. A configured context renders on into an offscreen texture,
+ // whose latest frame the next attached surface gets; present() no-ops until
+ // then. Safe to call when already offscreen.
+ void detach() {
void *releasedSurfaces[2] = {nullptr, nullptr};
NativeSurfaceReleaser releasers[2];
+ // Destroyed outside the lock, like the releasers.
+ NativeSurfaceFirstPresent droppedFirstPresents[2];
+ // Destroyed outside the lock: they release JNI references.
+ NativeSurfacePresentPoster releasedPresentPosters[2];
#if defined(__ANDROID__)
// Destroyed outside the lock: it releases a JNI reference.
std::function releasedFrameReady;
@@ -726,8 +886,8 @@ class SurfaceInfo {
std::unique_lock lock(_mutex);
#if defined(__ANDROID__)
// Leaving pool mode (the WebGPUNativeBufferView detached): drop the
- // pool. A configured context keeps rendering into the offscreen
- // fallback, exactly like the surface path.
+ // pool. A configured context keeps rendering offscreen, exactly like the
+ // surface path.
if (_poolMode.load()) {
_poolMode.store(false);
{
@@ -748,9 +908,6 @@ class SurfaceInfo {
releasedFrameReady = std::move(_poolFrameReady);
_poolFrameReady = nullptr;
}
- if (createFallbackTexture && _config.device != nullptr && !_texture) {
- _texture = createOffscreenTextureLocked();
- }
_frameEpoch++;
}
#endif
@@ -761,26 +918,31 @@ class SurfaceInfo {
_pendingSurface = nullptr;
releasedSurfaces[0] = _pendingNativeSurface;
releasers[0] = std::move(_pendingReleaser);
+ droppedFirstPresents[0] = std::move(_pendingFirstPresent);
+ releasedPresentPosters[0] = std::move(_pendingPresentPoster);
_pendingNativeSurface = nullptr;
}
+ droppedFirstPresents[1] = std::move(_firstPresent);
if (_surface) {
- if (createFallbackTexture && _config.device != nullptr) {
- _texture = createOffscreenTextureLocked();
- }
_surface = nullptr;
#if defined(__ANDROID__)
// DAWN_WORKAROUND_DEVICE_DESTROY_BEFORE_SURFACE_RELEASE
_surfaceDevice = nullptr;
#endif
// The in-flight frame (if any) rendered into the destroyed surface;
- // presentFrame() must not present it.
+ // presentFrame() must not present it, nor presentPostedFrame() one
+ // waiting for its posted present.
_acquiredFromSurface = false;
+ _presentPending = false;
+ _acquiresAfterDraw = false;
+ _deferredFrame = DeferredFrame::None;
}
_frameEpoch++;
// Window ownership is independent of the Dawn surface handle (which can
// be null if surface creation failed): always return the window.
releasedSurfaces[1] = _nativeSurface;
releasers[1] = std::move(_releaser);
+ releasedPresentPosters[1] = std::move(_presentPoster);
_nativeSurface = nullptr;
}
// Release outside the lock: the platform may do real work here.
@@ -791,16 +953,79 @@ class SurfaceInfo {
}
}
+private:
// All *Locked helpers below require _mutex to be held exclusively.
+ // A frame presented on the main thread can be inside a Choreographer
+ // frame, ahead of the window's draw, and a TextureView's buffer queue
+ // replaces a frame its window has not drawn yet: every other frame would
+ // never show. Such a frame is presented behind the draw the window has
+ // pending instead. Frames presented on another thread land between the
+ // main thread's frames and are presented straight away.
+ bool presentsBehindPendingDrawLocked() const {
+#if defined(__ANDROID__)
+ return _presentPoster != nullptr && gettid() == getpid();
+#else
+ return false;
+#endif
+ }
+
+ // A frame waiting for its posted present is presented before the next
+ // frame boundary changes the surface, so no frame is acquired over it.
+ void presentPendingFrameLocked() {
+ if (_presentPending) {
+ _presentPending = false;
+ presentSurfaceLocked();
+ }
+ }
+
+ // Every present goes through here, counted for awaitsWindowDrawLocked.
+ void presentSurfaceLocked() {
+ _surface.Present();
+ _presentCount++;
+ }
+
+ // Whether the attached surface makes an acquire wait until its window has
+ // drawn the frame presented last, and the window has yet to draw it.
+ bool awaitsWindowDrawLocked() const {
+ return _acquiresAfterDraw && _drawnPresentCount != _presentCount;
+ }
+
+ // Copies a deferred frame waiting in the offscreen texture onto the surface
+ // once the window has drawn the frame presented last.
+ void deliverDeferredFrameLocked() {
+ if (_deferredFrame != DeferredFrame::Waiting || awaitsWindowDrawLocked()) {
+ return;
+ }
+ _deferredFrame = DeferredFrame::None;
+ if (blitOffscreenToSurfaceLocked()) {
+ presentSurfaceLocked();
+ }
+ }
+
+ // A deferred frame waiting in the offscreen texture goes with it.
+ void dropOffscreenTextureLocked() {
+ _texture = nullptr;
+ _deferredFrame = DeferredFrame::None;
+ }
+
+ // The formats keepFrame converts an RGBA8 frame into.
+ static bool keepsFramesOfFormat(wgpu::TextureFormat format) {
+ return format == wgpu::TextureFormat::RGBA8Unorm ||
+ format == wgpu::TextureFormat::RGBA8UnormSrgb ||
+ format == wgpu::TextureFormat::BGRA8Unorm ||
+ format == wgpu::TextureFormat::BGRA8UnormSrgb;
+ }
+
wgpu::Texture createOffscreenTextureLocked() {
wgpu::TextureDescriptor descriptor;
// Union with the user's usage so offscreen frames stay compatible with
// whatever they configured (e.g. CopySrc readbacks). RenderAttachment |
- // CopySrc | TextureBinding is what the fallback itself needs (rendering,
- // the attach blit, sampling).
+ // CopySrc | CopyDst | TextureBinding is what the fallback itself needs
+ // (rendering, the attach blit, a kept frame, sampling).
descriptor.usage = _config.usage | wgpu::TextureUsage::RenderAttachment |
wgpu::TextureUsage::CopySrc |
+ wgpu::TextureUsage::CopyDst |
wgpu::TextureUsage::TextureBinding;
descriptor.format = _config.format;
descriptor.size.width = std::max(1u, _config.width);
@@ -842,8 +1067,9 @@ class SurfaceInfo {
surfaceTexture.texture != nullptr;
}
- // Copy the last offscreen frame onto the freshly attached surface. Returns
- // true when the copy was submitted and the surface should be presented.
+ // Copy the last offscreen frame onto the surface, freshly attached or
+ // deferred to. Returns true when the copy was submitted and the surface
+ // should be presented.
bool blitOffscreenToSurfaceLocked() {
wgpu::SurfaceTexture surfaceTexture;
_surface.GetCurrentTexture(&surfaceTexture);
@@ -871,20 +1097,20 @@ class SurfaceInfo {
return true;
}
+ // Without a surface nothing is created: the offscreen texture appears with
+ // the first frame rendered into it (getCurrentTexture), so a surface that
+ // attaches before that frame is presented nothing rather than an empty
+ // frame, and a SurfaceView keeps its hole closed until the frame lands.
void _configureLocked() {
if (_surface) {
configureSurfaceLocked(_config);
#ifdef __APPLE__
applyCAMetalLayerColorSpace(_nativeSurface, _config.format);
#endif
- } else {
- _texture = createOffscreenTextureLocked();
}
}
- // Every Surface::Configure goes through here. Once the
- // DAWN_WORKAROUND_DEVICE_DESTROY_BEFORE_SURFACE_RELEASE lines are gone, this
- // is a plain wrapper around _surface.Configure.
+ // Every Surface::Configure goes through here.
void configureSurfaceLocked(const wgpu::SurfaceConfiguration &config) {
#if defined(__ANDROID__)
// DAWN_WORKAROUND_DEVICE_DESTROY_BEFORE_SURFACE_RELEASE
@@ -899,7 +1125,14 @@ class SurfaceInfo {
}
}
#endif
- _surface.Configure(&config);
+ wgpu::SurfaceConfiguration surfaceConfig = config;
+ if (_acquiresAfterDraw) {
+ // A deferred frame reaches the surface by a copy.
+ surfaceConfig.usage |= wgpu::TextureUsage::CopyDst;
+ }
+ _surface.Configure(&surfaceConfig);
+ // A new swapchain holds no frame for the window to draw.
+ _drawnPresentCount = _presentCount;
#if defined(__ANDROID__)
// DAWN_WORKAROUND_DEVICE_DESTROY_BEFORE_SURFACE_RELEASE
_surfaceDevice = config.device;
@@ -914,8 +1147,11 @@ class SurfaceInfo {
void recreateSurfaceLocked() {
_surface = nullptr;
_surfaceDevice = nullptr;
- // A frame acquired from the old surface can no longer be presented.
+ // A frame acquired from, or deferred for, the old surface can no longer
+ // be presented.
_acquiredFromSurface = false;
+ _presentPending = false;
+ _deferredFrame = DeferredFrame::None;
_frameEpoch++;
if (_nativeSurface) {
wgpu::SurfaceSourceAndroidNativeWindow source;
@@ -1176,7 +1412,7 @@ class SurfaceInfo {
std::function frameReady;
{
std::lock_guard lock(_poolMutex);
- // Pool torn down (switchToOffscreen) while this frame was in flight:
+ // Pool torn down (detach) while this frame was in flight:
// discard it now that its fences have been drained. Publishing it would
// pin the retired generation forever (nothing polls anymore).
if (_pool == nullptr) {
@@ -1229,9 +1465,31 @@ class SurfaceInfo {
void *_pendingNativeSurface = nullptr;
wgpu::Surface _pendingSurface = nullptr;
NativeSurfaceReleaser _pendingReleaser;
+ // See NativeSurfaceFirstPresent: the pending surface's, then the attached
+ // surface's until its first present takes it.
+ NativeSurfaceFirstPresent _pendingFirstPresent;
+ NativeSurfaceFirstPresent _firstPresent;
+ NativeSurfacePresentPoster _pendingPresentPoster;
// Frame state: set by getCurrentTexture, cleared by presentFrame.
bool _frameInFlight = false;
bool _acquiredFromSurface = false;
+ // The attached surface's present poster (null when it presents straight
+ // away), whether its last frame waits for the posted present, and whether
+ // a posted present is on its way.
+ NativeSurfacePresentPoster _presentPoster;
+ bool _presentPending = false;
+ bool _presentPosted = false;
+ // Whether the attached surface makes an acquire wait until its window has
+ // drawn the frame presented last, and the pending surface's flag.
+ bool _acquiresAfterDraw = false;
+ bool _pendingAcquiresAfterDraw = false;
+ // Frames presented, and the ones the attached surface's window has drawn.
+ uint64_t _presentCount = 0;
+ uint64_t _drawnPresentCount = 0;
+ // A frame rendering offscreen because the window had yet to draw the one
+ // presented last, or finished and waiting in the offscreen texture.
+ enum class DeferredFrame : uint8_t { None, Rendering, Waiting };
+ DeferredFrame _deferredFrame = DeferredFrame::None;
// Bumped on every acquire, present, configure/reconfigure/unconfigure,
// adoption, and detach. The deferred blit-present in applyPendingAttach
// revalidates against it so it never presents a texture that stopped being
@@ -1322,14 +1580,18 @@ class SurfaceRegistry {
// an entry that a concurrent destroyContext is erasing (it either marks the
// entry attached before the check, or re-creates the entry after the
// erase). Lock order is registry -> SurfaceInfo, matching every other path.
- std::shared_ptr attachSurface(int id, wgpu::Instance gpu,
- int width, int height,
- void *nativeSurface,
- wgpu::Surface surface,
- NativeSurfaceReleaser releaser) {
+ std::shared_ptr
+ attachSurface(int id, wgpu::Instance gpu, int width, int height,
+ void *nativeSurface, wgpu::Surface surface,
+ NativeSurfaceReleaser releaser,
+ NativeSurfaceFirstPresent firstPresent = nullptr,
+ NativeSurfacePresentPoster presentPoster = nullptr,
+ bool acquiresAfterDraw = false) {
std::unique_lock lock(_mutex);
auto info = getSurfaceInfoOrCreateLocked(id, gpu, width, height);
- info->attachSurface(nativeSurface, std::move(surface), std::move(releaser));
+ info->attachSurface(nativeSurface, std::move(surface), std::move(releaser),
+ std::move(firstPresent), std::move(presentPoster),
+ acquiresAfterDraw);
return info;
}
diff --git a/packages/webgpu/src/Canvas.tsx b/packages/webgpu/src/Canvas.tsx
index c7244c213..cb62a980f 100644
--- a/packages/webgpu/src/Canvas.tsx
+++ b/packages/webgpu/src/Canvas.tsx
@@ -19,9 +19,12 @@ export interface AndroidCanvasProps {
/**
* Backing view. Defaults to `SurfaceView` when the canvas is opaque and to
* `TextureView` otherwise; both composite correctly in React Native
- * stacking order without further flags. `HardwareBufferView` (Android 10+,
- * a plain View that draws each frame's AHardwareBuffer inline with no extra
- * copy) is opt-in and experimental; on older devices it uses `TextureView`.
+ * stacking order without further flags. Before Android 12L an opaque canvas
+ * defaults to `TextureView` too, unless it sets `zOrderOnTop`: those
+ * releases remove a `SurfaceView` as it leaves the window, a frame before
+ * the window stops showing it. `HardwareBufferView` (Android 10+, a plain
+ * View that draws each frame's AHardwareBuffer inline with no extra copy) is
+ * opt-in and experimental; on older devices it uses `TextureView`.
*/
surfaceType?: AndroidSurfaceType;
/**
diff --git a/packages/webgpu/src/__tests__/TextureViewPresent.spec.ts b/packages/webgpu/src/__tests__/TextureViewPresent.spec.ts
new file mode 100644
index 000000000..cac5b98dd
--- /dev/null
+++ b/packages/webgpu/src/__tests__/TextureViewPresent.spec.ts
@@ -0,0 +1,95 @@
+import { client } from "./setup";
+
+// The harness mounts a 32x32pt canvas backed by a TextureView on Android
+// (apps/example/src/Tests.tsx). A frame presented on the UI thread reaches it
+// behind the window's pending draw, and on Android 11 a frame presented before
+// the window drew the previous one is copied in once it has; this checks that
+// the window shows the last frame presented from either thread, also after a
+// frame acquired while the previous one still waited for its present, and that
+// presenting never stalls the thread.
+
+type RGBA = [number, number, number, number];
+
+const LIME: RGBA = [0, 255, 0, 255];
+
+// An acquire that waits for the window's draw on the thread that draws it
+// stalls that thread for Android 11's 4.29 s dequeue timeout.
+const FRAME_GAP_BUDGET_MS = 2000;
+
+const expectColor = (actual: number[], expected: RGBA, tolerance = 3) => {
+ expected.forEach((channel, i) => {
+ expect(Math.abs(actual[i] - channel)).toBeLessThanOrEqual(tolerance);
+ });
+};
+
+describe("TextureView canvas", () => {
+ it.each(["UI", "JS"] as const)(
+ "shows the last frame presented on the %s thread without stalling it",
+ async (presentingThread) => {
+ if (client.OS !== "android") {
+ return;
+ }
+ const result = await client.eval(
+ ({ device, views, presentFrames, thread }) => {
+ const context = views?.textureCanvas()?.getContext("webgpu");
+ const source = views?.textureCanvasView;
+ if (!views || !context || !source || !presentFrames) {
+ throw new Error("The Tests screen has no TextureView canvas");
+ }
+ context.configure({
+ device,
+ format: navigator.gpu.getPreferredCanvasFormat(),
+ alphaMode: "opaque",
+ });
+ const size = Math.round(views.textureCanvasSize * views.pixelRatio);
+ const texture = device.createTexture({
+ size: [size, size],
+ format: "rgba8unorm",
+ usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC,
+ });
+ const bytesPerRow = Math.ceil((size * 4) / 256) * 256;
+ const output = device.createBuffer({
+ size: bytesPerRow * size,
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
+ });
+ const colors: GPUColor[] = [
+ [1, 0, 0, 1],
+ [1, 1, 0, 1],
+ [0, 1, 1, 1],
+ [1, 0, 1, 1],
+ [0, 1, 0, 1],
+ ];
+ let longestFrameGapMs = 0;
+ return presentFrames(device, context, colors, thread)
+ .then((frameGapMs) => {
+ longestFrameGapMs = frameGapMs;
+ return device.queue.drawElementImageToTexture(
+ { source },
+ { texture },
+ );
+ })
+ .then(() => {
+ const encoder = device.createCommandEncoder();
+ encoder.copyTextureToBuffer(
+ { texture },
+ { buffer: output, bytesPerRow },
+ [size, size],
+ );
+ device.queue.submit([encoder.finish()]);
+ return output.mapAsync(GPUMapMode.READ);
+ })
+ .then(() => {
+ const data = new Uint8Array(output.getMappedRange());
+ const centre = (size / 2) * bytesPerRow + (size / 2) * 4;
+ const pixel = Array.from(data.slice(centre, centre + 4));
+ output.unmap();
+ return { pixel, longestFrameGapMs };
+ });
+ },
+ { thread: presentingThread },
+ );
+ expectColor(result.pixel, LIME);
+ expect(result.longestFrameGapMs).toBeLessThan(FRAME_GAP_BUDGET_MS);
+ },
+ );
+});
diff --git a/packages/webgpu/src/__tests__/setup.ts b/packages/webgpu/src/__tests__/setup.ts
index 25d7552b7..089209530 100644
--- a/packages/webgpu/src/__tests__/setup.ts
+++ b/packages/webgpu/src/__tests__/setup.ts
@@ -11,6 +11,7 @@ import { mat4, vec3, mat3 } from "wgpu-matrix";
import type { Server, WebSocket } from "ws";
import type { Browser, Page } from "puppeteer";
+import type { CanvasRef, RNCanvasContext } from "../Canvas";
import type { GPUOffscreenCanvas } from "../Offscreen";
import { cubeVertexArray } from "./components/cube";
@@ -79,13 +80,27 @@ interface GPUTestingContext {
GPUShaderStage: typeof GPUShaderStage;
GPUTextureUsage: typeof GPUTextureUsage;
};
+ // Native app harness only: clears a canvas to each color in turn from
+ // Reanimated's UI thread or the JS thread and resolves with the longest
+ // time between two frames, see apps/example/src/components/presentFrames.ts.
+ presentFrames?: (
+ device: GPUDevice,
+ context: RNCanvasContext,
+ colors: GPUColor[],
+ thread: "UI" | "JS",
+ ) => Promise;
// Native app harness only: a mounted probe view for the
// drawElementImageToTexture spec (its React tag, its size in points and the
- // device pixel ratio). See apps/example/src/Tests.tsx.
+ // device pixel ratio), and for the TextureView present spec a canvas
+ // backed by a TextureView on Android, the React tag of the view around it
+ // and its size in points. See apps/example/src/Tests.tsx.
views?: {
probe: number | null;
probeSize: number;
pixelRatio: number;
+ textureCanvas: () => CanvasRef | null;
+ textureCanvasView: number | null;
+ textureCanvasSize: number;
};
mat4: typeof mat4;
vec3: typeof vec3;