React Native WebGPU

Three.js

Run Three.js on React Native WebGPU

Using the Three.js WebGPU backend, you can run the library on top of React Native.

Loading WebGPU playground…

To start from a preconfigured Expo project, create the hosted WebGPU template and choose Three.js from its menu. The sections below explain what that template sets up, so you can apply the same configuration to an existing app.

Project setup

Import the WebGPU entry point

Always import from three/webgpu, not from three:

import * as  from "three/webgpu";

Every React Native version react-native-webgpu supports ships Metro with package exports enabled, so this import (as well as three/addons/* and three/tsl) resolves without any custom configuration. For a self-contained scene, that is all you need.

Alias three when you use addons

If your scene uses any addons from three/addons/*, you must ensure that bare three imports are redirected to the WebGPU build.

// metro.config.js
const path = require("path");

const threePackagePath = path.dirname(require.resolve("three/package.json"));

config.resolver.resolveRequest = (context, moduleName, platform) => {
  if (moduleName === "three" || moduleName === "three/webgpu") {
    return {
      filePath: path.resolve(threePackagePath, "build/three.webgpu.js"),
      type: "sourceFile",
    };
  }
  return context.resolveRequest(context, moduleName, platform);
};

Static class blocks

Expo's babel-preset-expo already includes this transform. If you are not using this preset, you need to add the following plugin manually:

yarn add -D @babel/plugin-transform-class-static-block
// babel.config.js
module.exports = {
  presets: ["module:@react-native/babel-preset"],
  plugins: ["@babel/plugin-transform-class-static-block"],
};

TextDecoder Polyfill

Hermes provides TextEncoder but not TextDecoder, which Three.js uses in its core and in loaders such as GLTFLoader. Install a polyfill and import it once, before any Three.js code runs:

yarn add fast-text-encoding
// index.js or App.tsx, first import
import "fast-text-encoding";

Type checking

@types/three publishes the three/webgpu, three/tsl, and three/addons/* typings only through its package exports. React Native's default @react-native/typescript-config uses moduleResolution: "bundler", which honours exports, so nothing else is needed there. Import addons with their .js extension, as in three/addons/loaders/GLTFLoader.js: the exports map points at exact files, so the extension-less form does not resolve.

If your tsconfig.json uses moduleResolution: "node", TypeScript ignores exports and those imports fail with "Cannot find module". Map them explicitly, and map bare three to the WebGPU typings as well so the types mirror the Metro alias:

tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "three": ["node_modules/@types/three/src/Three.WebGPU.d.ts"],
      "three/webgpu": ["node_modules/@types/three/src/Three.WebGPU.d.ts"],
      "three/tsl": ["node_modules/@types/three/src/Three.TSL.d.ts"],
      "three/addons/*": ["node_modules/@types/three/examples/jsm/*"]
    }
  }
}

Create the renderer

WebGPURenderer expects a DOM-like canvas. The native canvas from getContext("webgpu") already satisfies this interface, so pass it directly along with the same GPUCanvasContext. Pass your GPUDevice as well. Left out, the renderer requests one of its own, which puts the device out of reach for teardown and for any WebGPU code of yours that needs to share it.

const  = .!.("webgpu")!;
const  = new .({
  : true,
  : .,
  ,
  ,
});

The renderer configures the context itself (preferred canvas format, alpha mode), so do not call context.configure() yourself. Nothing else needs setting up: hand your frame callback to setAnimationLoop() and start rendering, as shown in the next section.

Driving frames yourself

The renderer finishes its setup in renderer.init(), an async method that setAnimationLoop() calls for you. If you call render() from your own loop instead, for example from requestAnimationFrame or from react-three-fiber, await renderer.init() once before the first render(). Otherwise three logs a warning and turns that first frame into renderAsync().

Render loop + present

Size the drawing buffer for the device pixel ratio (Canvas), build the scene, then hand the loop to setAnimationLoop(). Call present() after each render(): unlike the browser, React Native does not swap the frame to the screen for you.

const  = .!.("webgpu")!;
const  = . as HTMLCanvasElement;

. = . * .();
. = . * .();

const  = (, );

const  = new .();
const  = new .(70, . / ., 0.01, 10);
.. = 1;

const  = new .(
  new .(0.35, 0.35, 0.35),
  new .(),
);
.();

.(() => {
  .. =  / 2000;
  .. =  / 1000;
  .(, );
  .(); // required on React Native
});

Teardown

Stop the loop and dispose the renderer when the component unmounts. setAnimationLoop(null) alone is not enough: dispose() is what stops the internal frame callback that keeps the whole renderer graph alive.

.(null);
.();

Known leak in three r184

Three's RenderObjects.dispose() leaves dispose listeners of the disposed renderer on a module-level shared QuadMesh geometry, which keeps the old backend alive across remounts. Until the upstream fix lands, the example app clears those listeners after dispose(). See issue #445.

Loading assets

Three's loaders fetch URLs, so give them one. Register the model formats you use as Metro assets, then resolve the bundled file to a URL with Image.resolveAssetSource:

// metro.config.js
config.resolver.assetExts = [...config.resolver.assetExts, "glb", "gltf", "bin", "hdr"];
const  = .(("./assets/DamagedHelmet.glb")).;

new ().(, () => {
  // gltf.scene is ready to be added to your scene
});

Prefer self-contained files

In development, Metro serves the asset folder over HTTP, so a .gltf can reference sibling .bin and texture files by relative path. Release builds bundle each asset individually with a flattened name, and those relative references no longer resolve. Ship .glb (and embedded textures) unless you have verified your loader configuration in a release build.

Rendering into a Skia canvas

With a Graphite build of React Native Skia, Three.js can render into a texture that Skia draws as a regular image, so a 3D scene composes with everything else on a Skia <Canvas /> (blurs, masks, text, gestures). Three renders on Skia's own device; the texture is wrapped into an SkImage without a copy.

The renderer only needs a GPUCanvasContext-shaped object whose getCurrentTexture() returns a texture you allocated:

import { Skia } from "@shopify/react-native-skia";
import { importDevice } from "react-native-webgpu";

const device = importDevice(Skia.getNativeDevice());
const format = navigator.gpu.getPreferredCanvasFormat();
const texture = device.createTexture({
  size: [width, height],
  format,
  usage:
    GPUTextureUsage.RENDER_ATTACHMENT |
    GPUTextureUsage.TEXTURE_BINDING |
    GPUTextureUsage.COPY_SRC,
});

// Minimal stand-in for a GPUCanvasContext: no swapchain, one persistent texture.
const context = {
  canvas: { width, height },
  configure: () => undefined,
  unconfigure: () => undefined,
  getCurrentTexture: () => texture,
} as unknown as GPUCanvasContext;

const renderer = makeWebGPURenderer(context, device);

renderer.setAnimationLoop(() => {
  renderer.render(scene, camera);
  // Wrap the texture into a fresh SkImage and hand it to a Skia <Image />.
  image.value = Skia.Image.MakeImageFromNativeTexture(texture.nativePointer);
});

No present() is needed here: Skia owns the screen and redraws when the image changes. See Zero-copy texture sharing for the interop rules, and the react-native-skia example app for a complete hook.

Next: React Three Fiber for declarative JSX on top of the same renderer.