Rasterizing SVG Animations

Written by Tyler Sticka on

The web is by far my favorite platform. I love its openness, its immediacy, its adaptability. But I still enjoy dabbling in other platforms, designing games like Spinner Galactic for iOS. A surprising amount of my favorite web techniques carry over… I’ve even animated some effects with SVG and GSAP!

Why?

Some animations are best handled by the game engine… particle effects, motion based on player input, etc. Other animations are so quirky and expressive that they deserve to be rendered frame by frame, using the same basic techniques animators have relied on since the early twentieth century.

A green block screams infinitely
A green block darts its eyes and sweats repeatedly

Two simple examples of traditional animation from one of my games.

But some animations exist somewhere in-between, more expressive than dynamic but still benefiting from the precise timing and smooth easing that software can provide.

A comet with several trails travels leftward

A GSAP animation from Spinner Galactic.

While I’m sure I could have used something like Adobe Animate to make that happen, I chose SVG and GSAP for a few reasons:

  • I already knew how to use them! That’s a big plus!
  • Sharing was really fast! No export, no download, just a link to a CodePen.
  • It empowered my collaborator! Instead of relying on me to tweak an animation’s settings, he could change whatever variables he wanted directly.

There was just one problem. All of our games so far have been two-dimensional, composed of bitmap imagery known as sprites. These sprites are stitched together by a framework like Cocos2d or SpriteKit to form the characters and environment you see while playing the game. How could we translate JavaScript animation of vector assets to a format these frameworks understand?

We needed to export each frame of animation to a PNG!

Demo Time!

Here’s an example. I’ve designed a hypothetical in-game asset (a little amoeba-like blob with eyes), which I’ve exported to SVG from Adobe Illustrator. I then created a little entrance animation using GSAP, with an accompanying export dialog:

A few moments after pressing the “export” button in a modern browser, you should be prompted to download a ZIP file containing every frame of animation as an alpha-transparent PNG. The amount, dimensions and filenames of those images will vary depending on the FPS and scale values you specify.

MacOS Finder window populated with PNG files output by previous demo

Is this wizardry? Nope, just a convenient combination of open source goodies. Let’s break it down…

How It Works

You can use any animation tool you’d like, but one benefit of GSAP is that it comes with TimelineMax, which will make it easy to jump to any point of our animation later on.

const timeline = new TimelineMax({
  repeat: -1,
  repeatDelay: 1
});

/* Actual animation steps start here… */

Once the animation is looking spiffy, we can start writing a function that will generate our images. To start with, we’ll need that function to calculate the total number of frames based on the timeline duration and number of frames per second.

function generate(fps = 24, scale = 2) {
  // Get duration from our timeline
  const duration = timeline.duration();
  // Calculate the total number of frames
  const frames = Math.ceil(duration * fps);
}

Next (and still in that function), we should establish our file naming convention for each image. I’ve included a prefix, a file extension, and a scale based on what iOS expects for PNGs.

let filePrefix = "amoeba-";
let fileScale = scale === 1 ? "" : `@${scale}x`;
let fileExtension = ".png";

Now that we’ve used the intended image scale in our filename, we can safely adjust that scale based on our device’s actual pixel ratio. If we don’t do this, the dimensions of our image files will be larger than intended on higher-resolution displays.

scale = scale / window.devicePixelRatio;

We’re almost ready to start generating images. But because we don’t want to trigger dozens of “save file” dialog windows, we’re going to bundle them together using JSZip.

// Create a ZIP file we'll add images to
const zip = new JSZip();

Here comes the tricky part! For every frame of animation we need to generate, we’re going to do the following:

  1. Pause the animation at the correct point in time.
  2. Grab the image data of that paused animation using saveSvgAsPng.
  3. Add that data as a new PNG file to the zip variable we created.

To make matters slightly more complicated, saveSvgAsPng uses Promises. Normally this would be a good thing, allowing us to export multiple frames of animation at the same time. But unless we duplicate our SVG element and timeline as well, we need each frame of animation to finish exporting before the next begins. This requires a bit of trickery to pull off.

// Set up a resolved promise for our loop
let step = Promise.resolve();

// For every frame we need to generate…
for (let i = 0; i <= frames; i++) {
  let position = duration / frames * i;
  let filename = `${filePrefix}${i}${fileScale}${fileExtension}`;
  // Begin this step when the previous finishes
  step = step.then(() => {
    timeline.pause(position);
    return svgAsPngUri(document.getElementById("amoeba"), { scale }).then(
      uri => {
        // Convert data URI to plain base64
        let imgDataIndex = uri.indexOf("base64,") + "base64,".length;
        let imgData = uri.substr(imgDataIndex);
        zip.file(filename, imgData, { base64: true });
      }
    );
  });
}

Phew! Now that we’ve captured all the image data, we can finalize our bundled file and give it a filename using FileSaver.js.

step.then(() => {
  zip.generateAsync({ type: "blob" }).then(blob => {
    saveAs(blob, `${filePrefix}frames.zip`);
  });
  // Resume animation
  timeline.play();
});

With our handy generate function in hand, all that’s left is to wire it up to our export form!

document.getElementById("controls").addEventListener("submit", event => {
  // Convert the input values to numbers
  const fps = parseFloat(document.getElementById("fps").value, 10);
  const scale = parseFloat(document.getElementById("scale").value, 10);
  // Prevent the form from taking us anywhere
  event.preventDefault();
  // Pass these values to our PNG/ZIP generator
  generate(fps, scale);
});

We did it! Less than 100 lines of JavaScript later, we now have a means of exporting our SVG and GSAP animations to a format any sprite-based framework can understand!

Scratching the Surface

Although I originally solved this problem for an iOS game, it opened a Pandora’s box of ideas!

If we can export PNGs, why not GIFs? Could I use something like gif.js or gifshot to make a “shareable” version of a dynamic animation?

And why limit ourselves to animation? Maybe our brand style guides could include custom asset generators, like this Cloud Four fallback avatar generator I whipped up…

It’s ironic that working on a native application would renew my love for the web, but that’s exactly what discoveries like this tend to do. The web is special, and it never fails to spark my imagination!

Tyler Sticka

Tyler Sticka is Cloud Four's VP of Design, allowing him to think about design systems every day. When he isn’t directing his team, sketching on sticky notes or nitpicking CSS, he enjoys reading comics, making video games and listening to weird music. He tweets as @tylersticka.

Never miss an article!

Get Weekly Digests


Leave a Comment

Please be kind, courteous and constructive. You may use simple HTML or Markdown in your comments. All fields are required.


Let’s discuss your project! Email Us