React Three Fiber Full documentation content. \[!IMPORTANT] You're reading the docs for **v10, currently in alpha**: APIs can still change before release. For the current stable release, see the [v9 docs](https://docs.pmnd.rs/react-three-fiber). The version switcher at the bottom of the sidebar switches between them. [![React Three Fiber banner](https://github.com/pmndrs/react-three-fiber/raw/v10/docs/banner-r3f.jpg)](/next/getting-started/examples) ```bash npm install three @types/three @react-three/fiber@alpha ``` > \[!WARNING]\ > Three-fiber is a React renderer, it must pair with a major version of React, just like react-dom, react-native, etc. @react-three/fiber\@8 pairs with react\@18, @react-three/fiber\@9 and @10 pair with react\@19. > \[!TIP] **New in v10:** WebGPU by default (WebGL stays available from `@react-three/fiber/legacy`) and a rewritten frame-loop scheduler. See the [v10 Migration Guide](/next/migration/v10), the [WebGPU section](/next/webgpu/overview), and [Frame Loop & Scheduler](/next/frame-loop). React Native moves to its own package, [`@react-three/native`](/next/getting-started/installation#react-native), which is not on npm yet. ## Does it have limitations? None. Everything that works in Threejs will work here without exception. ## Is it slower than plain Threejs? No. There is no overhead. Components render outside of React. It outperforms Threejs in scale due to React's scheduling abilities. ## Can it keep up with frequent feature updates to Threejs? Yes. It merely expresses Threejs in JSX, `` dynamically turns into `new THREE.Mesh()`. If a new Threejs version adds, removes or changes features, it will be available to you instantly without depending on updates to this library. ## What does it look like? Let's make a re-usable component that has its own state, reacts to user-input and participates in the render-loop: Interactive code example (available on the documentation website). Show TypeScript example ```bash npm install @types/three ``` Interactive code example (available on the documentation website). Show React Native example For installation instructions see [react native installation instructions](/next/getting-started/installation#react-native). ```jsx import React, { useRef, useState } from 'react' import { Canvas, useFrame } from '@react-three/native' function Box(props) { const meshRef = useRef(null) const [hovered, setHover] = useState(false) const [active, setActive] = useState(false) useFrame((state, delta) => (meshRef.current.rotation.x += delta)) return ( setActive(!active)} onPointerOver={(event) => setHover(true)} onPointerOut={(event) => setHover(false)}> ) } export default function App() { return ( ) } ``` ## First steps You need to be versed in both React and Threejs before rushing into this. If you are unsure about React consult the official [React docs](https://react.dev/learn), especially [the section about hooks](https://react.dev/reference/react). As for Threejs, make sure you at least glance over the following links: 1. Make sure you have a [basic grasp of Threejs](https://threejs.org/manual/#en/creating-a-scene). Keep that site open. 2. When you know what a scene is, a camera, mesh, geometry, material, fork the [demo above](https://github.com/pmndrs/react-three-fiber#what-does-it-look-like). 3. [Look up](https://threejs.org/docs/index.html#api/en/objects/Mesh) the JSX elements that you see (mesh, ambientLight, etc), _all_ threejs exports are native to three-fiber. 4. Try changing some values, scroll through our [API](/next/api/canvas) to see what the various settings and hooks do. Some helpful material: - [Threejs-docs](https://threejs.org/docs) and [examples](https://threejs.org/examples) - [Discover Threejs](https://discoverthreejs.com), especially the [Tips and Tricks](https://discoverthreejs.com/tips-and-tricks) chapter for best practices - [Bruno Simons Threejs Journey](https://threejs-journey.com), arguably the best learning resource, now includes a full [R3F chapter](https://threejs-journey.com/lessons/what-are-react-and-react-three-fiber) [![](https://github.com/pmndrs/react-three-fiber/raw/v10/docs/banner-journey.jpg)](https://threejs-journey.com) ## Ecosystem There is a vibrant and extensive ecosystem around three-fiber, full of libraries, helpers and abstractions. - [`@react-three/drei`](https://github.com/pmndrs/drei) – useful helpers, this is an ecosystem in itself - [`@react-three/gltfjsx`](https://github.com/pmndrs/gltfjsx) – turns GLTFs into JSX components - [`@react-three/postprocessing`](https://github.com/pmndrs/react-postprocessing) – post-processing effects - [`@react-three/test-renderer`](https://github.com/pmndrs/react-three-fiber/tree/master/packages/test-renderer) – for unit tests in node - [`@react-three/flex`](https://github.com/pmndrs/react-three-flex) – flexbox for react-three-fiber - [`@react-three/xr`](https://github.com/pmndrs/react-xr) – VR/AR controllers and events - [`@react-three/csg`](https://github.com/pmndrs/react-three-csg) – constructive solid geometry - [`@react-three/rapier`](https://github.com/pmndrs/react-three-rapier) – 3D physics using Rapier - [`@react-three/cannon`](https://github.com/pmndrs/use-cannon) – 3D physics using Cannon - [`@react-three/p2`](https://github.com/pmndrs/use-p2) – 2D physics using P2 - [`@react-three/a11y`](https://github.com/pmndrs/react-three-a11y) – real a11y for your scene - [`@react-three/gpu-pathtracer`](https://github.com/pmndrs/react-three-gpu-pathtracer) – realistic path tracing - [`create-r3f-app next`](https://github.com/pmndrs/react-three-next) – nextjs starter - [`lamina`](https://github.com/pmndrs/lamina) – layer based shader materials - [`zustand`](https://github.com/pmndrs/zustand) – flux based state management - [`jotai`](https://github.com/pmndrs/jotai) – atoms based state management - [`valtio`](https://github.com/pmndrs/valtio) – proxy based state management - [`react-spring`](https://github.com/pmndrs/react-spring) – a spring-physics-based animation library - [`framer-motion-3d`](https://www.framer.com/docs/three-introduction/) – framer motion, a popular animation library - [`use-gesture`](https://github.com/pmndrs/react-use-gesture) – mouse/touch gestures - [`leva`](https://github.com/pmndrs/leva) – create GUI controls in seconds - [`math`](https://github.com/pmndrs/math) – a kitchen sink for math helpers - [`koota`](https://github.com/pmndrs/koota) – ECS (entity management system) - [`composer-suite`](https://github.com/hmans/composer-suite) – composing shaders, particles, effects and game mechanics For a longer, categorized list of libraries, tools, learning resources and projects built with R3F, see [Awesome React Three Fiber](https://github.com/pmndrs/react-three-fiber/blob/v10/AwesomeR3F.md). ## How to contribute If you like this project, please consider helping out. All contributions are welcome as well as donations to [Opencollective](https://opencollective.com/react-three-fiber), or in crypto `BTC: 36fuguTPxGCNnYZSRdgdh6Ea94brCAjMbH`, `ETH: 0x6E3f79Ea1d0dcedeb33D3fC6c34d2B1f156F2682`. ## Backers Thank you to all our backers! 🙏 [![](https://opencollective.com/react-three-fiber/backers.svg?width=890)](https://opencollective.com/react-three-fiber#backers) ## Contributors This project exists thanks to all the people who contribute. [![](https://opencollective.com/react-three-fiber/contributors.svg?width=890)](https://github.com/pmndrs/react-three-fiber/graphs/contributors) ]]> \[!WARNING]\ > Fiber is a React renderer, so it must pair with a major version of React, just like react-dom or react-native. It works with ReactDOM and React Native. > > | Fiber | React | three | > | ------------------------------- | -------------- | ----------- | > | `@react-three/fiber@8` | `react@18` | — | > | `@react-three/fiber@9` | `react@19` | — | > | `@react-three/fiber@10` (alpha) | `>=19.0 <19.4` | `>=0.185.0` | > > The v10 bounds are deliberate — they state the versions we have tested (React 19.0 through 19.3), and move when a newer one is verified. Installing v10 against three r184 will fail peer resolution. Getting started with React Three Fiber is not nearly as hard as you might have thought, but various frameworks may require particular attention. We've put together guides for getting started with each popular framework: - Vite.js - Next.js - CDN w/o build tools - React Native If you just want to give it a try, fork this [example on codesandbox](https://codesandbox.io/s/rrppl0y8l4?file=/src/App.js)! ## Vite.js `vite` will also work out of the box. ```bash # Create app npm create vite my-app # Select react as framework # Install dependencies cd my-app npm install three @react-three/fiber@alpha # Start development server npm run dev ``` ## Next.js It should work out of the box but you will encounter untranspiled add-ons in the three.js ecosystem, in that case, ### Next.js 13.1 or latest version You need to add three to `transpilePackages` property in `next.config.js`: ```js transpilePackages: ['three'], ``` ### Next.js 13.0 or oldest version You can install the `next-transpile-modules` module: ```bash npm install next-transpile-modules --save-dev ``` then, add this to your `next.config.js` ```js const withTM = require('next-transpile-modules')(['three']) module.exports = withTM() ``` Make sure to check out our [official next.js starter](https://github.com/pmndrs/react-three-next), too! ## Without build tools You can use React Three Fiber with browser-ready ES Modules from [esm.sh](https://esm.sh) and a JSX-like syntax powered by [htm](https://github.com/developit/htm). ```jsx import ReactDOM from 'https://esm.sh/react-dom' import React, { useRef, useState } from 'https://esm.sh/react' import { Canvas, useFrame } from 'https://esm.sh/@react-three/fiber' import htm from 'https://esm.sh/htm' const html = htm.bind(React.createElement) ReactDOM.render(html`<${Canvas}>...`, document.getElementById('root')) ``` Full example ```jsx import ReactDOM from 'https://esm.sh/react-dom' import React, { useRef, useState } from 'https://esm.sh/react' import { Canvas, useFrame } from 'https://esm.sh/@react-three/fiber' import htm from 'https://esm.sh/htm' const html = htm.bind(React.createElement) function Box(props) { const meshRef = useRef() const [hovered, setHover] = useState(false) const [active, setActive] = useState(false) useFrame(() => (meshRef.current.rotation.x = meshRef.current.rotation.y += 0.01)) return html` setActive(!active)} onPointerOver=${() => setHover(true)} onPointerOut=${() => setHover(false)} > ` } ReactDOM.render( html` <${Canvas}> <${Box} position=${[-1.2, 0, 0]} /> <${Box} position=${[1.2, 0, 0]} /> `, document.getElementById('root'), ) ``` ## React Native React Native support lives in its own package, `@react-three/native` (in v9 and earlier this was the `@react-three/fiber/native` entry point). It uses `expo-gl` and `expo-asset` under the hood for WebGL2 bindings and ensuring interplay between Metro and three.js loaders. > \[!WARNING] `@react-three/native` is not on npm yet. Its source lives at [pmndrs/native](https://github.com/pmndrs/native). Until it is published, React Native apps stay on v9 and import from `@react-three/fiber/native`. To get started, create an app via `expo` or `react-native`: ```bash # Create a managed/bare app npx create-expo-app cd my-app # or # Create and link bare app npx react-native init my-app npx install-expo-modules@latest cd my-app ``` Then install dependencies (for manual installation or migration, see [expo modules installation](https://docs.expo.dev/bare/installing-expo-modules)): ```bash # Automatically install expo install expo-gl # Install NPM dependencies npm install three @react-three/native ``` Some configuration may be required to tell the Metro bundler about your assets if you use `useLoader` or Drei abstractions like `useGLTF` and `useTexture`: ```js // metro.config.js module.exports = { resolver: { sourceExts: ['js', 'jsx', 'json', 'ts', 'tsx', 'cjs', 'mjs'], assetExts: ['glb', 'gltf', 'png', 'jpg'], }, } ``` R3F's API is completely x-platform, so you can use [events](/next/api/events) and [hooks](/next/api/hooks) just as you would on the web. > \[!NOTE] Make sure to import from `@react-three/native` or `@react-three/drei/native` for correct IntelliSense and platform-specific abstractions. > \[!NOTE] iOS simulators often have incomplete or unreliable OpenGL ES support, which can cause EXC_BAD_ACCESS crashes when rendering 3D content. Always test on a physical iOS device (iPhone/iPad running iOS 16 or later) to ensure stable WebGL rendering. ```jsx import { Suspense } from 'react' import { Canvas } from '@react-three/native' import { useGLTF } from '@react-three/drei/native' import modelPath from './path/to/model.glb' function Model(props) { const gltf = useGLTF(modelPath) return } export default function App() { return ( ) } ``` ]]> ` component from `@react-three/fiber` and putting it in our React tree. ```jsx import { createRoot } from 'react-dom/client' import { Canvas } from '@react-three/fiber' function App() { return (
) } createRoot(document.getElementById('root')).render() ``` The Canvas component does some important setup work behind the scenes: - It sets up a **Scene** and a **Camera**, the basic building blocks necessary for rendering - It renders our scene every frame, you do not need a traditional render-loop > \[!NOTE] Canvas is responsive to fit the parent node, so you can control how big it is by changing the parents width and height, in this case #canvas-container. ## Adding a Mesh Component To actually see something in our scene, we'll add a lowercase `` native element, which is the direct equivalent to new THREE.Mesh(). ```js ``` > \[!NOTE] Note that we don't need to import anything, All three.js objects will be treated as native JSX elements, just like you can just write \
or \ in regular ReactDOM. The general rule is that Fiber components are available under the camel-case version of their name in three.js. A [`Mesh`](https://threejs.org/docs/#api/en/objects/Mesh) is a basic scene object in three.js, and it's used to hold the geometry and the material needed to represent a shape in 3D space. We'll create a new mesh using a **BoxGeometry** and a **MeshStandardMaterial** which [automatically attach](/next/api/objects#attach) to their parent. ```jsx ``` Let's pause for a moment to understand exactly what is happening here. The code we just wrote is the equivalent to this three.js code: ```jsx import * as THREE from 'three/webgpu' const scene = new THREE.Scene() const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000) const renderer = new THREE.WebGPURenderer() await renderer.init() renderer.setSize(width, height) document.querySelector('#canvas-container').appendChild(renderer.domElement) const mesh = new THREE.Mesh() mesh.geometry = new THREE.BoxGeometry() mesh.material = new THREE.MeshStandardMaterial() scene.add(mesh) function animate() { requestAnimationFrame(animate) renderer.render(scene, camera) } animate() ``` ### Constructor arguments According to the [docs for `BoxGeometry`](https://threejs.org/docs/#api/en/geometries/BoxGeometry) we can optionally pass three arguments for: width, length and depth: ```js new THREE.BoxGeometry(2, 2, 2) ``` In order to do this in Fiber we use the `args` prop, which _always_ takes an array whose items represent the constructor arguments. ```jsx ``` > \[!NOTE] Note that every time you change args, the object must be re-constructed! ## Adding lights Next, we will add some lights to our scene, by putting these components into our canvas. ```jsx ``` ### Props This introduces us to the last fundamental concept of Fiber, how React `props` work on three.js objects. When you set any prop on a Fiber component, it will set the property of the same name on the three.js instance. Let's focus on our `ambientLight`, whose [documentation](https://threejs.org/docs/#api/en/lights/AmbientLight) tells us that we can optionally construct it with a color, but it can also receive props. ```jsx ``` Which is the equivalent to: ```jsx const light = new THREE.AmbientLight() light.intensity = 0.1 ``` ### Shortcuts There are a few shortcuts for props that have a `.set()` method (colors, vectors, etc). ```jsx const light = new THREE.DirectionalLight() light.position.set(0, 0, 5) light.color.set('red') ``` Which is the same as the following in JSX: ```jsx ``` Please refer to the API for [a deeper explanation](/next/api/objects). ## The result Interactive code example (available on the documentation website). > \[!TIP] You can live-edit the code above: > > - try different materials, like [`MeshNormalMaterial`](https://threejs.org/docs/#api/en/materials/MeshNormalMaterial) or [`MeshBasicMaterial`](https://threejs.org/docs/#api/en/materials/MeshBasicMaterial), give them a color > - try different geometries, like [`SphereGeometry`](https://threejs.org/docs/#api/en/geometries/SphereGeometry) or [`OctahedronGeometry`](https://threejs.org/docs/#api/en/geometries/OctahedronGeometry) > - try changing the `position` on our `mesh` component, by setting the prop with the same name > - try extracting our mesh to a new component ]]> ( ) ``` ## Properties | Prop | Description | Default | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | | children | three.js JSX elements or regular components | | | fallback | optional DOM JSX elements or regular components in case GL is not supported | | | renderer | Configures the `WebGPURenderer` (`@react-three/fiber`, `/webgpu`): a props bag, a renderer instance, or a sync/async callback — R3F handles `init()` for you. The props bag takes constructor parameters, renderer properties and R3F's renderer settings `shadows` and `textureColorSpace`; all of it is honored only by a canvas that owns its renderer (see [Renderer settings](#renderer-settings)). `renderer` alone is the same as omitting it. Not on `/legacy` | `WebGPURenderer` | | gl | **`/legacy` only.** Configures the `WebGLRenderer`: props, an instance, or a sync/async callback with default props `gl={defaults => new Renderer({ ...defaults })}`, and the same `shadows` / `textureColorSpace` settings as `renderer`. Throws on `@react-three/fiber` and `/webgpu` | `{}` | | id | Canvas id: sets the `` element's `id`, names the canvas for `scheduler` `before`/`after`, and for `share="id"` on a `primary` | `undefined` | | primary | **WebGPU.** This canvas owns the renderer other canvases share: every other WebGPU canvas mounted with or after it borrows its renderer unless it passes `share={false}` or its own renderer instance/factory. Not on `/legacy`. See [Multi-Canvas](../webgpu/multi-canvas) | `false` | | share | **WebGPU.** `"id"` shares the renderer of `` (needed when several primaries are mounted); `false` owns a renderer even while a primary is mounted. Omitted: share the single primary, if any. Not on `/legacy` | `undefined` | | scheduler | This canvas's place in the frame loop: `{ before, after, order, fps }`. `before`/`after` take canvas ids, `order` sorts numerically (lower first), `fps` caps the default render. A canvas sharing a primary's renderer renders after it unless `before`/`after` is set. See [Frame Loop](../frame-loop#ordering-canvas-roots) | `undefined` | | camera | Props that go into the default camera, or your own `THREE.Camera` | `{ fov: 75, near: 0.1, far: 1000, position: [0, 0, 5] }` | | scene | Props that go into the default scene, or your own `THREE.Scene` | `{}` | | background | Sets the scene background — a CSS color, hex number, `THREE.Color`, a Drei environment preset name, an HDR/EXR/image URL, or a config object. See [Canvas Background](#canvas-background) | `undefined` | | raycaster | Props that go into the default raycaster | `{}` | | frameloop | Render mode: always, demand, never | `always` | | resize | Resize config, see react-use-measure's options | `{ scroll: true, debounce: { scroll: 50, resize: 0 } }` | | orthographic | Creates an orthographic camera | `false` | | dpr | Pixel-ratio: a fixed number, or automatic: \[min, max], which clamps `window.devicePixelRatio` and follows display changes. See [Pixel ratio](#pixel-ratio) | `[1, 2]` | | width | Fixed drawing-buffer width in pixels. When set, R3F stops auto-sizing to the container width. See [Canvas Size Control](#canvas-size-control) | container width | | height | Fixed drawing-buffer height in pixels. When set, R3F stops auto-sizing to the container height | container height | | forceEven | Round computed width/height down to even numbers (useful for video encoders) | `false` | | events | Configuration for the event manager, as a function of state | `import { events } from "@react-three/fiber"` | | eventSource | The source where events are being subscribed to, any DOM `Element` (an `` works) | `React.RefObject`, `renderer.domElement.parentNode` | | eventPrefix | The event prefix that is cast into canvas pointer x/y events | `offset` | | autoUpdateFrustum | Enable/disable automatic camera frustum updates each frame | `true` | | onCreated | Callback after the canvas has rendered (but not yet committed) | `(state) => {}` | | onPointerMissed | Response for pointer clicks that have missed any target | `(event) => {}` | ## Defaults Canvas uses [createRoot](#createroot) which will create a translucent `THREE.WebGLRenderer` with the following constructor args: - antialias=true - alpha=true - powerPreference="high-performance" and with the following properties: - outputColorSpace = THREE.SRGBColorSpace - toneMapping = THREE.ACESFilmicToneMapping It will also create the following scene internals: - A `THREE.Perspective` camera - A `THREE.Orthographic` cam if `orthographic` is true - A `THREE.PCFShadowMap` if `renderer={{ shadows: true }}` (or `gl={{ shadows: true }}`) - A `THREE.Scene` (into which all the JSX is rendered) and a `THREE.Raycaster` In recent versions of threejs, `THREE.ColorManagement.enabled` will be set to `true` to enable automatic conversion of colors according to the renderer's configured color space. R3F will handle texture color space conversion. For more on this topic, see . ## Errors and fallbacks On some systems WebGL may not be supported, you can provide a fallback component that will be rendered instead of the canvas: ```jsx Sorry no WebGL supported!
}>
``` You should also safeguard the canvas against WebGL context crashes, for instance if users have the GPU disabled or GPU drivers are faulty. ```jsx import { useErrorBoundary } from 'use-error-boundary' function App() { const { ErrorBoundary, didCatch, error } = useErrorBoundary() return didCatch ? (
{error.message}
) : ( ) } ``` > \[!NOTE] Ideally, and if possible, your fallback is a seamless, visual replacement for what the canvas would have otherwise rendered. ## Canvas Background The `background` prop sets the scene background (and, for environment maps, image-based lighting) declaratively — replacing manual `` or a separate Environment component for simple cases. ```tsx // Color (CSS color names, hex strings, rgb(), or a hex number) // Drei environment preset (apartment, city, dawn, forest, lobby, night, park, studio, sunset, warehouse) // URL to an HDR/EXR/image file: .hdr, .exr, .jpg (Ultra HDR or plain), .png, .webp, .avif, .gif // Six cube faces (+x, -x, +y, -y, +z, -z); `.hdr` faces load as an HDR cubemap ``` When a **string** is given it is resolved in this order: exact **preset** name → **URL** (starts with `/`, `./`, `../`, `http://`, `https://`, `data:`, or ends in `.hdr`/`.exr`/`.jpg`/`.jpeg`/`.png`/`.webp`/`.avif`/`.gif`, optionally followed by a `?query` or `#hash`) → otherwise a **CSS color**. So `"city"` is a preset, `"./city.jpg"` is a URL, and `"cyan"` is a color. HDR files (`.hdr`, `.exr`, an Ultra HDR `.jpg`) also light the scene with linear radiance. Any other image is an sRGB equirect: it works as a backdrop and as a (low dynamic range) environment. For separate backdrop and environment maps, pass an object: ```tsx ``` The underlying `Environment` component is also exported from `@react-three/fiber` for direct use. ## Canvas Size Control By default the canvas auto-sizes to its container. The `width`/`height` props pin the drawing-buffer resolution instead — independent of CSS size — which is useful for fixed-resolution capture such as video export. ```tsx // Fixed resolution regardless of container size // Partial: width pinned, height from container // 4K capture (set dpr={1} for exact pixel dimensions) ``` Size follows an ownership model: props (or container fallback) drive size until you call `setSize()` imperatively, which takes ownership; call `setSize()` with no arguments to release back to props/container. ```tsx const { setSize } = useThree() setSize(3840, 2160) // imperative mode, 4K setSize(1024) // 1024×1024 square setSize() // reset to props/container ``` > \[!NOTE] `width`/`height` are logical pixels; DPR multiplies as usual. Set `dpr={1}` for exact pixel dimensions. Use `forceEven` to round dimensions down to even numbers for video encoders. ## Pixel ratio `dpr` is either a fixed number or a `[min, max]` range (the default is `[1, 2]`). A range clamps `window.devicePixelRatio` into it, and keeps following it: when the window moves to a display with a different pixel ratio, or the user zooms the page, R3F re-resolves the range and resizes the drawing buffer. It listens with `matchMedia`, since neither change resizes the canvas element. A fixed number never follows the display. ```tsx // follows the display, clamped between 1 and 2 // always 1.5 ``` A value set imperatively with `setDpr()` wins until the `dpr` prop itself changes. During a WebXR session the change waits until the session ends, since three.js manages the pixel ratio while presenting. ## Renderer settings Renderer-wide settings go into the `renderer` prop (WebGPU, `@react-three/fiber`) or the `gl` prop (WebGL, `@react-three/fiber/legacy`): constructor parameters (`antialias`, `forceWebGL`, `depth`, ...), renderer properties (`toneMapping`, `outputColorSpace`, ...), and two settings R3F applies itself: - `shadows`: `true` for `THREE.PCFShadowMap`, one of `'basic'`, `'percentage'`, `'soft'`, `'variance'`, or props assigned to `renderer.shadowMap`. `'soft'` is kept only as a deprecated alias of `'percentage'`: both use `THREE.PCFShadowMap` (three.js deprecated `PCFSoftShadowMap` and made `PCFShadowMap` soft), and `'soft'` logs a deprecation notice. Default `false`. Applied to a renderer R3F builds; a renderer instance or factory keeps the `shadowMap` you give it. - `textureColorSpace`: the color space of 8-bit input textures loaded in this canvas. ```tsx ``` These configure a renderer the canvas owns. A canvas sharing a ``'s renderer ignores them (with a development warning) because the primary owns the renderer; `textureColorSpace`, which is per canvas, still applies. Constructor parameters are read once, when the renderer is built: changing them later has no effect. ### Color settings Renderer color options — `textureColorSpace`, `outputColorSpace`, `toneMapping`: ```tsx // WebGL (@react-three/fiber/legacy) // WebGPU ``` Defaults: `outputColorSpace` = `THREE.SRGBColorSpace`, `toneMapping` = `THREE.ACESFilmicToneMapping`, `textureColorSpace` = `THREE.SRGBColorSpace`. ## WebGPU and WebGL `` from `@react-three/fiber` renders with three's `WebGPURenderer`, which needs async initialization; R3F handles `init()` for you — no manual `await renderer.init()`. Browser WebGPU support is not required: `WebGPURenderer` falls back to a WebGL2 backend automatically. Node materials are available as JSX: ```tsx import { Canvas } from '@react-three/fiber' export default () => ( ) ``` `renderer` takes a config bag, a renderer instance, or a sync/async callback. R3F disposes a renderer it built from a config bag or a callback once the last Canvas using it unmounts; an instance you pass in is yours to dispose. Three's WebGPU renderer is a separate chunk, fetched when the first `` mounts. For node materials, TSL hooks and multi-canvas, see the [WebGPU section](../webgpu/overview). `WebGLRenderer` is on `@react-three/fiber/legacy`. Use it for GLSL `ShaderMaterial`s, `onBeforeCompile` and libraries that need `WebGLRenderer`: `WebGPURenderer` cannot compile GLSL, and R3F warns in development when a GLSL material reaches a WebGPU canvas. ```tsx import { Canvas } from '@react-three/fiber/legacy' export default () => {/* ... */} ``` ## View transitions React 19.3's `` can wrap elements inside a Canvas. React hands a commit that touches a `` to the renderer, and R3F applies it synchronously, the same way react-dom does in browsers without `document.startViewTransition`. Nothing is animated: the scene is not a DOM snapshot, so `enter`, `exit` and `update` animations don't play on 3D content, but the update commits and effects run as usual. Gesture transitions are not supported. ```tsx import { ViewTransition } from 'react' {shown && } ``` ## Custom Canvas R3F can render to a root, similar to how `react-dom` and all the other React renderers work. This allows you to shave off `react-dom` (\~40kb), `react-use-measure` (\~3kb) and, if you don't need them, `pointer-events` (\~7kb) (you need to explicitly import `events` and add them to the config otherwise). Roots have the same options and properties as `Canvas`, but you are responsible for resizing it. It requires an existing DOM `` object into which it renders. ### CreateRoot Creates a root targeting a canvas, rendering JSX. ```jsx import { createRoot, events } from '@react-three/fiber' // Create a react root. Three's classes are JSX elements on it without any extend() call: // the root resolves element names against the namespace of the renderer it loads. const root = createRoot(document.querySelector('canvas')) // Keep the whole configuration in one object: each configure() call replaces the previous one const config = { events, camera: { position: [0, 0, 50] } } async function app() { // Configure the root, inject events optionally, set camera, etc // This *must* be called before render, and it must be awaited await root.configure(config) // createRoot by design is not responsive, you have to take care of resize yourself. // Pass the same config again with the new size, not just the size on its own window.addEventListener('resize', () => { root.configure({ ...config, size: { width: window.innerWidth, height: window.innerHeight } }) }) // Trigger resize window.dispatchEvent(new Event('resize')) // Render entry point root.render() // Unmount and dispose of memory // root.unmount() } app() ``` `configure()` replaces the root's configuration rather than merging into it, the same way `` passes its full set of props every time it renders. A prop left out of a later call goes back to its default: calling `root.configure({ size })` on its own drops the `renderer` or `gl` settings (turning shadows off) and resets `frameloop` to `'always'` and `dpr` to `[1, 2]`. So keep one config object and spread it into every call, changing only what's new. The camera, the scene and the `events` manager are the exception: the camera and scene are created by the first call, the `events` manager by the first call that passes `events`, and all three are kept after that. Leaving `camera` out of a later call does not reset the camera, and `camera` props passed to a later call are not re-applied. To move the camera after the first `configure()`, change it from inside the tree (`useThree`) or through the store `root.render()` returns. ## Tree-shaking What a bundle carries is decided by the renderer, not by which elements you use. Fiber's core imports nothing from three; a root loads one renderer's support module (`three` for WebGL, `three/webgpu` for WebGPU) when it mounts, and that module carries the whole namespace of its flavour. So an app on `@react-three/fiber` ships no three code eagerly, downloads only the renderer its Canvas asks for, and never both. Three's own classes are not tree-shaken below that: `` and `` resolve against the loaded namespace, and an `extend({ Mesh, BoxGeometry })` call does not remove anything from it. `extend` is for classes three does not ship, such as addons and your own subclasses: ```jsx import { extend, createRoot } from '@react-three/fiber' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' extend({ OrbitControls }) createRoot(canvas).render( <> , ) ``` The `/legacy` and `/webgpu` entries import their renderer statically instead, for apps that would rather skip the extra request. See [Entry Points](../webgpu/overview#entry-points) for the comparison. ]]>
``` ✅ The problem is that all of these properties will always be re-created. Instead, you should define properties declaratively. ```jsx ``` ## Constructor arguments In three.js objects are classes that are instantiated. These classes can receive one-time constructor arguments (`new THREE.SphereGeometry(1, 32)`), and properties (`someObject.visible = true`). In React Three Fiber, constructor arguments are always passed as an array via `args`. If args change later on, the object must naturally get reconstructed from scratch! ```jsx ``` ## Shortcuts ### Set All properties whose underlying object has a `.set()` method can directly receive the same arguments that `set` would otherwise take. For example [`THREE.Color.set`](https://threejs.org/docs/#api/en/math/Color.set) can take a color string, so instead of `color={new THREE.Color('hotpink')}` you can simply write `color="hotpink"`. Some `set` methods take multiple arguments, for instance [`THREE.Vector3`](https://threejs.org/docs/#api/en/math/Vector3.set), give it an array in that case `position={[100, 0, 0]}`. ```jsx ``` > \[!NOTE] If you do link up an existing object to a property, for instance a THREE.Vector3() to a position, be aware that this will end up copying the object in most cases as it calls .copy() on the target. This only applies to objects that expose both .set() and .copy() (Vectors, Eulers, Matrix, ...). If you link up an existing material or geometry on the other hand, it will overwrite, because these objects do not have a .set() method. The copy happens when the prop changes, not on every render. See [Immutability](#immutability). ### SetScalar Properties that have a `setScalar` method (for instance `Vector3`) can be set like so: ```jsx // Translates to ``` ## Piercing into nested properties If you want to reach into nested attributes (for instance: `mesh.rotation.x`), just use dash-case. ```jsx ``` ## Immutability React Three Fiber follows React's rules and enforces props only update with immutable state. On every commit it compares each prop against the value it saw last time and only touches the underlying three.js object when something has changed. Class instances are compared **by reference**, arrays and plain objects are compared **shallowly**. That means a mutation is invisible to R3F whenever the reference you pass stays the same. ❌ The vector is created once and mutated in place, so its reference never changes. R3F sees the same prop it saw on the previous commit and skips it. The mest never moves. ```jsx function Component({ x }) { const position = useMemo(() => new THREE.Vector3(), []) position.set(x, 0, 0) return } ``` ✅ Pass a value instead. Both of these change with `x`, so the prop is diffed and applied: ```jsx ``` > \[!WARNING] The same trap applies to arrays. `position={[x, 0, 0]}` is fine because a fresh array is allocated on every render, but a memoized array mutated in place (`coords[0] = x`) has a stable reference and is skipped just like the vector above. ### Mutating three.js objects directly Immutability applies to props, not to the objects R3F creates for you. Reaching into the scene graph through a ref is the intended escape hatch, and the recommended way to animate. Do not mutate the property in a ref if it is also controlled via a React prop. The React prop will overwrite the imperative mutations. ```jsx function Component({ x }) { const ref = useRef() useFrame(() => ref.current.position.set(x, 0, 0)) return } ``` ## Dealing with non-scene objects You can put non-Object3D primitives (geometries, materials, etc) into the render tree as well. They take the same properties and constructor arguments they normally would. You might be wondering why you would want to put something in the "scene" that normally would not be part of it, in a vanilla three.js app at least. For the same reason you declare any object: it becomes managed, reactive and auto-disposes. These objects are not technically part of the scene, but they "attach" to a parent which is. ### Attach Use `attach` to bind objects to their parent. If you unmount the attached object it will be taken off its parent automatically. The following attaches a material to the `material` property of a mesh and a geometry to the `geometry` property: ```jsx ``` > \[!NOTE] All objects extending `THREE.Material` receive `attach="material"`, and all objects extending `THREE.BufferGeometry` receive `attach="geometry"`. You do not have to type it out! ```jsx ``` You can also deeply nest attach through piercing. The following adds a buffer-attribute to `geometry.attributes.position` and then adds the buffer geometry to `mesh.geometry`. ```jsx ``` #### More examples ```jsx // Attach bar to foo.a // Attach bar to foo.a.b and foo.a.b.c (nested object attach) // Attach bar to foo.a[0] and foo.a[1] (array attach is just object attach) // Attach bar to foo via explicit add/remove functions { parent.add(self) return () => parent.remove(self) }} /> // The same as a one liner (parent.add(self), () => parent.remove(self))} /> ``` #### Real-world use-cases: Attaching to nested objects, for instance a shadow-camera: ```diff - + + + ``` Arrays must have explicit order, for instance multi-materials: ```jsx {colors.map((color, index) => } ``` ## Putting already existing objects into the scene-graph You can use the `primitive` placeholder for that. You can still give it properties or attach nodes to it. Never add the same object multiple times, this is not allowed in three.js! Primitives will not dispose of the object they carry on unmount, you are responsible for disposing of it! ```jsx const mesh = new THREE.Mesh(geometry, material) function Component() { return ``` > \[!NOTE] Scene objects can only ever be added once in Threejs. If you attempt to add one and the same object in two places Threejs will remove the first instance automatically. This will also happen with primitive! If you want to re-use an existing object, you must clone it first. ## Using 3rd-party objects declaratively The `extend` function extends React Three Fiber's catalogue of JSX elements. Components added this way can then be referenced in the scene-graph using camel casing similar to other primitives. ```jsx import { extend } from '@react-three/fiber' import { OrbitControls } from 'three/addons/controls/OrbitControls.js' import { TransformControls } from 'three/addons/controls/TransformControls.js' extend({ OrbitControls, TransformControls }) // ... return ( <> ``` If you're using TypeScript, you'll also need to [extend the JSX namespace](/next/tutorials/v9-migration-guide#threeelements). ## Disposal Freeing resources is a [manual chore in `three.js`](https://threejs.org/manual/#en/how-to-dispose-of-objects), but React is aware of object-lifecycles, hence React Three Fiber will attempt to free resources for you by calling `object.dispose()`, if present, on all unmounted objects. If you manage assets by yourself, globally or in a cache, this may _not_ be what you want. You can switch it off by placing `dispose={null}` onto meshes, materials, etc, or even on parent containers like groups, it is now valid for the entire tree. ```jsx const globalGeometry = new THREE.BoxGeometry() const globalMaterial = new THREE.MeshBasicMaterial() function Mesh() { return ( ``` ## Materials and `needsUpdate` Some material properties are compiled into the shader, so three.js only picks up a change to them after `material.needsUpdate = true`. React Three Fiber sets it for you when one of these props changes value: `transparent`, `vertexColors`, `flatShading`, `fog`, `side`, `alphaHash`, `premultipliedAlpha`, `blending`, `alphaToCoverage`, `dithering`, `sizeAttenuation`, `combine`, `normalMapType` and `depthPacking`. The same applies when a texture slot (`map`, `normalMap`, `envMap`, ...) gains or loses its texture, whether through a prop or an attached child, and when a `*Node` prop changes on a node material. ```jsx // Toggling these recompiles the material automatically ``` Props three.js picks up on its own are left alone, since `needsUpdate` forces a full rebuild: colors, `opacity`, `roughness` and other uniform-backed values, thresholds such as `alphaTest`, `wireframe`, `toneMapped`, clipping planes, and swapping one texture for another. This only covers changes made through props (and `applyProps`); if you mutate a material through a ref, set `needsUpdate` yourself. ## Shader material uniforms `ShaderMaterial`, `RawShaderMaterial` and subclasses keep the material's [`uniforms` object](https://threejs.org/docs/#ShaderMaterial.uniforms) stable. When you pass a new `uniforms` object, React Three Fiber merges the incoming uniforms into the existing target instead of replacing it. This preserves three.js internal uniform caching, even if your component creates a fresh `uniforms` object on each render. Existing uniform entries are updated in place and new entries are added as needed. ```jsx ``` To get a stable reference to the material's `uniforms`, use the React ref. ```jsx function Component() { const ref = useRef() // This object is merged into the material, so mutating this reference // later will not update the shader. const uniforms = useMemo( () => ({ time: { value: 0 }, color: { value: color }, }), [color], ) useFrame(({ elapsed }) => { // The material ref's uniforms object is stable and should be mutated instead. ref.current.uniforms.time.value = elapsed }) return ( ) } ``` > \[!NOTE] This applies to GLSL `ShaderMaterial` on the WebGL backend. TSL node materials on WebGPU declare their uniforms with `useUniform`/`useUniforms` from `@react-three/tsl` instead (see [TSL hooks](/next/webgpu/tsl-hooks)). ]]> \[!NOTE] Hooks can only be used inside the Canvas element because they rely on context! ❌ You cannot expect something like this to work: ```jsx import { useThree } from '@react-three/fiber' function App() { const { size } = useThree() // This will just crash return ( ``` ✅ Do this instead: ```jsx function Foo() { const { size } = useThree() ... } function App() { return ( ``` ## `useThree` This hook gives you access to the state model which contains the default renderer, the scene, your camera, and so on. It also gives you the current size of the canvas in screen and viewport coordinates. ```jsx import { useThree } from '@react-three/fiber' function Foo() { const state = useThree() ``` The hook is reactive, if you resize the browser for instance, you get fresh measurements, same applies to any of the state objects that may change. ### `state` properties | Prop | Description | Type | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `renderer` | The renderer instance — type depends on entry point (WebGL, WebGPU, or the union for the default entry) | `R3FRenderer` | | `gl` | **Deprecated** — alias of `renderer`, typed as `WebGLRenderer` for back-compat. Use `renderer` | `THREE.WebGLRenderer` | | `scene` | Scene | `THREE.Scene` | | `camera` | Camera | `THREE.PerspectiveCamera` | | `frustum` | Camera frustum for visibility checks, auto-updated each frame | `THREE.Frustum` | | `autoUpdateFrustum` | Whether frustum auto-updates each frame (default: true) | `boolean` | | `raycaster` | Default raycaster | `THREE.Raycaster` | | `pointer` | Contains updated, normalized, centric pointer coordinates | `THREE.Vector2` | | `mouse` | Note: this is deprecated, use `pointer` instead! Normalized event coordinates | `THREE.Vector2` | | `frameloop` | Render mode: always, demand, never | `always`, `demand`, `never` | | `performance` | System regression | `{ current: number, min: number, max: number, debounce: number, regress: () => void }` | | `size` | Canvas size in pixels | `{ width: number, height: number, top: number, left: number }` | | `viewport` | Canvas viewport size in three.js units. Note: This is different from [`gl.getViewport`](https://threejs.org/docs/#api/en/renderers/WebGLRenderer.getViewport) which returns the drawbuffer size | `{ width: number, height: number, initialDpr: number, dpr: number, factor: number, distance: number, aspect: number, getCurrentViewport: (camera?: Camera, target?: THREE.Vector3, size?: Size) => Viewport }` | | `xr` | XR interface, manages WebXR rendering | `{ connect: () => void, disconnect: () => void }` | | `set` | Allows you to set any state property | `(state: SetState) => void` | | `get` | Allows you to retrieve any state property non-reactively | `() => GetState` | | `invalidate` | Request a new render, given that `frameloop === 'demand'` | `() => void` | | `advance` | Advance one tick, given that `frameloop === 'never'` | `(timestamp: number, runGlobalEffects?: boolean) => void` | | `setSize` | Resize the canvas | `(width: number, height: number, top?: number, left?: number) => void` | | `setDpr` | Set the pixel-ratio | `(dpr: number) => void` | | `setFrameloop` | Shortcut to set the current render mode | `(frameloop?: 'always', 'demand', 'never') => void` | | `setEvents` | Shortcut to setting the event layer | `(events: Partial>) => void` | | `onPointerMissed` | Response for pointer clicks that have missed a target | `() => void` | | `events` | Pointer-event handling | `{ connected: TargetNode, handlers: Events, connect: (target: TargetNode) => void, disconnect: () => void }` | ### Selector You can also select properties, this allows you to avoid needless re-render for components that are interested only in particulars. Reactivity does not include deeper three.js internals! ```jsx // Will only trigger re-render when the default camera is exchanged const camera = useThree((state) => state.camera) // Will only re-render on resize changes const viewport = useThree((state) => state.viewport) // ❌ You cannot expect reactivity from three.js internals! const zoom = useThree((state) => state.camera.zoom) ``` ### Reading state from outside of the component cycle ```jsx function Foo() { const get = useThree((state) => state.get) ... get() // Get fresh state from anywhere you want ``` ### Exchanging defaults ```jsx function Foo() { const set = useThree((state) => state.set) ... useEffect(() => { set({ camera: new THREE.OrthographicCamera(...) }) }, []) ``` ## `useFrame` This hook allows you to execute code on every rendered frame, like running effects, updating controls, and so on. You receive the state (same as `useThree`) and the frame `delta`. The callback runs inside a shared render-loop; when the component unmounts it is unsubscribed automatically. ```jsx import { useFrame } from '@react-three/fiber' function Foo() { useFrame((state, delta, xrFrame) => { // This function runs at the native refresh rate inside of a shared render-loop }) ``` > \[!NOTE] There is no `state.clock` in v10. Timing comes through the frame state instead: `state.delta` (seconds since last frame), `state.elapsed` (seconds since the first frame), `state.time` (the RAF timestamp), `state.frame` (frame count), and `state.overstep` (how far, in `[0, 1)`, the loop is into the next fixed physics step). For example, `useFrame((state) => (mesh.rotation.y = state.elapsed))`. > \[!CAUTION] Be careful about what you do inside useFrame! You should never setState in there! Your calculations should be slim and you should mind all the commonly known pitfalls when dealing with loops in general, like re-use of variables, etc. `useFrame` accepts an options object for phase placement, ordering, fps throttling, and pause/resume — and the priority/render-takeover behavior below is part of a larger scheduler. See [Frame Loop & Scheduler](../frame-loop) for the full API. Jobs in the `physics` phase run on a fixed 1/60 s step rather than once per frame: `useFrame((_, delta) => world.step(delta), { phase: 'physics' })` is called zero or more times a frame, always with `delta` equal to the step. See [Fixed timestep and interpolation](../frame-loop#fixed-timestep-and-interpolation). ### Taking over the render-loop In v10 the render loop is phase-based. R3F runs its own default render job in the `render` phase. The moment you register **any** job in the `render` phase, R3F steps aside and that default render is skipped — it becomes your responsibility to render. The default render automatically resumes when your render-phase job unmounts. ```jsx import { useFrame } from '@react-three/fiber' function TakeOver() { // Registering a job in the 'render' phase disables the default render useFrame(({ renderer, scene, camera }) => { renderer.render(scene, camera) }, { phase: 'render' }) } ``` To order callbacks within a phase, name the dependency with `before`/`after` (by job id or phase name). If one job has to run before or after another, say so explicitly: ```jsx useFrame(() => {}, { id: 'a', phase: 'update' }) useFrame(() => {}, { phase: 'update', after: 'a' }) // runs after job 'a' ``` > \[!NOTE] A bare numeric second argument (`useFrame(fn, 1)`) is deprecated and is not an ordering mechanism. A positive number keeps its v9 meaning: it disables the default render (render takeover) and emits a deprecation warning. A negative number also warns, because v10 runs higher numbers first — the reverse of v9. Use `{ phase: 'render' }` for render takeover and `before`/`after` for ordering. `{ priority }` exists for legacy code and the rare loose tie-break; don't rely on it for order that matters. See [Frame Loop & Scheduler → Ordering jobs](../frame-loop#ordering-jobs) and [Render-phase takeover](../frame-loop#render-phase-takeover) for details. ## `useLoader` This hook loads assets and suspends for easier fallback- and error-handling. It can take any three.js loader as its first argument: GLTFLoader, OBJLoader, TextureLoader, FontLoader, etc. It is based on [React.Suspense](https://react.dev/reference/react/Suspense), so fallback-handling and [error-handling](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) happen at the parental level. ```jsx import { Suspense } from 'react' import { useLoader } from '@react-three/fiber' import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' function Model() { const result = useLoader(GLTFLoader, '/model.glb') // You don't need to check for the presence of the result, when we're here // the result is guaranteed to be present since useLoader suspends the component return } function App() { return ( /* or null */}> ) } ``` > \[!TIP] Internally, `useLoader` relies on [`suspend-react`](https://github.com/pmndrs/suspend-react). > \[!NOTE] Assets loaded with useLoader are cached by default. The urls given serve as cache-keys. This allows you to re-use loaded data everywhere in the component tree. > \[!WARNING] Be very careful with mutating or disposing of loaded assets, especially when you plan to re-use them. Refer to the automatic disposal section in the API. ### Loader extensions You can provide a callback as the third argument if you need to configure your loader: ```jsx import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader' useLoader(GLTFLoader, url, (loader) => { const dracoLoader = new DRACOLoader() dracoLoader.setDecoderPath('/draco-gltf/') loader.setDRACOLoader(dracoLoader) }) ``` ### Loading multiple assets at once It can also make multiple requests in parallel: ```jsx const [bumpMap, specMap, normalMap] = useLoader(TextureLoader, [url1, url2, url2]) ``` ### Loading status You can get the loading status from a callback you provide as the fourth argument. Though consider alternatives like THREE.DefaultLoadingManager or better yet, [Drei's](https://github.com/pmndrs/drei) loading helpers. ```jsx useLoader(loader, url, extensions, (xhr) => { console.log((xhr.loaded / xhr.total) * 100 + '% loaded') }) ``` ### Custom cache keys By default the request URL is also the cache key. When the URL changes on every request — for example signed or authenticated URLs — that would defeat caching and reload the same asset. Pass an explicit cache key as the fifth argument to load from the changing URL but cache under a stable key: ```jsx const model = useLoader(GLTFLoader, signedUrl, undefined, undefined, '/model.glb') ``` `preload` and `clear` accept the same cache key so the entries line up: ```jsx useLoader.preload(GLTFLoader, signedUrl, undefined, undefined, '/model.glb') useLoader.clear(GLTFLoader, signedUrl, '/model.glb') ``` ### Special treatment of GLTFLoaders and all loaders that return a scene prop If a `result.scene` prop is found the hook will automatically create a object & material collection: `{ nodes, materials }`. This lets you build immutable scene graphs selectively. You can also specifically alter the data without having to traverse it. [GLTFJSX](https://github.com/pmndrs/gltfjsx) specifically relies on this data. ```jsx const { nodes, materials } = useLoader(GLTFLoader, url) ``` ### Pre-loading assets You can pre-load assets in global space so that models can be loaded in anticipation before they're mounted in the component tree. ```jsx useLoader.preload(GLTFLoader, '/model.glb' /* extensions */) ``` ## `useTexture` In v10, `useTexture` is exported from `@react-three/fiber`. It loads textures through `TextureLoader` with Suspense support, without requiring you to import the loader. Pass a URL, an array of URLs, or a record of names to URLs; the returned textures mirror that input shape. ```jsx import { useTexture } from '@react-three/fiber' function Material() { const props = useTexture({ map: '/color.jpg', normalMap: '/normal.jpg', }) return } ``` The optional second argument accepts `{ onLoad, cache }` or an `onLoad` callback. `cache` defaults to `true`, enrolling the textures in the registry for access through `useTextures`; pass `{ cache: false }` to skip enrollment. See [Loading Textures](../tutorials/loading-textures) for examples and registry lifetime details. ## `useEnvironment` Loads an environment map with Suspense and returns a texture ready for `scene.environment` or `scene.background`, with its mapping and color space already set. It is what `` and the Canvas [`background`](./canvas#canvas-background) prop use under the hood. ```jsx import { useEnvironment } from '@react-three/fiber' function Lighting() { const texture = useEnvironment({ files: '/studio.hdr' }) // or { preset: 'sunset' } return } ``` Options: `files`, `path`, `preset` (one of `apartment`, `city`, `dawn`, `forest`, `lobby`, `night`, `park`, `studio`, `sunset`, `warehouse`), `colorSpace`, and `extensions(loader)` to configure the loader. `files` is a single `.hdr`, `.exr` or Ultra HDR `.jpg`/`.jpeg` (decoded by three's `UltraHDRLoader`), or an array of six cube faces (`.hdr` faces load as an HDR cubemap). Each decoder is downloaded only when a file needs it. `useEnvironment.preload(options)` and `useEnvironment.clear({ files, preset })` warm and evict the cache. > \[!NOTE] The split gain map format (`['sdr.webp', 'gainmap.webp', 'metadata.json']`) is no longer supported. Use a single-file Ultra HDR `.jpg`, or drei's `` if you need the split format. ## `useGraph` Convenience hook which creates a memoized, named object/material collection from any [`Object3D`](https://threejs.org/docs/#api/en/core/Object3D). ```jsx import { useLoader, useGraph } from '@react-three/fiber' function Model(url) { const scene = useLoader(OBJLoader, url) const { nodes, materials } = useGraph(scene) return } ``` ]]> console.log('click')} onContextMenu={(e) => console.log('context menu')} onDoubleClick={(e) => console.log('double click')} onWheel={(e) => console.log('wheel spins')} onPointerUp={(e) => console.log('up')} onPointerDown={(e) => console.log('down')} onPointerOver={(e) => console.log('over')} onPointerOut={(e) => console.log('out')} onPointerEnter={(e) => console.log('enter')} // see note 1 onPointerLeave={(e) => console.log('leave')} // see note 1 onPointerMove={(e) => console.log('move')} onPointerMissed={() => console.log('missed')} onDragOver={(e) => console.log('dragging over')} // continuous drag event onDragOverEnter={(e) => console.log('drag entered')} onDragOverLeave={(e) => console.log('drag left')} onDragOverMissed={(e) => console.log('drag missed this object')} onDrop={(e) => console.log('dropped!', e.nativeEvent.dataTransfer?.files)} onDropMissed={(e) => console.log('drop missed this object')} /> ``` ### Event data ```jsx ({ ...DomEvent // All the original event data ...Intersection // All of Three's intersection data - see note 2 intersections: Intersection[] // The first intersection of each intersected object object: Object3D // The object that was actually hit eventObject: Object3D // The object that registered the event unprojectedPoint: Vector3 // Camera-unprojected point ray: Ray // The ray that was used to strike the object camera: Camera // The camera that was used in the raycaster sourceEvent: DomEvent // A reference to the host event delta: number // Distance between mouse down and mouse up event in pixels }) => ... ``` ### File Drag & Drop R3F supports drag and drop events, allowing you to drag files from your desktop onto 3D objects. This is useful for features like dropping textures onto meshes or importing models. #### Drag events on meshes | Event | Description | | ------------------ | ----------------------------------------------------- | | `onDragOver` | Fires continuously while dragging over the object | | `onDragOverEnter` | Fires once when drag enters the object | | `onDragOverLeave` | Fires once when drag leaves the object | | `onDragOverMissed` | Fires when drag is over canvas but misses this object | | `onDrop` | Fires when a file/item is dropped on this object | | `onDropMissed` | Fires when a drop misses this object | #### Canvas-level missed events The Canvas component accepts `onDragOverMissed` and `onDropMissed` props that fire when drag/drop events don't hit any objects: ```jsx console.log('Dragging over empty space')} onDropMissed={(e) => console.log('Dropped on empty space', e.dataTransfer?.files)} > ``` #### Preventing browser defaults When using drag and drop, you must prevent the browser's default behavior to stop it from opening dropped files: ```jsx const preventDefaults = { onDrop: (e) => e.preventDefault(), onDragEnter: (e) => e.preventDefault(), onDragOver: (e) => e.preventDefault(), } ``` #### Accessing dropped files Dropped files are available via `e.nativeEvent.dataTransfer.files` on mesh events, or `e.dataTransfer.files` on Canvas missed events: ```jsx { const files = e.nativeEvent.dataTransfer?.files if (files?.length) { const file = files[0] console.log('Dropped:', file.name, file.type) } }} /> ``` #### Example: Loading a dropped texture Here's a complete example of loading a dropped image as a texture: ```jsx function useFileTexture(file) { const [texture, setTexture] = useState(null) useEffect(() => { if (!file) { texture?.dispose() setTexture(null) return } const url = URL.createObjectURL(file) new THREE.TextureLoader().load(url, (tex) => { tex.colorSpace = THREE.SRGBColorSpace setTexture((old) => { old?.dispose() return tex }) }) return () => URL.revokeObjectURL(url) }, [file]) return texture } function TexturedBox() { const [file, setFile] = useState(null) const texture = useFileTexture(file) return ( { const f = e.nativeEvent.dataTransfer?.files?.[0] if (f?.type.startsWith('image/')) setFile(f) }} onDragOverEnter={() => console.log('Drag over box')} > ) } ``` > \[!NOTE] When loading textures from dropped files, remember to: > > - Set `colorSpace = THREE.SRGBColorSpace` for proper color display > - Call `texture.dispose()` when replacing textures to free GPU memory > - Use `URL.revokeObjectURL()` to clean up blob URLs ### How the event-system works, bubbling and capture > \[!NOTE] > > - `pointerenter` and `pointerleave` events work exactly the same as pointerover and pointerout. > - `pointerenter` and `pointerleave` semantics are not implemented. > \[!NOTE] Some events (such as `pointerout`) happen when there is no intersection between `eventObject` and the ray. When this happens, the event will contain intersection data from a previous event with this object. ### Event propagation (bubbling) Propagation works a bit differently to the DOM because objects can occlude each other in 3D. The `intersections` array in the event includes all objects intersecting the ray, not just the nearest. Only the first intersection with each object is included. The event is first delivered to the object nearest the camera, and then bubbles up through its ancestors like in the DOM. After that, it is delivered to the next nearest object, and then its ancestors, and so on. This means objects are transparent to pointer events by default, even if the object handles the event. `event.stopPropagation()` doesn't just stop this event from bubbling up, it also stops it from being delivered to farther objects (objects behind this one). All other objects, nearer or farther, no longer count as being hit while the pointer is over this object. If they were previously delivered pointerover events, they will immediately be delivered pointerout events. If you want an object to block pointer events from objects behind it, it needs to have an event handler as follows: ```jsx onPointerOver={e => { e.stopPropagation() // ... }} ``` even if you don't want this object to respond to the pointer event. If you do want to handle the event as well as using `stopPropagation()`, remember that the pointerout events will happen **during** the `stopPropagation()` call. You probably want your other event handling to happen after this. ### Pointer capture Because events go to all intersected objects, capturing the pointer also works differently. In the DOM, the capturing object **replaces** the hit test, but in React Three Fiber, the capturing object is **added** to the hit test result: if the capturing object was not hit, then all of the hit objects (and their ancestors) get the event first, followed by the capturing object and its ancestors. The capturing object can also use `event.stopPropagation()` so that objects that really were hit get pointerout events. Note that you can access the `setPointerCapture` and `releasePointerCapture` methods **only** via `event.target`: they don't get added to the `Object3D` instances in the scene graph. `setPointerCapture` and `releasePointerCapture` take a `pointerId` parameter like in the DOM. In v10 the event system tracks each pointer independently (an internal per-`pointerId` map), so multi-touch and multiple simultaneous captures are supported — each pointer's hover/capture state is isolated from the others. ```jsx onPointerDown={e => { // Only the mesh closest to the camera will be processed e.stopPropagation() // You may optionally capture the target e.target.setPointerCapture(e.pointerId) }} onPointerUp={e => { e.stopPropagation() // Optionally release capture e.target.releasePointerCapture(e.pointerId) }} ``` ### Customizing the event settings For some advanced usage it's possible to customize the setting of the event manager globally with the `events` prop on ``: ```tsx import { Canvas, events } from '@react-three/fiber' const eventManagerFactory: Parameters[0]['events'] = (state) => ({ // Default configuration ...events(state), // Determines if the event layer is active enabled: true, // Event layer priority, higher prioritized layers come first and may stop(-propagate) lower layer priority: 1, // The filter can re-order or re-structure the intersections filter: (items: THREE.Intersection[], state: RootState) => items, // The compute defines how pointer events are translated into the raycaster and pointer vector2 compute: (event: DomEvent, state: RootState, previous?: RootState) => { state.pointer.set((event.offsetX / state.size.width) * 2 - 1, -(event.offsetY / state.size.height) * 2 + 1) state.raycaster.setFromCamera(state.pointer, state.camera) }, // Find more configuration default on ./packages/fiber/src/web/events.ts // And type definitions in ./packages/fiber/src/core/events.ts }) function App() { return ( } ``` ### Using a different target element There are cases in which you may want to connect the event handlers to another DOM element instead of the canvas. This is usually done to have events on a shared parent, which allows both the canvas, and dom overlays to receive events. You can either use the event manager: ```jsx const events => useThree(state => state.events) useEffect(() => { state.events.connect(domNode) ``` Or, the `eventSource` shortcut on the canvas (DOM only), which accepts dom-nodes and React.RefObjects to dom-nodes. ```jsx function App() { const target = useRef() return (
``` ### Using a different prefix (DOM only) By default Fiber will use offsetX/offsetY to set up the raycaster. You can change this with the `eventPrefix` shortcut. ```jsx function App() { return ( ``` ### Allow raycast without user interaction By default Fiber will only raycast when the user is interacting with the canvas. If, for instance, the camera moves a hoverable object underneath the cursor it will not trigger a hover event. If this is wanted behaviour you can force a raycast by executing `update()`, call it whenever necessary. ```jsx const events => useThree(state => state.events) useEffect(() => { // Will trigger a onPointerMove with the last-known pointer event state.events.update() ``` You can abstract this into more complex logic. ```jsx function RaycastWhenCameraMoves() { const matrix = new THREE.Matrix4() useFrame((state) => { // Act only when the camera has moved if (!matrix.equals(state.camera.matrixWorld)) { state.events.update() matrix.copy(state.camera.matrixWorld) } }) } ``` ### Prioritizing objects with `interactivePriority` By default, intersections are sorted by distance from the camera (nearest first). Set `userData.interactivePriority` on an object to make it win regardless of distance — useful for gizmos, handles, or UI that must stay interactive even when occluded. ```jsx // This handle is hit-tested before anything in front of it ``` Objects with an `interactivePriority` are sorted ahead of objects without one; among several that have it, the higher value wins. Objects without it keep their normal distance-based order behind them. ### Frame-timed raycasting For pointer-heavy scenes you can defer move-raycasts to the frame loop instead of running one per DOM pointer event. This is configured on the `events` prop and only applies while `frameloop="always"`: ```tsx const eventManagerFactory = (state) => ({ ...events(state), // Coalesce pointer-move raycasts to once per frame frameTimedRaycasts: true, // Whether wheel/scroll events still raycast immediately (default: fire on scroll) alwaysFireOnScroll: true, }) ``` When enabled, R3F batches pending move raycasts and flushes them once per frame; click events flush any pending move first so hover state stays accurate. ### XR pointers > \[!NOTE] **Preview.** WebXR controller/hand pointers register into the same event system via an internal pointer registry. The API surface is still settling — treat XR pointer support as a preview in v10. ]]> (null) return ( <> {/* target resolves after targetRef is populated */} ) } ``` When the prop needs the sibling _wrapped_ rather than assigned as-is, pass a transform as the second argument. It runs once the ref is populated and its result is what gets assigned: ```tsx import { lights } from 'three/tsl' // A node material's lightsNode needs a LightsNode, not the light itself lights([light]))} /> ``` Only top-level props on a three element are resolved. A marker nested inside another value (an array, an options object) or passed to a React component is not seen. ### `once` — mount-only method calls `once` marks a method prop to be applied only on initial mount, which is useful for geometry transforms that shouldn't compound every render: ```tsx import { once } from '@react-three/fiber' // Rotate geometry 90° on mount (without `once` this compounds every render) // Center geometry on mount // Apply a matrix on mount // Multi-argument methods take the arguments directly ``` `once` is typed as the value the prop declares: a single argument is the argument itself, several arguments are the tuple, and a bare `once()` is `true`. Pass the arguments individually, not as an array: `once([0, 0, 5])` calls `translate([0, 0, 5])`. When `args` change (triggering element reconstruction), `once` methods run again on the new instance. ## Reaching three from the shared core Every entry shares one core that never imports `three` itself; the first `` (or `createRoot`) to configure its renderer registers three's namespace. `getThree()` returns the classes and constants `three` and `three/webgpu` have in common. It is available before any element renders, any hook runs or `onCreated` fires, and throws if no root has loaded a renderer yet. Use `hasThree()` to check, or `await whenThree()` to wait. ```tsx import { getThree, whenThree } from '@react-three/fiber' // Inside a component or frame callback, three is already loaded const v = new (getThree().Vector3)(0, 1, 0) // Module-level code that may run before any Canvas const { Scene } = await whenThree() ``` Most apps should keep importing classes from `three` directly; these are for libraries that want to avoid a static `three` import. Renderer-specific classes (`WebGLRenderer`, node materials, ...) are not on it. For the same reason, `isLayers` and `isUniform` test for a `THREE.Layers` or `THREE.Uniform` by shape rather than `instanceof`. ## Extending the root These are for packages that build on R3F, such as `@react-three/tsl`. App code rarely needs them. ### `registerRootExtension` Registers per-root setup, cleanup and hot-reload handling without R3F knowing about your package. ```tsx import { registerRootExtension } from '@react-three/fiber/extension' registerRootExtension({ name: 'my-library', // Once per root, after its renderer exists and before onCreated and the first frame. // Return fields to merge into the root's state, or nothing to skip this root. setup(store) { if (store.getState().isLegacy) return return { /* per-root state */ } }, // When a root this extension set up unmounts. dispose(store) {}, // When detects hot module replacement (skipped with hmr={false}). hmr(store) {}, }) ``` - Roots that already exist when you register are set up immediately; later roots as they are created. - Registering the same `name` again replaces the entry, which keeps hot reloads safe: roots it already set up are not set up twice, and the new entry's `dispose` and `hmr` take over. - The function returned by `registerRootExtension` unregisters it. Roots it already set up keep their state and still get `dispose` when they unmount. ### `setRenderOverride` Points a root's default render job at your own render function, for example a post-processing pipeline. Pass `null` to go back to `renderer.render(scene, camera)`. ```tsx import { setRenderOverride, useStore } from '@react-three/fiber/extension' const store = useStore() setRenderOverride(store, () => composer.render()) ``` Unlike taking over the `render` phase with `useFrame`, the default job keeps its behaviour: the Canvas `fps` throttle, error reporting to the error boundary, and backing off when a user registers a `{ phase: 'render' }` job. `useRenderPipeline` renders through this. ### The `@react-three/fiber/extension` entry Every entry shares one core, and nothing in that core imports three: a library can import hooks from `@react-three/fiber` and add no renderer and no second copy of fiber to an app on any entry. `@react-three/fiber/extension` is the smallest stable surface for a package that builds on fiber: `useStore`, `useThree`, `useFrame`, `context`, `registerRootExtension`, `setRenderOverride`, `getTextureView` and the shared types, and nothing that could ever grow a renderer. `getTextureView(store)` is the texture registry as a hook should read it during render: registered textures plus those a `useTexture` earlier in the same render loaded but has not registered yet (registration waits for a layout effect). It works under a `` from any entry. Apps should keep importing from `@react-three/fiber`, `/webgpu` or `/legacy`. ]]> (null!) useEffect(() => { console.log(Boolean(meshRef.current)) }, []) return ( ) } ``` The exclamation mark is a non-null assertion that will let TS know that `ref.current` is defined when we access it in effects, useFrame et al. You do not need to check against null, the element is assumed to exist. ## Accessing typed three-elements Whenever you want to spread props or type components that rely on three elements, you can use the `ThreeElements` interface to extract the mesh, group, or any other three element, including custom elements. ```tsx import { ThreeElements } from '@react-three/fiber' type FooProps = ThreeElements['mesh'] & { bar: boolean } function Foo({ bar, ...props}: FooProps) { useEffect(() => { console.log(bar) }, [bar]) return } ``` ## Extend usage react-three-fiber can also accept third-party elements and extend them into its internal catalogue. ```tsx import { useRef, useEffect } from 'react' import { GridHelper } from 'three' import { extend } from '@react-three/fiber' // Create our custom element class CustomElement extends GridHelper {} // Extend so the reconciler will learn about it extend({ CustomElement }) ``` The catalogue teaches the underlying reconciler how to create fibers for these elements and treat them within the scene. You can then declaratively create custom elements with primitives, but TypeScript won't know about them nor their props. ```html // error: 'customElement' does not exist on type 'JSX.IntrinsicElements' ``` ### Extending ThreeElements To define our element in JSX, we'll use the `ThreeElement` interface to extend `ThreeElements`. This interface describes three.js classes that are available in the R3F catalog and can be used as native elements. ```tsx import { useRef, useEffect } from 'react' import { GridHelper } from 'three' import { extend, ThreeElement } from '@react-three/fiber' // Create our custom element class CustomElement extends GridHelper {} // Extend so the reconciler will learn about it extend({ CustomElement }) // Add types to ThreeElements elements so primitives pick up on it declare module '@react-three/fiber' { interface ThreeElements { customElement: ThreeElement } } // react-three-fiber will create your custom component and TypeScript will understand it ``` You can shorten element definition by using the `extend` factory signature, which will automatically extend the element locally. This will also prevent namespace bleeding. ```tsx // Create our custom element class CustomElement extends GridHelper {} // Extend so the reconciler will learn about it, types will be inferred const Element = extend(CustomElement) // react-three-fiber will create your custom component and TypeScript will understand it ``` ## Three's own elements Every three class is a JSX element without any `extend()` call, on `` and on a root you open with `createRoot` alike. A root resolves element names against the namespace of the renderer it loaded, so a WebGPU root has `` and a WebGL root does not. The element map follows the entry you import from: `@react-three/fiber` types both namespaces, `/legacy` only `three`, `/webgpu` only `three/webgpu`. `extend` is for classes three does not ship, and a registration always wins over the namespace, so `extend({ Mesh: MyMesh })` replaces `` on every root. ## Typing the renderer Each entry's hooks are typed for the renderer it renders with, so there is nothing to declare: | Import from | `useThree` / `useFrame` / `onCreated` state | `useRenderTarget()` | JSX elements | | ------------------------------ | ------------------------------------------- | ------------------- | -------------- | | `@react-three/fiber` | `WebGPURootState` (`WebGPURenderer`) | `RenderTarget` | `three/webgpu` | | `@react-three/fiber/legacy` | `LegacyRootState` (`WebGLRenderer`) | `WebGLRenderTarget` | `three` | | `@react-three/fiber/extension` | `RootState` (either renderer) | — | — | ```tsx import { useFrame, useRenderTarget, useThree } from '@react-three/fiber' const renderer = useThree((s) => s.renderer) // WebGPURenderer useFrame(({ renderer }) => renderer.compute(kernel)) const fbo = useRenderTarget() // RenderTarget ``` `RootState` is the base type every entry shares, with `renderer` typed as either renderer. The narrowed states extend it, so a function annotated with it (`useFrame((state: RootState) => ...)`) still compiles, and fields a package adds to it (`declare module '@react-three/fiber' { interface RootState { ... } }`) appear on the narrowed states too. The deprecated `state.gl` keeps the base type; use `state.renderer`. ### Libraries A library that works under either renderer imports its hooks from `@react-three/fiber/extension`, whose types are the base `RootState`. Narrow where it needs one renderer's members: ```tsx import { useThree } from '@react-three/fiber/extension' const renderer = useThree((s) => s.renderer) if ('isWebGPURenderer' in renderer) renderer.compute(kernel) ``` An app can narrow those base types too, for code that goes through them, by registering its renderer once: ```tsx declare module '@react-three/fiber' { interface Register { renderer: 'webgpu' // or 'webgl' } } ``` Registering only changes types. What a Canvas renders with is decided by the import: WebGPU on `@react-three/fiber`, WebGL on `/legacy`. (`/webgpu` is the deprecated name for the WebGPU entry and is removed in the first beta.) ## Exported types react-three-fiber is extensible and exports types for its internals, such as render props, canvas props, and events: ```tsx // Event raycaster intersection Intersection // `useFrame` internal subscription and render callback Subscription RenderCallback // The base root state every entry shares; `useThree` on @react-three/fiber returns WebGPURootState RootState WebGPURootState // Register a renderer to narrow the base RootState (see above) Register Performance Dpr Size Viewport Camera // Canvas props CanvasProps // Supported events Events // Event manager signature (is completely modular) EventManager // Wraps a platform event as it's passed through the event manager ThreeEvent ``` ]]> { const renderer = await ReactThreeTestRenderer.create() }) test('click event makes box bigger', async () => { const renderer = await ReactThreeTestRenderer.create() }) ``` In here we created three tests and in each we made sure we created the renderer by using the `create` function. Let's start with the first test and make sure our mesh has two children, the material and cube. We can start by getting the scene and it's children from the test instance we just created like so: ```js const meshChildren = renderer.scene.children ``` If you log this mesh out you can see that it returns an array of one element since that's all we have in the scene. Using this we can make sure to get that first child and use the `allChildren` property on it like so: ```js const meshChildren = renderer.scene.children[0].allChildren ``` There is also one property called `children` but this one is meant to be used for things like groups as this one does not return the geometry and the materials, for that we need `allChildren`. Now to create our assertion: ```js expect(meshChildren.length).toBe(2) ``` Our first test case looks like this: ```js test('mesh to have two children', async () => { const renderer = await ReactThreeTestRenderer.create() const mesh = renderer.scene.children[0].allChildren expect(mesh.length).toBe(2) }) ``` ## Testing interactions Now that we have gotten the first test out of the way we can test our interaction and make sure that when we click on the mesh it does indeed update the scale. We can do that by utilizing the `fireEvent` method existing in a test instance. We know we can get the mesh with: ```js const mesh = renderer.scene.children[0] ``` Since we already have that we can fire an event in it like so: ```js await renderer.fireEvent(mesh, 'click') ``` With that done, all that's left to do is the tree demonstration of our scene and make sure the scale prop on our mesh has updated: ```js expect(mesh.props.scale).toBe(1.5) ``` In the end our test looks something like this: ```js test('click event makes box bigger', async () => { const renderer = await ReactThreeTestRenderer.create() const mesh = renderer.scene.children[0] expect(mesh.props.scale).toBe(1) await renderer.fireEvent(mesh, 'click') expect(mesh.props.scale).toBe(1.5) }) ``` ## Testing WebGPU and TSL `@react-three/test-renderer` has an entry for each fiber entry. Test with the one that matches the import your app's `Canvas` comes from: | Your app's `Canvas` comes from | Test with | | ----------------------------------- | ----------------------------------- | | `@react-three/fiber` (WebGPU) | `@react-three/test-renderer` | | `@react-three/fiber/legacy` (WebGL) | `@react-three/test-renderer/legacy` | The default entry mocks the GPU, and every root it creates renders with `WebGPURenderer`, so node materials such as `` work as JSX without `extend()`. `@react-three/test-renderer/webgpu` does the same and is deprecated; it is removed in the first beta. The TSL hooks come from `@react-three/tsl` in tests, as in your app: ```tsx import ReactThreeTestRenderer from '@react-three/test-renderer' import { useUniforms } from '@react-three/tsl' it('creates a uniform', async () => { let uniforms function Uniforms() { uniforms = useUniforms({ uTime: 0 }) return null } const renderer = await ReactThreeTestRenderer.create() expect(uniforms.uTime.value).toBe(0) await renderer.unmount() }) ``` Unmount every renderer at the end of its test. Uniforms, nodes and other TSL resources belong to the root, and unmounting it releases them. The mock does not draw, so these tests check state and the scene graph, not pixels. If you want to learn more about React Three Test Renderer you can checkout the repo and their docs: - [Repo](https://github.com/pmndrs/react-three-fiber/tree/v10/packages/test-renderer) - [React Three Test Renderer API](https://github.com/pmndrs/react-three-fiber/blob/v10/packages/test-renderer/markdown/rttr.md#create) - [React Three Test Instance API](https://github.com/pmndrs/react-three-fiber/blob/v10/packages/test-renderer/markdown/rttr-instance.md) ## Exercises - Check the color of the Box we created - Check the rotation using the `advanceFrames` method. [CodeSandbox hqut4](https://codesandbox.io/s/hqut4) ]]> { ... })` works exactly as before. Underneath, every callback is now a **job** scheduled by a single global scheduler that gives you named phases, explicit ordering, per-job fps throttling, pause/resume controls, composable render takeover, and demand/manual modes. ```tsx // Still works exactly the same useFrame((state, delta) => { meshRef.current.rotation.y += delta }) ``` ## The shared RAF loop A single global singleton `Scheduler` drives one `requestAnimationFrame` loop for the **entire application**. Every `` (root) registers with this shared loop, so multiple canvases stay frame-synced instead of each spinning their own RAF. Each frame the scheduler walks its registered roots and executes their jobs in order. The scheduler instance is a cross-bundle singleton (shared via `Symbol.for`), so mixing imports from `@react-three/fiber` and `@react-three/fiber/webgpu` still resolves to the same scheduler. The same key means it also survives hot module reloads — the loop keeps running while you edit rather than stopping dead on every save. Each frame produces a state object passed to your callback: ```ts interface FrameState extends RootState { time: number // high-resolution RAF timestamp (milliseconds) delta: number // time since last frame (seconds — THREE.Clock compatible) elapsed: number // time since the first frame (seconds) frame: number // incrementing frame counter overstep: number // fraction of the next fixed physics step already banked, in [0, 1) // ...plus all RootState (gl/renderer, scene, camera, etc.) } ``` The `delta` passed as the second callback argument is the same value as `state.delta`. Inside a [fixed-timestep phase](#fixed-timestep-and-interpolation) such as `physics`, `delta` is the phase's timestep and `elapsed` is its simulated time. ## Ordering Canvas roots Order complete Canvas roots with the `scheduler` Canvas prop. Root ordering keeps every job owned by a Canvas together instead of ordering only its final render. It works on every entry, `@react-three/fiber/legacy` included: ```tsx ``` The same options go to `createRoot(canvas).configure({ scheduler })`. A canvas sharing a ``'s renderer (see [Multi-Canvas](./webgpu/multi-canvas)) renders after its primary unless you set `before` or `after` yourself. `before` and `after` accept a Canvas `id` or an array of ids. References can mount in either order; an unresolved reference remains dormant and becomes active when that root registers. `order` provides numeric root ordering, with lower values running first. `fps` is intentionally different: it throttles only that Canvas's default render job. Other jobs on the root, including `useFrame` updates, can continue each frame. ## Phases Jobs run inside named **phases** that execute in a fixed sequence each frame. The six default phases are: | Phase | Purpose | | --------- | ------------------------------------------------------------------------------------------ | | `start` | Early frame setup / initialization | | `input` | Input processing, event handling | | `physics` | Physics simulation on a fixed 1/60 s step (see [below](#fixed-timestep-and-interpolation)) | | `update` | Game logic and animations (**default phase**) | | `render` | Rendering and custom render passes | | `finish` | Cleanup, stats, telemetry | A `useFrame` callback with no phase runs in `update`: ```tsx useFrame(() => { /* runs in 'update' */ }) // Explicit, identical useFrame(() => {}, { phase: 'update' }) ``` Organize a game loop by assigning phases: ```tsx useFrame(processInput, { phase: 'input' }) useFrame((_, delta) => world.step(delta), { phase: 'physics' }) // delta is the fixed step, 1 / 60 useFrame(updateGameState, { phase: 'update' }) useFrame(renderEffects, { phase: 'render' }) useFrame(recordStats, { phase: 'finish' }) ``` ### Custom phases Add your own phases relative to existing ones with `scheduler.addPhase(name, { before, after })`: ```tsx const { scheduler } = useThree() useEffect(() => { // Insert an 'ai' phase between physics and update scheduler.addPhase('ai', { after: 'physics', before: 'update' }) }, [scheduler]) useFrame(aiSystemUpdate, { phase: 'ai' }) ``` Adding a phase with no position appends it to the end. Duplicate phase names are ignored with a warning. Inspect the current order with the `scheduler.phases` getter and check existence with `scheduler.hasPhase(name)`. Pass `timestep` (and optionally `maxSubsteps`) to make the new phase a [fixed-timestep phase](#fixed-timestep-and-interpolation) like `physics`. ## Ordering jobs Within a phase, jobs are sorted by `priority` (higher `priority` runs earlier, default `0`); jobs with equal priority run in registration order. `before` / `after` constraints between jobs take precedence over `priority`. Don't rely on `priority` for order that matters. It exists mostly for legacy code and is only a loose hint: any other job can pick the same or a higher number. If a job actually needs to run before or after something, name that dependency with `before` / `after`. For ordering across jobs, every job has a unique `id` (generated by the scheduler if you don't supply one). Reference an `id` — or a phase name — via `before` / `after` to create explicit dependencies: ```tsx // Camera updates first useFrame(updateCamera, { id: 'camera' }) // Character runs after the camera job useFrame(updateCharacter, { after: 'camera', id: 'character' }) // Enemies run after the character job useFrame(updateEnemies, { after: 'character' }) ``` `before` / `after` accept either a single name or an array, and a target can be a job `id` or a phase name: ```tsx useFrame(updateCharacter, { after: ['physics', 'input'] }) useFrame(prepRenderTargets, { before: 'render' }) ``` When you use `before`/`after` without an explicit `phase`, the scheduler auto-resolves placement (creating implicit ordering around the target), keeping any default job in that phase intact — your work happens around it, not instead of it. > **Migrating from priority numbers.** v9's numeric priority is still accepted for backwards compatibility, but it is deprecated for controlling order or render takeover. Prefer phases or `before`/`after`. Note that a numeric `priority > 0` retains its v9 meaning of "I am taking over rendering" and emits a deprecation notice — see [render takeover](#render-phase-takeover). The sort direction is also reversed: v9 ran lower numbers first, while v10 maps the number to `{ priority }` and runs higher numbers first, so ordering-only values such as `-1, 0` run in the opposite order. A negative number emits a deprecation notice for this reason; migrate it to a phase or to `before`/`after`. > > **Why the change.** A single number can't compose across packages. Two libraries that both pick `0` have no defined order between them, and there is no way for you to reorder a job you don't own — the number is baked into someone else's source. Naming the dependency instead (`after: 'physics'`) states the constraint you actually care about, so the scheduler can resolve a correct order no matter what else is registered. Numeric priority survives mostly as a v9 shim, plus a loose tie-breaker for the rare case where order is a preference rather than a requirement. ## FPS throttling Not all work needs to run every frame. Set `fps` to cap how often a job runs: ```tsx useFrame(expensiveAI, { fps: 20 }) useFrame(particlePhysics, { fps: 30 }) useFrame(smoothAnimation) // no limit — runs every frame ``` A throttled job runs **at most once per RAF**. The scheduler computes the minimum interval (`1000 / fps`) and skips the job on frames where not enough time has elapsed (with \~1ms tolerance for RAF jitter). It never invokes the callback multiple times in a single frame to "catch up." The `drop` option controls how the job's internal timestamp is advanced when it has fallen behind — it does **not** change how many times the callback fires: - **`drop: true`** (default) — snap the job's last-run time to now, discarding missed intervals. Good for visual / UI updates where only the latest state matters. - **`drop: false`** — advance the last-run time by whole interval steps (catch-up semantics), keeping timing aligned for physics and simulations. The callback still fires at most once per frame; only the timestamp accounting differs (and is clamped to avoid runaway drift). ```tsx // Visual updates: discard missed frames useFrame(updateUI, { fps: 30, drop: true }) // Simulation: keep interval timing consistent (still one call per frame) useFrame(physicsStep, { fps: 60, drop: false }) ``` `fps` throttles by wall clock, so it has no meaning in a [fixed-timestep phase](#fixed-timestep-and-interpolation): a job in `physics` with `fps` set logs a warning and runs every step. ## Fixed timestep and interpolation The `physics` phase runs on a fixed timestep of 1/60 s. Each frame the root banks its frame delta, and the phase's jobs run once per whole step banked: zero to `maxSubsteps` times per frame (default 8, surplus time beyond that is dropped). Every call receives the step as `delta` (`1 / 60`), and `state.elapsed` is the phase's simulated time. So a simulation steps at the same rate on a 30 Hz, 60 Hz or 144 Hz display, but a physics job no longer runs exactly once per frame. ```tsx useFrame((_, delta) => world.step(delta), { phase: 'physics', id: 'world-step' }) ``` Because steps and frames don't line up, drawing the latest physics state directly can stutter. `state.overstep` is the fraction of the next step already banked, in `[0, 1)`; use it in a per-frame job to blend between the previous and current simulated states: ```tsx function Ball({ body }) { const ref = useRef(null!) const [previous, current] = useMemo(() => [new THREE.Vector3(), new THREE.Vector3()], []) // Once per physics step, after the world has stepped useFrame( () => { previous.copy(current) current.copy(body.position) }, { phase: 'physics', after: 'world-step' }, ) // Once per frame: draw between the last two physics states useFrame(({ overstep }) => ref.current.position.lerpVectors(previous, current, overstep)) return {/* ... */} } ``` Change the step, or turn it off, with `scheduler.setPhaseTimestep`. Phases are shared by every root, and changing a timestep resets that phase's banked time. `scheduler.getPhaseTimestep(name)` reads the current value, and `scheduler.getOverstep(phase?, rootId?)` reads an overstep from outside a callback or for a second fixed phase. ```tsx const { scheduler } = useThree() scheduler.setPhaseTimestep('physics', 1 / 120, { maxSubsteps: 4 }) // 120 Hz, at most 4 steps per frame scheduler.setPhaseTimestep('physics', undefined) // opt out: once per frame with the frame delta scheduler.addPhase('navigation', { after: 'physics', timestep: 1 / 10 }) // a custom fixed phase at 10 Hz ``` ## Pause, resume & enabled `useFrame` returns a controls object for managing the job: ```ts interface FrameControls { id: string // the job's unique id scheduler: Scheduler // the global scheduler readonly rootId: string | undefined // the root currently owning this job step(timestamp?: number): void // step this job only (bypasses fps limiting) stepAll(timestamp?: number): void // step all jobs once invalidate(frames?: number, stackFrames?: boolean): void // invalidate this job's root pause(): void // pause this job (enabled = false) resume(): void // resume this job (enabled = true) isPaused: boolean // reactive — re-renders the component on change } ``` `isPaused` is reactive (backed by `useSyncExternalStore`), so it updates the component when the job is paused or resumed from anywhere: ```tsx function PausableEffect() { const controls = useFrame((state, delta) => { // animation work }, { id: 'particle-system' }) return ( ) } ``` Control any job by id through the scheduler — handy for debug panels and pause menus: ```tsx const { scheduler } = useThree() scheduler.pauseJob('particle-system') scheduler.resumeJob('particle-system') scheduler.isJobPaused('particle-system') ``` Resuming resets the job's timing so it doesn't try to account for the time it was paused. For conditional execution, prefer the `enabled` option over an early `return` inside the callback. A disabled job stays registered but is skipped entirely by the scheduler — no wasted frame budget: ```tsx const isActive = useGameState() useFrame(doWork, { enabled: isActive }) ``` ## Render-phase takeover By default R3F registers an internal **system** render job in the `render` phase that draws your scene each frame. When you register **any** `useFrame` job in the `render` phase, the default render is automatically skipped for that root — you have taken over rendering. ```tsx // You now control rendering for this root useFrame( () => { effectComposer.render() }, { phase: 'render' }, ) // The built-in render is skipped while this job is registered ``` The check is dynamic each frame (`scheduler.hasUserJobsInPhase('render')`), so when your render job unmounts the default render **resumes automatically** — nothing to clean up. To add render work _without_ taking over, attach to the render phase with `before` / `after` instead. The default render still runs, and multiple components can compose around it: ```tsx // Prep before the default render useFrame(updateUniforms, { before: 'render' }) // Overlay a HUD after the default render useFrame( ({ gl }) => { gl.autoClear = false gl.render(hudScene, hudCamera) gl.autoClear = true }, { after: 'render' }, ) ``` > The legacy v9 mechanism — a numeric `priority > 0` on `useFrame` — still disables the default render for backwards compatibility, but is deprecated. Migrate to `{ phase: 'render' }`. ## Frameloop modes The Canvas `frameloop` prop controls that Canvas's scheduler root. Every Canvas keeps its own mode and pending-frame count while sharing one RAF driver with the rest of the application. ### `frameloop="always"` (default) Continuous rendering — jobs run every RAF. ```tsx ``` ### `frameloop="demand"` Render only when explicitly requested via `invalidate()`. Ideal for static scenes that change occasionally. ```tsx import { useThree } from '@react-three/fiber' function StaticScene() { const { invalidate } = useThree() return ( { updateSomething() invalidate() // request one frame }} /> ) } ``` ### `frameloop="never"` The loop never runs on its own. Advance frames manually with the controls returned by `useFrame`, the standalone `advance()` util, or `scheduler.step()` — useful for tests, debugging, and non-realtime rendering. ```tsx function ManualScene() { const { stepAll } = useFrame((state, delta) => { // only runs when stepped }) return } ``` ### Changing modes at runtime ```tsx const { setFrameloop } = useThree() setFrameloop('demand') // store action — sets Canvas frameloop ``` `setFrameloop` updates only the owning Canvas. The scheduler starts the shared driver while any root has automatic work and stops it when every root is idle. `scheduler.frameloop` remains a legacy bulk/default control; use `scheduler.setRootFrameloop(rootId, mode)` when working with scheduler roots directly. ## Demand mode: invalidate, onIdle & addTail Demand and manual modes use a small set of utilities. **`onIdle` and `addTail` callbacks fire when the shared driver has no automatic work left.** An `always` root keeps the driver active even when every demand root is idle. ### `invalidate(frames?, stackFrames?)` Requests rendering in demand mode. Available as both a standalone export and from `useThree()`. ```tsx import { invalidate } from '@react-three/fiber' invalidate() // request a single frame invalidate(undefined, 5) // request 5 frames (standalone signature) ``` The standalone export's signature is `invalidate(state?, frames = 1, stackFrames = false)`. Passing a root state targets that root through `scheduler.invalidateRoot`; omitting it fans out through `scheduler.invalidate`. The `useThree()` action supplies its own state automatically. Notes: - Only roots in `'demand'` mode accumulate invalidations. - Pending frames are tracked per root and **capped at 60**. - Each rendered frame decrements that root's pending count by 1. - The shared loop stops when no root has automatic work left. - `stackFrames: true` adds to the existing pending count instead of replacing it. ### `advance(timestamp)` Standalone util that manually runs one frame for all roots through `scheduler.step(timestamp)`. The full signature is `advance(timestamp, runGlobalEffects?, state?)`; passing a root state targets only that root through `scheduler.stepRoot`. Use it in `frameloop="never"` mode and in tests. ```tsx import { advance } from '@react-three/fiber' advance(performance.now()) ``` ### `onIdle(callback)` Register a callback that fires when the demand-mode loop goes idle (pending frames hit 0). Returns an unsubscribe function. ```tsx function IdleHandler() { const { scheduler } = useFrame() useEffect(() => scheduler.onIdle((timestamp) => saveState()), [scheduler]) return null } ``` ### Deprecated globals: addEffect / addAfterEffect / addTail The v9 global hooks still work but are deprecated. They run once per frame (not per-root) and only receive the raw RAF timestamp — no `gl`/`scene`/`camera` and no seconds-based `delta`. Prefer phased `useFrame`: | Legacy | Modern replacement | | -------------------- | ----------------------------------------- | | `addEffect(cb)` | `useFrame(cb, { phase: 'start' })` | | `addAfterEffect(cb)` | `useFrame(cb, { phase: 'finish' })` | | `addTail(cb)` | `scheduler.onIdle(cb)` (demand mode only) | | `invalidate()` | unchanged ✅ | | `advance(timestamp)` | unchanged ✅ | ## Accessing the scheduler ```tsx // From useThree const { scheduler } = useThree() // From useFrame (with or without a callback) const { scheduler } = useFrame() // Inside a frame callback useFrame(({ scheduler }) => {}) // Outside React import { getScheduler } from '@react-three/fiber' const scheduler = getScheduler() ``` Calling `useFrame()` with **no callback** is a valid way to get scheduler access without registering a job. ### Using useFrame outside Canvas `useFrame` does not throw when used outside ``, for example in a DOM component next to it. The first `useFrame` (or `getScheduler().register`) lazily creates an internal **ambient root**, so the job registers right away. R3F's `useFrame` always hands its callback the full `RootState`, though, so the callback is held until a `` mounts and **adopts** the job; from then on it runs every frame with that Canvas's state. ```tsx import { useFrame } from '@react-three/fiber' // A DOM component beside the Canvas: runs once the Canvas mounts, with its state function Hud() { useFrame(({ camera }) => updateCompass(camera)) return
} ``` For a loop with no Canvas at all, register with the scheduler directly. Its callbacks receive timing-only state (`{ time, delta, elapsed, frame }`): ```tsx import { getScheduler } from '@react-three/fiber' const unregister = getScheduler().register((state, delta) => updateGame(delta)) ``` > The scheduler itself lives in [`@pmndrs/scheduler`](https://www.npmjs.com/package/@pmndrs/scheduler) and is consumed by R3F; you still reach it through `getScheduler()` / `useThree().scheduler`. ## Scheduler API reference `getScheduler()` (or `useThree().scheduler`) returns the singleton. Key members: **Phases:** `addPhase(name, { before?, after?, timestep?, maxSubsteps? })`, `hasPhase(name)`, `phases` (getter), `setPhaseTimestep(name, timestep | undefined, { maxSubsteps? })`, `getPhaseTimestep(name)`, `getOverstep(phase?, rootId?)`. **Roots:** `registerRoot(id, options?)`, `unregisterRoot(id)`, `generateRootId()`, `getRootCount()`, `getRootIds()`, `getRootFrameloop(id)`, `setRootFrameloop(id, mode)`, `setRootOrder(id, order)`, `setRootConstraints(id, { before?, after? })`, `isReady` (getter), `onRootReady(cb)`. Canvas manages these automatically. Each root gets its own `onError` (a Canvas reports to its error boundary), so a job that throws on one canvas never reaches another canvas's handler. **Jobs:** `register(callback, options?)`, `unregister(id, rootId?)`, `updateJob(id, partialOptions)`, `getJobCount()`, `getJobIds()`, `hasUserJobsInPhase(phase, rootId?)`. **Job state:** `pauseJob(id)`, `resumeJob(id)`, `isJobPaused(id)`, `subscribeJobState(id, listener)`. **Loop control:** `start()`, `stop()`, `isRunning` (getter), `frameloop` (legacy bulk/default getter/setter), `defaultFrameloop`, `invalidate(frames?, stackFrames?)`, `invalidateRoot(rootId, frames?, stackFrames?)`, `resetTiming()`. **Manual stepping:** `step(timestamp?)` (run one frame for all roots), `stepRoot(rootId, timestamp?)` (run one root), `stepJob(id, timestamp?)` (run one job in isolation). `register` accepts the full job options: ```ts interface JobOptions { id?: string // unique id (auto-generated if omitted) rootId?: string // target root (defaults to the first root) phase?: string // execution phase (default: 'update') before?: string | string[] // run before this phase or job id after?: string | string[] // run after this phase or job id priority?: number // loose tie-breaker within a phase (higher = earlier, default 0); prefer before/after fps?: number // throttle limit (at most one call per RAF) drop?: boolean // timestamp catch-up behavior when throttled (default true) enabled?: boolean // skip without unregistering (default true) } ``` `updateJob` can mutate `priority`, `fps`, `drop`, and `enabled` without a rebuild; changing `phase`, `before`, or `after` triggers a rebuild of the sorted job list. ## Example: a full game loop ```tsx function GameLoop() { const { scheduler } = useThree() useEffect(() => { scheduler.addPhase('ai', { after: 'physics', before: 'update' }) }, [scheduler]) // Input first useFrame(processInput, { phase: 'input', id: 'input-handler' }) // Physics on the fixed 1/60 s step (0..8 steps per frame) useFrame((_, delta) => physicsWorld.step(delta), { phase: 'physics' }) // AI at 20fps, dropping missed intervals useFrame(aiSystemUpdate, { phase: 'ai', fps: 20, drop: true }) // Game state every frame useFrame(updateGameState, { phase: 'update', id: 'game-state' }) // VFX after game state useFrame(updateVFX, { after: 'game-state' }) // HUD overlay after the default render (does not take over rendering) useFrame( ({ gl }) => { gl.autoClear = false gl.render(hudScene, hudCamera) gl.autoClear = true }, { after: 'render' }, ) // Stats last useFrame(collectStats, { phase: 'finish' }) return null } ``` ]]> **v10+ only.** The WebGPU hooks require React 19 and a WebGPU renderer. The TSL hooks ship in their own package, `@react-three/tsl`, next to `@react-three/fiber`: ```bash npm install @react-three/fiber@alpha @react-three/tsl@alpha three ``` ## Enabling WebGPU There is nothing to enable: `` from `@react-three/fiber` renders with `WebGPURenderer`. No manual renderer initialization is required - R3F handles it. Browser WebGPU support is not required either: `WebGPURenderer` falls back to a WebGL2 backend where the browser has none, and keeps speaking TSL. ```tsx import { Canvas } from '@react-three/fiber' // WebGPURenderer with defaults // With renderer parameters // Or pass a pre-created renderer instance (still no init needed) ``` The renderer is exposed on R3F state as `renderer` (the v9 `gl` name is deprecated): ```tsx useFrame(({ renderer, scene, camera }) => { renderer.render(scene, camera) }) ``` ## Entry Points The import decides the renderer. `@react-three/fiber` renders with `WebGPURenderer`, loaded on demand: nothing of three is in your eager bundle, and three's WebGPU renderer and node system are a lazy chunk fetched when the first `` mounts. `WebGLRenderer` is only on `@react-three/fiber/legacy`; there is no prop that switches a Canvas from one to the other. | Import from | Renderer | What you get | | --------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------- | | `@react-three/fiber` | WebGPURenderer | Loaded on demand; node materials as JSX | | `@react-three/fiber/webgpu` | WebGPURenderer | **Deprecated**, removed in the first beta. The same renderer, imported statically, typed against `WebGPURenderer` | | `@react-three/fiber/legacy` | WebGLRenderer | No WebGPU code reachable; typed against `WebGLRenderer`; GLSL `ShaderMaterial`, `onBeforeCompile` and WebGL-only libraries | ```tsx // WebGPU import { Canvas, useFrame } from '@react-three/fiber' import { useUniforms } from '@react-three/tsl' // WebGL - for GLSL shaders and libraries that need WebGLRenderer import { Canvas, useFrame } from '@react-three/fiber/legacy' ``` `@react-three/fiber/webgpu` was the WebGPU entry while the default import rendered with WebGL. It stays for the v10 alphas; import from `@react-three/fiber` instead, whose hooks are typed for WebGPU with nothing to register (see [Typing the renderer](../API/typescript#typing-the-renderer)). `WebGPURenderer` cannot compile GLSL. A `ShaderMaterial` or `RawShaderMaterial` on a WebGPU canvas renders as a blank default material, and R3F warns once in development, pointing here. Port the shader to a node material (TSL), or import from `/legacy`. Passing the WebGL `gl` prop to a `@react-three/fiber` Canvas throws for the same reason. All three share one core, so a library can import hooks from `@react-three/fiber` and work under a Canvas from any of them, `/legacy` included, without adding a renderer or a second copy of fiber to your app. The TSL hooks (`useUniforms`, `useNodes`, `useLocalNodes`, `useRenderPipeline`, `useBuffers`, `useGPUStorage`) come from `@react-three/tsl` and work under any Canvas that runs WebGPU. > **On the naming.** Three's `WebGPURenderer` is a slight misnomer — it is the renderer that speaks TSL, and WebGPU is only one of its backends. It can and does run on WebGL. So "WebGPU vs WebGL" is really two questions: which renderer (`WebGPURenderer` or the older `WebGLRenderer`), and which backend the former picks at runtime. R3F names the split by renderer, not backend: the new one is the default, the old one is `/legacy`. If you want the new renderer but not the WebGPU backend, that's `renderer={{ forceWebGL: true }}` — still TSL, still node materials. The split exists because `three` and `three/webgpu` are different bundles on one shared core, and there is no import that gives you the core alone: anything that imports `three` carries the WebGL renderer. Fiber's core imports neither; each renderer is loaded through a small support module, which is what keeps both renderers out of an app's eager bundle — see [BUILD.md](https://github.com/pmndrs/react-three-fiber/blob/v10/docs/development/BUILD.md). ## Node Materials as JSX A root resolves JSX element names against the three namespace of the renderer it loaded. Under a WebGPU renderer that is `three/webgpu`, so node materials are available declaratively - no `extend()` call needed. (On `/legacy`, `` is not part of the namespace and throws an error naming the fix.) ```tsx import { Canvas } from '@react-three/fiber' import { positionLocal, normalLocal } from 'three/tsl' function App() { return ( {/* MeshStandardNodeMaterial, MeshBasicNodeMaterial, etc. just work */} ) } ``` ## Quick Start ### Uniforms ```tsx import { useUniforms } from '@react-three/tsl' import { positionLocal, normalLocal } from 'three/tsl' function DisplacedSphere() { const { uDisplacement } = useUniforms({ uDisplacement: 0.5 }) useFrame(({ elapsed }) => { uDisplacement.value = Math.sin(elapsed) }) return ( ) } ``` ### Shared Nodes ```tsx import { useNodes, useLocalNodes } from '@react-three/tsl' import { Fn, vec3, sin, time } from 'three/tsl' // Define shared nodes once function GlobalEffects() { useNodes(() => ({ wobbleFn: Fn(() => vec3(sin(time), sin(time.mul(2)), sin(time.mul(3)))), })) return null } // Use in components function WobblyMesh() { const { colorNode } = useLocalNodes(({ nodes }) => ({ colorNode: nodes.wobbleFn.mul(0.5).add(0.5) }), []) return ( ) } ``` ## Build-Time vs Run-Time The most important concept when using these hooks: **TSL code executes at two different times.** ### Build-Time (JavaScript) A React component renders, JavaScript executes, and the shader node graph is built once. ```tsx const { colorNode } = useLocalNodes(({ uniforms }) => { // ❌ WRONG: JavaScript conditional runs ONCE at build, won't react to uniform changes if (uniforms.uMode.value === 0) { return { colorNode: vec3(1, 0, 0) } } return { colorNode: vec3(0, 0, 1) } }, []) ``` ### Run-Time (GPU) Every frame the compiled shader executes on the GPU, reading current uniform values. ```tsx import { If } from 'three/tsl' const { colorNode } = useLocalNodes(({ uniforms }) => { // ✅ CORRECT: TSL If() runs on GPU every frame, reacts to uniform changes const result = vec3(0).toVar() If(uniforms.uMode.equal(0), () => { result.assign(vec3(1, 0, 0)) }).Else(() => { result.assign(vec3(0, 0, 1)) }) return { colorNode: result } }, []) ``` **Quick rule:** - JavaScript `if/else/for` - build-time only, won't react to uniform changes - TSL `If()/Loop()/select()` - run-time GPU code, reacts to uniform changes ## Use TSL Built-ins First Before creating uniforms, check whether TSL already has a built-in node. ### Camera ```tsx import { cameraPosition } from 'three/tsl' // ✅ Use the TSL built-in const { colorNode } = useLocalNodes(() => ({ colorNode: length(cameraPosition.sub(positionWorld)) }), []) // ❌ Don't mirror state.camera into a uniform - redundant ``` Available TSL camera nodes: `cameraPosition`, `cameraViewMatrix`, `cameraProjectionMatrix`, `cameraNormalMatrix`, `cameraWorldMatrix`. ### Time ```tsx import { time } from 'three/tsl' // ✅ Use TSL's time node const { waveNode } = useLocalNodes(() => ({ waveNode: sin(time.mul(2)) }), []) // ❌ Don't create and drive a uTime uniform manually ``` ### When to Use Canvas State Creators receive the canvas state, so you can derive uniforms for values that have no TSL built-in, such as viewport/size: ```tsx const { uAspect, uDpr } = useUniforms((state) => ({ uAspect: state.size.width / state.size.height, uDpr: state.viewport.dpr, })) ``` ## Architecture Uniforms, nodes, buffers and GPU storage belong to the **renderer**, not to one canvas. They live on the primary canvas's `RootState`, which `@react-three/tsl` extends: ```typescript // Added to RootState by @react-three/tsl interface RootState { uniforms: AppUniforms // Global uniforms + scopes (typed by name once registered, loose otherwise) nodes: AppNodes // Global TSL nodes + scopes (typed by name once registered, loose otherwise) buffers: AppBuffers // GPU buffer storage + scopes (typed by name once registered) gpuStorage: AppGPUStorage // GPU texture storage + scopes (typed by name once registered) renderPipeline?: RenderPipeline | null // Per canvas, set by useRenderPipeline passes?: PassRecord // Render pipeline passes } ``` A secondary canvas holds the primary's map objects, and portals inherit them from their parent (a portal's own scene, camera and `state`-prop fields stay its own), so `state.uniforms` reads the same everywhere: in `useFrame`, `useThree`, creators and event handlers, on any canvas or portal. ```tsx useFrame(({ uniforms, elapsed }) => { uniforms.uTime.value = elapsed }) const uniforms = useThree((s) => s.uniforms) ``` To type uniforms, nodes, buffers and GPU storage by name across the app, see [Typed Resources](./typed-resources). ### Create-If-Not-Exists All creator hooks use a "create if not exists, reuse if exists" pattern: ```tsx // First call creates const { uTime } = useUniforms({ uTime: 0 }) // Second call (any component) reuses the same uniform instance const { uTime } = useUniforms({ uTime: 0 }) ``` This means no duplicate GPU allocations, shared state across the component tree, safe to call in multiple components, and compatibility with React Strict Mode. ## Sections - [TSL Hooks](./tsl-hooks) - `useUniform`, `useUniforms`, `useNodes`, `useLocalNodes`, ScopedStore - [Render Pipeline](./render-pipeline) - `useRenderPipeline`, MRT, custom passes - [Compute (Buffers & Storage)](./compute) - `useBuffers`, `useGPUStorage` - [Textures](./textures) - `useTexture`, `useTextures`, the texture registry - [Typed Resources](./typed-resources) - `Register`, `configureTSL` - [Multi-Canvas](./multi-canvas) - sharing a renderer across canvases - [TSL HMR](./hmr) - what rebuilds on hot reload > Building GPU pipelines often pairs with the **texture registry** (`useTexture`/`useTextures`). Those are core hooks that work with any renderer — see [Textures](./textures). ## External Resources - [Three.js TSL Docs](https://threejs.org/docs/#manual/en/introduction/WebGPU-TSL) - [WebGPU Renderer](https://threejs.org/docs/#api/en/renderers/WebGPURenderer) ]]> { const fogNode = fog(uniforms.scope('fog').uColor, uniforms.scope('fog').uDensity) return () => { scene.fogNode = fogNode return () => void (scene.fogNode = null) } }, []) ``` ## useUniform Single-uniform hook with create/get/update semantics. ```tsx // Create uniform (or get existing + update value) const uName = useUniform(name, value) // Get existing uniform (throws if not found) const uName = useUniform(name) ``` ### Parameters | Parameter | Type | Description | | --------- | -------------- | ---------------------------------------------- | | `name` | `string` | Unique identifier for the uniform | | `value` | `UniformValue` | Optional. Initial value, or value to update to | ### Return Value Returns a `UniformNode` - a Three.js TSL uniform node. Reading by name alone (`useUniform('uTime')`) returns it typed by name when your uniforms are [registered](./typed-resources), exactly when you pass its type (`useUniform('uTint')`), and as a [loose](#reading-by-name) `LooseUniformNode` otherwise, which TSL accepts as is. | Property | Type | Description | | -------- | -------- | ---------------------------------------------- | | `value` | `T` | Current uniform value (get/set) | | `uuid` | `string` | Three.js UUID | | `name` | `string` | Debug name (set automatically from `name` arg) | ### Supported Types ```typescript type UniformValue = | number | boolean | string // string colors auto-convert to THREE.Color | Vector2 | Vector3 | Vector4 | Color | Matrix3 | Matrix4 | Node // TSL nodes like color(), vec3(), float() for type casting | UniformNode // existing uniform nodes ``` ### Usage ```tsx import { useUniform } from '@react-three/tsl' import { positionLocal, normalLocal } from 'three/tsl' function DisplacedSphere() { const uDisplacement = useUniform('uDisplacement', 0.5) useFrame((state) => { uDisplacement.value = Math.sin(state.elapsed) * 0.5 }) return ( ) } ``` - **Create mode** (`useUniform('uTime', 0)`): creates if missing; if it exists, returns the existing uniform and updates its value. When a later render passes a different value, the uniform's `.value` is updated in place (the node is not recreated). An equal value in a new object, such as an inline `new Color('red')`, is not a change, so an imperative `.value` write survives the re-render. - **Get mode** (`useUniform('uTime')`): returns the existing uniform; throws if it does not exist. Uniforms are shared across components - one component can create a uniform and another can read it by name. ## useUniforms Batch uniform management with scoping and the create-if-not-exists pattern. ```tsx // Create/update uniforms at root level const uniforms = useUniforms({ uTime: 0, uColor: '#ff0000' }) // Create/update uniforms in a scope const uniforms = useUniforms({ uHealth: 100 }, 'player') // Function syntax for RootState access const uniforms = useUniforms((state) => ({ uAspect: state.size.width / state.size.height, })) // Read all uniforms / read a scope const all = useUniforms() const playerUniforms = useUniforms('player') // A reader cannot infer types from the store. Name what was registered and the // uniforms come back fully typed: uHealth is a UniformNode<'float', number> const { uHealth } = useUniforms<{ uHealth: number; uSpeed: number }>('player') ``` A reader sees what was declared above it in the same render: a child calling `useUniforms()` gets the uniforms its parent creates on its very first render, before the parent's registration has landed on the store, so a render pipeline or material it builds right away closes over the real node. The same holds for the reader forms of `useNodes`, `useBuffers` and `useGPUStorage`, and for `useUniform(name)`. A reader rendered _before_ the component that declares an entry (a sibling earlier in the tree) gets it once that registration commits, and re-renders then; `useUniform(name)` there throws, since the uniform does not exist yet. ### Parameters | Parameter | Type | Description | | --------- | ------------------------------------ | -------------------------------------------- | | `creator` | `UniformCreator \| object \| string` | Uniform definitions, function, or scope name | | `scope` | `string` | Optional scope name for organization | When using the function form, the creator receives a [`CreatorState`](#scopedstore) (RootState with ScopedStore wrappers on `uniforms`, `nodes`, `buffers`, `gpuStorage`). ### Return Value Returns `UniformsWithUtils` - your uniforms by name, **plus three util functions**: ```typescript { // ...your uniforms uTime: UniformNode uColor: UniformNode // utils removeUniforms: (names: string | string[], scope?: string) => void clearUniforms: (scope?: string) => void rebuildUniforms: (scope?: string) => void } ``` - **`removeUniforms`** - remove specific uniforms by name from root or a scope. - **`clearUniforms`** - clear a scope, `'root'` for root only, or no argument to clear everything. - **`rebuildUniforms`** - invalidate the cache and bump the HMR version to force re-creation of the creators (all, `'root'`, or a named scope). ### Input Types ```tsx const uniforms = useUniforms({ uNumber: 5.0, // primitive uBool: true, uColor: '#ff0000', // string -> THREE.Color uVec2: { x: 1, y: 2 }, // plain object -> THREE.Vector2 (great for Leva) uVec3: new THREE.Vector3(1, 2, 3), // Three.js types uColorNode: color('#ff0000'), // TSL node -> typed uniform }) ``` ### Leva Integration Leva controls work directly. The component re-renders on change, plain objects auto-convert to vectors, and function syntax deep-compares output so the GPU only updates when a value actually changes: ```tsx const { color, intensity } = useControls({ color: { x: 1, y: 0.5, z: 0 }, intensity: { value: 1.0, min: 0, max: 2 }, }) const { uColor, uIntensity } = useUniforms({ uColor: color, uIntensity: intensity }) ``` ### Updating Values Always update uniform values imperatively in `useFrame` (no React re-render): ```tsx const { uTime } = useUniforms({ uTime: 0 }) useFrame(({ elapsed }) => { uTime.value = elapsed }) // Also available from state useFrame(({ elapsed, uniforms }) => { uniforms.uTime.value = elapsed }) ``` ### Scoped Uniforms Scopes prevent naming conflicts across features. The same name lives independently in each scope: ```tsx useUniforms({ uHealth: 100, uSpeed: 1.0 }, 'player') useUniforms({ uHealth: 75, uSpeed: 0.8 }, 'enemy') // Read elsewhere, with the schema the creator registered const playerUniforms = useUniforms<{ uHealth: number; uSpeed: number }>('player') // or without one: each uniform is loose, still usable in TSL as is const enemy = useUniforms('enemy') enemy.uHealth.mul(2) ``` The same schema form exists on `useNodes`, `useBuffers` and `useGPUStorage`: `useNodes<{ wobble: Node }>('fx')`. ## useNodes Global TSL node management with the create-if-not-exists pattern and optional scoping. Use it to share expensive TSL expressions, functions, and varyings across components. ```tsx const nodes = useNodes(() => ({ wobbleFn: Fn(() => vec3(sin(time), cos(time), sin(time.mul(2)))), })) // Scoped const nodes = useNodes(() => ({ effectFn: Fn(() => /* ... */) }), 'effects') // Read all / read a scope const all = useNodes() const effectNodes = useNodes('effects') ``` The creator's job is registration: whatever it returns is stored, so other components can read it with `useNodes('scope')`, `state.nodes` or a creator's `nodes`. Keeping the return value is optional - `useNodes(registerEffects)` on its own registers them. A creator must return the nodes it registers; to put a node onto a Three object instead (`scene.fogNode`), use `useLocalNodes` and [return an install function](#installing-nodes-on-three-objects). ### When the creator runs Once per generation. The first component to register a name wins, and later renders - including renders with different props - reuse the registered node: the creator does **not** re-run when a value it captured changes. This is deliberate. Other components build their own graphs from a shared node, and replacing it would not reach all of them; every replacement would also recompile each material that uses it. So: - A value that changes goes into a uniform (`useUniforms`), and the node reads the uniform. - A structural change goes through `rebuildNodes(scope)` (or a hot update), which starts a new generation: every creator re-runs together and every consumer sees the new nodes. Because of that, a creator that reads an entry before it exists keeps `undefined` until the next rebuild. Register what a creator reads above it (earlier in the same component, or higher in the tree). In development a warning names such a read; use `.has()` when probing for an optional entry on purpose. The same holds for `useUniforms`, `useBuffers` and `useGPUStorage` creators. ### Parameters | Parameter | Type | Description | | --------- | ----------------------- | ---------------------------------------------- | | `creator` | `NodeCreator \| string` | Function that returns TSL nodes, or scope name | | `scope` | `string` | Optional scope name for organization | The creator receives a [`CreatorState`](#scopedstore). ### Return Value Returns `NodesWithUtils` - your nodes by name, **plus three util functions**. A creator's nodes keep the types it inferred; a reader's (`useNodes()`, `useNodes('fx')`) are typed by name once you [register your nodes](./typed-resources#register-your-nodes), and [loose](#reading-by-name) otherwise: TSL accepts them and they can be called (`nodes.rimLightFn()`) without a cast. ```typescript { // ...your nodes // utils removeNodes: (names: string | string[], scope?: string) => void clearNodes: (scope?: string) => void rebuildNodes: (scope?: string) => void } ``` - **`removeNodes`** - remove specific nodes by name from root or a scope. - **`clearNodes`** - clear a scope, `'root'` for root only, or no argument to clear everything. - **`rebuildNodes`** - invalidate the cache and bump the HMR version to force re-creation of node creators (all, `'root'`, or a named scope). > The `rebuild*` utils are returned by the hook directly; you do not need the standalone `rebuildAllNodes`/`rebuildAllUniforms` exports for in-component use. ### What to Store Store expensive, reusable work - complex noise/effect functions, shared varyings: ```tsx useNodes(() => ({ noiseFn: Fn(() => fbm(positionWorld.mul(scale))), glowFn: Fn(([color, intensity]) => color.mul(pow(fresnel, intensity))), vWorldPos: varying(vec3()), })) ``` Don't store trivial expressions or constants - use them directly instead. ### Usage ```tsx import { useNodes, useLocalNodes } from '@react-three/tsl' import { Fn, vec3, sin, cos, time } from 'three/tsl' // Define shared nodes once, usually near the root function GlobalNodes() { useNodes(() => ({ wobbleFn: Fn(() => vec3(sin(time), cos(time.mul(1.3)), sin(time.mul(2)))), })) return null } function WobblyMesh() { const { colorNode } = useLocalNodes(({ nodes }) => ({ colorNode: nodes.wobbleFn.mul(0.5).add(0.5) }), []) return ( ) } ``` ## useLocalNodes Creates component-local values from the rendering context and the shared TSL resources. Unlike `useNodes`, this does **not** register to the global store - the result is local to the component, and nothing is published during render or commit. ```tsx const localNodes = useLocalNodes( ({ uniforms, nodes }) => ({ colorNode: nodes.noiseFn.mul(uniforms.intensity), positionNode: positionLocal.add(vec3(0, uniforms.height, 0)), }), [], ) ``` ### Signature ```typescript function useLocalNodes>(creator: LocalNodeCreator, deps?: React.DependencyList): T type LocalNodeCreator = (state: CreatorState) => T ``` The creator receives a [`CreatorState`](#scopedstore) - **not** a raw `RootState`. `uniforms`, `nodes`, `buffers`, and `gpuStorage` are wrapped in a ScopedStore (property access returns the leaf node; use `.scope(name)` for nested scopes). All other RootState properties (`size`, `viewport`, `camera`, etc.) pass through unchanged. Returns any object, not limited to TSL nodes; the exact keys and types of the creator's return are preserved. `deps` accepts a readonly list. ### Dependency Array The second argument works like `useMemo`'s: it declares the **JavaScript construction inputs** the creator reads while building the graph. Whether the array is present selects the mode; its length is up to you. **`[]` is the normal case.** A value that changes over time - a color from a prop, a slider, a strength - belongs in a uniform (`useUniforms`), whose `.value` updates on the GPU with no rebuild and no shader recompile. Declare a dependency only for an input that decides the graph's **structure** and cannot be a uniform: which node to use, how many octaves a JavaScript loop unrolls, whether a branch exists at all. | Call | Ordinary component renders | | -------------------------------- | --------------------------------------------------------------------- | | `useLocalNodes(creator)` | Re-evaluate on every render, even when the creator is a `useCallback` | | `useLocalNodes(creator, [])` | Reuse the result - the creator has no JavaScript construction inputs | | `useLocalNodes(creator, [a, b])` | Reuse until a declared dependency changes by `Object.is` | Whichever form you use, three triggers rebuild independently of `deps` (see [Rebuild Triggers](#rebuild-triggers)), and every rebuild runs the creator **from the current render** - so `[]` means "no JS inputs", not "never rebuild". Creator identity is never a trigger once an array is supplied, which is what makes the inline form cheap: ```tsx // Resource-driven composition: everything comes from the shared resources. const local = useLocalNodes(({ uniforms }) => ({ result: uniforms.strength.mul(2) }), []) // `pattern` picks which node the graph is built from: a structural input, so declare it. const local = useLocalNodes(({ nodes }) => ({ result: pattern === 'noise' ? nodes.noise : nodes.stripes }), [pattern]) ``` #### Live TSL values vs JS construction inputs The array is about what the **graph is built from**, not about what it reads at run-time: - A `UniformNode`, a `texture()`, `time`, `cameraPosition` - these are **live TSL values**. The graph references the node; updating `uTime.value` in `useFrame` reaches the GPU without a rebuild and must not be a dependency. - A prop, a piece of state, a number you multiply by, a string you branch on in JavaScript - these are **construction inputs**. Reading them while building the graph bakes them in, so a change needs a rebuild: declare them. ```tsx const { uStrength } = useUniforms({ uStrength: 1 }) const { colorNode } = useLocalNodes( ({ uniforms }) => ({ // uniforms.uStrength is live: read on the GPU every frame // `octaves` is baked in by the JavaScript loop: declared below colorNode: fbm(positionWorld, octaves).mul(uniforms.uStrength), }), [octaves], ) useFrame(() => { uStrength.value = 5 // value-only change -> no rebuild, no dependency }) ``` #### Stale-closure caveat An omitted construction input does not trigger a rebuild, and until one happens the result reflects the value from the render that built it. When a legitimate rebuild happens later (resource replacement, HMR, a declared dependency changing) the current render's creator runs, so the omitted input's **latest** value shows up then - the usual `useMemo` model. If you find yourself adding a prop or a piece of state to `deps`, first ask whether it should be a uniform instead. Declaring it rebuilds the graph - and recompiles every material using it - each time it changes; a uniform does neither. For the same reason, don't enable `react-hooks/exhaustive-deps` for `useLocalNodes` through `additionalHooks`: it asks for every captured value to be declared, which steers towards rebuilding on every prop change. Use [`@react-three/eslint-plugin`](https://github.com/pmndrs/react-three-fiber/blob/v10/packages/eslint-plugin/docs/rules/prefer-local-nodes-deps.md)'s `prefer-local-nodes-deps` instead (in its `recommended` config): it flags an inline creator with no array, and when the creator reads values from the component it points you to a uniform first. #### Behavior change for stable callbacks Before this contract existed, `useLocalNodes` memoized against creator identity, so a `useCallback` creator was reused across renders while an inline creator reran every render. Now the **absence of the array** means "re-evaluate every render" regardless of how stable the function is. Callers who passed a `useCallback` creator for that effect should pass `[]` (or their inputs) instead - the `useCallback` is no longer needed. ### Rebuild Triggers Independently of `deps`, the creator re-runs when: - A shared resource the creator **read** changes: it is replaced, removed, or appears where the creator read nothing. This covers `uniforms`, `nodes`, `buffers`, `gpuStorage` and `textures`. Registrations the creator did not read change nothing, so a model finishing loading or a panel registering its own uniforms neither re-renders this component nor rebuilds its graph. - The owning store changes (multi-canvas `primaryStore` resolution). - The HMR version is bumped, by a hot update or a manual `rebuild*` call (see [TSL HMR](./hmr)). Value changes do **not** trigger a rebuild - TSL nodes reference uniforms, they do not capture values: ```tsx const { uTime } = useUniforms({ uTime: 0 }) const { colorNode } = useLocalNodes(({ uniforms }) => ({ colorNode: sin(uniforms.uTime) }), []) useFrame(() => { uTime.value = performance.now() // value-only change -> no rebuild }) ``` #### Textures in a creator `textures` is the canvas's texture registry, a `Map` keyed by URL. A texture that `useTexture` loads earlier in the same render is already there, before its registration lands, so a creator right after it builds with the texture on the first try: ```tsx const TEXTURES = { day: '/earth-day.jpg', night: '/earth-night.jpg' } function Globe() { useTexture(TEXTURES) const { dayNode } = useLocalNodes(({ textures }) => ({ dayNode: texture(textures.get(TEXTURES.day)) }), []) // ... } ``` A texture loaded somewhere else later rebuilds a creator that read its URL as missing. #### What counts as a read | In the creator | Rebuilds when | | --------------------------------------------------------- | ------------------------------------------------------- | | `uniforms.uTime`, `nodes.noise`, `buffers.positions` | that entry is replaced, removed, or appears | | `uniforms.scope('fog').uDensity`, `uniforms.fog.uDensity` | that leaf changes; other leaves in `fog` don't count | | `uniforms.has('uTime')`, `'uTime' in uniforms` | the entry appears or disappears | | `uniforms.keys()`, `Object.keys(...)`, spreading a scope | an entry is added to or removed from that scope | | `textures.get(url)`, `textures.has(url)` | the texture at that URL is replaced, loads or goes | | iterating `textures`, `textures.size` | a URL is added or removed, or an entry iterated changes | Only reads made **before the creator returns** are tracked. The body of a `Fn(() => ...)` runs later, while three builds the shader, so a lookup there is not seen - and you get a development warning. Read the resource in the creator and close over it: ```tsx // Tracked: the uniform is read while the creator runs useLocalNodes(({ uniforms }) => { const strength = uniforms.uStrength return { effect: Fn(() => strength.mul(2)) } }, []) // Not tracked: the lookup happens inside Fn, after the creator returned useLocalNodes(({ uniforms }) => ({ effect: Fn(() => uniforms.uStrength.mul(2)) }), []) ``` The same applies to a value captured from **outside** the creator, such as a node returned by `useUniform`: the creator did not read it through its `state`, so declare it in `deps` if a replacement should rebuild the graph. Neither this hook nor React guarantees exactly one creator invocation: StrictMode, an initial suspension, or a discarded render can run it again. Keep creators pure apart from node construction. ### Installing Nodes on Three Objects Some nodes are not returned to anyone: they go straight onto a Three object, like `scene.fogNode` or `scene.backgroundNode`. For those, return a **function** instead of a record. The creator still builds the nodes during render; the function you return runs after commit, and whatever **it** returns is the cleanup: ```tsx useLocalNodes(({ scene, uniforms }) => { // render: build only const fogNode = fog(uniforms.fogColor, rangeFogFactor(uniforms.near, uniforms.far)) // after commit: install, and say how to undo it return () => { scene.fogNode = fogNode return () => { scene.fogNode = null } } }, []) ``` - The install step runs as a layout effect, so the node is in place before the first frame. - Everything that rebuilds a record (`deps`, a resource the creator read, a hot update or `rebuild*`) rebuilds here too: the previous cleanup runs, then the new install. - The cleanup also runs on unmount, and StrictMode's mount, cleanup, mount leaves the last install in place. - Resources read inside the install step are tracked like reads in the creator. - The hook returns nothing in this form. Don't assign inside the creator itself (`useLocalNodes(({ scene }) => { scene.fogNode = ... })`): that runs during render, which React can discard or repeat, and nothing would ever undo it. A creator that returns nothing is a type error and warns in development. ### Use For - Component-specific compositions of shared nodes - Graphs whose structure depends on props (which node, how many octaves) - values from props go into uniforms - Combining global uniforms/nodes with local logic Do not use it for expensive nodes shared across many components (use `useNodes`) or for static nodes that never change (use `useMemo` or direct creation). ### GPU-Side Branching Use TSL conditionals so branches react to uniform changes at run-time: ```tsx import { If, select } from 'three/tsl' const { colorNode } = useLocalNodes(({ uniforms, nodes }) => { const result = vec3(0).toVar() If(uniforms.uVariant.equal(0), () => result.assign(nodes.noisePattern)) .ElseIf(uniforms.uVariant.equal(1), () => result.assign(nodes.stripesPattern)) .Else(() => result.assign(nodes.dotsPattern)) return { colorNode: result } }, []) // Or select() for two-way branching const { colorNode } = useLocalNodes( ({ uniforms, nodes }) => ({ colorNode: select(uniforms.uUseNoise.equal(1), nodes.noisePattern, nodes.stripesPattern), }), [], ) ``` ### useNodes vs useLocalNodes | Feature | useNodes | useLocalNodes | | --------------- | ------------------------- | ----------------------------------------------- | | Storage | Global store | Component-local | | Sharing | Shared across components | Component-specific | | Best for | Expensive, reusable nodes | Component compositions | | Rebuild trigger | HMR / `rebuildNodes` only | Declared deps, resource replacement, store, HMR | ## ScopedStore Creator functions for `useUniforms`, `useNodes`, `useLocalNodes`, `useBuffers`, and `useGPUStorage` receive a `CreatorState`, not the raw `RootState`. `CreatorState` wraps the nested stores in a **ScopedStore** for type-safe access: ```typescript type CreatorState = RootState & { uniforms: CreatorUniforms // typed by name once you register your uniforms nodes: CreatorNodes // typed by name once you register your nodes buffers: CreatorBuffers // BufferLike reads, typed by name once registered gpuStorage: CreatorGPUStorage // StorageLike reads, typed by name once registered } ``` The runtime structure of these stores is `Record>` (a leaf node, or a nested scope). The ScopedStore wrapper lets you: 1. Access a leaf directly by property - `uniforms.uTime`, or `uniforms.player.uHealth` inside a scope. 2. Access a nested scope explicitly with `.scope(key)` - returns an empty wrapper if the scope does not exist, so it is safe. 3. Iterate with `has(key)`, `keys()`, `Object.keys()`, spread, and `for...in`. ### Reading by name TSL operators work on every uniform and node read, registered or not: ```tsx useLocalNodes( ({ uniforms, nodes }) => ({ wobble: sin(uniforms.uTime.mul(2)), playerHealth: uniforms.scope('player').uHealth, // or uniforms.player.uHealth glow: nodes.scope('effects').rimLightFn(), }), [], ) ``` How exactly they are typed depends on [registration](./typed-resources). Registered, a read is typed by name: `uniforms.uTime` is a float uniform, and `uniforms.uTiem` is an error. Unregistered, a uniform read is a `LooseUniformNode` and a node read a `LooseNode`. A string name can't tell TypeScript what the entry holds, and three's types only give operator methods to a node whose type (float, vec3, color, ...) is known, so these loose types have every member of three's nodes, typed `any` (a `LooseNode` can also be called, as an `Fn()` can). Every operator, TSL function and material slot accepts them; a misspelt member is still an error. That is a deliberate simplification: what their own methods return is `any`, and an operation that needs a specific type (`cross()` wants vec3s) isn't checked. When you want the exact type, cast once or give the scope its schema: ```tsx import type { UniformNode, Vector3 } from 'three/webgpu' useLocalNodes(({ uniforms }) => { const uDir = uniforms.uDir as UniformNode<'vec3', Vector3> // one `as` is enough const player = uniforms.scope<{ uHealth: UniformNode<'float', number> }>('player') return { facing: cross(uDir, normalView), damage: player.uHealth.mul(2) } }, []) ``` The same holds outside creators: `useUniforms()`, `useUniforms('scope')`, `useUniform('name')`, `useNodes()`, `useNodes('scope')`, and `state.uniforms` / `state.nodes` in `useFrame` and `useThree`. See [What stays loose](./typed-resources#what-stays-loose). Buffers and GPU storage are not loose: an unregistered read is the broad `BufferLike` or `StorageLike` union (a typed array is nothing like a buffer node), which one `as` or a schema (`useBuffers<{ positions: StorageBufferNode<'vec3'> }>('particles')`) narrows. [Registered](./typed-resources#register-buffers-and-gpu-storage), they are typed by name like uniforms and nodes. The wrappers are created lazily - the underlying Proxy is only built when you actually access `uniforms`, `nodes`, `buffers`, or `gpuStorage`, so creators that don't touch them pay no cost. ## Related - [WebGPU Overview](./overview) - concepts and architecture - [Render Pipeline](./render-pipeline) - post-processing and passes - [TSL HMR](./hmr) - what rebuilds on hot reload ]]> Three.js renamed `PostProcessing` to `RenderPipeline` in r183. The hook builds a `THREE.RenderPipeline`. ## API ```tsx // Basic - configure the output useRenderPipeline(({ renderPipeline, passes }) => { renderPipeline.outputNode = bloom(passes.scenePass.getTextureNode()) }) // With MRT setup useRenderPipeline( // mainCB - configure output ({ renderPipeline, passes }) => { const beauty = passes.scenePass.getTextureNode() const velocity = passes.scenePass.getTextureNode('velocity') renderPipeline.outputNode = motionBlur(beauty, velocity) }, // setupCB - configure MRT (runs first) ({ passes }) => { passes.scenePass.setMRT(mrt({ output, velocity })) }, ) // Read-only access const { renderPipeline, passes } = useRenderPipeline() ``` ### Parameters | Parameter | Type | Description | | --------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `mainCB` | `RenderPipelineMainCallback` | Configure `outputNode` and create effect passes | | `setupCB` | `RenderPipelineSetupCallback` | Optional. Configure MRT on the scene pass | | `options` | `RenderPipelineOptions` | Optional. `{ persist: true }` keeps the pipeline rendering after unmount, see [Ownership](#ownership-and-cleanup) | Both callbacks receive the same state as [`useNodes`](./tsl-hooks) creators (scene, camera, size, the wrapped `uniforms`, `nodes`, `buffers`, `gpuStorage` and `textures`) plus the pipeline and its passes. Returning an object from either callback registers those passes into `state.passes`. The `scenePass` key is reserved for the hook's own pass and cannot be returned. ```typescript type RegisteredPasses = Record & { scenePass?: never } type RenderPipelineMainCallback = (state: RenderPipelineCallbackState) => RegisteredPasses | void type RenderPipelineSetupCallback = (state: RenderPipelineCallbackState) => RegisteredPasses | void ``` ### Return Value ```typescript type UseRenderPipelineReturn = { passes: PassRecord // All registered passes clearPasses: () => void // Clear the passes record reset: () => void // Free the pipeline now rebuild: () => void // Re-run the callbacks against a fresh scene pass } & ({ isReady: true; renderPipeline: RenderPipeline } | { isReady: false; renderPipeline: null }) ``` The return is discriminated on `isReady`, so checking it narrows `renderPipeline` to non-null: ```tsx const { isReady, renderPipeline } = useRenderPipeline() if (isReady) renderPipeline.needsUpdate = true // RenderPipeline, not RenderPipeline | null ``` ## You must set outputNode If you change away from the default scene pass, you **must** set `renderPipeline.outputNode` yourself. This is deliberate - changing the pipeline output is not inferred automatically, so you stay in control of what gets rendered. ## Usage ### Simple Bloom ```tsx import { useRenderPipeline } from '@react-three/tsl' import { bloom } from 'three/tsl' function Scene() { useRenderPipeline(({ renderPipeline, passes }) => { const sceneTexture = passes.scenePass.getTextureNode() renderPipeline.outputNode = bloom(sceneTexture, 1.5, 0.1, 0.9) }) return ( ) } ``` ### Read-Only Access Called without callbacks, the hook only reads. It never creates the pipeline, and mounting or unmounting it has no effect on the one that is rendering: ```tsx function DebugPanel() { const { renderPipeline, passes, isReady } = useRenderPipeline() if (!isReady) return
No render pipeline
return

Passes: {Object.keys(passes).join(', ')}

} ``` ## What the Hook Does Automatically When called with a callback: 1. Creates a RenderPipeline for this component. 2. Creates a default scene pass (no MRT by default). 3. Sets a default `outputNode` to the scene pass (passthrough). 4. Recreates the scene pass only when the scene or camera actually changes, or on `rebuild()`. 5. Frees the pipeline and scene pass when the component unmounts, and the canvas goes back to rendering without post-processing. ## Ownership and Cleanup The component that configures the pipeline owns it. Conditionally rendering it turns the effect on and off: ```tsx function Bloom() { useRenderPipeline(({ renderPipeline, passes }) => { renderPipeline.outputNode = bloom(passes.scenePass.getTextureNode()) }) return null } // Unmounting frees its pipeline; the scene renders without bloom {showBloom && } ``` - **Readers don't count.** `useRenderPipeline()` without callbacks reads whatever pipeline is rendering, and never creates, keeps alive or frees one. - **Swapping effects works.** Replacing `` with `` in one render frees Bloom's pipeline and renders through Blur's. - **The latest owner renders.** If two mounted components configure the pipeline at the same time, the most recently mounted one renders and the hook warns once in development. When it unmounts, the other takes over again with the graph it already built. - **Remounts keep the pipeline.** Cleanup is deferred by one task, so StrictMode's double mount, and a Fast Refresh that keeps the component's state, reuse the pipeline and scene pass instead of building new ones. A remount that resets the component (Fast Refresh after you add or remove a hook, or `// @refresh reset`) is a new instance: it builds a new pipeline and the old one is freed. - **Canvas unmount frees everything** on that canvas, including persisted pipelines. Every mounted instance owns its own pipeline, so remounting the same effect component, on another route for example, always builds a new one. `persist` is for a different case: keeping an effect on screen after the component that configured it unmounts, when nothing replaces it. ```tsx useRenderPipeline(mainCB, setupCB, { persist: true }) ``` A persisted pipeline keeps rendering until another component configures the canvas's pipeline, `reset()` is called (a reader can call it), or the canvas unmounts. It cannot `rebuild()` once its component has unmounted, since its callbacks are gone. Passes your callbacks return are not disposed by the hook; see [#3864](https://github.com/pmndrs/react-three-fiber/issues/3864). ## MRT (Multiple Render Targets) For effects like motion blur, velocity buffers, or G-buffers, configure MRT in `setupCB`, which runs before `mainCB`. ```tsx import { mrt, output, velocity } from 'three/tsl' import { motionBlur } from 'three/addons/tsl/display/MotionBlur.js' function RenderPipelineManager() { useRenderPipeline( // mainCB - configure effect (MRT already set) ({ renderPipeline, passes }) => { const beauty = passes.scenePass.getTextureNode() const vel = passes.scenePass.getTextureNode('velocity') renderPipeline.outputNode = motionBlur(beauty, vel.mul(1.0)) }, // setupCB - configure MRT BEFORE mainCB runs ({ passes }) => { passes.scenePass.setMRT(mrt({ output, velocity })) }, ) return null } ``` ## Callback Execution Order: `setupCB` runs **first** (MRT must be configured before rendering), then `mainCB`. Callbacks run when: - The RenderPipeline is created: on mount, and after `reset()` the next time the hook's effect runs (`rebuild()`, a hot update, or a scene or camera change) - The scene or camera changes (the scene pass needs recreation) - Any hot update in the app, or a `rebuild*()` from another TSL hook, bumps the TSL resource generation - A resource the callbacks read appears, is replaced or is removed (see [Reading Resources](#reading-resources)) - `rebuild()` is called explicitly Callbacks do **not** re-run on: - Component re-renders - StrictMode's double mount ### HMR On a hot update the TSL hooks register a fresh generation of uniforms and nodes, and `useRenderPipeline` re-runs its callbacks once they have, so the graph picks up both your edits and the new uniforms. This happens on every hot update in the app, not only on edits to the component that owns the pipeline: the pipeline and scene pass are kept, and only the graph built on them is replaced and recompiled. A hot update that resets the owning component builds a new pipeline instead (see [Ownership](#ownership-and-cleanup)). `` turns this off. See [TSL HMR](./hmr). To re-run the callbacks yourself, against a fresh scene pass, call `rebuild()`: ```tsx function RebuildButton() { const { rebuild } = useRenderPipeline() // Re-runs setupCB/mainCB with current closure values return } ``` ### Reading Resources Read uniforms and nodes from the callback state, not from a variable the component captured. Reads through the state are tracked as in [`useLocalNodes`](./tsl-hooks): if a resource the callbacks read appears, is replaced (a hot update, `rebuildUniforms()`) or is removed, they re-run on the same scene pass. A registration they did not read changes nothing. That makes resources registered anywhere in the tree safe to use, including in a parent, whose registration React commits after this component's effects: ```tsx function Effects() { useRenderPipeline(({ renderPipeline, passes, uniforms }) => { const vel = passes.scenePass.getTextureNode('velocity') renderPipeline.outputNode = motionBlur(passes.scenePass.getTextureNode(), vel.mul(uniforms.blurAmount)) }) return null } function Scene() { useUniforms({ blurAmount: 1 }) // registered by the parent return } ``` A value captured from a reader in the component body (`const { blurAmount } = useUniforms()`) is not tracked: the callbacks do not re-run when it is registered later, replaced or removed. Only reads made before the callback returns are tracked; inside `Fn(() => …)`, read the resource in the callback and close over it. ### Prefer Uniforms for Dynamic Values Because callbacks don't re-run on render, drive dynamic parameters through uniforms rather than closure values: ```tsx const { intensity } = useControls({ intensity: 1.0 }) const { uIntensity } = useUniforms(() => ({ uIntensity: intensity })) useRenderPipeline(({ renderPipeline, passes, uniforms }) => { // uniforms.uIntensity stays current with no callback re-run renderPipeline.outputNode = bloom(passes.scenePass.getTextureNode(), uniforms.uIntensity) }) ``` ## Registering Custom Passes Register passes by **returning** them from a callback. The hook merges returned passes into `state.passes`. Direct assignment to `passes.x` does not register and is lost after the callback completes. ```tsx useRenderPipeline(({ renderPipeline, passes, scene, camera }) => { const customPass = pass(scene, camera) renderPipeline.outputNode = customPass // ✅ Return to register - now available via useRenderPipeline() anywhere return { customPass } }) ``` You can register from `setupCB` too, and return multiple passes at once: ```tsx useRenderPipeline(({ renderPipeline, scene, camera }) => { const depthPass = pass(scene, camera) depthPass.setMRT(mrt({ depth })) const normalPass = pass(scene, camera) normalPass.setMRT(mrt({ normal })) renderPipeline.outputNode = ssao(depthPass.getTextureNode('depth'), normalPass.getTextureNode('normal')) return { depthPass, normalPass } }) ``` ## Pass Registry Passes live in `state.passes`: ```typescript interface PassRecord { scenePass?: PassNode // three's PassNode, created automatically by the hook [key: string]: Node // Anything you register: texture reads, effect nodes, extra passes } ``` Registered entries are typed as three's `Node`. Narrow them at the call site when you need a specific member: ```tsx const { passes } = useThree() const velocity = passes.velocity as TextureNode ``` Inside `mainCB` and `setupCB`, `passes.scenePass` is typed as required: the hook installs it before either callback runs, so no guard or cast is needed: ```tsx useRenderPipeline(({ renderPipeline, passes }) => { const beauty = passes.scenePass.getTextureNode() // PassNode, no cast renderPipeline.outputNode = bloom(beauty) }) ``` From `useThree()` or `useRenderPipeline()` it is optional, since `state.passes` is empty while no pipeline is rendering (before one is created, after its owner unmounts, after `reset()`) and after `clearPasses()`: ```tsx const { passes } = useThree() passes.scenePass?.getTextureNode() ``` ## Manual Control ```tsx const { reset, rebuild, clearPasses } = useRenderPipeline() reset() // Free the pipeline and scene pass now; the previous owner, or plain rendering, takes over rebuild() // Re-run setupCB/mainCB with current closures, against a fresh scene pass clearPasses() // Remove all passes from the record ``` Called from a reader, each acts on the pipeline that is rendering. ## Best Practices - **One configuring `useRenderPipeline` mounted at a time.** Mount the effect component to turn it on and unmount it to turn it off; read the pipeline elsewhere with `useRenderPipeline()`. - **Use uniforms for dynamic values** - closure values need a `rebuild()` to update. - **Configure MRT in `setupCB`**, not `mainCB`. - **Return custom passes** to register them. ## Error Handling `useRenderPipeline` throws if used with a WebGL (`/legacy`) renderer - it requires `WebGPURenderer`, which `@react-three/fiber` and `/webgpu` render with. In read-only mode, guard on `isReady` before using `renderPipeline`. ## TypeScript ```typescript import type { RenderPipeline, PassNode } from 'three/webgpu' // PassRecord and the callback types are global types that ship with @react-three/tsl const { passes, renderPipeline, clearPasses, reset, rebuild, isReady } = useRenderPipeline() const mainCB: RenderPipelineMainCallback = ({ renderPipeline, passes }) => { /* ... */ } const setupCB: RenderPipelineSetupCallback = ({ passes }) => { /* ... */ } useRenderPipeline(mainCB, setupCB) ``` ## Related - [TSL Hooks](./tsl-hooks) - uniforms and nodes for effect parameters - [TSL HMR](./hmr) - what rebuilds on hot reload (and what does not) - [Three.js RenderPipeline](https://threejs.org/docs/#api/en/renderers/webgpu/RenderPipeline) - [Built-in TSL effects](https://github.com/mrdoob/three.js/tree/dev/examples/jsm/tsl/display) ]]>
**Experimental.** `useBuffers` and `useGPUStorage` cover GPU compute workflows that are still evolving. APIs may change between releases. Pin your version and expect churn. `useBuffers` and `useGPUStorage` manage GPU storage for compute operations. They follow the same create-if-not-exists pattern and scoping model as [`useNodes` and `useUniforms`](./tsl-hooks), and their creators receive a [`CreatorState`](./tsl-hooks#scopedstore) with ScopedStore wrappers. ```tsx import { useBuffers, useGPUStorage } from '@react-three/tsl' ``` ## useBuffers Manages buffer data: CPU TypedArrays, GPU buffer attributes, and TSL buffer nodes. ```tsx // Root level const buffers = useBuffers(() => ({ positions: instancedArray(count, 'vec3'), velocities: new Float32Array(count * 3), })) // Scoped const buffers = useBuffers(() => ({ particlePos: new StorageBufferAttribute(count, 4) }), 'particles') // Read all / read a scope const all = useBuffers() const particleBuffers = useBuffers('particles') ``` ### Parameters | Parameter | Type | Description | | --------- | ------------------------- | -------------------------------------------- | | `creator` | `BufferCreator \| string` | Function that returns buffers, or scope name | | `scope` | `string` | Optional scope name for organization | ### Return Value Returns `BuffersWithUtils` - your buffers by name, **plus four util functions**: ```typescript { // ...your buffers removeBuffers: (names: string | string[], scope?: string) => void clearBuffers: (scope?: string) => void rebuildBuffers: (scope?: string) => void disposeBuffers: (names: string | string[], scope?: string) => void } ``` - **`removeBuffers`** - remove buffers by name from root or a scope (store only). - **`clearBuffers`** - clear a scope, `'root'` for root only, or no argument for everything. - **`rebuildBuffers`** - invalidate the cache and bump the HMR version to force re-creation (all, `'root'`, or a scope). See [TSL HMR](./hmr) for the rebuild guarantees. - **`disposeBuffers`** - releases GPU resources (`.dispose()`) **and** removes from the store. ### What Can Be Stored ```tsx useBuffers(() => ({ // TypedArrays (CPU-side data) cpuData: new Float32Array(count * 3), indices: new Uint32Array(count), // Three.js buffer attributes storageAttr: new StorageBufferAttribute(count, 4), instancedAttr: new StorageInstancedBufferAttribute(count, 4), indirectAttr: new IndirectStorageBufferAttribute(data, 1), // TSL buffer nodes positions: instancedArray(count, 'vec3'), custom: storage(bufferAttribute, 'vec4', count), })) ``` ## useGPUStorage Manages GPU storage textures for compute operations. ```tsx // Root level const storage = useGPUStorage(() => ({ heightMap: new StorageTexture(512, 512) })) // Scoped const storage = useGPUStorage(() => ({ normalMap: new StorageTexture(1024, 1024) }), 'terrain') // Read all / read a scope const all = useGPUStorage() const terrainStorage = useGPUStorage('terrain') ``` ### Parameters | Parameter | Type | Description | | --------- | -------------------------- | -------------------------------------------- | | `creator` | `StorageCreator \| string` | Function that returns storage, or scope name | | `scope` | `string` | Optional scope name for organization | ### Return Value Returns `StorageWithUtils` - your storage by name, **plus four util functions**: ```typescript { // ...your storage removeStorage: (names: string | string[], scope?: string) => void clearStorage: (scope?: string) => void rebuildStorage: (scope?: string) => void disposeStorage: (names: string | string[], scope?: string) => void } ``` These mirror the `useBuffers` utils. `disposeStorage` releases GPU texture memory and removes the entry from the store. ### What Can Be Stored ```tsx useGPUStorage(() => ({ // Three.js storage textures heightMap: new StorageTexture(512, 512), voxelData: new Storage3DTexture(64, 64, 64), // TSL storage texture nodes storageNode: storageTexture(texture, uvNode, storeNode), })) ``` ## Accessing in Node Creators Both hooks expose their data to `useNodes` and `useLocalNodes` through the creator state's ScopedStore wrappers: ```tsx const { positions } = useBuffers(() => ({ positions: instancedArray(count, 'vec3') }), 'particles') const { heightMap } = useGPUStorage(() => ({ heightMap: new StorageTexture(512, 512) }), 'terrain') const { positionNode, heightNode } = useNodes(({ buffers, gpuStorage }) => ({ positionNode: buffers.scope('particles').positions.element(instanceIndex), heightNode: texture(gpuStorage.scope('terrain').heightMap), })) ``` ## Example: Particle System ```tsx import { useBuffers, useGPUStorage, useNodes, useLocalNodes } from '@react-three/tsl' import { instancedArray, Fn, vec3, instanceIndex, texture } from 'three/tsl' import { StorageTexture } from 'three/webgpu' const PARTICLE_COUNT = 10000 function ParticleSystem() { const { positions, velocities } = useBuffers( () => ({ positions: instancedArray(PARTICLE_COUNT, 'vec3'), velocities: instancedArray(PARTICLE_COUNT, 'vec3'), }), 'particles', ) const { forceField } = useGPUStorage(() => ({ forceField: new StorageTexture(256, 256) }), 'particles') const { updateParticles } = useNodes( ({ buffers, gpuStorage }) => ({ updateParticles: Fn(() => { const pos = buffers.scope('particles').positions.element(instanceIndex) const vel = buffers.scope('particles').velocities.element(instanceIndex) const uv = pos.xz.div(100).add(0.5) const force = texture(gpuStorage.scope('particles').forceField, uv).xyz vel.addAssign(force.mul(0.01)) pos.addAssign(vel) })(), }), 'compute', ) const { positionNode } = useLocalNodes( ({ buffers }) => ({ positionNode: buffers.particles.positions.element(instanceIndex), }), [], ) return ( ) } ``` ## Best Practices - Use scopes to group buffers/storage by system (`'particles'`, `'terrain'`). - Call `disposeBuffers` / `disposeStorage` in cleanup to free GPU resources. - Use `.scope('name')` for safe access (returns an empty wrapper if the scope is missing); direct `buffers.name` access works when you know the scope exists. ## Related - [TSL Hooks](./tsl-hooks) - nodes and uniforms, plus the ScopedStore wrapper - [WebGPU Overview](./overview) - concepts and architecture - [TSL HMR](./hmr) - rebuild behavior for buffers and storage ]]> **Experimental (A5).** The `useTextures` API may change before it's stabilized. ## useTexture ```tsx import { useTexture } from '@react-three/fiber' // Single const diffuse = useTexture('/diffuse.png') // Array const [diffuse, normal] = useTexture(['/diffuse.png', '/normal.png']) // Record - the most convenient form, since it spreads into a material const props = useTexture({ map: '/diffuse.png', normalMap: '/normal.png' }) return ``` ### Parameters | Parameter | Type | Description | | ---------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------- | | `input` | `string \| string[] \| Record` | URL, list of URLs, or a name to URL map. The return value mirrors the input shape. | | `options.onLoad` | `(texture) => void` | Called once when the texture(s) finish loading. Not called on a registry hit. | | `options.cache` | `boolean` | Enroll the result in the registry. Defaults to `true`. | Passing a bare function instead of `options` still works and is treated as `onLoad`: ```tsx const floor = useTexture('/floor.jpg', (t) => { t.wrapS = t.wrapT = THREE.RepeatWrapping }) ``` ### Registry enrollment `cache` defaults to `true`, so loading enrolls into the registry automatically. The same URL then returns the **same** `Texture` object everywhere, and that object outlives its last consumer. ```tsx const diffuse = useTexture('/diffuse.png') diffuse.colorSpace = THREE.SRGBColorSpace // Elsewhere: the same object, with colorSpace already set const same = useTexture('/diffuse.png') ``` Pass `{ cache: false }` to skip registry enrollment for a texture: ```tsx const scratch = useTexture('/scratch.png', { cache: false }) ``` Registry reads in `useTexture` happen **once at mount**. A mounted `useTexture` does not re-render when the registry changes, so loading texture A never re-renders a component using texture B. To react to registry changes, use the selector form of `useTextures`. ## useTextures `useTextures` returns a handle onto the registry, a `Map` of plain `Texture` objects keyed by URL (or by any key you choose for textures you register yourself). Calling `useTextures()` with no selector subscribes to the whole registry, so the component re-renders whenever a key is added or disposed. Pass a selector to narrow that to a single key. Reading with `get()` inside `useFrame` is live either way and needs no subscription. ### Return value | Member | Type | Description | | ------------------------ | -------------------------------------------- | ---------------------------------------------------------- | | `all` | `Map` | The live registry. Treat as read-only. | | `get(input)` | `Texture \| undefined` (mirrors input shape) | Read one key, an array of keys, or a name to key map. | | `has(key)` | `boolean` | Whether a key exists. | | `add(key, texture)` | `void` | Register a texture that has no URL. Also accepts a record. | | `dispose(key, options?)` | `boolean` | Free GPU resources and remove the key. Refcount-aware. | | `disposeAll()` | `void` | Dispose everything and clear the registry. | ### Reading textures ```tsx import { useTexture, useTextures } from '@react-three/fiber' function Floor() { // Load once, anywhere in the tree — registered by default useTexture({ map: '/PavingStones092_1K_Color.jpg', normalMap: '/PavingStones092_1K_Normal.jpg', }) return null } function Wall() { // Reach the same textures from a different component — the record form // mirrors its input, so it drops straight into a material const { map, normalMap } = useTextures().get({ map: '/PavingStones092_1K_Color.jpg', normalMap: '/PavingStones092_1K_Normal.jpg', }) return ( ) } ``` ### Registering textures without a URL Render targets and procedural textures have no URL to key off, so register them yourself: ```tsx const textures = useTextures() textures.add('rt-main', renderTarget.texture) ``` ## Lifecycle and disposal Each mounted `useTexture` consumer (with `cache` on, the default) holds a reference on the keys it uses, and releases it on unmount. `dispose(key)` is refcount-aware. If the key is still referenced by a mounted consumer, it **warns to the console, skips the disposal, and returns `false`**: ```tsx const textures = useTextures() textures.dispose('/diffuse.png') // false if still in use, and nothing is freed textures.dispose('/diffuse.png', { force: true }) // disposes regardless textures.disposeAll() // scene teardown ``` > **A reference count of zero does not free anything.** Unmounting the last consumer only releases the reference. The texture stays in the registry and keeps its GPU memory until `dispose` or `disposeAll` is called. That is deliberate: leaving a scene and coming back should not re-upload its textures. It does mean a long-lived app has to dispose the keys it is finished with. ## When is the registry reactive? (and the flip-flop pattern) There are three distinct situations, and only one of them wants React reactivity: | Use case | Tool | Reactive? | | ------------------------------------------------------- | ------------------------------------- | ----------------------------- | | Load an asset by URL | `useTexture(url)` | No — the object never changes | | **Swap** which texture lives under a key (occasionally) | `useTextures(s => s.get(key))` | Yes — scoped to that one key | | Swap a texture **every frame** (ping-pong) | `registry.get(key)` inside `useFrame` | No — read it imperatively | Loading a URL never needs reactivity (the texture instance is stable), and a render target being drawn into updates _inside_ three.js (`needsUpdate`) without changing the JS object — so neither involves React. The one case that does is a **flip-flop / ping-pong**: you keep two textures and swap which one a material reads. For an _occasional_ swap (e.g. toggling an output on some event), subscribe to just that key with the selector form — only this component re-renders, and only when the slot actually changes: ```tsx function Output() { // Re-renders only when the texture under 'finalOutput' is swapped const tex = useTextures((s) => s.get('finalOutput')) return ( ) } // Elsewhere, from an event handler or effect (never during render, // since add() sets store state) — the Output component picks up texB useTextures().add('finalOutput', texB) ``` For a **per-frame** ping-pong, don't make React re-render every frame — read the current texture imperatively in the loop instead: ```tsx function PingPong({ material }) { const textures = useTextures() useFrame((state) => { const even = Math.floor(state.elapsed * 60) % 2 === 0 material.map = textures.get(even ? 'a' : 'b') }) return null } ``` ## Related - [Loading Textures](/next/tutorials/loading-textures) - tutorial introduction to texture loading - [Render Targets](/next/scene/render-targets) - creating the render targets you can register here ]]> `. From then on: ```tsx const all = useUniforms() all.uTime.value // number all.player.uHealth.value // number all.uTiem // ❌ type error: not registered const player = useUniforms('player') // 'player' autocompletes useUniform('uTime').value // number, no generic needed useFrame(({ uniforms }, delta) => { uniforms.uTime.value += delta // state.uniforms is typed too }) useThree((s) => s.uniforms.uColor) // and in useThree useNodes(({ uniforms }) => ({ tinted: mix(uniforms.uColor, uniforms.player.uTint, uniforms.uTime), // and in creators })) ``` Creators are checked against what you registered. New keys are always allowed: ```tsx useUniforms(() => ({ uTime: 0, uWind: new Vector3() })) // ✅ useUniforms(() => ({ uTime: 'fast' })) // ❌ uTime is registered as a number useUniforms({ uHealth: new Color() }, 'player') // ❌ uHealth is a number in 'player' ``` ## What stays loose Registration never makes existing code stricter than it has to: | Call | Not registered | Registered | | ----------------------------------------------------------- | ---------------------------- | ----------------------------------------- | | `useUniforms(() => ({...}))` | Inferred from the creator | Inferred; registered keys keep their type | | `useUniforms()` / `state.uniforms` / a creator's `uniforms` | Loose | Typed; unknown root keys are errors | | `useUniforms('player')` (registered scope) | Loose | Typed | | `useUniforms('enemy')` (other scope) | Loose | Loose | | `useUniforms<{ blur: number }>('fx')` | Typed by the explicit schema | Same | | `useUniform('uTime')` | Loose unless you pass a type | Typed by name | A loose read is a `LooseUniformNode`, and it works in TSL as is: ```tsx const { uTime, uColor } = useUniforms() material.colorNode = mix(color('red'), uColor, sin(uTime.mul(2))) useFrame((_, delta) => (uTime.value += delta)) ``` Why it is typed that way: a read by name can't know what the uniform under that name holds, and three's types only give the operator methods (`.mul`, `.add`, swizzles, `.toVar()`) to a node whose type is known: a float, a vec3, a color. A plain "some uniform" type has none of them, which is why these reads used to need a cast. So an unregistered read is typed as a node that has every member that any of three's uniform nodes has, each typed `any`. Every operator, TSL function and material slot accepts it, and a misspelt member (`uTime.valeu`) is still an error. It's a deliberate simplification, and it gives up two things. What its own methods return is `any` (TSL functions such as `sin()` or `mix()` still return typed nodes), and nothing checks that an operation fits the uniform's real type: `cross()` wants two vec3s and will take it. Cast once when you want the exact type; a single `as` works, since every member is `any`: ```tsx import type { Color, UniformNode, Vector3 } from 'three/webgpu' const uDir = uniforms.uDir as UniformNode<'vec3', Vector3> const facing = cross(uDir, normalView) // checked as vec3 const uTint = uniforms.uTint as UniformNode<'color', Color> uTint.value.set('red') // .value is a Color ``` > **Feedback wanted.** This loose default is new in the v10 alpha. If it gets in your way, or lets through a mistake you'd want TypeScript to catch, please [open or comment on a GitHub issue](https://github.com/pmndrs/react-three-fiber/issues). `UniformNode` here is three's (``). A schema does the same without a cast: `useUniforms<{ blur: number }>('fx')`, `useUniform('uTint')`, or `uniforms.scope<{ uHealth: UniformNode<'float', number> }>('player')` in a creator. Registering gives exact types everywhere and also catches a misspelt name, which no loose read can. A root read (`useUniforms().x`, `state.uniforms.x`, a creator's `uniforms.x`) may name a scope as well as a uniform, so `state.uniforms.player.uHealth` is loose the same way. Cast the uniform, not the scope. ## Strict or loose By default, reading an unregistered key from the root (`useUniforms()`, `state.uniforms`, a creator's `uniforms`) is a type error. That is what catches typos. If your app creates many uniforms on the fly and reads them from the root elsewhere, opt out: ```ts declare module '@react-three/tsl' { interface Register { uniforms: typeof globalUniforms strict: false } } ``` Registered keys stay fully typed. Unregistered root keys are allowed and read [loose](#what-stays-loose), so a typo compiles. `strict` covers registered [nodes](#register-your-nodes), [buffers and GPU storage](#register-buffers-and-gpu-storage) the same way. ## Register your nodes Nodes register the same way, by their node types. `nodes` holds root-level nodes, `nodeScopes` named scopes: ```ts import type { Node } from 'three/webgpu' import { Fn, sin, vec3 } from 'three/tsl' import type { CreatorState } from '@react-three/tsl' export const rimLightFn = Fn(() => vec3(1, 0.5, 0)) export const sharedNodes = ({ uniforms }: CreatorState) => ({ wobble: sin(uniforms.uTime.mul(2)).toVar(), rimLight: rimLightFn, }) declare module '@react-three/tsl' { interface Register { nodes: ReturnType // or spelled out: { wobble: Node<'float'>; rimLight: typeof rimLightFn } nodeScopes: { fx: { blur: Node<'vec4'> } } } } ``` Then `useNodes().wobble`, `useNodes('fx').blur`, `state.nodes.wobble` and a creator's `nodes.rimLight()` are typed by name, typos on the root are errors (unless `strict: false`), and creators are checked: `useNodes(() => ({ wobble: vec3(1) }))` is an error once `wobble` is registered as a float node. A scope only known at runtime (`useNodes(creator, props.scope)`) is not checked. Unlike uniforms there is no `configureTSL` for nodes: a registered node exists once the component that creates it (`useNodes(sharedNodes)`) has rendered. A component rendered before that reads `undefined`, whatever the type says. Without registration a node read is a loose `LooseNode`, for the same reason a uniform read is a `LooseUniformNode`: it has every member of three's nodes and of an `Fn()`, typed `any`, and can be called, so `nodes.wobble.mul(2)`, `material.colorNode = nodes.wobble` and `nodes.rimLightFn()` compile. One `as` makes it exact: `nodes.blur as Node<'vec4'>`, `nodes.rimLight as typeof rimLightFn`. ## Register buffers and GPU storage `useBuffers` and `useGPUStorage` register the same way: `buffers` / `bufferScopes` and `gpuStorage` / `gpuStorageScopes`, each by the type the hook stores. ```ts import type { StorageBufferNode, StorageTexture } from 'three/webgpu' declare module '@react-three/tsl' { interface Register { buffers: { positions: StorageBufferNode<'vec3'>; seeds: Float32Array } bufferScopes: { particles: { colors: StorageBufferNode<'vec4'> } } gpuStorage: { heightMap: StorageTexture } gpuStorageScopes: { terrain: { normals: StorageTexture } } } } ``` Then `useBuffers().positions.element(instanceIndex)`, `useBuffers('particles').colors` (the scope name autocompletes), `state.buffers.seeds[0]`, `texture(useGPUStorage().heightMap)` and a creator's `buffers` / `gpuStorage` are typed by name, root typos are errors (unless `strict: false`), and creators are checked: `useBuffers(() => ({ positions: new Float32Array(12) }))` is an error once `positions` is registered as a buffer node. A scope only known at runtime is not checked. As with nodes, a registered entry exists only once the component that creates it has rendered. Unregistered reads are not loose here. A read is the union of what the hook stores (`BufferLike`: typed arrays, buffer attributes and buffer nodes; `StorageLike`: storage textures and nodes), since a CPU array and a GPU buffer node have little in common to make loose. Narrow it with one `as` (`buffers.positions as StorageBufferNode<'vec3'>`) or a schema (`useBuffers<{ positions: StorageBufferNode<'vec3'> }>('particles')`). With `strict: false`, an unregistered root key reads as that union too. ## configureTSL `Register` only describes types. `configureTSL` makes them true at runtime: it creates the uniforms on every primary canvas's `RootState`, including canvases already mounted, before any component runs. Secondaries and portals see them like any shared uniform. - A hook that declares the same name later gets the configured node, not a new one. - Calling it again adds to the configuration. Uniforms that already exist are never replaced. - It is optional. Without it, a registered uniform only exists once some component creates it, so a read before that returns `undefined` even though its type says otherwise. Pass the same objects you registered, so the types and the runtime come from one place. ## Notes - `Register` is global to your TypeScript program, like any module augmentation. Declare it once, with every resource family you register in the same interface. - Each family is independent: registering nodes leaves uniforms loose, and so on. `strict` applies to every family you register. ]]> ``` The primary creates the `WebGPURenderer`; each other canvas borrows it and draws into its own `` element through a three.js `CanvasTarget`. Renderer-wide settings (everything in `renderer`: shadows, tone mapping, output color space, constructor options such as `antialias`) belong on the primary. With no canvas marked `primary`, nothing changes: every canvas creates its own renderer, as without multi-canvas. ## Who Owns, Who Shares When a canvas creates its renderer, it shares a primary's if: - it names one with `share="id"`, or - exactly one `` is mounted (or mounting in the same commit), and the canvas did not opt out. A canvas owns its renderer instead when: - it is the `primary` - it passes `share={false}` - it passes its own renderer **instance or factory** (`renderer={myRenderer}`, `renderer={(defaults) => ...}`) - it uses the `gl` prop (WebGL can't share) - no primary is mounted A `renderer` **props bag** does not opt out: a canvas sharing the primary's renderer ignores its bag (and warns once in development, _"renderer settings on a sharing canvas are ignored; set them on the \"_), because settings on a shared renderer would change every canvas drawing with it. Pass `share={false}` if that canvas should have its own renderer with those settings. `textureColorSpace` is the one exception: it is per-canvas texture-loading state, not a renderer setting, so a sharing canvas keeps its own. `primary` and `share` are read when the canvas creates its renderer. Changing them on a mounted canvas has no effect until it remounts. ### Several primaries Give each primary an `id`, and point the other canvases at one with `share`: ```tsx ... ... ... ... ``` Automatic sharing only works with a single primary: with several mounted, a canvas without `share` throws, asking you to pick one. Two canvases marked `primary` without distinct ids throw too; the first keeps the renderer. `share="id"` waits for that primary to mount, so it can appear anywhere in the tree or in a later commit. It times out after 5 seconds with an error naming the missing ``. ## Timing Sharing is decided once, when a canvas creates its renderer, so it matters what is mounted by then: - **Same commit, any order.** A primary announces itself as soon as it mounts, before any canvas in that commit creates a renderer. A canvas placed before its primary in the tree still waits for it and shares. - **Primary first.** A canvas mounting later shares it straight away. - **Primary later.** Canvases that already created their own renderer keep it: they are not adopted after the fact. The primary logs one development warning when this happens. Mount the primary with or before the canvases that share it, or give them `share="id"` so they wait for it. StrictMode, Fast Refresh (of your components or of R3F itself) and HMR keep the same renderer and the same sharing; they don't create a second renderer or leave canvases waiting. When the primary unmounts first, its sharing canvases keep rendering: each holds a lease on the renderer, which is disposed with the last canvas using it. A remounted primary creates a new renderer; canvases that were sharing the old one keep it until they remount, and canvases mounting afterwards share the new one. ## Scheduling A sharing canvas renders after its primary by default. Order it yourself, or cap its frame rate, with the `scheduler` Canvas prop: ```tsx // Limit to 30fps (useful for HUDs that don't need full framerate); still after the primary // Render before the primary instead ``` `before` / `after` take Canvas `id`s and replace the default ordering. `scheduler` is a Canvas prop on every entry, not only for multi-canvas; see [Frame Loop](../frame-loop#ordering-canvas-roots). | Option | Type | Description | | -------- | -------------------- | --------------------------------------- | | `after` | `string \| string[]` | Render after other canvases (by `id`) | | `before` | `string \| string[]` | Render before other canvases (by `id`) | | `order` | `number` | Numeric root order; lower runs first | | `fps` | `number` | Limit this canvas's default render rate | ## Accessing the Primary Scene (HUD Pattern) A secondary canvas can render the primary's scene from a different viewpoint using `primaryStore` from R3F state. This is the basis for HUDs and picture-in-picture. ```tsx import { Canvas, useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import * as THREE from 'three/webgpu' function HudScene() { const orthoCamera = useRef(null!) useEffect(() => { const cam = new THREE.OrthographicCamera(-3, 3, 3, -3, 0.1, 100) cam.position.set(0, 10, 0) // Above the scene cam.lookAt(0, 0, 0) cam.updateProjectionMatrix() orthoCamera.current = cam }, []) // Take over rendering to draw the primary's scene with our own camera useFrame( ({ primaryStore, renderer }) => { if (!orthoCamera.current) return const primaryState = primaryStore.getState() renderer.render(primaryState.scene, orthoCamera.current) }, { phase: 'render' }, ) return null } function App() { return ( <> {/* Main 3D scene: owns the renderer */} {/* HUD overlay - top-down view of the same scene, sharing the renderer */}
) } ``` ## How It Works Each canvas maintains its own scene graph and camera, event handling, and state (its own Zustand store). Secondary canvases share the `WebGPURenderer` instance and its GPU resources/context. Renderer-wide settings come from the primary, which owns the renderer: a secondary sharing it does not apply its own, so it cannot reset the primary's. The `primaryStore` on each canvas's state provides access to the primary's scene/camera, enabling advanced patterns like HUDs. TSL resources (uniforms, nodes, buffers, GPU storage) are shared automatically: they live on the primary's `RootState`, each secondary holds the same map objects, and portals inherit them from their parent, so `state.uniforms` (and the `@react-three/tsl` hooks) read the same set on every canvas and portal. R3F automatically manages `CanvasTarget` switching across canvases. A job in the scheduler's `start` phase sets the correct canvas target before any other jobs run for that root, so user render callbacks render to the right canvas automatically. ## WebGL2 Fallback When `navigator.gpu` is unavailable, three's `WebGPURenderer` silently falls back to its WebGL2 backend. A WebGL context is bound to the canvas element it was created on, so a renderer cannot be shared across canvases in that environment. A secondary whose primary fell back therefore creates its own renderer, forced onto the same WebGL2 backend (`forceWebGL`), and renders independently: scenes stay correct, at the cost of one GPU context per canvas instead of one shared. Such a fallback secondary owns its renderer, so it is a standalone root: it is not flagged `internal.isSecondary` (which marks a canvas borrowing another's renderer), it applies its own `renderer` settings (shadows included), and unmounting it disposes the renderer R3F built for it, whether or not the primary is still mounted. It does not inherit the primary's settings: set them on it too if they should match. `internal.sharedRendererFallback` records the fallback. GPU resources cannot cross GL contexts, so TSL resources stay local (`primaryStore` self-references, so the HUD pattern above sees the secondary's own scene), and a fallback secondary is never registered as a shareable primary for further canvases. ## API Reference **Canvas props:** | Prop | Type | Description | | ----------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `primary` | `boolean` | Own the renderer other canvases share. Not on `/legacy` | | `id` | `string` | Canvas id; the name `share="id"` and `scheduler` `before`/`after` refer to | | `share` | `string \| false` | `"id"`: share that primary's renderer. `false`: own a renderer even while a primary is mounted | | `scheduler` | `{ before, after, order, fps }` | Ordering and fps cap; a sharing canvas renders after its primary unless `before`/`after` is set | | `renderer` | props bag, instance or factory | Renderer settings, honored by the canvas that owns its renderer. An instance or factory opts out of sharing | **State properties:** | Property | Type | Description | | --------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------- | | `primaryStore` | `RootStore` | Zustand store of the primary canvas | | `internal.isSecondary` | `boolean` | Whether this canvas borrows another's renderer (not set for a WebGL2-fallback secondary, which owns its renderer) | | `internal.sharedRendererFallback` | `boolean` | Whether this secondary stopped sharing because the primary fell back to WebGL2 | | `internal.isMultiCanvas` | `boolean` | Whether multi-canvas mode is active | ## Related - [WebGPU Overview](./overview) - the `renderer` prop and entry points - [Frame Loop](../frame-loop) - the `scheduler` prop and root ordering - [Render Pipeline](./render-pipeline) - post-processing per canvas ]]>
({ wobble: sin(time.mul(2)), // change to mul(5), save, see the update })) const { uColor } = useUniforms({ uColor: '#ff0000', // change the color, save, see the update }) ``` ## What Rebuilds on HMR Every TSL hook re-runs when the HMR version is bumped: | Hook | Rebuilds on HMR | How | | ---------------------------- | --------------- | ----------------------------------- | | `useNodes` / `useLocalNodes` | Yes | Re-runs creator | | `useUniforms` / `useUniform` | Yes | Re-runs creator | | `useBuffers` | Yes | Re-runs creator | | `useGPUStorage` | Yes | Re-runs creator | | `useRenderPipeline` | Yes | Re-runs callbacks, keeps scene pass | `useLocalNodes` also rebuilds because it subscribes to the HMR version directly, alongside its `uniforms`/`nodes`/`textures` triggers - in every mode, including `useLocalNodes(creator, [])`. The dependency array only governs ordinary component renders (see [TSL Hooks](./tsl-hooks#dependency-array)). ## Rebuild Guarantees A rebuild — whether from a hot update or a manual `rebuild*` call — is **atomic** from a reader's point of view: - The shared maps are never emptied. Readers keep the previous generation of nodes/uniforms until the re-run creators commit the new one, then see a single swap from old-complete to new-complete. A reader can never observe an empty or half-rebuilt scope (which would strip `*Node` props off materials mid-reload). - Creators never write to the store during render. New entries are registered in React's commit phase, so a rebuild can't interrupt other components mid-render. - Reuse is generation-aware. After an invalidation, previously cached entries can't be re-adopted into the new generation — every creator produces fresh resources, no matter how re-runs interleave (StrictMode double-invokes, multiple canvases sharing a `primaryStore`, and so on). Two consequences worth knowing: - **Removed creators leave their last generation in place.** If an edit deletes a creator component (or a whole scope), the old entries stay visible to readers until something re-registers the scope or you call `clearNodes`/`removeNodes`. During a hot reload, last-good data beats an empty scene. - **Captured references go stale across rebuilds.** A rebuild replaces uniform/node objects. Code that reads through the hooks each render picks up the new generation automatically, but a long-lived closure that captured a uniform object directly (for example a `useFrame` callback or an external library callback holding `uTime` from an old generation) keeps writing to the replaced object. Prefer reading through the hook, or re-capture inside the component so the closure refreshes with the rebuild. ## Effects Must Be Re-Run Safe Fast Refresh re-runs your components' effects on every hot update, while R3F's reconciler correctly keeps the underlying three.js objects alive. An effect that mutates a reconciler-managed object must therefore be idempotent: ```tsx // ❌ accumulates 90° per hot update — after one edit the plane faces away useEffect(() => { geometryRef.current?.rotateX(-Math.PI / 2) }, []) // ✅ guard per instance, safe to re-run useEffect(() => { const geometry = geometryRef.current if (geometry && !geometry.userData.rotated) { geometry.userData.rotated = true geometry.rotateX(-Math.PI / 2) } }, []) ``` Where possible, prefer declarative props (`rotation` on the mesh) or bake transforms at creation time instead of mutating in effects. ## useRenderPipeline on HMR `useRenderPipeline` re-runs its `setupCB`/`mainCB` after every hot update in the app, once the other TSL hooks have registered their fresh generation, so the graph picks up both your edits and the new uniform objects. It keeps the pipeline and scene pass and replaces only the graph built on them. The remount Fast Refresh does on the owning component keeps the pipeline too; a reset of that component (after adding or removing a hook, or `// @refresh reset`) builds a new pipeline and frees the old one. To re-run the callbacks yourself, against a fresh scene pass, call `rebuild()`: ```tsx function RebuildButton() { const { rebuild } = useRenderPipeline() return } ``` For values that change at runtime, drive them through uniforms rather than closure values. See [Render Pipeline](./render-pipeline#hmr). ## Disabling HMR If you need to turn off automatic refresh (rare), use the `hmr` prop: ```tsx {/* HMR won't auto-refresh TSL state */} ``` ## Manual Rebuild Each creator hook returns a `rebuild*` util that invalidates its cache and bumps the HMR version to force re-creation (with the same atomic-swap guarantees as automatic HMR). Pass a scope name to rebuild a single scope, `'root'` for root only, or no argument for everything. Other scopes are untouched — their entries keep their identity. ```tsx const { rebuildNodes } = useNodes(() => ({ wobble: sin(time.mul(2)) })) const { rebuildUniforms } = useUniforms({ uColor: '#ff0000' }) const { rebuildBuffers } = useBuffers(() => ({ positions: instancedArray(count, 'vec3') })) const { rebuildStorage } = useGPUStorage(() => ({ heightMap: new StorageTexture(512, 512) })) rebuildNodes() // all nodes rebuildNodes('player') // only the 'player' scope rebuildUniforms() rebuildBuffers('particles') rebuildStorage() ``` ## Global Rebuild Functions For custom HMR handlers or dev tooling, standalone rebuild functions are exported. They require the R3F store reference: ```tsx import { rebuildAllNodes, rebuildAllUniforms, rebuildAllBuffers, rebuildAllStorage } from '@react-three/tsl' rebuildAllNodes(store) rebuildAllUniforms(store) rebuildAllBuffers(store) rebuildAllStorage(store) ``` ## Reader Reactivity Reader-mode hooks subscribe to store changes, so they update reactively when nodes/uniforms change elsewhere (including after an HMR-triggered rebuild): ```tsx const allNodes = useNodes() const playerNodes = useNodes('player') const allUniforms = useUniforms() const playerUniforms = useUniforms('player') ``` ## Related - [TSL Hooks](./tsl-hooks) - the `rebuild*` utils on each hook - [Render Pipeline](./render-pipeline) - why pipeline callbacks skip HMR - [Compute (Buffers & Storage)](./compute) - rebuild for buffers and storage ]]> { console.log(inView ? 'Object entered view' : 'Object left view') }}> ) } ``` Useful for pausing animations off-screen, lazy-loading heavy resources, or analytics on what users actually see. ### onOccluded — occlusion queries (WebGPU only) Fires when the object becomes occluded or revealed, based on GPU occlusion queries. This only works with the WebGPU renderer. ```tsx function OcclusionAwareObject() { return ( { console.log(occluded ? 'Object is hidden behind something' : 'Object is visible') }}> ) } ``` When you attach `onOccluded` (or `onVisible`), R3F automatically sets `occlusionTest = true` on the object and enables the occlusion system for the canvas. On a WebGL renderer a one-time warning is logged and the occlusion events simply never fire. ### onVisible — combined visibility Fires when the _combined_ visibility state changes. An object is considered visible when **all** of the following are true: - it is within the camera frustum, **and** - it is not occluded (WebGPU only — on WebGL it is always treated as not occluded), **and** - its `visible` property is `true`. ```tsx function FullyVisibleObject() { return ( { if (visible) { // start expensive animations, load high-res textures, etc. } else { // pause animations, reduce quality, etc. } }}> ) } ``` ## The camera frustum R3F keeps a `THREE.Frustum` on the root state, synchronized with the default camera. The visibility events above use it internally, but you can read it directly from `useThree` for custom logic. ```tsx import { useThree } from '@react-three/fiber' function VisibilityCheck() { const frustum = useThree((state) => state.frustum) // point test if (frustum.containsPoint(new THREE.Vector3(0, 5, 0))) { // point is in view } // object test (bounding sphere) if (frustum.intersectsObject(mesh)) { // object is at least partially visible } // sphere test if (frustum.intersectsSphere(boundingSphere)) { // sphere intersects frustum } } ``` ### In the frame loop The frustum is updated automatically each frame — after all `update`-phase callbacks and before `render` — so it is safe to read inside `useFrame`: ```tsx useFrame(({ frustum }) => { for (const object of objects) { object.visible = frustum.intersectsObject(object) } }) ``` ## autoUpdateFrustum By default R3F refreshes the frustum every frame. If you don't need it, or want to drive updates yourself, disable it on the `Canvas`: ```tsx ``` | Canvas prop | Type | Default | Description | | ------------------- | --------- | ------- | ---------------------------------------- | | `autoUpdateFrustum` | `boolean` | `true` | Enable/disable automatic frustum updates | The matching `autoUpdateFrustum` and `frustum` values are also available on the root state via `useThree`. ### Manual frustum updates The `updateFrustum` utility is exported for manual control. Passing an existing frustum updates it in place (no allocation); omitting it returns a new one. ```tsx import { updateFrustum } from '@react-three/fiber' // signature: updateFrustum(camera, frustum?) => THREE.Frustum // update an existing frustum in place updateFrustum(camera, existingFrustum) // create a new frustum from a camera const newFrustum = updateFrustum(camera) // works with any camera — e.g. a shadow or portal camera updateFrustum(light.shadow.camera, shadowFrustum) ``` ## Performance & implementation notes - Events fire on state changes only, not every frame. - Frustum checks rely on bounding spheres; geometry without a computed `boundingSphere` is computed on first check. - The visibility scan runs in the `preRender` phase, after frustum updates. - Objects are unregistered automatically when they unmount. - When occlusion is enabled, R3F adds an invisible internal helper group named `__r3fInternal` to the scene, containing a tiny observer mesh that caches `isOccluded()` results during the render pass (WebGPU's `isOccluded()` only returns valid results while rendering). Both are invisible, carry a `__r3fInternal` flag, and are cleaned up on unmount — relevant only if you are counting scene children. ]]> { const cam = new THREE.PerspectiveCamera() someGroup.add(cam) // you parented it yourself return cam }, []) // camera stays parented to someGroup — R3F won't move it return {/* ... */} } ``` ## Parenting objects under the camera You can imperatively add to the camera, but remember to clean up: ```tsx function HUD() { const { camera } = useThree() const hudRef = useRef(null) useEffect(() => { const hud = hudRef.current if (!hud) return camera.add(hud) return () => void camera.remove(hud) }, [camera]) return ( ) } ``` The declarative `Portal` component below is usually cleaner. ## The Portal component `Portal` renders its children into any `Object3D` container — the camera, a group, or any other scene object — and handles mounting and cleanup for you. ```tsx import { Portal, useThree } from '@react-three/fiber' function CameraHeadlights() { const { camera } = useThree() return ( ) } ``` `container` accepts an `Object3D` directly or a ref to one. Children are added on mount and disposed on unmount, including internal subscriptions. ## injectScene — how children attach to the container This is the important detail to get right. By default `Portal` does **not** add your children directly to the container. It inserts a fresh `THREE.Scene` _under_ the container and renders the children into that scene. So when you ``, your objects become **grand-children** of the camera: ``` camera → (injected THREE.Scene) → your children ``` This default exists so that `state.scene` inside the portal is always a real `THREE.Scene`, which means scene-only properties like `background`, `environment`, and `fog` keep working inside the portal ([#2725](https://github.com/pmndrs/react-three-fiber/issues/2725)). The injection is controlled by `injectScene`, which defaults to `true` and is passed through the Portal's `state` prop: ```tsx // default — injectScene is true {/* rendered into an injected THREE.Scene that is a child of the camera */} ``` > Note: if the `container` you pass is itself already a `THREE.Scene`, no extra scene is injected — the container is used directly. ### injectScene= — direct camera children When you specifically need your objects to be _direct_ children of the camera (for example a light whose transform must be exactly the camera's, with no intermediate node), set `injectScene: false` in the portal `state`: ```tsx {/* rendered directly as children of the camera — no injected scene */} ``` With `injectScene={false}` the hierarchy is simply: ``` camera → your children ``` This is treated as an edge-case "anti-pattern" in the source because the portal's `state.scene` is then the container itself rather than a real `THREE.Scene` (so scene-level properties like `background`/`environment`/`fog` won't apply). Use it only when you genuinely need direct parenting. > In practice the default `injectScene={true}` is what you want for HUDs and overlays — the extra scene node is harmless for transforms since it sits at the camera's origin. Reach for `injectScene={false}` only when the intermediate scene is a problem. ]]> { renderer.setRenderTarget(fbo) renderer.render(scene, camera) renderer.setRenderTarget(null) }) return ( ) } ``` ## Argument forms The hook accepts several patterns. With no size, it uses the canvas dimensions (and resizes with the canvas). ```tsx // canvas size (resizes with canvas) const fbo = useRenderTarget() // canvas size with options const fbo = useRenderTarget({ samples: 4 }) // square render target (512×512) const fbo = useRenderTarget(512) // square render target with options const fbo = useRenderTarget(512, { depthBuffer: true }) // explicit dimensions const fbo = useRenderTarget(512, 256) // explicit dimensions with options const fbo = useRenderTarget(512, 256, { samples: 4 }) ``` ### Signatures ```ts // canvas size (defaults to canvas dimensions) useRenderTarget(options?: RenderTargetOptions): R3FRenderTarget // square render target useRenderTarget(size: number, options?: RenderTargetOptions): R3FRenderTarget // explicit dimensions useRenderTarget(width: number, height: number, options?: RenderTargetOptions): R3FRenderTarget ``` The return type `R3FRenderTarget` is `WebGLRenderTarget | RenderTarget` on `@react-three/fiber`, and narrows to the one class once the app registers its renderer or imports from a single-renderer entry (see [Per-renderer type](#per-renderer-type) below). ## Options | Option | Type | Default | Description | | ----------------- | --------- | ------------------ | ------------------------- | | `depthBuffer` | `boolean` | `true` | Create depth buffer | | `stencilBuffer` | `boolean` | `false` | Create stencil buffer | | `samples` | `number` | `0` | MSAA sample count | | `count` | `number` | `1` | MRT target count (WebGPU) | | `format` | `number` | `RGBAFormat` | Texture format | | `type` | `number` | `UnsignedByteType` | Data type | | `minFilter` | `number` | `LinearFilter` | Minification filter | | `magFilter` | `number` | `LinearFilter` | Magnification filter | | `generateMipmaps` | `boolean` | `false` | Generate mipmaps | ## Per-renderer type The class you get back follows the renderer the Canvas loaded: a `WebGLRenderTarget` on WebGL, a `RenderTarget` on WebGPU. The hook constructs the target class carried by the root's renderer support (`state.internal.support.RenderTarget`), so it is always valid for the renderer in use without branching yourself. Only the declared type differs: - **`@react-three/fiber`**: `WebGLRenderTarget | RenderTarget` (either renderer can be running). Register the renderer once and it narrows, with no cast, along with `state.renderer` and the rest (see [Typing the renderer](../API/typescript#typing-the-renderer)): ```ts declare module '@react-three/fiber' { interface Register { renderer: 'webgpu' // or 'webgl' } } ``` - **`@react-three/fiber/legacy`**: `WebGLRenderTarget`. - **`@react-three/fiber/webgpu`** (deprecated): `RenderTarget`. ]]> ``` ### Triggering manual frames One major caveat is that if anything in the tree _mutates_ props, then React cannot be aware of it and the display would be stale. For instance, camera controls just grab into the camera and mutate its values. Here you can use React Three Fiber's `invalidate` function to trigger frames manually. ```jsx function Controls() { const orbitControlsRef = useRef() const { invalidate, camera, renderer } = useThree() useEffect(() => { orbitControlsRef.current.addEventListener('change', invalidate) return () => orbitControlsRef.current.removeEventListener('change', invalidate) }, []) return ``` > \[!NOTE] Drei's controls do this automatically for you. Generally you can call invalidate whenever you need to render: ```jsx invalidate() ``` > \[!IMPORTANT] Calling `invalidate()` will not render immediately, it merely requests a new frame to be rendered out. Calling invalidate multiple times will not render multiple times. Think of it as a flag to tell the system that something has changed. ### Sync animations with on-demand-rendering and invalidate Since `invalidate()` is only a flag that schedules render, you might bump into syncing issues when you run animations that are synchronous (as in, they start immediately). By the time fiber renders the first frame the animation has already progressed which leads to a visible jump. In such cases you should pre-emptively schedule a render and then start the animation in the next frame. ```jsx { // Pre-emptively schedule a render invalidate() // Wait for the next frame to start the animation requestAnimationFrame(() => controls.dolly(1, true)) }} ``` ## Re-using geometries and materials Each geometry and material means additional overhead for the GPU. You should try to re-use resources if you know they will repeat. You could do this globally: ```jsx const red = new THREE.MeshLambertMaterial({ color: "red" }) const sphere = new THREE.SphereGeometry(1, 28, 28) function Scene() { return ( <> ``` If you create a material or color in global space - outside of React Three Fiber's `Canvas` context - you should enable [ColorManagement](https://threejs.org/docs/#manual/en/introduction/Color-management) in three.js. This will allow certain conversions (for hexadecimal and CSS colors in sRGB) to be made automatically, producing correct colors in all cases. ```jsx import * as THREE from 'three' // r150 THREE.ColorManagement.enabled = true // r139-r149 THREE.ColorManagement.legacyMode = false ``` ### Caching with `useLoader` > \[!NOTE] Every resource that is loaded with useLoader is cached automatically! If you access a resource via useLoader with the same URL, throughout the component tree, then you will always refer to the same asset and thereby re-use it. This is especially useful if you run your GLTF assets through [GLTFJSX](https://github.com/pmndrs/gltfjsx) because it links up geometries and materials and thereby creates re-usable models. [CodeSandbox dix1y](https://codesandbox.io/s/dix1y) ```jsx function Shoe(props) { const { nodes, materials } = useLoader(GLTFLoader, "/shoe.glb") return ( ) } ``` ## Instancing Each mesh is a draw call, you should be mindful of how many of these you employ: no more than 1000 as the very maximum, and optimally a few hundred or less. You can win performance back by reducing draw calls, for example by instancing repeating objects. This way you can have hundreds of thousands of objects in a single draw call. [CodeSandbox h873k](https://codesandbox.io/s/h873k) Setting up instancing is not so hard, consult [the three.js docs](https://threejs.org/docs/#api/en/objects/InstancedMesh) if you need help. ```jsx function Instances({ count = 100000, temp = new THREE.Object3D() }) { const instancedMeshRef = useRef() useEffect(() => { // Set positions for (let i = 0; i < count; i++) { temp.position.set(Math.random(), Math.random(), Math.random()) temp.updateMatrix() instancedMeshRef.current.setMatrixAt(i, temp.matrix) } // Update the instance instancedMeshRef.current.instanceMatrix.needsUpdate = true }, []) return ( ) } ``` ## Level of detail Sometimes it can be beneficial to reduce the quality of an object the further it is away from the camera. Why would you display it full resolution if it is barely visible. This can be a good strategy to reduce the overall vertex-count which means less work for the GPU. Scroll in and out to see the effect: [CodeSandbox 12nmp](https://codesandbox.io/s/12nmp) There is a small component in Drei called `` which sets up LOD without boilerplate. You load or prepare a couple of resolution stages, as many as you like, and then give them the same amount of distances from the camera, starting from highest quality to lowest. ```jsx import { Detailed, useGLTF } from '@react-three/drei' function Model() { const [low, mid, high] = useGLTF(["/low.glb", "/mid.glb", "/high.glb"]) return ( ) } ``` ## Nested loading Nested loading means that lesser textures and models are loaded first, higher-resolution later. The following sandbox goes through three loading stages: - A loading indicator - Low quality - High quality [CodeSandbox 7duy8](https://codesandbox.io/s/7duy8) And this is how easy it is to achieve it, you can nest suspense and even use it as a fallback: ```jsx function App() { return ( loading...}> }> ) } function Model({ url }) { const { scene } = useGLTF(url) return } ``` ## Performance monitoring Drei has a new component [PerformanceMonitor](https://github.com/pmndrs/drei#performancemonitor) that allows you to monitor, and adapt to, device performance. This component will collect the average fps (frames per second) over time. If after a couple of iterations the averages are below or above a threshold it will trigger onIncline and onDecline callbacks that allow you to respond. Typically you would reduce the quality of your scene, the resolution, effects, the amount of stuff to render, or, increase it if you have enough framerate to fill. Since this would normally cause ping-ponging between the two callbacks you define upper and lower framerate bounds, as long as you stay within that margin nothing will trigger. Ideally your app should find its way into that margin by gradually altering quality. A simple example for regulating the resolution. It starts out with 1.5, if the system falls below the bounds it goes to 1, if it's fast enough it goes to 2. ```jsx function App() { const [dpr, setDpr] = useState(1.5) return ( setDpr(2)} onDecline={() => setDpr(1)} > ``` You can also use the onChange callback to get notified when the average changes in whichever direction. This allows you to make gradual changes. It gives you a factor between 0 and 1, which is increased by incline and decreased by decline. The factor is initially 0.5 by default. ```jsx import round from 'lodash/round' const [dpr, setDpr] = useState(1) return ( setDpr(round(0.5 + 1.5 * factor, 1))}> ``` If you still experience flip flops despite the bounds you can define a limit of flipflops. If it is met onFallback will be triggered which typically sets a lowest possible baseline for the app. After the fallback has been called PerformanceMonitor will shut down. ```jsx setDpr(1)}> ``` PerformanceMonitor can also have children, if you wrap your app in it you get to use usePerformanceMonitor which allows individual components down the nested tree to respond to performance changes on their own. ```jsx ; function Effects() { usePerformanceMonitor({ onIncline, onDecline, onFallback, onChange }) // ... } ``` ## Movement regression Websites like Sketchfab make sure the scene is always fluid, running at 60 fps, and responsive, no matter which device is being used or how expensive a loaded model is. They do this by regressing movement, where effects, textures, shadows will slightly reduce quality until still-stand The following sandbox uses expensive lights and post-processing. In order for it to run relatively smooth it will scale the pixel ratio on movement and also skip heavy post-processing effects like ambient occlusion. [CodeSandbox pz0q6](https://codesandbox.io/s/pz0q6) When you inspect the state model you will notice an object called `performance`. ```jsx performance: { current: 1, min: 0.1, max: 1, debounce: 200, regress: () => void, }, ``` - `current`: Performance factor alternates between min and max - `min`: Performance lower bound (should be less than 1) - `max`: Performance upper bound (no higher than 1) - `debounce`: Debounce timeout until it goes to upper bound (1) again - `regress()`: Function that temporarily regresses performance You can define defaults like so: ```jsx ... ``` ### This is how you can put the system into regression The only thing you have to do is call `regress()`. When exactly you do that, that is up to you, but it could be when the mouse moves, or the scene is moving, for instance when controls fire their change-event. Say you are using controls, then the following code puts the system in regress when they are active: ```jsx const regress = useThree((state) => state.performance.regress) useEffect(() => { controls.current?.addEventListener('change', regress) ``` ### This is how you can respond to it > \[!NOTE] Mere calls to `regress()` will not change or affect anything! Your app has to opt into performance scaling by listening to the performance `current`! The number itself will tell you what to do. 1 (max) means everything is ok, the default. Less than 1 (min) means a regression is requested and the number itself tells you how far you should go when scaling down. For instance, you could simply multiply `current` with the pixel ratio to cut down on resolution. If you have defined `min: 0.5` that would mean it will half the resolution for at least 200ms (delay) when regress is called. It can be used for anything else, too: switching off lights when `current < 1`, using lower-res textures, skip post-processing effects, etc. You could of course also animate/lerp these changes. Here is a small prototype component that scales the pixel ratio: ```jsx function AdaptivePixelRatio() { const current = useThree((state) => state.performance.current) const setPixelRatio = useThree((state) => state.setDpr) useEffect(() => { setPixelRatio(window.devicePixelRatio * current) }, [current]) return null } ``` Drop this component into the scene, combine it with the code above that calls `regress()`, and you have adaptive resolution: ```jsx ``` There are pre-made components for this already in the [Drei library](https://github.com/pmndrs/drei/#performance). ## Enable concurrency React 18 introduces concurrent scheduling, specifically time slicing via `startTransition` and `useTransition`. This will virtualize the component graph, which then allows you to prioritise components and actions. Think of how a virtual list avoids scaling issues because it only renders as many items as the screen can take, it is not affected by the amount of items it has to render, be it 10 or 100.000.000. React 18 functions very similar to this, it can potentially defer load and heavy tasks in ways that would be hard or impossible to achieve in a vanilla application. It thereby holds on to a stable framerate even in the most demanding situations. The following benchmark shows how powerful concurrency can be: It simulates heavy load by creating hundreds of THREE.TextGeometry instances (510 to be exact). This class, like many others in three.js, is expensive and takes a while to construct. If all 510 instances are created the same time **it will cause approximately 1.5 seconds of pure jank** (Apple M1), the tab would normally freeze. It runs in an interval and **will execute every 2 seconds**. | | Distributed | At-once | | -------- | ----------- | ------- | | three.js | \~20fps | \~5fps | | React | \~60fps | \~60fps | ![](https://github.com/drcmda/scheduler-test/raw/master/assets/three-distributed.jpg)![](https://github.com/drcmda/scheduler-test/raw/master/assets/three-at-once.jpg)![](https://github.com/drcmda/scheduler-test/raw/master/assets/react-distributed.jpg)![](https://github.com/drcmda/scheduler-test/raw/master/assets/react-at-once.jpg) For more on how to use this API, see [use startTransition for expensive ops](/next/advanced/pitfalls#%E2%9C%85-use-starttransition-for-expensive-ops). ]]> The most important gotcha in three.js is that creating objects can be expensive, think twice before you mount/unmount things! Every material or light that you put into the scene has to compile, every geometry you create will be processed. Share materials and geometries if you can, either in global scope or locally: ```jsx const geom = useMemo(() => new BoxGeometry(), []) const mat = useMemo(() => new MeshBasicMaterial(), []) return items.map(i => { const interval = setInterval(() => setX((x) => x + 0.1), 1) return () => clearInterval(interval) }, []) ``` ### ❌ `setState` in useFrame is bad ```jsx const [x, setX] = useState(0) useFrame(() => setX((x) => x + 0.1)) return ``` ### ❌ `setState` in fast events is bad ```jsx setX((x) => e.point.x)} /> ``` ### ✅ Instead, just mutate, use deltas In general you should prefer useFrame. Consider mutating props safe as long as the component is the only entity that mutates. Use deltas instead of fixed values so that your app is refresh-rate independent and runs at the same speed everywhere! ```jsx const meshRef = useRef() useFrame((state, delta) => (meshRef.current.position.x += delta)) return ``` Same goes for events, use references. ```jsx (ref.current.position.x = e.point.x)} /> ``` If you must use intervals, use references as well, but keep in mind that this is not refresh-rate independent. ```jsx useEffect(() => { const interval = setInterval(() => ref.current.position.x += 0.1, 1) return () => clearInterval(interval) }, []) ``` ## Handle animations in loops The frame loop is where you should place your animations. For instance using lerp, or damp. ### ✅ Use `lerp` + `useFrame` ```jsx function Signal({ active }) { const meshRef = useRef() useFrame((state, delta) => { meshRef.current.position.x = THREE.MathUtils.lerp(meshRef.current.position.x, active ? 100 : 0, 0.1) }) return ``` ### ✅ Or react-spring Or, use animation libraries. React-spring has its own frame-loop and animates outside of React. Framer-motion is another popular alternative. ```jsx import { a, useSpring } from '@react-spring/three' function Signal({ active }) { const { x } = useSpring({ x: active ? 100 : 0 }) return ``` ## Do not bind to fast state reactively Using state-managers and selective state is fine, but not for updates that happen rapidly for the same reason as above. ### ❌ Don't bind reactive fast-state ```jsx import { useSelector } from 'react-redux' // Assuming that x gets animated inside the store 60fps const x = useSelector((state) => state.x) return ``` ### ✅ Fetch state directly For instance using [Zustand](https://github.com/pmndrs/zustand) (same in Redux et al). ```jsx useFrame(() => (ref.current.position.x = api.getState().x)) return ``` ## Don't mount indiscriminately In threejs it is very common to not re-mount at all, see the ["disposing of things"](https://discoverthreejs.com/tips-and-tricks/) section in discover-three. This is because buffers and materials get re-initialized/compiled, which can be expensive. ### ❌ Avoid mounting runtime ```jsx { stage === 1 && } { stage === 2 && } { stage === 3 && } ``` ### ✅ Consider using visibility instead ```jsx function Stage1(props) { return ( ... ``` ### ✅ Use `startTransition` for expensive ops React 18 introduces the `startTransition` and `useTransition` APIs to defer and schedule work and state updates. Use these to de-prioritize expensive operations. Since version 8 of Fiber canvases use concurrent mode by default, which means React will schedule and defer expensive operations. You don't need to do anything, but you can play around with the [experimental scheduler](https://github.com/drcmda/scheduler-test) and see if marking ops with a lesser priority makes a difference. ```jsx import { useTransition } from 'react' import { Points } from '@react-three/drei' const [isPending, startTransition] = useTransition() const [radius, setRadius] = useState(1) const positions = calculatePositions(radius) const colors = calculateColors(radius) const sizes = calculateSizes(radius) { startTransition(() => { setRadius(prev => prev + 1) }) }} > ``` ## Don't re-create objects in loops Try to avoid creating too much effort for the garbage collector, re-pool objects when you can! ### ❌ Bad news for the GC This creates a new vector 60 times a second, which allocates memory and forces the GC to eventually kick in. ```jsx useFrame(() => { ref.current.position.lerp(new THREE.Vector3(x, y, z), 0.1) }) ``` ### ✅ Better re-use object Set up re-used objects in global or local space, now the GC will be silent. ```jsx function Foo(props) const vec = new THREE.Vector() useFrame(() => { ref.current.position.lerp(vec.set(x, y, z), 0.1) }) ``` ## `useLoader` instead of plain loaders Threejs loaders give you the ability to load async assets (models, textures, etc), but if you do not re-use assets it can quickly become problematic. ### ❌ No re-use is bad for perf This re-fetches, re-parses for every component instance. ```jsx function Component() { const [texture, set] = useState() useEffect(() => void new TextureLoader().load(url, set), []) return texture ? ( ) : null } ``` Instead use useLoader, which caches assets and makes them available throughout the scene. ### ✅ Cache and re-use objects ```jsx function Component() { const texture = useLoader(TextureLoader, url) return ( ) } ``` Regarding GLTF's try to use [GLTFJSX](https://github.com/pmndrs/gltfjsx) as much as you can, this will create immutable JSX graphs which allow you to even re-use full models. ]]> \[!TIP] Looking to upgrade to the latest version? See the [v10 Migration Guide](/next/migration/v10) for WebGPU support and the new scheduler. This is a compatibility release for React 19, which brings further performance, stability, and type improvements. You can check out the React 19 changelog [here](https://react.dev/blog/2024/04/25/react-19). We would like to express our gratitude to the community for their continued support, as well as to all our contributors, including the React team at Meta and Vercel, for ensuring this upgrade went smoothly. 🎉 > \[!WARNING]\ > This release contains breaking changes when using Strict Mode, which can highlight bugs during development. See [StrictMode](#strictmode). ## Features ### useLoader Accepts Loader Instance `useLoader` now supports re-use of external loader instances for more controlled pooling and setup. ```jsx import { GLTFLoader } from 'three/addons' import { useLoader } from '@react-three/fiber' function Model() { const gltf = useLoader(GLTFLoader, '/path/to/model.glb') // ... } // or, const loader = new GLTFLoader() function Model() { const gltf = useLoader(loader, '/path/to/model.glb') // ... } ``` ### Factory extend Signature `extend` can now produce a component when a three.js class is passed to it individually instead of a catalog of named classes. This is backwards compatible and reduces TypeScript boilerplate and JSX collisions. We recommend libraries migrate to this signature so internal components don't clash with user-land declarations. ```tsx import { OrbitControls } from 'three/addons' import { type ThreeElement, type ThreeElements } from '@react-three/fiber' declare module '@react-three/fiber' { interface ThreeElements { orbitControls: ThreeElement } } extend({ OrbitControls }) // or, const Controls = extend(OrbitControls) ``` ### Async GL prop The Canvas GL prop accepts constructor parameters, properties, or a renderer instance via a callback. The callback now passes constructor parameters instead of just a canvas reference. ```diff new WebGLRenderer({ canvas })} + gl={(props) => new WebGLRenderer(props)} > ``` Further, a callback passed to GL can now return a promise for async constructors like `WebGPURenderer` (see [WebGPU](#webgpu)). ```tsx { // ... return renderer }} > ``` ## WebGPU > **v10:** this section describes v9. In v10 `` from `@react-three/fiber` renders with `WebGPURenderer` by default, with node materials as JSX and no `extend(THREE)` call; `WebGLRenderer` is on `@react-three/fiber/legacy`. See the [WebGPU overview](../webgpu/overview) and the [v10 migration guide](../migration/v10). Recent Three.js now includes a WebGPU renderer. While still a work in progress and not fully backward-compatible with all of Three's features, the renderer requires an async initialization method. R3F streamlines this by allowing the gl prop to return a promise. ```tsx import * as THREE from 'three/webgpu' import * as TSL from 'three/tsl' import { Canvas, extend, useFrame, useThree } from '@react-three/fiber' declare module '@react-three/fiber' { interface ThreeElements extends ThreeToJSXElements {} } extend(THREE) export default () => ( { const renderer = new THREE.WebGPURenderer(props as any) await renderer.init() return renderer }}> ) ``` ## Fixes ### Color Management of Textures Automatic sRGB conversion of texture props has been removed. Color textures are now handled automatically for built-in materials, aligning with vanilla Three.js behavior. This prevents issues where data textures (e.g., normals or displacement) become corrupted or non-linear. For custom materials or shaders, annotate color textures with `texture.colorSpace = THREE.SRGBColorSpace` or `texture-colorSpace={THREE.SRGBColorSpace}` in JSX. For more details, see . ### Suspense and Side-Effects The handling of Suspense and fallback content has improved in React and R3F. Side-effects like attach and constructor effects (e.g., controls adding event listeners) no longer fire repeatedly without proper cleanup during suspension. ```jsx import { ThreeElement, useThree } from '@react-three/fiber' import { OrbitControls } from 'three/addons' declare module '@react-three/fiber' { interface ThreeElements { OrbitControls: ThreeElement } } extend({ OrbitControls }) function Controls() { const camera = useThree((state) => state.camera) const gl = useThree((state) => state.gl) // Will only initialize when tree is connected to screen return } ``` ### Swapping with args and primitives Swapping elements when changing the `args` or primitive `object` prop has been improved for structured children like arrays or iterators (React supports both, including async iterators). Previously, primitives sharing an object could update out of order or be removed from the scene along with their children. See: ## TypeScript Changes ### Props renamed to CanvasProps Canvas `Props` is now called `CanvasProps` for clarity. These were aliased in v8 for forward compatibility, but `Props` is removed with v9. ```diff -function Canvas(props: Props) +function Canvas(props: CanvasProps) ``` ### Dynamic JSX Types Since R3F's creation, JSX types had to be maintained in accordance with additions to three core API. Although missing or future types could be ignored or resilient for forward and backwards compatibility, this was a major maintenance burden for us and those extending three. Furthermore, libraries which wrap or bind to the known catalog of elements (e.g. React Spring ``) had no way of knowing which elements belonged to a renderer. Since v8, we've added a catalog of known elements to a `ThreeElements` interface, and with v9 automatically map three API to JSX types. As types are now dynamically mapped, hardcoded exports like `MeshProps` have been removed, and can be accessed as `ThreeElements['mesh']`. Helper types like `Color` or `Vector3` remain to reflect the JSX `MathType` API for shorthand expression. ```diff -import { MeshProps } from '@react-three/fiber' -type Props = MeshProps +import { ThreeElements } from '@react-three/fiber' +type Props = ThreeElements['mesh'] ``` ### Node Helpers Specialized `Node` type helpers for extending JSX (`Node`, `Object3DNode`, `BufferGeometryNode`, `MaterialNode`, `LightNode`) are removed and combined into 'ThreeElement', which accepts a single type representing the extended element instance. ```tsx import { type ThreeElement } from '@react-three/fiber' declare module '@react-three/fiber' { interface ThreeElements { customElement: ThreeElement } } extend({ CustomElement }) ``` ### ThreeElements `ThreeElements` was added as an interface since v8.1.0 and is the current way of declaring or accessing JSX within R3F since React's deprecation of `global` JSX (see ). All JSX types belonging to R3F are accessible from `ThreeElements`. ```diff -import { type Node } from '@react-three/fiber' - -declare global { - namespace JSX { - interface IntrinsicElements { - customElement: Node - } - } -} - -extend({ CustomElement }) +import { type ThreeElement } from '@react-three/fiber' + +declare module '@react-three/fiber' { + interface ThreeElements { + customElement: ThreeElement + } +} + +extend({ CustomElement }) ``` ## Testing ### StrictMode `StrictMode` is now correctly inherited from a parent renderer like react-dom. Previously, `` mounted in another root such as react-dom would not affect the R3F canvas, so it had to be redeclared within the canvas as well. ```diff - - // ... - + // ... ``` Keep in mind, this change may affect the behavior of your application. If you encounter anything that worked before and fails now, profile it first in dev and then production. If it works in prod then strict mode has flushed out a side-effect in your code. ### Act `act` is now exported from React itself and can be used for all renderers. It will return the contents of a passed async callback like before and recursively flush async effects to synchronously test React output. ```tsx import { act } from 'react' import { createRoot } from '@react-three/fiber' const store = await act(async () => createRoot(canvas).render()) console.log(store.getState()) ``` ### Test Renderer Entry Points `@react-three/test-renderer` now supports multiple entry points matching the fiber package structure. Choose the entry that matches your application's fiber import: | Your Fiber Import | Test Renderer Import | | ---------------------------------------- | ------------------------------------------------ | | `@react-three/fiber` | `@react-three/test-renderer` | | `@react-three/fiber/legacy` | `@react-three/test-renderer/legacy` | | `@react-three/fiber/webgpu` (deprecated) | `@react-three/test-renderer/webgpu` (deprecated) | The default entry renders with WebGPU, like `@react-three/fiber`. Tests for a scene that imports `Canvas` from `/legacy` switch entry: ```diff -import ReactThreeTestRenderer from '@react-three/test-renderer' +import ReactThreeTestRenderer from '@react-three/test-renderer/legacy' ``` The TSL hooks come from `@react-three/tsl`, in tests as in app code: ```tsx import ReactThreeTestRenderer from '@react-three/test-renderer' import { useUniform, useUniforms, useNodes, useLocalNodes } from '@react-three/tsl' describe('WebGPU Hooks', () => { it('should test uniforms', async () => { let uniforms: any = null function TestComponent() { uniforms = useUniforms({ uTime: 0 }) return null } const renderer = await ReactThreeTestRenderer.create() expect(uniforms.uTime.value).toBe(0) await renderer.unmount() }) }) ``` For more details, see [Testing WebGPU and TSL](/next/api/testing#testing-webgpu-and-tsl). ]]> console.log('click')} onContextMenu={(e) => console.log('context menu')} onDoubleClick={(e) => console.log('double click')} onWheel={(e) => console.log('wheel spins')} onPointerUp={(e) => console.log('up')} onPointerDown={(e) => console.log('down')} onPointerOver={(e) => console.log('over')} onPointerOut={(e) => console.log('out')} onPointerEnter={(e) => console.log('enter')} onPointerLeave={(e) => console.log('leave')} onPointerMove={(e) => console.log('move')} onPointerMissed={() => console.log('missed')} /> ``` From this we can see that what we need to do is use the old `onClick` event we use on any DOM element to react to a user clicking the mesh. Let's add it then: ```jsx alert('Hellooo')}> ``` We did it! We created the most boring interaction in the story of 3D and we made an alert show up. Now let's make it actually animate our mesh. Let's start by setting some state to check if the mesh is active: ```jsx const [active, setActive] = useState(false) ``` After we have this we can set the scale with a ternary operator like so: ```jsx setActive(!active)}> ``` If you try to click on your mesh now, it scales up and down. We just made our first interactive 3D mesh! What we did in this chapter was: - Attached a click handler to our mesh - Added some state to track if the mesh is currently active - Changed the scale based on that state [CodeSandbox 98ppy](https://codesandbox.io/s/98ppy) **Exercises** - Change other props of the mesh like the `position` or even the `color` of the material. - Use `onPointerOver` and `onPointerOut` to change the props of the mesh on hover events. ## Next steps We just made our mesh react to user interaction but it looks pretty bland without any transition, right? In the next chapter let's integrate `react-spring` into our project to make this into an actual animation. ]]> All the models in this page were created by Sara Vieira and are freely available to download from any of the sandboxes. There are many types of 3D model extensions, in this page we will focus on loading the three most common ones: `GLTF`, `FBX` and `OBJ`. All of these will use the `useLoader` function but in slightly different ways. This whole section will assume you have placed your models in the public folder or in a place in your application where you can import them easily. ## Loading GLTF models Starting with the open standard and the one that has more support in React Three Fiber we will load a `.gltf` model. Let's start by importing the two things we need: ```js import { useLoader } from '@react-three/fiber' import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js' ``` With this we can create a Model component and place it in our scene like so: ```jsx function Scene() { const gltf = useLoader(GLTFLoader, '/Poimandres.gltf') return } ``` You can play with the sandbox and see how it looks here after I added an HDRI background: [CodeSandbox 6etx1](https://codesandbox.io/s/6etx1) ### Loading GLTF models as JSX Components Here comes the really fancy part, you can transform these models into React components and then use them as you would any React component. To do this, grab your `GLTF` model and head over to and drop your `GLTF`, after that you should see something like: ![gltfjsx](https://github.com/pmndrs/react-three-fiber/raw/v10/docs/tutorials/gltfjsx.png) Let's now copy the code and move it over to `Model.js`: ```jsx /* Auto-generated by: https://github.com/pmndrs/gltfjsx */ import React, { useRef } from 'react' import { useGLTF } from '@react-three/drei' export default function Model(props) { const groupRef = useRef() const { nodes, materials } = useGLTF('/Poimandres.gltf') return ( ) } useGLTF.preload('/Poimandres.gltf') ``` Now we can import our model like we would import any React component and use it in our app: ```jsx import { Suspense } from 'react' import { Canvas } from '@react-three/fiber' import { Environment } from '@react-three/drei' import Model from './Model' export default function App() { return (
) } ``` You can play with the sandbox here: [CodeSandbox vbnbf](https://codesandbox.io/s/vbnbf) ## Loading OBJ models In this case, we will use the trusted `useLoader` hook but in combination with `three.js` `OBJLoader`. ```js import { OBJLoader } from 'three/addons/loaders/OBJLoader.js' import { useLoader } from '@react-three/fiber' ``` With these imported let's get the mesh into our scene: ```jsx function Scene() { const obj = useLoader(OBJLoader, '/Poimandres.obj') return } ``` And here we go, we have an OBJ model showing on the web! Pretty cool ah? You can play with the sandbox here: [CodeSandbox 51zks](https://codesandbox.io/s/51zks) ## Loading FBX models Let's again use the trusted `useLoader` but this time with the `FBXLoader` that comes from `three.js` ```js import { useLoader } from '@react-three/fiber' import { FBXLoader } from 'three/addons/loaders/FBXLoader.js' ``` To create our scene we can get the FBX as a return value of `useLoader` by passing `FBXLoader` and the location of our file like so: ```jsx function Scene() { const fbx = useLoader(FBXLoader, '/Poimandres.fbx') return } ``` You can play with the sandbox here: [CodeSandbox ssrfg](https://codesandbox.io/s/ssrfg) ### Loading FBX models using `useFBX` [@react-three/drei](https://github.com/pmndrs/drei) exports a very useful helper when it comes to loading FBX models and it's called `useFBX`, in this case there is no need to import anything from `three.js` as it is all done behind the scenes and we can just pass the location of the file to `useFBX` like so: ```jsx function Scene() { const fbx = useFBX('/Poimandres.fbx') return } ``` You can play with the sandbox here: [CodeSandbox m6p73](https://codesandbox.io/s/m6p73) ## Showing a loader If your model is big and takes a while to load, it's always good to show a small loader of how much is already is loaded and again [@react-three/drei](https://github.com/pmndrs/drei) is here to help with `Html` and `useProgress`. - `Html` allows you place plain ol' HTML in your canvas and render it like you would a normal DOM element. - `useProgress` is a hook that gives you a bunch of information about the loading status of your model. With these two things, we can create a very bare-bones loading component like so: ```jsx import { Html, useProgress } from '@react-three/drei' function Loader() { const { progress } = useProgress() return {progress} % loaded } ``` We can then wrap our model in it using `Suspense` like so: ```jsx export default function App() { return ( }> ) } ``` The hook returns much more than just the progress so there is a lot you can do there to give the user more information about the loading status of the application. You can play with all of them in this sandbox: [CodeSandbox nn2m7](https://codesandbox.io/s/nn2m7) ]]>
All textures used in this chapter were downloaded from [ambientCG](https://ambientcg.com/) (formerly cc0textures). ## Using `TextureLoader` and `useLoader` To load the textures we will use the `TextureLoader` from three.js in combination with `useLoader` that will allow us to pass the location of the texture and get the map back. It's better to explain with code, let's say you downloaded [this texture](https://ambientcg.com/view?id=PavingStones092) and placed it in the public folder of your site, to get the color map from it you could do: ```js const colorMap = useLoader(TextureLoader, 'PavingStones092_1K_Color.jpg') ``` Let's then with this information create a small scene where we can use this texture: ```jsx import { Suspense } from 'react' import { Canvas, useLoader } from '@react-three/fiber' import { TextureLoader } from 'three' function Scene() { const colorMap = useLoader(TextureLoader, 'PavingStones092_1K_Color.jpg') return ( <> ) } export default function App() { return ( ) } ``` If everything went according to plan, you should now be able to apply this texture to the sphere like so: ```jsx ``` Awesome! That works but we have a lot more textures to import and do we have to create a different useLoader for each of them? That's the great part! You don't, the second argument is an array where you can pass all the textures you have and the maps will be returned and ready to use: ```js const [colorMap, displacementMap, normalMap, roughnessMap, aoMap] = useLoader(TextureLoader, [ 'PavingStones092_1K_Color.jpg', 'PavingStones092_1K_Displacement.jpg', 'PavingStones092_1K_Normal.jpg', 'PavingStones092_1K_Roughness.jpg', 'PavingStones092_1K_AmbientOcclusion.jpg', ]) ``` Now we can place them in our mesh like so: ```jsx ``` The displacement will probably be too much, usually setting it to 0.2 will make it look good. Our final code would look something like: ```jsx function Scene() { const [colorMap, displacementMap, normalMap, roughnessMap, aoMap] = useLoader(TextureLoader, [ 'PavingStones092_1K_Color.jpg', 'PavingStones092_1K_Displacement.jpg', 'PavingStones092_1K_Normal.jpg', 'PavingStones092_1K_Roughness.jpg', 'PavingStones092_1K_AmbientOcclusion.jpg', ]) return ( {/* Width and height segments for displacementMap */} ) } ``` ## Using `useTexture` In v10, `useTexture` is built into `@react-three/fiber`, so there is no need to import `TextureLoader` or install drei for this hook. The same textures can be loaded with: ```js import { useTexture } from '@react-three/fiber' ... const [colorMap, displacementMap, normalMap, roughnessMap, aoMap] = useTexture([ 'PavingStones092_1K_Color.jpg', 'PavingStones092_1K_Displacement.jpg', 'PavingStones092_1K_Normal.jpg', 'PavingStones092_1K_Roughness.jpg', 'PavingStones092_1K_AmbientOcclusion.jpg', ]) ``` The `useTexture` hook from [`@react-three/drei`](https://github.com/pmndrs/drei) still works, but is not required for v10. You can also use object-notation which is the most convenient: ```jsx const props = useTexture({ map: 'PavingStones092_1K_Color.jpg', displacementMap: 'PavingStones092_1K_Displacement.jpg', normalMap: 'PavingStones092_1K_Normal.jpg', roughnessMap: 'PavingStones092_1K_Roughness.jpg', aoMap: 'PavingStones092_1K_AmbientOcclusion.jpg', }) return ( ) ``` You can play with the sandbox and see how it looks: [CodeSandbox rusfd](https://codesandbox.io/s/rusfd) ## The texture registry with `useTextures` In v10, `useTexture` is built into `@react-three/fiber` and, by default, enrolls what it loads into a global texture registry. `useTextures()` lets you reach those textures from anywhere and manage their GPU lifetime, without threading refs through your component tree. See [Textures](/next/webgpu/textures) for the registry API, the `cache` option, refcounted disposal, and when the registry is reactive. ]]> ` parent**! ```jsx import { useFrame } from '@react-three/fiber' function MyAnimatedBox() { useFrame(() => { console.log("Hey, I'm executing every frame!") }) return ( ) } ``` This loop is the basic building block of our animation, the callback we pass to `useFrame` will be executed every frame and it will be passed an object containing the state of our Fiber scene: For example, we can read timing information directly off the state to know how much time has elapsed in our application, and use that to animate a value: ```jsx useFrame((state) => { const a = state.elapsed console.log(a) // the value will be ~0 at scene initialization and grow each frame }) ``` `state.elapsed` is the number of seconds since the first frame. The frame state also gives you `state.delta` (seconds since the previous frame), `state.time` (the RAF timestamp), and `state.frame` (frame count) — these replace the old `state.clock` from v9. ## Animating with Refs It would be tempting to just update the state of our component via `setState` and let it change the `mesh` via props, but going through state isn't ideal, when dealing with continuous updates, commonly known as [transient updates](https://zustand.docs.pmnd.rs/guides/updating-state#transient-updates-\(for-often-occurring-state-changes\)). Instead, we want to **directly mutate our mesh each frame**. First, we'll have to get a `reference` to it, via the `useRef` React hook: ```jsx import React from 'react' function MyAnimatedBox() { const myMesh = React.useRef() return ( ) } ``` `myMesh` will now hold a reference to the actual three.js object, which we can now freely mutate in `useFrame`, without having to worry about React: ```jsx useFrame((state) => { myMesh.current.rotation.x = state.elapsed }) ``` Let's have a closer look: - We are reading `elapsed` from the `state` passed to `useFrame`, which we know is the state of our Fiber scene. - We are accessing the `rotation.x` property of `myMesh.current` object, which is a reference to our mesh object - We are assigning our time-dependent value to the `rotation` on the `x` axis, meaning our object will now infinitely rotate around the x axis! [CodeSandbox 29gxw](https://codesandbox.io/s/29gxw) **Exercises** - Try `Math.sin(state.elapsed)` and see how your animation changes ## Next steps Now that you understand the basic technique for animating in Fiber, [learn how event works](/next/tutorials/events-and-interaction)! If you want to go deeper into animations, check these out: - [Animating with React Spring](https://react-spring.dev/docs/getting-started) ]]> ) } ``` In three.js, this is equivalent to: ```js import * as THREE from 'three' const scene = new THREE.Scene() // const group = new THREE.Group() // const mesh = new THREE.Mesh() // const material = new THREE.MeshNormalMaterial() // const geometry = new THREE.BoxGeometry(2, 2, 2) // mesh.material = material mesh.geometry = geometry group.add(mesh) scene.add(group) ``` Our `Canvas` element will create a new scene, and Fiber will instantiate new objects for each component and correctly compose them together in a scene graph! Additionally, Fiber will: - Setup a new perspective camera at \[0, 0, 0] and set it as default - Setup a **render loop** with automatic render to screen - Setup pointer events via raycasting on all meshes with `onPointer` props - Setup tone mapping - Automatically handle window resize **Let's break this down!** ## Creating THREE objects In three.js, we can create new object using the classic JS API: ```js const myBox = new THREE.BoxGeometry(1, 2, 3) ``` Object creation is handled transparently by the Fiber renderer, the name of the constructor `BoxGeometry` is equivalent to the camel case component ``, while the constructor arguments - in our example `[1, 2, 3]` - are passed via the `args` prop: ```jsx ``` > \[!NOTE] Note that the object will be created only when first adding the component to the React tree! ## The `attach` props Fiber always tries to correctly infer the relationship between components and their parents, for example: ```jsx ``` Here, we always know that a group can only have children, so Fiber just calls the `add` method on the group: ```js group.add(mesh) ``` For meshes and other three.js objects, rules can be different. Looking at the three.js documentation, we can see how a `THREE.Mesh` object is constructed using a `material` and a `geometry`. With the `attach` prop, we can precisely tell the renderer what property to attach each component to: ```jsx ``` This will _explicitly_ tell Fiber to render like this: ```js mesh.material = new THREE.MeshNormalMaterial() mesh.geometry = new THREE.BoxGeometry() ``` As you can see, the `attach` prop is telling Fiber to set the parent's `material` property to a reference to our `` object. Note that while we used geometry and material for this example, Fiber also infers the attach property from the constructor name, so anything with `material` or `geometry` will automatically get attached to the correct property of its parent. ## Props With Fiber, you can pass any three.js property as a React property, and it will be assigned to the constructed object: ```jsx ``` is equivalent to: ```jsx const material = new THREE.MeshBasicMaterial() material.color = 'red' ``` Fiber will check the type of the property value and either: - assign the new value directly - if the value is an object with a `set` method, call that - construct a new object if needed. - convert between formats ```jsx ``` is equivalent to: ```jsx const mesh = new THREE.Mesh() mesh.scale = new THREE.Vector3(1, 2, 3) // on update, it will instead `set()` the vector mesh.scale.set(3, 4, 5) ``` ## Pointer Events [Pointer Events](/next/api/events) are transparently handled by Fiber. On startup, it will create a [raycaster](https://threejs.org/docs/#api/en/core/Raycaster) for mouse picking. Every object with `onPointer` props will be added to the array of objects checked every frame by the raycaster: ```jsx ... ``` The ray's `origin` and `direction` are updated every time the mouse moves on the `` element or the window is resized. Fiber also handles camera switching, meaning that the raycaster will always use the currently active camera. When using the `raycast` prop, the object will instead be picked using a custom ray: ```jsx import { useCamera } from '@react-three/drei' return ``` ## Render Loop By default, Fiber sets up a render loop that renders the default `scene` from the default `camera` to the renderer (a `WebGPURenderer`, or a `WebGLRenderer` on `@react-three/fiber/legacy`). In v10 the loop is driven by a single shared `requestAnimationFrame` scheduler, not three.js' `setAnimationLoop`. Work is organised into ordered **phases** — the defaults are `start`, `input`, `physics`, `update`, `render`, `finish` — and on each frame the scheduler: 1. Computes a shared frame `delta` and `elapsed`, so all jobs in the frame see the same timing 2. Runs every registered job in phase order (and, within a phase, by `before`/`after`/`priority`) 3. Runs the built-in render job in the `render` phase, which calls `renderer.render(scene, camera)` — unless a user job has taken over that phase This single scheduler is shared across all roots/canvases, and `useFrame` is simply a job registered into it. For the full model — phases, ordering, fps throttling, render takeover, and demand mode — see [Frame Loop & Scheduler](/next/frame-loop). ]]> **Alpha Release** - v10 is currently in alpha. We'd love your feedback! > > - [Report an issue](https://github.com/pmndrs/react-three-fiber/issues/new?labels=v10) - Found a bug? Let us know > - [Join the discussion](https://github.com/pmndrs/react-three-fiber/discussions) - Questions or ideas > - [Track progress](https://github.com/pmndrs/react-three-fiber/milestones) - See what's coming ## The Short Version **`@react-three/fiber` now renders with WebGPU.** Every `` from the default import uses three's `WebGPURenderer`, which falls back to a WebGL2 backend where the browser has no WebGPU, so it runs everywhere v9 did. What it cannot run is GLSL: `ShaderMaterial`, `RawShaderMaterial`, `onBeforeCompile`, and libraries built on them (much of drei's shader-based components, `postprocessing`). Those need `WebGLRenderer`, which is now only on `@react-three/fiber/legacy`. So the first decision is one import: - **Your scene uses GLSL or WebGL-only libraries:** change `@react-three/fiber` to `@react-three/fiber/legacy` wherever you import `Canvas` (and `createRoot`). Everything else in this guide still applies, and the rest of your app and libraries keep importing hooks from `@react-three/fiber`. - **It doesn't:** keep the import. Standard materials, lights, shadows, loaders and controls work on `WebGPURenderer` unchanged. A few other things break, and most of them fail loudly rather than silently: `` throws (the setting moved into `gl` / `renderer`), `gl={...}` on a `@react-three/fiber` Canvas throws (it configures `WebGLRenderer`), the `onUpdate` prop is gone, and the dependency ranges moved. Three changes are quieter: a GLSL material on a WebGPU canvas renders blank (with a development warning), a `useFrame` job in the `physics` phase now runs on a fixed timestep, and negative numeric `useFrame` priorities run in the opposite order from v9 (with a warning). The checklist below covers all of them. ## Quick Checklist `@react-three/eslint-plugin`'s `migration` config finds most of these in your code, and `--fix` rewrites many of them: add `configs.migration` to your `eslint.config.js` while you upgrade (see the [plugin readme](https://github.com/pmndrs/react-three-fiber/tree/v10/packages/eslint-plugin#migration)). - [ ] Decide on the renderer: keep `@react-three/fiber` (WebGPU), or import `Canvas` from `@react-three/fiber/legacy` (WebGL) for GLSL and WebGL-only libraries (see [`@react-three/fiber` Renders With WebGPU](#react-threefiber-renders-with-webgpu)) - [ ] Upgrade `three` to `>=0.185.0` (see [Dependency ranges moved](#dependency-ranges-moved)) - [ ] Replace `state.gl` with `state.renderer` - [ ] Replace any `onUpdate` prop with an effect, `useFrame` or a ref callback (see [`onUpdate` Removed](#onupdate-removed)) - [ ] Move `` into the renderer settings: `gl={{ shadows }}` / `renderer={{ shadows }}` (see [`shadows` Moved](#shadows-moved-into-the-renderer-settings)) - [ ] Step simulations in the `physics` phase by the `delta` they are given, or move per-frame work out of it (see [`physics` Runs on a Fixed Timestep](#physics-runs-on-a-fixed-timestep)) - [ ] Replace negative `useFrame` priorities with phases or `before` / `after` (see [Render Phase Takes Over Rendering](#render-phase-takes-over-rendering)) - [ ] Replace split gain map environments with a single-file `.jpg` (see [Split Gain Map Environments Removed](#split-gain-map-environments-removed)) Upgrading from an earlier v10 alpha? Also: - [ ] Import the TSL hooks from `@react-three/tsl` (see [TSL hooks moved](#tsl-hooks-moved-to-react-threetsl)) - [ ] Replace `renderer={{ primaryCanvas }}` and `renderer={{ scheduler }}` with `` and `` (see [Multi-Canvas Props](#multi-canvas-props-alpha-upgraders)) - [ ] Pass `{ persist: true }` to `useRenderPipeline` where post-processing should outlive the component that set it up (see [TSL hooks moved](#tsl-hooks-moved-to-react-threetsl)) - [ ] Drop the `renderer` boolean: `` is now the same as ``. An app on `@react-three/fiber` without it, which was on WebGL, is now on WebGPU - [ ] Move imports from the deprecated `@react-three/fiber/webgpu` (and `@react-three/test-renderer/webgpu`) to `@react-three/fiber` (and `@react-three/test-renderer`); its hooks are already typed for WebGPU (see [Hooks Typed for WebGPU](#hooks-typed-for-webgpu)). They are removed in the first beta WebGPU needs no manual init. Pass renderer parameters or a pre-created renderer instance through `renderer`, and R3F handles initialization: ```tsx // Pass parameters // Or a renderer instance (still no init needed) ``` --- ## Breaking Changes at a Glance Every change below is covered in its own section. The same list, with the alpha each one landed in, is on its own page: [v10 Breaking Changes](/next/migration/breaking-changes). **Silent** changes alter behaviour or types without an error, so check for those. | Change | What to do | | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | | [`@react-three/fiber` renders with WebGPU](#react-threefiber-renders-with-webgpu) | Keep it, or import `Canvas` from `/legacy` for GLSL and WebGL-only libraries | | **Silent:** GLSL materials render blank on a WebGPU canvas | `/legacy`, or port to a node material (warns once in development) | | `gl={...}` on a `@react-three/fiber` Canvas throws | `/legacy`, or move settings to `renderer={{ ... }}` | | [Hooks are typed for WebGPU](#hooks-typed-for-webgpu) | Nothing for most apps; libraries for both renderers type against `/extension` | | [`/webgpu` entry deprecated](#renderer-selection) | Import from `@react-three/fiber` | | [Dependency ranges](#dependency-ranges-moved): three `>=0.185.0`, React `>=19.0 <19.4` | Upgrade three | | [React Native](#react-native-moved-to-its-own-package) is `@react-three/native` | Change the import | | [`state.gl` deprecated](#gl-renamed-to-renderer) | `state.renderer` | | [`state.clock` removed](#useframe-clock-removed) | `state.time` / `delta` / `elapsed` | | **Silent:** [numeric `useFrame` priority](#render-phase-takes-over-rendering) runs higher first | Phases or `before` / `after` | | **Silent:** [`physics` phase is a fixed timestep](#physics-runs-on-a-fixed-timestep) | Step by the `delta` you get | | [`legacy`, `linear`, `flat`, `colorSpace`, `toneMapping` props removed](#renderer-props-removed) | Renderer settings in `renderer` / `gl` | | [`shadows` prop throws](#shadows-moved-into-the-renderer-settings) | `renderer={{ shadows }}` / `gl={{ shadows }}` | | [`onUpdate` removed](#onupdate-removed) | An effect, `useFrame` or a ref callback | | [Split gain map environments](#split-gain-map-environments-removed) | Single-file Ultra HDR `.jpg` | | [Multi-canvas props](#multi-canvas-props-alpha-upgraders) (alpha upgraders) | ``, `share`, `` | | [TSL hooks](#tsl-hooks-moved-to-react-threetsl) (alpha upgraders) | Import from `@react-three/tsl` | --- ## Breaking Changes ### `@react-three/fiber` Renders With WebGPU The default import renders every `` with `WebGPURenderer`. In v9, and in the v10 alphas before alpha.6, it rendered with `WebGLRenderer` unless you passed `renderer`. `WebGLRenderer` now lives only on `@react-three/fiber/legacy`, and the import is the switch: there is no prop that turns a default-import Canvas back into WebGL. ```diff // GLSL shaders, onBeforeCompile, postprocessing, drei's shader-based components - import { Canvas } from '@react-three/fiber' + import { Canvas } from '@react-three/fiber/legacy' ``` Change it where you import `Canvas` or `createRoot`. Hooks (`useThree`, `useFrame`, `useLoader`, ...) can keep coming from `@react-three/fiber`: every entry shares one core, so they work under a `/legacy` Canvas, and libraries that import from `@react-three/fiber` do too. What tells you which one you need: - **GLSL materials.** `WebGPURenderer` compiles node materials (TSL), not GLSL. A `ShaderMaterial` or `RawShaderMaterial` on a WebGPU canvas is drawn with a blank default material, three logs `NodeBuilder: Material "ShaderMaterial" is not compatible`, and R3F warns once per material type in development, naming `/legacy`. `onBeforeCompile` is ignored. Standard materials (`MeshStandardMaterial`, `MeshPhysicalMaterial`, ...) are converted to their node equivalents automatically. - **The `gl` prop.** It configures `WebGLRenderer`, so on a `@react-three/fiber` Canvas it throws, naming `/legacy`. Move renderer settings to `renderer={{ ... }}` to stay on WebGPU. - **Libraries.** Anything that needs a `WebGLRenderer` (`postprocessing`'s `EffectComposer`, GLSL-based drei components, code calling WebGL-only renderer APIs) needs `/legacy` until it supports `WebGPURenderer`. `state.gl` on a WebGPU canvas aliases the `WebGPURenderer`, with a one-time deprecation notice: code that only reads `gl.domElement` or `gl.setSize` keeps working, WebGL-only calls do not. Node materials go the other way: on `/legacy` they throw an error pointing back to `@react-three/fiber`. ### Hooks Typed for WebGPU `useThree`, `useFrame`, `useRenderTarget` and `Canvas`'s `onCreated` from `@react-three/fiber` are typed for the renderer that import renders with: `state.renderer` is a `WebGPURenderer`, and WebGPU-only members need no cast or declaration. The JSX elements are three's `three/webgpu` namespace, so node materials are typed and `` is not. ```tsx import { useFrame, useThree } from '@react-three/fiber' const renderer = useThree((s) => s.renderer) // WebGPURenderer useFrame(({ renderer }) => renderer.compute(kernel)) ``` `RootState` itself is still the shared base type (either renderer), so packages that augment it keep working, and code annotated with it still compiles: `useFrame((state: RootState) => ...)` is fine. The deprecated `state.gl` keeps its base type; use `state.renderer`. What this breaks is code that relied on the union from the default import, such as reading `state.renderer` as a `WebGLRenderer`. That code would fail at runtime on a WebGPU canvas anyway; it belongs on `/legacy`, whose hooks are typed for `WebGLRenderer`. Libraries that support both renderers type against `@react-three/fiber/extension`, which keeps the union. See [Typing the renderer](../API/typescript#typing-the-renderer). ### Dependency ranges moved The first thing you will hit, before any code change: v10 will not install alongside the versions v9 accepted. | Peer | v9 | v10 | | ------- | --------- | -------------- | | `three` | `>=0.156` | `>=0.185.0` | | `react` | `>=19.0` | `>=19.0 <19.4` | The three floor moved because v10 uses three's own `RenderPipeline` (renamed from `PostProcessing` in r183), `CubeRenderTarget`, and the real `UniformNode` types, rather than shadowing them. **If you are on r181–r184 you cannot install v10** — upgrade three alongside R3F. The React ceiling states the versions we have tested (19.0 through 19.3), and moves when a newer one is verified. ### React Native moved to its own package `@react-three/fiber/native` is now **`@react-three/native`**, versioned independently. > \[!WARNING] `@react-three/native` is not on npm yet; its source lives at [pmndrs/native](https://github.com/pmndrs/native). v10 has no `/native` entry, so React Native apps stay on v9 until it is published. ```diff - import { Canvas, useFrame } from '@react-three/fiber/native' + import { Canvas, useFrame } from '@react-three/native' ``` ### `gl` renamed to `renderer` The `gl` property in state has been renamed to `renderer` to be renderer-agnostic and match Three.js conventions. ```diff function MyComponent() { const { renderer } = useThree() - const { gl } = useThree() // Or in useFrame - useFrame(({ gl }) => { + useFrame(({ renderer }) => { // ... }) } ``` > **Note:** `state.gl` still works. On a WebGPU canvas it aliases `state.renderer` (the `WebGPURenderer`) and logs a one-time deprecation notice; on `/legacy` it is the `WebGLRenderer`, as before, with no notice. Prefer `state.renderer` in new code. ### Renderer Selection | Your Use Case | Import From | Renderer | | ------------------------------------- | --------------------------- | ------------------------------------ | | Default | `@react-three/fiber` | WebGPURenderer, downloaded on demand | | GLSL shaders, WebGL-only libraries | `@react-three/fiber/legacy` | WebGLRenderer, no extra request | | Deprecated, removed in the first beta | `@react-three/fiber/webgpu` | WebGPURenderer, imported statically | ```tsx // Default - WebGPURenderer, fetched when the first Canvas mounts import { Canvas, useFrame } from '@react-three/fiber' // Legacy - WebGLRenderer only, typed as such import { Canvas, useFrame } from '@react-three/fiber/legacy' // TSL hooks come from @react-three/tsl on either WebGPU import import { useUniforms } from '@react-three/tsl' ``` All three entries share one core, so libraries built on `@react-three/fiber` work under a Canvas from any of them without adding a renderer to your bundle. ### `useFrame` Clock Removed The `state.clock` (THREE.Clock) has been removed from FrameState. Timing information is now provided directly from the RAF timer, which is more accurate and consistent. ```diff useFrame((state, delta) => { - const elapsed = state.clock.getElapsedTime() - const delta = state.clock.getDelta() + const { time, delta, elapsed } = state + // time: High-resolution timestamp from RAF (milliseconds) + // delta: Time since last frame (seconds) + // elapsed: Elapsed time since first frame (seconds) }) ``` ### Render Phase Takes Over Rendering In v9, passing a numeric `priority > 0` to `useFrame` disables R3F's default rendering, requiring you to render manually. In v10, this behavior is preserved for backwards compatibility, but emits a deprecation warning. The recommended v10 mechanism is **registering a callback with `{ phase: 'render' }`**. This also disables R3F's default rendering, so your callback is responsible for rendering the scene. Existing numeric-priority callbacks that render manually still work; migrate them to the render phase to avoid the warning. ```tsx // v9: Numeric priority > 0 takes over rendering (still supported in v10, but deprecated) useFrame(({ gl, scene, camera }) => { gl.render(scene, camera) }, 1) // v10: Use the render phase explicitly useFrame( ({ renderer, scene, camera }) => { renderer.render(scene, camera) }, { phase: 'render' }, ) // Render phase takes over ``` For ordering without taking over rendering, use phases or `before`/`after` constraints. In the legacy numeric form, only priorities of `0` or less leave the default render enabled; a positive priority takes over rendering even if the callback does not render anything. ```tsx // v9: Order callbacks without disabling the default render useFrame(() => physicsStep(), -2) useFrame(() => updateGame(), -1) useFrame(() => updateAI(), 0) // v10: Phase-based ordering (recommended) useFrame(() => physicsStep(), { phase: 'physics' }) useFrame(() => updateGame(), { phase: 'update' }) useFrame(() => updateAI(), { after: 'update', before: 'render' }) ``` In the v10 form, `physicsStep` runs on a fixed timestep: see the next section. > **Ordering direction is reversed.** A bare number is mapped to `{ priority: n }`, and v10 runs higher priority first (v9 ran lower first). Left unmigrated, the v9 example above runs `updateAI` first and `physicsStep` last on v10. Only `priority > 0` (render takeover) and negative values (reversed ordering) emit a deprecation notice; a bare `0` is the default and is silent. Migrate to phases or `before`/`after` rather than to `{ priority }`: a number is only a loose tie-break and doesn't guarantee order against other jobs. ### `physics` Runs on a Fixed Timestep The built-in `physics` phase steps at a fixed `1 / 60` of a second (from `@pmndrs/scheduler` 0.3). A job in it runs once for every whole step of time that has built up, not once per frame: on a 120 Hz display it skips every other frame, on a slow frame it runs several times (at most 8, after which the surplus time is dropped), and it always gets `delta === 1 / 60`. This is what a physics engine wants, and it makes the simulation run at the same speed on every display. ```diff - useFrame(() => world.step(1 / 60), { phase: 'physics', fps: 60, drop: false }) + useFrame((_, delta) => world.step(delta), { phase: 'physics' }) ``` Work that has to run exactly once per frame belongs in another phase (`update` is the default). To interpolate what you render between the last two physics states, read `state.overstep`, and to make `physics` run once per frame again, call `getScheduler().setPhaseTimestep('physics', undefined)`. See [Frame Loop](../frame-loop). --- ## Removed Props ### Renderer Props Removed The following Canvas props have been removed. Use the `gl` (WebGL LEGACY ONLY) or `renderer` (WebGPU) props to pass these settings directly to the renderer: **Removed props:** - `legacy` - `THREE.ColorManagement.enabled` is now always `true` (modern Three.js default) - `linear` - Use `renderer={{ outputColorSpace: THREE.LinearSRGBColorSpace }}` - `flat` - Use `renderer={{ toneMapping: THREE.NoToneMapping }}` - `colorSpace` - Use `renderer={{ outputColorSpace: ... }}` - `toneMapping` - Use `renderer={{ toneMapping: ... }}` ```diff - + - + - + ``` **Why the change?** These props were redundant with what you can already pass via `gl`/`renderer`. Removing them simplifies the API and makes it clear that renderer settings should be configured through the renderer props. **Accessing values:** If you were reading these from `useThree()`, access them from the renderer directly: ```diff - const { colorSpace, toneMapping, legacy, linear, flat } = useThree() + const { renderer } = useThree() + const colorSpace = renderer.outputColorSpace + const toneMapping = renderer.toneMapping ``` **Note:** `textureColorSpace` survives, but **not as a Canvas prop**. It lives in `ColorManagementConfig`, intersected into `GLProps` and `RendererProps`, so it goes inside the config bag rather than at the top level: ```diff - + + ``` ### `shadows` Moved into the Renderer Settings Shadows are a renderer-wide setting, so `shadows` moved from the Canvas into the `gl` / `renderer` props bag, next to `toneMapping` and the other renderer settings. It accepts the same values. The old prop throws an error naming the new place. ```diff - + // WebGL, @react-three/fiber/legacy + // WebGPU ``` R3F applies it to a renderer it builds. A renderer instance or factory you pass keeps the `shadowMap` you configured on it. ### Multi-Canvas Props (alpha upgraders) Earlier v10 alphas configured renderer sharing inside the `renderer` bag. It is now a pair of Canvas props, and scheduling is its own Canvas prop on every entry. There is no deprecation path: the old keys throw, naming the new API. ```diff - - + + // shares the primary automatically, renders after it ``` - `renderer={{ primaryCanvas }}` → mark the owner ``; other WebGPU canvases share it automatically. With several primaries, pick one with `share="id"`; opt out with `share={false}`. - `renderer={{ scheduler }}` → `` (also `createRoot().configure({ scheduler })`, and now available on `/legacy`). - `renderer` means only `true`, a props bag, an instance or a factory, and a props bag configures a renderer the canvas owns: a canvas sharing the primary's renderer ignores it (with a development warning). Renderer-wide settings belong on the primary. - Mind the direction when ordering canvases with numbers: `scheduler={{ order }}` runs **lower** values first, while a `useFrame` job's `priority` runs **higher** values first. Prefer `before` / `after` with canvas ids for both. See [Multi-Canvas](../webgpu/multi-canvas). ### `onUpdate` Removed The `onUpdate` prop is gone. It was called after every prop update as a hook into the reconciler's internals, but it fired for reasons that had nothing to do with the props you cared about (attach changes, Suspense hide/unhide, child mounts) and not for the ones that did (imperative mutation, anything set in a `useFrame`). What you want is one of these three, all plain React or R3F: **An effect keyed on the props that change.** The effect runs exactly when those values change, and you can read the object through its ref. ```diff - self.computeVertexNormals()} /> + const ref = useRef(null) + useEffect(() => ref.current?.computeVertexNormals(), [positions]) + + + ``` **Read the object in a `useFrame` loop.** For continuous work, or anything that has to react to mutations made outside React, read the object each frame instead of waiting for a render. ```tsx const ref = useRef(null) useFrame(() => { ref.current!.rotation.y += 0.01 }) return ``` **A ref callback.** For one-time setup when the object is created, a ref callback receives the object directly. Keep its identity stable with `useCallback`: React re-invokes an inline callback on every render. It runs again with a new object when `args` change. ```tsx // Braces matter: a React 19 ref callback's return value is its cleanup, and lookAt returns the mesh const setup = useCallback((mesh: THREE.Mesh | null) => { mesh?.lookAt(0, 0, 0) }, []) return ``` `onUpdate` is no longer reserved, so it is applied to the object by name like any other prop. That is deliberate for ``: three.js has a real `Texture.onUpdate` callback that the renderer fires after uploading the texture to the GPU, and `` now sets it. It is not called when props change, so a v9 `onUpdate={(self) => (self.needsUpdate = true)}` no longer flags a new `image` for upload: set `needsUpdate` in an effect keyed on the data instead. On a TSL node the prop replaces the `Node.onUpdate()` method, and on anything else (a mesh, a group) the assignment is inert. R3F logs a one-time notice the first time it sees an `onUpdate` prop. ### Split Gain Map Environments Removed ``, `useEnvironment` and the Canvas `background` prop no longer read the split gain map format (`['sdr.webp', 'gainmap.webp', 'metadata.json']`), and `@monogrid/gainmap-js` is no longer a dependency. Use a single-file Ultra HDR `.jpg`, which three's own `UltraHDRLoader` decodes, or keep the split format through drei's ``. `.hdr`, `.exr` and six-face cube sets are unchanged. ```diff - + ``` ### `` Pattern Deprecated The pattern of using `` to set the scene background is deprecated in favor of the new `background` prop on Canvas. ```diff - - - - + + + ``` The new `background` prop is more powerful, supporting colors, URLs, presets, and expanded object form for separate background/environment maps: ```tsx // Simple color // Hex number // Environment preset (applies to both background and PBR lighting) // HDR file // Advanced: separate background and environment ``` The old `` pattern still works but is more verbose and doesn't support environments. See [Canvas Background](/next/api/canvas#canvas-background) for full documentation. --- ## What's New ### Powerful New Scheduler v10 completely rewrites the frame loop with a scheduler that gives you **real control** over how and when your code runs. **Your existing code works unchanged.** But now you can do so much more: **Run code in the right order** - No more guessing with priority numbers. Organize your frame work into logical phases that execute in sequence: ```tsx useFrame(({ delta }) => processInput(), { phase: 'input' }) useFrame(({ delta }) => physicsWorld.step(delta), { phase: 'physics' }) // fixed 1/60 steps useFrame(({ delta }) => updateGame(delta), { phase: 'update' }) useFrame(({ delta }) => renderEffects(), { phase: 'render' }) ``` **Throttle expensive work** - Heavy AI computation? Particle updates? Run them at whatever framerate makes sense: ```tsx useFrame(() => runAI(), { fps: 20 }) useFrame(() => updateParticles(), { fps: 30 }) useFrame(() => smoothAnimation()) // Full speed ``` **Pause and resume anything** - Every `useFrame` returns controls. Build pause menus, debug tools, or conditional animations: ```tsx const physics = useFrame(() => simulate(), { phase: 'physics' }) const animations = useFrame(() => animate()) // Pause physics but keep animations running physics.pause() // Resume when ready physics.resume() ``` **Multiple canvases, one loop** - The scheduler manages all your Canvas instances in a single RAF loop. No more frame sync issues. 📚 **Learn more:** [Frame Loop & Scheduler](/next/frame-loop) --- ### WebGPU & TSL Hooks v10 brings **first-class WebGPU support** with a suite of hooks that make TSL (Three.js Shading Language) feel native to React. **Write shaders the React way** - Uniforms, nodes, and post-processing all managed declaratively. Share shader logic across components. Update values without re-renders. ```tsx import { Canvas } from '@react-three/fiber' import { useUniforms, useNodes, useLocalNodes } from '@react-three/tsl' import { Fn, vec3, sin, time, positionLocal, normalLocal } from 'three/tsl' // Define reusable shader functions function GlobalEffects() { useNodes(() => ({ wobble: Fn(() => vec3(sin(time), sin(time.mul(1.3)), sin(time.mul(2)))), })) return null } // Use them anywhere - shared across your entire app function WobblyMesh() { const { uIntensity } = useUniforms({ uIntensity: 0.5 }) // Build the node once, outside JSX: a node created inline is new on every render and recompiles the shader const { positionNode } = useLocalNodes( ({ nodes }) => ({ positionNode: positionLocal.add(normalLocal.mul(nodes.wobble.mul(uIntensity))) }), [uIntensity], ) return ( ) } ``` **What you get:** - **`useUniforms`** - Create and share uniforms across components. Scoping prevents naming conflicts. Auto-converts Leva controls. - **`useNodes`** - Define TSL functions once, use everywhere. Share expensive noise functions, effects, varyings. - **`useLocalNodes`** - Compose shared nodes with local logic. Access all uniforms and nodes from state. The second argument is a `useMemo`-style dependency array: `[]` reuses the result across renders, `[a, b]` rebuilds when a declared JavaScript input changes, and no array re-evaluates every render (a `useCallback` creator no longer opts out of that - pass `[]` instead). - **`useRenderPipeline`** - Declarative render pipeline setup with automatic MRT configuration. The component that configures it owns the pipeline and frees it on unmount (`{ persist: true }` to keep it); callbacks re-run after a hot update. #### TSL hooks moved to `@react-three/tsl` The TSL resource hooks live in their own package. Install it and change the import; apart from `useRenderPipeline` (below), they work as before. `state.uniforms`, `state.nodes`, `state.buffers` and `state.gpuStorage` stay on `RootState` (the package adds them), so `useFrame(({ uniforms }) => ...)` and `useThree((s) => s.uniforms)` keep working. ```bash npm install @react-three/tsl@alpha ``` ```tsx // Before import { Canvas, useUniforms, useNodes } from '@react-three/fiber/webgpu' // After import { Canvas } from '@react-three/fiber' import { useUniforms, useNodes } from '@react-three/tsl' ``` This covers `useUniforms`, `useUniform`, `useNodes`, `useLocalNodes`, `useBuffers`, `useGPUStorage`, `useRenderPipeline`, and the `rebuildAll*` helpers. In tests, import them from `@react-three/tsl` too: `@react-three/test-renderer/webgpu` no longer re-exports them. `useRenderPipeline` now belongs to the component that configures it. When that component unmounts, its pipeline and scene pass are freed and the canvas renders without post-processing again; before, the pipeline kept rendering until `reset()`. Pass `{ persist: true }` as the third argument to keep the old behaviour. A call without callbacks only reads the pipeline and no longer creates one. See [Render Pipeline](/next/webgpu/render-pipeline). #### Deprecated standalone TSL utilities removed `removeUniforms(set, ...)`, `clearScope`, `clearRootUniforms`, `removeNodes(set, ...)`, `clearNodeScope` and `clearRootNodes` are removed. Use the utilities the hooks return instead: `const { removeUniforms, clearUniforms } = useUniforms()` and `const { removeNodes, clearNodes } = useNodes()`. 📚 **Learn more:** [WebGPU Overview](/next/webgpu/overview) --- ## TypeScript Changes ### Hooks Follow the Import `useThree`, `useFrame`, `useRenderTarget` and `onCreated` are typed for the renderer of the entry you import them from: `WebGPURenderer` on `@react-three/fiber`, `WebGLRenderer` on `/legacy`. The `Register` declaration earlier alphas suggested for this is no longer needed in apps. See [Hooks Typed for WebGPU](#hooks-typed-for-webgpu) and [Typing the renderer](../API/typescript#typing-the-renderer). ### Test Renderer Entry Points Match your test renderer import to your fiber import: ```tsx // If using default fiber (WebGPU: mocks WebGPU and creates WebGPU canvases) import ReactThreeTestRenderer from '@react-three/test-renderer' // If using legacy (WebGL) import ReactThreeTestRenderer from '@react-three/test-renderer/legacy' // Deprecated with @react-three/fiber/webgpu (removed in the first beta) import ReactThreeTestRenderer from '@react-three/test-renderer/webgpu' ``` --- ## FAQ ### Do I need to switch to WebGPU? Not if your scene needs WebGL: import from `@react-three/fiber/legacy`, which is fully supported and keeps `WebGLRenderer`. But WebGPU is the default now, so staying on WebGL is the choice you make explicitly. You don't need browser WebGPU support for the default either: `WebGPURenderer` falls back to a WebGL2 backend automatically, and keeps running node materials and TSL there. ### Will my drei/postprocessing code work? On `@react-three/fiber/legacy`, yes: it keeps the v9 `WebGLRenderer` surface these packages build on. On the default import, the renderer-agnostic parts of drei (controls, cameras, loaders, `Html`, ...) work, but anything built on GLSL or `EffectComposer` (most of `postprocessing`, drei's shader materials) needs `/legacy` until those packages support `WebGPURenderer`. There is also a peer-range caveat during the alpha: a `@react-three/drei` release declaring support for `@react-three/fiber@^10` is still pending, so your package manager may warn or refuse to resolve until it lands. ### What if I'm using `state.gl` everywhere? It still works: `state.gl` aliases `state.renderer`. On a WebGPU canvas you'll see a one-time deprecation notice in development, and since that renderer is a `WebGPURenderer`, WebGL-only calls on it fail; if your code makes those, import from `/legacy`. Update to `state.renderer` when convenient. --- ## Migration from v9 If you're coming from v9, also review the [v9 Migration Guide](/next/tutorials/v9-migration-guide) for React 19 compatibility changes that may apply. --- ## Need Help? - [GitHub Discussions](https://github.com/pmndrs/react-three-fiber/discussions) - [Discord](https://discord.gg/ZZjjNvJ) ]]> \[!TIP] `@react-three/eslint-plugin`'s `migration` config finds most of these in your code and fixes many of them with `--fix`: the `gl` and `clock` reads, numeric `useFrame` priorities, removed Canvas props and imports, `onUpdate` and ``. Add `configs.migration` to your `eslint.config.js` (eslintrc: `plugin:@react-three/legacy-migration`) while you upgrade; see the [plugin readme](https://github.com/pmndrs/react-three-fiber/tree/v10/packages/eslint-plugin#migration). ## Renderer and entries | Change | What to do | Since | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | **`@react-three/fiber` renders with WebGPU.** `` and `createRoot` use `WebGPURenderer` (with a WebGL2 backend where the browser has no WebGPU). `WebGLRenderer` is only on `@react-three/fiber/legacy`. | Keep the import if your scene uses standard materials. Import `Canvas` / `createRoot` from `@react-three/fiber/legacy` for GLSL `ShaderMaterial`s, `onBeforeCompile`, `postprocessing` and other WebGL-only libraries. | alpha.6 | | **silent:** a GLSL material (`ShaderMaterial`, `RawShaderMaterial`) on a WebGPU canvas renders as a blank default material; `onBeforeCompile` is ignored. | Use `/legacy`, or port the shader to a node material (TSL). R3F warns once per material type in development. | alpha.6 | | `gl={...}` on a `@react-three/fiber` Canvas throws: it configures `WebGLRenderer`. | Use `/legacy`, or move the settings to `renderer={{ ... }}` for WebGPU. | alpha.6 | | **Types:** `useThree`, `useFrame`, `useRenderTarget` and `Canvas`'s `onCreated` from `@react-three/fiber` are typed for `WebGPURenderer`, and its JSX map is `three/webgpu`. | Nothing for most apps (WebGPU members need no cast now). Code typed against the WebGL renderer imports from `/legacy`. Libraries that run on both type against `@react-three/fiber/extension`. | alpha.6 | | `@react-three/fiber/webgpu` and `@react-three/test-renderer/webgpu` are deprecated, removed in the first beta. | Import from `@react-three/fiber` and `@react-three/test-renderer`. | alpha.6 | | `state.gl` is deprecated. On a WebGPU canvas it aliases the `WebGPURenderer`, with a one-time notice. | Use `state.renderer`. Code calling WebGL-only APIs on `gl` needs `/legacy`. | alpha.1 | | React Native moved to its own package. | `@react-three/fiber/native` → `@react-three/native` ([pmndrs/native](https://github.com/pmndrs/native), not on npm yet; stay on v9 until it is). | alpha.1 | ## Dependencies | Change | What to do | Since | | ---------------------------------------------------- | ---------------------------------------------- | ------- | | `three` peer is `>=0.185.0`. | Upgrade three (r181–r184 cannot install v10). | alpha.3 | | `react` / `react-dom` peer is `>=19.0 <19.4`. | React 19.0–19.3 (alpha.3 to alpha.5: `<19.3`). | alpha.3 | | `@react-three/drei` has no release for fiber 10 yet. | Expect peer warnings during the alpha. | — | ## Canvas props | Change | What to do | Since | | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------- | | `shadows` moved into the renderer settings; the Canvas prop throws. | `renderer={{ shadows }}` (WebGPU) or `gl={{ shadows }}` (`/legacy`). | alpha.6 | | **silent:** `legacy`, `linear`, `flat`, `colorSpace` and `toneMapping` Canvas props are removed and ignored (TypeScript reports them). | Pass `outputColorSpace` / `toneMapping` in `renderer` or `gl`. Color management is always on. | alpha.3 | | **silent:** `textureColorSpace` is not a Canvas prop and is ignored there. | Put it in the `renderer` / `gl` bag. | alpha.3 | | `onUpdate` is removed. | An effect keyed on the props, `useFrame`, or a ref callback. | alpha.6 | | **silent:** the default shadow type is `PCFShadowMap` (three made it soft; `'soft'` is a deprecated alias). | Nothing, unless you relied on `PCFSoftShadowMap` by name. | alpha.3 | | `` is deprecated. | The `background` Canvas prop. | alpha.3 | ## Frame loop | Change | What to do | Since | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------- | | `state.clock` is removed. | `state.time`, `state.delta`, `state.elapsed`. | alpha.1 | | **silent:** a `physics`-phase job runs on a fixed 1/60 timestep: zero or more times a frame, always with `delta === 1/60`. | Step by the `delta` you get; per-frame work goes in another phase. `setPhaseTimestep('physics', undefined)` opts out. | alpha.6 | | **silent:** numeric `useFrame` priority runs higher numbers first (v9 ran lower first), so negative numbers reverse. Positive numbers still take over rendering. | Use phases or `{ before, after }`; `{ phase: 'render' }` for render takeover. Both forms warn. | alpha.1 | | **silent:** pointer-move raycasts run once per frame (`events.frameTimedRaycasts: true`). | Set it to `false` for per-event raycasts. | alpha.3 | | **silent:** the default camera is a child of `scene`, shifting `scene.children` indices by one. | Find objects by reference or name, not index. | alpha.3 | ## Assets and textures | Change | What to do | Since | | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------- | | Split gain map environments (`['sdr.webp', 'gainmap.webp', 'metadata.json']`) are no longer read. | A single-file Ultra HDR `.jpg`, or drei's ``. | alpha.6 | | **silent:** `useTexture` caches by default (`{ cache: true }`); textures live until disposed. | `{ cache: false }` per call to opt out. | alpha.3 | | `useTextures()` returns `all` (was `textures`); `addMultiple`, `remove*` and `disposeMultiple` are gone. | Use `all`, `add`, and refcounted `dispose`. | alpha.3 | ## TypeScript and testing | Change | What to do | Since | | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------- | | `act` is no longer re-exported. | `import { act } from 'react'`. | alpha.3 | | `advance()` takes only `(timestamp)`. | Drop the extra arguments. | alpha.3 | | `Mutable

` uses `-readonly`; `ObjectMap` is generic. | Affects code naming these types directly. | alpha.3 | | `@react-three/test-renderer`'s default entry mocks WebGPU. | Test `/legacy` scenes with `@react-three/test-renderer/legacy`. | alpha.6 | | `@react-three/eslint-plugin` exports flat configs; `eslint` is a peer. | `configs.recommended` in `eslint.config.js`; eslintrc uses `plugin:@react-three/legacy-recommended`. | alpha.6 | ## Upgrading from an earlier v10 alpha On top of the above, alpha users also meet these changes to APIs that only existed in the alphas: - **TSL hooks moved to `@react-three/tsl`** (`useUniforms`, `useUniform`, `useNodes`, `useLocalNodes`, `useBuffers`, `useGPUStorage`, `useRenderPipeline`, `rebuildAll*`); the standalone utilities (`removeUniforms(set, …)`, `clearScope`, `clearRootUniforms`, `removeNodes(set, …)`, `clearNodeScope`, `clearRootNodes`) are removed. _alpha.6_ - **`useRenderPipeline` belongs to the component that configures it.** Unmounting that component frees the pipeline and its scene pass, and the canvas renders without post-processing again. Pass `{ persist: true }` as the third argument to keep it rendering after unmount. A call without callbacks only reads and no longer creates a pipeline. _alpha.6_ - **Unregistered TSL reads are typed `LooseUniformNode` / `LooseNode`**, so they work in TSL without casts. Writing a concrete node straight into `state.uniforms` or `state.nodes` with `setState` (instead of through the hooks) now needs a cast. _alpha.6_ - **`useLocalNodes` with no dependency array re-runs every render**; pass `[]` for the old memoised behaviour. _alpha.6_ - **Multi-canvas is configured on the Canvas.** `renderer={{ primaryCanvas }}` and `renderer={{ scheduler }}` throw: use ``, `share`, and ``. _alpha.6_ - **`` is the same as ``** on `@react-three/fiber`; a plain `` that was on WebGL is now on WebGPU. _alpha.6_ - **`usePostProcessing` → `useRenderPipeline`**, and `state.postProcessing` → `state.renderPipeline`. _alpha.3_ - **`RenderTargetCompat` → `R3FRenderTarget`, `ThreeElementsImpl` → `ThreeElementsOf`**, and no entry calls `extend(THREE)` at import time. _alpha.6_ ]]> `) is emitted once as `dist/shared/fiber..mjs`, and each entry is a few kilobytes of glue that imports it. ### Renderer supports Each renderer is described by a _support_ module, the only two modules in fiber that import three: | File | Imports | Carries | | ----------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `src/support/webgl.ts` | `three` | `three` namespace, `WebGLRenderer`, `WebGLRenderTarget`, `WebGLCubeRenderTarget` | | `src/support/webgpu.ts` | `three/webgpu`, `three/tsl` | `three/webgpu` namespace, `WebGPURenderer`, `RenderTarget`, `CubeRenderTarget`, `CanvasTarget`, occlusion node classes | The shapes are in `types/provider.d.ts`. An entry hands `createRoot`/`Canvas` a **provider**, a loader per renderer it can construct: ```typescript // src/index.tsx -- both, each downloaded when a root first asks for it const provider: RendererProvider = { webgl: () => import('./support/webgl').then((m) => m.webglSupport), webgpu: () => import('./support/webgpu').then((m) => m.webgpuSupport), } // src/legacy.tsx -- one, already loaded const provider: RendererProvider = { webgl: () => webglSupport } ``` `configure()` picks the renderer the entry's provider offers (WebGPU on the root entry and `/webgpu`, WebGL on `/legacy`), awaits its support, and keeps it as `state.internal.support`. That is the moment three is downloaded on the root entry: bundlers emit `dist/chunks/webgpu.mjs` as a lazy chunk. `/legacy` and `/webgpu` import their support statically, for apps that would rather not have that request; they are the same core. ### Core responsibilities A separate file is useful when it isolates behavior or protects a dependency boundary. It does not necessarily introduce another domain. Independent semantic tests support a boundary; the boundary should also reduce what a maintainer must understand to change that behavior. | Area | Implementation | Contract and semantic coverage | | -------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Root management | `root.tsx`, `configuration.ts`, `store.ts` | Root accepts work and commits or tears down in order. Configuration applies changed inputs. Store owns runtime state transitions and their effects. Tests cover readiness, cancelled unmounts, input diffing, and imperative ownership. | | Reconciliation | `reconciler.tsx`, `extend.ts`, `portal.tsx` | Constructor registration and lookup, scene mutations, and portal state inheritance. Tests cover cross-entry registrations, attachments, disposal, and Activity hide/reveal. | | Renderer resources | `renderer.ts` | Resolve supports, acquire shared resources, and release owned resources after the final consumer. Direct tests cover borrowing, caller ownership, idempotent release, asynchronous disposal, and registry lookup. | | Frame scheduling | `loop.ts` | Register frame jobs and honor frame modes and ordering. Tests cover invalidation, render takeover, and per-canvas scheduling. | | Interaction and visibility | `events.ts`, `visibility.ts` | Separate implementations for pointer propagation/capture and frustum/occlusion transitions, with dedicated behavior tests. | | Package integration | `extensions.ts` | Register external packages for root setup, replacement, hot updates, and cleanup. Tests cover late registration, replacement, and disposal. | Within root management, the root sequences renderer initialization, synchronous configuration, scheduler registration, and extension setup before resolving readiness. Configuration decides which inputs changed and resolves requested dimensions. The store accepts configured dimensions only while sizing remains automatic; public `setSize()` takes or releases imperative ownership. Both paths update size and viewport through the same store operation, so subscribers never observe temporary ownership changes. Camera and renderer resizing remain store effects. `context.ts` and `three.ts` are infrastructure modules: they isolate the lightweight extension entry and lazy renderer loading. They are not separate application domains. Promise helpers live in `utils/promise.ts`; React helpers, including `useGate`, live in `utils/react.tsx`. `web/Canvas.tsx` adapts DOM and React to the root API. Activity connectivity does not own renderer lifetime: final root removal releases resources after accepted configuration and React cleanup finish. Portals reconnect their injected scene on reveal; the reconciler owns disposal of their children. ### How core reaches three Nothing in `src/core/` knows which renderer it will run with, and nothing there imports a three value: - **`state.internal.support`** carries the renderer-specific classes core touches by name, and the three namespace of that flavour. JSX element names resolve against explicit `extend()` registrations first, then against `support.three` (`src/core/extend.ts`), so a WebGPU root sees node materials and a WebGL root does not. No entry calls `extend(THREE)` at import time any more; that call is what used to force a whole namespace into every entry's eager graph. - **`getThree()`** (`src/core/three.ts`) is three's _shared core_: `Vector3`, `Scene`, `Raycaster`, the constants. The first support that loads registers its namespace; both flavours export the very same objects for these, so one registration serves every root. It is available before any element renders, hook runs or `onCreated` fires, and throws a pointed error before that. - **Store objects that are three instances** (`pointer`, `frustum`, the viewport's scratch vectors) are created in `configure()` once the support is loaded, not in `createStore`. Module-level scratch objects (`utils/three.ts`, `visibility.ts`) are created on first use. - **Three addons** (`three/examples/jsm/*`) import from `three`, so they are loaded with a dynamic import where they are used: the environment decoders in `useEnvironment.tsx` (three's own HDR, EXR, Ultra HDR and cube loaders; no third-party decoder), `GroundedSkybox` in `Environment.tsx`. On the root entry and `/webgpu`, an app that uses an `.hdr`/`.exr` environment therefore also fetches the chunk that holds `three.module.js` (the addon needs it), lazily. ```typescript // In src/core/: types are free, values are not import type { Scene, Vector3 } from 'three' import type { WebGPURenderer } from 'three/webgpu' const scene = new (getThree().Scene)() // shared core const fbo = new support.CubeRenderTarget(size) // renderer-specific, from the root's support const { Node } = support.occlusion // WebGPU only ``` ### The contract for libraries A library that imports anything except `Canvas` or `createRoot` from `@react-three/fiber` adds **no renderer** to an app, on any entry, and no second copy of core. `@react-three/fiber/extension` remains the stable, smallest surface for packages that build on fiber (`@react-three/tsl` uses it). `pnpm verify-treeshake` builds such consumers with Vite and esbuild and asserts all of it on the output; see [TESTING.md](./TESTING.md#bundle--type-verification-built-output). ### Element types per entry `ThreeElementsOf` (`types/three.d.ts`) derives a JSX map from a three namespace. Each entry declares its own `ThreeElements` from it and augments react's `IntrinsicElements` at the bottom of its file: the root entry from `typeof import('three') & typeof import('three/webgpu')`, `/legacy` from `three` only, `/webgpu` from `three/webgpu` only. So `/legacy` never advertises a `` it cannot build, and the shared declaration chunk carries no augmentation at all. `import type { ReactThreeFiber } from '@react-three/fiber/legacy'` gives that entry's namespace (`types/entries/*.d.ts`). ### Stub Mode for Development `unbuild --stub` creates lightweight stubs that redirect to source: ```javascript // dist/index.mjs (stub) import * as module from '../src/index.tsx' export * from '../src/index.tsx' export default module.default ``` Code changes reflect immediately without rebuilding. ### Build Outputs ```text packages/fiber/dist/ ├── index.{mjs,cjs} # Default entry: glue + lazy provider ├── legacy.{mjs,cjs} # Legacy entry: glue + static WebGL support ├── webgpu/ │ └── index.{mjs,cjs} # WebGPU entry: glue + static WebGPU support ├── extension.{mjs,cjs} # Three-free surface for libraries ├── chunks/ │ ├── webgl.{mjs,cjs} # WebGL support: the one module importing `three` │ └── webgpu.{mjs,cjs} # WebGPU support: the one module importing `three/webgpu` + `three/tsl` └── shared/ └── fiber..{mjs,cjs} # Core, imported by every entry ``` Both `pnpm verify-treeshake` and `pnpm verify-types` run against this output; `pnpm run ci` and CI run them right after the build. --- ## Adding a New Entry Point To add a specialized bundle (e.g., a new rendering backend): 1. **Create the support** — `src/support/my-backend.ts`, modelled on `src/support/webgl.ts`: the three namespace it registers and the renderer-specific classes core needs (extend `types/provider.d.ts`) 2. **Create the entry** — `src/my-entry.tsx`, modelled on `src/legacy.tsx`: a provider with the support(s) it can construct, the `createRoot`/`Canvas` wrappers, its `R3F_BUILD_*` flags, core re-exports, and its `ThreeExports`/`ThreeElements` with the JSX augmentation 3. **Configure Unbuild** — Add the entry to `entries` in `build.config.ts`; it joins the shared chunk automatically 4. **Update exports** — Add the sub-path to `package.json` exports 5. **Audit** — Add cases to `scripts/verify-treeshake.js`, `scripts/verify-types.js` (+ a consumer in `scripts/type-tests/`), `packages/fiber/tests/entries.test.tsx` and `tests/bundles.test.ts` --- ## Publishing `@react-three/tsl` for the first time `@react-three/tsl` is in `scripts/release.js` and `.github/workflows/canary.yml`. The canary publishes with npm trusted publishing (OIDC, no token), which can only be configured for a package that already exists on npm, so the very first version is published by hand, as a canary, **before** the change that adds tsl to the canary workflow is merged. Otherwise the first canary run after that merge fails at tsl, after fiber and test-renderer have already published. 1. **Check out the branch that adds tsl to the canary workflow** (not `v10`) and build from a clean install: `pnpm install && pnpm build && pnpm verify-treeshake && pnpm verify-types`. 2. **Publish the first version by hand**, logged in to npm as a `@react-three` maintainer, stamped like the workflow stamps canaries: ```bash cd packages/tsl npm version "10.0.0-canary.$(git rev-parse --short HEAD)" --no-git-tag-version pnpm publish --tag canary --access public --no-git-checks --dry-run # check name, version, files pnpm publish --tag canary --access public --no-git-checks git checkout package.json ``` `--no-git-checks` skips pnpm's publish-branch check (it expects `master`/`main`; the canary workflow passes the same flag). On a brand-new package npm also points `latest` at this first version; fix that later with `npm dist-tag add` once there is a real release. 3. **Configure trusted publishing** on npmjs.com → `@react-three/tsl` → Settings: GitHub Actions, repository `pmndrs/react-three-fiber`, workflow `canary.yml`. 4. **Merge the branch.** From then on every push to `v10` publishes a tsl canary next to fiber's, and `pnpm release` publishes it with the other packages (its version is bumped by hand with theirs). tsl's peer range, `^10.0.0-alpha.6`, accepts every fiber canary and every release after `10.0.0-alpha.5`, the last version without the `@react-three/fiber/extension` entry tsl needs. To check the package as npm will see it without publishing: `cd packages/tsl && pnpm pack`, then install the tarball (next to a packed fiber) into a fresh app. ## Migration History ### Jest → Vitest (v10) Vitest provides faster native ESM testing with better React 19 compatibility. - Full suite runs \~2x faster - No complex Babel transformations - Simplified `act` synchronization in JSDOM ### Yarn → pnpm (v10) pnpm's strictness and efficiency make it preferred for modern React monorepos. ### Preconstruct → Unbuild (v10) Preconstruct couldn't support per-entry alias resolution for our THREE.js import strategy. - Simpler project structure (no stub folders) - Better tree-shaking and bundle optimization - Per-entry THREE.js import control - Faster development with better stub support ]]> ` on `@react-three/fiber` renders with WebGPURenderer; `/legacy` keeps WebGLRenderer, `/webgpu` loads WebGPU statically - **Native Split** — React Native support moves to the separate `@react-three/native` package ([pmndrs/native](https://github.com/pmndrs/native), not yet on npm) - **New Build System** — Unbuild, one build with a shared core; renderer supports loaded on demand - **Modern Tooling** — pnpm workspaces and Vitest for testing These changes mean an app downloads only the renderer its Canvas asks for, and libraries built on fiber add no renderer at all. --- ## Development Workflow //\* Development Cycle =================================== ```bash pnpm dev # Generate stubs linking src → dist pnpm examples # Launch example suite for UI testing pnpm test # Run Vitest suite pnpm build # Generate production bundles ``` The general loop: 1. `pnpm dev` — Start development mode 2. Edit files in `packages/fiber/src/` 3. `pnpm test` — Verify changes 4. `pnpm examples` — Visual verification --- ## Project Structure ```text packages/fiber/ ├── src/ │ ├── index.tsx # Default entry (WebGL + WebGPU) │ ├── legacy.tsx # Legacy entry (WebGL only) │ ├── webgpu/ # WebGPU-specific entry/logic │ ├── core/ # Core reconciler and hooks (shared) │ └── support/ # webgl.ts / webgpu.ts: the only modules that import three ├── types/ # Internal TypeScript definitions ├── tests/ # Vitest tests └── build.config.ts # Build entry point configuration ``` - **Shared Logic** — Most changes happen in `src/core/` - **Three imports** — Core never imports a three value; it reads classes from the renderer support a root loaded (see [BUILD](./BUILD.md)) --- ## Adding Features //\* All Entry Points ------------------------------------ 1. Add logic in `src/core/` 2. Export from `src/core/index.tsx` 3. Automatically available in all bundles //\* WebGPU-Specific ------------------------------------- 1. Add code to `src/webgpu/` 2. Export from `src/webgpu/index.tsx` 3. Only exported from `@react-three/fiber/webgpu` //\* New THREE.js Imports -------------------------------- 1. In `src/core/`, `import type` only. Read a shared-core class from `getThree()`, a renderer-specific one from `state.internal.support` 2. A renderer-specific class core does not have yet goes on the support (`types/provider.d.ts`, `src/support/webgl.ts`, `src/support/webgpu.ts`) 3. A three addon is loaded where it is used, with a dynamic import --- ## Documentation Index | Guide | Purpose | | --------------------------- | ----------------------------------------------------------------- | | **[BUILD](./BUILD.md)** | Build system architecture, renderer supports, adding entry points | | **[TESTING](./TESTING.md)** | Testing strategy, bundle verification, troubleshooting | Design rationale is not kept as separate documents — it lives with the feature it explains. See [README](./README.md#where-design-rationale-lives). ]]> `, events, TSL HMR). It runs on a machine with a GPU as a **pre-release gate**, not on every PR. _(Strategy documented below; harness not yet built.)_ If you only remember one rule: **a green `pnpm test` does not prove the WebGPU runtime works** — `pnpm test:gpu` proves the part of it that runs headless, and Tier 2 the rest. --- ## The three-tier model R3F spans pure JS, React reconciliation, and a GPU runtime that jsdom cannot execute. One test runner can't cover all of that, so we split by _what each tier can actually prove_. | Tier | Command | Runs where | Proves | Gate | | ----------------------- | ------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | | **1 — Unit + mock** | `pnpm test` | CI, every PR (jsdom) | Pure-JS logic, React behavior, hook create/update/dispose/rebuild against the `WebGPUContext` mock, export parity | **Per-PR (blocking)** | | **2 — Browser / GPU** | `pnpm test:browser` _(planned)_ | Local GPU box / display | Real WebGPU in a browser: `` init and first frame, render-pipeline delegation, occlusion, TSL HMR, events | **Pre-release (manual)** | | **3 — Headless WebGPU** | `pnpm test:gpu` | CI, every PR (own job) | Real WebGPU without a browser or physical GPU (Dawn in Node): rendered pixels, depth attachments, node materials and TSL uniforms, multi-canvas sharing, renderer disposal | **Per-PR** | **The remaining CI gap:** per-PR CI now guards the mock + pure-JS layer (Tier 1) and what a root draws on a real device (Tier 3). What needs a browser (``, DOM events, Vite HMR) or a hardware GPU's behaviour is still caught at the Tier-2 pre-release gate, not on the PR that introduces it. **Design principle — push work down a tier.** The cheapest, most reliable test is a Tier-1 test. Before writing a GPU test, ask: _can this run against the `WebGPUContext` mock instead?_ Lifecycle, wiring, store resolution, and rebuild logic usually can. Genuinely GPU-dependent behavior (does it actually render? is the depth correct?) goes to Tier 3 if it can run on `createRoot` without a DOM, and to Tier 2 only if it cannot. --- ## Tier 1 — Unit + mock (Vitest) This is the suite you run constantly and the one CI blocks on. It runs against **source files** (not `dist`) via the aliases in [`vitest.config.ts`](../../vitest.config.ts), in a jsdom environment, with v8 coverage. ```bash pnpm test # full run + coverage (CI parity) pnpm test:watch # watch mode, no coverage pnpm vitest packages/fiber/tests/events.test.tsx # single file pnpm vitest -t "interactivePriority" # by test name ``` ### What Tier 1 covers - **Pure-JS logic** — the multi-canvas registry in `renderer.ts`, the `renderer` config-bag parser, `ScopedStore` Proxy semantics, prop diffing, background parsing. > **The frame scheduler is an external dependency** (`@pmndrs/scheduler`) as of alpha.3 — its internals (phase graph, topo sort, fps rate limiter) are unit-tested **upstream**, not here. This repo tests the **integration**: that `useFrame` drives the scheduler correctly (phase ordering, render-phase takeover, fps throttling end-to-end, pause/resume). Don't re-test scheduler internals in R3F. - **React behavior** — reconciliation, hooks (`useFrame`, `useThree`, `useLoader`, …), event handling and raycasting, store updates. - **Export parity** — each entry point (`default` / `legacy` / `webgpu`) exports the right symbols (`tests/{default,legacy,webgpu}/index.test.tsx`). - **WebGPU hook lifecycle via the mock** — create / update / dispose / rebuild for the TSL hooks, exercised against `WebGPUContext` (see [Mocks](#mocks-and-the-webgpu-context)). ### What Tier 1 _cannot_ cover Anything that needs a real GPU device: actual rendering, node-material compilation, render-pipeline output, occlusion queries, real TSL HMR. Those are Tier 3 (headless) or Tier 2 (browser). Don't fake a passing assertion for them in jsdom — write the Tier-3 test, or leave an `it.todo` pointing at the Tier-2 check. ### Mocks and the WebGPU context jsdom has no WebGL or WebGPU. We stub them so Tier 1 can run: - [`packages/fiber/tests/setupTests.ts`](../../packages/fiber/tests/setupTests.ts) — mocks WebGL2, `ResizeObserver`, `PointerEvent`, and suppresses the benign "multiple instances of Three.js" warning. - [`packages/test-renderer/src/WebGPUContext.ts`](../../packages/test-renderer/src/WebGPUContext.ts) — a mock WebGPU context that lets hook lifecycle tests run without a device. **The `WebGPUContext` mock is the single biggest lever for CI coverage.** Every behavior we can drive against it is a behavior that moves from "Tier-2 browser-only" into "Tier-1, guarded on every PR." Investing in the mock is preferred over deferring a hook to browser-only validation. Document clearly, in the test, which paths are mock-validated vs which still require Tier 2. --- ## Tier 2 — Browser / GPU validation **Status: strategy decided, harness not yet built (planned `pnpm test:browser`).** Part of the checklist below now runs headless on every PR in [Tier 3](#tier-3--headless-webgpu-in-ci-pnpm-testgpu); the rest still needs a browser. WebGPU is a first-class, baseline path in v10 — not an exotic add-on. The stable WebGPU surface ships in 10.0, so it must be validated against a real device before each release. Tier 2 is that gate. ### How it works Non-headless Playwright (`headless: false`, Chromium with WebGPU enabled) drives the **example app** on a machine with a real GPU + display. It is scripted and repeatable — it replaces ad-hoc "I clicked around and it looked fine" browser checks. It is **not** a per-PR CI gate (it needs a physical GPU + display). Treat it as a **pre-release gate**: run it during a release pass and before each promotion (alpha → beta → RC → stable). ### What Tier 2 must prove (the checklist) These are the real-GPU behaviors no jsdom/mock test can reach. Each should become a scripted Playwright check against the example app. _Tier 3 notes_ say which part already runs headless on `createRoot`; the browser check still owes the `` half. 1. **Basic WebGPU render** — `` inits without a manual `renderer.init()`; first frame draws; no depth mismatch. _Tier 3: `render.gpu.test.tsx`, `primary-depth.gpu.test.tsx`._ 2. **WebGPU-only entry** — `@react-three/fiber/webgpu` runs with no WebGL renderer loaded; node materials resolve from the root's namespace. _Tier 3: node materials on the root entry, `render.gpu.test.tsx`._ 3. **Multi-canvas shared renderer** — a `` + a plain secondary `` (auto-sharing) and one with `share="main"` share one renderer, switch targets correctly, clean up, render after the primary by default, honor `scheduler={{ after, fps }}`, **and share state via `primaryStore`**. jsdom covers the sharing rules and timing (`tests/multi-canvas-primary.test.tsx`); this checks the pixels. _Tier 3: `multi-canvas.gpu.test.tsx`, and the shared uniform in `packages/tsl/tests/gpu/uniforms.gpu.test.tsx`._ 4. **Occlusion** — `onOccluded` / `onVisible` fire only on state change; clean up on unmount. 5. **Render pipeline** — `useRenderPipeline` makes the default render delegate to `renderPipeline.render()`. 6. **TSL HMR** — editing a TSL node in Vite rebuilds without a full reload and leaves no stale nodes/uniforms. 7. **Camera parenting** — camera children render and clean up. ### When the harness is built (future) ```bash pnpm test:browser # run the Playwright/WebGPU suite against the example app (planned) ``` Proposed layout: a separate Playwright project (e.g. `e2e/` or `packages/fiber/e2e/`) with `*.gpu.spec.ts` files and its own config — kept **out** of the Vitest `include` glob so it never runs accidentally in jsdom CI. Until then, the checklist above is run manually on a GPU box. --- ## Tier 3 — Headless WebGPU in CI (`pnpm test:gpu`) R3F against a real WebGPU device, with no browser and no physical GPU. [`vitest-environment-webgpu-node`](https://github.com/bhouston/vitest-gpu/tree/main/packages/vitest-environment-webgpu-node) backs `navigator.gpu` with Google's Dawn in the Node process and provides a headless canvas whose `webgpu` context renders into a real texture; the tests read that texture back. It runs on every PR as the `Headless WebGPU tests` job in [`.github/workflows/test.yml`](../../.github/workflows/test.yml). ```bash pnpm test:gpu # the whole Tier-3 project pnpm test:gpu packages/fiber/tests/gpu/render.gpu.test.tsx # one file UPDATE_SCREENSHOTS=1 pnpm test:gpu # rewrite screenshot baselines (see below) ``` ### Running it locally - **Linux:** Dawn needs Vulkan. Without a GPU (a container, WSL, a CI runner) install Mesa's software Vulkan driver, lavapipe: `sudo apt-get install -y mesa-vulkan-drivers`. A machine with a GPU and its Vulkan driver works as is. - **macOS:** Dawn uses Metal. Nothing to install. - **Windows:** not tried; Dawn's D3D12 backend should work, but CI does not cover it. It needs Node 22+ (the repo's version). The suite takes a few seconds. ### How it is set up - [`vitest.gpu.config.ts`](../../vitest.gpu.config.ts) is its own Vitest config with `environment: 'webgpu-node'`. It shares the jsdom project's source aliases and **nothing else**: no `setupFiles` (they mock WebGL and WebGPU, which must stay real here) and no coverage. - It includes `packages/**/tests/gpu/**/*.gpu.test.{ts,tsx}`; [`vitest.config.ts`](../../vitest.config.ts) excludes `**/tests/gpu/**`, so `pnpm test` and the coverage ratchet never load these files. - [`packages/fiber/tests/gpu/harness.tsx`](../../packages/fiber/tests/gpu/harness.tsx) has the helpers: `mount()` configures a root on a headless canvas (`frameloop: 'never'`, a camera framing a 2x2 quad, tone mapping off so read-back colours equal material colours), `frame()` draws one frame through R3F's own loop, and `expectColor()` compares a pixel with a per-channel tolerance. ### What runs there | File | Proves | | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `packages/fiber/tests/gpu/render.gpu.test.tsx` | The root entry builds a `WebGPURenderer` on the WebGPU backend (`gl === renderer`), draws the first frame without a manual `init()`, compiles node materials, and matches a screenshot baseline | | `packages/fiber/tests/gpu/primary-depth.gpu.test.tsx` | A lone primary's depth attachment matches its drawing buffer, at mount and after a resize (#3847 / #3905: Dawn rejects the frame if it does not) | | `packages/fiber/tests/gpu/multi-canvas.gpu.test.tsx` | Secondaries (`share="id"` and automatic) draw their own scenes through the primary's renderer, at their own sizes, including after resizing while inactive | | `packages/fiber/tests/gpu/renderer-dispose.gpu.test.tsx` | Unmount destroys the device of a renderer R3F built (a shared one only after its last canvas), and leaves a caller's renderer alive and drawing | | `packages/tsl/tests/gpu/uniforms.gpu.test.tsx` | A `useUniforms` uniform in a node material recolours the output when `.value` changes, with no re-render; a secondary reads the primary's uniform | ### Limits - **No DOM.** The environment provides `window`, `document`, a canvas, `Image`, `fetch` and `requestAnimationFrame`, but no react-dom layout, `ResizeObserver` or pointer events. Use `createRoot` with an explicit `size`, never ``. `` and event tests stay in jsdom (Tier 1) and the browser (Tier 2). - **Not a browser.** Dawn in Node is the same implementation Chrome uses, but browser integration (presentation, `devicePixelRatio`, HMR) is Tier 2. - **Software rasterizer in CI.** lavapipe is slow for heavy scenes and rounds slightly differently from a hardware GPU. Assert colours with `expectColor`'s tolerance, not exact equality, and keep scenes small (32–64 px). ### Screenshot baselines [`vitest-screenshot`](https://github.com/bhouston/vitest-gpu/tree/main/packages/vitest-screenshot) adds `await expect(canvas).toMatchScreenshot('name.png', options)`. Baselines are PNGs in `__screenshots__/` beside the test, and they are committed. - A missing baseline is written on a local run and **fails in CI**, so commit the new PNG with the test. - Rasterizers disagree on edge pixels: always pass a tolerance, e.g. `comparatorOptions: { allowedMismatchedPixelRatio: 0.02 }`. - Regenerate with `UPDATE_SCREENSHOTS=1 pnpm test:gpu` (or `pnpm test:gpu -u`) **only when the rendering change is intended**, and look at the new PNG before committing it. - On a mismatch, `.actual.png` and `.diff.png` are written next to the baseline (gitignored); CI uploads them as the `gpu-screenshots` artifact. Prefer pixel assertions (`expectColor`) for behaviour; keep screenshots for "does the whole picture still look right". --- ## Local ↔ CI parity (one source of truth) The local full gate and the CI workflow **must run the same checks in the same way**, or they drift and "passes locally" stops meaning anything. - **Local full gate:** `pnpm run ci` → `build → verify-treeshake → verify-types → typecheck → eslint → dev → test → format`. - **CI workflow:** [`.github/workflows/test.yml`](../../.github/workflows/test.yml) — runs on PRs and `master`, across a React version matrix (19.0.0 + latest). > CI runs `verify-treeshake` and `verify-types` right after the build, matching the local order. **Rule for new checks:** if you add a verification step, add it to _both_ the `ci` script and the workflow — or, better, add it to the shared script the workflow calls. > The one exception is `pnpm test:gpu`: it is a separate CI job and stays out of `pnpm run ci`, because it needs a Vulkan driver (Linux) that not every contributor machine has. Run it yourself when you touch rendering, the renderer lifecycle or multi-canvas. --- ## Test organization & conventions ### Where tests live | Location | Contents | Naming | | ----------------------------------------------- | --------------------------------------------------------------------------------- | ---------------------- | | `packages/fiber/tests/*.test.tsx` | Core unit + integration tests (events, hooks, renderer, scheduler, visibility, …) | `.test.tsx` | | `packages/fiber/tests/{default,legacy,webgpu}/` | Per-entry export-parity tests | `index.test.tsx` | | `packages/fiber/tests/setupTests.ts` | Global mocks (WebGL2, ResizeObserver, PointerEvent) | — | | `packages/fiber/tests/gpu/` | Tier-3 headless WebGPU tests, `harness.tsx` helpers, `__screenshots__/` baselines | `.gpu.test.tsx` | | `packages/tsl/tests/gpu/` | Tier-3 tests of the TSL hooks on a real device | `.gpu.test.tsx` | | `packages/test-renderer/src/__tests__/` | Test-renderer suite | `RTTR..test.tsx` | | `packages/test-renderer/src/WebGPUContext.ts` | WebGPU mock used for hook lifecycle tests | — | | `e2e/` _(planned)_ | Tier-2 Playwright/WebGPU checks | `*.gpu.spec.ts` | ### Conventions - **One area per file.** Match the source file/feature you're testing (`events.ts` → `events.test.tsx`). - **Co-locate TSL hook tests** in `packages/tsl/tests/` (fiber's own WebGPU-entry tests stay in `packages/fiber/tests/webgpu/`) and drive them against `WebGPUContext`. Note in the file which assertions are mock-validated vs Tier-2-only. - **Keep GPU tests out of jsdom.** Tier-3 tests are `*.gpu.test.tsx` under a `tests/gpu/` folder, which only `vitest.gpu.config.ts` includes. Tier-2 browser specs use a distinct extension (`*.gpu.spec.ts`) and live outside both `include` globs. - **Label the irreducible.** If a behavior genuinely needs a GPU, write the Tier-3 test; if it needs a browser too, leave an `it.todo('covered by Tier 2: ')` rather than a hollow jsdom assertion — that keeps the gap visible. - **Tests run against source**, not `dist`. Bundle/type correctness is verified separately (below). ### How to add a test - **Pure-JS / React behavior** → add a `*.test.tsx` in `packages/fiber/tests/`, run `pnpm test:watch`. - **A TSL hook's lifecycle** → add a test under `packages/tsl/tests/` driven by `WebGPUContext`; cover create/update/dispose/rebuild. If a path needs a real device, stop and add it to the Tier-2 checklist instead. - **A new export** → add/extend the parity test in `tests/{default,legacy,webgpu}/`. - **A real-GPU behavior** → if it runs on `createRoot` without a DOM, add a `*.gpu.test.tsx` under `packages//tests/gpu/` using `harness.tsx` and run `pnpm test:gpu` ([Tier 3](#tier-3--headless-webgpu-in-ci-pnpm-testgpu)). Otherwise add it to the [Tier-2 checklist](#what-tier-2-must-prove-the-checklist). --- ## Coverage Coverage uses the v8 provider; reporters are `text`, `text-summary`, `json`, and `html` (configured in [`vitest.config.ts`](../../vitest.config.ts)). Open `coverage/index.html` after `pnpm test` for the line-by-line view. ### Current policy: a ratchet that blocks drops `pnpm test` runs with `--coverage`, and `vitest.config.ts` sets global `coverage.thresholds` (lines, functions, statements, branches). If any total falls below its threshold the run fails, so **CI fails on a coverage drop**. The thresholds are a **ratchet, not a target**: each sits just under what the suite currently achieves, measured on the lower of the CI React legs (`19.0.0` reads slightly below `latest`) and floored with \~0.5–1% headroom so neither leg flakes. When coverage goes up, raise them; never lower one to make a build pass. `lines` is the meaningful figure for R3F's own code — statements/branches read low because v8 also counts the bundled three.js code the suite touches. The per-area targets below are **not** enforced by config; treat them as expectations, not gates. ### Soft targets by area | Area | Target | Rationale | | ---------------------------- | -------------------------------- | -------------------------------------------------------------------- | | `src/core` (pure-JS + React) | **\~80% lines** | jsdom can exercise nearly all of it; no excuse for gaps | | `src/webgpu` (TSL hooks) | **best-effort via the mock** | jsdom can't run the GPU; raise the `WebGPUContext` mock to lift this | | New / changed v10 surface | **dedicated test before stable** | a green suite must actually guard the headline features | ### Highest-value coverage to add These are the v10-_changed_ surfaces with little or no dedicated coverage — the highest return per test: - **Render-phase takeover** — default render skipped when a `{ phase: 'render' }` job is registered; resumes on unmount. _(integration with `@pmndrs/scheduler`)_ - **fps throttling end-to-end** via `useFrame({ fps: N })` (`drop: true/false`). _(integration, not the upstream `shouldRun` predicate)_ - **Canvas size control** — `width`/`height`/`forceEven`; `setSize()` variants + ownership state machine; DPR. - **Multi-canvas pure-JS** — `renderer.ts` register/wait/unregister; the `renderer` config-bag parser. - **`ScopedStore` Proxy** — get / `.scope()` / `.has()` / `.keys()` / `Object.keys()` / spread / `for…in` / missing-scope. - **`interactivePriority` sort**, **XR `registerPointer`/`unregisterPointer`**, **frame-timed event edges**, **`textureColorSpace`**, **`gl` deprecation warning path**, **`useRenderTarget` per-entry differences**. - **WebGPU hook lifecycle via the mock** — `useUniforms` / `useNodes` / `useBuffers` / `useGPUStorage` / `useRenderPipeline` create/update/dispose/rebuild; `_hmrVersion` rebuild logic. > Any per-file coverage numbers quoted in docs are point-in-time snapshots — re-run `pnpm test` for current numbers; don't trust a table after the code moves. --- ## Bundle & type verification (built output) Separate from the test suite: these check the **built `dist`**, not source. What matters is what an app downloads, and that depends on transitive imports, chunk assignment and the bundler -- none of which a grep over `dist` can see. So `verify-treeshake` writes small consumer apps, bundles each with Vite (Rollup) and with esbuild, and checks the output. ```bash pnpm build && pnpm verify-treeshake # consumer bundles carry exactly the expected renderer(s) pnpm verify-types # per-entry declarations: ThreeExports, JSX augmentation, hygiene, consumers pnpm analyze-fiber # dry-run @react-three/fiber package contents pnpm analyze-test # dry-run @react-three/test-renderer package contents ``` What `verify-treeshake` checks, per bundler: - **Root app** (`Canvas` from `@react-three/fiber`): the eager output carries neither renderer; the one lazy renderer chunk carries WebGPU only, and no chunk carries WebGL. - **`/webgpu` app**: the WebGPU renderer and never the WebGL one. **`/legacy` app**: the reverse. - **A library importing hooks** from the root or `/extension` entry adds no renderer, next to any app, and core is bundled once. - **Root app with ``**: the decoders stay lazy. `R3F_TREESHAKE_DUMP=1` keeps every case's output under `node_modules/.cache/r3f-verify-treeshake`; `R3F_TREESHAKE_VERBOSE=1` prints every check. These belong in CI as well as locally — see the parity note above. --- ## Troubleshooting **Changes not showing?** Run `pnpm stub` to regenerate the `dist/` → `src/` development links. **Import errors in the IDE?** Restart the TypeScript server or run `pnpm typecheck`. **"EPERM" / symlink errors on Windows?** Enable [Developer Mode](https://howtogeek.com/292914/what-is-developer-mode-in-windows-10) for symlink support. **"Multiple instances of Three.js" warning?** Common in Vitest/jsdom — safe to ignore; common variants are suppressed in `setupTests.ts`. **"Cannot find module" errors?** Ensure `pnpm install` ran; if the error references `dist`, run `pnpm stub`. **`verify-treeshake` fails?** You must `pnpm build` first (`pnpm install` re-stubs `dist`). A renderer in the eager output means something in `src/core/` imports a value from `three`/`three/webgpu`, or a module that does (a three addon) is imported statically; see [BUILD.md](./BUILD.md). **A test on the root entry fails inside three's WebGL backend?** The root entry renders with `WebGPURenderer`, which in jsdom runs its WebGL2 fallback backend against the WebGL mock. `setupTests.ts` stubs what that backend needs and jsdom lacks (`ImageBitmap`; the mock's `getSupportedExtensions`), so a frame can render. Tests that exercise core behaviour (events, visibility, sizing, Suspense) rather than the renderer still import `Canvas` / `createRoot` from `../src/legacy`, which renders through the WebGL mock directly; that keeps them independent of the WebGPU backend. Tests of WebGPU behaviour mock the renderer (see `renderer-lifecycle.test.tsx`) or use `frameloop: 'never'`. **Tests rendering on the root entry time out or find `scene` null?** The root entry loads a renderer support with a dynamic import. `setupTests.ts` imports both supports so that import is cached and resolves inside `act()`; a test that builds a bare store with `createStore` must `registerThree(...)` itself (see `webgpu/useRenderTarget.test.tsx`). **`pnpm test:gpu` fails with no adapter, or `navigator.gpu.requestAdapter()` returns null?** Dawn found no Vulkan driver. On Linux install `mesa-vulkan-drivers` (lavapipe); check `/usr/share/vulkan/icd.d/` lists `lvp_icd.json`. **A Tier-3 test logs a WGSL error (`... is a reserved keyword`) and reads back transparent pixels?** The shader failed to compile on the device; the jsdom mock never compiles shaders, so only Tier 3 sees this. A uniform's name becomes its WGSL identifier, so a `useUniforms` key such as `shared` breaks compilation. **A Tier-3 screenshot fails in CI but passes locally?** Your machine rasterizes differently from lavapipe. Look at the uploaded `gpu-screenshots` artifact; raise the tolerance only if the diff is edge pixels, and regenerate the baseline on Linux with lavapipe if the picture legitimately changed. **Package too small in `analyze-*` dry-run?** If it shows \~100–200 KB instead of the expected \~MB, `dist/` is being excluded — ensure the package's `files` field includes `dist`. --- ## Unification & coverage roadmap Open work to bring local/CI testing fully into line with this guide. Trim items as they land. ### Parity & CI - [x] **Make CI run the same checks as `pnpm run ci`.** `verify-treeshake` + `verify-types` now run in [`.github/workflows/test.yml`](../../.github/workflows/test.yml) right after Build, matching the local `pnpm run ci` order. _(task D5)_ - [x] **Surface coverage in CI** — the `text-summary` reporter prints totals in the run log, and the `coverage/` report is uploaded as a build artifact (from the `latest` React leg). ### Coverage - [x] Add the highest-value tests listed above — landed: render-phase takeover, fps throttling, Canvas size control, multi-canvas pure-JS, `ScopedStore`, `interactivePriority`, XR pointers, frame-timed events, `textureColorSpace`, `gl` deprecation (+ hardened heuristic, task D1), `useRenderTarget`, and WebGPU hook lifecycle. Overall lines coverage \~78%. - [x] **Coverage floor** — global `coverage.thresholds` in `vitest.config.ts` fail `pnpm test` (and CI) on a drop; see [Current policy](#current-policy-a-ratchet-that-blocks-drops). _(task D4)_ - [ ] **Per-area floors** — the soft targets above (e.g. \~80% lines on `src/core`, lower on `src/webgpu`) are not yet enforced per directory. ### WebGPU tiers - \[\~] **Invest in the `WebGPUContext` mock** — done for the TSL hook lifecycle (`useUniforms`/`useNodes`/`useBuffers`/`useGPUStorage`/`useRenderPipeline` now 62–90% via the mock). Extend further as new GPU paths become mockable. - [ ] **Stand up the Tier-2 Playwright harness** (`pnpm test:browser`, Chromium + WebGPU, pointed at the example app) so the [Tier-2 checklist](#what-tier-2-must-prove-the-checklist) is a runnable suite, not a manual list. _(task C0)_ - [x] **Tier 3 in CI** — `pnpm test:gpu` runs R3F on Dawn (vitest-environment-webgpu-node) on every PR, on lavapipe. _(task D8, #4015)_ - [ ] **More Tier-3 coverage** — `useRenderPipeline` output, `onOccluded` / `onVisible` with real occlusion queries, `useRenderTarget` read-back, and a macOS (Metal) leg in CI. ]]>