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..cb107dd4e 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,9 +227,16 @@ 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
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..c851acde5 100644
--- a/packages/webgpu/android/cpp/cpp-adapter.cpp
+++ b/packages/webgpu/android/cpp/cpp-adapter.cpp
@@ -162,20 +162,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 +192,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 +291,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) {
if (manager == nullptr) {
return;
}
@@ -298,13 +307,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) {
@@ -322,6 +350,32 @@ extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onSurfaceCreate(
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,7 +383,7 @@ 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();
}
}
@@ -360,7 +414,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 +508,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..6058d51f4
--- /dev/null
+++ b/packages/webgpu/android/src/main/java/com/webgpu/BackingViewKind.java
@@ -0,0 +1,76 @@
+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;
+ }
+}
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..d35c12cdb
--- /dev/null
+++ b/packages/webgpu/android/src/main/java/com/webgpu/FirstFrameDrawGate.java
@@ -0,0 +1,139 @@
+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);
+ }
+ }
+
+ /** 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/WebGPUAPI.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAPI.java
index 487128285..10050c167 100644
--- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAPI.java
+++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAPI.java
@@ -13,8 +13,15 @@
*/
public interface WebGPUAPI {
+ /**
+ * 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.
+ */
void surfaceCreated(
- Surface surface
+ Surface surface,
+ long firstFrameToken
);
void surfaceChanged(
@@ -23,5 +30,8 @@ void surfaceChanged(
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..3945ef56b 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,35 @@ 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);
+ }
+
@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);
}
@Override
@@ -45,8 +89,30 @@ 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);
+ }
+
@Override
public void surfaceDestroyed(@NonNull SurfaceHolder holder) {
+ releaseSurface();
+ }
+
+ /** 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..e38e3230f 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);
}
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..0ee9e9f7d 100644
--- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUTextureView.java
+++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUTextureView.java
@@ -23,7 +23,7 @@ public WebGPUTextureView(Context context, WebGPUAPI api, boolean opaque) {
@Override
public void onSurfaceTextureAvailable(@NonNull SurfaceTexture surfaceTexture, int width, int height) {
mSurface = new Surface(surfaceTexture);
- mApi.surfaceCreated(mSurface);
+ mApi.surfaceCreated(mSurface, 0);
}
@Override
@@ -45,6 +45,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..0a7a88d5e 100644
--- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java
+++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java
@@ -2,6 +2,8 @@
import android.content.Context;
import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
import android.view.Surface;
import android.view.View;
@@ -11,19 +13,18 @@
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(
@@ -63,24 +64,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;
- }
- 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;
+ // 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;
}
- 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,8 +79,8 @@ 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) {
if (mView != null) {
removeView(mView);
@@ -98,12 +89,13 @@ public void updateView() {
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;
@@ -113,9 +105,9 @@ public void updateView() {
// 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) {
+ } 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.
@@ -134,15 +126,36 @@ protected void onLayout(boolean changed, int left, int top, int right, int botto
}
@Override
- public void surfaceCreated(Surface surface) {
+ public void surfaceCreated(Surface surface, long firstFrameToken) {
float density = getResources().getDisplayMetrics().density;
float width = getWidth() / density;
float height = getHeight() / density;
- boolean votesFrameRate = mAppliedKind == KIND_SURFACE_VIEW;
+ 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);
+ }
+
+ /**
+ * 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) mView).onFirstFramePresented(firstFrameToken);
+ }
+ });
}
@Override
@@ -169,11 +182,28 @@ 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.
@@ -183,6 +213,9 @@ public void destroy() {
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 +224,10 @@ private native void onSurfaceCreate(
int contextId,
float width,
float height,
- boolean reportAnimation
+ boolean presentsBehindPendingDraw,
+ boolean acquiresAfterDraw,
+ boolean reportAnimation,
+ long firstFrameToken
);
@DoNotStrip
@@ -202,6 +238,15 @@ 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);
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..c52a78e87
--- /dev/null
+++ b/packages/webgpu/android/src/test/java/com/webgpu/BackingViewKindTest.java
@@ -0,0 +1,107 @@
+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 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..9432b35c9
--- /dev/null
+++ b/packages/webgpu/android/src/test/java/com/webgpu/FirstFrameDrawGateTest.java
@@ -0,0 +1,242 @@
+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 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/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..316a83f7f 100644
--- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h
+++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h
@@ -58,6 +58,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 +150,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 +204,51 @@ 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();
+ }
// Reflects native view layout changes. Does not resize the drawing buffer:
// that tracks canvas.width/height (like on the web), see
@@ -241,6 +277,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 +294,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 +307,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 +355,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 +371,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 +383,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 +408,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 +545,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 +587,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 +614,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 +808,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 +827,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 +849,6 @@ class SurfaceInfo {
releasedFrameReady = std::move(_poolFrameReady);
_poolFrameReady = nullptr;
}
- if (createFallbackTexture && _config.device != nullptr && !_texture) {
- _texture = createOffscreenTextureLocked();
- }
_frameEpoch++;
}
#endif
@@ -761,26 +859,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,8 +894,62 @@ 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;
+ }
+
wgpu::Texture createOffscreenTextureLocked() {
wgpu::TextureDescriptor descriptor;
// Union with the user's usage so offscreen frames stay compatible with
@@ -842,8 +999,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 +1029,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 +1057,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 +1079,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 +1344,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 +1397,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 +1512,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;