Getting Creative with Shapes and Transformations 1.6

GETTING CREATIVE WITH SHAPES AND TRANSFORMATIONS

In this chapter, we’ll explore some of the geometric shapes that are built into three.js. We’ve already seen one of them, our trusty BoxBufferGeometry, which we’ve used in every chapter so far.

Using some of these shapes, we’ll take a look at how to move things around in our scene (translate them), scale them up and down, and rotate them. Collectively these are known as transformations.

We’ll also introduce an object called Group, which is used for organizing our scene and grouping objects together. Groups hold a position in the scene but are otherwise invisible.

As usual, we’ll pick up where we left off in the last chapter:

The Built-In Geometries

All the built-in geometries, 20 in total, are listed in the docs and come in two flavors: Geometry, and BufferGeometry. Remember from Chapter 1.1 that you will always be choosing the BufferGeometry version. We’ll cover the reasons for this in Section 6: Exploring Geometry.

Take a few minutes to look through the docs and explore the options available to you now (search for “BufferGeometry”).

Over the rest of this chapter, we’ll take a couple of the simpler geometries and use them like Lego blocks to build a toy train.

A Simple Toy Train Model

  1. Setup
  2. Introducing The Group object
  3. The Materials
  4. The Body
  5. The Wheels
  6. The Chimney

Our final code for creating the toy train

  scene.add( frontLight, backLight );

}

function initMeshes() {

  // create a Group to hold the pieces of the train
  const train = new THREE.Group();
  scene.add( train );

  const bodyMaterial = new THREE.MeshStandardMaterial( {
    color: 0xff3333, // red
    flatShading: true,
  } );

  const detailMaterial = new THREE.MeshStandardMaterial( {
    color: 0x333333, // darkgrey
    flatShading: true,
  } );

  const noseGeometry = new THREE.CylinderBufferGeometry( 0.75, 0.75, 3, 12 );
  const nose = new THREE.Mesh( noseGeometry, bodyMaterial );
  nose.rotation.z = Math.PI / 2;

  nose.position.x = -1;

  const cabinGeometry = new THREE.BoxBufferGeometry( 2, 2.25, 1.5 );
  const cabin = new THREE.Mesh( cabinGeometry, bodyMaterial );
  cabin.position.set( 1.5, 0.4, 0 );

  train.add( nose, cabin );

  const wheelGeo = new THREE.CylinderBufferGeometry( 0.4, 0.4, 1.75, 16 );
  wheelGeo.rotateX( Math.PI / 2 );


  const smallWheelRear = new THREE.Mesh( wheelGeo, detailMaterial );
  smallWheelRear.position.set( 0, -0.5, 0 );

  const smallWheelCenter = smallWheelRear.clone();
  smallWheelCenter.position.x = -1;

  const smallWheelFront = smallWheelRear.clone();
  smallWheelFront.position.x = -2;

  const bigWheel = smallWheelRear.clone();
  bigWheel.scale.set( 2, 2, 1.25 );
  bigWheel.position.set( 1.5, -0.1, 0 );

  train.add( smallWheelRear, smallWheelCenter, smallWheelFront, bigWheel );

  const chimneyGeometry = new THREE.CylinderBufferGeometry( 0.3, 0.1, 0.5 );
  const chimney = new THREE.Mesh( chimneyGeometry, detailMaterial );
  chimney.position.set( -2, 0.9, 0 );

  train.add( chimney );

}

function initRenderer() {

Let’s get creative! With the basic three.js geometries, we have an infinite box of stretchy, squishy Lego at our disposal. We’ll use them to build a toy train just like this one:

1. Setup

  1. Setup
  2. Introducing The Group object
  3. The Materials
  4. The Body
  5. The Wheels
  6. The Chimney

Position the camera to give a birds-eye view of the scene

function initCamera() {

  camera = new THREE.PerspectiveCamera( 35, container.clientWidth / container.clientHeight, 0.1, 100 );

  camera.position.set( -5, 5, 7 );

}

Clear everything from the initMeshes function

function initMeshes() {

  // just waiting for your beautiful creation

}

As usual, we need to do some housekeeping before we get started. First of all, we’ll move our camera 5 units left along the $X$-axis and 5 units up along the $Y$-axis to give ourselves a birds-eye overview of our scene.

Note that since we are using OrbitControls, the camera is automatically pointed at the OrbitControls.target, which by default is located at $(0,0,0)$.

Next, delete everything from the initMeshes function. For the rest of this chapter, we’ll focus exclusively on this function.

2. Introducing the Group Object

  1. Setup
  2. Introducing The Group object
  3. The Materials
  4. The Body
  5. The Wheels
  6. The Chimney
Group in the scene graph

To start building our train, we’ll introduce a new object called a Group, which is a special object used to organize our scene.

Groups occupy a position in the scene and can have children, but they are invisible themselves.

When we move a group around, all of its children will move too. Likewise, if we rotate or scale it, all of its children will be rotated or scaled too. The children can still be still be moved independently as well, of course.

This is exactly the way things behave in the real world - a car consists of a body, wheels, an engine, windows, and so on, and when you move the car they all move with it. But you can still rotate the wheels, open the doors and roll down the windows.

If the scene represents the entire universe, then a group represents a single object within that universe, such as a toy train.

2.1 Create a Group

Create a Group to hold the pieces of the train

function initMeshes() {

  // create a Group to hold the pieces of the train
  const train = new THREE.Group();
  scene.add( train );

  const bodyMaterial = new THREE.MeshStandardMaterial( {

Just like the Scene constructor, the Group constructor doesn’t take any parameters.

However, we’ll give it a descriptive name, rather than just calling it “group”. Generally, you’ll want to name your groups after the object they represent, so in this case, we’ll name our group train.

Next, we’ll add the train group to the scene using scene.add().

Group also has Group.add and Group.remove methods for adding and removing children, so from now on we’ll add the pieces of the train to our train using train.add().

3. The Materials

  1. Setup
  2. Introducing The Group object
  3. The Materials
  4. The Body
  5. The Wheels
  6. The Chimney

Create materials for the body and the wheels

  scene.add( train );

  const bodyMaterial = new THREE.MeshStandardMaterial( {
    color: 0xff3333, // red
    flatShading: true,
  } );

  const detailMaterial = new THREE.MeshStandardMaterial( {
    color: 0x333333, // darkgrey
    flatShading: true,
  } );

  const noseGeometry = new THREE.CylinderBufferGeometry( 0.75, 0.75, 3, 12 );

Creating materials should be becoming familiar to you now by now. We’ll continue to use the MeshStandardMaterial along with our lighting setup from the previous chapter. However, we will introduce one new parameter here: Material.flatShading.

This is a Boolean parameter, meaning that we can set it to false (the default) or true. When we turn it on the material will have a faceted look, which can be used to give an object a carved or faceted look. This is especially useful for low-poly objects without much detail.

Go ahead and create two materials, a red bodyMaterial, and a dark grey detailsMaterial for the wheels and chimney, and then we’ll start building our train.

4. The Body

  1. Setup
  2. Introducing The Group object
  3. The Materials
  4. The Body
  5. The Wheels
  6. The Chimney

The train’s body will consist of just two pieces, the nose (a cylinder shape), and the cabin (a box shape). We’ll use a new geometry called CylinderBufferGeometry, along with our trusty BoxBufferGeometry, to create these.

4.1 Create the Nose

Create the train’s nose

  const detailMaterial = new THREE.MeshStandardMaterial( {
    color: 0x333333, // darkgrey
    flatShading: true,
  } );

  const noseGeometry = new THREE.CylinderBufferGeometry( 0.75, 0.75, 3, 12 );
  const nose = new THREE.Mesh( noseGeometry, bodyMaterial );
  nose.rotation.z = Math.PI / 2;

  nose.position.x = -1;

  const cabinGeometry = new THREE.BoxBufferGeometry( 2, 2.25, 1.5 );
  const cabin = new THREE.Mesh( cabinGeometry, bodyMaterial );
  cabin.position.set( 1.5, 0.4, 0 );

  train.add( nose, cabin );

If you take a look at the CylinderBufferGeometry docs page, you’ll see that the constructor for this geometry takes quite a few parameters. Fortunately, the docs page has an interactive example where you can play around with the parameters to see what each of them does. Most of the docs pages for materials and geometries have similar interactive examples.

We need to set the first four parameters here, and we can omit the last four to leave them at their default values:

4.2 Position the Nose

Create the train’s nose

  const detailMaterial = new THREE.MeshStandardMaterial( {
    color: 0x333333, // darkgrey
    flatShading: true,
  } );

  const noseGeometry = new THREE.CylinderBufferGeometry( 0.75, 0.75, 3, 12 );
  const nose = new THREE.Mesh( noseGeometry, bodyMaterial );
  nose.rotation.z = Math.PI / 2;

  nose.position.x = -1;

  const cabinGeometry = new THREE.BoxBufferGeometry( 2, 2.25, 1.5 );
  const cabin = new THREE.Mesh( cabinGeometry, bodyMaterial );
  cabin.position.set( 1.5, 0.4, 0 );

  train.add( nose, cabin );

Next, we need to position (technically transform) the cylinder so that it’s rotated parallel to the $X$-axis, and then move (technically translate it) into the correct position.

The three.js coordinate system

Take a look at the three.js coordinate system again, and try to visualize the transformations that we making in the code as we go.

Note that the default direction of rotation in three.js is anti-clockwise.

CylinderBufferGeometry's initial rotation

By default, the cylinder is created pointing upwards like a tree trunk, which means that it is oriented along the $Y$-axis.

The cylinder after rotation

We want it to lie along the $X$-axis instead, so we’ll need to rotate it by 90 degrees around the $Z$-axis.

Simple right? We can just set nose.rotation.z = 90.

Not so fast!

You see, angles in three.js are specified in radians, not degrees, with the notable exception of the PerspectiveCamera.fov (Field of View), as we saw in Chapter 1.1.

Fortunately, it’s easy to convert between radians and degrees and it so happens that $90^{\circ} = \frac{\pi}{2}$ radians, so we can rotate the cylinder using nose.rotation.z = Math.PI / 2.

Finally, we will move our rotated cylinder 1 unit to the left along the $X$-axis by settings nose.position.x = -1, and with that, our nose is in place!

4.3 Create the Cabin

Create the train’s cabin

  const detailMaterial = new THREE.MeshStandardMaterial( {
    color: 0x333333, // darkgrey
    flatShading: true,
  } );

  const noseGeometry = new THREE.CylinderBufferGeometry( 0.75, 0.75, 3, 12 );
  const nose = new THREE.Mesh( noseGeometry, bodyMaterial );
  nose.rotation.z = Math.PI / 2;

  nose.position.x = -1;

  const cabinGeometry = new THREE.BoxBufferGeometry( 2, 2.25, 1.5 );
  const cabin = new THREE.Mesh( cabinGeometry, bodyMaterial );
  cabin.position.set( 1.5, 0.4, 0 );

  train.add( nose, cabin );

The cabin is just a rectangular box, so we can use our now familiar BoxBufferGeometry to create it. We’ll make a box 2 units wide ($X$-axis), 2.25 units high ($Y$-axis), and 1.5 units deep ($Z$-axis).

Then we’ll move it into position: 1.5 units to the right along the $X$-axis, 0.4 units up on the $Y$-axis, and we’ll leave it at 0 on the $Z$-axis.

Remember, units in three.js are meters.

4.4 Add the Body Pieces to the train Group

Add the nose and cabin to the train

  const nose = new THREE.Mesh( noseGeometry, bodyMaterial );
  nose.rotation.z = Math.PI / 2;

  nose.position.x = -1;

  const cabinGeometry = new THREE.BoxBufferGeometry( 2, 2.25, 1.5 );
  const cabin = new THREE.Mesh( cabinGeometry, bodyMaterial );
  cabin.position.set( 1.5, 0.4, 0 );

  train.add( nose, cabin );

  const wheelGeo = new THREE.CylinderBufferGeometry( 0.4, 0.4, 1.75, 16 );

Now that we have created the pieces, we need to add them to our train. We’ll do this in the same way that we previously added objects to our scene, substituting train for scene, to give us train.add().

We’ll add both pieces in a single line: train.add( nose, cabin ).

So far, we have been adding a single object at a time, however, we can add any number of objects at once using .add:


scene.add( object1, object2, object3, object4, object5 );

5. The Wheels

  1. Setup
  2. Introducing The Group object
  3. The Materials
  4. The Body
  5. The Wheels
  6. The Chimney

Create the wheels

  train.add( nose, cabin );

  const wheelGeo = new THREE.CylinderBufferGeometry( 0.4, 0.4, 1.75, 16 );
  wheelGeo.rotateX( Math.PI / 2 );


  const smallWheelRear = new THREE.Mesh( wheelGeo, detailMaterial );
  smallWheelRear.position.set( 0, -0.5, 0 );

  const smallWheelCenter = smallWheelRear.clone();
  smallWheelCenter.position.x = -1;

  const smallWheelFront = smallWheelRear.clone();
  smallWheelFront.position.x = -2;

  const bigWheel = smallWheelRear.clone();
  bigWheel.scale.set( 2, 2, 1.25 );
  bigWheel.position.set( 1.5, -0.1, 0 );

  train.add( smallWheelRear, smallWheelCenter, smallWheelFront, bigWheel );

  const chimneyGeometry = new THREE.CylinderBufferGeometry( 0.3, 0.1, 0.5 );

Our train won’t get far without wheels!

We have already created the detailMaterial, which we’ll use for the wheels, so now we need a geometry that we can reuse for all four wheels too. Just like with the nose we’ll use a CylinderBufferGeometry.

5.1 Create a Wheel Shaped Geometry

Create a cylinder geometry for the wheels

  train.add( nose, cabin );

  const wheelGeo = new THREE.CylinderBufferGeometry( 0.4, 0.4, 1.75, 16 );
  wheelGeo.rotateX( Math.PI / 2 );


  const smallWheelRear = new THREE.Mesh( wheelGeo, detailMaterial );

We want the top and the bottom of the cylinder to have the same radius, so a value of $0.4$ will work nicely for the first two parameters.

We want to create a single long cylinder that will stretch under the train to create the left and right wheels at the same time. Not especially realistic, perhaps, but this is just a toy train after all! Pass in $1.75$ as the third parameter for the height of the cylinder.

Finally, we’ll use a value of $16$ for the radialSegments, which will give us enough detail to make the wheels look round while still showing the faceted look of the material with flatShading: true.

5.2 Rotate the Wheel Geometry

Rotate the wheel geometry so that we can reuse it for all the wheels

  train.add( nose, cabin );

  const wheelGeo = new THREE.CylinderBufferGeometry( 0.4, 0.4, 1.75, 16 );
  wheelGeo.rotateX( Math.PI / 2 );


  const smallWheelRear = new THREE.Mesh( wheelGeo, detailMaterial );
Wheel geometry's initial rotation

Again, the cylinder we created for the wheels points upwards, along the $Y$-axis.

Wheel geometry's final rotation

We’ll need to rotate this one by 90 degrees, or $\frac{\pi}{2}$ radians, as well, although this time we want it to lie parallel to the $Z$-axis, so we’ll rotate it around the $X$-axis.

Last time, when we rotated the nose, we did this:

  1. Create the geometry
  2. Create the mesh
  3. Rotate the mesh

This time, since we’re going to reuse the geometry to create all four of the wheels, we’ll rotate the geometry instead:

  1. Create the geometry
  2. Rotate the geometry
  3. Create the mesh

If you look at the BufferGeometry docs page, you’ll see that there are a number of methods for applying transformations directly to the geometry. The one we want is called BufferGeometry.rotateX, so go ahead and add the above line and then our geometry will be ready for use.

5.3 Create the Small Wheels

Using the rotated geometry, we’ll create the three small wheels at the front of the train

  wheelGeo.rotateX( Math.PI / 2 );


  const smallWheelRear = new THREE.Mesh( wheelGeo, detailMaterial );
  smallWheelRear.position.set( 0, -0.5, 0 );

  const smallWheelCenter = smallWheelRear.clone();
  smallWheelCenter.position.x = -1;

  const smallWheelFront = smallWheelRear.clone();
  smallWheelFront.position.x = -2;

  const bigWheel = smallWheelRear.clone();

Now that we have our rotated geometry, we can use it to create the wheels for our train.

Up to now, we’ve been creating objects by making one geometry, one material and one mesh for each. But since we’re using the same geometry and material for all four wheels, we’ll introduce a new method called Mesh.clone that will allow us to create an exact copy of a mesh.

We’ll create one wheel that will serve as a prototype and then clone that to create the rest.

Create and position the smallWheelRear in the normal way - first, create the mesh, and then move it into position using the position.set method. This wheel is our prototype.

Next, we’ll create the smallWheelCenter and smallWheelFront using smallWheelRear.clone().

The only difference between the small wheels is their position on the $X$-axis, so after cloning, update position.x on the new meshes.

5.4 Create the Large Rear Wheel

Create the rear wheel using clone and scale

  smallWheelFront.position.x = -2;

  const bigWheel = smallWheelRear.clone();
  bigWheel.scale.set( 2, 2, 1.25 );
  bigWheel.position.set( 1.5, -0.1, 0 );

  train.add( smallWheelRear, smallWheelCenter, smallWheelFront, bigWheel );

To create the large rear wheel, once again clone the prototype small wheel and move it into position.

This time though, we’ll need to perform a new transformation that we have not seen so far: scale.

Just like Mesh.position, Mesh.scale is also stored in a Vector3 so we can use all the same methods for updating the scale that we can use for the position.

We want to double the radius of the wheel, and increase the length a bit, so we’ll set a scale of $(2, 2, 1.25)$ - that is, scale to double size along the $X$ and $Y$ axes, and scale by $1.25$ along the $Z$-axis.

5.5 Add the Wheels to the Train

Add all of the wheels to the train

  bigWheel.position.set( 1.5, -0.1, 0 );

  train.add( smallWheelRear, smallWheelCenter, smallWheelFront, bigWheel );

  const chimneyGeometry = new THREE.CylinderBufferGeometry( 0.3, 0.1, 0.5 );

All that remains is to add the wheels to the train. Once again, we’ll do this in a single line using train.add().

With all the wheels added, our train just needs one final finishing touch - a chimney!

6. The Chimney

  1. Setup
  2. Introducing The Group object
  3. The Materials
  4. The Body
  5. The Wheels
  6. The Chimney

Create the chimney

  train.add( smallWheelRear, smallWheelCenter, smallWheelFront, bigWheel );

  const chimneyGeometry = new THREE.CylinderBufferGeometry( 0.3, 0.1, 0.5 );
  const chimney = new THREE.Mesh( chimneyGeometry, detailMaterial );
  chimney.position.set( -2, 0.9, 0 );

  train.add( chimney );

}

function initRenderer() {

Once again we’ll use a CylinderBufferGeometry for this part of the train. However, this time we are going to taper it to create a cone-shaped. The first parameter we pass into the constructor defines the radius of the top of the cylinder, and the second parameter defines to bottom radius - to create an upside-down cone we’ll pass in a bigger number for the top radius.

No need to rotate the geometry this time, it’s already in the correct orientation. We just need to translate it along the $X$ and $Y$ axes. Again, we’ll do this in a single step using position.set.

Finally, .add the chimney to the train, and we are done!

Final result

Hopefully, this chapter has shown you both the strengths and limitations of the built-in three.js geometries.

Creating a geometric toy train was fairly easy, but we would find it hard to create something complex or organic like a human face using just these built-in geometries.

Also, even for this simple model, our initMeshes function runs to fifty lines or so. three.js is clearly not designed to be a modeling program, and we should not treat it as such.

To create complex, animated models, we will need to use an external modeling program. In the next chapter, we’ll take a brief look at how to load models that were created in this way, and later we’ll spend all of Section 10: Working With Other Applications expanding on this topic.