(env->NewGlobalRef(localCls));
+ gFrameDriverStart =
+ env->GetStaticMethodID(gFrameDriverClass, "start", "()V");
+ gFrameDriverStop = env->GetStaticMethodID(gFrameDriverClass, "stop", "()V");
+ env->DeleteLocalRef(localCls);
+ }
+ rnwgpu::FrameDriver::getInstance().setPlatformVSync(
+ [] { callFrameDriver(gFrameDriverStart); },
+ [] { callFrameDriver(gFrameDriverStop); });
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_webgpu_WebGPUFrameDriver_nativeOnVSync(JNIEnv * /*env*/,
+ jclass /*clazz*/) {
+ rnwgpu::FrameDriver::getInstance().onVSync();
}
extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onSurfaceChanged(
@@ -66,6 +119,7 @@ Java_com_webgpu_WebGPUView_switchToOffscreenSurface(JNIEnv *env, jobject thiz,
extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onSurfaceDestroy(
JNIEnv *env, jobject thiz, jint contextId) {
+ rnwgpu::FrameDriver::getInstance().cancelPresent(contextId);
auto ®istry = rnwgpu::SurfaceRegistry::getInstance();
registry.removeSurfaceInfo(contextId);
}
\ No newline at end of file
diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUFrameDriver.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUFrameDriver.java
new file mode 100644
index 000000000..03a1d2c29
--- /dev/null
+++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUFrameDriver.java
@@ -0,0 +1,66 @@
+package com.webgpu;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.view.Choreographer;
+
+/**
+ * Drives WebGPU auto-present from the main-thread {@link Choreographer},
+ * replacing the manual {@code context.present()} call.
+ *
+ * {@link #start()} / {@link #stop()} are invoked from native code
+ * (rnwgpu::FrameDriver::setPlatformVSync) on arbitrary threads; both hop to the
+ * main thread. While running, {@link #doFrame(long)} calls back into native
+ * once per vsync, where pending surfaces are presented.
+ */
+public class WebGPUFrameDriver implements Choreographer.FrameCallback {
+ private static final WebGPUFrameDriver INSTANCE = new WebGPUFrameDriver();
+
+ private final Handler mainHandler = new Handler(Looper.getMainLooper());
+ private boolean running = false;
+
+ private WebGPUFrameDriver() {}
+
+ /** Called from native (any thread). */
+ public static void start() {
+ INSTANCE.startInternal();
+ }
+
+ /** Called from native (any thread). */
+ public static void stop() {
+ INSTANCE.stopInternal();
+ }
+
+ private void startInternal() {
+ mainHandler.post(
+ () -> {
+ if (running) {
+ return;
+ }
+ running = true;
+ Choreographer.getInstance().postFrameCallback(this);
+ });
+ }
+
+ private void stopInternal() {
+ mainHandler.post(
+ () -> {
+ if (!running) {
+ return;
+ }
+ running = false;
+ Choreographer.getInstance().removeFrameCallback(this);
+ });
+ }
+
+ @Override
+ public void doFrame(long frameTimeNanos) {
+ if (!running) {
+ return;
+ }
+ nativeOnVSync();
+ Choreographer.getInstance().postFrameCallback(this);
+ }
+
+ private static native void nativeOnVSync();
+}
diff --git a/packages/webgpu/apple/MetalView.mm b/packages/webgpu/apple/MetalView.mm
index ccff1245c..e617da889 100644
--- a/packages/webgpu/apple/MetalView.mm
+++ b/packages/webgpu/apple/MetalView.mm
@@ -1,6 +1,8 @@
#import "MetalView.h"
#import "webgpu/webgpu_cpp.h"
+#include "FrameDriver.h"
+
@implementation MetalView {
BOOL _isConfigured;
}
@@ -42,6 +44,8 @@ - (void)update {
}
- (void)dealloc {
+ // Stop any pending auto-present for this surface before it goes away.
+ rnwgpu::FrameDriver::getInstance().cancelPresent([_contextId intValue]);
auto ®istry = rnwgpu::SurfaceRegistry::getInstance();
// Remove the surface info from the registry
registry.removeSurfaceInfo([_contextId intValue]);
diff --git a/packages/webgpu/apple/WebGPUFrameDriver.h b/packages/webgpu/apple/WebGPUFrameDriver.h
new file mode 100644
index 000000000..aacae84ee
--- /dev/null
+++ b/packages/webgpu/apple/WebGPUFrameDriver.h
@@ -0,0 +1,13 @@
+#pragma once
+
+#import
+
+// Objective-C wrapper around the platform vsync source (CADisplayLink) that
+// drives rnwgpu::FrameDriver::onVSync() once per frame. start/stop are invoked
+// by the C++ FrameDriver via setPlatformVSync; both hop to the main thread.
+@interface WebGPUFrameDriver : NSObject
+
++ (void)start;
++ (void)stop;
+
+@end
diff --git a/packages/webgpu/apple/WebGPUFrameDriver.mm b/packages/webgpu/apple/WebGPUFrameDriver.mm
new file mode 100644
index 000000000..1d302e2fa
--- /dev/null
+++ b/packages/webgpu/apple/WebGPUFrameDriver.mm
@@ -0,0 +1,88 @@
+#import "WebGPUFrameDriver.h"
+
+#import "RNWGUIKit.h"
+#import
+
+#include "FrameDriver.h"
+
+@implementation WebGPUFrameDriver
+
++ (void)onFrame {
+ rnwgpu::FrameDriver::getInstance().onVSync();
+}
+
+#if !TARGET_OS_OSX
+
+// iOS / tvOS: CADisplayLink on the main run loop, paused/resumed for
+// start/stop.
+static CADisplayLink *sDisplayLink = nil;
+
++ (void)tick:(CADisplayLink *)link {
+ [WebGPUFrameDriver onFrame];
+}
+
++ (void)start {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (sDisplayLink == nil) {
+ sDisplayLink = [CADisplayLink displayLinkWithTarget:self
+ selector:@selector(tick:)];
+ [sDisplayLink addToRunLoop:[NSRunLoop mainRunLoop]
+ forMode:NSRunLoopCommonModes];
+ }
+ sDisplayLink.paused = NO;
+ });
+}
+
++ (void)stop {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ sDisplayLink.paused = YES;
+ });
+}
+
+#else // TARGET_OS_OSX
+
+// macOS: CADisplayLink is available via NSScreen on 14.0+. On older systems we
+// fall back to an NSTimer at ~60Hz (not vsync-aligned, but keeps auto-present
+// working). FrameDriver self-idles cheaply when nothing is rendering.
+static id sDisplayLink = nil;
+
++ (void)tick:(id)sender {
+ [WebGPUFrameDriver onFrame];
+}
+
++ (void)start {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (sDisplayLink == nil) {
+ if (@available(macOS 14.0, *)) {
+ CADisplayLink *link =
+ [NSScreen.mainScreen displayLinkWithTarget:self
+ selector:@selector(tick:)];
+ [link addToRunLoop:[NSRunLoop mainRunLoop]
+ forMode:NSRunLoopCommonModes];
+ sDisplayLink = link;
+ } else {
+ sDisplayLink = [NSTimer scheduledTimerWithTimeInterval:1.0 / 60.0
+ target:self
+ selector:@selector(tick:)
+ userInfo:nil
+ repeats:YES];
+ }
+ }
+ if ([sDisplayLink isKindOfClass:[CADisplayLink class]]) {
+ ((CADisplayLink *)sDisplayLink).paused = NO;
+ }
+ });
+}
+
++ (void)stop {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if ([sDisplayLink isKindOfClass:[CADisplayLink class]]) {
+ ((CADisplayLink *)sDisplayLink).paused = YES;
+ }
+ // NSTimer fallback keeps firing; onVSync is a cheap no-op while idle.
+ });
+}
+
+#endif // TARGET_OS_OSX
+
+@end
diff --git a/packages/webgpu/apple/WebGPUModule.mm b/packages/webgpu/apple/WebGPUModule.mm
index 99580aa14..c4c7224ad 100644
--- a/packages/webgpu/apple/WebGPUModule.mm
+++ b/packages/webgpu/apple/WebGPUModule.mm
@@ -1,6 +1,8 @@
#import "WebGPUModule.h"
#include "ApplePlatformContext.h"
+#include "FrameDriver.h"
#import "GPUCanvasContext.h"
+#import "WebGPUFrameDriver.h"
#import
#import
@@ -78,6 +80,11 @@ - (void)invalidate {
std::make_shared();
webgpuManager = std::make_shared(runtime, jsInvoker,
platformContext);
+
+ // Drive auto-present from the display's vsync (replaces context.present()).
+ rnwgpu::FrameDriver::getInstance().setPlatformVSync(
+ [] { [WebGPUFrameDriver start]; }, [] { [WebGPUFrameDriver stop]; });
+
return @true;
}
diff --git a/packages/webgpu/cpp/rnwgpu/FrameDriver.cpp b/packages/webgpu/cpp/rnwgpu/FrameDriver.cpp
new file mode 100644
index 000000000..792940e5e
--- /dev/null
+++ b/packages/webgpu/cpp/rnwgpu/FrameDriver.cpp
@@ -0,0 +1,81 @@
+#include "FrameDriver.h"
+
+#include
+#include
+#include
+
+namespace jsi = facebook::jsi;
+
+namespace rnwgpu {
+
+FrameDriver &FrameDriver::getInstance() {
+ static FrameDriver instance;
+ return instance;
+}
+
+void FrameDriver::setPlatformVSync(std::function start,
+ std::function stop) {
+ std::lock_guard lock(_mutex);
+ _start = std::move(start);
+ _stop = std::move(stop);
+}
+
+void FrameDriver::requestPresent(
+ int contextId, std::shared_ptr surface,
+ std::shared_ptr scheduler) {
+ if (!surface || !scheduler) {
+ return;
+ }
+
+ std::function startToCall;
+ {
+ std::lock_guard lock(_mutex);
+ _pending[contextId] = {std::move(surface), std::move(scheduler)};
+ _idleFrames = 0;
+ if (!_running && _start) {
+ _running = true;
+ startToCall = _start;
+ }
+ }
+
+ // Invoked outside the lock: the platform start hops to the UI thread.
+ if (startToCall) {
+ startToCall();
+ }
+}
+
+void FrameDriver::cancelPresent(int contextId) {
+ std::lock_guard lock(_mutex);
+ _pending.erase(contextId);
+}
+
+void FrameDriver::onVSync() {
+ std::vector toPresent;
+ std::function stopToCall;
+ {
+ std::lock_guard lock(_mutex);
+ if (!_pending.empty()) {
+ toPresent.reserve(_pending.size());
+ for (auto &entry : _pending) {
+ toPresent.push_back(std::move(entry.second));
+ }
+ _pending.clear();
+ _idleFrames = 0;
+ } else if (_running && ++_idleFrames >= kMaxIdleFrames) {
+ _running = false;
+ stopToCall = _stop;
+ }
+ }
+
+ for (auto &pending : toPresent) {
+ auto surface = pending.surface;
+ pending.scheduler->scheduleOnJS(
+ [surface](jsi::Runtime & /*runtime*/) { surface->presentFrame(); });
+ }
+
+ if (stopToCall) {
+ stopToCall();
+ }
+}
+
+} // namespace rnwgpu
diff --git a/packages/webgpu/cpp/rnwgpu/FrameDriver.h b/packages/webgpu/cpp/rnwgpu/FrameDriver.h
new file mode 100644
index 000000000..c16fedabf
--- /dev/null
+++ b/packages/webgpu/cpp/rnwgpu/FrameDriver.h
@@ -0,0 +1,83 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+
+#include "SurfaceRegistry.h"
+#include "rnwgpu/async/RuntimeScheduler.h"
+
+namespace rnwgpu {
+
+/**
+ * Global vsync-driven auto-present coordinator. Replaces the manual
+ * `context.present()` call.
+ *
+ * Flow:
+ * - `GPUCanvasContext::getCurrentTexture()` (JS thread) calls
+ * `requestPresent` for its surface, tagged with the owning runtime's
+ * RuntimeScheduler.
+ * - A platform vsync source (iOS CADisplayLink / Android Choreographer) calls
+ * `onVSync()` on the UI thread once per frame.
+ * - On each vsync, every surface that requested a present has its present
+ * dispatched onto its owning runtime's JS thread (so `Surface.Present()`
+ * and the Apple Metal scheduling wait run on the same thread that did
+ * getCurrentTexture / submit, preserving Dawn surface thread-affinity and
+ * present-after-submit ordering via FIFO on that loop).
+ *
+ * The vsync source is request-driven: it is started when the first present is
+ * requested and stopped after a few idle frames, so an idle (non-rendering) app
+ * costs zero CPU.
+ */
+class FrameDriver {
+public:
+ static FrameDriver &getInstance();
+
+ /**
+ * Register how to start/stop the platform vsync source. `start`/`stop` are
+ * invoked when presents begin/cease; each implementation is responsible for
+ * hopping to the UI thread as needed. Called once per platform at init.
+ */
+ void setPlatformVSync(std::function start,
+ std::function stop);
+
+ /**
+ * Request that `surface` be presented at the next vsync. Coalesced per
+ * contextId (at most one present per surface per frame). Thread-safe; called
+ * from a JS thread inside getCurrentTexture. Surfaces with no on-screen
+ * `wgpu::Surface` (offscreen) should not be registered.
+ */
+ void requestPresent(int contextId, std::shared_ptr surface,
+ std::shared_ptr scheduler);
+
+ /**
+ * Drop any pending present for a surface (e.g. when its view is torn down).
+ * Thread-safe.
+ */
+ void cancelPresent(int contextId);
+
+ /** Called by the platform vsync source on the UI thread, once per frame. */
+ void onVSync();
+
+private:
+ FrameDriver() = default;
+
+ struct Pending {
+ std::shared_ptr surface;
+ std::shared_ptr scheduler;
+ };
+
+ // Number of consecutive empty frames before the vsync source is stopped.
+ // A small grace period avoids start/stop thrash during continuous rendering.
+ static constexpr int kMaxIdleFrames = 3;
+
+ std::mutex _mutex;
+ std::unordered_map _pending;
+ std::function _start;
+ std::function _stop;
+ bool _running = false;
+ int _idleFrames = 0;
+};
+
+} // namespace rnwgpu
diff --git a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h
index 110a45d44..ed098896a 100644
--- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h
+++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h
@@ -7,6 +7,12 @@
#include "webgpu/webgpu_cpp.h"
+#ifdef __APPLE__
+namespace dawn::native::metal {
+void WaitForCommandsToBeScheduled(WGPUDevice device);
+} // namespace dawn::native::metal
+#endif
+
namespace rnwgpu {
struct NativeInfo {
@@ -113,7 +119,22 @@ class SurfaceInfo {
height = newHeight;
}
- void present() {
+ // Present the current surface texture. Called at the frame boundary from the
+ // owning runtime's JS thread (via FrameDriver), replacing the old manual
+ // present(). No-op when offscreen / unconfigured (no surface).
+ void presentFrame() {
+#ifdef __APPLE__
+ // Ensure command buffers are scheduled before presenting. Read the device
+ // under a shared lock, then wait without holding it (the wait can block).
+ wgpu::Device device;
+ {
+ std::shared_lock lock(_mutex);
+ device = config.device;
+ }
+ if (device) {
+ dawn::native::metal::WaitForCommandsToBeScheduled(device.Get());
+ }
+#endif
std::unique_lock lock(_mutex);
if (surface) {
surface.Present();
@@ -131,6 +152,12 @@ class SurfaceInfo {
}
}
+ // True when an on-screen wgpu::Surface is attached (vs offscreen texture).
+ bool hasSurface() {
+ std::shared_lock lock(_mutex);
+ return surface != nullptr;
+ }
+
NativeInfo getNativeInfo() {
std::shared_lock lock(_mutex);
return {.nativeSurface = nativeSurface, .width = width, .height = height};
diff --git a/packages/webgpu/cpp/rnwgpu/api/GPU.h b/packages/webgpu/cpp/rnwgpu/api/GPU.h
index e7dc15caf..b2488d4c7 100644
--- a/packages/webgpu/cpp/rnwgpu/api/GPU.h
+++ b/packages/webgpu/cpp/rnwgpu/api/GPU.h
@@ -53,6 +53,7 @@ class GPU : public NativeObject {
}
inline const wgpu::Instance get() { return _instance; }
+ inline std::shared_ptr getContext() { return _async; }
private:
wgpu::Instance _instance;
diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp
index d75eb7b0f..c4390ba6d 100644
--- a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp
+++ b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp
@@ -1,17 +1,33 @@
#include "GPUCanvasContext.h"
#include "Convertors.h"
+#include "FrameDriver.h"
#include "RNWebGPUManager.h"
#include
-#ifdef __APPLE__
-namespace dawn::native::metal {
-
-void WaitForCommandsToBeScheduled(WGPUDevice device);
+namespace rnwgpu {
+namespace {
+// Runtimes whose present is automatic (no ctx.present() needed): the main JS
+// runtime and the Reanimated UI runtime. Both are reached correctly by the
+// global vsync FrameDriver dispatching through the main runtime's scheduler.
+// Dedicated worklet runtimes (createWorkletRuntime, Vision Camera frame
+// processors, …) run on their own thread with no safe scheduler hook, so they
+// present explicitly via ctx.present().
+bool isAutoPresentedRuntime(jsi::Runtime &runtime) {
+ if (async::RuntimeContext::get(runtime) != nullptr) {
+ return true; // main JS runtime
+ }
+ // Worklets tags every runtime with a numeric `__RUNTIME_KIND`
+ // (worklets::RuntimeKind: ReactNative=1, UI=2, Worker=3). Auto-present only
+ // the UI runtime; treat Worker / unknown / untagged as needing ctx.present().
+ auto kind = runtime.global().getProperty(runtime, "__RUNTIME_KIND");
+ if (kind.isNumber()) {
+ constexpr int kRuntimeKindUI = 2;
+ return static_cast(kind.asNumber()) == kRuntimeKindUI;
+ }
+ return false;
}
-#endif
-
-namespace rnwgpu {
+} // namespace
void GPUCanvasContext::configure(
std::shared_ptr configuration) {
@@ -39,7 +55,10 @@ void GPUCanvasContext::configure(
void GPUCanvasContext::unconfigure() {}
-std::shared_ptr GPUCanvasContext::getCurrentTexture() {
+jsi::Value GPUCanvasContext::getCurrentTexture(jsi::Runtime &runtime,
+ const jsi::Value & /*thisValue*/,
+ const jsi::Value * /*args*/,
+ size_t /*count*/) {
auto prevSize = _surfaceInfo->getConfig();
auto width = _canvas->getWidth();
auto height = _canvas->getHeight();
@@ -47,21 +66,44 @@ std::shared_ptr GPUCanvasContext::getCurrentTexture() {
if (sizeHasChanged) {
_surfaceInfo->reconfigure(width, height);
}
+
auto texture = _surfaceInfo->getCurrentTexture();
- // Pass reportsMemoryPressure=false to avoid triggering spurious Hermes GC
- // cycles every frame since the canvas texture doesn't own the buffer.
- return std::make_shared(texture, "", false);
-}
-void GPUCanvasContext::present() {
-#ifdef __APPLE__
- dawn::native::metal::WaitForCommandsToBeScheduled(
- _surfaceInfo->getDevice().Get());
-#endif
auto size = _surfaceInfo->getSize();
_canvas->setClientWidth(size.width);
_canvas->setClientHeight(size.height);
- _surfaceInfo->present();
+
+ // Auto-present on the JS / UI runtime: acquiring the current texture
+ // schedules a present for this surface at the next vsync (spec-aligned
+ // "update the rendering" after the frame), dispatched through the main
+ // runtime's scheduler. Dedicated worklet runtimes instead call ctx.present()
+ // explicitly on their own thread. Offscreen surfaces have no wgpu::Surface,
+ // so skip them (their texture is read back directly).
+ if (_surfaceInfo->hasSurface() && isAutoPresentedRuntime(runtime)) {
+ FrameDriver::getInstance().requestPresent(_contextId, _surfaceInfo,
+ _gpu->getContext()->scheduler());
+ }
+
+ // Pass reportsMemoryPressure=false to avoid triggering spurious Hermes GC
+ // cycles every frame since the canvas texture doesn't own the buffer.
+ auto gpuTexture = std::make_shared(texture, "", false);
+ return JSIConverter>::toJSI(runtime, gpuTexture);
+}
+
+jsi::Value GPUCanvasContext::present(jsi::Runtime &runtime,
+ const jsi::Value & /*thisValue*/,
+ const jsi::Value * /*args*/,
+ size_t /*count*/) {
+ // Only meaningful on a dedicated worklet runtime, where present can't be
+ // automated. On the JS / UI runtime present is automatic, so this is a no-op
+ // there — which makes it safe to call from a worklet shared between the UI
+ // runtime and a dedicated runtime. Presents synchronously on the calling
+ // thread (the one that did getCurrentTexture / submit), preserving Dawn
+ // surface thread-affinity.
+ if (!isAutoPresentedRuntime(runtime) && _surfaceInfo->hasSurface()) {
+ _surfaceInfo->presentFrame();
+ }
+ return jsi::Value::undefined();
}
} // namespace rnwgpu
diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h
index 4b97a7887..a2e80b7cc 100644
--- a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h
+++ b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h
@@ -26,7 +26,7 @@ class GPUCanvasContext : public NativeObject {
GPUCanvasContext(std::shared_ptr gpu, int contextId, int width,
int height)
- : NativeObject(CLASS_NAME), _gpu(std::move(gpu)) {
+ : NativeObject(CLASS_NAME), _contextId(contextId), _gpu(std::move(gpu)) {
_canvas = std::make_shared