Rasterizing SVG Animations
At 4/19/2024
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.
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.
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.
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… */
Code language: JavaScript (javascript)
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);
}
Code language: JavaScript (javascript)
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";
Code language: JavaScript (javascript)
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;
Code language: JavaScript (javascript)
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();
Code language: JavaScript (javascript)
Here comes the tricky part! For every frame of animation we need to generate, we’re going to do the following:
- Pause the animation at the correct point in time.
- Grab the image data of that paused animation using saveSvgAsPng.
- 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 });
}
);
});
}
Code language: JavaScript (javascript)
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();
});
Code language: JavaScript (javascript)
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);
});
Code language: JavaScript (javascript)
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!