Building our bubbly holiday poster, part II

Adam Luikart
Adam Luikart Developer February 14, 2013

It's kind of fun to do the impossible

In part one, I talked about the process I used to generate the organic pattern of bubbles that make up the poster’s background. My initial idea was to generate and animate a new layout of bubbles on each page load, but the generation process just took too long. Besides, I was aiming for a subtle effect that wouldn’t steal the limelight from the poster’s message.

Undulating pattern

Here’s my first pass at a simple undulating animation pattern, built using D3’s transitions module. It builds a transition over a set of circles, increasing the opacity (weighted so that bigger circles end up brighter), and bumping each circle a little to the right. Setting the delay equal to the sum of each circle’s x & y coordinates creates a wave moving from upper-left to lower-right.

A second transition, chained after the first, brings each circle back to its initial state.

glimmer: () =>
  @circles.transition()
      .duration(3000)
      .delay((d, i) -> d.cx + d.cy)
      .style("opacity", (d) -> 0.3 + (d.r/30) * 0.7)
      .attr("cx", (d) -> +d.cx + (+d.r/30) * 3)
    .transition()
      .duration(3000)
      .style("opacity", 0.3)
      .attr("cx", (d) -> d.cx - (d.r/30) * 3)

While this syntax is D3-specific, it should look familiar to any front-end developer who’s done animation with jQuery; you set a duration, specify target values for attributes, and let ‘er rip! Behind the scenes, the library makes sure your animation completes in the allotted time with as smooth a framerate as possible.

Performance Woes

At small-to-medium window sizes, this animation performed well, but at larger sizes, the framerate dropped precipitously, to just a frame or two per second. Unacceptable!

The Chrome Developer Tools’ Timeline panel has an awesome Frames mode which shows you how much work your code is doing per frame. It’s super useful for situations like this; just open the panel with your animation running, and click the ● (big black dot) button in the toolbar at the bottom of the window to begin capturing frames.

Frame inspector

The graph at the top shows frames on the x axis and execution time on the y axis, along with guidelines to show you how close you are to hitting 30 or 60fps. You can see from the graph above that I was nowhere near – the “wave” of my animation corresponds to those ugly yellow bars shooting through the roof.

To reach 60fps, each frame of animation must finish executing and painting in less than 16.6ms. At a resolution of 1679✕1323, with roughly 2,800 circles, script execution was taking ~150ms per frame. Alarmingly, the paint times (the amount of time it took the browser to render each svg circle node to the screen) were even worse, at upwards of 165ms/frame.

I was able to shave a little time off the JavaScript execution by ditching the side-to-side movement and only animating the opacity, but such high paint times meant that ultimately, performance on large screens was out of my control — at least using D3.

Canvas Power

I was curious about whether or not rendering that many DOM nodes was the source of the high paint times, so I rewrote the renderer, switching from D3’s SVG-based code to one that drew circles straight to a canvas element, using tween.js as the animation engine.

My CoffeeScript Circle class is extremely straight-forward:

class Circle
  constructor: ({@r, @cx, @cy}) ->
    @[p] = (Math.round v) for own p, v of @
    @opacity = 0.3

  draw: (ctx) ->
    ctx.beginPath()
    ctx.arc(@cx, @cy, @r, 0, Math.PI*2)
    ctx.globalAlpha = @opacity
    ctx.stroke()


  # Class vars
  @fill_colors: [160, 148, 135]
  @stroke_colors: [185, 175, 165]
  @line_width: 2.5

  # Class methods
  @a2rgb: (a) ->
    "rgb(#{a.join()})"

  @setup_context: (ctx) ->
    ctx.strokeStyle = @a2rgb @stroke_colors
    ctx.fillStyle   = @a2rgb @fill_colors
    ctx.lineWidth   = @line_width


# exports
window.Circle = Circle

The Poster#glimmer method got a little more complex, but isn’t too much worse to look at than the D3 version. Most of the “extra” code is spent setting up a glimmer that radiates outward from a single random point along the edge of the window:

glimmer: () =>
  tween_duration = 1000

  cx = parseInt Math.random() * @w
  cy = parseInt Math.random() * @h

  if Math.random() > 0.5
    if Math.random() > 0.5 then cx = 0 else cx = @w
  else
    if Math.random() > 0.5 then cy = 0 else cy = @h

  for c in @circles
    do (c) ->
      opacity = 0.4 + (c.r/30) * 0.6
      d1 = c.cx - cx
      d2 = c.cy - cy
      delay = Math.sqrt(d1*d1 + d2*d2)

      c.tween.stop()

      update = () ->
        c.opacity = @opacity

      tween_in = new TWEEN.Tween(opacity: 0.4)
        .to({ opacity: opacity }, tween_duration)
        .easing(TWEEN.Easing.Quadratic.InOut)
        .delay(delay)
        .onUpdate update

      tween_out = new TWEEN.Tween(c)
        .to({ opacity: 0.1 }, tween_duration)
        .easing(TWEEN.Easing.Quadratic.InOut)
        .onUpdate update

      tween_back = new TWEEN.Tween(c)
        .to({ opacity: 0.4 }, tween_duration)
        .easing(TWEEN.Easing.Quadratic.InOut)
        .onUpdate update

      tween_in.chain(tween_out)
      tween_out.chain(tween_back)

      c.tween = tween_in.start()

The animation is driven by a requestAnimationFrame loop. Each frame, the global TWEEN is updated (moving all in-flight animations one step forward), the canvas is cleared, and the circles are drawn. I have no idea why I decided to break this up into two methods.

animate: () =>
  @raf = requestAnimationFrame @animate
  TWEEN.update()
  @draw() unless @freeze_circles

draw: () ->
  @ctx.clearRect(0, 0, @w, @h)
  c.draw(@ctx) for c in @circles

Results

So, was it worth it? Here’s what the Frames inspector says:

IT’S SUPER EFFECTIVE — BUT WAIT!

Except for the occasional garbage collection spike, each frame now takes only ~18ms to execute with the same window dimensions and 2,800 circle objects! Frustratingly, though, in Chrome 24, there’s a strange ~200ms gap between Animation Frame Fired and Composite Layers for each frame in the graph, preventing us from getting anywhere near 30fps.

My best guess is that although I’m able to draw to the Canvas context very quickly, it still takes time for the browser to get those pixels ready to be composited. Interestingly, in Chrome 26 (Chrome Canary, at the time of this writing), this gap is greatly reduced, to ~40ms, and the animation is much much smoother.

Ticks, not Durations

So, after all this, I found myself still struggling with framerates. But then, this cool isometric animation demo popped up on my Twitter feed (click and drag up or down to see it in action). Even at full screen, the animation looks amazingly fluid, with none of the “dropped” frames that made my poster look so choppy. What gives?

Duration-based animation

Most animations have a fixed duration. Imagine a dialog box you want to animate from offscreen at point A to onscreen at point B in exactly 1 second. Fast devices may be able to render 60 frames of the journey in that time, but a slower device may only be able to squeeze in 30 frames. You wouldn’t want the animation to take longer just because the device can’t keep up, so frames are dropped. A really slow device may only be able to draw a frame or two before jumping straight to the end of the animation.

The point is that the visual experience may degrade, but the functional experience is unchanged: the dialog box appears on screen.

Tick-based animation

If the visual experience is the whole point, though, there’s another option. Instead of checking to see how far along the animation ought be each time you draw a frame, you just do a small, set amount of the work each time. Each tick of the clock, no matter when it comes, moves you forward a tiny amount. You may be well below 30fps, but by taking such small steps, the low framerate becomes much less noticable even if the the entire animation takes much longer to complete.

Glimmering ticks

The tick-based version of Poster#glimmer looks completely different than its predecessors. The older versions ran once at the beginning of each cycle of the animation and declaratively set things up for the tween engines to update each time requestAnimationFrame fired.

The trickiest part was conceptually inverting my mental model of how animations work, and then hacking in a way to use tween.js’s easing methods.

glimmer: () =>
  # The distance is where the current "crest" of the wave is.
  #
  # The target is the point on the other side of the screen we're
  # driving towards.
  if @glimmer_distance > @glimmer_target
    @init_glimmer()

  @glimmer_velocity *= @glimmer_accel
  @glimmer_distance += @glimmer_velocity


  # These steps represent how far from @glimmer_distance the bubbles
  # should start to brighten, fade out to almost nothing, and then
  # fade back to default brightness.
  #
  # In the declarative version, these were set as animation durations,
  # not distances-from-the-crest. Here, they're precalcuated distances
  # based on screen size.
  s1 = @glimmer_step1
  s2 = @glimmer_step2
  s3 = @glimmer_step3

  for c in @circles
    cd = @dist(c.cx, c.cy, @glimmer_cx, @glimmer_cy)
    delta = cd - @glimmer_distance

    # Determine which stage of the animation we're in and
    # tween accordingly:

    # Brighten to max
    if (0 < delta < s1)
      c.opacity = @tween_glimmer(s1-delta, 0.4, c.max_opacity, s1)
    # Fade to min
    else if (-s2 < delta < 0)
      c.opacity = @tween_glimmer(s2+delta, 0.1, c.max_opacity, s2)
    # Return to base
    else if (-(s2+s3) < delta < -s2)
      c.opacity = @tween_glimmer(-delta-s2, 0.1, 0.4, s3)
    # Nowhere near the crest
    else
      c.opacity = c.base_opacity
  return undefined

# c.f. http://upshots.org/actionscript/jsas-understanding-easing
tween_glimmer: (t, b, e, d) ->
  elapsed = t / d
  elapsed = 1 if elapsed > 1
  value = TWEEN.Easing.Quadratic.InOut(elapsed)
  b + ( e - b ) * value

Wrapping It Up

Getting the animation engine to this point required more near-rewrites than I expected, but it was worth it: the poster performs reasonably well even at crazy-large screen sizes.

Some of my favorite features of the final product, like the glimmering bubble intensity, are a direct result of mistakes along the way that turned out to be interesting.

If you want to dig in deeper, the source for the poster is published on Github, and you can always ask us questions or follow us on Twitter!

Further reading