Vanilla Three.js to React Three Fiber

Entry 05 Dec 28, 2020 04 min read ✸ Edit Post

99 bottles of, uh, topical formulations consisting of three active ingredients and a compounded base on the wall, 99 bottles of — ah, forget it.

x:
02
y:
01
z:
01
wiggle
0.01
↓ Scroll ↓

In this post, I’ll walk through how I converted an old project from vanilla Three.js to React Three Fiber.

Why React Three Fiber?

Working in plain old JavaScript was a great way to learn Three.js fundamentals, but I knew I eventually had to use a library to integrate with React apps and sites.

Maybe I’d have to add interactivity or keep track of state. Or maybe I’d want to port over Three.js components to different places on my site, including this page using MDX!

Doing any of these things in vanilla Three.js would be pretty hard, so leaning on a well-supported third-party library seemed the way to go. There used to be a number of different options (e.g., react-three-renderer, react-three-renderer-fiber), but react-three-fiber seems to have won out as a relatively light-weight renderer for Three.js.

I think of react-three-fiber as providing JSX “syntactic sugar” for Three.js. For example, <mesh/> translates to new Three.Mesh(). So anything that’s possible with Three.js is possible with react-three-fiber, and you don’t have to worry about the library staying up to date with the latest Three.js version, which is nice considering how often Three.js changes.

Getting Started

With a third-party library picked out, I was ready to take this standalone codesandbox project and integrate it with my Gatsby (i.e. React) site. This was initially a little harder than expected, but in the end it wasn’t too bad.

There’s a small learning curve to approaching Three.js with a React state of mind. I was used to creating things imperatively in vanilla Three.js, but now I had to think of components, state, and so on. But seeing as how I was already familiar with React, I got used to that pretty quickly.

I also encountered TypeScript weirdness often. But unlike the more die-hard TypeScript proponents out there, I have no issue with making liberal use of // @ts-ignore as needed. 😇

In the end, things came together nicely since I had a working knowledge of Three.js. The whole process only took a couple hours, and most of that time was spent adding additional features like dynamically changing the number of bottles and the “wiggle” velocity from the UI.

I can definitely see how diving into react-three-fiber without a basic understanding of core Three.js concepts would have made things impossibly hard, and I generally have a high tolerance for muddling through projects without knowing what I’m doing.

But you don’t need to be a Three.js expert to get started! I went from knowing nothing about Three.js to giving a talk about it (where I live-coded much of what’s in the codesandbox above) in 2 weeks of leisurely study. All I did was follow the first two chapters of this excellent e-book. Trust me, you’ll want a good foundation before starting your first react-three-fiber project.

Start with a blank <Canvas/>

Starting a Three.js project from scratch typically involves a lot of boilerplate.

You need to manually select a container element from the DOM:

let renderer, camera, scene, container;
let bottles = [];
const main = () => {
container = document.querySelector('#scene-container');
scene = new THREE.Scene();
scene.background = new THREE.Color(0x332e54);
createCamera();
createLights();
createMeshes();
createControls();
createRenderer();
renderer.setAnimationLoop(() => {
update();
render();
});
};

Set-up your scene:

const main = () => {
container = document.querySelector('#scene-container');
scene = new THREE.Scene();
scene.background = new THREE.Color(0x332e54);
createCamera();
createLights();
createMeshes();
createControls();
createRenderer();
renderer.setAnimationLoop(() => {
update();
render();
});
};

And your camera (and lights if you’re planning to use anything other than a basic material):

const main = () => {
// [...]
createCamera();
createLights();
// [...]
};
const createCamera = () => {
const fieldOfView = 35;
const aspectRatio = container.clientWidth / container.clientHeight;
const nearPlane = 0.01;
const farPlane = 1000;
camera = new THREE.PerspectiveCamera(
fieldOfView,
aspectRatio,
nearPlane,
farPlane
);
camera.position.set(0, 0, 10);
};
// I ended up using a basic material for my old project, so I took this out.
const createLights = () => {
const ambientLight = new THREE.AmbientLight(0xffffff, 1);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
scene.add(ambientLight, directionalLight);
};

You also have to code up a renderer:

const main = () => {
// [...]
createRenderer();
renderer.setAnimationLoop(() => {
update();
render();
});
};
const createRenderer = () => {
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.gammaFactor = 2.2;
renderer.gammaOutput = true;
renderer.physicallyCorrectLights = true;
container.appendChild(renderer.domElement);
};
const render = () => {
renderer.render(scene, camera);
};

And handle other stuff like window resizing:

const onWindowResize = () => {
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
};
window.addEventListener('resize', onWindowResize);

Writing all this out is great when you’re trying to learn how individual pieces fit together, but not so much every time you want to spin up a new project.

Luckily with react-three-fiber, all that boilerplate (and then some) can be condensed into a single <Canvas/> element, which wraps our entire Three.js project. ✨

import { Canvas } from 'react-three-fiber';
const Bottles = () => (
<Canvas>
<ambientLight intensity={0.5} />
<directionalLight />
</Canvas>
)

Nice! You can pass various props to customize the camera and so on, but I tend to lean on sensible defaults whenever possible.

Creating meshes and stuff

Once you have your <Canvas/>, you can start declaring Three.js objects (e.g., <mesh/>, <group/>, etc.) as children. There’s no need to import these separately, as they’re all available within <Canvas/> as Three.js (not DOM) elements.

Note that that all Three.js magic needs to happen within the <Canvas/> element. So don’t try to use a hook at the top-level!

For my project, I needed to create a <Bottle/>, which was basically a <cylinderBufferGeometry/> covered by three custom textures. I also wanted the bottle to “wiggle” by adjusting its rotational position on the x, y, and z axes by some random amount.

In vanilla Three.js, this looked something like:

const main = () => {
// [...]
createMeshes();
// [...]
renderer.setAnimationLoop(() => {
update();
// [...]
});
};
const createMeshes = () => {
const geometry = new THREE.CylinderBufferGeometry(1, 1, 7, 50);
const { topTexture, bodyTexture, bottomTexture } = createTextures();
const topMaterial = new THREE.MeshBasicMaterial({
map: topTexture
});
const bodyMaterial = new THREE.MeshBasicMaterial({
map: bodyTexture
});
const bottomMaterial = new THREE.MeshBasicMaterial({
map: bottomTexture
});
const count = 12;
const offsetDistance = 16;
/**
* Looping over a bunch of objects like this is kind of
* sub-optimal given how expensive material/mesh creation can be.
* But when you're learning the basics, it's fine? ¯\_(ツ)_/¯
* */
for (let x = 0; x < count; x++) {
for (let y = 0; y < count; y++) {
for (let z = 0; z < count; z++) {
const bottle = new THREE.Mesh(geometry, [
bodyMaterial,
topMaterial,
bottomMaterial
]);
// [...]
scene.add(bottle);
}
}
}
};
const createTextures = () => {
const textureLoader = new THREE.TextureLoader();
const textures = {
topTexture: textureLoader.load('textures/superbottle-top.png'),
bodyTexture: textureLoader.load('textures/superbottle-body.png'),
bottomTexture: textureLoader.load('textures/superbottle-bottom.png')
};
Object.values(textures).forEach((texture) => {
texture.encoding = THREE.sRGBEncoding;
texture.anisotropy = 16;
});
return textures;
};
const update = () => {
// [...]
// Update the x, y, and z rotation of each bottle
};

Since I was just starting out, I didn’t worry too much about optimizations (I just wanted it to work!). But in general, creating multiple objects like I did in a triply-nested for-loop isn’t the most optimal way of managing Three.js objects.

Anyway, the goal of this project wasn’t to optimize, it was to convert everything over to react-three-fiber. Here’s how that looked:

import React, { useRef } from 'react';
import { MeshProps, useLoader, useFrame } from 'react-three-fiber';
import { Mesh, TextureLoader, sRGBEncoding } from 'three';
import SuperbottleTop from './textures/superbottle-top.png';
import SuperbottleBody from './textures/superbottle-body.png';
import SuperbottleBottom from './textures/superbottle-bottom.png';
const Bottle = (props: MeshProps & { rotationalForces: { x: number; y: number; z: number; } }) => {
const { rotationalForces: { x, y, z } } = props;
const bottle = useRef<Mesh>();
const { topTexture, bodyTexture, bottomTexture } = useBottleTextures();
useFrame(() => {
if (!bottle || !bottle.current) {
return;
}
bottle.current.rotation.x += x;
bottle.current.rotation.y += y;
bottle.current.rotation.z += z;
});
return (
<mesh
{...props}
ref={bottle}
>
<cylinderBufferGeometry attach="geometry" args={[0.5, 0.5, 3.5, 48]} />
<meshStandardMaterial map={bodyTexture} attachArray="material" />
<meshStandardMaterial map={topTexture} attachArray="material" />
<meshStandardMaterial map={bottomTexture} attachArray="material" />
</mesh>
);
};
const useBottleTextures = () => {
const textures = {
topTexture: useLoader(TextureLoader, SuperbottleTop),
bodyTexture: useLoader(TextureLoader, SuperbottleBody),
bottomTexture: useLoader(TextureLoader, SuperbottleBottom),
};
Object.values(textures).forEach(texture => {
texture.encoding = sRGBEncoding;
texture.anisotropy = 16;
});
return textures;
};
export default Bottle;

useFrame replaces renderer.setAnimationLoop(), and I keep track of the bottle with a ref. But for the most part, the code looks about the same.

Given relatively sparse documentation, I did have to dive into the source code regularly. That’s how I figured out that I needed to use attachArray to map multiple textures to a mesh.

I also ran into TypeScript inconsistencies. For example, it seems you should be able to load multiple textures into useLoader:

// useLoader(loader: THREE.Loader, url: string | string[], extensions?, xhr?)
const [bumpMap, specMap, normalMap] = useLoader(TextureLoader, [url1, url2, url2])

But when I tried that, the compiler complained even though the types seemed to allow an array of strings as a second argument.

And in the end, I just re-used my triply-nested for loop to actually place the bottles in my top-level <Bottles/> component. Sorry!

const Bottles = () => {
// [...]
return(
// [...]
<Canvas>
<CameraControls />
<ambientLight intensity={0.5} />
<directionalLight />
// I needed to use React.Suspense to wait for the textures to load.
<Suspense fallback={null}>
{Array.from({ length: bottlesCount.x }).map((_, xdx) => (
Array.from({ length: bottlesCount.y }).map((_, ydx) => (
Array.from({ length: bottlesCount.z }).map((_, zdx) => (
<Bottle
key={uuid()}
position={[
xdx * BOTTLE_OFFSET,
ydx * BOTTLE_OFFSET,
zdx * BOTTLE_OFFSET,
]}
rotationalForces={{
x: getRandomValue(0, maxBottleRotationalForce),
y: getRandomValue(0, maxBottleRotationalForce),
z: getRandomValue(0, maxBottleRotationalForce),
}}
/>
))
))
))}
</Suspense>
</Canvas>
// [...]
);
}

With that, my project was 90% converted.

Adding camera controls

The only remaining task was to add camera controls to enable panning, zooming, and all that fun interactivity. In vanilla Three.js, this was easy enough:

const main = () => {
// [...]
createControls();
// [...]
};
const createControls = () => {
const controls = new THREE.OrbitControls(camera, container);
};

The process was a bit (just a tad) more involved with react-three-fiber. I created a new <CameraControls/> component that used the extend function to essentially turn a Three.js object into a JSX element. Here’s a more in-depth guide if you’re interested.

import React, { useRef } from 'react';
import { useFrame, useThree, extend } from 'react-three-fiber';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
extend({ OrbitControls });
const CameraControls = () => {
const {
camera,
gl: { domElement },
} = useThree();
const controls = useRef();
// @ts-ignore
useFrame(() => controls.current.update());
// @ts-ignore
return <orbitControls ref={controls} args={[camera, domElement]} />;
};
export default CameraControls;

And I placed <CameraControls/> within the <Canvas/> in my <Bottles/> component:

import CameraControls from './components/cameraControls';
const Bottles = () => {
// [...]
return(
// [...]
<Canvas>
<CameraControls />
// [...]
</Canvas>
// [...]
);
}

Cool.

Taking advantage of state

Okay I lied, the only real remaining task was to take advantage of React state. After all, that’s one of the main benefits of using react-three-fiber, right?

To keep things simple, I decided to add the ability to change the number of bottles on the x, y, and z axes as well as the “wiggle” velocity from 0 (no wiggle) to 1 (crazy wiggle).

const Bottles = () => {
const [bottlesCount, setBottlesCount] = useState<BottlesCountType>({
x: 2,
y: 1,
z: 1,
});
const [maxBottleRotationalForce, setMaxBottleRotationalForce] = useState(0.01);
// [...]
// handle changes to state in <SettingsPanel/>
}

And that was pretty much it! You can see the full source code of the finished product here.

My User Manual