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>:

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

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.


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.

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:

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:

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:

...to:

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:

Next comes the event handler function:


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:

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.


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

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


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:

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:


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:

...to this:


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:


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():

...is rewritten as:


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.

Tags:

Comments

Related Articles