Extend three.js With a Camera Controls Plugin
The three.js core is a powerful, lightweight, and focused rendering framework, with intentionally limited capabilities. It has everything you need to create and render physically correct scenes, however, it does not have everything you need to create, say, a game, or a product configurator. Even when building relatively simple apps, you will often find yourself needing functionality that’s not in the core. When this happens, before you write any code yourself, check to see whether there’s a plugin available. The three.js repo contains hundreds of extensions, in the examples/jsm folder. These are also included in the NPM package, for those of you using a package manager.
There are also a huge number of plugins to be found scattered around the web. However, these are sometimes poorly maintained and may not work with the latest three.js version, so in this book, we’ll restrict ourselves to using the official plugins from the repo. There, we’ll find all kinds of plugins, and most of them are showcased in one of the examples. These add all kinds of functionality, such as mirrored surfaces:
Or, how about a loader for the Lego LDraw format:
Here are a few more:
- One of the many post-processing effects
- A loader for the Autodesk FBX format
- An exporter for glTF format
- Physically accurate ocean and sky
Each extension is stored in a separate module in examples/jsm, and to use them, we simply import them into our app, much like any other three.js class.
Our First Plugin: OrbitControls
One of the most popular extensions is
OrbitControls
, a camera controls plugin which allows you to orbit, pan, and zoom the camera using touch, mouse, or keyboard. With these controls, we can view a scene from all angles, zoom in to check tiny details, or zoom out to get a birds-eye overview. Orbit controls allow us to control the camera in three ways:
- Orbit around a fixed point, using the left mouse button or a single finger swipe.
- Pan the camera using the right mouse button, the arrow keys, or a two-finger swipe.
- Zoom the camera using the scroll wheel or a pinch gesture.
You can find the module containing OrbitControls
on the three.js repo, in the examples/jsm/controls/ folder, in a file called
OrbitControls.js. There’s also an
official example showcasing OrbitControls
. For a quick reference of all the control’s settings and features, head over to the
OrbitControls
doc page.
Importing Plugins
Since the plugins are part of the three.js repo and included in the NPM package, importing them works in much the same way as importing classes from the three.js core, except that each plugin is in a separate module. Refer back to the intro for a reminder of how to include the three.js files in your app, or head over to the appendix for a deeper exploration of how JavaScript modules work.
In the editor, we’ve placed the OrbitControls.js file in the equivalent directory to the repo, under vendor/. Go ahead and locate the file now. Since the editor uses NPM style imports, we can import OrbitControls
like this, from anywhere in our code like this:
Once again, if you’re working locally and not using a bundler, you’ll have to change the import path. For example, you can import from skypack.dev instead.
Important note: Make sure you import plugins from examples/jsm/ and not legacy plugins from examples/js/!
The controls.js Module
As usual, we’ll create a new module in our app to handle setting up the controls. Since the controls operate on the camera, they will go in the
systems category. Open or create the module systems/controls.js to handle setting up the camera controls. This new module has the same structure as most of our other modules. First, import the OrbitControls
class, then make a createControls
function, and finally, export the function:
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
function createControls() {}
export { createControls };
Back over in World, add the new function to the list of imports:
import { createCamera } from './components/camera.js';
import { createCube } from './components/cube.js';
import { createLights } from './components/lights.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';
import { Loop } from './systems/Loop.js';
Next, call the function and store the result in a variable called controls
. While you’re here, comment out the line adding cube
to the updatables
array. This will stop the cube from rotating and make the effect of the controls easier to see:
constructor() {
camera = createCamera();
scene = createScene();
renderer = createRenderer();
const controls = createControls();
const cube = createCube();
const light = createLights();
// disabled mesh rotation
// updatables.push(cube);
scene.add(cube, light);
this.canvas = renderer.domElement;
}
Initialize the Controls
If you check out the
OrbitControls
docs page, you’ll see that the constructor takes two parameters: a Camera
, and a
HTMLDOMElement
. We’ll use our camera for the first parameter and the canvas, stored in renderer.domElement
, for the second.
Internally, OrbitControls
uses addEventListener
to listen for user input. The controls will listen for events such as click
, wheel
, touchmove
, and keydown
, amongst others, and use these to move the camera. We previously used this method to
listen for the resize
event when we set up automatic resizing. There, we listened for the resize
event on the entire window
. Here, the controls will listen for user input on whatever element we pass in as the second parameter. The rest of the page will be unaffected. In other words, when we pass in the canvas, the controls will work when the mouse/touch is over the canvas, but the rest of the page will continue to work as normal.
Pass the camera and canvas into the createControls
function, then create the controls:
function createControls(camera, canvas) {
const controls = new OrbitControls(camera, canvas);
return controls;
}
Back over in the world module, pass in the camera
and renderer.domElement
:
constructor(container) {
camera = createCamera();
scene = createScene();
renderer = createRenderer();
container.append(renderer.domElement);
const controls = createControls(camera, renderer.domElement);
// ...
}
With that, the controls should start to work. Take them for a spin!
You’ll immediately notice the cube is not illuminated from the back. We’ll explain why and how to fix this in the next chapter.
Working with the Controls
Manually Set the Target
By default, the controls orbit around the center of the scene, point $(0,0,0)$. This is stored in the controls.target
property, which is a Vector3
. We can move this target to a new position:
We can also point the controls at an object by copying the object’s position.
Whenever you pan the controls (using the right mouse button), the target will pan too. If you need a fixed target, you can disable panning using controls.enablePan = false
.
Enable Damping for Added Realism
As soon as the user stops interacting with the scene, the camera will come to an abrupt stop. Objects in the real world have inertia and never stop abruptly like this, so we can make the controls feel more realistic by enabling damping.
With damping enabled, the controls will slow to a stop over several frames which gives them a feeling of
weight. You can adjust
the .dampingFactor
to control how fast the camera comes to a stop. However, for damping to work, we must call controls.update
every frame in the animation loop. If we’re
rendering frames on demand instead of using the loop, we cannot use damping.
Update the Controls in the Animation Loop
Whenever we need to update an object in the loop, we’ll use the technique we devised when creating
the cube’s animation. In other words, we’ll give the controls a .tick
method and then add them to the loop.updatables
array. First, the .tick
method:
function createControls(camera, canvas) {
const controls = new OrbitControls(camera, canvas);
// damping and auto rotation require
// the controls to be updated each frame
// this.controls.autoRotate = true;
controls.enableDamping = true;
controls.tick = () => controls.update();
return controls;
}
Here, .tick
simply calls controls.update
. Next, add the controls to the updatables
array:
camera = createCamera();
renderer = createRenderer();
scene = createScene();
loop = new Loop(camera, scene, renderer);
container.append(renderer.domElement);
const controls = createControls(camera, renderer.domElement);
const cube = createCube();
const light = createLights();
loop.updatables.push(controls);
// stop the cube's animation
// loop.updatables.push(cube);
scene.add(cube, light);
const resizer = new Resizer(container, camera, renderer);
}
Now, controls.tick
will be called once per frame in
the update loop, and damping will work. Test it out. Can you see the difference?
Working With the Camera While Using OrbitControls
With the controls in place, we have relinquished control of the camera to them. However, sometimes you need to take back control to manually position the camera. There are two ways to go about this:
- Cut/jump to a new camera position
- Smoothly animate to a new camera position
We’ll take a brief look at how you would go about both of these, but we won’t add the code to our app.
Cut to a New Camera Position
To perform a camera cut, update the camera’s transform as usual, and then call controls.update
:
If you’re calling .update
in the loop, you don’t need to do it manually and you can simply move the camera. If you move the camera without calling .update
, weird things will happen, so watch out!
One important thing to note here: when you move the camera, the controls.target
does not move. If you have not moved it, it will remain at the center of the scene. When you move the camera to a new position but leave the target unchanged, the camera will not only move but also rotate so that it continues to point at the target. This means that camera movements may not work as you expect when using the controls. Often, you will need to move the camera and the target at the same time to get your desired outcome.
Smoothly Transition to a New Camera Position
If you want to smoothly animate the camera to a new position, you will probably need to transition the camera and the target at the same time, and the best place to do this is in the controls.tick
method. However, you will need to disable the controls for the duration of the animation, otherwise, if the user attempts to move the camera before the animation has completed, you’ll end up with the controls fighting against your animation, often with disastrous results.
Save and Restore a View State
You can save the current view using
.saveState
, and later restore it using
.reset
:
If we call .reset
without first calling .saveState
, the camera will jump back to the position it was in when we created the controls.
Disposing of the Controls
If you no longer need the controls, you can clean them up using .dispose, which will remove all event listeners created by the controls from the canvas.
Rendering on Demand with OrbitControls
A couple of chapters ago we set up the animation loop, a powerful tool that allows us to create beautiful animations with ease. On the other hand, as we discussed at the end of that chapter, the loop does have some downsides, such as increased battery use on mobile devices. As a result, sometime we’ll choose to render frames on demand instead of generating a constant stream of frames using the loop.
Now that our app has orbit controls, whenever the user interacts with your scene, the controls will move the camera to a new position, and when this occurs you must draw a new frame, otherwise, you won’t be able to see that the camera has moved. If you’re using the animation loop, that’s not a problem. However, if we’re rendering on demand we’ll have to figure something else out.
Fortunately, OrbitControls
provides an easy way to generate new frames whenever the camera moves. The controls have a custom event called change
which we can listen for using
addEventListener
. This event will fire whenever a user interaction causes the controls to move the camera.
To use rendering on demand with the orbit control, you must render a frame whenever this event fires:
To set this up inside World.js, you’ll use this.render
:
Next, over in main.js, make sure we’re no longer starting the loop. Instead, render the initial frame:
// render the inital frame
world.render();
If you make these changes in your app, you’ll see that this results in a slight problem. When we render the initial frame in main.js, the texture has not yet loaded, so the cube will look black. If we were running the loop, this frame would almost instantly be replaced with a new one after the texture loads, so it might not even be noticeable that the cube was black for a few milliseconds. However, with rendering on demand, we are now only generating new frames when the user interacts with the scene and moves the camera. As soon as you move the controls, sure enough, a new frame will be created and the texture will show up.
As a result, you also need to generate a new frame after the texture has loaded. We won’t cover how to do that here, but hopefully, it highlights why rendering on demand is trickier than using the loop. You have to consider all situations where you need a new frame (for example, don’t forget that you’ll also need to render a frame on resize).
OrbitControls
Configuration
The controls have lots of options that allow us to adjust them to our needs. Most of these are well explained in the docs, so we won’t cover them exhaustively here. The following are some of the most important.
Enable or Disable the Controls
We can enable or disable the controls entirely:
Or, we can disable any of the three modes of control individually:
You can optionally listen for key events and use the arrow keys to pan the camera:
Auto Rotate
.autoRotate
will make the camera automatically rotate around the .target
, and
.autoRotateSpeed
controls how fast:
As with .enableDamping
, you must call controls.update
every frame for this to work. Note that .autoRotate
will still work if the controls are disabled.
Limiting Zoom
We can limit how far the controls will zoom in or out:
Make sure minDistance
is not smaller than
the camera’s near clipping plane and maxDistance
is not greater than
the camera’s far clipping plane. Also, minDistance
must be smaller than maxDistance
.
Limiting Rotation
We can limit the control’s rotation, both horizontally (azimuth angle):
… and vertically (polar angle)
Remember, rotations are specified using radians, not degrees, and $\pi$ radians is equal to $180^{\circ}$.
A Glaring Problem!
As soon as we rotate the camera using our fancy new orbit controls, we’ll see a glaring problem. The camera rotates, but the light is fixed and shines only from one direction. The rear faces of the cube receive no light at all!
In the real world, light bounces and reflects off every surface, so the rear of the cube would be dimly lit. There’s nothing in this simple scene aside from the cube, so there’s nothing for the light to bounce off. But, even if there was, performing these calculations is much too expensive for us to do in real-time. In the next chapter, we will look at a technique for overcoming this problem known as ambient lighting.