WebGL: Animating a 3D scene


(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


(For more resources on JavaScript, see here.)

Architectural updates

Let's review the structure of the examples developed in the book. Each web page includes several scripts. One of them is WebGLApp.js. This script contains the WebGLApp object.

WebGLApp review

The WebGLApp object defines three function hooks that control the life cycle of the application. As shown in the diagram, we create a WebGLApp instance inside the runWebGLApp function. Then, we connect the WebGLApp hooks to the configure, load, and draw functions that we coded. Also, please notice that the runWebGLApp function is the entry point for the application and it is automatically invoked using the onload event of the web page.

Application Architecture

Adding support for matrix stacks

The diagram also shows a new script: SceneTransforms.js. This file contains the SceneTransforms objects that encapsulate the matrix-handling operations including matrix stacks operations push and pop. The SceneTransforms object replaces the functionality by the initTransforms, updateTransforms, and setMatrixUniforms functions.

Configuring the rendering rate

After setting the connections between the WebGLApp hooks and our configure, load and draw functions, WebGLApp.run() is invoked. This call creates a JavaScript timer that is triggered every 500 ms. The callback for this timer is the draw function. Up to now a refresh rate of 500 ms was more than acceptable because we did not have any animations. However, this is a parameter that you could tweak later on to optimize your rendering speed. To do so please change the value of the constant WEBGLAPP_RENDER_RATE. This constant is defined in the source code for WebGLApp.

Creating an animation timer

As shown in the previous architecture diagram, we have added a call to the new startAnimation function inside the runWebGLApp function . This causes the animation to start when the page loads.

Connecting matrix stacks and JavaScript timers

In the following Time for action section, we will take a look at a simple scene where we have animated a cone and a sphere. In this example, we are using matrix stacks to implement local transformations and JavaScript timers to implement the animation sequence.

Time for action – simple animation

  1. Open ch5_SimpleAnimation.html using your WebGL-enabled Internet browser of choice.
  2. Move the camera around and see how the objects (sphere and cone) move independently of each other (local transformations) and from the camera position (global transformation).
  3. Move the camera around pressing the left mouse button and holding it while you drag the mouse.
  4. You can also dolly the camera by clicking the left mouse button while pressing the Alt key and then dragging the mouse.
  5. Now change the camera type to Tracking. If for any reason you lose your bearings, click on go home.
  6. Let's examine the source code to see how we have implemented this example. Open ch5_SimpleAnimation.html using the source code editor of your choice.
  7. Take a look at the functions startAnimation, onFrame, and animate. Which timing strategy are we using here?
  8. The global variables pos_sphere and pos_cone contain the position of the sphere and the cone respectively. Scroll up to the draw function. Inside the main for loop where each object of the scene is rendered, a different local transformation is calculated depending on the current object being rendered. The code looks like the following:

    transforms.calculateModelView(); transforms.push(); if (object.alias == 'sphere'){ var sphereTransform = transforms.mvMatrix; mat4.translate(sphereTransform,[0,0,pos_sphere]); } else if (object.alias == 'cone'){ var coneTransform = transforms.mvMatrix; mat4.translate(coneTransform, [pos_cone,0,0]); } transforms.setMatrixUniforms(); transforms.pop();

    Using the transforms object (which is an instance of SceneTransforms) we obtain the global Model-View matrix by calling transforms.calculateModelView(). Then, we push it into a matrix stack by calling the push method. Now we can apply any transform that we want, knowing that we can retrieve the global transform so it is available for the next object on the list. We actually do so at the end of the code snippet by calling the pop method. Between the push and pop calls, we determine which object is currently being rendered and depending on that, we use the global pos_sphere or pos_cone to apply a translation to the current Model-View matrix. By doing so, we create a local transform.
  9. Take a second look at the previous code. As you saw at the beginning of this exercise, the cone is moving in the x axis while the sphere is moving in the z axis. What do you need to change to animate the cone in the y axis? Test your hypothesis by modifying this code, saving the web page, and opening it again on your HTML5 web browser.
  10. Let's go now back to the animate function. What do we need to modify here to make the objects to move faster? Hint: take a look at the global variables that this function uses.

What just happened?

In this exercise, we saw a simple animation of two objects. We examined the source code to understand the call stack of functions that make the animation possible. At the end of this call stack, there is a draw function that takes the information of the calculated object positions and applies the respective local transforms.

Have a go hero – simulating dropped and frozen frames

  1. Open the ch5_DroppingFrames.html file using your HTML5 web browser. Here you will see the same scene that we analyzed in the previous Time for action section. You can see here that the animation is not smooth because we are simulating dropping frames.
  2. Take a look at the source code in an editor of your choice. Scroll to the animate function. You can see that we have included a new variable: simulationRate. In the onFrame function, this new variable calculates how many simulation steps need to be performed when the time elapsed is around 300 ms (animationRate). Given that the simulationRate is 30 ms this will produce a total of 10 simulation steps. These steps can be more if there are unexpected delays and the elapsed time is considerably higher. This is the behavior that we expect.
  3. In this section we want you to experiment with different values for the animationRate and simulationRate variables to answer the following questions:
    • How do we get rid of the dropping frames issue?
    • How can we simulate frozen frames?
    • Hint: the calculated steps should always be zero.

    • What is the relationship between the animationRate and the simulationRate variables when simulating frozen frames?

Parametric curves

There are many situations where we don't know the exact position that an object will have at a given time but we know an equation that describe its movement. These equations are known as parametric curves and are called like that because the position depends on one parameter: the time.

There are many examples of parametric curves. We can think for instance of a projectile that we shoot on a game, a car that is going downhill or a bouncing ball. In each case, there are equations that describe the motion of these objects under ideal conditions. The next diagram shows the parametric equation that describes free fall motion.

Parametric Curves: Free Fall

We are going to use parametric curves for animating objects in a WebGL scene. In this example, we will model a set of bouncing balls.

(For more resources on JavaScript, see here.)

Initialization steps

We will create a global variable that will store the time (simulation time).

var sceneTime = 0;

We also create the global variables that regulate the animation:

var animationRate = 15; /* 15 ms */ var elapsedTime = undefined; var initialTime = undefined;

The load function is updated to load a bunch of balls using the same geometry (same JSON file) but adding it several times to the scene object. The code looks like this:

function load(){ Floor.build(80,2); Axis.build(82); Scene.addObject(Floor); for (var i=0;i<NUM_BALLS;i++){ var pos = generatePosition(); ball.push(new BouncingBall(pos[0],pos[1],pos[2])); Scene.loadObject('models/geometry/ball.json','ball'+i); } }

Notice that here we also populate an array named ball[]. We do this so that we can store the ball positions every time the global time changes. We will talk in depth about the bouncing ball simulation in the next Time for action section. For the moment, it is worth mentioning that it is on the load function that we load the geometry and initialize the ball array with the initial ball positions.

Setting up the animation timer

The startAnimation and onFrame functions look exactly as in the previous examples:

function onFrame() { elapsedTime = (new Date).getTime() - initialTime; if (elapsedTime < animationRate) { return;} //come back later var steps = Math.floor(elapsedTime / animationRate); while(steps > 0){ animate(); steps -= 1; } initialTime = (new Date).getTime(); } function startAnimation(){ initialTime = (new Date).getTime(); setInterval(onFrame,animationRate/1000); // animation rate }

Running the animation

The animate function passes the sceneTime variable to the update method of every ball in the ball array. Then, sceneTime is updated by a fixed amount. The code looks like this:

function animate(){ for (var i = 0; i<ball.length; i++){ ball[i].update(sceneTime); } sceneTime += 33/1000; //simulation time draw(); }

Again, parametric curves are really helpful because we do not need to know beforehand the location of every object that we want to move. We just apply a parametric equation that gives us the location based on the current time. This occurs for every ball inside its update method.

Drawing each ball in its current position

In the draw function, we use matrix stack to save the state of the Model-View matrix before applying a local transformation for each one of the balls. The code looks like this:

transforms.calculateModelView(); transforms.push(); if (object.alias.substring(0,4) == 'ball'){ var index = parseInt(object.alias.substring(4,8)); var ballTransform = transforms.mvMatrix; mat4.translate(ballTransform,ball[index].position); object.diffuse = ball[index].color; } transforms.setMatrixUniforms(); transforms.pop();

The trick here is to use the number that makes part of the ball alias to look up the respective ball position in the ball array. For example, if the ball being rendered has the alias ball32 then this code will look for the current position of the ball whose index is 32 in the ball array. This one-to-one correspondence between the ball alias and its location in the ball array was established in the load function.

In the following Time for action section, we will see the bouncing balls animation working. We will also discuss some of the code details.

Time for action – bouncing ball

  1. Open ch5_BouncingBalls.html in your HTML5-enabled Internet browser.
  2. The orbiting camera is activated by default. Move the camera and you will see how all the objects adjust to the global transform (camera) and yet they keep bouncing according to its local transform (bouncing ball).
  3. Parametric Curves: Bouncing Balls

  4. Let's explain here a little bit more in detail how we keep track of each ball.
    • First of all let's define some global variables and constants:
    • var ball = []; //Each element of this array is a ball var BALL_GRAVITY = 9.8; //Earth acceleration 9.8 m/s2 var NUM_BALLS = 50; //Number of balls in this simulation

    • Next, we need to initialize the ball array. We use a for loop in the load function to achieve it:
    • for (var i=0;i<NUM_BALLS;i++){ ball.push(new BouncingBall()); Scene.loadObject('models/geometry/ball. json','ball'+i); }

    • The BouncingBall function initializes the simulation variables for each ball in the ball array. One of this attributes is the position, which we select randomly. You can see how we do this by using the generatePosition function.
    • After adding a new ball to the ball array, we add a new ball object (geometry) to the Scene object. Please notice that the alias that we create includes the current index of the ball object in the ball array. For example, if we are adding the 32nd ball to the array, the alias that the corresponding geometry will have in the Scene will be ball32.
    • The only other object that we add to the scene here is the Floor object.
  5. Now let's talk about the draw function. Here, we go through the elements of the Scene and retrieve each object's alias. If the alias starts with the word ball then we know that the reminder of the alias corresponds to its index in the ball array. We could have probably used an associative array here to make it look nicer but it does not really change the goal. The main point here is to make sure that we can associate the simulation variables for each ball with the corresponding object (geometry) in the Scene.

    Itis important to notice here that for each object (ball geometry) in the scene, we extract the current position and the color from the respective BouncingBall object in the ball array.

    Also, we alter the current Model-View matrix for each ball using a matrix stack to handle local transformations, as previously described in this article. In our case, we want the animation for each ball to be independent from the camera transform and from each other.

  6. Up to this point, we have described how the bouncing balls are created (load) and how they are rendered (draw). None of these functions modify the current position of the balls. We do that using BouncingBall.update(). The code there uses the animation time (global variable named sceneTime) to calculate the position for the bouncing ball. As each BouncingBall has its own simulation parameters, we can calculate the position for each given position when a sceneTime is given. In short, the ball position is a function of time and as such, it falls into the category of motion described by parametric curves.
  7. The BouncingBall.update() method is called inside the animate function. As we saw before, this function is invoked by the animation timer each time the timer is up. You can see inside this function how the simulation variables are updated in order to reflect the current state of that ball in the simulation.

What just happened?

We have seen how to handle several object local transformations using the matrix stack strategy while we keep global transformation consistent through each rendering frame.

In the bouncing ball example, we have used an animation timer for the animation that is independent from the rendering timer.

The bouncing ball update method shows how parametric curves work.

Optimization strategies

If you play a little and increase the value of the global constant NUM_BALLS from 50 to 500, you will start noticing degradation in the frame rate at which the simulation runs as shown in the following screenshot:

Parametric Curves: Bouncing Balls

Depending on your computer, the average time for the draw function can be higher than the frequency at which the animation timer callback is invoked. This will result in dropped frames. We need to make the draw function faster. Let's see a couple of strategies to do this.

Optimizing batch performance

We can use geometry caching as a way to optimize the animation of a scene full of similar objects. This is the case of the bouncing balls example. Each bouncing ball has a different position and color. These features are unique and independent for each ball. However, all balls share the same geometry.

In the load function, for ch5_BouncingBalls.html we created 50 vertex buffer objects (VBOs) one for each ball. Additionally, the same geometry is loaded 50 times, and on every rendering loop (draw function) a different VBO is bound every time, despite of the fact that the geometry is the same for all the balls!

In ch5_BouncingBalls_Optimized.html we modified the functions load and draw to handle geometry caching. In the first place, the geometry is loaded just once (load function):


Secondly, when the object with alias 'ball' is the current object in the rendering loop (draw function), the delegate drawBalls function is invoked. This function sets some of the uniforms that are common to all bouncing balls (so we do not waste time passing them every time to the program for every ball). After that, the drawBall function is invoked. This function will set up those elements that are unique for each ball. In our case, we set up the program uniform that corresponds to the ball color, and the Model-View matrix, which is unique for each ball too because of the local transformation (ball position).

Optimizing batch performance

Performing translations in the vertex shader

If you take a look at the code in ch5_BouncingBalls_Optimized.html, you may notice that we have taken an extra step and that the Model-View matrix is cached!

The basic idea behind it is to transfer once the original matrix to the GPU (global) and then perform the translation for each ball (local) directly into the vertex shader. This change improves performance considerably because of the parallel nature of the vertex shader.

This is what we do, step-by-step:

  1. Create a new uniform that tells the vertex shader if it should perform a translation or not (uTranslate).
  2. Map these two new uniforms to JavaScript variables (we do this in the configure function).
  3. prg.uTranslation = gl.getUniformLocation(prg, "uTranslation"); gl.uniform3fv(prg.uTranslation, [0,0,0]); prg.uTranslate = gl.getUniformLocation(prg, "uTranslate"); gl.uniform1i(prg.uTranslate, false);

  4. Perform the translation inside the vertex shader. This part is probably the trickiest as it implies a little bit of ESSL programming.
  5. //translate vertex if there is a translation uniform vec3 vecPosition = aVertexPosition; if (uTranslate){ vecPosition += uTranslation; } //Transformed vertex position vec4 vertex = uMVMatrix * vec4(vecPosition, 1.0);

    In this code fragment we are defining vecPosition, a variable of vec3 type. This vector is initialized to the vertex position. If the uTranslate uniform is active (meaning we are trying to render a bouncing ball) then we update vecPosition with the translation. This is implemented using vector addition.

    After this we need to make sure that the transformed vertex carries the translation in case of having one. So the next line looks like the following code:

    //Transformed vertex position vec4 vertex = MV * vec4(vecPosition, 1.0);

  6. In drawBall we pass the current ball position as the content for the uniform uTranslation:
  7. gl.uniform3fv(prg.uTranslation, ball.position);

  8. In drawBalls we set the uniform uTranslate to true:
  9. gl.uniform1i(prg.uTranslate, true);

  10. In draw we pass the Model-View matrix once for all balls by using the following line of code:
  11. transforms.setMatrixUniforms();

After making these changes we can increase the global variable NUM_BALLS from 50 to 300 and see how the application keeps performing reasonably well regardless of the increased scene complexity. The improvement in execution times is shown in the following screenshot:

Parametric Curves: Optimized Bouncing Balls


In this article, we have covered the basic concepts behind object animation in WebGL. Specifically we have learned about the difference between local and global transformations. We have seen how matrix stacks allows us saving and retrieving the Model-View matrix and how a stack allows us to implement local transformation.

We learned to use JavaScript timers for animation. The fact that an animation timer is not tied up to the rendering cycle gives a lot of flexibility. Think a moment about it: the time in the scene should be independent of how fast you can render it on your computer. We also distinguished between animation and simulation strategies and learned what problems they solve.

We discussed a couple of methods to optimize animations through a practical example and we have seen what we need to do to implement these optimizations in the code.

Further resources on this subject:

You've been reading an excerpt of:

WebGL Beginner's Guide

Explore Title