diff --git a/packages/webgpu/apple/MetalLayerColorSpace.mm b/packages/webgpu/apple/MetalLayerColorSpace.mm index 1a918ee3e..e8f963f44 100644 --- a/packages/webgpu/apple/MetalLayerColorSpace.mm +++ b/packages/webgpu/apple/MetalLayerColorSpace.mm @@ -1,7 +1,6 @@ #import #import #import -#import #include "webgpu/webgpu_cpp.h" @@ -31,14 +30,6 @@ void applyCAMetalLayerColorSpace(void *nativeSurface, // 8-bit format. metalLayer.colorspace = nil; } - // The change must be set synchronously so the first present already sees - // it, and it must reach the render server. On a non-main thread (RN JS or - // worklet runtime) the property lands in that thread's implicit - // CATransaction, which may never commit on threads without a spinning - // runloop, so flush it now. On the main thread the runloop commits it. - if (!NSThread.isMainThread) { - [CATransaction flush]; - } } } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp index 871e31196..1f1d4f3a3 100644 --- a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp +++ b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp @@ -239,11 +239,19 @@ void RNWebGPUManager::installWebGPUWorkletHelpers(jsi::Runtime &runtime) { void RNWebGPUManager::flushPendingSurfaceTransition( std::shared_ptr info) { - if (info == nullptr || _jsCallInvoker == nullptr) { + if (info == nullptr) { + return; + } +#ifdef __APPLE__ + RunOnMainThreadAsync( + [info = std::move(info)] { info->applyPendingAttach(); }); +#else + if (_jsCallInvoker == nullptr) { return; } _jsCallInvoker->invokeAsync( [info = std::move(info)] { info->applyPendingAttach(); }); +#endif } RNWebGPUManager::~RNWebGPUManager() { diff --git a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.h b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.h index cd2ea85a8..504de1576 100644 --- a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.h +++ b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.h @@ -36,10 +36,9 @@ class RNWebGPUManager { /** * Applies a surface attach latched by the platform UI thread (see - * SurfaceInfo::applyPendingAttach) from the JS thread. Surface attaches are - * normally adopted at the next frame boundary by whichever thread renders; - * this flush covers contexts that are not actively rendering (static - * content), so the last offscreen frame still makes it on screen. + * SurfaceInfo::applyPendingAttach). Apple runs the transition on main; + * other platforms use the JS CallInvoker. This covers contexts that are not + * actively rendering, so the last offscreen frame still reaches the screen. */ void flushPendingSurfaceTransition(std::shared_ptr info); diff --git a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h index 8dad9d36f..3d950ae0b 100644 --- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h +++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -13,6 +14,8 @@ #include "webgpu/webgpu_cpp.h" #ifdef __APPLE__ +#include +#include namespace dawn::native::metal { void WaitForCommandsToBeScheduled(WGPUDevice device); } // namespace dawn::native::metal @@ -21,6 +24,53 @@ void WaitForCommandsToBeScheduled(WGPUDevice device); namespace rnwgpu { #ifdef __APPLE__ +namespace detail { +inline void InvokeMainThreadFunction(void *context) { + (*static_cast *>(context))(); +} +} // namespace detail + +// Surface transitions are rare and synchronous WebGPU calls must observe the +// configured surface before they return. Dispatch before taking SurfaceInfo's +// mutex so the main thread can never wait on a lock held by the caller. +inline void RunOnMainThreadSync(std::function function) { + if (pthread_main_np() != 0) { + function(); + return; + } + + std::exception_ptr error; + std::function guarded = [&]() { + try { + function(); + } catch (...) { + error = std::current_exception(); + } + }; + dispatch_sync_f(dispatch_get_main_queue(), &guarded, + detail::InvokeMainThreadFunction); + if (error) { + std::rethrow_exception(error); + } +} + +inline void RunOnMainThreadAsync(std::function function) { + if (!function) { + return; + } + if (pthread_main_np() != 0) { + function(); + return; + } + + auto *ownedFunction = new std::function(std::move(function)); + dispatch_async_f(dispatch_get_main_queue(), ownedFunction, [](void *context) { + std::unique_ptr> function( + static_cast *>(context)); + (*function)(); + }); +} + // Tags the CAMetalLayer with the colorspace matching the configured texture // format. Implemented in apple/MetalLayerColorSpace.mm. void applyCAMetalLayerColorSpace(void *nativeSurface, @@ -58,21 +108,20 @@ using NativeSurfaceReleaser = std::function; // destruction alone (backgrounding, TextureView teardown) never removes an // entry; it only detaches the surface. // - Attaching a surface is LATCHED: the UI thread stores it as pending -// (attachSurface) and it is adopted at the next frame boundary — start of -// getCurrentTexture or end of presentFrame — on whichever thread renders -// (main JS, Reanimated UI, or a worklet runtime). This preserves Dawn -// surface thread-affinity and guarantees a surface is never swapped in the -// 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. +// (attachSurface) and it is adopted at a frame boundary, never in the middle +// of a frame. Apple adopts and configures on the main thread because Dawn +// mutates CAMetalLayer there. Other platforms adopt on the rendering +// runtime. RNWebGPUManager::flushPendingSurfaceTransition also covers +// contexts that are not actively rendering. +// - Detaching (switchToOffscreen) is IMMEDIATE from the context's perspective. +// A configured context falls back to an offscreen texture, so a running +// render loop keeps working. If a frame already owns a surface texture, the +// old surface and native window stay retained until present() or the next +// frame boundary; destroying the swapchain earlier invalidates the texture +// before queue.submit(). 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. class SurfaceInfo { public: SurfaceInfo(wgpu::Instance gpu, int width, int height) @@ -82,6 +131,7 @@ class SurfaceInfo { // Drop the Dawn objects before releasing the native surfaces they borrow. _surface = nullptr; _pendingSurface = nullptr; + _retiredSurface = nullptr; _texture = nullptr; if (_pendingReleaser && _pendingNativeSurface) { _pendingReleaser(_pendingNativeSurface); @@ -89,6 +139,9 @@ class SurfaceInfo { if (_releaser && _nativeSurface) { _releaser(_nativeSurface); } + if (_retiredReleaser && _retiredNativeSurface) { + _retiredReleaser(_retiredNativeSurface); + } } // --- Platform UI thread --------------------------------------------------- @@ -150,6 +203,22 @@ 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) { +#ifdef __APPLE__ + if (pthread_main_np() == 0) { + { + std::unique_lock lock(_mutex); + if (supersedeInFlightFrame) { + _frameInFlight = false; + _acquiredFromSurface = false; + } + if (!_hasPendingAttach) { + return; + } + } + RunOnMainThreadSync([this]() { applyPendingAttach(); }); + return; + } +#endif bool presentBlit = false; uint64_t blitEpoch = 0; wgpu::Device device = nullptr; @@ -227,6 +296,17 @@ class SurfaceInfo { void configure(wgpu::SurfaceConfiguration &newConfig, std::vector viewFormats) { + releaseRetiredSurface(); +#ifdef __APPLE__ + if (pthread_main_np() == 0) { + auto config = newConfig; + RunOnMainThreadSync( + [this, config, viewFormats = std::move(viewFormats)]() mutable { + configure(config, std::move(viewFormats)); + }); + return; + } +#endif applyPendingAttach(/* supersedeInFlightFrame = */ true); std::unique_lock lock(_mutex); _viewFormats = std::move(viewFormats); @@ -247,6 +327,13 @@ class SurfaceInfo { // Resize the drawing buffer (canvas.width/height changed). void reconfigure(int newWidth, int newHeight) { +#ifdef __APPLE__ + if (pthread_main_np() == 0) { + RunOnMainThreadSync( + [this, newWidth, newHeight]() { reconfigure(newWidth, newHeight); }); + return; + } +#endif std::unique_lock lock(_mutex); if (_config.device == nullptr) { return; @@ -259,6 +346,13 @@ class SurfaceInfo { } void unconfigure() { + releaseRetiredSurface(); +#ifdef __APPLE__ + if (pthread_main_np() == 0) { + RunOnMainThreadSync([this]() { unconfigure(); }); + return; + } +#endif std::unique_lock lock(_mutex); if (_surface) { _surface.Unconfigure(); @@ -281,7 +375,8 @@ class SurfaceInfo { // otherwise (see RNWebGPU::destroyContext). bool hasNativeSurface() { std::shared_lock lock(_mutex); - return _nativeSurface != nullptr || _hasPendingAttach; + return _nativeSurface != nullptr || _hasPendingAttach || + _retiredNativeSurface != nullptr; } // Returns the texture for the current frame: the surface's swapchain texture @@ -290,30 +385,58 @@ class SurfaceInfo { wgpu::Texture getCurrentTexture() { // Start-of-frame boundary; a new acquire supersedes any previous frame // that never presented. + releaseRetiredSurface(); applyPendingAttach(/* supersedeInFlightFrame = */ true); - std::unique_lock lock(_mutex); - if (_config.device == nullptr) { - throw std::runtime_error( - "[WebGPU] getCurrentTexture() called on a canvas context that is " - "not configured; call context.configure() first"); - } - _frameInFlight = true; - _acquiredFromSurface = false; - _frameEpoch++; - if (_surface) { - auto texture = acquireSurfaceTextureLocked(); - if (texture) { - _acquiredFromSurface = true; - return texture; + for (int attempt = 0; attempt < 2; attempt++) { +#ifdef __APPLE__ + uint64_t recoveryEpoch = 0; +#endif + { + std::unique_lock lock(_mutex); + if (_config.device == nullptr) { + throw std::runtime_error( + "[WebGPU] getCurrentTexture() called on a canvas context that " + "is not configured; call context.configure() first"); + } + _frameInFlight = true; + _acquiredFromSurface = false; + _frameEpoch++; + if (_surface) { + auto acquired = acquireSurfaceTextureLocked(); + if (acquired.texture) { + _acquiredFromSurface = true; + return acquired.texture; + } +#ifdef __APPLE__ + if (acquired.needsReconfigure && attempt == 0) { + // Release the surface lock before synchronously dispatching to + // main. The epoch prevents a concurrent frame or lifecycle change + // from being overwritten by the recovery configure. + _frameInFlight = false; + _frameEpoch++; + recoveryEpoch = _frameEpoch; + } else +#endif + { + // The surface is transiently unusable: render offscreen and drop + // this frame so the render loop survives the lifecycle change. + if (!_texture) { + _texture = createOffscreenTextureLocked(); + } + return _texture; + } + } else { + if (!_texture) { + _texture = createOffscreenTextureLocked(); + } + return _texture; + } } - // The surface is transiently unusable (e.g. mid-resize, lost while - // backgrounding): fall back to an offscreen texture so the render loop - // survives; this frame is simply not presented. - } - if (!_texture) { - _texture = createOffscreenTextureLocked(); +#ifdef __APPLE__ + reconfigureAfterAcquireFailure(recoveryEpoch); +#endif } - return _texture; + throw std::runtime_error("[WebGPU] surface recovery failed"); } // Present the current frame. Runs synchronously on the thread that did @@ -335,6 +458,8 @@ class SurfaceInfo { dawn::native::metal::WaitForCommandsToBeScheduled(device.Get()); } #endif + void *retiredNativeSurface = nullptr; + NativeSurfaceReleaser retiredReleaser; { std::unique_lock lock(_mutex); if (_surface && _acquiredFromSurface) { @@ -343,6 +468,10 @@ class SurfaceInfo { _acquiredFromSurface = false; _frameInFlight = false; _frameEpoch++; + takeRetiredSurfaceLocked(retiredNativeSurface, retiredReleaser); + } + if (retiredReleaser && retiredNativeSurface) { + retiredReleaser(retiredNativeSurface); } applyPendingAttach(); } @@ -365,9 +494,48 @@ class SurfaceInfo { } private: +#ifdef __APPLE__ + void reconfigureAfterAcquireFailure(uint64_t expectedEpoch) { + RunOnMainThreadSync([this, expectedEpoch]() { + std::unique_lock lock(_mutex); + if (!_surface || _config.device == nullptr || _frameInFlight || + _frameEpoch != expectedEpoch) { + return; + } + _texture = nullptr; + _frameEpoch++; + _configureLocked(); + }); + } +#endif + + // A native detach can race the synchronous JS frame sequence between + // getCurrentTexture() and queue.submit(). Keep that frame's Dawn surface and + // native window alive until submit has completed and present() reaches the + // next frame boundary. A second acquire also supersedes and releases it. + void releaseRetiredSurface() { + void *retiredNativeSurface = nullptr; + NativeSurfaceReleaser retiredReleaser; + { + std::unique_lock lock(_mutex); + takeRetiredSurfaceLocked(retiredNativeSurface, retiredReleaser); + } + if (retiredReleaser && retiredNativeSurface) { + retiredReleaser(retiredNativeSurface); + } + } + + void takeRetiredSurfaceLocked(void *&nativeSurface, + NativeSurfaceReleaser &releaser) { + _retiredSurface = nullptr; + nativeSurface = _retiredNativeSurface; + releaser = std::move(_retiredReleaser); + _retiredNativeSurface = nullptr; + } + void detach(bool createFallbackTexture) { - void *releasedSurfaces[2] = {nullptr, nullptr}; - NativeSurfaceReleaser releasers[2]; + void *releasedSurfaces[3] = {nullptr, nullptr, nullptr}; + NativeSurfaceReleaser releasers[3]; { std::unique_lock lock(_mutex); // The platform is tearing surfaces down; a not-yet-adopted attach is @@ -379,24 +547,40 @@ class SurfaceInfo { releasers[0] = std::move(_pendingReleaser); _pendingNativeSurface = nullptr; } + + // A retired surface can only belong to a frame already superseded by + // the active one being detached now. + takeRetiredSurfaceLocked(releasedSurfaces[1], releasers[1]); + if (_surface) { if (createFallbackTexture && _config.device != nullptr) { _texture = createOffscreenTextureLocked(); } - _surface = nullptr; - // The in-flight frame (if any) rendered into the destroyed surface; - // presentFrame() must not present it. + if (_frameInFlight && _acquiredFromSurface) { + _retiredSurface = std::move(_surface); + _retiredNativeSurface = _nativeSurface; + _retiredReleaser = std::move(_releaser); + } else { + _surface = nullptr; + releasedSurfaces[2] = _nativeSurface; + releasers[2] = std::move(_releaser); + } + // The detached frame may still submit against _retiredSurface, but it + // must never present into a native view that has gone away. _acquiredFromSurface = false; + } else if (_nativeSurface) { + // Surface creation may fail while the retained native window remains + // valid. It still needs a balanced release on detach. + releasedSurfaces[2] = _nativeSurface; + releasers[2] = std::move(_releaser); } _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); _nativeSurface = nullptr; } // Release outside the lock: the platform may do real work here. - for (int i = 0; i < 2; i++) { + for (int i = 0; i < 3; i++) { if (releasers[i] && releasedSurfaces[i]) { releasers[i](releasedSurfaces[i]); } @@ -424,21 +608,30 @@ class SurfaceInfo { // Acquire the surface's current texture, reconfiguring once when the surface // reports it is stale (rotation, resize, coming back from background). - wgpu::Texture acquireSurfaceTextureLocked() { + struct SurfaceTextureAcquireResult { + wgpu::Texture texture = nullptr; + bool needsReconfigure = false; + }; + + SurfaceTextureAcquireResult acquireSurfaceTextureLocked() { wgpu::SurfaceTexture surfaceTexture; _surface.GetCurrentTexture(&surfaceTexture); if (!isAcquireSuccess(surfaceTexture)) { if (surfaceTexture.status == wgpu::SurfaceGetCurrentTextureStatus::Error) { - return nullptr; + return {}; } +#ifdef __APPLE__ + return {.texture = nullptr, .needsReconfigure = true}; +#else _surface.Configure(&_config); _surface.GetCurrentTexture(&surfaceTexture); if (!isAcquireSuccess(surfaceTexture)) { - return nullptr; + return {}; } +#endif } - return surfaceTexture.texture; + return {.texture = surfaceTexture.texture, .needsReconfigure = false}; } static bool isAcquireSuccess(const wgpu::SurfaceTexture &surfaceTexture) { @@ -494,6 +687,11 @@ class SurfaceInfo { void *_nativeSurface = nullptr; wgpu::Surface _surface = nullptr; NativeSurfaceReleaser _releaser; + // A detached surface retained until the frame that acquired from it has + // submitted, or until a new frame supersedes that unfinished frame. + void *_retiredNativeSurface = nullptr; + wgpu::Surface _retiredSurface = nullptr; + NativeSurfaceReleaser _retiredReleaser; // Offscreen fallback drawing buffer. wgpu::Texture _texture = nullptr; // Surface attached by the UI thread, awaiting adoption at a frame boundary.