Advertisement
  1. Web Design
  2. Animation

HTML5 Canvas Optimization: A Practical Example

Scroll to top
Read Time: 34 min

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 = 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.

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Web Design tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.