diff --git a/apps/typegpu-docs/astro.config.mjs b/apps/typegpu-docs/astro.config.mjs
index 1ac7e12b31..379d9c4853 100644
--- a/apps/typegpu-docs/astro.config.mjs
+++ b/apps/typegpu-docs/astro.config.mjs
@@ -243,6 +243,11 @@ export default defineConfig({
label: 'React Native',
slug: 'integration/react-native',
},
+ {
+ label: 'React Native Worklets',
+ slug: 'integration/react-native/worklets',
+ badge: { text: 'experimental', variant: 'caution' },
+ },
{
label: 'WESL Interoperability',
slug: 'integration/wesl-interoperability',
diff --git a/apps/typegpu-docs/src/content/docs/integration/react-native/worklets.mdx b/apps/typegpu-docs/src/content/docs/integration/react-native/worklets.mdx
new file mode 100644
index 0000000000..0526bd57fc
--- /dev/null
+++ b/apps/typegpu-docs/src/content/docs/integration/react-native/worklets.mdx
@@ -0,0 +1,117 @@
+---
+title: React Native Worklets
+description: A guide on running TypeGPU render loops on the UI thread with react-native-worklets.
+---
+
+With [react-native-worklets](https://docs.swmansion.com/react-native-worklets/), per-frame GPU work can be scheduled on the UI thread, unaffected by tasks running on the default React Native thread (or RN thread).
+TypeGPU resources created on the RN thread can be captured by worklets directly, they are transferred between runtimes automatically.
+
+## Setup
+
+Follow the [React Native guide](/TypeGPU/integration/react-native/) first, then install `react-native-worklets` and enable its babel plugin with [Bundle Mode](https://docs.swmansion.com/react-native-worklets/docs/bundleMode/):
+
+```diff lang=js title="babel.config.js"
++const workletsPluginOptions = {
++ bundleMode: true,
++ importForwarding: {
++ moduleNames: ['typegpu'],
++ // Directories with your module-scope shader definitions
++ relativePaths: ['my-app/components'],
++ },
++};
+
+module.exports = (api) => {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ plugins: [
+ 'unplugin-typegpu/babel',
++ ['react-native-worklets/plugin', workletsPluginOptions],
+ ],
+ };
+};
+```
+
+No extra imports are needed - `@typegpu/react` detects `react-native-worklets` at runtime and registers the transfer support for TypeGPU resources automatically.
+`useFrame` runs its callback on the UI thread whenever the callback is marked with the `'worklet'` directive; plain callbacks keep running on the RN thread.
+
+After changing the babel config, clear the Metro cache with `npx expo start --clear`.
+
+## Example
+
+Create resources on the RN thread, then use them freely inside a `useFrame` worklet:
+
+```tsx
+import { useMemo } from 'react';
+import { Canvas } from 'react-native-webgpu';
+import tgpu, { common, d } from 'typegpu';
+import { useConfigureContext, useFrame, useRoot, useUniform } from '@typegpu/react';
+
+export function Pulse() {
+ const root = useRoot();
+ const color = useUniform(d.vec3f, { initial: d.vec3f(0.114, 0.447, 0.941) });
+
+ const pipeline = useMemo(
+ () =>
+ root.createRenderPipeline({
+ vertex: common.fullScreenTriangle,
+ fragment: () => {
+ 'use gpu';
+ return d.vec4f(color.$, 1);
+ },
+ }),
+ [root, color],
+ );
+
+ const { ref, ctxRef } = useConfigureContext({ alphaMode: 'premultiplied' });
+
+ // Runs each frame on the UI thread
+ useFrame(({ elapsedSeconds }) => {
+ 'worklet';
+ const ctx = ctxRef.current;
+ if (!ctx) return;
+
+ color.write(d.vec3f(0.5 + Math.sin(elapsedSeconds) * 0.5, 0.447, 0.941));
+ pipeline.withColorAttachment({ view: ctx }).draw(3);
+ ctx.present?.();
+ });
+
+ return ;
+}
+```
+
+The `color` uniform and `pipeline` captured by the worklet are transferred to the UI runtime on first use.
+Both runtimes share the same underlying GPU objects, and transferring the same resource again yields the same object back.
+
+This works for buffers (including `createUniform`/`createMutable`/`createReadonly`), textures, samplers, bind groups and their layouts, vertex layouts, query sets, pipelines, roots, slots, accessors, consts, and vector/matrix instances.
+
+## Opting out
+
+To keep everything on the RN thread even with `react-native-worklets` installed, pass `disableWorklets` to the `Root` provider:
+
+```tsx
+
+
+
+```
+
+`useFrame` then runs its callbacks on the RN thread, and `useConfigureContext` returns a plain object of the same shape, so the code above keeps working unchanged.
+
+## Rules of transfer
+
+**Definitions are runtime-local.**
+Shader functions (`tgpu.fn`, entry functions), `tgpu.comptime`, and schemas cannot be serialized.
+Make sure to create them on the runtime they will be used on, or keep them at module scope in files covered by `importForwarding`; worklets then re-import them natively on the UI runtime instead of transferring them.
+
+**Functions crossing runtimes must be worklets.**
+Any plain function reachable from a transferred resource (e.g. a `withPerformanceCallback` callback) has to be marked with the `'worklet'` directive.
+
+:::note
+Definitions created dynamically (inside components or worklets) cannot cross runtimes and throw.
+If you create pipelines on the UI thread, memoize them - resolving a pipeline every frame is wasted work.
+:::
+
+:::caution
+Render pipelines created on the UI runtime must specify an explicit target format (e.g. `targets: { format: 'bgra8unorm' }`), the default relies on
+`navigator.gpu.getPreferredCanvasFormat()`, and `navigator` is not available on worklet runtimes by default.
+:::
diff --git a/packages/typegpu-react/README.md b/packages/typegpu-react/README.md
index bb7eb5d3ea..9ec734cdd2 100644
--- a/packages/typegpu-react/README.md
+++ b/packages/typegpu-react/README.md
@@ -46,6 +46,12 @@ const App = (props: Props) => {
};
```
+# React Native
+
+When `react-native-worklets` is installed, per-frame GPU work can run on the UI thread - `useFrame` picks it up automatically for worklet callbacks.
+TypeGPU resources captured by worklets are transferred between runtimes automatically.
+See the [React Native Worklets guide](https://typegpu.com/integration/react-native/worklets).
+
## TypeGPU is created by Software Mansion
[](https://swmansion.com)
diff --git a/packages/typegpu-react/package.json b/packages/typegpu-react/package.json
index 46ed2a9291..383aaf7ad4 100644
--- a/packages/typegpu-react/package.json
+++ b/packages/typegpu-react/package.json
@@ -17,7 +17,11 @@
"sideEffects": false,
"types": "./src/browser/index.ts",
"exports": {
- ".": "./src/browser/index.ts",
+ ".": {
+ "react-native": "./src/react-native/index.ts",
+ "browser": "./src/browser/index.ts",
+ "default": "./src/browser/index.ts"
+ },
"./package.json": "./package.json"
},
"publishConfig": {
@@ -53,6 +57,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"react-native": "0.84.1",
+ "react-native-worklets": "0.10.2",
"tsdown": "catalog:build",
"typegpu": "workspace:*",
"typegpu-testing-utility": "workspace:^",
@@ -63,6 +68,7 @@
"react": "^19.0.0",
"react-native": "*",
"react-native-webgpu": "*",
+ "react-native-worklets": "*",
"typegpu": "workspace:^"
},
"peerDependenciesMeta": {
@@ -71,6 +77,9 @@
},
"react-native-webgpu": {
"optional": true
+ },
+ "react-native-worklets": {
+ "optional": true
}
}
}
diff --git a/packages/typegpu-react/src/core/root-context.tsx b/packages/typegpu-react/src/core/root-context.tsx
index 790638a6f8..cf802342fd 100644
--- a/packages/typegpu-react/src/core/root-context.tsx
+++ b/packages/typegpu-react/src/core/root-context.tsx
@@ -9,7 +9,7 @@ import React, {
useRef,
useState,
} from 'react';
-import { tgpu, type TgpuRoot } from 'typegpu';
+import { tgpu, type InitOptions, type TgpuRoot } from 'typegpu';
import { useDeferredCleanup } from './helper-hooks.ts';
import { useBailOnServer } from './use-bail-on-server.ts';
@@ -83,6 +83,11 @@ interface RootContext {
class OwnRootContext implements RootContext {
#result: RootContextResult | undefined;
#destroyed: boolean = false;
+ readonly #options: InitOptions | undefined;
+
+ constructor(options?: InitOptions) {
+ this.#options = options;
+ }
initOrGetRoot(): RootContextResult {
if (this.#destroyed) {
@@ -91,7 +96,7 @@ class OwnRootContext implements RootContext {
}
if (!this.#result) {
- const promise = tgpu.init().then(
+ const promise = tgpu.init(this.#options).then(
(root) => {
if (this.#destroyed) {
root.destroy();
@@ -156,7 +161,16 @@ const globalRootContextValue = new OwnRootContext();
const rootContext = createContext(null);
+const workletsDisabledContext = createContext(false);
+
+/** @internal Reads the `disableWorklets` flag from the nearest provider */
+export function useWorkletsDisabled(): boolean {
+ return useContext(workletsDisabledContext);
+}
+
export interface RootProps {
+ /** Options used when this provider creates its own root, ignored when `root` is provided */
+ options?: InitOptions | undefined;
/**
* An existing root to provide. If undefined (default), a new root will be initialized for
* this provider's children.
@@ -164,6 +178,13 @@ export interface RootProps {
* @default undefined
*/
root?: TgpuRoot | undefined;
+ /**
+ * (React Native only) When true, `useFrame` runs on the RN thread even if
+ * `react-native-worklets` is installed. Ignored on the web
+ *
+ * @default false
+ */
+ disableWorklets?: boolean | undefined;
children?: ReactNode | undefined;
}
@@ -183,8 +204,8 @@ function WarnSuspense() {
return null;
}
-export const Root = ({ children, root }: RootProps) => {
- const [ownCtx] = useState(() => new OwnRootContext());
+export const Root = ({ children, options, root, disableWorklets = false }: RootProps) => {
+ const [ownCtx] = useState(() => new OwnRootContext(options));
const existingRootCtx = useMemo(() => {
if (root) {
return new ExistingRootContext(root);
@@ -198,7 +219,9 @@ export const Root = ({ children, root }: RootProps) => {
return (
- }>{children}
+
+ }>{children}
+
);
};
diff --git a/packages/typegpu-react/src/core/use-frame.ts b/packages/typegpu-react/src/core/use-frame.ts
index c47498c729..d69e93871f 100644
--- a/packages/typegpu-react/src/core/use-frame.ts
+++ b/packages/typegpu-react/src/core/use-frame.ts
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
-interface FrameCtx {
+export interface FrameCtx {
/**
* Time elapsed since the last frame
*/
@@ -11,6 +11,35 @@ interface FrameCtx {
readonly elapsedSeconds: number;
}
+export function startFrameLoop(cb: (ctx: FrameCtx) => void): () => void {
+ let frameId: number | undefined;
+ let startTime: number | undefined;
+ let lastTime: number | undefined;
+
+ const loop = () => {
+ frameId = requestAnimationFrame(loop);
+
+ const now = performance.now();
+ if (lastTime === undefined || startTime === undefined) {
+ startTime = now;
+ lastTime = now;
+ }
+ cb({
+ deltaSeconds: (now - lastTime) / 1000,
+ elapsedSeconds: (now - startTime) / 1000,
+ });
+ lastTime = now;
+ };
+
+ loop();
+
+ return () => {
+ if (frameId !== undefined) {
+ cancelAnimationFrame(frameId);
+ }
+ };
+}
+
export function useFrame(cb: (ctx: FrameCtx) => void) {
const latestCb = useRef(cb);
@@ -18,32 +47,5 @@ export function useFrame(cb: (ctx: FrameCtx) => void) {
latestCb.current = cb;
}, [cb]);
- useEffect(() => {
- let frameId: number | undefined;
- let startTime: number | undefined;
- let lastTime: number | undefined;
-
- const loop = () => {
- frameId = requestAnimationFrame(loop);
-
- const now = performance.now();
- if (lastTime === undefined || startTime === undefined) {
- startTime = now;
- lastTime = now;
- }
- latestCb.current({
- deltaSeconds: (now - lastTime) / 1000,
- elapsedSeconds: (now - startTime) / 1000,
- });
- lastTime = now;
- };
-
- loop();
-
- return () => {
- if (frameId !== undefined) {
- cancelAnimationFrame(frameId);
- }
- };
- }, []);
+ useEffect(() => startFrameLoop((ctx) => latestCb.current(ctx)), []);
}
diff --git a/packages/typegpu-react/src/react-native/index.ts b/packages/typegpu-react/src/react-native/index.ts
index bfb514da65..2e6c3ce100 100644
--- a/packages/typegpu-react/src/react-native/index.ts
+++ b/packages/typegpu-react/src/react-native/index.ts
@@ -1,8 +1,14 @@
import { WebGPUModule } from 'react-native-webgpu';
+import { registerTypegpuReactSerializables } from './serialization/register-serializables.ts';
+
// Making sure the WebGPU module is installed before navigator.gpu is accessed
WebGPUModule.install();
+// No-ops when react-native-worklets is not installed
+registerTypegpuReactSerializables();
export * from '../shared-exports.ts';
-export { useConfigureContext } from './use-configure-context.ts';
+// Intentionally shadows the browser `useFrame`, this one can run the frame loop on the UI runtime
+export { useFrame } from './use-frame.ts';
+export { useConfigureContext } from './use-configure-worklet-context.ts';
diff --git a/packages/typegpu-react/src/react-native/serialization/register-serializables.ts b/packages/typegpu-react/src/react-native/serialization/register-serializables.ts
new file mode 100644
index 0000000000..5c184e6e12
--- /dev/null
+++ b/packages/typegpu-react/src/react-native/serialization/register-serializables.ts
@@ -0,0 +1,91 @@
+import { installWebGPU } from 'react-native-webgpu';
+import {
+ isNonTransferableResource,
+ isSnapshotableResource,
+ restoreResource,
+ snapshotResource,
+ type TgpuResourceSnapshot,
+} from 'typegpu/~internal';
+import {
+ cacheTransferredResource,
+ getCachedTransferredResource,
+ getOrCreateTransferId,
+ getTransferredRoot,
+} from './transfer-cache.ts';
+import { getWorkletsModule } from '../worklets-integration.ts';
+
+export type PackedTgpuResource = {
+ id: number;
+ snapshot: TgpuResourceSnapshot;
+};
+
+let registered = false;
+
+export function registerTypegpuReactSerializables(): void {
+ if (registered) {
+ return;
+ }
+ const worklets = getWorkletsModule();
+ if (!worklets) {
+ return;
+ }
+ registered = true;
+
+ worklets.registerCustomSerializable({
+ name: 'TypeGPU',
+ determine(value: object): value is object {
+ 'worklet';
+ // Non-transferable TypeGPU objects are claimed too, so pack() fails loudly
+ return isSnapshotableResource(value) || isNonTransferableResource(value);
+ },
+ pack(value: object): PackedTgpuResource {
+ 'worklet';
+ const snapshot = snapshotResource(value);
+ if (!snapshot) {
+ const resourceType = (value as { resourceType?: string }).resourceType ?? 'unknown';
+ throw new Error(
+ `[typegpu-react] TypeGPU object '${resourceType}' cannot be transferred to a worklet. ` +
+ 'Definitions (functions, comptime, derived) are runtime-local: import them from a module ' +
+ 'covered by importForwarding, or build pipelines on the RN thread and transfer the result.',
+ );
+ }
+ for (const [key, field] of Object.entries(snapshot)) {
+ // Inlined isWorkletFunction, the lazily resolved module cannot be captured in a worklet
+ if (
+ typeof field === 'function' &&
+ !(field as { __workletHash?: unknown }).__workletHash &&
+ !(field as { __bundleData?: unknown }).__bundleData
+ ) {
+ throw new Error(
+ `[typegpu-react] Cannot transfer '${snapshot.type}': its '${key}' is a plain function. ` +
+ "Only worklets can cross runtimes - mark it with 'worklet'. If it is a schema or " +
+ 'TypeGPU definition, it cannot be transferred yet.',
+ );
+ }
+ }
+ return { id: getOrCreateTransferId(value), snapshot };
+ },
+ unpack(payload: PackedTgpuResource): object {
+ 'worklet';
+ try {
+ const cached = getCachedTransferredResource(payload.id);
+ if (cached) {
+ return cached;
+ }
+
+ installWebGPU();
+ const resource = restoreResource(payload.snapshot, {
+ getRoot: getTransferredRoot,
+ }) as object;
+ cacheTransferredResource(payload.id, resource);
+ return resource;
+ } catch (err) {
+ const details = err instanceof Error ? (err.stack ?? err.message) : String(err);
+ throw new Error(
+ `[typegpu-react] Failed to restore '${payload?.snapshot?.type}' (id ${payload?.id}). Cause: ${details}`,
+ { cause: err },
+ );
+ }
+ },
+ });
+}
diff --git a/packages/typegpu-react/src/react-native/serialization/transfer-cache.ts b/packages/typegpu-react/src/react-native/serialization/transfer-cache.ts
new file mode 100644
index 0000000000..1cb03c8551
--- /dev/null
+++ b/packages/typegpu-react/src/react-native/serialization/transfer-cache.ts
@@ -0,0 +1,117 @@
+import { tgpu, type TgpuRoot } from 'typegpu';
+
+export type TransferredResourceRef = {
+ deref(): object | undefined;
+};
+
+type ResourceWeakRefConstructor = new (
+ target: T,
+) => {
+ deref(): T | undefined;
+};
+
+type WeakRefGlobals = {
+ WeakRef?: ResourceWeakRefConstructor;
+};
+
+type ResourceFinalizationRegistry = {
+ register(target: object, heldValue: number): void;
+};
+
+type ResourceFinalizationRegistryConstructor = new (
+ cleanup: (heldValue: number) => void,
+) => ResourceFinalizationRegistry;
+
+type TypegpuReactTransferGlobals = typeof globalThis & {
+ __TYPEGPU_REACT_NEXT_TRANSFER_ID__?: number;
+ __TYPEGPU_REACT_TRANSFER_IDS__?: WeakMap