Get Started With ThreeJS

Recently, I used ThreeJS and it was really fun. Today, I'll teach you how to get started with it, with a fun (and simple) tutorial.

Three.js is a cross-browser JavaScript library and application programming interface (API) used to create and display animated 3D computer graphics in a web browser using WebGL.

Here's is the finished product:
rotating plane

I also made a demo of the finished product.

Table of Contents

I'm going to do this in React, but most of this stuff should apply for plain HTML CSS and JS. The ThreeJS docs have a really nice starter guide to get you up and running with vanilla JS, so do check it out. If you haven't done React before, I would suggest watching this video by Aaron Jack to get you started as fast as possible.

React Setup

Anyways, let's initialize a React project. If you want, you could also use something like NextJS, but I'm sticking to Create React App for now.

I'm using yarn to initialize my project, so here are the two commands (one with npm and the other with yarn) to create a React project.

npm: npx create-react-app threejs-learning
yarn yarn create react-app threejs-learning

And yes, as explained by the React docs, npx is not a typo (it's something to run scripts that comes with npm).

When you initialize the project, you'll see something like this:
New Project Structure

Disregard this for now (we'll deal with the unnecessary files later). What you should do is start the server so you can see what the boilerplate looks like.

To do that, run the command that corresponds to what you initalized the project with:
yarn: yarn start
npm: npm run start

This will open up a browser tab at http://localhost:3000 and you'll see something like this:
Starter template

Great job you now have a React project set up!

Back to your editor now. In the src folder, you'll see these files: src folder structure

Here, you can delete App.css, App.test.js, index.css, logo.svg, reportWebVitals.js, and setupTests.js

Shoot! If you look back at the browser tab, we encounter an error:
Files not found error

If you take a look back at the App.js and index.js files in your editor, you'll see that they are importing some of the files we deleted, thus resulting in an error:

App.js:

import logo from "./logo.svg";
import "./App.css";

index.js

import "./index.css";
import reportWebVitals from "./reportWebVitals";

The solution is simple and requires only a couple keys. Just delete those lines from each file 🤪.

But some further works still needs to be done. Our code is still using the stuff we imported.

In index.js, after deleting the imports, your file should look like this:

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

We need to delete the reportWebVitals(); and the <React.StrictMode> since we removed the imports for that.

This is index.js after those changes:

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

ReactDOM.render(<App />, document.getElementById("root"));

Now, let's fix App.js. This is how it should look like right now:

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
}

export default App;

Just delete everything in the return(); function and replace it with a simple <h1>I love React!</h1>. This is how it should look:

function App() {
  return <h1>I love React!</h1>;
}

export default App;

Ok great, now we have all the bloat out of our way. Note that this stuff we deleted can be important if you have a big project, but for now it can be discarded since this a learning project.

Now, if you save it, you should see this in the browser: Result after deleting files

ThreeJS Setup

So now we get to interact with ThreeJS. To get started, install it:

yarn: yarn add three
npm: npm install three

Okay, now go into your App.js file and import ThreeJS like this:

import * as THREE from "three";

Then, change your <h1>I love React!</h1> to <canvas id="bg"></canvas> in the return function of the component. This is so that ThreeJS has something to attach itself on and do its work.

At this point, we're going to have to do a clever "hack" if you'll call it that. Since the JavaScript loads before the JSX (JSX is the code that looks like HTML), our code is unable to reference the canvas element if placed before the return statement.

We're going to have to use something called useEffect so that ThreeJS runs after the first render and we can access the canvas element.

Import useEffect with

import { useEffect } from "react";

and insert

useEffect(() => {}, []);

above the return statement. Here, the empty array as the second argument indicates for the useEffect hook to only run on the first render, not repeatedly after each one. Traditionally, you would put variable name(s) there so that useEffect would run after those variable(s) changed, but we only want it to run after the first render.

Now, in the useEffect, create a new ThreeJS scene (this scene is where everything will show up):

useEffect(() => {
  const scene = new THREE.Scene();
}, []);

Now we have to create a camera. Add a camera with

useEffect(() => {
  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
  );
}, []);

This might be a bit overwhelming, but let me break it down. The first parameter (the 75) is the FOV of the camera. FOV (aka Field of View) is basically how much the camera can see.

Think of it like this pizza:
Pizza without slice

The angle of the missing slice is how much the camera can see. The higher the angle, the more it can see. However, if it's too high, you can get results that don't look right.

The second parameter is for the aspect ratio of the view. This is basically the ratio of the width : height, and I've done so with the space of the page using the window object. The next two parameters are how near and far the camera can view objects.

Next, we have to create a renderer. Below the camera, create a renderer and set the background of the scene:

useEffect(() => {
  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
  );

  const renderer = new THREE.WebGL1Renderer({
    canvas: document.querySelector("#bg"),
  });
  scene.background = new THREE.Color(0x4e9fe5);
}, []);

The canvas option allows ThreeJS to latch itself on an element in the DOM. The scene.background enables us to create a color with the #4e9fe5 hex code (which will be our sky).

Next, add the following code:

renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
camera.position.set(10, 2, 0);
renderer.render(scene, camera);

The first line sets the pixel ratio, while the second sets the dimensions for the renderer. The third line sets the position for the camera (with the x, y, and z axes, respectively). The last line renders the scene with the camera we made above.

Now, let's make some lights:

const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444);
hemiLight.position.set(0, 20, 0);
scene.add(hemiLight);

const dirLight = new THREE.DirectionalLight(0xffffff);
dirLight.position.set(-3, 10, -10);
scene.add(dirLight);

const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
ambientLight.position.set(0, 0, 0);
scene.add(ambientLight);

The first chunk of code creates a new light, creates a gradient (from colors white to grey) of light from the top to the bottom of the scene. To wrap your head around this concept, I would suggest this interactive playground online) We then set the position of the light (with the xyz axes) and add it to the scene.

The second chunk sets up a directional light, which is like a traditional light source (it illuminates from a point). We set its color to white, set its position, and add it to the scene.

The ambient light is basically a light that is illuminating from everywhere in your scene. Think of your scene being put in a ball, which is then illuminated from the inside. We then set it's position to the center of the scene and add it.

Ok so now we have a basic scene set up. It should look like this:
Scene without anything

We need to get a model in the scene now, to make it interesting. I would suggest going to poly.pizza and getting a model. I am using this airplane, (Attribution for model: Small Airplane by Vojtěch Balák CC-BY via Poly Pizza) but I highly recommend you to use any model you want. Download the .glb format of the object with this button:
Download Button with .obj and .glb files

Once downloaded, at this .glb file to the public/ folder in the root of your project.

At the top of your code, add this to import the GLTFLoader:

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

Next, add this code under the ambient light code to import the model:

const loader = new GLTFLoader();

loader.load("/NAME_OF_FILE.glb", function (gltf) {
  gltf.scene.scale.set(0.8, 0.8, 0.8);
  scene.add(gltf.scene);
});

The first line creates a new loader object, so we can load the file. The loader.load part loads the actual model. The first argument is the .glb file. The /NAME_OF_FILE.glb accesses it from the public/ folder, which is replaced by the name of your .glb file. The second argument is a function that has the resulted model as a varible. We can access the proper model with gltf.scene, hence why we are adding that to our scene instead of just gltf.

Inside the function, I am scaling the model down to 80% of its original size since it was way too big for the viewport. Note that this is optional based on how good your model looks. It might even be small, so you can scale it up in that case.

Next, we have to add an animate() function. This basically just re-renders our ThreeJS scene constantly. To do that, just create a function like so:

function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

animate();

The first line inside of the function acts like a loop (the actual term is recursion). It calls the animate function again inside of itself, so it keeps re-rendering. The next line renders the scene and the camera again. We call the function outside of itself so it can start.

But wait a minute, nothing shows up in the browser! It's just a blue background! That's because we didn't add controls for the scene. ThreeJS doesn't work without these controls, hence why they are necessary.

To put them in the scene, import:

import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

and add this above the function animate() stuff:

const controls = new OrbitControls(camera, renderer.domElement);

This creates a new object called controls, which is made from the OrbitControls class. The constructor of the OrbitControls has a camera (which we previously defined), and the domElement to put the controls in (which we set in the creation of the renderer with canvas: document.querySelector("#bg");

Now, you should see this in the browser!:
Plane in the scene

You can even interact with it by dragging using your left mouse button, scrolling to zoom in, and using right click to move the camera.

The only problem with this is that when you resize the window, it becomes really, REALLY distorted:
distorted plane

This is definitely not what we want, so let's change that. Above the place where you defined your animate function, create a function like so:

const resizeWindow = () => {
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(window.devicePixelRatio);
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.render(scene, camera);
};

Here, we are updating the renderer dimension data. First, we set the new width and height. Then we set the new pixel ratio (this probably will not change but we're setting it just in case). Next, we change the aspect ratio of the camera to the new width and height. We then update the camera's view and re-render the scene.

If you check the browser again and resize it, you'll see that now this happens:
width and height not changing after function creation

This is happening because we haven't added called the function at all yet. To do so, add this after your resizeWindow function:

window.addEventListener("resize", resizeWindow);

This line of code adds an event listener to the window object, and calls the resizeWindow function whenever the window is resized.

Now the plane is not distorted anymore!
Undistorted plane

We have the model loaded, but we should add some auto-rotation to make it look cool. To do that, add this in the function:

controls.autoRotate = true;
controls.autoRotateSpeed = 4.0;
controls.update();

This essentially enables auto rotation, multiplies the speed by 4, and updates the controls to make it spin. If you want a laugh, change the autoRotateSpeed to something like 1000.0 and watch it go crazy 🤣.

In the end, your App.js should look something like this:

import * as THREE from "three";
import { useEffect } from "react";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";

function App() {
  useEffect(() => {
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(
      75,
      window.innerWidth / window.innerHeight,
      0.1,
      1000
    );

    const renderer = new THREE.WebGL1Renderer({
      canvas: document.querySelector("#bg"),
    });

    scene.background = new THREE.Color(0x4e9fe5);

    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.setSize(window.innerWidth, window.innerHeight);
    camera.position.set(10, 2, 0);
    renderer.render(scene, camera);

    const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444);
    hemiLight.position.set(0, 20, 0);
    scene.add(hemiLight);

    const dirLight = new THREE.DirectionalLight(0xffffff);
    dirLight.position.set(-3, 10, -10);
    scene.add(dirLight);

    const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
    ambientLight.position.set(0, 0, 0);
    scene.add(ambientLight);

    const controls = new OrbitControls(camera, renderer.domElement);

    const loader = new GLTFLoader();

    loader.load("/small-airplane-v3.glb", function (gltf) {
      gltf.scene.scale.set(0.8, 0.8, 0.8);
      scene.add(gltf.scene);
    });

    const resizeWindow = () => {
      renderer.setSize(window.innerWidth, window.innerHeight);
      renderer.setPixelRatio(window.devicePixelRatio);
      camera.aspect = window.innerWidth / window.innerHeight;
      camera.updateProjectionMatrix();
      renderer.render(scene, camera);
    };

    window.addEventListener("resize", resizeWindow);

    function animate() {
      requestAnimationFrame(animate);

      controls.autoRotate = true;
      controls.autoRotateSpeed = 4.0;

      controls.update();

      renderer.render(scene, camera);
    }

    animate();
  }, []);

  return <canvas id="bg"></canvas>;
}

export default App;

That's it! Now you're up and running with ThreeJS. This is a beginner tutorial and there is a bunch of stuff I didn't cover, so check out the ThreeJS docs and examples. If you've followed along with this tutorial, choose another model and send a picture in the comments section so you can share your work!

The full code is in a repository on GitHub:

GitHub logo ShubhamPatilsd / threejs-learning

Code for the tutorial for ThreeJS!

If you liked this post, the three shiny buttons on the left are waiting to be clicked, and if you didn't like the post, they still are open to clicking.

Oh yeah, and I also have a Twitter now (very exciting stuff). If you enjoy my blogs, do follow me as I share my thoughts about programming there as well (but more frequently). Follow me at: https://twitter.com/ShubhamPatilsd

18