3D in React with Three.js
Tags: react, threejs, 3d, typescript
Published: 2023-03-26 19:44:26

If you have never worked with any 3D software or three.js before, this article can help you understand the basics of three.js and how to integrate it with React. At first, it may seem a bit overwhelming, but in the end, you will have a base app for playing and experimenting with three.js.

&nbsp;
## Three.js

Let's start with the basics, three.js is a WebGL engine, ok but what does that mean?

Well, WebGL allows web content to use an API based on OpenGL ES 2.0 to carry out 2D and 3D rendering in an HTML canvas element without using any plug-ins.

The three.js library provides many functions and APIs for drawing 3D scenes in the browser.

This is the minimal structure of an app with three.js.

<img src="https://threejs.org/manual/resources/images/threejs-1cube-no-light-scene.svg" />
<figcaption style="text-align:center;">three.js app setup - <a href="https://threejs.org/manual/#en/fundamentals">threejs.org</a></figcaption>

&nbsp;
## Hello cube... no, Loaders!

When we learn a programming language, the first thing we do is a "Hello World!"
the second thing is to add it to our Linkedin profile :P, well what we will do here is make a minimal scene but not with a simple and boring cube, we will use `Loaders` to load a 3D model in our scene.

So get to work!

&nbsp;
## Project setup

I'll use [this](https://github.com/devil-geek/parcel-seed.git) project as a base, it already has typescript, parcel, eslint, emotion and react configured. You can use create-react-app or any other base if you want.

### Dependencies

We are going to need to install three.js

```
yarn add three 
```
```
npm i three
```
and since we are going to use typescript, we need to install the types
```
yarn add -D @types/three
```
```
npm i -D @types/three
```


### Integrate Three with React

First of all, React is unaware of changes made to the DOM outside of React, if the DOM nodes are manipulated by another library, React gets confused. To make React happy and avoid conflicts we must prevent the component from updating, using elements that React has no reason to update like an empty div or in our case, a canvas.

Our three.js code uses the dom so we need to wait for the first render, for this we will use a hook we could use useEffect, but this is a good case for useLayoutEffect. If you want, take a look at this excellent article where you can read about the differences between the two: https://kentcdodds.com/blog/useeffect-vs-uselayouteffect

That being said, our component would look like this:

```jsx
export function App() {

  useLayoutEffect(()=>{
    // three-js code here
  },[])

  return (
    <canvas/>
  )
}
```

## Renderer

This is the main object of Three.js, it receives a scene and a camera and renders (draws) the part of the scene that is inside the camera as a 2d image on the canvas.

Now, we need to import three.js

```jsx
import * as THREE from 'three';
```

We will create an instance of WebGLRenderer, for this we need to pass it a canvas, if it is not passed the canvas it will create one and you will have to add it to the document later.

To use the canvas of our component we need a reference to the DOM element and for that, we will use another hook, `useRef`

```jsx
export function App() {
  
  const canvasRef = useRef<HTMLCanvasElement>(null)

  useLayoutEffect(()=>{
    // three-js code here
  },[])

  return (
    <canvas ref={canvasRef}/>
  )
}
```

Now with the reference to the canvas, we can create the instance

```jsx
const renderer = new THREE.WebGLRenderer({
    canvas: canvasRef.current as HTMLCanvasElement,
    antialias: true,
    alpha: true,
})
```

Aside from the canvas, we're passing a couple of additional parameters, `antialias` which makes sure we don't have weird borders on our objects, and `alpha` which makes our canvas have a transparent background. So we can set the background with CSS.


## Camera

So, we need a camera, we'll use a `PerspectiveCamera` and set its position to x,y,z coordinates.

```jsx
const camera = new THREE.PerspectiveCamera(
    45, // fov - field of view
    window.innerWidth / window.innerHeight, // aspect
    0.1, // near
    100 // far
)

camera.position.set(2, 1, 2)
```    
the parameters for `PerspectiveCamera` are `fov`, `aspect`, `near` and `far`:
    
  - `fov` is the `field of view` in degrees

  - `aspect` is the aspect ratio of the canvas

  - `near` and `far` represent the space in front of the camera that will be rendered, anything outside of that range will not be rendered.

<img src="https://threejs.org/manual/resources/frustum-3d.svg"/>
<figcaption style="text-align:center;">three.js perspective camera - <a href="https://threejs.org/manual/#en/fundamentals">threejs.org</a></figcaption>


&nbsp;
## Scene

Whats next? Create a scene. Basically a scene is a container, everything you want to be drawn needs to be added to the scene.

```jsx
const scene = new THREE.Scene()
```

## Lights
Our scene requires lights so that the objects can be seen better, we will create two lights: a `DirectionalLight` and a `HemisphereLight`.

```jsx
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.2);
directionalLight.castShadow = true;
directionalLight.position.set(-1, 2, 4)
scene.add(directionalLight);

const ambientLight = new THREE.AmbientLight(0xffffff, 0.7)
scene.add(ambientLight)

```

## Loaders

Now that we have our scene lit, we can start adding objects.
We will use a loader to load a model, in fact, there are different loaders for different formats (.gltf, .obj, .fbx, etc.). In this case, I will use an fbx model (model) but you can use any model with fbx format.

Let's import the FBXLoader

```jsx
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader"
```
FBXLoader receives as parameters the path where the .fbx file is and `onLoad`, `onProgress` and `onError` callbacks.

I will create a 3D object as a container for my model, this is useful to use it as a pivot, in the case the model is not at the origin, so we can center it easily.

In the `onLoad` we get the object, we can scale, set his position, and add it to the container as a child, in my case the model was a bit up in the `Y` coordinate. Finally, we add the container to the scene.

```jsx
const container = new Object3D()
container.position.set(0, 0, 0)

const fbxLoader = new FBXLoader()
fbxLoader.load(
    "/mush.fbx",
    (object) => {
        object.scale.set(0.05, 0.05, 0.05)
        object.position.set(0, -0.3, 0)
        container.add(object)
    },
    (xhr) => {
        console.log((xhr.loaded / xhr.total) * 100 + "% loaded")
    },
    (error) => {
        console.log(error)
    }
)
scene.add(container)
```    
 

## Render and animation

We are missing an important step, we need to tell the renderer that we want to render, so that it can update the next frame, for this we can use setAnimationLoop or requestanimationFrame, for projects with WebXR we must use setAnimationLoop.

```jsx
renderer.setAnimationLoop(() => {
    renderer.render(scene, camera)
})
```
Now we can see our object, for example; if we wanted to rotate the object
we can use the container, since the model is a child of the container, we can animate it and the model will follow the movement of the container.

```jsx
renderer.setAnimationLoop(() => {
    container.rotation.y -= 0.01
    renderer.render(scene, camera)
})
```


## Responsive

We will focus on the canvas occupying 100% of its container, so we add some CSS. Note that I am using emotion.
Do you remember that our canvas has a transparent background? That allows us to set the background color with CSS.

```jsx
import { css, Global } from "@emotion/react"
...
<>
    <Global
        styles={css`
            html,
            body {
                margin: 0;
                padding: 0;
                height: 100%;
            }
        `}
    />
    <canvas
        css={css`
            width: 100%;
            height: 100%;
            display: block;
            background-image: linear-gradient(
                to top,
                #0a264f,
                #15487a,
                #1b6ea6,
                #1b96d2,
                #16c1fd
            );
        `}
        ref={canvasRef}
    />
</>
```

It's still not fully responsive, we need to update the size of the renderer and the camera aspect when the window resizes. For this, we add an event, and we also remove the event when the component is unmounted at the end of our hook.

```jsx
useLayoutEffect(() => {
    ...
    const onResize = () => {
      camera.aspect = window.innerWidth / window.innerHeight
      camera.updateProjectionMatrix()
      renderer.setSize(window.innerWidth, window.innerHeight)
    }

    window.addEventListener("resize", onResize, false)

    return () => {
      window.removeEventListener("resize", onResize)
    }
  }, [])
```



## Controls

We already have a 3d scene, but we can't move through it. So, let's add controls to make it more interactive.


```jsx
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls"
```

```jsx
useLayoutEffect(() => {
    ...
    new OrbitControls(camera, renderer.domElement)

```

With this, we can zoom, pan, and move around the scene using the mouse. 
And voila! We have a 3d scene in react with three.js.

<img src="https://github.com/devil-geek/react-three/raw/main/static/mush.gif"/>

You can play with it [here](https://superlative-boba-45191d.netlify.app).

&nbsp;
## Code

If you got lost, I put the complete code. 

```jsx
import { useLayoutEffect, useRef } from "react"
import {
  DirectionalLight,
  Object3D,
  PerspectiveCamera,
  Scene,
  WebGLRenderer,
  AmbientLight,
} from "three"
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls"
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader"

export function App() {
  const canvasRef = useRef<HTMLCanvasElement>(null)

  useLayoutEffect(() => {
    const renderer = new WebGLRenderer({
      canvas: canvasRef.current as HTMLCanvasElement,
      antialias: true,
      alpha: true,
    })

    const camera = new PerspectiveCamera(
      45, // fov
      window.innerWidth / window.innerHeight, // aspect
      0.1, // near
      100 // far
    )

    camera.position.set(2, 1, 2)

    const scene = new Scene()

    // lights
    const directionalLight = new DirectionalLight(0xffffff, 0.2)
    directionalLight.castShadow = true
    directionalLight.position.set(-1, 2, 4)
    scene.add(directionalLight)

    const ambientLight = new AmbientLight(0xffffff, 0.7)
    scene.add(ambientLight)

    //fbx model
    const container = new Object3D()
    container.position.set(0, 0, 0)

    const fbxLoader = new FBXLoader()
    fbxLoader.load(
      "/mush.fbx",
      (object) => {
        object.scale.set(0.05, 0.05, 0.05)
        object.position.set(0, -0.3, 0)
        container.add(object)
      },
      (xhr) => {
        console.log((xhr.loaded / xhr.total) * 100 + "% loaded")
      },
      (error) => {
        console.log(error)
      }
    )
    scene.add(container)

    renderer.setAnimationLoop(() => {
      //container.rotation.y -= 0.01
      renderer.render(scene, camera)
    })

    renderer.setSize(window.innerWidth, window.innerHeight)

    const onResize = () => {
      camera.aspect = window.innerWidth / window.innerHeight
      camera.updateProjectionMatrix()
      renderer.setSize(window.innerWidth, window.innerHeight)
    }

    window.addEventListener("resize", onResize, false)

    new OrbitControls(camera, renderer.domElement)

    return () => {
      window.removeEventListener("resize", onResize)
    }
  }, [])

  return (
    <>
      <Global
        styles={css`
          html,
          body {
            margin: 0;
            padding: 0;
            height: 100%;
          }
        `}
      />
      <canvas
        css={css`
          width: 100%;
          height: 100%;
          display: block;
          background-image: linear-gradient(
            to top,
            #0a264f,
            #15487a,
            #1b6ea6,
            #1b96d2,
            #16c1fd
          );
        `}
        ref={canvasRef}
      />
    </>
  )
}

```

If you want to be lazy, you can clone the repo [here](https://github.com/devil-geek/react-three.git).



## Conclusion

If you got this far, you already have a sample react app with three.js, I recommend checking out the examples on the three.js page, taking a look at the docs, and exploring your own ideas.