ThreeJs Improving Our Animation Loop and Adding Automatic Resizing
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 very respectable result for such a small amount of code.
However, there is 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 when the browser window changes size, such as 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:
- Get user input
- Update animations
- 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 thatmuch 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.
You can listen for all kinds of events using
addEventListener
, such as click
, scroll
, keypress
and many more, on any HTML element. In [Ch 3.4: Adding Interactivity to Our Scene with Event Listeners], we’ll see how we can listen for keypress
events to add keyboard controls to our scene
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();
Here, we want to listen for a
resize
event, which will fire any time the browser’s window changes size.
You can add event listeners to any HTML element, but 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.
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:
- when we set the aspect ratio of the camera
- 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!
What About the Pixel Ratio?
What about the pixel ratio, which we set back in Ch 1.1: Hello Cube?
renderer.setPixelRatio( window.devicePixelRatio );
Can this change when the browser’s window resizes?
Well, no. It’s fixed for a particular screen. However, there are some rare situations when a user may have a multiple monitor set up with screens that have different pixel ratios. We can safely ignore that for now, but we’ll come back to it in Ch 2.2: A Bullet-Proof Reusable App.
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.
Comments
Post a Comment