Skip to content

Instantly share code, notes, and snippets.

@jakearchibald
Created December 19, 2012 17:28
Show Gist options
  • Save jakearchibald/4338551 to your computer and use it in GitHub Desktop.
Save jakearchibald/4338551 to your computer and use it in GitHub Desktop.

Snow in canvas land

Other people's code is awful, and your own code from months previous counts as someone else's. With this and the festive spirit in mind, I dug up a canvas snow demo I made two years ago to see how bad my code really was.

Turns out the performance landscape has changed quite a bit, but after applying a couple of workarounds, best practices, and memory management, I got the demo running smoother than it ever did.

Ugh, I can't believe I just wrote "performance landscape". Anyway...

How does the demo work?

Snowflake objects are created with a radius, opacity, y-velocity, and x-range (they drift from side to side). As they fall, they're drawn to a canvas, this canvas is cleared on every frame. When snowflakes land, they're drawn to another canvas, and the snowflake is removed from the "active snowflakes" list. The second canvas is never cleared, snowflakes shapes are added as they land, meaning I don't have to redraw all the landed snowflakes per frame.

Two years of browser development later, what's wrong with it?

Arghh! What happened to the performance?

Depending on your browser, the demo grinds to an almost-halt as more flakes appear. Chrome, Firefox Windows, and IE10 preview on Win7 all suffer. Safari, Firefox Mac, and Opera don't do too badly.

This slowdown wasn't there when I first made the demo, so something's changed in the browsers. The cause was pretty difficult to track down, Chrome Devtools and Firebug laid the blame on my frame function...

Firebug

Chrome Devtools

...which isn't specific enough. Points go to IE10, which correctly called it:

IE10 Devtools

I decide a flake has landed when the pixel beneath it on the "landed" canvas is above 80% opacity. I only test one pixel per flake, but that happens every frame. That's more effort for some browsers than it used to be.

It wasn't clear why at first, but Tab Atkins pointed me in the right direction. When I originally made the demo, 2D canvas operations were software-driven. These days, depending on the browser, most of the drawing work is done on the GPU. This is much faster, until JavaScript wants to know the pixel data. When this happens, the image data (or a portion of it) gets transferred from graphics memory to RAM. This is where performance falls away.

Instead of checking the canvas to determine if a flake should land, I maintain an array with length equal to the width of the canvas, where each item is the y-position that snowflakes cannot continue beyond for that x-position. When a snowflake lands, the array is updated, adding the new snowflake to its representation of the terrain.

The improvement is huge in the browsers that were struggling before (diff). In Chrome, we're approaching 60fps, but we're not quite there yet.

Lack of requestAnimationFrame

There are already articles covering why requestAnimtionFrame is great, but basically setTimeout is not in sync with when the browser draws. This results in one of, or a combination of, more than one frame calculation per drawn frame, and less than one frame calculation per drawn frame. With the snow example, here it is in Chrome Devtools' frame view:

Chrome Devtools

Here the yellow boxes represent JS triggered by setTimeout, the green boxes are drawn frames. The JS blocks appear before the draw, but they're out of sync. They get closer and closer until they block a draw, then the draws happen before the timers, until of course they swap over again and you get another missed draw. These dropped frames are a noticeable jolt visually.

Let's fix this. Instead of...

function frame() {
	// ...
}
setInterval(frame, 1000 / assumedFps);

...we'll do:

var requestAnimationFrame = window.requestAnimationFrame ||
    window.webkitRequestAnimationFrame ||
    window.mozRequestAnimationFrame ||
    function(func) { setTimeout(func, 1000 / assumedFps); };

function frame() {
	// ...
	requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

Here's the version using requestAnimationFrame (diff).The difference is noticeable across all browsers except Opera (which doesn't support requestAnimationFrame yet), especially if your display isn't running at 60hz as the old setTimeout assumed. Here's how Chrome's frame view looks now:

Chrome Devtools

A few years ago our main goal was to make stuff look "right" in all browsers. That's a lot easier than it used to be. Now most browsers can render complex designs really fast. This introduces a new challenge that was previously impossible, can you make it animate/scroll at 60fps? (or whatever refresh rate the user has)

Some things are allowed to be lumpy, like soup and perhaps orange juice. Animation is more like milk and farts: lumps are bad.

Garbage day!*

We're so close 60fps in Chrome, but as you can see from the screenshot above, we're not always hitting it. What's going on there?

Chrome Devtools

A frame was missed due to garbage collection. The most explicit bit of object creation & dereferencing is with the snowflakes:

function Snowflake() {
  // ...
}

function frame() {
  // ...
  if (shouldMakeSnowflake) {
    fallingSnowflakes.push(new Snowflake());
  }
  if (shouldLandSnowflake) {
    fallingSnowflakes.splice(index, 1);
  }
  // ...
}

When I create a new snowflake an object is created in memory, a reference to that object goes into the fallingSnowflakes array where it can be accessed later for drawing. When a snowflake lands it's taken out of the fallingSnowflakes array. At this point, the snowflake object is still taking up memory, but it's inaccessible - no objects reference it.

When the browser feels the time is right, it looks for objects that can no longer be accessed, either because nothing references them or they're only referenced by objects that are themselves inaccessible (IE6 didn't do the second check, which is why circular references caused leaks), and frees up the memory they're using. This is "garbage collection", commonly referred to as GC.

Throwing objects away and creating similar new ones is a waste (cut to scene of Native American with tear in eye), let's fix that with some environmentally-friendly recycling:

function Snowflake() {
  this.reset();
}
Snowflake.prototype.reset = function() {
  // ...
  return this;
}

function frame() {
  // ...
  if (shouldMakeSnowflake) {
    if (snowflakePool.length) {
      fallingSnowflakes.push(snowflakePool.pop().reset());
    }
    else {
      fallingSnowflakes.push(new Snowflake());
    }
  }
  if (shouldLandSnowflake) {
    snowflakePool.push.apply(snowflakePool,
      fallingSnowflakes.splice(index, 1)
    );
  }
  // ...
}

Here I move my constructor stuff to a reset method, so an existing object can be reconstructed. When a snowflake lands, it's put into an array for reuse rather than being thrown away. When a new snowflake is needed we take one from the pool, or create a new one if the pool is empty. How does that look?

Chrome Devtools

60fps. Wonderful. (diff)

I hope other browsers get a frame-by-frame breakdown of events similar or better than Chrome's, it's dead handy (disclaimer: I work for Google, probably biased).

*this

A bit of artistic direction

Paul Lewis (@aerotwist) was extremely dissatisfied with my snowflake simulation, and offered up some tips (well, he grabbed my keyboard, hit me with it a bit, then made some changes). He got rid of the side-to-side wobble, instead giving each flake a constant x-velocity, tweaked the sizes a bit, and since we're hitting 60fps pretty easily... moar snow! It looks much better I reckon (diff).

Not perfect yet

If you run the final demo in Chrome and check out the memory timeline (open devtools > timeline > memory > press record). There's still a memory build-up, but the tools aren't making it easy to determine what that build-up is. Chrome's the only browser to provide this kind of memory use timeline at the moment, but it's by no means 'done'. Look out for improvements to these tools in the coming months, and check out Chrome Canary if you want a sneak peek at the features in development.

And with that, I wish you a holiday free of inappropriate lumps!

@matthiasak
Copy link

Wow... Jake this is a fantastic anecdote!

I find it might be helpful to do a recap of what you've covered, for us newbies who look up to you :)

Recap of things covered
  1. How to use profiling to find functions that take up a large amount of processing time
  2. Analyzing Paint events and how they coincide with a 60fps timing -> rAF
  3. How to reuse objects to avoid Garbage Collection
  4. Paul Lewis (@aerotwist) beat you with a keyboard and made your animations more artistically inclined

Did I miss anything?

@coderkind
Copy link

Great article (and laughed at the lumps). The demo's real purty too.

@qharlie
Copy link

qharlie commented Dec 21, 2012

Awesome article, and awesome recap @matthiasak

@rpearce
Copy link

rpearce commented Dec 21, 2012

Any problem with citing you and using on production app for a couple days of holiday ridesharing cheer? https://www.ridepost.com

@codecowboy
Copy link

So I decided to mash this up with http://codingwithpassion.blogspot.co.uk/2012/08/just-koch-snowflake-in-canvas-html5.html. Any idea why the fractals won't save to the settleContext? Can I not pass a context around like that? See line 187 and below. https://gist.github.com/cd4a436d8ffa8d3ce873

@codecowboy
Copy link

I figured it out and updated the gist. The fractal draw code could not distinguish between the drawing contexts

@jamienewman
Copy link

This is quite wonderful. Thanks for sharing.

@roughearth
Copy link

Lovely. The only downside is that using requestAnimation means the snow doesn't build up if the tab isn't "visible".

@blaise-io
Copy link

I didn't know Chrome had a frame view! This will be immensely useful for me!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment