├── .changeset ├── README.md └── config.json ├── .codesandbox └── ci.json ├── .eslintignore ├── .eslintrc.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── issue_template.md └── workflows │ └── test.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .prettierrc ├── CONTRIBUTING.md ├── LICENSE ├── babel.config.js ├── docs ├── API │ ├── additional-exports.mdx │ ├── canvas.mdx │ ├── events.mdx │ ├── hooks.mdx │ └── objects.mdx ├── advanced │ ├── pitfalls.mdx │ └── scaling-performance.mdx ├── banner-journey.jpg ├── banner-r3f.jpg ├── basic-app.gif ├── getting-started │ ├── basic-example.gif │ ├── examples.mdx │ ├── installation.mdx │ ├── introduction.mdx │ └── your-first-scene.mdx ├── preview.jpg └── tutorials │ ├── basic-animations.mdx │ ├── events-and-interaction.mdx │ ├── gltfjsx.png │ ├── how-it-works.mdx │ ├── loading-models.mdx │ ├── loading-textures.mdx │ ├── testing.mdx │ ├── typescript.mdx │ ├── using-with-react-spring.mdx │ └── v8-migration-guide.mdx ├── example ├── .gitignore ├── CHANGELOG.md ├── favicon.svg ├── index.html ├── package.json ├── public │ ├── Parrot.glb │ ├── Stork.glb │ ├── apple.gltf │ ├── bottle.gltf │ ├── farm.gltf │ ├── lightning.gltf │ └── ramen.gltf ├── src │ ├── App.tsx │ ├── demos │ │ ├── Animation.tsx │ │ ├── AutoDispose.tsx │ │ ├── ClickAndHover.tsx │ │ ├── ContextMenuOverride.tsx │ │ ├── Gestures.tsx │ │ ├── Gltf.tsx │ │ ├── Inject.tsx │ │ ├── Layers.tsx │ │ ├── Lines.tsx │ │ ├── MultiMaterial.tsx │ │ ├── MultiRender.tsx │ │ ├── MultiView.tsx │ │ ├── Pointcloud.tsx │ │ ├── Portals.tsx │ │ ├── Reparenting.tsx │ │ ├── ResetProps.tsx │ │ ├── SVGRenderer.tsx │ │ ├── Selection.tsx │ │ ├── StopPropagation.tsx │ │ ├── SuspenseAndErrors.tsx │ │ ├── SuspenseMaterial.tsx │ │ ├── Test.tsx │ │ ├── ViewTracking.tsx │ │ ├── Viewcube.tsx │ │ └── index.tsx │ ├── index.tsx │ └── styles.tsx ├── tsconfig.json ├── typings │ └── global.d.ts ├── vite.config.ts └── yarn.lock ├── jest.config.js ├── package.json ├── packages ├── eslint-plugin │ ├── .npmignore │ ├── CHANGELOG.md │ ├── README.md │ ├── docs │ │ └── rules │ │ │ ├── no-clone-in-loop.md │ │ │ └── no-new-in-loop.md │ ├── package.json │ ├── scripts │ │ └── codegen.ts │ ├── src │ │ ├── configs │ │ │ ├── all.ts │ │ │ └── recommended.ts │ │ ├── index.ts │ │ ├── lib │ │ │ └── url.ts │ │ └── rules │ │ │ ├── index.ts │ │ │ ├── no-clone-in-loop.ts │ │ │ └── no-new-in-loop.ts │ └── tests │ │ └── rules │ │ ├── no-clone-in-loop.test.ts │ │ └── no-new-in-loop.test.ts ├── fiber │ ├── .npmignore │ ├── CHANGELOG.md │ ├── __mocks__ │ │ ├── expo-asset.ts │ │ ├── expo-file-system.ts │ │ ├── expo-gl.ts │ │ ├── react-native.ts │ │ └── react-use-measure.ts │ ├── native │ │ └── package.json │ ├── package.json │ ├── readme.md │ ├── src │ │ ├── core │ │ │ ├── events.ts │ │ │ ├── hooks.tsx │ │ │ ├── index.tsx │ │ │ ├── loop.ts │ │ │ ├── renderer.ts │ │ │ ├── store.ts │ │ │ └── utils.ts │ │ ├── index.tsx │ │ ├── native.tsx │ │ ├── native │ │ │ ├── Canvas.tsx │ │ │ ├── events.ts │ │ │ └── polyfills.ts │ │ ├── three-types.ts │ │ └── web │ │ │ ├── Canvas.tsx │ │ │ └── events.ts │ └── tests │ │ ├── core │ │ ├── events.test.tsx │ │ ├── hooks.test.tsx │ │ ├── renderer.test.tsx │ │ └── utils.test.ts │ │ ├── native │ │ ├── __snapshots__ │ │ │ └── canvas.test.tsx.snap │ │ ├── canvas.test.tsx │ │ └── hooks.test.tsx │ │ ├── setupTests.ts │ │ └── web │ │ ├── __snapshots__ │ │ └── canvas.test.tsx.snap │ │ └── canvas.test.tsx ├── shared │ ├── pointerEventPolyfill.ts │ └── setupTests.ts └── test-renderer │ ├── .npmignore │ ├── CHANGELOG.md │ ├── README.md │ ├── markdown │ ├── rttr-instance.md │ └── rttr.md │ ├── package.json │ ├── src │ ├── WebGL2RenderingContext.ts │ ├── __tests__ │ │ ├── RTTR.core.test.tsx │ │ ├── RTTR.events.test.tsx │ │ ├── RTTR.hooks.test.tsx │ │ ├── RTTR.methods.test.tsx │ │ ├── __snapshots__ │ │ │ └── RTTR.core.test.tsx.snap │ │ └── is.test.ts │ ├── createTestCanvas.ts │ ├── createTestInstance.ts │ ├── fireEvent.ts │ ├── helpers │ │ ├── events.ts │ │ ├── graph.ts │ │ ├── is.ts │ │ ├── strings.ts │ │ ├── testInstance.ts │ │ ├── tree.ts │ │ └── waitFor.ts │ ├── index.tsx │ └── types │ │ ├── index.ts │ │ ├── internal.ts │ │ └── public.ts │ └── yarn.lock ├── readme.md ├── tsconfig.json └── yarn.lock /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.6.0/schema.json", 3 | "changelog": "@changesets/changelog-git", 4 | "commit": true, 5 | "linked": [], 6 | "access": "public", 7 | "baseBranch": "master", 8 | "updateInternalDependencies": "minor", 9 | "ignore": [], 10 | "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { 11 | "onlyUpdatePeerDependentsWhenOutOfRange": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.codesandbox/ci.json: -------------------------------------------------------------------------------- 1 | { 2 | "sandboxes": ["/example"], 3 | "node": "14" 4 | } 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | .yarn/ -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "prettier", 9 | "plugin:prettier/recommended", 10 | "plugin:react-hooks/recommended", 11 | "plugin:import/recommended", 12 | "plugin:@react-three/recommended" 13 | ], 14 | "plugins": ["@typescript-eslint", "react", "react-hooks", "import", "jest", "prettier", "@react-three"], 15 | "parser": "@typescript-eslint/parser", 16 | "parserOptions": { 17 | "ecmaFeatures": { 18 | "jsx": true 19 | }, 20 | "ecmaVersion": 2018, 21 | "sourceType": "module", 22 | "rules": { 23 | "curly": ["warn", "multi-line", "consistent"], 24 | "no-console": "off", 25 | "no-empty-pattern": "warn", 26 | "no-duplicate-imports": "error", 27 | "import/no-unresolved": "off", 28 | "import/export": "error", 29 | // https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/FAQ.md#eslint-plugin-import 30 | // We recommend you do not use the following import/* rules, as TypeScript provides the same checks as part of standard type checking: 31 | "import/named": "off", 32 | "import/namespace": "off", 33 | "import/default": "off", 34 | "no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], 35 | "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], 36 | "@typescript-eslint/no-use-before-define": "off", 37 | "@typescript-eslint/no-empty-function": "off", 38 | "@typescript-eslint/no-empty-interface": "off", 39 | "@typescript-eslint/no-explicit-any": "off", 40 | "jest/consistent-test-it": ["error", { "fn": "it", "withinDescribe": "it" }] 41 | } 42 | }, 43 | "settings": { 44 | "react": { 45 | "version": "detect" 46 | }, 47 | "import/extensions": [".js", ".jsx", ".ts", ".tsx"], 48 | "import/parsers": { 49 | "@typescript-eslint/parser": [".js", ".jsx", ".ts", ".tsx"] 50 | }, 51 | "import/resolver": { 52 | "node": { 53 | "extensions": [".js", ".jsx", ".ts", ".tsx", ".json"], 54 | "paths": ["src"] 55 | }, 56 | "alias": { 57 | "extensions": [".js", ".jsx", ".ts", ".tsx", ".json"], 58 | "map": [["@react-three/fiber", "./packages/fiber/src/web"]] 59 | } 60 | } 61 | }, 62 | "overrides": [ 63 | { 64 | "files": ["src"], 65 | "parserOptions": { 66 | "project": "./tsconfig.json" 67 | } 68 | } 69 | ], 70 | "rules": { 71 | "import/no-unresolved": "off", 72 | "import/named": "off", 73 | "import/namespace": "off" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: drcmda 4 | open_collective: react-three-fiber 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | 👋 hi there, for issues that aren't that pressing, that could be related to threejs etc, please consider [github discussions](https://github.com/pmndrs/react-three-fiber/discussions). 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | Hi, 👋 2 | 3 | if this is about a bug, before you go ahead, please do us a favour and make sure to check the [threejs issue tracker](https://github.com/mrdoob/three.js/issues) for the problem you are experiencing. This library is just a soft wrap around threejs without direct dependencies. So if something is flipped upside down, or doesn't project the way you intent to, there's a good chance others will have experienced the same issue with plain threejs. 4 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - 'master' 6 | pull_request: {} 7 | jobs: 8 | build: 9 | name: Build, lint, and test 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repo 14 | uses: actions/checkout@v2 15 | 16 | - name: Use Node ${{ matrix.node }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node }} 20 | 21 | - name: Install deps and build (with cache) 22 | uses: bahmutov/npm-install@v1 23 | with: 24 | install-command: yarn --immutable --silent 25 | 26 | - name: Check types 27 | run: yarn run typecheck 28 | 29 | - name: Check lint 30 | run: yarn run eslint 31 | 32 | - name: Build 33 | run: yarn run build 34 | 35 | - name: Jest run 36 | run: yarn run test 37 | 38 | - name: Report Fiber size 39 | run: yarn run analyze-fiber 40 | 41 | - name: Report Test Renderer size 42 | run: yarn run analyze-test 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | dist/ 4 | build/ 5 | types/ 6 | # commit types in src 7 | !packages/*/src/types/ 8 | Thumbs.db 9 | ehthumbs.db 10 | Desktop.ini 11 | $RECYCLE.BIN/ 12 | .DS_Store 13 | .vscode 14 | .docz/ 15 | package-lock.json 16 | coverage/ 17 | .idea 18 | yarn-error.log 19 | .size-snapshot.json 20 | __tests__/__image_snapshots__/__diff_output__ 21 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx pretty-quick --staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "printWidth": 120, 7 | "jsxBracketSameLine": true 8 | } 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | This project uses [semantic commits](https://conventionalcommits.org) and [semver](https://semver.org). 4 | 5 | To get started, make sure you have [Node](https://nodejs.org) and [Yarn 1](https://classic.yarnpkg.com) (newer versions of Yarn do not work) installed. Install dependencies with: 6 | 7 | ```bash 8 | yarn 9 | ``` 10 | 11 | [Preconstruct](https://github.com/preconstruct/preconstruct) will automatically build and link packages for local development via symlinks. If you ever need to do this manually, try running: 12 | 13 | ```bash 14 | yarn dev 15 | ``` 16 | 17 | > **Note**: Some Windows users may need to [enable developer mode](https://howtogeek.com/292914/what-is-developer-mode-in-windows-10) if experiencing `EPERM: operation not permitted, symlink` with Preconstruct. If this persists, you might be running on an unsupported drive/format. In which case, consider using [Docker](https://docs.docker.com/docker-for-windows). 18 | 19 | ### Development 20 | 21 | Locally run examples against the library with: 22 | 23 | ```bash 24 | yarn examples 25 | ``` 26 | 27 | ### Testing 28 | 29 | Run test suites against the library with: 30 | 31 | ```bash 32 | yarn test 33 | 34 | # or, to test live against changes 35 | yarn test:watch 36 | ``` 37 | 38 | If your code invalidates a snapshot, you can update it with: 39 | 40 | ```bash 41 | yarn test -u 42 | ``` 43 | 44 | > **Note**: Use discretion when updating snapshots, as they represent the integrity of the package. 45 | > 46 | > If the difference is complex or you're unsure of the changes, leave it for review and we'll unblock it. 47 | 48 | ### Publishing 49 | 50 | We use [atlassian/changesets](https://github.com/atlassian/changesets) to publish our packages, which will automatically document and version changes. 51 | 52 | To publish a release on NPM, run the following and complete the dialog (see [FAQ](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)): 53 | 54 | ```bash 55 | # Describe the changes you've made as you would semantic commits for CHANGELOG.md 56 | yarn changeset:add 57 | 58 | # Tag which packages should receive an update and be published. 59 | yarn vers 60 | 61 | # Commit and publish changes to NPM. 62 | yarn release 63 | ``` 64 | 65 | We don't have automatic CI deployments yet, so make sure to [create a release](https://github.com/pmndrs/react-three-fiber/releases/new) on GitHub to notify people when it's ready. Choose or create the version generated by your changeset, and you can leave the rest to auto-fill via the "Generate release notes" button to describe PRs since the last release. 66 | 67 | ### Prerelease 68 | 69 | Follow the same steps as before, but specify a tag for [prerelease mode](https://github.com/changesets/changesets/blob/main/docs/prereleases.md) with: 70 | 71 | ```bash 72 | yarn changeset pre enter 73 | ``` 74 | 75 | To cancel or leave prerelease mode, try running: 76 | 77 | ```bash 78 | yarn changeset pre exit 79 | ``` 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Paul Henschel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [], 3 | presets: [ 4 | [ 5 | '@babel/preset-env', 6 | { 7 | include: [ 8 | '@babel/plugin-proposal-class-properties', 9 | '@babel/plugin-proposal-optional-chaining', 10 | '@babel/plugin-proposal-nullish-coalescing-operator', 11 | '@babel/plugin-proposal-numeric-separator', 12 | '@babel/plugin-proposal-logical-assignment-operators', 13 | ], 14 | bugfixes: true, 15 | loose: true, 16 | modules: false, 17 | targets: '> 1%, not dead, not ie 11, not op_mini all', 18 | }, 19 | ], 20 | '@babel/preset-react', 21 | '@babel/preset-typescript', 22 | ], 23 | } 24 | -------------------------------------------------------------------------------- /docs/API/additional-exports.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Additional Exports 3 | nav: 9 4 | --- 5 | 6 | | export | usage | 7 | | ------------------ | -------------------------------------------------------------- | 8 | | addEffect | Adds a global render callback which is called each frame | 9 | | addAfterEffect | Adds a global after-render callback which is called each frame | 10 | | addTail | Adds a global callback which is called when rendering stops | 11 | | flushGlobalEffects | Flushes global render-effects for when manually driving a loop | 12 | | invalidate | Forces view global invalidation | 13 | | advance | Advances the frameloop (given that it's set to 'never') | 14 | | extend | Extends the native-object catalogue | 15 | | createPortal | Creates a portal (it's a React feature for re-parenting) | 16 | | createRoot | Creates a root that can render three JSX into a canvas | 17 | | events | Dom pointer-event system | 18 | | applyProps | `applyProps(element, props)` sets element properties, | 19 | | act | usage with react-testing | 20 | | useInstanceHandle | Exposes react-internal local state from `instance.__r3f` | 21 | | | | 22 | -------------------------------------------------------------------------------- /docs/banner-journey.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedev935/react-three/df797bfb864c8103abcca258466b2a5ba822dda0/docs/banner-journey.jpg -------------------------------------------------------------------------------- /docs/banner-r3f.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedev935/react-three/df797bfb864c8103abcca258466b2a5ba822dda0/docs/banner-r3f.jpg -------------------------------------------------------------------------------- /docs/basic-app.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedev935/react-three/df797bfb864c8103abcca258466b2a5ba822dda0/docs/basic-app.gif -------------------------------------------------------------------------------- /docs/getting-started/basic-example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedev935/react-three/df797bfb864c8103abcca258466b2a5ba822dda0/docs/getting-started/basic-example.gif -------------------------------------------------------------------------------- /docs/getting-started/your-first-scene.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Your first scene 3 | description: This guide will help you setup your first React Three Fiber scene and introduce you to its core concepts. 4 | nav: 2 5 | --- 6 | 7 | This tutorial will assume some React knowledge. 8 | 9 | ## Setting up the Canvas 10 | 11 | We'll start by importing the `` component from `@react-three/fiber` and putting it in our React tree. 12 | 13 | ```jsx 14 | import { createRoot } from 'react-dom/client' 15 | import { Canvas } from '@react-three/fiber' 16 | 17 | function App() { 18 | return ( 19 |
20 | 21 |
22 | ) 23 | } 24 | 25 | createRoot(document.getElementById('root')).render() 26 | ``` 27 | 28 | The Canvas component does some important setup work behind the scenes: 29 | 30 | - It sets up a **Scene** and a **Camera**, the basic building blocks necessary for rendering 31 | - It renders our scene every frame, you do not need a traditional render-loop 32 | 33 | 34 | Canvas is responsive to fit the parent node, so you can control how big it is by changing the parents width and 35 | height, in this case #canvas-container. 36 | 37 | 38 | ## Adding a Mesh Component 39 | 40 | To actually see something in our scene, we'll add a lowercase `` native element, which is the direct equivalent to new THREE.Mesh(). 41 | 42 | ```js 43 | 44 | 45 | ``` 46 | 47 | 48 | Note that we don't need to import anything, All three.js objects will be treated as native JSX elements, just like you 49 | can just write <div /> or <span /> in regular ReactDOM. The general rule is that Fiber components are 50 | available under the camel-case version of their name in three.js. 51 | 52 | 53 | 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. 54 | We'll create a new mesh using a **BoxGeometry** and a **MeshStandardMaterial** which [automatically attach](https://docs.pmnd.rs/react-three-fiber/api/objects#attach) to their parent. 55 | 56 | ```jsx 57 | 58 | 59 | 60 | 61 | 62 | ``` 63 | 64 | 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: 65 | 66 | ```jsx 67 | const scene = new THREE.Scene() 68 | const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000) 69 | 70 | const renderer = new THREE.WebGLRenderer() 71 | renderer.setSize(width, height) 72 | document.querySelector('#canvas-container').appendChild(renderer.domElement) 73 | 74 | const mesh = new THREE.Mesh() 75 | mesh.geometry = new THREE.BoxGeometry() 76 | mesh.material = new THREE.MeshStandardMaterial() 77 | 78 | scene.add(mesh) 79 | 80 | function animate() { 81 | requestAnimationFrame(animate) 82 | renderer.render(scene, camera) 83 | } 84 | 85 | animate() 86 | ``` 87 | 88 | ### Constructor arguments 89 | 90 | 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: 91 | 92 | ```js 93 | new THREE.BoxGeometry(2, 2, 2) 94 | ``` 95 | 96 | In order to do this in Fiber we use the `args` prop, which _always_ takes an array whose items represent the constructor arguments. 97 | 98 | ```jsx 99 | 100 | ``` 101 | 102 | Note that every time you change args, the object must be re-constructed! 103 | 104 | ## Adding lights 105 | 106 | Next, we will add some lights to our scene, by putting these components into our canvas. 107 | 108 | ```jsx 109 | 110 | 111 | 112 | ``` 113 | 114 | ### Props 115 | 116 | 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. 117 | 118 | 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. 119 | 120 | ```jsx 121 | 122 | ``` 123 | 124 | Which is the equivalent to: 125 | 126 | ```jsx 127 | const light = new THREE.AmbientLight() 128 | light.intensity = 0.1 129 | ``` 130 | 131 | ### Shortcuts 132 | 133 | There are a few shortcuts for props that have a `.set()` method (colors, vectors, etc). 134 | 135 | ```jsx 136 | const light = new THREE.DirectionalLight() 137 | light.position.set(0, 0, 5) 138 | light.color.set('red') 139 | ``` 140 | 141 | Which is the same as the following in JSX: 142 | 143 | ```jsx 144 | 145 | ``` 146 | 147 | Please refer to the API for [a deeper explanation](/react-three-fiber/api/objects). 148 | 149 | ## The result 150 | 151 | ```jsx 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | ``` 161 | 162 | 163 | 164 | ## Exercise 165 | 166 | - 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 167 | - try different geometries, like [`SphereGeometry`](https://threejs.org/docs/#api/en/geometries/SphereGeometry) or [`OctahedronGeometry`](https://threejs.org/docs/#api/en/geometries/OctahedronGeometry) 168 | - try changing the `position` on our `mesh` component, by setting the prop with the same name 169 | - try extracting our mesh to a new component 170 | -------------------------------------------------------------------------------- /docs/preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedev935/react-three/df797bfb864c8103abcca258466b2a5ba822dda0/docs/preview.jpg -------------------------------------------------------------------------------- /docs/tutorials/basic-animations.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Basic Animations 3 | description: This guide will help you understand refs, useFrame and how to make basic animations with Fiber 4 | nav: 16 5 | --- 6 | 7 | This tutorial will assume some React knowledge, and will be based on [this starter codesandbox](https://codesandbox.io/s/getting-started-01-12q81?from-embed), so just fork it and follow along! 8 | 9 | We will build a really small, continuous animation loop, that will be the basic building block of more advanced animations later on. 10 | 11 | ## useFrame 12 | 13 | `useFrame` is a Fiber hook that lets you execute code on every frame of Fiber's render loop. This can have a lot of uses, but we will focus on building an animation with it. 14 | 15 | It's important to remember that **Fiber hooks can only be called inside a `` parent**! 16 | 17 | ```jsx 18 | import { useFrame } from '@react-three/fiber' 19 | 20 | function MyAnimatedBox() { 21 | useFrame(() => { 22 | console.log("Hey, I'm executing every frame!") 23 | }) 24 | return ( 25 | 26 | 27 | 28 | 29 | ) 30 | } 31 | ``` 32 | 33 | 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: 34 | 35 | For example, we can extract time information from the `clock` parameter, to know how much time has elapsed in our application, and use that time to animate a value: 36 | 37 | ```jsx 38 | useFrame(({ clock }) => { 39 | const a = clock.getElapsedTime() 40 | console.log(a) // the value will be 0 at scene initialization and grow each frame 41 | }) 42 | ``` 43 | 44 | `clock` is a [three.js Clock](https://threejs.org/docs/#api/en/core/Clock) object, from which we are getting the total elapsed time, which will be key for our animations. 45 | 46 | ## Animating with Refs 47 | 48 | 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 know as [transient updates](). 49 | 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: 50 | 51 | ```jsx 52 | import React from 'react' 53 | 54 | function MyAnimatedBox() { 55 | const myMesh = React.useRef() 56 | return ( 57 | 58 | 59 | 60 | 61 | ) 62 | } 63 | ``` 64 | 65 | `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: 66 | 67 | ```jsx 68 | useFrame(({ clock }) => { 69 | myMesh.current.rotation.x = clock.getElapsedTime() 70 | }) 71 | ``` 72 | 73 | Let's have a closer look: 74 | 75 | - We are destructuring `clock` from the argument passed to `useFrame`, which we know is the state of our Fiber scene. 76 | - We are accessing the `rotation.x` property of `myMesh.current` object, which is a reference to our mesh object 77 | - We are assigning our time-dependent value `a` to the `rotation` on the `x` axis, meaning our object will now infinitely rotate between -1 and 1 radians around the x axis! 78 | 79 | 80 | 81 | **Exercises** 82 | 83 | - Try `Math.sin(clock.getElapsedTime())` and see how your animation changes 84 | 85 | ## Next steps 86 | 87 | Now that you understand the basic technique for animating in Fiber, [learn how event works](/react-three-fiber/tutorials/events-and-interaction)! 88 | 89 | If you want to go deeper into animations, check these out: 90 | 91 | - [Animating with React Spring](/react-three-fiber/tutorials/using-with-react-spring) 92 | -------------------------------------------------------------------------------- /docs/tutorials/events-and-interaction.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Events and Interaction' 3 | description: Let's make our meshes react to user input. 4 | nav: 13 5 | --- 6 | 7 | This tutorial will assume some React knowledge, and will be based on [this starter codesandbox](https://codesandbox.io/s/getting-started-01-12q81?from-embed), so just fork it and follow along! 8 | 9 | After we have our continuous loop running the next step would be to allow our mesh to react to user interaction, so in this part let's attach a click handler to the cube and make it bigger on click. 10 | 11 | ## User Interaction 12 | 13 | Any Object3D that has a raycast method can receive a large number of events, for instance a mesh: 14 | 15 | ```jsx 16 | console.log('click')} 18 | onContextMenu={(e) => console.log('context menu')} 19 | onDoubleClick={(e) => console.log('double click')} 20 | onWheel={(e) => console.log('wheel spins')} 21 | onPointerUp={(e) => console.log('up')} 22 | onPointerDown={(e) => console.log('down')} 23 | onPointerOver={(e) => console.log('over')} 24 | onPointerOut={(e) => console.log('out')} 25 | onPointerEnter={(e) => console.log('enter')} 26 | onPointerLeave={(e) => console.log('leave')} 27 | onPointerMove={(e) => console.log('move')} 28 | onPointerMissed={() => console.log('missed')} 29 | onUpdate={(self) => console.log('props have been updated')} 30 | /> 31 | ``` 32 | 33 | 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. 34 | 35 | Let's add it then: 36 | 37 | ```jsx 38 | alert('Hellooo')} ref={myMesh}> 39 | 40 | 41 | 42 | ``` 43 | 44 | 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. 45 | 46 | Let's start by setting some state to check if the mesh is active: 47 | 48 | ```jsx 49 | const [active, setActive] = useState(false) 50 | ``` 51 | 52 | After we have this we can set the scale with a ternary operator like so: 53 | 54 | ```jsx 55 | setActive(!active)} ref={myMesh}> 56 | 57 | 58 | 59 | ``` 60 | 61 | If you try to click on your mesh now, it scales up and down. We just made our first interactive 3D mesh! 62 | 63 | What we did in this chapter was: 64 | 65 | - Attached a click handler to our mesh 66 | - Added some state to track if the mesh is currently active 67 | - Changed the scale based on that state 68 | 69 | 70 | 71 | **Exercises** 72 | 73 | - Change other props of the mesh like the `position` or even the `color` of the material. 74 | - Use `onPointerOver` and `onPointerOut` to change the props of the mesh on hover events. 75 | 76 | ## Next steps 77 | 78 | We just made our mesh react to user interaction but it looks pretty bland without any transition, right? 79 | In the next chapter let's integrate `react-spring` into our project to make this into an actual animation. 80 | -------------------------------------------------------------------------------- /docs/tutorials/gltfjsx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedev935/react-three/df797bfb864c8103abcca258466b2a5ba822dda0/docs/tutorials/gltfjsx.png -------------------------------------------------------------------------------- /docs/tutorials/loading-textures.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Loading Textures' 3 | description: Let's load some fancy textures. 4 | nav: 15 5 | --- 6 | 7 | > All textures used in this chapter were downloaded from [cc0textures](https://cc0textures.com/). 8 | 9 | ## Using TextureLoader & useLoader 10 | 11 | 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. 12 | 13 | It's better to explain with code, let's say you downloaded [this texture](https://cc0textures.com/view?id=PavingStones092) and placed it in the public folder of your site, to get the color map from it you could do: 14 | 15 | ```js 16 | const colorMap = useLoader(TextureLoader, 'PavingStones092_1K_Color.jpg') 17 | ``` 18 | 19 | Let's then with this information create a small scene where we can use this texture: 20 | 21 | ```jsx 22 | import { Suspense } from 'react' 23 | import { Canvas, useLoader } from '@react-three/fiber' 24 | import { TextureLoader } from 'three/src/loaders/TextureLoader' 25 | 26 | function Scene() { 27 | const colorMap = useLoader(TextureLoader, 'PavingStones092_1K_Color.jpg') 28 | return ( 29 | <> 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | export default function App() { 41 | return ( 42 | 43 | 44 | 45 | 46 | 47 | ) 48 | } 49 | ``` 50 | 51 | If everything went according to plan, you should now be able to apply this texture to the sphere like so: 52 | 53 | ```jsx 54 | 55 | ``` 56 | 57 | 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? 58 | 59 | 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: 60 | 61 | ```js 62 | const [colorMap, displacementMap, normalMap, roughnessMap, aoMap] = useLoader(TextureLoader, [ 63 | 'PavingStones092_1K_Color.jpg', 64 | 'PavingStones092_1K_Displacement.jpg', 65 | 'PavingStones092_1K_Normal.jpg', 66 | 'PavingStones092_1K_Roughness.jpg', 67 | 'PavingStones092_1K_AmbientOcclusion.jpg', 68 | ]) 69 | ``` 70 | 71 | Now we can place them in our mesh like so: 72 | 73 | ```jsx 74 | 81 | ``` 82 | 83 | 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: 84 | 85 | ```jsx 86 | function Scene() { 87 | const [colorMap, displacementMap, normalMap, roughnessMap, aoMap] = useLoader(TextureLoader, [ 88 | 'PavingStones092_1K_Color.jpg', 89 | 'PavingStones092_1K_Displacement.jpg', 90 | 'PavingStones092_1K_Normal.jpg', 91 | 'PavingStones092_1K_Roughness.jpg', 92 | 'PavingStones092_1K_AmbientOcclusion.jpg', 93 | ]) 94 | return ( 95 | 96 | {/* Width and height segments for displacementMap */} 97 | 98 | 106 | 107 | ) 108 | } 109 | ``` 110 | 111 | ## Using useTexture 112 | 113 | Another way to import these is using `useTexture` from [`@react-three/drei`](https://github.com/pmndrs/drei), that will make it slightly easier and there is no need to import the `TextureLoader`, our code would look like: 114 | 115 | ```js 116 | import { useTexture } from "@react-three/drei" 117 | 118 | ... 119 | 120 | const [colorMap, displacementMap, normalMap, roughnessMap, aoMap] = useTexture([ 121 | 'PavingStones092_1K_Color.jpg', 122 | 'PavingStones092_1K_Displacement.jpg', 123 | 'PavingStones092_1K_Normal.jpg', 124 | 'PavingStones092_1K_Roughness.jpg', 125 | 'PavingStones092_1K_AmbientOcclusion.jpg', 126 | ]) 127 | ``` 128 | 129 | You can also use object-notation which is the most convenient: 130 | 131 | ```jsx 132 | const props = useTexture({ 133 | map: 'PavingStones092_1K_Color.jpg', 134 | displacementMap: 'PavingStones092_1K_Displacement.jpg', 135 | normalMap: 'PavingStones092_1K_Normal.jpg', 136 | roughnessMap: 'PavingStones092_1K_Roughness.jpg', 137 | aoMap: 'PavingStones092_1K_AmbientOcclusion.jpg', 138 | }) 139 | 140 | return ( 141 | 142 | 143 | 144 | 145 | ) 146 | ``` 147 | 148 | You can play with the sandbox and see how it looks: 149 | 150 | 151 | -------------------------------------------------------------------------------- /docs/tutorials/testing.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Testing' 3 | description: Let's test our 3D Scene 4 | nav: 19 5 | --- 6 | 7 | Like with every other application testing is an important factor when it comes to releasing an application into the wild and when it comes to React Three Fiber we can use React Three Test Renderer to achieve this. 8 | 9 | We will be testing the [sandbox](https://codesandbox.io/s/98ppy) we created in [events and interactions](events-and-interaction). 10 | 11 | ## How to test React Three Fiber 12 | 13 | Let's start by installing the React Three Test Renderer: 14 | 15 | ```bash 16 | npm install @react-three/test-renderer --save-dev 17 | ``` 18 | 19 | Afterwards, if you are using Create React App you can just add a file that ends in `.test.js` and start writing your code, because React Three Test Renderer is testing library agnostic, so it works with libraries such as `jest`, `jasmine` etc. 20 | 21 | Let's create an `App.test.js` and set up all our test cases: 22 | 23 | ```jsx 24 | import ReactThreeTestRenderer from '@react-three/test-renderer' 25 | import { MyRotatingBox } from './App' 26 | 27 | test('mesh to have two children', async () => { 28 | const renderer = await ReactThreeTestRenderer.create() 29 | }) 30 | 31 | test('click event makes box bigger', async () => { 32 | const renderer = await ReactThreeTestRenderer.create() 33 | }) 34 | ``` 35 | 36 | In here we created three tests and in each we made sure we created the renderer by using the `create` function. 37 | 38 | Let's start with the first test and make sure our mesh has two children, the material and cube. 39 | 40 | We can start by getting the scene and it's children from the test instance we just created like so: 41 | 42 | ```js 43 | const meshChildren = renderer.scene.children 44 | ``` 45 | 46 | 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. 47 | 48 | Using this we can make sure to get that first child and use the `allChildren` property on it like so: 49 | 50 | ```js 51 | const meshChildren = renderer.scene.children[0].allChildren 52 | ``` 53 | 54 | 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`. 55 | 56 | Now to create our assertion: 57 | 58 | ```js 59 | expect(meshChildren.length).toBe(2) 60 | ``` 61 | 62 | Our first test case looks like this: 63 | 64 | ```js 65 | test('mesh to have two children', async () => { 66 | const renderer = await ReactThreeTestRenderer.create() 67 | const mesh = renderer.scene.children[0].allChildren 68 | expect(mesh.length).toBe(2) 69 | }) 70 | ``` 71 | 72 | ## Testing interactions 73 | 74 | 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. 75 | 76 | We can do that by utilizing the `fireEvent` method existing in a test instance. 77 | 78 | We know we can get the mesh with: 79 | 80 | ```js 81 | const mesh = renderer.scene.children[0] 82 | ``` 83 | 84 | Since we already have that we can fire an event in it like so: 85 | 86 | ```js 87 | await renderer.fireEvent(mesh, 'click') 88 | ``` 89 | 90 | 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: 91 | 92 | ```js 93 | expect(mesh.props.scale).toBe(1.5) 94 | ``` 95 | 96 | In the end our test looks something like this: 97 | 98 | ```js 99 | test('click event makes box bigger', async () => { 100 | const renderer = await ReactThreeTestRenderer.create() 101 | const mesh = renderer.scene.children[0] 102 | expect(mesh.props.scale).toBe(1) 103 | await renderer.fireEvent(mesh, 'click') 104 | expect(mesh.props.scale).toBe(1.5) 105 | }) 106 | ``` 107 | 108 | If you want to learn more about React Three Test Renderer you can checkout the repo and their docs: 109 | 110 | - [Repo](https://github.com/pmndrs/react-three-fiber/blob/master/packages/test-renderer) 111 | - [React Three Test Renderer API](https://github.com/pmndrs/react-three-fiber/blob/master/packages/test-renderer/markdown/rttr.md#create) 112 | - [React Three Test Instance API](https://github.com/pmndrs/react-three-fiber/blob/master/packages/test-renderer/markdown/rttr-instance.md) 113 | 114 | ## Exercises 115 | 116 | - Check the color of the Box we created 117 | - Check the rotation using the `advanceFrames` method. 118 | 119 | 120 | -------------------------------------------------------------------------------- /docs/tutorials/typescript.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Using with TypeScript 3 | description: This guide will help through common scenarios and how to approach them with TypeScript. 4 | nav: 18 5 | --- 6 | 7 | This tutorial will assume some React and TypeScript knowledge. You can fork and follow along from [this starter codesandbox](https://codesandbox.io/s/brnsm). 8 | 9 | ## Typing with useRef 10 | 11 | React's `useRef` won't automatically infer types despite pointing it to a typed ref. 12 | 13 | You can type the ref yourself by passing a type through `useRef`'s generics: 14 | 15 | ```tsx 16 | import { useRef, useEffect } from 'react' 17 | import { Mesh } from 'three' 18 | 19 | function Box(props) { 20 | const meshRef = useRef(null!) 21 | 22 | useEffect(() => { 23 | console.log(Boolean(meshRef.current)) 24 | }, []) 25 | 26 | return ( 27 | 28 | 29 | 30 | 31 | ) 32 | } 33 | ``` 34 | 35 | The exclamation mark is a non-null assertion that will let TS know that `ref.current` is defined when we access it in effects. 36 | 37 | ## Typing shorthand props 38 | 39 | react-three-fiber accepts short-hand props like scalars, strings, and arrays so you can declaratively set properties without side effects. 40 | 41 | Here are the different variations of props: 42 | 43 | ```tsx 44 | import { Euler, Vector3, Color } from 'three' 45 | 46 | rotation: Euler || [x, y, z] 47 | position: Vector3 || [x, y, z] || scalar 48 | color: Color || 'hotpink' || 0xffffff 49 | ``` 50 | 51 | Each property has extended types which you can pull from to type these properties. 52 | 53 | ```tsx 54 | import { Euler, Vector3, Color } from '@react-three/fiber' 55 | // or 56 | // import { ReactThreeFiber } from '@react-three/fiber' 57 | // ReactThreeFiber.Euler, ReactThreeFiber.Vector3, etc. 58 | 59 | rotation: Euler 60 | position: Vector3 61 | color: Color 62 | ``` 63 | 64 | This is particularly useful if you are typing properties outside of components, such as a store or a hook. 65 | 66 | ## Extend usage 67 | 68 | react-three-fiber can also accept third-party elements and extend them into its internal catalogue. 69 | 70 | ```tsx 71 | import { useRef, useEffect } from 'react' 72 | import { GridHelper } from 'three' 73 | import { extend } from '@react-three/fiber' 74 | 75 | // Create our custom element 76 | class CustomElement extends GridHelper {} 77 | 78 | // Extend so the reconciler will learn about it 79 | extend({ CustomElement }) 80 | ``` 81 | 82 | The catalogue teaches the underlying reconciler how to create fibers for these elements and treat them within the scene. 83 | 84 | You can then declaratively create custom elements with primitives, but TypeScript won't know about them nor their props. 85 | 86 | ```html 87 | // error: 'customElement' does not exist on type 'JSX.IntrinsicElements' 88 | 89 | 90 | ``` 91 | 92 | ### Node Helpers 93 | 94 | react-three-fiber exports helpers that you can use to define different types of nodes. These nodes will type an element that we'll attach to the global JSX namespace. 95 | 96 | ```tsx 97 | Node 98 | Object3DNode 99 | BufferGeometryNode 100 | MaterialNode 101 | LightNode 102 | ``` 103 | 104 | ### Extending ThreeElements 105 | 106 | Since our custom element is an object, we'll use `Object3DNode` to define it. 107 | 108 | ```tsx 109 | import { useRef, useEffect } from 'react' 110 | import { GridHelper } from 'three' 111 | import { extend, Object3DNode } from '@react-three/fiber' 112 | 113 | // Create our custom element 114 | class CustomElement extends GridHelper {} 115 | 116 | // Extend so the reconciler will learn about it 117 | extend({ CustomElement }) 118 | 119 | // Add types to ThreeElements elements so primitives pick up on it 120 | declare module '@react-three/fiber' { 121 | interface ThreeElements { 122 | customElement: Object3DNode 123 | } 124 | } 125 | 126 | // react-three-fiber will create your custom component and TypeScript will understand it 127 | 128 | ``` 129 | 130 | ## Exported types 131 | 132 | react-three-fiber is extensible and exports types for its internals, such as render props, canvas props, and events: 133 | 134 | ```tsx 135 | // Event raycaster intersection 136 | Intersection 137 | 138 | // `useFrame` internal subscription and render callback 139 | Subscription 140 | RenderCallback 141 | 142 | // `useThree`'s returned internal state 143 | RootState 144 | Performance 145 | Dpr 146 | Size 147 | Viewport 148 | Camera 149 | 150 | // Canvas props 151 | Props 152 | 153 | // Supported events 154 | Events 155 | 156 | // Event manager signature (is completely modular) 157 | EventManager 158 | 159 | // Wraps a platform event as it's passed through the event manager 160 | ThreeEvent 161 | ``` 162 | -------------------------------------------------------------------------------- /docs/tutorials/using-with-react-spring.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Using with React Spring' 3 | description: Animating props with ease. 4 | nav: 17 5 | --- 6 | 7 | This tutorial will assume some React knowledge, and will be based on [this starter codesandbox](https://codesandbox.io/s/interaction-98ppy?file=/src/App.js), so just fork it and follow along! 8 | 9 | We learned how to create small animations and also how to react to user interactions, but we haven't yet learned how to change these props in a way to create animations. 10 | 11 | For that, we are gonna use `react-spring`. `react-spring` is a spring physics based animation library and it works perfectly with React Three Fiber as it comes from the same maintainers, and it also has exports specifically created for use with React Three Fiber. 12 | 13 | ## Spring Animations 14 | 15 | Let's start by defining some concepts about `react-spring` as it works with animations in a way you may not be used to. Usually when defining an animation or even a transition in CSS, you tell the code how much time you want the transition to last. 16 | 17 | ```css 18 | transition: opacity 200ms ease; 19 | ``` 20 | 21 | This is not how `react-spring` works, it instead works with `springs` and what that means is, the animation's flow depends on things like the mass, tension and friction of what you want to animate, and this is exactly what makes it so perfect to use with 3D. 22 | 23 | ## Using `react-spring` 24 | 25 | Let's start by installing it: 26 | 27 | ```bash 28 | npm install three @react-spring/three 29 | ``` 30 | 31 | After that, we import everything from `@react-spring/three` as it contains the components that were created specifically for use with React Three Fiber. 32 | 33 | We need to import two things from `react-spring`: 34 | 35 | ```js 36 | import { useSpring, animated } from '@react-spring/three' 37 | ``` 38 | 39 | Let's go over them, shall we? 40 | 41 | - `useSpring` - A hook to transform values into animated-values 42 | - `animated` - A component that is used instead of your DOM or mesh, so instead of using `mesh` you will be using `animated.mesh` if you want it to be affected by `react-spring` 43 | 44 | Let's create our first spring and attach it to our mesh when the user clicks. 45 | 46 | ```js 47 | const springs = useSpring({ scale: active ? 1.5 : 1 }) 48 | ``` 49 | 50 | What we did here is create a constant called `springs`, this constant will hold the animated values. 51 | 52 | `useSpring` itself takes one argument, and that is an object with all the things you want to animate. In this case, we just want to animate the scale and to hop between the value of 1 and the value of 1.5 depending on the active state. 53 | 54 | We can also deconstruct the return value of `useSpring` and just get the value we want, like so: 55 | 56 | ```js 57 | const { scale } = useSpring({ scale: active ? 1.5 : 1 }) 58 | ``` 59 | 60 | Now that we have this animated value, let's place it in our mesh: 61 | 62 | ```jsx 63 | setActive(!active)} ref={myMesh}> 64 | 65 | 66 | 67 | ``` 68 | 69 | If you now click on the cube, you can see that it doesn't just jump from one value to the other, but instead it animates smoothly between the two values. 70 | 71 | One last touch we might want to add is the wobblier effect to the animation. For that we can import the `config` object from `react-spring`: 72 | 73 | ```js 74 | import { useSpring, animated, config } from '@react-spring/three' 75 | ``` 76 | 77 | Lastly when we call the hook, we can pass a value for config and pass the `wobbly` configuration: 78 | 79 | ```js 80 | const { scale } = useSpring({ 81 | scale: active ? 1.5 : 1, 82 | config: config.wobbly, 83 | }) 84 | ``` 85 | 86 | You can check the other configuration options at the [`react-spring` documentation](https://react-spring.io). 87 | 88 | What we did in this chapter was: 89 | 90 | - Learn how to use `react-spring` with React Three Fiber 91 | - Animate props in our 3D Mesh 92 | 93 | 94 | 95 | **Exercises** 96 | 97 | - Animate the position of the mesh using `react-spring` 98 | 99 | **Further Reading** 100 | 101 | - [React Spring Documentation](https://www.react-spring.io/) 102 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | -------------------------------------------------------------------------------- /example/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # example 2 | 3 | ## 1.1.0 4 | 5 | ### Minor Changes 6 | 7 | - 85c80e70: eventsource and eventprefix on the canvas component 8 | 9 | ## 1.0.0 10 | 11 | ### Major Changes 12 | 13 | - 385ba9c: v8 major, react-18 compat 14 | - 04c07b8: v8 major, react-18 compat 15 | 16 | ## 1.0.0-beta.0 17 | 18 | ### Major Changes 19 | 20 | - 385ba9c: v8 major, react-18 compat 21 | -------------------------------------------------------------------------------- /example/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | react-three-fiber example 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build", 7 | "serve": "vite preview" 8 | }, 9 | "dependencies": { 10 | "@react-spring/core": "^9.6.1", 11 | "@react-spring/three": "^9.6.1", 12 | "@react-three/drei": "^9.74.7", 13 | "@use-gesture/react": "latest", 14 | "@vitejs/plugin-react": "^3.1.0", 15 | "react": "^18.2.0", 16 | "react-dom": "^18.2.0", 17 | "react-merge-refs": "^2.0.1", 18 | "react-use-refs": "^1.0.1", 19 | "styled-components": "^5.3.6", 20 | "three": "^0.149.0", 21 | "three-stdlib": "^2.21.8", 22 | "use-error-boundary": "^2.0.6", 23 | "wouter": "^2.10.0", 24 | "zustand": "^4.3.3" 25 | }, 26 | "devDependencies": { 27 | "@types/react": "^18.0.28", 28 | "@types/styled-components": "^5.1.26", 29 | "@vitejs/plugin-react-refresh": "^1.3.6", 30 | "typescript": "^4.9.5", 31 | "vite": "^4.1.1" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /example/public/Parrot.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedev935/react-three/df797bfb864c8103abcca258466b2a5ba822dda0/example/public/Parrot.glb -------------------------------------------------------------------------------- /example/public/Stork.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedev935/react-three/df797bfb864c8103abcca258466b2a5ba822dda0/example/public/Stork.glb -------------------------------------------------------------------------------- /example/public/bottle.gltf: -------------------------------------------------------------------------------- 1 | {"extensionsUsed":["KHR_materials_unlit","KHR_draco_mesh_compression"],"asset":{"generator":"UniGLTF-1.27","version":"2.0"},"buffers":[{"name":"buffer","byteLength":1004,"uri":"data:application/octet-stream;base64,RFJBQ08CAgEBAAAAJDQCMwMCGQ8ZCwMQ79auW9NWVfEva0vXddGiAIACAHFkC4gpS5BuMmCDL1WAZAuIKUuQbjJggy9VgAP/AAEAAQABAQABAAkDAAACAQEJAwABAwEDCQIAAgIEAQEADAPJARPlCFUVHQeRE1UFDCNY15ilc8hSzX+8Xw48sQv+QBEc5E7EKhDg+xP/AYACUHwHAPgEAPgHADgKABwFAb5B8R8AngAOQQEoqgCQFRQAQBAAHFIAAPALsHPUBgQCYCUAELwBwMUCAIAbAAAAgDNtALBYAF1+ucTMJ4DFJwBGEgEAABcBNBEFAKgBwG0AAA63BNHgA4EaWQWKsQfEQgarA7/KggRAA0CIgAAAAAD/BwAA5dK4vQAAAADnD6C9uDkFPwsGAwEBBP8B4Sy5Cf///+9pCSHsJJGNFX1HBAI97/XOnNfjTSqhdGtLEH5V8IWKRsmHS4D/AAAAfwAAAP8CGEgIBQEBAAsDKQi9GUEBC+EBJQM5F6Ac4zcnvBmY1hE3kfWoH7ZVdyCtMA/1+1Uf6eVPgI6JnaMhYg6H8Bqi8K6h6DuH8BoiKQHAa4jCu4aigP+j6Ugk6pKmi9QCSJoOUQsgabpILYCk6SI8mo6oGXjhOhIYrl/ArpjyukrsIqSzxy6qdSXxYSOKe5EduyBeiJgSOyIRQErsiEQAKbGDDOB67CJK6nb0IiIUQEcvqnj0ggA0AAAAAQKCQwAAAAD/AwAAXsAvwPpQmcGLV7RBCgBEUkFDTwICAQEAAAAeKAIoAQEnCwEMX7tu7fK1bakWVS0t/wEiiAnoNmQvEM0Sa4CICeg2ZC8QzRJrgAP/AAEAAQABAQABAAkDAAACAQEJAwABAwEDCQIAAgIEAQEADAOJCA/NDEUEAzUTaQbNDAuvfqTVRnKEpXCJbjoGgLSQIGoADCfUlBoBPisAnj0CAJ8B8CwSQg0A4oQaACTQawXgAwkApGPUlgEAADxtAdgJ4DGAtQAA3AKwE7gA8LT1mCfwANgJ3AHAswPqHwA6wP4BAAqzA5uFTBxlBg5hgZ55gAAAAAAAAP8HAAAA1aq9mwUaPuPxk72E69E+CwYDAQEEAkE4wQcL0OIw5NWXSnUq+5b/AAAAfwAAAP8C3UQIBQEBAAsDhQ9VFekCA/gD+K0K8QG1CRbt1EYh/80XV9wKBm+OKvgrGjszv/+VFP3JQJIgRUpOlCQ6kZIUJYlyEABFSaIwUZLoREpSlCTKENIA2oD44ICKAmDin6IAoKIAkAioKAAm/ikKACoKvvkVEIguxiIrJgAAAAECf0IAAAAA/wMAAF0t0r/6gajBJXy6QQo="}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":571},{"buffer":0,"byteOffset":572,"byteLength":432}],"accessors":[{"type":"SCALAR","componentType":5125,"count":156,"min":[0],"max":[101]},{"type":"VEC3","componentType":5126,"count":102,"max":[0.09051262757649467,0.5206661997740596,0.07845129908718129],"min":[-0.09050023093870575,-0.00025423154285842753,-0.07840956285646851]},{"type":"VEC3","componentType":5126,"count":102,"min":[-0.8701851585332085,-1.0078100779477288,-1.0035950162831475],"max":[0.8714622238103082,0.38238379674799305,1.0035950162831475]},{"type":"VEC2","componentType":5126,"count":102,"min":[-2.768152080789456,-19.186575256601223],"max":[2.762863699408687,3.400242172494778]},{"type":"SCALAR","componentType":5125,"count":120,"min":[0],"max":[65]},{"type":"VEC3","componentType":5126,"count":66,"max":[0.0836303700134704,0.5606122827917177,0.07237291420137934],"min":[-0.0836143708616335,0.15021171506346873,-0.07243899915514775]},{"type":"VEC3","componentType":5126,"count":66,"min":[-0.8738685851003609,-1.007843137254902,-1.007843137254902],"max":[0.8751510227427763,1.007843137254902,1.007843137254902]},{"type":"VEC2","componentType":5126,"count":66,"min":[-1.6647959047981256,-21.086251645726776],"max":[1.6620370837134235,2.2699388558097837]}],"materials":[{"name":"brownDark","pbrMetallicRoughness":{"baseColorFactor":[0.6392157,0.3882353,0.2784314,1],"metallicFactor":1,"roughnessFactor":1},"doubleSided":false,"alphaMode":"OPAQUE","emissiveFactor":[0,0,0]},{"name":"red","pbrMetallicRoughness":{"baseColorFactor":[0.9098039,0.333333343,0.3254902,1],"metallicFactor":1,"roughnessFactor":1},"doubleSided":false,"alphaMode":"OPAQUE","emissiveFactor":[0,0,0]}],"meshes":[{"name":"Mesh sodaBottle","primitives":[{"mode":4,"indices":0,"attributes":{"POSITION":1,"NORMAL":2,"TEXCOORD_0":3},"material":0,"extensions":{"KHR_draco_mesh_compression":{"bufferView":0,"attributes":{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2}}}},{"mode":4,"indices":4,"attributes":{"POSITION":5,"NORMAL":6,"TEXCOORD_0":7},"material":1,"extensions":{"KHR_draco_mesh_compression":{"bufferView":1,"attributes":{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2}}}}]}],"nodes":[{"children":[1],"name":"tmpParent","translation":[0,0,0],"rotation":[0,0,0,1],"scale":[1,1,1]},{"name":"sodaBottle","translation":[0,0,0],"rotation":[0,0,0,1],"scale":[1,1,1],"mesh":0}],"scenes":[{"nodes":[1]}],"scene":0,"extensionsRequired":["KHR_draco_mesh_compression"]} 2 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useErrorBoundary } from 'use-error-boundary' 3 | import { Route, useRoute, Redirect } from 'wouter' 4 | 5 | import { Global, Loading, Page, DemoPanel, Dot, Error } from './styles' 6 | 7 | import * as demos from './demos' 8 | 9 | const DEFAULT_COMPONENT_NAME = 'Portals' 10 | const visibleComponents: any = Object.entries(demos).reduce((acc, [name, item]) => ({ ...acc, [name]: item }), {}) 11 | 12 | function ErrorBoundary({ children, fallback, name }: any) { 13 | const { ErrorBoundary, didCatch, error } = useErrorBoundary() 14 | return didCatch ? fallback(error) : {children} 15 | } 16 | 17 | function Demo() { 18 | const [match, params] = useRoute('/demo/:name') 19 | const compName = match ? params.name : DEFAULT_COMPONENT_NAME 20 | const { Component } = visibleComponents[compName] 21 | 22 | return ( 23 | {e}}> 24 | 25 | 26 | ) 27 | } 28 | 29 | function Dots() { 30 | const [match, params] = useRoute('/demo/:name') 31 | if (!match) return null 32 | 33 | return ( 34 | <> 35 | 36 | {Object.entries(visibleComponents).map(function mapper([name, item]) { 37 | const background = params.name === name ? 'salmon' : '#fff' 38 | return 39 | })} 40 | 41 | {params.name} 42 | 43 | ) 44 | } 45 | 46 | export default function App() { 47 | const dev = new URLSearchParams(location.search).get('dev') 48 | 49 | return ( 50 | <> 51 | 52 | 53 | }> 54 | } /> 55 | 56 | 57 | 58 | 59 | {dev === null && } 60 | 61 | 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /example/src/demos/Animation.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Canvas } from '@react-three/fiber' 3 | import { a, useSpring } from '@react-spring/three' 4 | import { OrbitControls } from '@react-three/drei' 5 | 6 | export default function Box() { 7 | const [active, setActive] = useState(0) 8 | // create a common spring that will be used later to interpolate other values 9 | const { spring } = useSpring({ 10 | spring: active, 11 | config: { mass: 5, tension: 400, friction: 50, precision: 0.0001 }, 12 | }) 13 | // interpolate values from commong spring 14 | const scale = spring.to([0, 1], [1, 2]) 15 | const rotation = spring.to([0, 1], [0, Math.PI]) 16 | const color = spring.to([0, 1], ['#6246ea', '#e45858']) 17 | return ( 18 | 19 | setActive(Number(!active))}> 20 | 21 | 22 | 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /example/src/demos/AutoDispose.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import React, { useRef, useState } from 'react' 3 | import { Canvas, useFrame } from '@react-three/fiber' 4 | 5 | function Box1(props: any) { 6 | const mesh = useRef(null!) 7 | const [hovered, setHover] = useState(false) 8 | useFrame((state) => (mesh.current.position.y = Math.sin(state.clock.elapsedTime))) 9 | return ( 10 | props.setActive(!props.active)} 14 | onPointerOver={(e) => setHover(true)} 15 | onPointerOut={(e) => setHover(false)}> 16 | 17 | 18 | 19 | ) 20 | } 21 | 22 | function Box2(props: any) { 23 | const mesh = useRef(null!) 24 | const [hovered, setHover] = useState(false) 25 | useFrame((state) => (mesh.current.position.y = Math.sin(state.clock.elapsedTime))) 26 | return ( 27 | 28 | props.setActive(!props.active)} 32 | onPointerOver={(e) => setHover(true)} 33 | onPointerOut={(e) => setHover(false)}> 34 | 35 | 36 | 37 | 38 | ) 39 | } 40 | 41 | function Switcher() { 42 | const [active, setActive] = useState(false) 43 | return ( 44 | <> 45 | {active && } 46 | {!active && } 47 | 48 | ) 49 | } 50 | 51 | export default function App() { 52 | return ( 53 | 54 | 55 | 56 | 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /example/src/demos/ClickAndHover.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import React, { useState, useRef } from 'react' 3 | import { Canvas, useFrame } from '@react-three/fiber' 4 | 5 | const mesh = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial({ color: 'red' })) 6 | const group = new THREE.Group() 7 | group.add(mesh) 8 | 9 | function Box(props: any) { 10 | const ref = useRef(null!) 11 | const [hovered, setHovered] = useState(false) 12 | const [clicked, setClicked] = useState(false) 13 | useFrame((state) => { 14 | ref.current.position.y = Math.sin(state.clock.elapsedTime) / 3 15 | }) 16 | return ( 17 | setHovered(true)} 20 | onPointerOut={(e) => setHovered(false)} 21 | onClick={() => setClicked(!clicked)} 22 | scale={clicked ? [1.5, 1.5, 1.5] : [1, 1, 1]} 23 | {...props}> 24 | 25 | 26 | 27 | ) 28 | } 29 | 30 | function Box2(props: any) { 31 | return console.log('hi')} /> 32 | } 33 | 34 | export default function App() { 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /example/src/demos/ContextMenuOverride.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Canvas } from '@react-three/fiber' 3 | 4 | export default function App() { 5 | const [state, set] = useState(false) 6 | 7 | return ( 8 | console.log('canvas.missed')}> 12 | 13 | 14 | { 18 | ev.nativeEvent.preventDefault() 19 | set((value) => !value) 20 | }} 21 | onPointerMissed={() => console.log('mesh.missed')}> 22 | 23 | 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /example/src/demos/Gestures.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from 'react' 2 | import { Canvas, useThree, useFrame } from '@react-three/fiber' 3 | import { useDrag } from '@use-gesture/react' 4 | 5 | function Obj({ scale = 1, z = 0, opacity = 1 }) { 6 | const { viewport } = useThree() 7 | const [hovered, hover] = useState(false) 8 | const [position, set] = useState<[number, number, number]>([0, 0, z]) 9 | const bind = useDrag(({ event, offset: [x, y] }) => { 10 | event.stopPropagation() 11 | const aspect = viewport.getCurrentViewport().factor 12 | set([x / aspect, -y / aspect, z]) 13 | }) 14 | 15 | const mesh = useRef() 16 | 17 | useFrame(() => { 18 | mesh.current!.rotation.x = mesh.current!.rotation.y += 0.01 19 | }) 20 | 21 | return ( 22 | { 27 | e.stopPropagation() 28 | hover(true) 29 | }} 30 | onPointerOut={(e) => { 31 | e.stopPropagation() 32 | hover(false) 33 | }} 34 | onClick={(e) => { 35 | e.stopPropagation() 36 | console.log('clicked', { z }) 37 | }} 38 | castShadow 39 | scale={scale}> 40 | 41 | 42 | 43 | ) 44 | } 45 | 46 | export default function App() { 47 | return ( 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /example/src/demos/Gltf.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense, useEffect, useReducer } from 'react' 2 | import { Canvas, useLoader } from '@react-three/fiber' 3 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' 4 | 5 | function Test() { 6 | const [flag, toggle] = useReducer((state) => !state, true) 7 | useEffect(() => { 8 | const interval = setInterval(toggle, 1000) 9 | return () => clearInterval(interval) 10 | }, []) 11 | const { scene } = useLoader(GLTFLoader, flag ? '/Stork.glb' : '/Parrot.glb') as any 12 | return 13 | } 14 | 15 | export default function App() { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /example/src/demos/Inject.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import React, { useState, useEffect } from 'react' 3 | import { Canvas, createPortal, useThree, getRootState } from '@react-three/fiber' 4 | 5 | const customCamera1 = new THREE.PerspectiveCamera() 6 | const customCamera2 = new THREE.PerspectiveCamera() 7 | 8 | export default function App() { 9 | const [scene1] = useState(() => new THREE.Scene()) 10 | const [scene2] = useState(() => new THREE.Scene()) 11 | const [mounted, mount] = React.useReducer(() => true, false) 12 | React.useEffect(() => { 13 | const timeout = setTimeout(mount, 1000) 14 | return () => clearTimeout(timeout) 15 | }, []) 16 | return ( 17 | 18 | 19 | {createPortal( 20 | 21 | {mounted && } 22 | {createPortal(, scene2, { camera: customCamera2 })} 23 | , 24 | scene1, 25 | { camera: customCamera1 }, 26 | )} 27 | 28 | 29 | 30 | ) 31 | } 32 | 33 | function Cube({ color, ...props }: any) { 34 | const camera = useThree((state) => state.camera) 35 | const ref = React.useRef(null!) 36 | useEffect(() => { 37 | console.log(`from within ${color}.useEffect`, getRootState(ref.current)?.camera, 'camera', camera.uuid) 38 | }, []) 39 | return ( 40 | 41 | 42 | 43 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /example/src/demos/Layers.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import React, { useEffect, useReducer } from 'react' 3 | import { Canvas } from '@react-three/fiber' 4 | 5 | const invisibleLayer = new THREE.Layers() 6 | invisibleLayer.set(4) 7 | 8 | const visibleLayers = new THREE.Layers() 9 | visibleLayers.enableAll() 10 | visibleLayers.disable(4) 11 | 12 | function Box(props: any) { 13 | return ( 14 | 15 | 16 | 17 | 18 | ) 19 | } 20 | 21 | function Sphere(props: any) { 22 | return ( 23 | 24 | 25 | 26 | 27 | ) 28 | } 29 | 30 | export default function App() { 31 | const [visible, toggle] = useReducer((state) => !state, false) 32 | useEffect(() => { 33 | const interval = setInterval(toggle, 1000) 34 | return () => clearInterval(interval) 35 | }) 36 | return ( 37 | 38 | 39 | 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /example/src/demos/Lines.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect, useState, useCallback, useContext, useMemo } from 'react' 2 | import { extend, Canvas, useThree, ReactThreeFiber, ThreeEvent } from '@react-three/fiber' 3 | import { OrbitControls } from 'three-stdlib' 4 | extend({ OrbitControls }) 5 | 6 | function useHover(stopPropagation = true) { 7 | const [hovered, setHover] = useState(false) 8 | const hover = useCallback( 9 | (e) => { 10 | if (stopPropagation) e.stopPropagation() 11 | setHover(true) 12 | }, 13 | [stopPropagation], 14 | ) 15 | const unhover = useCallback( 16 | (e) => { 17 | if (stopPropagation) e.stopPropagation() 18 | setHover(false) 19 | }, 20 | [stopPropagation], 21 | ) 22 | const [bind] = useState(() => ({ onPointerOver: hover, onPointerOut: unhover })) 23 | return [bind, hovered] 24 | } 25 | 26 | function useDrag(onDrag: any, onEnd: any) { 27 | const [active, setActive] = useState(false) 28 | const [, toggle] = useContext(camContext) as any 29 | 30 | const down = (event: ThreeEvent) => { 31 | console.log('down') 32 | setActive(true) 33 | toggle(false) 34 | event.stopPropagation() 35 | // @ts-ignore 36 | event.target.setPointerCapture(event.pointerId) 37 | } 38 | 39 | const up = (event: ThreeEvent) => { 40 | console.log('up') 41 | setActive(false) 42 | toggle(true) 43 | event.stopPropagation() 44 | // @ts-ignore 45 | event.target.releasePointerCapture(event.pointerId) 46 | if (onEnd) onEnd() 47 | } 48 | 49 | const move = (event: ThreeEvent) => { 50 | if (active) { 51 | event.stopPropagation() 52 | onDrag(event.unprojectedPoint) 53 | } 54 | } 55 | 56 | return { onPointerDown: down, onPointerUp: up, onPointerMove: move } 57 | } 58 | 59 | function EndPoint({ position, onDrag, onEnd }: any) { 60 | let [bindHover, hovered] = useHover(false) 61 | let bindDrag = useDrag(onDrag, onEnd) 62 | return ( 63 | 64 | 65 | 66 | 67 | ) 68 | } 69 | 70 | function Line({ defaultStart, defaultEnd }: any) { 71 | const [start, setStart] = useState(defaultStart) 72 | const [end, setEnd] = useState(defaultEnd) 73 | const positions = useMemo(() => new Float32Array([...start, ...end]), [start, end]) 74 | const lineRef = useRef(null!) 75 | useEffect(() => { 76 | const { current } = lineRef 77 | current.geometry.attributes.position.needsUpdate = true 78 | current.geometry.computeBoundingSphere() 79 | }, [lineRef, start, end]) 80 | 81 | return ( 82 | <> 83 | 84 | 85 | 86 | 87 | 88 | 89 | setStart(v.toArray())} /> 90 | setEnd(v.toArray())} /> 91 | 92 | ) 93 | } 94 | 95 | const camContext = React.createContext(null) 96 | function Controls({ children }: any) { 97 | const { gl, camera, invalidate } = useThree() 98 | const api = useState(true) 99 | const ref = useRef(null!) 100 | useEffect(() => { 101 | const current = ref.current 102 | const onChange = () => invalidate() 103 | 104 | current.addEventListener('change', onChange) 105 | return () => current.removeEventListener('change', onChange) 106 | }, [invalidate]) 107 | 108 | return ( 109 | <> 110 | 111 | {children} 112 | 113 | ) 114 | } 115 | 116 | export default function App() { 117 | return ( 118 | 123 | 124 | 125 | 126 | 127 | 128 | ) 129 | } 130 | -------------------------------------------------------------------------------- /example/src/demos/MultiMaterial.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import React, { useState, useEffect, useRef, useMemo } from 'react' 3 | import { Canvas } from '@react-three/fiber' 4 | 5 | const redMaterial = new THREE.MeshBasicMaterial({ color: 'aquamarine', toneMapped: false }) 6 | 7 | function ReuseMaterial(props: any) { 8 | return ( 9 | 10 | 11 | 12 | 13 | ) 14 | } 15 | 16 | function TestReuse() { 17 | const [i, set] = useState(true) 18 | useEffect(() => { 19 | const interval = setInterval(() => set((s) => !s), 1000) 20 | return () => clearInterval(interval) 21 | }, []) 22 | return ( 23 | <> 24 | {i && } 25 | 26 | 27 | ) 28 | } 29 | 30 | function TestMultiMaterial(props: any) { 31 | const ref = useRef(null!) 32 | const [ok, set] = useState(true) 33 | useEffect(() => { 34 | const interval = setInterval(() => set((ok) => !ok), 1000) 35 | return () => clearInterval(interval) 36 | }, []) 37 | useEffect(() => { 38 | console.log(ref.current.material) 39 | }, [ok]) 40 | return ( 41 | 42 | 43 | 44 | 45 | {ok ? ( 46 | 47 | ) : ( 48 | 49 | )} 50 | 51 | 52 | 53 | 54 | ) 55 | } 56 | 57 | function TestMultiDelete(props: any) { 58 | const ref = useRef(null!) 59 | const [ok, set] = useState(true) 60 | useEffect(() => { 61 | const interval = setInterval(() => set((ok) => !ok), 1000) 62 | return () => clearInterval(interval) 63 | }, []) 64 | useEffect(() => { 65 | console.log(ref.current.material) 66 | }, [ok]) 67 | return ( 68 | 69 | 70 | 71 | 72 | {ok && } 73 | 74 | 75 | 76 | 77 | ) 78 | } 79 | 80 | function TestMix(props: any) { 81 | const [size, set] = useState(0.1) 82 | useEffect(() => { 83 | const timeout = setInterval( 84 | () => 85 | set((s) => { 86 | return s < 0.4 ? s + 0.025 : 0 87 | }), 88 | 1000, 89 | ) 90 | return () => clearTimeout(timeout) 91 | }, []) 92 | let g = useMemo(() => new THREE.SphereGeometry(size, 64, 64), [size]) 93 | return ( 94 | 95 | 96 | 97 | ) 98 | } 99 | 100 | export default function Test() { 101 | return ( 102 | 103 | 104 | 105 | 106 | 107 | 108 | ) 109 | } 110 | -------------------------------------------------------------------------------- /example/src/demos/MultiRender.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from 'react' 2 | import { Canvas, useFrame } from '@react-three/fiber' 3 | 4 | const CanvasStyle = { 5 | width: '100%', 6 | height: '50%', 7 | } 8 | 9 | const Obj = () => { 10 | const meshRef = useRef(null!) 11 | useFrame(() => { 12 | if (meshRef.current) { 13 | meshRef.current.rotation.y += 0.03 14 | } 15 | }) 16 | return ( 17 | 18 | 19 | 20 | 21 | ) 22 | } 23 | const SpinningScene = () => ( 24 |
25 | 26 | 27 | 28 |
29 | ) 30 | 31 | const StaticScene = () => ( 32 |
33 | 34 | 35 | 36 | 37 | 38 | 39 |
40 | ) 41 | /** Main component */ 42 | function App() { 43 | const [secondScene, setSecondScene] = useState(false) 44 | 45 | useEffect(() => { 46 | setTimeout(() => setSecondScene(true), 500) 47 | }, []) 48 | 49 | return ( 50 |
51 | 52 | {secondScene && } 53 |
54 | ) 55 | } 56 | 57 | export default App 58 | -------------------------------------------------------------------------------- /example/src/demos/MultiView.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import * as React from 'react' 3 | import { useCallback, useEffect, useState } from 'react' 4 | import { Canvas, useFrame, useThree, createPortal } from '@react-three/fiber' 5 | import { 6 | useGLTF, 7 | PerspectiveCamera, 8 | OrthographicCamera, 9 | Environment, 10 | OrbitControls, 11 | ArcballControls, 12 | TransformControls, 13 | CameraShake, 14 | Bounds, 15 | } from '@react-three/drei' 16 | 17 | export function Soda(props: any) { 18 | const [hovered, spread] = useHover() 19 | const { nodes, materials } = useGLTF('/bottle.gltf') as any 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | ) 28 | } 29 | 30 | function useHover() { 31 | const [hovered, hover] = useState(false) 32 | return [hovered, { onPointerOver: (e: any) => (e.stopPropagation(), hover(true)), onPointerOut: () => hover(false) }] 33 | } 34 | 35 | function View({ index = 1, children, clearColor, placement }: any) { 36 | const { events, size } = useThree() 37 | const [scene] = useState(() => new THREE.Scene()) 38 | const [position] = useState(() => new THREE.Vector2()) 39 | const [el] = useState(() => { 40 | const div = document.createElement('div') 41 | div.style.zIndex = index 42 | div.style.position = 'absolute' 43 | div.style.width = div.style.height = '50%' 44 | return div 45 | }) 46 | 47 | useEffect(() => { 48 | switch (placement) { 49 | case 'topright': 50 | position.set(1, 1) 51 | el.style.top = el.style.right = '0px' 52 | break 53 | case 'topleft': 54 | position.set(0, 1) 55 | el.style.top = el.style.left = '0px' 56 | break 57 | case 'bottomright': 58 | position.set(1, 0) 59 | el.style.bottom = el.style.right = '0px' 60 | break 61 | case 'bottomleft': 62 | default: 63 | position.set(0, 0) 64 | el.style.bottom = el.style.left = '0px' 65 | break 66 | } 67 | }, [placement]) 68 | 69 | useEffect(() => { 70 | if (events.connected) { 71 | const target = events.connected 72 | target.appendChild(el) 73 | return () => void target.removeChild(el) 74 | } 75 | }, [events, el]) 76 | 77 | const compute = useCallback((event, state) => { 78 | if (event.target === el) { 79 | const width = state.size.width 80 | const height = state.size.height 81 | state.pointer.set((event.offsetX / width) * 2 - 1, -(event.offsetY / height) * 2 + 1) 82 | state.raycaster.setFromCamera(state.pointer, state.camera) 83 | } 84 | }, []) 85 | 86 | return ( 87 | <> 88 | {createPortal( 89 | 90 | {children} 91 | , 92 | scene, 93 | { 94 | events: { compute, priority: events.priority + index, connected: el }, 95 | size: { width: size.width / 2, height: size.height / 2 }, 96 | }, 97 | )} 98 | 99 | ) 100 | } 101 | 102 | function Container({ children, index, clearColor, position }: any) { 103 | const { size, camera, scene } = useThree() 104 | 105 | useFrame((state) => { 106 | const left = Math.floor(size.width * position.x) 107 | const bottom = Math.floor(size.height * position.y) 108 | const width = Math.floor(size.width) 109 | const height = Math.floor(size.height) 110 | state.gl.setViewport(left, bottom, width, height) 111 | state.gl.setScissor(left, bottom, width, height) 112 | state.gl.setScissorTest(true) 113 | if (clearColor) state.gl.setClearColor(clearColor) 114 | state.gl.render(scene, camera) 115 | }, index) 116 | 117 | return <>{children} 118 | } 119 | 120 | const App = () => ( 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | ) 148 | 149 | function Scene({ children, controls = true, preset }: any) { 150 | return ( 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | {children} 160 | 161 | ) 162 | } 163 | 164 | export default App 165 | -------------------------------------------------------------------------------- /example/src/demos/Pointcloud.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect, useState, useCallback, useContext, useMemo } from 'react' 2 | import { extend, Canvas, useThree, ReactThreeFiber } from '@react-three/fiber' 3 | import * as THREE from 'three' 4 | import { OrbitControls } from 'three-stdlib' 5 | extend({ OrbitControls }) 6 | 7 | export class DotMaterial extends THREE.ShaderMaterial { 8 | constructor() { 9 | super({ 10 | transparent: true, 11 | uniforms: { size: { value: 15 }, scale: { value: 1 } }, 12 | vertexShader: THREE.ShaderLib.points.vertexShader, 13 | fragmentShader: ` 14 | varying vec3 vColor; 15 | void main() { 16 | gl_FragColor = vec4(vColor, step(length(gl_PointCoord.xy - vec2(0.5)), 0.5)); 17 | }`, 18 | }) 19 | } 20 | } 21 | 22 | extend({ DotMaterial }) 23 | 24 | const white = new THREE.Color('white') 25 | const hotpink = new THREE.Color('hotpink') 26 | function Particles({ pointCount }: any) { 27 | const [positions, colors] = useMemo(() => { 28 | const positions = [...new Array(pointCount * 3)].map(() => 5 - Math.random() * 10) 29 | const colors = [...new Array(pointCount)].flatMap(() => hotpink.toArray()) 30 | return [new Float32Array(positions), new Float32Array(colors)] 31 | }, [pointCount]) 32 | 33 | const points = useRef(null!) 34 | const hover = useCallback((e) => { 35 | e.stopPropagation() 36 | white.toArray(points.current.geometry.attributes.color.array, e.index * 3) 37 | points.current.geometry.attributes.color.needsUpdate = true 38 | }, []) 39 | 40 | const unhover = useCallback((e) => { 41 | hotpink.toArray(points.current.geometry.attributes.color.array, e.index * 3) 42 | points.current.geometry.attributes.color.needsUpdate = true 43 | }, []) 44 | 45 | return ( 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | ) 54 | } 55 | 56 | export default function App() { 57 | return ( 58 | 62 | 63 | 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /example/src/demos/Portals.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import * as React from 'react' 3 | import { useCallback, useLayoutEffect, useRef, useState } from 'react' 4 | import { Canvas, useFrame, useThree, createPortal } from '@react-three/fiber' 5 | import { useGLTF, OrbitControls, useFBO, Environment } from '@react-three/drei' 6 | 7 | export function Lights() { 8 | return ( 9 | <> 10 | 11 | 12 | 13 | 14 | 15 | ) 16 | } 17 | 18 | export function Farm(props: any) { 19 | const { scene } = useGLTF('/farm.gltf') 20 | return 21 | } 22 | 23 | export function Ramen(props: any) { 24 | const { scene } = useGLTF('/ramen.gltf') 25 | return 26 | } 27 | 28 | export function Soda(props: any) { 29 | const [hovered, spread] = useHover() 30 | const { nodes, materials } = useGLTF('/bottle.gltf') as any 31 | return ( 32 | 33 | 34 | 35 | 36 | 37 | 38 | ) 39 | } 40 | 41 | function useHover() { 42 | const [hovered, hover] = useState(false) 43 | return [hovered, { onPointerOver: (e: any) => (e.stopPropagation(), hover(true)), onPointerOut: () => hover(false) }] 44 | } 45 | 46 | function Portal({ children, scale = [1, 1, 1], clearColor = 'white', ...props }: any) { 47 | const ref = useRef(null!) 48 | const fbo = useFBO() 49 | const { events } = useThree() 50 | // The portal will render into this scene 51 | const [scene] = useState(() => new THREE.Scene()) 52 | // We have our own camera in here, separate from the default 53 | const [camera] = useState(() => new THREE.PerspectiveCamera(50, 1, 0.1, 1000)) 54 | 55 | useLayoutEffect(() => { 56 | camera.aspect = ref.current.scale.x / ref.current.scale.y 57 | camera.updateProjectionMatrix() 58 | }, [scale]) 59 | 60 | useFrame((state) => { 61 | // Copy the default cameras whereabous 62 | camera.position.copy(state.camera.position) 63 | camera.rotation.copy(state.camera.rotation) 64 | camera.scale.copy(state.camera.scale) 65 | // Render into a WebGLRenderTarget as a texture (the FBO above) 66 | state.gl.clearColor() 67 | state.gl.setRenderTarget(fbo) 68 | state.gl.render(scene, camera) 69 | state.gl.setRenderTarget(null) 70 | }) 71 | 72 | // This is a custom raycast-compute function, it controls how the raycaster functions. 73 | const compute = useCallback((event, state, previous) => { 74 | // First we call the previous state-onion-layers compute, this is what makes it possible to nest portals 75 | if (!previous.raycaster.camera) previous.events.compute(event, previous, previous.previousRoot?.getState()) 76 | // We run a quick check against the textured plane itself, if it isn't hit there's no need to raycast at all 77 | const [intersection] = previous.raycaster.intersectObject(ref.current) 78 | if (!intersection) return false 79 | // We take that hits uv coords, set up this layers raycaster, et voilà, we have raycasting with perspective shift 80 | const uv = intersection.uv 81 | state.raycaster.setFromCamera(state.pointer.set(uv.x * 2 - 1, uv.y * 2 - 1), camera) 82 | }, []) 83 | 84 | return ( 85 | <> 86 | {/* This mesh receives the render-targets texture and draws it onto a plane */} 87 | 88 | 89 | 90 | 91 | {/* A portal by default now has its own state, separate from the root state. 92 | The third argument to createPortal allows you to override parts of it, in here for example 93 | we place our own camera and override the events definition with a lower proprity than 94 | the previous layer, and our custom compute function. */} 95 | {createPortal(children, scene, { camera, events: { compute, priority: events.priority - 1 } })} 96 | 97 | ) 98 | } 99 | 100 | const App = () => ( 101 | 102 | 103 | 104 | {/* First layer, a portal */} 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | ) 122 | 123 | function Test() { 124 | const controls = useThree((state) => state.controls) 125 | console.log(controls) 126 | useFrame((state) => { 127 | //console.log(state.pointer.x) 128 | }) 129 | } 130 | 131 | export default App 132 | -------------------------------------------------------------------------------- /example/src/demos/Reparenting.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from 'react' 2 | import { Canvas, createPortal } from '@react-three/fiber' 3 | import { useReducer } from 'react' 4 | 5 | function Icosahedron() { 6 | const [active, set] = useState(false) 7 | const handleClick = useCallback((e) => set((state) => !state), []) 8 | return ( 9 | 10 | 11 | 12 | 13 | ) 14 | } 15 | 16 | function RenderToPortal({ targets }: any) { 17 | const [target, toggle] = useReducer((state) => (state + 1) % targets.length, 0) 18 | useEffect(() => { 19 | const interval = setInterval(toggle, 1000) 20 | return () => clearInterval(interval) 21 | }, [targets]) 22 | return <>{createPortal(, targets[target])} 23 | } 24 | 25 | export default function Group() { 26 | const [ref1, set1] = useState(null!) 27 | const [ref2, set2] = useState(null!) 28 | return ( 29 | console.log('onCreated')}> 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {ref1 && ref2 && } 38 | 39 | 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /example/src/demos/ResetProps.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import React, { useEffect, useState, useRef } from 'react' 3 | import { Canvas, useThree, useFrame } from '@react-three/fiber' 4 | import { OrbitControls } from '@react-three/drei' 5 | 6 | function AdaptivePixelRatio() { 7 | const gl = useThree((state) => state.gl) 8 | const current = useThree((state) => state.performance.current) 9 | const initialDpr = useThree((state) => state.viewport.initialDpr) 10 | const setDpr = useThree((state) => state.setDpr) 11 | // Restore initial pixelratio on unmount 12 | useEffect(() => { 13 | const domElement = gl.domElement 14 | return () => { 15 | setDpr(initialDpr) 16 | domElement.style.imageRendering = 'auto' 17 | } 18 | }, []) 19 | // Set adaptive pixelratio 20 | useEffect(() => { 21 | setDpr(current * initialDpr) 22 | gl.domElement.style.imageRendering = current === 1 ? 'auto' : 'pixelated' 23 | }, [current]) 24 | return null 25 | } 26 | 27 | function AdaptiveEvents() { 28 | const get = useThree((state) => state.get) 29 | const current = useThree((state) => state.performance.current) 30 | useEffect(() => { 31 | const enabled = get().events.enabled 32 | return () => void (get().events.enabled = enabled) 33 | }, []) 34 | useEffect(() => void (get().events.enabled = current === 1), [current]) 35 | return null 36 | } 37 | 38 | function Scene() { 39 | const group = useRef(null!) 40 | const [showCube, setShowCube] = useState(false) 41 | const [hovered, setHovered] = useState(false) 42 | const [color, setColor] = useState('pink') 43 | 44 | useEffect(() => { 45 | const interval = setInterval(() => setShowCube((showCube) => !showCube), 1000) 46 | return () => clearInterval(interval) 47 | }, []) 48 | 49 | useFrame(({ clock }) => group.current?.rotation.set(Math.sin(clock.elapsedTime), 0, 0)) 50 | 51 | return ( 52 | <> 53 | 54 | 55 | 56 | 57 | setHovered(true)} 60 | onPointerOut={() => setHovered(false)} 61 | onClick={() => setColor(color === 'pink' ? 'peachpuff' : 'pink')}> 62 | 63 | 64 | 65 | 66 | {showCube ? ( 67 | 68 | 69 | 70 | 71 | ) : ( 72 | 73 | 74 | 75 | 76 | )} 77 | 78 | 79 | 80 | {showCube ? : } 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | ) 89 | } 90 | 91 | export default function App() { 92 | return ( 93 | 94 | 95 | 96 | ) 97 | } 98 | -------------------------------------------------------------------------------- /example/src/demos/SVGRenderer.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import React, { useRef, useState } from 'react' 3 | import { 4 | RenderProps, 5 | extend, 6 | createRoot, 7 | unmountComponentAtNode, 8 | useFrame, 9 | events, 10 | ReconcilerRoot, 11 | } from '@react-three/fiber' 12 | import useMeasure, { Options as ResizeOptions } from 'react-use-measure' 13 | import mergeRefs from 'react-merge-refs' 14 | import { SVGRenderer } from 'three-stdlib' 15 | 16 | function TorusKnot() { 17 | const [hovered, hover] = useState(false) 18 | const ref = useRef(null!) 19 | useFrame((state) => { 20 | const t = state.clock.elapsedTime / 2 21 | ref.current.rotation.set(t, t, t) 22 | }) 23 | return ( 24 | hover(true)} onPointerOut={() => hover(false)}> 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | export default function () { 32 | return ( 33 | 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | interface Props extends Omit, 'size' | 'gl'>, React.HTMLAttributes { 41 | children: React.ReactNode 42 | resize?: ResizeOptions 43 | } 44 | 45 | function Canvas({ children, resize, style, className, ...props }: Props) { 46 | React.useMemo(() => extend(THREE), []) 47 | 48 | const [bind, size] = useMeasure({ scroll: true, debounce: { scroll: 50, resize: 0 }, ...resize }) 49 | const ref = React.useRef(null!) 50 | const [gl] = useState(() => new SVGRenderer() as unknown as THREE.WebGLRenderer) 51 | const root = React.useRef>(null!) 52 | 53 | if (size.width > 0 && size.height > 0) { 54 | if (!root.current) root.current = createRoot(ref.current) 55 | root.current.configure({ ...props, size, events, gl }) 56 | root.current.render(children) 57 | } 58 | 59 | React.useEffect(() => { 60 | const container = ref.current 61 | container.appendChild(gl.domElement) 62 | return () => { 63 | container.removeChild(gl.domElement) 64 | unmountComponentAtNode(container) 65 | } 66 | }, []) 67 | 68 | return ( 69 |
74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /example/src/demos/Selection.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Canvas } from '@react-three/fiber' 3 | 4 | function Sphere() { 5 | const [hovered, set] = useState(false) 6 | console.log('sphere', hovered) 7 | return ( 8 | (e.stopPropagation(), set(true))} onPointerOut={(e) => set(false)}> 9 | 10 | 11 | 12 | ) 13 | } 14 | 15 | function Circle() { 16 | const [hovered, set] = useState(false) 17 | console.log('circle', hovered) 18 | return ( 19 | (e.stopPropagation(), set(true))} onPointerOut={(e) => set(false)}> 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | export default function App() { 27 | return ( 28 | 29 | console.log('group1 over')} 32 | onPointerOut={(e) => console.log('group1 out')}> 33 | console.log(' group2 over')} 35 | onPointerOut={(e) => console.log(' group2 out')}> 36 | console.log(' white mesh over')} 39 | onPointerOut={(e) => console.log(' white mesh out')}> 40 | 41 | 42 | 43 | console.log(' black mesh over')} 46 | onPointerOut={(e) => console.log(' black mesh out')}> 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /example/src/demos/StopPropagation.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import React, { Suspense, useState, useCallback } from 'react' 3 | import { Canvas, createPortal, useThree, useFrame } from '@react-three/fiber' 4 | import { useGLTF, Environment, OrbitControls } from '@react-three/drei' 5 | 6 | function Soda(props: any) { 7 | const [hovered, spread] = useHover() 8 | const { nodes, materials } = useGLTF('/bottle.gltf') as any 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | ) 17 | } 18 | 19 | function useHover() { 20 | const [hovered, hover] = useState(false) 21 | return [hovered, { onPointerOver: (e: any) => (e.stopPropagation(), hover(true)), onPointerOut: () => hover(false) }] 22 | } 23 | 24 | function Hud({ priority = 1, children }: any) { 25 | const { gl, scene: defaultScene, camera: defaultCamera } = useThree() 26 | const [scene] = useState(() => new THREE.Scene()) 27 | useFrame(() => { 28 | if (priority === 1) { 29 | gl.autoClear = true 30 | gl.render(defaultScene, defaultCamera) 31 | gl.autoClear = false 32 | } 33 | gl.clearDepth() 34 | gl.render(scene, defaultCamera) 35 | }, priority) 36 | return <>{createPortal(children, scene, { events: { priority: priority + 1 } })} 37 | } 38 | 39 | function Plane({ stop = false, color, position }: any) { 40 | const [hovered, set] = useState(false) 41 | const onPointerOver = useCallback((e) => { 42 | if (stop) e.stopPropagation() 43 | set(true) 44 | }, []) 45 | const onPointerOut = useCallback((e) => { 46 | if (stop) e.stopPropagation() 47 | set(false) 48 | }, []) 49 | return ( 50 | 51 | 52 | 53 | 54 | ) 55 | } 56 | 57 | const App = () => ( 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | ) 77 | 78 | export default App 79 | -------------------------------------------------------------------------------- /example/src/demos/SuspenseAndErrors.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useState, useEffect } from 'react' 3 | import { Canvas, useLoader } from '@react-three/fiber' 4 | import { suspend } from 'suspend-react' 5 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' 6 | 7 | // Suspends the scene for 2 seconds, simulating loading an async asset 8 | function AsyncComponent({ cacheKey }: any) { 9 | suspend(() => new Promise((res) => setTimeout(res, 2000)), [cacheKey]) 10 | return null 11 | } 12 | 13 | // Loads a file that does not exist 14 | function SimulateError() { 15 | useLoader(GLTFLoader, '/doesnotexist.glb') 16 | return null 17 | } 18 | 19 | export default function App() { 20 | const [load, set] = useState(false) 21 | useEffect(() => { 22 | const timeout = setTimeout(() => set(true), 3000) 23 | return () => clearTimeout(timeout) 24 | }, []) 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | {load && } 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /example/src/demos/SuspenseMaterial.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Suspense, useReducer } from 'react' 3 | import { Canvas } from '@react-three/fiber' 4 | import { suspend } from 'suspend-react' 5 | 6 | export default function App() { 7 | const [arg, inc] = useReducer((x) => x + 1, 0) 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | }> 15 | 16 | 17 | 18 | 19 | ) 20 | } 21 | 22 | function SlowMaterial({ arg = 0 }) { 23 | suspend(() => new Promise((res) => setTimeout(res, 1000)), [arg]) 24 | return 25 | } 26 | 27 | function FallbackMaterial() { 28 | return 29 | } 30 | -------------------------------------------------------------------------------- /example/src/demos/Test.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import React, { useState, useEffect, useReducer } from 'react' 3 | import { Canvas, useFrame } from '@react-three/fiber' 4 | 5 | function Test() { 6 | const [o1] = useState( 7 | () => new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial({ color: 'hotpink' })), 8 | ) 9 | const [o2] = useState( 10 | () => new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial({ color: 'aquamarine' })), 11 | ) 12 | const [which, toggle] = useReducer((state) => !state, true) 13 | useEffect(() => { 14 | const interval = setInterval(toggle, 1000) 15 | return () => clearInterval(interval) 16 | }, []) 17 | 18 | useFrame((state) => { 19 | //console.log(state.pointer.x) 20 | }) 21 | 22 | return 23 | } 24 | 25 | export default function App() { 26 | const [foo, bar] = useState(0) 27 | useEffect(() => { 28 | setTimeout(() => bar(1), 1000) 29 | }, []) 30 | return ( 31 | 32 | 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /example/src/demos/Viewcube.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import React, { useRef, useLayoutEffect, useState } from 'react' 3 | import { Canvas, useFrame, useThree, createPortal } from '@react-three/fiber' 4 | import { OrbitControls } from '@react-three/drei' 5 | 6 | function Viewcube() { 7 | const { gl, scene: defaultScene, camera: defaultCamera, size, events } = useThree() 8 | const [scene] = useState(() => new THREE.Scene()) 9 | const [camera] = useState(() => new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000)) 10 | 11 | useLayoutEffect(() => { 12 | camera.left = -size.width / 2 13 | camera.right = size.width / 2 14 | camera.top = size.height / 2 15 | camera.bottom = -size.height / 2 16 | camera.position.set(0, 0, 100) 17 | camera.updateProjectionMatrix() 18 | }, [size]) 19 | 20 | const ref = useRef(null!) 21 | const [hover, set] = useState(null) 22 | const matrix = new THREE.Matrix4() 23 | 24 | useFrame(() => { 25 | matrix.copy(defaultCamera.matrix).invert() 26 | ref.current.quaternion.setFromRotationMatrix(matrix) 27 | gl.autoClear = true 28 | gl.render(defaultScene, defaultCamera) 29 | gl.autoClear = false 30 | gl.clearDepth() 31 | gl.render(scene, camera) 32 | }, 1) 33 | 34 | return ( 35 | <> 36 | {createPortal( 37 | 38 | set(null)} 42 | onPointerMove={(e) => set(Math.floor((e.faceIndex || 0) / 2))}> 43 | {[...Array(6)].map((_, index) => ( 44 | 49 | ))} 50 | 51 | 52 | 53 | 54 | , 55 | scene, 56 | { camera, events: { priority: events.priority + 1 } }, 57 | )} 58 | 59 | ) 60 | } 61 | 62 | export default function App() { 63 | return ( 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /example/src/demos/index.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'react' 2 | 3 | const Animation = { Component: lazy(() => import('./Animation')) } 4 | const AutoDispose = { Component: lazy(() => import('./AutoDispose')) } 5 | const ClickAndHover = { Component: lazy(() => import('./ClickAndHover')) } 6 | const ContextMenuOverride = { Component: lazy(() => import('./ContextMenuOverride')) } 7 | const Gestures = { Component: lazy(() => import('./Gestures')) } 8 | const Gltf = { Component: lazy(() => import('./Gltf')) } 9 | const Inject = { Component: lazy(() => import('./Inject')) } 10 | const Layers = { Component: lazy(() => import('./Layers')) } 11 | const Lines = { Component: lazy(() => import('./Lines')) } 12 | const MultiMaterial = { Component: lazy(() => import('./MultiMaterial')) } 13 | const MultiRender = { Component: lazy(() => import('./MultiRender')) } 14 | const MultiView = { Component: lazy(() => import('./MultiView')) } 15 | const Pointcloud = { Component: lazy(() => import('./Pointcloud')) } 16 | const Reparenting = { Component: lazy(() => import('./Reparenting')) } 17 | const ResetProps = { Component: lazy(() => import('./ResetProps')) } 18 | const Selection = { Component: lazy(() => import('./Selection')) } 19 | const StopPropagation = { Component: lazy(() => import('./StopPropagation')) } 20 | const SuspenseAndErrors = { Component: lazy(() => import('./SuspenseAndErrors')) } 21 | const SuspenseMaterial = { Component: lazy(() => import('./SuspenseMaterial')) } 22 | const SVGRenderer = { Component: lazy(() => import('./SVGRenderer')) } 23 | const Test = { Component: lazy(() => import('./Test')) } 24 | const Viewcube = { Component: lazy(() => import('./Viewcube')) } 25 | const Portals = { Component: lazy(() => import('./Portals')) } 26 | const ViewTracking = { Component: lazy(() => import('./ViewTracking')) } 27 | 28 | export { 29 | Animation, 30 | AutoDispose, 31 | ClickAndHover, 32 | ContextMenuOverride, 33 | Gestures, 34 | Gltf, 35 | Inject, 36 | Layers, 37 | Lines, 38 | MultiMaterial, 39 | MultiRender, 40 | Pointcloud, 41 | Reparenting, 42 | ResetProps, 43 | Selection, 44 | StopPropagation, 45 | SuspenseAndErrors, 46 | SuspenseMaterial, 47 | SVGRenderer, 48 | Test, 49 | Viewcube, 50 | MultiView, 51 | Portals, 52 | ViewTracking, 53 | } 54 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | import App from './App' 4 | 5 | ReactDOM.createRoot(document.getElementById('root')!).render() 6 | -------------------------------------------------------------------------------- /example/src/styles.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled, { createGlobalStyle } from 'styled-components' 3 | import { Link } from 'wouter' 4 | 5 | const Page = styled.div` 6 | position: relative; 7 | width: 100%; 8 | height: 100vh; 9 | 10 | & > h1 { 11 | font-family: 'Roboto', sans-serif; 12 | font-weight: 900; 13 | font-size: 8em; 14 | margin: 0; 15 | color: white; 16 | line-height: 0.59em; 17 | letter-spacing: -2px; 18 | } 19 | 20 | & > h1 { 21 | position: absolute; 22 | top: 70px; 23 | left: 60px; 24 | } 25 | 26 | & > span { 27 | position: absolute; 28 | bottom: 60px; 29 | right: 60px; 30 | } 31 | 32 | @media only screen and (max-width: 1000px) { 33 | & > h1 { 34 | font-size: 5em; 35 | letter-spacing: -1px; 36 | } 37 | } 38 | 39 | & > a { 40 | margin: 0; 41 | color: white; 42 | text-decoration: none; 43 | } 44 | ` 45 | 46 | const Global = createGlobalStyle` 47 | @import url('@pmndrs/branding/styles.css'); 48 | 49 | * { 50 | box-sizing: border-box; 51 | } 52 | 53 | html, 54 | body, 55 | #root { 56 | width: 100%; 57 | height: 100%; 58 | margin: 0; 59 | padding: 0; 60 | -webkit-touch-callout: none; 61 | -webkit-user-select: none; 62 | -khtml-user-select: none; 63 | -moz-user-select: none; 64 | -ms-user-select: none; 65 | user-select: none; 66 | overflow: hidden; 67 | } 68 | 69 | #root { 70 | overflow: auto; 71 | } 72 | 73 | body { 74 | position: fixed; 75 | overflow: hidden; 76 | overscroll-behavior-y: none; 77 | font-family: 'Inter var', sans-serif; 78 | color: black; 79 | background: #dedddf !important; 80 | } 81 | 82 | canvas { 83 | touch-action: none; 84 | } 85 | 86 | .container { 87 | position: relative; 88 | width: 100%; 89 | height: 100%; 90 | } 91 | 92 | .text { 93 | line-height: 1em; 94 | text-align: left; 95 | font-size: 8em; 96 | word-break: break-word; 97 | position: absolute; 98 | top: 0; 99 | left: 0; 100 | width: 100%; 101 | height: 100%; 102 | } 103 | ` 104 | export const DemoPanel = styled.div` 105 | z-index: 1000; 106 | position: absolute; 107 | bottom: 50px; 108 | left: 50px; 109 | max-width: 250px; 110 | ` 111 | 112 | export const Dot = styled(Link)` 113 | display: inline-block; 114 | width: 20px; 115 | height: 20px; 116 | border-radius: 50%; 117 | margin: 8px; 118 | ` 119 | 120 | const LoadingContainer = styled.div` 121 | position: fixed; 122 | inset: 0; 123 | z-index: 100; 124 | display: flex; 125 | align-items: center; 126 | justify-content: center; 127 | 128 | background-color: #dedddf; 129 | color: white; 130 | ` 131 | 132 | const LoadingMessage = styled.div` 133 | font-family: 'Inter', Helvetica, sans-serif; 134 | ` 135 | 136 | export const Loading = () => { 137 | return ( 138 | 139 | Loading. 140 | 141 | ) 142 | } 143 | 144 | const StyledError = styled.div` 145 | position: absolute; 146 | padding: 10px 20px; 147 | bottom: unset; 148 | right: unset; 149 | top: 60px; 150 | left: 60px; 151 | max-width: 380px; 152 | border: 2px solid #ff5050; 153 | color: #ff5050; 154 | ` 155 | 156 | export const Error = ({ children }: React.PropsWithChildren<{}>) => { 157 | return {children} 158 | } 159 | 160 | export { Global, Page } 161 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "allowJs": false, 6 | "skipLibCheck": false, 7 | "esModuleInterop": false, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "ESNext", 12 | "moduleResolution": "Node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react" 17 | }, 18 | "include": ["./src", "./typings/*.d.ts", "../packages/*"] 19 | } 20 | -------------------------------------------------------------------------------- /example/typings/global.d.ts: -------------------------------------------------------------------------------- 1 | import { ReactThreeFiber } from '@react-three/fiber' 2 | import { OrbitControls } from 'three-stdlib' 3 | 4 | import { DotMaterial } from '../src/demos/Pointcloud' 5 | 6 | declare module '@react-three/fiber' { 7 | interface ThreeElements { 8 | orbitControls: ReactThreeFiber.Node 9 | dotMaterial: ReactThreeFiber.MaterialNode 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | export default defineConfig({ 5 | optimizeDeps: { 6 | exclude: ['@react-three/fiber'], 7 | }, 8 | plugins: [react()], 9 | }) 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | testPathIgnorePatterns: ['/node_modules/'], 5 | coveragePathIgnorePatterns: [ 6 | '/node_modules/', 7 | '/packages/fiber/dist', 8 | '/packages/fiber/src/index', 9 | '/packages/test-renderer/dist', 10 | '/test-utils', 11 | ], 12 | coverageDirectory: './coverage/', 13 | collectCoverage: false, 14 | moduleFileExtensions: ['js', 'ts', 'tsx'], 15 | verbose: false, 16 | testTimeout: 30000, 17 | setupFilesAfterEnv: ['/packages/shared/setupTests.ts', '/packages/fiber/tests/setupTests.ts'], 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-three-fiber--root", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "workspaces": [ 7 | "packages/*", 8 | "example" 9 | ], 10 | "husky": { 11 | "hooks": { 12 | "pre-commit": "lint-staged" 13 | } 14 | }, 15 | "preconstruct": { 16 | "packages": [ 17 | "packages/*" 18 | ] 19 | }, 20 | "lint-staged": { 21 | "*.{js,jsx,ts,tsx}": [ 22 | "eslint --fix" 23 | ] 24 | }, 25 | "scripts": { 26 | "changeset:add": "changeset add", 27 | "postinstall": "preconstruct dev", 28 | "build": "preconstruct build", 29 | "examples": "yarn workspace example dev", 30 | "dev": "preconstruct dev", 31 | "prepare": "husky install", 32 | "eslint": "eslint packages/**/src/**/*.{ts,tsx}", 33 | "eslint:fix": "yarn run eslint --fix", 34 | "test": "jest --coverage", 35 | "test:watch": "jest --watchAll", 36 | "typecheck": "tsc --noEmit --emitDeclarationOnly false --strict --jsx react", 37 | "validate": "preconstruct validate", 38 | "release": "yarn build && yarn changeset publish", 39 | "vers": "yarn changeset version", 40 | "codegen:eslint": "cd packages/eslint-plugin && yarn codegen", 41 | "analyze-fiber": "cd packages/fiber && npm publish --dry-run", 42 | "analyze-test": "cd packages/test-renderer && npm publish --dry-run" 43 | }, 44 | "devDependencies": { 45 | "@babel/core": "7.17.8", 46 | "@babel/preset-env": "7.16.11", 47 | "@babel/preset-react": "7.16.7", 48 | "@babel/preset-typescript": "^7.16.7", 49 | "@changesets/changelog-git": "^0.1.11", 50 | "@changesets/cli": "^2.22.0", 51 | "@preconstruct/cli": "^2.1.5", 52 | "@testing-library/react": "^13.0.0-alpha.5", 53 | "@types/jest": "^29.2.5", 54 | "@types/react": "^18.0.5", 55 | "@types/react-dom": "^18.0.1", 56 | "@types/react-native": "0.67.4", 57 | "@types/react-test-renderer": "^17.0.1", 58 | "@types/scheduler": "^0.16.2", 59 | "@types/three": "^0.139.0", 60 | "@typescript-eslint/eslint-plugin": "^5.48.1", 61 | "@typescript-eslint/parser": "^5.48.1", 62 | "eslint": "^8.32.0", 63 | "eslint-config-prettier": "^8.5.0", 64 | "eslint-import-resolver-alias": "^1.1.2", 65 | "eslint-plugin-import": "^2.25.4", 66 | "eslint-plugin-jest": "^27.2.1", 67 | "eslint-plugin-prettier": "^4.0.0", 68 | "eslint-plugin-react": "^7.29.4", 69 | "eslint-plugin-react-hooks": "^4.4.0", 70 | "expo-asset": "^8.4.6", 71 | "expo-file-system": "^15.4.3", 72 | "expo-gl": "^11.1.2", 73 | "husky": "^7.0.4", 74 | "jest": "^29.3.1", 75 | "jest-cli": "^27.5.1", 76 | "lint-staged": "^12.3.7", 77 | "prettier": "^2.6.1", 78 | "pretty-quick": "^3.1.3", 79 | "react": "^18.0.0", 80 | "react-dom": "^18.0.0", 81 | "react-native": "0.67.4", 82 | "react-test-renderer": "^18.0.0", 83 | "regenerator-runtime": "^0.13.9", 84 | "three": "^0.139.0", 85 | "three-stdlib": "^2.8.11", 86 | "ts-jest": "^27.1.4", 87 | "typescript": "^4.6.3" 88 | }, 89 | "dependencies": {} 90 | } 91 | -------------------------------------------------------------------------------- /packages/eslint-plugin/.npmignore: -------------------------------------------------------------------------------- 1 | scripts/ 2 | src/ 3 | index.js 4 | -------------------------------------------------------------------------------- /packages/eslint-plugin/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @react-three/eslint-plugin 2 | 3 | ## 0.1.0 4 | 5 | ### Minor Changes 6 | 7 | - 75521d21: Initial release of the eslint plugin containing two rules, `no-clone-in-loop` and `no-new-in-loop`. 8 | -------------------------------------------------------------------------------- /packages/eslint-plugin/README.md: -------------------------------------------------------------------------------- 1 | # @react-three/eslint-plugin 2 | 3 | [![Version](https://img.shields.io/npm/v/@react-three/eslint-plugin?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/@react-three/eslint-plugin) 4 | [![Twitter](https://img.shields.io/twitter/follow/pmndrs?label=%40pmndrs&style=flat&colorA=000000&colorB=000000&logo=twitter&logoColor=000000)](https://twitter.com/pmndrs) 5 | [![Discord](https://img.shields.io/discord/740090768164651008?style=flat&colorA=000000&colorB=000000&label=discord&logo=discord&logoColor=000000)](https://discord.gg/ZZjjNvJ) 6 | [![Open Collective](https://img.shields.io/opencollective/all/react-three-fiber?style=flat&colorA=000000&colorB=000000)](https://opencollective.com/react-three-fiber) 7 | [![ETH](https://img.shields.io/badge/ETH-f5f5f5?style=flat&colorA=000000&colorB=000000)](https://blockchain.com/eth/address/0x6E3f79Ea1d0dcedeb33D3fC6c34d2B1f156F2682) 8 | [![BTC](https://img.shields.io/badge/BTC-f5f5f5?style=flat&colorA=000000&colorB=000000)](https://blockchain.com/btc/address/36fuguTPxGCNnYZSRdgdh6Ea94brCAjMbH) 9 | 10 | An ESLint plugin which provides lint rules for [@react-three/fiber](https://github.com/pmndrs/react-three-fiber). 11 | 12 | ## Installation 13 | 14 | ```bash 15 | npm install @react-three/eslint-plugin --save-dev 16 | ``` 17 | 18 | ## Configuration 19 | 20 | Use the recommended [config](#recommended) to get reasonable defaults: 21 | 22 | ```json 23 | "extends": [ 24 | "plugin:@react-three/recommended" 25 | ] 26 | ``` 27 | 28 | If you do not use a config you will need to specify individual rules and add extra configuration. 29 | 30 | Add "@react-three" to the plugins section. 31 | 32 | ```json 33 | "plugins": [ 34 | "@react-three" 35 | ] 36 | ``` 37 | 38 | Enable the rules that you would like to use. 39 | 40 | ```json 41 | "rules": { 42 | "@react-three/no-clone-in-frame-loop": "error" 43 | } 44 | ``` 45 | 46 | ## Rules 47 | 48 | ✅ Enabled in the `recommended` [configuration](#recommended).
49 | 🔧 Automatically fixable by the `--fix` [CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
50 | 💡 Manually fixable by [editor suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions). 51 | 52 | 53 | 54 | 55 | | Rule | Description | ✅ | 🔧 | 💡 | 56 | | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | --- | --- | --- | 57 | | no-clone-in-loop | Disallow cloning vectors in the frame loop which can cause performance problems. | ✅ | | | 58 | | no-new-in-loop | Disallow instantiating new objects in the frame loop which can cause performance problems. | ✅ | | | 59 | 60 | 61 | 62 | ## Shareable configs 63 | 64 | ### Recommended 65 | 66 | This plugin exports a `recommended` configuration that enforces rules appropriate for everyone using React Three Fiber. 67 | 68 | ```json 69 | "extends": [ 70 | "plugin:@react-three/recommended" 71 | ] 72 | ``` 73 | 74 | ### All 75 | 76 | This plugin also exports an `all` configuration that includes every available rule. 77 | 78 | ```json 79 | "extends": [ 80 | "plugin:@react-three/all" 81 | ] 82 | ``` 83 | -------------------------------------------------------------------------------- /packages/eslint-plugin/docs/rules/no-clone-in-loop.md: -------------------------------------------------------------------------------- 1 | Cloning vectors in the frame loop instantiates new objects wasting large amounts of memory, 2 | which is especially bad for Three.js classes. 3 | Instead create once in a `useMemo` or a single shared reference outside of the component. 4 | 5 | #### ❌ Incorrect 6 | 7 | This creates a new vector 60+ times a second allocating large amounts of memory. 8 | 9 | ```js 10 | function Direction({ targetPosition }) { 11 | const ref = useRef() 12 | 13 | useFrame(() => { 14 | const direction = ref.current.position.clone().sub(targetPosition).normalize() 15 | }) 16 | 17 | return 18 | } 19 | ``` 20 | 21 | #### ✅ Correct 22 | 23 | This creates a vector outside of the frame loop to be reused each frame. 24 | 25 | ```js 26 | const tempVec = new THREE.Vector3() 27 | 28 | function Direction({ targetPosition }) { 29 | const ref = useRef() 30 | 31 | useFrame(() => { 32 | const direction = tempVec.copy(ref.current.position).sub(targetPosition).normalize() 33 | }) 34 | 35 | return 36 | } 37 | ``` 38 | 39 | This creates a vector once outside of the frame loop inside a `useMemo` to be reused each frame. 40 | 41 | ```js 42 | function Direction({ targetPosition }) { 43 | const ref = useRef() 44 | const tempVec = useMemo(() => new THREE.Vector3()) 45 | 46 | useFrame(() => { 47 | const direction = tempVec.copy(ref.current.position).sub(targetPosition).normalize() 48 | }) 49 | 50 | return 51 | } 52 | ``` 53 | -------------------------------------------------------------------------------- /packages/eslint-plugin/docs/rules/no-new-in-loop.md: -------------------------------------------------------------------------------- 1 | Instantiating new objects in the frame loop can waste large amounts of memory, 2 | which is especially bad for large CPU containers such as Three.js classes and any GPU resource as there is no reliable garbage collection. 3 | Instead create once in a `useMemo` or a single shared reference outside of the component. 4 | 5 | #### ❌ Incorrect 6 | 7 | This creates a new vector 60+ times a second allocating large amounts of memory. 8 | 9 | ```js 10 | function MoveTowards({ x, y, z }) { 11 | const ref = useRef() 12 | 13 | useFrame(() => { 14 | ref.current.position.lerp(new THREE.Vector3(x, y, z), 0.1) 15 | }) 16 | 17 | return 18 | } 19 | ``` 20 | 21 | #### ✅ Correct 22 | 23 | This creates a vector outside of the frame loop to be reused each frame. 24 | 25 | ```js 26 | const tempVec = new THREE.Vector3() 27 | 28 | function MoveTowards({ x, y, z }) { 29 | const ref = useRef() 30 | 31 | useFrame(() => { 32 | ref.current.position.lerp(tempVec.set(x, y, z), 0.1) 33 | }) 34 | 35 | return 36 | } 37 | ``` 38 | 39 | This creates a vector once outside of the frame loop inside a `useMemo` to be reused each frame. 40 | 41 | ```js 42 | function MoveTowards({ x, y, z }) { 43 | const ref = useRef() 44 | const tempVec = useMemo(() => new THREE.Vector3()) 45 | 46 | useFrame(() => { 47 | ref.current.position.lerp(tempVec.set(x, y, z), 0.1) 48 | }) 49 | 50 | return 51 | } 52 | ``` 53 | -------------------------------------------------------------------------------- /packages/eslint-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@react-three/eslint-plugin", 3 | "version": "0.1.1", 4 | "description": "An ESLint plugin which provides lint rules for @react-three/fiber.", 5 | "keywords": [ 6 | "react", 7 | "renderer", 8 | "fiber", 9 | "three", 10 | "threejs", 11 | "eslint" 12 | ], 13 | "author": "Michael Dougall (https://github.com/itsdouges)", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/pmndrs/react-three-fiber/issues" 17 | }, 18 | "homepage": "https://github.com/pmndrs/react-three-fiber/packages/eslint-plugin#readme", 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/pmndrs/react-three-fiber.git" 22 | }, 23 | "collective": { 24 | "type": "opencollective", 25 | "url": "https://opencollective.com/react-three-fiber" 26 | }, 27 | "main": "dist/react-three-eslint-plugin.cjs.js", 28 | "module": "dist/react-three-eslint-plugin.esm.js", 29 | "types": "dist/react-three-eslint-plugin.cjs.d.ts", 30 | "sideEffects": false, 31 | "preconstruct": { 32 | "entrypoints": [ 33 | "index.ts" 34 | ] 35 | }, 36 | "dependencies": { 37 | "@babel/runtime": "^7.17.8", 38 | "eslint": "^8.12.0" 39 | }, 40 | "devDependencies": { 41 | "@types/eslint": "^8.4.10", 42 | "@types/lodash": "^4.14.191", 43 | "lodash": "^4.17.19", 44 | "prettier": "^2.6.1", 45 | "ts-node": "^10.9.1" 46 | }, 47 | "scripts": { 48 | "codegen": "ts-node scripts/codegen.ts" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/eslint-plugin/scripts/codegen.ts: -------------------------------------------------------------------------------- 1 | import type { Rule } from 'eslint' 2 | import fs from 'fs/promises' 3 | import { join, extname, relative } from 'path' 4 | import { camelCase } from 'lodash' 5 | import { format, resolveConfig } from 'prettier' 6 | 7 | const jsHeader = (file: string) => 8 | `// THIS FILE WAS GENERATED DO NOT MODIFY BY HAND 9 | // @command yarn codegen:eslint 10 | ` + file 11 | 12 | interface FoundRule { 13 | module: Rule.RuleModule 14 | moduleName: string 15 | } 16 | 17 | interface GeneratedConfig { 18 | name: string 19 | path: string 20 | } 21 | 22 | const ignore = ['index.ts'] 23 | const srcDir = join(__dirname, '../src') 24 | const docsDir = join(__dirname, '../docs/rules') 25 | const rulesDir = join(srcDir, 'rules') 26 | const configsDir = join(srcDir, 'configs') 27 | const generatedConfigs: GeneratedConfig[] = [] 28 | 29 | async function ruleDocsPath(name: string): Promise { 30 | const absolutePath = join(docsDir, name + '.md') 31 | const relativePath = '.' + absolutePath.replace(process.cwd(), '') 32 | 33 | try { 34 | await fs.readFile(absolutePath) 35 | return relativePath 36 | } catch (_) { 37 | throw new Error(`invariant: rule ${name} should have docs at ${absolutePath}`) 38 | } 39 | } 40 | 41 | async function generateConfig(name: string, rules: FoundRule[]) { 42 | const code = ` 43 | export default { 44 | plugins: ['@react-three'], 45 | rules: { 46 | ${rules.map((rule) => `'@react-three/${rule.moduleName}': 'error'`).join(',')} 47 | }, 48 | } 49 | ` 50 | 51 | const filepath = join(configsDir, `${name}.ts`) 52 | await writeFile(filepath, code) 53 | 54 | generatedConfigs.push({ name: camelCase(name), path: './' + relative(srcDir, join(configsDir, name)) }) 55 | } 56 | 57 | async function writeFile(filepath: string, code: string) { 58 | const config = await resolveConfig(filepath) 59 | await fs.writeFile(filepath, format(extname(filepath) === '.md' ? code : jsHeader(code), { ...config, filepath })) 60 | } 61 | 62 | async function generateRuleIndex(rules: FoundRule[]) { 63 | const code = ` 64 | ${rules.map((rule) => `import ${camelCase(rule.moduleName)} from './${rule.moduleName}'`).join('\n')} 65 | 66 | export default { 67 | ${rules.map((rule) => `'${rule.moduleName}': ${camelCase(rule.moduleName)}`).join(',')} 68 | } 69 | ` 70 | 71 | const filepath = join(rulesDir, 'index.ts') 72 | await writeFile(filepath, code) 73 | } 74 | 75 | async function generatePluginIndex() { 76 | const code = ` 77 | ${generatedConfigs.map((config) => `import ${config.name} from '${config.path}'`).join('\n')} 78 | 79 | export { default as rules } from './rules/index' 80 | 81 | export const configs = { 82 | ${generatedConfigs.map((config) => `${config.name}`).join(',')} 83 | } 84 | ` 85 | 86 | const filepath = join(srcDir, 'index.ts') 87 | await writeFile(filepath, code) 88 | } 89 | 90 | const conditional = (cond: string, content?: boolean | string) => (content ? cond : '') 91 | const link = (content: string, url?: string) => (url ? `${content}` : content) 92 | 93 | async function generateReadme(rules: FoundRule[]) { 94 | const filepath = join(srcDir, '../', 'README.md') 95 | const readme = await fs.readFile(filepath, 'utf-8') 96 | 97 | const rows: string[] = [] 98 | 99 | for (const rule of rules) { 100 | const docsPath = await ruleDocsPath(rule.moduleName) 101 | const row = `| ${link(rule.moduleName, docsPath)} | ${rule.module.meta?.docs?.description} | ${conditional( 102 | '✅', 103 | rule.module.meta?.docs?.recommended, 104 | )} | ${conditional('🔧', rule.module.meta?.fixable)} | ${conditional('💡', rule.module.meta?.hasSuggestions)} |` 105 | 106 | rows.push(row) 107 | } 108 | 109 | const code = ` 110 | | Rule | Description | ✅ | 🔧 | 💡 | 111 | | ---- | -- | -- | -- | -- | 112 | ${rows.join('\n')} 113 | ` 114 | 115 | const found = /(.|\n)*/.exec(readme) 116 | 117 | if (!found) { 118 | throw new Error('invariant') 119 | } 120 | 121 | const newReadme = readme.replace( 122 | found[0], 123 | '' + '\n' + code + '\n', 124 | ) 125 | 126 | await writeFile(filepath, newReadme) 127 | } 128 | 129 | async function generate() { 130 | const rulePaths = await fs.readdir(rulesDir) 131 | const recommended: FoundRule[] = [] 132 | const rules: FoundRule[] = [] 133 | 134 | for (const moduleName of rulePaths) { 135 | if (ignore.includes(moduleName)) { 136 | continue 137 | } 138 | 139 | const rule: Rule.RuleModule = (await import(join(rulesDir, moduleName))).default 140 | const foundRule = { module: rule, moduleName: moduleName.replace(extname(moduleName), '') } 141 | rules.push(foundRule) 142 | 143 | if (rule.meta?.docs?.recommended) { 144 | recommended.push(foundRule) 145 | } 146 | } 147 | 148 | await generateRuleIndex(rules) 149 | await generateConfig('all', rules) 150 | await generateConfig('recommended', recommended) 151 | await generatePluginIndex() 152 | await generateReadme(rules) 153 | } 154 | 155 | generate() 156 | -------------------------------------------------------------------------------- /packages/eslint-plugin/src/configs/all.ts: -------------------------------------------------------------------------------- 1 | // THIS FILE WAS GENERATED DO NOT MODIFY BY HAND 2 | // @command yarn codegen:eslint 3 | 4 | export default { 5 | plugins: ['@react-three'], 6 | rules: { 7 | '@react-three/no-clone-in-loop': 'error', 8 | '@react-three/no-new-in-loop': 'error', 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /packages/eslint-plugin/src/configs/recommended.ts: -------------------------------------------------------------------------------- 1 | // THIS FILE WAS GENERATED DO NOT MODIFY BY HAND 2 | // @command yarn codegen:eslint 3 | 4 | export default { 5 | plugins: ['@react-three'], 6 | rules: { 7 | '@react-three/no-clone-in-loop': 'error', 8 | '@react-three/no-new-in-loop': 'error', 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /packages/eslint-plugin/src/index.ts: -------------------------------------------------------------------------------- 1 | // THIS FILE WAS GENERATED DO NOT MODIFY BY HAND 2 | // @command yarn codegen:eslint 3 | 4 | import all from './configs/all' 5 | import recommended from './configs/recommended' 6 | 7 | export { default as rules } from './rules/index' 8 | 9 | export const configs = { 10 | all, 11 | recommended, 12 | } 13 | -------------------------------------------------------------------------------- /packages/eslint-plugin/src/lib/url.ts: -------------------------------------------------------------------------------- 1 | export function gitHubUrl(name: string) { 2 | return `https://github.com/pmndrs/react-three-fiber/blob/master/packages/eslint-plugin/docs/rules/${name}.md` 3 | } 4 | -------------------------------------------------------------------------------- /packages/eslint-plugin/src/rules/index.ts: -------------------------------------------------------------------------------- 1 | // THIS FILE WAS GENERATED DO NOT MODIFY BY HAND 2 | // @command yarn codegen:eslint 3 | 4 | import noCloneInLoop from './no-clone-in-loop' 5 | import noNewInLoop from './no-new-in-loop' 6 | 7 | export default { 8 | 'no-clone-in-loop': noCloneInLoop, 9 | 'no-new-in-loop': noNewInLoop, 10 | } 11 | -------------------------------------------------------------------------------- /packages/eslint-plugin/src/rules/no-clone-in-loop.ts: -------------------------------------------------------------------------------- 1 | import type { Rule } from 'eslint' 2 | import * as ESTree from 'estree' 3 | import { gitHubUrl } from '../lib/url' 4 | 5 | const rule: Rule.RuleModule = { 6 | meta: { 7 | messages: { 8 | noClone: 9 | 'Cloning vectors in the frame loop can cause performance problems. Instead, create once in a useMemo or a single, shared reference outside of the component.', 10 | }, 11 | docs: { 12 | url: gitHubUrl('no-clone-in-loop'), 13 | recommended: true, 14 | description: 'Disallow cloning vectors in the frame loop which can cause performance problems.', 15 | }, 16 | }, 17 | create(ctx) { 18 | return { 19 | ['CallExpression[callee.name=useFrame] CallExpression MemberExpression Identifier[name=clone]']( 20 | node: ESTree.NewExpression, 21 | ) { 22 | ctx.report({ 23 | messageId: 'noClone', 24 | node: node, 25 | }) 26 | }, 27 | } 28 | }, 29 | } 30 | 31 | export default rule 32 | -------------------------------------------------------------------------------- /packages/eslint-plugin/src/rules/no-new-in-loop.ts: -------------------------------------------------------------------------------- 1 | import type { Rule } from 'eslint' 2 | import * as ESTree from 'estree' 3 | import { gitHubUrl } from '../lib/url' 4 | 5 | const rule: Rule.RuleModule = { 6 | meta: { 7 | messages: { 8 | noNew: 9 | 'Instantiating new objects in the frame loop can cause performance problems. Instead, create once in a useMemo or a single, shared reference outside of the component.', 10 | }, 11 | docs: { 12 | url: gitHubUrl('no-new-in-loop'), 13 | recommended: true, 14 | description: 'Disallow instantiating new objects in the frame loop which can cause performance problems.', 15 | }, 16 | }, 17 | create(ctx) { 18 | return { 19 | ['CallExpression[callee.name=useFrame] NewExpression'](node: ESTree.NewExpression) { 20 | ctx.report({ 21 | messageId: 'noNew', 22 | node: node, 23 | }) 24 | }, 25 | } 26 | }, 27 | } 28 | 29 | export default rule 30 | -------------------------------------------------------------------------------- /packages/eslint-plugin/tests/rules/no-clone-in-loop.test.ts: -------------------------------------------------------------------------------- 1 | import { RuleTester } from 'eslint' 2 | import rule from '../../src/rules/no-clone-in-loop' 3 | 4 | const tester = new RuleTester({ 5 | parserOptions: { ecmaVersion: 2015 }, 6 | }) 7 | 8 | tester.run('no-new-in-loop', rule, { 9 | valid: [ 10 | ` 11 | const vec = new THREE.Vector3() 12 | 13 | useFrame(() => { 14 | ref.current.position.copy(vec) 15 | }) 16 | `, 17 | ` 18 | useFrame(() => { 19 | clone() 20 | }) 21 | `, 22 | ` 23 | useFrame(() => { 24 | const clone = vec.copy(); 25 | }) 26 | `, 27 | ], 28 | invalid: [ 29 | { 30 | code: ` 31 | useFrame(() => { 32 | ref.current.position.clone() 33 | }) 34 | `, 35 | errors: [{ messageId: 'noClone' }], 36 | }, 37 | ], 38 | }) 39 | -------------------------------------------------------------------------------- /packages/eslint-plugin/tests/rules/no-new-in-loop.test.ts: -------------------------------------------------------------------------------- 1 | import { RuleTester } from 'eslint' 2 | import rule from '../../src/rules/no-new-in-loop' 3 | 4 | const tester = new RuleTester({ 5 | parserOptions: { ecmaVersion: 2015 }, 6 | }) 7 | 8 | tester.run('no-new-in-loop', rule, { 9 | valid: [ 10 | ` 11 | const vec = new THREE.Vector3() 12 | 13 | useFrame(() => { 14 | ref.current.position.copy(vec) 15 | }) 16 | `, 17 | ` 18 | const vec = new THREE.Vector3() 19 | 20 | useFrame(() => { 21 | ref.current.position.lerp(vec.set(x, y, z), 0.1) 22 | }) 23 | `, 24 | ` 25 | const vec = new Vector3() 26 | 27 | useFrame(() => { 28 | ref.current.position.copy(vec) 29 | }) 30 | `, 31 | ` 32 | const vec = new Vector3() 33 | 34 | useFrame(() => { 35 | ref.current.position.lerp(vec.set(x, y, z), 0.1) 36 | }) 37 | `, 38 | ], 39 | invalid: [ 40 | { 41 | code: ` 42 | useFrame(() => { 43 | ref.current.position.lerp(new THREE.Vector3(x, y, z), 0.1) 44 | }) 45 | `, 46 | errors: [{ messageId: 'noNew' }], 47 | }, 48 | { 49 | code: ` 50 | useFrame(() => { 51 | ref.current.position.lerp(new Vector3(x, y, z), 0.1) 52 | }) 53 | `, 54 | errors: [{ messageId: 'noNew' }], 55 | }, 56 | ], 57 | }) 58 | -------------------------------------------------------------------------------- /packages/fiber/.npmignore: -------------------------------------------------------------------------------- 1 | examples/ 2 | example/ 3 | .codesandbox/ 4 | .github/ 5 | .husky/ 6 | markdown/ 7 | src/ 8 | tests/ 9 | __mocks__ -------------------------------------------------------------------------------- /packages/fiber/__mocks__/expo-asset.ts: -------------------------------------------------------------------------------- 1 | class Asset { 2 | name = 'test asset' 3 | type = 'glb' 4 | hash = null 5 | uri = 'test://null' 6 | localUri = 'test://null' 7 | width = 800 8 | height = 400 9 | static fromURI = () => new Asset() 10 | static fromModule = () => new Asset() 11 | downloadAsync = async () => new Promise((res) => res(this)) 12 | } 13 | 14 | export { Asset } 15 | -------------------------------------------------------------------------------- /packages/fiber/__mocks__/expo-file-system.ts: -------------------------------------------------------------------------------- 1 | export const readAsStringAsync = async () => new Promise((res) => res('')) 2 | -------------------------------------------------------------------------------- /packages/fiber/__mocks__/expo-gl.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { WebGL2RenderingContext } from '@react-three/test-renderer/src/WebGL2RenderingContext' 3 | 4 | export const GLView = ({ onContextCreate }: { onContextCreate: (gl: any) => void }) => { 5 | React.useLayoutEffect(() => { 6 | const gl = new WebGL2RenderingContext({ width: 1280, height: 800 } as HTMLCanvasElement) 7 | onContextCreate(gl) 8 | }, []) 9 | 10 | return null 11 | } 12 | -------------------------------------------------------------------------------- /packages/fiber/__mocks__/react-native.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ViewProps, LayoutChangeEvent } from 'react-native' 3 | 4 | // Mocks a View or container as React sees it 5 | const Container = React.memo( 6 | React.forwardRef(({ onLayout, ...props }: ViewProps, ref) => { 7 | React.useLayoutEffect(() => { 8 | onLayout?.({ 9 | nativeEvent: { 10 | layout: { 11 | x: 0, 12 | y: 0, 13 | width: 1280, 14 | height: 800, 15 | }, 16 | }, 17 | } as LayoutChangeEvent) 18 | }, [onLayout]) 19 | 20 | React.useImperativeHandle(ref, () => props) 21 | 22 | return null 23 | }), 24 | ) 25 | 26 | export const View = Container 27 | export const Pressable = Container 28 | 29 | export const StyleSheet = { 30 | absoluteFill: { 31 | position: 'absolute', 32 | left: 0, 33 | right: 0, 34 | top: 0, 35 | bottom: 0, 36 | }, 37 | } 38 | 39 | export const PanResponder = { 40 | create: () => ({ panHandlers: {} }), 41 | } 42 | 43 | export const Image = { 44 | getSize(_uri: string, res: Function, rej?: Function) { 45 | res(1, 1) 46 | }, 47 | } 48 | 49 | export const Platform = { 50 | OS: 'web', 51 | } 52 | -------------------------------------------------------------------------------- /packages/fiber/__mocks__/react-use-measure.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export default function useMeasure() { 4 | const element = React.useRef(null) 5 | const [bounds] = React.useState({ 6 | left: 0, 7 | top: 0, 8 | width: 1280, 9 | height: 800, 10 | bottom: 0, 11 | right: 0, 12 | x: 0, 13 | y: 0, 14 | }) 15 | const ref = (node: HTMLElement) => { 16 | if (!node || element.current) { 17 | return 18 | } 19 | element.current = node 20 | } 21 | return [ref, bounds] 22 | } 23 | -------------------------------------------------------------------------------- /packages/fiber/native/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "dist/react-three-fiber-native.cjs.js", 3 | "module": "dist/react-three-fiber-native.esm.js", 4 | "types": "dist/react-three-fiber-native.cjs.d.ts" 5 | } 6 | -------------------------------------------------------------------------------- /packages/fiber/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@react-three/fiber", 3 | "version": "8.14.1", 4 | "description": "A React renderer for Threejs", 5 | "keywords": [ 6 | "react", 7 | "renderer", 8 | "fiber", 9 | "three", 10 | "threejs" 11 | ], 12 | "author": "Paul Henschel (https://github.com/drcmda)", 13 | "license": "MIT", 14 | "maintainers": [ 15 | "Josh Ellis (https://github.com/joshuaellis)", 16 | "Cody Bennett (https://github.com/codyjasonbennett)" 17 | ], 18 | "bugs": { 19 | "url": "https://github.com/pmndrs/react-three-fiber/issues" 20 | }, 21 | "homepage": "https://github.com/pmndrs/react-three-fiber#readme", 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/pmndrs/react-three-fiber.git" 25 | }, 26 | "collective": { 27 | "type": "opencollective", 28 | "url": "https://opencollective.com/react-three-fiber" 29 | }, 30 | "main": "dist/react-three-fiber.cjs.js", 31 | "module": "dist/react-three-fiber.esm.js", 32 | "types": "dist/react-three-fiber.cjs.d.ts", 33 | "react-native": "native/dist/react-three-fiber-native.cjs.js", 34 | "sideEffects": false, 35 | "preconstruct": { 36 | "entrypoints": [ 37 | "index.tsx", 38 | "native.tsx" 39 | ] 40 | }, 41 | "scripts": { 42 | "prebuild": "cp ../../readme.md readme.md" 43 | }, 44 | "dependencies": { 45 | "@babel/runtime": "^7.17.8", 46 | "@types/react-reconciler": "^0.26.7", 47 | "base64-js": "^1.5.1", 48 | "its-fine": "^1.0.6", 49 | "react-reconciler": "^0.27.0", 50 | "react-use-measure": "^2.1.1", 51 | "scheduler": "^0.21.0", 52 | "suspend-react": "^0.1.3", 53 | "zustand": "^3.7.1" 54 | }, 55 | "peerDependencies": { 56 | "expo": ">=43.0", 57 | "expo-asset": ">=8.4", 58 | "expo-gl": ">=11.0", 59 | "expo-file-system": ">=11.0", 60 | "react": ">=18.0", 61 | "react-dom": ">=18.0", 62 | "react-native": ">=0.64", 63 | "three": ">=0.133" 64 | }, 65 | "peerDependenciesMeta": { 66 | "react-dom": { 67 | "optional": true 68 | }, 69 | "react-native": { 70 | "optional": true 71 | }, 72 | "expo": { 73 | "optional": true 74 | }, 75 | "expo-asset": { 76 | "optional": true 77 | }, 78 | "expo-file-system": { 79 | "optional": true 80 | }, 81 | "expo-gl": { 82 | "optional": true 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /packages/fiber/src/core/loop.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import { Root } from './renderer' 3 | import { RootState, Subscription } from './store' 4 | 5 | export type GlobalRenderCallback = (timeStamp: number) => void 6 | type SubItem = { callback: GlobalRenderCallback } 7 | 8 | function createSubs(callback: GlobalRenderCallback, subs: Set): () => void { 9 | const sub = { callback } 10 | subs.add(sub) 11 | return () => void subs.delete(sub) 12 | } 13 | 14 | let i 15 | let globalEffects: Set = new Set() 16 | let globalAfterEffects: Set = new Set() 17 | let globalTailEffects: Set = new Set() 18 | 19 | /** 20 | * Adds a global render callback which is called each frame. 21 | * @see https://docs.pmnd.rs/react-three-fiber/api/additional-exports#addEffect 22 | */ 23 | export const addEffect = (callback: GlobalRenderCallback) => createSubs(callback, globalEffects) 24 | 25 | /** 26 | * Adds a global after-render callback which is called each frame. 27 | * @see https://docs.pmnd.rs/react-three-fiber/api/additional-exports#addAfterEffect 28 | */ 29 | export const addAfterEffect = (callback: GlobalRenderCallback) => createSubs(callback, globalAfterEffects) 30 | 31 | /** 32 | * Adds a global callback which is called when rendering stops. 33 | * @see https://docs.pmnd.rs/react-three-fiber/api/additional-exports#addTail 34 | */ 35 | export const addTail = (callback: GlobalRenderCallback) => createSubs(callback, globalTailEffects) 36 | 37 | function run(effects: Set, timestamp: number) { 38 | if (!effects.size) return 39 | for (const { callback } of effects.values()) { 40 | callback(timestamp) 41 | } 42 | } 43 | 44 | export type GlobalEffectType = 'before' | 'after' | 'tail' 45 | 46 | export function flushGlobalEffects(type: GlobalEffectType, timestamp: number): void { 47 | switch (type) { 48 | case 'before': 49 | return run(globalEffects, timestamp) 50 | case 'after': 51 | return run(globalAfterEffects, timestamp) 52 | case 'tail': 53 | return run(globalTailEffects, timestamp) 54 | } 55 | } 56 | 57 | let subscribers: Subscription[] 58 | let subscription: Subscription 59 | function render(timestamp: number, state: RootState, frame?: THREE.XRFrame) { 60 | // Run local effects 61 | let delta = state.clock.getDelta() 62 | // In frameloop='never' mode, clock times are updated using the provided timestamp 63 | if (state.frameloop === 'never' && typeof timestamp === 'number') { 64 | delta = timestamp - state.clock.elapsedTime 65 | state.clock.oldTime = state.clock.elapsedTime 66 | state.clock.elapsedTime = timestamp 67 | } 68 | // Call subscribers (useFrame) 69 | subscribers = state.internal.subscribers 70 | for (i = 0; i < subscribers.length; i++) { 71 | subscription = subscribers[i] 72 | subscription.ref.current(subscription.store.getState(), delta, frame) 73 | } 74 | // Render content 75 | if (!state.internal.priority && state.gl.render) state.gl.render(state.scene, state.camera) 76 | // Decrease frame count 77 | state.internal.frames = Math.max(0, state.internal.frames - 1) 78 | return state.frameloop === 'always' ? 1 : state.internal.frames 79 | } 80 | 81 | export function createLoop(roots: Map) { 82 | let running = false 83 | let repeat: number 84 | let frame: number 85 | let state: RootState 86 | 87 | function loop(timestamp: number): void { 88 | frame = requestAnimationFrame(loop) 89 | running = true 90 | repeat = 0 91 | 92 | // Run effects 93 | flushGlobalEffects('before', timestamp) 94 | 95 | // Render all roots 96 | for (const root of roots.values()) { 97 | state = root.store.getState() 98 | // If the frameloop is invalidated, do not run another frame 99 | if ( 100 | state.internal.active && 101 | (state.frameloop === 'always' || state.internal.frames > 0) && 102 | !state.gl.xr?.isPresenting 103 | ) { 104 | repeat += render(timestamp, state) 105 | } 106 | } 107 | 108 | // Run after-effects 109 | flushGlobalEffects('after', timestamp) 110 | 111 | // Stop the loop if nothing invalidates it 112 | if (repeat === 0) { 113 | // Tail call effects, they are called when rendering stops 114 | flushGlobalEffects('tail', timestamp) 115 | 116 | // Flag end of operation 117 | running = false 118 | return cancelAnimationFrame(frame) 119 | } 120 | } 121 | 122 | function invalidate(state?: RootState, frames = 1): void { 123 | if (!state) return roots.forEach((root) => invalidate(root.store.getState()), frames) 124 | if (state.gl.xr?.isPresenting || !state.internal.active || state.frameloop === 'never') return 125 | // Increase frames, do not go higher than 60 126 | state.internal.frames = Math.min(60, state.internal.frames + frames) 127 | // If the render-loop isn't active, start it 128 | if (!running) { 129 | running = true 130 | requestAnimationFrame(loop) 131 | } 132 | } 133 | 134 | function advance( 135 | timestamp: number, 136 | runGlobalEffects: boolean = true, 137 | state?: RootState, 138 | frame?: THREE.XRFrame, 139 | ): void { 140 | if (runGlobalEffects) flushGlobalEffects('before', timestamp) 141 | if (!state) for (const root of roots.values()) render(timestamp, root.store.getState()) 142 | else render(timestamp, state, frame) 143 | if (runGlobalEffects) flushGlobalEffects('after', timestamp) 144 | } 145 | 146 | return { 147 | loop, 148 | /** 149 | * Invalidates the view, requesting a frame to be rendered. Will globally invalidate unless passed a root's state. 150 | * @see https://docs.pmnd.rs/react-three-fiber/api/additional-exports#invalidate 151 | */ 152 | invalidate, 153 | /** 154 | * Advances the frameloop and runs render effects, useful for when manually rendering via `frameloop="never"`. 155 | * @see https://docs.pmnd.rs/react-three-fiber/api/additional-exports#advance 156 | */ 157 | advance, 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /packages/fiber/src/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './three-types' 2 | import * as ReactThreeFiber from './three-types' 3 | export { ReactThreeFiber } 4 | export type { BaseInstance, LocalState } from './core/renderer' 5 | export type { 6 | Intersection, 7 | Subscription, 8 | Dpr, 9 | Size, 10 | Viewport, 11 | RenderCallback, 12 | Performance, 13 | RootState, 14 | } from './core/store' 15 | export type { ThreeEvent, Events, EventManager, ComputeFunction } from './core/events' 16 | export { createEvents } from './core/events' 17 | export type { ObjectMap, Camera } from './core/utils' 18 | export * from './web/Canvas' 19 | export { createPointerEvents as events } from './web/events' 20 | export type { GlobalRenderCallback, GlobalEffectType } from './core/loop' 21 | export * from './core' 22 | -------------------------------------------------------------------------------- /packages/fiber/src/native.tsx: -------------------------------------------------------------------------------- 1 | export * from './three-types' 2 | import * as ReactThreeFiber from './three-types' 3 | export { ReactThreeFiber } 4 | export type { BaseInstance, LocalState } from './core/renderer' 5 | export type { 6 | Intersection, 7 | Subscription, 8 | Dpr, 9 | Size, 10 | Viewport, 11 | RenderCallback, 12 | Performance, 13 | RootState, 14 | } from './core/store' 15 | export type { ThreeEvent, Events, EventManager, ComputeFunction } from './core/events' 16 | export { createEvents } from './core/events' 17 | export type { ObjectMap, Camera } from './core/utils' 18 | export * from './native/Canvas' 19 | export { createTouchEvents as events } from './native/events' 20 | export type { GlobalRenderCallback, GlobalEffectType } from './core/loop' 21 | export * from './core' 22 | 23 | import { polyfills } from './native/polyfills' 24 | polyfills() 25 | -------------------------------------------------------------------------------- /packages/fiber/src/native/events.ts: -------------------------------------------------------------------------------- 1 | import { UseBoundStore } from 'zustand' 2 | import { RootState } from '../core/store' 3 | import { createEvents, DomEvent, EventManager, Events } from '../core/events' 4 | import { type GestureResponderEvent, PanResponder } from 'react-native' 5 | 6 | /** Default R3F event manager for react-native */ 7 | export function createTouchEvents(store: UseBoundStore): EventManager { 8 | const { handlePointer } = createEvents(store) 9 | 10 | const handleTouch = (event: GestureResponderEvent, name: string): true => { 11 | event.persist() 12 | 13 | // Apply offset 14 | ;(event as any).nativeEvent.offsetX = event.nativeEvent.locationX 15 | ;(event as any).nativeEvent.offsetY = event.nativeEvent.locationY 16 | 17 | // Emulate DOM event 18 | const callback = handlePointer(name) 19 | callback(event.nativeEvent as any) 20 | 21 | return true 22 | } 23 | 24 | const responder = PanResponder.create({ 25 | onStartShouldSetPanResponder: () => true, 26 | onMoveShouldSetPanResponder: () => true, 27 | onMoveShouldSetPanResponderCapture: () => true, 28 | onPanResponderTerminationRequest: () => true, 29 | onStartShouldSetPanResponderCapture: (e) => handleTouch(e, 'onPointerCapture'), 30 | onPanResponderStart: (e) => handleTouch(e, 'onPointerDown'), 31 | onPanResponderMove: (e) => handleTouch(e, 'onPointerMove'), 32 | onPanResponderEnd: (e, state) => { 33 | handleTouch(e, 'onPointerUp') 34 | if (Math.hypot(state.dx, state.dy) < 20) handleTouch(e, 'onClick') 35 | }, 36 | onPanResponderRelease: (e) => handleTouch(e, 'onPointerLeave'), 37 | onPanResponderTerminate: (e) => handleTouch(e, 'onLostPointerCapture'), 38 | onPanResponderReject: (e) => handleTouch(e, 'onLostPointerCapture'), 39 | }) 40 | 41 | return { 42 | priority: 1, 43 | enabled: true, 44 | compute(event: DomEvent, state: RootState, previous?: RootState) { 45 | // https://github.com/pmndrs/react-three-fiber/pull/782 46 | // Events trigger outside of canvas when moved, use offsetX/Y by default and allow overrides 47 | state.pointer.set((event.offsetX / state.size.width) * 2 - 1, -(event.offsetY / state.size.height) * 2 + 1) 48 | state.raycaster.setFromCamera(state.pointer, state.camera) 49 | }, 50 | 51 | connected: undefined, 52 | handlers: responder.panHandlers as unknown as Events, 53 | update: () => { 54 | const { events, internal } = store.getState() 55 | if (internal.lastEvent?.current && events.handlers) { 56 | handlePointer('onPointerMove')(internal.lastEvent.current) 57 | } 58 | }, 59 | connect: () => { 60 | const { set, events } = store.getState() 61 | events.disconnect?.() 62 | 63 | set((state) => ({ events: { ...state.events, connected: true } })) 64 | }, 65 | disconnect: () => { 66 | const { set } = store.getState() 67 | 68 | set((state) => ({ events: { ...state.events, connected: false } })) 69 | }, 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/fiber/src/web/events.ts: -------------------------------------------------------------------------------- 1 | import { UseBoundStore } from 'zustand' 2 | import { RootState } from '../core/store' 3 | import { EventManager, Events, createEvents, DomEvent } from '../core/events' 4 | 5 | const DOM_EVENTS = { 6 | onClick: ['click', false], 7 | onContextMenu: ['contextmenu', false], 8 | onDoubleClick: ['dblclick', false], 9 | onWheel: ['wheel', true], 10 | onPointerDown: ['pointerdown', true], 11 | onPointerUp: ['pointerup', true], 12 | onPointerLeave: ['pointerleave', true], 13 | onPointerMove: ['pointermove', true], 14 | onPointerCancel: ['pointercancel', true], 15 | onLostPointerCapture: ['lostpointercapture', true], 16 | } as const 17 | 18 | /** Default R3F event manager for web */ 19 | export function createPointerEvents(store: UseBoundStore): EventManager { 20 | const { handlePointer } = createEvents(store) 21 | 22 | return { 23 | priority: 1, 24 | enabled: true, 25 | compute(event: DomEvent, state: RootState, previous?: RootState) { 26 | // https://github.com/pmndrs/react-three-fiber/pull/782 27 | // Events trigger outside of canvas when moved, use offsetX/Y by default and allow overrides 28 | state.pointer.set((event.offsetX / state.size.width) * 2 - 1, -(event.offsetY / state.size.height) * 2 + 1) 29 | state.raycaster.setFromCamera(state.pointer, state.camera) 30 | }, 31 | 32 | connected: undefined, 33 | handlers: Object.keys(DOM_EVENTS).reduce( 34 | (acc, key) => ({ ...acc, [key]: handlePointer(key) }), 35 | {}, 36 | ) as unknown as Events, 37 | update: () => { 38 | const { events, internal } = store.getState() 39 | if (internal.lastEvent?.current && events.handlers) events.handlers.onPointerMove(internal.lastEvent.current) 40 | }, 41 | connect: (target: HTMLElement) => { 42 | const { set, events } = store.getState() 43 | events.disconnect?.() 44 | set((state) => ({ events: { ...state.events, connected: target } })) 45 | Object.entries(events.handlers ?? []).forEach(([name, event]) => { 46 | const [eventName, passive] = DOM_EVENTS[name as keyof typeof DOM_EVENTS] 47 | target.addEventListener(eventName, event, { passive }) 48 | }) 49 | }, 50 | disconnect: () => { 51 | const { set, events } = store.getState() 52 | if (events.connected) { 53 | Object.entries(events.handlers ?? []).forEach(([name, event]) => { 54 | if (events && events.connected instanceof HTMLElement) { 55 | const [eventName] = DOM_EVENTS[name as keyof typeof DOM_EVENTS] 56 | events.connected.removeEventListener(eventName, event) 57 | } 58 | }) 59 | set((state) => ({ events: { ...state.events, connected: undefined } })) 60 | } 61 | }, 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/fiber/tests/core/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { is } from '../../src/core/utils' 2 | 3 | describe('is', () => { 4 | const myFunc = () => null 5 | const myObj = { myProp: 'test-prop' } 6 | const myStr = 'test-string' 7 | const myNum = 1 8 | const myUnd = undefined 9 | const myArr = [1, 2, 3] 10 | 11 | it('should tell me if something IS a function', () => { 12 | expect(is.fun(myFunc)).toBe(true) 13 | 14 | expect(is.fun(myObj)).toBe(false) 15 | expect(is.fun(myStr)).toBe(false) 16 | expect(is.fun(myNum)).toBe(false) 17 | expect(is.fun(myUnd)).toBe(false) 18 | expect(is.fun(myArr)).toBe(false) 19 | }) 20 | it('should tell me if something IS an object', () => { 21 | expect(is.obj(myFunc)).toBe(false) 22 | 23 | expect(is.obj(myObj)).toBe(true) 24 | 25 | expect(is.obj(myStr)).toBe(false) 26 | expect(is.obj(myNum)).toBe(false) 27 | expect(is.obj(myUnd)).toBe(false) 28 | expect(is.obj(myArr)).toBe(false) 29 | }) 30 | it('should tell me if something IS a string', () => { 31 | expect(is.str(myFunc)).toBe(false) 32 | expect(is.str(myObj)).toBe(false) 33 | 34 | expect(is.str(myStr)).toBe(true) 35 | 36 | expect(is.str(myNum)).toBe(false) 37 | expect(is.str(myUnd)).toBe(false) 38 | expect(is.str(myArr)).toBe(false) 39 | }) 40 | it('should tell me if something IS a number', () => { 41 | expect(is.num(myFunc)).toBe(false) 42 | expect(is.num(myObj)).toBe(false) 43 | expect(is.num(myStr)).toBe(false) 44 | 45 | expect(is.num(myNum)).toBe(true) 46 | 47 | expect(is.num(myUnd)).toBe(false) 48 | expect(is.num(myArr)).toBe(false) 49 | }) 50 | it('should tell me if something IS undefined', () => { 51 | expect(is.und(myFunc)).toBe(false) 52 | expect(is.und(myObj)).toBe(false) 53 | expect(is.und(myStr)).toBe(false) 54 | expect(is.und(myNum)).toBe(false) 55 | 56 | expect(is.und(myUnd)).toBe(true) 57 | 58 | expect(is.und(myArr)).toBe(false) 59 | }) 60 | it('should tell me if something is an array', () => { 61 | expect(is.arr(myFunc)).toBe(false) 62 | expect(is.arr(myObj)).toBe(false) 63 | expect(is.arr(myStr)).toBe(false) 64 | expect(is.arr(myNum)).toBe(false) 65 | expect(is.arr(myUnd)).toBe(false) 66 | 67 | expect(is.arr(myArr)).toBe(true) 68 | }) 69 | it('should tell me if something is equal', () => { 70 | expect(is.equ([], '')).toBe(false) 71 | 72 | expect(is.equ('hello', 'hello')).toBe(true) 73 | expect(is.equ(1, 1)).toBe(true) 74 | 75 | const obj = { type: 'Mesh' } 76 | expect(is.equ(obj, obj)).toBe(true) 77 | expect(is.equ({}, {})).toBe(false) 78 | expect(is.equ({}, {}, { objects: 'reference' })).toBe(false) 79 | expect(is.equ({}, {}, { objects: 'shallow' })).toBe(true) 80 | expect(is.equ({ a: 1 }, { a: 1 })).toBe(false) 81 | expect(is.equ({ a: 1 }, { a: 1 }, { objects: 'reference' })).toBe(false) 82 | expect(is.equ({ a: 1 }, { a: 1 }, { objects: 'shallow' })).toBe(true) 83 | expect(is.equ({ a: 1, b: 1 }, { a: 1 }, { objects: 'shallow' })).toBe(false) 84 | expect(is.equ({ a: 1 }, { a: 1, b: 1 }, { objects: 'shallow' })).toBe(false) 85 | expect(is.equ({ a: 1 }, { a: 1, b: 1 }, { objects: 'shallow', strict: false })).toBe(true) 86 | expect(is.equ({ a: [1, 2, 3] }, { a: [1, 2, 3] }, { arrays: 'reference', objects: 'reference' })).toBe(false) 87 | expect(is.equ({ a: [1, 2, 3] }, { a: [1, 2, 3] }, { objects: 'reference' })).toBe(false) 88 | expect(is.equ({ a: [1, 2, 3] }, { a: [1, 2, 3] }, { objects: 'shallow' })).toBe(true) 89 | expect(is.equ({ a: [1, 2, 3] }, { a: [1, 2, 3, 4] }, { objects: 'shallow' })).toBe(false) 90 | expect(is.equ({ a: [1, 2, 3] }, { a: [1, 2, 3, 4] }, { objects: 'shallow', strict: false })).toBe(true) 91 | expect(is.equ({ a: [1, 2, 3] }, { a: [1, 2, 3], b: 1 }, { objects: 'shallow' })).toBe(false) 92 | expect(is.equ({ a: [1, 2, 3] }, { a: [1, 2, 3], b: 1 }, { objects: 'shallow', strict: false })).toBe(true) 93 | 94 | const arr = [1, 2, 3] 95 | expect(is.equ(arr, arr)).toBe(true) 96 | expect(is.equ([], [])).toBe(true) 97 | expect(is.equ([], [], { arrays: 'reference' })).toBe(false) 98 | expect(is.equ([], [], { arrays: 'shallow' })).toBe(true) 99 | expect(is.equ([1, 2, 3], [1, 2, 3])).toBe(true) 100 | expect(is.equ([1, 2, 3], [1, 2, 3], { arrays: 'shallow' })).toBe(true) 101 | expect(is.equ([1, 2, 3], [1, 2, 3], { arrays: 'reference' })).toBe(false) 102 | expect(is.equ([1, 2], [1, 2, 3])).toBe(false) 103 | expect(is.equ([1, 2, 3, 4], [1, 2, 3])).toBe(false) 104 | expect(is.equ([1, 2], [1, 2, 3], { strict: false })).toBe(true) 105 | }) 106 | }) 107 | -------------------------------------------------------------------------------- /packages/fiber/tests/native/__snapshots__/canvas.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`native Canvas should correctly mount 1`] = `null`; 4 | -------------------------------------------------------------------------------- /packages/fiber/tests/native/canvas.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { View } from 'react-native' 3 | import { act, create, ReactTestRenderer } from 'react-test-renderer' 4 | 5 | import { Canvas } from '../../src/native' 6 | 7 | describe('native Canvas', () => { 8 | it('should correctly mount', async () => { 9 | let renderer: ReactTestRenderer = null! 10 | 11 | await act(async () => { 12 | renderer = create( 13 | 14 | 15 | , 16 | ) 17 | }) 18 | 19 | expect(renderer.toJSON()).toMatchSnapshot() 20 | }) 21 | 22 | it('should forward ref', async () => { 23 | const ref = React.createRef() 24 | 25 | await act(async () => { 26 | create( 27 | 28 | 29 | , 30 | ) 31 | }) 32 | 33 | expect(ref.current).toBeDefined() 34 | }) 35 | 36 | it('should correctly unmount', async () => { 37 | let renderer: ReactTestRenderer = null! 38 | 39 | await act(async () => { 40 | renderer = create( 41 | 42 | 43 | , 44 | ) 45 | }) 46 | 47 | expect(() => renderer.unmount()).not.toThrow() 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /packages/fiber/tests/native/hooks.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as THREE from 'three' 3 | import * as Stdlib from 'three-stdlib' 4 | import { createCanvas } from '@react-three/test-renderer/src/createTestCanvas' 5 | import { waitFor } from '@react-three/test-renderer' 6 | 7 | import { createRoot, useLoader, act } from '../../src/native' 8 | 9 | describe('useLoader', () => { 10 | let canvas: HTMLCanvasElement = null! 11 | 12 | beforeEach(() => { 13 | canvas = createCanvas() 14 | 15 | // Emulate GLTFLoader 16 | jest.spyOn(Stdlib, 'GLTFLoader').mockImplementation( 17 | () => 18 | ({ 19 | load: jest.fn().mockImplementation((_input, onLoad) => { 20 | onLoad(true) 21 | }), 22 | parse: jest.fn().mockImplementation((_data, _, onLoad) => { 23 | onLoad(true) 24 | }), 25 | } as unknown as Stdlib.GLTFLoader), 26 | ) 27 | }) 28 | 29 | it('produces data textures for TextureLoader', async () => { 30 | let texture: any 31 | 32 | const Component = () => { 33 | texture = useLoader(THREE.TextureLoader, '/texture.jpg') 34 | return null 35 | } 36 | 37 | await act(async () => { 38 | createRoot(canvas).render( 39 | 40 | 41 | , 42 | ) 43 | }) 44 | 45 | await waitFor(() => expect(texture).toBeDefined()) 46 | 47 | expect(texture.isDataTexture).toBe(true) 48 | }) 49 | 50 | it('can load external assets using the loader signature', async () => { 51 | let gltf: any 52 | 53 | const Component = () => { 54 | gltf = useLoader(Stdlib.GLTFLoader, 'http://test.local/test.glb') 55 | return null 56 | } 57 | 58 | await act(async () => { 59 | createRoot(canvas).render( 60 | 61 | 62 | , 63 | ) 64 | }) 65 | 66 | await waitFor(() => expect(gltf).toBeDefined()) 67 | 68 | expect(gltf).toBe(true) 69 | }) 70 | 71 | it('can parse assets using the file system', async () => { 72 | let gltf: any 73 | 74 | const Component = () => { 75 | gltf = useLoader(Stdlib.GLTFLoader, 1 as any) 76 | return null 77 | } 78 | 79 | await act(async () => { 80 | createRoot(canvas).render( 81 | 82 | 83 | , 84 | ) 85 | }) 86 | 87 | await waitFor(() => expect(gltf).toBeDefined()) 88 | 89 | expect(gltf).toBe(true) 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /packages/fiber/tests/setupTests.ts: -------------------------------------------------------------------------------- 1 | import { WebGL2RenderingContext } from '@react-three/test-renderer/src/WebGL2RenderingContext' 2 | import * as THREE from 'three' 3 | import { extend } from '../src' 4 | 5 | globalThis.WebGL2RenderingContext = WebGL2RenderingContext as any 6 | globalThis.WebGLRenderingContext = class WebGLRenderingContext extends WebGL2RenderingContext {} as any 7 | 8 | HTMLCanvasElement.prototype.getContext = function (this: HTMLCanvasElement) { 9 | return new WebGL2RenderingContext(this) as any 10 | } 11 | 12 | // Extend catalogue for render API in tests 13 | extend(THREE) 14 | -------------------------------------------------------------------------------- /packages/fiber/tests/web/__snapshots__/canvas.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`web Canvas should correctly mount 1`] = ` 4 |
5 |
8 |
11 | 17 |
18 |
19 |
20 | `; 21 | -------------------------------------------------------------------------------- /packages/fiber/tests/web/canvas.test.tsx: -------------------------------------------------------------------------------- 1 | // use default export for jest.spyOn 2 | import React from 'react' 3 | import { render, RenderResult } from '@testing-library/react' 4 | 5 | import { Canvas, act } from '../../src' 6 | 7 | describe('web Canvas', () => { 8 | it('should correctly mount', async () => { 9 | let renderer: RenderResult = null! 10 | 11 | await act(async () => { 12 | renderer = render( 13 | 14 | 15 | , 16 | ) 17 | }) 18 | 19 | expect(renderer.container).toMatchSnapshot() 20 | }) 21 | 22 | it('should forward ref', async () => { 23 | const ref = React.createRef() 24 | 25 | await act(async () => { 26 | render( 27 | 28 | 29 | , 30 | ) 31 | }) 32 | 33 | expect(ref.current).toBeDefined() 34 | }) 35 | 36 | it('should correctly unmount', async () => { 37 | let renderer: RenderResult = null! 38 | await act(async () => { 39 | renderer = render( 40 | 41 | 42 | , 43 | ) 44 | }) 45 | 46 | expect(() => renderer.unmount()).not.toThrow() 47 | }) 48 | 49 | it('plays nice with react SSR', async () => { 50 | const useLayoutEffect = jest.spyOn(React, 'useLayoutEffect') 51 | 52 | await act(async () => { 53 | render( 54 | 55 | 56 | , 57 | ) 58 | }) 59 | 60 | expect(useLayoutEffect).not.toHaveBeenCalled() 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /packages/shared/pointerEventPolyfill.ts: -------------------------------------------------------------------------------- 1 | // PointerEvent is not in JSDOM 2 | // https://github.com/jsdom/jsdom/pull/2666#issuecomment-691216178 3 | export const pointerEventPolyfill = () => { 4 | if (!global.PointerEvent) { 5 | class PointerEvent extends MouseEvent { 6 | public height?: number 7 | public isPrimary?: boolean 8 | public pointerId?: number 9 | public pointerType?: string 10 | public pressure?: number 11 | public tangentialPressure?: number 12 | public tiltX?: number 13 | public tiltY?: number 14 | public twist?: number 15 | public width?: number 16 | 17 | constructor(type: string, params: PointerEventInit = {}) { 18 | super(type, params) 19 | this.pointerId = params.pointerId 20 | this.width = params.width 21 | this.height = params.height 22 | this.pressure = params.pressure 23 | this.tangentialPressure = params.tangentialPressure 24 | this.tiltX = params.tiltX 25 | this.tiltY = params.tiltY 26 | this.pointerType = params.pointerType 27 | this.isPrimary = params.isPrimary 28 | } 29 | } 30 | global.PointerEvent = PointerEvent as any 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/shared/setupTests.ts: -------------------------------------------------------------------------------- 1 | import 'regenerator-runtime/runtime' 2 | import { pointerEventPolyfill } from './pointerEventPolyfill' 3 | 4 | declare global { 5 | var IS_REACT_ACT_ENVIRONMENT: boolean 6 | } 7 | 8 | // Let React know that we'll be testing effectful components 9 | global.IS_REACT_ACT_ENVIRONMENT = true 10 | 11 | pointerEventPolyfill() 12 | 13 | // Mock scheduler to test React features 14 | jest.mock('scheduler', () => require('scheduler/unstable_mock')) 15 | 16 | // Silence react-dom & react-dom/client mismatch in RTL 17 | const logError = global.console.error 18 | global.console.error = (...args: any[]) => { 19 | if (args.join('').startsWith('Warning')) return 20 | return logError(...args) 21 | } 22 | -------------------------------------------------------------------------------- /packages/test-renderer/.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | markdown/ -------------------------------------------------------------------------------- /packages/test-renderer/README.md: -------------------------------------------------------------------------------- 1 | # React Three Test Renderer ⚛️🔼🧪 2 | 3 | [![Version](https://img.shields.io/npm/v/@react-three/test-renderer?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/@react-three/test-renderer) 4 | [![Downloads](https://img.shields.io/npm/dt/@react-three/test-renderer.svg?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/@react-three/test-renderer) 5 | [![Twitter](https://img.shields.io/twitter/follow/pmndrs?label=%40pmndrs&style=flat&colorA=000000&colorB=000000&logo=twitter&logoColor=000000)](https://twitter.com/pmndrs) 6 | [![Twitter](https://img.shields.io/twitter/follow/_josh_ellis_?label=%40_josh_ellis_&style=flat&colorA=000000&colorB=000000&logo=twitter&logoColor=000000)](https://twitter.com/_josh_ellis_) 7 | [![Discord](https://img.shields.io/discord/740090768164651008?style=flat&colorA=000000&colorB=000000&label=discord&logo=discord&logoColor=000000)](https://discord.gg/ZZjjNvJ) 8 | 9 | `@react-three/test-renderer` is a React testing renderer for threejs in node. 10 | 11 | ```bash 12 | yarn add @react-three/fiber three 13 | yarn add -D @react-three/test-renderer 14 | ``` 15 | 16 | --- 17 | 18 | ## The problem 19 | 20 | You've written a complex and amazing webgl experience using [`@react-three/fiber`](https://github.com/pmndrs/react-three-fiber) and you want to test it to make sure it works even after you add even more features. 21 | 22 | You go to use `react-dom` but hang on, `THREE` elements aren't in the DOM! You decide to use `@react-three/test-renderer` you can see the container & the canvas but you can't see the tree for the scene!? That's because `@react-three/fiber` renders to a different react root with it's own reconciler. 23 | 24 | ## The solution 25 | 26 | You use `@react-three/test-renderer` ⚛️-🔼-🧪, an experimental React renderer using `@react-three/fiber` under the hood to expose the scene graph wrapped in a test instance providing helpful utilities to test with. 27 | 28 | Essentially, this package makes it easy to grab a snapshot of the Scene Graph rendered by `three` without the need for webgl & browser. 29 | 30 | --- 31 | 32 | ## Usage 33 | 34 | RTTR is testing library agnostic, so we hope that it works with libraries such as [`jest`](https://jestjs.io/), [`jasmine`](https://jasmine.github.io/) etc. 35 | 36 | ```tsx 37 | import ReactThreeTestRenderer from '@react-three/test-renderer' 38 | 39 | const renderer = await ReactThreeTestRenderer.create( 40 | 41 | 42 | 49 | , 50 | ) 51 | 52 | // assertions using the TestInstance & Scene Graph 53 | console.log(renderer.toGraph()) 54 | ``` 55 | 56 | --- 57 | 58 | ## API 59 | 60 | - [React Three Test Renderer API](/packages/test-renderer/markdown/rttr.md) 61 | - [React Three Test Instance API](/packages/test-renderer/markdown/rttr-instance.md) 62 | -------------------------------------------------------------------------------- /packages/test-renderer/markdown/rttr-instance.md: -------------------------------------------------------------------------------- 1 | # React Three Test Instance API 2 | 3 | ## Table of Contents 4 | 5 | - [`ReactThreeTestInstance`](#instance) 6 | - Properties 7 | - [`instance`](#instance-prop-instance) 8 | - [`type`](#instance-prop-type) 9 | - [`props`](#instance-prop-props) 10 | - [`parent`](#instance-prop-parent) 11 | - [`children`](#instance-prop-children) 12 | - [`allChildren`](#instance-prop-allChildren) 13 | - Methods 14 | - [`find`](#instance-meth-find) 15 | - [`findAll`](#instance-meth-findall) 16 | - [`findByType`](#instance-meth-findbytype) 17 | - [`findAllByType`](#instance-meth-findallbytype) 18 | - [`findByProps`](#instance-meth-findbyprops) 19 | - [`findAllByProps`](#instance-meth-findallbyprops) 20 | 21 | --- 22 | 23 | ## `ReactThreeTestInstance` ⚛️ 24 | 25 | This is an internal class that wraps the elements returned from [`ReactThreeTestRenderer.create`](/packages/test-renderer/markdown/rttr.md#create). It has several properties & methods to enhance the testing experience. Similar to the core API, it closely mirrors the API of [`react-test-renderer`](https://reactjs.org/docs/test-renderer.html). 26 | 27 | ### `instance` 28 | 29 | ```ts 30 | testInstance.instance 31 | ``` 32 | 33 | Returns the instance object of the specific testInstance. This will be the `THREE` initialized class. 34 | 35 | ### `type` 36 | 37 | ```ts 38 | testInstance.type 39 | ``` 40 | 41 | Returns the `THREE` type of the test instance, e.g `Scene` or `Mesh`. 42 | 43 | ### `props` 44 | 45 | ```ts 46 | testInstance.props 47 | ``` 48 | 49 | Returns an object of the props that are currently being passed to the element. This will include hidden ones such as `attach="geometry"` which are automatically applied in the reconciler. 50 | 51 | ### `parent` 52 | 53 | ```ts 54 | testInstance.parent 55 | ``` 56 | 57 | Returns the parent testInstance of this testInstance. If no parent is available, it will return `null`. 58 | 59 | ### `children` 60 | 61 | ```ts 62 | testInstance.children 63 | ``` 64 | 65 | Returns the children test instances of this test instance according to the property `children`, this will not include Geometries, Materials etc. 66 | 67 | ### `allChildren` 68 | 69 | ```ts 70 | testInstance.allChildren 71 | ``` 72 | 73 | Returns all the children testInstances of this test instance, this will be as thorough as [`testRenderer.toTree()`](/packages/test-renderer/markdown/rttr.md#create-totree) capturing all react components in the tree. 74 | 75 | ### `find()` 76 | 77 | ```ts 78 | testInstance.find(test) 79 | ``` 80 | 81 | Find a single test instance for which `test(testInstance)` returns `true`. If `test(testInstance)` does not return `true` for exactly one test instance it will throw an error. 82 | 83 | ### `findAll()` 84 | 85 | ```ts 86 | testInstance.findAll(test) 87 | ``` 88 | 89 | Finds all test instances for which `test(testInstance)` returns `true`. If no test instances are found, it will return an empty array. 90 | 91 | ### `findByType()` 92 | 93 | ```ts 94 | testInstance.findByType(type) 95 | ``` 96 | 97 | Find a single test instance with the provided type. If there is not exactly one test instance with the provided type it will throw an error. 98 | 99 | ### `findAllByType()` 100 | 101 | ```ts 102 | testInstance.findAllByType(type) 103 | ``` 104 | 105 | Finds all test instances with the provided type. If no test instances are found, it will return an empty array. 106 | 107 | ### `findByProps()` 108 | 109 | ```ts 110 | testInstance.findByProps(props) 111 | 112 | // Also accepts RegExp matchers 113 | testInstance.findByProps({ [prop]: /^match/i }) 114 | ``` 115 | 116 | Find a single test instance with the provided props. If there is not exactly one test instance with the provided props it will throw an error. 117 | 118 | ### `findAllByProps()` 119 | 120 | ```ts 121 | testInstance.findAllByProps(props) 122 | 123 | // Also accepts RegExp matchers 124 | testInstance.findAllByProps({ [prop]: /^matches/i }) 125 | ``` 126 | 127 | Finds all test instances with the provided props. If no test instances are found, it will return an empty array. 128 | -------------------------------------------------------------------------------- /packages/test-renderer/markdown/rttr.md: -------------------------------------------------------------------------------- 1 | # React Three Test Renderer API 2 | 3 | ## Table of Contents 4 | 5 | - [`create()`](#create) 6 | - [`scene`](#create-scene) 7 | - [`getInstance()`](#create-getinstance) 8 | - [`toTree()`](#create-totree) 9 | - [`toGraph()`](#create-tograph) 10 | - [`fireEvent()`](#create-fireevent) 11 | - [`advanceFrames()`](#create-advanceframes) 12 | - [`update()`](#create-update) 13 | - [`unmount()`](#create-unmount) 14 | - [`act()`](#act) 15 | 16 | --- 17 | 18 | ## `create()` 🧪 19 | 20 | ```tsx 21 | const renderer = ReactThreeTestRenderer.create(element, options) 22 | ``` 23 | 24 | Create a ReactThreeTestRenderer instance with a passed `three` element e.g. ``. By default, it won't create an actual `THREE.WebGLRenderer` and there will be no loop. But it will still render the complete scene graph. Returns the properties below. 25 | 26 | #### CreateOptions 27 | 28 | ```ts 29 | // RenderProps is from react-three-fiber 30 | interface CreateOptions extends RenderProps { 31 | width?: number // width of canvas 32 | height?: number // height of canvas 33 | } 34 | ``` 35 | 36 | ### `scene` 37 | 38 | ```tsx 39 | renderer.scene 40 | ``` 41 | 42 | Returns the root “react three test instance” object that is useful for making assertions. You can use it to find other “test instances” deeper below. 43 | 44 | ### `getInstance()` 45 | 46 | ```tsx 47 | renderer.getInstance() 48 | ``` 49 | 50 | Return the instance corresponding to the root three element, if available. This will not work if the root element is a function component because they don’t have instances. 51 | 52 | ### `toTree()` 53 | 54 | ```tsx 55 | renderer.toTree() 56 | ``` 57 | 58 | Returns an object representing the rendered tree similar to [`react-test-renderer`](https://reactjs.org/docs/test-renderer.html#overview). This will include all elements written as react components. 59 | 60 | ### `toGraph()` 61 | 62 | ```tsx 63 | renderer.toGraph() 64 | ``` 65 | 66 | Returns an object representing the [`scene graph`](https://threejs.org/manual/#en/scenegraph). This will not include all elements such as ones that use `attach`. 67 | 68 | ### `fireEvent()` 69 | 70 | ```tsx 71 | renderer.fireEvent(testInstance, eventName, mockEventData) 72 | ``` 73 | 74 | Native method to fire events on the specific part of the rendererd tree through passing an element within the tree and an event name. The third argument is appended to the [`MockSyntheticEvent`](#create-fireevent-mocksyntheticevent) passed to the event handler. 75 | 76 | Event names follow camelCase convention (e.g. `pointerUp`), or you can pass event handler name instead (e.g. `onPointerUp`). 77 | 78 | #### `MockSyntheticEvent` 79 | 80 | ```ts 81 | type MockSyntheticEvent = { 82 | camera: Camera // the default camera of the rendered scene 83 | stopPropagation: () => void 84 | target: ReactThreeTestInstance 85 | currentTarget: ReactThreeTestInstance 86 | sourceEvent: MockEventData 87 | ...mockEventData 88 | } 89 | ``` 90 | 91 | ### `advanceFrames()` 92 | 93 | ```tsx 94 | renderer.advanceFrames(frames, delta) 95 | ``` 96 | 97 | Native method to advance the frames (therefore running subscribers to the GL Render loop such as `useFrame`). Requires an amount of frames to advance by & a parameter of delta to pass to the subscribers creating a more controlled testing environment. 98 | 99 | ### `update()` 100 | 101 | ```tsx 102 | renderer.update(element) 103 | ``` 104 | 105 | Rerender the tree with the new root element. This simulates a react update at the update, thus updating the children below. If the new element has the same type and key as the previous element, the tree will be updated. 106 | 107 | ### `unmount()` 108 | 109 | ```tsx 110 | renderer.unmount() 111 | ``` 112 | 113 | Unmount the tree, triggering the appropriate lifecycle events. 114 | 115 | --- 116 | 117 | ## `act()` ⚛️ 118 | 119 | ```tsx 120 | ReactThreeTestRenderer.act(callback) 121 | ``` 122 | 123 | Similar to the [`act()` in `react-test-renderer`](https://reactjs.org/docs/test-renderer.html#testrendereract). `ReactThreeTestRenderer.act` prepares a component for assertions. Unlike `react-test-renderer` you do not have to wrap calls to `ReactThreeTestRenderer.create` and `renderer.update`. 124 | 125 | #### Act example (using jest) 126 | 127 | ```tsx 128 | import ReactThreeTestRenderer from 'react-three-test-renderer' 129 | 130 | const Mesh = () => { 131 | const meshRef = React.useRef() 132 | useFrame((_, delta) => { 133 | meshRef.current.rotation.x += delta 134 | }) 135 | 136 | return ( 137 | 138 | 139 | 140 | 141 | ) 142 | } 143 | 144 | const renderer = await ReactThreeTestRenderer.create() 145 | 146 | expect(renderer.scene.children[0].instance.rotation.x).toEqual(0) 147 | 148 | await ReactThreeTestRenderer.act(async () => { 149 | await renderer.advanceFrames(2, 1) 150 | }) 151 | 152 | expect(renderer.scene.children[0].instance.rotation.x).toEqual(2) 153 | ``` 154 | -------------------------------------------------------------------------------- /packages/test-renderer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@react-three/test-renderer", 3 | "version": "8.2.0", 4 | "description": "Test Renderer for react-three-fiber", 5 | "author": "Josh Ellis", 6 | "license": "MIT", 7 | "private": false, 8 | "main": "dist/react-three-test-renderer.cjs.js", 9 | "module": "dist/react-three-test-renderer.esm.js", 10 | "types": "dist/react-three-test-renderer.cjs.d.ts", 11 | "bugs": { 12 | "url": "https://github.com/pmndrs/react-three-fiber/issues" 13 | }, 14 | "homepage": "https://github.com/pmndrs/react-three-fiber/packages/react-three-test-renderer", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/pmndrs/react-three-fiber.git" 18 | }, 19 | "preconstruct": { 20 | "entrypoints": [ 21 | "index.tsx" 22 | ] 23 | }, 24 | "peerDependencies": { 25 | "react": ">=17.0", 26 | "@react-three/fiber": ">=8.0.0", 27 | "three": ">=0.126" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/test-renderer/src/__tests__/RTTR.events.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import ReactThreeTestRenderer from '../index' 4 | import type { ReactThreeTest } from '../index' 5 | 6 | describe('ReactThreeTestRenderer Events', () => { 7 | it('should fire an event', async () => { 8 | const handlePointerDown = jest.fn().mockImplementationOnce((event: ReactThreeTest.MockSyntheticEvent) => { 9 | expect(() => event.stopPropagation()).not.toThrow() 10 | expect(event.offsetX).toEqual(640) 11 | expect(event.offsetY).toEqual(400) 12 | }) 13 | 14 | const Component = () => { 15 | return ( 16 | 17 | 18 | 19 | 20 | ) 21 | } 22 | 23 | const { scene, fireEvent } = await ReactThreeTestRenderer.create() 24 | 25 | const eventData = { 26 | offsetX: 640, 27 | offsetY: 400, 28 | } 29 | 30 | await fireEvent(scene.children[0], 'onPointerDown', eventData) 31 | 32 | expect(handlePointerDown).toHaveBeenCalledTimes(1) 33 | 34 | await fireEvent(scene.children[0], 'pointerDown') 35 | 36 | expect(handlePointerDown).toHaveBeenCalledTimes(2) 37 | }) 38 | 39 | it('should not throw if the handle name is incorrect', async () => { 40 | const handlePointerDown = jest.fn() 41 | 42 | const Component = () => { 43 | return ( 44 | 45 | 46 | 47 | 48 | ) 49 | } 50 | 51 | const { scene, fireEvent } = await ReactThreeTestRenderer.create() 52 | 53 | expect(async () => await fireEvent(scene.children[0], 'onPointerUp')).not.toThrow() 54 | 55 | expect(handlePointerDown).not.toHaveBeenCalled() 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /packages/test-renderer/src/__tests__/RTTR.hooks.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as Stdlib from 'three-stdlib' 3 | import * as THREE from 'three' 4 | 5 | import { useFrame, useLoader, useThree } from '@react-three/fiber' 6 | import { waitFor } from '@react-three/test-renderer' 7 | 8 | import ReactThreeTestRenderer from '../index' 9 | 10 | describe('ReactThreeTestRenderer Hooks', () => { 11 | it('can handle useThree hook', async () => { 12 | let result = {} as { 13 | camera: THREE.Camera 14 | scene: THREE.Scene 15 | raycaster: THREE.Raycaster 16 | size: { width: number; height: number } 17 | } 18 | 19 | const Component = () => { 20 | const res = useThree((state) => ({ 21 | camera: state.camera, 22 | scene: state.scene, 23 | size: state.size, 24 | raycaster: state.raycaster, 25 | })) 26 | 27 | result = res 28 | 29 | return 30 | } 31 | 32 | await ReactThreeTestRenderer.create() 33 | 34 | expect(result.camera instanceof THREE.Camera).toBeTruthy() 35 | expect(result.scene instanceof THREE.Scene).toBeTruthy() 36 | expect(result.raycaster instanceof THREE.Raycaster).toBeTruthy() 37 | expect(result.size).toEqual({ height: 0, width: 0, top: 0, left: 0, updateStyle: false }) 38 | }) 39 | 40 | it('can handle useLoader hook', async () => { 41 | const MockMesh = new THREE.Mesh() 42 | jest.spyOn(Stdlib, 'GLTFLoader').mockImplementation( 43 | () => 44 | ({ 45 | load: jest.fn().mockImplementation((_url, onLoad) => { 46 | onLoad(MockMesh) 47 | }), 48 | } as unknown as Stdlib.GLTFLoader), 49 | ) 50 | 51 | const Component = () => { 52 | const model = useLoader(Stdlib.GLTFLoader, '/suzanne.glb') 53 | 54 | return 55 | } 56 | 57 | const renderer = await ReactThreeTestRenderer.create( 58 | 59 | 60 | , 61 | ) 62 | 63 | await waitFor(() => expect(renderer.scene.children[0]).toBeDefined()) 64 | 65 | expect(renderer.scene.children[0].instance).toBe(MockMesh) 66 | }) 67 | 68 | it('can handle useFrame hook using test renderers advanceFrames function', async () => { 69 | const Component = () => { 70 | const meshRef = React.useRef(null!) 71 | useFrame((_, delta) => { 72 | meshRef.current.rotation.x += delta 73 | }) 74 | 75 | return ( 76 | 77 | 78 | 79 | 80 | ) 81 | } 82 | 83 | const renderer = await ReactThreeTestRenderer.create() 84 | 85 | expect(renderer.scene.children[0].instance.rotation.x).toEqual(0) 86 | 87 | await ReactThreeTestRenderer.act(async () => { 88 | await renderer.advanceFrames(2, 1) 89 | }) 90 | 91 | expect(renderer.scene.children[0].instance.rotation.x).toEqual(2) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /packages/test-renderer/src/__tests__/RTTR.methods.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import ReactThreeTestRenderer from '../index' 4 | 5 | describe('ReactThreeTestRenderer instance methods', () => { 6 | const ExampleComponent = () => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ) 19 | } 20 | 21 | it('should pass the parent', async () => { 22 | const { scene } = await ReactThreeTestRenderer.create() 23 | 24 | expect(scene.parent).toBeNull() 25 | 26 | expect(scene.children[0].parent).toBeDefined() 27 | expect(scene.children[0].parent!.type).toEqual('Scene') 28 | }) 29 | 30 | it('searches via .find() / .findAll()', async () => { 31 | const { scene } = await ReactThreeTestRenderer.create() 32 | 33 | const foundByName = scene.find((node) => node.instance.name === 'mesh_01') 34 | 35 | expect(foundByName.type).toEqual('Mesh') 36 | 37 | const foundAllByColor = scene.findAll((node) => node.props.color === 0x0000ff) 38 | 39 | expect(foundAllByColor).toHaveLength(2) 40 | expect(foundAllByColor[0].type).toEqual('MeshStandardMaterial') 41 | expect(foundAllByColor[1].type).toEqual('MeshBasicMaterial') 42 | 43 | const foundAllByType = scene.findAll((node) => node.type === 'InstancedMesh') 44 | 45 | expect(foundAllByType).toHaveLength(0) 46 | expect(foundAllByType).toEqual([]) 47 | 48 | expect(() => scene.find((node) => node.props.color === 0x0000ff)).toThrow() 49 | }) 50 | 51 | it('searches via .findByType() / findAllByType()', async () => { 52 | const { scene } = await ReactThreeTestRenderer.create() 53 | 54 | const foundByStandardMaterial = scene.findByType('MeshStandardMaterial') 55 | 56 | expect(foundByStandardMaterial).toBeDefined() 57 | 58 | const foundAllByMesh = scene.findAllByType('Mesh') 59 | 60 | expect(foundAllByMesh).toHaveLength(2) 61 | expect(foundAllByMesh[0].instance.name).toEqual('mesh_01') 62 | expect(foundAllByMesh[1].instance.name).toEqual('mesh_02') 63 | 64 | const foundAllByBoxBufferGeometry = scene.findAllByType('BoxBufferGeometry') 65 | 66 | expect(foundAllByBoxBufferGeometry).toHaveLength(0) 67 | expect(foundAllByBoxBufferGeometry).toEqual([]) 68 | 69 | expect(() => scene.findByType('BufferGeometry')).toThrow() 70 | }) 71 | 72 | it('searches via .findByProps() / .findAllByProps()', async () => { 73 | const { scene } = await ReactThreeTestRenderer.create() 74 | 75 | const foundByName = scene.findByProps({ 76 | name: 'mesh_01', 77 | }) 78 | 79 | expect(foundByName.type).toEqual('Mesh') 80 | 81 | const foundAllByColor = scene.findAllByProps({ 82 | color: 0x0000ff, 83 | }) 84 | 85 | expect(foundAllByColor).toHaveLength(2) 86 | expect(foundAllByColor[0].type).toEqual('MeshStandardMaterial') 87 | expect(foundAllByColor[1].type).toEqual('MeshBasicMaterial') 88 | 89 | const foundAllByColorAndName = scene.findAllByProps({ 90 | color: 0x0000ff, 91 | name: 'mesh_01', 92 | }) 93 | 94 | expect(foundAllByColorAndName).toHaveLength(0) 95 | expect(foundAllByColorAndName).toEqual([]) 96 | 97 | expect(() => scene.findByProps({ color: 0x0000ff })).toThrow() 98 | }) 99 | 100 | it('searches RegExp via .findByProps() / .findAllByProps()', async () => { 101 | const { scene } = await ReactThreeTestRenderer.create() 102 | 103 | const single = scene.findByProps({ 104 | name: /^mesh_01$/, 105 | }) 106 | 107 | expect(single.type).toEqual('Mesh') 108 | 109 | const multiple = scene.findAllByProps({ 110 | name: /^mesh_\d+$/, 111 | }) 112 | 113 | expect(multiple.length).toEqual(2) 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /packages/test-renderer/src/__tests__/is.test.ts: -------------------------------------------------------------------------------- 1 | import { is } from '../helpers/is' 2 | 3 | describe('is', () => { 4 | const myFunc = () => { 5 | return null 6 | } 7 | const myObj = { 8 | myProp: 'test-prop', 9 | } 10 | const myStr = 'test-string' 11 | const myNum = 1 12 | const myUnd = undefined 13 | const myArr = [1, 2, 3] 14 | 15 | it('should tell me if something IS a function', () => { 16 | expect(is.fun(myFunc)).toBe(true) 17 | 18 | expect(is.fun(myObj)).toBe(false) 19 | expect(is.fun(myStr)).toBe(false) 20 | expect(is.fun(myNum)).toBe(false) 21 | expect(is.fun(myUnd)).toBe(false) 22 | expect(is.fun(myArr)).toBe(false) 23 | }) 24 | it('should tell me if something IS an object', () => { 25 | expect(is.obj(myFunc)).toBe(false) 26 | 27 | expect(is.obj(myObj)).toBe(true) 28 | 29 | expect(is.obj(myStr)).toBe(false) 30 | expect(is.obj(myNum)).toBe(false) 31 | expect(is.obj(myUnd)).toBe(false) 32 | expect(is.obj(myArr)).toBe(false) 33 | }) 34 | it('should tell me if something IS a string', () => { 35 | expect(is.str(myFunc)).toBe(false) 36 | expect(is.str(myObj)).toBe(false) 37 | 38 | expect(is.str(myStr)).toBe(true) 39 | 40 | expect(is.str(myNum)).toBe(false) 41 | expect(is.str(myUnd)).toBe(false) 42 | expect(is.str(myArr)).toBe(false) 43 | }) 44 | it('should tell me if something IS a number', () => { 45 | expect(is.num(myFunc)).toBe(false) 46 | expect(is.num(myObj)).toBe(false) 47 | expect(is.num(myStr)).toBe(false) 48 | 49 | expect(is.num(myNum)).toBe(true) 50 | 51 | expect(is.num(myUnd)).toBe(false) 52 | expect(is.num(myArr)).toBe(false) 53 | }) 54 | it('should tell me if something IS undefined', () => { 55 | expect(is.und(myFunc)).toBe(false) 56 | expect(is.und(myObj)).toBe(false) 57 | expect(is.und(myStr)).toBe(false) 58 | expect(is.und(myNum)).toBe(false) 59 | 60 | expect(is.und(myUnd)).toBe(true) 61 | 62 | expect(is.und(myArr)).toBe(false) 63 | }) 64 | it('should tell me if something is an array', () => { 65 | expect(is.arr(myFunc)).toBe(false) 66 | expect(is.arr(myObj)).toBe(false) 67 | expect(is.arr(myStr)).toBe(false) 68 | expect(is.arr(myNum)).toBe(false) 69 | expect(is.arr(myUnd)).toBe(false) 70 | 71 | expect(is.arr(myArr)).toBe(true) 72 | }) 73 | it('should tell me if something is equal', () => { 74 | expect(is.equ([], '')).toBe(false) 75 | 76 | expect(is.equ('hello', 'hello')).toBe(true) 77 | expect(is.equ(1, 1)).toBe(true) 78 | expect(is.equ(myObj, myObj)).toBe(true) 79 | expect(is.equ(myArr, myArr)).toBe(true) 80 | expect(is.equ([1, 2, 3], [1, 2, 3])).toBe(true) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /packages/test-renderer/src/createTestCanvas.ts: -------------------------------------------------------------------------------- 1 | import { WebGL2RenderingContext } from './WebGL2RenderingContext' 2 | import type { CreateCanvasParameters } from './types/internal' 3 | 4 | export const createCanvas = ({ beforeReturn, width = 1280, height = 800 }: CreateCanvasParameters = {}) => { 5 | let canvas: HTMLCanvasElement 6 | 7 | if (typeof document !== 'undefined' && typeof document.createElement === 'function') { 8 | canvas = document.createElement('canvas') 9 | } else { 10 | canvas = { 11 | style: {}, 12 | addEventListener: (() => {}) as any, 13 | removeEventListener: (() => {}) as any, 14 | clientWidth: width, 15 | clientHeight: height, 16 | getContext: (() => new WebGL2RenderingContext(canvas)) as any, 17 | } as HTMLCanvasElement 18 | } 19 | canvas.width = width 20 | canvas.height = height 21 | 22 | if (globalThis.HTMLCanvasElement) { 23 | const getContext = HTMLCanvasElement.prototype.getContext 24 | HTMLCanvasElement.prototype.getContext = function (id: string) { 25 | if (id.startsWith('webgl')) return new WebGL2RenderingContext(this) 26 | return getContext.apply(this, arguments as any) 27 | } as any 28 | } 29 | 30 | beforeReturn?.(canvas) 31 | 32 | class WebGLRenderingContext extends WebGL2RenderingContext {} 33 | // @ts-ignore 34 | globalThis.WebGLRenderingContext ??= WebGLRenderingContext 35 | // @ts-ignore 36 | globalThis.WebGL2RenderingContext ??= WebGL2RenderingContext 37 | 38 | return canvas 39 | } 40 | -------------------------------------------------------------------------------- /packages/test-renderer/src/createTestInstance.ts: -------------------------------------------------------------------------------- 1 | import { Object3D } from 'three' 2 | 3 | import type { MockInstance, MockScene, Obj, TestInstanceChildOpts } from './types/internal' 4 | 5 | import { expectOne, matchProps, findAll } from './helpers/testInstance' 6 | 7 | export class ReactThreeTestInstance { 8 | _fiber: MockInstance 9 | 10 | constructor(fiber: MockInstance | MockScene) { 11 | this._fiber = fiber as MockInstance 12 | } 13 | 14 | public get instance(): Object3D { 15 | return this._fiber as unknown as TInstance 16 | } 17 | 18 | public get type(): string { 19 | return this._fiber.type 20 | } 21 | 22 | public get props(): Obj { 23 | return this._fiber.__r3f.memoizedProps 24 | } 25 | 26 | public get parent(): ReactThreeTestInstance | null { 27 | const parent = this._fiber.__r3f.parent 28 | if (parent !== null) { 29 | return wrapFiber(parent) 30 | } 31 | return parent 32 | } 33 | 34 | public get children(): ReactThreeTestInstance[] { 35 | return this.getChildren(this._fiber) 36 | } 37 | 38 | public get allChildren(): ReactThreeTestInstance[] { 39 | return this.getChildren(this._fiber, { exhaustive: true }) 40 | } 41 | 42 | private getChildren = ( 43 | fiber: MockInstance, 44 | opts: TestInstanceChildOpts = { exhaustive: false }, 45 | ): ReactThreeTestInstance[] => { 46 | if (opts.exhaustive) { 47 | /** 48 | * this will return objects like 49 | * color or effects etc. 50 | */ 51 | return [ 52 | ...(fiber.children || []).map((fib) => wrapFiber(fib as MockInstance)), 53 | ...fiber.__r3f.objects.map((fib) => wrapFiber(fib as MockInstance)), 54 | ] 55 | } else { 56 | return (fiber.children || []).map((fib) => wrapFiber(fib as MockInstance)) 57 | } 58 | } 59 | 60 | public find = (decider: (node: ReactThreeTestInstance) => boolean): ReactThreeTestInstance => 61 | expectOne(findAll(this, decider), `matching custom checker: ${decider.toString()}`) 62 | 63 | public findAll = (decider: (node: ReactThreeTestInstance) => boolean): ReactThreeTestInstance[] => 64 | findAll(this, decider) 65 | 66 | public findByType = (type: string): ReactThreeTestInstance => 67 | expectOne( 68 | findAll(this, (node) => Boolean(node.type && node.type === type)), 69 | `with node type: "${type || 'Unknown'}"`, 70 | ) 71 | 72 | public findAllByType = (type: string): ReactThreeTestInstance[] => 73 | findAll(this, (node) => Boolean(node.type && node.type === type)) 74 | 75 | public findByProps = (props: Obj): ReactThreeTestInstance => 76 | expectOne(this.findAllByProps(props), `with props: ${JSON.stringify(props)}`) 77 | 78 | public findAllByProps = (props: Obj): ReactThreeTestInstance[] => 79 | findAll(this, (node: ReactThreeTestInstance) => Boolean(node.props && matchProps(node.props, props))) 80 | } 81 | 82 | const fiberToWrapper = new WeakMap() 83 | export const wrapFiber = (fiber: MockInstance | MockScene): ReactThreeTestInstance => { 84 | let wrapper = fiberToWrapper.get(fiber) 85 | if (wrapper === undefined) { 86 | wrapper = new ReactThreeTestInstance(fiber) 87 | fiberToWrapper.set(fiber, wrapper) 88 | } 89 | return wrapper 90 | } 91 | -------------------------------------------------------------------------------- /packages/test-renderer/src/fireEvent.ts: -------------------------------------------------------------------------------- 1 | import ReactReconciler from 'react-reconciler' 2 | 3 | import { toEventHandlerName } from './helpers/strings' 4 | 5 | import { ReactThreeTestInstance } from './createTestInstance' 6 | 7 | import type { MockSyntheticEvent } from './types/public' 8 | import type { MockUseStoreState, MockEventData } from './types/internal' 9 | 10 | export const createEventFirer = ( 11 | act: ReactReconciler.Reconciler['act'], 12 | store: MockUseStoreState, 13 | ) => { 14 | const findEventHandler = ( 15 | element: ReactThreeTestInstance, 16 | eventName: string, 17 | ): ((event: MockSyntheticEvent) => any) | null => { 18 | const eventHandlerName = toEventHandlerName(eventName) 19 | 20 | const props = element.props 21 | 22 | if (typeof props[eventHandlerName] === 'function') { 23 | return props[eventHandlerName] 24 | } 25 | 26 | if (typeof props[eventName] === 'function') { 27 | return props[eventName] 28 | } 29 | 30 | console.warn( 31 | `Handler for ${eventName} was not found. You must pass event names in camelCase or name of the handler https://github.com/pmndrs/react-three-fiber/blob/master/packages/test-renderer/markdown/rttr.md#create-fireevent`, 32 | ) 33 | 34 | return null 35 | } 36 | 37 | const createSyntheticEvent = (element: ReactThreeTestInstance, data: MockEventData): MockSyntheticEvent => { 38 | const raycastEvent = { 39 | camera: store.getState().camera, 40 | stopPropagation: () => {}, 41 | target: element, 42 | currentTarget: element, 43 | sourceEvent: data, 44 | ...data, 45 | } 46 | return raycastEvent 47 | } 48 | 49 | const invokeEvent = async (element: ReactThreeTestInstance, eventName: string, data: MockEventData): Promise => { 50 | const handler = findEventHandler(element, eventName) 51 | 52 | if (!handler) { 53 | return 54 | } 55 | 56 | let returnValue: any 57 | 58 | await act(async () => { 59 | returnValue = handler(createSyntheticEvent(element, data)) 60 | }) 61 | 62 | return returnValue 63 | } 64 | 65 | const fireEvent = async ( 66 | element: ReactThreeTestInstance, 67 | eventName: string, 68 | data: MockEventData = {}, 69 | ): Promise => await invokeEvent(element, eventName, data) 70 | 71 | return fireEvent 72 | } 73 | -------------------------------------------------------------------------------- /packages/test-renderer/src/helpers/events.ts: -------------------------------------------------------------------------------- 1 | import type { MockEventData } from '../types/internal' 2 | 3 | export const calculateDistance = (event: MockEventData) => { 4 | if (event.offsetX && event.offsetY && event.initialClick.x && event.initialClick.y) { 5 | const dx = event.offsetX - event.initialClick.x 6 | const dy = event.offsetY - event.initialClick.y 7 | return Math.round(Math.sqrt(dx * dx + dy * dy)) 8 | } 9 | return 0 10 | } 11 | -------------------------------------------------------------------------------- /packages/test-renderer/src/helpers/graph.ts: -------------------------------------------------------------------------------- 1 | import type { MockScene, MockSceneChild } from '../types/internal' 2 | import type { SceneGraphItem } from '../types/public' 3 | 4 | const graphObjectFactory = ( 5 | type: SceneGraphItem['type'], 6 | name: SceneGraphItem['name'], 7 | children: SceneGraphItem['children'], 8 | ): SceneGraphItem => ({ 9 | type, 10 | name, 11 | children, 12 | }) 13 | 14 | export const toGraph = (object: MockScene | MockSceneChild): SceneGraphItem[] => 15 | object.children.map((child) => graphObjectFactory(child.type, child.name || '', toGraph(child))) 16 | -------------------------------------------------------------------------------- /packages/test-renderer/src/helpers/is.ts: -------------------------------------------------------------------------------- 1 | export const is = { 2 | obj: (a: any) => a === Object(a) && !is.arr(a) && typeof a !== 'function', 3 | fun: (a: any) => typeof a === 'function', 4 | str: (a: any) => typeof a === 'string', 5 | num: (a: any) => typeof a === 'number', 6 | und: (a: any) => a === void 0, 7 | arr: (a: any) => Array.isArray(a), 8 | equ(a: any, b: any) { 9 | // Wrong type or one of the two undefined, doesn't match 10 | if (typeof a !== typeof b || !!a !== !!b) return false 11 | // Atomic, just compare a against b 12 | if (is.str(a) || is.num(a) || is.obj(a)) return a === b 13 | // Array, shallow compare first to see if it's a match 14 | if (is.arr(a) && a == b) return true 15 | // Last resort, go through keys 16 | let i 17 | for (i in a) if (!(i in b)) return false 18 | for (i in b) if (a[i] !== b[i]) return false 19 | return is.und(i) ? a === b : true 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /packages/test-renderer/src/helpers/strings.ts: -------------------------------------------------------------------------------- 1 | export const lowerCaseFirstLetter = (str: string) => `${str.charAt(0).toLowerCase()}${str.slice(1)}` 2 | 3 | export const toEventHandlerName = (eventName: string) => `on${eventName.charAt(0).toUpperCase()}${eventName.slice(1)}` 4 | -------------------------------------------------------------------------------- /packages/test-renderer/src/helpers/testInstance.ts: -------------------------------------------------------------------------------- 1 | import { ReactThreeTestInstance } from '../createTestInstance' 2 | import type { Obj } from '../types/internal' 3 | 4 | export const expectOne = (items: TItem[], msg: string) => { 5 | if (items.length === 1) { 6 | return items[0] 7 | } 8 | 9 | const prefix = 10 | items.length === 0 ? 'RTTR: No instances found' : `RTTR: Expected 1 but found ${items.length} instances` 11 | 12 | throw new Error(`${prefix} ${msg}`) 13 | } 14 | 15 | export const matchProps = (props: Obj, filter: Obj) => { 16 | for (const key in filter) { 17 | // Check for matches if filter contains regex matchers 18 | const isRegex = filter[key] instanceof RegExp 19 | const shouldMatch = isRegex && typeof props[key] === 'string' 20 | const match = shouldMatch && filter[key].test(props[key]) 21 | 22 | // Bail if props aren't identical and filters found no match 23 | if (props[key] !== filter[key] && !match) { 24 | return false 25 | } 26 | } 27 | 28 | return true 29 | } 30 | 31 | export const findAll = (root: ReactThreeTestInstance, decider: (node: ReactThreeTestInstance) => boolean) => { 32 | const results = [] 33 | 34 | if (decider(root)) { 35 | results.push(root) 36 | } 37 | 38 | root.allChildren.forEach((child) => { 39 | results.push(...findAll(child, decider)) 40 | }) 41 | 42 | return results 43 | } 44 | -------------------------------------------------------------------------------- /packages/test-renderer/src/helpers/tree.ts: -------------------------------------------------------------------------------- 1 | import type { TreeNode, Tree } from '../types/public' 2 | import type { MockSceneChild, MockScene } from '../types/internal' 3 | import { lowerCaseFirstLetter } from './strings' 4 | 5 | const treeObjectFactory = ( 6 | type: TreeNode['type'], 7 | props: TreeNode['props'], 8 | children: TreeNode['children'], 9 | ): TreeNode => ({ 10 | type, 11 | props, 12 | children, 13 | }) 14 | 15 | const toTreeBranch = (obj: MockSceneChild[]): TreeNode[] => 16 | obj.map((child) => { 17 | return treeObjectFactory( 18 | lowerCaseFirstLetter(child.type || child.constructor.name), 19 | { ...child.__r3f.memoizedProps }, 20 | toTreeBranch([...(child.children || []), ...child.__r3f.objects]), 21 | ) 22 | }) 23 | 24 | export const toTree = (root: MockScene): Tree => 25 | root.children.map((obj) => 26 | treeObjectFactory( 27 | lowerCaseFirstLetter(obj.type), 28 | { ...obj.__r3f.memoizedProps }, 29 | toTreeBranch([...(obj.children as MockSceneChild[]), ...(obj.__r3f.objects as MockSceneChild[])]), 30 | ), 31 | ) 32 | -------------------------------------------------------------------------------- /packages/test-renderer/src/helpers/waitFor.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | const act: (cb: () => Promise) => Promise = (React as any).unstable_act 4 | 5 | export interface WaitOptions { 6 | interval?: number 7 | timeout?: number 8 | } 9 | 10 | export async function waitFor( 11 | callback: () => boolean | void, 12 | { interval = 50, timeout = 5000 }: WaitOptions = {}, 13 | ): Promise { 14 | await act(async () => { 15 | const start = performance.now() 16 | 17 | while (true) { 18 | const result = callback() 19 | if (result || result == null) break 20 | if (interval) await new Promise((resolve) => setTimeout(resolve, interval)) 21 | if (timeout && performance.now() - start >= timeout) throw new Error(`Timed out after ${timeout}ms.`) 22 | } 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /packages/test-renderer/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as THREE from 'three' 3 | 4 | import { extend, _roots as mockRoots, createRoot, reconciler, act as _act } from '@react-three/fiber' 5 | 6 | import { toTree } from './helpers/tree' 7 | import { toGraph } from './helpers/graph' 8 | import { is } from './helpers/is' 9 | 10 | import { createCanvas } from './createTestCanvas' 11 | import { createEventFirer } from './fireEvent' 12 | 13 | import type { MockScene } from './types/internal' 14 | import type { CreateOptions, Renderer, Act } from './types/public' 15 | import { wrapFiber } from './createTestInstance' 16 | import { waitFor, WaitOptions } from './helpers/waitFor' 17 | 18 | // Extend catalogue for render API in tests. 19 | extend(THREE) 20 | 21 | const act = _act as unknown as Act 22 | 23 | const create = async (element: React.ReactNode, options?: Partial): Promise => { 24 | const canvas = createCanvas(options) 25 | 26 | const _root = createRoot(canvas).configure({ frameloop: 'never', ...options, events: undefined }) 27 | const _store = mockRoots.get(canvas)!.store 28 | 29 | await act(async () => _root.render(element)) 30 | const scene = _store.getState().scene as unknown as MockScene 31 | 32 | return { 33 | scene: wrapFiber(scene), 34 | async unmount() { 35 | await act(async () => _root.unmount()) 36 | }, 37 | getInstance() { 38 | // Bail if canvas is unmounted 39 | if (!mockRoots.has(canvas)) return null 40 | 41 | // Traverse fiber nodes for R3F root 42 | const root = { current: mockRoots.get(canvas)!.fiber.current } 43 | while (!root.current.child?.stateNode) root.current = root.current.child 44 | 45 | // Return R3F instance from root 46 | return reconciler.getPublicRootInstance(root) 47 | }, 48 | async update(newElement: React.ReactNode) { 49 | if (!mockRoots.has(canvas)) return console.warn('RTTR: attempted to update an unmounted root!') 50 | 51 | await act(async () => _root.render(newElement)) 52 | }, 53 | toTree() { 54 | return toTree(scene) 55 | }, 56 | toGraph() { 57 | return toGraph(scene) 58 | }, 59 | fireEvent: createEventFirer(act, _store), 60 | async advanceFrames(frames: number, delta: number | number[] = 1) { 61 | const state = _store.getState() 62 | const storeSubscribers = state.internal.subscribers 63 | 64 | const promises: Promise[] = [] 65 | 66 | for (const subscriber of storeSubscribers) { 67 | for (let i = 0; i < frames; i++) { 68 | if (is.arr(delta)) { 69 | promises.push( 70 | new Promise(() => subscriber.ref.current(state, (delta as number[])[i] || (delta as number[])[-1])), 71 | ) 72 | } else { 73 | promises.push(new Promise(() => subscriber.ref.current(state, delta as number))) 74 | } 75 | } 76 | } 77 | 78 | Promise.all(promises) 79 | }, 80 | } 81 | } 82 | 83 | export { create, act, waitFor } 84 | export type { WaitOptions } 85 | 86 | export * as ReactThreeTest from './types' 87 | export default { create, act, waitFor } 88 | -------------------------------------------------------------------------------- /packages/test-renderer/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './public' 2 | -------------------------------------------------------------------------------- /packages/test-renderer/src/types/internal.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import { UseBoundStore } from 'zustand' 3 | 4 | import type { BaseInstance, LocalState, RootState } from '@react-three/fiber' 5 | 6 | export type MockUseStoreState = UseBoundStore 7 | 8 | export interface MockInstance extends Omit { 9 | __r3f: Omit & { 10 | root: MockUseStoreState 11 | objects: MockSceneChild[] 12 | parent: MockInstance 13 | } 14 | } 15 | 16 | export interface MockSceneChild extends Omit { 17 | children: MockSceneChild[] 18 | } 19 | 20 | export interface MockScene extends Omit, Pick { 21 | children: MockSceneChild[] 22 | } 23 | 24 | export type CreateCanvasParameters = { 25 | beforeReturn?: (canvas: HTMLCanvasElement) => void 26 | width?: number 27 | height?: number 28 | } 29 | 30 | export interface Obj { 31 | [key: string]: any 32 | } 33 | 34 | /** 35 | * this is an empty object of any, 36 | * the data is passed to a new event 37 | * and subsequently passed to the 38 | * event handler you're calling 39 | */ 40 | export type MockEventData = { 41 | [key: string]: any 42 | } 43 | 44 | export interface TestInstanceChildOpts { 45 | exhaustive: boolean 46 | } 47 | -------------------------------------------------------------------------------- /packages/test-renderer/src/types/public.ts: -------------------------------------------------------------------------------- 1 | import type { Camera, RenderProps } from '@react-three/fiber' 2 | 3 | import { ReactThreeTestInstance } from '../createTestInstance' 4 | 5 | import type { MockEventData, CreateCanvasParameters } from './internal' 6 | 7 | export { ReactThreeTestInstance } 8 | 9 | export type MockSyntheticEvent = { 10 | camera: Camera 11 | stopPropagation: () => void 12 | target: ReactThreeTestInstance 13 | currentTarget: ReactThreeTestInstance 14 | sourceEvent: MockEventData 15 | [key: string]: any 16 | } 17 | 18 | export type CreateOptions = CreateCanvasParameters & RenderProps 19 | 20 | export type Act = (cb: () => Promise) => Promise 21 | 22 | export type Renderer = { 23 | scene: ReactThreeTestInstance 24 | unmount: () => Promise 25 | getInstance: () => null | unknown 26 | update: (el: React.ReactNode) => Promise 27 | toTree: () => Tree | undefined 28 | toGraph: () => SceneGraph | undefined 29 | fireEvent: (element: ReactThreeTestInstance, handler: string, data?: MockEventData) => Promise 30 | advanceFrames: (frames: number, delta: number | number[]) => Promise 31 | } 32 | 33 | export interface SceneGraphItem { 34 | type: string 35 | name: string 36 | children: SceneGraphItem[] | null 37 | } 38 | 39 | export type SceneGraph = SceneGraphItem[] 40 | 41 | export interface TreeNode { 42 | type: string 43 | props: { 44 | [key: string]: any 45 | } 46 | children: TreeNode[] 47 | } 48 | 49 | export type Tree = TreeNode[] 50 | -------------------------------------------------------------------------------- /packages/test-renderer/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | zustand@^3.3.3: 6 | version "3.3.3" 7 | resolved "https://registry.yarnpkg.com/zustand/-/zustand-3.3.3.tgz#88b930a873d0f13e406f96958c1409645ea8b370" 8 | integrity sha512-KTN/O76rVc9muDoprsCDe/LQjbuq+GHYt5JdYahuXaGZ+8Gyk44SepzTFeQTF5J+b8+/Q+o90BaSEQ3WImKPog== 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": ["es2019", "dom"], 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "jsx": "react", 9 | "pretty": true, 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "declaration": true, 13 | "removeComments": true, 14 | "emitDeclarationOnly": true, 15 | "resolveJsonModule": true, 16 | "noImplicitThis": false, 17 | "baseUrl": "./", 18 | "types": ["jest", "node", "offscreencanvas"] 19 | }, 20 | "include": ["packages/**/*"], 21 | "exclude": ["node_modules", "dist"] 22 | } 23 | --------------------------------------------------------------------------------