Improving Our Animation Loop and Adding Automatic Resizing 1.3

IMPROVING OUR ANIMATION LOOP AND ADDING AUTOMATIC RESIZING

Welcome back! Here’s where we finished up at the end of the last chapter:

It’s a respectable result for such a small amount of code.

However, there is currently a problem with our app that will quickly make it look at lot less professional to our users - that is, our scene doesn’t resize resize when the browser window changes size. This includes times when the user resizes the browser on their laptop, or when they change from landscape to portrait mode on their phone or tablet.

We’ll fix that in just a moment. First, let’s take a look at a couple of things we can do to improve our code and make sure it’s future proof as our app grows in complexity.

Improving Our Animation Loop

Our current animation loop function

function animate() {

  // call animate recursively
  requestAnimationFrame( animate );

  // increase the mesh's rotation each frame
  mesh.rotation.z += 0.01;
  mesh.rotation.x += 0.01;
  mesh.rotation.y += 0.01;

  // render, or 'create a still image', of the scene
  // this will create one still image / frame each time the animate
  // function calls itself
  renderer.render( scene, camera );

}

Take a look at the animate() function. Ignoring requestAnimationFrame for now, we can see that it’s currently doing two things - first, it’s updating the rotation of the mesh, and then it’s rendering the scene.

Introducing the Game Loop

Most game engines use the concept of a game loop, which is called once per frame and is used to update the game and then render the scene. A minimal game loop might look something like this:

  1. Get user input
  2. Update animations
  3. Render the frame

Looks familiar? Even though three.js is not a game engine and we are calling our loop an animation loop rather than a game loop, most of the same logic applies here, so we’ll take some ideas for this part of our app from game engine theory.

Split the Animation Loop into update() and render()

We’re not currently getting any user input so we’ll ignore step 1 for now and come back to it in Chapter 1.5: Camera Controls. That leaves the last two steps, “Update animations”, and “Render the frame”. So let’s split our app into two functions called update() and render(), adding these functions to the end of our app.js file, just before the call to init().

The update() Function

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

  // increase the mesh's rotation each frame
  mesh.rotation.z += 0.01;
  mesh.rotation.x += 0.01;
  mesh.rotation.y += 0.01;

}

// render, or 'draw a still image', of the scene
function render() {

  renderer.render( scene, camera );

}

// call the init function to set everything up
init();

Anything that involves updating the scene should go in here. The only thing that we’re currently updating each frame is the rotation of the mesh, so move those three lines into this function.

In a more complex app, this function could be doing a lot more. For example, if we were creating a driving game it would be calculating the direction, position, and velocity of each car from frame to frame. Physics is usually calculated separately to this function though, often on a separate thread.

The render() Function

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

  // increase the mesh's rotation each frame
  mesh.rotation.z += 0.01;
  mesh.rotation.x += 0.01;
  mesh.rotation.y += 0.01;

}

// render, or 'draw a still image', of the scene
function render() {

  renderer.render( scene, camera );

}

// call the init function to set everything up
init();

We’re currently rendering our frame using a single line, so it may seem like overkill to put it inside its own function and generally, this function won’t get that much more complicated.

However, you might want to do things like post-processing, or drawing to a separate frame buffer, or scissor tests or… OK, sorry, we won’t introduce too much advanced terminology just yet. We’ll explore these in much more detail in Section 8: The WebGLRenderer.

For now, we’ll just keep the call to renderer.render() separate to make sure our app is fully future-proof.

Introducing the setAnimationLoop Method

Virtual Reality devices handle requestAnimationFrame() differently than normal web pages. This means that our current animate function will not work as a WebVR app.

To make dealing with this easier, a new method called setAnimationLoop was recently added to the WebGLRenderer. This handles setting up of the animation loop for us and makes sure that it works no matter what kind of device we are viewing our app on.

As an added bonus using this method actually makes our code a little cleaner, since calling requestAnimationFrame is handled automatically for us.

Using setAnimationLoop()

Setting up the animation loop using the setAnimationLoop method

  container.appendChild( renderer.domElement );

  // start the animation loop
  renderer.setAnimationLoop( () => {

    update();
    render();

  } );

}

Switching to the setAnimationLoop method allows us to abstract away requestAnimationFrame() completely. Delete your current animate() function and replace with the setAnimationLoop code.

If we want to stop the animation loop at any time, we can pass in null to the method like this:


renderer.setAnimationLoop( null );

For example, at a later date we might want to add play and stop functions like this:


function play() {

  renderer.setAnimationLoop( () => {

    update();
    render();

  } );

}

function stop() {

  renderer.setAnimationLoop( null );

}

Seamlessly Handling Browser Window Size Changes

The user may resize their browser at any time. For example, they may rotate their phone from portrait to landscape, or they may change the size of the browser window on their laptop.

We want to handle this gracefully, in a manner that is essentially invisible to the user and involves a minimum of effort on our part.

Fortunately, this is easy to do, using a built-in browser method called addEventListener.

Adding a resize Event Listener

Add the following at the end of your code, just before the call to the init function, to create a listener for the resize event

function render() {

  renderer.render( scene, camera );

}

function onWindowResize() {

  console.log( 'You resized the browser window!' );

}

window.addEventListener( 'resize', onWindowResize );

// call the init function to set everything up
init();

You can listen for all kinds of events using addEventListener, such as click, scroll, keypress and many more. The one that we want to listen for here is called resize.

You can add event listeners to any HTML element - we could add one to our container DIV, for example. In this case, we want to listen for an event on the whole window so we’ll use window.addEventListener. This will call the onWindowResize function every time the window resizes.

Logging resize event to console

We need to be careful here though since whenever you resize the browser window, the function might get called many times - potentially hundreds of times when you thought you had resized the window just once. So don’t do any heavy calculation in here.

For now, we’ve just put console.log( ... ) inside the function. This is a useful way of making sure that something is working correctly. Open up the browser console now, then resize the window and you should see something like the image above.

Once we’ve confirmed that the event listener is firing as we expect, we can go ahead and add the desired functionality to it.

The onWindowResize Function

Our final resize handling code will look like this

// a function that will be called every time the window gets resized.
// It can get called a lot, so don't put any heavy computation in here!
function onWindowResize() {

  // set the aspect ratio to match the new browser window aspect ratio
  camera.aspect = container.clientWidth / container.clientHeight;


  // update the camera's frustum
  camera.updateProjectionMatrix();

  // update the size of the renderer AND the canvas
  renderer.setSize( container.clientWidth, container.clientHeight );

}

window.addEventListener( 'resize', onWindowResize );

// call the init function to set everything up
init();

Now that we’ve added the event listener and confirmed that the event is firing correctly, what should we put inside the onWindowResize function?

It’s fairly easy to figure this out actually - go over the code in the init function and make a note of everywhere that we used container.clientWidth or container.clientHeight.

Since the dimensions of the container will probably have changed after the resize, these are the things that we need to update.

Currently, there are only two places where we used the container’s size:

  1. when we set the aspect ratio of the camera
  2. when we set the renderer’s size

  const aspect = container.clientWidth / container.clientHeight;


  renderer.setSize( container.clientWidth, container.clientHeight );

We need to figure out a way of updating these to use the new width and height.

Update the Camera’s Aspect Ratio

Change the aspect ratio, then update the frustum

// a function that will be called every time the window gets resized.
// It can get called a lot, so don't put any heavy computation in here!
function onWindowResize() {

  // set the aspect ratio to match the new browser window aspect ratio
  camera.aspect = container.clientWidth / container.clientHeight;


  // update the camera's frustum
  camera.updateProjectionMatrix();

  // update the size of the renderer AND the canvas
  renderer.setSize( container.clientWidth, container.clientHeight );

}

window.addEventListener( 'resize', onWindowResize );

// call the init function to set everything up
init();

The camera’s aspect ratio is stored in camera.aspect, so we can just change that to the new value. However, we need to do one more thing to make the new aspect ratio take effect, and that is to update the camera’s frustum, we can do by calling the camera.updateProjectionMatrix method.

You will need to do this any time you make any changes to parameters that change the shape of the camera’s frustum, such as changing the Field of View, stored in camera.fov, updating the aspect ratio as we are doing here, or updating the clipping planes stored in camera.near and camera.far.

Update the Renderer’s Size

Call the renderer.setSize method with the new sizes

// a function that will be called every time the window gets resized.
// It can get called a lot, so don't put any heavy computation in here!
function onWindowResize() {

  // set the aspect ratio to match the new browser window aspect ratio
  camera.aspect = container.clientWidth / container.clientHeight;


  // update the camera's frustum
  camera.updateProjectionMatrix();

  // update the size of the renderer AND the canvas
  renderer.setSize( container.clientWidth, container.clientHeight );

}

window.addEventListener( 'resize', onWindowResize );

// call the init function to set everything up
init();

To update the renderer’s size (and automatically update the canvas element’s size), we can just call renderer.setSize() again with the new values.

Now try resizing the window and again and watch as your scene resizes to match. Nice!

Final Result

Great work! Our app now looks much more professional, and the code is fully future-proofed and ready to be expanded on in the next few chapters. Here’s our app running with the resize code and improved animation loop.

And here is the previous chapter’s code, without the resize function. Try resizing your browser now and see the difference. It will be easier to see if you start with a narrow window and increase the size.

Next up we’ll look at an important technique called texture mapping, which we can use to create photorealistic materials for our objects.