Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8cbc056
docs: plan for event-driven async + auto-present refactor
claude Jun 2, 2026
234384d
:wrench:
wcandillon Jun 2, 2026
e2acc77
:wrench:
wcandillon Jun 2, 2026
d2e2aaa
Merge origin/main and resolve GPUAdapter conflict
Copilot Jun 2, 2026
e71fcff
:wrench:
wcandillon Jun 2, 2026
83c07b0
Merge branch 'claude/keen-darwin-xeywa' of https://github.com/wcandil…
wcandillon Jun 2, 2026
c326873
:wrench:
wcandillon Jun 2, 2026
f5bc1c2
:wrench:
wcandillon Jun 2, 2026
ba9efe9
:wrench:
wcandillon Jun 2, 2026
0696aaa
Delete docs/refactor-async-present-plan.md
wcandillon Jun 2, 2026
f9833e3
:wrench:
wcandillon Jun 4, 2026
7f83d8b
:wrench:
wcandillon Jun 4, 2026
13a68cf
Merge remote-tracking branch 'origin/main' into framedriver
Copilot Jun 4, 2026
9388e92
:wrench:
wcandillon Jun 4, 2026
af695c1
Merge branch 'main' into framedriver
wcandillon Jun 4, 2026
fe20331
Merge branch 'main' into framedriver
wcandillon Jun 4, 2026
85341de
:wrench:
wcandillon Jun 6, 2026
d2d8363
Merge branch 'framedriver' of https://github.com/wcandillon/react-nat…
wcandillon Jun 6, 2026
b298cd1
:arrow_up:
wcandillon Jun 6, 2026
3290789
:wrench:
wcandillon Jun 6, 2026
5c1d352
:wrench:
wcandillon Jun 6, 2026
b9275a0
:wrench:
wcandillon Jun 6, 2026
5b97f33
:wrench:
wcandillon Jun 6, 2026
88de5bb
:wrench:
wcandillon Jun 7, 2026
a3bb58d
:wrench:
wcandillon Jun 7, 2026
19da770
:wrench:
wcandillon Jun 7, 2026
35deac5
:wrench:
wcandillon Jun 7, 2026
39304ad
:wrench:
wcandillon Jun 7, 2026
7aaf206
:wrench:
wcandillon Jun 7, 2026
b27167b
:wrench:
wcandillon Jun 7, 2026
228edf6
:wrench:
wcandillon Jun 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ npm install react-native-webgpu
## With Expo

Expo provides a React Native WebGPU template that works with React Three Fiber.
The works on iOS, Android, and Web.
This works on iOS, Android, and Web.

```
npx create-expo-app@latest -e with-webgpu
Expand Down Expand Up @@ -174,8 +174,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.
In React Native, frame presentation is a manual operation: when you are ready to present a frame, call `present()` on the context after submitting your commands to the queue. This works the same on every runtime: the main JS runtime, the Reanimated UI runtime, and dedicated worklet runtimes (`createWorkletRuntime` / `runOnRuntime`, or a Vision Camera frame processor). `present()` runs synchronously on the calling thread, so the frame is presented from whichever thread did the rendering.

```tsx
// draw
Expand All @@ -185,6 +184,13 @@ device.queue.submit([commandEncoder.finish()]);
context.present();
```

### Threading model

react-native-webgpu can drive WebGPU from more than one JavaScript runtime: the main JS runtime, the Reanimated UI runtime, and dedicated worklet runtimes (`createWorkletRuntime` / `runOnRuntime`, or a Vision Camera frame processor).
This module also works well with [Bundle Mode](https://docs.swmansion.com/react-native-worklets/docs/bundleMode/) and lets you run complex Three.js scenes on the UI thread or dedicated worklet threads.

There is a caveat with `device.lost` and `uncapturederror`: they are only delivered on the main JS runtime. This is usually fine because the GPU device is typically created on the main JS thread and then sent to the UI or a dedicated worklet thread. However, if for some reason you create the device outside the main JS thread, beware that `device.lost` and `uncapturederror` won't fire.

### Canvas Transparency

On Android, the `alphaMode` property is ignored when configuring the canvas.
Expand Down Expand Up @@ -293,10 +299,10 @@ const render = () => {

// ... encode a pass that samples `externalTexture`, then:
device.queue.submit([encoder.finish()]);
context.present();

// Release the surface's access window right after the submit that sampled it.
externalTexture.destroy();
context.present();
};
```

Expand All @@ -316,14 +322,21 @@ First, install the optional peer dependencies:
npm install react-native-reanimated react-native-worklets
```

WebGPU objects are automatically registered for Worklets serialization when the module loads. You can pass WebGPU objects like `GPUDevice` and `GPUCanvasContext` directly to worklets:
WebGPU objects are automatically registered for Worklets serialization when the module loads. You can pass WebGPU objects like `GPUDevice` and `GPUCanvasContext` directly to worklets.
Call `installWebGPU()` once at the top of the worklet to install flag constants like `GPUBufferUsage`, `GPUTextureUsage`, and so on.

```tsx
import { Canvas } from "react-native-webgpu";
import { Canvas, installWebGPU } from "react-native-webgpu";
import { runOnUI } from "react-native-reanimated";

const renderFrame = (device: GPUDevice, context: GPUCanvasContext) => {
"worklet";
installWebGPU();
// WebGPU constants are now available on this worklet thread
const buffer = device.createBuffer({
size,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
// WebGPU rendering code runs on the UI thread
const commandEncoder = device.createCommandEncoder();
// ... render ...
Expand Down
10 changes: 5 additions & 5 deletions apps/example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1924,7 +1924,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- react-native-wgpu (0.5.12):
- react-native-webgpu (0.5.15):
- boost
- DoubleConversion
- fast_float
Expand Down Expand Up @@ -2812,7 +2812,7 @@ DEPENDENCIES:
- React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`)
- "react-native-skia (from `../../../node_modules/@shopify/react-native-skia`)"
- react-native-wgpu (from `../../../node_modules/react-native-wgpu`)
- react-native-webgpu (from `../../../node_modules/react-native-webgpu`)
- React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
- React-oscompat (from `../../../node_modules/react-native/ReactCommon/oscompat`)
- React-perflogger (from `../../../node_modules/react-native/ReactCommon/reactperflogger`)
Expand Down Expand Up @@ -2948,8 +2948,8 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/react-native-safe-area-context"
react-native-skia:
:path: "../../../node_modules/@shopify/react-native-skia"
react-native-wgpu:
:path: "../../../node_modules/react-native-wgpu"
react-native-webgpu:
:path: "../../../node_modules/react-native-webgpu"
React-NativeModulesApple:
:path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
React-oscompat:
Expand Down Expand Up @@ -3074,7 +3074,7 @@ SPEC CHECKSUMS:
React-microtasksnativemodule: 75b6604b667d297292345302cc5bfb6b6aeccc1b
react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460
react-native-skia: fc73e9bdc46ebb420a98c9c2be29fee80f565e79
react-native-wgpu: 274ffec11ee3a082260d9f3d1fb54030a5ca0873
react-native-webgpu: 02d51c1d86e4d653de06bdc954d2f693dcead7a5
React-NativeModulesApple: 879fbdc5dcff7136abceb7880fe8a2022a1bd7c3
React-oscompat: 93b5535ea7f7dff46aaee4f78309a70979bdde9d
React-perflogger: 5536d2df3d18fe0920263466f7b46a56351c0510
Expand Down
1 change: 0 additions & 1 deletion apps/example/src/CanvasAPI/CanvasAPI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ export const CanvasAPI = () => {
passEncoder.end();

device.queue.submit([commandEncoder.finish()]);

context.present();
})()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,10 @@ export const ImportExternalTexture = () => {

pass.end();
device.queue.submit([encoder.finish()]);
context.present();
// Now that the work sampling it has been submitted, end the external
// texture's access window so the frame's surface is released promptly.
externalTex?.destroy();
context.present();
rafRef.current = requestAnimationFrame(render);
};
rafRef.current = requestAnimationFrame(render);
Expand Down
232 changes: 232 additions & 0 deletions apps/example/src/Reanimated/AsyncBuffer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import React, { useEffect, useRef, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import type { CanvasRef, RNCanvasContext } from "react-native-webgpu";
import { Canvas, GPUBufferUsage, GPUMapMode } from "react-native-webgpu";
import type { SharedValue } from "react-native-reanimated";
import { useSharedValue } from "react-native-reanimated";

import { redFragWGSL, triangleVertWGSL } from "../Triangle/triangle";

// A triangle demo that creates its adapter/device AND performs an async GPU
// readback (buffer.mapAsync) every frame, all on the runtime this worklet runs
// on. With the ProcessEvents async model the device must be created and used on
// the same runtime, so requestAdapter/requestDevice happen here in the worklet
// (the GPU object is passed in). The point: with the JS thread busy, the readback
// keeps resolving on this runtime's own thread and the triangle keeps animating.
//
// GPUBufferUsage / GPUMapMode are imported from react-native-webgpu: the bare
// globals are only installed on the main JS runtime, but importing them lets the
// Worklets serializer capture them by closure, so they work on this runtime too.
export const webGPUAsyncDemo = (
runAnimation: SharedValue<boolean>,
context: RNCanvasContext,
gpu: GPU,
presentationFormat: GPUTextureFormat,
) => {
"worklet";
if (!context) {
throw new Error("No context");
}

// Errors thrown on a worklet are forwarded to the JS thread by the worklets
// runtime; if the error object transitively references WebGPU host objects,
// JSON.stringify of it on the JS side can crash. So we catch everything here
// and forward only a plain string.
const logError = (where: string, e: unknown) => {
console.error(
`[asyncBuffer] ${where}: ` +
String((e as { message?: string })?.message ?? e),
);
};

const run = async () => {
const adapter = await gpu.requestAdapter();
if (!adapter) {
console.error("[asyncBuffer] failed to get adapter on worklet runtime");
return;
}
const device = await adapter.requestDevice();
if (!device) {
console.error("[asyncBuffer] failed to get device on worklet runtime");
return;
}
console.log("[asyncBuffer] device created on worklet runtime");

context.configure({
device,
format: presentationFormat,
alphaMode: "premultiplied",
});

const pipeline = device.createRenderPipeline({
layout: "auto",
vertex: {
module: device.createShaderModule({ code: triangleVertWGSL }),
entryPoint: "main",
},
fragment: {
module: device.createShaderModule({ code: redFragWGSL }),
entryPoint: "main",
targets: [{ format: presentationFormat }],
},
primitive: { topology: "triangle-list" },
});

const SIZE = 16; // 4 x f32
const readback = device.createBuffer({
size: SIZE,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});

let frameId = 0;

const frame = async () => {
try {
frameId += 1;
const commandEncoder = device.createCommandEncoder();
const textureView = context.getCurrentTexture().createView();

const time = Date.now() / 1000;
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 + Math.PI / 2) + 1) / 2;

const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: textureView,
clearValue: [r, g, b, 1],
loadOp: "clear",
storeOp: "store",
},
],
});
passEncoder.setPipeline(pipeline);
passEncoder.draw(3);
passEncoder.end();

const src = device.createBuffer({
size: SIZE,
usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.MAP_WRITE,
mappedAtCreation: true,
});
new Float32Array(src.getMappedRange()).set([frameId, r, g, b]);
src.unmap();
commandEncoder.copyBufferToBuffer(src, 0, readback, 0, SIZE);

device.queue.submit([commandEncoder.finish()]);

// THE ASYNC OP. With the ProcessEvents model this Promise is pumped and
// settled on THIS runtime's own thread, so it resolves even while the JS
// thread is busy. Watch the logs against the "Make JS busy" button.
await readback.mapAsync(GPUMapMode.READ);
const data = Array.from(new Float32Array(readback.getMappedRange()));
readback.unmap();
src.destroy();
if (frameId % 30 === 0) {
console.log(`[asyncBuffer] frame ${frameId} resolved ->`, data);
}

context.present();

if (runAnimation.value) {
requestAnimationFrame(frame);
}
} catch (e) {
logError("frame", e);
}
};
frame();
};
run().catch((e) => logError("run", e));
};

interface AsyncBufferExampleProps {
// Schedules the worklet on a given runtime (e.g. runOnUI for the UI thread,
// or runOnRuntime(runtime, ...) for a dedicated worklet runtime).
run: (
worklet: typeof webGPUAsyncDemo,
) => (
runAnimation: SharedValue<boolean>,
context: RNCanvasContext,
gpu: GPU,
presentationFormat: GPUTextureFormat,
) => void;
}

export function AsyncBufferExample({ run }: AsyncBufferExampleProps) {
const runAnimation = useSharedValue(true);
const ref = useRef<CanvasRef>(null);
const [busy, setBusy] = useState(false);

// Hammer the JS thread to prove the worklet's async readback + rendering are
// independent of it. Each tick blocks the JS thread for 250ms.
useEffect(() => {
if (!busy) {
return;
}
let job = requestAnimationFrame(function work() {
const start = Date.now();
while (Date.now() - start < 250) {
// Busy-wait, blocking the JS thread.
}
job = requestAnimationFrame(work);
});
return () => cancelAnimationFrame(job);
}, [busy]);

useEffect(() => {
const ctx = ref.current!.getContext("webgpu");
if (!ctx) {
console.error("Failed to get GPU canvas context");
return;
}
// The GPU object is created on the main runtime; we hand it to the worklet,
// which calls requestAdapter/requestDevice on its OWN runtime.
const { gpu } = navigator;
const presentationFormat = gpu.getPreferredCanvasFormat();
run(webGPUAsyncDemo)(runAnimation, ctx, gpu, presentationFormat);
return () => {
runAnimation.value = false;
};
// Init the GPU pipeline once on mount. Toggling `busy` must NOT re-run this
// (a second device + render loop would fight over the same surface and
// trigger a device-mismatch validation error).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
<View style={style.container}>
<Canvas ref={ref} style={style.webgpu} />
<Pressable style={style.button} onPress={() => setBusy((b) => !b)}>
<Text style={style.buttonText}>
{busy ? "Stop busy JS" : "Make JS busy"}
</Text>
</Pressable>
</View>
);
}

const style = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "rgb(90, 180, 255)",
},
webgpu: {
flex: 1,
},
button: {
position: "absolute",
bottom: 32,
alignSelf: "center",
backgroundColor: "rgba(0,0,0,0.6)",
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 24,
},
buttonText: {
color: "white",
fontSize: 16,
fontWeight: "600",
},
});
14 changes: 14 additions & 0 deletions apps/example/src/Reanimated/AsyncBufferDedicatedThread.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import React, { useMemo } from "react";
import { createWorkletRuntime, runOnRuntime } from "react-native-worklets";

import { AsyncBufferExample } from "./AsyncBuffer";

export const AsyncBufferDedicatedThread = () => {
const runtime = useMemo(
() => createWorkletRuntime({ name: "WebGPUAsyncBufferRuntime" }),
[],
);
return (
<AsyncBufferExample run={(worklet) => runOnRuntime(runtime, worklet)} />
);
};
8 changes: 8 additions & 0 deletions apps/example/src/Reanimated/AsyncBufferUIThread.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React from "react";
import { runOnUI } from "react-native-reanimated";

import { AsyncBufferExample } from "./AsyncBuffer";

export const AsyncBufferUIThread = () => {
return <AsyncBufferExample run={runOnUI} />;
};
8 changes: 8 additions & 0 deletions apps/example/src/Reanimated/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ export const examples = [
screen: "FrameProcessor",
title: "📷 Frame Processor",
},
{
screen: "AsyncBufferUIThread",
title: "🧵 Async Buffer (UI)",
},
{
screen: "AsyncBufferDedicatedThread",
title: "🔀 Async Buffer (Dedicated)",
},
] as const;

const styles = StyleSheet.create({
Expand Down
Loading
Loading