Getting started with interactive 3D

Getting started with interactive 3D

(note: write an intro about 3d design is getting more accessible, metaverse pushes for more spatial digital experiences, 3d on the web)

I was flirting with three JS for a while. Saved a couple of useful resources but haven't yet made time to start playing with it. Until I saw this tweet:

Step 1. Display, animate, interact with 3D objects

0:00
/

Let's begin with displaying a couple of 3D objects and start to animate them when one hovers over or clicks on them.

<Canvas>
    <ambientLight intensity={0.5} />
    <spotLight position={[10, 10, 10]} angle={0.15} penumbra={1} />
    <pointLight position={[-10, -10, -10]} />
    <motion.group>
        <motion.mesh
        position={[-1, 0, 0]}
        whileHover={{ scale: 1.5 }}
        whileTap={{ rotateY: 1.5 }}
        >
            <boxGeometry args={[1, 1, 1]} />
            <meshStandardMaterial color={"#A5CAF3"} />
        </motion.mesh>

        <motion.mesh
        position={[1, 0, 0]}
        whileHover={{ scale: 1.5 }}
        whileTap={{ rotateY: 2.5 }}
        >
            <torusGeometry args={[0.5, 0.2, 10, 50]} />
            <meshStandardMaterial color={"#A5CAF3"} />
        </motion.mesh>
    </motion.group>
</Canvas>  

What we have here is 2 geometry: a box a.k.a. boxGeometry and a doughnut called torusGeometry. Wrapped in an <Canvas></Canvas> alongside with 3 lights: an ambient,  a spot, and a point light. These are the basic Three.js parts.

The part which makes Framer Motion exciting is the combination of interactions (gestures) and animations. In order to use them, we need to turn parts of the elements into "motion" elements, like <motion.group/>, or <motion.mesh/>. Later we will use <motion.div/> too. In this example, I scale both objects to 1.5 times the size while hover happens and rotate the 3D objects when they are clicked or taped.

<motion.mesh whileHover={{ scale: 1.5 }} whileTap={{ rotateY: 1.5 }}/>

It is so delightful to get something interactive so quickly and the default spring animations are instantly giving everything a decent quality. Let's dive deeper into the ways we can manipulate these 3D geometries and explore more expressive interactions.

Step 2. Using props to control the Scene

0:00
/
  • There is now a "global" hovered and pressed state. When the cursor is moved over the scene, it changes the color of the background and both objects. It also slightly tilts both objects.
  • When the background is clicked, the colors are shifting again and objects tilt on another axis.
  • Hovering over the individual objects scales them up separately
  • Pressing each object will result in activating both the slight tilt from the background press and a further rotation on the clicked item

export default function App() {
  const [isHovered, setIsHovered] = useState(false);
  const [isPressed, setIsPressed] = useState(false);

  return (
    <div className="App">
      <motion.div
        className="ShapeContainer"
        variants={{
          rest: { background: "#F5F5F7" },
          hover: { background: "#FCE9E9" },
          press: { background: "#F9FADB" }
        }}
        whileTap="press"
        animate={isHovered ? "hover" : "rest"}
        onHoverStart={() => {
          setIsHovered(true);
        }}
        onHoverEnd={() => {
          setIsHovered(false);
        }}
        onTapStart={() => setIsPressed(true)}
        onTap={() => setIsPressed(false)}
        onTapCancel={() => setIsPressed(false)}
      >
        <Scene isHovered={isHovered} isPressed={isPressed} />
      </motion.div>
    </div>
  );
}

In the App.js we introduced 2 booleans: isHovered and isPressed . We will set them using Framer Morion gestures: onHoverStart will turn isHovered on, onHoverEnd will turn it off. And we can use onTapStart and onTapCancel to toggle isPressed.

To test it let's start using the variants of Framer Motion. Inside there, we can define animation states, in this example, we will control the background of the scene for rest, hover and press states.

whileTap="press"
animate={isHovered ? "hover" : "rest"}

And to make it work, we add the press state to the whileTap gesture and create a conditional "truthy" using our isHovered prop and if it is true the animation will so to the hover state and when it is false it will fall back to the rest.

Finally, we can pass these props through the Scene.

<Scene isHovered={isHovered} isPressed={isPressed} />

Let's take a look at the 3D scene.

export function Scene({ isHovered, isPressed }) {
  return (
    <Canvas>
      <ambientLight intensity={0.5} />
      <spotLight position={[10, 10, 10]} angle={0.15} penumbra={1} />
      <pointLight position={[-10, -10, -10]} />
      <motion.group>
        <motion.mesh
          variants={{
            hover: { rotateX: 0.5, rotateY: 0.5, rotateZ: isPressed ? 0.5 : 0 },
            rest: { rotateX: 0.25, rotateY: 1, rotateZ: 0 }
          }}
          position={[-1, 0, 0]}
          whileHover={{ scale: 1.5 }}
          whileTap={{ rotateY: 1.5 }}
          animate={isHovered ? "hover" : "rest"}
        >
          <boxGeometry args={[1, 1, 1]} />
          <meshStandardMaterial
            color={isHovered ? (isPressed ? "#F5F6C0" : "#F9D2D2") : "#A5CAF3"}
          />
        </motion.mesh>
        <motion.mesh
          variants={{
            hover: { rotateY: 0.5, rotateX: isPressed ? 0.5 : 0 },
            rest: { rotateY: 0 }
          }}
          position={[1, 0, 0]}
          whileHover={{ scale: 1.5 }}
          whileTap={{ rotateY: 2.5 }}
          animate={isHovered ? "hover" : "rest"}
        >
          <torusGeometry args={[0.5, 0.2, 10, 50]} />
          <meshStandardMaterial
            color={isHovered ? (isPressed ? "#F5F6C0" : "#F9D2D2") : "#A5CAF3"}
          />
        </motion.mesh>
      </motion.group>
    </Canvas>
  );
}

I used 3 different methods to interact with the scene:

  • Using the isHovered and isPressed passed props to select the variant. Also inside the variants to differentiate between hovered and pressed states. This is good for things I want to animate based on the interaction with the Scene container.
  • Using Framer Motion's gestures whileHover and whileTap to scale and rotate the objects individually.
  • Using the isHovered and isPressed passed props to modify the color of the objects. Unfortunately, that is not possible directly from the <motion.mesh> yet so the color is controlled inside the <meshStandardMaterial/> in this example.

Let's bring 3D models in

0:00
/
Utah teapot, Stanford bunny, Suzanne 

I used the Utah teapot, the Stanford bunny, and Suzanne as standard reference objects. By the way, did you know that Suzanne was named after the orangutan in Jay and Silent Bob Strike Back?

Blender is a free tool where you can create, modify, export 3D models and 3D scenes. For this example, I used Blender to scale the models, adjust polygon numbers to make the file size smaller thus faster to load, and apply different colors by changing the material properties. Following the official recommendation of the three.js doc, I exported the models separately into .GLB format.

After uploading the files into the ./assets/ folder, we can start by adding the libraries that will let us import these 3D models into our scene.

import React, { useMemo, Suspense } from "react";

import { Canvas, useLoader } from "@react-three/fiber";

import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";

import SuzanneUrl from "./assets/Suzanne_red.glb";
import TeapotUrl from "./assets/Utah_teapot_white.glb";
import BunnyUrl from "./assets/Stanford_Bunny_Yellow_Lowpoly.glb";

Next, we will add load the models as separate functions:

function Suzanne({ position, ...props }) {
  const obj = useLoader(GLTFLoader, SuzanneUrl);

  const scene = useMemo(() => {
    return obj?.scene?.clone(true);
  }, [obj]);

  return <primitive object={scene} position={position} {...props} />;
}

And add these functions to our Scene:

<Canvas>
    <ambientLight intensity={0.5} />
    <spotLight position={[10, 10, 10]} angle={0.15} penumbra={1} />
    <pointLight position={[-10, -10, -10]} />
    <motion.group>
		<motion.group>
    		<Suspense fallback={null}>
        		<Suzanne />
        	</Suspense>
		</motion.group>
	</motion.group>
</Canvas>

We packaged <Suzanne/> into a <Suspense/> and a dedicated <motion.group/>. Suspense for Data Fetching is a React feature that lets you also use <Suspense> to declaratively “wait” for anything else, including data. And we wrapped these into a Framer motion.group to add gestures and animations separately to each model.

You can see there is no <mashStandardMaterial/> to define or change the color of the model. For imported models, I could not find an easy way to do it programmatically, but you can do it in any 3D tool, in my case in Blender.

Basic geometries

If you don't wanna spend time on 3D modeling and start exploring what you can do with interactive 3D, you can find a series of geometries ready to use in three.js. The full list is here.

I made a quick demo to show what each looks like.

0:00
/