Sprite animations

2024-03-05

Sprite animations of a game as similar to a hand drawn flipbook animation. You take a bunch of individual drawings, each with one frame of the animation, and you paint them in the same place with a tiny delay. Your brain will do the rest and give you the impression of animation.

This post will try to explain how I've done this for Factini, a game written in Rust into WASM into web canvas. You can play the game here. But this post is more about the html canvas than anything else.

Let me give you a heads-up that I don't think this post is ground breaking in any way. So if you've ever done sprite animations before I don't think you'll see anything new. It's not exactly rocket science either.

The images


You basically have two options when using pregenerated art: either you have individual images that you stitch together or you have a sprite map from which you draw parts. The outcome is the same.



Sprite maps are more efficient for the loader since you can have dozens of tiny images painted into one image. This way you fetch one image rather than dozens of individual network requests.

On the flip side sprite maps are bigger, can have unused areas which wastes memory, and I'm always wondering whether it's actually more efficient at runtime under the hood. Feels like painting part of an image from an offset would take more effort than just painting a whole image verbatim. Empirically it doesn't seem to matter, even for big sprite map images (I'm talking over 10_000 x 10_000 pixels) so perhaps it's more of a problem at scale (number of paints per frame). And maybe it really just doesn't matter.

Whatever you end up with, you have to load the image and have to specify the source offset and dimensions.

Loading


For Rust, you still work with the DOM to load images, which means you create Image HTML elements that trigger the remote fetch. You can listen for the onload event and verify whether or not the image loaded. Or you can check the img.complete() method to see if it has finished, circumventing the events. For Factini I do the latter in every paint loop for as long as we are loading. Once all images are loaded (or failed to do so) you proceed with the next step.

Admittedly, using the DOM to do the file loading and "buffering" feels like cheating versus doing the whole thing in Rust. And at the same time, there's no real alternative. It's not like Rust can read the files from disk or network and give you their contents. You're still bound by the web sandbox. So it is what it is. Do bookkeeping of the Image elements and move on.

The drawImage API


The canvas api exposes a very convenient way of drawing images onto the canvas.

The offset and size of the image you want to paint are called "source" and they are denoted as sx, sy, sw, and sh in the various drawImage methods.

The coordinates where you want to paint the image in your canvas are then called "destination", denoted by dx, dy, dw, and dh

While the JS api method is "overloaded" (does different things depending on type or number of arguments passed in), in Rust (at least when using web_sys) these methods are explicit, having one for each combination of argument type.

In fact, in Rust you have to use a different method for painting an Image to a canvas (draw_image_with_html_image_element) then when using another Canvas (draw_image_with_html_canvas_element), SVG, or Video element as the source. In JS you don't need to think about this.

That API distinction can force some design decisions because in some cases I would want to pre-generate some content in a canvas. But then I would have mixed source images and be forced to copy-paint all images into a canvas element. I suppose that's itself not a huge deal, especially since it'd be a one-of startup thing. But still feels bad.

The painting


The first step is to just paint the image.

Honestly, this is as simple as it gets once you have the image loaded.

Code:
context.draw_image_with_html_image_element(&img, 100.0, 105.0);

This will draw the whole input image in the img var at 100x105 as large as the input image is. And it paints the whole input image.

Code:
context.draw_image_with_html_canvas_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh(
&img,
10.0, 20.0, 5.0, 5.0,
100.0, 105.0, 10.0, 10.0
);

It's a mouthful. This paints a 5x5 pixel part of the input image (stored in the img var) starting at offset 10x20 and paints it twice as big (10x10) starting at coordinate 100x105 in your canvas.

Scaling happens automatically. In fact, you don't have too much control over that part, which can lead to ugly artifacts. But that's a different topic.

The animation


Now that we can paint an image we can continue with an animation.

Like the hand drawn flipbook, the animation comes to life by having a set of sprites and painting them one after the other with a fixed (short) delay between them. The shorter the delay, the smoother the animation will look.



Animated at 50ms interval (plain JS):


Code:
const img = document.createElement('img');
img.src = './ud_frames.png'; // Canvas api will gracefully ignore images that haven't loaded yet so just yolo it
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 128;
document.body.appendChild(canvas);
const context = ce.getContext('2d');
let x = 0;
setTimeout(function r() {
context.drawImage(img, (++x % 8) * 32, 0, 32, 32, 0, 0, 128, 128)
setTimeout(r, 50);
}, 50);

Oh right, remember what I said about scaling? This is the same as above but with context.imageSmoothingEnabled = false;


Much better.

In Factini the delay is driven by the number of world ticks (this is indirectly the real world time because the game will try to stick to a certain tick rate over time).

Use these buttons to change the animation interval above:

Through a custom config you can tell Factini where the image is, how long each frame should be painted before moving to the next, whether the animation loops, or whether the animation bounces. You can tell it which frame is the first and whether the order is forward or backwards.

Through this you can create most (flat) animations in canvas.

Let's take a look at one such definition. Here's an animation of one of the belts in the game, similar to above:

Code:
# Belt_L_D
- frame_offset: 0
- frame_delay: 156
- frame: 1
- file: ./img/belt/anim/l_d2.png
- y: 0
- x: 0
- w: 32
- h: 32
- frame: 2
- x: 32
- frame: 3
- x: 64
- frame: 4
- x: 96
- frame: 5
- x: 128
- frame: 6
- x: 160
- frame: 7
- x: 192
- frame: 8
- x: 224

This tells the game that the first frame of the animation is the first one defined. May sound obvious but I needed this to align all belt animations such that their animation was in perfect harmony with each other. Some belts don't start at zero.

The next animation shows you the same code, painting the sprite twice, next to each other. On the left the animation is seamless, on the right you can clearly see it glitch because the frame offset of the right-hand sprite has a different offset.


In the config above there is a 156 (game) tick delay between frames. The image of the frame is in ./img/belt/anim/l_d2.png. Other frames will copy these parameters when they are started so we don't need to repeat the "file".

The first image starts at 0x0 and is 32 by 32 pixels. The other frames all start at x=0 and are all 32 by 32 pixels, so this too does not need to be repeated. And as you can see, every next frame increments the x by 32 pixels.

Note that the full game spritemap both offsets are different because the spritemap is auto-generated and images are stored in an optimal way. The frames don't need to be logically consecutive in any way or form. They don't even need to be same size or even same file, although this often does make the most sense.

In Factini, every time a frame is painted (hopefully 100 times per second) it will check how many ticks the game has moved forward since the start rather than last frame. Based on that number it will paint a certain frame.

The above defines 8 frames at 156 tick interval. So:

Code:
fn current_frame(ticks: u64, frame_delay: u64, frame_count: u64) {
return (ticks / frame_delay) % frame_count;
}

Every 156 ticks the next frame would be considered. We check how many times this happened. This number is most likely much bigger than the number of frames so we do % frame_count, which you could consider us counting from 1 to frame_count repeatedly. The last number we see will be the frame we need to paint.

By doing it like this for every sprite their animation order will lock in sync and belt animations will align (this is where some need a minor frame offset change).

Note that animation frame sync can also be annoying. It's fine for belts but when you have a bunch of animations on screen of unrelated particles and they animate all at exactly the same time/interval/loop it can be annoying. You could add some kind of noise flag for this to take a random (but fixed per particle) offset to make them less similar and make it look more chaotic / organic. You could use a random source for this or often the spawn tick time could work just as well.

The end


That's all I really have on sprite animations. Like I said, it's not rocket science. Your brain does all the magic and beyond that it's painting an image to the canvas. Which is super easy on the web.

Hope it helps you :)