DEV Community

Cover image for Creating a 3D Table Configurator with React Three Fiber
Wassim SAMAD
Wassim SAMAD

Posted on • Edited on • Originally published at wawasensei.hashnode.dev

Creating a 3D Table Configurator with React Three Fiber

Let's create a 3D Table Configurator using the following libraries:

  • Vite
  • React
  • Tailwind
  • Three.js
  • React Three Fiber
  • Material UI

🔥 This tutorial is a good starting point to create a product configurator for an exciting shopping experience.

The main topics covered are:

  • how to load a 3D model
  • how to modify it using a user interface
  • how to scale and move items smoothly

A video version is also available where you can watch the final render:

Project Setup

I prepared you a starter pack including:

  • a React app created with Vite
  • a MUI User Interface
  • three.js/React Three Fiber installed including a Canvas displaying a cube to get started
  • the 3D model of the table in public/models

3D Table model

The table model contains all the different legs layout

Table model hierarchy

I separated in different meshes the left and right legs to make it simple when we will expand the table width.

*Clone the repo and run *

yarn
yarn dev
Enter fullscreen mode Exit fullscreen mode

A simple 3D cube

You should see a cube and be able to rotate around it thanks to <OrbitControls />

Staging

Now let’s create a better staging environment. I rarely start from scratch, I recommend you to go through React Three Fiber examples to find a good starting point.

I chose one named stage presets, gltfjsx

3D car

It contains nice lighting and shadows settings.

We start by copying the canvas with the camera settings and enabling shadows.

<Canvas shadows camera={{ position: [4, 4, -12], fov: 35 }}>
...
</Canvas>
Enter fullscreen mode Exit fullscreen mode

I set y position of the camera to 4

Then we copy the stage component and orbitcontrols and instead of using their model, we use the cube for now.

<Stage intensity={1.5} environment="city" shadows={{
  type: "accumulative",
  color: "#85ffbd",
  colorBlend: 2,
  opacity: 2,
}}
  adjustCamera={2}
>
...
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2} />
Enter fullscreen mode Exit fullscreen mode

I assigned the color to the one of the gradient in the CSS index file.
It helps to build good looking shadows on the floor. I also changed adjustCamera to 2 to zoom out a little bit.

minPolarAngle and maxPolarAngle are limits you can define on OrbitControls to avoid going below or over the model.

Adjust those parameters to what you prefer.

We can’t see shadows yet even if the canvas and stage have shadows enabled.

We need to tell our mesh to castShadows.

Adding cast shadow to our mesh

Now we can see the very smooth shadow generated by the stage component.

Cube with shadows

Load the table

Let’s render our table instead of the default cube.

To do so we use the gltfjsx client with

npx gltjfs public/models/Table.gltf

It generates a Table.js file containing a React component with the extracted meshes from the table model file.

Let’s copy everything, delete the file, and create a new one in the components folder named Table.jsx

By default it’s named Model, rename it to Table. We need to fix the path adding ./models for both useGltf and the preload call.

Table component code
The final component code should looks like this

Now let’s replace the cube with the table in Experience.jsx.

Experience code

There’s no shadows yet. So let’s add the castShadow prop on every meshes on the Table component.

<mesh casthShadows />
Enter fullscreen mode Exit fullscreen mode

Table model with shadows enabled

Now our table renders shadows correctly, but all the legs layouts are displayed at the same time.

Legs layout

To be able to display only one layout at a time let’s create a folder named contexts and a file named Configurator.jsx

Let's create a context Boilerplate with createContext ConfiguratorProvider and useConfigurator.

Context boilerplate

Ok, now we want to have the choice between our legs layout, so we define legs and setLegs with useState.
Our layouts will be 0, 1 and 2. so let’s default to 0.

const [legs, setLegs] = useState(0);
Enter fullscreen mode Exit fullscreen mode

Go back to our table and get the legs from useConfigurator.

const { legs } = useConfigurator();
Enter fullscreen mode Exit fullscreen mode

And we will simply do conditional rendering for our legs.

If it’s 0, we render the first one, if it’s 1 we render the second layout, and if it’s 2 we render the last one.

{legs === 0 && (
        <>
          <mesh
            castShadow
            geometry={nodes.Legs01Left.geometry}
            material={materials.Metal}
            position={[-1.5, 0, 0]}
          />
          <mesh
            geometry={nodes.Legs01Right.geometry}
            material={materials.Metal}
            position={[1.5, 0, 0]}
            castShadow
          />
        </>
      )}
      {legs === 1 && (
        <>
          <mesh
            geometry={nodes.Legs02Left.geometry}
            material={materials.Metal}
            position={[-1.5, 0, 0]}
            castShadow
          />
          <mesh
            geometry={nodes.Legs02Right.geometry}
            material={materials.Metal}
            position={[1.5, 0, 0]}
            castShadow
          />
        </>
      )}
      {legs === 2 && (
        <>
          <mesh
            geometry={nodes.Legs03Left.geometry}
            material={materials.Metal}
            position={[-1.5, 0, 0]}
            castShadow
          />
          <mesh
            geometry={nodes.Legs03Right.geometry}
            material={materials.Metal}
            position={[1.5, 0, 0]}
            castShadow
          />
        </>
      )}
Enter fullscreen mode Exit fullscreen mode

In a real project you could refactor it more nicely.

Now jump into main.jsx and wrap the app in our ConfiguratorProvider to make our context available everywhere.

main source code
Wrapping the App in the Configurator provider

3D Table with first layout

It works, now we only see the first legs layout!

Let’s add the Interface component I prepared for you next to the canvas.

<Canvas shadows camera={{ position: [4, 4, -12], fov: 35 }}>
  <Experience />
</Canvas>
<Interface />
Enter fullscreen mode Exit fullscreen mode

Interface enabled
You now can see the different options available.

Now let's go to our Interface code.

I commented the full settings so we don’t mess up with the naming.

Let’s grab legs and setLegs from our configurator.

const [legs, setLegs] = useConfigurator();
Enter fullscreen mode Exit fullscreen mode

...and let’s uncomment the value and onChange on our layout radio buttons.

Code uncomment on the interface

Now when we switch, the legs change correctly...

...but the shadows are not re-rendered because the scene doesn’t know something changes.

A simple way to tell it is to get the legs value in the experience component to force the re-rendering.

const { legs } = useConfigurator();
Enter fullscreen mode Exit fullscreen mode

Add this line to the Experience component.

Now the shadows are re-generated when we switch.

Legs color

Let’s change the legs color. Go to the Configurator and create legsColor and setLegsColor with the same default value we have in the interface. Feel free to change it.
Don't forget to add it to the exposed values from the context.

Context code with legs colors

Let's apply the color to the Table.

const { legs, legsColor, setLegsColor } = useConfigurator();
Enter fullscreen mode Exit fullscreen mode

We add a useEffect with legsColor so every time it changes, this function will get called.

useEffect(() => {
  materials.Metal.color = new Three.Color(legsColor);
}, [legsColor]);
Enter fullscreen mode Exit fullscreen mode

We need to add this import line manually:

import * as Three from "three";
Enter fullscreen mode Exit fullscreen mode

On the interface let’s add the legsColor and setLegsColor and uncomment the value and onChange.

<FormControl>
  <FormLabel>Legs Color</FormLabel>
  <RadioGroup
    value={legsColor}
    onChange={(e) => setLegsColor(e.target.value)}
>
Enter fullscreen mode Exit fullscreen mode

Table with gold legs

Now the color of our legs changes every time we apply it.

Table width

Last step, let’s change the width from our table!

In our Configurator context create a tableWidth state with a default value of 100 (centimeters)

Context code with table width

Our final context should looks like this

Get it into the Table component, and let’s calculate a scalingPercentage by diving the tableWidth per one hundred.

const tableWidthScale = tableWidth / 100;
Enter fullscreen mode Exit fullscreen mode

On the plate, change the scale with the tableWidthScale on the x axis, and keep the y and z to 1.

Apply table width scale

In the Interface let’s uncomment the slider value and onChange.

const { tableWidth, setTableWidth, legs, setLegs, legsColor, setLegsColor } =
    useConfigurator();
...
<FormControl>
  <FormLabel>Table width</FormLabel>
  <Slider
  sx={{
  width: "200px",
  }}
  min={50}
  max={200}
  value={tableWidth}
  onChange={(e) => setTableWidth(e.target.value)}
  valueLabelDisplay="auto"
  />
</FormControl>
Enter fullscreen mode Exit fullscreen mode

Now our table width change but we need to move the legs accordingly, we can do it simply by multiplying the x position by the tableWidthScale.

Code apply x position of the table

Adjusting the x position of the table legs

3D table with a longer width

Now it works correctly, but still the movement is not very smooth

Smooth animation

To make a smooth animation when we changes the table width, let’s store references of our plate, left legs and right legs.

const plate = useRef();
const leftLegs = useRef();
const rightLegs = useRef();
Enter fullscreen mode Exit fullscreen mode

Because our leftLegs and rightlegs are never rendered at the same time, we can save the 3 legs layouts in the same references.

Let’s remove the tableWidthScale multiplier as we will do it another way.

{legs === 0 && (
        <>
          <mesh
            castShadow
            geometry={nodes.Legs01Left.geometry}
            material={materials.Metal}
            position={[-1.5, 0, 0]}
            ref={leftLegs}
          />
          <mesh
            geometry={nodes.Legs01Right.geometry}
            material={materials.Metal}
            position={[1.5, 0, 0]}
            castShadow
            ref={rightLegs}
          />
        </>
      )}
      {legs === 1 && (
        <>
          <mesh
            geometry={nodes.Legs02Left.geometry}
            material={materials.Metal}
            position={[-1.5, 0, 0]}
            castShadow
            ref={leftLegs}
          />
          <mesh
            geometry={nodes.Legs02Right.geometry}
            material={materials.Metal}
            position={[1.5, 0, 0]}
            castShadow
            ref={rightLegs}
          />
        </>
      )}
      {legs === 2 && (
        <>
          <mesh
            geometry={nodes.Legs03Left.geometry}
            material={materials.Metal}
            position={[-1.5, 0, 0]}
            castShadow
            ref={leftLegs}
          />
          <mesh
            geometry={nodes.Legs03Right.geometry}
            material={materials.Metal}
            position={[1.5, 0, 0]}
            castShadow
            ref={rightLegs}
          />
        </>
      )}
Enter fullscreen mode Exit fullscreen mode

We use the useFrame hook which will be called at each frame.

It provides the state, and the delta time. which is the time between elapsed from the last frame.

We declare a targetScale Vector3 with tableWidthScale on the x axis. And on the plate scale we use the lerp function to transition smoothly from our currentScale into our targetScale.

import { useFrame } from "@react-three/fiber"; 
...
useFrame((_state, delta) => {
  const tableWidthScale = tableWidth / 100;
  const targetScale = new Vector3(tableWidthScale, 1, 1);

  plate.current.scale.lerp(targetScale, delta);
});
Enter fullscreen mode Exit fullscreen mode

Now it animates smoothly, but it’s too slow, let’s define an ANIM_SPEED constant of 12 and multiply the delta by it.

const ANIM_SPEED = 12;
...
plate.current.scale.lerp(targetScale, delta * ANIM_SPEED);
Enter fullscreen mode Exit fullscreen mode

Let’s do the same process for both legs impacting the position instead of the scale:


const ANIM_SPEED = 12;

export function Table(props) {
  const { nodes, materials } = useGLTF("./models/Table.gltf");

  const { legs, legsColor, tableWidth } = useConfigurator();

  const plate = useRef();
  const leftLegs = useRef();
  const rightLegs = useRef();

  useEffect(() => {
    materials.Metal.color = new Three.Color(legsColor);
  }, [legsColor]);

  useFrame((_state, delta) => {
    const tableWidthScale = tableWidth / 100;
    const targetScale = new Vector3(tableWidthScale, 1, 1);

    plate.current.scale.lerp(targetScale, delta * ANIM_SPEED);

    const targetLeftPosition = new Vector3(-1.5 * tableWidthScale, 0, 0);
    leftLegs.current.position.lerp(targetLeftPosition, delta * ANIM_SPEED);

    const targetRightPosition = new Vector3(1.5 * tableWidthScale, 0, 0);
    rightLegs.current.position.lerp(targetRightPosition, delta * ANIM_SPEED);
  });
Enter fullscreen mode Exit fullscreen mode

Final result of the 3D table configurator

Yes! Our table configurator now works perfectly!

Conclusion

Congratulations you now have a great starting point to build a 3D product configurator using React Three Fiber and Material UI.

Live preview

The code is available here:
https://github.com/wass08/table-configurator-three-js-r3F-tutorial-final

I highly recommend you to read React Three Fiber documentation and check their examples to discover what you can achieve and how to do it.

For more React Three Fiber tutorials you can check my Three.js/React Three Fiber playlist on YouTube.

Thank you, don't hesitate to ask your questions in the comments section 🙏

Top comments (1)

Collapse
 
rockinrone profile image
Ronny Ewanek

Thanks for the lesson!

Just wanted to point out a typo: npx gltjfs public/models/Table.gltf

After npx it should be gltfjsx