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"
diff --git a/README.md b/README.md
index 0ae1359b4..75f105480 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, 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
@@ -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/App.tsx b/apps/example/src/App.tsx
index 667f87164..20ae81722 100644
--- a/apps/example/src/App.tsx
+++ b/apps/example/src/App.tsx
@@ -35,6 +35,8 @@ import { ComputeToys } from "./ComputeToys";
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
@@ -93,6 +95,11 @@ function App() {
+
+
{
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..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");
}
@@ -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/Diagnostics/MultiCanvasSubmit.tsx b/apps/example/src/Diagnostics/MultiCanvasSubmit.tsx
new file mode 100644
index 000000000..b02a37d14
--- /dev/null
+++ b/apps/example/src/Diagnostics/MultiCanvasSubmit.tsx
@@ -0,0 +1,158 @@
+import React, { useEffect, useRef } from "react";
+import { StyleSheet, Text, View } from "react-native";
+import type { CanvasRef } from "react-native-wgpu";
+import { Canvas } from "react-native-wgpu";
+
+type Mode = "combined" | "split";
+
+const runPair = (
+ device: GPUDevice,
+ contextA: GPUCanvasContext,
+ contextB: GPUCanvasContext,
+ format: GPUTextureFormat,
+ mode: Mode,
+ shouldStop: () => 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/Diagnostics/PresentRace.tsx b/apps/example/src/Diagnostics/PresentRace.tsx
new file mode 100644
index 000000000..0e0e3a60f
--- /dev/null
+++ b/apps/example/src/Diagnostics/PresentRace.tsx
@@ -0,0 +1,139 @@
+import React, { useEffect, useRef } from "react";
+import { StyleSheet, Text, View } from "react-native";
+import type { CanvasRef } from "react-native-wgpu";
+import { Canvas } from "react-native-wgpu";
+
+type Mode = "sync" | "microtask" | "longAwait";
+
+const runAnimatedClear = (
+ device: GPUDevice,
+ context: GPUCanvasContext,
+ format: GPUTextureFormat,
+ mode: Mode,
+ shouldStop: () => 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/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/Home.tsx b/apps/example/src/Home.tsx
index 9272dfec9..6658b2a3b 100644
--- a/apps/example/src/Home.tsx
+++ b/apps/example/src/Home.tsx
@@ -123,6 +123,14 @@ export const examples = [
screen: "DeviceLostHang",
title: "⚠️ Device Lost Hang",
},
+ {
+ 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/Reanimated/Reanimated.tsx b/apps/example/src/Reanimated/Reanimated.tsx
index fa1fa36b6..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";
@@ -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/apps/example/src/Route.ts b/apps/example/src/Route.ts
index 152923e1e..5721093c0 100644
--- a/apps/example/src/Route.ts
+++ b/apps/example/src/Route.ts
@@ -28,5 +28,7 @@ export type Routes = {
Reanimated: undefined;
AsyncStarvation: undefined;
DeviceLostHang: undefined;
+ PresentRace: undefined;
+ MultiCanvasSubmit: undefined;
StorageBufferVertices: undefined;
};
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..75f105480 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, 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
@@ -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/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..2f081de55 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,10 @@ @interface RCTBridge (JSIRuntime)
- (void *)runtime;
@end
-@implementation WebGPUModule
+@implementation WebGPUModule {
+ CADisplayLink *_displayLink;
+ BOOL _displayLinkActive;
+}
RCT_EXPORT_MODULE(WebGPUModule)
@@ -42,7 +47,47 @@ + (BOOL)requiresMainQueueSetup {
}
- (void)invalidate {
+ [self stopDisplayLink];
webgpuManager = nil;
+ [super invalidate];
+}
+
+- (void)startDisplayLink {
+ _displayLinkActive = YES;
+ 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->_displayLinkActive) {
+ return;
+ }
+ if (self->_displayLink != nil) {
+ return;
+ }
+ 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) {
+ return;
+ }
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [link invalidate];
+ });
+}
+
+- (void)onVsync:(CADisplayLink *)__unused link {
+ rnwgpu::SurfaceRegistry::getInstance().tickAll();
}
- (std::shared_ptr)getManager {
@@ -78,6 +123,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 110a45d44..7f8f710ca 100644
--- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h
+++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h
@@ -1,12 +1,24 @@
#pragma once
+#include
+#include
#include
+#include
#include
#include
#include
+#include
#include "webgpu/webgpu_cpp.h"
+#ifdef __APPLE__
+namespace dawn::native::metal {
+
+void WaitForCommandsToBeScheduled(WGPUDevice device);
+
+}
+#endif
+
namespace rnwgpu {
struct NativeInfo {
@@ -29,6 +41,7 @@ class SurfaceInfo {
void reconfigure(int newWidth, int newHeight) {
std::unique_lock lock(_mutex);
+ _resetPresentationStateLocked();
config.width = newWidth;
config.height = newHeight;
_configure();
@@ -36,6 +49,7 @@ class SurfaceInfo {
void configure(wgpu::SurfaceConfiguration &newConfig) {
std::unique_lock lock(_mutex);
+ _resetPresentationStateLocked();
config = newConfig;
config.width = width;
config.height = height;
@@ -45,6 +59,7 @@ class SurfaceInfo {
void unconfigure() {
std::unique_lock lock(_mutex);
+ _resetPresentationStateLocked();
if (surface) {
surface.Unconfigure();
} else {
@@ -54,6 +69,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) {
@@ -72,6 +88,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
@@ -103,6 +120,7 @@ class SurfaceInfo {
wgpu::Queue queue = device.GetQueue();
queue.Submit(1, &commands);
surface.Present();
+ _resetPresentationStateLocked();
texture = nullptr;
}
}
@@ -115,16 +133,35 @@ class SurfaceInfo {
void present() {
std::unique_lock lock(_mutex);
- if (surface) {
- surface.Present();
+ _presentLocked();
+ }
+
+ // 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 (_readyToPresent && _acquiredAtFrame &&
+ *_acquiredAtFrame < currentFrame) {
+ _presentLocked();
}
}
- wgpu::Texture getCurrentTexture() {
- std::shared_lock lock(_mutex);
+ 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 {
return texture;
@@ -167,6 +204,28 @@ 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;
+ _readyToPresent = false;
+ _acquiredAtFrame.reset();
+ }
+ }
+
+ // 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;
@@ -175,6 +234,9 @@ class SurfaceInfo {
wgpu::SurfaceConfiguration config;
int width;
int height;
+ bool _textureAcquired = false;
+ bool _readyToPresent = false;
+ std::optional _acquiredAtFrame;
};
class SurfaceRegistry {
@@ -221,10 +283,46 @@ 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);
+ }
+ }
+
+ 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;
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 d75eb7b0f..e852d973c 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(
@@ -37,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();
@@ -47,21 +39,14 @@ std::shared_ptr GPUCanvasContext::getCurrentTexture() {
if (sizeHasChanged) {
_surfaceInfo->reconfigure(width, height);
}
- auto texture = _surfaceInfo->getCurrentTexture();
- // Pass reportsMemoryPressure=false to avoid triggering spurious Hermes GC
- // cycles every frame since the canvas texture doesn't own the buffer.
- return std::make_shared(texture, "", false);
-}
-
-void GPUCanvasContext::present() {
-#ifdef __APPLE__
- dawn::native::metal::WaitForCommandsToBeScheduled(
- _surfaceInfo->getDevice().Get());
-#endif
auto size = _surfaceInfo->getSize();
_canvas->setClientWidth(size.width);
_canvas->setClientHeight(size.height);
- _surfaceInfo->present();
+ auto 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.
+ return std::make_shared(texture, "", false, _surfaceInfo);
}
} // 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