Camera Controls and Global Illumination 1.5

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!

Split the monolithic init() function into several small functions

let scene;
let mesh;

function init() {

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

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

  initCamera();
  initLights();
  initMeshes();
  initRenderer();

  renderer.setAnimationLoop( () => {

    update();
    render();

  } );

}

function initCamera() {

  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 initLights() {

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

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

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

}

function initMeshes() {

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

  const textureLoader = new THREE.TextureLoader();

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

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

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

  scene.add( mesh );

}

function initRenderer() {

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

  renderer.setPixelRatio( window.devicePixelRatio );

  // add the automatically created <canvas> element to the page
  container.appendChild( renderer.domElement );

}

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

After the code restructuring we did in the last couple of chapters, our app 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.

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!

}

// render, or 'draw a still image', of the scene

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 initCamera() {

  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="https://threejs.org/build/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="https://threejs.org/examples/js/controls/OrbitControls.js"></script>

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, using GitHub’s CDN (Content Delivery Network).

This CDN is extremely fast, and will always point to the latest release of three.js. This is perfect for experimenting and testing. However, it’s likely that any app you write this way will break after a few months as new versions of three.js are released, so never use it for a production app.

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 initControls() function to set up the controls

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

}

function initControls() {

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

}

function initLights() {

Finally, call the new function along with the rest of our init functions

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

  initCamera();
  initControls();
  initLights();
  initMeshes();

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 Chapter 1.3 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 final initLights() function will consist of one global light and two directional lights

function initLights() {

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

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

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

  scene.add( frontLight, backLight );

}

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. We will, however, move the light to shine from the top right, and then add a second directional light shining from behind.

First though, let’s introduce a second light type: the AmbientLight.

Simulating Indirect Illumination with an AmbientLight

The AmbientLight is a cheap and simple way of faking indirect illumination

function initLights() {

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

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

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

  scene.add( frontLight, backLight );

}

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.

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 reasonably decent approximation of global illumination.

The most obvious difference between a scene lit using direct and ambient lighting (as we are doing here), and a truly globally illuminated scene, is that our scene lacks ambient occlusion, but we’ll see how to fake this too in later sections.

Don’t forget to add the light to the scene once you have created it!

Position Our DirectionalLight

Rename our light to frontLight and update its position

function initLights() {

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

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

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

  scene.add( frontLight, backLight );

}

As we mentioned above, our directional light shines from its position to the position of light.target.

Only the position of this light is used, and setting the light.rotation has no effect at all.

We want the light to shine from the front, above and to the right, so we’ll set the position to $(10,10,10)$.

As we mentioned above, the target’s position is $(0,0,0)$, by default. This means that the rays of light coming form this light will be parallel to the ray going from $(10,10,10) \longrightarrow (0,0,0)$.

We’ll also rename the light to frontLight.

Add a Second DirectionalLight Shining from Behind

Next, we’ll add a second directional light shining from behind the cube

function initLights() {

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

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

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

  scene.add( frontLight, backLight );

}

Name the second directional light backLight since it will be shining from the back. We’ll set the position of this light to $(-10,10,-10)$ - behind, above and to the left of the cube.

As with the first light, the backlight.target is positioned at $(0,0,0)$ meaning that this light will emit rays parallel to the vector $(-10,10,-10) \longrightarrow (0,0,0)$.

Now our cube is fully lit from the back and the front, although the base will only receive ambient lighting since both of the lights are positioned above it. This will help to keep our users oriented as the rotate the camera around.

Final Result

Once we’ve added the second light, 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.

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.

.