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
like the examples use.
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.
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!
Taking the idea further, what if we could model three.js scene objects like React components and write code like this:
<Scene>
<cameraControls />
<Cube h={12} w={12} d={12} />
</Scene>
The pattern I explain below aims to allow this in a very minimally obtrusive way.
- A component:
ThreeJSManager
- A custom hook:
useThree
Combining them allows you to create React-ish three.js components without losing any control of three.js
This component takes 3 props:
getCamera
(Function): Function that returns a three.jsCamera
. Function called with acanvas
elementgetRenderer
(Function): Function that returns a three.jsRenderer
. Function called with acanvas
elementgetScene
(Function): Function that returns a three.jsScene
.
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.
useThree(setup, [destroy])
useThree
custom hook relies on the context provided by ThreeJSManager
, so it can only be used in components that have ThreeJSManager
as an ancestor.
Arguments:
setup
(Function): This function will be called when the component mounts. It gets called with the context value provided byThreeJSManager
. 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 thegetEntity
function, which is on the object returned byuseThree
.destroy
(Function): Optional. This function will be called when the component is unmounted. It gets called with 2 arguments: the context values provided byThreeJSManager
, and a reference to whatever you returned fromsetup
. If thedestroy
param isn't passed,scene.remove
is called with the return value ofsetup
by default. Note: the return value ofsetup
is the same as the output ofgetEntity
described below
Returns: (Object)
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.
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
, and some of the new, experimental Hooks: useRef
, and most importantly useEffect
.
Read the docs above, but in a nutshell what the features allow is the ability to create function components which:
- Use
useEffect
to configurably run arbitrary JS (such as calling a third party library) - Use
useRef
to store a reference to any arbitrary value (such as third-party classes) - Combine the above with
forwardRef
to create a component that makes any arbitrary reference available to it's parent
Here is how you could use these hooks to make a Cube
functional component:
function Cube = (props) {
const entityRef = useRef()
const scene = getScene()
useEffect(
() => {
entityRef.current = createThreeJSCube(scene, props)
return () => {
cleanupThreeJSCube(scene, entityRef.current)
}
},
[],
)
return null
}
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.
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.
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:
function Cube(props) {
…
useEffect(
() => {
updateCubeWithProps(entityRef.current, props)
},
[props]
)
}
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.
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.
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 <Canvas ref={myRef} />
, myRef
will point to the canvas
HTML element itself, not the Canvas
React component. Cool!
const Canvas = forwardRef((props, ref) => {
…
return (
<canvas ref={ref} … />
)
})
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.
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.
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.
Now with the context Provider
setup, we can use the useContext
hook to access the scene in our React/three.js components:
function Cube = (props) {
const context = useContext(ThreeJSContext)
const { scene } = context
…
}
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:
<ThreeJSContext.Provider
value={{
scene,
camera,
canvas,
}}
>
{ props.children }
</ThreeJSContext.Provider>
And here's how we might use it in our app:
<SceneManager
getCamera={getCamera}
getRenderer={getRenderer}
getScene={getScene}
>
<Ground />
<Lights />
<CameraControls />
<Cube color={Number(`0x${color}`)} />
</SceneManager>
With Ground
, Lights
, CameraControls
, and Cube
being components that make use of the useThree
hook.
Let's look at what useThree
does:
- Accesses the
scene
and other three.js objects withuseContext
- Initializes a placeholder that will store the 3D object with
useRef
(entityRef
) - Runs code on mount that instantializes the 3D object, assigns it to
entityRef
, adds it to thescene
, and returns a cleanup function that removes it fromscene
withuseEffect
- Returns an object with a
getEntity
function that can be used in other effects (such as when props change) to update the 3D object.
Here's the code for our custom hook:
import { ThreeJSContext } from './ThreeJSManager';
const useThree = (setup, destroy) => {
const entityRef = useRef();
const context = useContext(ThreeJSContext);
const getEntity = () => entityRef.current;
useEffect(
() => {
entityRef.current = setup(context);
return () => {
if (destroy) {
return destroy(context, getEntity());
}
context.scene.remove(getEntity());
};
},
[],
);
return {
getEntity,
...context,
};
}
Here's how you'd use it to add a simple grid object to the scene:
const Grid = () => {
useThree(({ scene }) => {
const grid = new THREE.GridHelper(1000, 100);
scene.add(grid);
return grid;
});
return null;
};
Notice a few things here:
- Our
setup
param method signature destructuresscene
since that's all we care about - We didn't pass
destroy
param, souseThree
will just callscene.remove
withgrid
, since that's what we returned fromsetup
. - The component renders
null
, otherwise React will throw an error. - We don't care about props changing, so we don't store the return value of
useThree
(which would give us access togrid
object throughgetEntity
).
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:
const Grid = props => {
const { color } = props
const { getEntity } = useThree(…)
useEffect(
() => {
const grid = getEntity()
grid.material.color.set(color)
},
[color],
)
…
}
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:
const ComplexThreeComponent = () => {
const getEntity = useThree(
({ scene }) => {
…
return {
arms,
body,
leg,
}
},
({ scene }, entity) => {
const { arms, body, leg } = entity
scene.remove(arms)
scene.remove(body)
scene.remove(leg)
}
})
…
};
If we want to setup a camera control, we can create a component that uses camera
and canvas
in its setup
function:
const CameraControls = () => {
useThree(({ camera, canvas }) => {
const controls = new OrbitControls(camera, canvas)
…
})
…
}
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:
const Cube = props => {
const { getEntity, timer } = useThree(…)
useEffect(
() => {
const cube = getEntity()
cube.rotation.x += .01
cube.rotation.z += .01
},
[timer],
)
…
}
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.
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.
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.
- Currently there's no way to switch the
scene
outside of rendering a differentThreeJSManager
component - Changing the props for
ThreeJSManager
doesn't have any effect since it only uses them on mount - The scene is always rerendered with
requestAnimationFrame
, it's needed for the props changing on auseThree
component to take effect. - The function passed to
requestAnimationFrame
doesn't actually trigger thetimer
effects in our components directly, so profiling the rendering performance could be harder
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.