From ede85b676a7fff6bb7d11460fec01f16cb4cab15 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Sat, 16 May 2026 09:16:01 +0200 Subject: [PATCH 01/16] :wrench: --- apps/example/src/Reanimated/Reanimated.tsx | 12 ++--- packages/webgpu/android/cpp/cpp-adapter.cpp | 9 ++++ .../src/main/java/com/webgpu/WebGPUView.java | 40 ++++++++++++++++ packages/webgpu/apple/MetalView.h | 2 + packages/webgpu/apple/MetalView.mm | 48 +++++++++++++++++++ packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h | 7 ++- 6 files changed, 108 insertions(+), 10 deletions(-) diff --git a/apps/example/src/Reanimated/Reanimated.tsx b/apps/example/src/Reanimated/Reanimated.tsx index fa1fa36b6..f6217a606 100644 --- a/apps/example/src/Reanimated/Reanimated.tsx +++ b/apps/example/src/Reanimated/Reanimated.tsx @@ -48,18 +48,15 @@ const webGPUDemo = ( }, }); const frame = () => { - console.log(Date.now()); const commandEncoder = device.createCommandEncoder(); const textureView = context.getCurrentTexture().createView(); - // Animate the clearValue color based on Date.now() - const time = Date.now() / 1000; // Convert to seconds for smoother animation + const time = Date.now() / 1000; - // Create animated RGB values using sine waves with different frequencies - const r = (Math.sin(time * 2) + 1) / 2; // Red channel oscillates faster - const g = (Math.sin(time * 1.5 + Math.PI / 3) + 1) / 2; // Green with phase offset - const b = (Math.sin(time * 1 + Math.PI / 2) + 1) / 2; // Blue with different phase + const r = (Math.sin(time * 2) + 1) / 2; + const g = (Math.sin(time * 1.5 + Math.PI / 3) + 1) / 2; + const b = (Math.sin(time * 1 + Math.PI / 2) + 1) / 2; const renderPassDescriptor: GPURenderPassDescriptor = { colorAttachments: [ @@ -79,7 +76,6 @@ const webGPUDemo = ( device.queue.submit([commandEncoder.finish()]); - context.present(); if (runAnimation.value) { requestAnimationFrame(frame); } diff --git a/packages/webgpu/android/cpp/cpp-adapter.cpp b/packages/webgpu/android/cpp/cpp-adapter.cpp index 2a441c218..739a25290 100644 --- a/packages/webgpu/android/cpp/cpp-adapter.cpp +++ b/packages/webgpu/android/cpp/cpp-adapter.cpp @@ -68,4 +68,13 @@ extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onSurfaceDestroy( JNIEnv *env, jobject thiz, jint contextId) { auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); registry.removeSurfaceInfo(contextId); +} + +extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_nativePresent( + JNIEnv *env, jobject thiz, jint contextId) { + auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); + auto info = registry.getSurfaceInfo(contextId); + if (info) { + info->present(); + } } \ No newline at end of file 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 3f73a1066..9e0396976 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,7 @@ import android.content.Context; import android.os.Build; +import android.view.Choreographer; import android.view.Surface; import android.view.View; @@ -16,11 +17,38 @@ public class WebGPUView extends ReactViewGroup implements WebGPUAPI { private boolean mTransparent = false; private WebGPUModule mModule; private View mView = null; + private boolean mPresentLoopRunning = false; + private final Choreographer.FrameCallback mPresentCallback = new Choreographer.FrameCallback() { + @Override + public void doFrame(long frameTimeNanos) { + if (!mPresentLoopRunning) { + return; + } + nativePresent(mContextId); + Choreographer.getInstance().postFrameCallback(this); + } + }; WebGPUView(Context context) { super(context); } + private void startPresentLoop() { + if (mPresentLoopRunning) { + return; + } + mPresentLoopRunning = true; + Choreographer.getInstance().postFrameCallback(mPresentCallback); + } + + private void stopPresentLoop() { + if (!mPresentLoopRunning) { + return; + } + mPresentLoopRunning = false; + Choreographer.getInstance().removeFrameCallback(mPresentCallback); + } + public void setContextId(int contextId) { if (mModule == null) { Context context = getContext(); @@ -63,6 +91,7 @@ public void surfaceCreated(Surface surface) { float width = getWidth() / density; float height = getHeight() / density; onSurfaceCreate(surface, mContextId, width, height); + startPresentLoop(); } @Override @@ -75,14 +104,22 @@ public void surfaceChanged(Surface surface) { @Override public void surfaceDestroyed() { + stopPresentLoop(); onSurfaceDestroy(mContextId); } @Override public void surfaceOffscreen() { + stopPresentLoop(); switchToOffscreenSurface(mContextId); } + @Override + protected void onDetachedFromWindow() { + stopPresentLoop(); + super.onDetachedFromWindow(); + } + @DoNotStrip private native void onSurfaceCreate( Surface surface, @@ -105,4 +142,7 @@ private native void onSurfaceChanged( @DoNotStrip private native void switchToOffscreenSurface(int contextId); + @DoNotStrip + private native void nativePresent(int contextId); + } diff --git a/packages/webgpu/apple/MetalView.h b/packages/webgpu/apple/MetalView.h index a563db974..1a295aaa4 100644 --- a/packages/webgpu/apple/MetalView.h +++ b/packages/webgpu/apple/MetalView.h @@ -9,5 +9,7 @@ - (void)configure; - (void)update; +- (void)startPresentLoop; +- (void)stopPresentLoop; @end diff --git a/packages/webgpu/apple/MetalView.mm b/packages/webgpu/apple/MetalView.mm index ccff1245c..ca84fab7e 100644 --- a/packages/webgpu/apple/MetalView.mm +++ b/packages/webgpu/apple/MetalView.mm @@ -1,8 +1,15 @@ #import "MetalView.h" #import "webgpu/webgpu_cpp.h" +namespace dawn::native::metal { + +void WaitForCommandsToBeScheduled(WGPUDevice device); + +} + @implementation MetalView { BOOL _isConfigured; + CADisplayLink *_displayLink; } #if !TARGET_OS_OSX @@ -32,6 +39,7 @@ - (void)configure { .getSurfaceInfoOrCreate([_contextId intValue], gpu, size.width, size.height) ->switchToOnscreen(nativeSurface, surface); + [self startPresentLoop]; } - (void)update { @@ -41,7 +49,47 @@ - (void)update { ->resize(size.width, size.height); } +#if !TARGET_OS_OSX +- (void)didMoveToWindow { + [super didMoveToWindow]; + if (self.window == nil) { + [self stopPresentLoop]; + } +} +#endif + +- (void)startPresentLoop { + if (_displayLink) { + return; + } +#if !TARGET_OS_OSX + _displayLink = [CADisplayLink displayLinkWithTarget:self + selector:@selector(presentTick:)]; + [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] + forMode:NSRunLoopCommonModes]; +#endif +} + +- (void)stopPresentLoop { + [_displayLink invalidate]; + _displayLink = nil; +} + +- (void)presentTick:(CADisplayLink *)link { + auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); + auto info = registry.getSurfaceInfo([_contextId intValue]); + if (!info) { + return; + } + auto device = info->getDevice(); + if (device) { + dawn::native::metal::WaitForCommandsToBeScheduled(device.Get()); + } + info->present(); +} + - (void)dealloc { + [self stopPresentLoop]; auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); // Remove the surface info from the registry registry.removeSurfaceInfo([_contextId intValue]); diff --git a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h index 110a45d44..bcbc867f0 100644 --- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h +++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h @@ -115,16 +115,18 @@ class SurfaceInfo { void present() { std::unique_lock lock(_mutex); - if (surface) { + if (surface && _textureAcquired) { surface.Present(); + _textureAcquired = false; } } wgpu::Texture getCurrentTexture() { - std::shared_lock lock(_mutex); + std::unique_lock lock(_mutex); if (surface) { wgpu::SurfaceTexture surfaceTexture; surface.GetCurrentTexture(&surfaceTexture); + _textureAcquired = true; return surfaceTexture.texture; } else { return texture; @@ -175,6 +177,7 @@ class SurfaceInfo { wgpu::SurfaceConfiguration config; int width; int height; + bool _textureAcquired = false; }; class SurfaceRegistry { From 46670f535ce0a54a8ba36b85addadf03c4103c20 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Sat, 16 May 2026 09:39:35 +0200 Subject: [PATCH 02/16] :wrench: --- README.md | 14 +------- apps/example/src/CanvasAPI/CanvasAPI.tsx | 2 -- apps/example/src/ComputeToys/engine/index.ts | 1 - .../src/GradientTiles/GradientTiles.tsx | 1 - .../StorageBufferVertices.tsx | 2 -- apps/example/src/ThreeJS/Backdrop.tsx | 1 - apps/example/src/ThreeJS/Cube.tsx | 1 - apps/example/src/ThreeJS/Helmet.tsx | 1 - apps/example/src/ThreeJS/InstancedMesh.tsx | 1 - apps/example/src/ThreeJS/PostProcessing.tsx | 1 - .../src/ThreeJS/components/FiberCanvas.tsx | 1 - apps/example/src/Triangle/HelloTriangle.tsx | 2 -- .../src/Triangle/HelloTriangleMSAA.tsx | 1 - apps/example/src/components/Texture.tsx | 1 - apps/example/src/components/useWebGPU.ts | 1 - packages/webgpu/README.md | 14 +------- packages/webgpu/apple/MetalView.mm | 15 ++------- packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h | 32 ++++++++++++++++++- .../cpp/rnwgpu/api/GPUCanvasContext.cpp | 22 ++----------- .../webgpu/cpp/rnwgpu/api/GPUCanvasContext.h | 2 -- packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp | 2 ++ packages/webgpu/src/Canvas.tsx | 4 +-- packages/webgpu/src/Offscreen.ts | 4 --- packages/webgpu/src/WebPolyfillGPUModule.ts | 5 +-- packages/webgpu/src/types.ts | 4 +-- 25 files changed, 43 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index 0ae1359b4..2395b3046 100644 --- a/README.md +++ b/README.md @@ -128,8 +128,6 @@ export function HelloTriangle() { passEncoder.end(); device.queue.submit([commandEncoder.finish()]); - - context.present(); }; helloTriangle(); }, [ref]); @@ -174,16 +172,7 @@ ctx.canvas.height = ctx.canvas.clientHeight * PixelRatio.get(); ### Frame Scheduling -In React Native, we want to keep frame presentation as a manual operation as we plan to provide more advanced rendering options that are React Native specific. -This means that when you are ready to present a frame, you need to call `present` on the context. - -```tsx -// draw -// submit to the queue -device.queue.submit([commandEncoder.finish()]); -// This method is React Native only -context.present(); -``` +Frame presentation is automatic. Submitted frames are presented on the next display vsync via a native display link (CADisplayLink on iOS, Choreographer on Android), matching the behavior of `GPUCanvasContext` on the Web. ### Canvas Transparency @@ -244,7 +233,6 @@ const renderFrame = (device: GPUDevice, context: GPUCanvasContext) => { const commandEncoder = device.createCommandEncoder(); // ... render ... device.queue.submit([commandEncoder.finish()]); - context.present(); }; // Initialize WebGPU on main thread, then run on UI thread diff --git a/apps/example/src/CanvasAPI/CanvasAPI.tsx b/apps/example/src/CanvasAPI/CanvasAPI.tsx index a9f5c4928..a403c8388 100644 --- a/apps/example/src/CanvasAPI/CanvasAPI.tsx +++ b/apps/example/src/CanvasAPI/CanvasAPI.tsx @@ -89,8 +89,6 @@ export const CanvasAPI = () => { passEncoder.end(); device.queue.submit([commandEncoder.finish()]); - - context.present(); })() } title="check surface" diff --git a/apps/example/src/ComputeToys/engine/index.ts b/apps/example/src/ComputeToys/engine/index.ts index f0fa08f07..8db2562ad 100644 --- a/apps/example/src/ComputeToys/engine/index.ts +++ b/apps/example/src/ComputeToys/engine/index.ts @@ -398,7 +398,6 @@ fn passSampleLevelBilinearRepeat(pass_index: int, uv: float2, lod: float) -> flo // Submit command buffer this.device.queue.submit([encoder.finish()]); - this.surface!.present(); // Update frame counter this.bindings!.time.host.frame += 1; diff --git a/apps/example/src/GradientTiles/GradientTiles.tsx b/apps/example/src/GradientTiles/GradientTiles.tsx index 3268cb7f4..fc5875b05 100644 --- a/apps/example/src/GradientTiles/GradientTiles.tsx +++ b/apps/example/src/GradientTiles/GradientTiles.tsx @@ -125,7 +125,6 @@ export function GradientTiles() { passEncoder.end(); device.queue.submit([commandEncoder.finish()]); - context.present(); }, [ref, device, root, spanX, spanY, state]); return ( diff --git a/apps/example/src/StorageBufferVertices/StorageBufferVertices.tsx b/apps/example/src/StorageBufferVertices/StorageBufferVertices.tsx index 907264638..b1906cf74 100644 --- a/apps/example/src/StorageBufferVertices/StorageBufferVertices.tsx +++ b/apps/example/src/StorageBufferVertices/StorageBufferVertices.tsx @@ -185,8 +185,6 @@ export function StorageBufferVertices() { const commandBuffer = encoder.finish(); device.queue.submit([commandBuffer]); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (context as any).present(); }); return ( diff --git a/apps/example/src/ThreeJS/Backdrop.tsx b/apps/example/src/ThreeJS/Backdrop.tsx index 8ed2a8c91..113325b9d 100644 --- a/apps/example/src/ThreeJS/Backdrop.tsx +++ b/apps/example/src/ThreeJS/Backdrop.tsx @@ -150,7 +150,6 @@ export const Backdrop = () => { } renderer.render(scene, camera); - context!.present(); } return () => { renderer.setAnimationLoop(null); diff --git a/apps/example/src/ThreeJS/Cube.tsx b/apps/example/src/ThreeJS/Cube.tsx index d3e9707b5..ea3fe0f23 100644 --- a/apps/example/src/ThreeJS/Cube.tsx +++ b/apps/example/src/ThreeJS/Cube.tsx @@ -31,7 +31,6 @@ export const Cube = () => { mesh.rotation.y = time / 1000; renderer.render(scene, camera); - context.present(); } renderer.setAnimationLoop(animate); return () => { diff --git a/apps/example/src/ThreeJS/Helmet.tsx b/apps/example/src/ThreeJS/Helmet.tsx index be7cb626f..70720d360 100644 --- a/apps/example/src/ThreeJS/Helmet.tsx +++ b/apps/example/src/ThreeJS/Helmet.tsx @@ -49,7 +49,6 @@ export const Helmet = () => { function animate() { animateCamera(); renderer.render(scene, camera); - context!.present(); } return () => { diff --git a/apps/example/src/ThreeJS/InstancedMesh.tsx b/apps/example/src/ThreeJS/InstancedMesh.tsx index 06e95245e..efbc3649a 100644 --- a/apps/example/src/ThreeJS/InstancedMesh.tsx +++ b/apps/example/src/ThreeJS/InstancedMesh.tsx @@ -61,7 +61,6 @@ export const InstancedMesh = () => { function animate() { render(); - context!.present(); } function render() { diff --git a/apps/example/src/ThreeJS/PostProcessing.tsx b/apps/example/src/ThreeJS/PostProcessing.tsx index d94ef1728..0c2980501 100644 --- a/apps/example/src/ThreeJS/PostProcessing.tsx +++ b/apps/example/src/ThreeJS/PostProcessing.tsx @@ -72,7 +72,6 @@ export const PostProcessing = () => { mixer.update(delta); } postProcessing.render(); - context!.present(); } return () => { renderer.setAnimationLoop(null); diff --git a/apps/example/src/ThreeJS/components/FiberCanvas.tsx b/apps/example/src/ThreeJS/components/FiberCanvas.tsx index 91b699553..92b928987 100644 --- a/apps/example/src/ThreeJS/components/FiberCanvas.tsx +++ b/apps/example/src/ThreeJS/components/FiberCanvas.tsx @@ -66,7 +66,6 @@ export const FiberCanvas = ({ const renderFrame = state.gl.render.bind(state.gl); state.gl.render = (s: THREE.Scene, c: THREE.Camera) => { renderFrame(s, c); - context?.present(); }; }, }); diff --git a/apps/example/src/Triangle/HelloTriangle.tsx b/apps/example/src/Triangle/HelloTriangle.tsx index 3e28d6c12..caeb560b3 100644 --- a/apps/example/src/Triangle/HelloTriangle.tsx +++ b/apps/example/src/Triangle/HelloTriangle.tsx @@ -77,8 +77,6 @@ export function HelloTriangle() { passEncoder.end(); device.queue.submit([commandEncoder.finish()]); - - context.present(); })(); }, [ref]); diff --git a/apps/example/src/Triangle/HelloTriangleMSAA.tsx b/apps/example/src/Triangle/HelloTriangleMSAA.tsx index 5d66983d5..b9518fbe9 100644 --- a/apps/example/src/Triangle/HelloTriangleMSAA.tsx +++ b/apps/example/src/Triangle/HelloTriangleMSAA.tsx @@ -87,7 +87,6 @@ export function HelloTriangleMSAA() { } frame(); - context.present(); })(); }, [ref]); diff --git a/apps/example/src/components/Texture.tsx b/apps/example/src/components/Texture.tsx index d9e689b41..5bd82a911 100644 --- a/apps/example/src/components/Texture.tsx +++ b/apps/example/src/components/Texture.tsx @@ -145,7 +145,6 @@ export const Texture = ({ texture, style, device }: GPUTextureProps) => { renderPass.end(); device.queue.submit([commandEncoder.finish()]); - context.present(); }, [device, state, texture, ref]); return ; }; diff --git a/apps/example/src/components/useWebGPU.ts b/apps/example/src/components/useWebGPU.ts index ac8a631ac..1a399aafe 100644 --- a/apps/example/src/components/useWebGPU.ts +++ b/apps/example/src/components/useWebGPU.ts @@ -57,7 +57,6 @@ export const useWebGPU = (scene: Scene) => { const render = () => { const timestamp = Date.now(); renderScene(timestamp); - context.present(); animationFrameId.current = requestAnimationFrame(render); }; diff --git a/packages/webgpu/README.md b/packages/webgpu/README.md index 0ae1359b4..2395b3046 100644 --- a/packages/webgpu/README.md +++ b/packages/webgpu/README.md @@ -128,8 +128,6 @@ export function HelloTriangle() { passEncoder.end(); device.queue.submit([commandEncoder.finish()]); - - context.present(); }; helloTriangle(); }, [ref]); @@ -174,16 +172,7 @@ ctx.canvas.height = ctx.canvas.clientHeight * PixelRatio.get(); ### Frame Scheduling -In React Native, we want to keep frame presentation as a manual operation as we plan to provide more advanced rendering options that are React Native specific. -This means that when you are ready to present a frame, you need to call `present` on the context. - -```tsx -// draw -// submit to the queue -device.queue.submit([commandEncoder.finish()]); -// This method is React Native only -context.present(); -``` +Frame presentation is automatic. Submitted frames are presented on the next display vsync via a native display link (CADisplayLink on iOS, Choreographer on Android), matching the behavior of `GPUCanvasContext` on the Web. ### Canvas Transparency @@ -244,7 +233,6 @@ const renderFrame = (device: GPUDevice, context: GPUCanvasContext) => { const commandEncoder = device.createCommandEncoder(); // ... render ... device.queue.submit([commandEncoder.finish()]); - context.present(); }; // Initialize WebGPU on main thread, then run on UI thread diff --git a/packages/webgpu/apple/MetalView.mm b/packages/webgpu/apple/MetalView.mm index ca84fab7e..ced36d956 100644 --- a/packages/webgpu/apple/MetalView.mm +++ b/packages/webgpu/apple/MetalView.mm @@ -1,12 +1,6 @@ #import "MetalView.h" #import "webgpu/webgpu_cpp.h" -namespace dawn::native::metal { - -void WaitForCommandsToBeScheduled(WGPUDevice device); - -} - @implementation MetalView { BOOL _isConfigured; CADisplayLink *_displayLink; @@ -78,14 +72,9 @@ - (void)stopPresentLoop { - (void)presentTick:(CADisplayLink *)link { auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); auto info = registry.getSurfaceInfo([_contextId intValue]); - if (!info) { - return; - } - auto device = info->getDevice(); - if (device) { - dawn::native::metal::WaitForCommandsToBeScheduled(device.Get()); + if (info) { + info->present(); } - info->present(); } - (void)dealloc { diff --git a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h index bcbc867f0..13d6f5c8c 100644 --- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h +++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h @@ -7,6 +7,14 @@ #include "webgpu/webgpu_cpp.h" +#ifdef __APPLE__ +namespace dawn::native::metal { + +void WaitForCommandsToBeScheduled(WGPUDevice device); + +} +#endif + namespace rnwgpu { struct NativeInfo { @@ -115,9 +123,22 @@ class SurfaceInfo { void present() { std::unique_lock lock(_mutex); - if (surface && _textureAcquired) { + if (surface && _textureAcquired && _readyToPresent) { +#ifdef __APPLE__ + if (config.device) { + dawn::native::metal::WaitForCommandsToBeScheduled(config.device.Get()); + } +#endif surface.Present(); _textureAcquired = false; + _readyToPresent = false; + } + } + + void markReadyToPresent() { + std::unique_lock lock(_mutex); + if (_textureAcquired) { + _readyToPresent = true; } } @@ -127,6 +148,7 @@ class SurfaceInfo { wgpu::SurfaceTexture surfaceTexture; surface.GetCurrentTexture(&surfaceTexture); _textureAcquired = true; + _readyToPresent = false; return surfaceTexture.texture; } else { return texture; @@ -178,6 +200,7 @@ class SurfaceInfo { int width; int height; bool _textureAcquired = false; + bool _readyToPresent = false; }; class SurfaceRegistry { @@ -224,6 +247,13 @@ class SurfaceRegistry { return info; } + void markAllReadyToPresent() { + std::shared_lock lock(_mutex); + for (auto &pair : _registry) { + pair.second->markReadyToPresent(); + } + } + private: SurfaceRegistry() = default; mutable std::shared_mutex _mutex; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp index d75eb7b0f..b49d22d93 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp @@ -3,14 +3,6 @@ #include "RNWebGPUManager.h" #include -#ifdef __APPLE__ -namespace dawn::native::metal { - -void WaitForCommandsToBeScheduled(WGPUDevice device); - -} -#endif - namespace rnwgpu { void GPUCanvasContext::configure( @@ -47,21 +39,13 @@ std::shared_ptr GPUCanvasContext::getCurrentTexture() { if (sizeHasChanged) { _surfaceInfo->reconfigure(width, height); } + auto size = _surfaceInfo->getSize(); + _canvas->setClientWidth(size.width); + _canvas->setClientHeight(size.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(); -} - } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h index 4b97a7887..12b0b4475 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h @@ -47,7 +47,6 @@ class GPUCanvasContext : public NativeObject { &GPUCanvasContext::unconfigure); installMethod(runtime, prototype, "getCurrentTexture", &GPUCanvasContext::getCurrentTexture); - installMethod(runtime, prototype, "present", &GPUCanvasContext::present); } // TODO: is this ok? @@ -55,7 +54,6 @@ class GPUCanvasContext : public NativeObject { void configure(std::shared_ptr configuration); void unconfigure(); std::shared_ptr getCurrentTexture(); - void present(); private: std::shared_ptr _canvas; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp index d3c0d65af..c64ca9c08 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp @@ -5,6 +5,7 @@ #include #include "Convertors.h" +#include "SurfaceRegistry.h" namespace rnwgpu { @@ -26,6 +27,7 @@ void GPUQueue::submit( return; } _instance.Submit(bufs_size, bufs.data()); + SurfaceRegistry::getInstance().markAllReadyToPresent(); } void GPUQueue::writeBuffer(std::shared_ptr buffer, diff --git a/packages/webgpu/src/Canvas.tsx b/packages/webgpu/src/Canvas.tsx index 142e5de2c..c2086164e 100644 --- a/packages/webgpu/src/Canvas.tsx +++ b/packages/webgpu/src/Canvas.tsx @@ -34,9 +34,7 @@ export interface NativeCanvas { clientHeight: number; } -export type RNCanvasContext = GPUCanvasContext & { - present: () => void; -}; +export type RNCanvasContext = GPUCanvasContext; export interface CanvasRef { getContextId: () => number; diff --git a/packages/webgpu/src/Offscreen.ts b/packages/webgpu/src/Offscreen.ts index c4e460bb2..6ce2f589c 100644 --- a/packages/webgpu/src/Offscreen.ts +++ b/packages/webgpu/src/Offscreen.ts @@ -64,10 +64,6 @@ class GPUOffscreenCanvasContext implements GPUCanvasContext { throw new Error("Method not implemented."); } - present() { - // Do nothing - } - getDevice() { if (!this.device) { throw new Error("Device is not configured."); diff --git a/packages/webgpu/src/WebPolyfillGPUModule.ts b/packages/webgpu/src/WebPolyfillGPUModule.ts index 9dcc1f1c5..04229cd05 100644 --- a/packages/webgpu/src/WebPolyfillGPUModule.ts +++ b/packages/webgpu/src/WebPolyfillGPUModule.ts @@ -39,10 +39,7 @@ function makeWebGPUCanvasContext( canvas.setAttribute("height", pixelHeight); } - const context = canvas.getContext("webgpu")!; - return Object.assign(context, { - present: () => {}, - }); + return canvas.getContext("webgpu")!; } // @ts-expect-error - polyfill for RNWebGPU native module diff --git a/packages/webgpu/src/types.ts b/packages/webgpu/src/types.ts index af4684cfa..d7d07843b 100644 --- a/packages/webgpu/src/types.ts +++ b/packages/webgpu/src/types.ts @@ -8,9 +8,7 @@ export interface NativeCanvas { clientHeight: number; } -export type RNCanvasContext = GPUCanvasContext & { - present: () => void; -}; +export type RNCanvasContext = GPUCanvasContext; export interface CanvasRef { getContextId: () => number; From 81e4e314a0002496473ec9ec4d6aa7fd0f05bef5 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Sat, 16 May 2026 10:33:01 +0200 Subject: [PATCH 03/16] :wrench: --- packages/webgpu/android/cpp/cpp-adapter.cpp | 9 ----- .../src/main/java/com/webgpu/WebGPUView.java | 40 ------------------- packages/webgpu/apple/MetalView.h | 2 - packages/webgpu/apple/MetalView.mm | 37 ----------------- packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h | 19 +-------- .../cpp/rnwgpu/api/GPUCanvasContext.cpp | 20 +++++++++- .../webgpu/cpp/rnwgpu/api/GPUCanvasContext.h | 4 +- packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp | 2 - 8 files changed, 22 insertions(+), 111 deletions(-) diff --git a/packages/webgpu/android/cpp/cpp-adapter.cpp b/packages/webgpu/android/cpp/cpp-adapter.cpp index 739a25290..2a441c218 100644 --- a/packages/webgpu/android/cpp/cpp-adapter.cpp +++ b/packages/webgpu/android/cpp/cpp-adapter.cpp @@ -68,13 +68,4 @@ extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onSurfaceDestroy( JNIEnv *env, jobject thiz, jint contextId) { auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); registry.removeSurfaceInfo(contextId); -} - -extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_nativePresent( - JNIEnv *env, jobject thiz, jint contextId) { - auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); - auto info = registry.getSurfaceInfo(contextId); - if (info) { - info->present(); - } } \ No newline at end of file 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 9e0396976..3f73a1066 100644 --- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java +++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java @@ -2,7 +2,6 @@ import android.content.Context; import android.os.Build; -import android.view.Choreographer; import android.view.Surface; import android.view.View; @@ -17,38 +16,11 @@ public class WebGPUView extends ReactViewGroup implements WebGPUAPI { private boolean mTransparent = false; private WebGPUModule mModule; private View mView = null; - private boolean mPresentLoopRunning = false; - private final Choreographer.FrameCallback mPresentCallback = new Choreographer.FrameCallback() { - @Override - public void doFrame(long frameTimeNanos) { - if (!mPresentLoopRunning) { - return; - } - nativePresent(mContextId); - Choreographer.getInstance().postFrameCallback(this); - } - }; WebGPUView(Context context) { super(context); } - private void startPresentLoop() { - if (mPresentLoopRunning) { - return; - } - mPresentLoopRunning = true; - Choreographer.getInstance().postFrameCallback(mPresentCallback); - } - - private void stopPresentLoop() { - if (!mPresentLoopRunning) { - return; - } - mPresentLoopRunning = false; - Choreographer.getInstance().removeFrameCallback(mPresentCallback); - } - public void setContextId(int contextId) { if (mModule == null) { Context context = getContext(); @@ -91,7 +63,6 @@ public void surfaceCreated(Surface surface) { float width = getWidth() / density; float height = getHeight() / density; onSurfaceCreate(surface, mContextId, width, height); - startPresentLoop(); } @Override @@ -104,22 +75,14 @@ public void surfaceChanged(Surface surface) { @Override public void surfaceDestroyed() { - stopPresentLoop(); onSurfaceDestroy(mContextId); } @Override public void surfaceOffscreen() { - stopPresentLoop(); switchToOffscreenSurface(mContextId); } - @Override - protected void onDetachedFromWindow() { - stopPresentLoop(); - super.onDetachedFromWindow(); - } - @DoNotStrip private native void onSurfaceCreate( Surface surface, @@ -142,7 +105,4 @@ private native void onSurfaceChanged( @DoNotStrip private native void switchToOffscreenSurface(int contextId); - @DoNotStrip - private native void nativePresent(int contextId); - } diff --git a/packages/webgpu/apple/MetalView.h b/packages/webgpu/apple/MetalView.h index 1a295aaa4..a563db974 100644 --- a/packages/webgpu/apple/MetalView.h +++ b/packages/webgpu/apple/MetalView.h @@ -9,7 +9,5 @@ - (void)configure; - (void)update; -- (void)startPresentLoop; -- (void)stopPresentLoop; @end diff --git a/packages/webgpu/apple/MetalView.mm b/packages/webgpu/apple/MetalView.mm index ced36d956..ccff1245c 100644 --- a/packages/webgpu/apple/MetalView.mm +++ b/packages/webgpu/apple/MetalView.mm @@ -3,7 +3,6 @@ @implementation MetalView { BOOL _isConfigured; - CADisplayLink *_displayLink; } #if !TARGET_OS_OSX @@ -33,7 +32,6 @@ - (void)configure { .getSurfaceInfoOrCreate([_contextId intValue], gpu, size.width, size.height) ->switchToOnscreen(nativeSurface, surface); - [self startPresentLoop]; } - (void)update { @@ -43,42 +41,7 @@ - (void)update { ->resize(size.width, size.height); } -#if !TARGET_OS_OSX -- (void)didMoveToWindow { - [super didMoveToWindow]; - if (self.window == nil) { - [self stopPresentLoop]; - } -} -#endif - -- (void)startPresentLoop { - if (_displayLink) { - return; - } -#if !TARGET_OS_OSX - _displayLink = [CADisplayLink displayLinkWithTarget:self - selector:@selector(presentTick:)]; - [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] - forMode:NSRunLoopCommonModes]; -#endif -} - -- (void)stopPresentLoop { - [_displayLink invalidate]; - _displayLink = nil; -} - -- (void)presentTick:(CADisplayLink *)link { - auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); - auto info = registry.getSurfaceInfo([_contextId intValue]); - if (info) { - info->present(); - } -} - - (void)dealloc { - [self stopPresentLoop]; auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); // Remove the surface info from the registry registry.removeSurfaceInfo([_contextId intValue]); diff --git a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h index 13d6f5c8c..c9da6d1bd 100644 --- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h +++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h @@ -123,7 +123,7 @@ class SurfaceInfo { void present() { std::unique_lock lock(_mutex); - if (surface && _textureAcquired && _readyToPresent) { + if (surface && _textureAcquired) { #ifdef __APPLE__ if (config.device) { dawn::native::metal::WaitForCommandsToBeScheduled(config.device.Get()); @@ -131,14 +131,6 @@ class SurfaceInfo { #endif surface.Present(); _textureAcquired = false; - _readyToPresent = false; - } - } - - void markReadyToPresent() { - std::unique_lock lock(_mutex); - if (_textureAcquired) { - _readyToPresent = true; } } @@ -148,7 +140,6 @@ class SurfaceInfo { wgpu::SurfaceTexture surfaceTexture; surface.GetCurrentTexture(&surfaceTexture); _textureAcquired = true; - _readyToPresent = false; return surfaceTexture.texture; } else { return texture; @@ -200,7 +191,6 @@ class SurfaceInfo { int width; int height; bool _textureAcquired = false; - bool _readyToPresent = false; }; class SurfaceRegistry { @@ -247,13 +237,6 @@ class SurfaceRegistry { return info; } - void markAllReadyToPresent() { - std::shared_lock lock(_mutex); - for (auto &pair : _registry) { - pair.second->markReadyToPresent(); - } - } - private: SurfaceRegistry() = default; mutable std::shared_mutex _mutex; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp index b49d22d93..74f956498 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp @@ -31,7 +31,10 @@ void GPUCanvasContext::configure( void GPUCanvasContext::unconfigure() {} -std::shared_ptr GPUCanvasContext::getCurrentTexture() { +jsi::Value GPUCanvasContext::getCurrentTexture(jsi::Runtime &runtime, + const jsi::Value & /*thisVal*/, + const jsi::Value * /*args*/, + size_t /*count*/) { auto prevSize = _surfaceInfo->getConfig(); auto width = _canvas->getWidth(); auto height = _canvas->getHeight(); @@ -43,9 +46,22 @@ std::shared_ptr GPUCanvasContext::getCurrentTexture() { _canvas->setClientWidth(size.width); _canvas->setClientHeight(size.height); auto texture = _surfaceInfo->getCurrentTexture(); + + auto surfaceInfo = _surfaceInfo; + auto present = jsi::Function::createFromHostFunction( + runtime, jsi::PropNameID::forAscii(runtime, "WebGPUPresent"), 0, + [surfaceInfo](jsi::Runtime & /*rt*/, const jsi::Value & /*thisValue*/, + const jsi::Value * /*args*/, + size_t /*count*/) -> jsi::Value { + surfaceInfo->present(); + return jsi::Value::undefined(); + }); + runtime.queueMicrotask(std::move(present)); + // 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); + auto gpuTexture = std::make_shared(texture, "", false); + return JSIConverter>::toJSI(runtime, gpuTexture); } } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h index 12b0b4475..f38aa27e9 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h @@ -53,7 +53,9 @@ class GPUCanvasContext : public NativeObject { inline const wgpu::Surface get() { return nullptr; } void configure(std::shared_ptr configuration); void unconfigure(); - std::shared_ptr getCurrentTexture(); + jsi::Value getCurrentTexture(jsi::Runtime &runtime, + const jsi::Value &thisVal, + const jsi::Value *args, size_t count); private: std::shared_ptr _canvas; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp index c64ca9c08..d3c0d65af 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp @@ -5,7 +5,6 @@ #include #include "Convertors.h" -#include "SurfaceRegistry.h" namespace rnwgpu { @@ -27,7 +26,6 @@ void GPUQueue::submit( return; } _instance.Submit(bufs_size, bufs.data()); - SurfaceRegistry::getInstance().markAllReadyToPresent(); } void GPUQueue::writeBuffer(std::shared_ptr buffer, From f58801eef9864f5dca091061a708ff67c407521e Mon Sep 17 00:00:00 2001 From: William Candillon Date: Sat, 16 May 2026 10:49:21 +0200 Subject: [PATCH 04/16] :wrench: --- .../cpp/rnwgpu/api/GPUCanvasContext.cpp | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp index 74f956498..cad67a7c6 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp @@ -48,15 +48,36 @@ jsi::Value GPUCanvasContext::getCurrentTexture(jsi::Runtime &runtime, auto texture = _surfaceInfo->getCurrentTexture(); auto surfaceInfo = _surfaceInfo; - auto present = jsi::Function::createFromHostFunction( - runtime, jsi::PropNameID::forAscii(runtime, "WebGPUPresent"), 0, - [surfaceInfo](jsi::Runtime & /*rt*/, const jsi::Value & /*thisValue*/, - const jsi::Value * /*args*/, - size_t /*count*/) -> jsi::Value { - surfaceInfo->present(); - return jsi::Value::undefined(); - }); - runtime.queueMicrotask(std::move(present)); + auto presentCb = [surfaceInfo](jsi::Runtime & /*rt*/, + const jsi::Value & /*thisValue*/, + const jsi::Value * /*args*/, + size_t /*count*/) -> jsi::Value { + surfaceInfo->present(); + return jsi::Value::undefined(); + }; + auto makeFn = [&]() { + return jsi::Function::createFromHostFunction( + runtime, jsi::PropNameID::forAscii(runtime, "WebGPUPresent"), 0, + presentCb); + }; + // Try queueMicrotask first (Hermes JS thread). If the runtime disables + // microtasks (e.g. Worklets), fall back to setImmediate, then setTimeout — + // both have end-of-current-task semantics with no display latency. + try { + runtime.queueMicrotask(makeFn()); + return JSIConverter>::toJSI( + runtime, std::make_shared(texture, "", false)); + } catch (...) { + // fall through + } + auto global = runtime.global(); + if (global.hasProperty(runtime, "setImmediate")) { + auto setImmediate = global.getPropertyAsFunction(runtime, "setImmediate"); + setImmediate.call(runtime, makeFn()); + } else if (global.hasProperty(runtime, "setTimeout")) { + auto setTimeout = global.getPropertyAsFunction(runtime, "setTimeout"); + setTimeout.call(runtime, makeFn(), jsi::Value(0)); + } // Pass reportsMemoryPressure=false to avoid triggering spurious Hermes GC // cycles every frame since the canvas texture doesn't own the buffer. From ecba1f98e9a0ea7d6d8289aabf219764e8c74def Mon Sep 17 00:00:00 2001 From: William Candillon Date: Sat, 16 May 2026 11:00:52 +0200 Subject: [PATCH 05/16] :wrench: --- .../webgpu/cpp/rnwgpu/RNWebGPUManager.cpp | 8 +++++ packages/webgpu/cpp/rnwgpu/RNWebGPUManager.h | 6 ++++ .../cpp/rnwgpu/api/GPUCanvasContext.cpp | 36 ++++++++----------- 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp index 5a2decc09..a99991ab8 100644 --- a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp +++ b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp @@ -50,6 +50,12 @@ namespace rnwgpu { +namespace { +jsi::Runtime *sMainJSRuntime = nullptr; +} // namespace + +jsi::Runtime *RNWebGPUManager::getMainJSRuntime() { return sMainJSRuntime; } + RNWebGPUManager::RNWebGPUManager( jsi::Runtime *jsRuntime, std::shared_ptr jsCallInvoker, @@ -57,6 +63,8 @@ RNWebGPUManager::RNWebGPUManager( : _jsRuntime(jsRuntime), _jsCallInvoker(jsCallInvoker), _platformContext(platformContext) { + sMainJSRuntime = jsRuntime; + // Register main runtime for RuntimeAwareCache BaseRuntimeAwareCache::setMainJsRuntime(_jsRuntime); diff --git a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.h b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.h index 2043c9658..24a59452a 100644 --- a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.h +++ b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.h @@ -34,6 +34,12 @@ class RNWebGPUManager { */ static void installWebGPUWorkletHelpers(jsi::Runtime &runtime); + /** + * Returns the main JS runtime registered when the module was initialized. + * Used to distinguish the JS thread (Hermes) from worklet runtimes. + */ + static jsi::Runtime *getMainJSRuntime(); + private: jsi::Runtime *_jsRuntime; std::shared_ptr _jsCallInvoker; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp index cad67a7c6..2cd6e55a2 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp @@ -55,28 +55,20 @@ jsi::Value GPUCanvasContext::getCurrentTexture(jsi::Runtime &runtime, surfaceInfo->present(); return jsi::Value::undefined(); }; - auto makeFn = [&]() { - return jsi::Function::createFromHostFunction( - runtime, jsi::PropNameID::forAscii(runtime, "WebGPUPresent"), 0, - presentCb); - }; - // Try queueMicrotask first (Hermes JS thread). If the runtime disables - // microtasks (e.g. Worklets), fall back to setImmediate, then setTimeout — - // both have end-of-current-task semantics with no display latency. - try { - runtime.queueMicrotask(makeFn()); - return JSIConverter>::toJSI( - runtime, std::make_shared(texture, "", false)); - } catch (...) { - // fall through - } - auto global = runtime.global(); - if (global.hasProperty(runtime, "setImmediate")) { - auto setImmediate = global.getPropertyAsFunction(runtime, "setImmediate"); - setImmediate.call(runtime, makeFn()); - } else if (global.hasProperty(runtime, "setTimeout")) { - auto setTimeout = global.getPropertyAsFunction(runtime, "setTimeout"); - setTimeout.call(runtime, makeFn(), jsi::Value(0)); + auto fn = jsi::Function::createFromHostFunction( + runtime, jsi::PropNameID::forAscii(runtime, "WebGPUPresent"), 0, + presentCb); + + // On the main JS runtime (Hermes), schedule the present as a microtask — + // it runs at end of current task with no display latency. + // On other runtimes (Worklets), microtasks are disabled, so use + // setTimeout(fn, 0) which gives the same end-of-task semantics. + if (&runtime == RNWebGPUManager::getMainJSRuntime()) { + runtime.queueMicrotask(std::move(fn)); + } else { + auto setTimeout = + runtime.global().getPropertyAsFunction(runtime, "setTimeout"); + setTimeout.call(runtime, fn, jsi::Value(0)); } // Pass reportsMemoryPressure=false to avoid triggering spurious Hermes GC From 17cc7bac6e62c584ab0abe429ef62e7f099c0418 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Sat, 16 May 2026 11:07:40 +0200 Subject: [PATCH 06/16] :wrench: --- packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp | 8 -------- packages/webgpu/cpp/rnwgpu/RNWebGPUManager.h | 6 ------ .../webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp | 18 ++++++++++-------- 3 files changed, 10 insertions(+), 22 deletions(-) diff --git a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp index a99991ab8..5a2decc09 100644 --- a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp +++ b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp @@ -50,12 +50,6 @@ namespace rnwgpu { -namespace { -jsi::Runtime *sMainJSRuntime = nullptr; -} // namespace - -jsi::Runtime *RNWebGPUManager::getMainJSRuntime() { return sMainJSRuntime; } - RNWebGPUManager::RNWebGPUManager( jsi::Runtime *jsRuntime, std::shared_ptr jsCallInvoker, @@ -63,8 +57,6 @@ RNWebGPUManager::RNWebGPUManager( : _jsRuntime(jsRuntime), _jsCallInvoker(jsCallInvoker), _platformContext(platformContext) { - sMainJSRuntime = jsRuntime; - // Register main runtime for RuntimeAwareCache BaseRuntimeAwareCache::setMainJsRuntime(_jsRuntime); diff --git a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.h b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.h index 24a59452a..2043c9658 100644 --- a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.h +++ b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.h @@ -34,12 +34,6 @@ class RNWebGPUManager { */ static void installWebGPUWorkletHelpers(jsi::Runtime &runtime); - /** - * Returns the main JS runtime registered when the module was initialized. - * Used to distinguish the JS thread (Hermes) from worklet runtimes. - */ - static jsi::Runtime *getMainJSRuntime(); - private: jsi::Runtime *_jsRuntime; std::shared_ptr _jsCallInvoker; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp index 2cd6e55a2..1f7d7a402 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp @@ -59,15 +59,17 @@ jsi::Value GPUCanvasContext::getCurrentTexture(jsi::Runtime &runtime, runtime, jsi::PropNameID::forAscii(runtime, "WebGPUPresent"), 0, presentCb); - // On the main JS runtime (Hermes), schedule the present as a microtask — - // it runs at end of current task with no display latency. - // On other runtimes (Worklets), microtasks are disabled, so use - // setTimeout(fn, 0) which gives the same end-of-task semantics. - if (&runtime == RNWebGPUManager::getMainJSRuntime()) { - runtime.queueMicrotask(std::move(fn)); + // If the runtime exposes queueMicrotask as a global (Hermes JS thread), + // schedule the present as a microtask — runs at end of current task with no + // display latency. Otherwise (Worklets, which disables microtasks), fall + // back to setTimeout(fn, 0) which gives the same end-of-task semantics. + auto global = runtime.global(); + if (global.hasProperty(runtime, "queueMicrotask")) { + auto queueMicrotask = + global.getPropertyAsFunction(runtime, "queueMicrotask"); + queueMicrotask.call(runtime, fn); } else { - auto setTimeout = - runtime.global().getPropertyAsFunction(runtime, "setTimeout"); + auto setTimeout = global.getPropertyAsFunction(runtime, "setTimeout"); setTimeout.call(runtime, fn, jsi::Value(0)); } From 4dfc66a27cbbf1b353691ca3817401a487a5d350 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Sat, 16 May 2026 13:48:50 +0200 Subject: [PATCH 07/16] :wrench: --- README.md | 2 +- apps/example/src/App.tsx | 2 + apps/example/src/Diagnostics/PresentRace.tsx | 139 ++++++++++++++++++ apps/example/src/Home.tsx | 4 + apps/example/src/Route.ts | 1 + packages/webgpu/README.md | 2 +- packages/webgpu/android/cpp/cpp-adapter.cpp | 6 + .../main/java/com/webgpu/WebGPUModule.java | 50 +++++++ packages/webgpu/apple/WebGPUModule.mm | 42 +++++- packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h | 62 ++++++-- .../cpp/rnwgpu/api/GPUCanvasContext.cpp | 38 +---- .../webgpu/cpp/rnwgpu/api/GPUCanvasContext.h | 4 +- 12 files changed, 303 insertions(+), 49 deletions(-) create mode 100644 apps/example/src/Diagnostics/PresentRace.tsx diff --git a/README.md b/README.md index 2395b3046..fb534b5de 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ ctx.canvas.height = ctx.canvas.clientHeight * PixelRatio.get(); ### Frame Scheduling -Frame presentation is automatic. Submitted frames are presented on the next display vsync via a native display link (CADisplayLink on iOS, Choreographer on Android), matching the behavior of `GPUCanvasContext` on the Web. +Frame presentation is automatic, matching the behavior of `GPUCanvasContext` on the Web. A native display link (CADisplayLink on iOS, Choreographer on Android) ticks once per vsync and presents any surface whose texture was acquired during a previous vsync interval, which gives your render code the full frame between two vsyncs to encode and submit. Short `await`s between `getCurrentTexture()` and `device.queue.submit(...)` are safe (microtasks drain before the next vsync); however, if your render code takes longer than one frame to submit, the present will fire before submit and you will see a stale frame. ### Canvas Transparency diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index 667f87164..c15710ecf 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -35,6 +35,7 @@ import { ComputeToys } from "./ComputeToys"; import { Reanimated } from "./Reanimated"; import { AsyncStarvation } from "./Diagnostics/AsyncStarvation"; import { DeviceLostHang } from "./Diagnostics/DeviceLostHang"; +import { PresentRace } from "./Diagnostics/PresentRace"; import { StorageBufferVertices } from "./StorageBufferVertices"; // The two lines below are needed by three.js @@ -93,6 +94,7 @@ function App() { + boolean, +) => { + context.configure({ + device, + format, + alphaMode: "premultiplied", + }); + + const frame = async () => { + if (shouldStop()) { + return; + } + + const texture = context.getCurrentTexture(); + + if (mode === "microtask") { + await Promise.resolve(); + } else if (mode === "longAwait") { + // Sleeps past one vsync interval, so the display-link tick presents + // the surface before our submit lands. + await new Promise((resolve) => setTimeout(resolve, 30)); + } + + const time = Date.now() / 1000; + const r = (Math.sin(time * 2.0) + 1) / 2; + const g = (Math.sin(time * 1.5 + Math.PI / 3) + 1) / 2; + const b = (Math.sin(time * 1.0 + Math.PI / 2) + 1) / 2; + + const commandEncoder = device.createCommandEncoder(); + const passEncoder = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: texture.createView(), + clearValue: [r, g, b, 1], + loadOp: "clear", + storeOp: "store", + }, + ], + }); + passEncoder.end(); + device.queue.submit([commandEncoder.finish()]); + + requestAnimationFrame(() => { + frame(); + }); + }; + + frame(); +}; + +const Panel = ({ mode, label }: { mode: Mode; label: string }) => { + const ref = useRef(null); + useEffect(() => { + let stopped = false; + (async () => { + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) { + return; + } + const device = await adapter.requestDevice(); + const context = ref.current?.getContext("webgpu"); + if (!context) { + return; + } + const format = navigator.gpu.getPreferredCanvasFormat(); + runAnimatedClear(device, context, format, mode, () => stopped); + })(); + return () => { + stopped = true; + }; + }, [mode]); + return ( + + {label} + + + ); +}; + +export const PresentRace = () => { + return ( + + + All three panels animate a clear color via requestAnimationFrame. The + present is driven by a native display link, so a short microtask await + between acquire and submit is safe. A long await (greater than one + vsync interval) still races: the display link presents the surface + before submit lands, producing a stale frame. + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#111", + padding: 12, + }, + intro: { + color: "#f5f5f5", + fontSize: 13, + lineHeight: 18, + marginBottom: 12, + }, + panel: { + flex: 1, + marginBottom: 12, + }, + label: { + color: "#f5f5f5", + fontSize: 13, + marginBottom: 6, + }, + canvas: { + flex: 1, + }, +}); diff --git a/apps/example/src/Home.tsx b/apps/example/src/Home.tsx index 9272dfec9..17cca39d0 100644 --- a/apps/example/src/Home.tsx +++ b/apps/example/src/Home.tsx @@ -123,6 +123,10 @@ export const examples = [ screen: "DeviceLostHang", title: "⚠️ Device Lost Hang", }, + { + screen: "PresentRace", + title: "⚠️ Present Race (await before submit)", + }, { screen: "StorageBufferVertices", title: "💾 Storage Buffer Vertices", diff --git a/apps/example/src/Route.ts b/apps/example/src/Route.ts index 152923e1e..13ebd97ad 100644 --- a/apps/example/src/Route.ts +++ b/apps/example/src/Route.ts @@ -28,5 +28,6 @@ export type Routes = { Reanimated: undefined; AsyncStarvation: undefined; DeviceLostHang: undefined; + PresentRace: undefined; StorageBufferVertices: undefined; }; diff --git a/packages/webgpu/README.md b/packages/webgpu/README.md index 2395b3046..fb534b5de 100644 --- a/packages/webgpu/README.md +++ b/packages/webgpu/README.md @@ -172,7 +172,7 @@ ctx.canvas.height = ctx.canvas.clientHeight * PixelRatio.get(); ### Frame Scheduling -Frame presentation is automatic. Submitted frames are presented on the next display vsync via a native display link (CADisplayLink on iOS, Choreographer on Android), matching the behavior of `GPUCanvasContext` on the Web. +Frame presentation is automatic, matching the behavior of `GPUCanvasContext` on the Web. A native display link (CADisplayLink on iOS, Choreographer on Android) ticks once per vsync and presents any surface whose texture was acquired during a previous vsync interval, which gives your render code the full frame between two vsyncs to encode and submit. Short `await`s between `getCurrentTexture()` and `device.queue.submit(...)` are safe (microtasks drain before the next vsync); however, if your render code takes longer than one frame to submit, the present will fire before submit and you will see a stale frame. ### Canvas Transparency diff --git a/packages/webgpu/android/cpp/cpp-adapter.cpp b/packages/webgpu/android/cpp/cpp-adapter.cpp index 2a441c218..3fbec8c0a 100644 --- a/packages/webgpu/android/cpp/cpp-adapter.cpp +++ b/packages/webgpu/android/cpp/cpp-adapter.cpp @@ -12,6 +12,7 @@ #include "AndroidPlatformContext.h" #include "GPUCanvasContext.h" #include "RNWebGPUManager.h" +#include "SurfaceRegistry.h" #define LOG_TAG "WebGPUModule" @@ -68,4 +69,9 @@ extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onSurfaceDestroy( JNIEnv *env, jobject thiz, jint contextId) { auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); registry.removeSurfaceInfo(contextId); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_webgpu_WebGPUModule_nativeTick(JNIEnv * /*env*/, jobject /*thiz*/) { + rnwgpu::SurfaceRegistry::getInstance().tickAll(); } \ No newline at end of file diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java index 75375700e..c0a91b29a 100644 --- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java +++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java @@ -1,6 +1,7 @@ package com.webgpu; import android.util.Log; +import android.view.Choreographer; import androidx.annotation.OptIn; @@ -24,6 +25,19 @@ public class WebGPUModule extends NativeWebGPUModuleSpec { System.loadLibrary("react-native-wgpu"); // Load the C++ library } + private volatile boolean mTickActive = false; + + private final Choreographer.FrameCallback mFrameCallback = new Choreographer.FrameCallback() { + @Override + public void doFrame(long frameTimeNanos) { + if (!mTickActive) { + return; + } + nativeTick(); + Choreographer.getInstance().postFrameCallback(this); + } + }; + public WebGPUModule(ReactApplicationContext reactContext) { super(reactContext); // Initialize the C++ module @@ -41,10 +55,46 @@ public boolean install() { throw new RuntimeException("React Native's BlobModule was not found!"); } initializeNative(jsContext.get(), (CallInvokerHolderImpl) callInvokerHolder, blobModule); + startVsyncTicks(); return true; } + @Override + public void invalidate() { + stopVsyncTicks(); + super.invalidate(); + } + + private void startVsyncTicks() { + if (mTickActive) { + return; + } + mTickActive = true; + // Choreographer instances are per-thread; the FrameCallback fires on the + // thread that posted it. Posting from the UI thread keeps the callback + // there, which is required for Vulkan/Surface ops on Android. + getReactApplicationContext().runOnUiQueueThread(new Runnable() { + @Override + public void run() { + Choreographer.getInstance().postFrameCallback(mFrameCallback); + } + }); + } + + private void stopVsyncTicks() { + mTickActive = false; + getReactApplicationContext().runOnUiQueueThread(new Runnable() { + @Override + public void run() { + Choreographer.getInstance().removeFrameCallback(mFrameCallback); + } + }); + } + @OptIn(markerClass = FrameworkAPI.class) @DoNotStrip private native void initializeNative(long jsRuntime, CallInvokerHolderImpl jsInvoker, BlobModule blobModule); + + @DoNotStrip + private native void nativeTick(); } diff --git a/packages/webgpu/apple/WebGPUModule.mm b/packages/webgpu/apple/WebGPUModule.mm index 99580aa14..a31728cb9 100644 --- a/packages/webgpu/apple/WebGPUModule.mm +++ b/packages/webgpu/apple/WebGPUModule.mm @@ -1,7 +1,9 @@ #import "WebGPUModule.h" #include "ApplePlatformContext.h" #import "GPUCanvasContext.h" +#include "SurfaceRegistry.h" +#import #import #import #import @@ -20,7 +22,9 @@ @interface RCTBridge (JSIRuntime) - (void *)runtime; @end -@implementation WebGPUModule +@implementation WebGPUModule { + CADisplayLink *_displayLink; +} RCT_EXPORT_MODULE(WebGPUModule) @@ -42,7 +46,42 @@ + (BOOL)requiresMainQueueSetup { } - (void)invalidate { + [self stopDisplayLink]; webgpuManager = nil; + [super invalidate]; +} + +- (void)startDisplayLink { + if (_displayLink != nil) { + return; + } + // CADisplayLink callbacks must be scheduled on a run loop. The main run + // loop is the safest choice: CAMetalLayer ops are main-thread-only, and + // SurfaceInfo's mutex serialises access with the JS thread. + dispatch_async(dispatch_get_main_queue(), ^{ + if (self->_displayLink != nil) { + return; + } + self->_displayLink = [CADisplayLink displayLinkWithTarget:self + selector:@selector(onVsync:)]; + [self->_displayLink addToRunLoop:[NSRunLoop mainRunLoop] + forMode:NSRunLoopCommonModes]; + }); +} + +- (void)stopDisplayLink { + CADisplayLink *link = _displayLink; + _displayLink = nil; + if (link == nil) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + [link invalidate]; + }); +} + +- (void)onVsync:(CADisplayLink *)__unused link { + rnwgpu::SurfaceRegistry::getInstance().tickAll(); } - (std::shared_ptr)getManager { @@ -78,6 +117,7 @@ - (void)invalidate { std::make_shared(); webgpuManager = std::make_shared(runtime, jsInvoker, platformContext); + [self startDisplayLink]; return @true; } diff --git a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h index c9da6d1bd..1b7cee2a8 100644 --- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h +++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h @@ -1,9 +1,13 @@ #pragma once +#include +#include #include +#include #include #include #include +#include #include "webgpu/webgpu_cpp.h" @@ -123,23 +127,26 @@ class SurfaceInfo { void present() { std::unique_lock lock(_mutex); - if (surface && _textureAcquired) { -#ifdef __APPLE__ - if (config.device) { - dawn::native::metal::WaitForCommandsToBeScheduled(config.device.Get()); - } -#endif - surface.Present(); - _textureAcquired = false; + _presentLocked(); + } + + // Called by the display-link tick. Presents only if the texture was acquired + // in a strictly earlier frame, so the user's render code (which runs between + // vsyncs) has finished encoding and submitting before we present. + void maybePresentForFrame(uint64_t currentFrame) { + std::unique_lock lock(_mutex); + if (_acquiredAtFrame && *_acquiredAtFrame < currentFrame) { + _presentLocked(); } } - wgpu::Texture getCurrentTexture() { + wgpu::Texture getCurrentTexture(uint64_t currentFrame) { std::unique_lock lock(_mutex); if (surface) { wgpu::SurfaceTexture surfaceTexture; surface.GetCurrentTexture(&surfaceTexture); _textureAcquired = true; + _acquiredAtFrame = currentFrame; return surfaceTexture.texture; } else { return texture; @@ -182,6 +189,20 @@ class SurfaceInfo { } } + // Caller must hold _mutex as unique_lock. + void _presentLocked() { + if (surface && _textureAcquired) { +#ifdef __APPLE__ + if (config.device) { + dawn::native::metal::WaitForCommandsToBeScheduled(config.device.Get()); + } +#endif + surface.Present(); + _textureAcquired = false; + _acquiredAtFrame.reset(); + } + } + mutable std::shared_mutex _mutex; void *nativeSurface = nullptr; wgpu::Surface surface = nullptr; @@ -191,6 +212,7 @@ class SurfaceInfo { int width; int height; bool _textureAcquired = false; + std::optional _acquiredAtFrame; }; class SurfaceRegistry { @@ -237,10 +259,32 @@ class SurfaceRegistry { return info; } + // Monotonically increasing tick counter. getCurrentTexture stamps the + // surface with the value seen at acquisition time; tickAll() presents + // surfaces whose stamp is strictly less than the current counter, which + // guarantees the JS render code between two vsyncs has finished. + uint64_t getCurrentFrame() const { return _frameCounter.load(); } + + void tickAll() { + auto current = _frameCounter.fetch_add(1, std::memory_order_acq_rel) + 1; + std::vector> snapshot; + { + std::shared_lock lock(_mutex); + snapshot.reserve(_registry.size()); + for (auto &entry : _registry) { + snapshot.push_back(entry.second); + } + } + for (auto &info : snapshot) { + info->maybePresentForFrame(current); + } + } + private: SurfaceRegistry() = default; mutable std::shared_mutex _mutex; std::unordered_map> _registry; + std::atomic _frameCounter{0}; }; } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp index 1f7d7a402..9c84b2e00 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp @@ -31,10 +31,7 @@ void GPUCanvasContext::configure( void GPUCanvasContext::unconfigure() {} -jsi::Value GPUCanvasContext::getCurrentTexture(jsi::Runtime &runtime, - const jsi::Value & /*thisVal*/, - const jsi::Value * /*args*/, - size_t /*count*/) { +std::shared_ptr GPUCanvasContext::getCurrentTexture() { auto prevSize = _surfaceInfo->getConfig(); auto width = _canvas->getWidth(); auto height = _canvas->getHeight(); @@ -45,38 +42,11 @@ jsi::Value GPUCanvasContext::getCurrentTexture(jsi::Runtime &runtime, auto size = _surfaceInfo->getSize(); _canvas->setClientWidth(size.width); _canvas->setClientHeight(size.height); - auto texture = _surfaceInfo->getCurrentTexture(); - - auto surfaceInfo = _surfaceInfo; - auto presentCb = [surfaceInfo](jsi::Runtime & /*rt*/, - const jsi::Value & /*thisValue*/, - const jsi::Value * /*args*/, - size_t /*count*/) -> jsi::Value { - surfaceInfo->present(); - return jsi::Value::undefined(); - }; - auto fn = jsi::Function::createFromHostFunction( - runtime, jsi::PropNameID::forAscii(runtime, "WebGPUPresent"), 0, - presentCb); - - // If the runtime exposes queueMicrotask as a global (Hermes JS thread), - // schedule the present as a microtask — runs at end of current task with no - // display latency. Otherwise (Worklets, which disables microtasks), fall - // back to setTimeout(fn, 0) which gives the same end-of-task semantics. - auto global = runtime.global(); - if (global.hasProperty(runtime, "queueMicrotask")) { - auto queueMicrotask = - global.getPropertyAsFunction(runtime, "queueMicrotask"); - queueMicrotask.call(runtime, fn); - } else { - auto setTimeout = global.getPropertyAsFunction(runtime, "setTimeout"); - setTimeout.call(runtime, fn, jsi::Value(0)); - } - + auto currentFrame = SurfaceRegistry::getInstance().getCurrentFrame(); + auto texture = _surfaceInfo->getCurrentTexture(currentFrame); // 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); + return std::make_shared(texture, "", false); } } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h index f38aa27e9..12b0b4475 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h @@ -53,9 +53,7 @@ class GPUCanvasContext : public NativeObject { inline const wgpu::Surface get() { return nullptr; } void configure(std::shared_ptr configuration); void unconfigure(); - jsi::Value getCurrentTexture(jsi::Runtime &runtime, - const jsi::Value &thisVal, - const jsi::Value *args, size_t count); + std::shared_ptr getCurrentTexture(); private: std::shared_ptr _canvas; From 9c7d46defa79d8e4a79414249efa076bf289aa11 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Sat, 16 May 2026 15:23:51 +0200 Subject: [PATCH 08/16] :wrench: --- packages/webgpu/apple/WebGPUModule.mm | 10 ++++-- packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h | 33 +++++++++++++++++--- packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp | 4 +++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/webgpu/apple/WebGPUModule.mm b/packages/webgpu/apple/WebGPUModule.mm index a31728cb9..2f081de55 100644 --- a/packages/webgpu/apple/WebGPUModule.mm +++ b/packages/webgpu/apple/WebGPUModule.mm @@ -24,6 +24,7 @@ - (void *)runtime; @implementation WebGPUModule { CADisplayLink *_displayLink; + BOOL _displayLinkActive; } RCT_EXPORT_MODULE(WebGPUModule) @@ -52,6 +53,7 @@ - (void)invalidate { } - (void)startDisplayLink { + _displayLinkActive = YES; if (_displayLink != nil) { return; } @@ -59,17 +61,21 @@ - (void)startDisplayLink { // loop is the safest choice: CAMetalLayer ops are main-thread-only, and // SurfaceInfo's mutex serialises access with the JS thread. dispatch_async(dispatch_get_main_queue(), ^{ + if (!self->_displayLinkActive) { + return; + } if (self->_displayLink != nil) { return; } - self->_displayLink = [CADisplayLink displayLinkWithTarget:self - selector:@selector(onVsync:)]; + self->_displayLink = + [CADisplayLink displayLinkWithTarget:self selector:@selector(onVsync:)]; [self->_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; }); } - (void)stopDisplayLink { + _displayLinkActive = NO; CADisplayLink *link = _displayLink; _displayLink = nil; if (link == nil) { diff --git a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h index 1b7cee2a8..ad250d115 100644 --- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h +++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h @@ -130,22 +130,31 @@ class SurfaceInfo { _presentLocked(); } - // Called by the display-link tick. Presents only if the texture was acquired - // in a strictly earlier frame, so the user's render code (which runs between - // vsyncs) has finished encoding and submitting before we present. + // Called by the display-link tick. Presents only after the app has submitted + // work for an acquired texture and at least one tick has passed since + // acquire. void maybePresentForFrame(uint64_t currentFrame) { std::unique_lock lock(_mutex); - if (_acquiredAtFrame && *_acquiredAtFrame < currentFrame) { + if (_readyToPresent && _acquiredAtFrame && + *_acquiredAtFrame < currentFrame) { _presentLocked(); } } + void markSubmittedForPresentation() { + std::unique_lock lock(_mutex); + if (_textureAcquired) { + _readyToPresent = true; + } + } + wgpu::Texture getCurrentTexture(uint64_t currentFrame) { std::unique_lock lock(_mutex); if (surface) { wgpu::SurfaceTexture surfaceTexture; surface.GetCurrentTexture(&surfaceTexture); _textureAcquired = true; + _readyToPresent = false; _acquiredAtFrame = currentFrame; return surfaceTexture.texture; } else { @@ -199,6 +208,7 @@ class SurfaceInfo { #endif surface.Present(); _textureAcquired = false; + _readyToPresent = false; _acquiredAtFrame.reset(); } } @@ -212,6 +222,7 @@ class SurfaceInfo { int width; int height; bool _textureAcquired = false; + bool _readyToPresent = false; std::optional _acquiredAtFrame; }; @@ -280,6 +291,20 @@ class SurfaceRegistry { } } + void markSubmittedSurfacesForPresentation() { + std::vector> snapshot; + { + std::shared_lock lock(_mutex); + snapshot.reserve(_registry.size()); + for (auto &entry : _registry) { + snapshot.push_back(entry.second); + } + } + for (auto &info : snapshot) { + info->markSubmittedForPresentation(); + } + } + private: SurfaceRegistry() = default; mutable std::shared_mutex _mutex; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp index d3c0d65af..b06483576 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp @@ -2,9 +2,12 @@ #include #include +#include +#include #include #include "Convertors.h" +#include "SurfaceRegistry.h" namespace rnwgpu { @@ -26,6 +29,7 @@ void GPUQueue::submit( return; } _instance.Submit(bufs_size, bufs.data()); + SurfaceRegistry::getInstance().markSubmittedSurfacesForPresentation(); } void GPUQueue::writeBuffer(std::shared_ptr buffer, From 4117b2a458d733f50b20c81098e8eb109b904b88 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Sat, 16 May 2026 15:49:16 +0200 Subject: [PATCH 09/16] :wrench: --- .github/actions/setup/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 359d3600d..dc7c63064 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -18,8 +18,8 @@ runs: run: | HASH_FILE="/tmp/.yarn-lock-hash" CURRENT_HASH=$(shasum yarn.lock | cut -d' ' -f1) - if [ -f "$HASH_FILE" ] && [ "$(cat "$HASH_FILE")" = "$CURRENT_HASH" ] && [ -d "node_modules" ]; then - echo "yarn.lock unchanged and node_modules exists — skipping install" + if [ -f "$HASH_FILE" ] && [ "$(cat "$HASH_FILE")" = "$CURRENT_HASH" ] && [ -d "node_modules" ] && [ -d "apps/example/node_modules" ] && [ -d "packages/webgpu/node_modules" ]; then + echo "yarn.lock unchanged and workspace node_modules present, skipping install" else yarn install --immutable echo "$CURRENT_HASH" > "$HASH_FILE" From b5906c3ca503cc7b2254813369de9dbc1295e0fe Mon Sep 17 00:00:00 2001 From: William Candillon Date: Sat, 16 May 2026 17:21:19 +0200 Subject: [PATCH 10/16] :wrench: --- apps/example/src/useClient.ts | 2 +- .../cpp/rnwgpu/api/GPUCanvasContext.cpp | 2 +- .../webgpu/cpp/rnwgpu/api/GPUCommandBuffer.h | 16 +++++++-- .../cpp/rnwgpu/api/GPUCommandEncoder.cpp | 36 ++++++++++++++++++- .../webgpu/cpp/rnwgpu/api/GPUCommandEncoder.h | 6 ++++ packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp | 23 +++++++++++- packages/webgpu/cpp/rnwgpu/api/GPUTexture.cpp | 3 +- packages/webgpu/cpp/rnwgpu/api/GPUTexture.h | 11 ++++-- .../webgpu/cpp/rnwgpu/api/GPUTextureView.h | 12 +++++-- 9 files changed, 100 insertions(+), 11 deletions(-) diff --git a/apps/example/src/useClient.ts b/apps/example/src/useClient.ts index d9b505cd8..2c98c3900 100644 --- a/apps/example/src/useClient.ts +++ b/apps/example/src/useClient.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { Platform } from "react-native"; -const ANDROID_WS_HOST = "10.0.2.2"; +const ANDROID_WS_HOST = "192.168.1.6"; const IOS_WS_HOST = "localhost"; const HOST = Platform.OS === "android" ? ANDROID_WS_HOST : IOS_WS_HOST; const PORT = 4242; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp index 9c84b2e00..fde0dffa9 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp @@ -46,7 +46,7 @@ std::shared_ptr GPUCanvasContext::getCurrentTexture() { auto texture = _surfaceInfo->getCurrentTexture(currentFrame); // 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); + return std::make_shared(texture, "", false, _surfaceInfo); } } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCommandBuffer.h b/packages/webgpu/cpp/rnwgpu/api/GPUCommandBuffer.h index 4f537d084..c656b7d0f 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUCommandBuffer.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUCommandBuffer.h @@ -1,6 +1,9 @@ #pragma once +#include #include +#include +#include #include "Unions.h" @@ -12,12 +15,17 @@ namespace rnwgpu { namespace jsi = facebook::jsi; +class SurfaceInfo; + class GPUCommandBuffer : public NativeObject { public: static constexpr const char *CLASS_NAME = "GPUCommandBuffer"; - explicit GPUCommandBuffer(wgpu::CommandBuffer instance, std::string label) - : NativeObject(CLASS_NAME), _instance(instance), _label(label) {} + explicit GPUCommandBuffer( + wgpu::CommandBuffer instance, std::string label, + std::vector> presentableSurfaces = {}) + : NativeObject(CLASS_NAME), _instance(instance), _label(label), + _presentableSurfaces(std::move(presentableSurfaces)) {} public: std::string getBrand() { return CLASS_NAME; } @@ -36,10 +44,14 @@ class GPUCommandBuffer : public NativeObject { } inline const wgpu::CommandBuffer get() { return _instance; } + const std::vector> &getPresentableSurfaces() { + return _presentableSurfaces; + } private: wgpu::CommandBuffer _instance; std::string _label; + std::vector> _presentableSurfaces; }; } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCommandEncoder.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUCommandEncoder.cpp index 7ef1ce064..a75327716 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUCommandEncoder.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUCommandEncoder.cpp @@ -34,7 +34,8 @@ std::shared_ptr GPUCommandEncoder::finish( auto commandBuffer = _instance.Finish(&desc); return std::make_shared( commandBuffer, - descriptor.has_value() ? descriptor.value()->label.value_or("") : ""); + descriptor.has_value() ? descriptor.value()->label.value_or("") : "", + _presentableSurfaces); } std::shared_ptr GPUCommandEncoder::beginRenderPass( @@ -56,6 +57,23 @@ std::shared_ptr GPUCommandEncoder::beginRenderPass( throw std::runtime_error("PUCommandEncoder::beginRenderPass(): couldn't " "get GPURenderPassDescriptor"); } + for (const auto &attachment : descriptor->colorAttachments) { + if (std::holds_alternative>( + attachment)) { + auto colorAttachment = + std::get>(attachment); + if (colorAttachment) { + if (colorAttachment->view) { + addPresentableSurface(colorAttachment->view->getSurfaceInfo()); + } + if (colorAttachment->resolveTarget.has_value() && + colorAttachment->resolveTarget.value()) { + addPresentableSurface( + colorAttachment->resolveTarget.value()->getSurfaceInfo()); + } + } + } + } auto renderPass = _instance.BeginRenderPass(&desc); return std::make_shared(renderPass, descriptor->label.value_or("")); @@ -91,6 +109,7 @@ void GPUCommandEncoder::copyTextureToTexture( !conv(size, copySize)) { return; } + addPresentableSurface(destination->texture->getSurfaceInfo()); _instance.CopyTextureToTexture(&src, &dst, &size); } @@ -148,6 +167,7 @@ void GPUCommandEncoder::copyBufferToTexture( return; } + addPresentableSurface(destination->texture->getSurfaceInfo()); _instance.CopyBufferToTexture(&src, &dst, &size); } @@ -176,4 +196,18 @@ void GPUCommandEncoder::insertDebugMarker(std::string markerLabel) { _instance.InsertDebugMarker(markerLabel.c_str()); } +void GPUCommandEncoder::addPresentableSurface( + std::weak_ptr surfaceInfo) { + auto surface = surfaceInfo.lock(); + if (!surface) { + return; + } + for (const auto &existing : _presentableSurfaces) { + if (existing.lock() == surface) { + return; + } + } + _presentableSurfaces.push_back(surface); +} + } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCommandEncoder.h b/packages/webgpu/cpp/rnwgpu/api/GPUCommandEncoder.h index 153426785..be47f851f 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUCommandEncoder.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUCommandEncoder.h @@ -2,6 +2,7 @@ #include #include +#include #include "Unions.h" @@ -25,6 +26,8 @@ namespace rnwgpu { namespace jsi = facebook::jsi; +class SurfaceInfo; + class GPUCommandEncoder : public NativeObject { public: static constexpr const char *CLASS_NAME = "GPUCommandEncoder"; @@ -104,8 +107,11 @@ class GPUCommandEncoder : public NativeObject { inline const wgpu::CommandEncoder get() { return _instance; } private: + void addPresentableSurface(std::weak_ptr surfaceInfo); + wgpu::CommandEncoder _instance; std::string _label; + std::vector> _presentableSurfaces; }; } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp index b06483576..539cb5288 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp @@ -29,7 +29,28 @@ void GPUQueue::submit( return; } _instance.Submit(bufs_size, bufs.data()); - SurfaceRegistry::getInstance().markSubmittedSurfacesForPresentation(); + std::vector> presentableSurfaces; + for (const auto &commandBuffer : commandBuffers) { + for (const auto &weakSurface : commandBuffer->getPresentableSurfaces()) { + auto surface = weakSurface.lock(); + if (!surface) { + continue; + } + bool alreadyTracked = false; + for (const auto &presentableSurface : presentableSurfaces) { + if (presentableSurface == surface) { + alreadyTracked = true; + break; + } + } + if (!alreadyTracked) { + presentableSurfaces.push_back(surface); + } + } + } + for (const auto &surface : presentableSurfaces) { + surface->markSubmittedForPresentation(); + } } void GPUQueue::writeBuffer(std::shared_ptr buffer, diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUTexture.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUTexture.cpp index f1d84b99c..859262dfb 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUTexture.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUTexture.cpp @@ -19,7 +19,8 @@ std::shared_ptr GPUTexture::createView( auto view = _instance.CreateView(&desc); return std::make_shared( view, - descriptor.has_value() ? descriptor.value()->label.value_or("") : ""); + descriptor.has_value() ? descriptor.value()->label.value_or("") : "", + _surfaceInfo); } uint32_t GPUTexture::getWidth() { return _instance.GetWidth(); } diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUTexture.h b/packages/webgpu/cpp/rnwgpu/api/GPUTexture.h index 772cf3788..2a5b83240 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUTexture.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUTexture.h @@ -3,6 +3,7 @@ #include #include #include +#include #include "Unions.h" @@ -17,14 +18,18 @@ namespace rnwgpu { namespace jsi = facebook::jsi; +class SurfaceInfo; + class GPUTexture : public NativeObject { public: static constexpr const char *CLASS_NAME = "GPUTexture"; explicit GPUTexture(wgpu::Texture instance, std::string label, - bool reportsMemoryPressure = true) + bool reportsMemoryPressure = true, + std::weak_ptr surfaceInfo = {}) : NativeObject(CLASS_NAME), _instance(instance), _label(label), - _reportsMemoryPressure(reportsMemoryPressure) {} + _reportsMemoryPressure(reportsMemoryPressure), + _surfaceInfo(std::move(surfaceInfo)) {} public: std::string getBrand() { return CLASS_NAME; } @@ -68,6 +73,7 @@ class GPUTexture : public NativeObject { } inline const wgpu::Texture get() { return _instance; } + std::weak_ptr getSurfaceInfo() { return _surfaceInfo; } size_t getMemoryPressure() override { if (!_reportsMemoryPressure) { @@ -157,6 +163,7 @@ class GPUTexture : public NativeObject { wgpu::Texture _instance; std::string _label; bool _reportsMemoryPressure = true; + std::weak_ptr _surfaceInfo; }; } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUTextureView.h b/packages/webgpu/cpp/rnwgpu/api/GPUTextureView.h index c37058517..cbb384171 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUTextureView.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUTextureView.h @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include "Unions.h" @@ -12,12 +14,16 @@ namespace rnwgpu { namespace jsi = facebook::jsi; +class SurfaceInfo; + class GPUTextureView : public NativeObject { public: static constexpr const char *CLASS_NAME = "GPUTextureView"; - explicit GPUTextureView(wgpu::TextureView instance, std::string label) - : NativeObject(CLASS_NAME), _instance(instance), _label(label) {} + explicit GPUTextureView(wgpu::TextureView instance, std::string label, + std::weak_ptr surfaceInfo = {}) + : NativeObject(CLASS_NAME), _instance(instance), _label(label), + _surfaceInfo(std::move(surfaceInfo)) {} public: std::string getBrand() { return CLASS_NAME; } @@ -35,10 +41,12 @@ class GPUTextureView : public NativeObject { } inline const wgpu::TextureView get() { return _instance; } + std::weak_ptr getSurfaceInfo() { return _surfaceInfo; } private: wgpu::TextureView _instance; std::string _label; + std::weak_ptr _surfaceInfo; }; } // namespace rnwgpu From cf24f5f7071694dfeddc6307a47fc5fc94bd1a04 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Sat, 16 May 2026 17:22:39 +0200 Subject: [PATCH 11/16] :arrow_up: --- packages/webgpu/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webgpu/package.json b/packages/webgpu/package.json index bda3bc9a3..cf3c7fb55 100644 --- a/packages/webgpu/package.json +++ b/packages/webgpu/package.json @@ -1,6 +1,6 @@ { "name": "react-native-wgpu", - "version": "0.5.11", + "version": "1.0.0", "description": "React Native WebGPU", "main": "lib/commonjs/index", "module": "lib/module/index", From 32723f50a6137fd943d0e6942ddd18351cbf0ee6 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Sat, 16 May 2026 17:22:58 +0200 Subject: [PATCH 12/16] :wrench: --- apps/example/src/useClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/example/src/useClient.ts b/apps/example/src/useClient.ts index 2c98c3900..d9b505cd8 100644 --- a/apps/example/src/useClient.ts +++ b/apps/example/src/useClient.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { Platform } from "react-native"; -const ANDROID_WS_HOST = "192.168.1.6"; +const ANDROID_WS_HOST = "10.0.2.2"; const IOS_WS_HOST = "localhost"; const HOST = Platform.OS === "android" ? ANDROID_WS_HOST : IOS_WS_HOST; const PORT = 4242; From 70835913f26b4e9e3f31bcdbd05cdac589514bbf Mon Sep 17 00:00:00 2001 From: William Candillon Date: Sat, 16 May 2026 17:32:43 +0200 Subject: [PATCH 13/16] Fix formatting in Frame Scheduling section of README --- packages/webgpu/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webgpu/README.md b/packages/webgpu/README.md index fb534b5de..75f105480 100644 --- a/packages/webgpu/README.md +++ b/packages/webgpu/README.md @@ -172,7 +172,7 @@ ctx.canvas.height = ctx.canvas.clientHeight * PixelRatio.get(); ### Frame Scheduling -Frame presentation is automatic, matching the behavior of `GPUCanvasContext` on the Web. A native display link (CADisplayLink on iOS, Choreographer on Android) ticks once per vsync and presents any surface whose texture was acquired during a previous vsync interval, which gives your render code the full frame between two vsyncs to encode and submit. Short `await`s between `getCurrentTexture()` and `device.queue.submit(...)` are safe (microtasks drain before the next vsync); however, if your render code takes longer than one frame to submit, the present will fire before submit and you will see a stale frame. +Frame presentation is automatic, matching the behavior of `GPUCanvasContext` on the Web. A native display link (CADisplayLink on iOS, Choreographer on Android) ticks once per vsync and presents any surface whose texture was acquired during a previous vsync interval, which gives your render code the full frame between two vsyncs to encode and submit. ### Canvas Transparency From 206d6dc1e873205f11b5d74eed63a72558718775 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Sat, 16 May 2026 17:45:06 +0200 Subject: [PATCH 14/16] :wrench: --- apps/example/src/ComputeToys/engine/index.ts | 6 +++--- apps/example/src/Diagnostics/PresentRace.tsx | 4 ++-- apps/example/src/Reanimated/Reanimated.tsx | 4 ++-- packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h | 11 +++++++++++ packages/webgpu/cpp/rnwgpu/api/GPUCommandEncoder.h | 3 +++ packages/webgpu/src/Canvas.tsx | 8 +++----- packages/webgpu/src/index.tsx | 4 ++-- packages/webgpu/src/types.ts | 4 +--- 8 files changed, 27 insertions(+), 17 deletions(-) diff --git a/apps/example/src/ComputeToys/engine/index.ts b/apps/example/src/ComputeToys/engine/index.ts index 8db2562ad..354bb5009 100644 --- a/apps/example/src/ComputeToys/engine/index.ts +++ b/apps/example/src/ComputeToys/engine/index.ts @@ -4,7 +4,7 @@ */ import { Mutex } from "async-mutex"; -import type { CanvasRef, RNCanvasContext } from "react-native-wgpu"; +import type { CanvasRef } from "react-native-wgpu"; import { Bindings } from "./bind"; import { Blitter, ColorSpace } from "./blit"; @@ -37,7 +37,7 @@ export class ComputeEngine { private device: GPUDevice; - private surface: RNCanvasContext | null = null; + private surface: GPUCanvasContext | null = null; private screenWidth = -1; private screenHeight = -1; @@ -110,7 +110,7 @@ export class ComputeEngine { } public setSurface(canvas: CanvasRef) { - const context = canvas.getContext("webgpu") as RNCanvasContext; + const context = canvas.getContext("webgpu"); if (!context) { throw new Error("WebGPU not supported"); } diff --git a/apps/example/src/Diagnostics/PresentRace.tsx b/apps/example/src/Diagnostics/PresentRace.tsx index 7d9554c51..0e0e3a60f 100644 --- a/apps/example/src/Diagnostics/PresentRace.tsx +++ b/apps/example/src/Diagnostics/PresentRace.tsx @@ -1,13 +1,13 @@ import React, { useEffect, useRef } from "react"; import { StyleSheet, Text, View } from "react-native"; -import type { CanvasRef, RNCanvasContext } from "react-native-wgpu"; +import type { CanvasRef } from "react-native-wgpu"; import { Canvas } from "react-native-wgpu"; type Mode = "sync" | "microtask" | "longAwait"; const runAnimatedClear = ( device: GPUDevice, - context: RNCanvasContext, + context: GPUCanvasContext, format: GPUTextureFormat, mode: Mode, shouldStop: () => boolean, diff --git a/apps/example/src/Reanimated/Reanimated.tsx b/apps/example/src/Reanimated/Reanimated.tsx index f6217a606..e7ce983a4 100644 --- a/apps/example/src/Reanimated/Reanimated.tsx +++ b/apps/example/src/Reanimated/Reanimated.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from "react"; import { StyleSheet, View } from "react-native"; -import type { CanvasRef, RNCanvasContext } from "react-native-wgpu"; +import type { CanvasRef } from "react-native-wgpu"; import { Canvas } from "react-native-wgpu"; import type { SharedValue } from "react-native-reanimated"; import { runOnUI, useSharedValue } from "react-native-reanimated"; @@ -10,7 +10,7 @@ import { redFragWGSL, triangleVertWGSL } from "../Triangle/triangle"; const webGPUDemo = ( runAnimation: SharedValue, device: GPUDevice, - context: RNCanvasContext, + context: GPUCanvasContext, presentationFormat: GPUTextureFormat, ) => { "worklet"; diff --git a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h index ad250d115..9681d2636 100644 --- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h +++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h @@ -57,6 +57,7 @@ class SurfaceInfo { void unconfigure() { std::unique_lock lock(_mutex); + _resetPresentationStateLocked(); if (surface) { surface.Unconfigure(); } else { @@ -66,6 +67,7 @@ class SurfaceInfo { void *switchToOffscreen() { std::unique_lock lock(_mutex); + _resetPresentationStateLocked(); // We only do this if the onscreen surface is configured. auto isConfigured = config.device != nullptr; if (isConfigured) { @@ -84,6 +86,7 @@ class SurfaceInfo { void switchToOnscreen(void *newNativeSurface, wgpu::Surface newSurface) { std::unique_lock lock(_mutex); + _resetPresentationStateLocked(); nativeSurface = newNativeSurface; surface = std::move(newSurface); // If we are comming from an offscreen context, we need to configure the new @@ -115,6 +118,7 @@ class SurfaceInfo { wgpu::Queue queue = device.GetQueue(); queue.Submit(1, &commands); surface.Present(); + _resetPresentationStateLocked(); texture = nullptr; } } @@ -213,6 +217,13 @@ class SurfaceInfo { } } + // Caller must hold _mutex as unique_lock. + void _resetPresentationStateLocked() { + _textureAcquired = false; + _readyToPresent = false; + _acquiredAtFrame.reset(); + } + mutable std::shared_mutex _mutex; void *nativeSurface = nullptr; wgpu::Surface surface = nullptr; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCommandEncoder.h b/packages/webgpu/cpp/rnwgpu/api/GPUCommandEncoder.h index be47f851f..8fc807455 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUCommandEncoder.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUCommandEncoder.h @@ -111,6 +111,9 @@ class GPUCommandEncoder : public NativeObject { wgpu::CommandEncoder _instance; std::string _label; + // Any encoder operation that can write to a canvas texture must register the + // texture's SurfaceInfo here so queue.submit() can make only those surfaces + // eligible for display-link presentation. std::vector> _presentableSurfaces; }; diff --git a/packages/webgpu/src/Canvas.tsx b/packages/webgpu/src/Canvas.tsx index c2086164e..dca590ee4 100644 --- a/packages/webgpu/src/Canvas.tsx +++ b/packages/webgpu/src/Canvas.tsx @@ -18,7 +18,7 @@ declare global { contextId: number, width: number, height: number, - ) => RNCanvasContext; + ) => GPUCanvasContext; DecodeToUTF8: (buffer: NodeJS.ArrayBufferView | ArrayBuffer) => string; createImageBitmap: typeof createImageBitmap; }; @@ -34,11 +34,9 @@ export interface NativeCanvas { clientHeight: number; } -export type RNCanvasContext = GPUCanvasContext; - export interface CanvasRef { getContextId: () => number; - getContext(contextName: "webgpu"): RNCanvasContext | null; + getContext(contextName: "webgpu"): GPUCanvasContext | null; getNativeSurface: () => NativeCanvas; } @@ -55,7 +53,7 @@ export const Canvas = ({ transparent, ref, ...props }: CanvasProps) => { getNativeSurface: () => { return RNWebGPU.getNativeSurface(contextId); }, - getContext(contextName: "webgpu"): RNCanvasContext | null { + getContext(contextName: "webgpu"): GPUCanvasContext | null { if (contextName !== "webgpu") { throw new Error(`[WebGPU] Unsupported context: ${contextName}`); } diff --git a/packages/webgpu/src/index.tsx b/packages/webgpu/src/index.tsx index a497a9bf0..6de9b0a47 100644 --- a/packages/webgpu/src/index.tsx +++ b/packages/webgpu/src/index.tsx @@ -1,6 +1,6 @@ /// -import type { NativeCanvas, RNCanvasContext } from "./types"; +import type { NativeCanvas } from "./types"; export * from "./main"; @@ -19,7 +19,7 @@ declare global { contextId: number, width: number, height: number, - ) => RNCanvasContext; + ) => GPUCanvasContext; DecodeToUTF8: (buffer: NodeJS.ArrayBufferView | ArrayBuffer) => string; createImageBitmap: typeof createImageBitmap; }; diff --git a/packages/webgpu/src/types.ts b/packages/webgpu/src/types.ts index d7d07843b..6dba47c42 100644 --- a/packages/webgpu/src/types.ts +++ b/packages/webgpu/src/types.ts @@ -8,11 +8,9 @@ export interface NativeCanvas { clientHeight: number; } -export type RNCanvasContext = GPUCanvasContext; - export interface CanvasRef { getContextId: () => number; - getContext(contextName: "webgpu"): RNCanvasContext | null; + getContext(contextName: "webgpu"): GPUCanvasContext | null; getNativeSurface: () => NativeCanvas; whenReady: (callback: () => void) => void; } From 893b7668a11a8a66c389b0bf7f89fe3e292e884e Mon Sep 17 00:00:00 2001 From: William Candillon Date: Sat, 16 May 2026 17:53:16 +0200 Subject: [PATCH 15/16] :wrench: --- packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp index fde0dffa9..e852d973c 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp @@ -29,7 +29,7 @@ void GPUCanvasContext::configure( _surfaceInfo->configure(surfaceConfiguration); } -void GPUCanvasContext::unconfigure() {} +void GPUCanvasContext::unconfigure() { _surfaceInfo->unconfigure(); } std::shared_ptr GPUCanvasContext::getCurrentTexture() { auto prevSize = _surfaceInfo->getConfig(); From 6aecbe1ecf2134bde12a15f8323f5171e5e01b83 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Sat, 16 May 2026 18:03:12 +0200 Subject: [PATCH 16/16] :wrench: --- README.md | 2 +- apps/example/src/App.tsx | 5 + .../src/Diagnostics/MultiCanvasSubmit.tsx | 158 ++++++++++++++++++ apps/example/src/Home.tsx | 4 + apps/example/src/Route.ts | 1 + packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h | 2 + 6 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 apps/example/src/Diagnostics/MultiCanvasSubmit.tsx diff --git a/README.md b/README.md index fb534b5de..75f105480 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ ctx.canvas.height = ctx.canvas.clientHeight * PixelRatio.get(); ### Frame Scheduling -Frame presentation is automatic, matching the behavior of `GPUCanvasContext` on the Web. A native display link (CADisplayLink on iOS, Choreographer on Android) ticks once per vsync and presents any surface whose texture was acquired during a previous vsync interval, which gives your render code the full frame between two vsyncs to encode and submit. Short `await`s between `getCurrentTexture()` and `device.queue.submit(...)` are safe (microtasks drain before the next vsync); however, if your render code takes longer than one frame to submit, the present will fire before submit and you will see a stale frame. +Frame presentation is automatic, matching the behavior of `GPUCanvasContext` on the Web. A native display link (CADisplayLink on iOS, Choreographer on Android) ticks once per vsync and presents any surface whose texture was acquired during a previous vsync interval, which gives your render code the full frame between two vsyncs to encode and submit. ### Canvas Transparency diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index c15710ecf..20ae81722 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -36,6 +36,7 @@ import { Reanimated } from "./Reanimated"; import { AsyncStarvation } from "./Diagnostics/AsyncStarvation"; import { DeviceLostHang } from "./Diagnostics/DeviceLostHang"; import { PresentRace } from "./Diagnostics/PresentRace"; +import { MultiCanvasSubmit } from "./Diagnostics/MultiCanvasSubmit"; import { StorageBufferVertices } from "./StorageBufferVertices"; // The two lines below are needed by three.js @@ -95,6 +96,10 @@ function App() { + boolean, +) => { + contextA.configure({ device, format, alphaMode: "premultiplied" }); + contextB.configure({ device, format, alphaMode: "premultiplied" }); + + const frame = () => { + if (shouldStop()) { + return; + } + + const textureA = contextA.getCurrentTexture(); + const textureB = contextB.getCurrentTexture(); + + const time = Date.now() / 1000; + const r = (Math.sin(time * 2.0) + 1) / 2; + const g = (Math.sin(time * 1.5 + Math.PI / 3) + 1) / 2; + const b = (Math.sin(time * 1.0 + Math.PI / 2) + 1) / 2; + + const drawClear = ( + encoder: GPUCommandEncoder, + view: GPUTextureView, + color: GPUColor, + ) => { + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view, + clearValue: color, + loadOp: "clear", + storeOp: "store", + }, + ], + }); + pass.end(); + }; + + if (mode === "combined") { + // One encoder, two passes targeting two different surfaces, one + // command buffer, one submit. Tracks that beginRenderPass accumulates + // every color-attachment surface into the encoder's presentable set. + const encoder = device.createCommandEncoder(); + drawClear(encoder, textureA.createView(), [r, g, b, 1]); + drawClear(encoder, textureB.createView(), [1 - r, 1 - g, 1 - b, 1]); + device.queue.submit([encoder.finish()]); + } else { + // Two encoders, two command buffers, one submit. Tracks that + // queue.submit aggregates presentable surfaces across every command + // buffer in the array. + const encoderA = device.createCommandEncoder(); + drawClear(encoderA, textureA.createView(), [r, g, b, 1]); + const encoderB = device.createCommandEncoder(); + drawClear(encoderB, textureB.createView(), [1 - r, 1 - g, 1 - b, 1]); + device.queue.submit([encoderA.finish(), encoderB.finish()]); + } + + requestAnimationFrame(frame); + }; + + frame(); +}; + +const Pair = ({ mode, label }: { mode: Mode; label: string }) => { + const refA = useRef(null); + const refB = useRef(null); + useEffect(() => { + let stopped = false; + (async () => { + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) { + return; + } + const device = await adapter.requestDevice(); + const contextA = refA.current?.getContext("webgpu"); + const contextB = refB.current?.getContext("webgpu"); + if (!contextA || !contextB) { + return; + } + const format = navigator.gpu.getPreferredCanvasFormat(); + runPair(device, contextA, contextB, format, mode, () => stopped); + })(); + return () => { + stopped = true; + }; + }, [mode]); + return ( + + {label} + + + + + + ); +}; + +export const MultiCanvasSubmit = () => { + return ( + + + Each row drives two canvases that render inverted hues from a single + submit. If the presentable-surface tracking is broken, one of the two + canvases will stop updating (no display-link tick will present it). + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#111", + padding: 12, + }, + intro: { + color: "#f5f5f5", + fontSize: 13, + lineHeight: 18, + marginBottom: 12, + }, + pair: { + flex: 1, + marginBottom: 12, + }, + label: { + color: "#f5f5f5", + fontSize: 13, + marginBottom: 6, + }, + row: { + flex: 1, + flexDirection: "row", + }, + canvas: { + flex: 1, + marginRight: 6, + }, +}); diff --git a/apps/example/src/Home.tsx b/apps/example/src/Home.tsx index 17cca39d0..6658b2a3b 100644 --- a/apps/example/src/Home.tsx +++ b/apps/example/src/Home.tsx @@ -127,6 +127,10 @@ export const examples = [ screen: "PresentRace", title: "⚠️ Present Race (await before submit)", }, + { + screen: "MultiCanvasSubmit", + title: "🖼️ Multi-Canvas Submit Tracking", + }, { screen: "StorageBufferVertices", title: "💾 Storage Buffer Vertices", diff --git a/apps/example/src/Route.ts b/apps/example/src/Route.ts index 13ebd97ad..5721093c0 100644 --- a/apps/example/src/Route.ts +++ b/apps/example/src/Route.ts @@ -29,5 +29,6 @@ export type Routes = { AsyncStarvation: undefined; DeviceLostHang: undefined; PresentRace: undefined; + MultiCanvasSubmit: undefined; StorageBufferVertices: undefined; }; diff --git a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h index 9681d2636..7f8f710ca 100644 --- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h +++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h @@ -41,6 +41,7 @@ class SurfaceInfo { void reconfigure(int newWidth, int newHeight) { std::unique_lock lock(_mutex); + _resetPresentationStateLocked(); config.width = newWidth; config.height = newHeight; _configure(); @@ -48,6 +49,7 @@ class SurfaceInfo { void configure(wgpu::SurfaceConfiguration &newConfig) { std::unique_lock lock(_mutex); + _resetPresentationStateLocked(); config = newConfig; config.width = width; config.height = height;