From ed9c74315ea8a8362eed898b3c5cd735a580042f Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Sat, 7 Nov 2026 13:31:26 +0000 Subject: [PATCH] =?UTF-8?q?fix(=F0=9F=A4=96):=20present=20TextureView=20fr?= =?UTF-8?q?ames=20rendered=20on=20the=20main=20thread=20after=20the=20Chor?= =?UTF-8?q?eographer=20frame?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A render loop driven from the main thread (Reanimated frame callbacks) presents in the Choreographer frame's animation phase, before its traversal. When a traversal is already scheduled, HWUI latches the new buffer in that frame and the SurfaceTexture frame-available callback does not invalidate the TextureView, so the next vsync has no traversal and its buffer is replaced before it is drawn: a loop presenting every vsync was shown at every other one (60 fps on a 120 Hz screen). For a TextureView's surface, presentFrame() on the main thread now marks the present pending and an eventfd on the main ALooper runs it right after the current frame. Anything that touches the surface first (getCurrentTexture, configure, unconfigure, attach, detach) flushes it, and recreating the surface for a destroyed device drops it. WebGPUView passes the backing kind explicitly; SurfaceViews and presents from other threads are unchanged. --- packages/webgpu/android/cpp/cpp-adapter.cpp | 5 +- .../src/main/java/com/webgpu/WebGPUView.java | 12 +- packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h | 166 +++++++++++++++++- 3 files changed, 177 insertions(+), 6 deletions(-) diff --git a/packages/webgpu/android/cpp/cpp-adapter.cpp b/packages/webgpu/android/cpp/cpp-adapter.cpp index 577bf3b3f..c715f084d 100644 --- a/packages/webgpu/android/cpp/cpp-adapter.cpp +++ b/packages/webgpu/android/cpp/cpp-adapter.cpp @@ -306,7 +306,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, jboolean deferPresent) { if (manager == nullptr) { return; } @@ -327,6 +327,9 @@ extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onSurfaceCreate( surface, [](void *nativeSurface) { ANativeWindow_release(static_cast(nativeSurface)); }); + // A TextureView's surface presents after the main thread's Choreographer + // frame (see MainLooperPresents in SurfaceRegistry.h). + info->setDeferPresent(deferPresent); // A SurfaceView asks to hear when it animates, for its refresh rate vote // (WebGPUView.onAnimating). if (reportAnimation) { 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..aacaf63f6 100644 --- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java +++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java @@ -194,7 +194,11 @@ public void surfaceCreated(Surface surface) { if (votesFrameRate) { mFrameRateVote.attach(surface); } - onSurfaceCreate(surface, mContextId, width, height, votesFrameRate); + // A TextureView shows a frame when its window next draws, so its presents + // wait for the end of the main thread's Choreographer frame (see + // MainLooperPresents in SurfaceRegistry.h). + boolean deferPresent = mKind == BackingViewKind.TEXTURE_VIEW; + onSurfaceCreate(surface, mContextId, width, height, votesFrameRate, deferPresent); } @Override @@ -236,14 +240,16 @@ public void destroy() { } // reportAnimation asks native to call onAnimating() when frames start being - // presented to this surface back to back. + // presented to this surface back to back. deferPresent moves presents made + // on the main thread to just after its current Choreographer frame. @DoNotStrip private native void onSurfaceCreate( Surface surface, int contextId, float width, float height, - boolean reportAnimation + boolean reportAnimation, + boolean deferPresent ); @DoNotStrip diff --git a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h index 70d0fe976..c99b20380 100644 --- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h +++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h @@ -25,7 +25,9 @@ #if defined(__ANDROID__) #include #include +#include #include +#include #include #endif @@ -37,6 +39,85 @@ void WaitForCommandsToBeScheduled(WGPUDevice device); namespace rnwgpu { +#if defined(__ANDROID__) +class SurfaceInfo; + +// Runs the presents that SurfaceInfo::presentFrame() defers for a TextureView +// on the main looper, right after the Choreographer frame that rendered them. +// +// A TextureView shows a new frame when the window next draws, and the window +// draws in the Choreographer frame's traversal. A render loop driven from the +// main thread (Reanimated's frame callbacks) presents in the same frame, but +// before that traversal. When a traversal is already scheduled, it picks the +// new buffer up at once and the frame-available callback never invalidates +// the view, so the next frame has no traversal and its buffer is dropped: a +// loop presenting every vsync showed every other one (60 fps on a 120 Hz +// screen). Presented after the frame instead, every buffer invalidates the +// view and the next traversal shows it, one frame later, at the full rate. +class MainLooperPresents { +public: + static MainLooperPresents &get() { + static MainLooperPresents instance; + return instance; + } + + // Called on the main thread (a view's surface callback) before anything is + // deferred; later calls are no-ops. + void attachToCurrentThread() { + std::lock_guard lock(_mutex); + if (_looper.load() != nullptr) { + return; + } + ALooper *looper = ALooper_forThread(); + if (looper == nullptr) { + return; + } + int fd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); + if (fd < 0) { + return; + } + ALooper_acquire(looper); + if (ALooper_addFd(looper, fd, ALOOPER_POLL_CALLBACK, ALOOPER_EVENT_INPUT, + &MainLooperPresents::onEvent, this) != 1) { + ALooper_release(looper); + close(fd); + return; + } + _fd = fd; + _looper.store(looper); + } + + bool onMainThread() const { + ALooper *looper = _looper.load(); + return looper != nullptr && ALooper_forThread() == looper; + } + + void schedule(SurfaceInfo *info) { + std::lock_guard lock(_mutex); + if (std::find(_pending.begin(), _pending.end(), info) == _pending.end()) { + _pending.push_back(info); + } + uint64_t one = 1; + (void)write(_fd, &one, sizeof(one)); + } + + // A SurfaceInfo being destroyed: blocks while its deferred present runs. + void cancel(SurfaceInfo *info) { + std::lock_guard lock(_mutex); + _pending.erase(std::remove(_pending.begin(), _pending.end(), info), + _pending.end()); + } + +private: + static int onEvent(int fd, int events, void *data); + + std::mutex _mutex; + std::atomic _looper{nullptr}; + int _fd = -1; + std::vector _pending; +}; +#endif + #ifdef __APPLE__ // Tags the CAMetalLayer with the colorspace matching the configured texture // format and the canvas's colorSpace, and opts it into the display's extended @@ -172,6 +253,7 @@ class SurfaceInfo { ~SurfaceInfo() { #if defined(__ANDROID__) + MainLooperPresents::get().cancel(this); // Stop the fence waiter before any pool generation is freed. stopWaiter(); { @@ -270,6 +352,10 @@ class SurfaceInfo { if (!_hasPendingAttach || _frameInFlight) { return; } +#if defined(__ANDROID__) + // The surface is about to be replaced. + presentDeferredLocked(); +#endif // 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). @@ -394,6 +480,9 @@ class SurfaceInfo { void unconfigure() { std::unique_lock lock(_mutex); +#if defined(__ANDROID__) + presentDeferredLocked(); +#endif if (_surface) { if (_releaseSurfaceOnUnconfigure) { // Destroy the surface, and so its swapchain, while their device is @@ -528,6 +617,9 @@ class SurfaceInfo { } #endif std::unique_lock lock(_mutex); +#if defined(__ANDROID__) + presentDeferredLocked(); +#endif if (_config.device == nullptr) { throw std::runtime_error( "[WebGPU] getCurrentTexture() called on a canvas context that is " @@ -594,13 +686,21 @@ class SurfaceInfo { #endif #if defined(__ANDROID__) std::function framePresented; + bool deferred = false; #endif { std::unique_lock lock(_mutex); if (_surface && _acquiredFromSurface) { - _surface.Present(); #if defined(__ANDROID__) - framePresented = _framePresented; + if (_deferPresent && MainLooperPresents::get().onMainThread()) { + _presentPending = true; + deferred = true; + } else { + _surface.Present(); + framePresented = _framePresented; + } +#else + _surface.Present(); #endif } _acquiredFromSurface = false; @@ -608,6 +708,9 @@ class SurfaceInfo { _frameEpoch++; } #if defined(__ANDROID__) + if (deferred) { + MainLooperPresents::get().schedule(this); + } if (framePresented) { framePresented(); } @@ -755,6 +858,26 @@ class SurfaceInfo { std::unique_lock lock(_mutex); _framePresented = std::move(cb); } + + // A TextureView's surface presents after the main thread's Choreographer + // frame rather than inside it; see MainLooperPresents. Call on the main + // thread. + void setDeferPresent(bool defer) { + if (defer) { + MainLooperPresents::get().attachToCurrentThread(); + } + std::unique_lock lock(_mutex); + _deferPresent = defer; + if (!defer) { + presentDeferredLocked(); + } + } + + // The main looper's half of a deferred present. + void presentDeferred() { + std::unique_lock lock(_mutex); + presentDeferredLocked(); + } #endif private: @@ -768,6 +891,7 @@ class SurfaceInfo { { std::unique_lock lock(_mutex); #if defined(__ANDROID__) + presentDeferredLocked(); // Leaving pool mode (the WebGPUNativeBufferView detached): drop the // pool. A configured context keeps rendering into the offscreen // fallback, exactly like the surface path. @@ -853,6 +977,21 @@ class SurfaceInfo { return _config.device.CreateTexture(&descriptor); } +#if defined(__ANDROID__) + // Presents the frame presentFrame() deferred, if any, before anything else + // touches the surface: the next acquire, a configure, or an attach or + // detach that replaces it. + void presentDeferredLocked() { + if (!_presentPending) { + return; + } + _presentPending = false; + if (_surface) { + _surface.Present(); + } + } +#endif + // Acquire the surface's current texture, reconfiguring once when the surface // reports it is stale (rotation, resize, coming back from background). wgpu::Texture acquireSurfaceTextureLocked() { @@ -931,6 +1070,7 @@ class SurfaceInfo { // is a plain wrapper around _surface.Configure. void configureSurfaceLocked(const wgpu::SurfaceConfiguration &config) { #if defined(__ANDROID__) + presentDeferredLocked(); // DAWN_WORKAROUND_DEVICE_DESTROY_BEFORE_SURFACE_RELEASE // Vulkan refuses to switch a surface to another device and keeps the old // swapchain attached, so a device change (device swap, recovery after a @@ -963,6 +1103,10 @@ class SurfaceInfo { #endif // A frame acquired from the old surface can no longer be presented. _acquiredFromSurface = false; +#if defined(__ANDROID__) + // Nor can one presentFrame() deferred: the replacement is unconfigured. + _presentPending = false; +#endif _frameEpoch++; if (_nativeSurface) { _surface = WebGPURuntime::makeSurface(_gpu, _nativeSurface); @@ -1308,6 +1452,9 @@ class SurfaceInfo { // See setFramePresentedCallback(); read under _mutex, called outside it. std::function _framePresented; + // See setDeferPresent() and MainLooperPresents; under _mutex. + bool _deferPresent = false; + bool _presentPending = false; std::atomic _poolMode{false}; std::mutex _poolMutex; @@ -1527,4 +1674,19 @@ class SurfaceRegistry { std::vector> _allSurfaces; }; +#if defined(__ANDROID__) +inline int MainLooperPresents::onEvent(int fd, int /* events */, void *data) { + uint64_t count = 0; + (void)read(fd, &count, sizeof(count)); + auto *self = static_cast(data); + std::lock_guard lock(self->_mutex); + std::vector pending; + pending.swap(self->_pending); + for (auto *info : pending) { + info->presentDeferred(); + } + return 1; // keep the callback registered +} +#endif + } // namespace rnwgpu