At Khan Academy, we put a lot of effort into creating interactive math content (e.g., Drawing Polygons).
The kinds of interactions we focus on are particularly important on touch devices: to create an engaging mobile experience, we strive to provide interactive content that feels effortless and fun—tightening the feedback loop as much as possible and enforcing the illusion of complete control.
Poor performance kills this illusion: if a user drags his or her finger across the screen and the polygon lags behind noticeably, the shapes feel heavy and the interaction becomes a chore.
So, when we recently discovered that many of our interactive exercises run at rates as low as 12 FPS on tablets, it was pretty startling.
(You might ask: why was this a recent discovery? For one, it's easy to hit 60 FPS on a MacBook Pro, but much harder to do so on a resource-constrained tablet. Thus, our poor perf wasn't apparent on desktop machines. "Discovery" is also a misnomer; we've just been focusing far more on mobile lately for a number of reasons.)
For Khan Academy's Hack Week (a week-long version of the Healthy Hackathon), I spent a few days addressing this problem. In the end, I was able to bump most of our interactive exercises up to 55-60 FPS on an iPad. That's a 2-5x speedup (depending on which exercise you're profiling) for a week's worth of work.
(And better yet, much of this has already shipped!)
Let's first lay out some benchmarks and background. (If you just want numbers and pictures, you can skip ahead to the results section.)
On the most recent iPad Mini (with a retina display), I clocked the following FPS numbers for our interactive exercises using Instruments on OS X. For each shape mentioned below, I had it follow my finger's touch around the canvas (as in Drawing Polygons) at maximum speed:
Yikes. For reference, humans can't really perceive any difference in frame rates about 60 FPS; 10 FPS, on the other hand, feels like slogging through tar, even to the most untrained eye.
It's worth noting that our existing interactive content used SVG (with Raphael).
(If you're doing any perf-related front-end work, I highly recommend you read High Performance Animations by Paul Lewis and Paul Irish. Below, I'll be referencing the concepts described in this article.)
Let's talk about how browsers work (for, like, 100 words, I promise). There are four key styles that a browser can animate cheaply:
Changing a node's style can force the browser to perform some expensive tasks. For example, changing the
width of an element may affect the layout of the page, including that of other nodes, so the browser has to recompute layout; changing the
background-color of an element requires that the element's pixels be repainted.
Sticking to the four properties above (i.e., linear transformations and opacity) lets the browser avoid these expensive tasks and instead merely move existing, painted pixels around the screen (i.e., "composite layers").
To achieve silky-smooth animation, then, you should obviously stick to these properties. And at Khan Academy, we generally did (with a few exceptions).
However, SVG doesn't provide these same performance guarantees. In other words, applying linear transformations to SVG elements does trigger re-layout and re-painting. Just take a look at this Fiddle and the timeline it produces in Chrome—see the purple layout events followed by green paint events?
(Aside: This came as something of a surprise to me. When I spoke with Paul Irish during Hack Week, he said that there wasn't any particular reason that SVG nodes had to behave differently than DOM nodes here, but that most browsers didn't have particularly good SVG implementations.)
In simplest terms: while we'd like our machine to do no more than shift pixels around the screen, our SVG nodes were demanding expensive re-computation and re-painting, leading to a sub-par experience on resource-constrained devices.
During the profiling process, my colleagues Aria Toole and Andy Matuschak had an interesting idea: Why don't we just wrap each individual SVG element in its own DOM node? Then, when we need to transform the underlying SVG, we can just apply an analogous CSS transform to the outer DOM node.
In effect, they were proposing that we go from:
<svg> <circle fill="red" transform="translate(2px, 3px)"></circle> <circle fill="blue" transform="scale(2)"></circle> </svg>
<div style="transform:translate(2px, 3px)"> <svg> <circle fill="red"></circle> </svg> </div> <div style="transform:scale(2)"> <svg> <circle fill="blue"></circle> </svg> </div>
Under this scheme, we'd regain all the benefits of cheap animation: as the browser is merely applying linear transforms to vanilla DOM nodes, there'd be no layout or painting required. Better yet: CSS Transforms are GPU accelerated, which would make for an even smoother experience.
So, for Hack Week, I gave it a shot.
The bulk of the work was in adding a layer of abstraction on top of our existing graphing code, mostly to handle the disparity between interacting with the outer DOM node and the underlying SVG element. For example, while you want transforms to be applied to the outer DOM node, other attribute changes (e.g., changing a circle's color from red to blue) should be propagated to the underlying SVG.
There were also spots in the code where we animated our SVGs with non-transform styles, e.g., setting the center coordinate (
cy) for a
<circle> rather than applying a transform, or cutting off a
<line> at the boundaries of the graph by setting its
clip-rect property. All of these inefficiencies had to go.
As an even more extreme example, we were drawing the tips (or arrows) of our lines by throwing an SVG
<path> into the DOM and, when the line moved, removing that node from the DOM and replacing it with a new
<path>. This was super inefficient, and my re-write instead re-used the existing DOM node, moving it with CSS transforms, rather than complete replacement.
This was a process of removing jank from our existing graphing code and deconstructing all of our animation into simple linear transforms—and doing so with as few hacks as possible, given that I wanted this to ship eventually.
Recall the benchmarks I introduced earlier:
After introducing the changes above, I was able to present these numbers during my Hack Week demo:
That's more like it.
To further hammer home the difference, check out these GIFs, taken in Chrome with "Show paint rectangles" enabled. First, in our previous implementation (note that the green rectangles indicate re-painting activity required by the browser):
And with these improvements, the green rectangles disappear:
My hope it that, at some point in this post, you asked yourself: "Why don't you just re-write all of your graphing tools to use HTML5 Canvas or WebGL?" Because that would be a great question! Moving to more performant animation technologies is certainly on our road map, but it's more of a several-month project, whereas my goal here was to get some immediate benefit and touch as little of the codebase as possible.
That said, we've already started to plan out the future of our interactive tools. We're hoping to use Three.js or Mathbox—after you've seen How to Fold a Julia Fractal, you don't really have any other choice.
As a caveat, I'd also note that this technique doesn't cover all of our interactive exercises. For example, you can't really cover parabolas or sinusoids under this scheme without distorting the lines in unacceptable ways. Moving over to WebGL would provide a more universal (and less hacky) solution.
P.S.: Thanks, Khan Academy! As some of you may know, I was a summer intern at KA in 2014. I had an absolute blast, learning and growing an immense amount from an amazing team with an amazing culture. KA is truly a special company, both in mission and the individuals that comprise it. I'm thrilled that I was able to make it out for Hack Week. Thanks for having me—and hope to see you all again soon!
There were a few more minor pain-points in the implementation. I'll go over them pretty quickly:
<path> elements couldn't be scaled and rotated in the same way for a number of reasons (aspect ratios would get all messed), but they could be translated with ease. We use paths heavily in our polygon implementation, which draws the boundary and interior with a
<path>, and features two key interactions:
My first change was to replace the polygon's boundary with a set of
<line> elements which could be easily transformed with CSS. Then, I had our DOM-node-wrapped
<path> elements detect whether a provided transform was a translation, and, if so, merely translate the DOM nodes (and completely re-draw itself for non-translations). This gave us excellent performance for interaction (1), but sub-par perf for (2). Luckily for us, the interior of the polygon is completely opaque in (2)—why animate what the user can't see? Instead of transforming the interior path onMove, I instead had the path re-draw only onMoveEnd. Major hacks, but the payoff was worth it.
In order to avoid clipping, we couldn't have the outer
<div> hug too tightly to the underlying
<svg>. Thus, I had to add some padding to the outer
<div>. This had the unfortunate effect of messing up the origin for transforms, so rotations and scaling transformations caused unexpected behavior. Taking some care to set the
transform-origin of the outer
<div> preserved the abstraction.
For example, SVG transforms are unit-less, while CSS transforms require units.
Thanks to Shubhro Saha for his feedback on a draft of this post.