Optimising JavaScript animation

I'm going to use this as a place to collect the tips and tricks I've picked up for creating performant animations in CSS, and both 2D and 3D Canvases.

Clustering DOM reads and writes

Setting any property on an element that changes it's position or appearance will mark the page's layout as 'dirty'. In order to reduce the amount of work it needs to do the browser will wait until the last possible moment before it renders these changes.

This work cannot be postponed when trying to get the position or dimensions of an element. Take the following as an example:

var width = domEl.clientWidth + 10;
domEl.style.width = width + 'px';
var height = domEl.clientHeight + 10;
domEl.style.width = height + 'px';

We're just adding 10 pixels to the height and width of the element. But as soon as we set the element's width the page's layout becomes dirty. So on the next line when we sample the element's height the browser has to reflow the entire page so that it can be sure that it's giving us the correct value following the change.

The solution is to get the dimensions of our element in one go.

var width = domEl.clientWidth + 10,
    height = domEl.clientHeight + 10;

domEl.style.width = width + 'px';
domEl.style.height = height + 'px';

By retrieving the element's width and height at the same time the browser can be sure that the page hasn't changed between each read.

The following list of methods and properties (and potentially others) can trigger a page reflow.

DOMElement

clientHeight, clientLeft, clientTop, clientWidth, focus(), getBoundingClientRect(),
getClientRects(), innerText, offsetHeight, offsetLeft, offsetParent, offsetTop,
offsetWidth, outerText, scrollByLines(), scrollByPages(), scrollHeight,
scrollIntoView(), scrollIntoViewIfNeeded(), scrollLeft, scrollTop, scrollWidth

window

getComputedStyle(), scrollBy(), scrollTo(), scrollX, scrollY

Cache DOM reads

Once again we want to limit the number of reflows the browser has to deal with. So caching values where we can is very important.

For example:

var loop = function () {
      var width = domEl.clientWidth + 10;
      domEl.style.width = width + 'px';

      loop();
    };

loop();

This will force a reflow on every iteration (ignoring the over simplified loop).

We'd be better to cache the initial clientWidth value and increment it.

var width = domEl.clientWidth,
    loop = function () {
      width = width + 10;
      domEl.style.width = width + 'px';

      loop();
    };

loop();

Note that we're now reading the clientWidth of the element just once.

Use requestAnimationFrame

The standard for any kind of repetition in JavaScript is setInterval. It takes a callback and fires it at an interval of your choosing. For animation this is pretty sucky. Regardless of whether your change is going to be rendered, setInterval will do it's work. This can cause a reduction in battery life and choppy animation.

var width = domEl.clientWidth;
setInterval(function () {
  width = width + 10;
  domEl.style.width = width + 'px';
}, 1000 / 60);

This is where requestAnimationFrame comes in. It will tell the browser that you have something you want to render and the browser will trigger the callback you provide just before it performs a repaint.

For example:

var width = domEl.clientWidth;
(function loop () {
  width = width + 10;
  domEl.style.width = width + 'px';

  window.requestAnimationFrame(loop);
})();

Using requestAnimationFrame will ensure that our tweening is only performed when the browser can actually render it. The browser can even pause animation when our page isn't active, again saving battery life.

Support is pretty widespread, but here's a simple shim with fallback to setTimeout for browsers that haven't caught up yet.

window.requestAnimationFrame = (function () {
  return  window.requestAnimationFrame || 
          window.webkitRequestAnimationFrame || 
          window.mozRequestAnimationFrame || 
          window.oRequestAnimationFrame || 
          window.msRequestAnimationFrame || 
          function (cb) {
            window.setTimeout(cb, 1000 / 60);
          };
}());

Hardware acceleration

With CSS transforms came hardware acceleration. Why is hardware acceleration good? Well it takes tasks that would usually be performed by the CPU and offloads them to the GPU which is better at drawing and probably not very busy when you're only viewing a webpage.

The simplest way to offload animation to the GPU is to use CSS transitions.

For example:

domEl.style.transition = 'all 1s ease-out'; domEl.style.transform = 'translateX(100px)';

In most cases it's usually enough to simply use CSS transitions to offload the work to the GPU. However in some cases we may need to force the rendering of the element or it's parent onto the GPU. We can more strongly suggest that the browser should do this using a 3D transform.

We can achieve this by simply adding the following CSS property to the element in question.

transform: translateZ(0px);

This should be used sparingly and only where needed since it can cause some undesirable side effects (particularly around the positioning of child elements) and may even worsen performance.

Round pixel values

Sub pixel rendering can cause choppy animation since the browser is forced to anti-alias CSS pixels when they're positioned between real screen pixels. This is a lot of extra work and is often unnecesary.

We can avoid this by rounding values to full pixels

For example:

domEl.style.width = 100 / 3; // 33.333...
domEl.style.width = width + 'px';

Could be written as:

domEl.style.width = Math.floor(100 / 3); // 33px
domEl.style.width = width + 'px'; // 33px

Or slightly more efficiently with some bitwise shenanigans http://jsperf.com/math-round-vs-hack

var width = ~~(100 / 3);
domEl.style.width = width + 'px'; // 33px

Comments

No comments yet

Share your opinion