diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d986f278d..0c6a0f50c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -390,6 +390,12 @@ jobs:
ORG_GRADLE_PROJECT_reactNativeArchitectures: x86_64
run: ./gradlew assembleDebug --warning-mode all --stacktrace
+ - name: Unit test the Android library
+ working-directory: apps/example/android
+ env:
+ ORG_GRADLE_PROJECT_reactNativeArchitectures: x86_64
+ run: ./gradlew :react-native-webgpu:testDebugUnitTest
+
# Hardware acceleration for the emulator.
- name: Enable KVM group perms
run: |
diff --git a/apps/docs/content/api/canvas.mdx b/apps/docs/content/api/canvas.mdx
index dc34e4594..57dc53250 100644
--- a/apps/docs/content/api/canvas.mdx
+++ b/apps/docs/content/api/canvas.mdx
@@ -156,7 +156,7 @@ On Android, `Canvas` can be backed by one of three native views, with different
**`TextureView`** is the default: a regular view, so transforms, clipping, alpha and z-order all apply, and the canvas can be composited over other React Native views. The cost is an extra copy, typically a frame of latency, and occasional stalls during some view animations.
-**`SurfaceView`** is the fastest option. It is not regular view content, though: parent transforms, clipping, rounded corners and z-ordering with sibling views do not apply to it. Use it for a canvas that is a plain rectangle: a full-screen scene or a game. It is opaque unless asked otherwise: a translucent `SurfaceView` blends against the window background (usually black) instead of the React Native views beneath it, which are punched out. Pair it with `zOrderOnTop` to composite over React Native content, and note that `zOrderOnTop` draws above all React Native views in the window, including navigation screens, regardless of `zIndex`.
+**`SurfaceView`** is the fastest option. It is not regular view content, though: parent transforms, clipping, rounded corners and z-ordering with sibling views do not apply to it. When the `SurfaceView` gets a surface, the window opens the hole once the canvas has presented a frame on it, and shows the views behind the canvas until then. Use it for a canvas that is a plain rectangle: a full-screen scene or a game. It is opaque unless asked otherwise: a translucent `SurfaceView` blends against the window background (usually black) instead of the React Native views beneath it, which are punched out. Pair it with `zOrderOnTop` to composite over React Native content, and note that `zOrderOnTop` draws above all React Native views in the window, including navigation screens, regardless of `zIndex`.
**`HardwareBufferView`** composites like a `TextureView`, with lower latency. It is experimental: if the device cannot allocate the buffers the canvas shows nothing instead of falling back, and some devices may show a stale or torn frame. Test it on your target devices, and drop the `backingView` to go back to `TextureView`. Below Android 10 it uses a `TextureView`, with a warning in development.
@@ -167,6 +167,8 @@ The backing view is created when the canvas mounts and kept while it is mounted.
```
+A canvas that stops drawing keeps showing its last frame, as it does on iOS and the web: when the app comes back from the background, and when its view comes back to the window (a native-stack screen covered and revealed again, a list with `removeClippedSubviews` scrolling it out and back). Android destroys a `SurfaceView`'s surface, with its buffers, in each case, so the canvas reads that frame back once as the surface goes and shows it on the next one. A `TextureView` keeps its `SurfaceTexture`, and a `HardwareBufferView` its last buffer, while the view is out of the window.
+
Some combinations for other trade-offs:
```tsx
diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx
index 59a306904..9919076e8 100644
--- a/apps/example/src/App.tsx
+++ b/apps/example/src/App.tsx
@@ -46,6 +46,7 @@ import { RenderAfterUnmount } from "./Diagnostics/RenderAfterUnmount";
import { BackgroundDetach } from "./Diagnostics/BackgroundDetach";
import { SurfaceChurn } from "./Diagnostics/SurfaceChurn";
import { TransparencyMode } from "./Diagnostics/TransparencyMode";
+import { FirstFrameHole } from "./Diagnostics/FirstFrameHole";
import { DeviceDestroyBeforeDetach } from "./Diagnostics/DeviceDestroyBeforeDetach";
import { StorageBufferVertices } from "./StorageBufferVertices";
import { SharedTextureMemory } from "./SharedTextureMemory";
@@ -107,6 +108,7 @@ const linking: LinkingOptions = {
SurfaceChurn: "diagnostics/surface-churn",
TransparencyMode: "diagnostics/transparency-mode",
DeviceDestroyBeforeDetach: "diagnostics/device-destroy-before-detach",
+ FirstFrameHole: "diagnostics/first-frame-hole",
StorageBufferVertices: "storage-buffer-vertices",
SharedTextureMemory: "shared-texture-memory",
ImportExternalTexture: "import-external-texture",
@@ -198,6 +200,7 @@ function App() {
+ {
diff --git a/apps/example/src/Diagnostics/FirstFrameHole.tsx b/apps/example/src/Diagnostics/FirstFrameHole.tsx
new file mode 100644
index 000000000..7e64101bc
--- /dev/null
+++ b/apps/example/src/Diagnostics/FirstFrameHole.tsx
@@ -0,0 +1,279 @@
+import React, { useEffect, useRef, useState } from "react";
+import { Button, ScrollView, StyleSheet, Text, View } from "react-native";
+import type {
+ AndroidBackingView,
+ CanvasRef,
+ RNCanvasContext,
+} from "react-native-webgpu";
+import { Canvas } from "react-native-webgpu";
+
+import type { DiagnosticGPU } from "./surfaceLifecycle";
+import {
+ diagnosticStyles,
+ drawClearFrame,
+ initGPU,
+ useDiagnosticLog,
+} from "./surfaceLifecycle";
+
+// Long enough to see the canvas before its first frame.
+const FIRST_FRAME_DELAY_MS = 500;
+// Longer than the second a window waits for a first frame before Android 14.
+const LATE_FIRST_FRAME_DELAY_MS = 1500;
+const REATTACH_STEP_MS = 1500;
+const RED: GPUColor = [1, 0, 0, 1];
+const MAGENTA: GPUColor = [1, 0, 1, 1];
+const STAGE_BLUE = "#3498db";
+const BACKING_VIEWS: readonly AndroidBackingView[] = [
+ "SurfaceView",
+ "TextureView",
+ "HardwareBufferView",
+];
+
+interface ReattachStep {
+ away: boolean;
+ redraw?: GPUColor;
+}
+
+// The reattach cycle's steps, one every REATTACH_STEP_MS, in turn: the canvas
+// scrolls away or back, and halfway through a step with a redraw draws a
+// frame in its colour, magenta while it is away and red while it shows.
+const REATTACH_STEPS: readonly ReattachStep[] = [
+ { away: true, redraw: MAGENTA },
+ { away: false },
+ { away: true },
+ { away: false, redraw: RED },
+ { away: true },
+ { away: false },
+];
+
+interface Redraw {
+ count: number;
+ colour: GPUColor;
+}
+
+const NO_REDRAW: Redraw = { count: 0, colour: RED };
+
+interface ProbeCanvasProps {
+ configureHidden: boolean;
+ lateFirstFrame: boolean;
+ backingView: AndroidBackingView;
+ redraw: Redraw;
+ append: (line: string) => void;
+}
+
+// One mount: the device, then the first frame FIRST_FRAME_DELAY_MS later, or
+// LATE_FIRST_FRAME_DELAY_MS with lateFirstFrame. With configureHidden the
+// canvas is configured while hidden, before its surface exists, and shown
+// afterwards. Every new redraw draws one more frame, in its colour.
+const ProbeCanvas = ({
+ configureHidden,
+ lateFirstFrame,
+ backingView,
+ redraw,
+ append,
+}: ProbeCanvasProps) => {
+ const ref = useRef(null);
+ const target = useRef<{
+ gpu: DiagnosticGPU;
+ context: RNCanvasContext;
+ } | null>(null);
+ const [hidden, setHidden] = useState(configureHidden);
+
+ useEffect(() => {
+ let cancelled = false;
+ let timer: ReturnType | undefined;
+ initGPU(append)
+ .then((gpu) => {
+ const context = ref.current?.getContext("webgpu");
+ if (cancelled || !context) {
+ return;
+ }
+ context.configure({
+ device: gpu.device,
+ format: gpu.format,
+ alphaMode: "opaque",
+ });
+ target.current = { gpu, context };
+ setHidden(false);
+ timer = setTimeout(
+ () => {
+ drawClearFrame(gpu.device, context, 0, RED);
+ append("first frame presented");
+ },
+ lateFirstFrame ? LATE_FIRST_FRAME_DELAY_MS : FIRST_FRAME_DELAY_MS,
+ );
+ })
+ .catch((error: Error) => append(`init failed: ${error.message}`));
+ return () => {
+ cancelled = true;
+ clearTimeout(timer);
+ };
+ }, [append, lateFirstFrame]);
+
+ useEffect(() => {
+ const { current } = target;
+ if (redraw.count === 0 || current === null) {
+ return;
+ }
+ drawClearFrame(current.gpu.device, current.context, 0, redraw.colour);
+ }, [redraw]);
+
+ return (
+
+
+
+ );
+};
+
+// "reattach" scrolls the canvas out of a row that removes its clipped views
+// and back (REATTACH_STEPS), so the same canvas leaves its window and comes
+// back, mostly without drawing again: its last frame, red or magenta, should
+// show again at once.
+export const FirstFrameHole = () => {
+ const { log, append } = useDiagnosticLog();
+ const [mount, setMount] = useState(0);
+ const [configureHidden, setConfigureHidden] = useState(false);
+ const [lateFirstFrame, setLateFirstFrame] = useState(false);
+ const [backingViewIndex, setBackingViewIndex] = useState(0);
+ const [reattach, setReattach] = useState(false);
+ const [rowWidth, setRowWidth] = useState(0);
+ const [redraw, setRedraw] = useState(NO_REDRAW);
+ const row = useRef(null);
+ const backingView = BACKING_VIEWS[backingViewIndex];
+
+ useEffect(() => {
+ if (!reattach || rowWidth === 0) {
+ return;
+ }
+ let step = 0;
+ let redrawTimer: ReturnType | undefined;
+ const timer = setInterval(() => {
+ const { away, redraw: colour } =
+ REATTACH_STEPS[step % REATTACH_STEPS.length];
+ step += 1;
+ row.current?.scrollTo({ x: away ? rowWidth : 0, animated: false });
+ if (colour !== undefined) {
+ redrawTimer = setTimeout(
+ () => setRedraw((value) => ({ count: value.count + 1, colour })),
+ REATTACH_STEP_MS / 2,
+ );
+ }
+ }, REATTACH_STEP_MS);
+ return () => {
+ clearInterval(timer);
+ clearTimeout(redrawTimer);
+ };
+ }, [reattach, rowWidth]);
+
+ const canvas = (
+
+ );
+
+ return (
+
+
+
+ A canvas over a blue stage, backed on Android by the view the backing
+ view button names, a SurfaceView at first. Its first frame, solid red,
+ is drawn half a second after its device is ready, or a second and a
+ half with a late first frame, and the stage should show until then:
+ black means the window showed the surface before it had a frame. When
+ it scrolls away and back, its last frame, red or magenta, should show
+ again at once.
+
+
+
+
+
+ {reattach ? (
+ setRowWidth(event.nativeEvent.layout.width)}
+ >
+
+ {rowWidth > 0 ? canvas : null}
+
+
+
+ ) : (
+ canvas
+ )}
+
+
+ {log.map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ buttons: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
+ stage: { flex: 1, backgroundColor: STAGE_BLUE },
+ // The row shows its own colour only in the frame between a scroll and the
+ // clipping that follows it, when neither cell is attached.
+ row: { backgroundColor: "#7f8c8d" },
+ home: { backgroundColor: STAGE_BLUE },
+ away: { backgroundColor: "#27ae60" },
+ fill: { flex: 1 },
+ hidden: { display: "none" },
+});
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/packages/webgpu/android/WebGPUHardwareBufferView.md b/packages/webgpu/android/WebGPUHardwareBufferView.md
index a1b35e858..7e7a753cc 100644
--- a/packages/webgpu/android/WebGPUHardwareBufferView.md
+++ b/packages/webgpu/android/WebGPUHardwareBufferView.md
@@ -67,8 +67,8 @@ On resize the native pool reallocates to the new canvas size (a new generation),
## Lifecycle
-- **Attach / size change.** `onSizeChanged` and `onAttachedToWindow` call `nEnablePool` (first time, which also registers the frame-ready callback) or `nSetClientSize` (subsequently) with the dp size. Attach also posts one `consume()` to pick up a frame that became ready while detached.
-- **Detach.** The frame-ready callback is unregistered, `nSwitchToOffscreen` flips the context to an offscreen texture so JS keeps rendering safely (it does not block in `getCurrentTexture`), any pending `consume()` is cancelled, and all cached `Bitmap`s are recycled. A frame caught between `getCurrentTexture` and `present` gets its `EndAccess` and is handed to the waiter, so its fences are drained before the retired generation is freed; the waiter discards frames that finish after teardown instead of publishing them. The `SurfaceInfo` is intentionally not removed from the registry, so a temporary detach/re-attach (for example scrolling off screen) keeps working on the same context.
+- **Attach / size change.** `onSizeChanged` and `onAttachedToWindow` call `nEnablePool` (first time, which also registers the frame-ready callback) or `nSetClientSize` (subsequently) with the dp size. Attach also posts one `consume()` to pick up a frame that became ready while detached, and `nEnablePool` schedules a flush that draws the latest frame the canvas rendered offscreen while the view was away into a slot and presents it (`SurfaceInfo::applyPendingPoolBlit`), unless a frame starts first.
+- **Detach.** The frame-ready callback is unregistered, `nSwitchToOffscreen` flips the context to an offscreen texture so JS keeps rendering safely (it does not block in `getCurrentTexture`), any pending `consume()` is cancelled, and every cached `Bitmap` but the displayed one is recycled. The view keeps that one, which holds its buffer, and draws it again when it is back in a window until the next frame replaces it; it is recycled once `WebGPUView` drops the view (`FrameKeeper.retire`). A frame caught between `getCurrentTexture` and `present` gets its `EndAccess` and is handed to the waiter, so its fences are drained before the retired generation is freed; the waiter discards frames that finish after teardown instead of publishing them. The `SurfaceInfo` is intentionally not removed from the registry, so a temporary detach/re-attach (for example scrolling off screen) keeps working on the same context.
- **Drop.** When RN drops the view for good, `WebGPUViewManager.onDropViewInstance` removes the `SurfaceInfo` from the registry. The JS context keeps its own reference; once JS releases it, the destructor stops the fence waiter thread and frees the remaining GPU resources.
## Files
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..e4a6cf786 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,22 @@ 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
+// 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). 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);
}
@@ -189,7 +239,7 @@ struct WeakViewCall {
}
// Called on a native thread, outside the SurfaceInfo locks.
- void operator()() const {
+ template void operator()(Args... args) const {
facebook::jni::ThreadScope scope;
JNIEnv *env = facebook::jni::Environment::current();
if (env == nullptr || method == nullptr) {
@@ -199,7 +249,7 @@ struct WeakViewCall {
if (local == nullptr) {
return; // the view was garbage collected
}
- env->CallVoidMethod(local, method);
+ env->CallVoidMethod(local, method, args...);
if (env->ExceptionCheck()) {
env->ExceptionClear();
}
@@ -284,7 +334,7 @@ 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 reportAnimation, jlong firstFrameToken) {
if (manager == nullptr) {
return;
}
@@ -298,13 +348,26 @@ 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);
+ };
+ }
// 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));
// A SurfaceView asks to hear when it animates, for its refresh rate vote
// (WebGPUView.onAnimating).
if (reportAnimation) {
@@ -329,10 +392,65 @@ 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();
+ }
+}
+
+extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_suspendSurface(
+ JNIEnv *env, jobject thiz, jint contextId) {
+ auto ®istry = rnwgpu::SurfaceRegistry::getInstance();
+ if (auto info = registry.getSurfaceInfo(contextId)) {
+ info->suspend();
}
}
+extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_resumeSurface(
+ JNIEnv *env, jobject thiz, jint contextId) {
+ if (manager == nullptr) {
+ return;
+ }
+ auto ®istry = rnwgpu::SurfaceRegistry::getInstance();
+ if (auto info = registry.getSurfaceInfo(contextId)) {
+ info->resume();
+ // The resume is adopted at the next frame boundary; schedule a flush so a
+ // context that is not rendering presents its latest offscreen frame.
+ manager->flushPendingSurfaceTransition(info);
+ }
+}
+
+// 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.
extern "C" JNIEXPORT jlong JNICALL
Java_com_webgpu_WebGPUView_releaseFrameRateVoteIfIdle(JNIEnv *env, jobject thiz,
@@ -360,7 +478,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);
}
@@ -381,6 +499,11 @@ Java_com_webgpu_WebGPUHardwareBufferView_nEnablePool(JNIEnv *env, jobject thiz,
auto waker = std::make_shared(env, thiz, "onNativeFrameReady");
info->setPoolFrameReadyCallback([waker]() { (*waker)(); });
info->enablePool(static_cast(dpW), static_cast(dpH));
+ // A frame rendered offscreen while the view was away is presented at the
+ // next frame boundary; schedule a flush for a context that is not rendering.
+ if (manager != nullptr) {
+ manager->flushPendingSurfaceTransition(info);
+ }
}
// Keep the canvas-client (dp) size in sync on resize.
@@ -454,6 +577,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/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/FrameKeeper.java b/packages/webgpu/android/src/main/java/com/webgpu/FrameKeeper.java
new file mode 100644
index 000000000..e70fc087e
--- /dev/null
+++ b/packages/webgpu/android/src/main/java/com/webgpu/FrameKeeper.java
@@ -0,0 +1,14 @@
+package com.webgpu;
+
+/**
+ * A backing view that keeps the frame it shows while it is out of its window,
+ * and shows it again when it comes back, as a CAMetalLayer and a web canvas
+ * keep theirs.
+ */
+interface FrameKeeper {
+ /**
+ * The canvas will not attach the view again: it releases the frame it keeps
+ * now, or, while it is still in its window, as it leaves. Main thread.
+ */
+ void retire();
+}
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..4a0bc8b70 100644
--- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAPI.java
+++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAPI.java
@@ -7,21 +7,45 @@
/**
* Surface lifecycle events a WebGPU child view reports. The registry entry
* itself is owned by the JS Canvas component (created lazily, removed via
- * RNWebGPU.destroyContext on unmount); views only attach and detach surfaces.
- * A detached context keeps rendering into an offscreen texture whose content
- * is blitted onto the next attached surface.
+ * RNWebGPU.destroyContext on unmount); views only attach and detach surfaces,
+ * and suspend and resume a surface they keep while out of their window. A
+ * detached or suspended context keeps rendering into an offscreen texture
+ * whose content is blitted onto the next attached or resumed surface.
*/
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(
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();
+ /**
+ * The view left its window and keeps its surface, connected, for its
+ * return: the canvas presents nothing into it, so it keeps its last frame.
+ */
+ void surfaceSuspended();
+
+ /** The view is back in its window with the surface it kept. */
+ void surfaceResumed();
+
int getContextId();
}
diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUHardwareBufferView.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUHardwareBufferView.java
index 49f5c4ffb..6570405b3 100644
--- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUHardwareBufferView.java
+++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUHardwareBufferView.java
@@ -14,6 +14,7 @@
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import java.util.ArrayDeque;
@@ -39,7 +40,7 @@
*/
@RequiresApi(api = Build.VERSION_CODES.Q)
@SuppressLint("ViewConstructor")
-public class WebGPUHardwareBufferView extends View {
+public class WebGPUHardwareBufferView extends View implements FrameKeeper {
private static final int HELD_MAX = 2; // displayed frames kept before release (release safety)
@@ -63,9 +64,14 @@ public class WebGPUHardwareBufferView extends View {
private int mDisplayedW;
private int mDisplayedH;
private int mCurrentGen;
+ // The frame displayed when the view left its window, displayed again once
+ // it is back until the next frame replaces it. Its pool is gone, so nothing
+ // else holds it.
+ @Nullable private Bitmap mKeptFrame;
private boolean mAttached;
private boolean mEnabled;
+ private boolean mRetired;
public WebGPUHardwareBufferView(Context context, WebGPUAPI api) {
super(context);
@@ -163,6 +169,7 @@ private void onFrameReady(long token) {
mDisplayed = bmp;
mDisplayedW = bmp.getWidth();
mDisplayedH = bmp.getHeight();
+ releaseKeptFrame();
mHeld.addLast(token);
while (mHeld.size() > HELD_MAX) {
@@ -242,15 +249,44 @@ protected void onDetachedFromWindow() {
// the view for good.
nSwitchToOffscreen(contextId());
- mDisplayed = null;
mHeld.clear();
for (Bitmap b : mBitmaps.values()) {
- if (!b.isRecycled()) {
+ if (b != mDisplayed && !b.isRecycled()) {
b.recycle();
}
}
mBitmaps.clear();
mCurrentGen = 0;
+
+ // Unless the view is retired, the displayed frame is kept, so the view
+ // shows it again when it comes back: its Bitmap holds the buffer, which
+ // the next pool does not reuse.
+ mKeptFrame = mDisplayed;
+ if (mRetired) {
+ releaseKeptFrame();
+ }
+ }
+
+ @Override
+ public void retire() {
+ mRetired = true;
+ if (!mAttached) {
+ releaseKeptFrame();
+ }
+ }
+
+ private void releaseKeptFrame() {
+ Bitmap kept = mKeptFrame;
+ if (kept == null) {
+ return;
+ }
+ mKeptFrame = null;
+ if (kept == mDisplayed) {
+ mDisplayed = null;
+ }
+ if (!kept.isRecycled()) {
+ kept.recycle();
+ }
}
@Override
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 e508483bd..03e047147 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 zOrderMediaOverlay, boolean opaque) {
@@ -44,17 +70,35 @@ public void setZOrder(boolean onTop, boolean mediaOverlay) {
invalidate();
}
+ // 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
@@ -62,8 +106,34 @@ 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();
+ }
+
+ /** 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..1ee35daa4 100644
--- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUTextureView.java
+++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUTextureView.java
@@ -6,12 +6,18 @@
import android.view.Surface;
import android.view.TextureView;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
@SuppressLint("ViewConstructor")
-public class WebGPUTextureView extends TextureView implements TextureView.SurfaceTextureListener {
+public class WebGPUTextureView extends TextureView implements TextureView.SurfaceTextureListener, FrameKeeper {
WebGPUAPI mApi;
private Surface mSurface;
+ // The SurfaceTexture, with the frame in it, the view keeps while it is out
+ // of its window, until it is attached again or retired.
+ @Nullable private SurfaceTexture mKeptSurfaceTexture;
+ private boolean mLeavingWindow = false;
+ private boolean mRetired = false;
public WebGPUTextureView(Context context, WebGPUAPI api, boolean opaque) {
super(context);
@@ -23,7 +29,25 @@ 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
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ mLeavingWindow = false;
+ SurfaceTexture kept = mKeptSurfaceTexture;
+ if (kept != null) {
+ mKeptSurfaceTexture = null;
+ setSurfaceTexture(kept);
+ mApi.surfaceResumed();
+ }
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ mLeavingWindow = true;
+ super.onDetachedFromWindow();
}
@Override
@@ -31,16 +55,43 @@ public void onSurfaceTextureSizeChanged(@NonNull SurfaceTexture surfaceTexture,
mApi.surfaceChanged(mSurface);
}
+ // Destroyed as the view leaves its window, the SurfaceTexture is kept, still
+ // connected to the canvas, so it shows its last frame when the view comes
+ // back. Destroyed while the view stays attached, as Android 7 does when the
+ // window drops its hardware resources, or once the view is retired, it is
+ // released.
@Override
public boolean onSurfaceTextureDestroyed(@NonNull SurfaceTexture surfaceTexture) {
- // Detach first (synchronous through JNI) so the native side has dropped
- // its window reference before we release ours.
+ if (mLeavingWindow && !mRetired) {
+ mApi.surfaceSuspended();
+ mKeptSurfaceTexture = surfaceTexture;
+ return false;
+ }
+ // Synchronous through JNI: the native side drops its window reference
+ // before the view releases the surface.
mApi.surfaceOffscreen();
+ releaseSurface();
+ return true;
+ }
+
+ @Override
+ public void retire() {
+ mRetired = true;
+ SurfaceTexture kept = mKeptSurfaceTexture;
+ if (kept == null) {
+ return;
+ }
+ mKeptSurfaceTexture = null;
+ mApi.surfaceOffscreen();
+ kept.release();
+ releaseSurface();
+ }
+
+ private void releaseSurface() {
if (mSurface != null) {
mSurface.release();
mSurface = null;
}
- return true;
}
@Override
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 04663c300..271110801 100644
--- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java
+++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java
@@ -1,6 +1,7 @@
package com.webgpu;
import android.content.Context;
+import android.graphics.Bitmap;
import android.os.Build;
import android.util.Log;
import android.view.Surface;
@@ -36,6 +37,9 @@ public class WebGPUView extends ReactViewGroup implements WebGPUAPI {
private View mView = null;
@Nullable
private BackingViewKind mKind = null;
+ // Once React drops the view its registry entry is retired: what its backing
+ // view reports afterwards, until it leaves the window, attaches no surface.
+ private boolean mDestroyed = false;
// Only a SurfaceView votes: a TextureView or HardwareBufferView is
// composited into the window, which votes for it.
private final SurfaceFrameRateVote mFrameRateVote = new SurfaceFrameRateVote(
@@ -186,7 +190,10 @@ 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) {
+ if (mDestroyed) {
+ return;
+ }
float density = getResources().getDisplayMetrics().density;
float width = getWidth() / density;
float height = getHeight() / density;
@@ -194,7 +201,20 @@ public void surfaceCreated(Surface surface) {
if (votesFrameRate) {
mFrameRateVote.attach(surface);
}
- onSurfaceCreate(surface, mContextId, width, height, votesFrameRate);
+ onSurfaceCreate(surface, mContextId, width, height, 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
@@ -205,6 +225,20 @@ 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);
+ }
+
+ 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.
@@ -212,6 +246,16 @@ public void surfaceOffscreen() {
switchToOffscreenSurface(mContextId);
}
+ @Override
+ public void surfaceSuspended() {
+ suspendSurface(mContextId);
+ }
+
+ @Override
+ public void surfaceResumed() {
+ resumeSurface(mContextId);
+ }
+
/**
* Called from native (AnimationReporter in cpp-adapter.cpp) on the thread
* that presents to the surface, once per animation, only for a SurfaceView.
@@ -231,10 +275,20 @@ public int getContextId() {
* view: the view dies with its Canvas, so it retires the registry entry.
*/
public void destroy() {
+ mDestroyed = true;
+ if (mView != null) {
+ retire(mView);
+ }
mFrameRateVote.detach();
onViewDestroyed(mContextId);
}
+ private static void retire(View view) {
+ if (view instanceof FrameKeeper) {
+ ((FrameKeeper) view).retire();
+ }
+ }
+
// reportAnimation asks native to call onAnimating() when frames start being
// presented to this surface back to back.
@DoNotStrip
@@ -243,7 +297,8 @@ private native void onSurfaceCreate(
int contextId,
float width,
float height,
- boolean reportAnimation
+ boolean reportAnimation,
+ long firstFrameToken
);
@DoNotStrip
@@ -257,6 +312,20 @@ private native void onSurfaceChanged(
@DoNotStrip
private native void switchToOffscreenSurface(int contextId);
+ @DoNotStrip
+ private native void suspendSurface(int contextId);
+
+ @DoNotStrip
+ private native void resumeSurface(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/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/FrameBlit.h b/packages/webgpu/cpp/rnwgpu/FrameBlit.h
new file mode 100644
index 000000000..ea94e2a90
--- /dev/null
+++ b/packages/webgpu/cpp/rnwgpu/FrameBlit.h
@@ -0,0 +1,98 @@
+#pragma once
+
+#include
+
+#include "webgpu/webgpu_cpp.h"
+
+namespace rnwgpu {
+
+// Draws a texture into another of the same format, texel for texel over the
+// region both cover, and clears the rest: a copy that needs RenderAttachment
+// on the destination where a copy needs CopyDst. SurfaceInfo blits its
+// offscreen frame onto a surface with it, since configuring a surface for
+// CopyDst replaces its swapchain, which on Android reconnects the window and
+// drops the frame it shows. The pipeline is built for the first device it
+// draws with and the destination's format; reset() drops it.
+class FrameBlit {
+public:
+ void encode(const wgpu::Device &device, const wgpu::CommandEncoder &encoder,
+ const wgpu::Texture &source, const wgpu::Texture &destination) {
+ if (!_pipeline || _format != destination.GetFormat()) {
+ _format = destination.GetFormat();
+ _pipeline = createPipeline(device, _format);
+ }
+ wgpu::BindGroupEntry entry = {};
+ entry.binding = 0;
+ entry.textureView = source.CreateView();
+ wgpu::BindGroupDescriptor bindGroupDesc = {};
+ bindGroupDesc.layout = _pipeline.GetBindGroupLayout(0);
+ bindGroupDesc.entryCount = 1;
+ bindGroupDesc.entries = &entry;
+ wgpu::BindGroup bindGroup = device.CreateBindGroup(&bindGroupDesc);
+
+ wgpu::RenderPassColorAttachment attachment = {};
+ attachment.view = destination.CreateView();
+ attachment.loadOp = wgpu::LoadOp::Clear;
+ attachment.storeOp = wgpu::StoreOp::Store;
+ attachment.clearValue = {0.0, 0.0, 0.0, 0.0};
+ wgpu::RenderPassDescriptor passDesc = {};
+ passDesc.label = "offscreen frame blit";
+ passDesc.colorAttachmentCount = 1;
+ passDesc.colorAttachments = &attachment;
+
+ wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&passDesc);
+ pass.SetPipeline(_pipeline);
+ pass.SetBindGroup(0, bindGroup);
+ pass.SetScissorRect(0, 0,
+ std::min(source.GetWidth(), destination.GetWidth()),
+ std::min(source.GetHeight(), destination.GetHeight()));
+ pass.Draw(3);
+ pass.End();
+ }
+
+ void reset() { _pipeline = nullptr; }
+
+private:
+ static constexpr const char *kShader = R"(
+@group(0) @binding(0) var frame: texture_2d;
+
+@vertex fn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f {
+ var corners = array(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
+ return vec4f(corners[index], 0.0, 1.0);
+}
+
+@fragment fn fs(@builtin(position) position: vec4f) -> @location(0) vec4f {
+ return textureLoad(frame, vec2u(position.xy), 0);
+}
+)";
+
+ static wgpu::RenderPipeline createPipeline(const wgpu::Device &device,
+ wgpu::TextureFormat format) {
+ wgpu::ShaderSourceWGSL wgsl = {};
+ wgsl.code = kShader;
+ wgpu::ShaderModuleDescriptor moduleDesc = {};
+ moduleDesc.label = "offscreen frame blit";
+ moduleDesc.nextInChain = &wgsl;
+ wgpu::ShaderModule module = device.CreateShaderModule(&moduleDesc);
+
+ wgpu::ColorTargetState target = {};
+ target.format = format;
+ wgpu::FragmentState fragment = {};
+ fragment.module = module;
+ fragment.entryPoint = "fs";
+ fragment.targetCount = 1;
+ fragment.targets = ⌖
+ wgpu::RenderPipelineDescriptor pipelineDesc = {};
+ pipelineDesc.label = "offscreen frame blit";
+ pipelineDesc.vertex.module = module;
+ pipelineDesc.vertex.entryPoint = "vs";
+ pipelineDesc.fragment = &fragment;
+ pipelineDesc.primitive.topology = wgpu::PrimitiveTopology::TriangleList;
+ return device.CreateRenderPipeline(&pipelineDesc);
+ }
+
+ wgpu::RenderPipeline _pipeline;
+ wgpu::TextureFormat _format = wgpu::TextureFormat::Undefined;
+};
+
+} // namespace rnwgpu
diff --git a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h
index e734e65f6..54fbd89b3 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
@@ -19,6 +20,8 @@
#include "webgpu/webgpu_cpp.h"
+#include "FrameBlit.h"
+
#if defined(__ANDROID__)
#include
#include
@@ -58,6 +61,12 @@ 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;
+
#if defined(__ANDROID__)
// --- AHB-pool presentation mode (WebGPUNativeBufferView)
// -----------------------------
@@ -137,14 +146,22 @@ 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.
+// - Suspending (suspend) keeps the surface of a view that left its window,
+// connected, so it goes on showing the frame it last presented, as a
+// CAMetalLayer and a web canvas do: frames render offscreen, as while
+// detached, and nothing is presented into it. Resuming (resume) is latched
+// like an attach: from the next frame boundary frames present into the
+// surface again, once the latest offscreen frame, if one was rendered
+// meanwhile, is blitted onto it.
class SurfaceInfo {
public:
SurfaceInfo(wgpu::Instance gpu, int width, int height)
@@ -184,35 +201,81 @@ 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) {
void *replacedSurface = nullptr;
NativeSurfaceReleaser replacedReleaser;
+ NativeSurfaceFirstPresent replacedFirstPresent;
{
std::unique_lock lock(_mutex);
if (_hasPendingAttach) {
// Replaced before it was ever adopted.
replacedSurface = _pendingNativeSurface;
replacedReleaser = std::move(_pendingReleaser);
+ replacedFirstPresent = std::move(_pendingFirstPresent);
}
_hasPendingAttach = true;
_pendingNativeSurface = nativeSurface;
_pendingSurface = std::move(surface);
_pendingReleaser = std::move(releaser);
+ _pendingFirstPresent = std::move(firstPresent);
}
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 size keepFrame takes a frame at: the drawing buffer's, while a surface
+ // the context is configured for is attached and its format is one keepFrame
+ // writes; {0, 0} otherwise.
+ Size keepableFrameSize() {
+ std::shared_lock lock(_mutex);
+ if (!_surface || _config.device == nullptr ||
+ !keepsFramesOfFormat(_config.format)) {
+ return {0, 0};
+ }
+ return {static_cast(_config.width), static_cast(_config.height)};
+ }
- // Detach without creating the offscreen fallback: used when the context is
- // being destroyed and nothing will consume further frames.
- void detachSurface() { detach(/* createFallbackTexture = */ false); }
+ // 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;
+ }
+ 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
@@ -223,12 +286,33 @@ class SurfaceInfo {
_height = newHeight;
}
+ // The surface's view left its window and keeps the surface: frames render
+ // offscreen and nothing is presented into it until resume(), so it goes on
+ // showing its last frame. A frame acquired from it before still presents,
+ // and a surface attached while suspended is adopted suspended.
+ void suspend() {
+ std::unique_lock lock(_mutex);
+ _presentation = Presentation::Suspended;
+ }
+
+ // The surface's view is back in its window with the surface suspend() kept.
+ // Latched like an attach (applyPendingAttach); callers follow up with
+ // RNWebGPUManager::flushPendingSurfaceTransition.
+ void resume() {
+ std::unique_lock lock(_mutex);
+ if (_presentation == Presentation::Suspended) {
+ _presentation = Presentation::Resuming;
+ }
+ }
+
// --- Frame boundary (rendering thread, or the JS thread via
// RNWebGPUManager::flushPendingSurfaceTransition) ---------------------------
- // Adopt a pending surface if no frame is in flight: configure it and, if
- // frames were rendered offscreen, blit the most recent one onto it and
- // present it, so content shows up without waiting for the render loop.
+ // Adopt a pending surface, or resume a suspended one, if no frame is in
+ // flight: configure a pending surface and, if frames were rendered
+ // offscreen, blit the most recent one onto the surface and present it, so
+ // content shows up without waiting for the render loop. A surface adopted
+ // while suspended is configured, and presented nothing until it resumes.
// Safe to call from any thread; no-ops when there is nothing pending.
//
// supersedeInFlightFrame is set by the rendering thread when it starts a new
@@ -236,51 +320,76 @@ class SurfaceInfo {
// block adoption. The flush path (other threads) leaves it false so it never
// swaps the surface under a frame that is genuinely in flight.
void applyPendingAttach(bool supersedeInFlightFrame = false) {
+#if defined(__ANDROID__)
+ if (!supersedeInFlightFrame) {
+ applyPendingPoolBlit();
+ }
+#endif
bool presentBlit = false;
uint64_t blitEpoch = 0;
wgpu::Device device = nullptr;
void *replacedSurface = nullptr;
NativeSurfaceReleaser replacedReleaser;
+ NativeSurfaceFirstPresent replacedFirstPresent;
+ NativeSurfaceFirstPresent firstPresent;
{
std::unique_lock lock(_mutex);
if (supersedeInFlightFrame) {
_frameInFlight = false;
_acquiredFromSurface = false;
+#if defined(__ANDROID__)
+ _poolBlitPending = false;
+#endif
}
- if (!_hasPendingAttach || _frameInFlight) {
+ bool resuming = _presentation == Presentation::Resuming;
+ if ((!_hasPendingAttach && !resuming) || _frameInFlight) {
return;
}
- // Attach over attach without a detach in between: replace. Ownership
- // tracks the native window pointer, not the Dawn surface handle (which
- // can be null if surface creation failed).
- replacedSurface = _nativeSurface;
- replacedReleaser = std::move(_releaser);
- _surface = std::move(_pendingSurface);
+ bool adopting = _hasPendingAttach;
+ if (adopting) {
+ // Attach over attach without a detach in between: replace. Ownership
+ // tracks the native window pointer, not the Dawn surface handle
+ // (which can be null if surface creation failed).
+ replacedSurface = _nativeSurface;
+ replacedReleaser = std::move(_releaser);
+ replacedFirstPresent = std::move(_firstPresent);
+ _surface = std::move(_pendingSurface);
#if defined(__ANDROID__)
- // DAWN_WORKAROUND_DEVICE_DESTROY_BEFORE_SURFACE_RELEASE
- _surfaceDevice = nullptr;
+ // DAWN_WORKAROUND_DEVICE_DESTROY_BEFORE_SURFACE_RELEASE
+ _surfaceDevice = nullptr;
#endif
- _nativeSurface = _pendingNativeSurface;
- _releaser = std::move(_pendingReleaser);
- _hasPendingAttach = false;
- _pendingNativeSurface = nullptr;
+ _nativeSurface = _pendingNativeSurface;
+ _releaser = std::move(_pendingReleaser);
+ _firstPresent = std::move(_pendingFirstPresent);
+ _hasPendingAttach = false;
+ _pendingNativeSurface = nullptr;
+ }
+ if (resuming) {
+ _presentation = Presentation::Active;
+ }
_frameEpoch++;
+ bool blit = _texture != nullptr && _presentation == Presentation::Active;
+ bool copies = blit && !blitDrawsLocked();
// _surface can be null here when Dawn surface creation failed for a
// valid native window; the context then just keeps rendering offscreen.
+ // A resumed surface keeps its configuration unless the blit is a copy:
+ // configuring replaces the swapchain, which on Android reconnects the
+ // window and drops the frame it shows.
if (_config.device != nullptr && _surface) {
- bool blit = _texture != nullptr;
- // The blit needs CopyDst on the surface. Configure with a widened
- // copy while keeping _config at the usage the user asked for, so any
- // later reconfigure drops the extra flag again.
- wgpu::SurfaceConfiguration config = _config;
- if (blit) {
- config.usage |= wgpu::TextureUsage::CopyDst;
- }
- configureSurfaceLocked(config);
+ if (adopting || copies) {
+ // A copy needs CopyDst on the surface. Configure with a widened
+ // copy while keeping _config at the usage the user asked for, so
+ // any later reconfigure drops the extra flag again.
+ wgpu::SurfaceConfiguration config = _config;
+ if (copies) {
+ config.usage |= wgpu::TextureUsage::CopyDst;
+ }
+ configureSurfaceLocked(config);
#ifdef __APPLE__
- applyCAMetalLayerColorSpace(_nativeSurface, _config.format);
+ applyCAMetalLayerColorSpace(_nativeSurface, _config.format);
#endif
+ }
if (blit) {
presentBlit = blitOffscreenToSurfaceLocked();
device = _config.device;
@@ -308,8 +417,12 @@ class SurfaceInfo {
// present-without-acquire error).
if (_surface && !_frameInFlight && _frameEpoch == blitEpoch) {
_surface.Present();
+ firstPresent = std::move(_firstPresent);
}
}
+ if (firstPresent) {
+ firstPresent();
+ }
}
// --- Rendering thread
@@ -320,6 +433,10 @@ class SurfaceInfo {
applyPendingAttach(/* supersedeInFlightFrame = */ true);
std::unique_lock lock(_mutex);
_viewFormats = std::move(viewFormats);
+ if (_config.device.Get() != newConfig.device.Get() ||
+ _config.format != newConfig.format) {
+ _frameBlit.reset();
+ }
_config = newConfig;
// The caller's viewFormats storage dies with the call; point the stored
// configuration at our own copy.
@@ -369,6 +486,7 @@ class SurfaceInfo {
}
_texture = nullptr;
_config = {};
+ _frameBlit.reset();
_viewFormats.clear();
_acquiredFromSurface = false;
_frameEpoch++;
@@ -445,7 +563,8 @@ class SurfaceInfo {
}
// Returns the texture for the current frame: the surface's swapchain texture
- // when a surface is attached and healthy, an offscreen texture otherwise.
+ // when a surface is attached, not suspended and healthy, an offscreen
+ // texture otherwise.
// Never returns null; throws when called before configure(). When frameEpoch
// is given, it receives the epoch identifying this frame (see
// isCurrentFrame).
@@ -496,7 +615,7 @@ class SurfaceInfo {
if (frameEpoch) {
*frameEpoch = _frameEpoch;
}
- if (_surface) {
+ if (_surface && _presentation == Presentation::Active) {
auto texture = acquireSurfaceTextureLocked();
if (texture) {
_acquiredFromSurface = true;
@@ -515,12 +634,13 @@ class SurfaceInfo {
// True while the frame started by the getCurrentTexture() call that
// returned frameEpoch is still the current one: nothing presented,
// configured, resized, unconfigured, detached, or adopted a surface since.
- // A pending attach also ends it, so a frame that never presents cannot block
- // adoption: the next getCurrentTexture() supersedes it, as an uncached call
- // would.
+ // A pending attach or resume also ends it, so a frame that never presents
+ // cannot block adoption: the next getCurrentTexture() supersedes it, as an
+ // uncached call would.
bool isCurrentFrame(uint64_t frameEpoch) {
std::shared_lock lock(_mutex);
- return _frameInFlight && !_hasPendingAttach && _frameEpoch == frameEpoch;
+ return _frameInFlight && !_hasPendingAttach &&
+ _presentation != Presentation::Resuming && _frameEpoch == frameEpoch;
}
// Present the current frame. Runs synchronously on the thread that did
@@ -552,6 +672,7 @@ class SurfaceInfo {
#if defined(__ANDROID__)
std::function framePresented;
#endif
+ NativeSurfaceFirstPresent firstPresent;
{
std::unique_lock lock(_mutex);
if (_surface && _acquiredFromSurface) {
@@ -559,6 +680,7 @@ class SurfaceInfo {
#if defined(__ANDROID__)
framePresented = _framePresented;
#endif
+ firstPresent = std::move(_firstPresent);
}
_acquiredFromSurface = false;
_frameInFlight = false;
@@ -569,6 +691,9 @@ class SurfaceInfo {
framePresented();
}
#endif
+ if (firstPresent) {
+ firstPresent();
+ }
applyPendingAttach();
}
@@ -600,12 +725,60 @@ class SurfaceInfo {
std::unique_lock lock(_mutex);
_width = dpW;
_height = dpH;
+ _poolBlitPending = _texture != nullptr;
}
std::lock_guard poolLock(_poolMutex);
_poolMode.store(true);
_shutdown = false;
}
+ // A view entering pool mode shows the latest frame rendered offscreen while
+ // it was away, as an attached surface gets it blitted: the frame is drawn
+ // into a slot and presented through the waiter. Runs at the frame boundary
+ // nEnablePool's flush reaches, unless a frame starts first and renders anew.
+ void applyPendingPoolBlit() {
+ wgpu::Texture frame;
+ wgpu::Device device;
+ int width = 0;
+ int height = 0;
+ uint64_t blitEpoch = 0;
+ {
+ std::unique_lock lock(_mutex);
+ if (!_poolBlitPending || !_poolMode.load() || _frameInFlight ||
+ !_texture || _config.device == nullptr) {
+ return;
+ }
+ _poolBlitPending = false;
+ frame = _texture;
+ device = _config.device;
+ width = static_cast(_config.width);
+ height = static_cast(_config.height);
+ _frameInFlight = true;
+ blitEpoch = ++_frameEpoch;
+ }
+ poolResize(width, height);
+ wgpu::Texture target = poolGetCurrentTexture();
+ std::unique_lock lock(_mutex);
+ // A frame that started meanwhile superseded the blit and owns the slot
+ // poolGetCurrentTexture handed out, which it presents.
+ if (!_frameInFlight || _frameEpoch != blitEpoch) {
+ return;
+ }
+ if (target) {
+ wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
+ _frameBlit.encode(device, encoder, frame, target);
+ wgpu::CommandBuffer commands = encoder.Finish();
+ device.GetQueue().Submit(1, &commands);
+ if (_texture.Get() == frame.Get()) {
+ _texture = nullptr;
+ }
+ // Under _mutex, so a frame that starts now gets a slot of its own.
+ poolPresent();
+ }
+ _frameInFlight = false;
+ _frameEpoch++;
+ }
+
// Set the canvas-client (dp) size without changing pool mode. Keeps getSize()
// (and thus the JS canvas.clientWidth/Height) in sync on resize.
void setPoolClientSize(int dpW, int dpH) {
@@ -714,10 +887,15 @@ class SurfaceInfo {
}
#endif
-private:
- void detach(bool createFallbackTexture) {
+ // The platform surface is being destroyed, or the view with it: detach
+ // immediately, a suspended surface included. 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];
#if defined(__ANDROID__)
// Destroyed outside the lock: it releases a JNI reference.
std::function releasedFrameReady;
@@ -726,8 +904,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 +926,6 @@ class SurfaceInfo {
releasedFrameReady = std::move(_poolFrameReady);
_poolFrameReady = nullptr;
}
- if (createFallbackTexture && _config.device != nullptr && !_texture) {
- _texture = createOffscreenTextureLocked();
- }
_frameEpoch++;
}
#endif
@@ -761,12 +936,11 @@ class SurfaceInfo {
_pendingSurface = nullptr;
releasedSurfaces[0] = _pendingNativeSurface;
releasers[0] = std::move(_pendingReleaser);
+ droppedFirstPresents[0] = std::move(_pendingFirstPresent);
_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
@@ -776,6 +950,7 @@ class SurfaceInfo {
// presentFrame() must not present it.
_acquiredFromSurface = false;
}
+ _presentation = Presentation::Active;
_frameEpoch++;
// Window ownership is independent of the Dawn surface handle (which can
// be null if surface creation failed): always return the window.
@@ -791,16 +966,26 @@ class SurfaceInfo {
}
}
+private:
// All *Locked helpers below require _mutex to be held exclusively.
+ // 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 +1027,17 @@ 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.
+ // A canvas configured for rendering takes the blit as a draw, which needs
+ // nothing from the surface's configuration; otherwise it is a copy.
+ bool blitDrawsLocked() const {
+ return static_cast(_config.usage &
+ wgpu::TextureUsage::RenderAttachment);
+ }
+
+ // Draw or copy the last offscreen frame onto the attached surface. Returns
+ // true when the commands were submitted and the surface should be
+ // presented. The offscreen frame and the surface can disagree on size (e.g.
+ // the device rotated while detached); the shared region is blitted.
bool blitOffscreenToSurfaceLocked() {
wgpu::SurfaceTexture surfaceTexture;
_surface.GetCurrentTexture(&surfaceTexture);
@@ -851,34 +1045,38 @@ class SurfaceInfo {
return false;
}
- wgpu::TexelCopyTextureInfo source = {};
- source.texture = _texture;
- wgpu::TexelCopyTextureInfo destination = {};
- destination.texture = surfaceTexture.texture;
-
- // The offscreen frame and the new surface can disagree on size (e.g. the
- // device rotated while detached); copy the shared region.
- wgpu::Extent3D size = {
- std::min(_texture.GetWidth(), surfaceTexture.texture.GetWidth()),
- std::min(_texture.GetHeight(), surfaceTexture.texture.GetHeight()), 1};
-
wgpu::CommandEncoderDescriptor encoderDescriptor;
wgpu::CommandEncoder encoder =
_config.device.CreateCommandEncoder(&encoderDescriptor);
- encoder.CopyTextureToTexture(&source, &destination, &size);
+ if (blitDrawsLocked()) {
+ _frameBlit.encode(_config.device, encoder, _texture,
+ surfaceTexture.texture);
+ } else {
+ wgpu::TexelCopyTextureInfo source = {};
+ source.texture = _texture;
+ wgpu::TexelCopyTextureInfo destination = {};
+ destination.texture = surfaceTexture.texture;
+ wgpu::Extent3D size = {
+ std::min(_texture.GetWidth(), surfaceTexture.texture.GetWidth()),
+ std::min(_texture.GetHeight(), surfaceTexture.texture.GetHeight()),
+ 1};
+ encoder.CopyTextureToTexture(&source, &destination, &size);
+ }
wgpu::CommandBuffer commands = encoder.Finish();
_config.device.GetQueue().Submit(1, &commands);
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();
}
}
@@ -1176,7 +1374,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) {
@@ -1222,13 +1420,24 @@ class SurfaceInfo {
void *_nativeSurface = nullptr;
wgpu::Surface _surface = nullptr;
NativeSurfaceReleaser _releaser;
+ // Whether frames present into the attached surface: a suspended one is kept
+ // and presented nothing, a resuming one presents again from the next frame
+ // boundary (see suspend and resume).
+ enum class Presentation { Active, Suspended, Resuming };
+ Presentation _presentation = Presentation::Active;
// Offscreen fallback drawing buffer.
wgpu::Texture _texture = nullptr;
+ // Draws _texture onto a surface; built for _config's device and format.
+ FrameBlit _frameBlit;
// Surface attached by the UI thread, awaiting adoption at a frame boundary.
bool _hasPendingAttach = false;
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;
// Frame state: set by getCurrentTexture, cleared by presentFrame.
bool _frameInFlight = false;
bool _acquiredFromSurface = false;
@@ -1271,6 +1480,9 @@ class SurfaceInfo {
wgpu::TextureFormat _poolFormat = wgpu::TextureFormat::Undefined;
wgpu::TextureUsage _poolUsage = wgpu::TextureUsage::RenderAttachment;
bool _poolUnsupported = false; // device lacks AHB shared-texture support
+ // Set by enablePool when an offscreen frame waits to be shown, under _mutex
+ // (see applyPendingPoolBlit).
+ bool _poolBlitPending = false;
std::shared_ptr _pool; // current generation
std::unordered_map> _pools; // gen -> pool
std::shared_ptr _renderPool; // generation of the in-flight render
@@ -1322,14 +1534,15 @@ 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) {
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));
return info;
}