Improving <canvas> performance – never underestimate copy and paste

As part of a current pet project (more on that some other time) I stumbled upon a fantastic canvas-based library for creating heat maps- the aptly named heatmap.js. It only has one flaw- when you start adding a lot of data, it’s slow. That’s a problem for me, because I want to animate my heat maps. With no modifications, the map was rendering at around 3fps on my 2010 Macbook Air.

How does this thing work anyway?

The first step was to find out how the library worked in the first place. It’s pretty ingenious- drawing monochromatic blurred circles for each of the data points, at different alpha levels depending on value. Then it redraws pixel by pixel, colorising according to the alpha value of the pixel. This means that overlapping points combine alphas, dictating the overall colour.

Problem #1: this involves using <canvas>’s getImageData() function- which has some serious performance problems. Surprise #1: while different browsers have huge performance differences, all benefitted by splitting the map into multiple canvases and reading them in turn. As it happens, that suits my needs perfectly- I am going to be displaying the heat map on top of tiled map images.

Still not good enough

It was an improvement, but I was still looking at around 12fps in Chrome- not bad, but not ideal. With the getImageData() parts optimised as best I could manage, I looked at the next big drain- the initial drawing of blurred data points. Problem #2: what can I do that is any simpler than drawing a circle? Surprise #2: I can just copy and paste the same circle over and over.

The existing code was drawing it’s circles like so:

ctx.shadowColor = ('rgba(0,0,0,'+((count)?(count/me.store.max):'0.1')+')');
ctx.shadowOffsetX = 1000;
ctx.shadowOffsetY = 1000;
ctx.shadowBlur = 15;
ctx.beginPath();
ctx.arc(x - 1000, y - 1000, radius, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fill();

I extracted that code and drew it (with 1.0 opacity) on another separate <canvas>. Then all I needed to do for each point was:

var pointTemplate = me.get("pointTemplate"); // the canvas tag
ctx.globalAlpha = ((count)?(count/me.store.max):0.1)
ctx.drawImage(pointTemplate,x-offset,y-offset)

It didn’t seem like it ought to be faster- switching between two different tags and running drawImage() seemed like it would be far more complex than drawing a simple circle. But I was wrong- the performance increase was massive- take a look at the before and after test pages. There are undoubtedly further improvements to be made, but my heat maps are now flowing along at over 30fps, which gives me a good amount of leeway as I continue to develop my project. I would love to be able to transfer these calculations to a web worker, but alas they have no DOM, and so there’s no way for me to get a 2D drawing context.

Did I miss something fundamental? Any ideas for improvements? Let me know!

  • ManiSto

    Image drawing is extremely fast, and a canvas is essentially an image. Drawing shapes is slow in all browsers, and the shadow/blur part makes it even slower. You should read this article:

    http://www.html5rocks.com/en/tutorials/canvas/performance/

    It also discusses the technique you already applied, with prerendering graphics. Drawing text is also slow, so if you ever need to draw the same string over and over again, you should prerender the text as well.

    One last thought, have you suggested this change to the people behind heatmap.js?

    • http://twitter.com/_alastair Alastair Coote

      I intend to package it up in a pull request once I’ve done a little more poking around. There are actually a number of interesting pull request that haven’t been merged, such as this one:

      https://github.com/pa7/heatmap.js/pull/24

      I may try to incorporate them into my repo. We’ll see.

      • http://www.aqualgidus.org/ Michael Chui

        Just wanted to say thanks for submitting the pull request. Wouldn’t have seen this otherwise, and I’ll probably end up merging it into my copy even if pa7 never puts it into master.

        • http://twitter.com/_alastair Alastair Coote

          No worries! I see a number of interesting pull requests- if pa7 doesn’t have the time to administer it I might try to get merges into my repo and flesh it out as I work more on the animation side of things.

  • adad95

    Relevant XKDC. http://xkcd.com/1138/

    • http://twitter.com/_alastair Alastair Coote

      Ha. My project should be demonstrating the opposite- when activity does *not* match population / existing norms.

  • Sean Loughran

    I have a very similar method for visualizing certain data as well. What I found was that the JAVA constructor for a Geometry object was SUPER slow. I would figure out my circles, create them, draw them on a canvas. SO SLOW.

    What I found was Quicker was to figure out the resolution of my end picture. Then take the edge points of my circle, find the bounding box, then go through the bounding box’s footprint in the end picture, pixel by pixel and say, is this in my circle? If so, increment the pixel. This was MANY orders faster, even though in my head it should be MUCH slower.

    But that’s something you may want to try.

  • Pingback: Improving performance – never underestimate copy and paste | Adventures (in code) | Recently read | Scoop.it

  • Tim

    Just make sure your image drawing is pixel-aligned! It made a huge difference last time I experimented with canvas. Try something like ctx.drawImage(pointTemplate,round(x-offset)+0.5,round(y-offset)+0.5)

  • Pierre Paul Lefebvre

    In my case your “before” demo was 300% faster than the after. Chrome ubuntu 12.10.

  • XueqiaoXu

    The conclusion that splitting the canvas into multiple smaller ones can boost the performance is based on an erroneous test.

    The canvas of size 2048×2048 should be 64 times the size of the canvas of size 256×256. But in your test it was miscalculated to be 8, thus leading to the huge performance difference.

    • http://twitter.com/_alastair Alastair Coote

      Woops. You’re right. That’ll teach me to do these things in the middle of the night. But running your amended test:

      http://jsperf.com/canvas-getimagedata/5

      on my machine shows that smaller tiles are still faster, just not by as much of a margin. Your slightly older Chrome isn’t though, so it isn’t much to go on. For my purposes I need to use 256×256 tiles anyway, but I’ll accept that the jury is out on this one.

  • JOE

    I just use KineticJS…gets 40+ FPS

  • http://www.simonsarris.com simonsarris

    Hi,

    I’m the canvas bum who originally suggested using shadows to solve a problem that the author of heatmap.js had, namely that he wanted to be able to union gradients and not just add them. You can read about the problem and its solution here:

    http://stackoverflow.com/questions/10060242/html5-canvas-globalcompositeoperation-for-overlaying-gradients-not-adding-up-to/10166995#10166995

    Unfortunately, from a cursory look your proposed edit to the library, your method of drawing breaks that functionality. It may not be present in your tests because the gradients are not subtle enough, but I’ve only taken a quick peek.

    PS:

    > running drawImage() seemed like it would be far more complex than drawing a simple circle.

    The reason its slow isn’t drawing the arc in this case, its the use of shadows. Drawing shadows and text on the canvas are extremely slow compared to other drawing methods.

  • Scott Turner

    The current baseline of heatmap.js seems to use createRadialGradient instead of drawing the filled circle. Is this optimization still relevant?

    • Scott Turner

      Never mind, I see that the createRadialGradient is the older version of the code.

  • picardo

    I wonder if you think the problem#1 has been sufficiently addressed by the QuadTree algorithm in the new version of heatmap-leaflet.js plugin:

    https://github.com/pa7/heatmap.js/blob/master/src/heatmap-leaflet.js#L103