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>
:
<div id="animationWidget" > <canvas width="368" height="208" id="mainCanvas" ></canvas> <video width="184" height="104" id="video" autobuffer="autobuffer" controls="controls" poster="poster.jpg" > <source src="sintel.mp4" type="video/mp4" ></source> <source src="sintel.webm" type="video/webm" ></source> </video> </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).
#animationWidget{ border:1px #222 solid; position:relative; width: 570px; height: 220px; } #animationWidget canvas{ border:1px #222 solid; position:absolute; top:5px; left:5px; } #animationWidget video{ position:absolute; top:110px; left:380px; }
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, whilesetBlade()
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.
(function(){ if( !document.createElement("canvas").getContext ){ return; } //the canvas tag isn't supported var mainCanvas = document.getElementById("mainCanvas"); // points to the HTML canvas element above var mainContext = mainCanvas.getContext('2d'); //the drawing context of the canvas element var video = document.getElementById("video"); // points to the HTML video element var frameDuration = 33; // the animation's speed in milliseconds video.addEventListener( 'play', init ); // The init() function is called whenever the user presses play & the video starts/continues playing video.addEventListener( 'ended', function(){ drawStats(true); } ); //drawStats() is called one last time when the video end, to sum up all the statistics 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' 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 var blade; //An canvas element to store the image copied from blade.png var bladeSrc = 'blade.png'; //path to the blade's image source file var lastPaintCount = 0; // stores the last value of mozPaintCount sampled var paintCountLog = []; // an array containing all measured values of mozPaintCount over time var speedLog = []; // an array containing all the execution speeds of main(), measured in milliseconds var fpsLog = []; // an array containing the calculated frames per secong (fps) of the script, measured by counting the calls made to main() per second var frameCount = 0; // counts the number of times main() is executed per second. var frameStartTime = 0; // the last time main() was called // Called when the video starts playing. Sets up all the javascript objects required to generate the canvas animation and measure perfomance function init(){ if( video.currentTime > 1 ){ return; } bladeSrc = new Image(); bladeSrc.src = "blade.png"; bladeSrc.onload = setBlade; backgrounds = []; videoSamples = []; fpsLog = []; paintCountLog = []; if( window.mozPaintCount ){ lastPaintCount = window.mozPaintCount; } speedLog = []; frameCount = 0; frameStartTime = 0; main(); setTimeout( getStats, 1000 ); } // As the scripts main function, it controls the pace of the animation function main(){ setTimeout( main, frameDuration ); if( video.paused || video.ended ){ return; } var now = new Date().getTime(); if( frameStartTime ){ speedLog.push( now - frameStartTime ); } frameStartTime = now; if( video.readyState < 2 ){ return; } frameCount++; mainCanvas.width = mainCanvas.width; //clear the canvas drawBackground(); drawFilm(); drawDescription(); drawStats(); drawBlade(); drawTitle(); } // This function is called every second, and it calculates and stores the current frame rate function getStats(){ if( video.readyState >= 2 ){ 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 paintCountLog.push( window.mozPaintCount - lastPaintCount ); lastPaintCount = window.mozPaintCount; } fpsLog.push(frameCount); frameCount = 0; } setTimeout( getStats, 1000 ); } // create blade, the ofscreen canavs that will contain the spining animation of the image copied from blade.png function setBlade(){ blade = document.createElement("canvas"); blade.width = 400; blade.height = 400; blade.angle = 0; blade.x = -blade.height * 0.5; blade.y = mainCanvas.height/2 - blade.height/2; } // Creates and returns a new image that contains a snapshot of the currently playing video. function sampleVideo(){ var newCanvas = document.createElement("canvas"); newCanvas.width = video.width; newCanvas.height = video.height; newCanvas.getContext("2d").drawImage( video, 0, 0, video.width, video.height ); return newCanvas; } // renders the dark background for the whole canvas element. The background features a greyscale sample of the video and a gradient overlay function drawBackground(){ var newCanvas = document.createElement("canvas"); var newContext = newCanvas.getContext("2d"); newCanvas.width = mainCanvas.width; newCanvas.height = mainCanvas.height; newContext.drawImage( video, 0, video.height * 0.1, video.width, video.height * 0.5, 0, 0, mainCanvas.width, mainCanvas.height ); var imageData, data; try{ imageData = newContext.getImageData( 0, 0, mainCanvas.width, mainCanvas.height ); data = imageData.data; } catch(error){ // CORS error (eg when viewed from a local file). Create a solid fill background instead newContext.fillStyle = "yellow"; newContext.fillRect( 0, 0, mainCanvas.width, mainCanvas.height ); imageData = mainContext.createImageData( mainCanvas.width, mainCanvas.height ); data = imageData.data; } //loop through each pixel, turning its color into a shade of grey for( var i = 0; i < data.length; i += 4 ){ var red = data[i]; var green = data[i + 1]; var blue = data[i + 2]; var grey = Math.max( red, green, blue ); data[i] = grey; data[i+1] = grey; data[i+2] = grey; } newContext.putImageData( imageData, 0, 0 ); //add the gradient overlay var gradient = newContext.createLinearGradient( mainCanvas.width/2, 0, mainCanvas.width/2, mainCanvas.height ); gradient.addColorStop( 0, '#000' ); gradient.addColorStop( 0.2, '#000' ); gradient.addColorStop( 1, "rgba(0,0,0,0.5)" ); newContext.fillStyle = gradient; newContext.fillRect( 0, 0, mainCanvas.width, mainCanvas.height ); mainContext.save(); mainContext.drawImage( newCanvas, 0, 0, mainCanvas.width, mainCanvas.height ); mainContext.restore(); } // renders the 'film reel' animation that scrolls across the canvas function drawFilm(){ var sampleWidth = 116; // the width of a sampled video frame, when painted on the canvas as part of a 'film reel' var sampleHeight = 80; // the height of a sampled video frame, when painted on the canvas as part of a 'film reel' var filmSpeed = 20; // determines how fast the 'film reel' scrolls across the generated canvas animation. var filmTop = 120; //the y co-ordinate of the 'film reel' animation var filmAngle = -10 * Math.PI / 180; //the slant of the 'film reel' 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 //here, we check if the first frame of the 'film reel' has scrolled out of view if( videoSamples.length > 0 ){ var bottomLeftX = videoSamples[0].x + sampleWidth; var bottomLeftY = filmTop + sampleHeight; bottomLeftX = Math.floor( Math.cos(filmAngle) * bottomLeftX - Math.sin(filmAngle) * bottomLeftY ); // the final display position after rotation if( bottomLeftX < 0 ){ //the frame is offscreen, remove it's refference from the film array videoSamples.shift(); } } // add new frames to the reel as required while( filmRight <= mainCanvas.width ){ var newFrame = {}; newFrame.x = filmRight; newFrame.canvas = sampleVideo(); videoSamples.push(newFrame); filmRight += sampleWidth; } // create the gradient fill for the reel var gradient = mainContext.createLinearGradient( 0, 0, mainCanvas.width, mainCanvas.height ); gradient.addColorStop( 0, '#0D0D0D' ); gradient.addColorStop( 0.25, '#300A02' ); gradient.addColorStop( 0.5, '#AF5A00' ); gradient.addColorStop( 0.75, '#300A02' ); gradient.addColorStop( 1, '#0D0D0D' ); mainContext.save(); mainContext.globalAlpha = 0.9; mainContext.fillStyle = gradient; mainContext.rotate(filmAngle); // loops through all items of film array, using the stored co-ordinate values of each to draw part of the 'film reel' for( var i in videoSamples ){ var sample = videoSamples[i]; var punchX, punchY, punchWidth = 4, punchHeight = 6, punchInterval = 11.5; //draws the main rectangular box of the sample mainContext.beginPath(); mainContext.moveTo( sample.x, filmTop ); mainContext.lineTo( sample.x + sampleWidth, filmTop ); mainContext.lineTo( sample.x + sampleWidth, filmTop + sampleHeight ); mainContext.lineTo( sample.x, filmTop + sampleHeight ); mainContext.closePath(); //adds the small holes lining the top and bottom edges of the 'fim reel' for( var j = 0; j < 10; j++ ){ punchX = sample.x + ( j * punchInterval ) + 5; punchY = filmTop + 4; mainContext.moveTo( punchX, punchY + punchHeight ); mainContext.lineTo( punchX + punchWidth, punchY + punchHeight ); mainContext.lineTo( punchX + punchWidth, punchY ); mainContext.lineTo( punchX, punchY ); mainContext.closePath(); punchX = sample.x + ( j * punchInterval ) + 5; punchY = filmTop + 70; mainContext.moveTo( punchX, punchY + punchHeight ); mainContext.lineTo( punchX + punchWidth, punchY + punchHeight ); mainContext.lineTo( punchX + punchWidth, punchY ); mainContext.lineTo( punchX, punchY ); mainContext.closePath(); } mainContext.fill(); } //loop through all items of videoSamples array, update the x co-ordinate values of each item, and draw its stored image onto the canvas mainContext.globalCompositeOperation = 'lighter'; for( var i in videoSamples ){ var sample = videoSamples[i]; sample.x -= filmSpeed; mainContext.drawImage( sample.canvas, sample.x + 5, filmTop + 10, 110, 62 ); } mainContext.restore(); } // renders the canvas title function drawTitle(){ mainContext.save(); mainContext.fillStyle = 'black'; mainContext.fillRect( 0, 0, 368, 25 ); mainContext.fillStyle = 'white'; mainContext.font = "bold 21px Georgia"; mainContext.fillText( "SINTEL", 10, 20 ); mainContext.restore(); } // renders all the text appearing at the top left corner of the canvas function drawDescription(){ var text = []; //stores all text items, to be displayed over time. the video is 60 seconds, and each will be visible for 10 seconds. text[0] = "Sintel is an independently produced short film, initiated by the Blender Foundation."; 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'."; text[2] = "It is an epic short film that takes place in a fantasy world, where a girl befriends a baby dragon."; text[3] = "After the little dragon is taken from her violently, she undertakes a long journey that leads her to a dramatic confrontation."; text[4] = "The script was inspired by a number of story suggestions by Martin Lodewijk around a Cinderella character (Cinder in Dutch is 'Sintel'). "; 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."; text = text[Math.floor( video.currentTime / 10 )]; //use the videos current time to determine which text item to display. mainContext.save(); var alpha = 1 - ( video.currentTime % 10 ) / 10; mainContext.globalAlpha = ( alpha < 5 )? alpha : 1; mainContext.fillStyle = '#fff'; mainContext.font = "normal 12px Georgia"; //break the text up into several lines as required, and write each line on the canvas text = text.split(' '); var colWidth = mainCanvas.width * .75; var line = ''; var y = 40; for(var i in text ){ line += text[i] + ' '; if( mainContext.measureText(line).width > colWidth ){ mainContext.fillText( line, 10, y ); line = ''; y += 12; } } mainContext.fillText( line, 10, y ); mainContext.restore(); } //updates the bottom-right potion of the canvas with the latest perfomance statistics function drawStats( average ){ var x = 245.5, y = 130.5, graphScale = 0.25; mainContext.save(); mainContext.font = "normal 10px monospace"; mainContext.textAlign = 'left'; mainContext.textBaseLine = 'top'; mainContext.fillStyle = 'black'; mainContext.fillRect( x, y, 120, 75 ); //draw the x and y axis lines of the graph y += 30; x += 10; mainContext.beginPath(); mainContext.strokeStyle = '#888'; mainContext.lineWidth = 1.5; mainContext.moveTo( x, y ); mainContext.lineTo( x + 100, y ); mainContext.stroke(); mainContext.moveTo( x, y ); mainContext.lineTo( x, y - 25 ); mainContext.stroke(); // draw the last 50 speedLog entries on the graph mainContext.strokeStyle = '#00ffff'; mainContext.fillStyle = '#00ffff'; mainContext.lineWidth = 0.3; var imax = speedLog.length; var i = ( speedLog.length > 50 )? speedLog.length - 50 : 0 mainContext.beginPath(); for( var j = 0; i < imax; i++, j += 2 ){ mainContext.moveTo( x + j, y ); mainContext.lineTo( x + j, y - speedLog[i] * graphScale ); mainContext.stroke(); } // the red line, marking the desired maximum rendering time mainContext.beginPath(); mainContext.strokeStyle = '#FF0000'; mainContext.lineWidth = 1; var target = y - frameDuration * graphScale; mainContext.moveTo( x, target ); mainContext.lineTo( x + 100, target ); mainContext.stroke(); // current/average speedLog items y += 12; if( average ){ var speed = 0; for( i in speedLog ){ speed += speedLog[i]; } speed = Math.floor( speed / speedLog.length * 10) / 10; }else { speed = speedLog[speedLog.length-1]; } mainContext.fillText( 'Render Time: ' + speed, x, y ); // canvas fps mainContext.fillStyle = '#00ff00'; y += 12; if( average ){ fps = 0; for( i in fpsLog ){ fps += fpsLog[i]; } fps = Math.floor( fps / fpsLog.length * 10) / 10; }else { fps = fpsLog[fpsLog.length-1]; } mainContext.fillText( ' Canvas FPS: ' + fps, x, y ); // browser frames per second (fps), using window.mozPaintCount (firefox only) if( window.mozPaintCount ){ y += 12; if( average ){ fps = 0; for( i in paintCountLog ){ fps += paintCountLog[i]; } fps = Math.floor( fps / paintCountLog.length * 10) / 10; }else { fps = paintCountLog[paintCountLog.length-1]; } mainContext.fillText( 'Browser FPS: ' + fps, x, y ); } mainContext.restore(); } //draw the spining blade that appears in the begining of the animation function drawBlade(){ if( !blade || blade.x > mainCanvas.width ){ return; } blade.x += 2.5; blade.angle = ( blade.angle - 45 ) % 360; //update blade, an ofscreen canvas containing the blade's image var angle = blade.angle * Math.PI / 180; var bladeContext = blade.getContext('2d'); blade.width = blade.width; //clear the canvas bladeContext.save(); bladeContext.translate( 200, 200 ); bladeContext.rotate(angle); bladeContext.drawImage( bladeSrc, -bladeSrc.width/2, -bladeSrc.height/2 ); bladeContext.restore(); mainContext.save(); mainContext.globalAlpha = 0.95; mainContext.drawImage( blade, blade.x, blade.y + Math.sin(angle) * 50 ); mainContext.restore(); } })();
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.
var frameDuration = 33; // set the animation's target speed in milliseconds function main(){ if( video.paused || video.ended ){ return false; } 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:
var lastPaintCount = window.mozPaintCount; setInterval( function(){ console.log( window.mozPaintCount - lastPaintCount ); lastPaintCount = window.mozPaintCount; }, 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:
requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.msRequestAnimationFrame || 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:
setTimeout( main, frameDuration );
...to:
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:
document.addEventListener( 'visibilitychange', onVisibilityChange, false);
Next comes the event handler function:
// Adjusts the program behavior, based on whether the webpage is active or hidden function onVisibilityChange() { if( document.hidden && !video.paused ){ video.pause(); }else if( video.paused ){ video.play(); } }
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:
mainContext.beginPath(); for( var j = 0; i < imax; i++, j += 2 ){ mainContext.moveTo( x + j, y ); // define the subpaths starting point mainContext.lineTo( x + j, y - speedLog[i] * graphScale ); // set the subpath as a line, and define its endpoint mainContext.stroke(); // draw the subpath to the canvas }
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.
mainContext.beginPath(); for( var j = 0; i < imax; i++, j += 2 ){ mainContext.moveTo( x + j, y ); // define the subpaths starting point mainContext.lineTo( x + j, y - speedLog[i] * graphScale ); // set the subpath as a line, and define its endpoint } 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>
.
var mainCanvas = document.getElementById("mainCanvas"); // points to the on-screen, original HTML canvas element var mainContext = mainCanvas.getContext('2d'); // the drawing context of the on-screen canvas element var osCanvas = document.createElement("canvas"); // creates a new off-screen canvas element var osContext = osCanvas.getContext('2d'); //the drawing context of the off-screen canvas element osCanvas.width = mainCanvas.width; // match the off-screen canvas dimensions with that of #mainCanvas 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>
.
// As the scripts main function, it controls the pace of the animation function main(){ requestAnimationFrame( main, mainCanvas ); if( video.paused || video.currentTime > 59 ){ return; } var now = new Date().getTime(); if( frameStartTime ){ speedLog.push( now - frameStartTime ); } frameStartTime = now; if( video.readyState < 2 ){ return; } frameCount++; osCanvas.width = osCanvas.width; //clear the offscreen canvas drawBackground(); drawFilm(); drawDescription(); drawStats(); drawBlade(); drawTitle(); mainContext.drawImage( osCanvas, 0, 0 ); // copy the off-screen canvas graphics to the on-screen canvas }
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:
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
:
// renders the canvas title function drawTitle(){ if( titleCache == false ){ // create and save the title image titleCache = document.createElement('canvas'); titleCache.width = osCanvas.width; titleCache.height = 25; var context = titleCache.getContext('2d'); context.fillStyle = 'black'; context.fillRect( 0, 0, 368, 25 ); context.fillStyle = 'white'; context.font = "bold 21px Georgia"; context.fillText( "SINTEL", 10, 20 ); } osContext.drawImage( titleCache, 0, 0 ); }
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:
osCanvas.width = osCanvas.width; //clear the off-screen canvas
...to this:
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:
if( titleCache == false ){ // If titleCache is false, the animation's title hasn't been drawn yet drawTitle(); // we draw the title. This function will now be called just once, when the program starts osContext.rect( 0, 25, osCanvas.width, osCanvas.height ); // this creates a path covering the area outside by the title osContext.clip(); // we use the path to create a clipping region, that ignores the title's region }
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()
:
punchX = sample.x + ( j * punchInterval ) + 5; // the x co-ordinate
...is rewritten as:
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.
Comments