Organizing Your Scenes
In every chapter so far, we’ve created examples using nothing but our trusty cube. Don’t you think it’s about time we moved on to some other shapes? Or even (gasp!) more than one object at the same time? Switching to a new geometry is easy since we can use any of the twenty or so geometries that come with the three.js core, as we’ll see in the next chapter. However, once we start to add lots of objects to our scenes, we also need to think about how to organize and keep track of them, both within the 3D space of the scene and in our code.
In this chapter, we’ll introduce a new geometry called SphereBufferGeometry
, and we’ll use this to showcase some features we can use to keep our scenes and code organized: the Group
class, which is used to organize objects within the
scene graph, and the .clone
method, which you can use to create identical copies of an existing object in a single line of code.
Introducing SphereBufferGeometry
The
SphereBufferGeometry
geometry constructor takes up to seven parameters, all optional. We’ll focus on the first three here:
The radius defines how big the sphere will be. More interesting are the next two parameters, which specify how much detail the geometry has around its width (equator) and height, respectively. The
BoxBufferGeometry
has similar parameters, however, they are less important as they don’t change the shape of the box. The reason for this is that all geometries are made out of triangles - you can see these outlined on the sphere in the scene above. To create a curved surface like a sphere we need to use lots of very tiny triangles.
Try experimenting with different values for widthSegments
and heightSegments
to see how these settings affect the quality of the geometry. It’s important to use the smallest value that looks good for both settings. The number of triangles the sphere is built from increases very quickly when you use larger values for these parameters. What you’re looking for is a tradeoff between quality and performance. If the sphere is far away from the camera or very small, you might be able to get away with a low-quality geometry made out of very few triangles, while if the sphere is the main focal point of your scene (such as a globe or planet), you will probably want to use a higher quality geometry.
Adding Many Objects to the Scene
In a few moments, we’ll create twenty-one sphere-shaped meshes and add them to our scene, arranged in a circle around the center. We could, of course, add each sphere to the scene one by one (in the following examples we’ve skipped setting the spheres' positions for brevity).
Kind of tedious, don’t you think? This is the perfect time to use a loop:
That’s better. We’ve gone from over forty lines of code to just four. However, we have to think about
this issue from two perspectives: clean code, and a clean scene graph. There’s nothing wrong, technically, with adding lots of objects directly to the scene like this. There are no issues with performance or anything else. The problems will come when we want to do something with the spheres. Perhaps we want to show/hide them all at once, or perhaps we want to animate them (as we’ll do below). In that case, we’ll have to keep track of all of them in our code and change them one by one, and to animate them we would have to add a .tick
method to all twenty-one spheres.
It would be much better if we had some way of treating them as a group, don’t you think?
The Group
Object
Groups occupy
a position in the scene graph and can have children, but are themselves invisible. If the Scene
represents the entire universe, then you can think of a Group
as a single compound object within that universe.
When we move a group around, all of its children move too. Likewise, if we rotate or scale a group, all of its children will be rotated or scaled too. However, the children can also be translated, rotated, or scaled independently. This is exactly how objects behave in the real world. For example, a car is made up of separate pieces like the body, windows, wheels, engine, and so on, and when you move the car they all move with it. But the wheels can rotate independently, and you can open the doors and roll down the windows, spin the steering wheel, and so on.
Of course, all of that applies to every scene object. Every scene object has .add
and .remove
methods inherited from Object3D
, just like the Group
and the Scene
itself, and
every object can hold a position in the scene graph and have children. The difference is that groups are purely organizational. Other scene objects, like meshes, lights, cameras, and so on, have some other purpose in addition to occupying a place in the scene graph. However, groups exist purely to help you manipulate other scene objects.
Working with Groups
Like the Scene
constructor, the Group
constructor doesn’t take any parameters:
You can
.add
and
.remove
children from a group:
Once you add the group to your scene, any children of the group become part of the scene too:
Getting back to our spheres, we’ll create the spheres in a loop like before, but now we’ll add them to a group, and then we’ll add the group to the scene.
In case our simple group of spheres has not convinced you, a classic example of the reason for grouping objects is a robotic arm. The arm in this scene consists of at least four individually moving pieces, and they are connected by joints in a hierarchy, with the base of the arm at the top and the “hand” at the bottom. Imagine if these were all added directly to the scene, with no connection to each other, and our task was to animate them. Each joint in the arm requires the joints preceding it to remain connected while it moves. If we had to account for this without any kind of connection between the pieces, there would be a lot of painful math involved. However, when we connect the pieces in a parent-child relationship within the scene graph, the hierarchical movements logically follow. When we move the entire group, the whole arm will move. If we rotate the base, the upper joints will move but the group and base will not move. When we rotate the middle joint, the top joint will rotate too, and finally, when we rotate the top joint, nothing else will be forced to move with it.
This kind of logical connection between objects is one of the things that grouping objects within the scene graph makes easy.
The .clone
Method
In the above examples where we created lots of spheres, we skipped over the part where we have to move each sphere into a new position. If we don’t do that, all of the spheres will remain at the exact center of the scene, all jumbled on top of each other. This is where cloning an object can be useful. We can set up one object just how we like it, then we can create an exact clone. This clone will have the same transform, the same shape, the same material, if it’s a light, it will have the same color and intensity, if it’s a camera it will have the same field of view and aspect ratio, and so on. Then, we can make whatever adjustments we want to the clone.
Nearly all objects in three.js have a .clone
method, which allows you to create an identical copy of that object. All scene objects inherit from
Object3D.clone
, while geometries inherit from
BufferGeometry.clone
, and materials inherit from
Material.clone
.
In this chapter, we’ll focus on cloning meshes, which works like this:
If we set the position, rotation, and scale of mesh
, and then clone it, clonedMesh
will start with the same position, rotation, and scale as the original.
After cloning, you can adjust the transforms on the original mesh and the cloned mesh separately.
clonedMesh
also has the same geometry and material as mesh
. However, the geometry and material are not cloned, they are shared. If we make any changes to the shared material, for example, to change its color, all the cloned meshes will change, along with the original. The same applies if you make any changes to the geometry.
However, you can give a clone an entirely new material, and the original will not be affected.
Custom Properties like .tick
are Not Cloned
One important final note. Only the default properties of an object will be cloned. If you create custom properties like
the .tick
method we’re using to create animations, these will not be cloned. You’ll have to set up any custom properties again on the cloned mesh.
Create the meshGroup.js Module
Now, we will finally add these twenty-one spheres to our scene. Rename the cube.js module from the previous chapter to meshGroup.js, and delete everything inside it (in the editor we’ve done this for you). Inside this new module, we’ll use SphereBufferGeometry
, Group
, and .clone
to create a bunch of spheres and then spend some time experimenting with them.
First, set up the imports. These are mostly the same as the previous chapter, except that we have replaced BoxBufferGeometry
and TextureLoader
, with SphereBufferGeometry
and Group
. Next, create the createMeshGroup
function, and finally, export this function at the bottom of the module:
import {
SphereBufferGeometry,
Group,
MathUtils,
Mesh,
MeshStandardMaterial,
} from 'three';
function createMeshGroup() {}
export { createMeshGroup };
Create the Group
Inside the function, create a new group, and then give it a .tick
method:
function createMeshGroup() {
// a group holds other objects
// but cannot be seen itself
const group = new Group();
group.tick = (delta) => {};
return group;
}
export { createMeshGroup };
This completes the skeleton structure for the new module. Over in World, switch the createCube
import to createMeshGroup
(again, already done for you in the editor):
import { createCamera } from './components/camera.js';
import { createLights } from './components/lights.js';
import { createMeshGroup } from './components/meshGroup.js';
import { createScene } from './components/scene.js';
import { createControls } from './systems/controls.js';
import { createRenderer } from './systems/renderer.js';
import { Resizer } from './systems/Resizer.js';
Make a similar change in the constructor:
constructor(container) {
camera = createCamera();
renderer = createRenderer();
scene = createScene();
loop = new Loop(camera, scene, renderer);
container.append(renderer.domElement);
const controls = createControls(camera, renderer.domElement);
const { ambientLight, mainLight } = createLights();
const meshGroup = createMeshGroup();
loop.updatables.push(controls, meshGroup);
scene.add(ambientLight, mainLight, meshGroup);
const resizer = new Resizer(container, camera, renderer);
}
At this point, your scene will contain a single empty group and nothing else. However, groups are invisible so all you’ll see is the blue background.
Create the Prototype Sphere
Next, we’ll create the spheres and add them to the group. We’ll do this by creating one prototype sphere and then we’ll clone that twenty times for a total of twenty-one spheres.
First, create a SphereBufferGeometry
to give the prototype mesh its shape. This geometry will be shared by all the spheres. We’ll give it a radius
of 0.25, and set both widthSegments
and heightSegments
to sixteen:
const geometry = new SphereBufferGeometry(0.25, 16, 16);
Setting both widthSegments
and heightSegments
to sixteen gives us a decent tradeoff between quality and performance, as long as we don’t zoom in too close. With these settings, each sphere will be made out of 480 tiny triangles.
Next, create a MeshStandardMaterial
. Nothing new here, except this time we’ll
set the color to indigo. Once again, this material will be shared by all of the spheres.
const material = new MeshStandardMaterial({
color: 'indigo',
});
Finally, create the mesh and then add it to the group:
const protoSphere = new Mesh(geometry, material);
// add the sphere to the group
group.add(protoSphere);
We’ll .clone
this mesh to create the rest of the meshes, hence the name protoSphere
. Putting all that together, here’s the createMeshGroup
function so far:
function createMeshGroup() {
// a group holds other objects
// but cannot be seen itself
const group = new Group();
const geometry = new SphereBufferGeometry(0.25, 16, 16);
const material = new MeshStandardMaterial({
color: 'indigo',
});
// create one prototype sphere
const protoSphere = new Mesh(geometry, material);
// add the sphere to the group
group.add(protoSphere);
group.tick = (delta) => {};
return group;
}
At this point, the protoSphere
should show up in the center of your scene.
Note how the HemisphereLight
we added in the last chapter combines with the color of the sphere to create different shades across the surface. Also, look closely at the silhouette of the sphere. Can you see that it’s made from lots of short straight lines? If you zoom way in using the orbit controls and then rotate the camera, this should become more obvious. Clearly, widthSegments
and heightSegments
at sixteen doesn’t give us enough detail for a full-screen sphere. Now, zoom back out to the original size. The sphere should look better now, showing us that this quality level is fine for small or far-away spheres.
Clone the protoSphere
This sub-heading wins the prize for most likely to be a line of dialogue in a cheesy sci-fi movie.
With our prototype mesh set up, we’ll clone it to create the other meshes.
We’ll use a for loop to create twenty new spheres, adding each to the group as we create them. Normally, to loop twenty times, we would do this:
However, in a moment, we’ll arrange the cloned spheres in a circle using some trigonometry and we’ll need values of i
between zero and one. Since $\frac{1}{20}=0.05$, we can write the loop this way instead:
Add this loop to createMeshGroup
to create the twenty new spheres:
...
const protoSphere = new Mesh(geometry, material);
// add the sphere to the group
group.add(protoSphere);
// create twenty clones of the protoSphere
// and add each to the group
for (let i = 0; i < 1; i += 0.05) {
const sphere = protoSphere.clone();
group.add(sphere);
}
...
Now we have a total of twenty-one spheres (the original sphere plus twenty clones). However, we haven’t moved any of the spheres yet, so they are all positioned exactly on top of each other at the center of the scene and it looks like there is still only one sphere.
Position the Cloned Spheres in a Circle
We’ll use a bit of trigonometry to place the cloned spheres in a circle surrounding the protoSphere
. Here’s one way to write the equations of a circle with radius one, where $0 \le i \le 1$:
$$ \begin{aligned} x &= \cos(2 \pi i) \cr y &= \sin(2 \pi i) \cr \end{aligned} $$
If we input values of $i$ between zero and one, we’ll get points spread around the circumference of the circle. We can easily rewrite these function in JavaScript
using the built-in Math
class:
Next, move the equations into your for loop (now can you see why we wanted values of i
between zero and one?):
for (let i = 0; i < 1; i += 0.05) {
const sphere = protoSphere.clone();
// position the spheres on around a circle
sphere.position.x = Math.cos(2 _ Math.PI _ i);
sphere.position.y = Math.sin(2 _ Math.PI _ i);
this.group.add(sphere);
}
Once you do this, the cloned spheres will move into a circle surrounding the original protoSphere
.
Scale the Group
The circle we created has radius one, which is quite small. We’ll double the scale of the group to make it bigger:
// every sphere inside the group will be scaled
group.scale.multiplyScalar(2);
The
.multiplyScalar
method
multiplies the $x$, $y$, and $z$ components of a vector by a number. When we double the scale of the group, every object inside the group doubles in size too.
Scale the Spheres
For some extra visual flair, let’s scale the cloned spheres from tiny to large. Add the following line to the loop:
for (let i = 0; i < 1; i += 0.05) {
const sphere = protoSphere.clone();
// position the spheres on around a circle
sphere.position.x = Math.cos(2 * Math.PI * i);
sphere.position.y = Math.sin(2 * Math.PI * i);
sphere.scale.multiplyScalar(0.01 + i);
group.add(sphere);
}
The variable i
lies in the range $0 \le i \le 1$, so here, we are scaling the meshes from nearly zero to full size.
Spin the Wheel
Finally, update the group.tick
method to set the spheres in motion. We’ll use the same approach we used to
create the cube animation, except this time we are rotating on a single axis so it’s a simple spinning motion, like a wheel rotating around its center.
const radiansPerSecond = MathUtils.degToRad(30);
// each frame, rotate the entire group of spheres
group.tick = (delta) => {
group.rotation.z -= delta * radiansPerSecond;
};
Complete createMeshGroup
Function
With all that in place, here’s the complete createMeshGroup
function:
function createMeshGroup() {
// a group holds other objects
// but cannot be seen itself
const group = new Group();
const geometry = new SphereBufferGeometry(0.25, 16, 16);
const material = new MeshStandardMaterial({
color: 'indigo',
});
const protoSphere = new Mesh(geometry, material);
// add the protoSphere to the group
group.add(protoSphere);
// create twenty clones of the protoSphere
// and add each to the group
for (let i = 0; i < 1; i += 0.05) {
const sphere = protoSphere.clone();
// position the spheres on around a circle
sphere.position.x = Math.cos(2 * Math.PI * i);
sphere.position.y = Math.sin(2 * Math.PI * i);
sphere.scale.multiplyScalar(0.01 + i);
group.add(sphere);
}
// every sphere inside the group will be scaled
group.scale.multiplyScalar(2);
const radiansPerSecond = MathUtils.degToRad(30);
// each frame, rotate the entire group of spheres
group.tick = (delta) => {
group.rotation.z -= delta * radiansPerSecond;
};
return group;
}
Experiment!
Finally, we have a scene that we can play with. You can get interesting results by making tiny changes within the loop. For example, try experimenting with a different step size in the loop to create more or fewer spheres:
for (let i = 0; i < 1; i += 0.05) {
What happens if you change 0.05 to 0.001? How small can that value be before you start to notice a drop in the frame rate?
Or, how about changing the $z$ positions in the loop as well as $x$ and $y$?
sphere.position.x = Math.cos(2 _ Math.PI _ i);
sphere.position.y = Math.sin(2 _ Math.PI _ i);
sphere.position.z = -i \* 5;
You’ll have to adjust the camera as well to get this exact view. That sounds like a “hard” challenge!