From 4844eea661a6c7e2509538154ec718706b4e9322 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 17:10:43 +0300 Subject: [PATCH 1/6] =?UTF-8?q?fix(=F0=9F=A4=96):=20show=20every=20frame?= =?UTF-8?q?=20a=20worklet=20presents=20on=20a=20TextureView=20canvas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A canvas drawn from Reanimated's UI thread presents inside the main thread's Choreographer frame, ahead of the window's draw. A TextureView takes its next buffer only when the window draws, and its buffer queue replaces a buffer it has not taken yet, so the next present replaced every other frame and the window showed half of them. A frame presented on the main thread to a TextureView surface now waits for a present the view posts to the main looper, behind the draw the window has pending, as react-native-skia's FrameScheduler does. The next frame boundary presents a frame still waiting first, a detach drops it with its surface, and frames presented on other threads, or to a SurfaceView or HardwareBufferView, are presented as before. Android 11 keeps presenting at once: its Vulkan loader gives a TextureView's buffer queue a 4.3 s dequeue timeout, and a present held behind the draw stretched those waits into 14 s freezes and an ANR on the Android 11 emulator. The weak handle the hardware buffer view's waker kept on its view is shared by both calls from native, the canvas docs say when a UI-thread frame reaches a TextureView, and the e2e suite presents frames from a worklet on a TextureView canvas on the tests screen. --- apps/docs/content/api/canvas.mdx | 6 +- apps/example/src/Tests.tsx | 25 ++++ .../src/components/workletPresentFrames.ts | 51 ++++++++ packages/webgpu/android/cpp/cpp-adapter.cpp | 45 +++++-- .../src/main/java/com/webgpu/WebGPUView.java | 32 ++++- packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h | 110 +++++++++++++++--- .../src/__tests__/TextureViewPresent.spec.ts | 79 +++++++++++++ packages/webgpu/src/__tests__/setup.ts | 16 ++- 8 files changed, 333 insertions(+), 31 deletions(-) create mode 100644 apps/example/src/components/workletPresentFrames.ts create mode 100644 packages/webgpu/src/__tests__/TextureViewPresent.spec.ts diff --git a/apps/docs/content/api/canvas.mdx b/apps/docs/content/api/canvas.mdx index eabc58677..24087918c 100644 --- a/apps/docs/content/api/canvas.mdx +++ b/apps/docs/content/api/canvas.mdx @@ -204,8 +204,10 @@ 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, except on Android 11. +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 diff --git a/apps/example/src/Tests.tsx b/apps/example/src/Tests.tsx index e62dfd551..33717b57f 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 { workletPresentFrames } from "./components/workletPresentFrames"; 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, + workletPresentFrames, workletSerialization, exportedFlags: { GPUBufferUsage: exportedBufferUsage, @@ -229,6 +243,17 @@ export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => { }} /> + + + => + new Promise((resolve) => { + scheduleOnUI(() => { + "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; + const step = () => { + if (next === 0) { + present(colors[next++]); + } + if (next < colors.length) { + present(colors[next++]); + requestAnimationFrame(step); + return; + } + requestAnimationFrame(() => + requestAnimationFrame(() => scheduleOnRN(resolve)), + ); + }; + requestAnimationFrame(step); + }); + }); diff --git a/packages/webgpu/android/cpp/cpp-adapter.cpp b/packages/webgpu/android/cpp/cpp-adapter.cpp index b3768488c..fb9378303 100644 --- a/packages/webgpu/android/cpp/cpp-adapter.cpp +++ b/packages/webgpu/android/cpp/cpp-adapter.cpp @@ -165,9 +165,11 @@ 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). +// animating (AnimationReporter), 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; @@ -188,22 +190,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. + bool operator()() 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()) { + const bool called = !env->ExceptionCheck(); + if (!called) { env->ExceptionClear(); } env->DeleteLocalRef(local); + return called; } }; @@ -284,7 +289,8 @@ 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 reportAnimation) { if (manager == nullptr) { return; } @@ -298,13 +304,21 @@ extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onSurfaceCreate( auto &runtime = rnwgpu::WebGPURuntime::shared(); auto gpu = runtime.instance(); auto surface = runtime.makeSurface(window); + 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(presentPoster)); // A SurfaceView asks to hear when it animates, for its refresh rate vote // (WebGPUView.onAnimating). if (reportAnimation) { @@ -322,6 +336,15 @@ 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(); + } +} + extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_switchToOffscreenSurface(JNIEnv *env, jobject thiz, jint contextId) { 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..8f336d8fd 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; @@ -24,6 +26,10 @@ public class WebGPUView extends ReactViewGroup implements WebGPUAPI { 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( @@ -142,7 +148,15 @@ public void surfaceCreated(Surface surface) { if (votesFrameRate) { mFrameRateVote.attach(surface); } - onSurfaceCreate(surface, mContextId, width, height, votesFrameRate); + onSurfaceCreate(surface, mContextId, width, height, presentsBehindPendingDraw(), votesFrameRate); + } + + // A TextureView shows a new frame only when its window draws. Android 11's + // Vulkan loader gives a TextureView's buffer queue a 4.3 s dequeue timeout, + // and a present held behind the draw there stretches those waits into + // freezes long enough for an ANR. + private boolean presentsBehindPendingDraw() { + return mAppliedKind == KIND_TEXTURE_VIEW && Build.VERSION.SDK_INT != Build.VERSION_CODES.R; } @Override @@ -174,6 +188,12 @@ 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,14 +203,17 @@ public void destroy() { onViewDestroyed(mContextId); } - // reportAnimation asks native to call onAnimating() when frames start being - // presented to this surface back to back. + // presentsBehindPendingDraw has native post a present made on the main + // thread through postPresentBehindPendingDraw(); reportAnimation asks native + // to call onAnimating() when frames start being presented to this surface + // back to back. @DoNotStrip private native void onSurfaceCreate( Surface surface, int contextId, float width, float height, + boolean presentsBehindPendingDraw, boolean reportAnimation ); @@ -202,6 +225,9 @@ private native void onSurfaceChanged( float height ); + @DoNotStrip + private native void presentPostedFrame(int contextId); + @DoNotStrip private native void switchToOffscreenSurface(int contextId); diff --git a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h index e734e65f6..36234168e 100644 --- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h +++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h @@ -58,6 +58,13 @@ struct Size { // CAMetalLayer on Apple platforms). May run on any thread. using NativeSurfaceReleaser = 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) // ----------------------------- @@ -184,20 +191,24 @@ 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, + NativeSurfacePresentPoster presentPoster = nullptr) { void *replacedSurface = nullptr; NativeSurfaceReleaser replacedReleaser; + NativeSurfacePresentPoster replacedPresentPoster; { std::unique_lock lock(_mutex); if (_hasPendingAttach) { // Replaced before it was ever adopted. replacedSurface = _pendingNativeSurface; replacedReleaser = std::move(_pendingReleaser); + replacedPresentPoster = std::move(_pendingPresentPoster); } _hasPendingAttach = true; _pendingNativeSurface = nativeSurface; _pendingSurface = std::move(surface); _pendingReleaser = std::move(releaser); + _pendingPresentPoster = std::move(presentPoster); } if (replacedReleaser && replacedSurface) { replacedReleaser(replacedSurface); @@ -241,6 +252,7 @@ class SurfaceInfo { wgpu::Device device = nullptr; void *replacedSurface = nullptr; NativeSurfaceReleaser replacedReleaser; + NativeSurfacePresentPoster replacedPresentPoster; { std::unique_lock lock(_mutex); if (supersedeInFlightFrame) { @@ -255,6 +267,10 @@ class SurfaceInfo { // can be null if surface creation failed). replacedSurface = _nativeSurface; replacedReleaser = std::move(_releaser); + replacedPresentPoster = std::move(_presentPoster); + // A frame waiting for its posted present belongs to the replaced + // surface. + _presentPending = false; _surface = std::move(_pendingSurface); #if defined(__ANDROID__) // DAWN_WORKAROUND_DEVICE_DESTROY_BEFORE_SURFACE_RELEASE @@ -262,6 +278,7 @@ class SurfaceInfo { #endif _nativeSurface = _pendingNativeSurface; _releaser = std::move(_pendingReleaser); + _presentPoster = std::move(_pendingPresentPoster); _hasPendingAttach = false; _pendingNativeSurface = nullptr; _frameEpoch++; @@ -319,6 +336,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 @@ -355,6 +373,7 @@ class SurfaceInfo { if (_config.device == nullptr) { return; } + presentPendingFrameLocked(); _config.width = std::max(1, newWidth); _config.height = std::max(1, newHeight); _texture = nullptr; @@ -364,6 +383,7 @@ class SurfaceInfo { void unconfigure() { std::unique_lock lock(_mutex); + presentPendingFrameLocked(); if (_surface) { _surface.Unconfigure(); } @@ -490,6 +510,7 @@ class SurfaceInfo { "[WebGPU] getCurrentTexture() called on a canvas context that is " "not configured; call context.configure() first"); } + presentPendingFrameLocked(); _frameInFlight = true; _acquiredFromSurface = false; _frameEpoch++; @@ -525,10 +546,12 @@ 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. 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,21 +572,33 @@ class SurfaceInfo { dawn::native::metal::WaitForCommandsToBeScheduled(device.Get()); } #endif + NativeSurfacePresentPoster presentPoster; #if defined(__ANDROID__) std::function framePresented; #endif { std::unique_lock lock(_mutex); if (_surface && _acquiredFromSurface) { - _surface.Present(); + if (presentsBehindPendingDrawLocked()) { + _presentPending = true; + if (!_presentPosted) { + _presentPosted = true; + presentPoster = _presentPoster; + } + } else { + _surface.Present(); #if defined(__ANDROID__) - framePresented = _framePresented; + framePresented = _framePresented; #endif + } } _acquiredFromSurface = false; _frameInFlight = false; _frameEpoch++; } + if (presentPoster && !presentPoster()) { + presentPostedFrame(); + } #if defined(__ANDROID__) if (framePresented) { framePresented(); @@ -572,6 +607,15 @@ class SurfaceInfo { 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. @@ -718,6 +762,8 @@ class SurfaceInfo { void detach(bool createFallbackTexture) { void *releasedSurfaces[2] = {nullptr, nullptr}; NativeSurfaceReleaser releasers[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; @@ -761,6 +807,7 @@ class SurfaceInfo { _pendingSurface = nullptr; releasedSurfaces[0] = _pendingNativeSurface; releasers[0] = std::move(_pendingReleaser); + releasedPresentPosters[0] = std::move(_pendingPresentPoster); _pendingNativeSurface = nullptr; } if (_surface) { @@ -773,14 +820,17 @@ class SurfaceInfo { _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; } _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. @@ -793,6 +843,29 @@ class SurfaceInfo { // 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; + _surface.Present(); + } + } + wgpu::Texture createOffscreenTextureLocked() { wgpu::TextureDescriptor descriptor; // Union with the user's usage so offscreen frames stay compatible with @@ -916,6 +989,7 @@ class SurfaceInfo { _surfaceDevice = nullptr; // A frame acquired from the old surface can no longer be presented. _acquiredFromSurface = false; + _presentPending = false; _frameEpoch++; if (_nativeSurface) { wgpu::SurfaceSourceAndroidNativeWindow source; @@ -1229,9 +1303,16 @@ class SurfaceInfo { void *_pendingNativeSurface = nullptr; wgpu::Surface _pendingSurface = nullptr; NativeSurfaceReleaser _pendingReleaser; + 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; // 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 +1403,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, + NativeSurfacePresentPoster presentPoster = 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(presentPoster)); return info; } diff --git a/packages/webgpu/src/__tests__/TextureViewPresent.spec.ts b/packages/webgpu/src/__tests__/TextureViewPresent.spec.ts new file mode 100644 index 000000000..c7cae3185 --- /dev/null +++ b/packages/webgpu/src/__tests__/TextureViewPresent.spec.ts @@ -0,0 +1,79 @@ +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; this checks that the window shows the last +// frame presented, also after a frame acquired while the previous one still +// waited for its present. + +type RGBA = [number, number, number, number]; + +const LIME: RGBA = [0, 255, 0, 255]; + +const expectColor = (actual: number[], expected: RGBA, tolerance = 3) => { + expected.forEach((channel, i) => { + expect(Math.abs(actual[i] - channel)).toBeLessThanOrEqual(tolerance); + }); +}; + +describe("TextureView canvas", () => { + it("shows the last frame a worklet presents on the UI thread", async () => { + if (client.OS !== "android") { + return; + } + const result = await client.eval( + ({ device, views, workletPresentFrames }) => { + const context = views?.textureCanvas()?.getContext("webgpu"); + const source = views?.textureCanvasView; + if (!views || !context || !source || !workletPresentFrames) { + 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], + ]; + return workletPresentFrames(device, context, colors) + .then(() => + 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; + }); + }, + ); + expectColor(result, LIME); + }); +}); diff --git a/packages/webgpu/src/__tests__/setup.ts b/packages/webgpu/src/__tests__/setup.ts index 25d7552b7..a157a3ff3 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,26 @@ 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, see + // apps/example/src/components/workletPresentFrames.ts. + workletPresentFrames?: ( + device: GPUDevice, + context: RNCanvasContext, + colors: GPUColor[], + ) => 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; From 4bf38768d5baf9cd60b9705e041e7a65d8b9290a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 17:12:05 +0300 Subject: [PATCH 2/6] =?UTF-8?q?fix(=F0=9F=A4=96):=20stop=20a=20TextureView?= =?UTF-8?q?=20canvas=20from=20freezing=20the=20UI=20thread=20on=20Android?= =?UTF-8?q?=2011?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android 11's Vulkan loader gives a TextureView's queue a 4.29 s dequeue timeout where it means none, and a canvas presenting on the UI thread then waits there for a buffer only that thread's next draw gives back. On Android 11 a TextureView canvas acquires only once its window has drawn the frame presented last, rendering into the offscreen texture meanwhile, so a frame presented on the UI thread waits behind the window's pending draw there too. The TextureViewPresent spec runs on the UI thread as well. --- apps/docs/content/api/canvas.mdx | 8 +- apps/example/src/Tests.tsx | 4 +- apps/example/src/components/presentFrames.ts | 81 ++++++++++ .../src/components/workletPresentFrames.ts | 51 ------- packages/webgpu/android/cpp/cpp-adapter.cpp | 21 ++- .../src/main/java/com/webgpu/WebGPUAPI.java | 3 + .../java/com/webgpu/WebGPUTextureView.java | 2 +- .../src/main/java/com/webgpu/WebGPUView.java | 46 ++++-- packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h | 128 +++++++++++++--- .../src/__tests__/TextureViewPresent.spec.ts | 138 ++++++++++-------- packages/webgpu/src/__tests__/setup.ts | 9 +- 11 files changed, 338 insertions(+), 153 deletions(-) create mode 100644 apps/example/src/components/presentFrames.ts delete mode 100644 apps/example/src/components/workletPresentFrames.ts diff --git a/apps/docs/content/api/canvas.mdx b/apps/docs/content/api/canvas.mdx index 24087918c..c9b6e2f66 100644 --- a/apps/docs/content/api/canvas.mdx +++ b/apps/docs/content/api/canvas.mdx @@ -206,8 +206,10 @@ 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. 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, except on Android 11. -It works on every supported Android version. +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 @@ -222,7 +224,7 @@ Requesting it on a device below API 29 silently uses `TextureView`. | --- | --- | --- | --- | | Minimum Android | any | any | 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"` | diff --git a/apps/example/src/Tests.tsx b/apps/example/src/Tests.tsx index 33717b57f..7c77c1dc4 100644 --- a/apps/example/src/Tests.tsx +++ b/apps/example/src/Tests.tsx @@ -27,7 +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 { workletPresentFrames } from "./components/workletPresentFrames"; +import { presentFrames } from "./components/presentFrames"; import { workletSerialization } from "./components/workletSerialization"; export const CI = process.env.CI === "true"; @@ -144,7 +144,7 @@ export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => { textureCanvasSize: TEXTURE_CANVAS_SIZE, }, workletDeviceStress, - workletPresentFrames, + presentFrames, workletSerialization, exportedFlags: { GPUBufferUsage: exportedBufferUsage, diff --git a/apps/example/src/components/presentFrames.ts b/apps/example/src/components/presentFrames.ts new file mode 100644 index 000000000..6b9affa2c --- /dev/null +++ b/apps/example/src/components/presentFrames.ts @@ -0,0 +1,81 @@ +import type { RNCanvasContext } from "react-native-webgpu"; +import { scheduleOnRN, scheduleOnUI } from "react-native-worklets"; + +// Test-harness helper (see packages/webgpu TextureViewPresent.spec.ts): clears +// the canvas to each color in turn, the first two in the same animation frame +// and the rest one per frame, and calls done two frames after the last +// present, once the window has drawn it, with the longest time between two of +// those frames. This must live in app code: the "worklet" directive only works +// when the babel worklets plugin compiles it into the app bundle. +const presentColors = ( + device: GPUDevice, + context: RNCanvasContext, + colors: GPUColor[], + done: (longestFrameGapMs: number) => 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/apps/example/src/components/workletPresentFrames.ts b/apps/example/src/components/workletPresentFrames.ts deleted file mode 100644 index de9988386..000000000 --- a/apps/example/src/components/workletPresentFrames.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { RNCanvasContext } from "react-native-webgpu"; -import { scheduleOnRN, scheduleOnUI } from "react-native-worklets"; - -// Test-harness helper (see packages/webgpu TextureViewPresent.spec.ts): clears -// the canvas to each color in turn from Reanimated's UI thread, the first two -// in the same animation frame and the rest one per frame, and resolves two -// frames after the last present, once the window has drawn it. This must live -// in app code: the "worklet" directive only works when the babel worklets -// plugin compiles it into the app bundle. -export const workletPresentFrames = ( - device: GPUDevice, - context: RNCanvasContext, - colors: GPUColor[], -): Promise => - new Promise((resolve) => { - scheduleOnUI(() => { - "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; - const step = () => { - if (next === 0) { - present(colors[next++]); - } - if (next < colors.length) { - present(colors[next++]); - requestAnimationFrame(step); - return; - } - requestAnimationFrame(() => - requestAnimationFrame(() => scheduleOnRN(resolve)), - ); - }; - requestAnimationFrame(step); - }); - }); diff --git a/packages/webgpu/android/cpp/cpp-adapter.cpp b/packages/webgpu/android/cpp/cpp-adapter.cpp index fb9378303..f2ac19cfa 100644 --- a/packages/webgpu/android/cpp/cpp-adapter.cpp +++ b/packages/webgpu/android/cpp/cpp-adapter.cpp @@ -290,7 +290,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 presentsBehindPendingDraw, - jboolean reportAnimation) { + jboolean acquiresAfterDraw, jboolean reportAnimation) { if (manager == nullptr) { return; } @@ -318,7 +318,7 @@ extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onSurfaceCreate( [](void *nativeSurface) { ANativeWindow_release(static_cast(nativeSurface)); }, - std::move(presentPoster)); + std::move(presentPoster), acquiresAfterDraw); // A SurfaceView asks to hear when it animates, for its refresh rate vote // (WebGPUView.onAnimating). if (reportAnimation) { @@ -345,6 +345,23 @@ extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_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) { 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..b9c673458 100644 --- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAPI.java +++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAPI.java @@ -23,5 +23,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/WebGPUTextureView.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUTextureView.java index e84f73806..91f121d5c 100644 --- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUTextureView.java +++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUTextureView.java @@ -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 8f336d8fd..3663c83e3 100644 --- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java +++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java @@ -148,15 +148,15 @@ public void surfaceCreated(Surface surface) { if (votesFrameRate) { mFrameRateVote.attach(surface); } - onSurfaceCreate(surface, mContextId, width, height, presentsBehindPendingDraw(), votesFrameRate); + onSurfaceCreate(surface, mContextId, width, height, presentsBehindPendingDraw(), + acquiresAfterDraw(), votesFrameRate); } - // A TextureView shows a new frame only when its window draws. Android 11's - // Vulkan loader gives a TextureView's buffer queue a 4.3 s dequeue timeout, - // and a present held behind the draw there stretches those waits into - // freezes long enough for an ANR. + // A TextureView shows a new frame only when its window draws. On Android 11 + // the frame is acquired only after that draw (acquiresAfterDraw), which keeps + // the held present clear of the loader's 4.3 s dequeue timeout. private boolean presentsBehindPendingDraw() { - return mAppliedKind == KIND_TEXTURE_VIEW && Build.VERSION.SDK_INT != Build.VERSION_CODES.R; + return mAppliedKind == KIND_TEXTURE_VIEW; } @Override @@ -183,6 +183,26 @@ void onAnimating() { mFrameRateVote.onAnimating(); } + @Override + public void surfaceUpdated() { + if (!acquiresAfterDraw()) { + 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)); + } + + // 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: a frame is acquired only once the window + // has drawn the one presented last. + private boolean acquiresAfterDraw() { + return mAppliedKind == KIND_TEXTURE_VIEW && Build.VERSION.SDK_INT == Build.VERSION_CODES.R; + } + @Override public int getContextId() { return mContextId; @@ -204,9 +224,10 @@ public void destroy() { } // presentsBehindPendingDraw has native post a present made on the main - // thread through postPresentBehindPendingDraw(); reportAnimation asks native - // to call onAnimating() when frames start being presented to this surface - // back to back. + // 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 private native void onSurfaceCreate( Surface surface, @@ -214,6 +235,7 @@ private native void onSurfaceCreate( float width, float height, boolean presentsBehindPendingDraw, + boolean acquiresAfterDraw, boolean reportAnimation ); @@ -228,6 +250,12 @@ private native void onSurfaceChanged( @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/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h index 36234168e..89fd2772b 100644 --- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h +++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h @@ -152,6 +152,12 @@ struct AHBPool { // 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 whose buffer queue makes an acquire wait until its window has +// drawn the frame presented last (an Android 11 TextureView, see +// WebGPUView.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) @@ -192,7 +198,8 @@ class SurfaceInfo { // currently rendering also pick it up. void attachSurface(void *nativeSurface, wgpu::Surface surface, NativeSurfaceReleaser releaser, - NativeSurfacePresentPoster presentPoster = nullptr) { + NativeSurfacePresentPoster presentPoster = nullptr, + bool acquiresAfterDraw = false) { void *replacedSurface = nullptr; NativeSurfaceReleaser replacedReleaser; NativeSurfacePresentPoster replacedPresentPoster; @@ -209,6 +216,7 @@ class SurfaceInfo { _pendingSurface = std::move(surface); _pendingReleaser = std::move(releaser); _pendingPresentPoster = std::move(presentPoster); + _pendingAcquiresAfterDraw = acquiresAfterDraw; } if (replacedReleaser && replacedSurface) { replacedReleaser(replacedSurface); @@ -225,6 +233,22 @@ class SurfaceInfo { // being destroyed and nothing will consume further frames. void detachSurface() { detach(/* createFallbackTexture = */ false); } + // 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; + } + + // 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 // GPUCanvasContext::getCurrentTexture. @@ -268,9 +292,10 @@ class SurfaceInfo { replacedSurface = _nativeSurface; replacedReleaser = std::move(_releaser); replacedPresentPoster = std::move(_presentPoster); - // A frame waiting for its posted present belongs to the replaced - // surface. + // 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 @@ -279,6 +304,7 @@ class SurfaceInfo { _nativeSurface = _pendingNativeSurface; _releaser = std::move(_pendingReleaser); _presentPoster = std::move(_pendingPresentPoster); + _acquiresAfterDraw = _pendingAcquiresAfterDraw; _hasPendingAttach = false; _pendingNativeSurface = nullptr; _frameEpoch++; @@ -324,7 +350,7 @@ class SurfaceInfo { // (their newer content stands; presenting here would be a Dawn // present-without-acquire error). if (_surface && !_frameInFlight && _frameEpoch == blitEpoch) { - _surface.Present(); + presentSurfaceLocked(); } } } @@ -348,7 +374,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__) { @@ -376,7 +402,7 @@ class SurfaceInfo { presentPendingFrameLocked(); _config.width = std::max(1, newWidth); _config.height = std::max(1, newHeight); - _texture = nullptr; + dropOffscreenTextureLocked(); _frameEpoch++; _configureLocked(); } @@ -387,7 +413,7 @@ class SurfaceInfo { if (_surface) { _surface.Unconfigure(); } - _texture = nullptr; + dropOffscreenTextureLocked(); _config = {}; _viewFormats.clear(); _acquiredFromSurface = false; @@ -513,11 +539,17 @@ class SurfaceInfo { 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; @@ -549,7 +581,8 @@ class SurfaceInfo { // 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. This is also the + // 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() { @@ -586,11 +619,14 @@ class SurfaceInfo { presentPoster = _presentPoster; } } else { - _surface.Present(); + presentSurfaceLocked(); #if defined(__ANDROID__) framePresented = _framePresented; #endif } + } else if (_deferredFrame == DeferredFrame::Rendering) { + _deferredFrame = DeferredFrame::Waiting; + deliverDeferredFrameLocked(); } _acquiredFromSurface = false; _frameInFlight = false; @@ -824,6 +860,8 @@ class SurfaceInfo { // 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 @@ -862,10 +900,40 @@ class SurfaceInfo { void presentPendingFrameLocked() { if (_presentPending) { _presentPending = false; - _surface.Present(); + 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 @@ -915,8 +983,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); @@ -955,9 +1024,7 @@ class SurfaceInfo { } } - // 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 @@ -972,7 +1039,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; @@ -987,9 +1061,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; @@ -1313,6 +1389,17 @@ class SurfaceInfo { 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 @@ -1407,11 +1494,12 @@ class SurfaceRegistry { attachSurface(int id, wgpu::Instance gpu, int width, int height, void *nativeSurface, wgpu::Surface surface, NativeSurfaceReleaser releaser, - NativeSurfacePresentPoster presentPoster = 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), - std::move(presentPoster)); + std::move(presentPoster), acquiresAfterDraw); return info; } diff --git a/packages/webgpu/src/__tests__/TextureViewPresent.spec.ts b/packages/webgpu/src/__tests__/TextureViewPresent.spec.ts index c7cae3185..cac5b98dd 100644 --- a/packages/webgpu/src/__tests__/TextureViewPresent.spec.ts +++ b/packages/webgpu/src/__tests__/TextureViewPresent.spec.ts @@ -2,14 +2,20 @@ 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; this checks that the window shows the last -// frame presented, also after a frame acquired while the previous one still -// waited for its present. +// 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); @@ -17,63 +23,73 @@ const expectColor = (actual: number[], expected: RGBA, tolerance = 3) => { }; describe("TextureView canvas", () => { - it("shows the last frame a worklet presents on the UI thread", async () => { - if (client.OS !== "android") { - return; - } - const result = await client.eval( - ({ device, views, workletPresentFrames }) => { - const context = views?.textureCanvas()?.getContext("webgpu"); - const source = views?.textureCanvasView; - if (!views || !context || !source || !workletPresentFrames) { - 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], - ]; - return workletPresentFrames(device, context, colors) - .then(() => - 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; + 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", }); - }, - ); - expectColor(result, LIME); - }); + 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 a157a3ff3..089209530 100644 --- a/packages/webgpu/src/__tests__/setup.ts +++ b/packages/webgpu/src/__tests__/setup.ts @@ -81,13 +81,14 @@ interface GPUTestingContext { GPUTextureUsage: typeof GPUTextureUsage; }; // Native app harness only: clears a canvas to each color in turn from - // Reanimated's UI thread, see - // apps/example/src/components/workletPresentFrames.ts. - workletPresentFrames?: ( + // 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[], - ) => Promise; + 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), and for the TextureView present spec a canvas From 8017549315bb100e694571f1a75252a70b586038 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 21:44:59 +0300 Subject: [PATCH 3/6] =?UTF-8?q?fix(=F0=9F=A4=96):=20open=20a=20SurfaceView?= =?UTF-8?q?=20canvas's=20hole=20with=20its=20first=20frame?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An opaque canvas is a SurfaceView behind its window, and the window opens its hole once the SurfaceView reports its draw finished. WebGPUSurfaceView reported it as soon as surfaceChanged returned, so the hole opened on a surface with no frame and showed black until the first one landed. The view now holds the report until a frame is presented into the surface, as GLSurfaceView holds it until its next frame. attachSurface takes a callback for the surface's first present, and each surface carries a token, so a report for a replaced surface is ignored. Before Android 14 the wait is bounded at a second; from Android 14 the platform bounds it. A report that goes out without a frame, at that second or for a later surface of the view, which SurfaceView counts as reported already, no longer opens the hole: the view punches it, and adds it to the window's transparent region, only while its surface has a frame. A context configured before its surface exists, or left by a lost one, no longer presents an empty frame when the next surface attaches, and detach is one operation for a lost surface and a destroyed view. The canvas docs say when the hole opens, the example gains a SurfaceView First Frame diagnostic that can also draw its first frame late, FirstFrameDrawGate is unit tested, and CI runs the Android library's unit tests. --- .github/workflows/ci.yml | 6 + apps/docs/content/api/canvas.mdx | 10 +- apps/example/src/App.tsx | 2 + .../src/Diagnostics/DiagnosticsList.tsx | 4 + .../src/Diagnostics/FirstFrameHole.tsx | 134 ++++++++++ apps/example/src/Route.ts | 1 + packages/webgpu/android/build.gradle | 1 + packages/webgpu/android/cpp/cpp-adapter.cpp | 43 +++- .../java/com/webgpu/FirstFrameDrawGate.java | 139 ++++++++++ .../src/main/java/com/webgpu/WebGPUAPI.java | 9 +- .../java/com/webgpu/WebGPUSurfaceView.java | 72 +++++- .../com/webgpu/WebGPUSurfaceViewWithSC.java | 2 +- .../java/com/webgpu/WebGPUTextureView.java | 2 +- .../src/main/java/com/webgpu/WebGPUView.java | 20 +- .../com/webgpu/FirstFrameDrawGateTest.java | 242 ++++++++++++++++++ packages/webgpu/apple/MetalView.mm | 2 +- packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h | 99 ++++--- 17 files changed, 722 insertions(+), 66 deletions(-) create mode 100644 apps/example/src/Diagnostics/FirstFrameHole.tsx create mode 100644 packages/webgpu/android/src/main/java/com/webgpu/FirstFrameDrawGate.java create mode 100644 packages/webgpu/android/src/test/java/com/webgpu/FirstFrameDrawGateTest.java 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..cd3e4221e 100644 --- a/apps/docs/content/api/canvas.mdx +++ b/apps/docs/content/api/canvas.mdx @@ -194,10 +194,12 @@ 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 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..958b0ab0e --- /dev/null +++ b/apps/example/src/Diagnostics/FirstFrameHole.tsx @@ -0,0 +1,134 @@ +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); + + return ( + + + + An opaque canvas, a SurfaceView on Android, 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, and the stage + should show until then: black means the window showed the surface + before it had a frame. + + +