Getting Creative with Shapes and Transformations 1.6

GETTING CREATIVE WITH SHAPES AND TRANSFORMATIONS

Welcome back! 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 the first chapter in this section that we’ll always choose the BufferGeometry version. We’ll cover the reasons for this in Section 8: Understanding 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 use some of the simpler geometries 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

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:

Here’s all the code we need to create this toy train:

function createMaterials() {

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

  // just as with textures, we need to put colors into linear color space
  body.color.convertSRGBToLinear();

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

  detail.color.convertSRGBToLinear();

  return {

    body,
    detail,

  };

}

function createGeometries() {

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

  const cabin = new THREE.BoxBufferGeometry( 2, 2.25, 1.5 );

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

  // we can reuse a single cylinder geometry for all 4 wheels
  const wheel = new THREE.CylinderBufferGeometry( 0.4, 0.4, 1.75, 16 );
  wheel.rotateX( Math.PI / 2 );


  return {
    nose,
    cabin,
    chimney,
    wheel,
  };

}

function createMeshes() {

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

  const materials = createMaterials();
  const geometries = createGeometries();

  const nose = new THREE.Mesh( geometries.nose, materials.body );
  nose.rotation.z = Math.PI / 2;

  nose.position.x = -1;

  const cabin = new THREE.Mesh( geometries.cabin, materials.body );
  cabin.position.set( 1.5, 0.4, 0 );

  const chimney = new THREE.Mesh( geometries.chimney, materials.detail );
  chimney.position.set( -2, 0.9, 0 );

  const smallWheelRear = new THREE.Mesh( geometries.wheel, materials.detail );
  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(

    nose,
    cabin,
    chimney,

    smallWheelRear,
    smallWheelCenter,
    smallWheelFront,
    bigWheel,

  );

}

Phew! It’s quite a bit. Not to worry though, it’s quite straightforward.

1. Setup

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

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

function createCamera() {

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

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

}

Setup our createGeometries, createMaterials and createMeshes functions

function createMaterials() {

  // we'll create a red materials for the body
  // and a dark grey material for the details here


}

function createGeometries() {

  // we'll create geometries for the nose, cabin, chimney, and wheels here

}

function createMeshes() {

  const materials = createMaterials();
  const geometries = createGeometries();

}

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 createMeshes function, and add empty createGeometries and createMaterials functions.

2. Introducing the Group Object

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

To start building our train, we’ll introduce a new object called a Group, which is a special object that we can use 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 createMeshes() {

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

  const materials = createMaterials();

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

Create materials for the body and the wheels

function createMaterials() {

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

  // just as with textures, we need to put colors into linear color space
  body.color.convertSRGBToLinear();

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

  detail.color.convertSRGBToLinear();

  return {

    body,
    detail,

  };

}

Creating materials should be 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 look0o This is especially useful for low-poly objects.

Go ahead and create two materials, a red body material for the main body piecess, and a dark grey details material for the wheels and chimney, and then we’ll start building our train.

Color Spaces Again

Just as with our texture in Ch 1.4: Introduction to Textures, in order for colors to end up looking correct once they have passed through the renderer and ended up on our screens, we’ll need to convert them to linear color space.

If you recall from way back in Ch 1.2: Lights! Color! Action!, when we pass in the in color parameter to a material, internally it’s creating a Color object, which we can then access in material.color. If we later want to change the material’s color to red, we can use material.color.set( 0xff3333 );.

When we do this, we are setting the color using the hex number 0xff3333, which corresponds to the CSS hex color #ff3333, a bright red. These colors are specified in sRGB color space, as we discussed in Chapter 1.4. If we care about accurate color presention in our final rendered image (and we should), then we’ll need to convert this sRGB red to linear red before passing it into the renderer.


const color = new THREE.Color( 0xff3333 );

color.convertSRGBToLinear();

In the case of our material, we can do do this after we have created the material, using material.color:


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

body.color.convertSRGBToLinear();

4. The Body

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

Now that we’ve created the materials, we can move on to creating the train’s body, which will consist of three pieces: the nose (a cylinder shape), the cabin (a box shape) and the chimney (a truncated cone 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, then move it into position

function createGeometries() {

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

  const cabin = new THREE.BoxBufferGeometry( 2, 2.25, 1.5 );

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

  // we can reuse a single cylinder geometry for all 4 wheels
  const wheel = new THREE.CylinderBufferGeometry( 0.4, 0.4, 1.75, 16 );
  wheel.rotateX( Math.PI / 2 );


  return {
    nose,
    cabin,
    chimney,
    wheel,
  };

}

function createMeshes() {

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

  const materials = createMaterials();
  const geometries = createGeometries();

  const nose = new THREE.Mesh( geometries.nose, materials.body );
  nose.rotation.z = Math.PI / 2;

  nose.position.x = -1;

  const cabin = new THREE.Mesh( geometries.cabin, materials.body );

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 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:

  1. radiusTop: the radius of the top of the cylinder.
  2. radiusBottom: the radius of the bottom of the cylinder. If we set this to a different value than radiusTop we’ll get a cone-like shape rather than a cylinder
  3. height: the height of the cylinder
  4. radialSegments: Finally, the radial segments parameter specifies the level of detail of the outer curve of the cylinder.

4.2 Position the Nose

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

function createGeometries() {

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

  const cabin = new THREE.BoxBufferGeometry( 2, 2.25, 1.5 );

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

  // we can reuse a single cylinder geometry for all 4 wheels
  const wheel = new THREE.CylinderBufferGeometry( 0.4, 0.4, 1.75, 16 );
  wheel.rotateX( Math.PI / 2 );


  return {
    nose,
    cabin,
    chimney,
    wheel,
  };

}

function createMeshes() {

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

  const materials = createMaterials();
  const geometries = createGeometries();

  const nose = new THREE.Mesh( geometries.nose, materials.body );
  nose.rotation.z = Math.PI / 2;

  nose.position.x = -1;

  const cabin = new THREE.Mesh( geometries.cabin, materials.body );
  cabin.position.set( 1.5, 0.4, 0 );

  const chimney = new THREE.Mesh( geometries.chimney, materials.detail );

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.

6. Create The Chimney

Create the train’s chimney

function createGeometries() {

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

  const cabin = new THREE.BoxBufferGeometry( 2, 2.25, 1.5 );

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

  // we can reuse a single cylinder geometry for all 4 wheels
  const wheel = new THREE.CylinderBufferGeometry( 0.4, 0.4, 1.75, 16 );
  wheel.rotateX( Math.PI / 2 );


  return {
    nose,
    cabin,
    chimney,
    wheel,
  };

}

function createMeshes() {

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

  const materials = createMaterials();
  const geometries = createGeometries();

  const nose = new THREE.Mesh( geometries.nose, materials.body );
  nose.rotation.z = Math.PI / 2;

  nose.position.x = -1;

  const cabin = new THREE.Mesh( geometries.cabin, materials.body );
  cabin.position.set( 1.5, 0.4, 0 );

  const chimney = new THREE.Mesh( geometries.chimney, materials.detail );
  chimney.position.set( -2, 0.9, 0 );

  const smallWheelRear = new THREE.Mesh( geometries.wheel, materials.detail );
  smallWheelRear.position.set( 0, -0.5, 0 );

The final piece of the trains body is the chimney. 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 truncated cone shape. 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.

5. The Wheels

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

Our train won’t get far without wheels!

We’ve created a material called detail, which we’ll use for the wheels as well as the chimney. Now, we need a geometry too, and since all the wheels are the same shape, we can create one single CylinderBufferGeometry and reuse it for all the wheels.

5.1 Create a Wheel Shaped Geometry

Create a cylinder geometry for the wheels and then rotate it

function createGeometries() {

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

  const cabin = new THREE.BoxBufferGeometry( 2, 2.25, 1.5 );

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

  // we can reuse a single cylinder geometry for all 4 wheels
  const wheel = new THREE.CylinderBufferGeometry( 0.4, 0.4, 1.75, 16 );
  wheel.rotateX( Math.PI / 2 );


  return {
    nose,
    cabin,
    chimney,
    wheel,
  };

}

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

As we mentioned above, we’re going to use a single CylinderBufferGeometry for all the wheels. When we created the nose and moved it into position, we did this:

  1. Create the cylinder geometry
  2. Create the nose mesh
  3. Rotate the nose 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 cylinder geometry
  2. Rotate the cylinder geometry
  3. Create 4 meshes, all using the rotated geometry

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

Wheel geometry's initial rotation

This time we want it to lie parallel to the $Z$-axis, so we’ll rotate it around the $X$-axis. Again, the rotation we need is 90 degrees, or $\frac{\pi}{2}$ radians.

Wheel geometry's final rotation

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

  const chimney = new THREE.Mesh( geometries.chimney, materials.detail );
  chimney.position.set( -2, 0.9, 0 );

  const smallWheelRear = new THREE.Mesh( geometries.wheel, materials.detail );
  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(

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

  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(

    nose,

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.

Add the Completed Pieces to the train group

Add everything at once to the train group

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

  train.add(

    nose,
    cabin,
    chimney,

    smallWheelRear,
    smallWheelCenter,
    smallWheelFront,
    bigWheel,

  );

}

All that remains is to add all the completed parts to the train group. We’ll do this in a single call to the train.add() method, and with that, our train is complete!

Final result

Hopefully, this chapter has shown you both the strengths and the 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 createMaterials, createGeometries, and createMeshes functions ran 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 13: Working With Other Applications expanding on this topic.