Camera Controls and Global Illumination
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:createCamera()
createLights()
createMeshes()
createRenderer()
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
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 - 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 (), and 4 units up (), 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 insideinit()
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 currentcreateLights
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
.
By default the target is located at . 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:
- Lights are expensive. We want to add as few lights as possible to our scene to ensure that it runs smoothly on mobile devices
- 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 , then back in the DirectionalLight
and set the intensity of that to as well.
Wait… better get our physical terminology correct - set the irradiance of both of the lights to . 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.
.
Comments
Post a Comment