HTML5 Canvas Optimization: A Practical Example

If you’ve been doing JavaScript development long enough, you’ve most likely crashed your browser a few times. The problem usually turns out to be some JavaScript bug, like an endless while loop; if not, the next suspect is page transformations or animations – the kind that involve adding and removing elements from the webpage or animating CSS style properties. This tutorial focuses on optimising animations produced using JS and the HTML5 <canvas> element.

This tutorial starts and ends with what the HTML5 animation widget you see below:

We will take it with us on a journey, exploring the different emerging canvas optimization tips and techniques and applying them to the widget’s JavaScript source code. The goal is to improve on the widget’s execution speed and end up with a smoother, more fluid animation widget, powered by leaner, more efficient JavaScript.

The source download contains the HTML and JavaScript from each step in the tutorial, so you can follow along from any point.

Let’s take the first step.


Step 1: Play the Movie Trailer

The widget above is based on the movie trailer for Sintel, a 3D animated movie by the Blender Foundation. It’s built using two of HTML5’s most popular additions: the <canvas> and <video> elements.

The <video> loads and plays the Sintel video file, while the <canvas> generates its own animation sequence by taking snapshots of the playing video and blending it with text and other graphics. When you click to play the video, the canvas springs to life with a dark background that’s a larger black and white copy of the playing video. Smaller, colored screen-shots of the video are copied to the scene, and glide across it as part of a film roll illustration.

In the top left corner, we have the title and a few lines of descriptive text that fade in and out as the animation plays. The script’s performance speed and related metrics are included as part of the animation, in the small black box at the bottom left corner with a graph and vivid text. We’ll be looking at this particular item in more detail later.

Finally, there’s a large rotating blade that flies across the scene at the beginning of the animation, whose graphic is loaded from an external PNG image file.


Step 2: View the Source

The source code contains the usual mix of HTML, CSS and Javascript. The HTML is sparse: just the <canvas> and <video> tags, enclosed in a container <div>:

1
<div id="animationWidget" >
2
 <canvas width="368" height="208" id="mainCanvas" ></canvas>
3
 <video width="184" height="104" id="video" autobuffer="autobuffer" controls="controls" poster="poster.jpg" >
4
 <source src="sintel.mp4" type="video/mp4" ></source>
5
 <source src="sintel.webm" type="video/webm" ></source>
6
 </video>
7
</div>

The container <div> is given an ID (animationWidget), which acts as a hook for all the CSS rules applied to it and its contents (below).

1
2
#animationWidget{
3
 border:1px #222 solid;
4
 position:relative;
5
 width: 570px;
6
 height: 220px;
7
}
8
#animationWidget canvas{
9
 border:1px #222 solid;
10
 position:absolute;
11
 top:5px;
12
 left:5px;
13
}
14
#animationWidget video{
15
 position:absolute;
16
 top:110px;
17
 left:380px;
18
}

While HTML and CSS are the marinated spices and seasoning, its the JavaScript that’s the meat of the widget.

  • At the top, we have the main objects that will be used often through the script, including references to the canvas element and its 2D context.
  • The init() function is called whenever the video starts playing, and sets up all the objects used in the script.
  • The sampleVideo() function captures the current frame of the playing video, while setBlade() loads an external image required by the animation.
  • The pace and contents of the canvas animation are controlled by the main() function, which is like the script’s heartbeat. Run at regular intervals once the video starts playing, it paints each frame of the animation by first clearing the canvas, then calling each one of the script’s five drawing functions:
    • drawBackground()
    • drawFilm()
    • drawTitle()
    • drawDescription()
    • drawStats()

As the the names suggest, each drawing function is responsible for drawing an item in the animation scene. Structuring the code this way improves flexibility and makes future maintenance easier.

The full script is shown below. Take a moment to assess it, and see if you can spot any changes you would make to speed it up.

1
(function(){
2
 if( !document.createElement("canvas").getContext ){ return; } //the canvas tag isn't supported
3
4
 var mainCanvas = document.getElementById("mainCanvas"); // points to the HTML canvas element above
5
 var mainContext = mainCanvas.getContext('2d'); //the drawing context of the canvas element
6
 var video = document.getElementById("video"); // points to the HTML video element
7
 var frameDuration = 33; // the animation's speed in milliseconds
8
 video.addEventListener( 'play', init ); // The init() function is called whenever the user presses play & the video starts/continues playing
9
 video.addEventListener( 'ended', function(){ drawStats(true); } ); //drawStats() is called one last time when the video end, to sum up all the statistics
10
11
 var videoSamples; // this is an array of images, used to store all the snapshots of the playing video taken over time. These images are used to create the 'film reel'
12
 var backgrounds; // this is an array of images, used to store all the snapshots of the playing video taken over time. These images are used as the canvas background
13
 var blade; //An canvas element to store the image copied from blade.png
14
 var bladeSrc = 'blade.png'; //path to the blade's image source file
15
16
 var lastPaintCount = 0; // stores the last value of mozPaintCount sampled
17
 var paintCountLog = []; // an array containing all measured values of mozPaintCount over time
18
 var speedLog = []; // an array containing all the execution speeds of main(), measured in milliseconds
19
 var fpsLog = []; // an array containing the calculated frames per secong (fps) of the script, measured by counting the calls made to main() per second
20
 var frameCount = 0; // counts the number of times main() is executed per second.
21
 var frameStartTime = 0; // the last time main() was called
22
23
 // Called when the video starts playing. Sets up all the javascript objects required to generate the canvas animation and measure perfomance
24
 function init(){
25
 if( video.currentTime > 1 ){ return; }
26
27
 bladeSrc = new Image();
28
 bladeSrc.src = "blade.png";
29
 bladeSrc.onload = setBlade;
30
31
 backgrounds = [];
32
 videoSamples = [];
33
 fpsLog = [];
34
 paintCountLog = [];
35
 if( window.mozPaintCount ){ lastPaintCount = window.mozPaintCount; }
36
 speedLog = [];
37
 frameCount = 0;
38
 frameStartTime = 0;
39
 main();
40
 setTimeout( getStats, 1000 );
41
 }
42
43
 // As the scripts main function, it controls the pace of the animation
44
 function main(){
45
 setTimeout( main, frameDuration );
46
 if( video.paused || video.ended ){ return; }
47
48
 var now = new Date().getTime();
49
 if( frameStartTime ){
50
 speedLog.push( now - frameStartTime );
51
 }
52
 frameStartTime = now;
53
 if( video.readyState < 2 ){ return; }
54
55
 frameCount++;
56
 mainCanvas.width = mainCanvas.width; //clear the canvas
57
 drawBackground();
58
 drawFilm();
59
 drawDescription();
60
 drawStats();
61
 drawBlade();
62
 drawTitle();
63
 }
64
65
 // This function is called every second, and it calculates and stores the current frame rate
66
 function getStats(){
67
 if( video.readyState >= 2 ){
68
 if( window.mozPaintCount ){ //this property is specific to firefox, and tracks how many times the browser has rendered the window since the document was loaded
69
 paintCountLog.push( window.mozPaintCount - lastPaintCount );
70
 lastPaintCount = window.mozPaintCount;
71
 }
72
73
 fpsLog.push(frameCount);
74
 frameCount = 0;
75
 }
76
 setTimeout( getStats, 1000 );
77
 }
78
79
 // create blade, the ofscreen canavs that will contain the spining animation of the image copied from blade.png
80
 function setBlade(){
81
 blade = document.createElement("canvas");
82
 blade.width = 400;
83
 blade.height = 400;
84
 blade.angle = 0;
85
 blade.x = -blade.height * 0.5;
86
 blade.y = mainCanvas.height/2 - blade.height/2;
87
 }
88
89
 // Creates and returns a new image that contains a snapshot of the currently playing video.
90
 function sampleVideo(){
91
 var newCanvas = document.createElement("canvas");
92
 newCanvas.width = video.width;
93
 newCanvas.height = video.height;
94
 newCanvas.getContext("2d").drawImage( video, 0, 0, video.width, video.height );
95
 return newCanvas;
96
 }
97
98
 // renders the dark background for the whole canvas element. The background features a greyscale sample of the video and a gradient overlay
99
 function drawBackground(){
100
 var newCanvas = document.createElement("canvas");
101
 var newContext = newCanvas.getContext("2d");
102
 newCanvas.width = mainCanvas.width;
103
 newCanvas.height = mainCanvas.height;
104
 newContext.drawImage( video, 0, video.height * 0.1, video.width, video.height * 0.5, 0, 0, mainCanvas.width, mainCanvas.height );
105
106
 var imageData, data;
107
 try{
108
 imageData = newContext.getImageData( 0, 0, mainCanvas.width, mainCanvas.height );
109
 data = imageData.data;
110
 } catch(error){ // CORS error (eg when viewed from a local file). Create a solid fill background instead
111
 newContext.fillStyle = "yellow";
112
 newContext.fillRect( 0, 0, mainCanvas.width, mainCanvas.height );
113
 imageData = mainContext.createImageData( mainCanvas.width, mainCanvas.height );
114
 data = imageData.data;
115
 }
116
117
 //loop through each pixel, turning its color into a shade of grey
118
 for( var i = 0; i < data.length; i += 4 ){
119
 var red = data[i];
120
 var green = data[i + 1];
121
 var blue = data[i + 2];
122
 var grey = Math.max( red, green, blue );
123
124
 data[i] = grey;
125
 data[i+1] = grey;
126
 data[i+2] = grey;
127
 }
128
 newContext.putImageData( imageData, 0, 0 );
129
130
 //add the gradient overlay
131
 var gradient = newContext.createLinearGradient( mainCanvas.width/2, 0, mainCanvas.width/2, mainCanvas.height );
132
 gradient.addColorStop( 0, '#000' );
133
 gradient.addColorStop( 0.2, '#000' );
134
 gradient.addColorStop( 1, "rgba(0,0,0,0.5)" );
135
 newContext.fillStyle = gradient;
136
 newContext.fillRect( 0, 0, mainCanvas.width, mainCanvas.height );
137
138
 mainContext.save();
139
 mainContext.drawImage( newCanvas, 0, 0, mainCanvas.width, mainCanvas.height );
140
141
 mainContext.restore();
142
 }
143
144
 // renders the 'film reel' animation that scrolls across the canvas
145
 function drawFilm(){
146
 var sampleWidth = 116; // the width of a sampled video frame, when painted on the canvas as part of a 'film reel'
147
 var sampleHeight = 80; // the height of a sampled video frame, when painted on the canvas as part of a 'film reel'
148
 var filmSpeed = 20; // determines how fast the 'film reel' scrolls across the generated canvas animation.
149
 var filmTop = 120; //the y co-ordinate of the 'film reel' animation
150
 var filmAngle = -10 * Math.PI / 180; //the slant of the 'film reel'
151
 var filmRight = ( videoSamples.length > 0 )? videoSamples[0].x + videoSamples.length * sampleWidth : mainCanvas.width; //the right edge of the 'film reel' in pixels, relative to the canvas
152
153
 //here, we check if the first frame of the 'film reel' has scrolled out of view
154
 if( videoSamples.length > 0 ){
155
 var bottomLeftX = videoSamples[0].x + sampleWidth;
156
 var bottomLeftY = filmTop + sampleHeight;
157
 bottomLeftX = Math.floor( Math.cos(filmAngle) * bottomLeftX - Math.sin(filmAngle) * bottomLeftY ); // the final display position after rotation
158
159
 if( bottomLeftX < 0 ){ //the frame is offscreen, remove it's refference from the film array
160
 videoSamples.shift();
161
 }
162
 }
163
164
 // add new frames to the reel as required
165
 while( filmRight <= mainCanvas.width ){
166
 var newFrame = {};
167
 newFrame.x = filmRight;
168
 newFrame.canvas = sampleVideo();
169
 videoSamples.push(newFrame);
170
 filmRight += sampleWidth;
171
 }
172
173
 // create the gradient fill for the reel
174
 var gradient = mainContext.createLinearGradient( 0, 0, mainCanvas.width, mainCanvas.height );
175
 gradient.addColorStop( 0, '#0D0D0D' );
176
 gradient.addColorStop( 0.25, '#300A02' );
177
 gradient.addColorStop( 0.5, '#AF5A00' );
178
 gradient.addColorStop( 0.75, '#300A02' );
179
 gradient.addColorStop( 1, '#0D0D0D' );
180
181
 mainContext.save();
182
 mainContext.globalAlpha = 0.9;
183
 mainContext.fillStyle = gradient;
184
 mainContext.rotate(filmAngle);
185
186
 // loops through all items of film array, using the stored co-ordinate values of each to draw part of the 'film reel'
187
 for( var i in videoSamples ){
188
 var sample = videoSamples[i];
189
 var punchX, punchY, punchWidth = 4, punchHeight = 6, punchInterval = 11.5;
190
191
 //draws the main rectangular box of the sample
192
 mainContext.beginPath();
193
 mainContext.moveTo( sample.x, filmTop );
194
 mainContext.lineTo( sample.x + sampleWidth, filmTop );
195
 mainContext.lineTo( sample.x + sampleWidth, filmTop + sampleHeight );
196
 mainContext.lineTo( sample.x, filmTop + sampleHeight );
197
 mainContext.closePath();
198
199
 //adds the small holes lining the top and bottom edges of the 'fim reel'
200
 for( var j = 0; j < 10; j++ ){
201
 punchX = sample.x + ( j * punchInterval ) + 5;
202
 punchY = filmTop + 4;
203
 mainContext.moveTo( punchX, punchY + punchHeight );
204
 mainContext.lineTo( punchX + punchWidth, punchY + punchHeight );
205
 mainContext.lineTo( punchX + punchWidth, punchY );
206
 mainContext.lineTo( punchX, punchY );
207
 mainContext.closePath();
208
 punchX = sample.x + ( j * punchInterval ) + 5;
209
 punchY = filmTop + 70;
210
 mainContext.moveTo( punchX, punchY + punchHeight );
211
 mainContext.lineTo( punchX + punchWidth, punchY + punchHeight );
212
 mainContext.lineTo( punchX + punchWidth, punchY );
213
 mainContext.lineTo( punchX, punchY );
214
 mainContext.closePath();
215
 }
216
 mainContext.fill();
217
 }
218
219
 //loop through all items of videoSamples array, update the x co-ordinate values of each item, and draw its stored image onto the canvas
220
 mainContext.globalCompositeOperation = 'lighter';
221
 for( var i in videoSamples ){
222
 var sample = videoSamples[i];
223
 sample.x -= filmSpeed;
224
 mainContext.drawImage( sample.canvas, sample.x + 5, filmTop + 10, 110, 62 );
225
 }
226
227
 mainContext.restore();
228
 }
229
230
 // renders the canvas title
231
 function drawTitle(){
232
 mainContext.save();
233
 mainContext.fillStyle = 'black';
234
 mainContext.fillRect( 0, 0, 368, 25 );
235
 mainContext.fillStyle = 'white';
236
 mainContext.font = "bold 21px Georgia";
237
 mainContext.fillText( "SINTEL", 10, 20 );
238
 mainContext.restore();
239
 }
240
241
 // renders all the text appearing at the top left corner of the canvas
242
 function drawDescription(){
243
 var text = []; //stores all text items, to be displayed over time. the video is 60 seconds, and each will be visible for 10 seconds.
244
 text[0] = "Sintel is an independently produced short film, initiated by the Blender Foundation.";
245
 text[1] = "For over a year an international team of 3D animators and artists worked in the studio of the Amsterdam Blender Institute on the computer-animated short 'Sintel'.";
246
 text[2] = "It is an epic short film that takes place in a fantasy world, where a girl befriends a baby dragon.";
247
 text[3] = "After the little dragon is taken from her violently, she undertakes a long journey that leads her to a dramatic confrontation.";
248
 text[4] = "The script was inspired by a number of story suggestions by Martin Lodewijk around a Cinderella character (Cinder in Dutch is 'Sintel').";
249
 text[5] = "Screenwriter Esther Wouda then worked with director Colin Levy to create a script with multiple layers, with strong characterization and dramatic impact as central goals.";
250
 text = text[Math.floor( video.currentTime / 10 )]; //use the videos current time to determine which text item to display.
251
252
 mainContext.save();
253
 var alpha = 1 - ( video.currentTime % 10 ) / 10;
254
 mainContext.globalAlpha = ( alpha < 5 )? alpha : 1;
255
 mainContext.fillStyle = '#fff';
256
 mainContext.font = "normal 12px Georgia";
257
258
 //break the text up into several lines as required, and write each line on the canvas
259
 text = text.split('');
260
 var colWidth = mainCanvas.width * .75;
261
 var line = '';
262
 var y = 40;
263
 for(var i in text ){
264
 line += text[i] + '';
265
 if( mainContext.measureText(line).width > colWidth ){
266
 mainContext.fillText( line, 10, y );
267
 line = '';
268
 y += 12;
269
 }
270
 }
271
 mainContext.fillText( line, 10, y );
272
273
 mainContext.restore();
274
 }
275
276
 //updates the bottom-right potion of the canvas with the latest perfomance statistics
277
 function drawStats( average ){
278
 var x = 245.5, y = 130.5, graphScale = 0.25;
279
280
 mainContext.save();
281
 mainContext.font = "normal 10px monospace";
282
 mainContext.textAlign = 'left';
283
 mainContext.textBaseLine = 'top';
284
 mainContext.fillStyle = 'black';
285
 mainContext.fillRect( x, y, 120, 75 );
286
287
 //draw the x and y axis lines of the graph
288
 y += 30;
289
 x += 10;
290
 mainContext.beginPath();
291
 mainContext.strokeStyle = '#888';
292
 mainContext.lineWidth = 1.5;
293
 mainContext.moveTo( x, y );
294
 mainContext.lineTo( x + 100, y );
295
 mainContext.stroke();
296
 mainContext.moveTo( x, y );
297
 mainContext.lineTo( x, y - 25 );
298
 mainContext.stroke();
299
300
 // draw the last 50 speedLog entries on the graph
301
 mainContext.strokeStyle = '#00ffff';
302
 mainContext.fillStyle = '#00ffff';
303
 mainContext.lineWidth = 0.3;
304
 var imax = speedLog.length;
305
 var i = ( speedLog.length > 50 )? speedLog.length - 50 : 0
306
 mainContext.beginPath();
307
 for( var j = 0; i < imax; i++, j += 2 ){
308
 mainContext.moveTo( x + j, y );
309
 mainContext.lineTo( x + j, y - speedLog[i] * graphScale );
310
 mainContext.stroke();
311
 }
312
313
 // the red line, marking the desired maximum rendering time
314
 mainContext.beginPath();
315
 mainContext.strokeStyle = '#FF0000';
316
 mainContext.lineWidth = 1;
317
 var target = y - frameDuration * graphScale;
318
 mainContext.moveTo( x, target );
319
 mainContext.lineTo( x + 100, target );
320
 mainContext.stroke();
321
322
 // current/average speedLog items
323
 y += 12;
324
 if( average ){
325
 var speed = 0;
326
 for( i in speedLog ){ speed += speedLog[i]; }
327
 speed = Math.floor( speed / speedLog.length * 10) / 10;
328
 }else {
329
 speed = speedLog[speedLog.length-1];
330
 }
331
 mainContext.fillText( 'Render Time:' + speed, x, y );
332
333
 // canvas fps
334
 mainContext.fillStyle = '#00ff00';
335
 y += 12;
336
 if( average ){
337
 fps = 0;
338
 for( i in fpsLog ){ fps += fpsLog[i]; }
339
 fps = Math.floor( fps / fpsLog.length * 10) / 10;
340
 }else {
341
 fps = fpsLog[fpsLog.length-1];
342
 }
343
 mainContext.fillText( ' Canvas FPS:' + fps, x, y );
344
345
 // browser frames per second (fps), using window.mozPaintCount (firefox only)
346
 if( window.mozPaintCount ){
347
 y += 12;
348
 if( average ){
349
 fps = 0;
350
 for( i in paintCountLog ){ fps += paintCountLog[i]; }
351
 fps = Math.floor( fps / paintCountLog.length * 10) / 10;
352
 }else {
353
 fps = paintCountLog[paintCountLog.length-1];
354
 }
355
 mainContext.fillText( 'Browser FPS:' + fps, x, y );
356
 }
357
358
 mainContext.restore();
359
 }
360
361
 //draw the spining blade that appears in the begining of the animation
362
 function drawBlade(){
363
 if( !blade || blade.x > mainCanvas.width ){ return; }
364
 blade.x += 2.5;
365
 blade.angle = ( blade.angle - 45 ) % 360;
366
367
 //update blade, an ofscreen canvas containing the blade's image
368
 var angle = blade.angle * Math.PI / 180;
369
 var bladeContext = blade.getContext('2d');
370
 blade.width = blade.width; //clear the canvas
371
 bladeContext.save();
372
 bladeContext.translate( 200, 200 );
373
 bladeContext.rotate(angle);
374
 bladeContext.drawImage( bladeSrc, -bladeSrc.width/2, -bladeSrc.height/2 );
375
 bladeContext.restore();
376
377
 mainContext.save();
378
 mainContext.globalAlpha = 0.95;
379
 mainContext.drawImage( blade, blade.x, blade.y + Math.sin(angle) * 50 );
380
 mainContext.restore();
381
 }
382
})();

Step 3: Code Optimization: Know the Rules

The first rule of code performance optimization is: Don’t.

The point of this rule is to discourage optimization for optimization’s sake, since the process comes at a price.

A highly optimized script will be easier for the browser to parse and process, but usually with a burden for humans who will find it harder to follow and maintain. Whenever you do decide that some optimization is necessary, set some goals beforehand so that you don’t get carried away by the process and overdo it.

The goal in optimizing this widget will be to have the main() function run in less than 33 milliseconds as it’s supposed to, which will match the frame rate of the playing video files (sintel.mp4 and sintel.webm). These files were encoded at a playback speed of 30fps (thirty frames per second), which translates to about 0.33 seconds or 33 milliseconds per frame ( 1 second ÷ 30 frames ).

Since JavaScript draws a new animation frame to the canvas every time the main() function is called, the goal of our optimization process will be to make this function take 33 milliseconds or less each time it runs. This function repeatedly calls itself using a setTimeout() Javascript timer as shown below.

1
2
var frameDuration = 33; // set the animation's target speed in milliseconds
3
function main(){
4
 if( video.paused || video.ended ){ return false; }
5
 setTimeout( main, frameDuration );

The second rule: Don’t yet.

This rule stresses the point that optimization should always be done at the end of the development process when you’ve already fleshed out some complete, working code. The optimization police will let us go on this one, since the widget’s script is a perfect example of complete, working program that’s ready for the process.

The third rule: Don’t yet, and profile first.

This rule is about understanding your program in terms of runtime performance. Profiling helps you know rather than guess which functions or areas of the script take up the most time or are used most often, so that you can focus on those in the optimization process. It is critical enough to make leading browsers ship with inbuilt JavaScript profilers, or have extensions that provide this service.

I ran the widget under the profiler in Firebug, and below is a screenshot of the results.


Step 4: Set Some Performance Metrics

As you ran the widget, I’m sure you found all the Sintel stuff okay, and were absolutely blown away by the item on the lower right corner of the canvas, the one with a beautiful graph and shiny text.

It’s not just a pretty face; that box also delivers some real-time performance statistics on the running program. Its actually a simple, bare-bones Javascript profiler. That’s right! Yo, I heard you like profiling, so I put a profiler in your movie, so that you can profile it while you watch.

The graph tracks the Render Time, calculated by measuring how long each run of main() takes in milliseconds. Since this is the function that draws each frame of the animation, it’s effectively the animation’s frame rate. Each vertical blue line on the graph illustrates the time taken by one frame. The red horizontal line is the target speed, which we set at 33ms to match the video file frame rates. Just below the graph, the speed of the last call to main() is given in milliseconds.

The profiler is also a handy browser rendering speed test. At the moment, the average render time in Firefox is 55ms, 90ms in IE 9, 41ms in Chrome, 148ms in Opera and 63ms in Safari. All the browsers were running on Windows XP, except for IE 9 which was profiled on Windows Vista.

The next metric below that is Canvas FPS (canvas frames per second), obtained by counting how many times main() is called per second. The profiler displays the latest Canvas FPS rate when the video is still playing, and when it ends it shows the average speed of all calls to main().

The last metric is Browser FPS, which measures how many the browser repaints the current window every second. This one is only available if you view the widget in Firefox, as it depends on a feature currently only available in that browser called window.mozPaintCount., a JavaScript property that keeps track of how many times the browser window has been repainted since the webpage first loaded.

The repaints usually occur when an event or action that changes the look of a page occurs, like when you scroll down the page or mouse-over a link. It’s effectively the browser’s real frame rate, which is determined by how busy the current webpage is.

To gauge what effect the un-optimized canvas animation had on mozPaintCount, I removed the canvas tag and all the JavaScript, so as to track the browser frame rate when playing just the video. My tests were done in Firebug’s console, using the function below:

1
 var lastPaintCount = window.mozPaintCount;
2
 setInterval( function(){
3
 console.log( window.mozPaintCount - lastPaintCount );
4
 lastPaintCount = window.mozPaintCount;
5
 }, 1000);

The results: The browser frame rate was between 30 and 32 FPS when the video was playing, and dropped to 0-1 FPS when the video ended. This means that Firefox was adjusting its window repaint frequency to match that of the playing video, encoded at 30fps. When the test was run with the un-optimized canvas animation and video playing together, it slowed down to 16fps, as the browser was now struggling to run all the JavaScript and still repaint its window on time, making both the video playback and canvas animations sluggish.

We’ll now start tweaking our program, and as we do so, we’ll keep track of the Render Time, Canvas FPS and Browser FPS to measure the effects of our changes.


Step 5: Use requestAnimationFrame()

The last two JavaScript snippets above make use of the setTimeout() and setInterval() timer functions. To use these functions, you specify a time interval in milliseconds and the callback function you want executed after the time elapses. The difference between the two is that setTimeout() will call your function just once, while setInterval() calls it repeatedly.

While these functions have always been indispensable tools in the JavaScript animator’s kit, they do have a few flaws:

First, the time interval set is not always reliable. If the program is still in the middle of executing something else when the interval elapses, the callback function will be executed later than originally set, once the browser is no longer busy. In the main() function, we set the interval to 33 milliseconds – but as the profiler reveals, the function is actually called every 148 milliseconds in Opera.

Second, there’s an issue with browser repaints. If we had a callback function that generated 20 animation frames per second while the browser repainted its window only 12 times a second, 8 calls to that function will be wasted as the user will never get to see the results.

Finally, the browser has no way of knowing that the function being called is animating elements in the document. This means that if those elements scroll out of view, or the user clicks on another tab, the callback will still get executed repeatedly, wasting CPU cycles.

Using requestAnimationFrame() solves most of these problems, and it can be used instead of the timer functions in HTML5 animations. Instead of specifying a time interval, requestAnimationFrame() synchronizes the function calls with browser window repaints. This results in more fluid, consistent animation as no frames are dropped, and the browser can make further internal optimizations knowing an animation is in progress.

To replace setTimeout() with requestAnimationFrame in our widget, we first add the following line at the top of our script:

1
requestAnimationFrame = window.requestAnimationFrame ||
2
 window.mozRequestAnimationFrame ||
3
 window.webkitRequestAnimationFrame ||
4
 window.msRequestAnimationFrame ||
5
 setTimeout;

As the specification is still quite new, some browsers or browser versions have their own experimental implementations, this line makes sure that the function name points to the right method if it is available, and falls back to setTimeout() if not. Then in the main() function, we change this line:

1
2
 setTimeout( main, frameDuration );

…to:

1
 requestAnimationFrame( main, canvas );

The first parameter takes the callback function, which in this case is the main() function. The second parameter is optional, and specifies the DOM element that contains the animation. It is supposed to be used by to compute additional optimizations.

Note that the getStats() function also uses a setTimeout(), but we leave that one in place since this particular function has nothing to do with animating the scene. requestAnimationFrame() was created specifically for animations, so if your callback function is not doing animation, you can still use setTimeout() or setInterval().


Step 6: Use the Page Visibility API

In the last step we made requestAnimationFrame power the canvas animation, and now we have a new problem. If we start running the widget, then minimize the browser window or switch to a new tab, the widget’s window repaint rate throttles down to save power. This also slows down the canvas animation since it is now synchronized with the repaint rate – which would be perfect if the video did not keep playing on to the end.

We need a way to detect when the page is not being viewed so that we can pause the playing video; this is where the Page Visibility API comes to the rescue.

The API contains a set of properties, functions and events we can use to detect if a webpage is in view or hidden. We can then add code that adjusts our program’s behavior accordingly. We will make use of this API to pause the widget’s playing video whenever the page is inactive.

We start by adding a new event listener to our script:

1
2
 document.addEventListener( 'visibilitychange', onVisibilityChange, false);

Next comes the event handler function:

1
2
// Adjusts the program behavior, based on whether the webpage is active or hidden
3
function onVisibilityChange() {
4
 if( document.hidden && !video.paused ){
5
 video.pause();
6
 }else if( video.paused ){
7
 video.play();
8
 }
9
}

Step 7: For Custom Shapes, Draw the Whole Path At Once

Paths are used to create and draw custom shapes and outlines on the <canvas> element, which will at all times have one active path.

A path holds a list of sub-paths, and each sub-path is made up of canvas co-ordinate points linked together by either a line or a curve. All the path making and drawing functions are properties of the canvas’s context object, and can be classified into two groups.

There are the subpath-making functions, used to define a subpath and include lineTo(), quadraticCurveTo(), bezierCurveTo(), and arc(). Then we have stroke() and fill(), the path/subpath drawing functions. Using stroke() will produce an outline, while fill() generates a shape filled by either a color, gradient or pattern.

When drawing shapes and outline on the canvas, it is more efficient to create the whole path first, then just stroke() or fill() it once, rather than defining and drawing each supbath at a time. Taking the profiler’s graph described in Step 4 as an example, each single vertical blue line is a subpath, while all of them together make up the whole current path.

The stroke() method is currently being called within a loop that defines each subpath:

1
2
 mainContext.beginPath();
3
 for( var j = 0; i < imax; i++, j += 2 ){
4
 mainContext.moveTo( x + j, y ); // define the subpaths starting point
5
 mainContext.lineTo( x + j, y - speedLog[i] * graphScale ); // set the subpath as a line, and define its endpoint
6
 mainContext.stroke(); // draw the subpath to the canvas
7
 }

This graph can be drawn much more efficiently by first defining all the subpaths, then just drawing the whole current path at once, as shown below.

1
2
 mainContext.beginPath();
3
 for( var j = 0; i < imax; i++, j += 2 ){
4
 mainContext.moveTo( x + j, y ); // define the subpaths starting point
5
 mainContext.lineTo( x + j, y - speedLog[i] * graphScale ); // set the subpath as a line, and define its endpoint
6
 }
7
 mainContext.stroke(); // draw the whole current path to the mainCanvas.

Step 8: Use an Off-Screen Canvas To Build the Scene

This optimization technique is related to the one in the previous step, in that they are both based on the same principle of minimizing webpage repaints.

Whenever something happens that changes a document’s look or content, the browser has to schedule a repaint operation soon after that to update the interface. Repaints can be an expensive operation in terms of CPU cycles and power, especially for dense pages with a lot of elements and animation going on. If you are building up a complex animation scene by adding up many items one at a time to the <canvas>, every new addition may just trigger a whole repaint.

It is better and much faster to build the scene on an off screen (in memory) <canvas>, and once done, paint the whole scene just once to the onscreen, visible <canvas>.

Just below the code that gets reference to the widget’s <canvas> and its context, we’ll add five new lines that create an off-screen canvas DOM object and match its dimensions with that of the original, visible <canvas>.

1
2
 var mainCanvas = document.getElementById("mainCanvas"); // points to the on-screen, original HTML canvas element
3
 var mainContext = mainCanvas.getContext('2d'); // the drawing context of the on-screen canvas element
4
 var osCanvas = document.createElement("canvas"); // creates a new off-screen canvas element
5
 var osContext = osCanvas.getContext('2d'); //the drawing context of the off-screen canvas element
6
 osCanvas.width = mainCanvas.width; // match the off-screen canvas dimensions with that of #mainCanvas
7
 osCanvas.height = mainCanvas.height;

We’ll then do as search and replace in all the drawing functions for all references to “mainCanvas” and change that to “osCanvas”. References to “mainContext” will be replaced with “osContext”. Everything will now be drawn to the new off-screen canvas, instead of the original <canvas>.

Finally, we add one more line to main() that paints what’s currently on the off-screen <canvas> into our original <canvas>.

1
2
// As the scripts main function, it controls the pace of the animation
3
function main(){
4
 requestAnimationFrame( main, mainCanvas );
5
 if( video.paused || video.currentTime > 59 ){ return; }
6
7
 var now = new Date().getTime();
8
 if( frameStartTime ){
9
 speedLog.push( now - frameStartTime );
10
 }
11
 frameStartTime = now;
12
 if( video.readyState < 2 ){ return; }
13
14
 frameCount++;
15
 osCanvas.width = osCanvas.width; //clear the offscreen canvas
16
 drawBackground();
17
 drawFilm();
18
 drawDescription();
19
 drawStats();
20
 drawBlade();
21
 drawTitle();
22
 mainContext.drawImage( osCanvas, 0, 0 ); // copy the off-screen canvas graphics to the on-screen canvas
23
}

Step 9: Cache Paths As Bitmap Images Whenever Possible

For many kinds of graphics, using drawImage() will be much faster than constructing the same image on canvas using paths. If you find that a large potion of your script is spent repeatedly drawing the same shapes and outlines over and over again, you may save the browser some work by caching the resulting graphic as a bitmap image, then painting it just once to the canvas whenever required using drawImage().

There are two ways of doing this.

The first is by creating an external image file as a JPG, GIF or PNG image, then loading it dynamically using JavaScript and copying it to your canvas. The one drawback of this method is the extra files your program will have to download from the network, but depending on the type of graphic or what your application does, this could actually be a good solution. The animation widget uses this method to load the spinning blade graphic, which would have been impossible to recreate using just the canvas path drawing functions.

The second method involves just drawing the graphic once to an off-screen canvas rather than loading an external image. We will use this method to cache the title of the animation widget. We first create a variable to reference the new off-screen canvas element to be created. Its default value is set to false, so that we can tell whether or not an image cache has been created, and saved once the script starts running:

1
2
 var titleCache = false; // points to an off-screen canvas used to cache the animation scene's title

We then edit the drawTitle() function to first check whether the titleCache canvas image has been created. If it hasn’t, it creates an off-screen image and stores a reference to it in titleCache:

1
2
// renders the canvas title
3
function drawTitle(){
4
 if( titleCache == false ){ // create and save the title image
5
 titleCache = document.createElement('canvas');
6
 titleCache.width = osCanvas.width;
7
 titleCache.height = 25;
8
9
 var context = titleCache.getContext('2d');
10
 context.fillStyle = 'black';
11
 context.fillRect( 0, 0, 368, 25 );
12
 context.fillStyle = 'white';
13
 context.font = "bold 21px Georgia";
14
 context.fillText( "SINTEL", 10, 20 );
15
 }
16
17
 osContext.drawImage( titleCache, 0, 0 );
18
}

Step 10: Clear the Canvas With clearRect()

The first step in drawing a new animation frame is to clear the canvas of the current one. This can be done by either resetting the width of the canvas element, or using the clearRect() function.

Resetting the width has a side effect of also clearing the current canvas context back to its default state, which can slow things down. Using clearRect() is always the faster and better way to clear the canvas.

In the main() function, we’ll change this:

1
2
 osCanvas.width = osCanvas.width; //clear the off-screen canvas

…to this:

1
2
 osContext.clearRect( 0, 0, osCanvas.width, osCanvas.height ); //clear the offscreen canvas

Step 11: Implement Layers

If you’ve worked with image or video editing software like Gimp or Photoshop before, then you’re already familiar with the concept of layers, where an image is composed by stacking many images on top of one another, and each can be selected and edited separately.

Applied to a canvas animation scene, each layer will be a separate canvas element, placed on top of each other using CSS to create the illusion of a single element. As an optimization technique, it works best when there is a clear distinction between foreground and background elements of a scene, with most of the action taking place in the foreground. The background can then be drawn on a canvas element that does not change much between animation frames, and the foreground on another more dynamic canvas element above it. This way, the whole scene doesn’t have to be redrawn again for each animation frame.

Unfortunately, the animation widget is a good example of a scene where we cannot usefully apply this technique, since both the foreground and background elements are highly animated.


Step 12: Update Only The Changing Areas of an Animation Scene

This is another optimization technique that depends heavily on the animation’s scene composition. It can be used when the scene animation is concentrated around a particular rectangular region on the canvas. We could then clear and redraw just redraw that region.

For example, the Sintel title remains unchanged throughout most of the animation, so we could leave that area intact when clearing the canvas for the next animation frame.

To implement this technique, we replace the line that calls the title drawing function in main() with the following block:

1
2
 if( titleCache == false ){ // If titleCache is false, the animation's title hasn't been drawn yet
3
 drawTitle(); // we draw the title. This function will now be called just once, when the program starts
4
 osContext.rect( 0, 25, osCanvas.width, osCanvas.height ); // this creates a path covering the area outside by the title
5
 osContext.clip(); // we use the path to create a clipping region, that ignores the title's region
6
 }

Step 13: Minimize Sub-Pixel Rendering

Sub-pixel rendering or anti-aliasing happens when the browser automatically applies graphic effects to remove jagged edges. It results in smoother looking images and animations, and is automatically activated whenever you specify fractional co-ordinates rather than whole number when drawing to the canvas.

Right now there is no standard on exactly how it should be done, so subpixel rendering is a bit inconsistent across browsers in terms of the rendered output. It also slows down rendering speeds as the browser has to do some calculations to generate the effect. As canvas anti-aliasing cannot be directly turned off, the only way to get around it is by always using whole numbers in your drawing co-ordinates.

We will use Math.floor() to ensure whole numbers in our script whenever applicable. For example, the following line in drawFilm():

1
2
 punchX = sample.x + ( j * punchInterval ) + 5; // the x co-ordinate

…is rewritten as:

1
2
 punchX = Math.floor( sample.x + ( j * punchInterval ) ) + 5; // the x co-ordinate

Step 14: Measure the Results

We’ve looked at quite a few canvas animation optimization techniques, and it now time to review the results.

This table shows the before and after average Render Times and Canvas FPS. We can see some significant improvements across all the browsers, though it’s only Chrome that really comes close to achieving our original goal of a maximum 33ms Render Time. This means there is still much work to be done to get that target.

We could proceed by applying more general JavaScript optimization techniques, and if that still fails, maybe consider toning down the animation by removing some bells and whistles. But we won’t be looking at any of those other techniques today, as the focus here was on optimizations for <canvas> animation.

The Canvas API is still quite new and growing every day, so keep experimenting, testing, exploring and sharing. Thanks for reading the tutorial.