WebGL: Animating a 3D scene

0
225
9 min read

 

(For more resources on JavaScript, see here.)

We will discuss the following topics:

  • Global versus local transformations
  • Matrix stacks and using them to perform animation
  • Using JavaScript timers to do time-based animation
  • Parametric curves

Global transformation allows us to create two different kinds of cameras. Once we have applied the camera transform to all the objects in the scene, each one of them could update its position; representing, for instance, targets that are moving in a first-person shooting game, or the position of other competitors in a car racing game.

 

This can be achieved by modifying the current Model-View transform for each object. However, if we modified the Model-View matrix, how could we make sure that these modifications do not affect other objects? After all, we only have one Model-View matrix, right?

The solution to this dilemma is to use matrix stacks.

Matrix stacks

A matrix stack provides a way to apply local transforms to individual objects in our scene while at the same time we keep the global transform (camera transform) coherent for all of them. Let’s see how it works.

Each rendering cycle (each call to draw function) requires calculating the scene matrices to react to camera movements. We are going to update the Model-View matrix for each object in our scene before passing the matrices to the shading program (as attributes). We do this in three steps as follows:

  1. Once the global Model-View matrix (camera transform) has been calculated, we proceed to save it in a stack. This step will allow us to recover the original matrix once we had applied to any local transforms.
  2. Calculate an updated Model-View matrix for each object in the scene. This update consists of multiplying the original Model-View matrix by a matrix that represents the rotation, translation, and/or scaling of each object in the scene. The updated Model-View matrix is passed to the program and the respective object then appears in the location indicated by its local transform.
  3. We recover the original matrix from the stack and then we repeat steps 1 to 3 for the next object that needs to be rendered.

The following diagram shows this three-step procedure for one object:

Matrix Stack Operations

Animating a 3D scene

To animate a scene is nothing else than applying the appropriate local transformations to objects in it. For instance, if we have a cone and a sphere and we want to move them, each one of them will have a corresponding local transformation that will describe its location, orientation, and scale. In the previous section, we saw that matrix stacks allow recovering the original Model-View transform so we can apply the correct local transform for the next object to be rendered.

Knowing how to move objects with local transforms and matrix stacks, the question that needs to be addressed is: When?

If we calculated the position that we want to give to the cone and the sphere of our example every time we called the draw function, this would imply that the animation rate would be dependent on how fast our rendering cycle goes. A slower rendering cycle would produce choppy animations and a too fast rendering cycle would create the illusion of objects jumping from one side to the other without smooth transitions.

Therefore, it is important to make the animation independent from the rendering cycle. There are a couple of JavaScript elements that we can use to achieve this goal: The requestAnimFrame function and JavaScript timers.

requestAnimFrame function

The window.requestAnimFrame() function is currently being implemented in HTML5-WebGL enabled Internet browsers. This function is designed such that it calls the rendering function (whatever function we indicate) in a safe way only when the browser/tab window is in focus. Otherwise, there is no call. This saves precious CPU, GPU, and memory resources.

Using the requestAnimFrame function, we can obtain a rendering cycle that goes as fast as the hardware allows and at the same time, it is automatically suspended whenever the window is out of focus. If we used requestAnimFrame to implement our rendering cycle, we could use then a JavaScript timer that fires up periodically calculating the elapsed time and updating the animation time accordingly. However, the function is a feature that is still in development.

To check on the status of the requestAnimFrame function, please refer to the following URL:
Mozilla Developer Network

JavaScript timers

We can use two JavaScript timers to isolate the rendering rate from the animation rate.

The rendering rate is controlled by the class WebGLApp. This class invokes the draw function, defined in our page, periodically using a JavaScript timer.

Unlike the requestAnimFrame function, JavaScript timers keep running in the background even when the page is not in focus. This is not optimal performance for your computer given that you are allocating resources to a scene that you are not even looking. To mimic some of the requestAnimFrame intelligent behavior provided for this purpose, we can use the onblur and onfocus events of the JavaScript window object.

Let’s see what we can do:

Action (What)

Goal (Why)

Method (How)

Pause the rendering

To stop the rendering until the window is in focus

Clear the timer calling clearInterval in the window.onblur function

Slow the rendering

To reduce resource consumption but make sure that the 3D scene keeps evolving even if we are not looking at it

We can clear current timer calling clearInterval in the window.onblur function and create a new timer with a more relaxed interval (higher value)

Resume the rendering

To activate the 3D scene at full speed when the browser window recovers its focus

We start a new timer with the original render rate in the window.onfocus function

By reducing the JavaScript timer rate or clearing the timer, we can handle hardware resources more efficiently.

In WebGLApp you can see how the onblur and onfocus events have been used to control the rendering timer as described previously.

Timing strategies

In this section, we will create the second JavaScript timer that will allow controlling the animation. As previously mentioned, a second JavaScript timer will provide independency between how fast your computer can render frames and how fast we want the animation to go. We have called this property the animation rate.

However, before moving forward you should know that there is a caveat when working with timers: JavaScript is not a multi-threaded language.

This means that if there are several asynchronous events occurring at the same time (blocking events) the browser will queue them for their posterior execution. Each browser has a different mechanism to deal with blocking event queues.

There are two blocking event-handling alternatives for the purpose of developing an animation timer.

Animation strategy

The first alternative is to calculate the elapsed time inside the timer callback. The pseudo-code looks like the following:

var initialTime = undefined; var elapsedTime = undefined; var animationRate = 30; //30 ms function animate(deltaT){ //calculate object positions based on deltaT } function onFrame(){ elapsedTime = (new Date).getTime() – initialTime; if (elapsedTime < animationRate) return; //come back later animate(elapsedTime); initialTime = (new Date).getTime(); } function startAnimation(){ setInterval(onFrame,animationRate/1000); }

Doing so, we can guarantee that the animation time is independent from how often the timer callback is actually executed. If there are big delays (due to other blocking events) this method can result in dropped frames. This means the object’s positions in our scene will be immediately moved to the current position that they should be in according to the elapsed time (between consecutive animation timer callbacks) and then the intermediate positions are to be ignored. The motion on screen may jump but often a dropped animation frame is an acceptable loss in a real-time application, for instance, when we move one object from point A to point B over a given period of time. However, if we were using this strategy when shooting a target in a 3D shooting game, we could quickly run into problems. Imagine that you shoot a target and then there is a delay, next thing you know the target is no longer there! Notice that in this case where we need to calculate a collision, we cannot afford to miss frames, because the collision could occur in any of the frames that we would drop otherwise without analyzing. The following strategy solves that problem.

Simulation strategy

There are several applications such as the shooting game example where we need all the intermediate frames to assure the integrity of the outcome. For example, when working with collision detection, physics simulations, or artificial intelligence for games. In this case, we need to update the object’s positions at a constant rate. We do so by directly calculating the next position for the objects inside the timer callback.

var animationRate = 30; //30 ms var deltaPosition = 0.1 function animate(deltaP){ //calculate object positions based on deltaP } function onFrame(){ animate(deltaPosition); } function startAnimation(){ setInterval(onFrame,animationRate/1000); }

This may lead to frozen frames when there is a long list of blocking events because the object’s positions would not be timely updated.

Combined approach: animation and simulation

Generally speaking, browsers are really efficient at handling blocking events and in most cases the performance would be similar regardless of the chosen strategy. Then, deciding to calculate the elapsed time or the next position in timer callbacks will then depend on your particular application.

Nonetheless, there are some cases where it is desirable to combine both animation and simulation strategies. We can create a timer callback that calculates the elapsed time and updates the animation as many times as required per frame. The pseudocode looks like the following:

var initialTime = undefined; var elapsedTime = undefined; var animationRate = 30; //30 ms var deltaPosition = 0.1; function animate(delta){ //calculate object positions based on delta } function onFrame(){ elapsedTime = (new Date).getTime() - initialTime; if (elapsedTime < animationRate) return; //come back later! var steps = Math.floor(elapsedTime / animationRate); while(steps > 0){ animate(deltaPosition); steps -= 1; } initialTime = (new Date).getTime(); } function startAnimation(){ initialTime = (new Date).getTime(); setInterval(onFrame,animationRate/1000); }

You can see from the preceding code snippet that the animation will always update at a fixed rate, no matter how much time elapses between frames. If the app is running at 60 Hz, the animation will update once every other frame, if the app runs at 30 Hz the animation will update once per frame, and if the app runs at 15 Hz the animation will update twice per frame. The key is that by always moving the animation forward a fixed amount it is far more stable and deterministic.

The following diagram shows the responsibilities of each function in the call stack for the combined approach:

Call stack example for the combined timing approach: animation + simulation

This approach can cause issues if for whatever reason an animation step actually takes longer to compute than the fixed step, but if that is occurring, you really ought to simplify your animation code or put out a recommended minimum system spec for your application.

Web Workers: Real multithreading in JavaScript

You may want to know that if performance is really critical to you and you need to ensure that a particular update loop always fires at a consistent rate then you could use Web Workers.

Web Workers is an API that allows web applications to spawn background processes running scripts in parallel to their main page. This allows for thread-like operation with message-passing as the coordination mechanism.

You can find the Web Workers specification at the following URL: W3C Web Workers

LEAVE A REPLY

Please enter your comment!
Please enter your name here