CAMERA CONTROLS AND GLOBAL ILLUMINATION

We’ve come far, but our scene is still lacking something important - there’s currently no way for us to interact with it!

One of the simplest ways of adding interactivity is to set up controls that allow us to move the camera around. As an added advantage, once we’ve done this, we’ll be able to view our scene from all angles, zoom in to check tiny details, or zoom out to get a birds-eye overview.

In this chapter we’ll add a prebuilt control system called OrbitControls to our camera, allowing it to be rotated around the cube so that we can see it from all angles.

As usual, we’ll continue from where we left off in the last chapter.

Code Organisation - Huge Functions Are Bad!

After the code restructuring we did in the last couple of chapters, our code is looking pretty good. However our init() function is growing larger and larger, and soon it will be hard to keep track of everything that’s going on inside it.

Let’s divide init() up into the following sub-functions:

We’ll also remove a few comments to make things cleaner. Hopefully you don’t need them anymore.

Once we’re done, our code will look like this:

let scene;
let mesh;

function init() {

  container = document.querySelector( '#scene-container' );

  scene = new THREE.Scene();
  scene.background = new THREE.Color( 0x8FBCD4 );

  createCamera();
  createLights();
  createMeshes();
  createRenderer();

  renderer.setAnimationLoop( () => {

    update();
    render();

  } );

}

function createCamera() {

  camera = new THREE.PerspectiveCamera(
    35, // FOV
    container.clientWidth / container.clientHeight, // aspect

    0.1, // near clipping plane
    100, // far clipping plane
  );

  camera.position.set( 0, 0, 10 );

}

function createLights() {

  // Create a directional light
  const light = new THREE.DirectionalLight( 0xffffff, 3.0 );

  // move the light back and up a bit
  light.position.set( 10, 10, 10 );

  // remember to add the light to the scene
  scene.add( light );

}

function createMeshes() {

  const geometry = new THREE.BoxBufferGeometry( 2, 2, 2 );

  const textureLoader = new THREE.TextureLoader();

  const texture = textureLoader.load( 'textures/uv_test_bw.png' );

  texture.encoding = THREE.sRGBEncoding;
  texture.anisotropy = 16;

  const material = new THREE.MeshStandardMaterial( {
    map: texture,
  } );

  mesh = new THREE.Mesh( geometry, material );

  scene.add( mesh );

}

function createRenderer() {

  renderer = new THREE.WebGLRenderer( { antialias: true } );
  renderer.setSize( container.clientWidth, container.clientHeight );

  renderer.setPixelRatio( window.devicePixelRatio );

  renderer.gammaFactor = 2.2;
  renderer.gammaOutput = true;

  container.appendChild( renderer.domElement );

}

// perform any updates to the scene, called once per frame
// avoid heavy computation here
function update() {

Adding Camera Controls to Our App

  1. Set up the Scene
  2. Include the OrbitControls.js Script
  3. Set Up the Controls

Moving the camera around the scene in such a way as to allow panning, zooming and rotation is a non-trivial task. How can we achieve this in the simplest way possible, with a minimum of effort on our part?

Well, we’ll let someone else do the work for us, of course! Never reinvent the wheel unless you have to. Especially since at this point in our careers, we’d be creating a wooden cartwheel while there’s a shiny titanium alloy version sitting unused in the garage.

Perhaps a steering wheel would make a better analogy since we’re using it for control. Anyway, this shiny wheel is called OrbitalControls, and you can find the script here, in the main three.js repo.

The name comes from the fact that these controls allow the camera to ‘orbit’ around a point in space (by default it will start to orbit around $(0, 0, 0)$ - the origin ).

1. Set Up the Scene

Remove the Rotation

Remove everything from the update() function to stop the cube from rotating

  container.appendChild( renderer.domElement );

}

// perform any updates to the scene, called once per frame
// avoid heavy computation here
function update() {

  // Don't delete this function!

}

First of all, let’s remove the three mesh.rotation lines from our update() function. We’ll be able to see our camera movement better if the thing we’re looking at is still.

Don’t remove the update function though! We’ll use it again soon. In fact, we’ll never remove that function, even if when we are not using it. This way we can preserve the structure of our app, allowing us to quickly test and make changes using a familiar framework.

Reposition the Camera

Move the camera a little so that we’re not looking at the cube head on

function createCamera() {

  camera = new THREE.PerspectiveCamera(
    35, // FOV
    container.clientWidth / container.clientHeight, // aspect

    0.1, // near clipping plane
    100, // far clipping plane
  );

  camera.position.set( -4, 4, 10 );

}

We’ll also move the camera 4 units to the left ($-X$), and 4 units up ($+Y$), so that we’re not looking at the cube head on when the scene first loads.

2. Include the OrbitControls.js Script

Add the OrbitControls.js script after the main three.js script

    <script src="js/vendor/three/three.js"></script>

    <!--

      Include the OrbitControls script.

      This must be included AFTER the three.js script as it
      needs to use the global THREE variable

    -->

    <script src="js/vendor/three/OrbitControls.js"></script>

  </head>

The OrbitControls object is not part of the three.js core. This means the controls come in a separate file, and we’ll need to include that file in our app before we can use them.

We’ll include the script containing the controls in the same way that we included the main three.js script, putting them in a folder called js/vendor/three beside our main app.js file.

Here’s the OrbitControls.js, so download the file and put it in the correct folder, either on your local computer or on CodeSandbox, then add the above line to your HTML.

3. Initialize The Controls

Create a new controls variable at the top of the file

// these need to be accessed inside more than one function so we'll declare them first
let container;
let camera;
let controls;
let renderer;
let scene;
let mesh;

Next, create a new createControls() function to set up the controls

  camera.position.set( -4, 4, 10 );

}

function createControls() {

  controls = new THREE.OrbitControls( camera, container );

}

function createLights() {

Finally, call the new function inside init()

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

  createCamera();
  createControls();
  createLights();
  createMeshes();

Now that we’ve added the script, the OrbitControls property has been added to the main THREE object and we can call it using THREE.OrbitControls.

Start by adding a controls variable to the top of the file, and then we can proceed to create a new function to set up our controls. For now, setup is a single line, but the controls have quite a few parameters that we might want to fine tune later.

We’re passing in two parameters to the OrbitControls constructor: camera and container.

The First Parameter: camera

The first parameter is the camera that we want to control - in this case, our PerspectiveCamera. This parameter is required.

The Second Parameter: container

The second parameter is the element that the controls will listen for mouse/touch events on. If you remember, back in Ch 1.3: Adding Automatic Resizing we used window.addEventListener to add a listener that fires whenever the browser window changes size. Well, OrbitControls use the same method to listen for mouse and touch movements.

We don’t have to pass in a second parameter here - if we leave it out, it will use document.addEventListener and listen for events on the whole page. By passing in the container we make sure that the controls only work when the mouse/touch happens on the 3D part of the page, which is generally what your users will expect.

And that’s it! You can now control the camera using touch or mouse. Experiment with the different mouse buttons and touch controls to see how it works.

Once we’ve added the controls, we’ll be able to rotate our camera around and view the cube from all sides.

Once we do rotate the cube though, we’ll immediately see a glaring problem. The camera rotates, but the light stays fixed and is only shining from one direction.

Unlike in the real world, the light has nothing to bounce off, so any surfaces that the light does not shine directly onto will be completely black.

Lighting from All Directions

Our current createLights function:

function createLights() {

  // Create a directional light
  const light = new THREE.DirectionalLight( 0xffffff, 3.0 );

  // move the light back and up a bit
  light.position.set( 10, 10, 10 );

  // remember to add the light to the scene
  scene.add( light );

}

We’re currently using a single DirectionalLight, which simulates light from a distant light source such as the sun. All rays are parallel and infinite and shine in the same direction as a line going from the light’s position to the position of the light’s target, which is stored in light.target.

Light rays of a directional light

By default the target is located at $(0,0,0)$. Since our cube is also located at the origin, this means that the light will automatically be shining onto it, so we’ll leave the target where it is for now.

The obvious solution to our problem is to add more lights. In the case of our simple cube, we could just add a couple more DirectionalLights and this would solve our problem.

However, there are two opposing problems with this approach:

  1. Lights are expensive. We want to add as few lights as possible to our scene to ensure that it runs smoothly on mobile devices
  2. As we strive for realism and quality lighting, we’ll need to keep adding more and more lights to achieve our desired results

Quite a conundrum. As we attempt to solve this problem, let’s introduce a second light type: the AmbientLight.

Simulating Indirect Illumination with an AmbientLight

An improved createLights function with ambient light

function createLights() {

  const ambientLight = new THREE.AmbientLight( 0xffffff, 1 );
  scene.add( ambientLight );

  const mainLight = new THREE.DirectionalLight( 0xffffff, 1 );
  mainLight.position.set( 10, 10, 10 );

  scene.add( ambientLight, mainLight );

}

Suppose for a moment that our DirectionalLight represents a beam of sunlight shining through a window into your kitchen. In the real world, this light would then bounce infinitely around in the room, reflecting from all the walls and surfaces before finally illuminating our object from all sides - most brightly from the direction of the light, and less brightly from the reflected light bouncing off the walls and other surfaces.

As we can see all too clearly above, the DirectionalLight shines in one direction, and one direction only. This is called direct illumination, while reflected light is called indirect illumination. Together these give us global illumination.

Of course, our scene doesn’t actually have any walls for the light to bounce off, but even if it did, simulating those bounces would generally be too expensive for a real-time app and three.js doesn’t provide any way of doing that out of the box.

However, we do need global illumination to create a realistic scene. Since the real thing is not possible, we’ll have to fake it in some way, and as you might expect by now, there are lots of techniques for doing this.

By far the most common and cheapest way of faking indirect lighting is the AmbientLight, which creates a light that shines equally on all objects but without any direction.

This is somewhat similar to indirect illumination caused by light bouncing off walls and surfaces, and when combined with the direct illumination of other light types, this gives a cheap approximation of global illumination.

Combining an ambient light with our single DirectionalLight at least allows us to overcome the problem of the cube being black from most angles:

However, the lighting lacks looks very flat and unexciting.

Enter the HemisphereLight

There is a second ambient light type included with three.js, called the HemisphereLight.

This light type takes advantage of the fact that, in most everyday situations, the light fades from bright at the top of the scene to darker near the ground.

For example, in a typical outdoor scene, objects are brightly lit from above by sunlight, and then more dimly lit by the sunlight bouncing off the ground and illuminating them from below.

Likewise, in an indoor environment, the brightest lights are nearly always on the ceiling and dimmer lights are near the floor.

With the HemisphereLight, we specify a (generally bright) sky color and a (generally dim) ground color. The light then fades from the sky color to the ground color across your scene.

function createLights() {

  const ambientLight = new THREE.HemisphereLight(
    0xddeeff, // bright sky color
    0x202020, // dim ground color
    3, // intensity
  );

  scene.add( ambientLight );

}

In fact, we can get quite a realistic illumination using just this one light, and no other lights at all!

We’re nearly there! However, this light does not actually shine from any particular direction, so there are no shiny (AKA specular) highlights in the above scene. It still looks too flat.

We’ll need to include at least one DirectionalLight in our scene as well to overcome this. Before we do that though, let’s introduce a new setting on the renderer.

Physically Correct Lighting

Setting physicallyCorrectLights to = true will allow us to use real-world lighting units in our lighting setup

function createRenderer() {

  renderer = new THREE.WebGLRenderer( { antialias: true } );
  renderer.setSize( container.clientWidth, container.clientHeight );

  renderer.setPixelRatio( window.devicePixelRatio );

  renderer.gammaFactor = 2.2;
  renderer.gammaOutput = true;

  renderer.physicallyCorrectLights = true;

  container.appendChild( renderer.domElement );

}

Throughout this book, we will be concentrating on creating a physically based workflow. An important part of this is making sure that we use standardized SI units. We’ve already been using meters to measure distances in our scenes, and later we’ll use seconds to time our animations.

Another consideration in our quest for physical correctness is the units that we measure our lighting in. So far, we’ve just been putting in whatever value looks good as the intensity parameter on our lights, without thinking too much about what it means.

However, three.js allows us to switch on physically correct lighting. Once we do this, can use SI units of lighting such as Lux, Candela, and Lumens to set the lighting in our scenes.

This means that we can actually look at the packaging of a lightbulb and use the bulb’s power (in lumens) and the brightness of our scene will match the bulb in the real world!

The first thing that you may notice is that the scene gets darker when we set physicallyCorrectLights = true, so we’ll turn up the brightness a bit. Later we’ll look at how we can adjust the exposure value on the renderer to compensate for this.

For now, set the intensity on the HemisphereLight light to $5$, then back in the DirectionalLight and set the intensity of that to $5$ as well.

Wait… better get our physical terminology correct - set the irradiance of both of the lights to $5$. Much better!

Our Final createLights() function

function createLights() {

  const ambientLight = new THREE.HemisphereLight(
    0xddeeff, // sky color
    0x202020, // ground color
    5, // intensity
  );

  const mainLight = new THREE.DirectionalLight( 0xffffff, 5 );
  mainLight.position.set( 10, 10, 10 );

  scene.add( ambientLight, mainLight );

}

Final Result

Our cube is fully illuminated from all angles and we can orbit, zoom and pan our camera all around it using our mouse or touchscreen. It’s not especially apparent with this simple cube and simple test texture, but we’ve set up a powerful, reusable lighting rig here which will really come into its own when tested with more complex models. In fact, after just a little bit more tweaking we’ll use this same lighting and camera controls set up for nearly every example in the whole book!

We’ve covered a lot so far - physically accurate materials, texture mapping, animation, automatic resizing and how to use plugins such as orbit controls, and we’ve set up a robust and future-proof code structure for our app while doing so.

Next up we’ll take a look at how to move our objects around in 3D space using translation, rotation, and scaling, collectively known as transformations.

.