├── .gitignore ├── .prettierrc ├── README.md ├── package.json ├── public ├── 404.html ├── favicon.ico └── index.html ├── src ├── App.js ├── ThreeJSManager │ ├── Canvas.js │ ├── ThreeJSManager.js │ ├── index.js │ ├── useAnimationFrame.js │ └── useThree.js ├── example-asteroids │ ├── Asteroid.js │ ├── GameExample.js │ ├── LaserStrengthMeter.js │ ├── LaserStrengthMeter.three.js │ ├── Laserbeam.js │ ├── Spaceship.js │ ├── hooks │ │ ├── useAsteroidsGame │ │ │ ├── index.js │ │ │ ├── useAsteroidsGame.js │ │ │ └── useAsteroidsGameUtil.js │ │ ├── useKnobs.js │ │ └── useSpaceshipControl.js │ └── threeSetup.js ├── example-cube │ ├── CameraControls.js │ ├── Cube.js │ ├── CubeExample.js │ ├── Grid.js │ └── threeSetup.js ├── example-globe │ ├── CameraControls.js │ ├── Country.js │ ├── GlobeContainer.js │ ├── MapContainer.js │ ├── MapExample.js │ ├── countries.geo.json │ ├── hooks │ │ └── useWorldMap.js │ ├── mapUtils.js │ └── threeSetup.js ├── index.css └── index.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Hooks + Three.js 2 | 3 | ## Motivation/Background 4 | With three.js, you need to instantiate several objects each time you use it. You need to create a Scene, a camera, a renderer, some 3D objects, and maybe a canvas. Beyond that, if you need controls, maybe you setup [`DAT.gui`](https://github.com/dataarts/dat.gui) like the examples use. 5 | 6 | The renderer needs a reference to the camera and the Scene. You need a reference to the Scene to add any 3D objects you create. For camera controls you need reference to the canvas DOM element. It's up to you how to structure everything cleanly. 7 | 8 | In contrast, React devs are now used to the ease and simplicity of using `create-react-app` to try things out. It would be great to have a React component you could throw some random three.js code into and have it work! 9 | 10 | Taking the idea further, what if we could model three.js scene objects like React components and write code like this: 11 | 12 | ```html 13 | 14 | 15 | 16 | 17 | ``` 18 | 19 | The pattern I explain below aims to allow this in a very minimally obtrusive way. 20 | 21 | ## What is the end result? 22 | - A component: `ThreeJSManager` 23 | - A custom hook: `useThree` 24 | 25 | Combining them allows you to create React-ish three.js components without losing any control of three.js 26 | 27 | ## API 28 | 29 | ### `ThreeJSManager`: 30 | This component takes 3 props: 31 | - `getCamera` _(Function)_: Function that returns a three.js `Camera`. Function called with a `canvas` element 32 | - `getRenderer` _(Function)_: Function that returns a three.js `Renderer`. Function called with a `canvas` element 33 | - `getScene` _(Function)_: Function that returns a three.js `Scene`. 34 | 35 | The output of these functions, along with a `canvas` and `timer`, are made available in a React `Context`. Any of the components you add the `useThree` hook to need to be a child of this component. 36 | 37 | ### `useThree` Custom Hook: 38 | ``` 39 | useThree(setup, [destroy]) 40 | ``` 41 | `useThree` custom hook relies on the context provided by `ThreeJSManager`, so it can only be used in components that have `ThreeJSManager` as an ancestor. 42 | 43 | Arguments: 44 | - `setup` _(Function)_: This function will be called when the component mounts. It gets called with the context value provided by `ThreeJSManager`. This function is where you setup the 3D objects to use in your component. Whatever you return here will be available to you later as the output of the `getEntity` function, which is on the object returned by `useThree`. 45 | - `destroy` _(Function)_: Optional. This function will be called when the component is unmounted. It gets called with 2 arguments: the context values provided by `ThreeJSManager`, and a reference to whatever you returned from `setup`. If the `destroy` param isn't passed, `scene.remove` is called with the return value of `setup` by default. _**Note**: the return value of `setup` is the same as the output of `getEntity` described below_ 46 | 47 | Returns: _(Object)_ 48 | 49 | `useThree` returns an object which has all the values from the context provided by `ThreeJSManager`, and a `getEntity` function which returns a reference to the return value of the `setup` function. Whatever is returned from `setup` is stored internally inside the hook for you to access with `getEntity` whenever you need to, ie when props change. 50 | 51 | ## How does it work? React Hooks. 52 | 53 | There are a few features in React 16.x that make using three.js (or any external library) in a React app a lot cleaner. Those are [`forwardRef`](https://reactjs.org/docs/forwarding-refs.html), and some of the new, experimental [Hooks](https://reactjs.org/docs/hooks-intro.html): [`useRef`](https://reactjs.org/docs/hooks-reference.html#useref), and most importantly [`useEffect`](https://reactjs.org/docs/hooks-reference.html#useeffect). 54 | 55 | Read the docs above, but in a nutshell what the features allow is the ability to create function components which: 56 | 57 | - Use `useEffect` to configurably run arbitrary JS (such as calling a third party library) 58 | - Use `useRef` to store a reference to any arbitrary value (such as third-party classes) 59 | - Combine the above with `forwardRef` to create a component that makes any arbitrary reference available to it's parent 60 | 61 | Here is how you could use these hooks to make a `Cube` functional component: 62 | 63 | ```js 64 | function Cube = (props) { 65 | const entityRef = useRef() 66 | const scene = getScene() 67 | 68 | useEffect( 69 | () => { 70 | entityRef.current = createThreeJSCube(scene, props) 71 | return () => { 72 | cleanupThreeJSCube(scene, entityRef.current) 73 | } 74 | }, 75 | [], 76 | ) 77 | 78 | return null 79 | } 80 | ``` 81 | 82 | Ignoring for now where the component gets `scene` from, what we've done is created a React component, which, when mounted, calls `createThreeJSCube` and stores a reference to the return value, and when unmounted, calls `cleanupThreeJSCube`. It renders `null`, so doesn't effect the DOM; it *only* has side effects. Interesting. 83 | 84 | In case you haven't read up on `useEffect` yet, the 2nd argument is the hook's dependencies, by specifying an empty array, we're indicating this hook doesn't have dependencies and should only be run once. Omitting the argument indicates it should be run on every render, and adding references into the array will cause the hook to run only when the references have changed. 85 | 86 | Using this knowledge, we can add a second hook to our `Cube` component to run some effects when props change. Since we stored the output from our three.js code into `entityRef.current`, we can now access it from this other hook: 87 | 88 | ```js 89 | function Cube(props) { 90 | … 91 | useEffect( 92 | () => { 93 | updateCubeWithProps(entityRef.current, props) 94 | }, 95 | [props] 96 | ) 97 | } 98 | ``` 99 | 100 | We now have a React component which adds a 3D object to a three.js scene, alters the object when it gets new props, and cleans itself up when we unmount it. Awesome! Now we just need to make `scene` available in our component so that it actually works. 101 | 102 | ## `forwardRef` 103 | 104 | Before discussing how we get `scene` available in our component, let's discuss another newer React feature that will help us setup three.js in the way we need: `forwardRef`. Remember, before we can even get to adding 3D objects to the `scene`, we still need to setup our canvas, renderer, camera, and all of that. 105 | 106 | Consider the fact that, in three.js, several things need reference to the `canvas` element. In more vanilla usages this `canvas` is created by THREE code itself, but we want more control, so we're going to render it from a React component so we can encapsulate resize actions and anything else specific to the canvas in that component. Now we have a problem though, in that, the DOM element is only available in that component. How do we solve this? `forwardRef`! With `forwardRef`, we can create a `Canvas` component, that renders a canvas element, and forwards it's ref to it. So anyone for anyone rendering ``, `myRef` will point to the `canvas` HTML element itself, not the `Canvas` React component. Cool! 107 | 108 | ```js 109 | const Canvas = forwardRef((props, ref) => { 110 | … 111 | return ( 112 | 113 | ) 114 | }) 115 | ``` 116 | 117 | Also remember that, from the React docs, `ref`s are not just for DOM element references! We could set and forward a `ref` to any value. 118 | 119 | ## `ThreeJSManager` Component 120 | 121 | Using the above techniques, we can create a `ThreeJSManager` component that has `ref`s to everything we need to use three.js: we'll pass it functions that return the `camera`, `renderer`, `scene` objects, and we'll use our `Canvas` component to reference the `canvas` DOM element. 122 | 123 | However, we'll still need to make these objects available to child components. For this, we'll have `ThreeJSManager` render a `Context.Provider` with all these values. Most components will only need `scene`, but the `canvas` and `camera` object will be useful for components that render for example camera controls. 124 | 125 | Now with the context `Provider` setup, we can use the `useContext` hook to access the scene in our React/three.js components: 126 | 127 | ```js 128 | function Cube = (props) { 129 | const context = useContext(ThreeJSContext) 130 | const { scene } = context 131 | … 132 | } 133 | ``` 134 | 135 | In a nutshell, `ThreeJSManager` abstracts away the base-level three.js tasks you need to do to before you can add 3D objects. Here's how its return value might look: 136 | 137 | ```html 138 | 145 | { props.children } 146 | 147 | ``` 148 | 149 | And here's how we might use it in our app: 150 | ```html 151 | 156 | 157 | 158 | 159 | 160 | 161 | ``` 162 | 163 | With `Ground`, `Lights`, `CameraControls`, and `Cube` being components that make use of the `useThree` hook. 164 | 165 | ## `useThree` Custom Hook 166 | Let's look at what `useThree` does: 167 | 168 | - Accesses the `scene` and other three.js objects with `useContext` 169 | - Initializes a placeholder that will store the 3D object with `useRef` (`entityRef`) 170 | - Runs code on mount that instantializes the 3D object, assigns it to `entityRef`, adds it to the `scene`, and returns a cleanup function that removes it from `scene` with `useEffect` 171 | - Returns an object with a `getEntity` function that can be used in other effects (such as when props change) to update the 3D object. 172 | 173 | Here's the code for our custom hook: 174 | 175 | ```js 176 | import { ThreeJSContext } from './ThreeJSManager'; 177 | 178 | const useThree = (setup, destroy) => { 179 | const entityRef = useRef(); 180 | const context = useContext(ThreeJSContext); 181 | 182 | const getEntity = () => entityRef.current; 183 | 184 | useEffect( 185 | () => { 186 | entityRef.current = setup(context); 187 | 188 | return () => { 189 | if (destroy) { 190 | return destroy(context, getEntity()); 191 | } 192 | context.scene.remove(getEntity()); 193 | }; 194 | }, 195 | [], 196 | ); 197 | 198 | return { 199 | getEntity, 200 | ...context, 201 | }; 202 | } 203 | ``` 204 | 205 | Here's how you'd use it to add a simple grid object to the scene: 206 | 207 | ```js 208 | const Grid = () => { 209 | useThree(({ scene }) => { 210 | const grid = new THREE.GridHelper(1000, 100); 211 | scene.add(grid); 212 | 213 | return grid; 214 | }); 215 | 216 | return null; 217 | }; 218 | ``` 219 | 220 | Notice a few things here: 221 | - Our `setup` param method signature destructures `scene` since that's all we care about 222 | - We didn't pass `destroy` param, so `useThree` will just call `scene.remove` with `grid`, since that's what we returned from `setup`. 223 | - The component renders `null`, otherwise React will throw an error. 224 | - We don't care about props changing, so we don't store the return value of `useThree` (which would give us access to `grid` object through `getEntity`). 225 | 226 | If we did care about the props changing, we could destructure `getEntity` from the return value of `useThree` and use it in another effect that triggers when props change: 227 | 228 | ```js 229 | const Grid = props => { 230 | const { color } = props 231 | const { getEntity } = useThree(…) 232 | 233 | useEffect( 234 | () => { 235 | const grid = getEntity() 236 | grid.material.color.set(color) 237 | }, 238 | [color], 239 | ) 240 | … 241 | } 242 | ``` 243 | 244 | If we wanted to do something specific on unmount, we can pass a `destroy` function as the 2nd argument. Perhaps our component is complex and has several three.js objects and our `setup` function returned an object containing all of them: 245 | ```js 246 | const ComplexThreeComponent = () => { 247 | const getEntity = useThree( 248 | ({ scene }) => { 249 | … 250 | return { 251 | arms, 252 | body, 253 | leg, 254 | } 255 | }, 256 | ({ scene }, entity) => { 257 | const { arms, body, leg } = entity 258 | scene.remove(arms) 259 | scene.remove(body) 260 | scene.remove(leg) 261 | } 262 | }) 263 | … 264 | }; 265 | ``` 266 | 267 | If we want to setup a camera control, we can create a component that uses `camera` and `canvas` in its `setup` function: 268 | 269 | ```js 270 | const CameraControls = () => { 271 | useThree(({ camera, canvas }) => { 272 | const controls = new OrbitControls(camera, canvas) 273 | … 274 | }) 275 | … 276 | } 277 | ``` 278 | 279 | ## Animations / `requestAnimationFrame` 280 | `ThreeJSManager` has a component state variable called `timer` which it provides on the context. We can create effects that use this, same as what we've already done for props. Here's how it looks to rotate our simple cube: 281 | 282 | ```js 283 | const Cube = props => { 284 | const { getEntity, timer } = useThree(…) 285 | 286 | useEffect( 287 | () => { 288 | const cube = getEntity() 289 | cube.rotation.x += .01 290 | cube.rotation.z += .01 291 | }, 292 | [timer], 293 | ) 294 | … 295 | } 296 | ``` 297 | 298 | ## Summary 299 | At a high level what we've done is created React components that don't render anything and *just* have side effects, in this case side effects all relating to calling the three.js library, but the same concept could be applied to anything. 300 | 301 | To manage the framework of side effects we created a component which provides a bunch of objects in a React `Context` that we can perform our side effects on. 302 | 303 | We used React's new experimental hooks feature to separate the concerns of the different side effects, and control when each gets run with a high level of granularity. We have could have achieved similar results with the classic lifecycle methods, but not as declaratively. 304 | 305 | 306 | ## Known Limitations 307 | - Currently there's no way to switch the `scene` outside of rendering a different `ThreeJSManager` component 308 | - Changing the props for `ThreeJSManager` doesn't have any effect since it only uses them on mount 309 | - The scene is always rerendered with `requestAnimationFrame`, it's needed for the props changing on a `useThree` component to take effect. 310 | - The function passed to `requestAnimationFrame` doesn't actually trigger the `timer` effects in our components directly, so profiling the rendering performance could be harder 311 | 312 | ## Caveat 313 | 314 | This is mostly an experiment to see what can be done with the new React hooks, but not intended for production-level use, given the "experimental" status of hooks. 315 | 316 | ## Inspiration 317 | - [How to use plain Three.js in your React apps](https://itnext.io/how-to-use-plain-three-js-in-your-react-apps-417a79d926e0) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-three-hook", 3 | "homepage": "https://aarosil.github.io/react-three-hook", 4 | "version": "0.1.0", 5 | "private": true, 6 | "dependencies": { 7 | "gh-pages": "^2.0.1", 8 | "react": "16.7.0-alpha.2", 9 | "react-dom": "16.7.0-alpha.2", 10 | "react-router": "^4.3.1", 11 | "react-router-dom": "^4.3.1", 12 | "react-scripts": "2.1.2", 13 | "three": "^0.99.0", 14 | "three-orbit-controls": "^82.1.0", 15 | "topojson": "^3.0.2" 16 | }, 17 | "scripts": { 18 | "start": "react-scripts start", 19 | "build": "react-scripts build", 20 | "test": "react-scripts test", 21 | "eject": "react-scripts eject", 22 | "predeploy": "yarn run build", 23 | "deploy": "gh-pages -d build" 24 | }, 25 | "eslintConfig": { 26 | "extends": "react-app" 27 | }, 28 | "browserslist": [ 29 | ">0.2%", 30 | "not dead", 31 | "not ie <= 11", 32 | "not op_mini all" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Single Page Apps for GitHub Pages 6 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aarosil/react-three-hook/c5caffc47f5824be1c298b42e9e9c6d8224c491e/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | (React + Three)JS 13 | 14 | 15 | 18 | 21 | 22 | 51 | 52 | 53 | 54 |
55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router, Route, Link } from 'react-router-dom'; 3 | import CubeExample from './example-cube/CubeExample'; 4 | import GameExample from './example-asteroids/GameExample'; 5 | import MapExample from './example-globe/MapExample'; 6 | 7 | const App = () => ( 8 | 9 |
10 | ( 14 |
15 |

Examples:

16 |
17 | Cube 18 |
19 |
20 | Asteroids 21 |
22 |
23 | World Map 24 |
25 |
26 | )} 27 | /> 28 | 29 | 30 | 31 |
32 |
33 | ); 34 | 35 | export default App; 36 | -------------------------------------------------------------------------------- /src/ThreeJSManager/Canvas.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { forwardRef, useEffect } from 'react'; 3 | 4 | const Canvas = ({ style }, ref) => { 5 | const onWindowResize = () => { 6 | ref.current.style.height = style.height; 7 | ref.current.style.width = style.width; 8 | }; 9 | 10 | useEffect(() => { 11 | window.addEventListener('resize', onWindowResize); 12 | return () => { 13 | window.removeEventListener('resize', onWindowResize); 14 | }; 15 | }, []); 16 | 17 | return ( 18 | 19 | ); 20 | }; 21 | 22 | export default forwardRef(Canvas); 23 | -------------------------------------------------------------------------------- /src/ThreeJSManager/ThreeJSManager.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createContext, useEffect, useRef, useState } from 'react'; 3 | 4 | import Canvas from './Canvas'; 5 | import useAnimationFrame from './useAnimationFrame'; 6 | 7 | export const ThreeJSContext = createContext(); 8 | 9 | const ThreeJSManager = ({ 10 | children, 11 | getCamera, 12 | getRenderer, 13 | getScene, 14 | canvasStyle, 15 | }) => { 16 | const [threeIsReady, setThreeIsReady] = useState(false); 17 | const [timer, updateTimer] = useState(0); 18 | const canvasRef = useRef({}); 19 | const sceneRef = useRef(); 20 | const cameraRef = useRef(); 21 | const rendererRef = useRef(); 22 | 23 | const { offsetWidth, offsetHeight } = canvasRef.current; 24 | const threeContext = { 25 | scene: sceneRef.current, 26 | camera: cameraRef.current, 27 | canvas: canvasRef.current, 28 | timer, 29 | }; 30 | 31 | // setup scene, camera, and renderer, and store references 32 | useEffect(() => { 33 | const canvas = canvasRef.current; 34 | sceneRef.current = getScene(); 35 | cameraRef.current = getCamera(canvas); 36 | rendererRef.current = getRenderer(canvas); 37 | 38 | setThreeIsReady(true); 39 | }, []); 40 | 41 | // update camera and renderer when dimensions change 42 | useEffect( 43 | () => { 44 | cameraRef.current.aspect = offsetWidth / offsetHeight; 45 | cameraRef.current.updateProjectionMatrix(); 46 | rendererRef.current.setSize(offsetWidth, offsetHeight); 47 | }, 48 | [offsetWidth, offsetHeight], 49 | ); 50 | 51 | // set animation frame timer value and rerender the scene 52 | useAnimationFrame(timer => { 53 | updateTimer(timer); 54 | rendererRef.current.render(sceneRef.current, cameraRef.current); 55 | }); 56 | 57 | return ( 58 | <> 59 | 60 | {threeIsReady && ( 61 | 62 | {children} 63 | 64 | )} 65 | 66 | ); 67 | }; 68 | 69 | export default ThreeJSManager; 70 | -------------------------------------------------------------------------------- /src/ThreeJSManager/index.js: -------------------------------------------------------------------------------- 1 | import ThreeJSManager from './ThreeJSManager'; 2 | export { default as useThree } from './useThree'; 3 | 4 | export default ThreeJSManager; 5 | -------------------------------------------------------------------------------- /src/ThreeJSManager/useAnimationFrame.js: -------------------------------------------------------------------------------- 1 | import { useRef, useMutationEffect, useLayoutEffect } from 'react'; 2 | 3 | const useAnimationFrame = callback => { 4 | const callbackRef = useRef(callback); 5 | useMutationEffect( 6 | () => { 7 | callbackRef.current = callback; 8 | }, 9 | [callback] 10 | ); 11 | 12 | const loop = time => { 13 | frameRef.current = requestAnimationFrame(loop); 14 | const cb = callbackRef.current; 15 | cb(time); 16 | }; 17 | 18 | const frameRef = useRef(); 19 | useLayoutEffect(() => { 20 | frameRef.current = requestAnimationFrame(loop); 21 | return () => cancelAnimationFrame(frameRef.current); 22 | }, []); 23 | }; 24 | 25 | export default useAnimationFrame; 26 | -------------------------------------------------------------------------------- /src/ThreeJSManager/useThree.js: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useRef } from 'react'; 2 | import { ThreeJSContext } from './ThreeJSManager'; 3 | 4 | const noop = () => {}; 5 | 6 | const useThree = (setup = noop, destroy) => { 7 | const entityRef = useRef(); 8 | const context = useContext(ThreeJSContext); 9 | 10 | const getEntity = () => entityRef.current; 11 | 12 | useEffect(() => { 13 | entityRef.current = setup(context); 14 | 15 | return () => { 16 | if (destroy) { 17 | return destroy(context, getEntity()); 18 | } 19 | context.scene.remove(getEntity()); 20 | }; 21 | }, []); 22 | 23 | return { 24 | getEntity, 25 | ...context, 26 | }; 27 | }; 28 | 29 | export default useThree; 30 | -------------------------------------------------------------------------------- /src/example-asteroids/Asteroid.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { useEffect } from 'react'; 3 | import { useThree } from '../ThreeJSManager'; 4 | 5 | const ASTEROID_SEGMENTS = 16; 6 | 7 | const Asteroid = ({ position, rotation, radius, uuid }) => { 8 | const { getEntity } = useThree( 9 | ({ scene }) => { 10 | const material = new THREE.LineBasicMaterial({ color: 0xFFFFFF }); 11 | const geometry = new THREE.Geometry(); 12 | 13 | for (let i = 0; i <= ASTEROID_SEGMENTS; i++) { 14 | const vertex = new THREE.Vector3() 15 | const randomX = Math.random() * (radius/2) - (radius/2); 16 | const randomY = Math.random() * (radius/2) - (radius/2); 17 | 18 | vertex.set( 19 | radius * Math.sin(i * 2*Math.PI / ASTEROID_SEGMENTS) + randomX, 20 | radius * Math.cos(i * 2*Math.PI / ASTEROID_SEGMENTS) + randomY, 21 | 0, 22 | ); 23 | 24 | if (i === ASTEROID_SEGMENTS) { 25 | geometry.vertices.push(geometry.vertices[0]); 26 | } else { 27 | geometry.vertices.push(vertex); 28 | } 29 | }; 30 | 31 | const asteroid = new THREE.Line(geometry, material); 32 | asteroid.userData.gameUuid = uuid; 33 | asteroid.userData.isAsteroid = true; 34 | scene.add(asteroid); 35 | 36 | return asteroid; 37 | } 38 | ); 39 | 40 | useEffect( 41 | () => { 42 | const asteroid = getEntity(); 43 | asteroid.position.x = position.x; 44 | asteroid.position.y = position.y; 45 | asteroid.rotation.z = rotation.z; 46 | }, 47 | [ 48 | position.x, 49 | position.y, 50 | rotation.z, 51 | ] 52 | ); 53 | 54 | return null; 55 | } 56 | 57 | export default Asteroid; 58 | -------------------------------------------------------------------------------- /src/example-asteroids/GameExample.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SceneManager from '../ThreeJSManager'; 3 | import Spaceship from './Spaceship'; 4 | import Asteroid from './Asteroid'; 5 | import Laserbeam from './Laserbeam'; 6 | import useAsteroidsGame from './hooks/useAsteroidsGame'; 7 | import { getCamera, getRenderer, getScene } from './threeSetup'; 8 | import LaserStrengthMeter from './LaserStrengthMeter'; 9 | 10 | const GameContainer = () => ( 11 |
12 | 23 | 24 | 25 |
26 | ); 27 | 28 | const Game = ({ asteroidCount = 3 }) => { 29 | const { 30 | laserbeams, 31 | asteroids, 32 | shootLaser, 33 | laserStrength, 34 | knobs, 35 | } = useAsteroidsGame({ asteroidCount }); 36 | 37 | return ( 38 | <> 39 | 40 | {asteroids.map(props => ( 41 | 42 | ))} 43 | {laserbeams.map(props => ( 44 | 45 | ))} 46 | {knobs} 47 | 48 | 49 | ); 50 | }; 51 | 52 | export default GameContainer; 53 | -------------------------------------------------------------------------------- /src/example-asteroids/LaserStrengthMeter.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useThree } from '../ThreeJSManager/'; 3 | import { 4 | setupMeter, 5 | destroyMeter, 6 | updateMeter, 7 | } from './LaserStrengthMeter.three'; 8 | 9 | const LaserStrengthMeter = ({ laserStrength }) => { 10 | const { getEntity } = useThree(setupMeter, destroyMeter); 11 | 12 | useEffect(() => updateMeter(getEntity(), laserStrength), [laserStrength]); 13 | 14 | return null; 15 | }; 16 | 17 | export default LaserStrengthMeter; 18 | -------------------------------------------------------------------------------- /src/example-asteroids/LaserStrengthMeter.three.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | const METER_WIDTH = 16; 4 | const METER_HEIGHT = 4; 5 | 6 | export const setupMeter = ({ scene, camera }) => { 7 | const { top, right } = camera; 8 | const meterOutlineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff }); 9 | const meterOutlineGeometry = new THREE.Geometry(); 10 | meterOutlineGeometry.vertices.push( 11 | new THREE.Vector3(METER_WIDTH, 0, 0), 12 | new THREE.Vector3(METER_WIDTH, METER_HEIGHT, 0), 13 | new THREE.Vector3(0, METER_HEIGHT, 0), 14 | new THREE.Vector3(0, 0, 0), 15 | new THREE.Vector3(METER_WIDTH, 0, 0), 16 | ); 17 | const meterOutline = new THREE.Line( 18 | meterOutlineGeometry, 19 | meterOutlineMaterial, 20 | ); 21 | meterOutline.position.x = right - METER_HEIGHT - METER_WIDTH; 22 | meterOutline.position.y = top - 2 * METER_HEIGHT; 23 | 24 | const meterMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff }); 25 | const meterGeometry = meterOutlineGeometry.clone(); 26 | meterGeometry.faces = [new THREE.Face3(0, 1, 2), new THREE.Face3(2, 3, 4)]; 27 | const meter = new THREE.Mesh(meterGeometry, meterMaterial); 28 | meter.position.x = right - METER_HEIGHT - METER_WIDTH; 29 | meter.position.y = top - 2 * METER_HEIGHT; 30 | 31 | scene.add(meterOutline); 32 | scene.add(meter); 33 | 34 | return { 35 | meter, 36 | meterOutline, 37 | }; 38 | }; 39 | 40 | export const destroyMeter = ({ scene }, { meter, meterOutline }) => { 41 | scene.remove(meter); 42 | scene.remove(meterOutline); 43 | }; 44 | 45 | export const updateMeter = (entity, laserStrength) => { 46 | const { meter } = entity; 47 | 48 | meter.geometry.vertices[0].x = METER_WIDTH * laserStrength; 49 | meter.geometry.vertices[1].x = METER_WIDTH * laserStrength; 50 | meter.geometry.vertices[4].x = METER_WIDTH * laserStrength; 51 | 52 | meter.geometry.verticesNeedUpdate = true; 53 | }; 54 | -------------------------------------------------------------------------------- /src/example-asteroids/Laserbeam.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { useEffect } from 'react'; 3 | import { useThree } from '../ThreeJSManager'; 4 | 5 | const Laserbeam = ({ position, direction }) => { 6 | const { getEntity } = useThree(({ scene }) => { 7 | const material = new THREE.LineBasicMaterial({ color: 0xffffff }); 8 | const geometry = new THREE.Geometry(); 9 | 10 | geometry.vertices.push( 11 | new THREE.Vector3(0, 0, 0), 12 | new THREE.Vector3(1, 0, 0), 13 | ); 14 | 15 | const line = new THREE.Line(geometry, material); 16 | line.position.x = position.x; 17 | line.position.y = position.y; 18 | line.rotation.z = Math.atan2(direction.y, direction.x); 19 | 20 | scene.add(line); 21 | 22 | return line; 23 | }); 24 | 25 | useEffect( 26 | () => { 27 | const line = getEntity(); 28 | line.position.x = position.x; 29 | line.position.y = position.y; 30 | }, 31 | [position.x, position.y], 32 | ); 33 | 34 | return null; 35 | }; 36 | 37 | export default Laserbeam; 38 | -------------------------------------------------------------------------------- /src/example-asteroids/Spaceship.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { useEffect } from 'react'; 3 | import { useThree } from '../ThreeJSManager/'; 4 | import useSpaceshipControl from './hooks/useSpaceshipControl'; 5 | 6 | const SPACEBAR_KEY_CODE = 32; 7 | 8 | const Spaceship = ({ shootLaser }) => { 9 | const { getEntity } = useThree( 10 | ({ scene }) => { 11 | const material = new THREE.LineBasicMaterial({ color: 0xffffff }); 12 | const geometry = new THREE.Geometry(); 13 | const spaceshipGroup = new THREE.Group(); 14 | 15 | geometry.vertices.push( 16 | new THREE.Vector3(0, 10, 0), 17 | new THREE.Vector3(-5, 0, 0), 18 | new THREE.Vector3(0, 2.5, 0), 19 | new THREE.Vector3(5, 0, 0), 20 | new THREE.Vector3(0, 10, 0), 21 | ); 22 | 23 | const spaceship = new THREE.Line(geometry, material); 24 | const box = new THREE.Box3().setFromObject(spaceship); 25 | 26 | box.getCenter(spaceship.position); 27 | spaceship.position.multiplyScalar(-1); 28 | spaceshipGroup.position.y = -50; 29 | spaceshipGroup.add(spaceship); 30 | scene.add(spaceshipGroup); 31 | 32 | return { 33 | spaceshipGroup, 34 | spaceship, 35 | }; 36 | }, 37 | ({ scene }, { spaceshipGroup }) => scene.remove(spaceshipGroup), 38 | ); 39 | 40 | const handleSpacebar = event => { 41 | if (event.keyCode !== SPACEBAR_KEY_CODE) return; 42 | 43 | const { 44 | spaceship: { geometry, matrixWorld }, 45 | } = getEntity(); 46 | const position = geometry.vertices[0]; 47 | const center = geometry.vertices[2]; 48 | 49 | const worldPosition = new THREE.Vector3() 50 | .copy(position) 51 | .applyMatrix4(matrixWorld); 52 | const worldCenter = new THREE.Vector3() 53 | .copy(center) 54 | .applyMatrix4(matrixWorld); 55 | const direction = new THREE.Vector3( 56 | worldPosition.x - worldCenter.x, 57 | worldPosition.y - worldCenter.y, 58 | 0, 59 | ).normalize(); 60 | 61 | shootLaser(worldPosition, direction); 62 | }; 63 | 64 | useEffect(() => { 65 | window.addEventListener('keydown', handleSpacebar); 66 | 67 | return () => { 68 | window.removeEventListener('keydown', handleSpacebar); 69 | }; 70 | }, []); 71 | 72 | const rotation = useSpaceshipControl({ turnSpeed: 6 }); 73 | 74 | useEffect( 75 | () => { 76 | const { spaceshipGroup } = getEntity(); 77 | spaceshipGroup.rotation.z = rotation; 78 | }, 79 | [rotation], 80 | ); 81 | 82 | return null; 83 | }; 84 | 85 | export default Spaceship; 86 | -------------------------------------------------------------------------------- /src/example-asteroids/hooks/useAsteroidsGame/index.js: -------------------------------------------------------------------------------- 1 | import useAsteroidsGame from './useAsteroidsGame'; 2 | 3 | export default useAsteroidsGame; 4 | -------------------------------------------------------------------------------- /src/example-asteroids/hooks/useAsteroidsGame/useAsteroidsGame.js: -------------------------------------------------------------------------------- 1 | import { useThree } from '../../../ThreeJSManager'; 2 | import { useKnobs } from '../useKnobs'; 3 | import { 4 | useEffect, 5 | useReducer, 6 | useRef, 7 | } from 'react'; 8 | import { 9 | generateLaserbeam, 10 | makeBoundingBoxFromCamera, 11 | proceedGame, 12 | setupAsteroids, 13 | } from './useAsteroidsGameUtil'; 14 | 15 | const DIMINISH_STRENGTH_PERCENTAGE = 0.5; 16 | const RECHARGE_RATE_PERCENTAGE = 0.02; 17 | 18 | const useAsteroidsGame = ({ asteroidCount }) => { 19 | const { 20 | scene, 21 | timer, 22 | camera, 23 | } = useThree(); 24 | 25 | const boundingBoxRef = useRef(makeBoundingBoxFromCamera(camera)); 26 | 27 | const [values, knobs] = useKnobs({ 28 | RECHARGE_RATE_PERCENTAGE, 29 | DIMINISH_STRENGTH_PERCENTAGE, 30 | }); 31 | 32 | const [{ laserbeams, asteroids, laserStrength }, dispatch] = useReducer( 33 | (state, action) => { 34 | switch (action.type) { 35 | case 'SHOOT_LASER': 36 | const updatedLaserbeams = state.laserStrength > values.DIMINISH_STRENGTH_PERCENTAGE 37 | ? [ 38 | ...state.laserbeams, 39 | generateLaserbeam(action), 40 | ] 41 | : state.laserbeams 42 | return { 43 | ...state, 44 | laserbeams: updatedLaserbeams, 45 | laserStrength: state.laserStrength > values.DIMINISH_STRENGTH_PERCENTAGE 46 | ? Math.max(0, state.laserStrength - values.DIMINISH_STRENGTH_PERCENTAGE) 47 | : state.laserStrength, 48 | }; 49 | case 'ADVANCE_GAME': 50 | const { 51 | laserbeams, 52 | asteroids, 53 | } = proceedGame(state); 54 | 55 | return { 56 | ...state, 57 | laserbeams, 58 | asteroids, 59 | laserStrength: Math.min(1, state.laserStrength + values.RECHARGE_RATE_PERCENTAGE), 60 | }; 61 | default: 62 | return state; 63 | } 64 | }, 65 | { 66 | asteroids: setupAsteroids(asteroidCount, boundingBoxRef.current), 67 | laserbeams: [], 68 | scene, 69 | boundingBox: boundingBoxRef.current, 70 | laserStrength: 1, 71 | }, 72 | ); 73 | 74 | useEffect( 75 | () => dispatch({ 76 | type: 'ADVANCE_GAME', 77 | }), 78 | [timer], 79 | ); 80 | 81 | return { 82 | laserbeams, 83 | asteroids, 84 | laserStrength, 85 | knobs, 86 | shootLaser: (position, direction) => 87 | dispatch({ 88 | type: 'SHOOT_LASER', 89 | position, 90 | direction, 91 | }), 92 | }; 93 | } 94 | 95 | export default useAsteroidsGame; 96 | -------------------------------------------------------------------------------- /src/example-asteroids/hooks/useAsteroidsGame/useAsteroidsGameUtil.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | const raycaster = new THREE.Raycaster(); 4 | raycaster.far = 0.1 5 | 6 | const DEFAULT_ASTEROID_RADIUS = 12; 7 | 8 | export const proceedGame = ({ laserbeams, asteroids, scene, boundingBox }) => { 9 | const newAsteroids = []; 10 | const newLaserbeams = []; 11 | const asteroidsMap = asteroids.reduce((memo, asteroid) => { 12 | memo[asteroid.uuid] = asteroid; 13 | return memo; 14 | }, {}); 15 | 16 | laserbeams.forEach(laserbeam => { 17 | let hit = false; 18 | raycaster.set(laserbeam.position, laserbeam.direction); 19 | const intersections = raycaster.intersectObjects(scene.children); 20 | 21 | if (intersections.length) { 22 | const hitAsteroid = intersections.find(({ object: { userData }}) => userData.isAsteroid); 23 | const gameAsteroid = hitAsteroid && asteroidsMap[hitAsteroid.object.userData.gameUuid]; 24 | 25 | if (gameAsteroid) { 26 | delete asteroidsMap[gameAsteroid.uuid]; 27 | hit = true; 28 | if (gameAsteroid.radius > 1) { 29 | newAsteroids.push(...divideAsteroid(gameAsteroid, boundingBox)); 30 | } 31 | } 32 | } 33 | 34 | laserbeam.position.add(laserbeam.direction); 35 | if (!hit && boundingBox.containsPoint(laserbeam.position)) { 36 | newLaserbeams.push(laserbeam); 37 | } 38 | }) 39 | 40 | const movedAsteroids = advanceAsteroids(boundingBox, Object.values(asteroidsMap)); 41 | 42 | return { 43 | asteroids: newAsteroids.concat(movedAsteroids), 44 | laserbeams: newLaserbeams, 45 | } 46 | } 47 | 48 | const divideAsteroid = ({ position, radius }, box) => 49 | Array(4) 50 | .fill() 51 | .map(() => ({ 52 | ...createAsteroidGeometry({ radius: radius/2, box }), 53 | position: position.clone(), 54 | })) 55 | 56 | const createAsteroidGeometry = ({ box, radius = DEFAULT_ASTEROID_RADIUS }) => { 57 | const xRange = box.max.x - box.min.x; 58 | const yRange = box.max.y - box.min.y; 59 | 60 | const direction = new THREE.Vector3( 61 | Math.random() - 0.5, 62 | Math.random() - 0.5, 63 | 0, 64 | ); 65 | 66 | const position = new THREE.Vector3( 67 | Math.random() * xRange - xRange/2, 68 | Math.random() * yRange - yRange/2, 69 | 0, 70 | ); 71 | 72 | return { 73 | position, 74 | rotation: new THREE.Vector3(), 75 | radius, 76 | direction, 77 | uuid: THREE.Math.generateUUID(), 78 | }; 79 | } 80 | 81 | export const setupAsteroids = (count, box) => 82 | Array(count) 83 | .fill() 84 | .map(() => createAsteroidGeometry({ box })); 85 | 86 | const advanceAsteroids = (box, asteroids) => 87 | asteroids.map(asteroid => { 88 | asteroid.rotation.z += asteroid.radius * 1/10 * Math.PI/180; 89 | asteroid.position.add(asteroid.direction) 90 | if (!box.containsPoint(asteroid.position)) { 91 | if (asteroid.position.x > box.max.x) asteroid.position.x = box.min.x; 92 | if (asteroid.position.x < box.min.x) asteroid.position.x = box.max.x; 93 | if (asteroid.position.y > box.max.y) asteroid.position.y = box.min.y; 94 | if (asteroid.position.y < box.min.y) asteroid.position.y = box.max.y; 95 | } 96 | return asteroid; 97 | }); 98 | 99 | export const generateLaserbeam = ({ position, direction }) => ({ 100 | direction, 101 | position: position, 102 | uuid: THREE.Math.generateUUID(), 103 | }); 104 | 105 | export const makeBoundingBoxFromCamera = camera => { 106 | const { top, left, bottom, right } = camera; 107 | 108 | return new THREE.Box3( 109 | new THREE.Vector3(left, bottom, 0), 110 | new THREE.Vector3(right, top, 0), 111 | ); 112 | } -------------------------------------------------------------------------------- /src/example-asteroids/hooks/useKnobs.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | function Knob({ name, value, onChange, min = 1, max = 500, step = 1 }) { 4 | return ( 5 | 16 | ) 17 | } 18 | 19 | export function useKnobs(initialValues, options) { 20 | const [values, setValues] = useState(initialValues) 21 | return [ 22 | values, 23 |
30 | {Object.keys(values).map(name => ( 31 | 40 | setValues({ 41 | ...values, 42 | [name]: newValue, 43 | }) 44 | } 45 | /> 46 | ))} 47 |
, 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /src/example-asteroids/hooks/useSpaceshipControl.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | const LEFT_ARROW_KEYCODE = 37; 4 | const RIGHT_ARROW_KEYCODE = 39; 5 | 6 | const useSpaceshipControl = ({ turnSpeed }) => { 7 | const rotation = useRef(0); 8 | 9 | const getDirection = (event) => { 10 | switch (event.keyCode) { 11 | case LEFT_ARROW_KEYCODE: 12 | return 1; 13 | case RIGHT_ARROW_KEYCODE: 14 | return -1; 15 | default: 16 | } 17 | } 18 | 19 | const handleControlKey = (event) => { 20 | const direction = getDirection(event); 21 | 22 | if (direction) { 23 | const rotationChange = turnSpeed * direction * Math.PI/180; 24 | rotation.current += rotationChange; 25 | } 26 | } 27 | 28 | useEffect( 29 | () => { 30 | window.addEventListener('keydown', handleControlKey); 31 | 32 | return () => { 33 | window.removeEventListener('keydown', handleControlKey); 34 | }; 35 | }, 36 | [], 37 | ); 38 | 39 | return rotation.current; 40 | } 41 | 42 | export default useSpaceshipControl; 43 | -------------------------------------------------------------------------------- /src/example-asteroids/threeSetup.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | export const getCamera = ({ offsetWidth, offsetHeight }) => { 4 | const viewSize = 150; 5 | const aspectRatio = offsetWidth / offsetHeight; 6 | const camera = new THREE.OrthographicCamera( 7 | (-aspectRatio * viewSize) / 2, 8 | (aspectRatio * viewSize) / 2, 9 | viewSize / 2, 10 | -viewSize / 2, 11 | 0, 12 | 1, 13 | ); 14 | return camera; 15 | }; 16 | 17 | export const getRenderer = canvas => { 18 | const context = canvas.getContext('webgl'); 19 | const renderer = new THREE.WebGLRenderer({ 20 | canvas, 21 | context, 22 | }); 23 | 24 | renderer.setSize(canvas.offsetWidth, canvas.offsetHeight); 25 | renderer.setPixelRatio(window.devicePixelRatio); 26 | 27 | return renderer; 28 | }; 29 | 30 | export const getScene = () => { 31 | const scene = new THREE.Scene(); 32 | scene.background = new THREE.Color(0x0); 33 | 34 | return scene; 35 | }; 36 | -------------------------------------------------------------------------------- /src/example-cube/CameraControls.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import OrbitControlsDefault from 'three-orbit-controls'; 3 | import { useThree } from '../ThreeJSManager/'; 4 | 5 | const OrbitControls = OrbitControlsDefault(THREE); 6 | 7 | const CameraControls = () => { 8 | useThree(({ camera, canvas }) => { 9 | const controls = new OrbitControls(camera, canvas); 10 | 11 | controls.enableDamping = true; 12 | controls.dampingFactor = 0.12; 13 | controls.rotateSpeed = 0.08; 14 | controls.autoRotate = true; 15 | controls.autoRotateSpeed = 0.08; 16 | controls.maxPolarAngle = Math.PI / 2; 17 | controls.enableKeys = false; 18 | controls.update(); 19 | }); 20 | 21 | return null; 22 | }; 23 | 24 | export default CameraControls; 25 | -------------------------------------------------------------------------------- /src/example-cube/Cube.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { useEffect } from 'react'; 3 | import { useThree } from '../ThreeJSManager/'; 4 | 5 | const Cube = props => { 6 | const { h = 50, w = 50, d = 50, color = 0x00ff00 } = props; 7 | 8 | const setup = context => { 9 | const { scene } = context; 10 | const cubegeometry = new THREE.BoxGeometry(h, w, d); 11 | const cubematerial = new THREE.MeshPhongMaterial({ color }); 12 | const cube = new THREE.Mesh(cubegeometry, cubematerial); 13 | cube.castShadow = true; 14 | cube.position.y = 50; 15 | scene.add(cube); 16 | 17 | return cube; 18 | }; 19 | 20 | const { getEntity, timer } = useThree(setup); 21 | 22 | useEffect( 23 | () => { 24 | const cube = getEntity(); 25 | cube.material.color.setHex(props.color); 26 | }, 27 | [props.color], 28 | ); 29 | 30 | useEffect( 31 | () => { 32 | const cube = getEntity(); 33 | const oscillator = Math.sin(timer / 1000) * Math.PI - Math.PI; 34 | cube.rotation.y = oscillator; 35 | cube.rotation.z = -oscillator; 36 | }, 37 | [timer], 38 | ); 39 | 40 | return null; 41 | }; 42 | 43 | export default Cube; 44 | -------------------------------------------------------------------------------- /src/example-cube/CubeExample.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import SceneManager from '../ThreeJSManager'; 3 | import Cube from './Cube'; 4 | import Grid from './Grid'; 5 | import CameraControls from './CameraControls'; 6 | import { getCamera, getRenderer, getScene } from './threeSetup'; 7 | 8 | const CubeExample = () => { 9 | const [color, changeColor] = useState('0000ff'); 10 | const [showGrid, toggleShowGrid] = useState(true); 11 | const [showCube, toggleShowCube] = useState(true); 12 | 13 | return ( 14 | 25 | 26 | {showGrid && } 27 | {showCube && } 28 |
34 |
40 | changeColor(e.target.value)} 44 | /> 45 | 46 | 54 | 55 | 63 |
64 |
65 |
66 | ); 67 | }; 68 | 69 | export default CubeExample; 70 | -------------------------------------------------------------------------------- /src/example-cube/Grid.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { useThree } from '../ThreeJSManager/'; 3 | 4 | const Grid = () => { 5 | useThree(({ scene }) => { 6 | const grid = new THREE.GridHelper(10000, 1000); 7 | scene.add(grid); 8 | 9 | return grid; 10 | }); 11 | 12 | return null; 13 | }; 14 | 15 | export default Grid; 16 | -------------------------------------------------------------------------------- /src/example-cube/threeSetup.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | export const getCamera = ({ offsetWidth, offsetHeight }) => { 4 | const camera = new THREE.PerspectiveCamera( 5 | 75, 6 | offsetWidth / offsetHeight, 7 | 0.1, 8 | 1000, 9 | ); 10 | camera.position.set(50, 150, 0); 11 | 12 | return camera; 13 | }; 14 | 15 | export const getRenderer = canvas => { 16 | const context = canvas.getContext('webgl'); 17 | const renderer = new THREE.WebGLRenderer({ 18 | canvas, 19 | context, 20 | }); 21 | 22 | renderer.setSize(canvas.offsetWidth, canvas.offsetHeight); 23 | renderer.setPixelRatio(window.devicePixelRatio); 24 | 25 | return renderer; 26 | }; 27 | 28 | export const getScene = () => { 29 | const scene = new THREE.Scene(); 30 | scene.background = new THREE.Color(0xcccccc); 31 | scene.fog = new THREE.FogExp2(0xcccccc, 0.002); 32 | 33 | const light = new THREE.SpotLight(0xffffff, 1, 750, 1); 34 | light.position.set(50, 200, 0); 35 | light.rotation.z = (90 * Math.PI) / 180; 36 | scene.add(light); 37 | 38 | const planeGeometry = new THREE.PlaneBufferGeometry(10000, 10000, 32, 32); 39 | const planeMaterial = new THREE.MeshPhongMaterial({ color: 0xcccccc }); 40 | const plane = new THREE.Mesh(planeGeometry, planeMaterial); 41 | 42 | plane.rotation.x = (-90 * Math.PI) / 180; 43 | plane.receiveShadow = true; 44 | scene.add(plane); 45 | 46 | return scene; 47 | }; 48 | -------------------------------------------------------------------------------- /src/example-globe/CameraControls.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, forwardRef } from 'react'; 2 | import * as THREE from 'three'; 3 | import OrbitControlsDefault from 'three-orbit-controls'; 4 | import { useThree } from '../ThreeJSManager/'; 5 | 6 | const OrbitControls = OrbitControlsDefault(THREE); 7 | 8 | const CameraControls = forwardRef((props, controlsRef) => { 9 | const [camera, setCamera] = useState(); 10 | const [canvas, setCanvas] = useState(); 11 | 12 | useThree(({ camera, canvas }) => { 13 | setCamera(camera); 14 | setCanvas(canvas); 15 | }); 16 | 17 | useEffect( 18 | () => { 19 | if (!(camera && canvas)) return; 20 | 21 | const controls = new OrbitControls(camera, canvas); 22 | 23 | controls.enableDamping = true; 24 | controls.dampingFactor = 0.12; 25 | controls.rotateSpeed = 0.08; 26 | controls.autoRotate = true; 27 | controls.autoRotateSpeed = 0.08; 28 | controls.enableKeys = false; 29 | controls.update(); 30 | 31 | controlsRef.current = { 32 | camera, 33 | controls, 34 | }; 35 | }, 36 | [camera, canvas], 37 | ); 38 | 39 | return null; 40 | }); 41 | 42 | export default CameraControls; 43 | -------------------------------------------------------------------------------- /src/example-globe/Country.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import * as topojson from 'topojson'; 3 | import { vertex, wireframe, canvasCoords } from './mapUtils'; 4 | import { useThree } from '../ThreeJSManager'; 5 | import React, { useState, useEffect, useRef } from 'react'; 6 | 7 | function screenXY(camera, position, canvas) { 8 | var vector = position.clone(); 9 | var widthHalf = canvas.width / 2; 10 | var heightHalf = canvas.height / 2; 11 | 12 | vector.project(camera); 13 | 14 | vector.x = vector.x * widthHalf + widthHalf; 15 | vector.y = vector.y * heightHalf + heightHalf; 16 | vector.z = 0; 17 | 18 | return vector; 19 | } 20 | 21 | const svgStyle = { 22 | position: 'absolute', 23 | top: '0px', 24 | left: '0px', 25 | height: '100%', 26 | width: '100%', 27 | color: '#ff00ff', 28 | }; 29 | 30 | const DISTANCE_FROM_CAMERA = 1; 31 | 32 | const Country = ({ data, radius, highlight, mapCenter }) => { 33 | const [coordinates, setCoordinates] = useState([]); 34 | const planeRef = useRef(null); 35 | 36 | const { camera, scene, canvas } = useThree(({ scene }) => { 37 | const topography = topojson.topology({ data }); 38 | setCoordinates(topography.arcs); 39 | 40 | const landMesh = topojson.mesh(topography, topography.objects.data); 41 | const land = wireframe( 42 | landMesh, 43 | radius, 44 | new THREE.LineBasicMaterial({ color: 0x008f11 }), 45 | ); 46 | 47 | scene.add(land); 48 | 49 | return land; 50 | }); 51 | 52 | const setPlaneFromCamera = (plane, camera) => { 53 | plane.quaternion.copy(camera.quaternion); 54 | const vector = camera.getWorldDirection(new THREE.Vector3()); 55 | const { x, y, z } = camera.position; 56 | const cameraDistance = Math.sqrt(x * x + y * y + z * z); 57 | const distanceFromOrigin = cameraDistance - DISTANCE_FROM_CAMERA; 58 | vector.negate().multiplyScalar(distanceFromOrigin); 59 | plane.position.set(vector.x, vector.y, vector.z); 60 | }; 61 | 62 | useEffect( 63 | () => { 64 | if (highlight && coordinates) { 65 | const vFOV = THREE.Math.degToRad(camera.fov); // convert vertical fov to radians 66 | const height = 2 * Math.tan(vFOV / 2) * DISTANCE_FROM_CAMERA; // visible height 67 | const width = height * camera.aspect; // visible width 68 | const planegeometry = new THREE.PlaneGeometry(width, height); 69 | const planematerial = new THREE.MeshBasicMaterial({ 70 | transparent: true, 71 | opacity: 0, 72 | }); 73 | const plane = new THREE.Mesh(planegeometry, planematerial); 74 | var material = new THREE.MeshBasicMaterial({ 75 | color: 0x00ff41, 76 | transparent: true, 77 | opacity: 0.6, 78 | }); 79 | 80 | const updated = coordinates 81 | .map(group => group.map(coord => vertex(coord, radius))) 82 | .map(group => group.map(coord => screenXY(camera, coord, canvas))); 83 | 84 | const scale = width / canvas.width; 85 | const shapes = updated.map(group => { 86 | var path = new THREE.Shape(); 87 | path.moveTo(group[0].x * scale, group[0].y * scale); 88 | group.forEach((coord, index, array) => { 89 | if (index === 0) return; 90 | const { x, y } = coord; 91 | const next = array[(index + 1) % array.length]; 92 | path.moveTo(x * scale, y * scale); 93 | path.lineTo(next.x * scale, next.y * scale); 94 | }); 95 | 96 | var geometry = new THREE.ShapeGeometry(path); 97 | var mesh = new THREE.Mesh(geometry, material); 98 | mesh.position.x -= width / 2; 99 | mesh.position.y -= height / 2; 100 | return mesh; 101 | }); 102 | 103 | plane.add(...shapes); 104 | setPlaneFromCamera(plane, camera); 105 | scene.add(plane); 106 | 107 | planeRef.current = plane; 108 | 109 | return () => { 110 | scene.remove(plane); 111 | planeRef.current = null; 112 | }; 113 | } 114 | }, 115 | [ 116 | mapCenter, 117 | coordinates, 118 | camera.position.x, 119 | camera.position.y, 120 | camera.position.z, 121 | ], 122 | ); 123 | 124 | return null; 125 | }; 126 | 127 | export default Country; 128 | -------------------------------------------------------------------------------- /src/example-globe/GlobeContainer.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import * as topojson from 'topojson'; 3 | import { 4 | graticule10, 5 | wireframe, 6 | spherePositionAtDeviceCoords, 7 | coords, 8 | normalizedDeviceCoords, 9 | vertex, 10 | getCameraAltitude, 11 | pointCameraAtSpherePosition, 12 | } from './mapUtils'; 13 | import { useThree } from '../ThreeJSManager'; 14 | import React, { useEffect, useRef, useState } from 'react'; 15 | import Country from './Country'; 16 | import data from './countries.geo'; 17 | const topography = topojson.topology({ data }); 18 | const upPosition = new THREE.Vector2(); 19 | 20 | // maximium pixel distance between pointer down and pointerup 21 | // that will be treated as a click instead of a drag 22 | const MOVE_DISTANCE_CLICK_THRESHOLD = 2; 23 | 24 | const GlobeContainer = ({ 25 | radius = 100, 26 | mapCenter, 27 | setMapCenter, 28 | getCameraControls, 29 | }) => { 30 | const [countries] = useState( 31 | () => topojson.feature(topography, topography.objects.data).features, 32 | ); 33 | const shouldPropagateChanges = useRef(false); 34 | const positionRef = useRef(new THREE.Vector2(0, 0)); 35 | const [center, setCenter] = useState(mapCenter); 36 | 37 | const { getEntity, canvas } = useThree(({ scene }) => { 38 | const sphereGeometry = new THREE.SphereGeometry(radius * 0.99, 128, 128); 39 | const sphereMaterialProperties = { 40 | color: 0x0d0208, 41 | opacity: 0.6, 42 | transparent: true, 43 | }; 44 | const sphereMaterial = new THREE.MeshBasicMaterial( 45 | sphereMaterialProperties, 46 | ); 47 | const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial); 48 | 49 | const graticuleGeometry = graticule10(); 50 | const graticule = wireframe( 51 | graticuleGeometry, 52 | radius, 53 | new THREE.LineBasicMaterial({ color: 0x003b00 }), 54 | ); 55 | 56 | sphere.add(graticule); 57 | scene.add(sphere); 58 | 59 | return sphere; 60 | }); 61 | 62 | // on pointer move center map at point on middle of globe 63 | const handlePointerMove = useRef(() => { 64 | const { camera } = getCameraControls(); 65 | const sphere = getEntity(); 66 | const spherePosition = spherePositionAtDeviceCoords(sphere, camera, { 67 | x: 0, 68 | y: 0, 69 | }); 70 | const [lat, lon] = coords(spherePosition, radius); 71 | setMapCenter([lat, lon]); 72 | }); 73 | 74 | // on pointer up 75 | const handlePointerUp = useRef(({ clientX, clientY }) => { 76 | upPosition.set(clientX, clientY); 77 | 78 | // check distance from pointer down 79 | const moveDistance = positionRef.current.distanceTo(upPosition); 80 | const isClick = moveDistance < MOVE_DISTANCE_CLICK_THRESHOLD; 81 | 82 | // center globe if distance counts as a click 83 | if (isClick) { 84 | const { camera } = getCameraControls(); 85 | const sphere = getEntity(); 86 | const deviceCoords = normalizedDeviceCoords(canvas, { 87 | clientX, 88 | clientY, 89 | }); 90 | const spherePosition = spherePositionAtDeviceCoords( 91 | sphere, 92 | camera, 93 | deviceCoords, 94 | ); 95 | 96 | if (spherePosition) { 97 | const globeCoords = coords(spherePosition, radius); 98 | setMapCenter(globeCoords); 99 | } 100 | } 101 | 102 | shouldPropagateChanges.current = false; 103 | canvas.removeEventListener('pointermove', handlePointerMove.current); 104 | canvas.removeEventListener('pointerup', handlePointerUp.current); 105 | }); 106 | 107 | // on pointer down 108 | const onPointerDown = useRef(({ clientX, clientY }) => { 109 | shouldPropagateChanges.current = true; 110 | // store down position 111 | positionRef.current.set(clientX, clientY); 112 | 113 | // add event listeners 114 | canvas.addEventListener('pointermove', handlePointerMove.current); 115 | canvas.addEventListener('pointerup', handlePointerUp.current); 116 | }); 117 | 118 | // set on pointer down event handlers when canvas updates 119 | useEffect( 120 | () => { 121 | if (!canvas) return; 122 | canvas.addEventListener('pointerdown', onPointerDown.current); 123 | 124 | return () => { 125 | canvas.removeEventListener('pointerdown', onPointerDown.current); 126 | }; 127 | }, 128 | [canvas], 129 | ); 130 | 131 | useEffect( 132 | () => { 133 | if (!shouldPropagateChanges.current) { 134 | const { camera, controls } = getCameraControls(); 135 | const position = vertex([mapCenter[1], mapCenter[0]], radius); 136 | const altitude = getCameraAltitude(camera); 137 | pointCameraAtSpherePosition(camera, controls, position, altitude); 138 | } 139 | setCenter(mapCenter); 140 | }, 141 | [mapCenter], 142 | ); 143 | 144 | return countries 145 | .reverse() 146 | .map((country, index) => ( 147 | 155 | )); 156 | }; 157 | 158 | export default GlobeContainer; 159 | -------------------------------------------------------------------------------- /src/example-globe/MapContainer.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | 3 | const mapConfig = { 4 | template: 5 | 'https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}', 6 | attribution: 7 | 'Map data © OpenStreetMap contributors, CC-BY-SA, Imagery © Mapbox', 8 | maxZoom: 10, 9 | id: 'mapbox.streets', 10 | accessToken: process.env.REACT_APP_MAPBOX_TOKEN, 11 | }; 12 | 13 | const MapContainer = ({ mapCenter, setMapCenter }) => { 14 | const [lat, lon] = mapCenter; 15 | const mapRef = useRef(); 16 | const shouldPropagateChange = useRef(true); 17 | 18 | const onMoveRef = useRef(() => { 19 | if (shouldPropagateChange.current) { 20 | const { lat, lng } = mapRef.current.getCenter(); 21 | if (shouldPropagateChange.current) setMapCenter([lat, lng]); 22 | } 23 | }); 24 | 25 | const onClickRef = useRef(({ originalEvent }) => { 26 | const { lat, lng } = mapRef.current.mouseEventToLatLng(originalEvent); 27 | setMapCenter([lat, lng]); 28 | }); 29 | 30 | const onMoveEndRef = useRef(() => (shouldPropagateChange.current = true)); 31 | 32 | useEffect(() => { 33 | const { template, ...config } = mapConfig; 34 | const map = window.L.map('map-root').setView([lat, lon], 8); 35 | window.L.tileLayer(template, config).addTo(map); 36 | 37 | map.on('move', onMoveRef.current); 38 | map.on('moveend', onMoveEndRef.current); 39 | map.on('click', onClickRef.current); 40 | map.on('mousemove', () => console.log('pointer moving!')); 41 | 42 | mapRef.current = map; 43 | }, []); 44 | 45 | useEffect( 46 | () => { 47 | if (!mapRef.current) return; 48 | 49 | shouldPropagateChange.current = false; 50 | mapRef.current.panTo(mapCenter); 51 | }, 52 | [mapCenter], 53 | ); 54 | 55 | return
; 56 | }; 57 | 58 | export default MapContainer; 59 | -------------------------------------------------------------------------------- /src/example-globe/MapExample.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from 'react'; 2 | import SceneManager from '../ThreeJSManager'; 3 | import GlobeContainer from './GlobeContainer'; 4 | import useWorldMap from './hooks/useWorldMap'; 5 | import MapContainer from './MapContainer'; 6 | import CameraControls from './CameraControls'; 7 | import { getCamera, getRenderer, getScene } from './threeSetup'; 8 | 9 | const MapExample = () => { 10 | const cameraControlsRef = useRef(); 11 | const thaBay = [37.7, -122.2]; 12 | const mapData = useWorldMap(); 13 | const [mapCenter, setMapCenter] = useState(thaBay); 14 | 15 | return ( 16 |
23 |
30 |
35 | 36 |
37 |
38 | 50 | {mapData && ( 51 | cameraControlsRef.current} 53 | mapData={mapData} 54 | setMapCenter={setMapCenter} 55 | mapCenter={mapCenter} 56 | /> 57 | )} 58 | ; 59 | 60 |
61 |
62 |
63 | ); 64 | }; 65 | 66 | export default MapExample; 67 | -------------------------------------------------------------------------------- /src/example-globe/hooks/useWorldMap.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export default function useWorldMap() { 4 | const [mapData, setMapData] = useState(); 5 | 6 | async function getMapData() { 7 | const response = await fetch( 8 | 'https://unpkg.com/world-atlas@1/world/50m.json', 9 | ); 10 | const data = await response.json(); 11 | 12 | setMapData(data); 13 | } 14 | 15 | useEffect(() => { 16 | getMapData(); 17 | }, []); 18 | 19 | return mapData; 20 | } 21 | -------------------------------------------------------------------------------- /src/example-globe/mapUtils.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | const raycaster = new THREE.Raycaster(); 4 | 5 | export function coords(position, radius) { 6 | const { x, y, z } = position; 7 | const lat = Math.asin(y / radius) / (Math.PI / 180); 8 | const lon = -Math.atan2(z, x) / (Math.PI / 180); 9 | 10 | return [lat, lon]; 11 | } 12 | 13 | export function vertex([longitude, latitude], radius) { 14 | const lambda = (longitude * Math.PI) / 180; 15 | const phi = (latitude * Math.PI) / 180; 16 | 17 | return new THREE.Vector3( 18 | radius * Math.cos(phi) * Math.cos(lambda), 19 | radius * Math.sin(phi), 20 | -radius * Math.cos(phi) * Math.sin(lambda), 21 | ); 22 | } 23 | 24 | export function wireframe(multilinestring, radius, material) { 25 | const geometry = new THREE.Geometry(); 26 | 27 | for (const P of multilinestring.coordinates) { 28 | for (let p0, p1 = vertex(P[0], radius), i = 1; i < P.length; ++i) { 29 | geometry.vertices.push((p0 = p1), (p1 = vertex(P[i], radius))); 30 | } 31 | } 32 | 33 | return new THREE.LineSegments(geometry, material); 34 | } 35 | 36 | export function* range(start, stop, step) { 37 | for (let i = 0, v = start; v < stop; v = start + ++i * step) { 38 | yield v; 39 | } 40 | } 41 | 42 | export function parallel(y, x0, x1, dx = 2.5) { 43 | return Array.from(range(x0, x1 + 1e-6, dx), x => [x, y]); 44 | } 45 | 46 | export function meridian(x, y0, y1, dy = 2.5) { 47 | return Array.from(range(y0, y1 + 1e-6, dy), y => [x, y]); 48 | } 49 | 50 | export function graticule10() { 51 | return { 52 | type: 'MultiLineString', 53 | coordinates: [].concat( 54 | Array.from(range(-180, 180, 10), x => 55 | x % 90 ? meridian(x, -80, 80) : meridian(x, -90, 90), 56 | ), 57 | Array.from(range(-80, 80 + 1e-6, 10), y => parallel(y, -180, 180)), 58 | ), 59 | }; 60 | } 61 | 62 | // returns between -1 and 1 for X,Y over the canvas element 63 | export function normalizedDeviceCoords(canvas, { clientX, clientY }) { 64 | const bbox = canvas.getBoundingClientRect(); 65 | return { 66 | x: ((clientX - bbox.x) / bbox.width) * 2 - 1, 67 | y: -(((clientY - bbox.y) / bbox.height) * 2) + 1, 68 | }; 69 | } 70 | 71 | export function canvasCoords(camera, canvas, vector) { 72 | const { offsetWidth, offsetHeight } = canvas; 73 | camera.updateMatrixWorld(); 74 | const position = vector.clone(); 75 | const { x, y } = position.project(camera); 76 | 77 | return { 78 | x: ((x + 1) * offsetWidth) / 2 + 1, 79 | y: ((-y + 1) * offsetHeight) / 2 + 1, 80 | }; 81 | } 82 | 83 | // returns the point on the sphere the pointer is above 84 | export function spherePositionAtDeviceCoords(sphere, camera, { x = 0, y = 0 }) { 85 | raycaster.setFromCamera({ x, y }, camera); 86 | const intersects = raycaster.intersectObject(sphere); 87 | 88 | if (intersects.length) return intersects[0].point; 89 | } 90 | 91 | export function getCameraAltitude(camera) { 92 | const { x, y, z } = camera.position; 93 | return Math.sqrt(x * x + y * y + z * z); 94 | } 95 | 96 | // focuses camera at a given point 97 | export function pointCameraAtSpherePosition(camera, controls, point, altitude) { 98 | const { x, y, z } = point; 99 | const radius = Math.sqrt(x * x + y * y + z * z); 100 | const coeff = 1 + (altitude - radius) / radius; 101 | 102 | camera.position.copy(point.multiplyScalar(coeff)); 103 | controls.update(); 104 | } 105 | -------------------------------------------------------------------------------- /src/example-globe/threeSetup.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | export const getCamera = ({ offsetWidth, offsetHeight }) => { 4 | const camera = new THREE.PerspectiveCamera( 5 | 75, 6 | offsetWidth / offsetHeight, 7 | 1, 8 | 1000, 9 | ); 10 | camera.position.set(120, 80, 140); 11 | 12 | return camera; 13 | }; 14 | 15 | export const getRenderer = canvas => { 16 | const context = canvas.getContext('webgl'); 17 | const renderer = new THREE.WebGLRenderer({ 18 | canvas, 19 | context, 20 | }); 21 | 22 | renderer.setSize(canvas.offsetWidth, canvas.offsetHeight); 23 | renderer.setPixelRatio(window.devicePixelRatio); 24 | 25 | return renderer; 26 | }; 27 | 28 | export const getScene = () => { 29 | const scene = new THREE.Scene(); 30 | scene.background = new THREE.Color(0x0); 31 | 32 | const light = new THREE.AmbientLight(0x404040); 33 | scene.add(light); 34 | 35 | return scene; 36 | }; 37 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 5 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | font-size: 10px; 10 | } 11 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | ReactDOM.render(, document.getElementById('root')); 7 | --------------------------------------------------------------------------------